presenterm: 0.9.0 -> 0.10.0 (#378946)
[NixPkgs.git] / pkgs / servers / home-assistant / update.py
blobc75bbc432aa30fe6a5f49d77a3b422f4de97f6e5
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 argparse
5 import asyncio
6 import json
7 import os
8 import re
9 import sys
10 from subprocess import check_output, run
11 from typing import Dict, Final, List, Optional, Union
13 import aiohttp
14 from aiohttp import ClientSession
15 from packaging.version import Version
17 ROOT: Final = check_output([
18 "git",
19 "rev-parse",
20 "--show-toplevel",
21 ]).decode().strip()
24 def run_sync(cmd: List[str]) -> None:
25 print(f"$ {' '.join(cmd)}")
26 process = run(cmd)
28 if process.returncode != 0:
29 sys.exit(1)
32 async def check_async(cmd: List[str]) -> str:
33 print(f"$ {' '.join(cmd)}")
34 process = await asyncio.create_subprocess_exec(
35 *cmd,
36 stdout=asyncio.subprocess.PIPE,
37 stderr=asyncio.subprocess.PIPE
39 stdout, stderr = await process.communicate()
41 if process.returncode != 0:
42 error = stderr.decode()
43 raise RuntimeError(f"{cmd[0]} failed: {error}")
45 return stdout.decode().strip()
48 async def run_async(cmd: List[str]):
49 print(f"$ {' '.join(cmd)}")
51 process = await asyncio.create_subprocess_exec(
52 *cmd,
53 stdout=asyncio.subprocess.PIPE,
54 stderr=asyncio.subprocess.PIPE,
56 stdout, stderr = await process.communicate()
58 print(stdout.decode())
60 if process.returncode != 0:
61 error = stderr.decode()
62 raise RuntimeError(f"{cmd[0]} failed: {error}")
65 class File:
66 def __init__(self, path: str):
67 self.path = os.path.join(ROOT, path)
69 def __enter__(self):
70 with open(self.path, "r") as handle:
71 self.text = handle.read()
72 return self
74 def get_exact_match(self, attr: str, value: str):
75 matches = re.findall(
76 rf'{re.escape(attr)}\s+=\s+\"?{re.escape(value)}\"?',
77 self.text
80 n = len(matches)
81 if n > 1:
82 raise ValueError(f"multiple occurrences found for {attr}={value}")
83 elif n == 1:
84 return matches.pop()
85 else:
86 raise ValueError(f"no occurrence found for {attr}={value}")
88 def substitute(self, attr: str, old_value: str, new_value: str) -> None:
89 old_line = self.get_exact_match(attr, old_value)
90 new_line = old_line.replace(old_value, new_value)
91 self.text = self.text.replace(old_line, new_line)
92 print(f"Substitute `{attr}` value `{old_value}` with `{new_value}`")
94 def __exit__(self, exc_type, exc_val, exc_tb):
95 with open(self.path, "w") as handle:
96 handle.write(self.text)
98 class Nurl:
99 @classmethod
100 async def prefetch(cls, url: str, version: str, *extra_args: str) -> str:
101 cmd = [
102 "nurl",
103 "--hash",
104 url,
105 version,
107 cmd.extend(extra_args)
108 return await check_async(cmd)
111 class Nix:
112 base_cmd: Final = [
113 "nix",
114 "--show-trace",
115 "--extra-experimental-features", "nix-command"
118 @classmethod
119 async def _run(cls, args: List[str]) -> Optional[str]:
120 return await check_async(cls.base_cmd + args)
122 @classmethod
123 async def eval(cls, expr: str) -> Union[List, Dict, int, float, str, bool]:
124 response = await cls._run([
125 "eval",
126 "-f", f"{ROOT}/default.nix",
127 "--json",
128 expr
130 if response is None:
131 raise RuntimeError("Nix eval expression returned no response")
132 try:
133 return json.loads(response)
134 except (TypeError, ValueError):
135 raise RuntimeError("Nix eval response could not be parsed from JSON")
137 @classmethod
138 async def hash_to_sri(cls, algorithm: str, value: str) -> Optional[str]:
139 return await cls._run([
140 "hash",
141 "to-sri",
142 "--type", algorithm,
143 value
147 class HomeAssistant:
148 def __init__(self, session: ClientSession):
149 self._session = session
151 async def get_latest_core_version(
152 self,
153 owner: str = "home-assistant",
154 repo: str = "core"
155 ) -> str:
156 async with self._session.get(
157 f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
158 ) as response:
159 document = await response.json()
160 try:
161 return str(document.get("name"))
162 except KeyError:
163 raise RuntimeError("No tag name in response document")
166 async def get_latest_frontend_version(
167 self,
168 core_version: str
169 ) -> str:
170 async with self._session.get(
171 f"https://raw.githubusercontent.com/home-assistant/core/{core_version}/homeassistant/components/frontend/manifest.json"
172 ) as response:
173 document = await response.json(content_type="text/plain")
175 requirements = [
176 requirement
177 for requirement in document.get("requirements", [])
178 if requirement.startswith("home-assistant-frontend==")
181 if len(requirements) > 1:
182 raise RuntimeError(
183 "Found more than one version specifier for the frontend package"
185 elif len(requirements) == 1:
186 requirement = requirements.pop()
187 _, version = requirement.split("==", maxsplit=1)
188 return str(version)
189 else:
190 raise RuntimeError(
191 "Found no version specifier for frontend package"
195 async def update_core(self, old_version: str, new_version: str) -> None:
196 old_sdist_hash = str(await Nix.eval("home-assistant.sdist.outputHash"))
197 new_sdist_hash = await Nurl.prefetch("https://pypi.org/project/homeassistant/", new_version)
198 print(f"sdist: {old_sdist_hash} -> {new_sdist_hash}")
200 old_git_hash = str(await Nix.eval("home-assistant.src.outputHash"))
201 new_git_hash = await Nurl.prefetch("https://github.com/home-assistant/core/", new_version)
202 print(f"git: {old_git_hash} -> {new_git_hash}")
204 with File("pkgs/servers/home-assistant/default.nix") as file:
205 file.substitute("hassVersion", old_version, new_version)
206 file.substitute("hash", old_sdist_hash, new_sdist_hash)
207 file.substitute("hash", old_git_hash, new_git_hash)
209 async def update_frontend(self, old_version: str, new_version: str) -> None:
210 old_hash = str(await Nix.eval("home-assistant.frontend.src.outputHash"))
211 new_hash = await Nurl.prefetch(
212 "https://pypi.org/project/home_assistant_frontend/",
213 new_version,
214 "-A", "format", "wheel",
215 "-A", "dist", "py3",
216 "-A", "python", "py3"
218 print(f"frontend: {old_hash} -> {new_hash}")
220 with File("pkgs/servers/home-assistant/frontend.nix") as file:
221 file.substitute("version", old_version, new_version)
222 file.substitute("hash", old_hash, new_hash)
224 async def update_components(self):
225 await run_async([
226 f"{ROOT}/pkgs/servers/home-assistant/update-component-packages.py"
230 async def main(target_version: Optional[str] = None):
231 headers = {}
232 if token := os.environ.get("GITHUB_TOKEN", None):
233 headers.update({"GITHUB_TOKEN": token})
235 async with aiohttp.ClientSession(headers=headers) as client:
236 hass = HomeAssistant(client)
238 core_current = str(await Nix.eval("home-assistant.version"))
239 core_latest = target_version or await hass.get_latest_core_version()
241 if Version(core_latest) > Version(core_current):
242 print(f"New Home Assistant version {core_latest} is available")
243 await hass.update_core(str(core_current), str(core_latest))
245 frontend_current = str(await Nix.eval("home-assistant.frontend.version"))
246 frontend_latest = await hass.get_latest_frontend_version(str(core_latest))
248 if Version(frontend_latest) > Version(frontend_current):
249 await hass.update_frontend(str(frontend_current), str(frontend_latest))
251 await hass.update_components()
253 else:
254 print(f"Home Assistant {core_current} is still the latest version.")
256 # wait for async client sessions to close
257 # https://docs.aiohttp.org/en/stable/client_advanced.html#graceful-shutdown
258 await asyncio.sleep(0)
260 if __name__ == "__main__":
261 parser = argparse.ArgumentParser()
262 parser.add_argument("version", nargs="?")
263 args = parser.parse_args()
265 run_sync(["pyright", __file__])
266 run_sync(["ruff", "check", "--ignore=E501", __file__])
267 run_sync(["isort", __file__])
269 asyncio.run(main(args.version))