/
/
/
1"""Generate updated constraint and requirements files."""
2
3from __future__ import annotations
4
5import json
6import os
7import re
8import sys
9import tomllib
10from pathlib import Path
11
12PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$")
13GIT_REPO_REGEX = re.compile(r"^(git\+https:\/\/[-_\.\w\d\/]+[-@_\.\w\d\/]*)$")
14
15# ruff: noqa: T201
16
17
18def _load_pyproject() -> dict:
19 """Load pyproject.toml once."""
20 with open("pyproject.toml", "rb") as fp:
21 return tomllib.load(fp)
22
23
24def gather_uv_index_config(data: dict) -> tuple[list[str], dict[str, str]]:
25 """
26 Read [tool.uv.index] and [tool.uv.sources] from pyproject.toml.
27
28 :return: Tuple of (extra_index_urls, package_variant_suffixes).
29 extra_index_urls: URLs to emit as --extra-index-url.
30 package_variant_suffixes: Maps package name to variant suffix
31 derived from the index URL path (e.g. "cpu" from ".../whl/cpu").
32 """
33 uv_cfg = data.get("tool", {}).get("uv", {})
34 indices = uv_cfg.get("index", [])
35 sources = uv_cfg.get("sources", {})
36
37 # Build index name â (url, variant) mapping
38 index_map: dict[str, tuple[str, str]] = {}
39 for idx in indices:
40 name = idx.get("name", "")
41 url = idx.get("url", "")
42 if not url:
43 continue
44 # Extract variant from URL path: ".../whl/cpu" â "cpu"
45 variant = url.rstrip("/").rsplit("/", 1)[-1] if "/whl/" in url else ""
46 index_map[name] = (url, variant)
47
48 extra_urls = [url for url, _ in index_map.values()]
49
50 # Map packages to their variant suffix
51 pkg_suffixes: dict[str, str] = {}
52 for pkg, source_cfg in sources.items():
53 idx_name = source_cfg.get("index", "")
54 if idx_name in index_map:
55 _, variant = index_map[idx_name]
56 if variant:
57 pkg_suffixes[pkg.lower().replace("_", "-")] = f"+{variant}"
58
59 return extra_urls, pkg_suffixes
60
61
62def gather_core_requirements(data: dict) -> list[str]:
63 """Gather core requirements out of pyproject.toml."""
64 return data["project"]["dependencies"]
65
66
67def gather_requirements_from_manifests() -> list[str]:
68 """Gather all of the requirements from provider manifests."""
69 dependencies: list[str] = []
70 providers_path = "music_assistant/providers"
71 for dir_str in sorted(os.listdir(providers_path)): # noqa: PTH208, RUF100
72 dir_path = os.path.join(providers_path, dir_str)
73 if not Path(dir_path).is_dir():
74 continue
75 # get files in subdirectory
76 for file_str in os.listdir(dir_path): # noqa: PTH208, RUF100
77 file_path = os.path.join(dir_path, file_str)
78 if not Path(file_path).is_file():
79 continue
80 if file_str != "manifest.json":
81 continue
82
83 with open(file_path) as _file:
84 provider_manifest = json.loads(_file.read())
85 if "requirements" in provider_manifest:
86 dependencies += provider_manifest["requirements"]
87 return dependencies
88
89
90def main() -> int:
91 """Run the script."""
92 if not Path("requirements_all.txt").is_file():
93 print("Run this from MA root dir")
94 return 1
95
96 pyproject = _load_pyproject()
97 extra_urls, pkg_suffixes = gather_uv_index_config(pyproject)
98 core_reqs = gather_core_requirements(pyproject)
99 extra_reqs = gather_requirements_from_manifests()
100
101 # use intermediate dict to detect duplicates
102 # TODO: compare versions and only store most recent
103 final_requirements: dict[str, str] = {}
104 for req_str in core_reqs + extra_reqs:
105 package_name = req_str
106 if match := PACKAGE_REGEX.search(req_str):
107 package_name = match.group(1).lower().replace("_", "-")
108 elif match := GIT_REPO_REGEX.search(req_str):
109 package_name = match.group(1)
110 elif package_name in final_requirements:
111 # duplicate package without version is safe to ignore
112 continue
113 else:
114 print(f"Found requirement without (exact) version specifier: {req_str}")
115 package_name = req_str
116
117 existing = final_requirements.get(package_name)
118 if existing:
119 print(f"WARNING: ignore duplicate package: {package_name} - existing: {existing}")
120 continue
121
122 # For packages pinned to alternate indices (e.g. pytorch-cpu), emit
123 # platform-conditional requirements: +cpu suffix on x86_64 (where PyPI
124 # defaults to CUDA), plain version on aarch64 (already CPU-only).
125 if package_name in pkg_suffixes:
126 suffix = pkg_suffixes[package_name]
127 final_requirements[package_name] = (
128 f"{req_str}{suffix}; sys_platform == 'linux' and platform_machine == 'x86_64'\n"
129 f"{req_str}; sys_platform != 'linux' or platform_machine != 'x86_64'"
130 )
131 else:
132 final_requirements[package_name] = req_str
133
134 content = "# WARNING: this file is autogenerated!\n\n"
135 for url in extra_urls:
136 content += f"--extra-index-url {url}\n"
137 content += "\n"
138 for req_key in sorted(final_requirements):
139 req_str = final_requirements[req_key]
140 content += f"{req_str}\n"
141 # Always use LF line endings for cross-platform compatibility
142 Path("requirements_all.txt").write_text(content, newline="\n")
143
144 return 0
145
146
147if __name__ == "__main__":
148 sys.exit(main())
149