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
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
@@ -0,0 +1,8 @@
1
+ """Allow running the package as ``python -m fs_tool``."""
2
+
3
+ import sys
4
+
5
+ from fs_tool.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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
@@ -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)
@@ -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