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
fs_tool/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""fs-tool – lightweight, secure and agent-friendly filesystem CLI."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _distribution_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _distribution_version("fs-tool-cli")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "0.1.0"
|
fs_tool/__main__.py
ADDED
fs_tool/cli.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Command-line interface for fs-tool.
|
|
2
|
+
|
|
3
|
+
Console script entry point: ``fs_tool.cli:main`` (``fs-tool``).
|
|
4
|
+
The package is also runnable as ``python -m fs_tool``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from click.exceptions import ClickException
|
|
15
|
+
from click.exceptions import Exit as ClickExit
|
|
16
|
+
|
|
17
|
+
from fs_tool import __version__
|
|
18
|
+
from fs_tool.core.config import load_config
|
|
19
|
+
from fs_tool.core.errors import ErrorCode, FsToolError
|
|
20
|
+
from fs_tool.core.traversal import traverse
|
|
21
|
+
from fs_tool.output.json import SCHEMA_VERSION, Envelope, Error
|
|
22
|
+
from fs_tool.safety.validation import validate_path
|
|
23
|
+
|
|
24
|
+
# Process exit codes (plan.md section 4.3).
|
|
25
|
+
EXIT_SUCCESS = 0
|
|
26
|
+
EXIT_FAILURE = 1
|
|
27
|
+
EXIT_INVALID_ARGS = 2
|
|
28
|
+
EXIT_ACCESS_DENIED = 3
|
|
29
|
+
EXIT_CONFLICT = 4
|
|
30
|
+
EXIT_INCOMPLETE = 5
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="fs-tool",
|
|
34
|
+
help="Inspect, analyze, compare and organize local filesystems.",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CliContext:
|
|
41
|
+
"""Shared CLI context populated by the root callback."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
allow_root: list[str],
|
|
46
|
+
config: str | None,
|
|
47
|
+
output_format: str,
|
|
48
|
+
json_output: bool,
|
|
49
|
+
plain_output: bool,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.allow_root = allow_root
|
|
52
|
+
self.config = config
|
|
53
|
+
self.output_format = output_format
|
|
54
|
+
self.json_output = json_output
|
|
55
|
+
self.plain_output = plain_output
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_format(
|
|
59
|
+
format_value: str,
|
|
60
|
+
json_output: bool,
|
|
61
|
+
plain_output: bool,
|
|
62
|
+
is_tty: bool,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Resolve the effective output format (plan.md decision D7).
|
|
65
|
+
|
|
66
|
+
Precedence: explicit --json/--plain > --format > auto (TTY -> plain,
|
|
67
|
+
otherwise JSON). ``--json`` and ``--plain`` are mutually exclusive.
|
|
68
|
+
"""
|
|
69
|
+
if json_output and plain_output:
|
|
70
|
+
raise FsToolError(
|
|
71
|
+
"--json and --plain are mutually exclusive.",
|
|
72
|
+
code=ErrorCode.INVALID_ARGUMENT,
|
|
73
|
+
)
|
|
74
|
+
if json_output:
|
|
75
|
+
return "json"
|
|
76
|
+
if plain_output:
|
|
77
|
+
return "plain"
|
|
78
|
+
if format_value not in ("auto", "json", "plain"):
|
|
79
|
+
raise FsToolError(
|
|
80
|
+
f"Unknown --format value {format_value!r}; use auto, json or plain.",
|
|
81
|
+
code=ErrorCode.INVALID_ARGUMENT,
|
|
82
|
+
)
|
|
83
|
+
if format_value == "auto":
|
|
84
|
+
return "plain" if is_tty else "json"
|
|
85
|
+
return format_value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def merge_globals(
|
|
89
|
+
ctx: typer.Context,
|
|
90
|
+
*,
|
|
91
|
+
allow_root: list[str] | None = None,
|
|
92
|
+
config: str | None = None,
|
|
93
|
+
format_value: str | None = None,
|
|
94
|
+
json_output: bool | None = None,
|
|
95
|
+
plain_output: bool | None = None,
|
|
96
|
+
version: bool = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Merge subcommand-level global options into the CLI context.
|
|
99
|
+
|
|
100
|
+
The root callback has already populated the context from root-level
|
|
101
|
+
options; subcommand-level options override them. The configuration
|
|
102
|
+
may only restrict allowed roots, never expand them (decision D4).
|
|
103
|
+
"""
|
|
104
|
+
if version:
|
|
105
|
+
typer.echo(f"fs-tool {__version__}")
|
|
106
|
+
raise typer.Exit(EXIT_SUCCESS)
|
|
107
|
+
|
|
108
|
+
if config is not None:
|
|
109
|
+
ctx.obj.config = config
|
|
110
|
+
if allow_root is not None:
|
|
111
|
+
# Re-resolve the config with the subcommand's CLI roots.
|
|
112
|
+
config_obj = load_config(
|
|
113
|
+
config_path=ctx.obj.config, cli_roots=tuple(Path(root) for root in allow_root)
|
|
114
|
+
)
|
|
115
|
+
ctx.obj.allow_root = [str(root) for root in config_obj.allowed_roots]
|
|
116
|
+
if format_value is not None:
|
|
117
|
+
ctx.obj.output_format = format_value
|
|
118
|
+
if json_output is not None:
|
|
119
|
+
ctx.obj.json_output = json_output
|
|
120
|
+
if plain_output is not None:
|
|
121
|
+
ctx.obj.plain_output = plain_output
|
|
122
|
+
|
|
123
|
+
if ctx.obj.json_output and ctx.obj.plain_output:
|
|
124
|
+
raise FsToolError(
|
|
125
|
+
"--json and --plain are mutually exclusive.",
|
|
126
|
+
code=ErrorCode.INVALID_ARGUMENT,
|
|
127
|
+
)
|
|
128
|
+
ctx.obj.output_format = _resolve_format(
|
|
129
|
+
ctx.obj.output_format,
|
|
130
|
+
ctx.obj.json_output,
|
|
131
|
+
ctx.obj.plain_output,
|
|
132
|
+
sys.stdout.isatty(),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.callback()
|
|
137
|
+
def _root(
|
|
138
|
+
ctx: typer.Context,
|
|
139
|
+
allow_root: list[str] = typer.Option(
|
|
140
|
+
[],
|
|
141
|
+
"--allow-root",
|
|
142
|
+
help="Set an allowed root, replacing the default Home and CWD roots (repeatable).",
|
|
143
|
+
),
|
|
144
|
+
config: str | None = typer.Option(None, "--config", help="Path to TOML configuration file."),
|
|
145
|
+
format_value: str = typer.Option(
|
|
146
|
+
"auto", "--format", help="Output format: auto, json or plain."
|
|
147
|
+
),
|
|
148
|
+
json_output: bool = typer.Option(False, "--json", help="Explicitly request JSON output."),
|
|
149
|
+
plain_output: bool = typer.Option(
|
|
150
|
+
False, "--plain", help="Human-readable output instead of JSON."
|
|
151
|
+
),
|
|
152
|
+
version: bool = typer.Option(False, "--version", help="Show application version and exit."),
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Root callback: global options and version information.
|
|
155
|
+
|
|
156
|
+
``--help``/``--version`` are documented exceptions to the JSON
|
|
157
|
+
envelope (plan.md section 4.3).
|
|
158
|
+
"""
|
|
159
|
+
ctx.obj = CliContext(
|
|
160
|
+
allow_root=[],
|
|
161
|
+
config=None,
|
|
162
|
+
output_format="auto",
|
|
163
|
+
json_output=False,
|
|
164
|
+
plain_output=False,
|
|
165
|
+
)
|
|
166
|
+
merge_globals(
|
|
167
|
+
ctx,
|
|
168
|
+
allow_root=list(allow_root),
|
|
169
|
+
config=config,
|
|
170
|
+
format_value=format_value,
|
|
171
|
+
json_output=json_output,
|
|
172
|
+
plain_output=plain_output,
|
|
173
|
+
version=version,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# Command groups are registered without commands in Phase 0; commands
|
|
178
|
+
# are implemented in later phases.
|
|
179
|
+
inspect_group = typer.Typer(name="inspect", help="Inspect the filesystem.", no_args_is_help=True)
|
|
180
|
+
find_group = typer.Typer(name="find", help="Find filesystem entries.", no_args_is_help=True)
|
|
181
|
+
duplicates_group = typer.Typer(
|
|
182
|
+
name="duplicates", help="Find duplicate files.", no_args_is_help=True
|
|
183
|
+
)
|
|
184
|
+
compare_group = typer.Typer(name="compare", help="Compare directory trees.", no_args_is_help=True)
|
|
185
|
+
verify_group = typer.Typer(name="verify", help="Verify filesystem state.", no_args_is_help=True)
|
|
186
|
+
write_group = typer.Typer(name="write", help="Write operations.", no_args_is_help=True)
|
|
187
|
+
|
|
188
|
+
for group in (
|
|
189
|
+
inspect_group,
|
|
190
|
+
find_group,
|
|
191
|
+
duplicates_group,
|
|
192
|
+
compare_group,
|
|
193
|
+
verify_group,
|
|
194
|
+
write_group,
|
|
195
|
+
):
|
|
196
|
+
app.add_typer(group)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@inspect_group.command(name="tree")
|
|
200
|
+
def inspect_tree(
|
|
201
|
+
ctx: typer.Context,
|
|
202
|
+
source: Path = typer.Argument(..., help="Directory to traverse."),
|
|
203
|
+
max_depth: int | None = typer.Option(None, "--max-depth", help="Maximum recursion depth."),
|
|
204
|
+
max_entries: int | None = typer.Option(
|
|
205
|
+
None, "--max-entries", help="Maximum number of entries."
|
|
206
|
+
),
|
|
207
|
+
dirs_only: bool = typer.Option(False, "--dirs-only", help="Directory entries only."),
|
|
208
|
+
ignore_dir: list[str] = typer.Option(
|
|
209
|
+
[],
|
|
210
|
+
"--ignore-dir",
|
|
211
|
+
help="Ignore a directory by name (repeatable).",
|
|
212
|
+
),
|
|
213
|
+
ignore_pattern: list[str] = typer.Option(
|
|
214
|
+
[],
|
|
215
|
+
"--ignore-pattern",
|
|
216
|
+
help="Ignore names matching a pattern (repeatable).",
|
|
217
|
+
),
|
|
218
|
+
) -> int:
|
|
219
|
+
"""Display a directory tree (JSON by default, plain with --plain).
|
|
220
|
+
|
|
221
|
+
Global options such as ``--plain`` and ``--allow-root`` precede
|
|
222
|
+
the command, e.g. ``fs-tool --plain inspect tree /tmp``.
|
|
223
|
+
"""
|
|
224
|
+
root = validate_path(source, tuple(ctx.obj.allow_root))
|
|
225
|
+
result = traverse(
|
|
226
|
+
root,
|
|
227
|
+
max_depth=max_depth,
|
|
228
|
+
max_entries=max_entries,
|
|
229
|
+
dirs_only=dirs_only,
|
|
230
|
+
ignore_dirs=frozenset(ignore_dir),
|
|
231
|
+
ignore_patterns=tuple(ignore_pattern),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if ctx.obj.output_format == "plain":
|
|
235
|
+
from fs_tool.output.plain import render_tree
|
|
236
|
+
|
|
237
|
+
typer.echo(render_tree(result.entries, root))
|
|
238
|
+
if not result.complete:
|
|
239
|
+
print("fs-tool: error: scan incomplete", file=sys.stderr)
|
|
240
|
+
for error in result.errors:
|
|
241
|
+
print(
|
|
242
|
+
f"fs-tool: error: {error.message} ({error.path})",
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
)
|
|
245
|
+
return EXIT_INCOMPLETE if not result.complete else EXIT_SUCCESS
|
|
246
|
+
|
|
247
|
+
entry_dicts = [asdict(entry) for entry in result.entries]
|
|
248
|
+
errors = tuple(
|
|
249
|
+
Error(code="INACCESSIBLE_ENTRY", message=error.message, path=error.path)
|
|
250
|
+
for error in result.errors
|
|
251
|
+
)
|
|
252
|
+
envelope = Envelope(
|
|
253
|
+
schema_version=SCHEMA_VERSION,
|
|
254
|
+
success=result.complete,
|
|
255
|
+
command="inspect.tree",
|
|
256
|
+
result={
|
|
257
|
+
"root": str(root),
|
|
258
|
+
"entries": entry_dicts,
|
|
259
|
+
"count": len(entry_dicts),
|
|
260
|
+
},
|
|
261
|
+
errors=errors,
|
|
262
|
+
complete=result.complete,
|
|
263
|
+
)
|
|
264
|
+
typer.echo(envelope.to_json())
|
|
265
|
+
return EXIT_INCOMPLETE if not result.complete else EXIT_SUCCESS
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main(argv: list[str] | None = None) -> int:
|
|
269
|
+
"""Application entry point.
|
|
270
|
+
|
|
271
|
+
Returns a process exit code (plan.md section 4.3):
|
|
272
|
+
|
|
273
|
+
* 0 - success
|
|
274
|
+
* 1 - operation failed or verification discrepancies
|
|
275
|
+
* 2 - invalid arguments or incomplete analysis
|
|
276
|
+
* 3 - access denied or safety restriction
|
|
277
|
+
* 4 - operation conflict
|
|
278
|
+
* 5 - incomplete scan or verification
|
|
279
|
+
"""
|
|
280
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
281
|
+
|
|
282
|
+
# ``--version`` is handled up front: typer only invokes the root
|
|
283
|
+
# callback when a subcommand is selected
|
|
284
|
+
# (invoke_without_command=False), so the option callback would never
|
|
285
|
+
# run for ``fs-tool --version``. --help/--version are documented
|
|
286
|
+
# exceptions to the JSON envelope (plan.md section 4.3).
|
|
287
|
+
if "--version" in args:
|
|
288
|
+
print(f"fs-tool {__version__}")
|
|
289
|
+
return EXIT_SUCCESS
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
result = app(args=args, standalone_mode=False)
|
|
293
|
+
except FsToolError as exc:
|
|
294
|
+
print(f"Error: {exc.message}", file=sys.stderr)
|
|
295
|
+
return exc.exit_code
|
|
296
|
+
except ClickExit as exc:
|
|
297
|
+
return exc.exit_code if isinstance(exc.exit_code, int) else EXIT_SUCCESS
|
|
298
|
+
except ClickException as exc:
|
|
299
|
+
# Invalid usage (unknown command, bad option, ...): exit 2.
|
|
300
|
+
exc.show()
|
|
301
|
+
return exc.exit_code if isinstance(exc.exit_code, int) else EXIT_FAILURE
|
|
302
|
+
except Exception as exc: # CLI boundary: report, do not swallow silently
|
|
303
|
+
print(f"Unexpected error: {exc}", file=sys.stderr)
|
|
304
|
+
return EXIT_FAILURE
|
|
305
|
+
return result if isinstance(result, int) else EXIT_SUCCESS
|
fs_tool/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Filesystem core: models, safety, traversal, hashing, metadata."""
|
fs_tool/core/config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""TOML configuration loading (plan.md Phase 1, P1-T14, decision D4).
|
|
2
|
+
|
|
3
|
+
Configuration file: ``$XDG_CONFIG_HOME/fs-tool/config.toml``, overridden
|
|
4
|
+
by ``--config PATH``. There is no automatic loading of project-local
|
|
5
|
+
configuration files. Precedence is CLI > Config > Defaults; the
|
|
6
|
+
configuration may only restrict the allowed roots, never expand them.
|
|
7
|
+
|
|
8
|
+
Example config file:
|
|
9
|
+
|
|
10
|
+
.. code-block:: toml
|
|
11
|
+
|
|
12
|
+
[fs-tool]
|
|
13
|
+
allowed_roots = ["/home/user", "/mnt/nas"]
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import tomllib
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from fs_tool.core.errors import ErrorCode, FsToolError
|
|
25
|
+
|
|
26
|
+
CONFIG_FILE_NAME = "config.toml"
|
|
27
|
+
CONFIG_DIR_NAME = "fs-tool"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigError(FsToolError):
|
|
31
|
+
"""A configuration file could not be read or interpreted."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, path: str | Path | None = None) -> None:
|
|
34
|
+
super().__init__(
|
|
35
|
+
message,
|
|
36
|
+
code=ErrorCode.INVALID_ARGUMENT,
|
|
37
|
+
path=str(path) if path is not None else None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AppConfig:
|
|
43
|
+
"""Resolved application configuration.
|
|
44
|
+
|
|
45
|
+
``allowed_roots`` is the effective set of roots after applying CLI
|
|
46
|
+
> Config > Defaults precedence. The configuration may only restrict
|
|
47
|
+
roots; it never expands them.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
allowed_roots: tuple[Path, ...]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_config_path() -> Path:
|
|
54
|
+
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
55
|
+
base = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
|
|
56
|
+
return base / CONFIG_DIR_NAME / CONFIG_FILE_NAME
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _config_allowed_roots(data: dict[str, Any]) -> tuple[Path, ...] | None:
|
|
60
|
+
fs_tool = data.get("fs-tool")
|
|
61
|
+
if not isinstance(fs_tool, dict):
|
|
62
|
+
return None
|
|
63
|
+
roots = fs_tool.get("allowed_roots")
|
|
64
|
+
if roots is None:
|
|
65
|
+
return None
|
|
66
|
+
if not isinstance(roots, list) or not all(isinstance(r, str) for r in roots):
|
|
67
|
+
raise ConfigError("fs-tool.allowed_roots must be a list of strings")
|
|
68
|
+
return tuple(Path(root) for root in roots)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_config(
|
|
72
|
+
config_path: str | Path | None = None,
|
|
73
|
+
cli_roots: tuple[Path, ...] | None = None,
|
|
74
|
+
) -> AppConfig:
|
|
75
|
+
"""Load the configuration and resolve the effective allowed roots.
|
|
76
|
+
|
|
77
|
+
Precedence:
|
|
78
|
+
|
|
79
|
+
1. ``cli_roots`` (from ``--allow-root``) if given, otherwise the
|
|
80
|
+
default roots (home plus CWD).
|
|
81
|
+
2. ``allowed_roots`` from the config file, if present, intersected
|
|
82
|
+
with the CLI roots so the configuration can only restrict roots.
|
|
83
|
+
3. Defaults otherwise.
|
|
84
|
+
"""
|
|
85
|
+
from fs_tool.core.filesystem import normalize_path
|
|
86
|
+
from fs_tool.safety.validation import default_allowed_roots
|
|
87
|
+
|
|
88
|
+
path = Path(config_path) if config_path is not None else _default_config_path()
|
|
89
|
+
|
|
90
|
+
data: dict[str, Any] = {}
|
|
91
|
+
if path.exists():
|
|
92
|
+
try:
|
|
93
|
+
with path.open("rb") as handle:
|
|
94
|
+
data = tomllib.load(handle)
|
|
95
|
+
except tomllib.TOMLDecodeError as exc:
|
|
96
|
+
raise ConfigError(f"Invalid TOML in {path}: {exc}") from exc
|
|
97
|
+
except OSError as exc:
|
|
98
|
+
raise ConfigError(f"Cannot read config file {path}: {exc.strerror or exc}") from exc
|
|
99
|
+
|
|
100
|
+
config_roots = _config_allowed_roots(data)
|
|
101
|
+
|
|
102
|
+
base: tuple[Path, ...]
|
|
103
|
+
if cli_roots:
|
|
104
|
+
base = cli_roots
|
|
105
|
+
else:
|
|
106
|
+
base = default_allowed_roots()
|
|
107
|
+
# Deduplicate (e.g. CWD == home) and normalize spellings.
|
|
108
|
+
base = tuple(dict.fromkeys(normalize_path(root) for root in base))
|
|
109
|
+
|
|
110
|
+
if config_roots is not None:
|
|
111
|
+
config_roots = tuple(dict.fromkeys(normalize_path(root) for root in config_roots))
|
|
112
|
+
# Intersection keeps CLI/default authorization: the configuration
|
|
113
|
+
# may only restrict roots, never expand them.
|
|
114
|
+
effective = tuple(root for root in base if root in config_roots)
|
|
115
|
+
return AppConfig(allowed_roots=effective)
|
|
116
|
+
|
|
117
|
+
return AppConfig(allowed_roots=base)
|
fs_tool/core/errors.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Centralized error handling for fs-tool.
|
|
2
|
+
|
|
3
|
+
Stable error codes mapped to process exit codes (plan.md section 4.3).
|
|
4
|
+
All application errors inherit from :class:`FsToolError` and carry an
|
|
5
|
+
error code, a human-readable message, and optional context (path,
|
|
6
|
+
details). Unexpected bugs are not swallowed silently: they propagate
|
|
7
|
+
to the CLI boundary, which reports them as a generic failure.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ErrorCode(str, Enum):
|
|
17
|
+
"""Stable machine-readable error codes.
|
|
18
|
+
|
|
19
|
+
The numeric process exit code is derived from the error code via
|
|
20
|
+
:data:`EXIT_CODES`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# 1 - Operation failed or verification found discrepancies.
|
|
24
|
+
OPERATION_FAILED = "OPERATION_FAILED"
|
|
25
|
+
VERIFICATION_FAILED = "VERIFICATION_FAILED"
|
|
26
|
+
|
|
27
|
+
# 2 - Invalid arguments or incomplete analysis.
|
|
28
|
+
INVALID_ARGUMENT = "INVALID_ARGUMENT"
|
|
29
|
+
INCOMPLETE_ANALYSIS = "INCOMPLETE_ANALYSIS"
|
|
30
|
+
|
|
31
|
+
# 3 - Access denied or safety restriction.
|
|
32
|
+
ACCESS_DENIED = "ACCESS_DENIED"
|
|
33
|
+
ROOT_NOT_ALLOWED = "ROOT_NOT_ALLOWED"
|
|
34
|
+
|
|
35
|
+
# 4 - Operation conflict.
|
|
36
|
+
TARGET_EXISTS = "TARGET_EXISTS"
|
|
37
|
+
TYPE_CONFLICT = "TYPE_CONFLICT"
|
|
38
|
+
|
|
39
|
+
# 5 - Incomplete scan or verification (not fully verifiable).
|
|
40
|
+
INCOMPLETE_SCAN = "INCOMPLETE_SCAN"
|
|
41
|
+
INCOMPLETE_VERIFICATION = "INCOMPLETE_VERIFICATION"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
EXIT_CODES: dict[ErrorCode, int] = {
|
|
45
|
+
ErrorCode.OPERATION_FAILED: 1,
|
|
46
|
+
ErrorCode.VERIFICATION_FAILED: 1,
|
|
47
|
+
ErrorCode.INVALID_ARGUMENT: 2,
|
|
48
|
+
ErrorCode.INCOMPLETE_ANALYSIS: 2,
|
|
49
|
+
ErrorCode.ACCESS_DENIED: 3,
|
|
50
|
+
ErrorCode.ROOT_NOT_ALLOWED: 3,
|
|
51
|
+
ErrorCode.TARGET_EXISTS: 4,
|
|
52
|
+
ErrorCode.TYPE_CONFLICT: 4,
|
|
53
|
+
ErrorCode.INCOMPLETE_SCAN: 5,
|
|
54
|
+
ErrorCode.INCOMPLETE_VERIFICATION: 5,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FsToolError(Exception):
|
|
59
|
+
"""Base class for all fs-tool errors.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
code: Stable error code.
|
|
63
|
+
message: Human-readable, actionable error message.
|
|
64
|
+
path: Filesystem path related to the error, if any.
|
|
65
|
+
details: Additional structured context.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
message: str,
|
|
71
|
+
*,
|
|
72
|
+
code: ErrorCode = ErrorCode.OPERATION_FAILED,
|
|
73
|
+
path: str | None = None,
|
|
74
|
+
details: dict[str, Any] | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.code = code
|
|
78
|
+
self.message = message
|
|
79
|
+
self.path = path
|
|
80
|
+
self.details = details or {}
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def exit_code(self) -> int:
|
|
84
|
+
"""Process exit code for this error code."""
|
|
85
|
+
return EXIT_CODES[self.code]
|
|
86
|
+
|
|
87
|
+
def __str__(self) -> str:
|
|
88
|
+
return self.message
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Filesystem path normalization (plan.md Phase 1).
|
|
2
|
+
|
|
3
|
+
Pure lexical normalization: expand the user's home directory, make the
|
|
4
|
+
path absolute and collapse ``.`` and ``..`` components. No filesystem
|
|
5
|
+
access is performed and symlinks are not resolved here (see
|
|
6
|
+
:func:`resolve_path` for symlink-aware resolution).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_path(path: str | Path) -> Path:
|
|
16
|
+
"""Lexically normalize ``path``.
|
|
17
|
+
|
|
18
|
+
Expands ``~``, makes the path absolute against the current working
|
|
19
|
+
directory and collapses redundant separators, ``.`` and up-level
|
|
20
|
+
references (``..``) without touching the filesystem. Symlinks are
|
|
21
|
+
not resolved; use :func:`resolve_path` for that.
|
|
22
|
+
"""
|
|
23
|
+
expanded = os.path.expanduser(os.fspath(path))
|
|
24
|
+
absolute = os.path.abspath(expanded)
|
|
25
|
+
parsed = Path(absolute)
|
|
26
|
+
collapsed: list[str] = []
|
|
27
|
+
for part in parsed.parts:
|
|
28
|
+
if part == "." or part == "":
|
|
29
|
+
continue
|
|
30
|
+
if part == parsed.anchor:
|
|
31
|
+
if collapsed and collapsed[-1] == parsed.anchor:
|
|
32
|
+
continue
|
|
33
|
+
collapsed.append(part)
|
|
34
|
+
continue
|
|
35
|
+
if part == "..":
|
|
36
|
+
if len(collapsed) > 1 and collapsed[-1] != parsed.anchor:
|
|
37
|
+
collapsed.pop()
|
|
38
|
+
# At the filesystem root, ".." goes nowhere.
|
|
39
|
+
continue
|
|
40
|
+
collapsed.append(part)
|
|
41
|
+
return Path(*collapsed)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_path(path: str | Path) -> Path:
|
|
45
|
+
"""Resolve symlinks and up-level references as far as possible.
|
|
46
|
+
|
|
47
|
+
Unlike :func:`normalize_path`, this accesses the filesystem. Missing
|
|
48
|
+
path components are kept as-is (``strict=False`` behavior).
|
|
49
|
+
"""
|
|
50
|
+
return Path(os.path.expanduser(os.fspath(path))).resolve(strict=False)
|
fs_tool/core/hashing.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""SHA-256 hashing of files (plan.md Phase 1, P1-T09).
|
|
2
|
+
|
|
3
|
+
Hashing is read-only: symlinks are never followed and only regular
|
|
4
|
+
files are hashed. Special files (FIFOs, sockets, devices) are
|
|
5
|
+
explicitly excluded and never opened.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
CHUNK_SIZE = 1 << 20 # 1 MiB
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sha256_of_file(path: str | Path) -> str | None:
|
|
19
|
+
"""Return the SHA-256 hex digest of ``path`` or ``None``.
|
|
20
|
+
|
|
21
|
+
``None`` is returned when the path does not exist, is not a regular
|
|
22
|
+
file (symlinks, directories, FIFOs, sockets, devices), or its
|
|
23
|
+
contents cannot be read. Errors are not raised: callers report them
|
|
24
|
+
as traversal or inventory errors instead.
|
|
25
|
+
"""
|
|
26
|
+
p = Path(path)
|
|
27
|
+
try:
|
|
28
|
+
st = p.lstat()
|
|
29
|
+
except OSError:
|
|
30
|
+
return None
|
|
31
|
+
if not stat.S_ISREG(st.st_mode):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
flags = os.O_RDONLY
|
|
35
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
36
|
+
flags |= os.O_NOFOLLOW # never follow a symlink at open time
|
|
37
|
+
try:
|
|
38
|
+
fd = os.open(p, flags)
|
|
39
|
+
except OSError:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
digest = hashlib.sha256()
|
|
43
|
+
try:
|
|
44
|
+
with os.fdopen(fd, "rb") as f:
|
|
45
|
+
for chunk in iter(lambda: f.read(CHUNK_SIZE), b""):
|
|
46
|
+
digest.update(chunk)
|
|
47
|
+
except OSError:
|
|
48
|
+
return None
|
|
49
|
+
return digest.hexdigest()
|
fs_tool/core/models.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Internal filesystem data model.
|
|
2
|
+
|
|
3
|
+
A consistent internal representation for filesystem entries
|
|
4
|
+
(plan.md section 8). The model is independent of JSON serialization
|
|
5
|
+
and terminal rendering. Fields that are unavailable or not applicable
|
|
6
|
+
are ``None``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EntryType(str, Enum):
|
|
17
|
+
FILE = "file"
|
|
18
|
+
DIRECTORY = "directory"
|
|
19
|
+
SYMLINK = "symlink"
|
|
20
|
+
OTHER = "other"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FilesystemEntry:
|
|
25
|
+
"""A single filesystem entry as observed by fs-tool."""
|
|
26
|
+
|
|
27
|
+
path: str
|
|
28
|
+
name: str
|
|
29
|
+
rel_path: str | None = None
|
|
30
|
+
type: EntryType = EntryType.FILE
|
|
31
|
+
extension: str | None = None
|
|
32
|
+
size: int | None = None
|
|
33
|
+
modified: str | None = None
|
|
34
|
+
created: str | None = None
|
|
35
|
+
accessed: str | None = None
|
|
36
|
+
changed: str | None = None
|
|
37
|
+
mode: str | None = None
|
|
38
|
+
owner: str | None = None
|
|
39
|
+
mime_type: str | None = None
|
|
40
|
+
is_symlink: bool = False
|
|
41
|
+
target: str | None = None
|
|
42
|
+
sha256: str | None = None
|
|
43
|
+
dev_ino: str | None = None
|
|
44
|
+
metadata: dict[str, Any] | None = None
|
fs_tool/core/py.typed
ADDED
|
File without changes
|