hashloom 0.4.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.
hashloom/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Hashloom treats software units as content-addressed contracts rather than
2
+ files: contracts with a hash-keyed verification cache for spec-driven agent
3
+ loops, exposed over MCP.
4
+
5
+ Contracts are warp. Code is weft.
6
+ """
7
+
8
+ __version__ = "0.4.0"
hashloom/api.py ADDED
@@ -0,0 +1,274 @@
1
+ """The five tool implementations, as plain functions over (root, store).
2
+
3
+ server.py exposes these over MCP; the CLI and benchmark call them directly.
4
+ Every function either returns a JSON-able dict or raises HashloomError.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from .config import resolve_python
14
+ from .contract import contract_hash, diff_contracts, parse_contract
15
+ from .errors import HashloomError, unknown_name
16
+ from .langs import adapter_for
17
+ from .project import atomic_write_text, case_collision, contract_lock, safe_contract_path
18
+ from .store import Store
19
+ from .verify import clear_pycache, verification_key, verify_one
20
+
21
+
22
+ # responses carry 12-hex-char hashes — plenty to compare against, a quarter of
23
+ # the tokens; full hashes never leave the store/verification keys
24
+ SHORT = 12
25
+
26
+
27
+ def _short(h: str) -> str:
28
+ return h[:SHORT]
29
+
30
+
31
+ def _load(store: Store, name: str) -> tuple[dict, str]:
32
+ row = store.get_contract(name)
33
+ if row is None:
34
+ raise unknown_name("unknown_contract", name, store.contract_names())
35
+ return yaml.safe_load(row["yaml"]), row["hash"]
36
+
37
+
38
+ def _is_inferred(store: Store, name: str) -> bool:
39
+ """A contract is inferred (machine-derived, not human-vetted) only when it
40
+ says so; absent status = confirmed, so 0.1.0 contracts keep full authority."""
41
+ data, _ = _load(store, name)
42
+ return data.get("status") == "inferred"
43
+
44
+
45
+ def get_contract(root: Path, store: Store, name: str) -> dict:
46
+ """The ~300-token context packet: contract + hash + dep signatures + callers."""
47
+ data, chash = _load(store, name)
48
+ deps = []
49
+ for dep in data.get("deps", []):
50
+ dep_data, dep_hash = _load(store, dep)
51
+ entry = {"name": dep, "signature": dep_data["signature"], "hash": _short(dep_hash)}
52
+ if dep_data.get("status") == "inferred":
53
+ entry["inferred"] = True
54
+ deps.append(entry)
55
+ return {
56
+ "name": name,
57
+ "hash": _short(chash),
58
+ # name and deps would duplicate the envelope — the deps array below
59
+ # carries them with signatures attached
60
+ "contract": {k: v for k, v in data.items() if k not in ("name", "deps")},
61
+ "deps": deps,
62
+ "callers": store.dependents_of(name),
63
+ }
64
+
65
+
66
+ def put_contract(root: Path, store: Store, name: str, yaml_text: str) -> dict:
67
+ """Validate, write to contracts/, reindex the one unit, invalidate dependents."""
68
+ data = parse_contract(yaml_text, expect_name=name)
69
+ known = set(store.contract_names()) | {name}
70
+ for dep in data.get("deps", []):
71
+ if dep == name:
72
+ raise HashloomError("invalid_shape", f"'{name}' cannot depend on itself", contract=name)
73
+ if dep not in known:
74
+ raise unknown_name("unknown_dep", dep, sorted(known - {name}), contract=name)
75
+
76
+ new_hash = contract_hash(data)
77
+ target = safe_contract_path(root, name) # also refuses a name that escapes contracts/
78
+ collision = case_collision(target)
79
+ if collision is not None:
80
+ raise HashloomError(
81
+ "name_collision",
82
+ f"'{name}' collides with existing contract file '{collision}' on a case-insensitive filesystem",
83
+ contract=name,
84
+ )
85
+ # lock the name so concurrent put_contract on it can't interleave the file
86
+ # write and the store update and leave the two disagreeing
87
+ with contract_lock(root, name):
88
+ old = store.get_contract(name)
89
+ changed = old is None or old["hash"] != new_hash
90
+ # what changed vs the previously stored contract: empty for a new unit or
91
+ # a cosmetic-only edit, using the same normalisation as the hash
92
+ diff = diff_contracts(yaml.safe_load(old["yaml"]), data) if old is not None else {}
93
+
94
+ atomic_write_text(target, yaml_text)
95
+ store.upsert_contract(name, new_hash, yaml_text)
96
+ store.set_deps(name, data.get("deps", []))
97
+ if "impl" in data:
98
+ adapter = adapter_for(data["impl"])
99
+ try:
100
+ ihash = adapter.impl_hash(root, data["impl"], contract=name)
101
+ except HashloomError:
102
+ ihash = None
103
+ store.upsert_impl(name, ihash, data["impl"].partition("::")[0])
104
+
105
+ invalidated: list[str] = []
106
+ if changed:
107
+ invalidated = store.dependents_of(name, transitive=True)
108
+ store.mark_stale([name, *invalidated])
109
+ result = {"name": name, "hash": _short(new_hash), "changed": changed, "invalidated": invalidated}
110
+ # advisory provenance flags — present only when something is actually inferred
111
+ if data.get("status") == "inferred":
112
+ result["inferred"] = True
113
+ invalidated_inferred = [n for n in invalidated if _is_inferred(store, n)]
114
+ if invalidated_inferred:
115
+ result["invalidated_inferred"] = invalidated_inferred
116
+ if diff:
117
+ result["diff"] = diff
118
+ return result
119
+
120
+
121
+ def get_dependents(root: Path, store: Store, name: str, transitive: bool = False) -> dict:
122
+ """Blast-radius query: who is invalidated if this contract changes."""
123
+ if store.get_contract(name) is None:
124
+ raise unknown_name("unknown_contract", name, store.contract_names())
125
+ hashes = store.contract_hashes()
126
+ names = store.dependents_of(name, transitive=transitive)
127
+ dependents = []
128
+ for n in names:
129
+ d = {"name": n, "hash": _short(hashes[n])}
130
+ if _is_inferred(store, n):
131
+ d["inferred"] = True # advisory: this dependent's contract is unvetted
132
+ dependents.append(d)
133
+ return {"name": name, "transitive": transitive, "dependents": dependents}
134
+
135
+
136
+ def _radius(store: Store, names: list[str]) -> list[str]:
137
+ """Each seed plus its transitive dependents, deduped in encounter order.
138
+
139
+ Spec-only units (no impl) are dropped — nothing to verify, the same rule
140
+ `status` uses for dirtiness. Unknown names are kept so they surface as
141
+ error results and fail the gate rather than vanishing silently.
142
+ """
143
+ out: list[str] = []
144
+ seen: set[str] = set()
145
+ for name in names:
146
+ for n in (name, *store.dependents_of(name, transitive=True)):
147
+ if n in seen:
148
+ continue
149
+ seen.add(n)
150
+ row = store.get_contract(n)
151
+ if row is not None and "impl" not in yaml.safe_load(row["yaml"]):
152
+ continue
153
+ out.append(n)
154
+ return out
155
+
156
+
157
+ def verify(
158
+ root: Path,
159
+ store: Store,
160
+ names: str | list[str],
161
+ python: str | None = None,
162
+ timeout: int | float | None = None,
163
+ pycache_trust: bool = True,
164
+ radius: bool = False,
165
+ ) -> dict:
166
+ """Per-unit cached-pass / pass / fail. Runs pytest only on cache misses.
167
+
168
+ `radius=True` widens each name to its full blast radius (itself plus every
169
+ transitive dependent), and the top-level `ok` is the hard pass/fail bit a
170
+ CI step or agent loop can block on.
171
+ """
172
+ if isinstance(names, str):
173
+ names = [names]
174
+ if radius:
175
+ names = _radius(store, names)
176
+ if not pycache_trust:
177
+ clear_pycache(root) # once per batch, before any pytest run
178
+ results = []
179
+ for name in names:
180
+ try:
181
+ r = verify_one(root, store, name, python=python, timeout=timeout)
182
+ r.pop("key") # internal cache key — pure token weight to an agent
183
+ if not r["summary"]:
184
+ r.pop("summary")
185
+ # computed here, not in verify_one: outside the cache key, so the flag
186
+ # reflects *current* status even when the verdict is a cached-pass
187
+ inferred = [n for n in (name, *store.transitive_deps(name)) if _is_inferred(store, n)]
188
+ if inferred:
189
+ r["inferred"] = inferred
190
+ results.append(r)
191
+ except HashloomError as e:
192
+ results.append({"name": name, "status": "error", **e.to_dict()})
193
+ # the gate bit: true iff every unit is green (vacuously true for an empty
194
+ # radius — nothing invalidated means nothing to block on)
195
+ ok = all(r["status"] in ("pass", "cached-pass") for r in results)
196
+ return {"ok": ok, "results": results}
197
+
198
+
199
+ def cached_impl_hash(root: Path, store: Store, impl: str, contract: str | None = None) -> str:
200
+ """`impl_hash` memoised by (impl ref, file mtime_ns, size).
201
+
202
+ `status` computes an impl hash for every contract; re-reading and re-parsing
203
+ each file on every call is the O(n) cost in ISSUES #10. `verify` never uses
204
+ this — a stale *verification* is unsafe, so it always hashes fresh — but
205
+ `status` is informational, and mtime_ns makes a same-size-same-instant miss
206
+ vanishingly unlikely.
207
+ """
208
+ adapter = adapter_for(impl)
209
+ path = root / impl.partition("::")[0]
210
+ try:
211
+ st = path.stat()
212
+ except OSError:
213
+ return adapter.impl_hash(root, impl, contract=contract) # absent file: let it raise cleanly
214
+ cached = store.get_cached_impl_hash(impl)
215
+ if cached is not None and cached["mtime_ns"] == st.st_mtime_ns and cached["size"] == st.st_size:
216
+ return cached["impl_hash"]
217
+ h = adapter.impl_hash(root, impl, contract=contract)
218
+ store.put_cached_impl_hash(impl, st.st_mtime_ns, st.st_size, h)
219
+ return h
220
+
221
+
222
+ def status(root: Path, store: Store) -> dict:
223
+ """Dirty contracts, stale verifications, cache hit-rate, token counters."""
224
+ dirty: list[str] = []
225
+ inferred: list[str] = []
226
+ for name in store.contract_names():
227
+ data, _ = _load(store, name)
228
+ if data.get("status") == "inferred":
229
+ inferred.append(name) # the human review queue; spec-only units count too
230
+ if "impl" not in data:
231
+ continue # spec-only contracts don't need verification
232
+ try:
233
+ ihash = cached_impl_hash(root, store, data["impl"], contract=name)
234
+ except HashloomError:
235
+ dirty.append(name)
236
+ continue
237
+ adapter = adapter_for(data["impl"])
238
+ thash = adapter.test_source_hash(root, data.get("tests", []))
239
+ try:
240
+ tid = adapter.toolchain_identity(root) # same key component verify stored
241
+ except HashloomError:
242
+ dirty.append(name) # can't resolve the toolchain -> can't claim a green
243
+ continue
244
+ v = store.get_verification(verification_key(store, name, ihash, thash, tid))
245
+ if v is None or v["status"] != "pass" or v["stale"]:
246
+ dirty.append(name)
247
+
248
+ c = store.counters()
249
+ hits, misses = c.get("cache_hits", 0), c.get("cache_misses", 0)
250
+ total = hits + misses
251
+ br, bru = c.get("bust_rechecks", 0), c.get("bust_rechecks_unchanged", 0)
252
+ tokens_by_tool = {k.removeprefix("tokens."): v for k, v in c.items() if k.startswith("tokens.")}
253
+ out = {
254
+ "contracts": len(store.contract_names()),
255
+ "dirty": dirty,
256
+ "stale_verifications": store.stale_verifications(),
257
+ "python": resolve_python(root), # which interpreter verify shells pytest to
258
+ "cache": {
259
+ "hits": hits,
260
+ "misses": misses,
261
+ "hit_rate": round(hits / total, 3) if total else None,
262
+ },
263
+ # re-verifications triggered by a contract-hash bust, and how many changed
264
+ # no verdict (wasted work); wasted_rate falls once invariants leave the hash
265
+ "rechecks": {
266
+ "after_change": br,
267
+ "verdict_unchanged": bru,
268
+ "wasted_rate": round(bru / br, 3) if br else None,
269
+ },
270
+ "tokens": {"total": sum(tokens_by_tool.values()), "by_tool": tokens_by_tool},
271
+ }
272
+ if inferred: # key absent on confirmed-only projects — zero token cost
273
+ out["inferred"] = inferred
274
+ return out
@@ -0,0 +1,154 @@
1
+ """The hashloom shared verification-cache server.
2
+
3
+ A minimal stdlib HTTP server wrapping one `SqliteStore`, exposing exactly the four
4
+ team-portable Store operations `LayeredStore` needs -- get/record a verdict,
5
+ get/put a blob -- as a tiny JSON API behind a bearer token. A team points each
6
+ developer's `.hashloom/config.json` `{"shared": {...}}` at one of these, so a unit
7
+ verified green once is served to everyone (see docs/hosted-store.md).
8
+
9
+ It is intentionally NOT a `hashloom` subcommand (the 5-CLI surface is fixed); run it
10
+ as a separate operational process:
11
+
12
+ python -m hashloom.cache_server --db cache.db --token SECRET [--host H --port P]
13
+
14
+ Single-threaded by design: a `SqliteStore` holds one sqlite connection (not
15
+ thread-safe), and the verdict/blob writes are idempotent upserts, so
16
+ last-writer-wins is fine for the MVP. A future throughput upgrade is
17
+ `ThreadingHTTPServer` + a per-thread or lock-guarded connection; CAS and
18
+ concurrent-writer ordering are deferred (docs/hosted-store.md #4).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import hmac
25
+ import json
26
+ import os
27
+ import sys
28
+ from http.server import BaseHTTPRequestHandler, HTTPServer
29
+ from urllib.parse import unquote
30
+
31
+ from .store import SqliteStore
32
+
33
+ DEFAULT_PORT = 8770
34
+
35
+
36
+ class CacheServer(HTTPServer):
37
+ """An HTTPServer that holds the store and expected token for the handler."""
38
+
39
+ def __init__(self, addr: tuple[str, int], store: SqliteStore, token: str):
40
+ super().__init__(addr, _Handler)
41
+ self.store = store
42
+ self.token = token
43
+
44
+
45
+ class _Handler(BaseHTTPRequestHandler):
46
+ server_version = "hashloom-cache/1"
47
+
48
+ # -- helpers ------------------------------------------------------------
49
+
50
+ def _send(self, status: int, body: dict | None = None) -> None:
51
+ payload = json.dumps(body).encode("utf-8") if body is not None else b""
52
+ self.send_response(status)
53
+ if payload:
54
+ self.send_header("Content-Type", "application/json")
55
+ self.send_header("Content-Length", str(len(payload)))
56
+ self.end_headers()
57
+ if payload:
58
+ self.wfile.write(payload)
59
+
60
+ def _error(self, status: int, code: str, message: str) -> None:
61
+ self._send(status, {"error": {"code": code, "message": message}})
62
+
63
+ def _authed(self) -> bool:
64
+ header = self.headers.get("Authorization", "")
65
+ prefix = "Bearer "
66
+ if not header.startswith(prefix):
67
+ return False
68
+ return hmac.compare_digest(header[len(prefix):], self.server.token)
69
+
70
+ def _read_body(self) -> dict | None:
71
+ length = int(self.headers.get("Content-Length") or 0)
72
+ if length <= 0:
73
+ return None
74
+ try:
75
+ return json.loads(self.rfile.read(length).decode("utf-8"))
76
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
77
+ return None
78
+
79
+ def log_message(self, *args) -> None: # keep the cache server quiet
80
+ pass
81
+
82
+ # -- routes -------------------------------------------------------------
83
+
84
+ def do_GET(self) -> None:
85
+ if not self._authed():
86
+ return self._error(401, "unauthorized", "missing or invalid bearer token")
87
+ store = self.server.store
88
+ if self.path.startswith("/verification/"):
89
+ row = store.get_verification(unquote(self.path[len("/verification/"):]))
90
+ return self._send(200, row) if row is not None else self._error(404, "not_found", "no such verdict")
91
+ if self.path.startswith("/blob/"):
92
+ content = store.get_blob(unquote(self.path[len("/blob/"):]))
93
+ return self._send(200, {"content": content}) if content is not None else self._error(404, "not_found", "no such blob")
94
+ return self._error(404, "not_found", "unknown route")
95
+
96
+ def do_POST(self) -> None:
97
+ if not self._authed():
98
+ return self._error(401, "unauthorized", "missing or invalid bearer token")
99
+ store = self.server.store
100
+ body = self._read_body()
101
+ if not isinstance(body, dict):
102
+ return self._error(400, "bad_request", "expected a JSON object body")
103
+ if self.path == "/verification":
104
+ try:
105
+ key, name, status = body["key"], body["contract_name"], body["status"]
106
+ except (KeyError, TypeError):
107
+ return self._error(400, "bad_request", "missing key/contract_name/status")
108
+ if status != "pass":
109
+ return self._error(400, "only_greens", "the shared cache stores passes only")
110
+ store.record_verification(key, name, status, body.get("summary", ""))
111
+ return self._send(204)
112
+ if self.path == "/blob":
113
+ content = body.get("content")
114
+ if not isinstance(content, str):
115
+ return self._error(400, "bad_request", "blob content must be a string")
116
+ return self._send(200, {"hash": store.put_blob(content)})
117
+ return self._error(404, "not_found", "unknown route")
118
+
119
+
120
+ def serve(db: str, token: str, host: str = "127.0.0.1", port: int = DEFAULT_PORT) -> None:
121
+ store = SqliteStore(db, check_same_thread=False) # used on the serve_forever thread
122
+ httpd = CacheServer((host, port), store, token)
123
+ print(f"hashloom cache server on http://{host}:{httpd.server_address[1]} (db: {db})", file=sys.stderr)
124
+ try:
125
+ httpd.serve_forever()
126
+ except KeyboardInterrupt:
127
+ pass
128
+ finally:
129
+ httpd.server_close()
130
+ store.close()
131
+
132
+
133
+ def main(argv: list[str] | None = None) -> int:
134
+ p = argparse.ArgumentParser(
135
+ prog="python -m hashloom.cache_server",
136
+ description="hashloom shared verification-cache server (operational, not a hashloom subcommand)",
137
+ )
138
+ p.add_argument("--db", default="cache.db", help="sqlite file for the shared cache (default: cache.db)")
139
+ p.add_argument("--host", default="127.0.0.1", help="bind host (default: 127.0.0.1; use 0.0.0.0 to share)")
140
+ p.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"bind port (default: {DEFAULT_PORT})")
141
+ p.add_argument(
142
+ "--token",
143
+ default=os.environ.get("HASHLOOM_CACHE_TOKEN"),
144
+ help="bearer token clients must present (or set HASHLOOM_CACHE_TOKEN)",
145
+ )
146
+ args = p.parse_args(argv)
147
+ if not args.token:
148
+ p.error("a --token (or HASHLOOM_CACHE_TOKEN env var) is required; refusing to run an unauthenticated cache")
149
+ serve(args.db, args.token, host=args.host, port=args.port)
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())
hashloom/cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """CLI: hashloom init · hashloom index · hashloom serve · hashloom status · hashloom verify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .errors import HashloomError
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(prog="hashloom", description="Content-addressed contracts + cached verification over MCP.")
16
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+ sub.add_parser("init", help="create .hashloom/ and contracts/ in the current directory")
19
+ sub.add_parser("index", help="rebuild the store from contracts/")
20
+ serve_parser = sub.add_parser("serve", help="run the MCP server on stdio")
21
+ serve_parser.add_argument(
22
+ "--python",
23
+ metavar="PATH",
24
+ help="interpreter to run pytest with (default: project .venv, else this interpreter)",
25
+ )
26
+ serve_parser.add_argument(
27
+ "--no-pycache-trust",
28
+ action="store_true",
29
+ help="clear project __pycache__ before each verify run (don't trust stale bytecode)",
30
+ )
31
+ sub.add_parser("status", help="dirty contracts, stale verifications, cache hit-rate, token counters")
32
+ verify_parser = sub.add_parser("verify", help="run cached verification for one or more contracts")
33
+ verify_parser.add_argument("names", nargs="+", metavar="NAME", help="contract names to verify")
34
+ verify_parser.add_argument(
35
+ "--radius",
36
+ action="store_true",
37
+ help="also verify every transitive dependent of each NAME (the blast radius)",
38
+ )
39
+ verify_parser.add_argument(
40
+ "--python",
41
+ metavar="PATH",
42
+ help="interpreter to run pytest with (default: project .venv, else this interpreter)",
43
+ )
44
+ verify_parser.add_argument(
45
+ "--no-pycache-trust",
46
+ action="store_true",
47
+ help="clear project __pycache__ before running (don't trust stale bytecode)",
48
+ )
49
+ args = parser.parse_args(argv)
50
+
51
+ try:
52
+ if args.command == "init":
53
+ from .project import init_project
54
+
55
+ created = init_project(Path.cwd())
56
+ print("\n".join(f"created {p}" for p in created) if created else "already initialised")
57
+ return 0
58
+
59
+ from .project import find_root
60
+
61
+ root = Path.cwd() if args.command == "init" else find_root()
62
+
63
+ if args.command == "serve":
64
+ from .server import serve
65
+
66
+ serve(root, python=args.python, pycache_trust=False if args.no_pycache_trust else None)
67
+ return 0
68
+
69
+ from .remote import build_store
70
+
71
+ store = build_store(root) # local, or LayeredStore over a shared cache if configured
72
+ if args.command == "index":
73
+ from .indexer import index
74
+
75
+ print(json.dumps(index(root, store), indent=2))
76
+ elif args.command == "status":
77
+ from . import api
78
+
79
+ print(json.dumps(api.status(root, store), indent=2))
80
+ elif args.command == "verify":
81
+ from . import api
82
+ from .config import resolve_pycache_trust
83
+
84
+ trust = resolve_pycache_trust(root, override=False if args.no_pycache_trust else None)
85
+ result = api.verify(root, store, args.names, python=args.python, pycache_trust=trust, radius=args.radius)
86
+ print(json.dumps(result, indent=2))
87
+ # the CI/pre-commit gate: exit mirrors the response's `ok` bit
88
+ if not result["ok"]:
89
+ return 1
90
+ return 0
91
+ except HashloomError as e:
92
+ print(json.dumps(e.to_dict(), indent=2), file=sys.stderr)
93
+ return 1
94
+
95
+
96
+ def serve_main(argv: list[str] | None = None) -> int:
97
+ """`hashloom-mcp` console script: `hashloom serve` as a single entry point.
98
+
99
+ The Docker image's ENTRYPOINT and a direct uvx target. Registry-listed
100
+ launches use `uvx hashloom serve` via server.json's packageArguments, since
101
+ the bare `hashloom` script is the CLI. A launch shim, not a sixth CLI
102
+ command.
103
+ """
104
+ return main(["serve", *(sys.argv[1:] if argv is None else argv)])
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
hashloom/config.py ADDED
@@ -0,0 +1,135 @@
1
+ """Interpreter resolution for the verify pytest runner.
2
+
3
+ `verify` shells pytest out to a python interpreter; which one is resolved by
4
+ precedence (most explicit first):
5
+
6
+ 1. an explicit override — the ``hashloom serve --python PATH`` flag
7
+ 2. ``.hashloom/config.json`` -> ``{"python": "..."}``
8
+ 3. an auto-detected project venv (``<root>/.venv/bin/python``, ...)
9
+ 4. ``sys.executable`` — the interpreter running hashloom itself (the v0.1 default)
10
+
11
+ This lets hashloom, even installed globally, run a target project's tests against
12
+ that project's own venv — without being installed into it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from .errors import HashloomError
23
+ from .project import HASHLOOM_DIR
24
+
25
+ CONFIG_NAME = "config.json"
26
+ DEFAULT_TIMEOUT = 300 # seconds for a single pytest run; override via verify_timeout
27
+
28
+ # checked in order; first one that exists wins. The .hashloom/config.json file is
29
+ # the future home for other project settings too (e.g. the verify timeout).
30
+ _VENV_CANDIDATES = (
31
+ ".venv/bin/python",
32
+ ".venv/bin/python3",
33
+ "venv/bin/python",
34
+ "venv/bin/python3",
35
+ ".venv/Scripts/python.exe", # Windows
36
+ )
37
+
38
+
39
+ def config_path(root: Path) -> Path:
40
+ return root / HASHLOOM_DIR / CONFIG_NAME
41
+
42
+
43
+ def load_config(root: Path) -> dict:
44
+ """Read .hashloom/config.json. Returns {} if absent; raises on malformed JSON."""
45
+ path = config_path(root)
46
+ if not path.exists():
47
+ return {}
48
+ try:
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ except (json.JSONDecodeError, OSError) as e:
51
+ raise HashloomError("bad_config", f"could not read {path.name}: {e}")
52
+ if not isinstance(data, dict):
53
+ raise HashloomError("bad_config", f"{path.name} must be a JSON object")
54
+ return data
55
+
56
+
57
+ def _is_executable(path: Path) -> bool:
58
+ return path.is_file() and os.access(path, os.X_OK)
59
+
60
+
61
+ def _check(path: Path, source: str, raw: str) -> str:
62
+ """Validate an explicitly-configured interpreter — fail clean if unusable."""
63
+ if not _is_executable(path):
64
+ raise HashloomError("bad_python", f"{source} '{raw}' is not an executable interpreter")
65
+ return str(path)
66
+
67
+
68
+ def resolve_python(root: Path, override: str | None = None) -> str:
69
+ """Resolve the interpreter verify should run pytest with. See module docstring."""
70
+ if override is not None:
71
+ return _check(Path(override).expanduser(), "--python", override)
72
+
73
+ configured = load_config(root).get("python")
74
+ if configured:
75
+ p = Path(configured).expanduser()
76
+ if not p.is_absolute():
77
+ p = root / p # config paths are project-relative
78
+ return _check(p, ".hashloom/config.json python", configured)
79
+
80
+ for rel in _VENV_CANDIDATES:
81
+ cand = root / rel
82
+ if _is_executable(cand):
83
+ return str(cand)
84
+
85
+ return sys.executable
86
+
87
+
88
+ def resolve_timeout(root: Path) -> int | float:
89
+ """Per-run pytest timeout in seconds (.hashloom/config.json verify_timeout)."""
90
+ value = load_config(root).get("verify_timeout")
91
+ if value is None:
92
+ return DEFAULT_TIMEOUT
93
+ # bool is an int subclass — reject it explicitly so `true` isn't read as 1
94
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
95
+ raise HashloomError("bad_config", f"verify_timeout must be a positive number, got {value!r}")
96
+ return value
97
+
98
+
99
+ def resolve_pycache_trust(root: Path, override: bool | None = None) -> bool:
100
+ """Whether verify may trust pre-existing __pycache__ (default True).
101
+
102
+ The runner already passes -B / PYTHONDONTWRITEBYTECODE so its own runs never
103
+ write bytecode, but a stale user-written .pyc that happens to share the
104
+ source's size and mtime-second could still be loaded. Set `pycache_trust:
105
+ false` in .hashloom/config.json (or pass `--no-pycache-trust`) to clear the
106
+ project's bytecode caches before each verify run instead.
107
+ """
108
+ if override is not None:
109
+ return override
110
+ value = load_config(root).get("pycache_trust")
111
+ if value is None:
112
+ return True
113
+ if not isinstance(value, bool):
114
+ raise HashloomError("bad_config", f"pycache_trust must be true or false, got {value!r}")
115
+ return value
116
+
117
+
118
+ def resolve_shared_store(root: Path) -> dict | None:
119
+ """Config for a shared/remote verification cache, or None if not configured.
120
+
121
+ `.hashloom/config.json` -> `{"shared": {"url": "...", "token": "..."}}` selects a
122
+ remote cache the local store reads/writes through (see remote.py / shared.py).
123
+ Validated here so a malformed block fails clean; absent means local-only.
124
+ """
125
+ cfg = load_config(root).get("shared")
126
+ if cfg is None:
127
+ return None
128
+ if not isinstance(cfg, dict):
129
+ raise HashloomError("bad_config", "shared must be a JSON object")
130
+ url, token = cfg.get("url"), cfg.get("token")
131
+ if not isinstance(url, str) or not url:
132
+ raise HashloomError("bad_config", "shared.url must be a non-empty string")
133
+ if not isinstance(token, str) or not token:
134
+ raise HashloomError("bad_config", "shared.token must be a non-empty string")
135
+ return {"url": url, "token": token}