diff --git a/CHANGELOG.md b/CHANGELOG.md index 270705ef..faca3c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ For releases before v3.3.5, see the [Full Change Log](https://github.com/asantag ### Changed -- Updated the `aioWiserHeatAPI` dependency from v1.7.3 to v1.7.5, using its native room heat-demand and button-panel battery support. Release builds remain pinned to the released API version, while normal dev-channel builds use the API `dev` branch - PR [#754](https://github.com/asantaga/wiserHomeAssistantPlatform/pull/754). +- Updated the `aioWiserHeatAPI` dependency from v1.7.3 to v1.7.5, using its native room heat-demand and button-panel battery support. Dev builds prefer a published API prerelease and fall back to the released API pin; local builds can explicitly use a manually installed API, while final integration releases always use the released pin - PR [#754](https://github.com/asantaga/wiserHomeAssistantPlatform/pull/754). ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43fc12cb..92290f66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,6 +116,12 @@ key`. ## Building a package ```sh +# Local development package +python3 scripts/build.py --channel dev + +# Local development package using a manually installed API +python3 scripts/build.py --channel dev --use-installed-api + # Stable release package python3 scripts/build.py --release --channel stable @@ -132,13 +138,18 @@ integration; without that option it offers stable releases only. See [Adding frontend cards and panels](docs/frontend-registry.md) for the registry format and shared panel API. -The source manifest pins the released `aioWiserHeatAPI` version. During a dev -channel build, the package installs the upstream API `dev` branch from GitHub. -Stable packages, including explicit `--release` builds, retain the released -version pin. The `--release` option controls frontend asset selection only. API -artifacts are built separately in the `aioWiserHeatAPI` repository; they are -not bundled in the integration package. The source manifest itself is never -modified by the build. +The source manifest pins the released `aioWiserHeatAPI` version. A dev-channel +build uses the newest published API prerelease that is newer than the released +pin; if none is available, or PyPI cannot be checked, it retains the released +pin. Pass `--use-installed-api` for local testing with a manually installed API; +this removes the API requirement from the packaged manifest so Home Assistant +does not replace the installed version. The option cannot be combined with +`--release`. A final release package (`--release --channel stable`) always +retains the released pin. + +API artifacts are built separately in the `aioWiserHeatAPI` repository and are +never bundled in the integration package. All API requirement changes apply +only to the staged package; the source manifest is never modified by the build. The output is `dist/wiser.zip`, with `dist/card-releases.json` recording the frontend artifacts and checksums included. If an individual card or panel diff --git a/docs/frontend-registry.md b/docs/frontend-registry.md index 228335d7..10f13e33 100644 --- a/docs/frontend-registry.md +++ b/docs/frontend-registry.md @@ -101,6 +101,15 @@ GitHub/ python3 scripts/build.py --channel dev ``` +This normal dev build selects a published API prerelease when one is available, +falling back to the released API pin. To use an API wheel installed manually in +Home Assistant instead, build with `--use-installed-api`; this deliberately +omits the API requirement from the packaged manifest: + +```sh +python3 scripts/build.py --channel dev --use-installed-api +``` + To test installation through Home Assistant instead, edit the installed `custom_components/wiser/frontend/cards.json` and run **Check for updates** on any Wiser frontend update entity. The shared coordinator reloads and validates the file, creates an update entity for the new definition, and offers it as installed version `0.0.0`. This does not require an integration rebuild or Home Assistant restart. To build a package containing the definitions but none of the JavaScript bundles, run: diff --git a/scripts/build.py b/scripts/build.py index 8378d0f2..e6813cf0 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -5,8 +5,11 @@ from pathlib import Path import shutil import tempfile +from urllib.request import Request, urlopen from zipfile import ZIP_DEFLATED, ZipFile +from packaging.version import InvalidVersion, Version + from fetch_card_releases import ( fetch_cards, repository_overrides, @@ -16,17 +19,11 @@ ROOT = Path(__file__).resolve().parents[1] API_PACKAGE = "aioWiserHeatAPI" -API_DEV_FALLBACK_REQUIREMENT = ( - f"{API_PACKAGE}@git+https://github.com/msp1974/aioWiserHeatAPI.git@dev" -) - +API_RELEASES_URL = "https://pypi.org/pypi/aioWiserHeatAPI/json" -def set_api_requirement(manifest_path, channel, release=False): - """Use API dev only for non-release dev-channel packages.""" - if release or channel != "dev": - return - manifest = json.loads(manifest_path.read_text("utf-8")) +def api_requirement(manifest): + """Return the released API requirement and its position.""" requirements = manifest.get("requirements", []) matches = [ index @@ -35,9 +32,64 @@ def set_api_requirement(manifest_path, channel, release=False): ] if len(matches) != 1: raise ValueError( - f"Expected exactly one {API_PACKAGE} requirement in {manifest_path}" + f"Expected exactly one {API_PACKAGE} requirement" ) - requirements[matches[0]] = API_DEV_FALLBACK_REQUIREMENT + requirement = requirements[matches[0]] + prefix = f"{API_PACKAGE}==" + if not requirement.startswith(prefix): + raise ValueError(f"Expected a released {API_PACKAGE} version pin") + version = Version(requirement.removeprefix(prefix)) + if version.is_prerelease or version.is_devrelease: + raise ValueError(f"Expected a stable {API_PACKAGE} version pin") + return requirements, matches[0], version + + +def latest_api_prerelease(released_version): + """Return the newest usable API prerelease newer than the released pin.""" + request = Request(API_RELEASES_URL, headers={"User-Agent": "wiser-packager"}) + try: + with urlopen(request, timeout=30) as response: + releases = json.load(response).get("releases", {}) + except (OSError, ValueError): + return None + + candidates = [] + for value, files in releases.items(): + try: + version = Version(value) + except InvalidVersion: + continue + if ( + version > released_version + and version.is_prerelease + and any(not file.get("yanked", False) for file in files) + ): + candidates.append(version) + return max(candidates) if candidates else None + + +def set_api_requirement( + manifest_path, + channel, + release=False, + use_installed_api=False, +): + """Select API handling for local, prerelease, and release packages.""" + if release and use_installed_api: + raise ValueError("--use-installed-api cannot be combined with --release") + + manifest = json.loads(manifest_path.read_text("utf-8")) + requirements, index, released_version = api_requirement(manifest) + + if use_installed_api: + # Explicit local testing uses whichever API is already installed. + requirements.pop(index) + elif channel == "dev": + # Dev builds prefer a published API prerelease. If none is available + # (or PyPI is unavailable), retain the released version pin. + if prerelease := latest_api_prerelease(released_version): + requirements[index] = f"{API_PACKAGE}=={prerelease}" + manifest_path.write_text(f"{json.dumps(manifest, indent=2)}\n", encoding="utf-8") @@ -49,6 +101,7 @@ def build( repositories=None, release=False, without_frontend=False, + use_installed_api=False, ): """Stage a fresh integration and replace the ZIP only after a successful build.""" # Stable builds and explicit releases must never package local card builds. @@ -65,6 +118,7 @@ def build( staging / "manifest.json", channel, release=release, + use_installed_api=use_installed_api, ) manifest = validate_registry( json.loads((staging / "frontend/cards.json").read_text("utf-8")) @@ -100,8 +154,14 @@ def build( def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--channel", choices=["dev", "stable"], default="dev") - parser.add_argument("--release", action="store_true", - help="Use only GitHub release assets, including for the dev channel") + parser.add_argument( + "--release", + action="store_true", + help=( + "Build a release package using published frontend assets and " + "release-aware API selection" + ), + ) parser.add_argument("--local-root", type=Path, default=ROOT.parent, help="Directory containing sibling card repositories") parser.add_argument("--output", type=Path, default=ROOT / "dist/wiser.zip") @@ -111,6 +171,14 @@ def main(): action="store_true", help="Package cards.json without any card or panel JavaScript bundles", ) + parser.add_argument( + "--use-installed-api", + action="store_true", + help=( + "Omit the API requirement and use the API already installed in " + "Home Assistant (local builds only)" + ), + ) args = parser.parse_args() try: report = build( @@ -119,6 +187,7 @@ def main(): repository_overrides(args.repository), release=args.release, without_frontend=args.without_frontend, + use_installed_api=args.use_installed_api, ) except Exception as error: parser.exit(1, f"Build failed: {error}\n") diff --git a/tests/requirements.txt b/tests/requirements.txt index 34f2539d..db396607 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,3 +1,4 @@ # Runtime helpers used by card-update tests; Home Assistant itself is stubbed. aiohttp>=3.11,<4 awesomeversion>=24.6,<26 +packaging>=24,<27 diff --git a/tests/test_build.py b/tests/test_build.py index f2a892c9..ca3f31d5 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,6 +1,7 @@ """Test automatic card source selection and integration packaging.""" import importlib.util +from io import StringIO import json from pathlib import Path import sys @@ -71,7 +72,14 @@ def local(self, card): return path def build(self): - return BUILD.build(self.source, self.output, self.root, "dev", CARD_REPOSITORIES) + return BUILD.build( + self.source, + self.output, + self.root, + "dev", + CARD_REPOSITORIES, + use_installed_api=True, + ) def filename(self, card): return next( @@ -82,7 +90,25 @@ def packaged_manifest(self): with ZipFile(self.output) as archive: return json.loads(archive.read("manifest.json")) - def test_dev_build_uses_upstream_api_dev_even_with_local_checkout(self): + def test_latest_api_prerelease_uses_newest_non_yanked_version(self): + response = StringIO(json.dumps({"releases": { + "1.7.5": [{"yanked": False}], + "1.7.6b1": [{"yanked": False}], + "1.7.7b1": [{"yanked": True}], + "not-a-version": [{"yanked": False}], + }})) + with patch.object(BUILD, "urlopen", return_value=response): + selected = BUILD.latest_api_prerelease(BUILD.Version("1.7.5")) + + self.assertEqual(selected, BUILD.Version("1.7.6b1")) + + def test_latest_api_prerelease_falls_back_when_pypi_is_unavailable(self): + with patch.object(BUILD, "urlopen", side_effect=OSError("offline")): + selected = BUILD.latest_api_prerelease(BUILD.Version("1.7.5")) + + self.assertIsNone(selected) + + def test_local_dev_build_uses_whichever_api_is_installed(self): api_root = self.root / "aioWiserHeatAPI" api_root.mkdir() (api_root / "setup.py").touch() @@ -92,10 +118,7 @@ def test_dev_build_uses_upstream_api_dev_even_with_local_checkout(self): self.assertEqual( self.packaged_manifest()["requirements"], - [ - "aioWiserHeatAPI@git+https://github.com/msp1974/" - "aioWiserHeatAPI.git@dev" - ], + [], ) self.assertEqual( json.loads((self.source / "manifest.json").read_text())["requirements"], @@ -106,7 +129,7 @@ def test_dev_build_uses_upstream_api_dev_even_with_local_checkout(self): any(name.startswith("vendor/") for name in archive.namelist()) ) - def test_stable_build_keeps_released_api_version(self): + def test_local_stable_build_uses_whichever_api_is_installed(self): with patch.object(FETCH, "list_releases", return_value=[]): BUILD.build( self.source, @@ -114,35 +137,72 @@ def test_stable_build_keeps_released_api_version(self): self.root, "stable", CARD_REPOSITORIES, + use_installed_api=True, ) self.assertEqual( self.packaged_manifest()["requirements"], - ["aioWiserHeatAPI==1.7.5"], + [], ) - def test_dev_release_build_keeps_released_api_version(self): - with patch.object(FETCH, "list_releases", return_value=[]): + def test_dev_build_prefers_published_api_prerelease(self): + with patch.object(FETCH, "list_releases", return_value=[]), patch.object( + BUILD, "latest_api_prerelease", return_value=BUILD.Version("1.7.6b1") + ): BUILD.build( self.source, self.output, self.root, "dev", CARD_REPOSITORIES, - release=True, ) self.assertEqual( self.packaged_manifest()["requirements"], - ["aioWiserHeatAPI==1.7.5"], + ["aioWiserHeatAPI==1.7.6b1"], ) with ZipFile(self.output) as archive: self.assertFalse( any(name.startswith("vendor/") for name in archive.namelist()) ) - def test_stable_release_build_keeps_released_api_version(self): - with patch.object(FETCH, "list_releases", return_value=[]): + def test_dev_build_falls_back_to_released_api_pin(self): + with patch.object(FETCH, "list_releases", return_value=[]), patch.object( + BUILD, "latest_api_prerelease", return_value=None + ): + BUILD.build( + self.source, + self.output, + self.root, + "dev", + CARD_REPOSITORIES, + ) + + self.assertEqual( + self.packaged_manifest()["requirements"], + ["aioWiserHeatAPI==1.7.5"], + ) + + def test_release_build_rejects_installed_api_flag(self): + with self.assertRaisesRegex( + ValueError, "--use-installed-api cannot be combined with --release" + ): + BUILD.build( + self.source, + self.output, + self.root, + "dev", + CARD_REPOSITORIES, + release=True, + use_installed_api=True, + ) + + self.assertFalse(self.output.exists()) + + def test_stable_release_build_keeps_released_api_pin(self): + with patch.object(FETCH, "list_releases", return_value=[]), patch.object( + BUILD, "latest_api_prerelease" + ) as prerelease: BUILD.build( self.source, self.output, @@ -156,6 +216,7 @@ def test_stable_release_build_keeps_released_api_version(self): self.packaged_manifest()["requirements"], ["aioWiserHeatAPI==1.7.5"], ) + prerelease.assert_not_called() def test_local_bundles_win_without_network_and_tracked_files_are_untouched(self): for card in self.payloads: @@ -206,6 +267,7 @@ def test_without_frontend_keeps_registry_and_omits_every_bundle(self): "dev", CARD_REPOSITORIES, without_frontend=True, + use_installed_api=True, ) releases.assert_not_called() @@ -256,7 +318,13 @@ def test_bundled_registry_adds_an_unknown_card_and_repository(self): bundle.parent.mkdir(parents=True) bundle.write_bytes(b"customElements.define('wiser-electricity-card',class{});customElements.define('wiser-electricity-panel',class{})") with patch.object(FETCH, "list_releases") as releases: - report = BUILD.build(self.source, self.output, self.root, "dev") + report = BUILD.build( + self.source, + self.output, + self.root, + "dev", + use_installed_api=True, + ) releases.assert_not_called() self.assertEqual(report[-1]["repository"], "example/monitoring") with ZipFile(self.output) as archive: @@ -326,7 +394,9 @@ def test_release_builds_ignore_local_bundles_for_both_channels(self): "sha256:rooms", ), (self.payloads["hub"], "sha256:hub"), - ]) as download: + ]) as download, patch.object( + BUILD, "latest_api_prerelease", return_value=None + ): report = BUILD.build( self.source, self.output, self.root, channel, CARD_REPOSITORIES, release=explicit_release, @@ -379,7 +449,11 @@ def test_stable_build_omits_an_unavailable_published_bundle(self): def test_release_failure_omits_bundles_without_falling_back_to_local(self): for card in self.payloads: self.local(card) - with patch.object(FETCH, "list_releases", side_effect=ValueError("unavailable")): + with patch.object( + FETCH, "list_releases", side_effect=ValueError("unavailable") + ), patch.object( + BUILD, "latest_api_prerelease", return_value=None + ): report = BUILD.build( self.source, self.output, self.root, "dev", CARD_REPOSITORIES, release=True,