Some checks failed
CI / lint (push) Successful in 2m45s
CI / test (push) Successful in 4m19s
CI / notebooks-smoke (push) Successful in 2m3s
Deploy / changes (push) Successful in 17s
Infra CI / zotero (push) Successful in 30s
Infra CI / notebooks (push) Successful in 1m12s
Infra CI / docs (push) Successful in 28s
Infra CI / api (push) Successful in 22s
Infra CI / bcda (push) Successful in 20s
Infra CI / llm (push) Successful in 19s
Infra CI / mc (push) Successful in 18s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Successful in 1m8s
Deploy / api (push) Successful in 1m36s
Deploy / bcda (push) Successful in 1m56s
Deploy / notebooks (push) Failing after 6m40s
Deploy / mc (push) Has been skipped
Deploy / llm (push) Successful in 2m17s
Deploy / report (push) Successful in 16s
OSF Projects freeze on 2027-02-19, and the LEAD living review's OSF project holds nothing but the registration. Design: keep registration 8wq3m as the anchor, publish each review update as a BagIt bag with RO-Crate metadata from rustfs/reach (stack lead release build/verify/ publish), and get the comment tagger back over a held-out gate before fanning out. dev/scripts/osf_metadata.py sets tags, bepress subjects and CC-BY 4.0 on the registration and project (dry run by default).
135 lines
4.1 KiB
Python
135 lines
4.1 KiB
Python
"""Set tags, subjects and license on the LEAD review's OSF records.
|
|
|
|
The OSF registration (8wq3m) and its project (bwc9n) were created bare:
|
|
no tags, no subjects, no license. Registration metadata stays editable
|
|
after OSF Projects go read-only (2027-02-19); the project is editable
|
|
until then. Dry run by default (prints the requests); --apply writes.
|
|
|
|
Needs a personal access token with osf.full_write in OSF_TOKEN (.env).
|
|
|
|
uv run python dev/scripts/osf_metadata.py # show the plan
|
|
uv run python dev/scripts/osf_metadata.py --apply # write it
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import httpx
|
|
|
|
API = "https://api.osf.io/v2"
|
|
UA = {"User-Agent": "stack-osf-metadata/1.0"}
|
|
|
|
TARGETS = [("registrations", "8wq3m"), ("nodes", "bwc9n")]
|
|
|
|
TAGS = [
|
|
"LEAD model",
|
|
"ACO",
|
|
"accountable care",
|
|
"Medicare",
|
|
"CMS Innovation Center",
|
|
"living review",
|
|
"scoping review",
|
|
"systematic review",
|
|
"prognostic factors",
|
|
"health policy",
|
|
"PRISMA-ScR",
|
|
"performance year 2027",
|
|
]
|
|
|
|
LICENSE_ID = "563c1cf88c5e4a3877f9e96a" # CC-By Attribution 4.0 International
|
|
|
|
# Leaf subjects (bepress taxonomy on the OSF registration provider);
|
|
# their ancestors are added at run time, as OSF expects full paths.
|
|
LEAF_SUBJECTS = {
|
|
"584240da54be81056cecacac": "Health Services Research",
|
|
"584240da54be81056cecab1f": "Health Policy",
|
|
"584240d954be81056ceca846": "Health Economics",
|
|
}
|
|
|
|
|
|
def subject_path(client: httpx.Client, sid: str) -> list[str]:
|
|
"""``sid`` and its ancestors, root first."""
|
|
path: list[str] = []
|
|
while sid:
|
|
path.insert(0, sid)
|
|
d = client.get(f"{API}/subjects/{sid}/").raise_for_status().json()["data"]
|
|
sid = ((d["relationships"].get("parent") or {}).get("data") or {}).get("id", "")
|
|
return path
|
|
|
|
|
|
def plan(client: httpx.Client) -> dict:
|
|
subjects: list[str] = []
|
|
for sid in LEAF_SUBJECTS:
|
|
for s in subject_path(client, sid):
|
|
if s not in subjects:
|
|
subjects.append(s)
|
|
return {"tags": TAGS, "license": LICENSE_ID, "subjects": subjects}
|
|
|
|
|
|
def apply(client: httpx.Client, kind: str, guid: str, p: dict) -> None:
|
|
body = {
|
|
"data": {
|
|
"type": kind,
|
|
"id": guid,
|
|
"attributes": {"tags": p["tags"]},
|
|
"relationships": {
|
|
"license": {"data": {"type": "licenses", "id": p["license"]}}
|
|
},
|
|
}
|
|
}
|
|
client.patch(f"{API}/{kind}/{guid}/", json=body).raise_for_status()
|
|
rel = {"data": [{"type": "subjects", "id": s} for s in p["subjects"]]}
|
|
client.patch(
|
|
f"{API}/{kind}/{guid}/relationships/subjects/", json=rel
|
|
).raise_for_status()
|
|
|
|
|
|
def verify(client: httpx.Client, kind: str, guid: str) -> dict:
|
|
d = (
|
|
client.get(f"{API}/{kind}/{guid}/?embed=license")
|
|
.raise_for_status()
|
|
.json()["data"]
|
|
)
|
|
subs = (
|
|
client.get(f"{API}/{kind}/{guid}/subjects/").raise_for_status().json()["data"]
|
|
)
|
|
lic = (d.get("embeds", {}).get("license", {}).get("data") or {}).get(
|
|
"attributes", {}
|
|
)
|
|
return {
|
|
"tags": d["attributes"].get("tags"),
|
|
"license": lic.get("name"),
|
|
"subjects": sorted(s["attributes"]["text"] for s in subs),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--apply", action="store_true")
|
|
args = ap.parse_args()
|
|
token = os.environ.get("OSF_TOKEN", "")
|
|
headers = dict(UA)
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
with httpx.Client(headers=headers, timeout=60, follow_redirects=True) as client:
|
|
p = plan(client)
|
|
print(json.dumps(p, indent=1))
|
|
if not args.apply:
|
|
print("dry run — pass --apply to write")
|
|
return 0
|
|
if not token:
|
|
print("OSF_TOKEN is not set", file=sys.stderr)
|
|
return 2
|
|
for kind, guid in TARGETS:
|
|
apply(client, kind, guid, p)
|
|
print(kind, guid, verify(client, kind, guid))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|