Compare commits

...
Author SHA1 Message Date
DeeJanuzandClaude Opus 5.5 1ee438cdc0 hands/rec: bounty rounds can come from the dataset with the board
A board fetched from the dataset could only describe, reorder or retire
bounties, so every new round needed a recorder release. hub.py board now
also fetches the round scripts the board names that the recorder doesn't
ship (bounties/<script>.json beside the board) into BASE/board-rounds/,
keeping each only if session.check_round passes it: a version 1 round of
that name with a scene, known keys and section kinds, no debug, objects,
controllers, requirements or demo files, texts and numbers in limits,
1-30 sets a second, windows within 3 m, a plan that builds and lasts at
most 8 minutes. A shipped script always wins. The session checks a
fetched round again as it starts, and session.json records where its
script came from and its sha256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:17:40 -06:00
6 changed files with 261 additions and 32 deletions

No files matched your search

+1 -1
View File
@@ -400,7 +400,7 @@ On 2026-10-02 a whole session showed "I can't see your hands": after the headset
Short rounds (about 3 min recorded, 4 with reading) of what the hand tracker needs most, so people can help where it counts without a 15-minute session. As in the eye recorder (gaze/rec/DESIGN.md, "Bounty board"), using the shared `recorder/bounties.py`:
- `bounties.json` is the board: `{"schema": 1, "updated", "bounties": [{"id", "title", "blurb", "minutes", "priority", "script"}]}`. A newer copy fetched from the dataset (`hub.py board`, the window's "Get the newest board", into `BASE/board-cache.json`) can only describe, reorder, reprioritise or retire bounties: each one's steps come from `bounties/<script>.json`, shipped with the recorder. A bounty whose script this recorder lacks shows "Needs a newer recorder".
- `bounties.json` is the board: `{"schema": 1, "updated", "bounties": [{"id", "title", "blurb", "minutes", "priority", "script"}]}`. A newer copy fetched from the dataset (`hub.py board`, the window's "Get the newest board", into `BASE/board-cache.json`) can only describe, reorder, reprioritise or retire bounties: each one's steps come from `bounties/<script>.json`, shipped with the recorder, or (since 2026-10-23) fetched with the board from the dataset's own `bounties/<script>.json` when the recorder doesn't ship that name. A shipped script always wins, so a changed round gets a new name. A fetched round is untrusted, as the board is: `session.check_round` keeps it in `BASE/board-rounds/` only if it is a version 1 round of that name with a scene, only the top-level keys a round has, sections of kind prompts or scene, nothing that changes what is recorded or where it goes (no `debug`, which would make it a testing session, no objects, controllers, checklist requirements or demo files), texts and numbers in limits, 1-30 sets a second, windows within 3 m and 2 m across, and a plan that builds (known gestures, windows and parts) and lasts at most 8 minutes. The session checks it again as it starts, and session.json's `script` says `"from": "board"` (or `"recorder"`) and the script's `sha256`. A round that doesn't come or doesn't pass keeps the copy that passed before; one the board no longer lists is deleted. A bounty whose script this recorder has neither way shows "Needs a newer recorder". So a new round needs no recorder release once the recorders out there have this: upload its script beside the board, then the board.
- A round script is a normal script (above) with `"record_hz": 30`: hand size first (the no-hands check), then one scene section, and at most 30 s of plain steps after it when a gesture needs no window (the palm up). At 30 sets a second a round is about 2.5 min recorded, about 2.1 MB a second exported.
- The window opens on the Bounties page: the board by priority, then least done first, then in the board's own order (a fetched board ranks bounties of one priority by listing them; before 2026-10-15 the title broke ties), each with Record (Record again once done), "Get the newest board", and below them the full session and the quick round. Picking a round goes to the consent page if this contributor hasn't agreed to the current version, else straight to the checklist; the checklist's Round section shows the pick (or the full/quick choice) with "Pick another…" back to the board, and hides what a bounty doesn't use (objects within reach, controllers, guidance). Its camera check and Start then run the script (`session.py --bounty ID`; `--board` lists them). When the round is done, the Session page's "Export and upload" queues its export to upload by itself once exported (logged in), or else exports it and opens the Upload page for that export once the export finishes (`exportDone`). session.json gets `"bounty"`, the upload's title and description name it, and Review shows its title by the session. A round that ends `done` (not a dry run) counts in profile.json's `bounties`.
+5 -4
View File
@@ -364,7 +364,8 @@ class Backend(RecorderBackend):
mod = self._runner()
if not mod:
return []
rows = bounties.view(self._board_now(), self.store.profile(), {}, mod.have_bounty_script)
rows = bounties.view(self._board_now(), self.store.profile(), {},
lambda n: mod.have_bounty_script(n, base_dir=self.store.base))
if self.frametopHands:
rows.insert(0, self._debug_row())
for b in rows:
@@ -618,8 +619,8 @@ class Backend(RecorderBackend):
if bounty_id and not entry and not debug:
return "Pick a bounty"
try:
script = mod.load_script(DEBUG_SCRIPT if debug else mod.bounty_script(entry["script"]) if entry
else SCRIPT_PATH)
script = mod.load_script(DEBUG_SCRIPT if debug else
mod.bounty_script(entry["script"], base_dir=self.store.base) if entry else SCRIPT_PATH)
quick = quick and not entry
plan, _ = mod.build_plan(script, dict(checklist), quick=quick)
return mod.plan_summary(script, plan, auto=auto)
@@ -669,7 +670,7 @@ class Backend(RecorderBackend):
checklist["objects"] = [o for o in checklist.get("objects", []) if o in dict(OBJECTS)]
checklist["own_objects"] = [o.strip() for o in checklist.get("own_objects", []) if str(o).strip()]
try:
script = DEBUG_SCRIPT if debug else mod.bounty_script(entry["script"]) if entry else SCRIPT_PATH
script = DEBUG_SCRIPT if debug else mod.bounty_script(entry["script"], base_dir=self.store.base) if entry else SCRIPT_PATH
self._session = mod.Session(self.store.base, self.store.profile(), checklist, lighting, script,
on_status=lambda s: self._statusArrived.emit(dict(s)), auto=auto,
quick=quick and not entry, guidance=self.guidance, bounty=entry,
+39 -4
View File
@@ -32,7 +32,8 @@ bounties' test phase (DESIGN.md "Testing") a session recorded under TESTING.md's
round or a debug session: manifest "consent_kind": "testing", its recordings encrypted) goes to
HF_TEST_DATASET instead (FT_HANDREC_TEST_DATASET overrides it), and its gate is TESTING.md's.
`board` fetches the bounty board (bounties.json) from the dataset into BASE/board-cache.json,
`board` fetches the bounty board (bounties.json) from the dataset into BASE/board-cache.json, and the
round scripts it names that the recorder doesn't ship (bounties/<script>.json, checked) into BASE/board-rounds/,
with the saved login when there is one (the dataset is gated): the window's board then uses it
when it's newer than the one shipped here (recorder/bounties.py checks what it may say).
@@ -180,9 +181,42 @@ def board_url(repo):
return f"{rhub.dataset_url(repo)}/resolve/main/bounties.json"
def round_url(repo, script):
return f"{rhub.dataset_url(repo)}/resolve/main/bounties/{script}.json"
def fetch_rounds(board, base, repo, token, fetch=bounties.fetch_json):
"""The round scripts the board names that this recorder doesn't ship, from the dataset's
bounties/<script>.json into base/board-rounds/ (session.bounty_script finds them), each kept
only if session.check_round passes it; the ones no longer wanted are deleted. Returns
{"rounds": [names kept], "rounds_failed": {name: why}}."""
import session # noqa: E402 (here: only the board needs it)
out_dir = session.fetched_rounds_dir(base)
wanted = sorted({b["script"] for b in board["bounties"] if b["priority"] != "retired"
and not os.path.exists(session.bounty_script(b["script"], session.BOUNTIES_DIR))})
kept, failed = [], {}
for name in wanted:
path = os.path.join(out_dir, name + ".json")
try:
script = session.check_round(name, fetch(round_url(repo, name), token, session.ROUND_MAX_BYTES))
except (OSError, ValueError) as e:
failed[name] = str(e) or type(e).__name__
continue # an older copy that passed when it came stays
bounties.keep_json(path, script)
kept.append(name)
try:
for n in os.listdir(out_dir):
if n.endswith(".json") and n[:-5] not in wanted:
os.remove(os.path.join(out_dir, n))
except OSError:
pass
return {"rounds": kept, "rounds_failed": failed}
def fetch_board(base):
"""The dataset's bounties.json into base/board-cache.json (session.load_board reads it).
Returns {"updated", "bounties"}; raises HubError."""
"""The dataset's bounties.json into base/board-cache.json (session.load_board reads it), and the
round scripts it names that the recorder lacks (fetch_rounds). Returns {"updated", "bounties",
"rounds", "rounds_failed"}; raises HubError."""
repo = dataset_id("public") # the board is the public dataset's
token = None
try:
@@ -201,7 +235,8 @@ def fetch_board(base):
if status == 404:
raise HubError("not-found", f"{repo} has no bounty board yet.") from None
raise HubError("network", f"Couldn't fetch the bounty board ({type(e).__name__}: {e}).") from None
return {"updated": board["updated"], "bounties": len(board["bounties"])}
return dict({"updated": board["updated"], "bounties": len(board["bounties"])},
**fetch_rounds(board, base, repo, token))
# ---------------------------------------------------------------- talking to the Hub
+103 -9
View File
@@ -979,13 +979,99 @@ def load_script(path):
return script
def bounty_script(script, bounties_dir=None):
"""A bounty's round script's path."""
return os.path.join(bounties_dir or BOUNTIES_DIR, script + ".json")
def fetched_rounds_dir(base_dir=None):
"""Where hub.py board keeps the round scripts it fetched from the dataset (check_round)."""
return os.path.join(base_dir or BASE_DIR, "board-rounds")
def have_bounty_script(script, bounties_dir=None):
return bool(bounties.ID.match(script)) and os.path.exists(bounty_script(script, bounties_dir))
def bounty_script(script, bounties_dir=None, base_dir=None):
"""A bounty's round script's path: the one shipped with the recorder, else one fetched from the
dataset (hub.py board), else the shipped one's (missing) path."""
shipped = os.path.join(bounties_dir or BOUNTIES_DIR, script + ".json")
if bounties_dir or os.path.exists(shipped) or not bounties.ID.match(script):
return shipped
fetched = os.path.join(fetched_rounds_dir(base_dir), script + ".json")
return fetched if os.path.exists(fetched) else shipped
def have_bounty_script(script, bounties_dir=None, base_dir=None):
return bool(bounties.ID.match(script)) and os.path.exists(bounty_script(script, bounties_dir, base_dir))
def bounty_script_fetched(path, base_dir=None):
"""Whether a round script's path is one hub.py board fetched."""
return os.path.dirname(os.path.abspath(path)) == os.path.abspath(fetched_rounds_dir(base_dir))
# A round script fetched from the dataset (hub.py board) is untrusted, as the board is: check_round
# keeps it only if it is a bounty round like the shipped ones. Its top level, its sections' kinds and
# nothing that changes what is recorded or where it goes: no debug (a testing session: live output,
# voice, the test dataset), objects or controllers asked for, checklist requirements or demo files.
ROUND_MAX_BYTES = 64 * 1024
ROUND_KEYS = {"version", "name", "record_hz", "intro_s", "between_s", "welcome", "done", "stopped", "sections",
"cue_names"}
ROUND_KINDS = ("prompts", "scene")
ROUND_NEVER = ("debug", "objects", "object", "controller", "controllers", "for_each", "demo", "demo_mirror")
ROUND_MAX_S = 8 * 60 # the plan at its longest (the shipped rounds: 2.5-5 min)
ROUND_MAX_TEXT = 600 # characters in any one string
ROUND_MAX_NUMBER = 600.0 # any one number (seconds, metres, degrees)
def check_round(name, script):
"""A fetched round script (dict) for the board's script name: returned as it is if it passes,
else ValueError saying why. It must be a version 1 script of that name with a scene section,
only the keys and section kinds above, none of ROUND_NEVER set anywhere, an empty "requires",
strings and numbers in limits, sets a second 1-30, windows within 3 m and 2 m across, and a
plan that builds (known gestures, windows and parts) and lasts at most ROUND_MAX_S."""
if not isinstance(script, dict) or script.get("version") != 1 or not isinstance(script.get("sections"), list):
raise ValueError("not a version 1 round script")
if script.get("name") != name:
raise ValueError("its name isn't %s" % name)
extra = sorted(set(script) - ROUND_KEYS)
if extra:
raise ValueError("it has %s" % ", ".join(extra))
hz = script.get("record_hz", RECORD_HZ)
if not isinstance(hz, (int, float)) or isinstance(hz, bool) or not 1 <= hz <= 30:
raise ValueError("record_hz %r" % hz)
def walk(v, where):
if isinstance(v, dict):
for k, x in v.items():
if k in ROUND_NEVER and x:
raise ValueError("%s: %s" % (where, k))
if k == "requires" and x:
raise ValueError("%s: requires %r" % (where, x))
walk(x, k)
elif isinstance(v, list):
for x in v:
walk(x, where)
elif isinstance(v, str):
if len(v) > ROUND_MAX_TEXT:
raise ValueError("%s: a text over %d characters" % (where, ROUND_MAX_TEXT))
elif isinstance(v, (int, float)) and not isinstance(v, bool):
if not math.isfinite(v) or abs(v) > ROUND_MAX_NUMBER:
raise ValueError("%s: %r" % (where, v))
elif v is not None and not isinstance(v, bool):
raise ValueError("%s: %r" % (where, v))
walk(script, "script")
for sec in script["sections"]:
if not isinstance(sec, dict) or sec.get("kind", "prompts") not in ROUND_KINDS:
raise ValueError("a section of kind %r" % (sec.get("kind") if isinstance(sec, dict) else sec))
try:
plan, _ = build_plan(script, {}, seed=1)
seconds = plan_seconds(script, plan, worst=True)
except (KeyError, TypeError, AttributeError, IndexError) as e:
raise ValueError("its plan doesn't build (%s: %s)" % (type(e).__name__, e)) from None
scenes = [p for sec in plan if sec["kind"] == "scene" for p in sec["prompts"]]
if not scenes:
raise ValueError("it has no scene")
for p in scenes:
for w in p["scene"]["windows"]:
if max(abs(float(v)) for v in w["at"]) > 3 or max(float(v) for v in w["size"]) > 2:
raise ValueError("%s: window %s is too far or too big" % (p["id"], w["id"]))
if seconds > ROUND_MAX_S:
raise ValueError("it lasts %d s" % seconds)
return script
def load_board(base_dir=None):
@@ -1561,6 +1647,13 @@ class Session:
self._button = None # the ButtonReader
self.print = print # where dry-run panel commands go (the CLI's stdout)
self.script = load_script(self.script_path)
with open(self.script_path, "rb") as f:
self.script_sha = hashlib.sha256(f.read()).hexdigest()
# A round fetched from the dataset (hub.py board) is checked again as it starts: the cache
# may be from an older recorder's check.
self.script_from = "board" if bounty_script_fetched(self.script_path, self.base_dir) else "recorder"
if self.script_from == "board":
check_round(os.path.splitext(os.path.basename(self.script_path))[0], self.script)
# A debug session (debug.json): one long free step with Frametop's own hand tracking on,
# its output copied beside the recording (livelog.py); the button marks what went wrong.
self.debug = bool(self.script.get("debug"))
@@ -1863,7 +1956,7 @@ class Session:
"calibration_removed": removed,
"script": {"version": self.script.get("version"), "name": self.script.get("name", ""),
"sections": [s["id"] for s in self.plan],
"skipped": self.skipped},
"skipped": self.skipped, "from": self.script_from, "sha256": self.script_sha},
"mode": "auto" if self.auto else "step", "quick": self.quick, "guidance": self.guidance,
"shuffle": {"seed": self.seed, "sweeps": plan_record(self.plan)},
"record_hz": self.record_hz,
@@ -3195,7 +3288,7 @@ def print_board(base):
print("no bounty board (%s)" % BOARD_PATH)
return
print("bounty board of %s" % board["updated"])
for b in bounties.view(board, profile, profile.get("checklist") or {}, have_bounty_script):
for b in bounties.view(board, profile, profile.get("checklist") or {}, lambda n: have_bounty_script(n, base_dir=base)):
print(" %-12s %-7s %4s min done %d %-20s %s" % (b["id"], b["priority"], "%g" % b["minutes"] if b["minutes"] else "?",
b["done"], b["title"], b["available"]))
@@ -3268,7 +3361,8 @@ def main():
if a.quick or script != SCRIPT_PATH:
ap.error("--bounty runs the bounty's own script: no --quick or --script")
board = load_board(base)
entry = next((b for b in bounties.view(board, profile, checklist, have_bounty_script) if b["id"] == a.bounty), None)
entry = next((b for b in bounties.view(board, profile, checklist, lambda n: have_bounty_script(n, base_dir=base))
if b["id"] == a.bounty), None)
if not entry:
print("no bounty %s on the board (--board lists them)" % a.bounty)
return 2
@@ -3276,7 +3370,7 @@ def main():
print("%s: %s" % (entry["title"], entry["available"]))
return 2
entry = dict(entry, updated=board["updated"])
script = bounty_script(entry["script"])
script = bounty_script(entry["script"], base_dir=base)
print("bounty %s: %s" % (entry["id"], entry["title"]))
t0 = time.monotonic()
+80
View File
@@ -133,6 +133,86 @@ class ShippedTest(unittest.TestCase):
self.assertLessEqual({"fist", "pinch", "card", "touch", "nogo"}, set(gestures))
self.assertGreater(gestures.count("fist"), len(gestures) / 2)
def test_shipped_rounds_pass_the_fetched_check(self):
"""Each shipped round would pass check_round if it came from the dataset (the check doesn't
refuse what a real round is)."""
for n in sorted(os.listdir(session.BOUNTIES_DIR)):
with open(os.path.join(session.BOUNTIES_DIR, n)) as f:
session.check_round(n[:-5], json.load(f))
def test_check_round_refuses(self):
"""A fetched round that could change what is recorded or where it goes, or isn't a round in
limits, is refused."""
with open(session.bounty_script("pokes")) as f:
good = json.load(f)
session.check_round("pokes", good)
def bad(change, name="pokes"):
s = json.loads(json.dumps(good))
change(s)
with self.assertRaises(ValueError):
session.check_round(name, s)
scene = lambda s: s["sections"][1]["prompts"][0] # noqa: E731
bad(lambda s: None, name="other") # not its name
bad(lambda s: s.update(debug=True)) # a testing session
bad(lambda s: s.update(objects={"keyboard": "a keyboard"}))
bad(lambda s: s.update(something=1)) # an unknown key
bad(lambda s: s.update(record_hz=60))
bad(lambda s: s["sections"][0].update(requires=["controllers"]))
bad(lambda s: s["sections"][0].update(kind="targets"))
bad(lambda s: scene(s).update(demo="../../x"))
bad(lambda s: scene(s).update(controller=True))
bad(lambda s: scene(s).update(text="x" * 700))
bad(lambda s: scene(s)["windows"][0].update(at=[0, 0, -5])) # too far
bad(lambda s: scene(s)["windows"][0].update(size=[0.3, float("inf")]))
bad(lambda s: scene(s).update(acts={"gen": "buttons", "mix": {"explode": 1}})) # a gesture it doesn't know
bad(lambda s: scene(s).update(acts={"gen": "buttons", "seconds": 500, "every_s": 0.5})) # too long
bad(lambda s: s.update(sections=[s["sections"][0]])) # no scene
def test_fetched_rounds(self):
"""hub.py board keeps the rounds the board names that the recorder doesn't ship (checked),
refuses bad ones, drops ones no longer wanted, and a shipped round wins over a fetched one."""
import hub
base = tempfile.mkdtemp(prefix="handrec-rounds-")
self.addCleanup(shutil.rmtree, base)
with open(session.bounty_script("pokes")) as f:
pokes = json.load(f)
served = {"new-round": dict(pokes, name="new-round"), "bad-round": dict(pokes, name="bad-round", debug=True),
"pokes": dict(pokes, record_hz=10)}
asked = []
def fetch(url, token, max_bytes):
name = url.rsplit("/", 1)[1][:-5]
asked.append(name)
if name not in served:
raise OSError("404")
return json.loads(json.dumps(served[name]))
old = os.path.join(session.fetched_rounds_dir(base), "old-round.json")
os.makedirs(os.path.dirname(old))
with open(old, "w") as f:
json.dump(dict(pokes, name="old-round"), f)
board = bounties.validate({"schema": 1, "updated": "2026-10-23", "bounties": [
{"id": "new", "script": "new-round", "priority": "high"}, {"id": "bad", "script": "bad-round"},
{"id": "gone", "script": "missing-round"}, {"id": "pokes", "script": "pokes"},
{"id": "old", "script": "old-round", "priority": "retired"}]})
r = hub.fetch_rounds(board, base, "x/y", None, fetch=fetch)
self.assertEqual(r["rounds"], ["new-round"])
self.assertEqual(sorted(r["rounds_failed"]), ["bad-round", "missing-round"])
self.assertNotIn("pokes", asked) # shipped: never fetched
self.assertFalse(os.path.exists(old)) # retired: dropped
self.assertTrue(session.have_bounty_script("new-round", base_dir=base))
self.assertFalse(session.have_bounty_script("bad-round", base_dir=base))
path = session.bounty_script("new-round", base_dir=base)
self.assertTrue(session.bounty_script_fetched(path, base))
self.assertFalse(session.bounty_script_fetched(session.bounty_script("pokes", base_dir=base), base))
view = {b["id"]: b["available"] for b in bounties.view(board, {}, {}, lambda n: session.have_bounty_script(n, base_dir=base))}
self.assertEqual(view["new"], "")
self.assertEqual(view["bad"], "Needs a newer recorder")
# A fetch that fails later keeps the copy that passed.
served.pop("new-round")
hub.fetch_rounds(board, base, "x/y", None, fetch=fetch)
self.assertTrue(session.have_bounty_script("new-round", base_dir=base))
def test_same_plan_for_a_seed(self):
script = session.load_script(session.bounty_script("index-taps"))
a = session.build_plan(script, {}, seed=7)[0][1]["prompts"]
+33 -14
View File
@@ -4,9 +4,13 @@ says which are wanted most (priority) and the profile keeps how often each was d
The board ships with the recorder (its bounties.json) and a newer copy is fetched from the
dataset when it can be. A fetched board is untrusted: it can only describe, reorder, reprioritise
or retire bounties. Each bounty's steps come from a round script shipped with the recorder
(its "script" names one), so a bounty whose script this recorder doesn't have is listed as
needing a newer recorder, and nothing fetched decides what a round does.
or retire bounties. Each bounty's steps come from a round script (its "script" names one): one
shipped with the recorder, or for the hand recorder since 2026-10-23 one fetched from the
dataset beside the board (bounties/<script>.json) that the recorder checks as untrusted and
keeps only if it passes (hands/rec/session.py check_round: scene steps of known gestures, in
limits, nothing that changes what is recorded or where it goes). A shipped script always wins
over a fetched one of the same name. A bounty whose script the recorder has neither way is
listed as needing a newer recorder.
bounties.json:
{"schema": 1, "updated": "YYYY-MM-DD", "bounties": [{"id", "title", "blurb", "minutes",
@@ -83,20 +87,35 @@ def newest(shipped, fetched):
return fetched if fetched["updated"] >= shipped["updated"] else shipped
def fetch(url, cache_path, token=None, timeout=5.0):
"""Fetch the board from url (the dataset is gated: token is the contributor's Hugging Face
token), validate it and keep it at cache_path. Returns the board; raises OSError or ValueError."""
def fetch_json(url, token=None, max_bytes=MAX_BYTES, timeout=5.0):
"""A JSON file from url (the dataset is gated: token is the contributor's Hugging Face token),
at most max_bytes. Raises OSError or ValueError."""
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + token} if token else {})
with urllib.request.urlopen(req, timeout=timeout) as r:
data = r.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise ValueError("the board is too big")
board = validate(json.loads(data.decode("utf-8")))
os.makedirs(os.path.dirname(os.path.abspath(cache_path)), exist_ok=True)
tmp = cache_path + ".tmp"
data = r.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError("it is too big")
return json.loads(data.decode("utf-8"))
def keep_json(path, value):
"""value written to path whole (a temporary file, then renamed)."""
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(board, f, indent=1)
os.replace(tmp, cache_path)
json.dump(value, f, indent=1, ensure_ascii=False)
os.replace(tmp, path)
def fetch(url, cache_path, token=None, timeout=5.0):
"""Fetch the board from url, validate it and keep it at cache_path. Returns the board; raises
OSError or ValueError."""
try:
data = fetch_json(url, token, MAX_BYTES, timeout)
except ValueError as e:
raise ValueError("the board: %s" % e) from None
board = validate(data)
keep_json(cache_path, board)
return board