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
« 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
12import tomlkit
13from typing_extensions import Self
16class Pathier(pathlib.Path):
17 """Subclasses the standard library pathlib.Path class."""
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
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.
37 Only affects Windows paths."""
38 try:
39 return self._convert_backslashes
40 except Exception as e:
41 return True
43 @convert_backslashes.setter
44 def convert_backslashes(self, should_convert: bool):
45 self._convert_backslashes = should_convert
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
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 )
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 )
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 )
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 )
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.
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 )
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")`.
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.
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 )
122 @property
123 def size(self) -> int:
124 """Returns the size in bytes of this file or directory.
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
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)
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}"
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
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`.
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
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`.
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 )
170 # ===============================================navigation===============================================
171 def mkcwd(self):
172 """Make this path your current working directory."""
173 os.chdir(self)
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
180 def add_to_PATH(self, index: int = 0):
181 """Insert this path into `sys.path` if it isn't already there.
183 #### :params:
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)
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)
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))
201 def moveup(self, name: str) -> Self:
202 """Return a new `Pathier` object that is a parent of this instance.
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]))
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
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`.
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)
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`.
239 `name` is case-sensitive and raises an exception if it isn't in `self.parts`.
241 #### :params:
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 :])
255 # ============================================write and read============================================
256 def mkdir(self, mode: int = 511, parents: bool = True, exist_ok: bool = True):
257 """Create this directory.
259 Same as `Path().mkdir()` except `parents` and `exist_ok` default to `True` instead of `False`.
260 """
261 super().mkdir(mode, parents, exist_ok)
263 def touch(self):
264 """Create file (and parents if necessary)."""
265 self.parent.mkdir()
266 super().touch()
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
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.
288 If a `TypeError` is raised, the function will attempt to cast `data` to a `str` and try the write again.
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
312 def write_bytes(self, data: bytes, parents: bool = True):
313 """Write bytes to file.
315 #### :params:
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
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.
333 #### :params:
335 `new_line`: If `True`, add `\\n` to `data`.
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)
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.
351 #### :params:
353 `count`: Only replace this many occurences of each pair.
354 By default (`-1`), all occurences are replaced.
356 `encoding`: The file encoding to use.
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)
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.
373 Equivalent to `Pathier("somefile.txt").write_text(sep.join(data), encoding=encoding)`
375 #### :params:
377 `encoding`: The file encoding to use.
379 `sep`: The separator to use when joining `data`."""
380 self.write_text(sep.join(data), encoding=encoding)
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.
385 Equivalent to `Pathier("somefile.txt").read_text(encoding=encoding).splitlines()`
387 #### :params:
389 `encoding`: The file encoding to use.
391 `keepend`: If `True`, line breaks will be included in returned strings."""
392 return self.read_text(encoding=encoding).splitlines(keepends)
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))
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 )
418 def pickle_loads(self) -> Any:
419 """Load pickle file."""
420 return pickle.loads(self.read_bytes())
422 def pickle_dumps(self, data: Any):
423 """Dump `data` to pickle file."""
424 self.write_bytes(pickle.dumps(data))
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()
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 )
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()
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)
476 def delete(self, missing_ok: bool = True):
477 """Delete the file or folder pointed to by this instance.
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)
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`.
492 Returns the new path.
494 #### :params:
496 `new_path`: The copy destination.
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
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.
521 #### :params:
523 `timestamp`: Add a timestamp to the backup name to prevent overriding previous backups.
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
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.
545 #### :params:
547 `command`: Program/command to precede the path with.
549 `args`: Any arguments that should come after the path.
551 :returns: The integer output of `os.system`.
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}")
562Pathy = Pathier | pathlib.Path
563Pathish = Pathier | pathlib.Path | str
566class PosixPath(Pathier, pathlib.PurePosixPath):
567 __slots__ = ()
568 _last_read_time = None
571class WindowsPath(Pathier, pathlib.PureWindowsPath):
572 __slots__ = ()
573 _last_read_time = None