Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a JSON output for pyodide xbuildenv search, better tabular output #28

Merged
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- The `pyodide xbuildenv search` command now accepts a `--json` flag to output the
search results in JSON format that is machine-readable.
[#28](https://github.com/pyodide/pyodide-build/pull/28)
agriyakhetarpal marked this conversation as resolved.
Show resolved Hide resolved

- `pyo3_config_file` is no longer available in `pyodide config` command.
Pyodide now sets `PYO3_CROSS_PYTHON_VERSION`, `PYO3_CROSS_LIB_DIR` to specify the cross compilation environment
for PyO3.
Expand Down
136 changes: 102 additions & 34 deletions pyodide_build/cli/xbuildenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ def _search(
"-a",
help="search all versions, without filtering out incompatible ones",
),
json_output: bool = typer.Option(
False,
"--json",
help="output results in JSON format",
),
) -> None:
"""
Search for available versions of cross-build environment.
Expand All @@ -175,40 +180,103 @@ def _search(
)
raise typer.Exit(1)

table = []
columns = [
# column name, width
("Version", 10),
("Python", 10),
("Emscripten", 10),
("pyodide-build", 25),
("Compatible", 10),
]
header = [f"{name:{width}}" for name, width in columns]
divider = ["-" * width for _, width in columns]

table.append("\t".join(header))
table.append("\t".join(divider))

for release in releases:
compatible = (
"Yes"
if release.is_compatible(
python_version=local["python"],
pyodide_build_version=local["pyodide-build"],
)
else "No"
)
pyodide_build_range = f"{release.min_pyodide_build_version or ''} - {release.max_pyodide_build_version or ''}"

row = [
f"{release.version:{columns[0][1]}}",
f"{release.python_version:{columns[1][1]}}",
f"{release.emscripten_version:{columns[2][1]}}",
f"{pyodide_build_range:{columns[3][1]}}",
f"{compatible:{columns[4][1]}}",
def _generate_json_output(releases, local) -> str:
agriyakhetarpal marked this conversation as resolved.
Show resolved Hide resolved
"""A helper function to help generate JSON output"""
import json

output = {
"environments": [
{
"version": release.version,
"python": release.python_version,
"emscripten": release.emscripten_version,
"pyodide_build": {
"min": release.min_pyodide_build_version,
"max": release.max_pyodide_build_version,
},
"compatible": release.is_compatible(
python_version=local["python"],
pyodide_build_version=local["pyodide-build"],
),
}
for release in releases
]
}
print(json.dumps(output, indent=2))

def _print_table_output(releases, local) -> None:
"""A helper function to print a tabular output"""

columns = [
("Version", 10),
("Python", 10),
("Emscripten", 10),
("pyodide-build", 25),
("Compatible", 10),
]

table.append("\t".join(row))
# Unicode box-drawing characters
top_left = "┌"
top_right = "┐"
bottom_left = "└"
bottom_right = "┘"
horizontal = "─"
vertical = "│"
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 = (
vertical
+ vertical.join(f" {name:<{width}} " for name, width in columns)
+ 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
)

### Printing
print(top_border)
print(header)
print(separator)
for release in releases:
compatible = (
"Yes"
if release.is_compatible(
python_version=local["python"],
pyodide_build_version=local["pyodide-build"],
)
else "No"
)
pyodide_build_range = f"{release.min_pyodide_build_version or ''} - {release.max_pyodide_build_version or ''}"

print("\n".join(table))
row = [
f"{release.version:<{columns[0][1]}}",
f"{release.python_version:<{columns[1][1]}}",
f"{release.emscripten_version:<{columns[2][1]}}",
f"{pyodide_build_range:<{columns[3][1]}}",
f"{compatible:<{columns[4][1]}}",
]

print(vertical + vertical.join(f" {cell} " for cell in row) + vertical)
print(bottom_border)

if json_output:
_generate_json_output(releases, local)
else:
_print_table_output(releases, local)
86 changes: 82 additions & 4 deletions pyodide_build/tests/test_cli_xbuildenv.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import shutil
from pathlib import Path
Expand Down Expand Up @@ -25,6 +26,18 @@ def mock_pyodide_lock() -> PyodideLockSpec:
)


@pytest.fixture()
def is_valid_json():
def _is_valid_json(json_str):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why is it defined as a fixture? Doesn't calling is_valid_json work?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I was using it in the test invocation, which meant that it needed to be a fixture for pytest to recognise it. But we don't need that, necessarily. In a9f9a61, I've removed the fixture and called it directly, as you suggest.

try:
json.loads(json_str)
except json.JSONDecodeError:
return False
return True

return _is_valid_json


@pytest.fixture()
def mock_xbuildenv_url(tmp_path_factory, httpserver):
"""
Expand Down Expand Up @@ -331,14 +344,79 @@ def test_xbuildenv_search(

assert result.exit_code == 0, result.stdout

header = result.stdout.splitlines()[0]
assert header.split() == [
lines = result.stdout.splitlines()
header = lines[1].strip().split("│")[1:-1]
assert [col.strip() for col in header] == [
"Version",
"Python",
"Emscripten",
"pyodide-build",
"Compatible",
]

row1 = result.stdout.splitlines()[2]
assert row1.split() == ["0.1.0", "4.5.6", "1.39.8", "-", "No"]
row1 = lines[3].strip().split("│")[1:-1]
assert [col.strip() for col in row1] == ["0.1.0", "4.5.6", "1.39.8", "-", "No"]


def test_xbuildenv_search_json(
tmp_path, fake_xbuildenv_releases_compatible, is_valid_json
):
result = runner.invoke(
xbuildenv.app,
[
"search",
"--metadata",
str(fake_xbuildenv_releases_compatible),
"--json",
"--all",
],
)

# Sanity check
assert result.exit_code == 0, result.stdout
assert is_valid_json(result.stdout), "Output is not valid JSON"

output = json.loads(result.stdout)

# First, check overall structure of JSON response
assert isinstance(output, dict), "Output should be a dictionary"
assert "environments" in output, "Output should have an 'environments' key"
assert isinstance(output["environments"], list), "'environments' should be a list"

# Now, we'll check types in each environment entry
for environment in output["environments"]:
assert isinstance(environment, dict), "Each environment should be a dictionary"
assert set(environment.keys()) == {
"version",
"python",
"emscripten",
"pyodide_build",
"compatible",
}, f"Environment {environment} has unexpected keys: {environment.keys()}"

assert isinstance(environment["version"], str), "version should be a string"
assert isinstance(environment["python"], str), "python should be a string"
assert isinstance(
environment["emscripten"], str
), "emscripten should be a string"
assert isinstance(
environment["compatible"], bool
), "compatible should be either True or False"

assert isinstance(
environment["pyodide_build"], dict
), "pyodide_build should be a dictionary"
assert set(environment["pyodide_build"].keys()) == {
"min",
"max",
}, f"pyodide_build has unexpected keys: {environment['pyodide_build'].keys()}"
assert isinstance(
environment["pyodide_build"]["min"], (str, type(None))
), "pyodide_build-min should be a string or None"
assert isinstance(
environment["pyodide_build"]["max"], (str, type(None))
), "pyodide_build-max should be a string or None"

assert any(
env["compatible"] for env in output["environments"]
), "There should be at least one compatible environment"
26 changes: 22 additions & 4 deletions pyodide_build/xbuildenv_releases.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import logging
import os
from contextlib import contextmanager
from functools import cache

from packaging.version import Version
Expand All @@ -19,7 +21,7 @@ class CrossBuildEnvReleaseSpec(BaseModel):
python_version: str
# The version of the Emscripten SDK
emscripten_version: str
# Minimum and maximum pyodide-build versions that is compatible with this release
# Minimum and maximum pyodide-build versions that are compatible with this release
min_pyodide_build_version: str | None = None
max_pyodide_build_version: str | None = None
model_config = ConfigDict(extra="forbid", title="CrossBuildEnvReleasesSpec")
Expand Down Expand Up @@ -184,6 +186,20 @@ def get_release(
return self.releases[version]


@contextmanager
def _suppress_urllib3_logging():
"""
Temporarily suppresses urllib3 logging for internal use.
"""
logger = logging.getLogger("urllib3")
original_level = logger.level
logger.setLevel(logging.WARNING)
try:
yield
finally:
logger.setLevel(original_level)


def cross_build_env_metadata_url() -> str:
"""
Get the URL to the Pyodide cross-build environment metadata
Expand Down Expand Up @@ -218,9 +234,11 @@ def load_cross_build_env_metadata(url_or_filename: str) -> CrossBuildEnvMetaSpec
if url_or_filename.startswith("http"):
import requests

response = requests.get(url_or_filename)
response.raise_for_status()
data = response.json()
with _suppress_urllib3_logging():
with requests.get(url_or_filename) as response:
response.raise_for_status()
data = response.json()

return CrossBuildEnvMetaSpec.model_validate(data)

with open(url_or_filename) as f:
Expand Down
Loading