runspec-fs-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -0,0 +1,35 @@
1
+ # runspec-fs-core Changelog
2
+
3
+ ## [0.1.0] — 2026-07-25
4
+
5
+ Initial release.
6
+
7
+ The pure-Python logic core behind `runspec-fs` — a stdlib port of the
8
+ [Filesystem MCP server](https://github.com/modelcontextprotocol/servers/tree/HEAD/src/filesystem)
9
+ tool set. Provides the filesystem helpers as plain importable functions — **no
10
+ dependency on `runspec`, no `runspec.toml`, no console-script entry points** — so
11
+ a package can `from runspec_fs_core import read_text_file` (and the rest) without
12
+ surfacing any runnables in the venv or in `runspec local` / `runspec serve`
13
+ discovery.
14
+
15
+ Each function returns plain data and *raises* on failure — the stdlib `OSError`
16
+ family (`FileNotFoundError`, `NotADirectoryError`, `PermissionError`,
17
+ `FileExistsError`) for filesystem problems, plus `EditNotAppliedError` when an
18
+ `edit_file` old-text block is not found; the runnable wrappers in `runspec-fs`
19
+ catch these and render the JSON/exit behaviour.
20
+
21
+ No directory jail is enforced: filesystem access is bounded by the OS permissions
22
+ of the user the process runs as. `allowed_directories()` reports the effective
23
+ user + home/cwd and is informational only.
24
+
25
+ Exports:
26
+
27
+ - **Read** — `read_text_file` (head/tail), `read_media_file` (base64 + MIME),
28
+ `read_multiple_files` (per-file errors)
29
+ - **Write / edit** — `write_file`, `edit_file` (ordered substring replacements,
30
+ unified diff, `dry_run`)
31
+ - **Directories** — `create_directory`, `list_directory`,
32
+ `list_directory_with_sizes` (name/size sort), `directory_tree`, `move_file`
33
+ - **Search / info** — `search_files` (glob or substring, exclude globs),
34
+ `get_file_info`, `allowed_directories`
35
+ - **Errors** — `FsCoreError`, `EditNotAppliedError`
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-fs-core
3
+ Version: 0.1.0
4
+ Summary: Pure-Python filesystem helpers — the importable core behind runspec-fs (no runspec dependency, no runnables)
5
+ Requires-Python: >=3.10
6
+ Provides-Extra: dev
7
+ Requires-Dist: mypy; extra == 'dev'
8
+ Requires-Dist: pytest>=8.0; extra == 'dev'
9
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-fs-core"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Pure-Python filesystem helpers — the importable core behind runspec-fs (no runspec dependency, no runnables)"
10
+ dependencies = []
11
+
12
+ [project.optional-dependencies]
13
+ dev = [
14
+ "ruff==0.15.20",
15
+ "mypy",
16
+ "pytest>=8.0",
17
+ ]
18
+
19
+ [tool.pytest.ini_options]
20
+ testpaths = ["tests"]
21
+
22
+ [tool.mypy]
23
+ python_version = "3.10"
24
+
25
+ [tool.ruff]
26
+ line-length = 200
27
+ target-version = "py310"
28
+
29
+ [tool.ruff.lint]
30
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,59 @@
1
+ """runspec-fs-core — pure-Python filesystem helpers.
2
+
3
+ This package has **no dependency on runspec** and ships **no runspec.toml and no
4
+ entry points**, so installing it exposes the helper functions for import without
5
+ surfacing any runnables (it is invisible to ``runspec local`` / ``runspec serve``
6
+ discovery). ``runspec-fs`` depends on it and wraps each helper as a verb of the
7
+ ``fs`` runnable.
8
+
9
+ The helpers are a stdlib port of the
10
+ `Filesystem MCP server <https://github.com/modelcontextprotocol/servers/tree/HEAD/src/filesystem>`_
11
+ tool set. Each function does the work and returns plain data; failures raise —
12
+ the stdlib ``OSError`` family (``FileNotFoundError``, ``NotADirectoryError``,
13
+ ``PermissionError`` …) for filesystem problems, plus
14
+ ``runspec_fs_core.errors.EditNotAppliedError`` for a failed ``edit_file``.
15
+
16
+ No directory jail is enforced: filesystem access is bounded by the OS permissions
17
+ of the user the process runs as. See ``allowed_directories``.
18
+ """
19
+
20
+ from runspec_fs_core.dirs import (
21
+ create_directory,
22
+ directory_tree,
23
+ list_directory,
24
+ list_directory_with_sizes,
25
+ move_file,
26
+ )
27
+ from runspec_fs_core.errors import EditNotAppliedError, FsCoreError
28
+ from runspec_fs_core.files import (
29
+ edit_file,
30
+ read_media_file,
31
+ read_multiple_files,
32
+ read_text_file,
33
+ write_file,
34
+ )
35
+ from runspec_fs_core.info import allowed_directories, get_file_info
36
+ from runspec_fs_core.search import search_files
37
+
38
+ __all__ = [
39
+ # errors
40
+ "FsCoreError",
41
+ "EditNotAppliedError",
42
+ # read
43
+ "read_text_file",
44
+ "read_media_file",
45
+ "read_multiple_files",
46
+ # write / edit
47
+ "write_file",
48
+ "edit_file",
49
+ # directories
50
+ "create_directory",
51
+ "list_directory",
52
+ "list_directory_with_sizes",
53
+ "directory_tree",
54
+ "move_file",
55
+ # search / info
56
+ "search_files",
57
+ "get_file_info",
58
+ "allowed_directories",
59
+ ]
@@ -0,0 +1,133 @@
1
+ """Directory helpers — the pure logic behind the ``fs`` runnable's directory
2
+ verbs (``create-directory``, ``list-directory``, ``list-directory-with-sizes``,
3
+ ``directory-tree``, ``move-file``).
4
+
5
+ Each function takes plain params, returns plain data, and raises on failure
6
+ (stdlib ``OSError`` family: ``FileNotFoundError``, ``NotADirectoryError``,
7
+ ``FileExistsError`` …).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import os
14
+ import shutil
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ def _as_path(path: str | os.PathLike[str]) -> Path:
20
+ return Path(path).expanduser()
21
+
22
+
23
+ def _excluded(name: str, exclude: list[str] | None) -> bool:
24
+ return bool(exclude) and any(fnmatch.fnmatch(name, pat) for pat in exclude) # type: ignore[union-attr]
25
+
26
+
27
+ def create_directory(path: str | os.PathLike[str]) -> dict[str, Any]:
28
+ """Create a directory (and any missing parents). Idempotent — confirming an
29
+ existing directory succeeds. Returns ``{path, created}`` where ``created`` is
30
+ ``False`` if it already existed."""
31
+ p = _as_path(path)
32
+ existed = p.exists()
33
+ p.mkdir(parents=True, exist_ok=True)
34
+ return {"path": str(p), "created": not existed}
35
+
36
+
37
+ def _entry_type(entry: os.DirEntry[str] | Path) -> str:
38
+ return "directory" if entry.is_dir() else "file"
39
+
40
+
41
+ def list_directory(path: str | os.PathLike[str]) -> dict[str, Any]:
42
+ """List a directory's immediate contents. Returns
43
+ ``{path, entries: [{name, type}]}`` sorted directories-first then by name."""
44
+ p = _as_path(path)
45
+ entries = [{"name": e.name, "type": _entry_type(e)} for e in sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))]
46
+ return {"path": str(p), "entries": entries, "count": len(entries)}
47
+
48
+
49
+ def list_directory_with_sizes(
50
+ path: str | os.PathLike[str],
51
+ *,
52
+ sort_by: str = "name",
53
+ ) -> dict[str, Any]:
54
+ """List a directory's contents with per-entry sizes and aggregate statistics.
55
+
56
+ ``sort_by`` is ``"name"`` (default) or ``"size"`` (descending). Returns
57
+ ``{path, entries: [{name, type, size}], total_files, total_directories,
58
+ total_size}``.
59
+ """
60
+ if sort_by not in ("name", "size"):
61
+ raise ValueError("sort_by must be 'name' or 'size'")
62
+ p = _as_path(path)
63
+
64
+ entries: list[dict[str, Any]] = []
65
+ total_files = 0
66
+ total_dirs = 0
67
+ total_size = 0
68
+ for e in p.iterdir():
69
+ is_dir = e.is_dir()
70
+ size = 0 if is_dir else e.stat().st_size
71
+ entries.append({"name": e.name, "type": "directory" if is_dir else "file", "size": size})
72
+ if is_dir:
73
+ total_dirs += 1
74
+ else:
75
+ total_files += 1
76
+ total_size += size
77
+
78
+ if sort_by == "size":
79
+ entries.sort(key=lambda x: x["size"], reverse=True)
80
+ else:
81
+ entries.sort(key=lambda x: (x["type"] == "file", x["name"].lower()))
82
+
83
+ return {
84
+ "path": str(p),
85
+ "entries": entries,
86
+ "total_files": total_files,
87
+ "total_directories": total_dirs,
88
+ "total_size": total_size,
89
+ }
90
+
91
+
92
+ def directory_tree(
93
+ path: str | os.PathLike[str],
94
+ *,
95
+ exclude: list[str] | None = None,
96
+ ) -> dict[str, Any]:
97
+ """Return a recursive JSON tree of a directory. Each node is
98
+ ``{name, type}`` and directories carry a ``children`` list. ``exclude`` is a
99
+ list of glob patterns matched against entry names at every level."""
100
+ root = _as_path(path)
101
+
102
+ def build(node: Path) -> dict[str, Any]:
103
+ result: dict[str, Any] = {"name": node.name, "type": "directory"}
104
+ children: list[dict[str, Any]] = []
105
+ for child in sorted(node.iterdir(), key=lambda c: (c.is_file(), c.name.lower())):
106
+ if _excluded(child.name, exclude):
107
+ continue
108
+ if child.is_dir():
109
+ children.append(build(child))
110
+ else:
111
+ children.append({"name": child.name, "type": "file"})
112
+ result["children"] = children
113
+ return result
114
+
115
+ tree = build(root)
116
+ # The root node's name is the full path so the caller knows what was walked.
117
+ tree["name"] = str(root)
118
+ return {"path": str(root), "tree": tree}
119
+
120
+
121
+ def move_file(
122
+ source: str | os.PathLike[str],
123
+ destination: str | os.PathLike[str],
124
+ ) -> dict[str, Any]:
125
+ """Move or rename a file or directory. Fails if the destination already
126
+ exists (mirrors the MCP tool — no silent overwrite). Returns
127
+ ``{source, destination}``."""
128
+ src = _as_path(source)
129
+ dst = _as_path(destination)
130
+ if dst.exists():
131
+ raise FileExistsError(f"destination already exists: {dst}")
132
+ shutil.move(str(src), str(dst))
133
+ return {"source": str(src), "destination": str(dst)}
@@ -0,0 +1,19 @@
1
+ """Exception types raised by the pure helper functions.
2
+
3
+ The functions in this package do the work and *raise* on failure; the thin
4
+ runnable wrappers in ``runspec-fs`` catch these (alongside the stdlib ``OSError``
5
+ family — ``FileNotFoundError``, ``NotADirectoryError``, ``PermissionError`` …)
6
+ and turn them into the JSON error payloads + non-zero exits that the CLI/agent
7
+ surface expects. The OS errors are left to propagate as themselves rather than
8
+ being re-wrapped, so a wrapper (or a corporate importer) can react to the exact
9
+ failure (missing path vs. permission denied) without parsing a message.
10
+ """
11
+
12
+
13
+ class FsCoreError(Exception):
14
+ """Base class for all runspec-fs-core failures that aren't a plain OSError."""
15
+
16
+
17
+ class EditNotAppliedError(FsCoreError):
18
+ """An ``edit_file`` old-text block was not found in the file, so the edit
19
+ could not be applied (nothing is written)."""
@@ -0,0 +1,144 @@
1
+ """File read/write/edit helpers — the pure logic behind the ``fs`` runnable's
2
+ file verbs (``read-text-file``, ``read-media-file``, ``read-multiple-files``,
3
+ ``write-file``, ``edit-file``).
4
+
5
+ Each function takes plain params (``str``/``os.PathLike``), returns plain data,
6
+ and raises on failure. Filesystem errors propagate as the stdlib ``OSError``
7
+ family; ``edit_file`` raises :class:`EditNotAppliedError` when an old-text block
8
+ is not present.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import difflib
15
+ import mimetypes
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from runspec_fs_core.errors import EditNotAppliedError
21
+
22
+
23
+ def _as_path(path: str | os.PathLike[str]) -> Path:
24
+ """Normalise an incoming path. ``expanduser`` only — the ``path``-typed args
25
+ are already resolved by runspec's coercer, and str-typed paths shouldn't have
26
+ symlinks silently resolved out from under the caller."""
27
+ return Path(path).expanduser()
28
+
29
+
30
+ def read_text_file(
31
+ path: str | os.PathLike[str],
32
+ *,
33
+ head: int | None = None,
34
+ tail: int | None = None,
35
+ ) -> dict[str, Any]:
36
+ """Read a file as UTF-8 text.
37
+
38
+ ``head``/``tail`` return only the first / last N lines respectively (they are
39
+ mutually exclusive, mirroring the MCP tool). Returns
40
+ ``{path, content, truncated}``.
41
+ """
42
+ if head is not None and tail is not None:
43
+ raise ValueError("Pass only one of head / tail, not both")
44
+ p = _as_path(path)
45
+ text = p.read_text(encoding="utf-8")
46
+
47
+ if head is None and tail is None:
48
+ return {"path": str(p), "content": text, "truncated": False}
49
+
50
+ lines = text.splitlines(keepends=True)
51
+ if head is not None:
52
+ if head < 0:
53
+ raise ValueError("head must be >= 0")
54
+ selected = lines[:head]
55
+ else: # tail is not None
56
+ if tail < 0: # type: ignore[operator]
57
+ raise ValueError("tail must be >= 0")
58
+ selected = lines[-tail:] if tail else []
59
+ return {
60
+ "path": str(p),
61
+ "content": "".join(selected),
62
+ "truncated": len(selected) < len(lines),
63
+ }
64
+
65
+
66
+ def read_media_file(path: str | os.PathLike[str]) -> dict[str, Any]:
67
+ """Read a file's raw bytes and return them base64-encoded with a guessed MIME
68
+ type. Returns ``{path, mime_type, base64, bytes}``."""
69
+ p = _as_path(path)
70
+ data = p.read_bytes()
71
+ mime, _ = mimetypes.guess_type(str(p))
72
+ return {
73
+ "path": str(p),
74
+ "mime_type": mime or "application/octet-stream",
75
+ "base64": base64.b64encode(data).decode("ascii"),
76
+ "bytes": len(data),
77
+ }
78
+
79
+
80
+ def read_multiple_files(paths: list[str | os.PathLike[str]]) -> dict[str, Any]:
81
+ """Read several files. A failed read is captured per-file rather than aborting
82
+ the whole batch. Returns ``{files: [{path, content} | {path, error}]}``."""
83
+ results: list[dict[str, Any]] = []
84
+ for path in paths:
85
+ p = _as_path(path)
86
+ try:
87
+ results.append({"path": str(p), "content": p.read_text(encoding="utf-8")})
88
+ except OSError as exc:
89
+ results.append({"path": str(p), "error": str(exc)})
90
+ except UnicodeDecodeError:
91
+ results.append({"path": str(p), "error": "not a UTF-8 text file"})
92
+ return {"files": results, "count": len(results)}
93
+
94
+
95
+ def write_file(path: str | os.PathLike[str], content: str) -> dict[str, Any]:
96
+ """Create or overwrite a file with UTF-8 text. Returns
97
+ ``{path, bytes_written}``."""
98
+ p = _as_path(path)
99
+ data = content.encode("utf-8")
100
+ p.write_bytes(data)
101
+ return {"path": str(p), "bytes_written": len(data)}
102
+
103
+
104
+ def edit_file(
105
+ path: str | os.PathLike[str],
106
+ edits: list[tuple[str, str]],
107
+ *,
108
+ dry_run: bool = False,
109
+ ) -> dict[str, Any]:
110
+ """Apply a sequence of ``(old_text, new_text)`` substring replacements to a
111
+ file, in order (first occurrence of each ``old_text``).
112
+
113
+ Raises :class:`EditNotAppliedError` if any ``old_text`` is not present at the
114
+ time it is applied — nothing is written in that case. With ``dry_run`` the new
115
+ content is computed and diffed but not written. Returns
116
+ ``{path, edits_applied, dry_run, diff}`` where ``diff`` is a unified diff.
117
+ """
118
+ p = _as_path(path)
119
+ original = p.read_text(encoding="utf-8")
120
+
121
+ updated = original
122
+ for index, (old_text, new_text) in enumerate(edits, start=1):
123
+ if old_text not in updated:
124
+ raise EditNotAppliedError(f"edit #{index}: old text not found in {p}: {old_text!r}")
125
+ updated = updated.replace(old_text, new_text, 1)
126
+
127
+ diff = "".join(
128
+ difflib.unified_diff(
129
+ original.splitlines(keepends=True),
130
+ updated.splitlines(keepends=True),
131
+ fromfile=f"a/{p.name}",
132
+ tofile=f"b/{p.name}",
133
+ )
134
+ )
135
+
136
+ if not dry_run:
137
+ p.write_text(updated, encoding="utf-8")
138
+
139
+ return {
140
+ "path": str(p),
141
+ "edits_applied": len(edits),
142
+ "dry_run": dry_run,
143
+ "diff": diff,
144
+ }
@@ -0,0 +1,55 @@
1
+ """Metadata helpers — the pure logic behind the ``fs`` runnable's
2
+ ``get-file-info`` and ``list-allowed-directories`` verbs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import getpass
7
+ import os
8
+ import stat
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ def _iso(ts: float) -> str:
15
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
16
+
17
+
18
+ def get_file_info(path: str | os.PathLike[str]) -> dict[str, Any]:
19
+ """Return detailed metadata for a file or directory: size, timestamps
20
+ (UTC ISO-8601), octal permissions, and type flags. Returns a plain dict."""
21
+ p = Path(path).expanduser()
22
+ st = p.stat()
23
+ return {
24
+ "path": str(p),
25
+ "type": "directory" if p.is_dir() else "file",
26
+ "size": st.st_size,
27
+ "created": _iso(st.st_ctime),
28
+ "modified": _iso(st.st_mtime),
29
+ "accessed": _iso(st.st_atime),
30
+ "permissions": oct(stat.S_IMODE(st.st_mode)),
31
+ "is_directory": p.is_dir(),
32
+ "is_file": p.is_file(),
33
+ "is_symlink": p.is_symlink(),
34
+ }
35
+
36
+
37
+ def allowed_directories() -> dict[str, Any]:
38
+ """Report the effective user and useful starting directories.
39
+
40
+ This runnable applies **no software directory jail** — access is bounded by
41
+ the operating-system permissions of the user the process runs as (the
42
+ logged-in user, or a ``run_as`` service account). This verb is informational,
43
+ so an agent knows *who* it is and *where* it starts; it never widens what the
44
+ OS already allows.
45
+ """
46
+ try:
47
+ user = getpass.getuser()
48
+ except Exception: # noqa: BLE001 — getuser can raise if no username resolves
49
+ user = str(os.getuid()) if hasattr(os, "getuid") else "unknown"
50
+ return {
51
+ "user": user,
52
+ "home": str(Path.home()),
53
+ "cwd": os.getcwd(),
54
+ "note": ("No directory jail is enforced. Filesystem access is bounded by the OS permissions of the user this process runs as."),
55
+ }
@@ -0,0 +1,50 @@
1
+ """Search helper — the pure logic behind the ``fs`` runnable's ``search-files``
2
+ verb. Recursively finds files/directories whose name matches a pattern,
3
+ case-insensitively (glob when the pattern contains glob metacharacters, else a
4
+ substring match), honouring a list of exclude globs."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import fnmatch
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ _GLOB_CHARS = set("*?[]")
14
+
15
+
16
+ def _matches(name: str, pattern: str) -> bool:
17
+ lname = name.lower()
18
+ lpat = pattern.lower()
19
+ if _GLOB_CHARS & set(pattern):
20
+ return fnmatch.fnmatch(lname, lpat)
21
+ return lpat in lname
22
+
23
+
24
+ def search_files(
25
+ path: str | os.PathLike[str],
26
+ pattern: str,
27
+ *,
28
+ exclude: list[str] | None = None,
29
+ ) -> dict[str, Any]:
30
+ """Recursively search ``path`` for entries whose name matches ``pattern``.
31
+
32
+ ``exclude`` is a list of glob patterns; a directory whose name matches an
33
+ exclude is pruned (not descended into), and matching files are skipped.
34
+ Returns ``{path, pattern, matches: [str], count}``.
35
+ """
36
+ root = Path(path).expanduser()
37
+ exclude = exclude or []
38
+ matches: list[str] = []
39
+
40
+ for dirpath, dirnames, filenames in os.walk(root):
41
+ # Prune excluded directories in place so os.walk doesn't descend them.
42
+ dirnames[:] = [d for d in dirnames if not any(fnmatch.fnmatch(d, pat) for pat in exclude)]
43
+ for name in list(dirnames) + filenames:
44
+ if any(fnmatch.fnmatch(name, pat) for pat in exclude):
45
+ continue
46
+ if _matches(name, pattern):
47
+ matches.append(str(Path(dirpath) / name))
48
+
49
+ matches.sort()
50
+ return {"path": str(root), "pattern": pattern, "matches": matches, "count": len(matches)}
File without changes
@@ -0,0 +1,76 @@
1
+ """Directory helpers against real trees under tmp_path."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from runspec_fs_core.dirs import (
8
+ create_directory,
9
+ directory_tree,
10
+ list_directory,
11
+ list_directory_with_sizes,
12
+ move_file,
13
+ )
14
+
15
+
16
+ def test_create_directory_idempotent(tmp_path: Path) -> None:
17
+ target = tmp_path / "a" / "b"
18
+ assert create_directory(target)["created"] is True
19
+ assert target.is_dir()
20
+ assert create_directory(target)["created"] is False
21
+
22
+
23
+ def test_list_directory(tmp_path: Path) -> None:
24
+ (tmp_path / "sub").mkdir()
25
+ (tmp_path / "file.txt").write_text("x")
26
+ out = list_directory(tmp_path)
27
+ names = {(e["name"], e["type"]) for e in out["entries"]}
28
+ assert ("sub", "directory") in names
29
+ assert ("file.txt", "file") in names
30
+ assert out["count"] == 2
31
+
32
+
33
+ def test_list_directory_with_sizes_sort_by_size(tmp_path: Path) -> None:
34
+ (tmp_path / "small.txt").write_text("a")
35
+ (tmp_path / "big.txt").write_text("a" * 100)
36
+ out = list_directory_with_sizes(tmp_path, sort_by="size")
37
+ files = [e for e in out["entries"] if e["type"] == "file"]
38
+ assert files[0]["name"] == "big.txt"
39
+ assert out["total_files"] == 2
40
+ assert out["total_size"] == 101
41
+
42
+
43
+ def test_list_directory_with_sizes_bad_sort(tmp_path: Path) -> None:
44
+ with pytest.raises(ValueError):
45
+ list_directory_with_sizes(tmp_path, sort_by="nope")
46
+
47
+
48
+ def test_directory_tree_with_exclude(tmp_path: Path) -> None:
49
+ (tmp_path / "keep").mkdir()
50
+ (tmp_path / "keep" / "f.txt").write_text("x")
51
+ (tmp_path / "node_modules").mkdir()
52
+ (tmp_path / "node_modules" / "junk.txt").write_text("y")
53
+ out = directory_tree(tmp_path, exclude=["node_modules"])
54
+ child_names = {c["name"] for c in out["tree"]["children"]}
55
+ assert "keep" in child_names
56
+ assert "node_modules" not in child_names
57
+
58
+
59
+ def test_move_file_renames(tmp_path: Path) -> None:
60
+ src = tmp_path / "src.txt"
61
+ src.write_text("data")
62
+ dst = tmp_path / "dst.txt"
63
+ out = move_file(src, dst)
64
+ assert not src.exists()
65
+ assert dst.read_text() == "data"
66
+ assert out["destination"] == str(dst)
67
+
68
+
69
+ def test_move_file_refuses_existing_destination(tmp_path: Path) -> None:
70
+ src = tmp_path / "src.txt"
71
+ src.write_text("a")
72
+ dst = tmp_path / "dst.txt"
73
+ dst.write_text("b")
74
+ with pytest.raises(FileExistsError):
75
+ move_file(src, dst)
76
+ assert src.exists() # nothing moved
@@ -0,0 +1,93 @@
1
+ """Read / write / edit helpers against real files under tmp_path."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from runspec_fs_core.errors import EditNotAppliedError
8
+ from runspec_fs_core.files import (
9
+ edit_file,
10
+ read_media_file,
11
+ read_multiple_files,
12
+ read_text_file,
13
+ write_file,
14
+ )
15
+
16
+
17
+ def test_read_text_file_full(tmp_path: Path) -> None:
18
+ f = tmp_path / "a.txt"
19
+ f.write_text("one\ntwo\nthree\n")
20
+ out = read_text_file(f)
21
+ assert out["content"] == "one\ntwo\nthree\n"
22
+ assert out["truncated"] is False
23
+
24
+
25
+ def test_read_text_file_head_and_tail(tmp_path: Path) -> None:
26
+ f = tmp_path / "a.txt"
27
+ f.write_text("l1\nl2\nl3\nl4\n")
28
+ assert read_text_file(f, head=2)["content"] == "l1\nl2\n"
29
+ assert read_text_file(f, head=2)["truncated"] is True
30
+ assert read_text_file(f, tail=1)["content"] == "l4\n"
31
+
32
+
33
+ def test_read_text_file_head_tail_mutually_exclusive(tmp_path: Path) -> None:
34
+ f = tmp_path / "a.txt"
35
+ f.write_text("x\n")
36
+ with pytest.raises(ValueError):
37
+ read_text_file(f, head=1, tail=1)
38
+
39
+
40
+ def test_read_text_file_missing_raises_oserror(tmp_path: Path) -> None:
41
+ with pytest.raises(FileNotFoundError):
42
+ read_text_file(tmp_path / "nope.txt")
43
+
44
+
45
+ def test_read_media_file(tmp_path: Path) -> None:
46
+ f = tmp_path / "a.bin"
47
+ f.write_bytes(b"\x00\x01\x02")
48
+ out = read_media_file(f)
49
+ assert out["bytes"] == 3
50
+ assert out["base64"] == "AAEC"
51
+
52
+
53
+ def test_read_multiple_files_captures_per_file_errors(tmp_path: Path) -> None:
54
+ good = tmp_path / "good.txt"
55
+ good.write_text("hi")
56
+ out = read_multiple_files([good, tmp_path / "missing.txt"])
57
+ assert out["count"] == 2
58
+ assert out["files"][0] == {"path": str(good), "content": "hi"}
59
+ assert "error" in out["files"][1]
60
+
61
+
62
+ def test_write_file(tmp_path: Path) -> None:
63
+ f = tmp_path / "sub" / "new.txt"
64
+ f.parent.mkdir()
65
+ out = write_file(f, "hello")
66
+ assert f.read_text() == "hello"
67
+ assert out["bytes_written"] == 5
68
+
69
+
70
+ def test_edit_file_applies_ordered_replacements(tmp_path: Path) -> None:
71
+ f = tmp_path / "a.txt"
72
+ f.write_text("alpha beta gamma")
73
+ out = edit_file(f, [("alpha", "ALPHA"), ("gamma", "GAMMA")])
74
+ assert f.read_text() == "ALPHA beta GAMMA"
75
+ assert out["edits_applied"] == 2
76
+ assert out["diff"]
77
+
78
+
79
+ def test_edit_file_dry_run_does_not_write(tmp_path: Path) -> None:
80
+ f = tmp_path / "a.txt"
81
+ f.write_text("keep me")
82
+ out = edit_file(f, [("keep", "drop")], dry_run=True)
83
+ assert f.read_text() == "keep me"
84
+ assert out["dry_run"] is True
85
+ assert "drop" in out["diff"]
86
+
87
+
88
+ def test_edit_file_missing_old_text_raises_and_does_not_write(tmp_path: Path) -> None:
89
+ f = tmp_path / "a.txt"
90
+ f.write_text("original")
91
+ with pytest.raises(EditNotAppliedError):
92
+ edit_file(f, [("absent", "x")])
93
+ assert f.read_text() == "original"
@@ -0,0 +1,50 @@
1
+ """search_files + get_file_info / allowed_directories against tmp_path."""
2
+
3
+ from pathlib import Path
4
+
5
+ from runspec_fs_core.info import allowed_directories, get_file_info
6
+ from runspec_fs_core.search import search_files
7
+
8
+
9
+ def test_search_files_substring_case_insensitive(tmp_path: Path) -> None:
10
+ (tmp_path / "Report.txt").write_text("x")
11
+ (tmp_path / "sub").mkdir()
12
+ (tmp_path / "sub" / "monthly_report.md").write_text("y")
13
+ (tmp_path / "other.log").write_text("z")
14
+ out = search_files(tmp_path, "report")
15
+ found = {Path(m).name for m in out["matches"]}
16
+ assert found == {"Report.txt", "monthly_report.md"}
17
+ assert out["count"] == 2
18
+
19
+
20
+ def test_search_files_glob(tmp_path: Path) -> None:
21
+ (tmp_path / "a.py").write_text("x")
22
+ (tmp_path / "b.txt").write_text("y")
23
+ out = search_files(tmp_path, "*.py")
24
+ assert {Path(m).name for m in out["matches"]} == {"a.py"}
25
+
26
+
27
+ def test_search_files_prunes_excluded_dirs(tmp_path: Path) -> None:
28
+ (tmp_path / "keep").mkdir()
29
+ (tmp_path / "keep" / "match.txt").write_text("x")
30
+ (tmp_path / "node_modules").mkdir()
31
+ (tmp_path / "node_modules" / "match.txt").write_text("y")
32
+ out = search_files(tmp_path, "match", exclude=["node_modules"])
33
+ assert all("node_modules" not in m for m in out["matches"])
34
+ assert out["count"] == 1
35
+
36
+
37
+ def test_get_file_info(tmp_path: Path) -> None:
38
+ f = tmp_path / "a.txt"
39
+ f.write_text("hello")
40
+ info = get_file_info(f)
41
+ assert info["type"] == "file"
42
+ assert info["size"] == 5
43
+ assert info["is_file"] is True
44
+ assert info["permissions"].startswith("0o")
45
+
46
+
47
+ def test_allowed_directories_is_informational() -> None:
48
+ out = allowed_directories()
49
+ assert "user" in out and "home" in out and "cwd" in out
50
+ assert "jail" in out["note"].lower()