fs-tool-cli 0.1.2__py3-none-any.whl

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 (60) hide show
  1. fs_tool/__init__.py +9 -0
  2. fs_tool/__main__.py +8 -0
  3. fs_tool/cli.py +305 -0
  4. fs_tool/core/__init__.py +1 -0
  5. fs_tool/core/config.py +117 -0
  6. fs_tool/core/errors.py +88 -0
  7. fs_tool/core/filesystem.py +50 -0
  8. fs_tool/core/hashing.py +49 -0
  9. fs_tool/core/models.py +44 -0
  10. fs_tool/core/py.typed +0 -0
  11. fs_tool/core/traversal.py +225 -0
  12. fs_tool/output/__init__.py +1 -0
  13. fs_tool/output/json.py +142 -0
  14. fs_tool/output/plain.py +58 -0
  15. fs_tool/output/py.typed +0 -0
  16. fs_tool/py.typed +0 -0
  17. fs_tool/safety/__init__.py +1 -0
  18. fs_tool/safety/py.typed +0 -0
  19. fs_tool/safety/validation.py +81 -0
  20. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/.nav.yml +15 -0
  21. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/_static/img/favicon.png +0 -0
  22. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/_static/img/logo.png +0 -0
  23. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/css/custom.css +5 -0
  24. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/develop/contributing.md +142 -0
  25. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/develop/naming_convention.md +46 -0
  26. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/funding/funding.md +55 -0
  27. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/getting-started/install.md +45 -0
  28. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/getting-started/virtual-environment.md +29 -0
  29. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/index.md +77 -0
  30. fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/usage/cli.md +13 -0
  31. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Auslandssemester.docx +0 -0
  32. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Barcelona Sehenswertes.docx +0 -0
  33. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Beruf/Bewerbungen.txt +0 -0
  34. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Der Goldschatz partygame.pdf +0 -0
  35. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Ablauf Freitag Grundschule.docx +0 -0
  36. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Dresdner Christstollen.pdf +0 -0
  37. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Kirschkuchen.pdf +0 -0
  38. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Pelmeni.odt +0 -0
  39. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Projekte/Sauna_Grundriss.odg +0 -0
  40. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Seemeilenbestaetigung.pdf +0 -0
  41. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Tidal Prediction Form.pdf +0 -0
  42. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Reise/Segeln/Dalmatien 2018/2017-08-26_19-18_Sat.gpx +559 -0
  43. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Reise/Segeln/Segeln Ibiza 2024.gpx +41 -0
  44. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Reise/Slowenien/Klettersteige Slowenien.gpx +6602 -0
  45. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Reise-Checkliste.odt +0 -0
  46. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Sauna_Aufriss.odg +0 -0
  47. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Schule/Antrag Sch/303/274lerausweis.docx +0 -0
  48. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Studium/Oberseminare 2009 Helduser.pdf +0 -0
  49. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Tokensystem Z/303/244hneputzen.odt +0 -0
  50. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Der Weg zum SHS.ods +0 -0
  51. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Italien/Klettersteige Italien 2024.kmz +0 -0
  52. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Radtouren/Gittersee-steinbach.gpx +1250 -0
  53. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Radtouren/SingletrailDresdenerHeide.gpx +3087 -0
  54. fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Radtouren/StauseeInDerDresdnerHeide.gpx +1466 -0
  55. fs_tool_cli-0.1.2.dist-info/METADATA +91 -0
  56. fs_tool_cli-0.1.2.dist-info/RECORD +60 -0
  57. fs_tool_cli-0.1.2.dist-info/WHEEL +5 -0
  58. fs_tool_cli-0.1.2.dist-info/entry_points.txt +2 -0
  59. fs_tool_cli-0.1.2.dist-info/licenses/LICENSE +24 -0
  60. fs_tool_cli-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,225 @@
1
+ """Recursive directory traversal (plan.md Phase 1).
2
+
3
+ Traversal is read-only: symlinks are recorded but never followed by
4
+ default, special files are reported as unsupported and never opened,
5
+ inaccessible entries are collected as errors instead of aborting the
6
+ scan, and results are deterministically sorted by entry name.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+ import mimetypes
13
+ import os
14
+ import stat
15
+ import sys
16
+ from dataclasses import dataclass, field
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Literal
20
+
21
+ from fs_tool.core.errors import ErrorCode, FsToolError
22
+ from fs_tool.core.filesystem import normalize_path
23
+ from fs_tool.core.models import EntryType, FilesystemEntry
24
+
25
+ DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
26
+ {
27
+ ".git",
28
+ ".venv",
29
+ "__pycache__",
30
+ ".pytest_cache",
31
+ ".ruff_cache",
32
+ ".mypy_cache",
33
+ "node_modules",
34
+ "build",
35
+ "dist",
36
+ ".idea",
37
+ ".vscode",
38
+ }
39
+ )
40
+
41
+ SortBy = Literal["name"]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TraversalError:
46
+ """An entry that could not be traversed or read."""
47
+
48
+ path: str
49
+ message: str
50
+
51
+
52
+ @dataclass
53
+ class TraversalResult:
54
+ """Result of a directory traversal."""
55
+
56
+ entries: list[FilesystemEntry] = field(default_factory=list)
57
+ errors: list[TraversalError] = field(default_factory=list)
58
+ complete: bool = True
59
+ skipped: int = 0
60
+
61
+
62
+ def _iso_timestamp(seconds: float) -> str:
63
+ return datetime.fromtimestamp(seconds, tz=UTC).isoformat()
64
+
65
+
66
+ def _entry_type(stat_result: os.stat_result | None, is_symlink: bool) -> EntryType:
67
+ if is_symlink:
68
+ return EntryType.SYMLINK
69
+ if stat_result is not None and stat.S_ISDIR(stat_result.st_mode):
70
+ return EntryType.DIRECTORY
71
+ if stat_result is not None and stat.S_ISREG(stat_result.st_mode):
72
+ return EntryType.FILE
73
+ return EntryType.OTHER
74
+
75
+
76
+ def _build_entry(path: Path, root: Path, follow_symlinks: bool = False) -> FilesystemEntry:
77
+ """Build a :class:`FilesystemEntry` without following symlinks."""
78
+ try:
79
+ lst = path.lstat()
80
+ except OSError as exc:
81
+ raise FsToolError(
82
+ f"Cannot stat {path}: {exc.strerror or exc}",
83
+ code=ErrorCode.INVALID_ARGUMENT,
84
+ path=str(path),
85
+ ) from exc
86
+
87
+ is_symlink = stat.S_ISLNK(lst.st_mode)
88
+ target: str | None = None
89
+ if is_symlink and not follow_symlinks:
90
+ try:
91
+ target = os.readlink(path)
92
+ except OSError:
93
+ target = None
94
+
95
+ stat_result: os.stat_result | None = None
96
+ if not is_symlink or follow_symlinks:
97
+ try:
98
+ stat_result = path.stat()
99
+ except OSError:
100
+ stat_result = None
101
+
102
+ owner: str | None = None
103
+ if sys.platform != "win32":
104
+ from pwd import getpwuid # POSIX only; Windows has no pwd module
105
+
106
+ try:
107
+ owner = getpwuid(lst.st_uid).pw_name
108
+ except KeyError: # pragma: no cover - POSIX without uid entry
109
+ owner = None
110
+
111
+ created: str | None = None
112
+ birthtime = getattr(lst, "st_birthtime", None)
113
+ if birthtime is not None:
114
+ created = _iso_timestamp(birthtime)
115
+
116
+ return FilesystemEntry(
117
+ path=str(path),
118
+ name=path.name,
119
+ rel_path=str(path.relative_to(root)),
120
+ type=_entry_type(stat_result, is_symlink),
121
+ extension=path.suffix.lower() if path.suffix else None,
122
+ size=lst.st_size,
123
+ modified=_iso_timestamp(lst.st_mtime),
124
+ accessed=_iso_timestamp(lst.st_atime),
125
+ changed=_iso_timestamp(lst.st_ctime),
126
+ created=created,
127
+ mode=stat.filemode(lst.st_mode),
128
+ mime_type=mimetypes.guess_type(path)[0],
129
+ owner=owner,
130
+ is_symlink=is_symlink,
131
+ target=target,
132
+ dev_ino=f"{lst.st_dev}:{lst.st_ino}",
133
+ )
134
+
135
+
136
+ def traverse(
137
+ root: str | Path,
138
+ *,
139
+ max_depth: int | None = None,
140
+ max_entries: int | None = None,
141
+ dirs_only: bool = False,
142
+ ignore_dirs: frozenset[str] | None = None,
143
+ ignore_patterns: tuple[str, ...] = (),
144
+ follow_symlinks: bool = False,
145
+ sort_by: SortBy = "name",
146
+ ) -> TraversalResult:
147
+ """Recursively traverse ``root`` and return its entries.
148
+
149
+ Symlinks are recorded but never followed unless ``follow_symlinks``
150
+ is set (cycles are still avoided). Inaccessible entries are
151
+ reported as errors and do not abort the scan. Entries are sorted
152
+ alphabetically by name within each directory.
153
+ """
154
+ if sort_by != "name":
155
+ raise FsToolError(
156
+ f"Unsupported sort_by value {sort_by!r}; only 'name' is supported",
157
+ code=ErrorCode.INVALID_ARGUMENT,
158
+ )
159
+
160
+ root_path = normalize_path(root)
161
+ ignored = set(DEFAULT_IGNORE_DIRS)
162
+ if ignore_dirs is not None:
163
+ ignored |= set(ignore_dirs)
164
+
165
+ result = TraversalResult()
166
+ visited_dirs: set[tuple[int, int]] = set()
167
+
168
+ def record_error(path: Path, message: str) -> None:
169
+ result.errors.append(TraversalError(path=str(path), message=message))
170
+ result.complete = False
171
+
172
+ def walk(directory: Path, depth: int) -> None:
173
+ """Walk ``directory``; ``depth`` is the level of its entries
174
+ relative to ``root`` (root's direct entries have depth 1)."""
175
+ if max_entries is not None and len(result.entries) >= max_entries:
176
+ result.complete = False
177
+ return
178
+ try:
179
+ pending = sorted(os.scandir(directory), key=lambda entry: entry.name)
180
+ except OSError as exc:
181
+ record_error(directory, f"Cannot read directory: {exc.strerror or exc}")
182
+ return
183
+ for entry in pending:
184
+ if max_entries is not None and len(result.entries) >= max_entries:
185
+ result.complete = False
186
+ return
187
+ name = entry.name
188
+ if name in ignored or any(fnmatch.fnmatch(name, p) for p in ignore_patterns):
189
+ result.skipped += 1
190
+ continue
191
+ path = Path(entry.path)
192
+ try:
193
+ is_link = entry.is_symlink()
194
+ is_dir = entry.is_dir(follow_symlinks=follow_symlinks)
195
+ except OSError as exc:
196
+ record_error(path, f"Cannot inspect entry: {exc.strerror or exc}")
197
+ continue
198
+ if is_link and not follow_symlinks:
199
+ if not dirs_only:
200
+ _append_entry(path)
201
+ continue
202
+ if is_link and follow_symlinks:
203
+ try:
204
+ real = entry.stat(follow_symlinks=True)
205
+ except OSError as exc:
206
+ record_error(path, f"Cannot follow symbolic link: {exc.strerror or exc}")
207
+ continue
208
+ key = (real.st_dev, real.st_ino)
209
+ if key in visited_dirs:
210
+ result.skipped += 1
211
+ continue
212
+ visited_dirs.add(key)
213
+ if not dirs_only or is_dir:
214
+ _append_entry(path)
215
+ if is_dir and (max_depth is None or depth < max_depth):
216
+ walk(path, depth + 1)
217
+
218
+ def _append_entry(path: Path) -> None:
219
+ try:
220
+ result.entries.append(_build_entry(path, root_path, follow_symlinks=follow_symlinks))
221
+ except FsToolError as exc:
222
+ record_error(path, exc.message)
223
+
224
+ walk(root_path, 1)
225
+ return result
@@ -0,0 +1 @@
1
+ """Output rendering: JSON envelope and plain-text presentation."""
fs_tool/output/json.py ADDED
@@ -0,0 +1,142 @@
1
+ """Common JSON response envelope.
2
+
3
+ Every command returns the envelope structure described in plan.md
4
+ section 4.3:
5
+
6
+ .. code-block:: json
7
+
8
+ {
9
+ "schema_version": 1,
10
+ "success": true,
11
+ "command": "inspect.info",
12
+ "result": {},
13
+ "errors": [],
14
+ "warnings": [],
15
+ "complete": true
16
+ }
17
+
18
+ ``--help`` and ``--version`` are documented exceptions to the JSON
19
+ envelope.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from dataclasses import asdict, dataclass, field
26
+ from typing import Any
27
+
28
+ SCHEMA_VERSION = 1
29
+
30
+ ENVELOPE_KEY_ORDER = (
31
+ "schema_version",
32
+ "success",
33
+ "command",
34
+ "result",
35
+ "errors",
36
+ "warnings",
37
+ "complete",
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Error:
43
+ """A structured error entry in the JSON envelope."""
44
+
45
+ code: str
46
+ message: str
47
+ path: str | None = None
48
+ details: dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Warning:
53
+ """A structured warning entry in the JSON envelope."""
54
+
55
+ code: str
56
+ message: str
57
+ path: str | None = None
58
+ details: dict[str, Any] = field(default_factory=dict)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Envelope:
63
+ """The common JSON response envelope."""
64
+
65
+ schema_version: int = SCHEMA_VERSION
66
+ success: bool = True
67
+ command: str = ""
68
+ result: Any = None
69
+ errors: tuple[Error, ...] = ()
70
+ warnings: tuple[Warning, ...] = ()
71
+ complete: bool = True
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ """Serialize to a plain dict with stable key order."""
75
+ ordered: dict[str, Any] = {}
76
+ ordered["schema_version"] = self.schema_version
77
+ ordered["success"] = self.success
78
+ ordered["command"] = self.command
79
+ ordered["result"] = self.result
80
+ ordered["errors"] = [asdict(error) for error in self.errors]
81
+ ordered["warnings"] = [asdict(warning) for warning in self.warnings]
82
+ ordered["complete"] = self.complete
83
+ return ordered
84
+
85
+ def to_json(self, **kwargs: Any) -> str:
86
+ """Serialize to deterministic JSON."""
87
+ return json.dumps(self.to_dict(), **kwargs)
88
+
89
+ @classmethod
90
+ def from_dict(cls, data: dict[str, Any]) -> Envelope:
91
+ return cls(
92
+ schema_version=int(data.get("schema_version", SCHEMA_VERSION)),
93
+ success=bool(data.get("success", True)),
94
+ command=str(data.get("command", "")),
95
+ result=data.get("result"),
96
+ errors=tuple(Error(**item) for item in data.get("errors", [])),
97
+ warnings=tuple(Warning(**item) for item in data.get("warnings", [])),
98
+ complete=bool(data.get("complete", True)),
99
+ )
100
+
101
+ @classmethod
102
+ def from_json(cls, text: str) -> Envelope:
103
+ return cls.from_dict(json.loads(text))
104
+
105
+
106
+ def success_result(
107
+ command: str,
108
+ result: Any = None,
109
+ *,
110
+ complete: bool = True,
111
+ warnings: tuple[Warning, ...] = (),
112
+ ) -> Envelope:
113
+ """Build a successful envelope."""
114
+ return Envelope(
115
+ schema_version=SCHEMA_VERSION,
116
+ success=True,
117
+ command=command,
118
+ result=result,
119
+ errors=(),
120
+ warnings=warnings,
121
+ complete=complete,
122
+ )
123
+
124
+
125
+ def failure_result(
126
+ command: str,
127
+ errors: tuple[Error, ...],
128
+ *,
129
+ complete: bool = False,
130
+ result: Any = None,
131
+ warnings: tuple[Warning, ...] = (),
132
+ ) -> Envelope:
133
+ """Build a failed envelope."""
134
+ return Envelope(
135
+ schema_version=SCHEMA_VERSION,
136
+ success=False,
137
+ command=command,
138
+ result=result,
139
+ errors=errors,
140
+ warnings=warnings,
141
+ complete=complete,
142
+ )
@@ -0,0 +1,58 @@
1
+ """Plain-text presentation (plan.md section 10.2).
2
+
3
+ Terminal output is optional and never influences filesystem operations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from fs_tool.core.models import EntryType, FilesystemEntry
11
+
12
+
13
+ def render_tree(entries: list[FilesystemEntry], root: Path) -> str:
14
+ """Render traversal entries as a plain-text tree rooted at ``root``.
15
+
16
+ Entries must be ordered as produced by the traversal (pre-order,
17
+ siblings sorted by name).
18
+ """
19
+
20
+ def parts_of(entry: FilesystemEntry) -> tuple[str, ...]:
21
+ assert entry.rel_path is not None
22
+ return Path(entry.rel_path).parts
23
+
24
+ def parent_of(entry: FilesystemEntry) -> tuple[str, ...]:
25
+ return parts_of(entry)[:-1]
26
+
27
+ def depth_of(entry: FilesystemEntry) -> int:
28
+ return len(parts_of(entry))
29
+
30
+ def is_last(index: int) -> bool:
31
+ entry = entries[index]
32
+ parent = parent_of(entry)
33
+ depth = depth_of(entry)
34
+ for later in entries[index + 1 :]:
35
+ later_parts = parts_of(later)
36
+ if len(later_parts) == depth and later_parts[:-1] == parent:
37
+ return False # a sibling follows
38
+ if len(later_parts) <= len(parent):
39
+ return True # moved past this entry's subtree
40
+ return True
41
+
42
+ root_name = root.name or str(root)
43
+ lines = [f"{root_name}/"]
44
+ stack: list[bool] = [] # is_last flag of each ancestor level
45
+
46
+ for index, entry in enumerate(entries):
47
+ depth = depth_of(entry)
48
+ while len(stack) > depth - 1:
49
+ stack.pop()
50
+ prefix = "".join(" " if was_last else "│ " for was_last in stack)
51
+ connector = "└── " if is_last(index) else "├── "
52
+ marker = "/" if entry.type == EntryType.DIRECTORY else ""
53
+ if entry.is_symlink and entry.target:
54
+ marker += f" -> {entry.target}"
55
+ lines.append(f"{prefix}{connector}{entry.name}{marker}")
56
+ stack.append(is_last(index))
57
+
58
+ return "\n".join(lines)
File without changes
fs_tool/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Filesystem safety: allowed-root checks and path traversal protection."""
File without changes
@@ -0,0 +1,81 @@
1
+ """Allowed-root validation (plan.md Phase 1, decision D6).
2
+
3
+ By default only the user's home directory and the current working
4
+ directory are accessible. Anything else requires explicit authorization
5
+ via ``--allow-root``. The current working directory of ``/`` is not
6
+ implicitly allowed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from fs_tool.core.errors import ErrorCode, FsToolError
14
+ from fs_tool.core.filesystem import normalize_path, resolve_path
15
+
16
+
17
+ class PathNotAllowedError(FsToolError):
18
+ """A requested path lies outside the allowed roots."""
19
+
20
+ def __init__(self, path: str | Path, roots: tuple[Path, ...]) -> None:
21
+ super().__init__(
22
+ f"Path not allowed: {path}",
23
+ code=ErrorCode.ROOT_NOT_ALLOWED,
24
+ path=str(path),
25
+ details={"roots": [str(root) for root in roots]},
26
+ )
27
+
28
+
29
+ def default_allowed_roots() -> tuple[Path, ...]:
30
+ """Default allowed roots: home plus CWD, unless CWD is ``/`` (D6)."""
31
+ roots = [Path.home()]
32
+ if Path.cwd() != Path("/"):
33
+ roots.append(Path.cwd())
34
+ return tuple(roots)
35
+
36
+
37
+ def is_within_root(path: Path, root: Path) -> bool:
38
+ """True if ``path`` is located at or below ``root`` (lexical check)."""
39
+ try:
40
+ path.relative_to(root)
41
+ except ValueError:
42
+ return False
43
+ return True
44
+
45
+
46
+ def validate_path(
47
+ path: str | Path,
48
+ allowed_roots: tuple[Path, ...] | None = None,
49
+ ) -> Path:
50
+ """Return the normalized path if it is within an allowed root.
51
+
52
+ Both the lexically normalized path and the symlink-resolved path
53
+ must lie within an allowed root, so symlinks cannot be used to
54
+ escape an allowed root. Raises :class:`PathNotAllowedError`
55
+ (exit code 3) otherwise.
56
+ """
57
+ roots = tuple(allowed_roots) if allowed_roots is not None else default_allowed_roots()
58
+ candidates = (normalize_path(path), resolve_path(path))
59
+ for candidate in candidates:
60
+ if not any(is_within_root(candidate, root) for root in roots):
61
+ raise PathNotAllowedError(path, roots)
62
+ return candidates[0]
63
+
64
+
65
+ def symlink_components(path: str | Path) -> list[Path]:
66
+ """Return the components of ``path`` that are symbolic links.
67
+
68
+ Uses ``lstat`` so symlinks are never followed. Components are
69
+ returned from the filesystem root toward ``path``.
70
+ """
71
+ resolved = normalize_path(path)
72
+ links: list[Path] = []
73
+ current: Path = resolved
74
+ while True:
75
+ if current.is_symlink():
76
+ links.append(current)
77
+ if current == current.parent:
78
+ break
79
+ current = current.parent
80
+ links.reverse()
81
+ return links
@@ -0,0 +1,15 @@
1
+ # Navigation tree powered by mkdocs-awesome-nav
2
+ #
3
+ # This MkDocs plugin gives you full control over your navigation structure
4
+ # without having to write the entire thing by hand. Whether you just want
5
+ # to add an external link or restructure the entire navigation tree,
6
+ # awesome-nav has you covered.
7
+ #
8
+ # https://github.com/lukasgeiter/mkdocs-awesome-nav
9
+
10
+ nav:
11
+ - index.md
12
+ - develop
13
+ - getting-started
14
+ - usage
15
+ - funding
@@ -0,0 +1,5 @@
1
+ /* docs/_static/custom.css */
2
+ .wy-nav-content {
3
+ max-width: 1000px;
4
+ margin: 0 auto;
5
+ }
@@ -0,0 +1,142 @@
1
+ # How to develop on this project
2
+
3
+ fs-tool welcomes contributions from the community.
4
+
5
+ These instructions are basically for linux base systems. (Linux, MacOS, BSD, etc.)
6
+
7
+
8
+ ## Setting up your own fork of this repo.
9
+
10
+ - On github interface click on `Fork` button.
11
+ - Clone your fork of this repo. `git clone git@github.com:YOUR_GIT_USERNAME/fs-tool.git`
12
+ - Enter the directory `cd fs-tool`
13
+ - Add upstream repo `git remote add upstream https://github.com/pamagister/fs-tool`
14
+
15
+ ## Setting up your own virtual environment
16
+
17
+ Run `make virtualenv` to create a virtual environment.
18
+ then activate it with `source .venv/bin/activate`.
19
+
20
+ ## Install the project in develop mode
21
+
22
+ Run `make install` to install the project in develop mode.
23
+
24
+ ## Run the tests to ensure everything is working
25
+
26
+ Run `make test` to run the tests.
27
+
28
+ ## Create a new branch to work on your contribution
29
+
30
+ Run `git checkout -b my_contribution`
31
+
32
+ ## Make your changes
33
+
34
+ Edit the files using your preferred editor. (we recommend PyCharm)
35
+
36
+ ## Format the code
37
+
38
+ Run `make fmt` to format the code.
39
+
40
+ ## Run the linter
41
+
42
+ Run `make lint` to run the linter.
43
+
44
+ ## Test your changes
45
+
46
+ Run `make test` to run the tests.
47
+
48
+ Review the coverage report and add tests for relevant uncovered behavior.
49
+
50
+ ## Build the docs locally
51
+
52
+ Run `make docs` to build the docs.
53
+
54
+ Ensure your new changes are documented.
55
+
56
+ ## Commit your changes
57
+
58
+ This project uses [conventional git commit messages](https://www.conventionalcommits.org/en/v1.0.0/).
59
+
60
+ Example: `fix(package): update setup.py arguments 🎉` (emojis are fine too)
61
+
62
+ ## Push your changes to your fork
63
+
64
+ Run `git push origin my_contribution`
65
+
66
+ ## Submit a pull request
67
+
68
+ On github interface, click on `Pull Request` button.
69
+
70
+ Wait CI to run and one of the developers will review your PR.
71
+ ## Makefile utilities
72
+
73
+ This project comes with a `Makefile` that contains a number of useful utility.
74
+
75
+ ```bash
76
+ ❯ make
77
+ Usage: make <target>
78
+ Targets:
79
+ help: ## Show the help.
80
+ install: ## Install the project and all dependency groups.
81
+ fmt: ## Format source code and tests.
82
+ lint: ## Run lint, formatting, and type checks.
83
+ test: ## Run tests and generate coverage report.
84
+ check: ## Run formatting, lint, and the test suite.
85
+ build: ## Build the Python sdist and wheel, including docs/ and res/.
86
+ release: ## Check, create, and push a version tag to trigger GitHub release workflows.
87
+ docs: ## Build and sync the documentation.
88
+ ```
89
+
90
+ ## Making a new release
91
+
92
+ This project uses [semantic versioning](https://semver.org/) and tags releases with `X.Y.Z`.
93
+ The package version is derived from the Git tag. Running `make release` locally runs the checks,
94
+ suggests the next patch version, creates the tag, and pushes it. GitHub Actions then builds the
95
+ Python distributions, creates the GitHub release, and publishes to PyPI.
96
+
97
+ PyPI publishing requires a repository secret named `PYPI_API_TOKEN`, generated on
98
+ [pypi.org](https://pypi.org/account/).
99
+
100
+ To trigger a new release all you need to do is.
101
+
102
+ 1. Commit all intended changes and ensure the worktree is clean.
103
+ 2. Verify your Git author identity with `git config user.name` and `git config user.email`.
104
+ 3. Run `make release` and confirm or edit the suggested version when prompted.
105
+
106
+ `make release` does not modify or commit project files. It refuses to release with uncommitted
107
+ changes or an invalid/already existing version tag.
108
+
109
+ ### Troubleshooting
110
+
111
+ #### 🧩 Git Push Authentication Error
112
+
113
+ If you encounter the following error during `make release` or when pushing manually:
114
+
115
+ ```bash
116
+ remote: Invalid username or token. Password authentication is not supported for Git operations.
117
+ fatal: Authentication failed for '[https://github.com/](https://github.com/)<user>/<repo>.git/'
118
+ ```
119
+
120
+ GitHub no longer supports password-based authentication over HTTPS.
121
+ To fix this, you need to use a **Personal Access Token (PAT)** instead of your password.
122
+
123
+ **Steps to resolve:**
124
+
125
+ 1. Go to [GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)](https://github.com/settings/tokens).
126
+ 2. Click **Generate new token (classic)** and select the following scopes:
127
+ - `repo`
128
+ - `workflow` (optional, for triggering GitHub Actions)
129
+ 3. Copy the generated token — you’ll only see it once.
130
+ 4. Update your Git remote to include your GitHub username:
131
+ ```bash
132
+ git remote set-url origin https://<YOUR_GITHUB_USERNAME>@github.com/<YOUR_GITHUB_USERNAME>/<YOUR_REPOSITORY>.git
133
+ ```
134
+
135
+ 5. The next time you push, Git will prompt for your password — **paste the token** instead.
136
+ 6. (Optional) To store credentials for future pushes:
137
+
138
+ ```bash
139
+ git config --global credential.helper store
140
+ ```
141
+
142
+ After this setup, `make release` and manual pushes will work without further authentication errors.