/
/
/
1"""
2Regenerate the Tidal official-API TypedDict models from the vendored OpenAPI spec.
3
4This produces ``music_assistant/providers/tidal/_openapi_models.py`` from the
5attribute schemas listed in ``SEED_SCHEMAS`` (plus their transitive ``$ref``
6closure). Only the ``*_Attributes`` payloads are generated: the JSON:API
7envelope (``data`` / ``included`` / ``links``) is handled generically in the
8provider's api client, not modelled here.
9
10Usage::
11
12 python scripts/tidal_openapi/generate_models.py
13
14To cover a new area in a later slice, add its ``*_Attributes`` schema name to
15``SEED_SCHEMAS`` and rerun. To refresh the spec itself, re-download it (see
16``README.md``) and rerun; a ``git diff`` on the spec then shows what changed
17upstream (new fields, deprecations, removals).
18"""
19
20from __future__ import annotations
21
22import json
23import subprocess
24import sys
25import tempfile
26from pathlib import Path
27from typing import Any
28
29# ruff: noqa: S603, S607, T201
30
31HERE = Path(__file__).parent
32SPEC_PATH = HERE / "tidal-api-oas.json"
33OUTPUT_PATH = HERE.parent.parent / "music_assistant" / "providers" / "tidal" / "_openapi_models.py"
34
35# Attribute schemas the provider parses. Extend this list as later slices add
36# coverage (e.g. "Playlists_Attributes", "Artworks_Attributes").
37SEED_SCHEMAS = [
38 "Tracks_Attributes",
39 "Albums_Attributes",
40 "Artists_Attributes",
41 "Playlists_Attributes",
42]
43
44FILE_HEADER = '''"""
45TypedDict models for the official TIDAL API (openapi.tidal.com/v2).
46
47DO NOT EDIT BY HAND. This file is generated from the vendored OpenAPI spec.
48Regenerate with: python scripts/tidal_openapi/generate_models.py
49"""
50# ruff: noqa'''
51
52
53def main() -> int:
54 """Generate the models file and return a process exit code."""
55 spec = json.loads(SPEC_PATH.read_text())
56 schemas = spec["components"]["schemas"]
57
58 missing = [name for name in SEED_SCHEMAS if name not in schemas]
59 if missing:
60 print(f"Seed schemas not found in spec: {missing}", file=sys.stderr)
61 return 1
62
63 wanted = _closure(schemas, SEED_SCHEMAS)
64 minimal = {
65 "openapi": spec.get("openapi", "3.1.0"),
66 "info": {"title": "TIDAL API (subset)", "version": spec["info"]["version"]},
67 "paths": {},
68 "components": {"schemas": {name: schemas[name] for name in sorted(wanted)}},
69 }
70 print(f"Generating {len(wanted)} schemas from {len(SEED_SCHEMAS)} seeds")
71
72 with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tmp:
73 json.dump(minimal, tmp)
74 tmp_path = Path(tmp.name)
75
76 try:
77 subprocess.run(
78 [
79 "uvx",
80 # Pinned: a new generator release can change the output arbitrarily,
81 # which would defeat the regenerate-and-diff workflow.
82 "--from",
83 "datamodel-code-generator==0.72.3",
84 "datamodel-codegen",
85 "--input",
86 str(tmp_path),
87 "--input-file-type",
88 "openapi",
89 "--output",
90 str(OUTPUT_PATH),
91 "--output-model-type",
92 "typing.TypedDict",
93 "--target-python-version",
94 "3.14",
95 "--use-double-quotes",
96 "--disable-timestamp",
97 "--custom-file-header",
98 FILE_HEADER,
99 ],
100 check=True,
101 )
102 # Match the repo's formatting so regeneration produces a stable diff,
103 # using the repo-pinned ruff rather than whatever is on PATH.
104 subprocess.run(["uv", "run", "ruff", "format", str(OUTPUT_PATH)], check=True)
105 finally:
106 tmp_path.unlink(missing_ok=True)
107
108 print(f"Wrote {OUTPUT_PATH.relative_to(HERE.parent.parent)}")
109 return 0
110
111
112def _collect_refs(obj: Any) -> list[str]:
113 """Return the schema names referenced by ``$ref`` anywhere within ``obj``."""
114 found: list[str] = []
115 if isinstance(obj, dict):
116 for key, value in obj.items():
117 if key == "$ref" and isinstance(value, str):
118 found.append(value.split("/")[-1])
119 else:
120 found.extend(_collect_refs(value))
121 elif isinstance(obj, list):
122 for value in obj:
123 found.extend(_collect_refs(value))
124 return found
125
126
127def _closure(schemas: dict[str, Any], seeds: list[str]) -> set[str]:
128 """Return the transitive ``$ref`` closure of ``seeds`` within ``schemas``."""
129 seen: set[str] = set()
130 stack = list(seeds)
131 while stack:
132 name = stack.pop()
133 if name in seen or name not in schemas:
134 continue
135 seen.add(name)
136 stack.extend(_collect_refs(schemas[name]))
137 return seen
138
139
140if __name__ == "__main__":
141 raise SystemExit(main())
142