diff --git a/Cargo.lock b/Cargo.lock index 63ed84c..c43a481 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,7 @@ dependencies = [ "log", "pyo3", "serde", + "serde_json", "tempfile", "toml 0.8.23", "wac-graph", diff --git a/crates/fastly-compute-py/Cargo.toml b/crates/fastly-compute-py/Cargo.toml index fdafb41..e72cdb3 100644 --- a/crates/fastly-compute-py/Cargo.toml +++ b/crates/fastly-compute-py/Cargo.toml @@ -29,6 +29,7 @@ componentize-py = { git = "https://github.com/bytecodealliance/componentize-py" futures = { version = "0.3", default-features = false, features = ["executor"] } pyo3 = { version = "0.27.2", optional = true } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" toml = "0.8" log = "0.4" env_logger = "0.11" diff --git a/crates/fastly-compute-py/src/cli.rs b/crates/fastly-compute-py/src/cli.rs index bf41327..f9d502c 100644 --- a/crates/fastly-compute-py/src/cli.rs +++ b/crates/fastly-compute-py/src/cli.rs @@ -36,4 +36,27 @@ pub enum Command { #[arg(short, long)] virtualenv: Option, }, + + /// List project dependencies in the specified format + Dependencies { + /// Output format for dependencies + #[arg(short, long, default_value = "json")] + format: DependencyFormat, + + /// Write output to this file instead of stdout + #[arg(short, long)] + output: Option, + + /// Virtual environment in which to look for modules (default: + /// VIRTUAL_ENV env var or .venv) + #[arg(short, long)] + virtualenv: Option, + }, +} + +#[derive(Clone, Debug, clap::ValueEnum)] +pub enum DependencyFormat { + /// JSON object format: {"package-name": "1.0.0", ...} + /// Matches the format used in fastly_data metadata + Json, } diff --git a/crates/fastly-compute-py/src/config.rs b/crates/fastly-compute-py/src/config.rs index 4414dac..520e7c0 100644 --- a/crates/fastly-compute-py/src/config.rs +++ b/crates/fastly-compute-py/src/config.rs @@ -74,6 +74,9 @@ impl ConfigBuilder { self.cli.output = output.clone(); self.cli.virtualenv = virtualenv.clone(); } + Command::Dependencies { .. } => { + // Dependencies command doesn't need config resolution + } } self } diff --git a/crates/fastly-compute-py/src/dependencies.rs b/crates/fastly-compute-py/src/dependencies.rs new file mode 100644 index 0000000..a4d39c3 --- /dev/null +++ b/crates/fastly-compute-py/src/dependencies.rs @@ -0,0 +1,198 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; + +/// A single package dependency with name and version +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Dependency { + pub name: String, + pub version: String, +} + +/// Fastly CLI DataCollection format for fastly_data metadata. +/// Matches the Go structs in pkg/commands/compute/build.go. +#[derive(Debug, Serialize)] +pub struct FastlyData { + #[serde(skip_serializing_if = "Option::is_none")] + pub package_info: Option, +} + +#[derive(Debug, Serialize)] +pub struct PackageInfo { + pub packages: HashMap, +} + +/// PEP 751 pylock.toml format structures +#[derive(Debug, Deserialize)] +struct PylockToml { + #[serde(rename = "lock-version")] + _lock_version: String, + packages: Vec, +} + +#[derive(Debug, Deserialize)] +struct PylockPackage { + name: String, + /// Present for registry/VCS packages; absent for local source trees + /// (PEP 751: version MUST NOT be included when using a source tree) + version: Option, +} + +/// Get dependencies using UV's PEP 751 export. +/// +/// Dependency collection only runs when both conditions are met: +/// +/// 1. A `pyproject.toml` exists in the current directory — confirming this is +/// a Python project that could have a lockfile. Without this, `uv export` +/// would fail with "no pyproject.toml found" (e.g. the Viceroy test +/// framework builds in a temp dir with no project files). +/// +/// 2. The `UV` environment variable is set — confirming the tool was invoked +/// via `uv run` or similar. We don't guess that `uv` should be used just +/// because a `uv` binary happens to be on PATH; the user may be using pip, +/// poetry, or another tool entirely. +/// +/// When both conditions are met, errors from `uv export` are bubbled up since +/// something genuinely went wrong in a context where UV is expected to work. +pub fn get_dependencies(_virtualenv: &Option) -> Result> { + if !std::fs::exists("pyproject.toml").unwrap_or_default() { + log::debug!("No pyproject.toml found, skipping dependency collection"); + return Ok(Vec::new()); + } + + let uv_bin = match std::env::var("UV") { + Ok(bin) => bin, + Err(_) => { + log::debug!("UV env var not set, skipping dependency collection"); + return Ok(Vec::new()); + } + }; + + log::info!("Collecting dependencies from project environment..."); + + let deps = get_dependencies_from_uv(&uv_bin)?; + log::info!("Found {} dependencies via UV", deps.len()); + Ok(deps) +} + +/// Serialize dependencies as fastly_data JSON, matching the CLI's DataCollection format. +/// +/// The build tool injects this directly so the CLI does not need to know anything +/// about the Python environment to collect package metadata. +pub fn get_fastly_data_json(virtualenv: &Option) -> Result { + let deps = get_dependencies(virtualenv)?; + + if deps.is_empty() { + return Ok(String::new()); + } + + let packages: HashMap = deps.into_iter().map(|d| (d.name, d.version)).collect(); + + let fastly_data = FastlyData { + package_info: Some(PackageInfo { packages }), + }; + + serde_json::to_string(&fastly_data).context("Failed to serialize fastly_data to JSON") +} + +/// Use UV export to get dependencies in PEP 751 pylock.toml format +fn get_dependencies_from_uv(uv_bin: &str) -> Result> { + log::debug!( + "Attempting to export dependencies using UV binary: {}", + uv_bin + ); + + let output = Command::new(uv_bin) + .args([ + "export", + "--format", + "pylock.toml", + "--no-emit-project", + "--frozen", + "--no-header", + ]) + .output() + .context("Failed to run 'uv export'")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("uv export failed: {}", stderr); + } + + let pylock_content = + String::from_utf8(output.stdout).context("UV output was not valid UTF-8")?; + + parse_pylock_toml(&pylock_content) +} + +/// Parse PEP 751 pylock.toml format +fn parse_pylock_toml(content: &str) -> Result> { + let pylock: PylockToml = + toml::from_str(content).context("Failed to parse pylock.toml format")?; + + let dependencies = pylock + .packages + .into_iter() + .map(|pkg| Dependency { + version: pkg.version.unwrap_or_else(|| "unknown".to_string()), + name: pkg.name, + }) + .collect(); + + Ok(dependencies) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_versioned_packages() { + let sample = r#" +lock-version = "1.0" +created-by = "uv" +requires-python = ">=3.12" + +[[packages]] +name = "bottle" +version = "0.13.4" + +[[packages]] +name = "requests" +version = "2.31.0" +"#; + + let deps = parse_pylock_toml(sample).unwrap(); + assert_eq!(deps.len(), 2); + assert_eq!(deps[0].name, "bottle"); + assert_eq!(deps[0].version, "0.13.4"); + assert_eq!(deps[1].name, "requests"); + assert_eq!(deps[1].version, "2.31.0"); + } + + #[test] + fn test_parse_directory_dependency_uses_unknown() { + // PEP 751: version MUST NOT be included for source trees. + // We record "unknown" so the dependency is still visible. + let sample = r#" +lock-version = "1.0" +created-by = "uv" +requires-python = ">=3.12" + +[[packages]] +name = "bottle" +version = "0.13.4" + +[[packages]] +name = "fastly-compute" +directory = { path = "../../", editable = true } +"#; + + let deps = parse_pylock_toml(sample).unwrap(); + assert_eq!(deps.len(), 2); + assert_eq!(deps[1].name, "fastly-compute"); + assert_eq!(deps[1].version, "unknown"); + } +} diff --git a/crates/fastly-compute-py/src/lib.rs b/crates/fastly-compute-py/src/lib.rs index f4784e2..1c755bb 100644 --- a/crates/fastly-compute-py/src/lib.rs +++ b/crates/fastly-compute-py/src/lib.rs @@ -14,6 +14,7 @@ use wasm_metadata::AddMetadata; pub mod cli; pub mod config; +pub mod dependencies; pub mod site_packages; use cli::Cli; @@ -80,23 +81,56 @@ fn init_logging(verbose: u8) { pub fn run_main(cli: &Cli) -> Result<()> { init_logging(cli.verbose); - log::info!("Building Python application for Fastly Compute..."); + match &cli.command { + cli::Command::Build { .. } => { + log::info!("Building Python application for Fastly Compute..."); - let config = ConfigBuilder::from_pyproject() - .unwrap_or_else(|e| { - log::warn!("Failed to load pyproject.toml: {}", e); - ConfigBuilder::default() - }) - .with_command(&cli.command) - .resolve(); + let config = ConfigBuilder::from_pyproject() + .unwrap_or_else(|e| { + log::warn!("Failed to load pyproject.toml: {}", e); + ConfigBuilder::default() + }) + .with_command(&cli.command) + .resolve(); - log::debug!("Final resolved configuration: {config:?}"); - log::info!(" Entry point: {}", config.entry); - log::info!(" Output: {}", config.output.display()); + log::debug!("Final resolved configuration: {config:?}"); + log::info!(" Entry point: {}", config.entry); + log::info!(" Output: {}", config.output.display()); - build(config.output.clone(), config.entry, config.virtualenv)?; + build(config.output.clone(), config.entry, config.virtualenv)?; - log::info!("✓ Build complete: {}", config.output.display()); + log::info!("✓ Build complete: {}", config.output.display()); + } + + cli::Command::Dependencies { + format, + output, + virtualenv, + } => { + let deps = dependencies::get_dependencies(virtualenv)?; + + match format { + cli::DependencyFormat::Json => { + // Convert to HashMap matching fastly_data packages format + let mut packages = std::collections::HashMap::new(); + for dep in deps { + packages.insert(dep.name, dep.version); + } + + let json = serde_json::to_string_pretty(&packages) + .context("Failed to serialize dependencies to JSON")?; + + match output { + Some(path) => { + fs::write(path, &json) + .context("Failed to write dependencies to output file")?; + } + None => println!("{}", json), + } + } + } + } + } Ok(()) } @@ -170,7 +204,7 @@ pub fn build(output: PathBuf, entry_name: String, virtualenv: Option) - compose_with_wasiless(&temp_component_wasm_path, WASILESS_WASM, WRAP_WAC, &output)?; log::info!(" Injecting Fastly metadata..."); - let annotated = inject_fastly_metadata(composed)?; + let annotated = inject_fastly_metadata(composed, &virtualenv)?; fs::write(&output, annotated) .with_context(|| format!("Failed to write output: {}", output.display()))?; @@ -249,16 +283,18 @@ fn compose_with_wasiless( /// - `processed-by: componentize-py ` — the tool that performed the /// core Wasm transformation. `fastly-compute-py` also adds itself here as /// the build orchestrator. +/// - `processed-by: fastly_data` — package dependency list in the same JSON +/// format the Fastly CLI uses for all other languages. The CLI merges its +/// own fields (build_info, machine_info, script_info) on top and skips +/// re-collecting package_info when this key is already present. /// /// Note: the Fastly-proprietary `fastly.manifest.*` custom sections /// (language, version, service_id, etc.) are **not** written here. Those are /// injected during package ingestion, sourced from the `fastly.toml` manifest /// that the CLI bundles alongside the Wasm in the upload package. -/// Dependency lists, build scripts, and machine info are similarly the CLI's -/// responsibility via its `fastly_data` producers entry. /// /// [Producers Section spec]: https://github.com/WebAssembly/tool-conventions/blob/main/ProducersSection.md -fn inject_fastly_metadata(wasm: Vec) -> Result> { +fn inject_fastly_metadata(wasm: Vec, virtualenv: &Option) -> Result> { let mut add_metadata = AddMetadata::default(); // Source language. The version is the CPython version bundled by @@ -284,6 +320,19 @@ fn inject_fastly_metadata(wasm: Vec) -> Result> { env!("CARGO_PKG_VERSION").to_owned(), )); + // Inject dependencies as fastly_data, matching the format the Fastly CLI + // writes for other languages. The CLI merges its own fields on top and + // skips re-collecting package_info when this key is already present. + let fastly_data_json = dependencies::get_fastly_data_json(virtualenv)?; + if !fastly_data_json.is_empty() { + log::debug!("Injecting fastly_data with package dependencies"); + add_metadata + .processed_by + .push(("fastly_data".to_owned(), fastly_data_json)); + } else { + log::debug!("No dependencies found to inject"); + } + add_metadata .to_wasm(&wasm) .context("Failed to add producers metadata to Wasm component") diff --git a/fastly_compute/runtime_patching/patches.py b/fastly_compute/runtime_patching/patches.py index cce0296..55752e6 100644 --- a/fastly_compute/runtime_patching/patches.py +++ b/fastly_compute/runtime_patching/patches.py @@ -40,7 +40,8 @@ # the wit_world, is around. def patch(): """Pretend to patch.""" - print("Faking the run of exception-mapping monkeypatches for test runner.") + import sys + print("Faking the run of exception-mapping monkeypatches for test runner.", file=sys.stderr) else: MAPPINGS = { wit_world.imports.acl.AclError.GENERIC_ERROR: fastly_compute.exceptions.acl.acl_error.GenericError, diff --git a/fastly_compute/tests/test_dependencies_command.py b/fastly_compute/tests/test_dependencies_command.py new file mode 100644 index 0000000..cd4fbaa --- /dev/null +++ b/fastly_compute/tests/test_dependencies_command.py @@ -0,0 +1,56 @@ +"""Tests for the fastly-compute-py dependencies command. + +Verifies that the dependencies subcommand outputs a {"name": "version"} JSON +map that matches what gets injected into fastly_data metadata. +""" + +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest + +BOTTLE_APP_DIR = Path(__file__).parent.parent.parent / "examples" / "bottle-app" + + +@pytest.fixture(scope="module") +def dependencies_output(): + """Run fastly-compute-py dependencies and return parsed JSON output. + + Uses --output to write JSON to a temp file, avoiding any ambiguity from + uv or logging infrastructure writing to stdout. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + output_path = Path(tmp_dir) / "dependencies.json" + + subprocess.run( + [ + "uv", + "run", + "fastly-compute-py", + "dependencies", + "--format", + "json", + "--output", + str(output_path), + ], + cwd=BOTTLE_APP_DIR, + check=True, + ) + return json.loads(output_path.read_text()) + + +def test_dependencies_includes_bottle(dependencies_output): + """Verify bottle is present with the correct version.""" + assert dependencies_output.get("bottle") == "0.13.4" + + +def test_dependencies_local_package_recorded_as_unknown(dependencies_output): + """Local source-tree dependencies have no version per PEP 751. + + fastly-compute is declared as ``path = "../../", editable = true`` so + the pylock.toml has no version field for it. We record "unknown" rather + than silently omitting the dependency. + """ + assert dependencies_output.get("fastly-compute") == "unknown" diff --git a/fastly_compute/tests/test_wasm_metadata.py b/fastly_compute/tests/test_wasm_metadata.py index 4a66822..ad1eacc 100644 --- a/fastly_compute/tests/test_wasm_metadata.py +++ b/fastly_compute/tests/test_wasm_metadata.py @@ -7,6 +7,7 @@ - sdk: fastly-compute-py 0.1.0 - processed-by: componentize-py 0.22.1 - processed-by: fastly-compute-py 0.1.0 + - processed-by: fastly_data (package_info with dependency versions) The language version is also cross-checked against the libpython*.so name embedded in the component tree to catch version drift when upgrading @@ -50,6 +51,17 @@ def producers(metadata): return dict(raw) if raw else {} +@pytest.fixture(scope="module") +def fastly_data(producers): + """Return the parsed fastly_data object injected by the build tool.""" + fastly_data_str = producers.get("processed-by", {}).get("fastly_data") + if fastly_data_str is None: + pytest.skip( + "fastly_data not present (UV may not have been available during build)" + ) + return json.loads(fastly_data_str) + + def _libpython_version(metadata) -> str | None: """Return the version from the first libpython*.so module name in the tree.""" nodes = [metadata] @@ -67,10 +79,6 @@ def _libpython_version(metadata) -> str | None: nodes.extend(data.get("children", [])) -def test_language_python(producers): - assert producers.get("language", {}).get("Python") == PYTHON_VERSION - - def test_language_python_version_matches_libpython(producers, metadata): """Declared Python version must match the libpython*.so embedded by componentize-py. @@ -88,21 +96,27 @@ def test_language_python_version_matches_libpython(producers, metadata): ) -def test_sdk_fastly_compute_py(producers): - assert ( - producers.get("sdk", {}).get("fastly-compute-py") == FASTLY_COMPUTE_PY_VERSION - ) +def test_processed_by_versions(producers): + """Verify componentize-py and fastly-compute-py versions in processed-by.""" + processed_by = producers.get("processed-by", {}) + assert processed_by.get("componentize-py") == COMPONENTIZE_PY_VERSION + assert processed_by.get("fastly-compute-py") == FASTLY_COMPUTE_PY_VERSION -def test_processed_by_componentize_py(producers): +def test_sdk_version(producers): assert ( - producers.get("processed-by", {}).get("componentize-py") - == COMPONENTIZE_PY_VERSION + producers.get("sdk", {}).get("fastly-compute-py") == FASTLY_COMPUTE_PY_VERSION ) -def test_processed_by_fastly_compute_py(producers): - assert ( - producers.get("processed-by", {}).get("fastly-compute-py") - == FASTLY_COMPUTE_PY_VERSION - ) +def test_fastly_data_contains_bottle_dependency(fastly_data): + """fastly_data injected by the build tool must include bottle. + + The build tool collects dependencies via UV's PEP 751 export and writes + them directly as fastly_data. The CLI merges its own fields (build_info, + machine_info, script_info) on top without overwriting package_info. + """ + packages = fastly_data["package_info"]["packages"] + assert packages.get("bottle") == "0.13.4" + # editable/directory deps have no version per PEP 751 — recorded as "unknown" + assert packages.get("fastly-compute") == "unknown"