Skip to content

Commit 836936a

Browse files
Automate TigerTag JSON database updates
- download_json.py now follows the sync strategy of the TigerTag RFID Guide: fetch the last_update manifest, download only the tables whose timestamp changed, use the TigerTag API with the GitHub mirror as fallback, and validate each table before replacing it - Tables are written indented so updates produce readable diffs - Add --force and --summary options; the summary lists added, removed and changed entries per table - Add a weekly workflow that syncs the tables, runs pytest and opens a pull request when something changed
1 parent a13bc9e commit 836936a

2 files changed

Lines changed: 237 additions & 18 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
name: Update TigerTag database
2+
3+
on:
4+
schedule:
5+
# Every Monday at 06:00 UTC
6+
- cron: '0 6 * * 1'
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
pull-requests: write
12+
13+
concurrency:
14+
group: update-tigertag-json
15+
cancel-in-progress: false
16+
17+
jobs:
18+
update:
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- name: Check out repository
23+
uses: actions/checkout@v4
24+
25+
- name: Set up Python
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: '3.11'
29+
cache: pip
30+
31+
- name: Install dependencies
32+
run: python -m pip install --upgrade pip && python -m pip install -r requirements.txt
33+
34+
- name: Sync TigerTag tables
35+
run: python src/tag/tigertag/download_json.py --summary "$RUNNER_TEMP/tigertag-summary.md"
36+
37+
- name: Check for changes
38+
id: changes
39+
run: |
40+
if [ -n "$(git status --porcelain -- src/tag/tigertag/database)" ]; then
41+
echo "changed=true" >> "$GITHUB_OUTPUT"
42+
fi
43+
44+
# Pull requests opened with GITHUB_TOKEN do not trigger other workflows,
45+
# so the tests run here before the pull request is opened.
46+
- name: Run pytest
47+
if: steps.changes.outputs.changed == 'true'
48+
env:
49+
FILAMENT_DETECT_TEST_CREALITY_ENCRYPTION_KEY: ${{ secrets.FILAMENT_DETECT_TEST_CREALITY_ENCRYPTION_KEY }}
50+
run: pytest
51+
52+
- name: Build pull request body
53+
if: steps.changes.outputs.changed == 'true'
54+
run: |
55+
{
56+
echo "Automated update of the TigerTag reference tables in \`src/tag/tigertag/database/\`, synced from api.tigertag.io (GitHub mirror of the TigerTag RFID Guide as fallback)."
57+
echo
58+
cat "$RUNNER_TEMP/tigertag-summary.md"
59+
echo
60+
echo "Tests passed in [this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
61+
} > "$RUNNER_TEMP/pr-body.md"
62+
63+
- name: Open pull request
64+
if: steps.changes.outputs.changed == 'true'
65+
uses: peter-evans/create-pull-request@v7
66+
with:
67+
branch: automation/update-tigertag-json
68+
delete-branch: true
69+
add-paths: src/tag/tigertag/database/*.json
70+
commit-message: Update TigerTag JSON database
71+
title: Update TigerTag JSON database
72+
body-path: ${{ runner.temp }}/pr-body.md

‎src/tag/tigertag/download_json.py‎

Lines changed: 165 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,184 @@
1+
"""Sync the TigerTag reference tables in ``database/``.
2+
3+
Follows the sync strategy from the TigerTag RFID Guide (section 2.0):
4+
fetch the ``last_update`` manifest first, then download only the tables
5+
whose timestamp changed. The live TigerTag API is the primary source; if
6+
it is unreachable, the whole run falls back to the GitHub mirror of the
7+
guide, so the manifest and the tables always come from the same source.
8+
9+
Tables are written indented, one entry per line, so updates produce
10+
readable diffs. A table is only replaced once its download has been
11+
validated, so a failed run never leaves a truncated file behind.
12+
13+
Usage:
14+
python download_json.py [--force] [--summary FILE]
15+
"""
16+
17+
import argparse
18+
import json
119
import os
20+
import sys
221
from pathlib import Path
322

423
import requests
524

625

7-
JSON_FILES = [
8-
("https://api.tigertag.io/api:tigertag/version/get/all", "id_version.json"),
9-
("https://api.tigertag.io/api:tigertag/material/get/all", "id_material.json"),
10-
("https://api.tigertag.io/api:tigertag/aspect/get/all", "id_aspect.json"),
11-
("https://api.tigertag.io/api:tigertag/type/get/all", "id_type.json"),
12-
("https://api.tigertag.io/api:tigertag/diameter/filament/get/all", "id_diameter.json"),
13-
("https://api.tigertag.io/api:tigertag/brand/get/all", "id_brand.json"),
14-
("https://api.tigertag.io/api:tigertag/measure_unit/get/all", "id_measure_unit.json"),
15-
]
26+
API_BASE = "https://api.tigertag.io/api:tigertag"
27+
GITHUB_RAW_BASE = "https://raw.githubusercontent.com/TigerTag-Project/TigerTag-RFID-Guide/main/database"
28+
29+
# last_update key -> (API endpoint, file name locally and on the GitHub mirror)
30+
DATASETS = {
31+
"versions": ("version/get/all", "id_version.json"),
32+
"types": ("type/get/all", "id_type.json"),
33+
"brands": ("brand/get/all", "id_brand.json"),
34+
"filament_diameters": ("diameter/filament/get/all", "id_diameter.json"),
35+
"filament_materials": ("material/get/all", "id_material.json"),
36+
"aspects": ("aspect/get/all", "id_aspect.json"),
37+
"measure_units": ("measure_unit/get/all", "id_measure_unit.json"),
38+
}
39+
LAST_UPDATE_FILE = "last_update.json"
1640
JSON_DIRECTORY = "database"
1741

42+
_LABEL_FIELDS = ("name", "label", "title", "description", "symbol", "tag")
43+
44+
45+
def _fetch_json(url: str, timeout: float):
46+
response = requests.get(url, timeout=timeout)
47+
response.raise_for_status()
48+
return response.json()
49+
50+
51+
def _pick_source(timeout: float):
52+
"""Return (source name, remote manifest, url builder) for this run."""
53+
try:
54+
manifest = _fetch_json(f"{API_BASE}/all/last_update", timeout)
55+
return "api", manifest, lambda endpoint, filename: f"{API_BASE}/{endpoint}"
56+
except (requests.RequestException, ValueError) as exc:
57+
print(f"TigerTag API unreachable ({exc}), falling back to the GitHub mirror", file=sys.stderr)
58+
manifest = _fetch_json(f"{GITHUB_RAW_BASE}/{LAST_UPDATE_FILE}", timeout)
59+
return "github", manifest, lambda endpoint, filename: f"{GITHUB_RAW_BASE}/{filename}"
60+
61+
62+
def _read_json(path: Path):
63+
try:
64+
with path.open("r", encoding="utf-8") as handle:
65+
return json.load(handle)
66+
except (FileNotFoundError, json.JSONDecodeError, OSError):
67+
return None
68+
69+
70+
def _write_json(path: Path, data):
71+
tmp_path = path.with_suffix(path.suffix + ".tmp")
72+
with tmp_path.open("w", encoding="utf-8") as handle:
73+
json.dump(data, handle, ensure_ascii=False, indent=2)
74+
handle.write("\n")
75+
os.replace(tmp_path, path)
76+
77+
78+
def _entries_by_id(data) -> dict:
79+
if not isinstance(data, list):
80+
return {}
81+
return {entry["id"]: entry for entry in data if isinstance(entry, dict) and "id" in entry}
82+
1883

19-
def download_json_files(target_folder: str | None = None, timeout: float = 10.0):
84+
def _label(entry: dict) -> str:
85+
for field in _LABEL_FIELDS:
86+
value = entry.get(field)
87+
if isinstance(value, str) and value.strip():
88+
return value.strip()
89+
return ""
90+
91+
92+
def _describe_changes(filename: str, old_data, new_data) -> list[str]:
93+
old, new = _entries_by_id(old_data), _entries_by_id(new_data)
94+
added = [new[key] for key in new if key not in old]
95+
removed = [old[key] for key in old if key not in new]
96+
changed = [new[key] for key in new if key in old and new[key] != old[key]]
97+
98+
lines = [f"### `{filename}`: {len(old)} → {len(new)} entries"]
99+
for title, entries in (("Added", added), ("Removed", removed), ("Changed", changed)):
100+
if entries:
101+
items = ", ".join(f"{_label(entry) or '?'} ({entry['id']})" for entry in entries)
102+
lines.append(f"- **{title}** ({len(entries)}): {items}")
103+
if not (added or removed or changed):
104+
lines.append("- Formatting only, no entry changed")
105+
return lines
106+
107+
108+
def download_json_files(target_folder: str | None = None, timeout: float = 30.0,
109+
force: bool = False) -> tuple[list[str], list[str]]:
110+
"""Sync the tables. Returns (updated file names, markdown change summary)."""
20111
base_directory = Path(target_folder or os.path.dirname(os.path.abspath(__file__)))
21112
destination = base_directory / JSON_DIRECTORY
22113
destination.mkdir(parents=True, exist_ok=True)
23114

24-
for url, filename in JSON_FILES:
115+
source, remote_manifest, url_for = _pick_source(timeout)
116+
print(f"Source: {source}")
117+
118+
last_update_path = destination / LAST_UPDATE_FILE
119+
local_manifest = _read_json(last_update_path) or {}
120+
synced_manifest = dict(local_manifest)
121+
updated: list[str] = []
122+
summary: list[str] = []
123+
failures: list[str] = []
124+
125+
for key, (endpoint, filename) in DATASETS.items():
25126
file_path = destination / filename
127+
remote_ts = remote_manifest.get(key)
128+
if not force and remote_ts is not None and remote_ts == local_manifest.get(key) and file_path.exists():
129+
print(f"Up to date: {filename}")
130+
continue
131+
132+
url = url_for(endpoint, filename)
26133
try:
27134
print(f"Downloading {url}...")
28-
response = requests.get(url, timeout=timeout)
29-
response.raise_for_status()
30-
file_path.write_text(response.text, encoding="utf-8")
31-
print(f"Saved: {file_path}")
32-
except Exception as exc:
33-
print(f"Failed to download {url}: {exc}")
135+
data = _fetch_json(url, timeout)
136+
if not isinstance(data, list) or not data:
137+
raise ValueError("expected a non-empty JSON array")
138+
except (requests.RequestException, ValueError) as exc:
139+
print(f"Failed to download {url}: {exc}", file=sys.stderr)
140+
failures.append(filename)
141+
continue
142+
143+
old_text = file_path.read_text(encoding="utf-8") if file_path.exists() else None
144+
old_data = _read_json(file_path)
145+
_write_json(file_path, data)
146+
if remote_ts is not None:
147+
synced_manifest[key] = remote_ts
148+
if file_path.read_text(encoding="utf-8") != old_text:
149+
updated.append(filename)
150+
summary += _describe_changes(filename, old_data, data) + [""]
151+
print(f"Updated: {file_path}")
152+
else:
153+
print(f"Unchanged: {filename}")
154+
155+
if synced_manifest != local_manifest:
156+
_write_json(last_update_path, synced_manifest)
157+
158+
if failures:
159+
raise RuntimeError(f"Failed to download: {', '.join(failures)}")
160+
return updated, summary
161+
162+
163+
def main() -> int:
164+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
165+
parser.add_argument("--force", action="store_true",
166+
help="download every table, even if its timestamp did not change")
167+
parser.add_argument("--summary", type=Path,
168+
help="write a markdown summary of the changes to this file")
169+
args = parser.parse_args()
170+
171+
try:
172+
updated, summary = download_json_files(force=args.force)
173+
except (requests.RequestException, ValueError, RuntimeError) as exc:
174+
print(f"Error: {exc}", file=sys.stderr)
175+
return 1
176+
177+
if args.summary:
178+
args.summary.write_text("\n".join(summary), encoding="utf-8")
179+
print(f"Updated {len(updated)} table(s)" if updated else "All tables already up to date")
180+
return 0
34181

35182

36183
if __name__ == "__main__":
37-
download_json_files()
184+
sys.exit(main())

0 commit comments

Comments
 (0)