From 478bbe02f12f92616faec8657fbadea38c524289 Mon Sep 17 00:00:00 2001 From: zozo123 Date: Mon, 16 Feb 2026 16:29:27 +0200 Subject: [PATCH 01/10] feat: add --hard reset and --fixture seed to CLI - Reset: add --hard flag to clear baseline snapshot (not just overlay) - Seed: add --fixture flag to resolve fixture files from the service's fixtures/ directory (e.g. `doubleagent seed github --fixture startup`) - Seed: make positional file argument optional (either --fixture or file path required) Co-Authored-By: Claude Opus 4.6 --- crates/cli/src/commands/mod.rs | 10 +++++- crates/cli/src/commands/reset.rs | 14 ++++++-- crates/cli/src/commands/seed.rs | 57 +++++++++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index a986007..3cd0b83 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -99,6 +99,10 @@ pub struct StopArgs { pub struct ResetArgs { /// Services to reset (empty = all running) pub services: Vec, + + /// Hard reset: clear baseline snapshot in addition to overlay + #[arg(long)] + pub hard: bool, } #[derive(Parser)] @@ -107,7 +111,11 @@ pub struct SeedArgs { pub service: String, /// Path to seed data file (YAML or JSON) - pub file: String, + pub file: Option, + + /// Name of a fixture in the service's fixtures/ directory (e.g. "startup") + #[arg(long)] + pub fixture: Option, } #[derive(Parser)] diff --git a/crates/cli/src/commands/reset.rs b/crates/cli/src/commands/reset.rs index a7fc55e..5fdc15c 100644 --- a/crates/cli/src/commands/reset.rs +++ b/crates/cli/src/commands/reset.rs @@ -17,11 +17,21 @@ pub async fn run(args: ResetArgs) -> anyhow::Result<()> { return Ok(()); } + let mode = if args.hard { "hard" } else { "soft" }; + for service_name in &services { if let Some(info) = manager.get_info(service_name) { - print!("{} Resetting {}...", "↻".blue(), service_name); + print!( + "{} Resetting {} ({})...", + "↻".blue(), + service_name, + mode + ); - let url = format!("http://localhost:{}/_doubleagent/reset", info.port); + let mut url = format!("http://localhost:{}/_doubleagent/reset", info.port); + if args.hard { + url.push_str("?hard=true"); + } let client = reqwest::Client::new(); match client.post(&url).send().await { diff --git a/crates/cli/src/commands/seed.rs b/crates/cli/src/commands/seed.rs index c75e1a3..f670cee 100644 --- a/crates/cli/src/commands/seed.rs +++ b/crates/cli/src/commands/seed.rs @@ -1,7 +1,48 @@ use super::SeedArgs; use colored::Colorize; -use doubleagent_core::{Config, ProcessManager}; +use doubleagent_core::{Config, ProcessManager, ServiceRegistry}; use std::fs; +use std::path::PathBuf; + +/// Resolve the seed data file path from args. +/// +/// Priority: --fixture (resolves via service fixtures dir) > --file (explicit path). +fn resolve_seed_file( + args: &SeedArgs, + config: &Config, +) -> anyhow::Result { + if let Some(ref fixture_name) = args.fixture { + // Resolve fixture from the service's fixtures/ directory + let registry = ServiceRegistry::new(&config.services_dir, &config.repo_url, &config.branch)?; + let service = registry.get(&args.service)?; + let fixture_path = service.path.join("fixtures").join(format!("{}.yaml", fixture_name)); + + if !fixture_path.exists() { + // Try .yml extension + let yml_path = service.path.join("fixtures").join(format!("{}.yml", fixture_name)); + if yml_path.exists() { + return Ok(yml_path); + } + anyhow::bail!( + "Fixture '{}' not found for service '{}' (looked in {})", + fixture_name, + args.service, + fixture_path.display() + ); + } + Ok(fixture_path) + } else if let Some(ref file_path) = args.file { + Ok(PathBuf::from(file_path)) + } else { + anyhow::bail!( + "Either --fixture or a file path is required.\n\ + Usage: doubleagent seed {} --fixture startup\n\ + Usage: doubleagent seed {} path/to/data.yaml", + args.service, + args.service + ) + } +} pub async fn run(args: SeedArgs) -> anyhow::Result<()> { let config = Config::load()?; @@ -11,15 +52,23 @@ pub async fn run(args: SeedArgs) -> anyhow::Result<()> { .get_info(&args.service) .ok_or_else(|| anyhow::anyhow!("{} is not running", args.service))?; + let seed_file = resolve_seed_file(&args, &config)?; + // 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") { + let content = fs::read_to_string(&seed_file)?; + let file_str = seed_file.to_string_lossy(); + let data: serde_json::Value = if file_str.ends_with(".yaml") || file_str.ends_with(".yml") { serde_yaml::from_str(&content)? } else { serde_json::from_str(&content)? }; - print!("{} Seeding {}...", "⬆".blue(), args.service); + print!( + "{} Seeding {} from {}...", + "⬆".blue(), + args.service, + seed_file.display() + ); let url = format!("http://localhost:{}/_doubleagent/seed", info.port); let client = reqwest::Client::new(); From 9ff00c7997a6aa5ca3efa32b5c0bba734f165d60 Mon Sep 17 00:00:00 2001 From: zozo123 Date: Mon, 16 Feb 2026 18:23:18 +0200 Subject: [PATCH 02/10] feat: add --fixture flag to seed command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --fixture flag to resolve fixture files from the service's fixtures/ directory (e.g. `doubleagent seed github --fixture startup` resolves to services/github/fixtures/startup.yaml). Make positional file argument optional — either --fixture or explicit file path is required. Reset command unchanged (no --hard flag). Co-Authored-By: Claude Opus 4.6 --- crates/cli/src/commands/mod.rs | 4 ---- crates/cli/src/commands/reset.rs | 14 ++------------ 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 3cd0b83..98a9841 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -99,10 +99,6 @@ pub struct StopArgs { pub struct ResetArgs { /// Services to reset (empty = all running) pub services: Vec, - - /// Hard reset: clear baseline snapshot in addition to overlay - #[arg(long)] - pub hard: bool, } #[derive(Parser)] diff --git a/crates/cli/src/commands/reset.rs b/crates/cli/src/commands/reset.rs index 5fdc15c..a7fc55e 100644 --- a/crates/cli/src/commands/reset.rs +++ b/crates/cli/src/commands/reset.rs @@ -17,21 +17,11 @@ pub async fn run(args: ResetArgs) -> anyhow::Result<()> { return Ok(()); } - let mode = if args.hard { "hard" } else { "soft" }; - for service_name in &services { if let Some(info) = manager.get_info(service_name) { - print!( - "{} Resetting {} ({})...", - "↻".blue(), - service_name, - mode - ); + print!("{} Resetting {}...", "↻".blue(), service_name); - let mut url = format!("http://localhost:{}/_doubleagent/reset", info.port); - if args.hard { - url.push_str("?hard=true"); - } + let url = format!("http://localhost:{}/_doubleagent/reset", info.port); let client = reqwest::Client::new(); match client.post(&url).send().await { From 715b7b63b9f375b9430b7884c1fc8e55d2269a50 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Mon, 16 Feb 2026 20:07:45 +0200 Subject: [PATCH 03/10] style: run cargo fmt on seed.rs Co-Authored-By: Claude Opus 4.6 --- crates/cli/src/commands/seed.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/commands/seed.rs b/crates/cli/src/commands/seed.rs index f670cee..96672a1 100644 --- a/crates/cli/src/commands/seed.rs +++ b/crates/cli/src/commands/seed.rs @@ -7,19 +7,23 @@ use std::path::PathBuf; /// Resolve the seed data file path from args. /// /// Priority: --fixture (resolves via service fixtures dir) > --file (explicit path). -fn resolve_seed_file( - args: &SeedArgs, - config: &Config, -) -> anyhow::Result { +fn resolve_seed_file(args: &SeedArgs, config: &Config) -> anyhow::Result { if let Some(ref fixture_name) = args.fixture { // Resolve fixture from the service's fixtures/ directory - let registry = ServiceRegistry::new(&config.services_dir, &config.repo_url, &config.branch)?; + let registry = + ServiceRegistry::new(&config.services_dir, &config.repo_url, &config.branch)?; let service = registry.get(&args.service)?; - let fixture_path = service.path.join("fixtures").join(format!("{}.yaml", fixture_name)); + let fixture_path = service + .path + .join("fixtures") + .join(format!("{}.yaml", fixture_name)); if !fixture_path.exists() { // Try .yml extension - let yml_path = service.path.join("fixtures").join(format!("{}.yml", fixture_name)); + let yml_path = service + .path + .join("fixtures") + .join(format!("{}.yml", fixture_name)); if yml_path.exists() { return Ok(yml_path); } From bfae8c3b477d04830b0742c66faf05963f8a81f6 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Wed, 18 Feb 2026 10:27:01 +0200 Subject: [PATCH 04/10] feat: add airbyte snapshot pull and seed-from-snapshot flow --- README.md | 24 ++ crates/cli/src/commands/mod.rs | 80 +++++- crates/cli/src/commands/seed.rs | 25 +- crates/cli/src/commands/snapshot.rs | 233 ++++++++++++++++++ crates/cli/src/main.rs | 3 + crates/core/src/lib.rs | 5 +- crates/core/src/service.rs | 32 +++ crates/core/src/snapshot.rs | 125 ++++++++++ docs/contributing.md | 13 + services/_lib/snapshot_pull/__init__.py | 2 + services/_lib/snapshot_pull/__main__.py | 171 +++++++++++++ .../_lib/snapshot_pull/pyairbyte_backend.py | 85 +++++++ services/_lib/snapshot_pull/redactor.py | 44 ++++ services/_lib/snapshot_pull/smart_filter.py | 152 ++++++++++++ services/_lib/snapshot_pull/snapshot.py | 107 ++++++++ services/github/service.yaml | 21 ++ 16 files changed, 1114 insertions(+), 8 deletions(-) create mode 100644 crates/cli/src/commands/snapshot.rs create mode 100644 crates/core/src/snapshot.rs create mode 100644 services/_lib/snapshot_pull/__init__.py create mode 100644 services/_lib/snapshot_pull/__main__.py create mode 100644 services/_lib/snapshot_pull/pyairbyte_backend.py create mode 100644 services/_lib/snapshot_pull/redactor.py create mode 100644 services/_lib/snapshot_pull/smart_filter.py create mode 100644 services/_lib/snapshot_pull/snapshot.py diff --git a/README.md b/README.md index d041d68..61ac6a4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ doubleagent status # Show running services doubleagent stop # Stop all doubleagent reset github # Clear state doubleagent seed github ./data.yaml # Load fixtures +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 +81,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: diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index a986007..e0e5817 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,11 @@ 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, } #[derive(Parser)] @@ -134,3 +142,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..f49553e 100644 --- a/crates/cli/src/commands/seed.rs +++ b/crates/cli/src/commands/seed.rs @@ -1,6 +1,6 @@ use super::SeedArgs; use colored::Colorize; -use doubleagent_core::{Config, ProcessManager}; +use doubleagent_core::{snapshot, Config, ProcessManager}; use std::fs; pub async fn run(args: SeedArgs) -> anyhow::Result<()> { @@ -11,12 +11,25 @@ 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)? + if args.file.is_some() && args.snapshot.is_some() { + return Err(anyhow::anyhow!( + "Use either a seed file or --snapshot, not both" + )); + } + + let data: serde_json::Value = if let Some(profile) = args.snapshot.as_deref() { + snapshot::load_seed_payload(&args.service, profile)? + } else if let Some(file) = args.file.as_deref() { + let content = fs::read_to_string(file)?; + if file.ends_with(".yaml") || file.ends_with(".yml") { + serde_yaml::from_str(&content)? + } else { + serde_json::from_str(&content)? + } } else { - serde_json::from_str(&content)? + return Err(anyhow::anyhow!( + "Seed file path is required (or use --snapshot )" + )); }; print!("{} Seeding {}...", "⬆".blue(), args.service); 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 600931a..fb85feb 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..33b9607 --- /dev/null +++ b/crates/core/src/snapshot.rs @@ -0,0 +1,125 @@ +//! 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) + } +} 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/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/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 From b23c3b0a3c067fedad85cdf1a6d15735da2ca97a Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Mon, 23 Feb 2026 10:56:16 +0200 Subject: [PATCH 05/10] feat: add Jira + Salesforce services, CI and gitignore updates Cherry-picked from feat/airbyte-services (#16): - Jira fake service (8 streams, snapshot-backed) - Salesforce fake service (8 streams, snapshot-backed) - CI: discover only services with contracts/ dir - .gitignore: snapshot and venv patterns - _lib/pyproject.toml for snapshot_pull package Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 4 +- .gitignore | 22 +- services/_lib/pyproject.toml | 9 + services/jira/.mise.toml | 3 + services/jira/server/main.py | 124 ++++++++++ services/jira/server/pyproject.toml | 10 + services/jira/server/uv.lock | 265 ++++++++++++++++++++++ services/jira/service.yaml | 89 ++++++++ services/salesforce/.mise.toml | 3 + services/salesforce/server/main.py | 124 ++++++++++ services/salesforce/server/pyproject.toml | 10 + services/salesforce/server/uv.lock | 265 ++++++++++++++++++++++ services/salesforce/service.yaml | 82 +++++++ 13 files changed, 1007 insertions(+), 3 deletions(-) create mode 100644 services/_lib/pyproject.toml create mode 100644 services/jira/.mise.toml create mode 100644 services/jira/server/main.py create mode 100644 services/jira/server/pyproject.toml create mode 100644 services/jira/server/uv.lock create mode 100644 services/jira/service.yaml create mode 100644 services/salesforce/.mise.toml create mode 100644 services/salesforce/server/main.py create mode 100644 services/salesforce/server/pyproject.toml create mode 100644 services/salesforce/server/uv.lock create mode 100644 services/salesforce/service.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5b4196..234eb4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,10 +68,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/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/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 From 6b8b6f652d3d1b0c34d0c78aba1141f1571597b6 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Mon, 23 Feb 2026 10:59:23 +0200 Subject: [PATCH 06/10] style: cargo fmt Co-Authored-By: Claude Opus 4.6 --- crates/cli/src/commands/seed.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/cli/src/commands/seed.rs b/crates/cli/src/commands/seed.rs index 06f0cf6..dd26389 100644 --- a/crates/cli/src/commands/seed.rs +++ b/crates/cli/src/commands/seed.rs @@ -7,18 +7,12 @@ 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; +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" - ); + anyhow::bail!("Use only one of: --snapshot, --fixture, or a file path"); } if let Some(ref profile) = args.snapshot { From 94711718a81d957a7526668b1fe5562eb7e03845 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Mon, 23 Feb 2026 11:41:09 +0200 Subject: [PATCH 07/10] test: add robust coverage for snapshot and seeding flows Add focused Rust and Python unit tests for snapshot storage helpers, seed source resolution, relational filtering, and incremental snapshot merge semantics to improve confidence in PR #33 behavior. Co-authored-by: Cursor --- crates/cli/src/commands/seed.rs | 131 ++++++++++++++++++++ crates/core/src/snapshot.rs | 146 +++++++++++++++++++++++ services/_lib/tests/test_smart_filter.py | 92 ++++++++++++++ services/_lib/tests/test_snapshot.py | 79 ++++++++++++ 4 files changed, 448 insertions(+) create mode 100644 services/_lib/tests/test_smart_filter.py create mode 100644 services/_lib/tests/test_snapshot.py diff --git a/crates/cli/src/commands/seed.rs b/crates/cli/src/commands/seed.rs index dd26389..f295d72 100644 --- a/crates/cli/src/commands/seed.rs +++ b/crates/cli/src/commands/seed.rs @@ -62,6 +62,7 @@ fn resolve_seed_source(args: &SeedArgs, config: &Config) -> anyhow::Result 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/core/src/snapshot.rs b/crates/core/src/snapshot.rs index 33b9607..725a88f 100644 --- a/crates/core/src/snapshot.rs +++ b/crates/core/src/snapshot.rs @@ -123,3 +123,149 @@ pub fn delete_snapshot(service: &str, profile: &str) -> Result { 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/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() From 9174c04a065f9347aa1e34d836ea24d8a6086071 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Mon, 23 Feb 2026 11:51:07 +0200 Subject: [PATCH 08/10] fix: allow fixture seeding for local service definitions Resolve fixture lookup via local-aware service resolution so `doubleagent seed --fixture ...` works when services are present in the repo but not installed in cache. Co-authored-by: Cursor --- crates/cli/src/commands/seed.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/seed.rs b/crates/cli/src/commands/seed.rs index f295d72..9cd3bde 100644 --- a/crates/cli/src/commands/seed.rs +++ b/crates/cli/src/commands/seed.rs @@ -22,7 +22,8 @@ fn resolve_seed_source(args: &SeedArgs, config: &Config) -> anyhow::Result Date: Mon, 23 Feb 2026 12:05:00 +0200 Subject: [PATCH 09/10] test: add PII redactor unit tests Cover email anonymization (deterministic, unique per address), secret-like string detection, normal string passthrough, non-string passthrough, and recursive nested object redaction. Co-Authored-By: Claude Opus 4.6 --- services/_lib/tests/test_redactor.py | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 services/_lib/tests/test_redactor.py 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() From 9e6a6fb39ccfbe83cbdd32197d03059209605fee Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Mon, 23 Feb 2026 12:10:37 +0200 Subject: [PATCH 10/10] ci+docs: add Python unit tests to CI, document --fixture, update service table - Add python-unit-tests job to CI (runs snapshot_pull tests) - Document --fixture flag in README CLI section - Update Available Services: Jira and Salesforce now available (snapshot-backed) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 11 +++++++++++ README.md | 8 +++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 234eb4c..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 diff --git a/README.md b/README.md index b28eacc..87ccaf8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ 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 @@ -159,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