Coverage for src\pathier\pathier.py: 89%
246 statements
« prev ^ index » next coverage.py v7.2.2, created at 2024-05-07 16:38 -0500
« prev ^ index » next coverage.py v7.2.2, created at 2024-05-07 16:38 -0500
1import datetime
2import functools
3import json
4import os
5import pathlib
6import pickle
7import shutil
8import sys
9import time
10from typing import Any
12import tomlkit
13from typing_extensions import IO, Buffer, Callable, Self, Sequence
16class Pathier(pathlib.Path):
17 """Subclasses the standard library pathlib.Path class."""
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
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.
41 Only affects Windows paths."""
42 try:
43 return self._convert_backslashes
44 except Exception as e:
45 return True
47 @convert_backslashes.setter
48 def convert_backslashes(self, should_convert: bool):
49 self._convert_backslashes = should_convert
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
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 )
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 )
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 )
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 )
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.
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 )
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")`.
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.
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 )
126 @property
127 def size(self) -> int:
128 """Returns the size in bytes of this file or directory.
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
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)
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}"
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
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`.
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
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`.
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 )
174 # ===============================================navigation===============================================
175 def mkcwd(self):
176 """Make this path your current working directory."""
177 os.chdir(self)
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
184 def add_to_PATH(self, index: int = 0):
185 """Insert this path into `sys.path` if it isn't already there.
187 #### :params:
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)
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)
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))
205 def moveup(self, name: str) -> Self:
206 """Return a new `Pathier` object that is a parent of this instance.
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]))
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
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`.
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)
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`.
243 `name` is case-sensitive and raises an exception if it isn't in `self.parts`.
245 #### :params:
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 :])
259 # ============================================write and read============================================
260 def mkdir(self, mode: int = 511, parents: bool = True, exist_ok: bool = True):
261 """Create this directory.
263 Same as `Path().mkdir()` except `parents` and `exist_ok` default to `True` instead of `False`.
264 """
265 super().mkdir(mode, parents, exist_ok)
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)
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
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.
299 If a `TypeError` is raised, the function will attempt to cast `data` to a `str` and try the write again.
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
323 def write_bytes(self, data: Buffer, parents: bool = True) -> int:
324 """Write bytes to file.
326 #### :params:
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
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.
344 #### :params:
346 `new_line`: If `True`, add `\\n` to `data`.
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)
354 def replace_strings(
355 self,
356 substitutions: Sequence[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.
362 #### :params:
364 `count`: Only replace this many occurences of each pair.
365 By default (`-1`), all occurences are replaced.
367 `encoding`: The file encoding to use.
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)
381 def join(self, data: Sequence[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.
384 Equivalent to `Pathier("somefile.txt").write_text(sep.join(data), encoding=encoding)`
386 #### :params:
388 `encoding`: The file encoding to use.
390 `sep`: The separator to use when joining `data`."""
391 self.write_text(sep.join(data), encoding=encoding)
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.
396 Equivalent to `Pathier("somefile.txt").read_text(encoding=encoding).splitlines()`
398 #### :params:
400 `encoding`: The file encoding to use.
402 `keepend`: If `True`, line breaks will be included in returned strings."""
403 return self.read_text(encoding=encoding).splitlines(keepends)
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))
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 )
429 def pickle_loads(self) -> Any:
430 """Load pickle file."""
431 return pickle.loads(self.read_bytes())
433 def pickle_dumps(self, data: Any):
434 """Dump `data` to pickle file."""
435 self.write_bytes(pickle.dumps(data))
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()
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.
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)
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 )
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.
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 )
525 def delete(self, missing_ok: bool = True):
526 """Delete the file or folder pointed to by this instance.
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)
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`.
541 Returns the new path.
543 #### :params:
545 `new_path`: The copy destination.
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
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.
570 #### :params:
572 `timestamp`: Add a timestamp to the backup name to prevent overriding previous backups.
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
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.
594 #### :params:
596 `command`: Program/command to precede the path with.
598 `args`: Any arguments that should come after the path.
600 :returns: The integer output of `os.system`.
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}")
611Pathy = Pathier | pathlib.Path
612Pathish = Pathier | pathlib.Path | str
615class PosixPath(Pathier, pathlib.PurePosixPath):
616 __slots__ = ()
617 _last_read_time = None
620class WindowsPath(Pathier, pathlib.PureWindowsPath):
621 __slots__ = ()
622 _last_read_time = None