diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5b4196..8bfeb99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,17 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + python-unit-tests: + name: Python Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Run snapshot_pull tests + run: python -m unittest discover -s services/_lib/tests -v + clippy: name: Clippy runs-on: ubuntu-latest @@ -68,10 +79,10 @@ jobs: services: ${{ steps.discover.outputs.services }} steps: - uses: actions/checkout@v4 - - name: Find services with service.yaml + - name: Find services with contract tests id: discover run: | - services=$(ls -d services/*/service.yaml 2>/dev/null | xargs -I {} dirname {} | xargs -I {} basename {} | jq -R -s -c 'split("\n") | map(select(length > 0))') + services=$(for d in services/*/; do [ -f "$d/service.yaml" ] && [ -d "$d/contracts" ] && basename "$d"; done | jq -R -s -c 'split("\n") | map(select(length > 0))') echo "services=$services" >> $GITHUB_OUTPUT contracts: diff --git a/.gitignore b/.gitignore index ea8c4bf..babd8df 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,21 @@ -/target +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +.pytest_cache/ + +# Rust +/target/ + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# DoubleAgent +.doubleagent.env diff --git a/README.md b/README.md index cabcb86..87ccaf8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,14 @@ doubleagent start github slack # Multiple services doubleagent status # Show running services doubleagent stop # Stop all doubleagent reset github # Clear state -doubleagent seed github ./data.yaml # Load fixtures +doubleagent seed github ./data.yaml # Load from file +doubleagent seed github --fixture startup # Load from service fixtures/ dir +doubleagent seed github --snapshot default # Seed from pulled snapshot + +doubleagent snapshot pull github # Pull snapshot from configured connector +doubleagent snapshot list # List local snapshots +doubleagent snapshot inspect github -p default +doubleagent snapshot delete github -p default ``` When a service starts, the CLI prints the environment variable to use: @@ -75,6 +82,24 @@ repo = client.get_user().create_repo("test-repo") issue = repo.create_issue(title="Test issue") ``` +### Snapshot Pulls (Airbyte) + +`doubleagent snapshot pull ` reads connector config from `service.yaml`, pulls +records from Airbyte, writes a local snapshot profile, and produces a seed payload at: + +`~/.doubleagent/snapshots///seed.json` + +Typical workflow: + +```bash +doubleagent start github +doubleagent snapshot pull github --profile default +doubleagent seed github --snapshot default +``` + +Snapshot pulls are gated by `connector.required_env` in the service definition and run with +redaction by default. + ## Project Configuration Define which services your project needs in a `doubleagent.yaml` file at the root of your repository: @@ -135,10 +160,11 @@ doubleagent start github slack | GitHub | ✅ Available | PyGithub, octokit | | Slack | ✅ Available | slack_sdk | | Descope | ✅ Available | descope | -| Jira | 🚧 Coming soon | atlassian-python-api | -| Okta | 🚧 Coming soon | okta | | Auth0 | ✅ Available | auth0-python | | Stripe | ✅ Available | stripe | +| Jira | ✅ Available (snapshot) | atlassian-python-api | +| Salesforce | ✅ Available (snapshot) | simple-salesforce | +| Okta | 🚧 Coming soon | okta | ## Contributing diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index a986007..b0dfba9 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod list; pub mod reset; pub mod run; pub mod seed; +pub mod snapshot; pub mod start; pub mod status; pub mod stop; @@ -50,6 +51,9 @@ pub enum Commands { /// Run a command with services started and env vars set Run(RunArgs), + + /// Pull and manage snapshot profiles + Snapshot(SnapshotArgs), } #[derive(Parser)] @@ -107,7 +111,15 @@ pub struct SeedArgs { pub service: String, /// Path to seed data file (YAML or JSON) - pub file: String, + pub file: Option, + + /// Seed from snapshot profile (uses ~/.doubleagent/snapshots///seed.json) + #[arg(long)] + pub snapshot: Option, + + /// Name of a fixture in the service's fixtures/ directory (e.g. "startup") + #[arg(long)] + pub fixture: Option, } #[derive(Parser)] @@ -134,3 +146,73 @@ pub struct RunArgs { #[arg(last = true, required = true)] pub command: Vec, } + +#[derive(Parser)] +pub struct SnapshotArgs { + #[command(subcommand)] + pub command: SnapshotCommands, +} + +#[derive(Subcommand)] +pub enum SnapshotCommands { + /// Pull a snapshot profile from a configured connector + Pull(SnapshotPullArgs), + /// List available snapshot profiles + List(SnapshotListArgs), + /// Print a snapshot manifest as JSON + Inspect(SnapshotInspectArgs), + /// Delete a snapshot profile + Delete(SnapshotDeleteArgs), +} + +#[derive(Parser)] +pub struct SnapshotPullArgs { + /// Service to pull a snapshot for + pub service: String, + + /// Snapshot profile name + #[arg(long, short)] + pub profile: Option, + + /// Max records per stream + #[arg(long, short)] + pub limit: Option, + + /// Disable redaction + #[arg(long)] + pub no_redact: bool, + + /// Merge into existing snapshot profile by id + #[arg(long)] + pub incremental: bool, + + /// Force backend ("pyairbyte") + #[arg(long)] + pub backend: Option, +} + +#[derive(Parser)] +pub struct SnapshotListArgs { + /// Filter by service + pub service: Option, +} + +#[derive(Parser)] +pub struct SnapshotInspectArgs { + /// Service name + pub service: String, + + /// Snapshot profile name + #[arg(long, short)] + pub profile: String, +} + +#[derive(Parser)] +pub struct SnapshotDeleteArgs { + /// Service name + pub service: String, + + /// Snapshot profile name + #[arg(long, short)] + pub profile: String, +} diff --git a/crates/cli/src/commands/seed.rs b/crates/cli/src/commands/seed.rs index c75e1a3..9cd3bde 100644 --- a/crates/cli/src/commands/seed.rs +++ b/crates/cli/src/commands/seed.rs @@ -1,7 +1,73 @@ use super::SeedArgs; use colored::Colorize; -use doubleagent_core::{Config, ProcessManager}; +use doubleagent_core::{snapshot, Config, ProcessManager, ServiceRegistry}; use std::fs; +use std::path::PathBuf; + +/// Resolve the seed data file path from args. +/// +/// Priority: --snapshot > --fixture (resolves via service fixtures dir) > --file (explicit path). +fn resolve_seed_source(args: &SeedArgs, config: &Config) -> anyhow::Result { + let flags_set = + args.snapshot.is_some() as u8 + args.fixture.is_some() as u8 + args.file.is_some() as u8; + + if flags_set > 1 { + anyhow::bail!("Use only one of: --snapshot, --fixture, or a file path"); + } + + if let Some(ref profile) = args.snapshot { + return Ok(SeedSource::Snapshot(profile.clone())); + } + + if let Some(ref fixture_name) = args.fixture { + let registry = + ServiceRegistry::new(&config.services_dir, &config.repo_url, &config.branch)?; + // Use local-aware resolution so fixture seeding works in repo/CI without prior install. + let service = registry.get_or_install(&args.service, false)?; + let fixture_path = service + .path + .join("fixtures") + .join(format!("{}.yaml", fixture_name)); + + if fixture_path.exists() { + return Ok(SeedSource::File(fixture_path)); + } + // Try .yml extension + let yml_path = service + .path + .join("fixtures") + .join(format!("{}.yml", fixture_name)); + if yml_path.exists() { + return Ok(SeedSource::File(yml_path)); + } + anyhow::bail!( + "Fixture '{}' not found for service '{}' (looked in {})", + fixture_name, + args.service, + fixture_path.display() + ); + } + + if let Some(ref file_path) = args.file { + return Ok(SeedSource::File(PathBuf::from(file_path))); + } + + anyhow::bail!( + "A seed source is required.\n\ + Usage: doubleagent seed {} --snapshot default\n\ + Usage: doubleagent seed {} --fixture startup\n\ + Usage: doubleagent seed {} path/to/data.yaml", + args.service, + args.service, + args.service + ) +} + +#[derive(Debug)] +enum SeedSource { + Snapshot(String), + File(PathBuf), +} pub async fn run(args: SeedArgs) -> anyhow::Result<()> { let config = Config::load()?; @@ -11,15 +77,31 @@ pub async fn run(args: SeedArgs) -> anyhow::Result<()> { .get_info(&args.service) .ok_or_else(|| anyhow::anyhow!("{} is not running", args.service))?; - // Read and parse seed file - let content = fs::read_to_string(&args.file)?; - let data: serde_json::Value = if args.file.ends_with(".yaml") || args.file.ends_with(".yml") { - serde_yaml::from_str(&content)? - } else { - serde_json::from_str(&content)? + let source = resolve_seed_source(&args, &config)?; + + let (data, source_label): (serde_json::Value, String) = match source { + SeedSource::Snapshot(ref profile) => { + let payload = snapshot::load_seed_payload(&args.service, profile)?; + (payload, format!("snapshot:{}", profile)) + } + SeedSource::File(ref path) => { + let content = fs::read_to_string(path)?; + let file_str = path.to_string_lossy(); + let parsed = if file_str.ends_with(".yaml") || file_str.ends_with(".yml") { + serde_yaml::from_str(&content)? + } else { + serde_json::from_str(&content)? + }; + (parsed, path.display().to_string()) + } }; - print!("{} Seeding {}...", "⬆".blue(), args.service); + print!( + "{} Seeding {} from {}...", + "⬆".blue(), + args.service, + source_label + ); let url = format!("http://localhost:{}/_doubleagent/seed", info.port); let client = reqwest::Client::new(); @@ -48,3 +130,133 @@ pub async fn run(args: SeedArgs) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn test_config(services_dir: PathBuf) -> Config { + Config { + services_dir, + state_file: PathBuf::from("/tmp/state.json"), + repo_url: "https://example.com/repo.git".to_string(), + branch: "main".to_string(), + project_config_path: None, + } + } + + fn write_service_yaml(services_dir: &std::path::Path, service: &str) -> PathBuf { + let service_dir = services_dir.join(service); + std::fs::create_dir_all(&service_dir).unwrap(); + std::fs::write( + service_dir.join("service.yaml"), + format!( + "name: {}\nserver:\n command:\n - uvicorn\n - app:app\n", + service + ), + ) + .unwrap(); + service_dir + } + + fn seed_args(service: &str) -> SeedArgs { + SeedArgs { + service: service.to_string(), + file: None, + snapshot: None, + fixture: None, + } + } + + #[test] + fn resolve_snapshot_source() { + let temp = tempdir().unwrap(); + let config = test_config(temp.path().to_path_buf()); + let mut args = seed_args("github"); + args.snapshot = Some("default".to_string()); + + let source = resolve_seed_source(&args, &config).unwrap(); + match source { + SeedSource::Snapshot(profile) => assert_eq!(profile, "default"), + SeedSource::File(_) => panic!("expected snapshot source"), + } + } + + #[test] + fn resolve_errors_when_multiple_sources_provided() { + let temp = tempdir().unwrap(); + let config = test_config(temp.path().to_path_buf()); + let mut args = seed_args("github"); + args.snapshot = Some("default".to_string()); + args.file = Some("seed.json".to_string()); + + let err = resolve_seed_source(&args, &config).unwrap_err().to_string(); + assert!(err.contains("Use only one of: --snapshot, --fixture, or a file path")); + } + + #[test] + fn resolve_fixture_yaml_source() { + let temp = tempdir().unwrap(); + let services_dir = temp.path().join("services"); + let config = test_config(services_dir.clone()); + let service_dir = write_service_yaml(&services_dir, "github"); + std::fs::create_dir_all(service_dir.join("fixtures")).unwrap(); + let fixture_path = service_dir.join("fixtures").join("startup.yaml"); + std::fs::write(&fixture_path, "users: []\n").unwrap(); + + let mut args = seed_args("github"); + args.fixture = Some("startup".to_string()); + let source = resolve_seed_source(&args, &config).unwrap(); + + match source { + SeedSource::File(path) => assert_eq!(path, fixture_path), + SeedSource::Snapshot(_) => panic!("expected file source"), + } + } + + #[test] + fn resolve_fixture_yml_fallback() { + let temp = tempdir().unwrap(); + let services_dir = temp.path().join("services"); + let config = test_config(services_dir.clone()); + let service_dir = write_service_yaml(&services_dir, "github"); + std::fs::create_dir_all(service_dir.join("fixtures")).unwrap(); + let fixture_path = service_dir.join("fixtures").join("startup.yml"); + std::fs::write(&fixture_path, "users: []\n").unwrap(); + + let mut args = seed_args("github"); + args.fixture = Some("startup".to_string()); + let source = resolve_seed_source(&args, &config).unwrap(); + + match source { + SeedSource::File(path) => assert_eq!(path, fixture_path), + SeedSource::Snapshot(_) => panic!("expected file source"), + } + } + + #[test] + fn resolve_explicit_file_source() { + let temp = tempdir().unwrap(); + let config = test_config(temp.path().to_path_buf()); + let mut args = seed_args("github"); + args.file = Some("fixtures/data.json".to_string()); + + let source = resolve_seed_source(&args, &config).unwrap(); + match source { + SeedSource::File(path) => assert_eq!(path, PathBuf::from("fixtures/data.json")), + SeedSource::Snapshot(_) => panic!("expected file source"), + } + } + + #[test] + fn resolve_errors_when_no_source_given() { + let temp = tempdir().unwrap(); + let config = test_config(temp.path().to_path_buf()); + let args = seed_args("github"); + + let err = resolve_seed_source(&args, &config).unwrap_err().to_string(); + assert!(err.contains("A seed source is required.")); + assert!(err.contains("Usage: doubleagent seed github --snapshot default")); + } +} diff --git a/crates/cli/src/commands/snapshot.rs b/crates/cli/src/commands/snapshot.rs new file mode 100644 index 0000000..d1beaf7 --- /dev/null +++ b/crates/cli/src/commands/snapshot.rs @@ -0,0 +1,233 @@ +use super::SnapshotArgs; +use colored::Colorize; +use doubleagent_core::{snapshot, Config, ServiceRegistry}; +use std::path::PathBuf; + +pub async fn run(args: SnapshotArgs) -> anyhow::Result<()> { + match args.command { + super::SnapshotCommands::Pull(args) => run_pull(args).await, + super::SnapshotCommands::List(args) => run_list(args).await, + super::SnapshotCommands::Inspect(args) => run_inspect(args).await, + super::SnapshotCommands::Delete(args) => run_delete(args).await, + } +} + +async fn run_pull(args: super::SnapshotPullArgs) -> anyhow::Result<()> { + if snapshot::is_compliance_mode() { + return Err(anyhow::anyhow!( + "Snapshot pull is disabled in strict compliance mode" + )); + } + + let profile = args.profile.as_deref().unwrap_or("default"); + let config = Config::load()?; + let registry = ServiceRegistry::new(&config.services_dir, &config.repo_url, &config.branch)?; + let service = registry.get_or_install(&args.service, true)?; + let connector = service.connector.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Service '{}' has no connector configuration in service.yaml", + args.service + ) + })?; + + let missing_env: Vec<&str> = connector + .required_env + .iter() + .map(String::as_str) + .filter(|key| { + std::env::var(key) + .map(|v| v.trim().is_empty()) + .unwrap_or(true) + }) + .collect(); + if !missing_env.is_empty() { + return Err(anyhow::anyhow!( + "Missing required env vars for '{}': {}", + args.service, + missing_env.join(", ") + )); + } + + let connector_type = connector.r#type.as_str(); + let (cmd_args, work_dir): (Vec, PathBuf) = if connector_type == "airbyte" { + let image = connector.image.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Service '{}' connector.type=airbyte requires connector.image", + args.service + ) + })?; + + let mut cmd_args = vec![ + "uv".to_string(), + "run".to_string(), + "python".to_string(), + "-m".to_string(), + "snapshot_pull".to_string(), + "--service".to_string(), + args.service.clone(), + "--profile".to_string(), + profile.to_string(), + "--image".to_string(), + image.clone(), + ]; + + if !connector.streams.is_empty() { + cmd_args.push("--streams".to_string()); + cmd_args.push(connector.streams.join(",")); + } + for (env_var, config_path) in &connector.config_env { + cmd_args.push("--config-env".to_string()); + cmd_args.push(format!("{env_var}={config_path}")); + } + for (stream, resource) in &connector.stream_mapping { + cmd_args.push("--stream-mapping".to_string()); + cmd_args.push(format!("{stream}={resource}")); + } + if let Some(seeding) = &connector.seeding { + cmd_args.push("--seeding-json".to_string()); + cmd_args.push(serde_json::to_string(seeding)?); + } + + let backend = args + .backend + .clone() + .or_else(|| connector.backend.clone()) + .unwrap_or_else(|| "pyairbyte".to_string()); + cmd_args.push("--backend".to_string()); + cmd_args.push(backend); + + if let Some(limit) = args.limit { + cmd_args.push("--limit".to_string()); + cmd_args.push(limit.to_string()); + } + if args.no_redact { + cmd_args.push("--no-redact".to_string()); + } + if args.incremental { + cmd_args.push("--incremental".to_string()); + } + + let lib_dir = config + .services_dir + .join(".repo") + .join("services") + .join("_lib"); + if !lib_dir.exists() { + return Err(anyhow::anyhow!( + "Snapshot helper directory not found at {}. Run 'doubleagent update' and try again.", + lib_dir.display() + )); + } + + (cmd_args, lib_dir) + } else if connector_type == "native" { + let script_path = service.path.join("connector").join("pull.py"); + if !script_path.exists() { + return Err(anyhow::anyhow!( + "Native connector missing script: {}", + script_path.display() + )); + } + + let mut cmd_args = vec![ + "uv".to_string(), + "run".to_string(), + "python".to_string(), + script_path.display().to_string(), + "--service".to_string(), + args.service.clone(), + "--profile".to_string(), + profile.to_string(), + ]; + if let Some(limit) = args.limit { + cmd_args.push("--limit".to_string()); + cmd_args.push(limit.to_string()); + } + if args.no_redact { + cmd_args.push("--no-redact".to_string()); + } + if args.incremental { + cmd_args.push("--incremental".to_string()); + } + + (cmd_args, service.path.clone()) + } else { + return Err(anyhow::anyhow!( + "Unsupported connector type '{}' for service '{}'", + connector_type, + args.service + )); + }; + + println!( + "{} Pulling snapshot for {} (profile: {})", + "▶".blue(), + args.service.bold(), + profile.cyan() + ); + + let mut cmd = doubleagent_core::mise::build_command(&work_dir, &cmd_args)?; + cmd.current_dir(&work_dir); + let status = cmd.status()?; + if !status.success() { + return Err(anyhow::anyhow!("Snapshot pull failed")); + } + + let dir = snapshot::snapshot_dir(&args.service, profile); + println!("{} Snapshot saved at {}", "✓".green(), dir.display()); + Ok(()) +} + +async fn run_list(args: super::SnapshotListArgs) -> anyhow::Result<()> { + let snapshots = snapshot::list_snapshots(args.service.as_deref())?; + if snapshots.is_empty() { + println!("No snapshots found."); + return Ok(()); + } + + println!("{}", "Available snapshots:".bold()); + for s in snapshots { + let mut counts: Vec = s + .resource_counts + .iter() + .map(|(k, v)| format!("{k}:{v}")) + .collect(); + counts.sort(); + + println!( + " {}/{} connector={} redacted={} resources=[{}]", + s.service, + s.profile, + s.connector, + s.redacted, + counts.join(", ") + ); + } + + Ok(()) +} + +async fn run_inspect(args: super::SnapshotInspectArgs) -> anyhow::Result<()> { + let manifest = snapshot::load_manifest(&args.service, &args.profile)?; + println!("{}", serde_json::to_string_pretty(&manifest)?); + Ok(()) +} + +async fn run_delete(args: super::SnapshotDeleteArgs) -> anyhow::Result<()> { + if snapshot::delete_snapshot(&args.service, &args.profile)? { + println!( + "{} Deleted snapshot {}/{}", + "✓".green(), + args.service, + args.profile + ); + } else { + println!( + "{} Snapshot {}/{} not found", + "⚠".yellow(), + args.service, + args.profile + ); + } + Ok(()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5fa994f..a467b94 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -45,6 +45,9 @@ async fn run() -> anyhow::Result<()> { } commands::Commands::Update(args) => run_command!("update", commands::update::run(args)), commands::Commands::Run(args) => run_command!("run", commands::run::run(args)), + commands::Commands::Snapshot(args) => { + run_command!("snapshot", commands::snapshot::run(args)) + } } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3c1f1e2..9c637c7 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,9 +9,12 @@ pub mod git; pub mod mise; pub mod process; pub mod service; +pub mod snapshot; // Re-exports for convenience pub use config::Config; pub use error::{Error, Result}; pub use process::{ProcessManager, ServiceInfo}; -pub use service::{ContractsConfig, ServerConfig, ServiceDefinition, ServiceRegistry}; +pub use service::{ + ConnectorConfig, ContractsConfig, ServerConfig, ServiceDefinition, ServiceRegistry, +}; diff --git a/crates/core/src/service.rs b/crates/core/src/service.rs index d884a89..6fdfa1c 100644 --- a/crates/core/src/service.rs +++ b/crates/core/src/service.rs @@ -30,11 +30,43 @@ pub struct ServiceDefinition { pub server: ServerConfig, /// Contract test configuration pub contracts: Option, + /// Snapshot connector configuration + pub connector: Option, /// Path to the service directory (not serialized) #[serde(skip)] pub path: PathBuf, } +/// Connector configuration for snapshot pulls. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectorConfig { + /// Connector type: "native" or "airbyte" (default: "native") + #[serde(default = "default_connector_type")] + pub r#type: String, + /// Airbyte source image (for type: airbyte) + pub image: Option, + /// Streams to pull (empty means connector default) + #[serde(default)] + pub streams: Vec, + /// Mapping of env var name -> connector config path + #[serde(default)] + pub config_env: HashMap, + /// Mapping of raw stream name -> resource name for seed payload + #[serde(default)] + pub stream_mapping: HashMap, + /// Required environment variables for pulling data + #[serde(default)] + pub required_env: Vec, + /// Optional backend hint (for example: "pyairbyte") + pub backend: Option, + /// Optional smart seeding config passed to pull helpers + pub seeding: Option, +} + +fn default_connector_type() -> String { + "native".to_string() +} + /// Server configuration for a service. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { diff --git a/crates/core/src/snapshot.rs b/crates/core/src/snapshot.rs new file mode 100644 index 0000000..725a88f --- /dev/null +++ b/crates/core/src/snapshot.rs @@ -0,0 +1,271 @@ +//! Snapshot metadata and storage helpers. + +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +/// Metadata for a stored snapshot profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnapshotManifest { + pub service: String, + pub profile: String, + pub version: u32, + pub pulled_at: f64, + pub connector: String, + pub redacted: bool, + pub resource_counts: HashMap, +} + +/// Return the snapshots root directory. +pub fn default_snapshots_dir() -> PathBuf { + if let Ok(dir) = std::env::var("DOUBLEAGENT_SNAPSHOTS_DIR") { + return PathBuf::from(dir); + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".doubleagent") + .join("snapshots") +} + +/// Return a specific snapshot directory path. +pub fn snapshot_dir(service: &str, profile: &str) -> PathBuf { + default_snapshots_dir().join(service).join(profile) +} + +/// Return true when strict compliance mode is enabled. +pub fn is_compliance_mode() -> bool { + std::env::var("DOUBLEAGENT_COMPLIANCE_MODE") + .map(|v| v.eq_ignore_ascii_case("strict")) + .unwrap_or(false) +} + +/// Read a snapshot manifest. +pub fn load_manifest(service: &str, profile: &str) -> Result { + let path = snapshot_dir(service, profile).join("manifest.json"); + if !path.exists() { + return Err(Error::Other(format!( + "Snapshot '{}/{}' not found", + service, profile + ))); + } + let content = fs::read_to_string(path)?; + Ok(serde_json::from_str(&content)?) +} + +/// Read the seed payload from a snapshot profile. +pub fn load_seed_payload(service: &str, profile: &str) -> Result { + let path = snapshot_dir(service, profile).join("seed.json"); + if !path.exists() { + return Err(Error::Other(format!( + "Snapshot seed payload not found at {}", + path.display() + ))); + } + let content = fs::read_to_string(path)?; + Ok(serde_json::from_str(&content)?) +} + +/// List manifests (optionally filtered by service), newest first. +pub fn list_snapshots(service: Option<&str>) -> Result> { + let base = default_snapshots_dir(); + if !base.exists() { + return Ok(Vec::new()); + } + + let service_dirs: Vec = if let Some(svc) = service { + vec![base.join(svc)] + } else { + fs::read_dir(&base)? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| path.is_dir()) + .collect() + }; + + let mut manifests = Vec::new(); + for svc_dir in service_dirs { + if !svc_dir.exists() || !svc_dir.is_dir() { + continue; + } + + for profile_entry in fs::read_dir(svc_dir)? { + let profile_dir = profile_entry?.path(); + if !profile_dir.is_dir() { + continue; + } + let manifest_path = profile_dir.join("manifest.json"); + if !manifest_path.exists() { + continue; + } + + let content = fs::read_to_string(manifest_path)?; + let manifest: SnapshotManifest = serde_json::from_str(&content)?; + manifests.push(manifest); + } + } + + manifests.sort_by(|a, b| { + b.pulled_at + .partial_cmp(&a.pulled_at) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(manifests) +} + +/// Delete a snapshot profile from disk. +pub fn delete_snapshot(service: &str, profile: &str) -> Result { + let dir = snapshot_dir(service, profile); + if dir.exists() { + fs::remove_dir_all(dir)?; + Ok(true) + } else { + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvGuard { + key: &'static str, + old: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let old = std::env::var(key).ok(); + std::env::set_var(key, value); + Self { key, old } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(old) = self.old.take() { + std::env::set_var(self.key, old); + } else { + std::env::remove_var(self.key); + } + } + } + + fn write_manifest(service: &str, profile: &str, pulled_at: f64) { + let dir = snapshot_dir(service, profile); + fs::create_dir_all(&dir).unwrap(); + let manifest = SnapshotManifest { + service: service.to_string(), + profile: profile.to_string(), + version: 1, + pulled_at, + connector: "airbyte:test".to_string(), + redacted: true, + resource_counts: HashMap::from([("issues".to_string(), 3)]), + }; + fs::write( + dir.join("manifest.json"), + serde_json::to_string(&manifest).unwrap(), + ) + .unwrap(); + } + + #[test] + fn default_snapshots_dir_uses_override() { + let _guard = env_lock().lock().unwrap(); + let temp = tempdir().unwrap(); + let expected = temp.path().join("snapshots-root"); + let _env = EnvGuard::set("DOUBLEAGENT_SNAPSHOTS_DIR", expected.to_str().unwrap()); + assert_eq!(default_snapshots_dir(), expected); + } + + #[test] + fn compliance_mode_only_accepts_strict() { + let _guard = env_lock().lock().unwrap(); + + let _env = EnvGuard::set("DOUBLEAGENT_COMPLIANCE_MODE", "strict"); + assert!(is_compliance_mode()); + + let _env = EnvGuard::set("DOUBLEAGENT_COMPLIANCE_MODE", "warn"); + assert!(!is_compliance_mode()); + } + + #[test] + fn list_snapshots_returns_newest_first() { + let _guard = env_lock().lock().unwrap(); + let temp = tempdir().unwrap(); + let _env = EnvGuard::set("DOUBLEAGENT_SNAPSHOTS_DIR", temp.path().to_str().unwrap()); + + write_manifest("github", "older", 100.0); + write_manifest("github", "newer", 200.0); + write_manifest("slack", "middle", 150.0); + + let list = list_snapshots(None).unwrap(); + let keys: Vec<(String, String)> = list + .iter() + .map(|m| (m.service.clone(), m.profile.clone())) + .collect(); + assert_eq!( + keys, + vec![ + ("github".to_string(), "newer".to_string()), + ("slack".to_string(), "middle".to_string()), + ("github".to_string(), "older".to_string()), + ] + ); + } + + #[test] + fn list_snapshots_can_filter_by_service() { + let _guard = env_lock().lock().unwrap(); + let temp = tempdir().unwrap(); + let _env = EnvGuard::set("DOUBLEAGENT_SNAPSHOTS_DIR", temp.path().to_str().unwrap()); + + write_manifest("github", "default", 100.0); + write_manifest("slack", "default", 200.0); + + let list = list_snapshots(Some("github")).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].service, "github"); + } + + #[test] + fn load_manifest_and_seed_payload_report_missing() { + let _guard = env_lock().lock().unwrap(); + let temp = tempdir().unwrap(); + let _env = EnvGuard::set("DOUBLEAGENT_SNAPSHOTS_DIR", temp.path().to_str().unwrap()); + + let missing_manifest = load_manifest("github", "missing").unwrap_err().to_string(); + assert!(missing_manifest.contains("Snapshot 'github/missing' not found")); + + let dir = snapshot_dir("github", "default"); + fs::create_dir_all(&dir).unwrap(); + let missing_seed = load_seed_payload("github", "default") + .unwrap_err() + .to_string(); + assert!(missing_seed.contains("Snapshot seed payload not found")); + } + + #[test] + fn delete_snapshot_returns_true_then_false() { + let _guard = env_lock().lock().unwrap(); + let temp = tempdir().unwrap(); + let _env = EnvGuard::set("DOUBLEAGENT_SNAPSHOTS_DIR", temp.path().to_str().unwrap()); + + let dir = snapshot_dir("github", "default"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("seed.json"), r#"{"issues":[]}"#).unwrap(); + + assert!(delete_snapshot("github", "default").unwrap()); + assert!(!delete_snapshot("github", "default").unwrap()); + } +} diff --git a/docs/contributing.md b/docs/contributing.md index e3b6806..eb00f2d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -109,6 +109,19 @@ contracts: env: API_URL: "http://localhost:${port}" API_TOKEN: "doubleagent-fake-token" + +# Optional: enable snapshot pulls via Airbyte +connector: + type: airbyte + image: airbyte/source-your-service:latest + streams: + - resources + config_env: + YOUR_TOKEN_ENV: credentials.api_token + stream_mapping: + resources: resources + required_env: + - YOUR_TOKEN_ENV ``` **.mise.toml** - Toolchain requirements (in service root): diff --git a/services/_lib/pyproject.toml b/services/_lib/pyproject.toml new file mode 100644 index 0000000..dce485b --- /dev/null +++ b/services/_lib/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "snapshot-pull" +version = "0.1.0" +description = "DoubleAgent snapshot pull tool — Airbyte connectors + smart seeding + PII redaction" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +airbyte = ["airbyte>=0.19.0"] diff --git a/services/_lib/snapshot_pull/__init__.py b/services/_lib/snapshot_pull/__init__.py new file mode 100644 index 0000000..4a9b273 --- /dev/null +++ b/services/_lib/snapshot_pull/__init__.py @@ -0,0 +1,2 @@ +"""Snapshot pull helpers for Airbyte-backed services.""" + diff --git a/services/_lib/snapshot_pull/__main__.py b/services/_lib/snapshot_pull/__main__.py new file mode 100644 index 0000000..2b77331 --- /dev/null +++ b/services/_lib/snapshot_pull/__main__.py @@ -0,0 +1,171 @@ +"""CLI entry point for Airbyte-based snapshot pulls.""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +from typing import Any + +from snapshot_pull.redactor import PiiRedactor +from snapshot_pull.smart_filter import SeedingConfig, apply_relational_filter +from snapshot_pull.snapshot import save_snapshot + +logging.basicConfig(level=logging.INFO, format=" %(message)s") +logger = logging.getLogger(__name__) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Pull snapshot data from Airbyte connector") + parser.add_argument("--service", required=True) + parser.add_argument("--profile", default="default") + parser.add_argument("--image", required=True, help="airbyte/source-xxx image") + parser.add_argument("--streams", default="", help="Comma-separated stream names") + parser.add_argument("--config-env", action="append", default=[], help="ENV=path.to.key") + parser.add_argument( + "--stream-mapping", + action="append", + default=[], + help="stream_name=resource_name", + ) + parser.add_argument("--seeding-json", default=None) + parser.add_argument("--limit", type=int, default=None) + parser.add_argument("--backend", default="pyairbyte") + parser.add_argument("--no-redact", action="store_true") + parser.add_argument("--incremental", action="store_true") + return parser.parse_args() + + +def parse_mappings(values: list[str]) -> dict[str, str]: + mapping: dict[str, str] = {} + for item in values: + if "=" not in item: + continue + key, value = item.split("=", 1) + mapping[key.strip()] = value.strip() + return mapping + + +def set_nested(data: dict[str, Any], dotted_path: str, value: Any) -> None: + keys = dotted_path.split(".") + current = data + for key in keys[:-1]: + if key not in current or not isinstance(current[key], dict): + current[key] = {} + current = current[key] + current[keys[-1]] = value + + +def build_connector_config(config_env: dict[str, str]) -> tuple[dict[str, Any], list[str]]: + config: dict[str, Any] = {} + missing: list[str] = [] + for env_var, config_path in config_env.items(): + value = os.environ.get(env_var) + if value is None or value.strip() == "": + missing.append(env_var) + continue + set_nested(config, config_path, value) + return config, missing + + +def build_limits(config: SeedingConfig) -> dict[str, int]: + limits: dict[str, int] = {} + for seed in config.seed_streams: + if seed.limit is not None: + limits[seed.stream] = seed.limit + if config.default_limit is not None: + for stream in config.all_stream_names(): + limits.setdefault(stream, config.default_limit) + return limits + + +def map_streams( + resources: dict[str, list[dict[str, Any]]], + stream_mapping: dict[str, str], +) -> dict[str, list[dict[str, Any]]]: + mapped: dict[str, list[dict[str, Any]]] = {} + for stream_name, rows in resources.items(): + resource_name = stream_mapping.get(stream_name, stream_name) + mapped.setdefault(resource_name, []).extend(rows) + return mapped + + +def main() -> None: + args = parse_args() + if args.backend != "pyairbyte": + raise SystemExit( + f"Unsupported backend '{args.backend}'. Supported backends: pyairbyte" + ) + + config_env = parse_mappings(args.config_env) + stream_mapping = parse_mappings(args.stream_mapping) + connector_config, missing_env = build_connector_config(config_env) + if missing_env: + raise SystemExit( + f"Missing required connector env vars: {', '.join(sorted(set(missing_env)))}" + ) + + from snapshot_pull.pyairbyte_backend import PyAirbyteBackend, image_to_connector_name + + connector_name = image_to_connector_name(args.image) + backend = PyAirbyteBackend(connector_name, connector_config) + + selected_streams = [s.strip() for s in args.streams.split(",") if s.strip()] + if not selected_streams: + selected_streams = backend.discover_streams() + + seeding_config: SeedingConfig | None = None + if args.seeding_json: + seeding_config = SeedingConfig.from_dict(json.loads(args.seeding_json)) + needed_streams = seeding_config.all_stream_names() + selected_streams = [s for s in selected_streams if s in needed_streams] + + limits = build_limits(seeding_config) if seeding_config is not None else None + resources = backend.pull_streams( + selected_streams, + per_stream_limits=limits, + global_limit=args.limit, + ) + if not resources: + logger.warning("No records pulled") + return + + if seeding_config is not None: + before = sum(len(rows) for rows in resources.values()) + resources = apply_relational_filter(resources, seeding_config) + after = sum(len(rows) for rows in resources.values()) + logger.info(" relational filter: %d -> %d records", before, after) + + resources = map_streams(resources, stream_mapping) + + if not args.no_redact: + redactor = PiiRedactor() + resources = { + resource: redactor.redact_resources(rows) + for resource, rows in resources.items() + } + + snapshot_path = save_snapshot( + service=args.service, + profile=args.profile, + resources=resources, + connector_name=f"airbyte:{args.image}", + redacted=not args.no_redact, + incremental=args.incremental, + ) + + total = sum(len(rows) for rows in resources.values()) + logger.info(" snapshot saved: %s", snapshot_path) + logger.info(" total records: %d", total) + for resource_name in sorted(resources): + logger.info(" %s: %d", resource_name, len(resources[resource_name])) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(130) + diff --git a/services/_lib/snapshot_pull/pyairbyte_backend.py b/services/_lib/snapshot_pull/pyairbyte_backend.py new file mode 100644 index 0000000..276671c --- /dev/null +++ b/services/_lib/snapshot_pull/pyairbyte_backend.py @@ -0,0 +1,85 @@ +"""PyAirbyte backend for snapshot pulls.""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +_INSTALL_HINT = ( + "PyAirbyte is not installed. Install it with:\n" + " uv pip install 'airbyte>=0.19.0'" +) + + +def _import_airbyte(): + try: + import airbyte as ab + except ImportError as exc: + raise RuntimeError(_INSTALL_HINT) from exc + return ab + + +def image_to_connector_name(image: str) -> str: + # airbyte/source-jira:latest -> source-jira + name = image.split("/")[-1] + name = name.split(":")[0] + return name + + +class PyAirbyteBackend: + def __init__(self, connector_name: str, config: dict[str, Any]) -> None: + self.connector_name = connector_name + self.config = config + self._source = None + + def _source_handle(self): + if self._source is None: + ab = _import_airbyte() + self._source = ab.get_source( + self.connector_name, + config=self.config, + install_if_missing=True, + ) + return self._source + + def discover_streams(self) -> list[str]: + source = self._source_handle() + return list(source.get_available_streams()) + + def pull_stream( + self, + stream: str, + limit: int | None = None, + ) -> list[dict[str, Any]]: + source = self._source_handle() + source.select_streams([stream]) + records: list[dict[str, Any]] = [] + for record in source.get_records(stream, limit=limit): + plain = dict(record) + plain = { + key: value + for key, value in plain.items() + if not key.startswith("_ab_") and not key.startswith("ab_") + } + records.append(plain) + logger.info(" pulled %s: %d", stream, len(records)) + return records + + def pull_streams( + self, + streams: list[str], + per_stream_limits: dict[str, int] | None = None, + global_limit: int | None = None, + ) -> dict[str, list[dict[str, Any]]]: + limits = per_stream_limits or {} + result: dict[str, list[dict[str, Any]]] = {} + for stream in streams: + limit = limits.get(stream, global_limit) + try: + result[stream] = self.pull_stream(stream, limit=limit) + except Exception as exc: # noqa: BLE001 + logger.warning("stream pull failed for %s: %s", stream, exc) + return result + diff --git a/services/_lib/snapshot_pull/redactor.py b/services/_lib/snapshot_pull/redactor.py new file mode 100644 index 0000000..716af8f --- /dev/null +++ b/services/_lib/snapshot_pull/redactor.py @@ -0,0 +1,44 @@ +"""Deterministic redaction for snapshot data.""" + +from __future__ import annotations + +import hashlib +import re +from typing import Any + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +class PiiRedactor: + def __init__(self) -> None: + self.email_map: dict[str, str] = {} + + def _stable_suffix(self, value: str) -> str: + return hashlib.sha1(value.encode("utf-8")).hexdigest()[:10] + + def redact_scalar(self, value: Any) -> Any: + if not isinstance(value, str): + return value + + if EMAIL_RE.match(value): + if value not in self.email_map: + idx = len(self.email_map) + 1 + self.email_map[value] = f"user-{idx}@doubleagent.local" + return self.email_map[value] + + lower = value.lower() + if any(token in lower for token in ["token", "secret", "password", "apikey", "api_key"]): + return f"redacted-{self._stable_suffix(value)}" + + return value + + def redact_obj(self, obj: Any) -> Any: + if isinstance(obj, dict): + return {k: self.redact_obj(v) for k, v in obj.items()} + if isinstance(obj, list): + return [self.redact_obj(v) for v in obj] + return self.redact_scalar(obj) + + def redact_resources(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [self.redact_obj(item) for item in items] + diff --git a/services/_lib/snapshot_pull/smart_filter.py b/services/_lib/snapshot_pull/smart_filter.py new file mode 100644 index 0000000..5885888 --- /dev/null +++ b/services/_lib/snapshot_pull/smart_filter.py @@ -0,0 +1,152 @@ +"""Smart relational filtering for pulled records.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class FollowRule: + child_stream: str + foreign_key: str + parent_key: str = "id" + limit_per_parent: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FollowRule": + return cls( + child_stream=data["child_stream"], + foreign_key=data["foreign_key"], + parent_key=data.get("parent_key", "id"), + limit_per_parent=data.get("limit_per_parent"), + ) + + +@dataclass +class SeedStreamConfig: + stream: str + limit: int | None = None + follow: list[FollowRule] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "SeedStreamConfig": + return cls( + stream=data["stream"], + limit=data.get("limit"), + follow=[FollowRule.from_dict(f) for f in data.get("follow", [])], + ) + + +@dataclass +class SeedingConfig: + seed_streams: list[SeedStreamConfig] = field(default_factory=list) + default_limit: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "SeedingConfig": + return cls( + seed_streams=[ + SeedStreamConfig.from_dict(s) for s in data.get("seed_streams", []) + ], + default_limit=data.get("default_limit"), + ) + + def all_stream_names(self) -> set[str]: + names: set[str] = set() + for stream_cfg in self.seed_streams: + names.add(stream_cfg.stream) + for rule in stream_cfg.follow: + names.add(rule.child_stream) + return names + + +def _collect_keys(records: list[dict[str, Any]], key: str) -> set[str]: + result: set[str] = set() + for row in records: + value = row.get(key) + if value is not None: + result.add(str(value)) + return result + + +def _filter_children( + records: list[dict[str, Any]], + foreign_key: str, + allowed_parent_ids: set[str], + limit_per_parent: int | None, +) -> list[dict[str, Any]]: + if limit_per_parent is None: + return [ + row for row in records if str(row.get(foreign_key, "")) in allowed_parent_ids + ] + + per_parent_count: dict[str, int] = defaultdict(int) + result: list[dict[str, Any]] = [] + for row in records: + parent_id = str(row.get(foreign_key, "")) + if parent_id not in allowed_parent_ids: + continue + if per_parent_count[parent_id] >= limit_per_parent: + continue + per_parent_count[parent_id] += 1 + result.append(row) + return result + + +def apply_relational_filter( + resources: dict[str, list[dict[str, Any]]], + config: SeedingConfig, +) -> dict[str, list[dict[str, Any]]]: + stream_cfg = {s.stream: s for s in config.seed_streams} + output: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) + queue: list[tuple[str, set[str] | None, FollowRule | None]] = [] + visited_edges: set[tuple[str, str]] = set() + + for root in config.seed_streams: + queue.append((root.stream, None, None)) + + while queue: + stream_name, parent_ids, follow_rule = queue.pop(0) + rows = resources.get(stream_name, []) + + if parent_ids is not None and follow_rule is not None: + selected = _filter_children( + rows, + follow_rule.foreign_key, + parent_ids, + follow_rule.limit_per_parent, + ) + else: + limit = stream_cfg.get(stream_name, SeedStreamConfig(stream=stream_name)).limit + if limit is None: + limit = config.default_limit + selected = rows[:limit] if limit is not None else list(rows) + + for row in selected: + record_id = str(row.get("id", id(row))) + output[stream_name][record_id] = row + + cfg = stream_cfg.get(stream_name) + if not cfg: + continue + + for rule in cfg.follow: + edge = (stream_name, rule.child_stream) + if edge in visited_edges: + continue + visited_edges.add(edge) + + child_rows = resources.get(rule.child_stream, []) + if not child_rows: + continue + + ids = _collect_keys(selected, rule.parent_key) + if ids: + queue.append((rule.child_stream, ids, rule)) + + return { + stream: list(id_map.values()) for stream, id_map in output.items() if id_map + } + diff --git a/services/_lib/snapshot_pull/snapshot.py b/services/_lib/snapshot_pull/snapshot.py new file mode 100644 index 0000000..b64bb77 --- /dev/null +++ b/services/_lib/snapshot_pull/snapshot.py @@ -0,0 +1,107 @@ +"""Snapshot writer for Airbyte pull results.""" + +from __future__ import annotations + +import json +import os +import time +from dataclasses import dataclass, asdict, field +from pathlib import Path +from typing import Any + + +@dataclass +class SnapshotManifest: + service: str + profile: str + version: int = 1 + pulled_at: float = field(default_factory=lambda: time.time()) + connector: str = "" + redacted: bool = True + resource_counts: dict[str, int] = field(default_factory=dict) + + +def default_snapshots_dir() -> Path: + base = os.environ.get("DOUBLEAGENT_SNAPSHOTS_DIR") + if base: + return Path(base) + return Path.home() / ".doubleagent" / "snapshots" + + +def snapshot_dir(service: str, profile: str) -> Path: + return default_snapshots_dir() / service / profile + + +def _record_id(record: dict[str, Any], fallback_index: int) -> str: + rid = record.get("id") + if rid is None: + return f"idx-{fallback_index}" + return str(rid) + + +def _merge_records( + existing: list[dict[str, Any]], + incoming: list[dict[str, Any]], +) -> list[dict[str, Any]]: + merged: dict[str, dict[str, Any]] = {} + + for idx, row in enumerate(existing): + merged[_record_id(row, idx)] = row + for idx, row in enumerate(incoming): + merged[_record_id(row, idx)] = row + + return list(merged.values()) + + +def _load_resource_file(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + return json.loads(path.read_text()) + + +def _load_seed_file(path: Path) -> dict[str, list[dict[str, Any]]]: + if not path.exists(): + return {} + data = json.loads(path.read_text()) + if isinstance(data, dict): + return {k: v for k, v in data.items() if isinstance(v, list)} + return {} + + +def save_snapshot( + *, + service: str, + profile: str, + resources: dict[str, list[dict[str, Any]]], + connector_name: str, + redacted: bool, + incremental: bool = False, +) -> Path: + sdir = snapshot_dir(service, profile) + sdir.mkdir(parents=True, exist_ok=True) + + merged_resources: dict[str, list[dict[str, Any]]] = {} + for resource_name, rows in resources.items(): + resource_path = sdir / f"{resource_name}.json" + existing_rows = _load_resource_file(resource_path) if incremental else [] + merged_rows = _merge_records(existing_rows, rows) if incremental else rows + merged_resources[resource_name] = merged_rows + resource_path.write_text(json.dumps(merged_rows, indent=2, default=str)) + + seed_path = sdir / "seed.json" + existing_seed = _load_seed_file(seed_path) if incremental else {} + for resource_name, rows in merged_resources.items(): + existing_seed[resource_name] = rows + seed_path.write_text(json.dumps(existing_seed, indent=2, default=str)) + + manifest = SnapshotManifest( + service=service, + profile=profile, + connector=connector_name, + redacted=redacted, + resource_counts={k: len(v) for k, v in existing_seed.items()}, + ) + (sdir / "manifest.json").write_text(json.dumps(asdict(manifest), indent=2)) + + return sdir + diff --git a/services/_lib/tests/test_redactor.py b/services/_lib/tests/test_redactor.py new file mode 100644 index 0000000..e9c0c2d --- /dev/null +++ b/services/_lib/tests/test_redactor.py @@ -0,0 +1,62 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from snapshot_pull.redactor import PiiRedactor + + +class RedactorTests(unittest.TestCase): + def test_emails_are_anonymized_deterministically(self) -> None: + redactor = PiiRedactor() + result1 = redactor.redact_scalar("alice@company.com") + result2 = redactor.redact_scalar("alice@company.com") + self.assertEqual(result1, result2) + self.assertIn("@doubleagent.local", result1) + self.assertNotIn("alice", result1) + + def test_different_emails_get_different_aliases(self) -> None: + redactor = PiiRedactor() + r1 = redactor.redact_scalar("alice@company.com") + r2 = redactor.redact_scalar("bob@company.com") + self.assertNotEqual(r1, r2) + self.assertIn("user-1@doubleagent.local", r1) + self.assertIn("user-2@doubleagent.local", r2) + + def test_secret_like_strings_are_redacted(self) -> None: + redactor = PiiRedactor() + for value in ["my-secret-key", "Bearer token-xyz", "password123", "api_key_abc"]: + result = redactor.redact_scalar(value) + self.assertTrue(result.startswith("redacted-"), f"Expected redaction for: {value}") + + def test_normal_strings_pass_through(self) -> None: + redactor = PiiRedactor() + for value in ["Hello world", "project-name", "42", "2026-02-23"]: + self.assertEqual(redactor.redact_scalar(value), value) + + def test_non_strings_pass_through(self) -> None: + redactor = PiiRedactor() + self.assertEqual(redactor.redact_scalar(42), 42) + self.assertEqual(redactor.redact_scalar(True), True) + self.assertIsNone(redactor.redact_scalar(None)) + + def test_nested_objects_are_recursively_redacted(self) -> None: + redactor = PiiRedactor() + data = { + "user": { + "email": "alice@company.com", + "name": "Alice", + "tokens": ["secret-abc"], + }, + "count": 5, + } + result = redactor.redact_obj(data) + self.assertIn("@doubleagent.local", result["user"]["email"]) + self.assertEqual(result["user"]["name"], "Alice") + self.assertTrue(result["user"]["tokens"][0].startswith("redacted-")) + self.assertEqual(result["count"], 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/_lib/tests/test_smart_filter.py b/services/_lib/tests/test_smart_filter.py new file mode 100644 index 0000000..1e6372f --- /dev/null +++ b/services/_lib/tests/test_smart_filter.py @@ -0,0 +1,92 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from snapshot_pull.smart_filter import SeedingConfig, apply_relational_filter + + +class SmartFilterTests(unittest.TestCase): + def test_relational_filter_applies_limits_and_follow_rules(self) -> None: + resources = { + "repos": [ + {"id": 1, "name": "a"}, + {"id": 2, "name": "b"}, + {"id": 3, "name": "c"}, + ], + "issues": [ + {"id": 10, "repo_id": 1}, + {"id": 11, "repo_id": 1}, + {"id": 12, "repo_id": 1}, + {"id": 20, "repo_id": 2}, + {"id": 30, "repo_id": 3}, + ], + "comments": [ + {"id": 100, "issue_id": 10}, + {"id": 101, "issue_id": 11}, + {"id": 102, "issue_id": 20}, + {"id": 103, "issue_id": 999}, + ], + } + config = SeedingConfig.from_dict( + { + "default_limit": 2, + "seed_streams": [ + { + "stream": "repos", + "follow": [ + { + "child_stream": "issues", + "foreign_key": "repo_id", + "limit_per_parent": 1, + } + ], + }, + { + "stream": "issues", + "follow": [{"child_stream": "comments", "foreign_key": "issue_id"}], + }, + ], + } + ) + + filtered = apply_relational_filter(resources, config) + + self.assertEqual([r["id"] for r in filtered["repos"]], [1, 2]) + # issues appears both as a root stream and as a followed stream; ids are deduped. + self.assertEqual([r["id"] for r in filtered["issues"]], [10, 11, 20]) + # comments follow the first issues selection path due to visited edge tracking. + self.assertEqual([r["id"] for r in filtered["comments"]], [100, 101]) + + def test_relational_filter_deduplicates_same_record_from_multiple_paths(self) -> None: + resources = { + "users": [{"id": "u1"}], + "memberships": [ + {"id": "m1", "user_id": "u1", "team_id": "t1"}, + {"id": "m2", "user_id": "u1", "team_id": "t2"}, + ], + "teams": [{"id": "t1"}, {"id": "t2"}], + } + config = SeedingConfig.from_dict( + { + "seed_streams": [ + { + "stream": "users", + "follow": [{"child_stream": "memberships", "foreign_key": "user_id"}], + }, + { + "stream": "teams", + "follow": [{"child_stream": "memberships", "foreign_key": "team_id"}], + }, + ] + } + ) + + filtered = apply_relational_filter(resources, config) + self.assertEqual(len(filtered["memberships"]), 2) + self.assertEqual(sorted(r["id"] for r in filtered["memberships"]), ["m1", "m2"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/_lib/tests/test_snapshot.py b/services/_lib/tests/test_snapshot.py new file mode 100644 index 0000000..d504a84 --- /dev/null +++ b/services/_lib/tests/test_snapshot.py @@ -0,0 +1,79 @@ +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from snapshot_pull.snapshot import save_snapshot + + +class SnapshotWriterTests(unittest.TestCase): + def test_save_snapshot_writes_manifest_seed_and_resources(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + os.environ["DOUBLEAGENT_SNAPSHOTS_DIR"] = tmp + out = save_snapshot( + service="github", + profile="default", + resources={ + "repos": [{"id": 1, "name": "api"}], + "issues": [{"id": 10, "repo_id": 1}], + }, + connector_name="airbyte:source-github", + redacted=True, + incremental=False, + ) + + self.assertEqual(out, Path(tmp) / "github" / "default") + manifest = json.loads((out / "manifest.json").read_text()) + seed = json.loads((out / "seed.json").read_text()) + repos = json.loads((out / "repos.json").read_text()) + + self.assertEqual(manifest["service"], "github") + self.assertEqual(manifest["connector"], "airbyte:source-github") + self.assertEqual(manifest["resource_counts"], {"repos": 1, "issues": 1}) + self.assertEqual(seed["repos"][0]["name"], "api") + self.assertEqual(repos[0]["id"], 1) + + def test_incremental_save_merges_by_id_and_preserves_unmodified_resources(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + os.environ["DOUBLEAGENT_SNAPSHOTS_DIR"] = tmp + save_snapshot( + service="github", + profile="default", + resources={ + "repos": [{"id": 1, "name": "old"}, {"id": 2, "name": "unchanged"}], + "issues": [{"id": 10, "title": "old issue"}], + }, + connector_name="airbyte:source-github", + redacted=True, + ) + out = save_snapshot( + service="github", + profile="default", + resources={"repos": [{"id": 1, "name": "new"}, {"id": 3, "name": "added"}]}, + connector_name="airbyte:source-github", + redacted=True, + incremental=True, + ) + + repos = json.loads((out / "repos.json").read_text()) + issues = json.loads((out / "issues.json").read_text()) + seed = json.loads((out / "seed.json").read_text()) + manifest = json.loads((out / "manifest.json").read_text()) + + by_id = {r["id"]: r for r in repos} + self.assertEqual(sorted(by_id), [1, 2, 3]) + self.assertEqual(by_id[1]["name"], "new") + self.assertEqual(by_id[2]["name"], "unchanged") + self.assertEqual(by_id[3]["name"], "added") + self.assertEqual(issues, [{"id": 10, "title": "old issue"}]) + self.assertEqual(len(seed["repos"]), 3) + self.assertEqual(len(seed["issues"]), 1) + self.assertEqual(manifest["resource_counts"], {"repos": 3, "issues": 1}) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/github/service.yaml b/services/github/service.yaml index 1f6e5de..9787b91 100644 --- a/services/github/service.yaml +++ b/services/github/service.yaml @@ -30,3 +30,24 @@ contracts: features: webhooks: true + +# Optional snapshot pull connector configuration. +# This enables: doubleagent snapshot pull github +connector: + type: airbyte + image: airbyte/source-github:latest + streams: + - repositories + - issues + - pull_requests + config_env: + GITHUB_ACCESS_TOKEN: credentials.personal_access_token + GITHUB_REPOSITORY: repository + stream_mapping: + repositories: repos + issues: issues + pull_requests: pulls + required_env: + - GITHUB_ACCESS_TOKEN + - GITHUB_REPOSITORY + backend: pyairbyte diff --git a/services/jira/.mise.toml b/services/jira/.mise.toml new file mode 100644 index 0000000..37113d7 --- /dev/null +++ b/services/jira/.mise.toml @@ -0,0 +1,3 @@ +[tools] +python = "3.11" +uv = "latest" diff --git a/services/jira/server/main.py b/services/jira/server/main.py new file mode 100644 index 0000000..845f84e --- /dev/null +++ b/services/jira/server/main.py @@ -0,0 +1,124 @@ +""" +Jira Cloud API Fake — DoubleAgent Service + +Read-only snapshot server for Jira data pulled via Airbyte connector. +Serves snapshot data as a REST API with standard DoubleAgent control-plane endpoints. + +Endpoints: + GET /resources -> list available resource types + GET /resources/{type} -> list resources (with filtering) + GET /resources/{type}/{id} -> get single resource by ID + /_doubleagent/* -> control plane (health, reset, seed) +""" + +import os +from typing import Any, Optional + +from fastapi import FastAPI, HTTPException, Query, Request + + +# ============================================================================= +# State +# ============================================================================= + +state: dict[str, dict[str, Any]] = {} + + +# ============================================================================= +# App +# ============================================================================= + +app = FastAPI( + title="Jira API Fake", + description="DoubleAgent fake of the Jira Cloud REST API (read-only snapshot server)", + version="0.1.0", +) + + +# ============================================================================= +# /_doubleagent control plane +# ============================================================================= + +@app.get("/_doubleagent/health") +async def health(): + return {"status": "healthy"} + + +@app.post("/_doubleagent/reset") +async def reset(): + global state + state = {} + return {"status": "ok"} + + +@app.post("/_doubleagent/seed") +async def seed(request: Request): + body = await request.json() + counts: dict[str, int] = {} + for rtype, resources in body.items(): + if isinstance(resources, dict): + state.setdefault(rtype, {}).update(resources) + counts[rtype] = len(resources) + return {"status": "ok", "seeded": counts} + + +# ============================================================================= +# Resource explorer endpoints (read-only) +# ============================================================================= + +@app.get("/resources") +async def list_resource_types(): + """List all available resource types and their counts.""" + result = {rtype: len(items) for rtype, items in sorted(state.items())} + return {"resource_types": result, "total_types": len(result)} + + +@app.get("/resources/{resource_type}") +async def list_resources( + resource_type: str, + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + q: Optional[str] = Query(default=None, description="Filter: field=value"), +): + """List resources of a given type with optional filtering.""" + items = list(state.get(resource_type, {}).values()) + + if q: + parts = q.split("=", 1) + if len(parts) == 2: + field, value = parts + items = [r for r in items if str(r.get(field, "")) == value] + + total = len(items) + items = items[offset : offset + limit] + return { + "resource_type": resource_type, + "total": total, + "offset": offset, + "limit": limit, + "items": items, + } + + +@app.get("/resources/{resource_type}/{resource_id}") +async def get_resource(resource_type: str, resource_id: str): + """Get a single resource by type and ID.""" + collection = state.get(resource_type, {}) + obj = collection.get(resource_id) + if obj is None: + raise HTTPException(status_code=404, detail={ + "error": "not_found", + "resource_type": resource_type, + "resource_id": resource_id, + }) + return obj + + +# ============================================================================= +# Main +# ============================================================================= + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 8090)) + uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning") diff --git a/services/jira/server/pyproject.toml b/services/jira/server/pyproject.toml new file mode 100644 index 0000000..b03e36b --- /dev/null +++ b/services/jira/server/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "jira-fake" +version = "0.1.0" +description = "Jira API snapshot explorer for DoubleAgent" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn>=0.32.0", + "pydantic>=2.0.0", +] diff --git a/services/jira/server/uv.lock b/services/jira/server/uv.lock new file mode 100644 index 0000000..d3e2601 --- /dev/null +++ b/services/jira/server/uv.lock @@ -0,0 +1,265 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.129.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jira-fake" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "uvicorn", specifier = ">=0.32.0" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] diff --git a/services/jira/service.yaml b/services/jira/service.yaml new file mode 100644 index 0000000..c034d63 --- /dev/null +++ b/services/jira/service.yaml @@ -0,0 +1,89 @@ +# Jira fake — uses Airbyte source-jira connector for snapshot pulling. +# +# No custom connector code needed! The Airbyte adapter handles everything: +# 1. Runs `docker run airbyte/source-jira:latest discover` to find streams +# 2. Runs `docker run airbyte/source-jira:latest read` to pull records +# 3. Parses JSONL output, groups by stream, applies PII redaction +# 4. Saves as a DoubleAgent snapshot +# +# Usage: +# export JIRA_API_TOKEN=your-api-token +# export JIRA_DOMAIN=yourcompany.atlassian.net +# export JIRA_EMAIL=you@company.com +# doubleagent snapshot pull jira +# +# The fake server (when built) will serve these snapshots as a local Jira API. + +name: jira +version: "0.1" +description: "Jira Cloud API fake (Airbyte-backed snapshot connector)" +docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/ + +server: + command: ["uv", "run", "python", "main.py"] + +connector: + type: airbyte + image: airbyte/source-jira:latest + + # Which Airbyte streams to pull (subset of the 40+ Jira streams) + streams: + - projects + - issues + - users + - sprints + - boards + - issue_comments + - issue_fields + - workflows + + # Map env vars → Airbyte connector config JSON paths + # (Airbyte source-jira expects these in its config) + config_env: + JIRA_API_TOKEN: api_token + JIRA_DOMAIN: domain + JIRA_EMAIL: email + + # Map Airbyte stream names → DoubleAgent resource types + # (keeps our naming consistent across services) + stream_mapping: + projects: projects + issues: issues + users: users + sprints: sprints + boards: boards + issue_comments: comments + issue_fields: fields + workflows: workflows + + # Smart seeding: pull a small, relationally-consistent dataset + # Projects → issues → comments, with per-parent limits + seeding: + default_limit: 50 + seed_streams: + - stream: projects + limit: 3 + follow: + - child_stream: issues + foreign_key: project_id + limit_per_parent: 10 + - child_stream: sprints + foreign_key: boardId + limit_per_parent: 5 + - child_stream: boards + foreign_key: projectId + limit_per_parent: 3 + - stream: issues + follow: + - child_stream: issue_comments + foreign_key: issueId + limit_per_parent: 5 + - stream: users + limit: 20 + - stream: workflows + limit: 10 + + required_env: + - JIRA_API_TOKEN + - JIRA_DOMAIN + - JIRA_EMAIL diff --git a/services/salesforce/.mise.toml b/services/salesforce/.mise.toml new file mode 100644 index 0000000..37113d7 --- /dev/null +++ b/services/salesforce/.mise.toml @@ -0,0 +1,3 @@ +[tools] +python = "3.11" +uv = "latest" diff --git a/services/salesforce/server/main.py b/services/salesforce/server/main.py new file mode 100644 index 0000000..c9d43c5 --- /dev/null +++ b/services/salesforce/server/main.py @@ -0,0 +1,124 @@ +""" +Salesforce CRM API Fake — DoubleAgent Service + +Read-only snapshot server for Salesforce data pulled via Airbyte connector. +Serves snapshot data as a REST API with standard DoubleAgent control-plane endpoints. + +Endpoints: + GET /resources -> list available resource types + GET /resources/{type} -> list resources (with filtering) + GET /resources/{type}/{id} -> get single resource by ID + /_doubleagent/* -> control plane (health, reset, seed) +""" + +import os +from typing import Any, Optional + +from fastapi import FastAPI, HTTPException, Query, Request + + +# ============================================================================= +# State +# ============================================================================= + +state: dict[str, dict[str, Any]] = {} + + +# ============================================================================= +# App +# ============================================================================= + +app = FastAPI( + title="Salesforce API Fake", + description="DoubleAgent fake of the Salesforce CRM REST API (read-only snapshot server)", + version="0.1.0", +) + + +# ============================================================================= +# /_doubleagent control plane +# ============================================================================= + +@app.get("/_doubleagent/health") +async def health(): + return {"status": "healthy"} + + +@app.post("/_doubleagent/reset") +async def reset(): + global state + state = {} + return {"status": "ok"} + + +@app.post("/_doubleagent/seed") +async def seed(request: Request): + body = await request.json() + counts: dict[str, int] = {} + for rtype, resources in body.items(): + if isinstance(resources, dict): + state.setdefault(rtype, {}).update(resources) + counts[rtype] = len(resources) + return {"status": "ok", "seeded": counts} + + +# ============================================================================= +# Resource explorer endpoints (read-only) +# ============================================================================= + +@app.get("/resources") +async def list_resource_types(): + """List all available resource types and their counts.""" + result = {rtype: len(items) for rtype, items in sorted(state.items())} + return {"resource_types": result, "total_types": len(result)} + + +@app.get("/resources/{resource_type}") +async def list_resources( + resource_type: str, + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + q: Optional[str] = Query(default=None, description="Filter: field=value"), +): + """List resources of a given type with optional filtering.""" + items = list(state.get(resource_type, {}).values()) + + if q: + parts = q.split("=", 1) + if len(parts) == 2: + field, value = parts + items = [r for r in items if str(r.get(field, "")) == value] + + total = len(items) + items = items[offset : offset + limit] + return { + "resource_type": resource_type, + "total": total, + "offset": offset, + "limit": limit, + "items": items, + } + + +@app.get("/resources/{resource_type}/{resource_id}") +async def get_resource(resource_type: str, resource_id: str): + """Get a single resource by type and ID.""" + collection = state.get(resource_type, {}) + obj = collection.get(resource_id) + if obj is None: + raise HTTPException(status_code=404, detail={ + "error": "not_found", + "resource_type": resource_type, + "resource_id": resource_id, + }) + return obj + + +# ============================================================================= +# Main +# ============================================================================= + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 8091)) + uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning") diff --git a/services/salesforce/server/pyproject.toml b/services/salesforce/server/pyproject.toml new file mode 100644 index 0000000..27a780f --- /dev/null +++ b/services/salesforce/server/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "salesforce-fake" +version = "0.1.0" +description = "Salesforce API snapshot explorer for DoubleAgent" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn>=0.32.0", + "pydantic>=2.0.0", +] diff --git a/services/salesforce/server/uv.lock b/services/salesforce/server/uv.lock new file mode 100644 index 0000000..1135d66 --- /dev/null +++ b/services/salesforce/server/uv.lock @@ -0,0 +1,265 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.129.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "salesforce-fake" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "uvicorn", specifier = ">=0.32.0" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] diff --git a/services/salesforce/service.yaml b/services/salesforce/service.yaml new file mode 100644 index 0000000..641ca84 --- /dev/null +++ b/services/salesforce/service.yaml @@ -0,0 +1,82 @@ +# Salesforce fake — uses Airbyte source-salesforce connector for snapshot pulling. +# +# This is the canonical example of why Airbyte-style connectors matter: +# Salesforce has 200+ object types with complex relationships. Writing a +# custom SnapshotConnector would take weeks. With Airbyte, we get a +# battle-tested connector that handles OAuth, pagination, rate limiting, +# and all the Salesforce API quirks — for free. +# +# Usage: +# export SALESFORCE_CLIENT_ID=your-connected-app-id +# export SALESFORCE_CLIENT_SECRET=your-secret +# export SALESFORCE_REFRESH_TOKEN=your-refresh-token +# doubleagent snapshot pull salesforce +# +# The fake server (when built) will serve these snapshots as a local SFDC API. + +name: salesforce +version: "0.1" +description: "Salesforce CRM API fake (Airbyte-backed snapshot connector)" +docs: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/ + +server: + command: ["uv", "run", "python", "main.py"] + +connector: + type: airbyte + image: airbyte/source-salesforce:latest + + # Common objects to pull (Salesforce has 200+, pick the useful ones) + streams: + - Account + - Contact + - Lead + - Opportunity + - Case + - User + - Task + - Event + + # Map env vars → Airbyte source-salesforce config + config_env: + SALESFORCE_CLIENT_ID: client_id + SALESFORCE_CLIENT_SECRET: client_secret + SALESFORCE_REFRESH_TOKEN: refresh_token + + # Normalize to lowercase resource types + stream_mapping: + Account: accounts + Contact: contacts + Lead: leads + Opportunity: opportunities + Case: cases + User: users + Task: tasks + Event: events + + # Smart seeding: pull a small, relationally-consistent dataset + # Accounts → contacts/opportunities/cases, with per-parent limits + seeding: + default_limit: 50 + seed_streams: + - stream: Account + limit: 5 + follow: + - child_stream: Contact + foreign_key: AccountId + limit_per_parent: 10 + - child_stream: Opportunity + foreign_key: AccountId + limit_per_parent: 5 + - child_stream: Case + foreign_key: AccountId + limit_per_parent: 5 + - stream: User + limit: 20 + - stream: Lead + limit: 10 + + required_env: + - SALESFORCE_CLIENT_ID + - SALESFORCE_CLIENT_SECRET + - SALESFORCE_REFRESH_TOKEN