diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf9d47b..235dc610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `pyodide xbuildenv search --all` and `pyodide xbuildenv search --all --json`. [#349](https://github.com/pyodide/pyodide-build/pull/349) +- `pyodide xbuildenv search` and `pyodide xbuildenv install` now support `--nightly` + and `--debug` flags to search and install nightly and nightly-debug cross-build + environments respectively. + [#350](https://github.com/pyodide/pyodide-build/pull/350) + ## [0.34.4] - 2026/05/15 ### Added diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md index ef003610..e86c4be0 100644 --- a/docs/getting-started/concepts.md +++ b/docs/getting-started/concepts.md @@ -29,9 +29,10 @@ When you run `pyodide build`, pyodide-build automatically downloads and sets up You can also manage the cross-build environment explicitly: ```bash -pyodide xbuildenv install # install (or update) the cross-build environment -pyodide xbuildenv install 0.29.3 # install a specific Pyodide version -pyodide xbuildenv versions # list installed versions +pyodide xbuildenv install # install (or update) the cross-build environment +pyodide xbuildenv install 0.29.3 # install a specific Pyodide version +pyodide xbuildenv install --nightly # install the latest nightly release +pyodide xbuildenv versions # list installed versions ``` See [Managing Cross-Build Environments](../how-to/xbuildenv.md) for more details. diff --git a/docs/how-to/xbuildenv.md b/docs/how-to/xbuildenv.md index f14f6751..f2bdc080 100644 --- a/docs/how-to/xbuildenv.md +++ b/docs/how-to/xbuildenv.md @@ -24,6 +24,15 @@ pyodide xbuildenv install --url https://example.com/xbuildenv-0.27.0.tar # Force install even if version compatibility check fails pyodide xbuildenv install --force + +# Install the latest nightly release +pyodide xbuildenv install --nightly + +# Install a specific nightly version +pyodide xbuildenv install 20260520 --nightly + +# Install the debug variant of the latest nightly release +pyodide xbuildenv install --debug ``` ## Listing installed versions @@ -68,9 +77,18 @@ pyodide xbuildenv uninstall 0.29.3 # Show versions compatible with your Python and pyodide-build pyodide xbuildenv search -# Show all available versions +# Show all available versions (including incompatible ones) pyodide xbuildenv search --all +# Search nightly releases +pyodide xbuildenv search --nightly + +# Search nightly debug releases +pyodide xbuildenv search --debug + +# Combine flags: show all nightly and debug releases +pyodide xbuildenv search --nightly --debug --all + # Output as JSON (useful for scripting) pyodide xbuildenv search --json ``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7a955adc..3a768cbc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -103,6 +103,8 @@ pyodide xbuildenv install [OPTIONS] [VERSION] | `--path` | `PYODIDE_XBUILDENV_PATH` | Destination directory | | `--url` | | Download from a custom URL | | `-f`, `--force` | | Force install even if version is incompatible | +| `--nightly` | | Install a nightly release instead of a stable one | +| `--debug` | | Install the debug variant of a nightly or stable release, as available | ### pyodide xbuildenv version @@ -144,6 +146,8 @@ pyodide xbuildenv search [OPTIONS] | Option | Description | |---|---| -| `--metadata` | Custom metadata file URL or path | +| `--metadata` | Custom metadata file URL or path (cannot be combined with `--nightly`/`--debug`) | | `-a`, `--all` | Show all versions, including incompatible ones | +| `--nightly` | Search nightly releases instead of stable ones | +| `--debug` | Search nightly debug releases instead of stable ones | | `--json` | Output as JSON | diff --git a/pyodide_build/cli/xbuildenv.py b/pyodide_build/cli/xbuildenv.py index ebd0564b..47e4d8c0 100644 --- a/pyodide_build/cli/xbuildenv.py +++ b/pyodide_build/cli/xbuildenv.py @@ -7,6 +7,8 @@ from pyodide_build.views import MetadataView from pyodide_build.xbuildenv import CrossBuildEnvManager from pyodide_build.xbuildenv_releases import ( + NIGHTLY_CROSS_BUILD_ENV_METADATA_URL, + NIGHTLY_DEBUG_CROSS_BUILD_ENV_METADATA_URL, cross_build_env_metadata_url, load_cross_build_env_metadata, ) @@ -51,6 +53,18 @@ def check_xbuildenv_root(path: Path) -> None: default=False, help="force installation even if the version is not compatible.", ) +@click.option( + "--nightly", + is_flag=True, + default=False, + help="install a nightly cross-build environment instead of a stable release.", +) +@click.option( + "--debug", + is_flag=True, + default=False, + help="install the debug variant of the cross-build environment (nightly only).", +) @click.option( "--skip-cross-build-packages", is_flag=True, @@ -65,6 +79,8 @@ def _install( path: Path, url: str | None, force_install: bool, + nightly: bool, + debug: bool, skip_cross_build_packages: bool, ) -> None: """Install cross-build environment. @@ -78,18 +94,21 @@ def _install( Arguments: VERSION: version of cross-build environment to install (optional) """ - manager = CrossBuildEnvManager(path) + if nightly or debug: + metadata_url = ( + NIGHTLY_DEBUG_CROSS_BUILD_ENV_METADATA_URL + if debug + else NIGHTLY_CROSS_BUILD_ENV_METADATA_URL + ) + else: + metadata_url = None + + manager = CrossBuildEnvManager(path, metadata_url=metadata_url) if url: - manager.install( - url=url, - force_install=force_install, - ) + manager.install(url=url, force_install=force_install) else: - manager.install( - version=version, - force_install=force_install, - ) + manager.install(version=version, force_install=force_install) click.echo(f"Pyodide cross-build environment installed at {path.resolve()}") @@ -194,6 +213,18 @@ def _use(version: str, path: Path) -> None: default=False, help="search all versions, without filtering out incompatible ones.", ) +@click.option( + "--nightly", + is_flag=True, + default=False, + help="search nightly releases instead of stable ones.", +) +@click.option( + "--debug", + is_flag=True, + default=False, + help="search nightly debug releases instead of stable ones.", +) @click.option( "--json", "json_output", @@ -204,32 +235,30 @@ def _use(version: str, path: Path) -> None: def _search( metadata_path: str | None, show_all: bool, + nightly: bool, + debug: bool, json_output: bool, ) -> None: """Search for available versions of cross-build environment.""" # TODO: cache the metadata file somewhere to avoid downloading it every time - metadata_path = metadata_path or cross_build_env_metadata_url() - metadata = load_cross_build_env_metadata(metadata_path) + if metadata_path and (nightly or debug): + click.echo("--metadata cannot be combined with --nightly or --debug") + raise SystemExit(1) + local = local_versions() - if show_all: - releases = metadata.list_compatible_releases() - else: - releases = metadata.list_compatible_releases( - python_version=local["python"], - pyodide_build_version=local["pyodide-build"], - ) + def _compat_kwargs() -> dict: + if show_all: + return {} + return { + "python_version": local["python"], + "pyodide_build_version": local["pyodide-build"], + } - if not releases: - click.echo( - "No compatible cross-build environment found for your system. Try using --all to see all versions." - ) - raise SystemExit(1) - - views = [ - MetadataView( + def _make_view(release, source: str = "stable") -> MetadataView: + return MetadataView( version=release.version, python=release.python_version, emscripten=release.emscripten_version, @@ -238,18 +267,57 @@ def _search( "max": release.max_pyodide_build_version, }, published_at=release.published_at, + source=source, compatible=release.is_compatible( python_version=local["python"], pyodide_build_version=local["pyodide-build"], ), ) - for release in releases - ] + if nightly or debug: + # Nightly and/or debug releases (mutually exclusive with stable) + sources = [] + if nightly: + sources.append(("nightly", NIGHTLY_CROSS_BUILD_ENV_METADATA_URL)) + if debug: + sources.append( + ("nightly-debug", NIGHTLY_DEBUG_CROSS_BUILD_ENV_METADATA_URL) + ) + compat = _compat_kwargs() + views = [ + _make_view(release, source) + for source, url in sources + for release in sorted( + ( + release + for release in load_cross_build_env_metadata(url).releases.values() + if release.is_compatible(**compat) + ), + key=lambda release: release.published_at, + reverse=True, + ) + ] + else: + # Stable releases + stable_metadata = load_cross_build_env_metadata( + metadata_path or cross_build_env_metadata_url() + ) + views = [ + _make_view(r, "stable") + for r in stable_metadata.list_compatible_releases(**_compat_kwargs()) + ] + + if not views: + click.echo( + "No compatible cross-build environment found for your system. Try using --all to see all versions." + ) + raise SystemExit(1) + + show_source = nightly or debug if json_output: - print(MetadataView.to_json(views)) + print(MetadataView.to_json(views, show_source=show_source)) else: - print(MetadataView.to_table(views)) + print(MetadataView.to_table(views, show_source=show_source)) @app.command("install-emscripten") diff --git a/pyodide_build/tests/test_cli_xbuildenv.py b/pyodide_build/tests/test_cli_xbuildenv.py index eda434e9..d5af4557 100644 --- a/pyodide_build/tests/test_cli_xbuildenv.py +++ b/pyodide_build/tests/test_cli_xbuildenv.py @@ -183,6 +183,88 @@ def test_xbuildenv_install_force_install( os.environ.pop(CROSS_BUILD_ENV_METADATA_URL_ENV_VAR, None) +def test_xbuildenv_install_nightly(tmp_path, mock_xbuildenv_url, monkeypatch): + """Installing with --nightly uses the nightly metadata URL, not stable.""" + from pyodide_build import build_env + + envpath = Path(tmp_path) / ".xbuildenv" + local = build_env.local_versions() + + nightly_data = { + "releases": { + "20260520": { + "version": "20260520", + "url": mock_xbuildenv_url, + "sha256": None, + "python_version": f"{local['python']}.0", + "emscripten_version": "5.0.3", + "published_at": "2026-05-20T04:40:12Z", + "min_pyodide_build_version": None, + "max_pyodide_build_version": None, + } + } + } + metadata_path = tmp_path / "nightly.json" + metadata_path.write_text(json.dumps(nightly_data)) + + monkeypatch.setattr( + "pyodide_build.cli.xbuildenv.NIGHTLY_CROSS_BUILD_ENV_METADATA_URL", + str(metadata_path), + ) + + result = runner.invoke( + xbuildenv.app, + ["install", "20260520", "--path", str(envpath), "--nightly"], + ) + + assert result.exit_code == 0, result.output + assert "Pyodide cross-build environment installed at" in result.output + assert str(envpath.resolve()) in result.output + assert (envpath / "xbuildenv").is_symlink() + assert (envpath / "20260520").exists() + + +def test_xbuildenv_install_debug(tmp_path, mock_xbuildenv_url, monkeypatch): + """Installing with --debug uses the nightly-debug metadata URL, not stable.""" + from pyodide_build import build_env + + envpath = Path(tmp_path) / ".xbuildenv" + local = build_env.local_versions() + + debug_data = { + "releases": { + "20260520": { + "version": "20260520", + "url": mock_xbuildenv_url, + "sha256": None, + "python_version": f"{local['python']}.0", + "emscripten_version": "5.0.3", + "published_at": "2026-05-20T04:40:12Z", + "min_pyodide_build_version": None, + "max_pyodide_build_version": None, + } + } + } + metadata_path = tmp_path / "nightly-debug.json" + metadata_path.write_text(json.dumps(debug_data)) + + monkeypatch.setattr( + "pyodide_build.cli.xbuildenv.NIGHTLY_DEBUG_CROSS_BUILD_ENV_METADATA_URL", + str(metadata_path), + ) + + result = runner.invoke( + xbuildenv.app, + ["install", "20260520", "--path", str(envpath), "--debug"], + ) + + assert result.exit_code == 0, result.output + assert "Pyodide cross-build environment installed at" in result.output + assert str(envpath.resolve()) in result.output + assert (envpath / "xbuildenv").is_symlink() + assert (envpath / "20260520").exists() + + def test_xbuildenv_version(tmp_path): envpath = Path(tmp_path) / ".xbuildenv" @@ -421,3 +503,173 @@ def test_xbuildenv_search_json(tmp_path, fake_xbuildenv_releases_compatible): assert any(env["compatible"] for env in output["environments"]), ( "There should be at least one compatible environment" ) + + +@pytest.fixture +def fake_nightly_release_metadata(tmp_path): + """Fake nightly release metadata (non-debug), two entries: one compatible, one not.""" + from pyodide_build import build_env + + local = build_env.local_versions() + data = { + "releases": { + "20260520": { + "version": "20260520", + "url": "https://example.com/20260520/xbuildenv.tar.bz2", + "sha256": "abc123", + "python_version": f"{local['python']}.0", + "emscripten_version": "5.0.3", + "published_at": "2026-05-20T04:40:12Z", + "min_pyodide_build_version": "0.26.0", + "max_pyodide_build_version": None, + }, + "20250101": { + "version": "20250101", + "url": "https://example.com/20250101/xbuildenv.tar.bz2", + "sha256": "def456", + "python_version": "3.12.0", + "emscripten_version": "3.1.58", + "published_at": "2025-01-01T02:53:30Z", + "min_pyodide_build_version": "0.26.0", + "max_pyodide_build_version": None, + }, + } + } + path = tmp_path / "nightly-release.json" + path.write_text(json.dumps(data)) + return path + + +@pytest.fixture +def fake_nightly_debug_metadata(tmp_path): + """Fake nightly debug metadata — only entries that have a debug build.""" + from pyodide_build import build_env + + local = build_env.local_versions() + data = { + "releases": { + "20260520": { + "version": "20260520", + "url": "https://example.com/20260520/xbuildenv-debug.tar.bz2", + "sha256": "debug_abc123", + "python_version": f"{local['python']}.0", + "emscripten_version": "5.0.3", + "published_at": "2026-05-20T04:40:12Z", + "min_pyodide_build_version": "0.26.0", + "max_pyodide_build_version": None, + }, + } + } + path = tmp_path / "nightly-debug.json" + path.write_text(json.dumps(data)) + return path + + +def test_xbuildenv_search_nightly( + tmp_path, + fake_nightly_release_metadata, + monkeypatch, +): + monkeypatch.setattr( + "pyodide_build.cli.xbuildenv.NIGHTLY_CROSS_BUILD_ENV_METADATA_URL", + str(fake_nightly_release_metadata), + ) + + result = runner.invoke( + xbuildenv.app, + [ + "search", + "--nightly", + "--all", + ], + ) + + assert result.exit_code == 0, result.output + + lines = result.output.splitlines() + header = lines[1].strip().split("│")[1:-1] + assert [col.strip() for col in header] == [ + "Version", + "Python", + "Emscripten", + "pyodide-build", + "Published", + "Compatible", + "Source", + ] + + # Only nightly entries should be present. Stable versions are not mixed in. + assert "0.1.0" not in result.output + assert "0.2.0" not in result.output + assert "20260520" in result.output + assert "20250101" in result.output + assert "stable" not in result.output + assert "nightly" in result.output + + +def test_xbuildenv_search_debug( + tmp_path, + fake_nightly_debug_metadata, + monkeypatch, +): + monkeypatch.setattr( + "pyodide_build.cli.xbuildenv.NIGHTLY_DEBUG_CROSS_BUILD_ENV_METADATA_URL", + str(fake_nightly_debug_metadata), + ) + + result = runner.invoke( + xbuildenv.app, + [ + "search", + "--debug", + "--all", + ], + ) + + assert result.exit_code == 0, result.output + + # Only nightly-debug entries should be present. Stable and nightly-release versions + # are not mixed in. 20250101 is absent because it has no debug build (not in the + # debug metadata file). + assert "stable" not in result.output + assert "nightly-debug" in result.output + assert "20260520" in result.output + assert "20250101" not in result.output + + +def test_xbuildenv_search_nightly_json( + tmp_path, + fake_nightly_release_metadata, + fake_nightly_debug_metadata, + monkeypatch, +): + monkeypatch.setattr( + "pyodide_build.cli.xbuildenv.NIGHTLY_CROSS_BUILD_ENV_METADATA_URL", + str(fake_nightly_release_metadata), + ) + monkeypatch.setattr( + "pyodide_build.cli.xbuildenv.NIGHTLY_DEBUG_CROSS_BUILD_ENV_METADATA_URL", + str(fake_nightly_debug_metadata), + ) + + result = runner.invoke( + xbuildenv.app, + [ + "search", + "--nightly", + "--debug", + "--all", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + assert is_valid_json(result.output) + + output = json.loads(result.output) + sources = {env["source"] for env in output["environments"]} + assert sources == {"nightly", "nightly-debug"} + + for env in output["environments"]: + assert "debug_url" not in env + assert "debug_sha256" not in env diff --git a/pyodide_build/views.py b/pyodide_build/views.py index 3968a9e8..5771a0c3 100644 --- a/pyodide_build/views.py +++ b/pyodide_build/views.py @@ -3,7 +3,7 @@ import json -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass @@ -14,16 +14,49 @@ class MetadataView: pyodide_build: dict[str, str | None] compatible: bool published_at: str = "" + source: str = field( + default="stable" + ) # "stable", "nightly", or "nightly-debug" # TODO: add stable-debug builds? @classmethod - def to_table(cls, views: list["MetadataView"]) -> str: - columns = [ - ("Version", 10), - ("Python", 10), - ("Emscripten", 10), - ("pyodide-build", 25), - ("Published", 10), - ("Compatible", 10), + def to_table(cls, views: list["MetadataView"], show_source: bool = False) -> str: + # Build cell values first so we can measure them for column widths + rows: list[list[str]] = [] + for view in views: + mn, mx = view.pyodide_build["min"], view.pyodide_build["max"] + if mn and mx: + pyodide_build_range = f"{mn} - {mx}" + elif mn: + pyodide_build_range = f"{mn} and later" + else: + pyodide_build_range = "-" + row = [ + view.version, + view.python, + view.emscripten, + pyodide_build_range, + view.published_at[:10], + "Yes" if view.compatible else "No", + ] + if show_source: + row.append(view.source) + rows.append(row) + + headers = [ + "Version", + "Python", + "Emscripten", + "pyodide-build", + "Published", + "Compatible", + ] + if show_source: + headers.append("Source") + + # Column width = max of header width and widest cell value + widths = [ + max(len(headers[i]), *(len(row[i]) for row in rows) if rows else [0]) + for i in range(len(headers)) ] # Unicode box-drawing characters @@ -33,51 +66,32 @@ def to_table(cls, views: list["MetadataView"]) -> str: t_down, t_up, t_right, t_left = "┬", "┴", "├", "┤" cross = "┼" - # Table elements - top_border = ( - top_left - + t_down.join(horizontal * (width + 2) for _, width in columns) - + top_right - ) - header = ( + def _border(left: str, mid: str, right: str) -> str: + return left + mid.join(horizontal * (w + 2) for w in widths) + right + + top_border = _border(top_left, t_down, top_right) + header_row = ( vertical - + vertical.join(f" {name:<{width}} " for name, width in columns) + + vertical.join(f" {h:<{w}} " for h, w in zip(headers, widths, strict=True)) + vertical ) - separator = ( - t_right - + cross.join(horizontal * (width + 2) for _, width in columns) - + t_left - ) - bottom_border = ( - bottom_left - + t_up.join(horizontal * (width + 2) for _, width in columns) - + bottom_right - ) + separator = _border(t_right, cross, t_left) + bottom_border = _border(bottom_left, t_up, bottom_right) - ### Printing - table = [top_border, header, separator] - for view in views: - pyodide_build_range = ( - f"{view.pyodide_build['min'] or ''} - {view.pyodide_build['max'] or ''}" - ) - published = view.published_at[:10] - row = [ - f"{view.version:<{columns[0][1]}}", - f"{view.python:<{columns[1][1]}}", - f"{view.emscripten:<{columns[2][1]}}", - f"{pyodide_build_range:<{columns[3][1]}}", - f"{published:<{columns[4][1]}}", - f"{'Yes' if view.compatible else 'No':<{columns[5][1]}}", - ] + table = [top_border, header_row, separator] + for row in rows: table.append( - vertical + vertical.join(f" {cell} " for cell in row) + vertical + vertical + + vertical.join( + f" {cell:<{w}} " for cell, w in zip(row, widths, strict=True) + ) + + vertical ) table.append(bottom_border) return "\n".join(table) @classmethod - def to_json(cls, views: list["MetadataView"]) -> str: + def to_json(cls, views: list["MetadataView"], show_source: bool = False) -> str: result = json.dumps( { "environments": [ @@ -87,6 +101,7 @@ def to_json(cls, views: list["MetadataView"]) -> str: "emscripten": view.emscripten, "pyodide_build": view.pyodide_build, "published_at": view.published_at, + **({"source": view.source} if show_source else {}), "compatible": view.compatible, } for view in views diff --git a/pyodide_build/xbuildenv_releases.py b/pyodide_build/xbuildenv_releases.py index 1f6814e4..0f89edc4 100644 --- a/pyodide_build/xbuildenv_releases.py +++ b/pyodide_build/xbuildenv_releases.py @@ -9,6 +9,12 @@ DEFAULT_CROSS_BUILD_ENV_METADATA_URL = ( "https://pyodide.github.io/pyodide/api/v2/pyodide-cross-build-environments.json" ) +NIGHTLY_CROSS_BUILD_ENV_METADATA_URL = ( + "https://pyodide.github.io/pyodide-build-environment-nightly/api/v2/release.json" +) +NIGHTLY_DEBUG_CROSS_BUILD_ENV_METADATA_URL = ( + "https://pyodide.github.io/pyodide-build-environment-nightly/api/v2/debug.json" +) CROSS_BUILD_ENV_METADATA_URL_ENV_VAR = "PYODIDE_CROSS_BUILD_ENV_METADATA_URL"