handler-ai 0.1.0__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.
- handler_ai-0.1.0.dist-info/METADATA +60 -0
- handler_ai-0.1.0.dist-info/RECORD +234 -0
- handler_ai-0.1.0.dist-info/WHEEL +4 -0
- handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
- handler_cli/__init__.py +1 -0
- handler_cli/account_commands.py +314 -0
- handler_cli/addon_commands.py +128 -0
- handler_cli/agent_commands.py +139 -0
- handler_cli/app_commands.py +175 -0
- handler_cli/app_window.py +85 -0
- handler_cli/automation_commands.py +309 -0
- handler_cli/bridge.py +42 -0
- handler_cli/cli.py +326 -0
- handler_cli/client.py +81 -0
- handler_cli/daemon_commands.py +108 -0
- handler_cli/image_commands.py +102 -0
- handler_cli/inbox_commands.py +33 -0
- handler_cli/output.py +12 -0
- handler_cli/project_commands.py +63 -0
- handler_cli/provider_commands.py +113 -0
- handler_cli/secret_commands.py +64 -0
- handler_cli/session_bridge.py +93 -0
- handler_cli/session_commands.py +485 -0
- handler_cli/setup.py +120 -0
- handler_cli/terminal.py +280 -0
- handler_cli/terminal_client.py +37 -0
- handler_contracts/__init__.py +642 -0
- handler_contracts/account.py +36 -0
- handler_contracts/acp_endpoint.py +69 -0
- handler_contracts/addon.py +147 -0
- handler_contracts/agent_spec.py +75 -0
- handler_contracts/automation.py +285 -0
- handler_contracts/common.py +229 -0
- handler_contracts/control.py +1208 -0
- handler_contracts/harness.py +300 -0
- handler_contracts/miniapp.py +141 -0
- handler_contracts/provider.py +82 -0
- handler_contracts/remote.py +167 -0
- handler_contracts/sandbox.py +199 -0
- handler_contracts/schemas.py +313 -0
- handler_contracts/terminal.py +32 -0
- handler_control/__init__.py +7 -0
- handler_control/access.py +62 -0
- handler_control/accounts.py +279 -0
- handler_control/addon_install.py +268 -0
- handler_control/addons.py +118 -0
- handler_control/agent_state.py +46 -0
- handler_control/agents.py +257 -0
- handler_control/api/__init__.py +137 -0
- handler_control/api/accounts.py +231 -0
- handler_control/api/acp.py +116 -0
- handler_control/api/addons.py +95 -0
- handler_control/api/agents.py +150 -0
- handler_control/api/apps.py +104 -0
- handler_control/api/automations.py +128 -0
- handler_control/api/bindings.py +105 -0
- handler_control/api/builtin_agents.py +22 -0
- handler_control/api/caches.py +79 -0
- handler_control/api/catalog.py +197 -0
- handler_control/api/common.py +205 -0
- handler_control/api/contexts.py +88 -0
- handler_control/api/folders.py +51 -0
- handler_control/api/images.py +164 -0
- handler_control/api/inbox.py +21 -0
- handler_control/api/projects.py +170 -0
- handler_control/api/providers.py +107 -0
- handler_control/api/remote.py +45 -0
- handler_control/api/secrets.py +177 -0
- handler_control/api/sessions.py +192 -0
- handler_control/api/skills.py +270 -0
- handler_control/app_api.py +84 -0
- handler_control/app_http.py +214 -0
- handler_control/app_remote.py +206 -0
- handler_control/apps.py +867 -0
- handler_control/auth.py +48 -0
- handler_control/automation_config.py +255 -0
- handler_control/automations.py +1134 -0
- handler_control/bindings.py +149 -0
- handler_control/builds.py +341 -0
- handler_control/builtin_agents.py +82 -0
- handler_control/caches.py +113 -0
- handler_control/checks.py +287 -0
- handler_control/compose.py +317 -0
- handler_control/config_files.py +41 -0
- handler_control/contexts.py +75 -0
- handler_control/daemon.py +64 -0
- handler_control/data/automation/bin/handler-llm +85 -0
- handler_control/data/automations/env-issues/automation.toml +17 -0
- handler_control/data/automations/env-issues/extract.py +73 -0
- handler_control/data/automations/env-issues/findings.schema.json +28 -0
- handler_control/data/automations/env-issues/prompt.md +19 -0
- handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
- handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
- handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
- handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
- handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
- handler_control/data/builtin-agents/skill-review/interface.json +83 -0
- handler_control/data/interfaces/llm/v1/interface.toml +17 -0
- handler_control/data/interfaces/llm/v1/tools.json +138 -0
- handler_control/data/templates/README.md +10 -0
- handler_control/data/templates/providers/anthropic-api.toml +18 -0
- handler_control/data/templates/providers/llamacpp.toml +210 -0
- handler_control/data/templates/providers/mistral-api.toml +14 -0
- handler_control/data/templates/providers/ollama.toml +10 -0
- handler_control/data/templates/providers/openai-api.toml +13 -0
- handler_control/data/templates/providers/openrouter.toml +10 -0
- handler_control/desktop.py +68 -0
- handler_control/display.py +62 -0
- handler_control/files.py +23 -0
- handler_control/folders.py +68 -0
- handler_control/git_read.py +39 -0
- handler_control/image_check.py +322 -0
- handler_control/image_updates.py +174 -0
- handler_control/inbox.py +70 -0
- handler_control/interfaces.py +151 -0
- handler_control/knowledge.py +237 -0
- handler_control/launcher.py +356 -0
- handler_control/llm.py +194 -0
- handler_control/migrations/__init__.py +62 -0
- handler_control/policy.py +305 -0
- handler_control/projects.py +266 -0
- handler_control/providers.py +180 -0
- handler_control/relay.py +133 -0
- handler_control/remote.py +888 -0
- handler_control/remote_crypto.py +97 -0
- handler_control/runtime.py +140 -0
- handler_control/sandbox_process.py +224 -0
- handler_control/schedules.py +58 -0
- handler_control/secrets.py +242 -0
- handler_control/service_router.py +180 -0
- handler_control/services.py +92 -0
- handler_control/session_files.py +429 -0
- handler_control/sessions.py +1116 -0
- handler_control/settings.py +129 -0
- handler_control/shortcuts.py +80 -0
- handler_control/skill_scan.py +88 -0
- handler_control/skill_sources.py +61 -0
- handler_control/skills.py +378 -0
- handler_control/terminal.py +79 -0
- handler_control/terminals.py +108 -0
- handler_control/transcript.py +174 -0
- handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
- handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
- handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- handler_control/web-ui/favicon.svg +1 -0
- handler_control/web-ui/index.html +15 -0
- handler_control/webpush.py +90 -0
- handler_control/webui.py +105 -0
- handler_harness/__init__.py +13 -0
- handler_harness/addons.py +129 -0
- handler_harness/catalog.py +116 -0
- handler_harness/cli.py +109 -0
- handler_harness/data/addons/README.md +44 -0
- handler_harness/data/addons/miniapp/Containerfile +20 -0
- handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
- handler_harness/data/addons/miniapp/addon.toml +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
- handler_harness/data/catalog/README.md +12 -0
- handler_harness/data/catalog/playwright/Containerfile +28 -0
- handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
- handler_harness/data/catalog/playwright/addon.toml +25 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
- handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
- handler_harness/data/catalog/shopify/Containerfile +22 -0
- handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
- handler_harness/data/catalog/shopify/addon.toml +39 -0
- handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
- handler_harness/data/handler-sandbox-env +30 -0
- handler_harness/data/harnesses/README.md +47 -0
- handler_harness/data/harnesses/claude-code/Containerfile +27 -0
- handler_harness/data/harnesses/claude-code/harness.toml +95 -0
- handler_harness/data/harnesses/codex/Containerfile +26 -0
- handler_harness/data/harnesses/codex/harness.toml +103 -0
- handler_harness/data/harnesses/pi/Containerfile +27 -0
- handler_harness/data/harnesses/pi/harness.toml +65 -0
- handler_harness/data/harnesses/vibe/Containerfile +19 -0
- handler_harness/data/harnesses/vibe/harness.toml +96 -0
- handler_harness/data/toolbox/Containerfile +56 -0
- handler_harness/data/toolbox/README.md +24 -0
- handler_harness/data/toolbox/toolbox.toml +41 -0
- handler_harness/images.py +62 -0
- handler_harness/providers.py +300 -0
- handler_harness/render.py +472 -0
- handler_sandbox_podman/__init__.py +1 -0
- handler_sandbox_podman/caches.py +97 -0
- handler_sandbox_podman/check.py +121 -0
- handler_sandbox_podman/cli.py +239 -0
- handler_sandbox_podman/egress.py +118 -0
- handler_sandbox_podman/images.py +199 -0
- handler_sandbox_podman/launch.py +474 -0
- handler_sandbox_podman/podman.py +108 -0
- handler_sandbox_podman/proxy/Containerfile +6 -0
- handler_sandbox_podman/proxy/addon.py +255 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""A session's folder as its people see it (F.8): its folders and files, read without following a
|
|
2
|
+
link nor leaving the folder, and what changed in it since the session started.
|
|
3
|
+
|
|
4
|
+
The agent writes in that folder, so nothing in it is trusted: every path is opened one name at a
|
|
5
|
+
time from the folder itself, never following a link; only regular files are read, up to a size;
|
|
6
|
+
`.gitignore` files are read the same way. Git objects are read by `git_read` in a child process
|
|
7
|
+
(a repository is the agent's too: handlerd never runs git nor reads `.git` itself).
|
|
8
|
+
|
|
9
|
+
The baseline, recorded when the session first starts (`baseline.json` in its folder, F.2): each
|
|
10
|
+
file's size and modification time, and the commit HEAD was at when the folder is a git repository
|
|
11
|
+
(the earlier versions of the files are that commit's). Files ignored by the folder's `.gitignore`
|
|
12
|
+
files (and `.git/info/exclude`) are left out, `.git` folders always.
|
|
13
|
+
|
|
14
|
+
What else of the host its sandbox sees is recorded at each start (`mounts.json`): knowledge
|
|
15
|
+
folders, skills and a run's subject are read here like its folder (the agent cannot change them);
|
|
16
|
+
the agent's own state (it holds the account's sign-in) and caches are only listed."""
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import errno
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import stat
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from dulwich.ignore import IgnoreFilter
|
|
29
|
+
|
|
30
|
+
from handler_contracts import (
|
|
31
|
+
SandboxSpec,
|
|
32
|
+
SessionChanges,
|
|
33
|
+
SessionFile,
|
|
34
|
+
SessionFileChange,
|
|
35
|
+
SessionFolder,
|
|
36
|
+
SessionFolderEntry,
|
|
37
|
+
SessionFound,
|
|
38
|
+
SessionMount,
|
|
39
|
+
SessionMounts,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from .files import write_private
|
|
43
|
+
|
|
44
|
+
MAX_FILES = 50_000 # beyond, changes are not followed
|
|
45
|
+
MAX_READ = 1 << 20 # a file shown, a .gitignore read
|
|
46
|
+
GIT_TIMEOUT = 10.0
|
|
47
|
+
IMAGES = {
|
|
48
|
+
b"\x89PNG\r\n\x1a\n": "image/png",
|
|
49
|
+
b"\xff\xd8\xff": "image/jpeg",
|
|
50
|
+
b"GIF87a": "image/gif",
|
|
51
|
+
b"GIF89a": "image/gif",
|
|
52
|
+
}
|
|
53
|
+
DIR = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
54
|
+
FILE = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC # a FIFO does not block
|
|
55
|
+
|
|
56
|
+
BROWSABLE = ("scope", "knowledge", "subject", "config")
|
|
57
|
+
|
|
58
|
+
Stats = dict[str, list[int]] # path → [size, mtime_ns]
|
|
59
|
+
Chain = list[tuple[str, IgnoreFilter]] # (folder, its ignore rules), deepest first
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PathError(Exception):
|
|
63
|
+
"""A path that cannot be shown: `missing` (404) or not one to show (400)."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, missing: bool = False) -> None:
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.missing = missing
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TooManyFilesError(Exception):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── what the sandbox sees ───────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def record_mounts(recorded: Path, spec: SandboxSpec, at: datetime) -> None:
|
|
78
|
+
mounts = [
|
|
79
|
+
SessionMount(
|
|
80
|
+
target=m.target,
|
|
81
|
+
kind=m.kind,
|
|
82
|
+
read_only=m.read_only,
|
|
83
|
+
source=m.source,
|
|
84
|
+
browsable=m.kind in BROWSABLE,
|
|
85
|
+
)
|
|
86
|
+
for m in spec.mounts
|
|
87
|
+
]
|
|
88
|
+
mounts += [
|
|
89
|
+
SessionMount(target=i.target, kind="tools", read_only=True, source=i.image, browsable=False)
|
|
90
|
+
for i in spec.image_mounts
|
|
91
|
+
]
|
|
92
|
+
found = SessionMounts(started_at=at, mounts=mounts)
|
|
93
|
+
write_private(recorded, found.model_dump_json(by_alias=True, exclude_none=True))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def mounts(recorded: Path, scope: str) -> SessionMounts:
|
|
97
|
+
try:
|
|
98
|
+
return SessionMounts.model_validate_json(recorded.read_text())
|
|
99
|
+
except FileNotFoundError: # never started yet
|
|
100
|
+
folder = SessionMount(
|
|
101
|
+
target=scope, kind="scope", read_only=False, source=scope, browsable=True
|
|
102
|
+
)
|
|
103
|
+
return SessionMounts(mounts=[folder])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def mount_root(recorded: Path, scope: str, target: str | None) -> tuple[Path, bool]:
|
|
107
|
+
"""(its folder on the host, whether it is the session's folder) of the browsable mount the
|
|
108
|
+
agent sees at `target`; the session's folder without one."""
|
|
109
|
+
if target is None:
|
|
110
|
+
return Path(scope), True
|
|
111
|
+
for m in mounts(recorded, scope).mounts:
|
|
112
|
+
if m.target == target and m.browsable:
|
|
113
|
+
return Path(m.source), m.kind == "scope"
|
|
114
|
+
raise PathError(f"the agent sees nothing to show at {target}", missing=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── reading ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def folder(root: Path, relative: str) -> SessionFolder:
|
|
121
|
+
parts = _parts(relative)
|
|
122
|
+
fd = _open_folder(root, parts)
|
|
123
|
+
try:
|
|
124
|
+
with os.scandir(fd) as found:
|
|
125
|
+
scanned = list(found)
|
|
126
|
+
entries: list[SessionFolderEntry] = []
|
|
127
|
+
for entry in scanned:
|
|
128
|
+
if entry.name == ".git":
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
st = entry.stat(follow_symlinks=False)
|
|
132
|
+
target = os.readlink(entry.name, dir_fd=fd) if entry.is_symlink() else None
|
|
133
|
+
except FileNotFoundError: # gone meanwhile
|
|
134
|
+
continue
|
|
135
|
+
if stat.S_ISDIR(st.st_mode):
|
|
136
|
+
entries.append(SessionFolderEntry(name=entry.name, kind="folder"))
|
|
137
|
+
elif stat.S_ISREG(st.st_mode):
|
|
138
|
+
entries.append(SessionFolderEntry(name=entry.name, kind="file", size=st.st_size))
|
|
139
|
+
elif target is not None:
|
|
140
|
+
entries.append(SessionFolderEntry(name=entry.name, kind="link", target=target))
|
|
141
|
+
finally:
|
|
142
|
+
os.close(fd)
|
|
143
|
+
entries.sort(key=lambda e: (e.kind != "folder", e.name.lower(), e.name))
|
|
144
|
+
return SessionFolder(path="/".join(parts), entries=entries)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read(root: Path, relative: str, baseline: Path | None) -> SessionFile:
|
|
148
|
+
"""A file, with what changed in it since the session started (with its `baseline`, for the
|
|
149
|
+
session's folder); a deleted one is shown with its earlier version when there is one."""
|
|
150
|
+
parts = _parts(relative)
|
|
151
|
+
if not parts:
|
|
152
|
+
raise PathError("not a file")
|
|
153
|
+
path = "/".join(parts)
|
|
154
|
+
recorded = _load(baseline) if baseline else {}
|
|
155
|
+
before_stats = recorded.get("files", {}).get(path)
|
|
156
|
+
commit = recorded.get("commit")
|
|
157
|
+
try:
|
|
158
|
+
data, size, mtime = _read_file(root, parts)
|
|
159
|
+
except PathError as e:
|
|
160
|
+
if not (e.missing and before_stats):
|
|
161
|
+
raise
|
|
162
|
+
before = git_text(root, commit, path) if commit else None
|
|
163
|
+
return SessionFile(path=path, change="deleted", before=before)
|
|
164
|
+
change = None
|
|
165
|
+
if "files" in recorded:
|
|
166
|
+
change = "added" if before_stats is None else None
|
|
167
|
+
if before_stats is not None and before_stats != [size, mtime]:
|
|
168
|
+
change = "modified"
|
|
169
|
+
view = SessionFile(path=path, size=size, change=change, **_shown(data, size))
|
|
170
|
+
if change == "modified" and commit:
|
|
171
|
+
view = view.model_copy(update={"before": git_text(root, commit, path)})
|
|
172
|
+
return view
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def host_path(root: Path, relative: str) -> tuple[Path, bool]:
|
|
176
|
+
"""(where it is on the host, whether it is a folder): checked like a read, inside the folder
|
|
177
|
+
and through no link on the way."""
|
|
178
|
+
parts = _parts(relative)
|
|
179
|
+
if not parts:
|
|
180
|
+
return root, True
|
|
181
|
+
fd = _open_folder(root, parts[:-1])
|
|
182
|
+
try:
|
|
183
|
+
st = os.stat(parts[-1], dir_fd=fd, follow_symlinks=False)
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
raise PathError(f"no file {'/'.join(parts)}", missing=True) from None
|
|
186
|
+
finally:
|
|
187
|
+
os.close(fd)
|
|
188
|
+
return root.joinpath(*parts), stat.S_ISDIR(st.st_mode)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _shown(data: bytes, size: int) -> dict[str, str]:
|
|
192
|
+
if size > MAX_READ:
|
|
193
|
+
return {}
|
|
194
|
+
for magic, mime in IMAGES.items():
|
|
195
|
+
if data.startswith(magic):
|
|
196
|
+
return {"image": f"data:{mime};base64,{base64.b64encode(data).decode()}"}
|
|
197
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
198
|
+
return {"image": f"data:image/webp;base64,{base64.b64encode(data).decode()}"}
|
|
199
|
+
return {"text": text} if (text := _text(data)) is not None else {}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _text(data: bytes) -> str | None:
|
|
203
|
+
if b"\0" in data:
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
return data.decode()
|
|
207
|
+
except UnicodeDecodeError:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parts(relative: str) -> list[str]:
|
|
212
|
+
parts = [p for p in relative.split("/") if p]
|
|
213
|
+
if relative.startswith("/") or "\0" in relative or any(p in (".", "..") for p in parts):
|
|
214
|
+
raise PathError("path invalid: relative to the session's folder, without . or ..")
|
|
215
|
+
if ".git" in parts:
|
|
216
|
+
raise PathError("a .git folder is not shown")
|
|
217
|
+
return parts
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _open_folder(root: Path, parts: list[str]) -> int:
|
|
221
|
+
"""The folder at `parts`, opened one name at a time from `root`, never through a link."""
|
|
222
|
+
fd = os.open(root, DIR & ~os.O_NOFOLLOW) # the session's folder itself: handlerd's to trust
|
|
223
|
+
opened: list[str] = []
|
|
224
|
+
try:
|
|
225
|
+
for name in parts:
|
|
226
|
+
opened.append(name)
|
|
227
|
+
sub = os.open(name, DIR, dir_fd=fd)
|
|
228
|
+
os.close(fd)
|
|
229
|
+
fd = sub
|
|
230
|
+
except OSError as e:
|
|
231
|
+
link = e.errno in (errno.ELOOP, errno.ENOTDIR) and _is_link(fd, opened[-1])
|
|
232
|
+
os.close(fd)
|
|
233
|
+
path = "/".join(opened)
|
|
234
|
+
if link:
|
|
235
|
+
raise PathError(f"{path} is a link: links are not followed") from None
|
|
236
|
+
if e.errno in (errno.ENOENT, errno.ENOTDIR):
|
|
237
|
+
raise PathError(f"no folder {path}", missing=True) from None
|
|
238
|
+
raise
|
|
239
|
+
return fd
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_link(fd: int, name: str) -> bool:
|
|
243
|
+
try:
|
|
244
|
+
return stat.S_ISLNK(os.stat(name, dir_fd=fd, follow_symlinks=False).st_mode)
|
|
245
|
+
except OSError:
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _read_file(root: Path, parts: list[str]) -> tuple[bytes, int, int]:
|
|
250
|
+
"""(its first MAX_READ + 1 bytes, size, mtime_ns) of a regular file."""
|
|
251
|
+
path = "/".join(parts)
|
|
252
|
+
fd = _open_folder(root, parts[:-1])
|
|
253
|
+
try:
|
|
254
|
+
file = os.open(parts[-1], FILE, dir_fd=fd)
|
|
255
|
+
except OSError as e:
|
|
256
|
+
if e.errno == errno.ENOENT:
|
|
257
|
+
raise PathError(f"no file {path}", missing=True) from None
|
|
258
|
+
if e.errno == errno.ELOOP:
|
|
259
|
+
raise PathError(f"{path} is a link: links are not followed") from None
|
|
260
|
+
raise
|
|
261
|
+
finally:
|
|
262
|
+
os.close(fd)
|
|
263
|
+
with os.fdopen(file, "rb") as f:
|
|
264
|
+
st = os.fstat(f.fileno())
|
|
265
|
+
if not stat.S_ISREG(st.st_mode):
|
|
266
|
+
raise PathError(f"{path} is not a file")
|
|
267
|
+
return f.read(MAX_READ + 1), st.st_size, st.st_mtime_ns
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ── changes ─────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def record_baseline(root: Path, baseline: Path) -> None:
|
|
274
|
+
"""The folder as the session starts (see above)."""
|
|
275
|
+
try:
|
|
276
|
+
recorded: dict[str, object] = {"files": _walk(root), "commit": git_head(root)}
|
|
277
|
+
except TooManyFilesError:
|
|
278
|
+
recorded = {"notFollowed": f"its folder has more than {MAX_FILES} files"}
|
|
279
|
+
write_private(baseline, json.dumps(recorded))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def changes(root: Path, baseline: Path, since: datetime) -> SessionChanges:
|
|
283
|
+
recorded = _load(baseline)
|
|
284
|
+
git = bool(recorded.get("commit"))
|
|
285
|
+
if "files" not in recorded:
|
|
286
|
+
reason = recorded.get("notFollowed", "its folder was not recorded when it started")
|
|
287
|
+
return SessionChanges(since=since, git=git, not_followed=reason)
|
|
288
|
+
try:
|
|
289
|
+
now = _walk(root)
|
|
290
|
+
except TooManyFilesError:
|
|
291
|
+
reason = f"its folder has more than {MAX_FILES} files"
|
|
292
|
+
return SessionChanges(since=since, git=git, not_followed=reason)
|
|
293
|
+
before: Stats = recorded["files"]
|
|
294
|
+
found = [
|
|
295
|
+
SessionFileChange(
|
|
296
|
+
path=path,
|
|
297
|
+
change="added" if path not in before else "modified",
|
|
298
|
+
at=datetime.fromtimestamp(stats[1] / 1e9, UTC),
|
|
299
|
+
)
|
|
300
|
+
for path, stats in now.items()
|
|
301
|
+
if before.get(path) != stats
|
|
302
|
+
]
|
|
303
|
+
found.sort(key=lambda c: c.at or since, reverse=True)
|
|
304
|
+
deleted = [SessionFileChange(path=p, change="deleted") for p in sorted(before) if p not in now]
|
|
305
|
+
return SessionChanges(since=since, git=git, files=found + deleted)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def find(root: Path, query: str, limit: int = 20) -> SessionFound:
|
|
309
|
+
"""The files whose path holds the query's letters in order, case aside: those whose name
|
|
310
|
+
starts with it first, then whose name, then whose path holds it, then the others; the shorter
|
|
311
|
+
path first. No query: the most recently modified. Ignored files and `.git` are not searched."""
|
|
312
|
+
try:
|
|
313
|
+
files = _walk(root)
|
|
314
|
+
except TooManyFilesError:
|
|
315
|
+
return SessionFound(paths=[], not_followed=f"its folder has more than {MAX_FILES} files")
|
|
316
|
+
q = query.lower()
|
|
317
|
+
if not q:
|
|
318
|
+
recent = sorted(files, key=lambda p: files[p][1], reverse=True)
|
|
319
|
+
return SessionFound(paths=recent[:limit])
|
|
320
|
+
ranked = [(rank, len(p), p) for p in files if (rank := _rank(p.lower(), q)) is not None]
|
|
321
|
+
return SessionFound(paths=[p for _, _, p in sorted(ranked)[:limit]])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _rank(path: str, q: str) -> int | None:
|
|
325
|
+
name = path.rsplit("/", 1)[-1]
|
|
326
|
+
if name.startswith(q):
|
|
327
|
+
return 0
|
|
328
|
+
if q in name:
|
|
329
|
+
return 1
|
|
330
|
+
if q in path:
|
|
331
|
+
return 2
|
|
332
|
+
letters = iter(path)
|
|
333
|
+
return 3 if all(c in letters for c in q) else None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _load(baseline: Path) -> dict:
|
|
337
|
+
try:
|
|
338
|
+
return json.loads(baseline.read_text())
|
|
339
|
+
except FileNotFoundError:
|
|
340
|
+
return {}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _walk(root: Path) -> Stats:
|
|
344
|
+
"""Every regular file and link (not followed) of the folder, but ignored ones and `.git`."""
|
|
345
|
+
found: Stats = {}
|
|
346
|
+
exclude = _rules(root, [".git", "info"], "exclude")
|
|
347
|
+
pending: list[tuple[list[str], Chain]] = [([], [("", exclude)] if exclude else [])]
|
|
348
|
+
while pending:
|
|
349
|
+
parts, chain = pending.pop()
|
|
350
|
+
try:
|
|
351
|
+
fd = _open_folder(root, parts)
|
|
352
|
+
except PathError: # replaced meanwhile
|
|
353
|
+
continue
|
|
354
|
+
try:
|
|
355
|
+
if own := _rules(root, parts, ".gitignore"):
|
|
356
|
+
chain = [("/".join(parts), own), *chain]
|
|
357
|
+
with os.scandir(fd) as entries:
|
|
358
|
+
for entry in entries:
|
|
359
|
+
if entry.name == ".git":
|
|
360
|
+
continue
|
|
361
|
+
path = "/".join([*parts, entry.name])
|
|
362
|
+
try:
|
|
363
|
+
st = entry.stat(follow_symlinks=False)
|
|
364
|
+
except FileNotFoundError:
|
|
365
|
+
continue
|
|
366
|
+
if stat.S_ISDIR(st.st_mode):
|
|
367
|
+
if not _ignored(chain, path + "/"):
|
|
368
|
+
pending.append(([*parts, entry.name], chain))
|
|
369
|
+
elif _followed(st.st_mode) and not _ignored(chain, path):
|
|
370
|
+
found[path] = [st.st_size, st.st_mtime_ns]
|
|
371
|
+
if len(found) > MAX_FILES:
|
|
372
|
+
raise TooManyFilesError
|
|
373
|
+
finally:
|
|
374
|
+
os.close(fd)
|
|
375
|
+
return found
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _followed(mode: int) -> bool:
|
|
379
|
+
return stat.S_ISREG(mode) or stat.S_ISLNK(mode)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _rules(root: Path, parts: list[str], name: str) -> IgnoreFilter | None:
|
|
383
|
+
try:
|
|
384
|
+
data, size, _ = _read_file(root, [*parts, name])
|
|
385
|
+
except (PathError, OSError):
|
|
386
|
+
return None
|
|
387
|
+
if size > MAX_READ:
|
|
388
|
+
return None
|
|
389
|
+
return IgnoreFilter(data.splitlines())
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _ignored(chain: Chain, path: str) -> bool:
|
|
393
|
+
"""git's rules: the deepest `.gitignore` that has a say decides."""
|
|
394
|
+
for base, rules in chain:
|
|
395
|
+
verdict = rules.is_ignored(path[len(base) + 1 :] if base else path)
|
|
396
|
+
if verdict is not None:
|
|
397
|
+
return verdict
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ── git, in a child process ─────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def git_head(root: Path) -> str | None:
|
|
405
|
+
"""The commit the folder's repository is at; None outside a repository or before a
|
|
406
|
+
commit."""
|
|
407
|
+
if not (root / ".git").is_dir():
|
|
408
|
+
return None
|
|
409
|
+
out = _git_read("head", str(root))
|
|
410
|
+
return out.decode() if out else None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def git_text(root: Path, commit: str, path: str) -> str | None:
|
|
414
|
+
"""`path`'s text at `commit`; None when it was not there, or not text."""
|
|
415
|
+
out = _git_read("show", str(root), commit, path)
|
|
416
|
+
return _text(out) if out is not None and len(out) <= MAX_READ else None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _git_read(*args: str) -> bytes | None:
|
|
420
|
+
try:
|
|
421
|
+
done = subprocess.run(
|
|
422
|
+
[sys.executable, "-m", "handler_control.git_read", *args],
|
|
423
|
+
capture_output=True,
|
|
424
|
+
timeout=GIT_TIMEOUT,
|
|
425
|
+
check=False,
|
|
426
|
+
)
|
|
427
|
+
except subprocess.TimeoutExpired:
|
|
428
|
+
return None
|
|
429
|
+
return done.stdout if done.returncode == 0 else None
|