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

246 statements  

« prev     ^ index     » next       coverage.py v7.2.2, created at 2024-02-16 15:27 -0600

1import datetime 

2import functools 

3import json 

4import os 

5import pathlib 

6import pickle 

7import shutil 

8import sys 

9import time 

10from typing import Any 

11 

12import tomlkit 

13from typing_extensions import Callable, Self, Sequence, Buffer, IO, Type 

14 

15 

16class Pathier(pathlib.Path): 

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

18 

19 def __new__( 

20 cls, 

21 *args: Self | str | pathlib.Path, 

22 **kwargs: Any, 

23 ) -> Self: 

24 if cls is Pathier: 

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

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

27 if not self._flavour.is_supported: # type: ignore 

28 raise NotImplementedError( 

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

30 ) 

31 if "convert_backslashes" in kwargs: 

32 self.convert_backslashes = kwargs["convert_backslashes"] 

33 else: 

34 self.convert_backslashes = True 

35 return self # type: ignore 

36 

37 @property 

38 def convert_backslashes(self) -> bool: 

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

40 

41 Only affects Windows paths.""" 

42 try: 

43 return self._convert_backslashes 

44 except Exception as e: 

45 return True 

46 

47 @convert_backslashes.setter 

48 def convert_backslashes(self, should_convert: bool): 

49 self._convert_backslashes = should_convert 

50 

51 def __str__(self) -> str: 

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

53 if self.convert_backslashes: 

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

55 return path 

56 

57 # ===============================================stats=============================================== 

58 @property 

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

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

61 return ( 

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

63 if self.exists() 

64 else None 

65 ) 

66 

67 @property 

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

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

70 return ( 

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

72 ) 

73 

74 @property 

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

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

77 return ( 

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

79 if self.exists() 

80 else None 

81 ) 

82 

83 @property 

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

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

86 return ( 

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

88 if self.mod_date 

89 else None 

90 ) 

91 

92 @property 

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

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

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

96 

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

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

99 """ 

100 return ( 

101 datetime.datetime.fromtimestamp(self._last_read_time) 

102 if self._last_read_time 

103 else None 

104 ) 

105 

106 @property 

107 def modified_since_last_read(self) -> bool: 

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

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

110 

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

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

113 

114 #### Caveat: 

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

116 (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()`.) 

117 """ 

118 return ( 

119 False 

120 if not self.mod_date 

121 or not self.last_read_time 

122 or self.mod_date < self.last_read_time 

123 else True 

124 ) 

125 

126 @property 

127 def size(self) -> int: 

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

129 

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

131 if not self.exists(): 

132 return 0 

133 elif self.is_file(): 

134 return self.stat().st_size 

135 elif self.is_dir(): 

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

137 return 0 

138 

139 @property 

140 def formatted_size(self) -> str: 

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

142 return self.format_bytes(self.size) 

143 

144 @staticmethod 

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

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

147 >>> 1234 -> "1.23 kb" """ 

148 unit = "bytes" 

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

150 if unit != "bytes": 

151 size *= 0.001 # type: ignore 

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

153 break 

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

155 

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

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

158 return self.size > path.size 

159 

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

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

162 

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

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

165 

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

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

168 

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

170 return ( 

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

172 ) 

173 

174 # ===============================================navigation=============================================== 

175 def mkcwd(self): 

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

177 os.chdir(self) 

178 

179 @property 

180 def in_PATH(self) -> bool: 

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

182 return str(self) in sys.path 

183 

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

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

186 

187 #### :params: 

188 

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

190 path = str(self) 

191 if not self.in_PATH: 

192 sys.path.insert(index, path) 

193 

194 def append_to_PATH(self): 

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

196 path = str(self) 

197 if not self.in_PATH: 

198 sys.path.append(path) 

199 

200 def remove_from_PATH(self): 

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

202 if self.in_PATH: 

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

204 

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

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

207 

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

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

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

211 >>> "C:/some/directory" 

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

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

214 if name not in self.parts: 

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

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

217 

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

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

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

221 >>> new_p = p - 3 

222 >>> print(new_p) 

223 >>> "C:/some/directory" """ 

224 path = self 

225 for _ in range(levels): 

226 path = path.parent 

227 return path 

228 

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

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

231 

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

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

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

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

236 if name not in self.parts: 

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

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

239 

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

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

242 

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

244 

245 #### :params: 

246 

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

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

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

250 >>> 'd/e/f/g' 

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

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

253 if name not in self.parts: 

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

255 if keep_name: 

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

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

258 

259 # ============================================write and read============================================ 

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

261 """Create this directory. 

262 

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

264 """ 

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

266 

267 def touch(self, mode: int = 438, exist_ok: bool = True): 

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

269 self.parent.mkdir() 

270 super().touch(mode, exist_ok) 

271 

272 def open( # type: ignore 

273 self, 

274 mode: str = "r", 

275 buffering: int = -1, 

276 encoding: str | None = None, 

277 errors: str | None = None, 

278 newline: str | None = None, 

279 ) -> IO[Any]: 

280 """ 

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

282 the built-in open() function does. 

283 """ 

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

285 if "r" in mode: 

286 self._last_read_time = time.time() 

287 return stream 

288 

289 def write_text( 

290 self, 

291 data: Any, 

292 encoding: Any | None = None, 

293 errors: Any | None = None, 

294 newline: Any | None = None, 

295 parents: bool = True, 

296 ) -> int: 

297 """Write data to file. 

298 

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

300 

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

302 """ 

303 write = functools.partial( 

304 super().write_text, 

305 encoding=encoding, 

306 errors=errors, 

307 newline=newline, 

308 ) 

309 try: 

310 return write(data) 

311 except TypeError: 

312 data = str(data) 

313 return write(data) 

314 except FileNotFoundError: 

315 if parents: 

316 self.parent.mkdir(parents=True) 

317 return write(data) 

318 else: 

319 raise 

320 except Exception as e: 

321 raise 

322 

323 def write_bytes(self, data: Buffer, parents: bool = True) -> int: 

324 """Write bytes to file. 

325 

326 #### :params: 

327 

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

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

330 try: 

331 return super().write_bytes(data) 

332 except FileNotFoundError: 

333 if parents: 

334 self.parent.mkdir(parents=True) 

335 return super().write_bytes(data) 

336 else: 

337 raise 

338 except Exception as e: 

339 raise 

340 

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

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

343 

344 #### :params: 

345 

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

347 

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

349 if new_line: 

350 data += "\n" 

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

352 file.write(data) 

353 

354 def replace_strings( 

355 self, 

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

357 count: int = -1, 

358 encoding: Any | None = None, 

359 ): 

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

361 

362 #### :params: 

363 

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

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

366 

367 `encoding`: The file encoding to use. 

368 

369 e.g. 

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

371 >>> 

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

373 equivalent to 

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

375 """ 

376 text = self.read_text(encoding) 

377 for sub in substitutions: 

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

379 self.write_text(text, encoding=encoding) 

380 

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

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

383 

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

385 

386 #### :params: 

387 

388 `encoding`: The file encoding to use. 

389 

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

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

392 

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

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

395 

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

397 

398 #### :params: 

399 

400 `encoding`: The file encoding to use. 

401 

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

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

404 

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

406 """Load json file.""" 

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

408 

409 def json_dumps( 

410 self, 

411 data: Any, 

412 encoding: Any | None = None, 

413 errors: Any | None = None, 

414 newline: Any | None = None, 

415 sort_keys: bool = False, 

416 indent: Any | None = 2, 

417 default: Any | None = str, 

418 parents: bool = True, 

419 ) -> Any: 

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

421 self.write_text( 

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

423 encoding, 

424 errors, 

425 newline, 

426 parents, 

427 ) 

428 

429 def pickle_loads(self) -> Any: 

430 """Load pickle file.""" 

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

432 

433 def pickle_dumps(self, data: Any): 

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

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

436 

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

438 """Load toml file.""" 

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

440 

441 def toml_dumps( 

442 self, 

443 data: Any, 

444 toml_encoders: Sequence[Callable[[Any], Any]] = [str], 

445 encoding: Any | None = None, 

446 errors: Any | None = None, 

447 newline: Any | None = None, 

448 sort_keys: bool = False, 

449 parents: bool = True, 

450 ): 

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

452 

453 `toml_encoders` can be a list of functions to call when a value in `data` doesn't map to `tomlkit`'s built in types. 

454 By default, anything that `tomlkit` can't convert will be cast to a string. Encoder order matters. 

455 e.g. By default any `Pathier` object in `data` will be converted to a string.""" 

456 encoders: list[Callable[[Any], Any]] = [] 

457 for toml_encoder in toml_encoders: 

458 encoder: Callable[[Any], Any] = lambda x: tomlkit.item( # type:ignore 

459 toml_encoder(x) 

460 ) 

461 encoders.append(encoder) 

462 tomlkit.register_encoder(encoder) 

463 try: 

464 self.write_text( 

465 tomlkit.dumps(data, sort_keys), # type:ignore 

466 encoding, 

467 errors, 

468 newline, 

469 parents, 

470 ) 

471 except Exception as e: 

472 raise e 

473 finally: 

474 for encoder in encoders: 

475 tomlkit.unregister_encoder(encoder) 

476 

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

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

479 match self.suffix: 

480 case ".json": 

481 return self.json_loads(encoding, errors) 

482 case ".toml": 

483 return self.toml_loads(encoding, errors) 

484 case ".pickle" | ".pkl": 

485 return self.pickle_loads() 

486 case _: 

487 raise ValueError( 

488 f"No load function exists for file type `{self.suffix}`." 

489 ) 

490 

491 def dumps( 

492 self, 

493 data: Any, 

494 encoding: Any | None = None, 

495 errors: Any | None = None, 

496 newline: Any | None = None, 

497 sort_keys: bool = False, 

498 indent: Any | None = None, 

499 default: Any | None = str, 

500 toml_encoders: Sequence[Callable[[Any], Any]] = [str], 

501 parents: bool = True, 

502 ): 

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

504 

505 For toml files: 

506 `toml_encoders` can be a list of functions to call when a value in `data` doesn't map to `tomlkit`'s built in types. 

507 By default, anything that `tomlkit` can't convert will be cast to a string. Encoder order matters. 

508 e.g. By default any `Pathier` object in `data` will be converted to a string.""" 

509 match self.suffix: 

510 case ".json": 

511 self.json_dumps( 

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

513 ) 

514 case ".toml": 

515 self.toml_dumps( 

516 data, toml_encoders, encoding, errors, newline, sort_keys, parents 

517 ) 

518 case ".pickle" | ".pkl": 

519 self.pickle_dumps(data) 

520 case _: 

521 raise ValueError( 

522 f"No dump function exists for file type `{self.suffix}`." 

523 ) 

524 

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

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

527 

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

529 if self.is_file(): 

530 self.unlink(missing_ok) 

531 elif self.is_dir(): 

532 shutil.rmtree(self) 

533 

534 def copy( 

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

536 ) -> Self: 

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

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

539 or `shutil.copytree`. 

540 

541 Returns the new path. 

542 

543 #### :params: 

544 

545 `new_path`: The copy destination. 

546 

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

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

549 dst = self.__class__(new_path) 

550 if self.is_dir(): 

551 if overwrite or not dst.exists(): 

552 dst.mkdir() 

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

554 else: 

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

556 for file in files: 

557 dst = dst.with_name(file.name) 

558 if not dst.exists(): 

559 shutil.copyfile(file, dst) 

560 elif self.is_file(): 

561 if overwrite or not dst.exists(): 

562 shutil.copyfile(self, dst) 

563 return dst 

564 

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

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

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

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

569 

570 #### :params: 

571 

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

573 

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

575 >>> path.backup() 

576 >>> list(path.iterdir()) 

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

578 >>> path.backup(True) 

579 >>> list(path.iterdir()) 

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

581 """ 

582 if not self.exists(): 

583 return None 

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

585 if timestamp: 

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

587 backup_path = self.with_stem(backup_stem) 

588 self.copy(backup_path, True) 

589 return backup_path 

590 

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

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

593 

594 #### :params: 

595 

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

597 

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

599 

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

601 

602 e.g. 

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

604 then 

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

606 equivalent to 

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

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

609 

610 

611Pathy = Pathier | pathlib.Path 

612Pathish = Pathier | pathlib.Path | str 

613 

614 

615class PosixPath(Pathier, pathlib.PurePosixPath): 

616 __slots__ = () 

617 _last_read_time = None 

618 

619 

620class WindowsPath(Pathier, pathlib.PureWindowsPath): 

621 __slots__ = () 

622 _last_read_time = None