superpathlib 2.0.14__tar.gz → 2.1.1__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.
Files changed (34) hide show
  1. {superpathlib-2.0.14/src/superpathlib.egg-info → superpathlib-2.1.1}/PKG-INFO +18 -16
  2. {superpathlib-2.0.14 → superpathlib-2.1.1}/README.md +13 -8
  3. {superpathlib-2.0.14 → superpathlib-2.1.1}/pyproject.toml +8 -21
  4. superpathlib-2.1.1/src/superpathlib/archive.py +71 -0
  5. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/common_folders.py +3 -8
  6. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/content_properties.py +38 -25
  7. superpathlib-2.1.1/src/superpathlib/encrypted.py +79 -0
  8. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/extra_functionality.py +63 -133
  9. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/metadata_properties.py +10 -52
  10. superpathlib-2.1.1/src/superpathlib/override.py +82 -0
  11. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/utils.py +17 -0
  12. {superpathlib-2.0.14 → superpathlib-2.1.1/src/superpathlib.egg-info}/PKG-INFO +18 -16
  13. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib.egg-info/SOURCES.txt +2 -5
  14. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib.egg-info/requires.txt +3 -6
  15. {superpathlib-2.0.14 → superpathlib-2.1.1}/tests/test_common_folders.py +3 -3
  16. superpathlib-2.1.1/tests/test_content.py +141 -0
  17. {superpathlib-2.0.14 → superpathlib-2.1.1}/tests/test_functionality.py +40 -93
  18. {superpathlib-2.0.14 → superpathlib-2.1.1}/tests/test_inheritance.py +3 -2
  19. {superpathlib-2.0.14 → superpathlib-2.1.1}/tests/test_metadata.py +3 -29
  20. superpathlib-2.0.14/src/superpathlib/cached_content.py +0 -63
  21. superpathlib-2.0.14/src/superpathlib/encryption.py +0 -73
  22. superpathlib-2.0.14/src/superpathlib/override.py +0 -114
  23. superpathlib-2.0.14/src/superpathlib/tags.py +0 -53
  24. superpathlib-2.0.14/tests/test_cached_content.py +0 -61
  25. superpathlib-2.0.14/tests/test_content.py +0 -101
  26. superpathlib-2.0.14/tests/test_encrypted_content.py +0 -66
  27. {superpathlib-2.0.14 → superpathlib-2.1.1}/LICENSE +0 -0
  28. {superpathlib-2.0.14 → superpathlib-2.1.1}/setup.cfg +0 -0
  29. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/__init__.py +0 -0
  30. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/base.py +0 -0
  31. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/path.py +0 -0
  32. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib/py.typed +0 -0
  33. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib.egg-info/dependency_links.txt +0 -0
  34. {superpathlib-2.0.14 → superpathlib-2.1.1}/src/superpathlib.egg-info/top_level.txt +0 -0
@@ -1,36 +1,33 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superpathlib
3
- Version: 2.0.14
3
+ Version: 2.1.1
4
4
  Summary: Extended Pathlib
5
5
  Author-email: Quinten Roets <qdr2104@columbia.edu>
6
6
  License-Expression: MIT
7
7
  Project-URL: Source Code, https://github.com/quintenroets/superpathlib
8
- Requires-Python: >=3.10
8
+ Requires-Python: >=3.11
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: simple-classproperty<5,>=4.0.2
12
- Requires-Dist: typing_extensions<5,>=4.0
13
12
  Provides-Extra: full
14
13
  Requires-Dist: dirhash<1,>=0.2.1; extra == "full"
15
14
  Requires-Dist: numpy<3,>=1.26.4; extra == "full"
16
- Requires-Dist: package-utils<1,>=0.8.1; extra == "full"
15
+ Requires-Dist: package-utils<1,>=0.9.0; extra == "full"
17
16
  Requires-Dist: PyYaml<7,>=6.0.1; extra == "full"
18
- Requires-Dist: xattr<2,>=0.10.1; extra == "full"
19
17
  Provides-Extra: dev
20
18
  Requires-Dist: hypothesis<7,>=6.97.1; extra == "dev"
21
- Requires-Dist: package-dev-tools<1,>=0.8.1; extra == "dev"
19
+ Requires-Dist: package-dev-tools<1,>=0.8.6; extra == "dev"
22
20
  Requires-Dist: types-PyYaml<7,>=6.0.12.12; extra == "dev"
23
21
  Requires-Dist: dirhash<1,>=0.2.1; extra == "dev"
24
22
  Requires-Dist: numpy<3,>=1.26.4; extra == "dev"
25
- Requires-Dist: package-utils<1,>=0.8.0; extra == "dev"
23
+ Requires-Dist: package-utils<1,>=0.9.0; extra == "dev"
26
24
  Requires-Dist: PyYaml<7,>=6.0.1; extra == "dev"
27
- Requires-Dist: xattr<2,>=0.10.1; extra == "dev"
28
25
  Dynamic: license-file
29
26
 
30
27
  # Superpathlib
31
28
  [![PyPI version](https://badge.fury.io/py/superpathlib.svg)](https://badge.fury.io/py/superpathlib)
32
29
  ![PyPI downloads](https://img.shields.io/pypi/dm/superpathlib)
33
- ![Python version](https://img.shields.io/badge/python-3.10+-brightgreen)
30
+ ![Python version](https://img.shields.io/badge/python-3.11+-brightgreen)
34
31
  ![Operating system](https://img.shields.io/badge/os-linux%20%7c%20macOS-brightgreen)
35
32
  ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
36
33
 
@@ -42,13 +39,13 @@ For custom operations, the package supports subclassing, which has been added to
42
39
 
43
40
  ## Key features:
44
41
  * Read and write content in various formats such as text, bytes, YAML, JSON, and even NumPy arrays
45
- * Access and modify file metadata like file size, modification time, and custom tags
42
+ * Access and modify file metadata like file size and modification time
46
43
  * Utility methods:
47
44
  * `rmtree` to remove directories recursively
48
45
  * `copy_to` to copy content
49
46
  * `tempfile` to create and use temporary files
50
47
  * `find` for recursive search
51
- * `unpack` for unpacking archives
48
+ * `unpack_if_archive` for unpacking archives
52
49
 
53
50
  ## Usage
54
51
 
@@ -66,10 +63,17 @@ path = Path(filename)
66
63
  * json
67
64
  * numpy
68
65
 
66
+ read-only:
67
+ * cached_yaml: yaml content, cached as json next to the file
68
+
69
+ gpg-encrypted on disk:
70
+ * encrypted: prefix any of the above to read & write plaintext (e.g. `path.encrypted.json`)
71
+
69
72
  examples:
70
73
 
71
74
  ```shell
72
75
  path.json = {key: value}
76
+ path.encrypted.json = {key: secret}
73
77
 
74
78
  for line in path.lines:
75
79
  if interesting(line):
@@ -85,14 +89,13 @@ for line in path.lines:
85
89
  * content_hash: a hash of the complete substructure found in a folder
86
90
  * get & set:
87
91
  * mtime: modified time
88
- * tag: can be used for alternative ordering or metadata
89
92
 
90
93
  examples:
91
94
 
92
95
  ```shell
93
96
  path_new.mtime = path_old.mtime
94
97
 
95
- if path.tag != skip_keyword and path.filetype == "video":
98
+ if path.filetype == "video":
96
99
  process(path)
97
100
  ```
98
101
  ### 3) Use class properties to access commonly used folders:
@@ -110,11 +113,10 @@ names = names_path.lines
110
113
  * find(): recursively find all paths under a root that match a condition (extra options available for performance optimization)
111
114
  * rmtree(): remove directory recursively
112
115
  * copy_to(dest): copy content to dest
113
- * copy_properties_to(dest): recursively copy path properties (mtime, tag) to all n-level children of dest
116
+ * copy_mtime_to(dest): recursively copy modified time to all n-level children of dest
114
117
  * tempfile(): create temporary file that can be used as context manager
115
- * unpack(): extract archive(zip, tar, ..) file to desired folder
118
+ * unpack_if_archive(): extract an archive(zip, tar, ..) file to desired folder, recursively
116
119
  * pop_parent(): remove first parent from path in filesystem
117
- * from_uri(uri): create path object from uri string
118
120
 
119
121
  examples:
120
122
 
@@ -1,7 +1,7 @@
1
1
  # Superpathlib
2
2
  [![PyPI version](https://badge.fury.io/py/superpathlib.svg)](https://badge.fury.io/py/superpathlib)
3
3
  ![PyPI downloads](https://img.shields.io/pypi/dm/superpathlib)
4
- ![Python version](https://img.shields.io/badge/python-3.10+-brightgreen)
4
+ ![Python version](https://img.shields.io/badge/python-3.11+-brightgreen)
5
5
  ![Operating system](https://img.shields.io/badge/os-linux%20%7c%20macOS-brightgreen)
6
6
  ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
7
7
 
@@ -13,13 +13,13 @@ For custom operations, the package supports subclassing, which has been added to
13
13
 
14
14
  ## Key features:
15
15
  * Read and write content in various formats such as text, bytes, YAML, JSON, and even NumPy arrays
16
- * Access and modify file metadata like file size, modification time, and custom tags
16
+ * Access and modify file metadata like file size and modification time
17
17
  * Utility methods:
18
18
  * `rmtree` to remove directories recursively
19
19
  * `copy_to` to copy content
20
20
  * `tempfile` to create and use temporary files
21
21
  * `find` for recursive search
22
- * `unpack` for unpacking archives
22
+ * `unpack_if_archive` for unpacking archives
23
23
 
24
24
  ## Usage
25
25
 
@@ -37,10 +37,17 @@ path = Path(filename)
37
37
  * json
38
38
  * numpy
39
39
 
40
+ read-only:
41
+ * cached_yaml: yaml content, cached as json next to the file
42
+
43
+ gpg-encrypted on disk:
44
+ * encrypted: prefix any of the above to read & write plaintext (e.g. `path.encrypted.json`)
45
+
40
46
  examples:
41
47
 
42
48
  ```shell
43
49
  path.json = {key: value}
50
+ path.encrypted.json = {key: secret}
44
51
 
45
52
  for line in path.lines:
46
53
  if interesting(line):
@@ -56,14 +63,13 @@ for line in path.lines:
56
63
  * content_hash: a hash of the complete substructure found in a folder
57
64
  * get & set:
58
65
  * mtime: modified time
59
- * tag: can be used for alternative ordering or metadata
60
66
 
61
67
  examples:
62
68
 
63
69
  ```shell
64
70
  path_new.mtime = path_old.mtime
65
71
 
66
- if path.tag != skip_keyword and path.filetype == "video":
72
+ if path.filetype == "video":
67
73
  process(path)
68
74
  ```
69
75
  ### 3) Use class properties to access commonly used folders:
@@ -81,11 +87,10 @@ names = names_path.lines
81
87
  * find(): recursively find all paths under a root that match a condition (extra options available for performance optimization)
82
88
  * rmtree(): remove directory recursively
83
89
  * copy_to(dest): copy content to dest
84
- * copy_properties_to(dest): recursively copy path properties (mtime, tag) to all n-level children of dest
90
+ * copy_mtime_to(dest): recursively copy modified time to all n-level children of dest
85
91
  * tempfile(): create temporary file that can be used as context manager
86
- * unpack(): extract archive(zip, tar, ..) file to desired folder
92
+ * unpack_if_archive(): extract an archive(zip, tar, ..) file to desired folder, recursively
87
93
  * pop_parent(): remove first parent from path in filesystem
88
- * from_uri(uri): create path object from uri string
89
94
 
90
95
  examples:
91
96
 
@@ -1,27 +1,25 @@
1
1
  [project]
2
2
  name = "superpathlib"
3
- version = "2.0.14"
3
+ version = "2.1.1"
4
4
  description = "Extended Pathlib"
5
5
  authors = [{name = "Quinten Roets", email = "qdr2104@columbia.edu"}]
6
6
  license = "MIT"
7
7
  readme = "README.md"
8
- requires-python = ">=3.10"
8
+ requires-python = ">=3.11"
9
9
  dependencies = [
10
10
  "simple-classproperty >=4.0.2, <5",
11
- "typing_extensions >=4.0, <5",
12
11
  ]
13
12
 
14
13
  [project.optional-dependencies]
15
14
  full = [
16
15
  "dirhash >=0.2.1, <1",
17
16
  "numpy >=1.26.4, <3",
18
- "package-utils >=0.8.1, <1",
17
+ "package-utils >=0.9.0, <1",
19
18
  "PyYaml >=6.0.1, <7",
20
- "xattr >=0.10.1, <2",
21
19
  ]
22
20
  dev = [
23
21
  "hypothesis >=6.97.1, <7",
24
- "package-dev-tools >=0.8.1, <1",
22
+ "package-dev-tools >=0.8.6, <1",
25
23
 
26
24
  # types
27
25
  "types-PyYaml >=6.0.12.12, <7",
@@ -29,9 +27,8 @@ dev = [
29
27
  # full
30
28
  "dirhash >=0.2.1, <1",
31
29
  "numpy >=1.26.4, <3",
32
- "package-utils >=0.8.0, <1",
30
+ "package-utils >=0.9.0, <1",
33
31
  "PyYaml >=6.0.1, <7",
34
- "xattr >=0.10.1, <2",
35
32
  ]
36
33
 
37
34
  [project.urls]
@@ -43,6 +40,7 @@ build-backend = "setuptools.build_meta"
43
40
 
44
41
  [tool.coverage.run]
45
42
  command_line = "-m pytest tests"
43
+ relative_files = true
46
44
 
47
45
  [tool.coverage.report]
48
46
  precision = 4
@@ -55,7 +53,6 @@ no_implicit_reexport = false
55
53
  [[tool.mypy.overrides]]
56
54
  module = [
57
55
  "dirhash.*",
58
- "xattr.*",
59
56
  ]
60
57
  ignore_missing_imports = true
61
58
 
@@ -71,6 +68,7 @@ fix = true
71
68
  select = ["ALL"]
72
69
  ignore = [
73
70
  "ANN401", # annotated with Any
71
+ "CPY001", # missing copyright notice
74
72
  "D1", # missing docstrings
75
73
  "D200", # one-line docstring
76
74
  "D203", # conflicts with D211
@@ -78,6 +76,7 @@ ignore = [
78
76
  "D212", # conflicts with D213
79
77
  "D401", # imperative first line
80
78
  "G004", # logging f-string
79
+ "PLC0415", # lazy imports for import performance and optional dependencies
81
80
  ]
82
81
 
83
82
  [tool.ruff.lint.per-file-ignores]
@@ -87,18 +86,6 @@ ignore = [
87
86
  "tests/*" = [
88
87
  "S101", # assert used
89
88
  ]
90
- "src/superpathlib/cached_content.py" = [
91
- "PLC0415", # lazy imports for optional dependency
92
- ]
93
- "src/superpathlib/content_properties.py" = [
94
- "PLC0415", # lazy imports for optional dependencies
95
- ]
96
- "src/superpathlib/extra_functionality.py" = [
97
- "PLC0415", # lazy imports for optional dependencies
98
- ]
99
- "src/superpathlib/metadata_properties.py" = [
100
- "PLC0415", # lazy imports for optional dependencies
101
- ]
102
89
 
103
90
  [tool.setuptools.package-data]
104
91
  superpathlib = ["py.typed"]
@@ -0,0 +1,71 @@
1
+ import shutil
2
+ from functools import cached_property
3
+ from typing import Generic, TypeVar, cast
4
+
5
+ from .extra_functionality import Path
6
+
7
+ P = TypeVar("P", bound=Path)
8
+
9
+
10
+ class Archive(Generic[P]):
11
+ def __init__(self, path: P) -> None:
12
+ self.path = path
13
+
14
+ @cached_property
15
+ def format_(self) -> str | None:
16
+ # noinspection PyProtectedMember
17
+ format_ = shutil._find_unpack_format(str(self.path)) # type: ignore[attr-defined] # noqa: SLF001
18
+ return cast("str | None", format_)
19
+
20
+ def unpack( # noqa: PLR0913
21
+ self,
22
+ extraction_directory: P | None = None,
23
+ *,
24
+ remove_existing: bool = True,
25
+ preserve_mtime: bool = True,
26
+ remove_original: bool = True,
27
+ format_: str | None = None,
28
+ recursive: bool = True,
29
+ ) -> None:
30
+ format_ = cast("str", self.format_) if format_ is None else format_
31
+ extraction_directory = (
32
+ self.create_extraction_directory(format_)
33
+ if extraction_directory is None
34
+ else extraction_directory
35
+ )
36
+
37
+ if remove_existing:
38
+ extraction_directory.remove()
39
+
40
+ shutil.unpack_archive(
41
+ self.path,
42
+ extract_dir=extraction_directory,
43
+ format=format_,
44
+ )
45
+
46
+ self.cleanup(extraction_directory)
47
+ if preserve_mtime:
48
+ self.path.copy_mtime_to(extraction_directory)
49
+
50
+ if remove_original:
51
+ self.path.unlink()
52
+
53
+ if recursive:
54
+ for path in extraction_directory.find():
55
+ path.unpack_if_archive()
56
+
57
+ def create_extraction_directory(self, format_: str) -> P:
58
+ # noinspection PyProtectedMember
59
+ unpack_formats = shutil._UNPACK_FORMATS # type: ignore[attr-defined] # noqa: SLF001
60
+ extensions, *_ = unpack_formats[format_]
61
+ extract_name = self.path.name
62
+ for archive_extension in extensions:
63
+ extract_name = extract_name.removesuffix(archive_extension)
64
+ return self.path.with_name(extract_name)
65
+
66
+ @staticmethod
67
+ def cleanup(extraction_directory: P) -> None:
68
+ (extraction_directory / "__MACOSX").rmtree(missing_ok=True)
69
+ subfolder = extraction_directory / extraction_directory.name
70
+ if subfolder.exists() and extraction_directory.number_of_children == 1:
71
+ subfolder.pop_parent() # pragma: nocover
@@ -1,17 +1,14 @@
1
1
  import abc
2
2
  import sys
3
3
  import typing
4
- from typing import Any, TypeVar
4
+ from typing import Any, Self
5
5
 
6
6
  from simple_classproperty import classproperty
7
- from typing_extensions import Self
8
7
 
9
8
  from . import base
10
9
 
11
- T = TypeVar("T", bound="Path")
12
10
 
13
-
14
- def enable_classproperties(cls: type[T]) -> None: # pragma: nocover
11
+ def enable_classproperties(cls: type) -> None: # pragma: nocover
15
12
  for name, method in vars(cls).items():
16
13
  if isinstance(method, classmethod):
17
14
  wrapped_method = method.__func__
@@ -28,9 +25,7 @@ class PropertyMeta(abc.ABCMeta):
28
25
  ) -> "PropertyMeta":
29
26
  meta_class = super().__new__(cls, name, bases, attributes)
30
27
  if sys.version_info >= (3, 13):
31
- enable_classproperties(
32
- meta_class, # type: ignore[arg-type]
33
- ) # pragma: nocover
28
+ enable_classproperties(meta_class) # pragma: nocover
34
29
  return meta_class
35
30
 
36
31
 
@@ -1,10 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  import typing
5
4
  from typing import Any
6
5
 
7
- from . import base
6
+ from . import metadata_properties
7
+ from .utils import catch_missing
8
8
 
9
9
  if typing.TYPE_CHECKING: # pragma: nocover
10
10
  from collections.abc import Iterable
@@ -12,12 +12,7 @@ if typing.TYPE_CHECKING: # pragma: nocover
12
12
  from numpy.typing import NDArray
13
13
 
14
14
 
15
- # Lazy imports for:
16
- # - performance optimization
17
- # - enabling optional dependencies
18
-
19
-
20
- class Path(base.Path):
15
+ class Path(metadata_properties.Path):
21
16
  """
22
17
  Properties to read & write content in different formats.
23
18
  """
@@ -31,11 +26,12 @@ class Path(base.Path):
31
26
  self.write_bytes(value)
32
27
 
33
28
  @property
29
+ @catch_missing(default="")
34
30
  def text(self) -> str:
35
31
  return self.read_text()
36
32
 
37
33
  @text.setter
38
- def text(self, value: str | Any) -> None:
34
+ def text(self, value: object) -> None:
39
35
  self.write_text(str(value))
40
36
 
41
37
  @property
@@ -56,31 +52,48 @@ class Path(base.Path):
56
52
  self.lines = typing.cast("list[str]", lines)
57
53
 
58
54
  @property
59
- def json(self) -> dict[str, Any] | list[Any]:
60
- value = json.loads(self.text or "{}")
61
- return typing.cast("dict[str, Any] | list[Any]", value)
55
+ @catch_missing(default=None)
56
+ def json(self) -> Any:
57
+ with self.open("rb") as fp:
58
+ import json
59
+
60
+ return json.load(fp)
62
61
 
63
62
  @json.setter
64
- def json(self, content: dict[Any, Any] | list[Any]) -> None:
65
- self.text = json.dumps(content)
63
+ def json(self, content: Any) -> None:
64
+ import json
65
+
66
+ with self.open("w") as fp:
67
+ json.dump(content, fp)
66
68
 
67
69
  @property
68
- def yaml(self) -> dict[str, Any] | list[Any]:
69
- import yaml
70
+ @catch_missing(default=None)
71
+ def yaml(self) -> Any:
72
+ with self.open("rb") as fp:
73
+ import yaml
70
74
 
71
- # C implementation much faster but only supported on Linux
72
- Loader: type[yaml.CFullLoader | yaml.FullLoader] = ( # noqa: N806
73
- yaml.CFullLoader if hasattr(yaml, "CFullLoader") else yaml.FullLoader
74
- )
75
- return yaml.load(self.text, Loader=Loader) or {} # noqa: S506
75
+ # C implementation much faster
76
+ loader = yaml.CSafeLoader if yaml.__with_libyaml__ else yaml.SafeLoader
77
+ return yaml.load(fp, Loader=loader) # noqa: S506
76
78
 
77
79
  @yaml.setter
78
- def yaml(self, value: dict[str, Any] | list[Any]) -> None:
80
+ def yaml(self, value: Any) -> None:
79
81
  import yaml
80
82
 
81
- # C implementation much faster but only supported on Linux
82
- Dumper = yaml.CDumper if hasattr(yaml, "CDumper") else yaml.Dumper # noqa: N806
83
- self.text = yaml.dump(value, Dumper=Dumper, width=1024)
83
+ # C implementation much faster
84
+ dumper = yaml.CSafeDumper if yaml.__with_libyaml__ else yaml.SafeDumper
85
+ with self.open("w") as fp:
86
+ yaml.dump(value, fp, Dumper=dumper, width=1024)
87
+
88
+ @property
89
+ def cached_yaml(self) -> Any:
90
+ cache_file = self.with_name(self.name + ".cache")
91
+ mtime = self.mtime
92
+ exists = mtime > 0
93
+ if exists and cache_file.mtime != mtime:
94
+ cache_file.json = self.yaml
95
+ cache_file.mtime = mtime
96
+ return cache_file.json if exists else None
84
97
 
85
98
  @property
86
99
  def numpy(self) -> NDArray[Any]:
@@ -0,0 +1,79 @@
1
+ import io
2
+ import subprocess
3
+ from functools import cache
4
+ from types import TracebackType
5
+ from typing import IO, Any, cast
6
+
7
+ from package_utils.secrets_ import load_secret
8
+
9
+ from .path import Path
10
+
11
+
12
+ class EncryptedPath(Path):
13
+ def open( # type: ignore[override]
14
+ self,
15
+ mode: str = "r",
16
+ buffering: int = -1, # noqa: ARG002
17
+ encoding: str | None = None,
18
+ errors: str | None = None,
19
+ newline: str | None = None,
20
+ ) -> IO[Any]:
21
+ access = mode.replace("b", "").replace("t", "")
22
+ writing = {"r": False, "w": True}[access]
23
+ buffer = EncryptedFile(Path(self), writing=writing)
24
+ return (
25
+ buffer
26
+ if "b" in mode
27
+ else EncryptedTextFile(buffer, encoding, errors, newline)
28
+ )
29
+
30
+
31
+ class EncryptedFile(io.BytesIO):
32
+ def __init__(self, path: Path, *, writing: bool) -> None:
33
+ self.path = path
34
+ self.writing = writing
35
+ plaintext = b"" if writing else run_gpg(path.read_bytes(), "--decrypt")
36
+ super().__init__(plaintext)
37
+
38
+ def __exit__(
39
+ self,
40
+ exception_type: type[BaseException] | None,
41
+ exception: BaseException | None,
42
+ traceback: TracebackType | None,
43
+ ) -> None:
44
+ self.cancel_write_if_failed(exception_type)
45
+ super().__exit__(exception_type, exception, traceback)
46
+
47
+ def cancel_write_if_failed(
48
+ self,
49
+ exception_type: type[BaseException] | None,
50
+ ) -> None:
51
+ self.writing &= exception_type is None
52
+
53
+ def close(self) -> None:
54
+ if not self.closed and self.writing:
55
+ self.path.write_bytes(run_gpg(self.getvalue(), "--symmetric"))
56
+ super().close()
57
+
58
+
59
+ class EncryptedTextFile(io.TextIOWrapper):
60
+ def __exit__(
61
+ self,
62
+ exception_type: type[BaseException] | None,
63
+ exception: BaseException | None,
64
+ traceback: TracebackType | None,
65
+ ) -> None:
66
+ cast("EncryptedFile", self.buffer).cancel_write_if_failed(exception_type)
67
+ super().__exit__(exception_type, exception, traceback)
68
+
69
+
70
+ def run_gpg(data: bytes, *options: str) -> bytes:
71
+ command = ("gpg", "--passphrase-fd", "0", "--batch", "--quiet", *options)
72
+ input_ = f"{load_passphrase()}\n".encode() + data
73
+ process = subprocess.run(command, input=input_, stdout=subprocess.PIPE, check=True) # noqa: S603
74
+ return process.stdout
75
+
76
+
77
+ @cache
78
+ def load_passphrase() -> str:
79
+ return load_secret("file encryption password")