pathlibutil 0.1.6__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pathlibutil
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: inherits from pathlib.Path with methods for hashing, copying, deleting and more
5
5
  Home-page: https://d-chris.github.io
6
6
  License: MIT
@@ -40,6 +40,7 @@ Description-Content-Type: text/markdown
40
40
  - `Path.hexdigest()` to calculate and `Path.verify()` for verification of hexdigest from a file
41
41
  - `Path.default_hash` to configurate default hash algorithm for `Path` class (default: *'md5'*)
42
42
  - `Path.size()` to get size in bytes of a file or directory
43
+ - `byteint` function decorator converts the return value of `int` to a `ByteInt` object
43
44
  - `Path.read_lines()` to yield over all lines from a file until EOF
44
45
  - `contextmanager` to change current working directory with `with` statement
45
46
  - `Path.copy()` copy a file or directory to a new path destination
@@ -47,8 +48,11 @@ Description-Content-Type: text/markdown
47
48
  - `Path.move()` move a file or directory to a new path destination
48
49
  - `Path.make_archive()` creates and `Path.unpack_archive()` uncompresses an archive from a file or directory
49
50
  - `Path.archive_formats` to get all available archive formats
50
- - `byteint` function decorator to convert return value of `int` to a `ByteInt` object
51
-
51
+ - `Path.stat()` returns a `StatResult` object to get file or directory information containing
52
+ - `TimeInt` objects for `atime`, `ctime`, `mtime` and `birthtime`
53
+ - `ByteInt` object for `size`
54
+ - `Path.relative_to()` to get relative path from a file or directory, `walk_up` to walk up the directory tree.
55
+
52
56
  ## Installation
53
57
 
54
58
  ```bash
@@ -16,6 +16,7 @@
16
16
  - `Path.hexdigest()` to calculate and `Path.verify()` for verification of hexdigest from a file
17
17
  - `Path.default_hash` to configurate default hash algorithm for `Path` class (default: *'md5'*)
18
18
  - `Path.size()` to get size in bytes of a file or directory
19
+ - `byteint` function decorator converts the return value of `int` to a `ByteInt` object
19
20
  - `Path.read_lines()` to yield over all lines from a file until EOF
20
21
  - `contextmanager` to change current working directory with `with` statement
21
22
  - `Path.copy()` copy a file or directory to a new path destination
@@ -23,8 +24,11 @@
23
24
  - `Path.move()` move a file or directory to a new path destination
24
25
  - `Path.make_archive()` creates and `Path.unpack_archive()` uncompresses an archive from a file or directory
25
26
  - `Path.archive_formats` to get all available archive formats
26
- - `byteint` function decorator to convert return value of `int` to a `ByteInt` object
27
-
27
+ - `Path.stat()` returns a `StatResult` object to get file or directory information containing
28
+ - `TimeInt` objects for `atime`, `ctime`, `mtime` and `birthtime`
29
+ - `ByteInt` object for `size`
30
+ - `Path.relative_to()` to get relative path from a file or directory, `walk_up` to walk up the directory tree.
31
+
28
32
  ## Installation
29
33
 
30
34
  ```bash
@@ -0,0 +1,8 @@
1
+ """
2
+ .. include:: ../README.md
3
+ """
4
+
5
+ from pathlibutil.path import Path, Register7zFormat
6
+ from pathlibutil.types import ByteInt, TimeInt, byteint, StatResult
7
+
8
+ __all__ = ["Path", "Register7zFormat", "ByteInt", "byteint", "TimeInt", "StatResult"]
@@ -0,0 +1,19 @@
1
+ import os
2
+ import pathlib
3
+ import sys
4
+ from typing import TypeVar
5
+
6
+ _Path = TypeVar("_Path", bound=pathlib.Path)
7
+
8
+
9
+ class BasePath(pathlib.Path):
10
+ """
11
+ Baseclass to inherit from `pathlib.Path`.
12
+
13
+ This class is only needed for python versions < 3.12.
14
+ """
15
+
16
+ if sys.version_info < (3, 12):
17
+ _flavour = (
18
+ pathlib._windows_flavour if os.name == "nt" else pathlib._posix_flavour
19
+ )
@@ -2,17 +2,14 @@ import errno
2
2
  import hashlib
3
3
  import itertools
4
4
  import os
5
- import pathlib
6
5
  import shutil
7
- import sys
8
- from typing import Callable, Dict, Generator, Set, TypeVar
6
+ from typing import Callable, Dict, Generator, List, Set, Union
9
7
 
10
- from pathlibutil.types import ByteInt, byteint
8
+ from pathlibutil.base import BasePath, _Path
9
+ from pathlibutil.types import ByteInt, StatResult, _stat_result, byteint
11
10
 
12
- _Path = TypeVar("_Path", bound="Path")
13
11
 
14
-
15
- class Path(pathlib.Path):
12
+ class Path(BasePath):
16
13
  """
17
14
  Path inherites from `pathlib.Path` and adds some methods to built-in python
18
15
  functions.
@@ -40,11 +37,6 @@ class Path(pathlib.Path):
40
37
  be the default
41
38
  """
42
39
 
43
- if sys.version_info < (3, 12):
44
- _flavour = (
45
- pathlib._windows_flavour if os.name == "nt" else pathlib._posix_flavour
46
- )
47
-
48
40
  def __init_subclass__(cls, **kwargs) -> None:
49
41
  """
50
42
  Register archive formats from subclasses.
@@ -172,7 +164,7 @@ class Path(pathlib.Path):
172
164
  if self.is_dir():
173
165
  return sum([p.size(**kwargs) for p in self.iterdir()])
174
166
 
175
- return self.stat(**kwargs).st_size
167
+ return super().stat(**kwargs).st_size
176
168
 
177
169
  def copy(self, dst: str, exist_ok: bool = True, **kwargs) -> _Path:
178
170
  """
@@ -246,11 +238,11 @@ class Path(pathlib.Path):
246
238
  return self.__class__(_path)
247
239
 
248
240
  @staticmethod
249
- def _find_archive_format(filename: "Path") -> str:
241
+ def _find_archive_format(filename: _Path) -> str:
250
242
  """
251
243
  Searches for a file the correct archive format.
252
244
  """
253
- ext = ".".join(filename.suffixes)
245
+ ext = "".join(filename.suffixes)
254
246
 
255
247
  for name, extensions, _ in shutil.get_unpack_formats():
256
248
  if ext in extensions:
@@ -270,19 +262,61 @@ class Path(pathlib.Path):
270
262
  else:
271
263
  register_format()
272
264
 
273
- def make_archive(self, archivename: str, **kwargs) -> _Path:
265
+ def make_archive(
266
+ self, archivename: str, *, exists_ok: bool = False, **kwargs
267
+ ) -> _Path:
274
268
  """
275
269
  Creates an archive file (eg. zip) and returns the path to the archive.
276
270
 
271
+ If `exists_ok` is `False` a `FileExistsError` is raised if the archive file
272
+ already exists.
273
+
274
+ If `exists_ok` is `True` the existing archive file will be deleted before
275
+ creating the new one.
276
+
277
277
  For `**kwargs` see `shutil.make_archive()`.
278
278
  - `root_dir` and `base_dir` will be resolved automatically
279
279
  - `format` will be determined by the file suffix
280
280
  - It can be overwritten with an `format` keyword-argument.
281
281
  - a `ValueError` is raised if the `format` is unknown.
282
+
283
+ >>> Path(__file__).make_archive('test.tar.gz')
284
+ Path('test.tar.gz')
285
+
286
+ >>> Path(__file__).make_archive('test.zpy', format='zip')
287
+ Path('test.zpy')
282
288
  """
289
+
290
+ def _archive_exists(file: str, exists_ok: bool) -> _Path:
291
+ """
292
+ Returns a `Path` object of the archive file or raises a `FileExistsError`
293
+ If `exists_ok` is `True` the file will be deleted.
294
+ """
295
+ file = self.__class__(file).resolve()
296
+
297
+ if file.exists():
298
+ if not exists_ok:
299
+ raise FileExistsError(f"{file} already exists")
300
+
301
+ file.unlink()
302
+
303
+ return file
304
+
305
+ def _archive_filename(expect: Path, real: str) -> _Path:
306
+ """
307
+ Check if the expected archive filename matches the real filename.
308
+ If not try to rename the real filename.
309
+ """
310
+ file = self.__class__(real).resolve(True)
311
+
312
+ if file.suffixes != expect.suffixes:
313
+ return file.rename(expect)
314
+
315
+ return file
316
+
283
317
  _self = self.resolve(strict=True)
284
- _archive = Path(archivename).resolve()
285
- _format = kwargs.pop("format", self._find_archive_format(_archive))
318
+ _filename = _archive_exists(archivename, exists_ok)
319
+ _format = kwargs.pop("format", self._find_archive_format(_filename))
286
320
 
287
321
  _ = kwargs.pop("root_dir", None)
288
322
  _ = kwargs.pop("base_dir", None)
@@ -290,17 +324,19 @@ class Path(pathlib.Path):
290
324
  for _ in range(2):
291
325
  try:
292
326
  _archive = shutil.make_archive(
293
- base_name=_archive.parent.joinpath(_archive.stem),
327
+ base_name=_filename.with_suffix([]),
294
328
  format=_format,
295
329
  root_dir=_self.parent,
296
330
  base_dir=_self.relative_to(_self.parent),
297
331
  **kwargs,
298
332
  )
299
333
 
300
- return self.__class__(_archive)
334
+ break
301
335
  except ValueError:
302
336
  self._register_format(_format)
303
337
 
338
+ return _archive_filename(_filename, _archive)
339
+
304
340
  def unpack_archive(self, extract_dir: str, **kwargs) -> _Path:
305
341
  """
306
342
  Unpacks an archive file (eg. zip) into a directory and returns the path to the
@@ -310,6 +346,12 @@ class Path(pathlib.Path):
310
346
  - `format` will be determined by the file suffix
311
347
  - It can be overwritten with an `format` keyword-argument.
312
348
  - a `ValueError` is raised if the `format` is unknown.
349
+
350
+ >>> Path('test.tar.gz').unpack_archive('test')
351
+ Path('test')
352
+
353
+ >>> Path('test.zpy').unpack_archive('test', format='zip')
354
+ Path('test')
313
355
  """
314
356
 
315
357
  _format = kwargs.pop("format", self._find_archive_format(self))
@@ -343,6 +385,96 @@ class Path(pathlib.Path):
343
385
 
344
386
  return set(formats)
345
387
 
388
+ def stat(self, **kwargs) -> _stat_result:
389
+ """
390
+ Returns a `StatResult` object which modifies following attributes:
391
+
392
+ - `st_size` is wrapped in `ByteInt`
393
+ - `st_atime`, `st_mtime`, `st_ctime`, `st_birthtime` are wrapped in `TimeInt`
394
+
395
+ For `**kwargs` see `pathlib.Path.stat()`.
396
+ """
397
+ return StatResult(super().stat(**kwargs))
398
+
399
+ def with_suffix(self, suffix: Union[str, List[str]]) -> _Path:
400
+ """
401
+ Return a new `Path` with changed suffix or remove it when its an empty
402
+ string.
403
+
404
+ Multiple suffixes can be changed at once by passing a list of suffixes.
405
+ With a empty list all suffixes will be removed.
406
+
407
+ >>> Path('test.a.b').with_suffix('.c')
408
+ Path('test.a.c')
409
+
410
+ >>> Path('test.a.b').with_suffix('')
411
+ Path('test.a')
412
+
413
+ >>> Path('test.a.b').with_suffix(['.c', '.d'])
414
+ Path('test.c.d')
415
+
416
+ >>> Path('test.a.b').with_suffix([])
417
+ Path('test')
418
+ """
419
+
420
+ try:
421
+ return super().with_suffix(suffix)
422
+ except (AttributeError, TypeError):
423
+ if isinstance(suffix, list) and not suffix:
424
+ suffix = ""
425
+ elif all(s.startswith(".") for s in suffix):
426
+ suffix = "".join(suffix)
427
+ elif all(not s for s in suffix):
428
+ suffix = ""
429
+ else:
430
+ raise ValueError(f"Invalid suffix '{suffix}'")
431
+
432
+ end = -1 * len(self.suffixes) or None
433
+ name = self.name.split(".")[0:end]
434
+ stem = self.parent.joinpath("".join(name))
435
+ return super(self.__class__, stem).with_suffix(suffix)
436
+
437
+ def relative_to(
438
+ self, *other: Union[str, _Path], walk_up: Union[bool, int] = False
439
+ ) -> _Path:
440
+ """
441
+ Return the relative path to another path identified by the passed
442
+ arguments. If the operation is not possible (because this is not
443
+ related to the other path), raise `ValueError`.
444
+
445
+ The `walk_up` parameter controls whether `..` may be used to resolve
446
+ the path.
447
+
448
+ If `walk_up` is a integer it specifies the maximum number of `..` to resolve, if
449
+ max is reached a `ValueError` is raised.
450
+
451
+ >>> Path('a/b/c/d').relative_to('a/b')
452
+ Path('c/d')
453
+
454
+ >>> Path('a/b/c/d').relative_to('a/b/e/f/g', walk_up=True)
455
+ Path('../../../c/d')
456
+
457
+ >>> Path('a/b/c/d').relative_to('a/b/e/f/g', walk_up=2)
458
+ ValueError: '../../../c/d' is outside of the relative deepth of '2'
459
+ """
460
+ if not walk_up:
461
+ return super().relative_to(*other)
462
+
463
+ try:
464
+ relative = super().relative_to(*other, walk_up=walk_up)
465
+ except TypeError:
466
+ relative = self.__class__(os.path.relpath(self, Path(*other)))
467
+
468
+ if type(walk_up) is not int:
469
+ return relative
470
+
471
+ if relative.parts.count("..") > walk_up:
472
+ raise ValueError(
473
+ f"'{relative}' is outside of the relative deepth of '{walk_up}'"
474
+ )
475
+
476
+ return relative
477
+
346
478
 
347
479
  class Register7zFormat(Path, archive="7z"):
348
480
  """
@@ -1,14 +1,17 @@
1
1
  import functools
2
+ import os
2
3
  import re
3
- from typing import Set, Tuple, TypeVar
4
+ from datetime import datetime, tzinfo
5
+ from typing import Set, Tuple, TypeVar, Iterable
4
6
 
5
7
  _ByteInt = TypeVar("_ByteInt", bound="ByteInt")
8
+ _stat_result = TypeVar("_stat_result", bound="os.stat_result")
6
9
 
7
10
 
8
11
  class ByteInt(int):
9
12
  """
10
13
  Inherit from `int` with attributes to convert bytes to decimal or binary `units` for
11
- measuring storage data. These attributes will return a `float`
14
+ measuring storage data. These attributes will return a `float`.
12
15
 
13
16
  >>> ByteInt(1234).kb
14
17
  1.234
@@ -17,6 +20,11 @@ class ByteInt(int):
17
20
 
18
21
  >>> f"{ByteInt(6543210):.2mib} mebibytes"
19
22
  '6.24 mebibytes'
23
+
24
+ String representation of `ByteInt` will return the most appropriate decimal unit.
25
+
26
+ >>> str(ByteInt(987654))
27
+ '987.7 kb'
20
28
  """
21
29
 
22
30
  __regex = re.compile(r"(?P<unit>[kmgtpezy]i?b)")
@@ -62,6 +70,36 @@ class ByteInt(int):
62
70
  """
63
71
  return set(self.__bytes.keys())
64
72
 
73
+ def __str__(self) -> str:
74
+ return self.string()
75
+
76
+ def string(self, decimal=True) -> str:
77
+ """
78
+ Return a string representation of `self` in the most appropriate unit.
79
+
80
+ If `decimal` is `False` then binary units will be used instead of `decimal`.
81
+
82
+ >>> ByteInt(12346789).string(False)
83
+ '11.77 mib'
84
+ """
85
+
86
+ def _decimal(x):
87
+ return "i" not in x[0]
88
+
89
+ def _binary(x):
90
+ return x[0].endswith("ib")
91
+
92
+ query = _decimal if decimal else _binary
93
+
94
+ for unit, (byte, _) in filter(query, self.__bytes.items()):
95
+ value = self / byte
96
+
97
+ if 1 <= value < 1000:
98
+ dec = 2 if value < 100 else 1
99
+ return value.__format__(f".{dec}f") + f" {unit}"
100
+
101
+ return f"{int(self)} b"
102
+
65
103
  @classmethod
66
104
  def info(cls, unit: str) -> Tuple[int, str]:
67
105
  """
@@ -190,3 +228,126 @@ def byteint(func):
190
228
  return value
191
229
 
192
230
  return wrapper
231
+
232
+
233
+ class TimeInt(float):
234
+ """
235
+ Inherit from `float` with attributes to convert seconds to `datetime` objects.
236
+
237
+ >>> TimeInt(0).datetime
238
+ datetime.datetime(1970, 1, 1, 0, 0)
239
+
240
+ >>> TimeInt(0).string('%d.%m.%Y')
241
+ '01.01.1970'
242
+
243
+ Return a string representation using `TimeInt.format`.
244
+
245
+ >>> str(TimeInt(0))
246
+ '1970-01-01 00:00:00'
247
+ """
248
+
249
+ format = "%Y-%m-%d %H:%M:%S"
250
+ """
251
+ Format string to which is uesed to convert `self` to a string. Default: 'isoformat'.
252
+ For more information see `datetime.datetime.strftime`.
253
+ """
254
+
255
+ def __new__(cls, value: int, tz: tzinfo = None) -> float:
256
+ """
257
+ Create a new instance from baseclass `int`.
258
+ """
259
+ return super().__new__(cls, value)
260
+
261
+ def __init__(self, value: int, tz: tzinfo = None) -> None:
262
+ """
263
+ Create a new instance from baseclass `int` with optional `timezone` info.
264
+ """
265
+ self.timezone = tz
266
+ """
267
+ property for `datetime.timezone` object is set with `__init__` or can be
268
+ changed to different timezones to get the correct string reprensentation. If
269
+ timezone is `None` then the local timezone is used.
270
+ """
271
+
272
+ @functools.cached_property
273
+ def datetime(self) -> datetime:
274
+ """
275
+ property returns a `datetime.datetime` object.
276
+ """
277
+ return datetime.fromtimestamp(self, self.timezone)
278
+
279
+ def __str__(self) -> str:
280
+ """
281
+ Return a string representation of `datetime` using `self.format`.
282
+ """
283
+ return self.string()
284
+
285
+ def string(self, format_string: str = None) -> str:
286
+ """
287
+ Return a string representation of `datetime` using the `format_string`.
288
+
289
+ If `format_string` is `None` then `TimeInt.format` is used.
290
+ """
291
+ return self.datetime.strftime(format_string or self.format)
292
+
293
+
294
+ class StatResult:
295
+ """
296
+ Object converts `st_size` to `ByteInt` and
297
+ `st_atime`, `st_mtime`, `st_ctime` and `st_birthtime` to `TimeInt`.
298
+
299
+ Inheritance was not possible due `@final` decorator is applied to `os.stat_result`
300
+ to prevent subclassing.
301
+ """
302
+
303
+ def __init__(self, stat):
304
+ """
305
+ Wrapper for `os.stat_result`.
306
+ """
307
+ self._obj = stat
308
+
309
+ def __getattr__(self, name):
310
+ """
311
+ Forward all unknown attributes to `self._obj`.
312
+ """
313
+ attr = getattr(self._obj, name)
314
+
315
+ if isinstance(attr, int):
316
+ if name == "st_size":
317
+ return ByteInt(attr)
318
+ elif isinstance(attr, float):
319
+ if name in ("st_atime", "st_mtime", "st_ctime", "st_birthtime"):
320
+ return TimeInt(attr)
321
+
322
+ return attr
323
+
324
+ def __str__(self) -> str:
325
+ """
326
+ Return a string of `os.stat_result` object.
327
+ """
328
+ return str(self._obj)
329
+
330
+ def __repr__(self) -> str:
331
+ """
332
+ Return representation of `os.stat_result` object.
333
+ """
334
+ return repr(self._obj)
335
+
336
+ def __dir__(self) -> Iterable[str]:
337
+ """
338
+ Return a list of attributes of `os.stat_result` object.
339
+ """
340
+ return dir(self._obj)
341
+
342
+ def __len__(self) -> int:
343
+ """
344
+ Return length of `os.stat_result` object.
345
+ """
346
+ return len(self._obj)
347
+
348
+ @property
349
+ def stat_result(self) -> os.stat_result:
350
+ """
351
+ Return the wrapped `os.stat_result` object.
352
+ """
353
+ return self._obj
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pathlibutil"
3
- version = "v0.1.6"
3
+ version = "v0.1.8"
4
4
  description = "inherits from pathlib.Path with methods for hashing, copying, deleting and more"
5
5
  authors = ["Christoph Dörrer <d-chris@web.de>"]
6
6
  readme = "README.md"
@@ -33,6 +33,7 @@ tox = "^4.11.4"
33
33
  pytest-random-order = "^1.1.0"
34
34
  pytest-cov = "^4.1.0"
35
35
  pytest-mock = "^3.12.0"
36
+ exrex = "^0.11.0"
36
37
 
37
38
  [tool.poetry.group.code.dependencies]
38
39
  flake8 = "^7.0.0"
@@ -63,8 +64,9 @@ testpaths = "tests"
63
64
  addopts = [
64
65
  "--random-order",
65
66
  "--color=yes",
66
- "--cov=pathlibutil",
67
- "--cov-report=term-missing:skip-covered",
68
- "--cov-append",
67
+ "-s",
68
+ # "--cov=pathlibutil",
69
+ # "--cov-report=term-missing:skip-covered",
70
+ # "--cov-append",
69
71
  # "--cov-report=html",
70
72
  ]
@@ -1,8 +0,0 @@
1
- """
2
- .. include:: ../README.md
3
- """
4
-
5
- from pathlibutil.path import Path, Register7zFormat
6
- from pathlibutil.types import ByteInt, byteint
7
-
8
- __all__ = ["Path", "Register7zFormat", "ByteInt", "byteint"]
File without changes