Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions crates/fastly-compute-py/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,27 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// 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<PathBuf>,

/// Virtual environment in which to look for modules (default:
/// VIRTUAL_ENV env var or .venv)
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},
}

#[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,
}
3 changes: 3 additions & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line number Diff line number Diff line change
@@ -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<PackageInfo>,
}

#[derive(Debug, Serialize)]
pub struct PackageInfo {
pub packages: HashMap<String, String>,
}

/// PEP 751 pylock.toml format structures
#[derive(Debug, Deserialize)]
struct PylockToml {
#[serde(rename = "lock-version")]
_lock_version: String,
packages: Vec<PylockPackage>,
}

#[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<String>,
}

/// 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<PathBuf>) -> Result<Vec<Dependency>> {
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<PathBuf>) -> Result<String> {
let deps = get_dependencies(virtualenv)?;

if deps.is_empty() {
return Ok(String::new());
}

let packages: HashMap<String, String> = 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<Vec<Dependency>> {
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<Vec<Dependency>> {
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");
}
}
83 changes: 66 additions & 17 deletions crates/fastly-compute-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use wasm_metadata::AddMetadata;

pub mod cli;
pub mod config;
pub mod dependencies;
pub mod site_packages;

use cli::Cli;
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -170,7 +204,7 @@ pub fn build(output: PathBuf, entry_name: String, virtualenv: Option<PathBuf>) -
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()))?;
Expand Down Expand Up @@ -249,16 +283,18 @@ fn compose_with_wasiless(
/// - `processed-by: componentize-py <version>` — 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<u8>) -> Result<Vec<u8>> {
fn inject_fastly_metadata(wasm: Vec<u8>, virtualenv: &Option<PathBuf>) -> Result<Vec<u8>> {
let mut add_metadata = AddMetadata::default();

// Source language. The version is the CPython version bundled by
Expand All @@ -284,6 +320,19 @@ fn inject_fastly_metadata(wasm: Vec<u8>) -> Result<Vec<u8>> {
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")
Expand Down
3 changes: 2 additions & 1 deletion fastly_compute/runtime_patching/patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can stay or go, I just changed this as it was causing grief trying to write tests against the cli output.

else:
MAPPINGS = {
wit_world.imports.acl.AclError.GENERIC_ERROR: fastly_compute.exceptions.acl.acl_error.GenericError,
Expand Down
Loading
Loading