Merged
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.

4 changes: 2 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,11 @@ serve: $(WASM_FILE)

# Test all examples (requires all WASM files to be built)
test: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest

# Update snapshots for snapshot tests
test-update-snapshots: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest --snapshot-update

# List available examples
list-examples:
Expand Down
1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line numberDiff line numberDiff 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.28.3", features = ["abi3-py312"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
log = "0.4"
env_logger = "0.11"
Expand Down
3 changes: 2 additions & 1 deletion crates/fastly-compute-py/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
println!("cargo:rerun-if-changed=../../Cargo.lock");

let root_dir = PathBuf::from("../../");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR");
let root_dir: PathBuf = [&manifest_dir, "..", ".."].iter().collect();
let wit_dir = root_dir.join("wit");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);

Expand Down
23 changes: 22 additions & 1 deletion crates/fastly-compute-py/src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,6 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// Generate WIT binding stubs for use with type checkers and IDEs
Bindings {
/// WIT directory to generate bindings from (default: wit)
Expand All@@ -54,4 +53,26 @@ pub enum Command {
/// Output directory for the generated stubs
output_dir: 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,
}
1 change: 1 addition & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ impl ConfigBuilder {
self.cli.virtualenv = virtualenv.clone();
}
Command::Bindings { .. } => {}
Command::Dependencies { .. } => {}
}
self
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line numberDiff line numberDiff 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");
}
}
52 changes: 48 additions & 4 deletions crates/fastly-compute-py/src/lib.rs
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -113,6 +114,34 @@ pub fn run_main(cli: &Cli) -> Result<()> {
output_dir,
)?;
}
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 DownExpand Up@@ -223,7 +252,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 DownExpand Up@@ -302,16 +331,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@@ -337,6 +368,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 numberDiff line numberDiff 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
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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.

4 changes: 2 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,11 @@ serve: $(WASM_FILE)

# Test all examples (requires all WASM files to be built)
test: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest

# Update snapshots for snapshot tests
test-update-snapshots: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest --snapshot-update

# List available examples
list-examples:
Expand Down
1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line numberDiff line numberDiff 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.28.3", features = ["abi3-py312"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
log = "0.4"
env_logger = "0.11"
Expand Down
3 changes: 2 additions & 1 deletion crates/fastly-compute-py/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
println!("cargo:rerun-if-changed=../../Cargo.lock");

let root_dir = PathBuf::from("../../");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR");
let root_dir: PathBuf = [&manifest_dir, "..", ".."].iter().collect();
let wit_dir = root_dir.join("wit");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);

Expand Down
23 changes: 22 additions & 1 deletion crates/fastly-compute-py/src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,6 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// Generate WIT binding stubs for use with type checkers and IDEs
Bindings {
/// WIT directory to generate bindings from (default: wit)
Expand All@@ -54,4 +53,26 @@ pub enum Command {
/// Output directory for the generated stubs
output_dir: 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,
}
1 change: 1 addition & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ impl ConfigBuilder {
self.cli.virtualenv = virtualenv.clone();
}
Command::Bindings { .. } => {}
Command::Dependencies { .. } => {}
}
self
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line numberDiff line numberDiff 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");
}
}
52 changes: 48 additions & 4 deletions crates/fastly-compute-py/src/lib.rs
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -113,6 +114,34 @@ pub fn run_main(cli: &Cli) -> Result<()> {
output_dir,
)?;
}
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 DownExpand Up@@ -223,7 +252,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 DownExpand Up@@ -302,16 +331,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@@ -337,6 +368,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 numberDiff line numberDiff 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
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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.

4 changes: 2 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,11 @@ serve: $(WASM_FILE)

# Test all examples (requires all WASM files to be built)
test: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest

# Update snapshots for snapshot tests
test-update-snapshots: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest --snapshot-update

# List available examples
list-examples:
Expand Down
1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line numberDiff line numberDiff 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.28.3", features = ["abi3-py312"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
log = "0.4"
env_logger = "0.11"
Expand Down
3 changes: 2 additions & 1 deletion crates/fastly-compute-py/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
println!("cargo:rerun-if-changed=../../Cargo.lock");

let root_dir = PathBuf::from("../../");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR");
let root_dir: PathBuf = [&manifest_dir, "..", ".."].iter().collect();
let wit_dir = root_dir.join("wit");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);

Expand Down
23 changes: 22 additions & 1 deletion crates/fastly-compute-py/src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,6 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// Generate WIT binding stubs for use with type checkers and IDEs
Bindings {
/// WIT directory to generate bindings from (default: wit)
Expand All@@ -54,4 +53,26 @@ pub enum Command {
/// Output directory for the generated stubs
output_dir: 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,
}
1 change: 1 addition & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ impl ConfigBuilder {
self.cli.virtualenv = virtualenv.clone();
}
Command::Bindings { .. } => {}
Command::Dependencies { .. } => {}
}
self
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line numberDiff line numberDiff 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");
}
}
52 changes: 48 additions & 4 deletions crates/fastly-compute-py/src/lib.rs
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -113,6 +114,34 @@ pub fn run_main(cli: &Cli) -> Result<()> {
output_dir,
)?;
}
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 DownExpand Up@@ -223,7 +252,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 DownExpand Up@@ -302,16 +331,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@@ -337,6 +368,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 numberDiff line numberDiff 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
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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.

4 changes: 2 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,11 @@ serve: $(WASM_FILE)

# Test all examples (requires all WASM files to be built)
test: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest

# Update snapshots for snapshot tests
test-update-snapshots: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest --snapshot-update

# List available examples
list-examples:
Expand Down
1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line numberDiff line numberDiff 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.28.3", features = ["abi3-py312"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
log = "0.4"
env_logger = "0.11"
Expand Down
3 changes: 2 additions & 1 deletion crates/fastly-compute-py/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
println!("cargo:rerun-if-changed=../../Cargo.lock");

let root_dir = PathBuf::from("../../");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR");
let root_dir: PathBuf = [&manifest_dir, "..", ".."].iter().collect();
let wit_dir = root_dir.join("wit");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);

Expand Down
23 changes: 22 additions & 1 deletion crates/fastly-compute-py/src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,6 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// Generate WIT binding stubs for use with type checkers and IDEs
Bindings {
/// WIT directory to generate bindings from (default: wit)
Expand All@@ -54,4 +53,26 @@ pub enum Command {
/// Output directory for the generated stubs
output_dir: 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,
}
1 change: 1 addition & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ impl ConfigBuilder {
self.cli.virtualenv = virtualenv.clone();
}
Command::Bindings { .. } => {}
Command::Dependencies { .. } => {}
}
self
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line numberDiff line numberDiff 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");
}
}
52 changes: 48 additions & 4 deletions crates/fastly-compute-py/src/lib.rs
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -113,6 +114,34 @@ pub fn run_main(cli: &Cli) -> Result<()> {
output_dir,
)?;
}
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 DownExpand Up@@ -223,7 +252,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 DownExpand Up@@ -302,16 +331,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@@ -337,6 +368,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 numberDiff line numberDiff 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
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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.

4 changes: 2 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,11 @@ serve: $(WASM_FILE)

# Test all examples (requires all WASM files to be built)
test: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest

# Update snapshots for snapshot tests
test-update-snapshots: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest --snapshot-update

# List available examples
list-examples:
Expand Down
1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line numberDiff line numberDiff 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.28.3", features = ["abi3-py312"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
log = "0.4"
env_logger = "0.11"
Expand Down
3 changes: 2 additions & 1 deletion crates/fastly-compute-py/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
println!("cargo:rerun-if-changed=../../Cargo.lock");

let root_dir = PathBuf::from("../../");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR");
let root_dir: PathBuf = [&manifest_dir, "..", ".."].iter().collect();
let wit_dir = root_dir.join("wit");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);

Expand Down
23 changes: 22 additions & 1 deletion crates/fastly-compute-py/src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,6 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// Generate WIT binding stubs for use with type checkers and IDEs
Bindings {
/// WIT directory to generate bindings from (default: wit)
Expand All@@ -54,4 +53,26 @@ pub enum Command {
/// Output directory for the generated stubs
output_dir: 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,
}
1 change: 1 addition & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ impl ConfigBuilder {
self.cli.virtualenv = virtualenv.clone();
}
Command::Bindings { .. } => {}
Command::Dependencies { .. } => {}
}
self
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line numberDiff line numberDiff 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");
}
}
52 changes: 48 additions & 4 deletions crates/fastly-compute-py/src/lib.rs
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -113,6 +114,34 @@ pub fn run_main(cli: &Cli) -> Result<()> {
output_dir,
)?;
}
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 DownExpand Up@@ -223,7 +252,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 DownExpand Up@@ -302,16 +331,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@@ -337,6 +368,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 numberDiff line numberDiff 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
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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.

4 changes: 2 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,11 @@ serve: $(WASM_FILE)

# Test all examples (requires all WASM files to be built)
test: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest

# Update snapshots for snapshot tests
test-update-snapshots: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest --snapshot-update

# List available examples
list-examples:
Expand Down
1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line numberDiff line numberDiff 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.28.3", features = ["abi3-py312"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
log = "0.4"
env_logger = "0.11"
Expand Down
3 changes: 2 additions & 1 deletion crates/fastly-compute-py/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
println!("cargo:rerun-if-changed=../../Cargo.lock");

let root_dir = PathBuf::from("../../");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR");
let root_dir: PathBuf = [&manifest_dir, "..", ".."].iter().collect();
let wit_dir = root_dir.join("wit");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);

Expand Down
23 changes: 22 additions & 1 deletion crates/fastly-compute-py/src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,6 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// Generate WIT binding stubs for use with type checkers and IDEs
Bindings {
/// WIT directory to generate bindings from (default: wit)
Expand All@@ -54,4 +53,26 @@ pub enum Command {
/// Output directory for the generated stubs
output_dir: 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,
}
1 change: 1 addition & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ impl ConfigBuilder {
self.cli.virtualenv = virtualenv.clone();
}
Command::Bindings { .. } => {}
Command::Dependencies { .. } => {}
}
self
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line numberDiff line numberDiff 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");
}
}
52 changes: 48 additions & 4 deletions crates/fastly-compute-py/src/lib.rs
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -113,6 +114,34 @@ pub fn run_main(cli: &Cli) -> Result<()> {
output_dir,
)?;
}
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 DownExpand Up@@ -223,7 +252,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 DownExpand Up@@ -302,16 +331,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@@ -337,6 +368,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 numberDiff line numberDiff 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
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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.

4 changes: 2 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,11 @@ serve: $(WASM_FILE)

# Test all examples (requires all WASM files to be built)
test: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest

# Update snapshots for snapshot tests
test-update-snapshots: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest --snapshot-update

# List available examples
list-examples:
Expand Down
1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line numberDiff line numberDiff 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.28.3", features = ["abi3-py312"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
log = "0.4"
env_logger = "0.11"
Expand Down
3 changes: 2 additions & 1 deletion crates/fastly-compute-py/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
println!("cargo:rerun-if-changed=../../Cargo.lock");

let root_dir = PathBuf::from("../../");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR");
let root_dir: PathBuf = [&manifest_dir, "..", ".."].iter().collect();
let wit_dir = root_dir.join("wit");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);

Expand Down
23 changes: 22 additions & 1 deletion crates/fastly-compute-py/src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,6 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// Generate WIT binding stubs for use with type checkers and IDEs
Bindings {
/// WIT directory to generate bindings from (default: wit)
Expand All@@ -54,4 +53,26 @@ pub enum Command {
/// Output directory for the generated stubs
output_dir: 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,
}
1 change: 1 addition & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ impl ConfigBuilder {
self.cli.virtualenv = virtualenv.clone();
}
Command::Bindings { .. } => {}
Command::Dependencies { .. } => {}
}
self
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line numberDiff line numberDiff 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");
}
}
52 changes: 48 additions & 4 deletions crates/fastly-compute-py/src/lib.rs
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -113,6 +114,34 @@ pub fn run_main(cli: &Cli) -> Result<()> {
output_dir,
)?;
}
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 DownExpand Up@@ -223,7 +252,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 DownExpand Up@@ -302,16 +331,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@@ -337,6 +368,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 numberDiff line numberDiff 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
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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.

4 changes: 2 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,11 @@ serve: $(WASM_FILE)

# Test all examples (requires all WASM files to be built)
test: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest

# Update snapshots for snapshot tests
test-update-snapshots: $(COMPOSED_WASMS)
VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update
VICEROY=$(VICEROY) FASTLY_COMPUTE_PY="$(FASTLY_COMPUTE_PY)" uv run --extra test pytest --snapshot-update

# List available examples
list-examples:
Expand Down
1 change: 1 addition & 0 deletions crates/fastly-compute-py/Cargo.toml
Original file line numberDiff line numberDiff 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.28.3", features = ["abi3-py312"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
log = "0.4"
env_logger = "0.11"
Expand Down
3 changes: 2 additions & 1 deletion crates/fastly-compute-py/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac");
println!("cargo:rerun-if-changed=../../Cargo.lock");

let root_dir = PathBuf::from("../../");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR");
let root_dir: PathBuf = [&manifest_dir, "..", ".."].iter().collect();
let wit_dir = root_dir.join("wit");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);

Expand Down
23 changes: 22 additions & 1 deletion crates/fastly-compute-py/src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,6 @@ pub enum Command {
#[arg(short, long)]
virtualenv: Option<PathBuf>,
},

/// Generate WIT binding stubs for use with type checkers and IDEs
Bindings {
/// WIT directory to generate bindings from (default: wit)
Expand All@@ -54,4 +53,26 @@ pub enum Command {
/// Output directory for the generated stubs
output_dir: 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,
}
1 change: 1 addition & 0 deletions crates/fastly-compute-py/src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ impl ConfigBuilder {
self.cli.virtualenv = virtualenv.clone();
}
Command::Bindings { .. } => {}
Command::Dependencies { .. } => {}
}
self
}
Expand Down
198 changes: 198 additions & 0 deletions crates/fastly-compute-py/src/dependencies.rs
Original file line numberDiff line numberDiff 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");
}
}
52 changes: 48 additions & 4 deletions crates/fastly-compute-py/src/lib.rs
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -113,6 +114,34 @@ pub fn run_main(cli: &Cli) -> Result<()> {
output_dir,
)?;
}
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 DownExpand Up@@ -223,7 +252,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 DownExpand Up@@ -302,16 +331,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@@ -337,6 +368,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 numberDiff line numberDiff 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
MemberAuthor

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