Coverage for src\pathier\pathier.py: 57%

232 statements  

« prev     ^ index     » next       coverage.py v7.2.2, created at 2024-01-15 15:38 -0600

1import datetime 

2import functools 

3import json 

4import os 

5import pathlib 

6import shutil 

7import sys 

8import time 

9import pickle 

10from typing import Any 

11 

12import tomlkit 

13from typing_extensions import Self 

14 

15 

16class Pathier(pathlib.Path): 

17 """Subclasses the standard library pathlib.Path class.""" 

18 

19 def __new__(cls, *args, **kwargs): 

20 if cls is Pathier: 

21 cls = WindowsPath if os.name == "nt" else PosixPath 

22 self = cls._from_parts(args) # type: ignore 

23 if not self._flavour.is_supported: 

24 raise NotImplementedError( 

25 "cannot instantiate %r on your system" % (cls.__name__,) 

26 ) 

27 if "convert_backslashes" in kwargs: 

28 self.convert_backslashes = kwargs["convert_backslashes"] 

29 else: 

30 self.convert_backslashes = True 

31 return self 

32 

33 @property 

34 def convert_backslashes(self) -> bool: 

35 """If True, when `self.__str__()`/`str(self)` is called, string representations will have double backslashes converted to a forward slash. 

36 

37 Only affects Windows paths.""" 

38 try: 

39 return self._convert_backslashes 

40 except Exception as e: 

41 return True 

42 

43 @convert_backslashes.setter 

44 def convert_backslashes(self, should_convert: bool): 

45 self._convert_backslashes = should_convert 

46 

47 def __str__(self) -> str: 

48 path = super().__new__(pathlib.Path, self).__str__() # type: ignore 

49 if self.convert_backslashes: 

50 path = path.replace("\\", "/") 

51 return path 

52 

53 # ===============================================stats=============================================== 

54 @property 

55 def dob(self) -> datetime.datetime | None: 

56 """Returns the creation date of this file or directory as a `dateime.datetime` object.""" 

57 return ( 

58 datetime.datetime.fromtimestamp(self.stat().st_ctime) 

59 if self.exists() 

60 else None 

61 ) 

62 

63 @property 

64 def age(self) -> float | None: 

65 """Returns the age in seconds of this file or directory.""" 

66 return ( 

67 (datetime.datetime.now() - self.dob).total_seconds() if self.dob else None 

68 ) 

69 

70 @property 

71 def mod_date(self) -> datetime.datetime | None: 

72 """Returns the modification date of this file or directory as a `datetime.datetime` object.""" 

73 return ( 

74 datetime.datetime.fromtimestamp(self.stat().st_mtime) 

75 if self.exists() 

76 else None 

77 ) 

78 

79 @property 

80 def mod_delta(self) -> float | None: 

81 """Returns how long ago in seconds this file or directory was modified.""" 

82 return ( 

83 (datetime.datetime.now() - self.mod_date).total_seconds() 

84 if self.mod_date 

85 else None 

86 ) 

87 

88 @property 

89 def last_read_time(self) -> datetime.datetime | None: 

90 """Returns the last time this object made a call to `self.read_text()`, `self.read_bytes()`, or `self.open(mode="r"|"rb")`. 

91 Returns `None` if the file hasn't been read from. 

92 

93 Note: This property is only relative to the lifetime of this `Pathier` instance, not the file itself. 

94 i.e. This property will reset if you create a new `Pathier` object pointing to the same file. 

95 """ 

96 return ( 

97 datetime.datetime.fromtimestamp(self._last_read_time) 

98 if self._last_read_time 

99 else None 

100 ) 

101 

102 @property 

103 def modified_since_last_read(self) -> bool: 

104 """Returns `True` if this file hasn't been read from or has been modified since the last time this object 

105 made a call to `self.read_text()`, `self.read_bytes()`, or `self.open(mode="r"|"rb")`. 

106 

107 Note: This property is only relative to the lifetime of this `Pathier` instance, not the file itself. 

108 i.e. This property will reset if you create a new `Pathier` object pointing to the same file. 

109 

110 #### Caveat: 

111 May not be accurate if the file was modified within a couple of seconds of checking this property. 

112 (For instance, on my machine `self.mod_date` is consistently 1-1.5s in the future from when `self.write_text()` was called according to `time.time()`.) 

113 """ 

114 return ( 

115 False 

116 if not self.mod_date 

117 or not self.last_read_time 

118 or self.mod_date < self.last_read_time 

119 else True 

120 ) 

121 

122 @property 

123 def size(self) -> int: 

124 """Returns the size in bytes of this file or directory. 

125 

126 If this path doesn't exist, `0` will be returned.""" 

127 if not self.exists(): 

128 return 0 

129 elif self.is_file(): 

130 return self.stat().st_size 

131 elif self.is_dir(): 

132 return sum(file.stat().st_size for file in self.rglob("*.*")) 

133 return 0 

134 

135 @property 

136 def formatted_size(self) -> str: 

137 """The size of this file or directory formatted with `self.format_bytes()`.""" 

138 return self.format_bytes(self.size) 

139 

140 @staticmethod 

141 def format_bytes(size: int) -> str: 

142 """Format `size` with common file size abbreviations and rounded to two decimal places. 

143 >>> 1234 -> "1.23 kb" """ 

144 unit = "bytes" 

145 for unit in ["bytes", "kb", "mb", "gb", "tb", "pb"]: 

146 if unit != "bytes": 

147 size *= 0.001 # type: ignore 

148 if size < 1000 or unit == "pb": 

149 break 

150 return f"{round(size, 2)} {unit}" 

151 

152 def is_larger(self, path: Self) -> bool: 

153 """Returns whether this file or folder is larger than the one pointed to by `path`.""" 

154 return self.size > path.size 

155 

156 def is_older(self, path: Self) -> bool | None: 

157 """Returns whether this file or folder is older than the one pointed to by `path`. 

158 

159 Returns `None` if one or both paths don't exist.""" 

160 return self.dob < path.dob if self.dob and path.dob else None 

161 

162 def modified_more_recently(self, path: Self) -> bool | None: 

163 """Returns whether this file or folder was modified more recently than the one pointed to by `path`. 

164 

165 Returns `None` if one or both paths don't exist.""" 

166 return ( 

167 self.mod_date > path.mod_date if self.mod_date and path.mod_date else None 

168 ) 

169 

170 # ===============================================navigation=============================================== 

171 def mkcwd(self): 

172 """Make this path your current working directory.""" 

173 os.chdir(self) 

174 

175 @property 

176 def in_PATH(self) -> bool: 

177 """Return `True` if this path is in `sys.path`.""" 

178 return str(self) in sys.path 

179 

180 def add_to_PATH(self, index: int = 0): 

181 """Insert this path into `sys.path` if it isn't already there. 

182 

183 #### :params: 

184 

185 `index`: The index of `sys.path` to insert this path at.""" 

186 path = str(self) 

187 if not self.in_PATH: 

188 sys.path.insert(index, path) 

189 

190 def append_to_PATH(self): 

191 """Append this path to `sys.path` if it isn't already there.""" 

192 path = str(self) 

193 if not self.in_PATH: 

194 sys.path.append(path) 

195 

196 def remove_from_PATH(self): 

197 """Remove this path from `sys.path` if it's in `sys.path`.""" 

198 if self.in_PATH: 

199 sys.path.remove(str(self)) 

200 

201 def moveup(self, name: str) -> Self: 

202 """Return a new `Pathier` object that is a parent of this instance. 

203 

204 `name` is case-sensitive and raises an exception if it isn't in `self.parts`. 

205 >>> p = Pathier("C:/some/directory/in/your/system") 

206 >>> print(p.moveup("directory")) 

207 >>> "C:/some/directory" 

208 >>> print(p.moveup("yeet")) 

209 >>> "Exception: yeet is not a parent of C:/some/directory/in/your/system" """ 

210 if name not in self.parts: 

211 raise Exception(f"{name} is not a parent of {self}") 

212 return self.__class__(*(self.parts[: self.parts.index(name) + 1])) 

213 

214 def __sub__(self, levels: int) -> Self: 

215 """Return a new `Pathier` object moved up `levels` number of parents from the current path. 

216 >>> p = Pathier("C:/some/directory/in/your/system") 

217 >>> new_p = p - 3 

218 >>> print(new_p) 

219 >>> "C:/some/directory" """ 

220 path = self 

221 for _ in range(levels): 

222 path = path.parent 

223 return path 

224 

225 def move_under(self, name: str) -> Self: 

226 """Return a new `Pathier` object such that the stem is one level below the given folder `name`. 

227 

228 `name` is case-sensitive and raises an exception if it isn't in `self.parts`. 

229 >>> p = Pathier("a/b/c/d/e/f/g") 

230 >>> print(p.move_under("c")) 

231 >>> 'a/b/c/d'""" 

232 if name not in self.parts: 

233 raise Exception(f"{name} is not a parent of {self}") 

234 return self - (len(self.parts) - self.parts.index(name) - 2) 

235 

236 def separate(self, name: str, keep_name: bool = False) -> Self: 

237 """Return a new `Pathier` object that is the relative child path after `name`. 

238 

239 `name` is case-sensitive and raises an exception if it isn't in `self.parts`. 

240 

241 #### :params: 

242 

243 `keep_name`: If `True`, the returned path will start with `name`. 

244 >>> p = Pathier("a/b/c/d/e/f/g") 

245 >>> print(p.separate("c")) 

246 >>> 'd/e/f/g' 

247 >>> print(p.separate("c", True)) 

248 >>> 'c/d/e/f/g'""" 

249 if name not in self.parts: 

250 raise Exception(f"{name} is not a parent of {self}") 

251 if keep_name: 

252 return self.__class__(*self.parts[self.parts.index(name) :]) 

253 return self.__class__(*self.parts[self.parts.index(name) + 1 :]) 

254 

255 # ============================================write and read============================================ 

256 def mkdir(self, mode: int = 511, parents: bool = True, exist_ok: bool = True): 

257 """Create this directory. 

258 

259 Same as `Path().mkdir()` except `parents` and `exist_ok` default to `True` instead of `False`. 

260 """ 

261 super().mkdir(mode, parents, exist_ok) 

262 

263 def touch(self): 

264 """Create file (and parents if necessary).""" 

265 self.parent.mkdir() 

266 super().touch() 

267 

268 def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None): 

269 """ 

270 Open the file pointed by this path and return a file object, as 

271 the built-in open() function does. 

272 """ 

273 stream = super().open(mode, buffering, encoding, errors, newline) 

274 if "r" in mode: 

275 self._last_read_time = time.time() 

276 return stream 

277 

278 def write_text( 

279 self, 

280 data: Any, 

281 encoding: Any | None = None, 

282 errors: Any | None = None, 

283 newline: Any | None = None, 

284 parents: bool = True, 

285 ): 

286 """Write data to file. 

287 

288 If a `TypeError` is raised, the function will attempt to cast `data` to a `str` and try the write again. 

289 

290 If a `FileNotFoundError` is raised and `parents = True`, `self.parent` will be created. 

291 """ 

292 write = functools.partial( 

293 super().write_text, 

294 encoding=encoding, 

295 errors=errors, 

296 newline=newline, 

297 ) 

298 try: 

299 write(data) 

300 except TypeError: 

301 data = str(data) 

302 write(data) 

303 except FileNotFoundError: 

304 if parents: 

305 self.parent.mkdir(parents=True) 

306 write(data) 

307 else: 

308 raise 

309 except Exception as e: 

310 raise 

311 

312 def write_bytes(self, data: bytes, parents: bool = True): 

313 """Write bytes to file. 

314 

315 #### :params: 

316 

317 `parents`: If `True` and the write operation fails with a `FileNotFoundError`, 

318 make the parent directory and retry the write.""" 

319 try: 

320 super().write_bytes(data) 

321 except FileNotFoundError: 

322 if parents: 

323 self.parent.mkdir(parents=True) 

324 super().write_bytes(data) 

325 else: 

326 raise 

327 except Exception as e: 

328 raise 

329 

330 def append(self, data: str, new_line: bool = True, encoding: Any | None = None): 

331 """Append `data` to the file pointed to by this `Pathier` object. 

332 

333 #### :params: 

334 

335 `new_line`: If `True`, add `\\n` to `data`. 

336 

337 `encoding`: The file encoding to use.""" 

338 if new_line: 

339 data += "\n" 

340 with self.open("a", encoding=encoding) as file: 

341 file.write(data) 

342 

343 def replace_strings( 

344 self, 

345 substitutions: list[tuple[str, str]], 

346 count: int = -1, 

347 encoding: Any | None = None, 

348 ): 

349 """For each pair in `substitutions`, replace the first string with the second string. 

350 

351 #### :params: 

352 

353 `count`: Only replace this many occurences of each pair. 

354 By default (`-1`), all occurences are replaced. 

355 

356 `encoding`: The file encoding to use. 

357 

358 e.g. 

359 >>> path = Pathier("somefile.txt") 

360 >>> 

361 >>> path.replace([("hello", "yeet"), ("goodbye", "yeehaw")]) 

362 equivalent to 

363 >>> path.write_text(path.read_text().replace("hello", "yeet").replace("goodbye", "yeehaw")) 

364 """ 

365 text = self.read_text(encoding) 

366 for sub in substitutions: 

367 text = text.replace(sub[0], sub[1], count) 

368 self.write_text(text, encoding=encoding) 

369 

370 def join(self, data: list[str], encoding: Any | None = None, sep: str = "\n"): 

371 """Write a list of strings, joined by `sep`, to the file pointed at by this instance. 

372 

373 Equivalent to `Pathier("somefile.txt").write_text(sep.join(data), encoding=encoding)` 

374 

375 #### :params: 

376 

377 `encoding`: The file encoding to use. 

378 

379 `sep`: The separator to use when joining `data`.""" 

380 self.write_text(sep.join(data), encoding=encoding) 

381 

382 def split(self, encoding: Any | None = None, keepends: bool = False) -> list[str]: 

383 """Returns the content of the pointed at file as a list of strings, splitting at new line characters. 

384 

385 Equivalent to `Pathier("somefile.txt").read_text(encoding=encoding).splitlines()` 

386 

387 #### :params: 

388 

389 `encoding`: The file encoding to use. 

390 

391 `keepend`: If `True`, line breaks will be included in returned strings.""" 

392 return self.read_text(encoding=encoding).splitlines(keepends) 

393 

394 def json_loads(self, encoding: Any | None = None, errors: Any | None = None) -> Any: 

395 """Load json file.""" 

396 return json.loads(self.read_text(encoding, errors)) 

397 

398 def json_dumps( 

399 self, 

400 data: Any, 

401 encoding: Any | None = None, 

402 errors: Any | None = None, 

403 newline: Any | None = None, 

404 sort_keys: bool = False, 

405 indent: Any | None = None, 

406 default: Any | None = None, 

407 parents: bool = True, 

408 ) -> Any: 

409 """Dump `data` to json file.""" 

410 self.write_text( 

411 json.dumps(data, indent=indent, default=default, sort_keys=sort_keys), 

412 encoding, 

413 errors, 

414 newline, 

415 parents, 

416 ) 

417 

418 def pickle_loads(self) -> Any: 

419 """Load pickle file.""" 

420 return pickle.loads(self.read_bytes()) 

421 

422 def pickle_dumps(self, data: Any): 

423 """Dump `data` to pickle file.""" 

424 self.write_bytes(pickle.dumps(data)) 

425 

426 def toml_loads(self, encoding: Any | None = None, errors: Any | None = None) -> Any: 

427 """Load toml file.""" 

428 return tomlkit.loads(self.read_text(encoding, errors)).unwrap() 

429 

430 def toml_dumps( 

431 self, 

432 data: Any, 

433 encoding: Any | None = None, 

434 errors: Any | None = None, 

435 newline: Any | None = None, 

436 sort_keys: bool = False, 

437 parents: bool = True, 

438 ): 

439 """Dump `data` to toml file.""" 

440 self.write_text( 

441 tomlkit.dumps(data, sort_keys), encoding, errors, newline, parents 

442 ) 

443 

444 def loads(self, encoding: Any | None = None, errors: Any | None = None) -> Any: 

445 """Load a json, toml, or pickle file based off this path's suffix.""" 

446 match self.suffix: 

447 case ".json": 

448 return self.json_loads(encoding, errors) 

449 case ".toml": 

450 return self.toml_loads(encoding, errors) 

451 case ".pickle" | ".pkl": 

452 return self.pickle_loads() 

453 

454 def dumps( 

455 self, 

456 data: Any, 

457 encoding: Any | None = None, 

458 errors: Any | None = None, 

459 newline: Any | None = None, 

460 sort_keys: bool = False, 

461 indent: Any | None = None, 

462 default: Any | None = None, 

463 parents: bool = True, 

464 ): 

465 """Dump `data` to a json or toml file based off this instance's suffix.""" 

466 match self.suffix: 

467 case ".json": 

468 self.json_dumps( 

469 data, encoding, errors, newline, sort_keys, indent, default, parents 

470 ) 

471 case ".toml": 

472 self.toml_dumps(data, encoding, errors, newline, sort_keys, parents) 

473 case ".pickle" | ".pkl": 

474 self.pickle_dumps(data) 

475 

476 def delete(self, missing_ok: bool = True): 

477 """Delete the file or folder pointed to by this instance. 

478 

479 Uses `self.unlink()` if a file and uses `shutil.rmtree()` if a directory.""" 

480 if self.is_file(): 

481 self.unlink(missing_ok) 

482 elif self.is_dir(): 

483 shutil.rmtree(self) 

484 

485 def copy( 

486 self, new_path: Self | pathlib.Path | str, overwrite: bool = False 

487 ) -> Self: 

488 """Copy the path pointed to by this instance 

489 to the instance pointed to by `new_path` using `shutil.copyfile` 

490 or `shutil.copytree`. 

491 

492 Returns the new path. 

493 

494 #### :params: 

495 

496 `new_path`: The copy destination. 

497 

498 `overwrite`: If `True`, files already existing in `new_path` will be overwritten. 

499 If `False`, only files that don't exist in `new_path` will be copied.""" 

500 dst = self.__class__(new_path) 

501 if self.is_dir(): 

502 if overwrite or not dst.exists(): 

503 dst.mkdir() 

504 shutil.copytree(self, dst, dirs_exist_ok=True) 

505 else: 

506 files = self.rglob("*.*") 

507 for file in files: 

508 dst = dst.with_name(file.name) 

509 if not dst.exists(): 

510 shutil.copyfile(file, dst) 

511 elif self.is_file(): 

512 if overwrite or not dst.exists(): 

513 shutil.copyfile(self, dst) 

514 return dst 

515 

516 def backup(self, timestamp: bool = False) -> Self | None: 

517 """Create a copy of this file or directory with `_backup` appended to the path stem. 

518 If the path to be backed up doesn't exist, `None` is returned. 

519 Otherwise a `Pathier` object for the backup is returned. 

520 

521 #### :params: 

522 

523 `timestamp`: Add a timestamp to the backup name to prevent overriding previous backups. 

524 

525 >>> path = Pathier("some_file.txt") 

526 >>> path.backup() 

527 >>> list(path.iterdir()) 

528 >>> ['some_file.txt', 'some_file_backup.txt'] 

529 >>> path.backup(True) 

530 >>> list(path.iterdir()) 

531 >>> ['some_file.txt', 'some_file_backup.txt', 'some_file_backup_04-28-2023-06_25_52_PM.txt'] 

532 """ 

533 if not self.exists(): 

534 return None 

535 backup_stem = f"{self.stem}_backup" 

536 if timestamp: 

537 backup_stem = f"{backup_stem}_{datetime.datetime.now().strftime('%m-%d-%Y-%I_%M_%S_%p')}" 

538 backup_path = self.with_stem(backup_stem) 

539 self.copy(backup_path, True) 

540 return backup_path 

541 

542 def execute(self, command: str = "", args: str = "") -> int: 

543 """Make a call to `os.system` using the path pointed to by this Pathier object. 

544 

545 #### :params: 

546 

547 `command`: Program/command to precede the path with. 

548 

549 `args`: Any arguments that should come after the path. 

550 

551 :returns: The integer output of `os.system`. 

552 

553 e.g. 

554 >>> path = Pathier("mydirectory") / "myscript.py" 

555 then 

556 >>> path.execute("py", "--iterations 10") 

557 equivalent to 

558 >>> os.system(f"py {path} --iterations 10")""" 

559 return os.system(f"{command} {self} {args}") 

560 

561 

562Pathy = Pathier | pathlib.Path 

563Pathish = Pathier | pathlib.Path | str 

564 

565 

566class PosixPath(Pathier, pathlib.PurePosixPath): 

567 __slots__ = () 

568 _last_read_time = None 

569 

570 

571class WindowsPath(Pathier, pathlib.PureWindowsPath): 

572 __slots__ = () 

573 _last_read_time = None