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 +8 -0
- hashloom/api.py +274 -0
- hashloom/cache_server.py +154 -0
- hashloom/cli.py +108 -0
- hashloom/config.py +135 -0
- hashloom/contract.py +212 -0
- hashloom/errors.py +26 -0
- hashloom/implhash.py +100 -0
- hashloom/indexer.py +96 -0
- hashloom/langs/__init__.py +65 -0
- hashloom/langs/go.py +173 -0
- hashloom/langs/gohash/go.mod +3 -0
- hashloom/langs/gohash/main.go +108 -0
- hashloom/langs/hashloom-init.gradle +9 -0
- hashloom/langs/java.py +294 -0
- hashloom/langs/javahash/JavaHash.java +162 -0
- hashloom/langs/python.py +56 -0
- hashloom/langs/tshash/main.js +189 -0
- hashloom/langs/typescript.py +269 -0
- hashloom/project.py +144 -0
- hashloom/py.typed +0 -0
- hashloom/remote.py +104 -0
- hashloom/server.py +91 -0
- hashloom/shared.py +65 -0
- hashloom/store.py +277 -0
- hashloom/tokens.py +36 -0
- hashloom/verify.py +149 -0
- hashloom-0.4.0.dist-info/METADATA +246 -0
- hashloom-0.4.0.dist-info/RECORD +32 -0
- hashloom-0.4.0.dist-info/WHEEL +4 -0
- hashloom-0.4.0.dist-info/entry_points.txt +3 -0
- hashloom-0.4.0.dist-info/licenses/LICENSE +202 -0
hashloom/__init__.py
ADDED
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
|
hashloom/cache_server.py
ADDED
|
@@ -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}
|