rio: 0.0.36 -> 0.0.37
[NixPkgs.git] / pkgs / servers / home-assistant / update.py
blob70eb77ce9a1cd988868c0db72a9c324f7c36374a
1 #!/usr/bin/env nix-shell
2 #!nix-shell -I nixpkgs=channel:nixpkgs-unstable -i python3 -p "python3.withPackages (ps: with ps; [ aiohttp packaging ])" -p git nurl pyright ruff isort
4 import asyncio
5 import json
6 import os
7 import re
8 import sys
9 from subprocess import check_output, run
10 from typing import Dict, Final, List, Optional, Union
12 import aiohttp
13 from aiohttp import ClientSession
14 from packaging.version import Version
16 ROOT: Final = check_output([
17 "git",
18 "rev-parse",
19 "--show-toplevel",
20 ]).decode().strip()
23 def run_sync(cmd: List[str]) -> None:
24 print(f"$ {' '.join(cmd)}")
25 process = run(cmd)
27 if process.returncode != 0:
28 sys.exit(1)
31 async def check_async(cmd: List[str]) -> str:
32 print(f"$ {' '.join(cmd)}")
33 process = await asyncio.create_subprocess_exec(
34 *cmd,
35 stdout=asyncio.subprocess.PIPE,
36 stderr=asyncio.subprocess.PIPE
38 stdout, stderr = await process.communicate()
40 if process.returncode != 0:
41 error = stderr.decode()
42 raise RuntimeError(f"{cmd[0]} failed: {error}")
44 return stdout.decode().strip()
47 async def run_async(cmd: List[str]):
48 print(f"$ {' '.join(cmd)}")
50 process = await asyncio.create_subprocess_exec(
51 *cmd,
52 stdout=asyncio.subprocess.PIPE,
53 stderr=asyncio.subprocess.PIPE,
55 stdout, stderr = await process.communicate()
57 print(stdout.decode())
59 if process.returncode != 0:
60 error = stderr.decode()
61 raise RuntimeError(f"{cmd[0]} failed: {error}")
64 class File:
65 def __init__(self, path: str):
66 self.path = os.path.join(ROOT, path)
68 def __enter__(self):
69 with open(self.path, "r") as handle:
70 self.text = handle.read()
71 return self
73 def get_exact_match(self, attr: str, value: str):
74 matches = re.findall(
75 rf'{re.escape(attr)}\s+=\s+\"?{re.escape(value)}\"?',
76 self.text
79 n = len(matches)
80 if n > 1:
81 raise ValueError(f"multiple occurrences found for {attr}={value}")
82 elif n == 1:
83 return matches.pop()
84 else:
85 raise ValueError(f"no occurrence found for {attr}={value}")
87 def substitute(self, attr: str, old_value: str, new_value: str) -> None:
88 old_line = self.get_exact_match(attr, old_value)
89 new_line = old_line.replace(old_value, new_value)
90 self.text = self.text.replace(old_line, new_line)
91 print(f"Substitute `{attr}` value `{old_value}` with `{new_value}`")
93 def __exit__(self, exc_type, exc_val, exc_tb):
94 with open(self.path, "w") as handle:
95 handle.write(self.text)
97 class Nurl:
98 @classmethod
99 async def prefetch(cls, url: str, version: str, *extra_args: str) -> str:
100 cmd = [
101 "nurl",
102 "--hash",
103 url,
104 version,
106 cmd.extend(extra_args)
107 return await check_async(cmd)
110 class Nix:
111 base_cmd: Final = [
112 "nix",
113 "--show-trace",
114 "--extra-experimental-features", "nix-command"
117 @classmethod
118 async def _run(cls, args: List[str]) -> Optional[str]:
119 return await check_async(cls.base_cmd + args)
121 @classmethod
122 async def eval(cls, expr: str) -> Union[List, Dict, int, float, str, bool]:
123 response = await cls._run([
124 "eval",
125 "-f", f"{ROOT}/default.nix",
126 "--json",
127 expr
129 if response is None:
130 raise RuntimeError("Nix eval expression returned no response")
131 try:
132 return json.loads(response)
133 except (TypeError, ValueError):
134 raise RuntimeError("Nix eval response could not be parsed from JSON")
136 @classmethod
137 async def hash_to_sri(cls, algorithm: str, value: str) -> Optional[str]:
138 return await cls._run([
139 "hash",
140 "to-sri",
141 "--type", algorithm,
142 value
146 class HomeAssistant:
147 def __init__(self, session: ClientSession):
148 self._session = session
150 async def get_latest_core_version(
151 self,
152 owner: str = "home-assistant",
153 repo: str = "core"
154 ) -> str:
155 async with self._session.get(
156 f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
157 ) as response:
158 document = await response.json()
159 try:
160 return str(document.get("name"))
161 except KeyError:
162 raise RuntimeError("No tag name in response document")
165 async def get_latest_frontend_version(
166 self,
167 core_version: str
168 ) -> str:
169 async with self._session.get(
170 f"https://raw.githubusercontent.com/home-assistant/core/{core_version}/homeassistant/components/frontend/manifest.json"
171 ) as response:
172 document = await response.json(content_type="text/plain")
174 requirements = [
175 requirement
176 for requirement in document.get("requirements", [])
177 if requirement.startswith("home-assistant-frontend==")
180 if len(requirements) > 1:
181 raise RuntimeError(
182 "Found more than one version specifier for the frontend package"
184 elif len(requirements) == 1:
185 requirement = requirements.pop()
186 _, version = requirement.split("==", maxsplit=1)
187 return str(version)
188 else:
189 raise RuntimeError(
190 "Found no version specifier for frontend package"
194 async def update_core(self, old_version: str, new_version: str) -> None:
195 old_sdist_hash = str(await Nix.eval("home-assistant.sdist.outputHash"))
196 new_sdist_hash = await Nurl.prefetch("https://pypi.org/project/homeassistant/", new_version)
197 print(f"sdist: {old_sdist_hash} -> {new_sdist_hash}")
199 old_git_hash = str(await Nix.eval("home-assistant.src.outputHash"))
200 new_git_hash = await Nurl.prefetch("https://github.com/home-assistant/core/", new_version)
201 print(f"git: {old_git_hash} -> {new_git_hash}")
203 with File("pkgs/servers/home-assistant/default.nix") as file:
204 file.substitute("hassVersion", old_version, new_version)
205 file.substitute("hash", old_sdist_hash, new_sdist_hash)
206 file.substitute("hash", old_git_hash, new_git_hash)
208 async def update_frontend(self, old_version: str, new_version: str) -> None:
209 old_hash = str(await Nix.eval("home-assistant.frontend.src.outputHash"))
210 new_hash = await Nurl.prefetch(
211 "https://pypi.org/project/home_assistant_frontend/",
212 new_version,
213 "-A", "format", "wheel",
214 "-A", "dist", "py3",
215 "-A", "python", "py3"
217 print(f"frontend: {old_hash} -> {new_hash}")
219 with File("pkgs/servers/home-assistant/frontend.nix") as file:
220 file.substitute("version", old_version, new_version)
221 file.substitute("hash", old_hash, new_hash)
223 async def update_components(self):
224 await run_async([
225 f"{ROOT}/pkgs/servers/home-assistant/update-component-packages.py"
229 async def main():
230 headers = {}
231 if token := os.environ.get("GITHUB_TOKEN", None):
232 headers.update({"GITHUB_TOKEN": token})
234 async with aiohttp.ClientSession(headers=headers) as client:
235 hass = HomeAssistant(client)
237 core_current = str(await Nix.eval("home-assistant.version"))
238 core_latest = await hass.get_latest_core_version()
240 if Version(core_latest) > Version(core_current):
241 print(f"New Home Assistant version {core_latest} is available")
242 await hass.update_core(str(core_current), str(core_latest))
244 frontend_current = str(await Nix.eval("home-assistant.frontend.version"))
245 frontend_latest = await hass.get_latest_frontend_version(str(core_latest))
247 if Version(frontend_latest) > Version(frontend_current):
248 await hass.update_frontend(str(frontend_current), str(frontend_latest))
250 await hass.update_components()
252 else:
253 print(f"Home Assistant {core_current} is still the latest version.")
255 # wait for async client sessions to close
256 # https://docs.aiohttp.org/en/stable/client_advanced.html#graceful-shutdown
257 await asyncio.sleep(0)
259 if __name__ == "__main__":
260 run_sync(["pyright", __file__])
261 run_sync(["ruff", "check", "--ignore=E501", __file__])
262 run_sync(["isort", __file__])
263 asyncio.run(main())