mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-24 07:00:43 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ee438cdc0 |
No files matched your search
+1
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user