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.
- fs_tool/__init__.py +9 -0
- fs_tool/__main__.py +8 -0
- fs_tool/cli.py +305 -0
- fs_tool/core/__init__.py +1 -0
- fs_tool/core/config.py +117 -0
- fs_tool/core/errors.py +88 -0
- fs_tool/core/filesystem.py +50 -0
- fs_tool/core/hashing.py +49 -0
- fs_tool/core/models.py +44 -0
- fs_tool/core/py.typed +0 -0
- fs_tool/core/traversal.py +225 -0
- fs_tool/output/__init__.py +1 -0
- fs_tool/output/json.py +142 -0
- fs_tool/output/plain.py +58 -0
- fs_tool/output/py.typed +0 -0
- fs_tool/py.typed +0 -0
- fs_tool/safety/__init__.py +1 -0
- fs_tool/safety/py.typed +0 -0
- fs_tool/safety/validation.py +81 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/.nav.yml +15 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/_static/img/favicon.png +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/_static/img/logo.png +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/css/custom.css +5 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/develop/contributing.md +142 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/develop/naming_convention.md +46 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/funding/funding.md +55 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/getting-started/install.md +45 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/getting-started/virtual-environment.md +29 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/index.md +77 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/docs/usage/cli.md +13 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Auslandssemester.docx +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Barcelona Sehenswertes.docx +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Beruf/Bewerbungen.txt +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Der Goldschatz partygame.pdf +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Ablauf Freitag Grundschule.docx +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Dresdner Christstollen.pdf +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Kirschkuchen.pdf +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Pelmeni.odt +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Projekte/Sauna_Grundriss.odg +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Seemeilenbestaetigung.pdf +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Freizeit/Tidal Prediction Form.pdf +0 -0
- 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
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Reise/Segeln/Segeln Ibiza 2024.gpx +41 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Reise/Slowenien/Klettersteige Slowenien.gpx +6602 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Reise-Checkliste.odt +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Sauna_Aufriss.odg +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Schule/Antrag Sch/303/274lerausweis.docx +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Studium/Oberseminare 2009 Helduser.pdf +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Tokensystem Z/303/244hneputzen.odt +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Der Weg zum SHS.ods +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Italien/Klettersteige Italien 2024.kmz +0 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Radtouren/Gittersee-steinbach.gpx +1250 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Radtouren/SingletrailDresdenerHeide.gpx +3087 -0
- fs_tool_cli-0.1.2.data/data/share/fs-tool/res/example_filesystem_unstructured/Urlaub/Radtouren/StauseeInDerDresdnerHeide.gpx +1466 -0
- fs_tool_cli-0.1.2.dist-info/METADATA +91 -0
- fs_tool_cli-0.1.2.dist-info/RECORD +60 -0
- fs_tool_cli-0.1.2.dist-info/WHEEL +5 -0
- fs_tool_cli-0.1.2.dist-info/entry_points.txt +2 -0
- fs_tool_cli-0.1.2.dist-info/licenses/LICENSE +24 -0
- 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
|
+
)
|
fs_tool/output/plain.py
ADDED
|
@@ -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)
|
fs_tool/output/py.typed
ADDED
|
File without changes
|
fs_tool/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Filesystem safety: allowed-root checks and path traversal protection."""
|
fs_tool/safety/py.typed
ADDED
|
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
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|