|
| 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 |
1 | 19 | import os |
| 20 | +import sys |
2 | 21 | from pathlib import Path |
3 | 22 |
|
4 | 23 | import requests |
5 | 24 |
|
6 | 25 |
|
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" |
16 | 40 | JSON_DIRECTORY = "database" |
17 | 41 |
|
| 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 | + |
18 | 83 |
|
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).""" |
20 | 111 | base_directory = Path(target_folder or os.path.dirname(os.path.abspath(__file__))) |
21 | 112 | destination = base_directory / JSON_DIRECTORY |
22 | 113 | destination.mkdir(parents=True, exist_ok=True) |
23 | 114 |
|
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(): |
25 | 126 | 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) |
26 | 133 | try: |
27 | 134 | 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 |
34 | 181 |
|
35 | 182 |
|
36 | 183 | if __name__ == "__main__": |
37 | | - download_json_files() |
| 184 | + sys.exit(main()) |
0 commit comments