pathroot 1.0.0__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.
@@ -0,0 +1 @@
1
+ include test_pathroot.py
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: pathroot
3
+ Version: 1.0.0
4
+ Summary: Subclass of a pathlib.Path object that does not allow traversal outside of a trusted root.
5
+ Author-email: Josh Schneider <josh.schneider@gmail.com>
6
+ Maintainer-email: Josh Schneider <josh.schneider@gmail.com>
7
+ License-Expression: MIT
8
+ Keywords: path,pathlib,filesystem
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Provides-Extra: dev
12
+ Requires-Dist: ruff; extra == "dev"
13
+ Requires-Dist: pytest; extra == "dev"
14
+
15
+ # PathRoot
16
+
17
+ ## What is PathRoot?
18
+
19
+ A `PathRoot` object is a subclass of `pathlib.Path`. It takes an extra `safe_root=` keyword argument to set a trusted
20
+ root, and prevents operations that traverse outside of the trusted root.
21
+
22
+ ## How do you use PathRoot?
23
+
24
+ You can initialize a `PathRoot` object like this:
25
+
26
+ ```python
27
+ from pathroot import PathRoot
28
+
29
+ root = PathRoot('/Users/foo/bar', safe_root='/Users/foo/bar')
30
+ root = PathRoot('/Users/foo/bar') # This also works.
31
+ ```
32
+
33
+ From there, you can do anything you can do with a `Path` object. For instance:
34
+
35
+ ```python
36
+ my_file = root / 'groceries.txt' # This would work.
37
+ my_file = root / '..' / '..' / 'groceries.txt' # This would raise a `PathOutsideRootError` exception.
38
+ ```
@@ -0,0 +1,24 @@
1
+ # PathRoot
2
+
3
+ ## What is PathRoot?
4
+
5
+ A `PathRoot` object is a subclass of `pathlib.Path`. It takes an extra `safe_root=` keyword argument to set a trusted
6
+ root, and prevents operations that traverse outside of the trusted root.
7
+
8
+ ## How do you use PathRoot?
9
+
10
+ You can initialize a `PathRoot` object like this:
11
+
12
+ ```python
13
+ from pathroot import PathRoot
14
+
15
+ root = PathRoot('/Users/foo/bar', safe_root='/Users/foo/bar')
16
+ root = PathRoot('/Users/foo/bar') # This also works.
17
+ ```
18
+
19
+ From there, you can do anything you can do with a `Path` object. For instance:
20
+
21
+ ```python
22
+ my_file = root / 'groceries.txt' # This would work.
23
+ my_file = root / '..' / '..' / 'groceries.txt' # This would raise a `PathOutsideRootError` exception.
24
+ ```
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: pathroot
3
+ Version: 1.0.0
4
+ Summary: Subclass of a pathlib.Path object that does not allow traversal outside of a trusted root.
5
+ Author-email: Josh Schneider <josh.schneider@gmail.com>
6
+ Maintainer-email: Josh Schneider <josh.schneider@gmail.com>
7
+ License-Expression: MIT
8
+ Keywords: path,pathlib,filesystem
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Provides-Extra: dev
12
+ Requires-Dist: ruff; extra == "dev"
13
+ Requires-Dist: pytest; extra == "dev"
14
+
15
+ # PathRoot
16
+
17
+ ## What is PathRoot?
18
+
19
+ A `PathRoot` object is a subclass of `pathlib.Path`. It takes an extra `safe_root=` keyword argument to set a trusted
20
+ root, and prevents operations that traverse outside of the trusted root.
21
+
22
+ ## How do you use PathRoot?
23
+
24
+ You can initialize a `PathRoot` object like this:
25
+
26
+ ```python
27
+ from pathroot import PathRoot
28
+
29
+ root = PathRoot('/Users/foo/bar', safe_root='/Users/foo/bar')
30
+ root = PathRoot('/Users/foo/bar') # This also works.
31
+ ```
32
+
33
+ From there, you can do anything you can do with a `Path` object. For instance:
34
+
35
+ ```python
36
+ my_file = root / 'groceries.txt' # This would work.
37
+ my_file = root / '..' / '..' / 'groceries.txt' # This would raise a `PathOutsideRootError` exception.
38
+ ```
@@ -0,0 +1,10 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pathroot.py
4
+ pyproject.toml
5
+ test_pathroot.py
6
+ pathroot.egg-info/PKG-INFO
7
+ pathroot.egg-info/SOURCES.txt
8
+ pathroot.egg-info/dependency_links.txt
9
+ pathroot.egg-info/requires.txt
10
+ pathroot.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ ruff
4
+ pytest
@@ -0,0 +1 @@
1
+ pathroot
@@ -0,0 +1,170 @@
1
+ """Variant of a Path that does not allow traversal outside of the root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from pathlib import Path, PosixPath, WindowsPath
8
+
9
+ LOG = logging.getLogger(__name__)
10
+ OS_NAME = os.name
11
+
12
+
13
+ class PathOutsideRootError(OSError):
14
+ """Exception to raise when a path traverses outside a root."""
15
+
16
+ def __init__(self, path: Path, root: PathRoot, *args):
17
+ """Prepare a PathOutsideRootError for use.
18
+
19
+ Args:
20
+ path: Target path.
21
+ root: Trusted root path.
22
+ *args: Arguments passed to OSError.
23
+ """
24
+ super().__init__(*args)
25
+ self.path = path
26
+ self.root = root
27
+
28
+ def __str__(self) -> str:
29
+ """String message."""
30
+ return f"Path {self.path} ({self.path.resolve()}) is outside of {self.root}."
31
+
32
+
33
+ class PathRoot(Path):
34
+ """Base class for a path that does not allow traversal outside.
35
+
36
+ Notes:
37
+ When a PathRoot is first instantiated, if a `safe_root` is not provided, then
38
+ the current directory is used as the SafeRoot. All methods that mutate the path
39
+ or work off of additional provided paths have those paths resolved and checked
40
+ against the safe root. If the resolved path is not relative to the safe root,
41
+ then a `PathOutsideRootError` is raised.
42
+ """
43
+
44
+ def __new__(cls, *args, **kwargs) -> WindowsPathRoot | PosixPathRoot: # noqa: ARG004
45
+ """Generate the OS-specific subclass based on the current OS."""
46
+ if cls is PathRoot:
47
+ cls = WindowsPathRoot if OS_NAME == "nt" else PosixPathRoot
48
+ return object.__new__(cls)
49
+
50
+ def __init__(self, *args, safe_root: Path | None = None):
51
+ """Prepare a PathRoot for use.
52
+
53
+ Args:
54
+ *args: Path segments, passed to Path.
55
+ safe_root: Root path to use for all operations. Defaults to None (current path is used).
56
+ """
57
+ super().__init__(*args)
58
+
59
+ # If the safe_root is None, then one was not provided. Look through the args
60
+ # and see if we have any PathRoot instances... first one wins.
61
+ if safe_root is None:
62
+ for arg in args:
63
+ if isinstance(arg, PathRoot):
64
+ safe_root = arg.safe_root
65
+ break
66
+ else: # no break
67
+ # Set the safe_root to this path.
68
+ safe_root = Path(self)
69
+ self.safe_root = safe_root.resolve() # Ensure safe_root is resolved.
70
+ LOG.debug("Created %r", self)
71
+
72
+ def __repr__(self) -> str:
73
+ """Internal string representation."""
74
+ return f"{type(self).__name__}({self.as_posix()!r}, safe_root={self.safe_root.as_posix()!r})"
75
+
76
+ def __check_path(self, path: Path | PathRoot) -> PathRoot:
77
+ """Check if a path traverses outside.
78
+
79
+ Args:
80
+ path: Path to check.
81
+
82
+ Returns:
83
+ The tested path.
84
+
85
+ Raises:
86
+ PathOutsideRootError: If the path traverses outside of the root path.
87
+ """
88
+ p = Path(path).resolve()
89
+ LOG.debug("Testing %s against %s", p, self.safe_root)
90
+ if not p.is_relative_to(self.safe_root):
91
+ raise PathOutsideRootError(path, self.safe_root)
92
+
93
+ match path:
94
+ # If the path is a PathRoot with no safe_root set, set it.
95
+ case PathRoot():
96
+ path.safe_root = self.safe_root
97
+
98
+ # If the path is not a PathRoot, make it one.
99
+ case Path() if not isinstance(path, PathRoot):
100
+ path = PathRoot(path, safe_root=self.safe_root)
101
+
102
+ return path
103
+
104
+ def with_segments(self, *args) -> PathRoot:
105
+ """Return a new path with segments.
106
+
107
+ Args:
108
+ *args: Path segments.
109
+
110
+ Returns:
111
+ New path.
112
+ """
113
+ return self.__check_path(super().with_segments(*args))
114
+
115
+ def rename(self, target: Path | str) -> PathRoot:
116
+ """Rename this path to the target path.
117
+
118
+ Args:
119
+ target: Target path. Must be in the root.
120
+
121
+ Returns:
122
+ New PathRoot instance pointing to the target path.
123
+
124
+ Notes:
125
+ The target path may be absolute or relative. Relative paths are
126
+ interpreted relative to the current working directory *not* the
127
+ directory of the Path object.
128
+ """
129
+ return super().rename(self.__check_path(target))
130
+
131
+ def replace(self, target: Path | str) -> PathRoot:
132
+ """Rename this path to the target path, overwriting if that path exists.
133
+
134
+ Args:
135
+ target: Target path. Must be in the root.
136
+
137
+ Returns:
138
+ New PathRoot instance pointing to the target path.
139
+
140
+ Notes:
141
+ The target path may be absolute or relative. Relative paths are
142
+ interpreted relative to the current working directory *not* the
143
+ directory of the Path object.
144
+ """
145
+ return super().replace(self.__check_path(target))
146
+
147
+ def symlink_to(self, target: Path | str, target_is_directory: bool = False) -> None:
148
+ """Make this path a symlink pointing to the target path.
149
+
150
+ Args:
151
+ target: Target to link to. Must be inside root path.
152
+ target_is_directory: Should the target be treated as a directory (only valid for Windows). Defaults to False.
153
+ """
154
+ return super().symlink_to(self.__check_path(target), target_is_directory)
155
+
156
+ def hardlink_to(self, target: Path | str) -> None:
157
+ """Make this path a hard link pointing to the same file as *target*.
158
+
159
+ Args:
160
+ target: Target to link to. Must be inside the root path.
161
+ """
162
+ return super().hardlink_to(self.__check_path(target))
163
+
164
+
165
+ class PosixPathRoot(PosixPath, PathRoot):
166
+ """Path that does not allow traversal outside of root for Linux/MacOS."""
167
+
168
+
169
+ class WindowsPathRoot(WindowsPath, PathRoot):
170
+ """Path that does not allow traversal outside of the root for Windows."""
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "pathroot"
3
+ version = "1.0.0"
4
+ description = "Subclass of a pathlib.Path object that does not allow traversal outside of a trusted root."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = []
8
+ authors = [{ name = "Josh Schneider", email = "josh.schneider@gmail.com" }]
9
+ maintainers = [{ name = "Josh Schneider", email = "josh.schneider@gmail.com" }]
10
+ license = "MIT"
11
+ keywords = ["path", "pathlib", "filesystem"]
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["ruff", "pytest"]
15
+
16
+ [build-system]
17
+ requires = ["setuptools"]
18
+ build-backend = "setuptools.build_meta"
19
+
20
+ [tool.setuptools]
21
+ py-modules = ["pathroot"]
22
+
23
+ [tool.ruff]
24
+ line-length = 120
25
+ respect-gitignore = true
26
+
27
+ [tool.ruff.lint]
28
+ select = [
29
+ "C90", # mccabe
30
+ "I", # iSort
31
+ "N", # pep8-naming
32
+ "D", # pydocstyle
33
+ "ANN", # flake8-annotations
34
+ "S", # flake8-bandit
35
+ "B", # flake8-bugbear
36
+ "A", # flake8-builtins
37
+ "COM", # flake8-commas
38
+ "C4", # flake8-comprehensions
39
+ "EM", # flake8-errmsg
40
+ "FA", # flake8-future-annotations
41
+ "ISC", # flake8-implicit-str-concat
42
+ "LOG", # flake8-logging
43
+ "G", # flake8-logging-format
44
+ "T20", # flake8-print
45
+ "Q", # flake8-quotes
46
+ "SIM", # flake8-simplify
47
+ "ARG", # flake8-unused-arguments
48
+ "PT", # flake8-pytest-style
49
+ "PTH", # flake8-use-pathlib
50
+ "PERF", # perflint
51
+ "RUF", # Ruff-specific rules
52
+ ]
53
+
54
+ ignore = [
55
+ "ANN002", # No need to type *args
56
+ "ANN003", # No need to type **kwargs
57
+ ]
58
+
59
+ [tool.ruff.lint.extend-per-file-ignores]
60
+ "test_*.py" = [ # Pytest tests
61
+ "ANN001", # No need to type hint fixtures
62
+ "T201", # Print is fine in unit tests
63
+ "S101", # assert is kinda the reason we're here...
64
+ ]
65
+
66
+ [tool.ruff.lint.pydocstyle]
67
+ convention = "google"
68
+
69
+ [tool.ruff.lint.flake8-annotations]
70
+ suppress-none-returning = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,221 @@
1
+ """Unit tests for pathroot."""
2
+
3
+ import logging
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ import pathroot
10
+
11
+ LOG = logging.getLogger(__name__)
12
+
13
+ # Dictionary of test files to create in root_folder.
14
+ # Keys should be Path objects, and values should be None
15
+ # for directories, or a bytes object for contents.
16
+ TEST_FILES = {
17
+ Path("d1"): None,
18
+ Path("d1/f1.txt"): b"First file",
19
+ Path("d2"): None,
20
+ Path("d2/f2.txt"): b"Second file",
21
+ }
22
+
23
+
24
+ # region Fixtures
25
+ @pytest.fixture
26
+ def root_folder(tmp_path) -> Path: # type: ignore
27
+ """Self cleaning test folder, populated by TEST_FILES."""
28
+ for p, c in TEST_FILES.items():
29
+ p = tmp_path / p
30
+ if c is None:
31
+ p.mkdir(exist_ok=True, parents=True)
32
+ LOG.info("** Created dir %s", p)
33
+ else:
34
+ p.parent.mkdir(exist_ok=True, parents=True)
35
+ p.write_bytes(c)
36
+ LOG.info("** Create file %s", p)
37
+
38
+ LOG.info("** Returning %s", tmp_path)
39
+ yield tmp_path
40
+
41
+ for p in sorted(tmp_path.rglob("*"), reverse=True):
42
+ if p.is_symlink() or p.is_file():
43
+ p.unlink()
44
+ LOG.info("** Unlinking %s", p)
45
+ elif p.is_dir():
46
+ p.rmdir()
47
+ LOG.info("** Removing dir %s", p)
48
+
49
+
50
+ @contextmanager
51
+ def fix_os_name(v: str):
52
+ """Context manager for replacing pathroot.OS_NAME temporarily.
53
+
54
+ Args:
55
+ v: Value to set OS_NAME to.
56
+ """
57
+ old_val = pathroot.OS_NAME
58
+ pathroot.OS_NAME = v
59
+ LOG.info("** Set OS_NAME to %r", v)
60
+ try:
61
+ yield
62
+ finally:
63
+ pathroot.OS_NAME = old_val
64
+ LOG.info("** Set OS_NAME back to %r", old_val)
65
+
66
+
67
+ @pytest.fixture
68
+ def _force_nt():
69
+ """Force the OS name to nt (for Windows)."""
70
+ with fix_os_name("nt"):
71
+ yield
72
+
73
+
74
+ @pytest.fixture
75
+ def _force_posix():
76
+ """Force the OS name to darwin (for POSIX)."""
77
+ with fix_os_name("darwin"):
78
+ yield
79
+
80
+
81
+ # endregion
82
+
83
+
84
+ # region Tests
85
+ @pytest.mark.usefixtures("_force_nt")
86
+ def test_new_windows(root_folder):
87
+ """Test that PathRoot, on Windows, returns a WindowsPathRoot instance."""
88
+ # Act
89
+ r = pathroot.PathRoot(root_folder)
90
+
91
+ # Assert
92
+ assert type(r) is pathroot.WindowsPathRoot
93
+
94
+
95
+ @pytest.mark.usefixtures("_force_posix")
96
+ def test_new_posix(root_folder):
97
+ """Test that PathRoot, on a POSIX OS, returns a PosixPathRoot instance."""
98
+ # Act
99
+ r = pathroot.PathRoot(root_folder)
100
+
101
+ # Assert
102
+ assert type(r) is pathroot.PosixPathRoot
103
+
104
+
105
+ def test_joinpath_works(root_folder):
106
+ """Test that when we use joinpath with a path inside the the root, it works, and we get a PathRoot instance."""
107
+ # Arrange
108
+ r = pathroot.PathRoot(root_folder)
109
+
110
+ # Act
111
+ p1 = r.joinpath("foo/bar.txt")
112
+
113
+ # Assert
114
+ assert isinstance(p1, pathroot.PathRoot)
115
+ assert p1.safe_root is r.safe_root
116
+
117
+
118
+ def test_joinpath_errors(root_folder):
119
+ """Test that when we use joinpath with a path outside the root, it raises a PathOutsideRootError."""
120
+ # Arrange
121
+ r = pathroot.PathRoot(root_folder)
122
+
123
+ # Act and Assert
124
+ with pytest.raises(pathroot.PathOutsideRootError):
125
+ r.joinpath("..", "..", "etc")
126
+
127
+
128
+ def test_divide_works(root_folder):
129
+ """Test that when we use the divide operator inside the root, it works, and we get a PathRoot instance."""
130
+ # Arrange
131
+ r = pathroot.PathRoot(root_folder)
132
+
133
+ # Act
134
+ p1 = r / "foo" / "bar.txt"
135
+
136
+ # Assert
137
+ assert isinstance(p1, pathroot.PathRoot)
138
+ assert p1.safe_root is r.safe_root
139
+
140
+
141
+ def test_divide_errors(root_folder):
142
+ """Test that when we use the divide operator outside the root, it raises a PathOutsideRootError."""
143
+ # Arrange
144
+ r = pathroot.PathRoot(root_folder)
145
+
146
+ # Act and Assert
147
+ with pytest.raises(pathroot.PathOutsideRootError):
148
+ r / ".." / ".." / "etc"
149
+
150
+
151
+ def test_with_segments_works(root_folder):
152
+ """Test that with_segments with a path inside the root works, and we get a PathRoot instance."""
153
+ # Arrange
154
+ r = pathroot.PathRoot(root_folder)
155
+
156
+ # Act
157
+ p1 = r.with_segments(root_folder, "foo/bar.txt")
158
+
159
+ # Assert
160
+ assert isinstance(p1, pathroot.PathRoot)
161
+ assert p1.safe_root is r.safe_root
162
+
163
+
164
+ def test_with_segments_errors(root_folder):
165
+ """Test that when we use with_segments with a path inside the the root, it works, and we get a PathRoot instance."""
166
+ # Arrange
167
+ r = pathroot.PathRoot(root_folder)
168
+
169
+ # Act and Assert
170
+ with pytest.raises(pathroot.PathOutsideRootError):
171
+ r.with_segments(root_folder, "..", "..", "etc")
172
+
173
+
174
+ def test_rename_works(root_folder):
175
+ """Test that rename works when it should."""
176
+ # Arrange
177
+ p1 = pathroot.PathRoot(root_folder) / "d1"
178
+
179
+ # Act
180
+ p2 = p1.rename(root_folder / "d3")
181
+
182
+ # Assert
183
+ assert isinstance(p2, pathroot.PathRoot)
184
+ assert p2.safe_root is p1.safe_root
185
+
186
+
187
+ def test_rename_errors(root_folder):
188
+ """Test that rename errors when the target path is outside of the root."""
189
+ # Arrange
190
+ r = pathroot.PathRoot(root_folder)
191
+
192
+ # Act and Assert
193
+ with pytest.raises(pathroot.PathOutsideRootError):
194
+ r.rename(root_folder / ".." / ".." / "etc")
195
+
196
+
197
+ def test_replace_works(root_folder):
198
+ """Test that replae works."""
199
+ # Arrange
200
+ p1 = pathroot.PathRoot(root_folder) / "d1"
201
+
202
+ # Act
203
+ p2 = p1.replace(root_folder / "d3")
204
+
205
+ # Assert
206
+ assert isinstance(p2, pathroot.PathRoot)
207
+ assert p2.safe_root is p1.safe_root
208
+
209
+
210
+ def test_replace_errors(root_folder):
211
+ """Test that replace errors when the target path is outside of the root."""
212
+ # Arrange
213
+ r = pathroot.PathRoot(root_folder)
214
+
215
+ # Act and Assert
216
+ with pytest.raises(pathroot.PathOutsideRootError):
217
+ r.replace(root_folder / ".." / ".." / "etc")
218
+
219
+
220
+ # TODO: Other corner cases?
221
+ # endregion