From c5a732fca969739517aec0479d81251d16b6c0ea Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Thu, 16 Apr 2026 16:48:19 -0500 Subject: [PATCH 1/3] Support exporting python dependency metadata This change introduces changes to support exporting information about the python dependencies used by a project in two different forms: - In the component itself in the producers section; this is part of the data typically injected by the fastly cli. - Via a separate subcommand; this is mostly useful for testing. Other languages today that include dependency information have that information extract by go code in the fastly cli directly. That is where I started, but there were issues given the variety of ways that python dependencies are managed (in virtualenvs, etc.). Ultimately, I decided that the optimal solution would be to get this information at the time of the build. Integration with the CLI is a seprate task, but the basic idea for python is that it will parse out the dependency metadata from the componennt if present (and not overwrite it) and merge it with the other metadata it wants to add. The dependencies subcommand was another approach considered, but it required other hacks to try to guess at how to correctly run fastly-compute-py based around parsing of the build command -- while this might work, it might also fail catastrophically if customers invoke make or other similar sorts of things. Pushing things into the build incrases our changes of consistenlty and correctly capturing this metadata. For now, this data is only inluded if targeting UV, but it should be possible to expand support if we have other heuristics to gather PEP751 compatible version information. --- Cargo.lock | 1 + crates/fastly-compute-py/Cargo.toml | 1 + crates/fastly-compute-py/src/cli.rs | 19 ++ crates/fastly-compute-py/src/config.rs | 3 + crates/fastly-compute-py/src/dependencies.rs | 197 ++++++++++++++++++ crates/fastly-compute-py/src/lib.rs | 72 +++++-- fastly_compute/runtime_patching/patches.py | 3 +- .../tests/test_dependencies_command.py | 41 ++++ fastly_compute/tests/test_wasm_metadata.py | 46 ++-- 9 files changed, 349 insertions(+), 34 deletions(-) create mode 100644 crates/fastly-compute-py/src/dependencies.rs create mode 100644 fastly_compute/tests/test_dependencies_command.py 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..c02caad 100644 --- a/crates/fastly-compute-py/src/cli.rs +++ b/crates/fastly-compute-py/src/cli.rs @@ -36,4 +36,23 @@ 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, + + /// 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..0574420 --- /dev/null +++ b/crates/fastly-compute-py/src/dependencies.rs @@ -0,0 +1,197 @@ +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. +/// +/// Errors are bubbled up when UV is known to be available (i.e. the `UV` +/// environment variable is set, which `uv run` always provides). If UV is +/// not discoverable at all we warn and return an empty list, since the project +/// may not be using UV as its package manager. +pub fn get_dependencies(_virtualenv: &Option) -> Result> { + log::info!("Collecting dependencies from project environment..."); + + let uv_bin = std::env::var("UV").unwrap_or_else(|_| "uv".to_string()); + let uv_from_env = std::env::var("UV").is_ok(); + + match get_dependencies_from_uv(&uv_bin) { + Ok(deps) => { + log::info!("Found {} dependencies via UV", deps.len()); + Ok(deps) + } + Err(e) if !uv_from_env && is_not_found(&e) => { + // UV not on PATH and not provided by the environment — not a UV project. + log::warn!("UV not found, skipping dependency collection: {}", e); + Ok(Vec::new()) + } + Err(e) => Err(e), + } +} + +/// Returns true if the error chain contains an OS "not found" error. +fn is_not_found(e: &anyhow::Error) -> bool { + e.chain().any(|cause| { + cause + .downcast_ref::() + .map(|io| io.kind() == std::io::ErrorKind::NotFound) + .unwrap_or(false) + }) +} + +/// 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..377dcc1 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,45 @@ 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, 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")?; + println!("{}", json); + } + } + } + } Ok(()) } @@ -170,7 +193,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 +272,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 +309,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..f92ac19 --- /dev/null +++ b/fastly_compute/tests/test_dependencies_command.py @@ -0,0 +1,41 @@ +"""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 +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.""" + result = subprocess.run( + ["uv", "run", "fastly-compute-py", "dependencies", "--format", "json"], + cwd=BOTTLE_APP_DIR, + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + + +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" From 49056e5466fc0755d6fd20156cdaf06ad11ec5e0 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 17 Apr 2026 12:17:22 -0500 Subject: [PATCH 2/3] Only attempt uv export if pyproject.toml exists This fixes test failures where we are using uv without a project for viceroy tests and is a reasonable heuristic. Requiring uv.lock would be a step further, but this may or may not be present when using uv depending on how it is used. --- crates/fastly-compute-py/src/dependencies.rs | 57 ++++++++++---------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/crates/fastly-compute-py/src/dependencies.rs b/crates/fastly-compute-py/src/dependencies.rs index 0574420..a4d39c3 100644 --- a/crates/fastly-compute-py/src/dependencies.rs +++ b/crates/fastly-compute-py/src/dependencies.rs @@ -42,38 +42,39 @@ struct PylockPackage { /// Get dependencies using UV's PEP 751 export. /// -/// Errors are bubbled up when UV is known to be available (i.e. the `UV` -/// environment variable is set, which `uv run` always provides). If UV is -/// not discoverable at all we warn and return an empty list, since the project -/// may not be using UV as its package manager. +/// 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> { - log::info!("Collecting dependencies from project environment..."); - - let uv_bin = std::env::var("UV").unwrap_or_else(|_| "uv".to_string()); - let uv_from_env = std::env::var("UV").is_ok(); + if !std::fs::exists("pyproject.toml").unwrap_or_default() { + log::debug!("No pyproject.toml found, skipping dependency collection"); + return Ok(Vec::new()); + } - match get_dependencies_from_uv(&uv_bin) { - Ok(deps) => { - log::info!("Found {} dependencies via UV", deps.len()); - Ok(deps) + 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()); } - Err(e) if !uv_from_env && is_not_found(&e) => { - // UV not on PATH and not provided by the environment — not a UV project. - log::warn!("UV not found, skipping dependency collection: {}", e); - Ok(Vec::new()) - } - Err(e) => Err(e), - } -} + }; + + log::info!("Collecting dependencies from project environment..."); -/// Returns true if the error chain contains an OS "not found" error. -fn is_not_found(e: &anyhow::Error) -> bool { - e.chain().any(|cause| { - cause - .downcast_ref::() - .map(|io| io.kind() == std::io::ErrorKind::NotFound) - .unwrap_or(false) - }) + 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. From 9cdb37c862737f8e53a2e62512b5221ca3c52cf9 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 17 Apr 2026 13:30:48 -0500 Subject: [PATCH 3/3] Add support for writing dependencies to file Stdout ends up with logging mixed in today, so for testing and machine use of JSON dependencies output, support specifying an output file. --- crates/fastly-compute-py/src/cli.rs | 4 +++ crates/fastly-compute-py/src/lib.rs | 15 +++++++-- .../tests/test_dependencies_command.py | 33 ++++++++++++++----- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/fastly-compute-py/src/cli.rs b/crates/fastly-compute-py/src/cli.rs index c02caad..f9d502c 100644 --- a/crates/fastly-compute-py/src/cli.rs +++ b/crates/fastly-compute-py/src/cli.rs @@ -43,6 +43,10 @@ pub enum Command { #[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)] diff --git a/crates/fastly-compute-py/src/lib.rs b/crates/fastly-compute-py/src/lib.rs index 377dcc1..1c755bb 100644 --- a/crates/fastly-compute-py/src/lib.rs +++ b/crates/fastly-compute-py/src/lib.rs @@ -102,7 +102,11 @@ pub fn run_main(cli: &Cli) -> Result<()> { log::info!("✓ Build complete: {}", config.output.display()); } - cli::Command::Dependencies { format, virtualenv } => { + cli::Command::Dependencies { + format, + output, + virtualenv, + } => { let deps = dependencies::get_dependencies(virtualenv)?; match format { @@ -115,7 +119,14 @@ pub fn run_main(cli: &Cli) -> Result<()> { let json = serde_json::to_string_pretty(&packages) .context("Failed to serialize dependencies to JSON")?; - println!("{}", json); + + match output { + Some(path) => { + fs::write(path, &json) + .context("Failed to write dependencies to output file")?; + } + None => println!("{}", json), + } } } } diff --git a/fastly_compute/tests/test_dependencies_command.py b/fastly_compute/tests/test_dependencies_command.py index f92ac19..cd4fbaa 100644 --- a/fastly_compute/tests/test_dependencies_command.py +++ b/fastly_compute/tests/test_dependencies_command.py @@ -6,6 +6,7 @@ import json import subprocess +import tempfile from pathlib import Path import pytest @@ -15,15 +16,29 @@ @pytest.fixture(scope="module") def dependencies_output(): - """Run fastly-compute-py dependencies and return parsed JSON output.""" - result = subprocess.run( - ["uv", "run", "fastly-compute-py", "dependencies", "--format", "json"], - cwd=BOTTLE_APP_DIR, - capture_output=True, - text=True, - check=True, - ) - return json.loads(result.stdout) + """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):