Clone of https://github.com/NixOS/nixpkgs.git (to stress-test knotserver)
at netboot-syslinux-multiplatform 306 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 nodePackages.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 "ha-av": "av", 44 "HAP-python": "hap-python", 45 "tensorflow": "tensorflow", 46 "youtube_dl": "youtube-dl-light", 47} 48 49# Some dependencies are loaded dynamically at runtime, and are not 50# mentioned in the manifest files. 51EXTRA_COMPONENT_DEPS = { 52 "conversation": [ 53 "intent" 54 ], 55 "default_config": [ 56 "backup", 57 ], 58} 59 60 61 62def run_sync(cmd: List[str]) -> None: 63 print(f"$ {' '.join(cmd)}") 64 process = subprocess.run(cmd) 65 66 if process.returncode != 0: 67 sys.exit(1) 68 69 70def get_version() -> str: 71 with open(os.path.dirname(sys.argv[0]) + "/default.nix") as f: 72 # A version consists of digits, dots, and possibly a "b" (for beta) 73 if match := re.search('hassVersion = "([\\d\\.b]+)";', f.read()): 74 return match.group(1) 75 raise RuntimeError("hassVersion not in default.nix") 76 77 78def parse_components(version: str = "master"): 79 components = {} 80 components_with_tests = [] 81 with tempfile.TemporaryDirectory() as tmp: 82 with urlopen( 83 f"https://github.com/home-assistant/home-assistant/archive/{version}.tar.gz" 84 ) as response: 85 tarfile.open(fileobj=BytesIO(response.read())).extractall(tmp) 86 # Use part of a script from the Home Assistant codebase 87 core_path = os.path.join(tmp, f"core-{version}") 88 89 for entry in os.scandir(os.path.join(core_path, "tests/components")): 90 if entry.is_dir(): 91 components_with_tests.append(entry.name) 92 93 sys.path.append(core_path) 94 from script.hassfest.model import Integration # type: ignore 95 integrations = Integration.load_dir( 96 pathlib.Path( 97 os.path.join(core_path, "homeassistant/components") 98 ) 99 ) 100 for domain in sorted(integrations): 101 integration = integrations[domain] 102 if extra_deps := EXTRA_COMPONENT_DEPS.get(integration.domain): 103 integration.dependencies.extend(extra_deps) 104 if not integration.disabled: 105 components[domain] = integration.manifest 106 107 return components, components_with_tests 108 109 110# Recursively get the requirements of a component and its dependencies 111def get_reqs(components: Dict[str, Dict[str, Any]], component: str, processed: Set[str]) -> Set[str]: 112 requirements = set(components[component].get("requirements", [])) 113 deps = components[component].get("dependencies", []) 114 deps.extend(components[component].get("after_dependencies", [])) 115 processed.add(component) 116 for dependency in deps: 117 if dependency not in processed: 118 requirements.update(get_reqs(components, dependency, processed)) 119 return requirements 120 121 122def repository_root() -> str: 123 return os.path.abspath(sys.argv[0] + "/../../../..") 124 125 126# For a package attribute and and an extra, check if the package exposes it via passthru.optional-dependencies 127def has_extra(package: str, extra: str): 128 cmd = [ 129 "nix-instantiate", 130 repository_root(), 131 "-A", 132 f"{package}.optional-dependencies.{extra}", 133 ] 134 try: 135 subprocess.run( 136 cmd, 137 check=True, 138 stdout=subprocess.DEVNULL, 139 stderr=subprocess.DEVNULL, 140 ) 141 except subprocess.CalledProcessError: 142 return False 143 return True 144 145 146def dump_packages() -> Dict[str, Dict[str, str]]: 147 # Store a JSON dump of Nixpkgs' python3Packages 148 output = subprocess.check_output( 149 [ 150 "nix-env", 151 "-f", 152 repository_root(), 153 "-qa", 154 "-A", 155 PKG_SET, 156 "--arg", "config", "{ allowAliases = false; }", 157 "--json", 158 ] 159 ) 160 return json.loads(output) 161 162 163def name_to_attr_path(req: str, packages: Dict[str, Dict[str, str]]) -> Optional[str]: 164 if req in PKG_PREFERENCES: 165 return f"{PKG_SET}.{PKG_PREFERENCES[req]}" 166 attr_paths = [] 167 names = [req] 168 # E.g. python-mpd2 is actually called python3.6-mpd2 169 # instead of python-3.6-python-mpd2 inside Nixpkgs 170 if req.startswith("python-") or req.startswith("python_"): 171 names.append(req[len("python-") :]) 172 for name in names: 173 # treat "-" and "_" equally 174 name = re.sub("[-_]", "[-_]", name) 175 # python(minor).(major)-(pname)-(version or unstable-date) 176 # we need the version qualifier, or we'll have multiple matches 177 # (e.g. pyserial and pyserial-asyncio when looking for pyserial) 178 pattern = re.compile(f"^python\\d+\\.\\d+-{name}-(?:\\d|unstable-.*)", re.I) 179 for attr_path, package in packages.items(): 180 if pattern.match(package["name"]): 181 attr_paths.append(attr_path) 182 # Let's hope there's only one derivation with a matching name 183 assert len(attr_paths) <= 1, f"{req} matches more than one derivation: {attr_paths}" 184 if attr_paths: 185 return attr_paths[0] 186 else: 187 return None 188 189 190def get_pkg_version(attr_path: str, packages: Dict[str, Dict[str, str]]) -> Optional[str]: 191 pkg = packages.get(attr_path, None) 192 if not pkg: 193 return None 194 return pkg["version"] 195 196 197def main() -> None: 198 packages = dump_packages() 199 version = get_version() 200 print("Generating component-packages.nix for version {}".format(version)) 201 components, components_with_tests = parse_components(version=version) 202 build_inputs = {} 203 outdated = {} 204 for component in sorted(components.keys()): 205 attr_paths = [] 206 extra_attrs = [] 207 missing_reqs = [] 208 reqs = sorted(get_reqs(components, component, set())) 209 for req in reqs: 210 # Some requirements are specified by url, e.g. https://example.org/foobar#xyz==1.0.0 211 # Therefore, if there's a "#" in the line, only take the part after it 212 req = req[req.find("#") + 1 :] 213 name, required_version = req.split("==", maxsplit=1) 214 # Strip conditions off version constraints e.g. "1.0; python<3.11" 215 required_version = required_version.split(";").pop(0) 216 # Split package name and extra requires 217 extras = [] 218 if name.endswith("]"): 219 extras = name[name.find("[")+1:name.find("]")].split(",") 220 name = name[:name.find("[")] 221 attr_path = name_to_attr_path(name, packages) 222 if attr_path: 223 if our_version := get_pkg_version(attr_path, packages): 224 attr_name = attr_path.split(".")[-1] 225 attr_outdated = False 226 try: 227 Version.parse(our_version) 228 except InvalidVersion: 229 print(f"Attribute {attr_name} has invalid version specifier {our_version}", file=sys.stderr) 230 attr_outdated = True 231 else: 232 attr_outdated = Version.parse(our_version) < Version.parse(required_version) 233 finally: 234 if attr_outdated: 235 outdated[attr_name] = { 236 'wanted': required_version, 237 'current': our_version 238 } 239 if attr_path is not None: 240 # Add attribute path without "python3Packages." prefix 241 pname = attr_path[len(PKG_SET + "."):] 242 attr_paths.append(pname) 243 for extra in extras: 244 # Check if package advertises extra requirements 245 extra_attr = f"{pname}.optional-dependencies.{extra}" 246 if has_extra(attr_path, extra): 247 extra_attrs.append(extra_attr) 248 else: 249 missing_reqs.append(extra_attr) 250 251 else: 252 missing_reqs.append(name) 253 else: 254 build_inputs[component] = (attr_paths, extra_attrs, missing_reqs) 255 256 with open(os.path.dirname(sys.argv[0]) + "/component-packages.nix", "w") as f: 257 f.write("# Generated by parse-requirements.py\n") 258 f.write("# Do not edit!\n\n") 259 f.write("{\n") 260 f.write(f' version = "{version}";\n') 261 f.write(" components = {\n") 262 for component, deps in build_inputs.items(): 263 available, extras, missing = deps 264 f.write(f' "{component}" = ps: with ps; [') 265 if available: 266 f.write("\n " + "\n ".join(available)) 267 f.write("\n ]") 268 if extras: 269 f.write("\n ++ " + "\n ++ ".join(extras)) 270 f.write(";") 271 if len(missing) > 0: 272 f.write(f" # missing inputs: {' '.join(missing)}") 273 f.write("\n") 274 f.write(" };\n") 275 f.write(" # components listed in tests/components for which all dependencies are packaged\n") 276 f.write(" supportedComponentsWithTests = [\n") 277 for component, deps in build_inputs.items(): 278 available, extras, missing = deps 279 if len(missing) == 0 and component in components_with_tests: 280 f.write(f' "{component}"' + "\n") 281 f.write(" ];\n") 282 f.write("}\n") 283 284 supported_components = reduce(lambda n, c: n + (build_inputs[c][2] == []), 285 components.keys(), 0) 286 total_components = len(components) 287 print(f"{supported_components} / {total_components} components supported, " 288 f"i.e. {supported_components / total_components:.2%}") 289 290 if outdated: 291 table = Table(title="Outdated dependencies") 292 table.add_column("Package") 293 table.add_column("Current") 294 table.add_column("Wanted") 295 for package, version in sorted(outdated.items()): 296 table.add_row(package, version['current'], version['wanted']) 297 298 console = Console() 299 console.print(table) 300 301 302if __name__ == "__main__": 303 run_sync(["pyright", __file__]) 304 run_sync(["ruff", "--ignore=E501", __file__]) 305 run_sync(["isort", __file__]) 306 main()