Skip to content

Commit fcefb69

Browse files
authored
Merge pull request #86 from fastly/posborne/python-component-metadata
Write correct producers metadata to generated components
2 parents 4dc00ba + 85c70df commit fcefb69

5 files changed

Lines changed: 219 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fastly-compute-py/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ wac-graph = "0.8"
2323
wac-parser = "0.8"
2424
wit-parser = "0.244"
2525
wit-component = "0.244"
26+
wasm-metadata = { version = "0.245", default-features = false }
2627
tempfile = "3"
2728
componentize-py = { git = "https://github.com/bytecodealliance/componentize-py" }
2829
futures = { version = "0.3", default-features = false, features = ["executor"] }
@@ -35,5 +36,6 @@ indexmap = "2"
3536

3637
[build-dependencies]
3738
anyhow = "1"
39+
cargo_metadata = "0.23.1"
3840
wit-parser = "0.219"
3941
wit-component = "0.219"

crates/fastly-compute-py/build.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ fn main() -> Result<()> {
88
println!("cargo:rerun-if-changed=../../wit");
99
println!("cargo:rerun-if-changed=../../crates/wasiless");
1010
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
11+
println!("cargo:rerun-if-changed=../../Cargo.lock");
1112

1213
let root_dir = PathBuf::from("../../");
1314
let wit_dir = root_dir.join("wit");
@@ -25,6 +26,20 @@ fn main() -> Result<()> {
2526
out_dir.join("wrap_app_in_wasiless.wac"),
2627
)?;
2728

29+
// Expose the componentize-py version for embedding in Wasm producers metadata.
30+
let metadata = cargo_metadata::MetadataCommand::new()
31+
.manifest_path(root_dir.join("Cargo.toml"))
32+
.exec()
33+
.context("Failed to run `cargo metadata`")?;
34+
let componentize_py_version = metadata
35+
.packages
36+
.iter()
37+
.find(|p| p.name == "componentize-py")
38+
.with_context(|| "componentize-py not found in cargo metadata")?
39+
.version
40+
.to_string();
41+
println!("cargo:rustc-env=COMPONENTIZE_PY_VERSION={componentize_py_version}");
42+
2843
Ok(())
2944
}
3045

@@ -71,7 +86,7 @@ fn build_wasiless_wasm(root_dir: impl AsRef<Path>, out_dir: impl AsRef<Path>) ->
7186
anyhow::bail!("Failed to build wasiless");
7287
}
7388

74-
// Transform wasiless into a component using wasm-tools compnent new
89+
// Transform wasiless into a component using wasm-tools component new
7590
let input_wasm = target_dir.join("wasm32-unknown-unknown/release/wasiless.wasm");
7691
let output_wasm = out_dir.as_ref().join("wasiless.wasm");
7792

crates/fastly-compute-py/src/lib.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use wac_graph::EncodeOptions;
1010
use wac_parser::Document;
1111
use wac_types::BorrowedPackageKey;
1212
use wac_types::Package;
13+
use wasm_metadata::AddMetadata;
1314

1415
pub mod cli;
1516
pub mod config;
@@ -168,7 +169,10 @@ pub fn build(output: PathBuf, entry_name: String, virtualenv: Option<PathBuf>) -
168169
let composed =
169170
compose_with_wasiless(&temp_component_wasm_path, WASILESS_WASM, WRAP_WAC, &output)?;
170171

171-
fs::write(&output, composed)
172+
log::info!(" Injecting Fastly metadata...");
173+
let annotated = inject_fastly_metadata(composed)?;
174+
175+
fs::write(&output, annotated)
172176
.with_context(|| format!("Failed to write output: {}", output.display()))?;
173177

174178
log::debug!("Composed output: {}", output.display());
@@ -231,3 +235,56 @@ fn compose_with_wasiless(
231235

232236
Ok(encoded)
233237
}
238+
239+
/// Inject build tool metadata into the Wasm component's standard `producers`
240+
/// custom section.
241+
///
242+
/// Follows the WebAssembly [Producers Section spec] field conventions:
243+
///
244+
/// - `language: Python <version>` — the source language and the CPython
245+
/// version bundled by componentize-py. Update this when upgrading to a
246+
/// componentize-py release that bundles a different CPython version.
247+
/// - `sdk: fastly-compute-py <version>` — the SDK library the user's code is
248+
/// written against, analogous to `@fastly/js-compute` for the JS SDK.
249+
/// - `processed-by: componentize-py <version>` — the tool that performed the
250+
/// core Wasm transformation. `fastly-compute-py` also adds itself here as
251+
/// the build orchestrator.
252+
///
253+
/// Note: the Fastly-proprietary `fastly.manifest.*` custom sections
254+
/// (language, version, service_id, etc.) are **not** written here. Those are
255+
/// injected during package ingestion, sourced from the `fastly.toml` manifest
256+
/// that the CLI bundles alongside the Wasm in the upload package.
257+
/// Dependency lists, build scripts, and machine info are similarly the CLI's
258+
/// responsibility via its `fastly_data` producers entry.
259+
///
260+
/// [Producers Section spec]: https://github.com/WebAssembly/tool-conventions/blob/main/ProducersSection.md
261+
fn inject_fastly_metadata(wasm: Vec<u8>) -> Result<Vec<u8>> {
262+
let mut add_metadata = AddMetadata::default();
263+
264+
// Source language. The version is the CPython version bundled by
265+
// componentize-py — update this when upgrading to a componentize-py
266+
// release that bundles a different CPython version.
267+
add_metadata
268+
.language
269+
.push(("Python".to_owned(), "3.14".to_owned()));
270+
271+
// The SDK the user's code is written against.
272+
add_metadata.sdk.push((
273+
"fastly-compute-py".to_owned(),
274+
env!("CARGO_PKG_VERSION").to_owned(),
275+
));
276+
277+
// Tools that performed the Wasm transformation.
278+
add_metadata.processed_by.push((
279+
"componentize-py".to_owned(),
280+
env!("COMPONENTIZE_PY_VERSION").to_owned(),
281+
));
282+
add_metadata.processed_by.push((
283+
"fastly-compute-py".to_owned(),
284+
env!("CARGO_PKG_VERSION").to_owned(),
285+
));
286+
287+
add_metadata
288+
.to_wasm(&wasm)
289+
.context("Failed to add producers metadata to Wasm component")
290+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Tests for Wasm producers metadata embedded in the built component.
2+
3+
Verifies that all top-level producers section fields written by
4+
inject_fastly_metadata() are present and correct:
5+
6+
- language: Python 3.14
7+
- sdk: fastly-compute-py 0.1.0
8+
- processed-by: componentize-py 0.22.1
9+
- processed-by: fastly-compute-py 0.1.0
10+
11+
The language version is also cross-checked against the libpython*.so name
12+
embedded in the component tree to catch version drift when upgrading
13+
componentize-py.
14+
"""
15+
16+
import json
17+
import re
18+
import subprocess
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
WASM_PATH = Path("build/bottle-app.composed.wasm")
24+
25+
# NOTE: these will need to be updated at times but serves as a sanity check
26+
FASTLY_COMPUTE_PY_VERSION = "0.1.0"
27+
COMPONENTIZE_PY_VERSION = "0.22.1"
28+
PYTHON_VERSION = "3.14"
29+
30+
31+
@pytest.fixture(scope="module")
32+
def metadata():
33+
"""Return the parsed wasm-tools metadata JSON for the built component."""
34+
if not WASM_PATH.exists():
35+
pytest.fail(f"Built wasm not found at {WASM_PATH}; run `make` first")
36+
37+
result = subprocess.run(
38+
["wasm-tools", "metadata", "show", "--json", str(WASM_PATH)],
39+
capture_output=True,
40+
text=True,
41+
check=True,
42+
)
43+
return json.loads(result.stdout)
44+
45+
46+
@pytest.fixture(scope="module")
47+
def producers(metadata):
48+
"""Return the top-level producers as a {field: {name: version}} dict."""
49+
raw = metadata["component"]["metadata"]["producers"]
50+
return dict(raw) if raw else {}
51+
52+
53+
def _libpython_version(metadata) -> str | None:
54+
"""Return the version from the first libpython*.so module name in the tree."""
55+
nodes = [metadata]
56+
while nodes:
57+
node = nodes.pop()
58+
kind = next(iter(node))
59+
data = node[kind]
60+
if kind == "module":
61+
name = data.get("name") # may be None or ""
62+
if name:
63+
m = re.match(r"libpython(\d+\.\d+)\.so", name)
64+
if m:
65+
return m.group(1)
66+
else:
67+
nodes.extend(data.get("children", []))
68+
69+
70+
def test_language_python(producers):
71+
assert producers.get("language", {}).get("Python") == PYTHON_VERSION
72+
73+
74+
def test_language_python_version_matches_libpython(producers, metadata):
75+
"""Declared Python version must match the libpython*.so embedded by componentize-py.
76+
77+
If this fails, update PYTHON_VERSION in this file and the hardcoded version
78+
in crates/fastly-compute-py/src/lib.rs. This is designed to keep us honest
79+
and ensure that we don't end up injecting the wrong version as it is
80+
not trivial to extract this from compnentize-py directly.
81+
"""
82+
embedded = _libpython_version(metadata)
83+
assert embedded is not None, "No libpython*.so module found in component tree"
84+
assert producers["language"]["Python"] == embedded, (
85+
f"language: Python {producers['language']['Python']!r} does not match "
86+
f"embedded libpython{embedded}.so — update PYTHON_VERSION in this file "
87+
"and the hardcoded version in crates/fastly-compute-py/src/lib.rs"
88+
)
89+
90+
91+
def test_sdk_fastly_compute_py(producers):
92+
assert (
93+
producers.get("sdk", {}).get("fastly-compute-py") == FASTLY_COMPUTE_PY_VERSION
94+
)
95+
96+
97+
def test_processed_by_componentize_py(producers):
98+
assert (
99+
producers.get("processed-by", {}).get("componentize-py")
100+
== COMPONENTIZE_PY_VERSION
101+
)
102+
103+
104+
def test_processed_by_fastly_compute_py(producers):
105+
assert (
106+
producers.get("processed-by", {}).get("fastly-compute-py")
107+
== FASTLY_COMPUTE_PY_VERSION
108+
)

0 commit comments

Comments
 (0)