Skip to content

Commit 6662c28

Browse files
Add a JSON output for pyodide xbuildenv search, better tabular output (#28)
## Description This PR closes #26. It adds a `pyodide xbuildenv search --json` option to print a JSON-based output, along with associated tests. The advantage is that it can be saved to a file, piped to `jq` or shell functions (or any equivalent tools), or simply imported into a Pythonic interface. Additionally, I added a small context manager that does not do anything but stop printing the following lines: ``` Starting new HTTPS connection (1): raw.githubusercontent.com:443 https://raw.githubusercontent.com:443 "GET /pyodide/pyodide/main/pyodide-cross-build-environments.json HTTP/11" 200 917 ``` in the output, because it conflicts with receiving valid JSON. Please let me know if this would be undesirable. If yes, I'll try to work around it so that it gets printed for the non-JSON output (table).
1 parent 9b65d5f commit 6662c28

File tree

5 files changed

+223
-43
lines changed

5 files changed

+223
-43
lines changed

CHANGELOG.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
- The `pyodide xbuildenv search` command now accepts a `--json` flag to output the
13+
search results in JSON format that is machine-readable. The design for the regular
14+
tabular output has been improved.
15+
[#28](https://github.com/pyodide/pyodide-build/pull/28)
16+
1017
### Changed
1118

1219
- The `pyodide skeleton pypi --update` command and the `--update-patched` variant now
@@ -30,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3037

3138
## [0.27.2] - 2024/07/11
3239

33-
## Changed
40+
### Changed
3441

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

4148
## [0.27.1] - 2024/06/28
4249

43-
## Changed
50+
### Changed
4451

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

pyodide_build/cli/xbuildenv.py

+24-33
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from pyodide_build.build_env import local_versions
66
from pyodide_build.common import xbuildenv_dirname
7+
from pyodide_build.views import MetadataView
78
from pyodide_build.xbuildenv import CrossBuildEnvManager
89
from pyodide_build.xbuildenv_releases import (
910
cross_build_env_metadata_url,
@@ -151,6 +152,11 @@ def _search(
151152
"-a",
152153
help="search all versions, without filtering out incompatible ones",
153154
),
155+
json_output: bool = typer.Option(
156+
False,
157+
"--json",
158+
help="output results in JSON format",
159+
),
154160
) -> None:
155161
"""
156162
Search for available versions of cross-build environment.
@@ -175,40 +181,25 @@ def _search(
175181
)
176182
raise typer.Exit(1)
177183

178-
table = []
179-
columns = [
180-
# column name, width
181-
("Version", 10),
182-
("Python", 10),
183-
("Emscripten", 10),
184-
("pyodide-build", 25),
185-
("Compatible", 10),
186-
]
187-
header = [f"{name:{width}}" for name, width in columns]
188-
divider = ["-" * width for _, width in columns]
189-
190-
table.append("\t".join(header))
191-
table.append("\t".join(divider))
192-
193-
for release in releases:
194-
compatible = (
195-
"Yes"
196-
if release.is_compatible(
184+
# Generate views for the metadata objects (currently tabular or JSON)
185+
views = [
186+
MetadataView(
187+
version=release.version,
188+
python=release.python_version,
189+
emscripten=release.emscripten_version,
190+
pyodide_build={
191+
"min": release.min_pyodide_build_version,
192+
"max": release.max_pyodide_build_version,
193+
},
194+
compatible=release.is_compatible(
197195
python_version=local["python"],
198196
pyodide_build_version=local["pyodide-build"],
199-
)
200-
else "No"
197+
),
201198
)
202-
pyodide_build_range = f"{release.min_pyodide_build_version or ''} - {release.max_pyodide_build_version or ''}"
203-
204-
row = [
205-
f"{release.version:{columns[0][1]}}",
206-
f"{release.python_version:{columns[1][1]}}",
207-
f"{release.emscripten_version:{columns[2][1]}}",
208-
f"{pyodide_build_range:{columns[3][1]}}",
209-
f"{compatible:{columns[4][1]}}",
210-
]
211-
212-
table.append("\t".join(row))
199+
for release in releases
200+
]
213201

214-
print("\n".join(table))
202+
if json_output:
203+
print(MetadataView.to_json(views))
204+
else:
205+
print(MetadataView.to_table(views))

pyodide_build/tests/test_cli_xbuildenv.py

+76-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
import shutil
34
from pathlib import Path
@@ -25,6 +26,14 @@ def mock_pyodide_lock() -> PyodideLockSpec:
2526
)
2627

2728

29+
def is_valid_json(json_str) -> bool:
30+
try:
31+
json.loads(json_str)
32+
except json.JSONDecodeError:
33+
return False
34+
return True
35+
36+
2837
@pytest.fixture()
2938
def mock_xbuildenv_url(tmp_path_factory, httpserver):
3039
"""
@@ -331,14 +340,77 @@ def test_xbuildenv_search(
331340

332341
assert result.exit_code == 0, result.stdout
333342

334-
header = result.stdout.splitlines()[0]
335-
assert header.split() == [
343+
lines = result.stdout.splitlines()
344+
header = lines[1].strip().split("│")[1:-1]
345+
assert [col.strip() for col in header] == [
336346
"Version",
337347
"Python",
338348
"Emscripten",
339349
"pyodide-build",
340350
"Compatible",
341351
]
342352

343-
row1 = result.stdout.splitlines()[2]
344-
assert row1.split() == ["0.1.0", "4.5.6", "1.39.8", "-", "No"]
353+
row1 = lines[3].strip().split("│")[1:-1]
354+
assert [col.strip() for col in row1] == ["0.1.0", "4.5.6", "1.39.8", "-", "No"]
355+
356+
357+
def test_xbuildenv_search_json(tmp_path, fake_xbuildenv_releases_compatible):
358+
result = runner.invoke(
359+
xbuildenv.app,
360+
[
361+
"search",
362+
"--metadata",
363+
str(fake_xbuildenv_releases_compatible),
364+
"--json",
365+
"--all",
366+
],
367+
)
368+
369+
# Sanity check
370+
assert result.exit_code == 0, result.stdout
371+
assert is_valid_json(result.stdout), "Output is not valid JSON"
372+
373+
output = json.loads(result.stdout)
374+
375+
# First, check overall structure of JSON response
376+
assert isinstance(output, dict), "Output should be a dictionary"
377+
assert "environments" in output, "Output should have an 'environments' key"
378+
assert isinstance(output["environments"], list), "'environments' should be a list"
379+
380+
# Now, we'll check types in each environment entry
381+
for environment in output["environments"]:
382+
assert isinstance(environment, dict), "Each environment should be a dictionary"
383+
assert set(environment.keys()) == {
384+
"version",
385+
"python",
386+
"emscripten",
387+
"pyodide_build",
388+
"compatible",
389+
}, f"Environment {environment} has unexpected keys: {environment.keys()}"
390+
391+
assert isinstance(environment["version"], str), "version should be a string"
392+
assert isinstance(environment["python"], str), "python should be a string"
393+
assert isinstance(
394+
environment["emscripten"], str
395+
), "emscripten should be a string"
396+
assert isinstance(
397+
environment["compatible"], bool
398+
), "compatible should be either True or False"
399+
400+
assert isinstance(
401+
environment["pyodide_build"], dict
402+
), "pyodide_build should be a dictionary"
403+
assert set(environment["pyodide_build"].keys()) == {
404+
"min",
405+
"max",
406+
}, f"pyodide_build has unexpected keys: {environment['pyodide_build'].keys()}"
407+
assert isinstance(
408+
environment["pyodide_build"]["min"], (str, type(None))
409+
), "pyodide_build-min should be a string or None"
410+
assert isinstance(
411+
environment["pyodide_build"]["max"], (str, type(None))
412+
), "pyodide_build-max should be a string or None"
413+
414+
assert any(
415+
env["compatible"] for env in output["environments"]
416+
), "There should be at least one compatible environment"

pyodide_build/views.py

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Class for generating "views", i.e., tabular and JSON outputs from
2+
# metadata objects, currently used in the xbuildenv CLI (search command).
3+
4+
5+
import json
6+
from dataclasses import dataclass
7+
8+
9+
@dataclass
10+
class MetadataView:
11+
version: str
12+
python: str
13+
emscripten: str
14+
pyodide_build: dict[str, str | None]
15+
compatible: bool
16+
17+
@classmethod
18+
def to_table(cls, views: list["MetadataView"]) -> str:
19+
columns = [
20+
("Version", 10),
21+
("Python", 10),
22+
("Emscripten", 10),
23+
("pyodide-build", 25),
24+
("Compatible", 10),
25+
]
26+
27+
# Unicode box-drawing characters
28+
top_left, top_right = "┌", "┐"
29+
bottom_left, bottom_right = "└", "┘"
30+
horizontal, vertical = "─", "│"
31+
t_down, t_up, t_right, t_left = "┬", "┴", "├", "┤"
32+
cross = "┼"
33+
34+
# Table elements
35+
top_border = (
36+
top_left
37+
+ t_down.join(horizontal * (width + 2) for _, width in columns)
38+
+ top_right
39+
)
40+
header = (
41+
vertical
42+
+ vertical.join(f" {name:<{width}} " for name, width in columns)
43+
+ vertical
44+
)
45+
separator = (
46+
t_right
47+
+ cross.join(horizontal * (width + 2) for _, width in columns)
48+
+ t_left
49+
)
50+
bottom_border = (
51+
bottom_left
52+
+ t_up.join(horizontal * (width + 2) for _, width in columns)
53+
+ bottom_right
54+
)
55+
56+
### Printing
57+
table = [top_border, header, separator]
58+
for view in views:
59+
pyodide_build_range = (
60+
f"{view.pyodide_build['min'] or ''} - {view.pyodide_build['max'] or ''}"
61+
)
62+
row = [
63+
f"{view.version:<{columns[0][1]}}",
64+
f"{view.python:<{columns[1][1]}}",
65+
f"{view.emscripten:<{columns[2][1]}}",
66+
f"{pyodide_build_range:<{columns[3][1]}}",
67+
f"{'Yes' if view.compatible else 'No':<{columns[4][1]}}",
68+
]
69+
table.append(
70+
vertical + vertical.join(f" {cell} " for cell in row) + vertical
71+
)
72+
table.append(bottom_border)
73+
return "\n".join(table)
74+
75+
@classmethod
76+
def to_json(cls, views: list["MetadataView"]) -> str:
77+
result = json.dumps(
78+
{
79+
"environments": [
80+
{
81+
"version": view.version,
82+
"python": view.python,
83+
"emscripten": view.emscripten,
84+
"pyodide_build": view.pyodide_build,
85+
"compatible": view.compatible,
86+
}
87+
for view in views
88+
]
89+
},
90+
indent=2,
91+
)
92+
return result

pyodide_build/xbuildenv_releases.py

+22-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import logging
12
import os
3+
from contextlib import contextmanager
24
from functools import cache
35

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

186188

189+
@contextmanager
190+
def _suppress_urllib3_logging():
191+
"""
192+
Temporarily suppresses urllib3 logging for internal use.
193+
"""
194+
logger = logging.getLogger("urllib3")
195+
original_level = logger.level
196+
logger.setLevel(logging.WARNING)
197+
try:
198+
yield
199+
finally:
200+
logger.setLevel(original_level)
201+
202+
187203
def cross_build_env_metadata_url() -> str:
188204
"""
189205
Get the URL to the Pyodide cross-build environment metadata
@@ -218,9 +234,11 @@ def load_cross_build_env_metadata(url_or_filename: str) -> CrossBuildEnvMetaSpec
218234
if url_or_filename.startswith("http"):
219235
import requests
220236

221-
response = requests.get(url_or_filename)
222-
response.raise_for_status()
223-
data = response.json()
237+
with _suppress_urllib3_logging():
238+
with requests.get(url_or_filename) as response:
239+
response.raise_for_status()
240+
data = response.json()
241+
224242
return CrossBuildEnvMetaSpec.model_validate(data)
225243

226244
with open(url_or_filename) as f:

0 commit comments

Comments
 (0)