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 16 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
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- The `pyodide xbuildenv search` command now accepts a `--json` flag to output the
search results in JSON format that is machine-readable. The design for the regular
tabular output has been improved.
[#28](https://github.com/pyodide/pyodide-build/pull/28)

### Changed

- `pyo3_config_file` is no longer available in `pyodide config` command.
Expand All @@ -26,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [0.27.2] - 2024/07/11

## Changed
### Changed

- `pyodide py-compile` command now accepts `excludes` flag.
[#9](https://github.com/pyodide/pyodide-build/pull/9)
Expand All @@ -36,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [0.27.1] - 2024/06/28

## Changed
### Changed

- ported f2c_fixes patch from https://github.com/pyodide/pyodide/pull/4822

Expand Down
57 changes: 24 additions & 33 deletions pyodide_build/cli/xbuildenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from ..build_env import local_versions
from ..common import xbuildenv_dirname
from ..views import MetadataView
from ..xbuildenv import CrossBuildEnvManager
from ..xbuildenv_releases import (
cross_build_env_metadata_url,
Expand Down Expand Up @@ -151,6 +152,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 +181,25 @@ 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(
# Generate views for the metadata objects (currently tabular or JSON)
views = [
MetadataView(
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"],
)
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]}}",
]

table.append("\t".join(row))
for release in releases
]

print("\n".join(table))
if json_output:
MetadataView.to_json(views)
else:
MetadataView.to_table(views)
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"
92 changes: 92 additions & 0 deletions pyodide_build/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Class for generating "views", i.e., tabular and JSON outputs from
# metadata objects, currently used in the xbuildenv CLI (search command).


import json
from dataclasses import dataclass


@dataclass
class MetadataView:
version: str
python: str
emscripten: str
pyodide_build: dict[str, str | None]
compatible: bool

@classmethod
def to_table(cls, views: list["MetadataView"]) -> None:
columns = [
("Version", 10),
("Python", 10),
("Emscripten", 10),
("pyodide-build", 25),
("Compatible", 10),
]

# 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
table = [top_border, header, separator]
for view in views:
pyodide_build_range = (
f"{view.pyodide_build['min'] or ''} - {view.pyodide_build['max'] or ''}"
)
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"{'Yes' if view.compatible else 'No':<{columns[4][1]}}",
]
table.append(
vertical + vertical.join(f" {cell} " for cell in row) + vertical
)
table.append(bottom_border)
print("\n".join(table))
agriyakhetarpal marked this conversation as resolved.
Show resolved Hide resolved

@classmethod
def to_json(cls, views: list["MetadataView"]) -> None:
result = json.dumps(
{
"environments": [
{
"version": view.version,
"python": view.python,
"emscripten": view.emscripten,
"pyodide_build": view.pyodide_build,
"compatible": view.compatible,
}
for view in views
]
},
indent=2,
)
print(result)
agriyakhetarpal marked this conversation as resolved.
Show resolved Hide resolved
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