Clone of https://github.com/NixOS/nixpkgs.git (to stress-test knotserver)
at devShellTools-shell 331 lines 12 kB view raw
1#! /usr/bin/env nix-shell 2#! nix-shell -i python3 -p "python3.withPackages (ps: with ps; [ packaging rich ])" -p pyright ruff isort 3# 4# This script downloads Home Assistant's source tarball. 5# Inside the homeassistant/components directory, each integration has an associated manifest.json, 6# specifying required packages and other integrations it depends on: 7# 8# { 9# "requirements": [ "package==1.2.3" ], 10# "dependencies": [ "component" ] 11# } 12# 13# By parsing the files, a dictionary mapping integrations to requirements and dependencies is created. 14# For all of these requirements and the dependencies' requirements, 15# nixpkgs' python3Packages are searched for appropriate names. 16# Then, a Nix attribute set mapping integration name to dependencies is created. 17 18import json 19import os 20import pathlib 21import re 22import subprocess 23import sys 24import tarfile 25import tempfile 26from functools import reduce 27from io import BytesIO 28from typing import Any, Dict, List, Optional, Set 29from urllib.request import urlopen 30 31from packaging import version as Version 32from packaging.version import InvalidVersion 33from rich.console import Console 34from rich.table import Table 35 36COMPONENT_PREFIX = "homeassistant.components" 37PKG_SET = "home-assistant.python.pkgs" 38 39# If some requirements are matched by multiple or no Python packages, the 40# following can be used to choose the correct one 41PKG_PREFERENCES = { 42 "fiblary3": "fiblary3-fork", # https://github.com/home-assistant/core/issues/66466 43 "HAP-python": "hap-python", 44 "ha-av": "av", 45 "numpy": "numpy", 46 "ollama-hass": "ollama", 47 "paho-mqtt": "paho-mqtt", 48 "sentry-sdk": "sentry-sdk", 49 "slackclient": "slack-sdk", 50 "SQLAlchemy": "sqlalchemy", 51 "tensorflow": "tensorflow", 52 "yt-dlp": "yt-dlp", 53} 54 55# Some dependencies are loaded dynamically at runtime, and are not 56# mentioned in the manifest files. 57EXTRA_COMPONENT_DEPS = { 58 "conversation": [ 59 "intent" 60 ], 61 "default_config": [ 62 "backup", 63 ], 64} 65 66# Sometimes we have unstable versions for libraries that are not 67# well-maintained. This allows us to mark our weird version as newer 68# than a certain wanted version 69OUR_VERSION_IS_NEWER_THAN = { 70 "blinkstick": "1.2.0", 71 "gps3": "0.33.3", 72 "pybluez": "0.22", 73} 74 75 76 77def run_sync(cmd: List[str]) -> None: 78 print(f"$ {' '.join(cmd)}") 79 process = subprocess.run(cmd) 80 81 if process.returncode != 0: 82 sys.exit(1) 83 84 85def get_version() -> str: 86 with open(os.path.dirname(sys.argv[0]) + "/default.nix") as f: 87 # A version consists of digits, dots, and possibly a "b" (for beta) 88 if match := re.search('hassVersion = "([\\d\\.b]+)";', f.read()): 89 return match.group(1) 90 raise RuntimeError("hassVersion not in default.nix") 91 92 93def parse_components(version: str = "master"): 94 components = {} 95 components_with_tests = [] 96 with tempfile.TemporaryDirectory() as tmp: 97 with urlopen( 98 f"https://github.com/home-assistant/home-assistant/archive/{version}.tar.gz" 99 ) as response: 100 tarfile.open(fileobj=BytesIO(response.read())).extractall(tmp, filter="data") 101 # Use part of a script from the Home Assistant codebase 102 core_path = os.path.join(tmp, f"core-{version}") 103 104 for entry in os.scandir(os.path.join(core_path, "tests/components")): 105 if entry.is_dir(): 106 components_with_tests.append(entry.name) 107 108 sys.path.append(core_path) 109 from script.hassfest.model import Config, Integration # type: ignore 110 config = Config( 111 root=pathlib.Path(core_path), 112 specific_integrations=None, 113 action="generate", 114 requirements=False, 115 ) 116 integrations = Integration.load_dir(config.core_integrations_path, config) 117 for domain in sorted(integrations): 118 integration = integrations[domain] 119 if extra_deps := EXTRA_COMPONENT_DEPS.get(integration.domain): 120 integration.dependencies.extend(extra_deps) 121 if not integration.disabled: 122 components[domain] = integration.manifest 123 124 return components, components_with_tests 125 126 127# Recursively get the requirements of a component and its dependencies 128def get_reqs(components: Dict[str, Dict[str, Any]], component: str, processed: Set[str]) -> Set[str]: 129 requirements = set(components[component].get("requirements", [])) 130 deps = components[component].get("dependencies", []) 131 deps.extend(components[component].get("after_dependencies", [])) 132 processed.add(component) 133 for dependency in deps: 134 if dependency not in processed: 135 requirements.update(get_reqs(components, dependency, processed)) 136 return requirements 137 138 139def repository_root() -> str: 140 return os.path.abspath(sys.argv[0] + "/../../../..") 141 142 143# For a package attribute and and an extra, check if the package exposes it via passthru.optional-dependencies 144def has_extra(package: str, extra: str): 145 cmd = [ 146 "nix-instantiate", 147 repository_root(), 148 "-A", 149 f"{package}.optional-dependencies.{extra}", 150 ] 151 try: 152 subprocess.run( 153 cmd, 154 check=True, 155 stdout=subprocess.DEVNULL, 156 stderr=subprocess.DEVNULL, 157 ) 158 except subprocess.CalledProcessError: 159 return False 160 return True 161 162 163def dump_packages() -> Dict[str, Dict[str, str]]: 164 # Store a JSON dump of Nixpkgs' python3Packages 165 output = subprocess.check_output( 166 [ 167 "nix-env", 168 "-f", 169 repository_root(), 170 "-qa", 171 "-A", 172 PKG_SET, 173 "--arg", "config", "{ allowAliases = false; }", 174 "--json", 175 ] 176 ) 177 return json.loads(output) 178 179 180def name_to_attr_path(req: str, packages: Dict[str, Dict[str, str]]) -> Optional[str]: 181 if req in PKG_PREFERENCES: 182 return f"{PKG_SET}.{PKG_PREFERENCES[req]}" 183 attr_paths = [] 184 names = [req] 185 # E.g. python-mpd2 is actually called python3.6-mpd2 186 # instead of python-3.6-python-mpd2 inside Nixpkgs 187 if req.startswith("python-") or req.startswith("python_"): 188 names.append(req[len("python-") :]) 189 for name in names: 190 # treat "-" and "_" equally 191 name = re.sub("[-_]", "[-_]", name) 192 # python(minor).(major)-(pname)-(version or unstable-date) 193 # we need the version qualifier, or we'll have multiple matches 194 # (e.g. pyserial and pyserial-asyncio when looking for pyserial) 195 pattern = re.compile(f"^python\\d+\\.\\d+-{name}-(?:\\d|unstable-.*)", re.I) 196 for attr_path, package in packages.items(): 197 if pattern.match(package["name"]): 198 attr_paths.append(attr_path) 199 # Let's hope there's only one derivation with a matching name 200 assert len(attr_paths) <= 1, f"{req} matches more than one derivation: {attr_paths}" 201 if attr_paths: 202 return attr_paths[0] 203 else: 204 return None 205 206 207def get_pkg_version(attr_path: str, packages: Dict[str, Dict[str, str]]) -> Optional[str]: 208 pkg = packages.get(attr_path, None) 209 if not pkg: 210 return None 211 return pkg["version"] 212 213 214def main() -> None: 215 packages = dump_packages() 216 version = get_version() 217 print("Generating component-packages.nix for version {}".format(version)) 218 components, components_with_tests = parse_components(version=version) 219 build_inputs = {} 220 outdated = {} 221 for component in sorted(components.keys()): 222 attr_paths = [] 223 extra_attrs = [] 224 missing_reqs = [] 225 reqs = sorted(get_reqs(components, component, set())) 226 for req in reqs: 227 # Some requirements are specified by url, e.g. https://example.org/foobar#xyz==1.0.0 228 # Therefore, if there's a "#" in the line, only take the part after it 229 req = req[req.find("#") + 1 :] 230 name, required_version = req.split("==", maxsplit=1) 231 # Strip conditions off version constraints e.g. "1.0; python<3.11" 232 required_version = required_version.split(";").pop(0) 233 # Split package name and extra requires 234 extras = [] 235 if name.endswith("]"): 236 extras = name[name.find("[")+1:name.find("]")].split(",") 237 name = name[:name.find("[")] 238 attr_path = name_to_attr_path(name, packages) 239 if attr_path: 240 if our_version := get_pkg_version(attr_path, packages): 241 attr_name = attr_path.split(".")[-1] 242 attr_outdated = False 243 try: 244 Version.parse(our_version) 245 except InvalidVersion: 246 print(f"Attribute {attr_name} has invalid version specifier {our_version}", file=sys.stderr) 247 248 # allow specifying that our unstable version is newer than some version 249 if newer_than_version := OUR_VERSION_IS_NEWER_THAN.get(attr_name): 250 attr_outdated = Version.parse(newer_than_version) < Version.parse(required_version) 251 else: 252 attr_outdated = True 253 else: 254 attr_outdated = Version.parse(our_version) < Version.parse(required_version) 255 finally: 256 if attr_outdated: 257 outdated[attr_name] = { 258 'wanted': required_version, 259 'current': our_version 260 } 261 if attr_path is not None: 262 # Add attribute path without "python3Packages." prefix 263 pname = attr_path[len(PKG_SET + "."):] 264 attr_paths.append(pname) 265 for extra in extras: 266 # Check if package advertises extra requirements 267 extra_attr = f"{pname}.optional-dependencies.{extra}" 268 if has_extra(attr_path, extra): 269 extra_attrs.append(extra_attr) 270 else: 271 missing_reqs.append(extra_attr) 272 273 else: 274 missing_reqs.append(name) 275 else: 276 build_inputs[component] = (attr_paths, extra_attrs, missing_reqs) 277 278 outpath = os.path.dirname(sys.argv[0]) + "/component-packages.nix" 279 with open(outpath, "w") as f: 280 f.write("# Generated by update-component-packages.py\n") 281 f.write("# Do not edit!\n\n") 282 f.write("{\n") 283 f.write(f' version = "{version}";\n') 284 f.write(" components = {\n") 285 for component, deps in build_inputs.items(): 286 available, extras, missing = deps 287 f.write(f' "{component}" = ps: with ps; [') 288 if available: 289 f.write("\n " + "\n ".join(sorted(available))) 290 f.write("\n ]") 291 if extras: 292 f.write("\n ++ " + "\n ++ ".join(sorted(extras))) 293 f.write(";") 294 if len(missing) > 0: 295 f.write(f" # missing inputs: {' '.join(sorted(missing))}") 296 f.write("\n") 297 f.write(" };\n") 298 f.write(" # components listed in tests/components for which all dependencies are packaged\n") 299 f.write(" supportedComponentsWithTests = [\n") 300 for component, deps in build_inputs.items(): 301 available, extras, missing = deps 302 if len(missing) == 0 and component in components_with_tests: 303 f.write(f' "{component}"' + "\n") 304 f.write(" ];\n") 305 f.write("}\n") 306 307 run_sync(["nixfmt", outpath]) 308 309 supported_components = reduce(lambda n, c: n + (build_inputs[c][2] == []), 310 components.keys(), 0) 311 total_components = len(components) 312 print(f"{supported_components} / {total_components} components supported, " 313 f"i.e. {supported_components / total_components:.2%}") 314 315 if outdated: 316 table = Table(title="Outdated dependencies") 317 table.add_column("Package") 318 table.add_column("Current") 319 table.add_column("Wanted") 320 for package, version in sorted(outdated.items()): 321 table.add_row(package, version['current'], version['wanted']) 322 323 console = Console() 324 console.print(table) 325 326 327if __name__ == "__main__": 328 run_sync(["pyright", __file__]) 329 run_sync(["ruff", "check", "--ignore=E501", __file__]) 330 run_sync(["isort", __file__]) 331 main()