/api/files is the library's home: list a user's done files (by folder, searched, sorted, or the ones shared with them), rename / move / keep / delete a file, make, rename and delete own folders (a deleted folder's files go back to their source folders), and set how long any folder keeps a file — done files shift by the difference, never closer than the GC's warning window. An upload can name its folder (an own folder or Uploads), checked before the body is read and again before the file lands; it is hashed while it streams, and any file can be hashed on first ask. The download says the hash (Repr-Digest) once it is known; a new file at an asset's path forgets it. Uploads, deletes, keeps, moves and folder changes are audited (ids only). The fileless "expired" rows of the old retention are deleted by migration 0103: an expired file now leaves the library. The shared route helpers moved to routes/files.py; the old /api/downloads library routes delegate to the new ones until the client moves over.
119 lines
5.0 KiB
Python
119 lines
5.0 KiB
Python
"""Two rows of CLAUDE.md's canonical-patterns table, held in code rather than prose.
|
|
|
|
A rule stated only in a comment drifts: this one had — 63 mutation responses without `ok`, and
|
|
twelve route helpers that committed on their own, among them an approval whose decision, activation
|
|
and token landed in three separate transactions. The debt burn of 2026-09 fixed the drift; these keep
|
|
it fixed.
|
|
"""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
ROUTES = Path(__file__).parent.parent / "app" / "routes"
|
|
MUTATING = {"post", "put", "patch", "delete"}
|
|
|
|
# A route helper that commits where it stands, and why the commit has to be there rather than in
|
|
# the handler. Anything else flushes and leaves the commit to its handler.
|
|
COMMITTING_HELPERS = {
|
|
"auth.py:_issue_token": "the token must be durable before the link that carries it is mailed",
|
|
"auth.py:_consume_token": "burning a single-use token is its own unit: a later failure must "
|
|
"not hand the token back",
|
|
"auth.py:_complete_link": "the tail of the Google callback route, which answers with its result",
|
|
"auth.py:_register_account": "a background task with its own session, i.e. a job wrapper",
|
|
"search.py:_ingest_candidates": "stages around YouTube calls: the stubs must survive an enrich "
|
|
"that fails",
|
|
"search.py:_classify_shorts": "the last stage of that ingest, after its probes",
|
|
"auth.py:upsert_pending_invite": "commits only when it writes — the contract its two "
|
|
"callers rely on (`_register_account` persists its own flushed user because a no-op here "
|
|
"commits nothing)",
|
|
"debug_sessions.py:_end_send": "gives back the claim a send took, in a `finally`: it runs when "
|
|
"the handler is already unwinding and has no commit left to reach",
|
|
}
|
|
|
|
|
|
def _modules():
|
|
for path in sorted(ROUTES.rglob("*.py")):
|
|
yield path.relative_to(ROUTES).as_posix(), ast.parse(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
|
|
# The calls a mutation may answer with in place of an acknowledgement: each describes the resource
|
|
# it wrote (or is the logout's cookie-clearing response). A domain function's REPORT dict is not one
|
|
# of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`.
|
|
SERIALIZERS = {
|
|
"JSONResponse",
|
|
"_collection_card",
|
|
"_playlist_card",
|
|
"_serialize",
|
|
"_serialize_folder",
|
|
"serialize",
|
|
"serialize_job",
|
|
"_serialize_profile",
|
|
"_serialize_user",
|
|
"_summary",
|
|
"admin_get_quota",
|
|
"describe",
|
|
"owner_view",
|
|
}
|
|
|
|
|
|
def _called(node: ast.Call) -> str:
|
|
f = node.func
|
|
return f.id if isinstance(f, ast.Name) else getattr(f, "attr", "")
|
|
|
|
|
|
def _is_route(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
return any(
|
|
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING | {"get", "websocket"}
|
|
for d in fn.decorator_list
|
|
)
|
|
|
|
|
|
def _is_mutation_route(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
return any(
|
|
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING
|
|
for d in fn.decorator_list
|
|
)
|
|
|
|
|
|
def test_every_mutation_answers_ok():
|
|
"""`{"ok": true, …}` (or `{"deleted": id}`) for every mutation, sync or async — one field a
|
|
client can read the same way everywhere. A serializer's return (SERIALIZERS) is exempt: it
|
|
describes a resource, not an acknowledgement. A dict built elsewhere — returned by name, or
|
|
straight from a domain call — is not: both hid acknowledgements without `ok` from a
|
|
literal-only scan, so such a dict is spread into a literal."""
|
|
missing = []
|
|
for name, tree in _modules():
|
|
for fn in ast.walk(tree):
|
|
if not (isinstance(fn, FUNCTIONS) and _is_mutation_route(fn)):
|
|
continue
|
|
for node in ast.walk(fn):
|
|
if not isinstance(node, ast.Return):
|
|
continue
|
|
if isinstance(node.value, ast.Call) and _called(node.value) in SERIALIZERS:
|
|
continue
|
|
keys = (
|
|
{k.value for k in node.value.keys if isinstance(k, ast.Constant)}
|
|
if isinstance(node.value, ast.Dict)
|
|
else set()
|
|
)
|
|
if not keys & {"ok", "deleted"}:
|
|
missing.append(f"{name}:{node.lineno} {fn.name}")
|
|
assert missing == []
|
|
|
|
|
|
def test_route_helpers_flush_and_leave_the_commit_to_the_handler():
|
|
"""Every function in a route module that is not itself a route is a helper — whatever its name
|
|
(two public ones committed unseen while this looked only at `_`-names) and sync or async."""
|
|
committing = set()
|
|
for name, tree in _modules():
|
|
for fn in ast.walk(tree):
|
|
if isinstance(fn, FUNCTIONS) and not _is_route(fn):
|
|
if any(
|
|
isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "commit"
|
|
for n in ast.walk(fn)
|
|
):
|
|
committing.add(f"{name}:{fn.name}")
|
|
assert committing == set(COMMITTING_HELPERS)
|