From 7c87f88f4e5e366c34b896a33c4f26385e6ecb05 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 10 Feb 2026 16:04:02 +0100 Subject: [PATCH 01/18] feat: rewrite Targeting/FrequencyCap to extend AdCP library types (#1006) - FrequencyCap now extends library FrequencyCap, inheriting suppress_minutes: float and adding scope as extension field - Targeting now extends TargetingOverlay with v3 structured geo fields (geo_countries, geo_regions, geo_metros, geo_postal_areas) - Added 4 exclusion extension fields (geo_*_exclude) - Added legacy normalizer to convert flat DB fields to v3 structured - Added backward-compat properties for adapters (to be removed in salesagent-oee/fwm) - Removed geo_city_any_of/none_of (never supported by any adapter) - Updated AdCP contract and schema validation tests for v3 fields --- .claude/agents/qc-validator.md | 96 ++++ .claude/commands/research.md | 83 ++++ .claude/rules/patterns/code-patterns.md | 59 +++ .claude/rules/patterns/mcp-patterns.md | 49 ++ .claude/rules/patterns/testing-patterns.md | 50 ++ .claude/rules/workflows/beads-workflow.md | 89 ++++ .claude/rules/workflows/bug-reporting.md | 66 +++ .claude/rules/workflows/quality-gates.md | 61 +++ .claude/rules/workflows/research-workflow.md | 88 ++++ .claude/rules/workflows/session-completion.md | 57 +++ .../subagent-implementation-guide.md | 67 +++ .claude/rules/workflows/tdd-workflow.md | 91 ++++ .gitignore | 4 + CLAUDE.md | 463 +++--------------- Makefile | 27 + pyproject.toml | 63 ++- scripts/deploy/run_all_services.py | 6 +- scripts/gam_prerequisites_check.py | 1 + scripts/ops/migrate.py | 3 +- src/adapters/gam/managers/creatives.py | 2 +- src/adapters/gam/managers/orders.py | 14 +- src/adapters/gam/managers/targeting.py | 4 +- src/adapters/gam_reporting_service.py | 58 ++- src/adapters/test_scenario_parser.py | 2 + src/admin/app.py | 4 +- src/admin/blueprints/inventory.py | 3 +- src/admin/blueprints/products.py | 28 +- src/admin/blueprints/publisher_partners.py | 4 +- src/admin/tests/integration/test_admin_app.py | 4 +- src/core/auth.py | 5 +- src/core/creative_agent_registry.py | 2 +- src/core/main.py | 4 +- src/core/schemas.py | 230 +++++++-- src/core/tools/products.py | 21 +- src/core/tools/properties.py | 2 +- src/core/validation_helpers.py | 2 +- src/services/ai/agents/review_agent.py | 2 +- src/services/dynamic_pricing_service.py | 53 +- src/services/media_buy_status_scheduler.py | 4 +- src/services/policy_service.py | 31 +- tests/e2e/test_a2a_regression_prevention.py | 12 +- tests/e2e/test_a2a_webhook_payload_types.py | 6 +- tests/e2e/test_delivery_webhooks_e2e.py | 24 +- tests/e2e/test_landing_pages.py | 48 +- .../test_a2a_response_compliance.py | 6 +- .../test_a2a_response_message_fields.py | 12 +- tests/integration/test_adapter_factory.py | 12 +- .../test_database_health_integration.py | 6 +- .../test_delivery_webhooks_force.py | 1 - .../test_duplicate_product_validation.py | 6 +- .../test_gam_pricing_models_integration.py | 30 +- .../test_gam_pricing_restriction.py | 24 +- .../test_inventory_profile_security.py | 6 +- .../test_list_creative_formats_params.py | 18 +- .../test_notification_urls_exist.py | 4 +- .../test_pricing_models_integration.py | 24 +- .../test_product_multiple_format_ids.py | 12 +- .../test_product_pricing_options_required.py | 6 +- .../test_schema_contract_validation.py | 45 +- tests/integration_v2/conftest.py | 13 +- .../test_a2a_error_responses.py | 6 +- .../test_a2a_skill_invocation.py | 64 ++- .../test_admin_ui_data_validation.py | 24 +- tests/integration_v2/test_mcp_tools_audit.py | 12 +- .../test_schema_database_mapping.py | 6 +- .../integration_v2/test_tool_result_format.py | 6 +- tests/unit/test_a2a_auth_optional.py | 3 - .../unit/test_a2a_function_call_validation.py | 8 +- tests/unit/test_a2a_parameter_mapping.py | 12 +- tests/unit/test_adapter_packages_fix.py | 6 +- tests/unit/test_adcp_contract.py | 190 +++---- tests/unit/test_auth_bearer_header.py | 2 +- tests/unit/test_axe_segment_targeting.py | 10 +- tests/unit/test_brand_manifest_rootmodel.py | 19 +- .../test_creative_format_validation_bug.py | 3 +- tests/unit/test_gam_pricing_compatibility.py | 36 +- tests/unit/test_gam_workflow_packages.py | 6 +- .../test_incremental_sync_stale_marking.py | 3 +- tests/unit/test_mcp_tool_imports.py | 6 +- tests/unit/test_mcp_tool_schemas.py | 114 ++--- tests/unit/test_naming_parameter_bug.py | 6 +- tests/unit/test_order_approval_service.py | 6 +- .../test_raw_function_parameter_validation.py | 6 +- tests/unit/test_schema_library_inheritance.py | 18 +- .../test_sync_creatives_format_validation.py | 1 - tests/unit/test_task_management_tools.py | 6 - 86 files changed, 1776 insertions(+), 1020 deletions(-) create mode 100644 .claude/agents/qc-validator.md create mode 100644 .claude/commands/research.md create mode 100644 .claude/rules/patterns/code-patterns.md create mode 100644 .claude/rules/patterns/mcp-patterns.md create mode 100644 .claude/rules/patterns/testing-patterns.md create mode 100644 .claude/rules/workflows/beads-workflow.md create mode 100644 .claude/rules/workflows/bug-reporting.md create mode 100644 .claude/rules/workflows/quality-gates.md create mode 100644 .claude/rules/workflows/research-workflow.md create mode 100644 .claude/rules/workflows/session-completion.md create mode 100644 .claude/rules/workflows/subagent-implementation-guide.md create mode 100644 .claude/rules/workflows/tdd-workflow.md create mode 100644 Makefile diff --git a/.claude/agents/qc-validator.md b/.claude/agents/qc-validator.md new file mode 100644 index 000000000..3acb77555 --- /dev/null +++ b/.claude/agents/qc-validator.md @@ -0,0 +1,96 @@ +--- +name: QC Validator +description: Validates task completion against acceptance criteria, quality gates, and AdCP compliance. Use after completing a beads task to verify everything meets standards before closing. +color: green +tools: + - Bash + - Read + - Grep + - Glob +--- + +# QC Validator Agent + +You are a quality control validator for the Prebid Sales Agent project. Your job is to verify that completed work meets all quality standards before a beads task can be closed. + +## Modes + +### Task Completion Mode (Default) +Fast validation for closing a single beads task. Run when someone says "validate task ". + +### Full Validation Mode +Comprehensive check before merging to main. Run when someone says "full validation". + +## Task Completion Validation + +### Step 1: Read the Task +```bash +bd show +``` +Extract: +- Acceptance criteria from description +- Type (feature/bug/task) +- Any notes or design fields + +### Step 2: Verify Acceptance Criteria +For each acceptance criterion in the task description: +- Check if it's implemented (search codebase) +- Check if it's tested (search test files) +- Mark as PASS or FAIL with evidence + +### Step 3: Run Quality Gates +```bash +make quality +``` +Must pass cleanly. Report any failures. + +### Step 4: Check AdCP Compliance (if applicable) +If the task touches schemas, models, or protocol: +```bash +uv run pytest tests/unit/test_adcp_contract.py -v +``` + +### Step 5: Verify Git State +```bash +git status +git diff --stat +``` +Check: +- All changes are committed (or staged) +- No unintended files modified +- Commit message follows conventional commits format + +### Step 6: Report + +Output a validation report: + +``` +## QC Validation Report: + +### Acceptance Criteria +- [ ] Criterion 1: PASS/FAIL — evidence +- [ ] Criterion 2: PASS/FAIL — evidence + +### Quality Gates +- [ ] ruff format: PASS/FAIL +- [ ] ruff check: PASS/FAIL +- [ ] mypy: PASS/FAIL +- [ ] unit tests: PASS/FAIL + +### AdCP Compliance +- [ ] Contract tests: PASS/FAIL/N/A + +### Git State +- [ ] Changes committed: YES/NO +- [ ] Commit message format: PASS/FAIL + +### Verdict: PASS / FAIL +``` + +## Full Validation Mode + +Runs everything above plus: +1. `make quality-full` (includes integration and e2e tests) +2. Verifies all open beads tasks are either completed or have clear follow-up issues +3. Checks `bd list --status=in_progress` for abandoned work +4. Verifies `bd sync --from-main` has been run diff --git a/.claude/commands/research.md b/.claude/commands/research.md new file mode 100644 index 000000000..a97bd82e6 --- /dev/null +++ b/.claude/commands/research.md @@ -0,0 +1,83 @@ +--- +name: research +description: Research a beads task before implementation +arguments: + - name: task_id + description: The beads task ID to research (e.g., beads-001) + required: true +--- + +# Research Task: $ARGUMENTS + +## Instructions + +You are researching beads task **$ARGUMENTS** before implementation begins. + +### Step 1: Read the Task +Run `bd show $ARGUMENTS` to get the full task description, acceptance criteria, and any design notes. + +### Step 2: Explore the Codebase +Based on the task requirements: +1. Search for relevant code using Grep and Glob +2. Read the files that will need to be modified +3. Check existing tests for the affected area +4. Look for similar implementations to follow as patterns + +### Step 3: Check Documentation (Doc-First Rule) +If the task involves external libraries: +- Use Ref MCP to search library documentation +- Use DeepWiki MCP to ask questions about GitHub repos +- Check CLAUDE.md for project-specific patterns +- Check `/docs` directory for detailed documentation + +### Step 4: Engineering Checklist +Run these checks against your findings. Each one should produce a concrete answer, not a shrug. + +1. **DRY**: Does similar logic already exist? Search for functions doing comparable work. Extend, don't duplicate. +2. **Library idioms**: How does the primary library (Pydantic, SQLAlchemy, FastMCP, etc.) solve this? Check docs via Ref/DeepWiki before hand-rolling. +3. **Data flow trace**: Walk one concrete example from system boundary (buyer JSON) → Pydantic parsing → logic layer → data layer (DB write/read) → response serialization. Trace both the success path and a failure/rejection path. Note where types change or could break. +4. **Consistency**: How do the language/framework and this codebase solve the same class of problem? Check both — codebase conventions take precedence unless they're a clear antipattern. +5. **Test infrastructure**: What fixtures, factories, helpers already exist in `tests/`? What's reusable vs needs new? + +### Step 5: Integrate Findings +Weave the checklist answers into your analysis. They should shape your architecture decisions and implementation plan — not appear as a standalone section in the artifact. + +### Step 6: Identify Architecture Decisions +Based on your research: +- What CLAUDE.md patterns apply? +- Are there multiple valid approaches? +- What are the risks or edge cases? + +### Step 7: Create Research Artifact +Create a research file at `.claude/research/$ARGUMENTS.md` with: + +```markdown +# Research: [task title from bd show] + +## Task +$ARGUMENTS: [description] + +## Findings +- [Key findings from codebase exploration] + +## Relevant Code +- `path/to/file.py:line` — [what it does] + +## CLAUDE.md Patterns +- [Which critical patterns apply and how] + +## Architecture Decisions +- [Decisions and rationale] + +## Implementation Plan +1. [First step] +2. [Second step] +3. [...] + +## Risks & Edge Cases +- [Potential issues to watch for] +``` + +### Step 8: Update the Task +Run `bd label add $ARGUMENTS research:complete` if research is sufficient. +Run `bd label add $ARGUMENTS research:blocked` if there are unresolved questions, and add notes explaining what's blocked. diff --git a/.claude/rules/patterns/code-patterns.md b/.claude/rules/patterns/code-patterns.md new file mode 100644 index 000000000..3bee7a46b --- /dev/null +++ b/.claude/rules/patterns/code-patterns.md @@ -0,0 +1,59 @@ +# Code Patterns + +Reference patterns for writing code in this project. Read this when implementing new features or modifying existing code. + +## SQLAlchemy 2.0 (MANDATORY for new code) +```python +from sqlalchemy import select + +# Use this +stmt = select(Model).filter_by(field=value) +instance = session.scalars(stmt).first() + +# Not this (deprecated) +instance = session.query(Model).filter_by(field=value).first() +``` + +## Database JSON Fields +```python +from src.core.database.json_type import JSONType + +class MyModel(Base): + config: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) +``` + +## Import Patterns +```python +# Always use absolute imports +from src.core.schemas import Principal +from src.core.database.database_session import get_db_session +from src.adapters import get_adapter +``` + +## No Quiet Failures +```python +# WRONG - Silent failure +if not self.supports_feature: + logger.warning("Skipping...") + +# CORRECT - Explicit failure +if not self.supports_feature and feature_requested: + raise FeatureNotSupportedException("Cannot fulfill contract") +``` + +## Code Style +- Use `uv` for dependencies +- Run `pre-commit run --all-files` +- Use type hints +- No hardcoded external system IDs (use config/database) +- No testing against production systems + +## Type Checking +```bash +uv run mypy src/core/your_file.py --config-file=mypy.ini +``` + +When modifying code: +1. Fix mypy errors in files you change +2. Use SQLAlchemy 2.0 `Mapped[]` annotations for new models +3. Use `| None` instead of `Optional[]` (Python 3.10+) diff --git a/.claude/rules/patterns/mcp-patterns.md b/.claude/rules/patterns/mcp-patterns.md new file mode 100644 index 000000000..0e8975845 --- /dev/null +++ b/.claude/rules/patterns/mcp-patterns.md @@ -0,0 +1,49 @@ +# MCP & A2A Patterns + +Reference patterns for working with MCP tools and A2A integration. Read this when adding or modifying tools. + +## MCP Client Usage +```python +from fastmcp.client import Client +from fastmcp.client.transports import StreamableHttpTransport + +headers = {"x-adcp-auth": "your_token"} +transport = StreamableHttpTransport(url="http://localhost:8000/mcp/", headers=headers) +client = Client(transport=transport) + +async with client: + products = await client.tools.get_products(brief="video ads") + result = await client.tools.create_media_buy(product_ids=["prod_1"], ...) +``` + +## CLI Testing +```bash +# List available tools +uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools + +# Get a real token from Admin UI -> Advertisers -> API Token +uvx adcp http://localhost:8000/mcp/ --auth get_products '{"brief":"video"}' +``` + +## Shared Implementation Pattern (Critical Pattern #5) +All tools use shared `_tool_name_impl()` called by both MCP and A2A paths: + +```python +# main.py +def _create_media_buy_impl(...) -> CreateMediaBuyResponse: + return response + +@mcp.tool() +def create_media_buy(...) -> CreateMediaBuyResponse: + return _create_media_buy_impl(...) + +# tools.py +def create_media_buy_raw(...) -> CreateMediaBuyResponse: + from src.core.main import _create_media_buy_impl + return _create_media_buy_impl(...) +``` + +## Access Points (via nginx at http://localhost:8000) +- Admin UI: `/admin/` or `/tenant/default` +- MCP Server: `/mcp/` +- A2A Server: `/a2a` diff --git a/.claude/rules/patterns/testing-patterns.md b/.claude/rules/patterns/testing-patterns.md new file mode 100644 index 000000000..87b810b85 --- /dev/null +++ b/.claude/rules/patterns/testing-patterns.md @@ -0,0 +1,50 @@ +# Testing Patterns + +Reference patterns for writing tests. Read this when adding or modifying tests. + +## Test Organization +- **tests/unit/**: Fast, isolated (mock external deps only) +- **tests/integration/**: Real PostgreSQL database +- **tests/e2e/**: Full system tests +- **tests/ui/**: Admin UI tests + +## Database Fixtures +```python +# Integration tests - use integration_db +@pytest.mark.requires_db +def test_something(integration_db): + with get_db_session() as session: + # Test with real PostgreSQL + pass + +# Unit tests - mock the database +def test_something(): + with patch('src.core.database.database_session.get_db_session') as mock_db: + # Test with mocked database + pass +``` + +## Quality Rules +- Max 10 mocks per test file (pre-commit enforces) +- AdCP compliance test for all client-facing models +- Test YOUR code, not Python built-ins +- Never skip tests - fix the issue (`skip_ci` for rare exceptions only) +- Roundtrip test required for any operation using `apply_testing_hooks()` + +## Testing Workflow (Before Commit) +```bash +# ALL changes +make quality + +# Refactorings (shared impl, moving code, imports) +uv run pytest tests/integration/ -x + +# Critical changes (protocol, schema updates) +uv run pytest tests/ -x +``` + +**Pre-commit hooks can't catch import errors** - You must run tests for refactorings! + +## Also See +- `.claude/rules/workflows/tdd-workflow.md` — Red-Green-Refactor cycle +- `.claude/rules/workflows/quality-gates.md` — Quality gate commands diff --git a/.claude/rules/workflows/beads-workflow.md b/.claude/rules/workflows/beads-workflow.md new file mode 100644 index 000000000..d6e47c9fe --- /dev/null +++ b/.claude/rules/workflows/beads-workflow.md @@ -0,0 +1,89 @@ +# Beads Workflow + +## 4-Step Loop + +### 1. Find & Review +```bash +bd ready # Show tasks ready to work (no blockers) +bd show # Read full description, acceptance criteria +``` + +Choose a task based on: +- Priority (P0 > P1 > P2 > P3 > P4) +- Dependencies (prefer unblocking other tasks) +- Logical ordering (setup before implementation) + +### 2. Validate Requirements + +Before writing code, verify you understand: + +**From the task itself:** +- What are the acceptance criteria? +- What does "done" look like? +- Are there dependencies or blocked tasks? + +**From CLAUDE.md (7 critical patterns):** +- Does this touch schemas? → Check AdCP pattern (#1) +- Does this add routes? → Check route conflict pattern (#2) +- Does this touch the database? → PostgreSQL only (#3) +- Does this serialize models? → Check nested serialization (#4) +- Does this add a tool? → Shared impl pattern (#5) +- Does this touch JavaScript? → script_root pattern (#6) +- Does this change validation? → Environment-based pattern (#7) + +**From existing code:** +- Read the files you'll modify +- Check existing tests for the area +- Look for similar implementations to follow + +**Decision checklist before implementing:** +- [ ] I understand the acceptance criteria +- [ ] I've read CLAUDE.md patterns relevant to this task +- [ ] I've read the existing code I'll modify +- [ ] I've checked for existing tests +- [ ] I know what "done" looks like + +### 3. Claim & Work +```bash +bd update --status=in_progress +``` + +Implement following TDD workflow (see tdd-workflow.md): +1. Write failing test +2. Make it pass +3. Refactor +4. Run `make quality` + +### 4. Verify & Close + +**QC validation before closure:** +- [ ] `make quality` passes +- [ ] Acceptance criteria from task description are met +- [ ] No regressions in existing tests +- [ ] Changes committed with conventional commit message + +```bash +bd close +``` + +## Creating New Tasks + +For discovered work: +```bash +bd create --title="..." --type=task|bug|feature --priority=2 +``` + +**Priority scale**: 0=critical, 1=high, 2=medium, 3=low, 4=backlog + +For dependent work: +```bash +bd dep add # child depends on parent +``` + +## Task Status Flow + +``` +pending → in_progress → completed (via bd close) +``` + +Use `bd blocked` to see tasks waiting on dependencies. diff --git a/.claude/rules/workflows/bug-reporting.md b/.claude/rules/workflows/bug-reporting.md new file mode 100644 index 000000000..beb0a17a1 --- /dev/null +++ b/.claude/rules/workflows/bug-reporting.md @@ -0,0 +1,66 @@ +# Bug Reporting & Fix Workflow + +## When You Find a Bug + +### 1. Create Beads Issue +```bash +bd create --title="Bug: " --type=bug --priority=<0-4> +``` + +Include in the description: +- **Observed behavior**: What actually happens +- **Expected behavior**: What should happen +- **Reproduction steps**: How to trigger it +- **Affected area**: Which files/components + +### 2. Validate Against Patterns + +Before fixing, check: +- Does this violate a CLAUDE.md critical pattern? +- Is this an AdCP spec compliance issue? (Check `tests/unit/test_adcp_contract.py`) +- Is this a regression from a recent change? (Check `git log --oneline -20`) + +### 3. Write Regression Test + +**Always write the test FIRST:** +```bash +# Write the failing test +uv run pytest tests/unit/test_.py::test_ -x +# Confirm it fails for the right reason +``` + +The test should: +- Demonstrate the bug clearly +- Be minimal (test one thing) +- Follow existing test patterns in the file + +### 4. Fix the Bug + +- Fix the root cause, not symptoms +- Keep the fix minimal and focused +- Don't refactor surrounding code (separate PR) + +### 5. Quality Gates +```bash +make quality +``` + +Verify: +- New test passes +- No existing tests broken +- Formatting and linting clean + +### 6. Close and Commit +```bash +bd close +git add +git commit -m "fix: " +``` + +## Bug Priority Guide + +- **P0 (critical)**: Data loss, security vulnerability, complete feature broken +- **P1 (high)**: Major feature degraded, blocking other work +- **P2 (medium)**: Feature works but incorrectly in some cases +- **P3 (low)**: Minor issue, workaround exists +- **P4 (backlog)**: Cosmetic, edge case, nice-to-have fix diff --git a/.claude/rules/workflows/quality-gates.md b/.claude/rules/workflows/quality-gates.md new file mode 100644 index 000000000..fc3a73740 --- /dev/null +++ b/.claude/rules/workflows/quality-gates.md @@ -0,0 +1,61 @@ +# Quality Gates + +## When to Run + +Run quality gates **before any commit** and **before closing a beads task**. + +## Quick Check (Every Change) + +```bash +make quality +``` + +This runs: +1. `ruff format --check .` — formatting +2. `ruff check .` — linting (includes C90 complexity and PLR refactor rules) +3. `mypy src/ --config-file=mypy.ini` — type checking +4. `pytest tests/unit/ -x` — unit tests (fail-fast) + +## Full Check (Before Merge) + +```bash +make quality-full +``` + +Runs everything above plus `./run_all_tests.sh ci` (integration + e2e with PostgreSQL). + +## Common Violations + +### Ruff Rules to Watch +- **C901** (complexity > 10): Break function into smaller pieces +- **PLR0912** (branches > 12): Simplify conditional logic +- **PLR0913** (args > 5): Use dataclass/config object for parameters +- **PLR0915** (statements > 50): Extract helper functions + +### Pre-commit Hooks (11 active) +The project has 11 pre-commit hooks that catch: +- Route conflicts +- SQLAlchemy 1.x patterns +- Star imports +- Excessive mocks in tests +- Documentation link breakage +- Import usage issues + +Run manually: `pre-commit run --all-files` + +**Important**: Pre-commit hooks can't catch import errors. After refactoring or moving code, always run `uv run pytest tests/unit/ -x` to verify. + +## AdCP Contract Compliance + +For any schema changes, run: +```bash +uv run pytest tests/unit/test_adcp_contract.py -v +``` + +## Fix Formatting/Linting Issues + +```bash +make lint-fix +``` + +This runs `ruff format .` then `ruff check --fix .`. diff --git a/.claude/rules/workflows/research-workflow.md b/.claude/rules/workflows/research-workflow.md new file mode 100644 index 000000000..69a228a5b --- /dev/null +++ b/.claude/rules/workflows/research-workflow.md @@ -0,0 +1,88 @@ +# Research Workflow + +## When Research is Needed + +Before implementing a beads task, research is warranted when: +- The task involves unfamiliar code paths +- External library APIs need verification (doc-first rule) +- Architecture decisions need to be made +- The acceptance criteria are ambiguous + +## 3-Path Flow + +### Path 1: Research Complete +Research answers all questions. Implementation can proceed. +```bash +bd label add research:complete +``` + +### Path 2: Research Blocked +Research reveals missing information or external blockers. +```bash +bd label add research:blocked +bd update --notes="Blocked because: " +``` + +### Path 3: No Research Needed +Task is clear and well-defined. Skip to implementation. + +## Research Process + +### 1. Explore the Codebase +- Read files involved in the change +- Trace execution paths +- Check existing tests for examples +- Look for similar implementations + +### 2. Check Documentation +**Doc-First Rule**: For external libraries, check docs before relying on training data. + +Available documentation sources: +- **Ref MCP**: Search library docs (FastMCP, SQLAlchemy, Flask, Pydantic, etc.) +- **DeepWiki MCP**: Ask questions about GitHub repositories +- **CLAUDE.md**: Project patterns and architecture +- **`/docs` directory**: Detailed project documentation + +### 3. Record Findings + +Create a research artifact: +``` +.claude/research/.md +``` + +Structure: +```markdown +# Research: + +## Task +: + +## Findings +- Key finding 1 +- Key finding 2 + +## Relevant Code +- `path/to/file.py:line` — description +- `path/to/other.py:line` — description + +## Architecture Decisions +- Decision 1: + +## Implementation Notes +- Start with: +- Watch out for: +``` + +### 4. Update Task +```bash +bd label add research:complete +bd update --notes="Research complete. See .claude/research/.md" +``` + +## Tips + +- Keep research artifacts concise (not exhaustive documentation) +- Focus on what the implementer needs to know +- Include specific file paths and line numbers +- Note any risks or edge cases discovered +- If research takes > 15 minutes, it's probably too broad — narrow scope diff --git a/.claude/rules/workflows/session-completion.md b/.claude/rules/workflows/session-completion.md new file mode 100644 index 000000000..651cc380f --- /dev/null +++ b/.claude/rules/workflows/session-completion.md @@ -0,0 +1,57 @@ +# Session Completion Checklist + +## Before Saying "Done" or "Complete" + +Run through this checklist in order: + +### Step 1: Check Incomplete Work +```bash +bd list --status=in_progress +``` +Review any tasks still in progress. Either complete them or file follow-up issues. + +### Step 2: File Issues for Remaining Work +For anything discovered but not completed: +```bash +bd create --title="..." --type=task --priority=2 +``` +Include enough context for the next session to pick up the work. + +### Step 3: Run Quality Gates +```bash +make quality +``` +All checks must pass. If they fail, fix the issues before committing. + +### Step 4: Close Completed Tasks +```bash +bd close ... +``` +Close all beads tasks that were fully completed this session. + +### Step 5: Commit and Sync +```bash +git add +bd sync --from-main +git commit -m "feat/fix/refactor: description" +``` + +**Important**: This is an ephemeral branch. No `git push`. Code is merged to main locally. + +### Step 6: Verify Clean State +```bash +git status +bd list --status=open +``` +Confirm: +- Working tree is clean (or only has expected untracked files) +- All completed tasks are closed +- Any remaining open tasks have clear descriptions + +## Ephemeral Branch Workflow + +This project uses ephemeral branches: +- Work happens on feature branches +- Branches are merged to main **locally** (not pushed) +- `bd sync --from-main` pulls beads updates from main before final commit +- No upstream tracking — don't run `git push` diff --git a/.claude/rules/workflows/subagent-implementation-guide.md b/.claude/rules/workflows/subagent-implementation-guide.md new file mode 100644 index 000000000..f2c621670 --- /dev/null +++ b/.claude/rules/workflows/subagent-implementation-guide.md @@ -0,0 +1,67 @@ +# Subagent Implementation Guide + +## When to Use Subagents + +Use the Task tool with subagents when: +- **Parallelizing independent work**: Multiple files to explore, multiple beads tasks to create +- **Protecting context**: Large search results that would clutter the main conversation +- **Specialized agents**: QC validation, deep research, code exploration + +## Subagent Types + +### Explore Agent (`subagent_type=Explore`) +Use for codebase exploration: +- Finding files by patterns +- Searching for code keywords +- Understanding how features work +- Tracing execution paths + +### General Purpose Agent (`subagent_type=general-purpose`) +Use for complex, multi-step tasks: +- Researching questions across multiple files +- Executing multi-step analysis +- When you need all tools available + +### QC Validator Agent (`.claude/agents/qc-validator.md`) +Use after completing a beads task: +- Validates acceptance criteria +- Runs quality gates +- Checks AdCP compliance +- Verifies git state + +## Implementation Pattern + +When a beads task has a design field or detailed acceptance criteria: + +### 1. Plan the Work +Read the beads task design/description for implementation guidance: +```bash +bd show +``` + +### 2. Parallelize Where Possible +Launch independent subagents simultaneously: +``` +Task 1: Explore agent — find related code patterns +Task 2: Explore agent — find existing tests +``` + +### 3. Implement Sequentially +After research, implement in order: +1. Write tests (TDD) +2. Write implementation +3. Run quality gates + +### 4. Validate with QC Agent +Before closing the beads task, run the QC validator: +- Checks all acceptance criteria +- Runs `make quality` +- Verifies AdCP compliance if applicable + +## Tips + +- **Be specific in prompts**: Tell subagents exactly what to find/do +- **Include file paths**: When you know which files to check +- **Set expectations**: Tell the agent whether to write code or just research +- **Use background agents**: For long-running tasks that don't block your work +- **Don't duplicate work**: If you delegate research, don't also search yourself diff --git a/.claude/rules/workflows/tdd-workflow.md b/.claude/rules/workflows/tdd-workflow.md new file mode 100644 index 000000000..b77873721 --- /dev/null +++ b/.claude/rules/workflows/tdd-workflow.md @@ -0,0 +1,91 @@ +# TDD Workflow + +## Red-Green-Refactor Cycle + +### 1. Red — Write Failing Test + +Before writing implementation code: +1. Understand requirements from beads task + CLAUDE.md patterns +2. Write a test that describes the desired behavior +3. Run it and confirm it fails: + +```bash +uv run pytest tests/unit/test_.py::test_ -x -v +``` + +**Test organization:** +- `tests/unit/` — Fast, isolated (mock external deps only) +- `tests/integration/` — Real PostgreSQL database +- `tests/e2e/` — Full system tests + +### 2. Green — Make It Pass + +Write the minimum code to make the test pass: +- Follow CLAUDE.md critical patterns +- Use existing patterns from surrounding code +- Don't add extras not covered by tests + +```bash +uv run pytest tests/unit/test_.py::test_ -x -v +``` + +### 3. Refactor — Clean Up + +With passing tests as safety net: +- Remove duplication +- Improve naming +- Simplify logic +- Ensure CLAUDE.md patterns are followed + +```bash +make quality # Full quality gate after refactoring +``` + +## Requirements Sources + +For salesagent, requirements come from: +1. **Beads task description** — acceptance criteria +2. **CLAUDE.md** — 7 critical architecture patterns +3. **AdCP spec** — protocol compliance (`tests/unit/test_adcp_contract.py`) +4. **Existing test patterns** — conventions in `tests/unit/` + +## Sacred Rule + +**NEVER adjust tests to match code.** + +If a test fails after implementation: +- The implementation is wrong, OR +- The test requirements were wrong (update requirements first, then test, then code) + +Tests define the contract. Code fulfills it. + +## Common Test Patterns + +### Unit Test (Mock External Deps) +```python +def test_something(): + with patch('src.core.database.database_session.get_db_session') as mock_db: + # Test with mocked database + pass +``` + +### Integration Test (Real PostgreSQL) +```python +@pytest.mark.requires_db +def test_something(integration_db): + with get_db_session() as session: + # Test with real PostgreSQL + pass +``` + +### AdCP Compliance +```python +# Run after any schema changes +uv run pytest tests/unit/test_adcp_contract.py -v +``` + +## Quality Rules +- Max 10 mocks per test file +- Test YOUR code, not Python built-ins +- Roundtrip test required for `apply_testing_hooks()` +- Never use `skip_ci` without explicit justification diff --git a/.gitignore b/.gitignore index b4908501d..edb0f5423 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,7 @@ test_*[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/ .env.test test_sync.py .env.bak* + +# Agentic coding — local-only files +.claude/research/ +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 004e31913..c78588d7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,24 +1,23 @@ # Prebid Sales Agent - Development Guide -## 🤖 For Claude (AI Assistant) +## Rules -This guide helps you work effectively with the Prebid Sales Agent codebase maintained under Prebid.org. Key principles: - -### Working with This Codebase 1. **Always read before writing** - Use Read/Glob to understand existing patterns -2. **Test your changes** - Run `uv run pytest tests/unit/ -x` before committing -3. **Follow the patterns** - 7 critical patterns below are non-negotiable -4. **When stuck** - Check `/docs` for detailed explanations +2. **Test your changes** - Run `make quality` before committing +3. **Follow the 7 patterns below** - They are non-negotiable +4. **Doc-first** - Search docs via MCP servers (Ref, DeepWiki) before relying on training data for external libraries (FastMCP, SQLAlchemy, adcp, Flask, Pydantic, etc.) 5. **Pre-commit hooks are your friend** - They catch most issues automatically -6. **Name your PRs correctly** - they need to pass .github/workflows/pr-title-check.yml - -### Common Task Patterns -- **Adding a new AdCP tool**: Extend library schema → Add `_impl()` function → Add MCP wrapper → Add A2A raw function → Add tests -- **Fixing a route issue**: Check for conflicts with `grep -r "@.*route.*your/path"` → Use `url_for()` in Python, `scriptRoot` in JavaScript -- **Modifying schemas**: Verify against AdCP spec → Update Pydantic model → Run `pytest tests/unit/test_adcp_contract.py` -- **Database changes**: Use SQLAlchemy 2.0 `select()` → Use `JSONType` for JSON → Create migration with `alembic revision` +6. **Conventional commits** - PRs need prefixes (`feat:`, `fix:`, `docs:`, `refactor:`, `perf:`, `chore:`) to pass `.github/workflows/pr-title-check.yml` and appear in release notes -### Key Files to Know +### What to Avoid +- Don't use `session.query()` (use `select()` + `scalars()`) +- Don't duplicate library schemas (extend with inheritance) +- Don't hardcode URLs in JavaScript (use `scriptRoot`) +- Don't bypass pre-commit hooks without good reason +- Don't skip tests to make CI pass (fix the underlying issue) +- Never add `# noqa` comments without explaining why in a code comment + +### Key Files - `src/core/main.py` - MCP tools and `_impl()` functions - `src/core/tools.py` - A2A raw functions - `src/core/schemas.py` - Pydantic models (AdCP-compliant) @@ -26,29 +25,15 @@ This guide helps you work effectively with the Prebid Sales Agent codebase maint - `src/adapters/gam/` - GAM implementation - `tests/unit/test_adcp_contract.py` - Schema compliance tests -### What to Avoid -- ❌ Don't use `session.query()` (use `select()` + `scalars()`) -- ❌ Don't duplicate library schemas (extend with inheritance) -- ❌ Don't hardcode URLs in JavaScript (use `scriptRoot`) -- ❌ Don't bypass pre-commit hooks without good reason -- ❌ Don't skip tests to make CI pass (fix the underlying issue) - -### Commit Messages & PR Titles -**Use Conventional Commits format** - release-please uses this to generate changelogs. - -PR titles should use one of these prefixes: -- `feat: Add new feature` - New functionality (appears in "Features" section) -- `fix: Fix bug description` - Bug fixes (appears in "Bug Fixes" section) -- `docs: Update documentation` - Documentation changes -- `refactor: Restructure code` - Code refactoring (appears in "Code Refactoring" section) -- `perf: Improve performance` - Performance improvements -- `chore: Update dependencies` - Maintenance tasks (hidden from changelog) - -**Without a prefix, commits won't appear in release notes!** The code will still be released, but the change won't be documented in the changelog. +### Common Task Patterns +- **Adding a new AdCP tool**: Extend library schema -> Add `_impl()` function -> Add MCP wrapper -> Add A2A raw function -> Add tests +- **Fixing a route issue**: Check for conflicts with `grep -r "@.*route.*your/path"` -> Use `url_for()` in Python, `scriptRoot` in JavaScript +- **Modifying schemas**: Verify against AdCP spec -> Update Pydantic model -> Run `pytest tests/unit/test_adcp_contract.py` +- **Database changes**: Use SQLAlchemy 2.0 `select()` -> Use `JSONType` for JSON -> Create migration with `alembic revision` --- -## 🚨 Critical Architecture Patterns +## Critical Architecture Patterns ### 1. AdCP Schema: Extend Library Schemas **MANDATORY**: Use `adcp` library schemas via inheritance, never duplicate. @@ -61,25 +46,13 @@ class Product(LibraryProduct): implementation_config: dict[str, Any] | None = Field(default=None, exclude=True) ``` -**Rules:** -- Extend library schemas for domain objects needing internal fields -- Mark internal fields with `exclude=True` -- Run `pytest tests/unit/test_adcp_contract.py` before commit -- Never bypass `--no-verify` without manual schema validation +Rules: Extend library schemas for domain objects needing internal fields. Mark internal fields with `exclude=True`. Run `pytest tests/unit/test_adcp_contract.py` before commit. ### 2. Flask: Prevent Route Conflicts -**Pre-commit hook detects duplicate routes** - Run manually: `uv run python .pre-commit-hooks/check_route_conflicts.py` - -When adding routes: -- Search existing: `grep -r "@.*route.*your/path"` -- Deprecate properly with early return, not comments +Pre-commit hook detects duplicate routes. Run manually: `uv run python .pre-commit-hooks/check_route_conflicts.py` ### 3. Database: PostgreSQL Only -**No SQLite support** - Production uses PostgreSQL exclusively. - -- Use `JSONType` for all JSON columns (not plain `JSON`) -- Use SQLAlchemy 2.0 patterns: `select()` + `scalars()`, not `query()` -- All tests require PostgreSQL: `./run_all_tests.sh ci` +No SQLite support. Use `JSONType` for all JSON columns (not plain `JSON`). Use SQLAlchemy 2.0 patterns: `select()` + `scalars()`, not `query()`. ### 4. Pydantic: Explicit Nested Serialization Parent models must override `model_dump()` to serialize nested children: @@ -95,386 +68,82 @@ class GetCreativesResponse(AdCPBaseModel): return result ``` -**Why**: Pydantic doesn't auto-call custom `model_dump()` on nested models. +Why: Pydantic doesn't auto-call custom `model_dump()` on nested models. ### 5. MCP/A2A: Shared Implementations -All tools use shared `_tool_name_impl()` function called by both MCP and A2A paths. - -```python -# main.py -def _create_media_buy_impl(...) -> CreateMediaBuyResponse: - # Real implementation - return response - -@mcp.tool() -def create_media_buy(...) -> CreateMediaBuyResponse: - return _create_media_buy_impl(...) - -# tools.py -def create_media_buy_raw(...) -> CreateMediaBuyResponse: - from src.core.main import _create_media_buy_impl - return _create_media_buy_impl(...) -``` +All tools use shared `_tool_name_impl()` function called by both MCP and A2A paths. See `.claude/rules/patterns/mcp-patterns.md` for full example. ### 6. JavaScript: Use request.script_root -**All JS must support reverse proxy deployments:** - ```javascript -const scriptRoot = '{{ request.script_root }}' || ''; // e.g., '/admin' or '' +const scriptRoot = '{{ request.script_root }}' || ''; const apiUrl = scriptRoot + '/api/endpoint'; -fetch(apiUrl, { credentials: 'same-origin' }); ``` - Never hardcode `/api/endpoint` - breaks with nginx prefix. ### 7. Schema Validation: Environment-Based -- **Production**: `ENVIRONMENT=production` → `extra="ignore"` (forward compatible) -- **Development/CI**: Default → `extra="forbid"` (strict validation) - ---- - -## Project Overview - -Python-based Prebid Sales Agent with: -- **MCP Server**: FastMCP tools for AI agents (via nginx at `/mcp/`) -- **Admin UI**: Google OAuth secured interface (via nginx at `/admin/` or `/tenant/`) -- **A2A Server**: python-a2a agent-to-agent communication (via nginx at `/a2a`) -- **Multi-Tenant**: Database-backed isolation with subdomain routing -- **PostgreSQL**: Production-ready with Docker deployment - -All services are accessed through the nginx proxy at **http://localhost:8000**. - ---- - -## Key Patterns - -### SQLAlchemy 2.0 (MANDATORY for new code) -```python -from sqlalchemy import select - -# Use this -stmt = select(Model).filter_by(field=value) -instance = session.scalars(stmt).first() - -# Not this (deprecated) -instance = session.query(Model).filter_by(field=value).first() -``` - -### Database JSON Fields -```python -from src.core.database.json_type import JSONType - -class MyModel(Base): - config: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) -``` - -### Import Patterns -```python -# Always use absolute imports -from src.core.schemas import Principal -from src.core.database.database_session import get_db_session -from src.adapters import get_adapter -``` - -### No Quiet Failures -```python -# ❌ WRONG - Silent failure -if not self.supports_feature: - logger.warning("Skipping...") - -# ✅ CORRECT - Explicit failure -if not self.supports_feature and feature_requested: - raise FeatureNotSupportedException("Cannot fulfill contract") -``` +- **Production**: `ENVIRONMENT=production` -> `extra="ignore"` (forward compatible) +- **Development/CI**: Default -> `extra="forbid"` (strict validation) --- -## Common Operations - -### Running Locally - -```bash -# Clone and start -git clone https://github.com/prebid/salesagent.git -cd salesagent -docker compose up -d # Build and start all services -docker compose logs -f # View logs (Ctrl+C to exit) -docker compose down # Stop - -# Migrations run automatically on startup -``` - -**Access at http://localhost:8000:** -- Admin UI: `/admin/` or `/tenant/default` -- MCP Server: `/mcp/` -- A2A Server: `/a2a` - -**Test login:** Click "Log in to Dashboard" button (password: `test123`) +## Commands -**Test MCP interface:** ```bash -uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools +make quality # Format + lint + mypy + unit tests (before every commit) +make quality-full # Above + integration/e2e with PostgreSQL +make lint-fix # Auto-fix formatting and lint issues +make test-fast # Unit tests only (fail-fast) ``` -**Note:** `docker compose` builds from local source. For a clean rebuild: `docker compose build --no-cache` - -### Testing -```bash -./run_all_tests.sh ci # Full suite with PostgreSQL (matches CI) -./run_all_tests.sh quick # Fast iteration (skips database tests) - -# Manual pytest -uv run pytest tests/unit/ # Unit tests only -uv run pytest tests/integration/ # Integration tests -uv run pytest tests/e2e/ # E2E tests - -# AdCP compliance (MANDATORY before commit) -uv run pytest tests/unit/test_adcp_contract.py -v -``` +### Git Workflow +Never push directly to main. Work on feature branches, create PR, merge via GitHub UI. ### Database Migrations ```bash -uv run python scripts/ops/migrate.py # Run migrations locally uv run alembic revision -m "description" # Create migration - -# In Docker (migrations run automatically, but can be run manually): -docker compose exec admin-ui python scripts/ops/migrate.py -``` - -**Never modify existing migrations after commit!** - -### Tenant Setup Dependencies -``` -Tenant → CurrencyLimit (USD required for budget validation) - → PropertyTag ("all_inventory" required for property_tags references) - → Products (require BOTH) -``` - ---- - -## Testing Guidelines - -### Test Organization -- **tests/unit/**: Fast, isolated (mock external deps only) -- **tests/integration/**: Real PostgreSQL database -- **tests/e2e/**: Full system tests -- **tests/ui/**: Admin UI tests - -### Database Fixtures -```python -# Integration tests - use integration_db -@pytest.mark.requires_db -def test_something(integration_db): - with get_db_session() as session: - # Test with real PostgreSQL - pass - -# Unit tests - mock the database -def test_something(): - with patch('src.core.database.database_session.get_db_session') as mock_db: - # Test with mocked database - pass -``` - -### Quality Rules -- Max 10 mocks per test file (pre-commit enforces) -- AdCP compliance test for all client-facing models -- Test YOUR code, not Python built-ins -- Never skip tests - fix the issue (`skip_ci` for rare exceptions only) -- Roundtrip test required for any operation using `apply_testing_hooks()` - -### Testing Workflow (Before Commit) -```bash -# ALL changes -uv run pytest tests/unit/ -x -python -c "from src.core.tools import your_import" # Verify imports - -# Refactorings (shared impl, moving code, imports) -uv run pytest tests/integration/ -x - -# Critical changes (protocol, schema updates) -uv run pytest tests/ -x -``` - -**Pre-commit hooks can't catch import errors** - You must run tests for refactorings! - ---- - -## Development Best Practices - -### Code Style -- Use `uv` for dependencies -- Run `pre-commit run --all-files` -- Use type hints -- No hardcoded external system IDs (use config/database) -- No testing against production systems - -### Type Checking -```bash -uv run mypy src/core/your_file.py --config-file=mypy.ini -``` - -When modifying code: -1. Fix mypy errors in files you change -2. Use SQLAlchemy 2.0 `Mapped[]` annotations for new models -3. Use `| None` instead of `Optional[]` (Python 3.10+) - ---- - -## Configuration - -### Secrets (.env.secrets - REQUIRED) -```bash -GEMINI_API_KEY=your-key -GOOGLE_CLIENT_ID=your-id.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=your-secret -SUPER_ADMIN_EMAILS=user@example.com -GAM_OAUTH_CLIENT_ID=your-gam-id.apps.googleusercontent.com -GAM_OAUTH_CLIENT_SECRET=your-gam-secret -APPROXIMATED_API_KEY=your-approximated-api-key +uv run python scripts/ops/migrate.py # Run migrations locally ``` - -### Database Schema -- **Core**: tenants, principals, products, media_buys, creatives, audit_logs -- **Workflow**: workflow_steps, object_workflow_mappings -- **Deprecated**: tasks, human_tasks (DO NOT USE) - ---- - -## Adapter Support - -### GAM Adapter -**Supported Pricing**: CPM, VCPM, CPC, FLAT_RATE - -- Automatic line item type selection based on pricing + guarantees -- FLAT_RATE → SPONSORSHIP with CPD translation -- VCPM → STANDARD only (GAM requirement) -- See `docs/adapters/` for compatibility matrix - -### Mock Adapter -**Supported**: All AdCP pricing models (CPM, VCPM, CPCV, CPP, CPC, CPV, FLAT_RATE) -- All currencies, simulates appropriate metrics -- Used for testing and development +Never modify existing migrations after commit. --- -## Deployment - -### Environments -- **Local Dev**: `docker compose up -d` → http://localhost:8000 (builds from source) -- **Production**: Deploy to your preferred hosting platform - -**Local Dev Notes:** -- Test mode enabled by default (`ADCP_AUTH_TEST_MODE=true`) -- Test credentials: Click "Log in to Dashboard" button (password: `test123`) - -### Git Workflow (MANDATORY) -**Never push directly to main** - -1. Work on feature branches: `git checkout -b feature/name` -2. Create PR: `gh pr create` -3. Merge via GitHub UI - -### Hosting Options -This app can be hosted anywhere: -- Docker (recommended) - Any Docker-compatible platform -- Kubernetes - Full k8s manifests supported -- Cloud Providers - AWS, GCP, Azure, DigitalOcean -- Platform Services - Fly.io, Heroku, Railway, Render +## Decision Tree -See `docs/deployment.md` for platform-specific guides. +**Adding a feature**: Search existing code -> Read patterns -> Design with critical patterns -> TDD (`.claude/rules/workflows/tdd-workflow.md`) -> `make quality` -> Commit ---- +**Fixing a bug**: Read code path -> Write failing test (`.claude/rules/workflows/bug-reporting.md`) -> Fix -> `make quality` -> Commit -## Documentation +**Refactoring**: Verify tests pass -> Small incremental changes -> `make quality` after each -> For imports: `python -c "from module import thing"` -> For shared impl: `uv run pytest tests/integration/ -x` -**Detailed docs in `/docs`:** -- `ARCHITECTURE.md` - System architecture -- `SETUP.md` - Initial setup guide -- `DEVELOPMENT.md` - Development workflow -- `testing/` - Testing patterns and case studies -- `TROUBLESHOOTING.md` - Common issues -- `security.md` - Security guidelines -- `deployment.md` - Deployment guides -- `adapters/` - Adapter-specific documentation +**"How does X work?"**: `Grep` for code -> Read implementation -> Check `tests/unit/test_*X*.py` -> Explain with file:line references --- -## Quick Reference - -### MCP Client -```python -from fastmcp.client import Client -from fastmcp.client.transports import StreamableHttpTransport - -headers = {"x-adcp-auth": "your_token"} -transport = StreamableHttpTransport(url="http://localhost:8000/mcp/", headers=headers) -client = Client(transport=transport) - -async with client: - products = await client.tools.get_products(brief="video ads") - result = await client.tools.create_media_buy(product_ids=["prod_1"], ...) -``` - -### CLI Testing -```bash -# List available tools -uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools - -# Get a real token from Admin UI → Advertisers → API Token -uvx adcp http://localhost:8000/mcp/ --auth get_products '{"brief":"video"}' -``` +## Self-Improvement -### Admin UI -- Local: http://localhost:8000/admin/ (or `/tenant/default`) -- Production: Configure based on your hosting +When something goes wrong (test failure you caused, pattern violation, rework): +1. Analyze what happened and why +2. Check if a CLAUDE.md rule or pattern would have prevented it +3. If yes, suggest the addition (do not modify CLAUDE.md without permission) --- -## Decision Tree for Claude - -**User asks to add a new feature:** -1. Search existing code: `Glob` for similar features -2. Read relevant files to understand patterns -3. Design solution following critical patterns -4. Write tests first (TDD) -5. Implement feature -6. Run tests: `uv run pytest tests/unit/ -x` -7. Commit with clear message - -**User reports a bug:** -1. Reproduce: Read the code path -2. Write failing test that demonstrates bug -3. Fix the code -4. Verify test passes -5. Check for similar issues in codebase -6. Commit fix with test - -**User asks "how does X work?"** -1. Search for X: Use `Grep` to find relevant code -2. Read the implementation -3. Check tests for examples: `tests/unit/test_*X*.py` -4. Explain with code references (file:line) -5. Link to relevant docs if they exist - -**User asks to refactor code:** -1. Verify tests exist and pass -2. Make small, incremental changes -3. Run tests after each change: `uv run pytest tests/unit/ -x` -4. For import changes, verify: `python -c "from module import thing"` -5. For shared implementations, run integration tests: `uv run pytest tests/integration/ -x` - -**User asks about best practices:** -1. Check this CLAUDE.md for patterns -2. Check `/docs` for detailed guidelines -3. Look at recent code for current conventions -4. When in doubt, follow the 7 critical patterns above - ---- - -## Support - -- Documentation: `/docs` directory -- Test examples: `/tests` directory -- Adapter implementations: `/src/adapters` directory -- Issues: File on GitHub repository +## Reference Docs + +**Load on demand** — read these when working in the relevant area: + +| When working on... | Read | +|---|---| +| Writing new code | `.claude/rules/patterns/code-patterns.md` | +| Writing tests | `.claude/rules/patterns/testing-patterns.md` | +| MCP/A2A tools | `.claude/rules/patterns/mcp-patterns.md` | +| Quality gates | `.claude/rules/workflows/quality-gates.md` | +| TDD workflow | `.claude/rules/workflows/tdd-workflow.md` | +| Bug fixes | `.claude/rules/workflows/bug-reporting.md` | +| Research | `.claude/rules/workflows/research-workflow.md` | +| Subagents | `.claude/rules/workflows/subagent-implementation-guide.md` | +| Adapters | `docs/adapters/` | +| Deployment | `docs/deployment/` | +| Architecture | `docs/development/architecture.md` | +| Setup | `docs/quickstart.md` | +| Troubleshooting | `docs/development/troubleshooting.md` | diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..9301f3124 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: quality quality-full lint-fix lint typecheck test-fast test-full + +quality: + uv run ruff format --check . + uv run ruff check . + uv run mypy src/ --config-file=mypy.ini + uv run pytest tests/unit/ -x + +quality-full: + $(MAKE) quality + ./run_all_tests.sh ci + +lint-fix: + uv run ruff format . + uv run ruff check --fix . + +lint: + uv run ruff check . + +typecheck: + uv run mypy src/ --config-file=mypy.ini + +test-fast: + uv run pytest tests/unit/ -x + +test-full: + ./run_all_tests.sh ci diff --git a/pyproject.toml b/pyproject.toml index 43737a271..f8141c511 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,28 +134,53 @@ exclude = [ [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "C90", # mccabe complexity + "PLR", # pylint refactor ] ignore = [ - "E501", # line too long (handled by formatter) - "E402", # module import not at top of file - "E722", # bare except - "F821", # undefined name - "F403", # star imports (TODO: fix by replacing with specific imports) - "F405", # undefined from star imports (related to F403) - "E741", # ambiguous variable names (l, I, O) - "B904", # raise without from (TODO: fix exception handling) - "B027", # empty method in abstract class (TODO: add @abstractmethod) - "F841", # unused variables (mostly fixed, remaining are complex) - "UP038", # Use (int, float) instead of int | float in isinstance for compatibility + # Justified suppressions — each must explain WHY it's ignored, not just WHAT it is. + # Do not add new entries without a justification comment. + "E501", # Handled by ruff formatter; manual enforcement would conflict + "E402", # Flask app (admin_ui.py) requires imports after app config; 3 occurrences + "E722", # 12 bare excepts in error-recovery paths (adapters, auth); TODO: add specific exceptions + "F821", # 4 false positives from conditional imports and TYPE_CHECKING blocks + "F403", # 8 star imports in __init__.py re-exports; TODO: replace with explicit imports + "F405", # Consequence of F403 — will resolve when F403 is fixed + "E741", # 3 uses of `l` variable in loops; renaming would hurt readability in math-heavy code + "B904", # 47 raise-without-from in adapter error handling; TODO: add `from e` systematically + "B027", # 6 empty methods in BaseAdapter — intentional optional interface, not abstract + "F841", # 5 remaining unused vars in complex destructuring; each reviewed, kept for clarity + "PLR2004", # 158 magic values — too noisy for signal; numeric constants in config/tests are readable + "PLR0911", # 67 functions with many returns — mostly adapter dispatch and validation; gradual fix ] +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +max-args = 5 +max-branches = 12 +max-statements = 50 + [tool.ruff.lint.per-file-ignores] -"tests/*" = ["E722", "F821"] +"tests/*" = ["E722", "F821", "PLR0912", "PLR0913", "PLR0915", "PLR1714", "C901"] "admin_ui.py" = ["E402", "E722"] +# Existing complexity violations — new files in these dirs are still checked +# Remove entries as files are refactored to comply +"src/adapters/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] +"src/admin/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] +"src/core/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] +"src/services/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] +"src/a2a_server/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] +"scripts/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] +".pre-commit-hooks/*" = ["C901"] +"examples/*" = ["C901", "PLR0913", "PLR0915"] +"src/admin/blueprints/creatives.py" = ["PLR1714"] +"src/core/tools/media_buy_create.py" = ["PLR0133"] diff --git a/scripts/deploy/run_all_services.py b/scripts/deploy/run_all_services.py index 5242704f3..bbc1e18c8 100644 --- a/scripts/deploy/run_all_services.py +++ b/scripts/deploy/run_all_services.py @@ -74,9 +74,9 @@ def check_database_health(): if db_url: try: config = DatabaseConfig.get_db_config() - print(f'Parsed host: {config.get("host", "NOT SET")}') - print(f'Parsed port: {config.get("port", "NOT SET")}') - print(f'Parsed database: {config.get("database", "NOT SET")}') + print(f"Parsed host: {config.get('host', 'NOT SET')}") + print(f"Parsed port: {config.get('port', 'NOT SET')}") + print(f"Parsed database: {config.get('database', 'NOT SET')}") except Exception as e: print(f"⚠️ Could not parse database config: {e}") diff --git a/scripts/gam_prerequisites_check.py b/scripts/gam_prerequisites_check.py index f03b312eb..c4c8edf63 100644 --- a/scripts/gam_prerequisites_check.py +++ b/scripts/gam_prerequisites_check.py @@ -8,6 +8,7 @@ Returns: Exit code 0 if all prerequisites met, 1 otherwise """ + import os import sys diff --git a/scripts/ops/migrate.py b/scripts/ops/migrate.py index b49aace8a..817db293b 100644 --- a/scripts/ops/migrate.py +++ b/scripts/ops/migrate.py @@ -4,9 +4,10 @@ import sys from pathlib import Path -from alembic import command from alembic.config import Config +from alembic import command + def run_migrations(exit_on_error=True): """Run all pending database migrations. diff --git a/src/adapters/gam/managers/creatives.py b/src/adapters/gam/managers/creatives.py index 1ed8ec937..38be9eef9 100644 --- a/src/adapters/gam/managers/creatives.py +++ b/src/adapters/gam/managers/creatives.py @@ -934,7 +934,7 @@ def _add_tracking_urls_to_creative(self, creative: dict[str, Any], asset: dict[s else: # No landing page to embed - ignore click tracker to avoid broken redirect logger.warning( - "Click tracker has {REDIRECT_URL} macro but no landing page provided. " "Click tracker ignored." + "Click tracker has {REDIRECT_URL} macro but no landing page provided. Click tracker ignored." ) elif original_destination: # Click tracker missing {REDIRECT_URL} - would lose landing page diff --git a/src/adapters/gam/managers/orders.py b/src/adapters/gam/managers/orders.py index c02dc3d9d..f1b86b50a 100644 --- a/src/adapters/gam/managers/orders.py +++ b/src/adapters/gam/managers/orders.py @@ -708,13 +708,10 @@ def log(msg): ) # If filtering removed all placeholders, keep originals (fail-safe) # This shouldn't happen if creatives have valid dimensions - else: - # No creatives in package - placeholders are optional - # Allow empty array if no format_ids and no creatives - if not creative_placeholders: - log( - " [yellow]No creatives and no format_ids - line item will have no creative placeholders[/yellow]" - ) + # No creatives in package - placeholders are optional + # Allow empty array if no format_ids and no creatives + elif not creative_placeholders: + log(" [yellow]No creatives and no format_ids - line item will have no creative placeholders[/yellow]") # Determine goal type and units goal_type = impl_config.get("primary_goal_type", "LIFETIME") @@ -805,8 +802,7 @@ def log(msg): # GAM CPD = cost per day (e.g., $10/day for 10-day campaign) if pricing_model == "flat_rate": campaign_days = (end_time - start_time).days - if campaign_days < 1: - campaign_days = 1 # Minimum 1 day for same-day campaigns + campaign_days = max(campaign_days, 1) # Minimum 1 day for same-day campaigns cpd_rate = rate / campaign_days log(f" FLAT_RATE: ${rate:,.2f} total cost / {campaign_days} days → ${cpd_rate:,.2f} CPD") diff --git a/src/adapters/gam/managers/targeting.py b/src/adapters/gam/managers/targeting.py index 57420e84d..d37db8f36 100644 --- a/src/adapters/gam/managers/targeting.py +++ b/src/adapters/gam/managers/targeting.py @@ -525,9 +525,7 @@ def _build_groups_custom_targeting_structure(self, targeting_config: dict[str, A is_exclude = criterion.get("exclude", False) if not key_id or not values: - logger.warning( - f"Skipping malformed criterion in groups targeting: " f"keyId={key_id}, values={values}" - ) + logger.warning(f"Skipping malformed criterion in groups targeting: keyId={key_id}, values={values}") continue # Resolve values to GAM value IDs diff --git a/src/adapters/gam_reporting_service.py b/src/adapters/gam_reporting_service.py index 3577fa5a1..4c9aa0e95 100644 --- a/src/adapters/gam_reporting_service.py +++ b/src/adapters/gam_reporting_service.py @@ -202,30 +202,29 @@ def _get_report_config( start_date = (now - timedelta(days=90)).replace(hour=0, minute=0, second=0, microsecond=0) end_date = now granularity = "total" - else: - # Include DATE dimension for time-series data - if date_range == "today": - # Today by hour - need both DATE and HOUR dimensions for hourly reporting - dimensions = ["DATE", "HOUR"] + base_dimensions - start_date = now.replace(hour=0, minute=0, second=0, microsecond=0) - end_date = now - granularity = "hourly" - elif date_range == "this_month": - # This month by day - dimensions = ["DATE"] + base_dimensions - start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - end_date = now - granularity = "daily" - else: # lifetime - # Lifetime by day - limit based on whether we're getting detailed dimensions - dimensions = ["DATE"] + base_dimensions - # Reduce to 30 days if we have ad unit or country dimensions to avoid timeouts - if include_country or include_ad_unit: - start_date = (now - timedelta(days=30)).replace(hour=0, minute=0, second=0, microsecond=0) - else: - start_date = (now - timedelta(days=90)).replace(hour=0, minute=0, second=0, microsecond=0) - end_date = now - granularity = "daily" + # Include DATE dimension for time-series data + elif date_range == "today": + # Today by hour - need both DATE and HOUR dimensions for hourly reporting + dimensions = ["DATE", "HOUR"] + base_dimensions + start_date = now.replace(hour=0, minute=0, second=0, microsecond=0) + end_date = now + granularity = "hourly" + elif date_range == "this_month": + # This month by day + dimensions = ["DATE"] + base_dimensions + start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end_date = now + granularity = "daily" + else: # lifetime + # Lifetime by day - limit based on whether we're getting detailed dimensions + dimensions = ["DATE"] + base_dimensions + # Reduce to 30 days if we have ad unit or country dimensions to avoid timeouts + if include_country or include_ad_unit: + start_date = (now - timedelta(days=30)).replace(hour=0, minute=0, second=0, microsecond=0) + else: + start_date = (now - timedelta(days=90)).replace(hour=0, minute=0, second=0, microsecond=0) + end_date = now + granularity = "daily" return dimensions, start_date, end_date, granularity @@ -584,12 +583,11 @@ def _calculate_data_validity(self, date_range: str, requested_tz: str = "America else: # Yesterday's data should be complete data_valid_until = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59) - else: # lifetime - # Same as this_month for the most recent data - if now.hour < 7: - data_valid_until = (now - timedelta(days=2)).replace(hour=23, minute=59, second=59) - else: - data_valid_until = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59) + # Same as this_month for the most recent data + elif now.hour < 7: + data_valid_until = (now - timedelta(days=2)).replace(hour=23, minute=59, second=59) + else: + data_valid_until = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59) return data_valid_until diff --git a/src/adapters/test_scenario_parser.py b/src/adapters/test_scenario_parser.py index d0746b050..164bb54f5 100644 --- a/src/adapters/test_scenario_parser.py +++ b/src/adapters/test_scenario_parser.py @@ -25,6 +25,8 @@ class TestScenario: """Parsed test scenario from keyword parsing.""" + __test__ = False # Not a pytest test class — prevents PytestCollectionWarning + # Timing control delay_seconds: int | None = None use_async: bool = False diff --git a/src/admin/app.py b/src/admin/app.py index 292e94351..be26e8cf8 100644 --- a/src/admin/app.py +++ b/src/admin/app.py @@ -298,9 +298,7 @@ def log_auth_cookies(response): parts = cookie.split(";") cookie_name = parts[0].split("=")[0] if parts else "unknown" attrs = "; ".join(p.strip() for p in parts[1:] if p.strip()) - logger.warning( - f"[SESSION_DEBUG] Set-Cookie on {request.path}: " f"name={cookie_name}, attrs=[{attrs}]" - ) + logger.warning(f"[SESSION_DEBUG] Set-Cookie on {request.path}: name={cookie_name}, attrs=[{attrs}]") else: # Only log if session was modified from flask import session diff --git a/src/admin/blueprints/inventory.py b/src/admin/blueprints/inventory.py index bd212d03a..854630b97 100644 --- a/src/admin/blueprints/inventory.py +++ b/src/admin/blueprints/inventory.py @@ -1350,8 +1350,7 @@ def size_sort_key(s): sorted_sizes = sorted(sizes, key=size_sort_key) logger.info( - f"Extracted {len(sorted_sizes)} unique sizes from " - f"{len(items)} inventory items for tenant {tenant_id}" + f"Extracted {len(sorted_sizes)} unique sizes from {len(items)} inventory items for tenant {tenant_id}" ) return jsonify({"sizes": sorted_sizes, "count": len(sorted_sizes)}) diff --git a/src/admin/blueprints/products.py b/src/admin/blueprints/products.py index 3e3b9a11d..33d4bdd86 100644 --- a/src/admin/blueprints/products.py +++ b/src/admin/blueprints/products.py @@ -455,7 +455,9 @@ def list_products(tenant_id): formats_data = ( product.format_ids if isinstance(product.format_ids, list) - else json.loads(product.format_ids) if product.format_ids else [] + else json.loads(product.format_ids) + if product.format_ids + else [] ) # Debug: Log raw formats data @@ -531,12 +533,16 @@ def list_products(tenant_id): "countries": ( product.countries if isinstance(product.countries, list) - else json.loads(product.countries) if product.countries else [] + else json.loads(product.countries) + if product.countries + else [] ), "implementation_config": ( product.implementation_config if isinstance(product.implementation_config, dict) - else json.loads(product.implementation_config) if product.implementation_config else {} + else json.loads(product.implementation_config) + if product.implementation_config + else {} ), "created_at": product.created_at if hasattr(product, "created_at") else None, "inventory_details": inventory_details.get( @@ -1964,14 +1970,18 @@ def edit_product(tenant_id, product_id): implementation_config = ( product.implementation_config if isinstance(product.implementation_config, dict) - else json.loads(product.implementation_config) if product.implementation_config else {} + else json.loads(product.implementation_config) + if product.implementation_config + else {} ) # Parse targeting_template - build from implementation_config if not set targeting_template = ( product.targeting_template if isinstance(product.targeting_template, dict) - else json.loads(product.targeting_template) if product.targeting_template else {} + else json.loads(product.targeting_template) + if product.targeting_template + else {} ) # If targeting_template doesn't have key_value_pairs but implementation_config has custom_targeting_keys, @@ -1990,12 +2000,16 @@ def edit_product(tenant_id, product_id): "formats": ( product.format_ids if isinstance(product.format_ids, list) - else json.loads(product.format_ids) if product.format_ids else [] + else json.loads(product.format_ids) + if product.format_ids + else [] ), "countries": ( product.countries if isinstance(product.countries, list) - else json.loads(product.countries) if product.countries else [] + else json.loads(product.countries) + if product.countries + else [] ), "implementation_config": implementation_config, "targeting_template": targeting_template, diff --git a/src/admin/blueprints/publisher_partners.py b/src/admin/blueprints/publisher_partners.py index 9ea61a14b..a74d30531 100644 --- a/src/admin/blueprints/publisher_partners.py +++ b/src/admin/blueprints/publisher_partners.py @@ -307,9 +307,7 @@ def sync_publisher_partners(tenant_id: str) -> Response | tuple[Response, int]: session.commit() else: - logger.info( - f"Fetched real properties from {domain}: " f"{domain_properties_created} created" - ) + logger.info(f"Fetched real properties from {domain}: {domain_properties_created} created") return jsonify( { diff --git a/src/admin/tests/integration/test_admin_app.py b/src/admin/tests/integration/test_admin_app.py index 1ce55e968..e3949b431 100644 --- a/src/admin/tests/integration/test_admin_app.py +++ b/src/admin/tests/integration/test_admin_app.py @@ -182,9 +182,7 @@ def test_tenant_dashboard(self, mock_get_db_session, mock_require_tenant_access, mock_session.query.return_value.filter_by.return_value.first.return_value = mock_tenant mock_session.query.return_value.filter_by.return_value.count.return_value = 0 mock_session.query.return_value.filter_by.return_value.filter.return_value.all.return_value = [] - mock_session.query.return_value.join.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = ( - [] - ) + mock_session.query.return_value.join.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [] response = client.get("/tenant/tenant_123") # Will redirect due to decorator, but shows route exists diff --git a/src/core/auth.py b/src/core/auth.py index 122a181c6..afdccc08b 100644 --- a/src/core/auth.py +++ b/src/core/auth.py @@ -65,9 +65,8 @@ def get_principal_from_token(token: str, tenant_id: str | None = None) -> str | logger.debug("Token not found in tenant '%s'", tenant_id) return None - else: - if _VERBOSE_AUTH_LOG: - logger.info("Found principal '%s' in tenant '%s'", principal.principal_id, tenant_id) + elif _VERBOSE_AUTH_LOG: + logger.info("Found principal '%s' in tenant '%s'", principal.principal_id, tenant_id) else: # No tenant specified - search globally by token logger.debug("No tenant specified - searching globally by token") diff --git a/src/core/creative_agent_registry.py b/src/core/creative_agent_registry.py index a78c12e3a..f19dd7a47 100644 --- a/src/core/creative_agent_registry.py +++ b/src/core/creative_agent_registry.py @@ -320,7 +320,7 @@ async def _fetch_formats_from_agent( error_msg = ( getattr(result, "error", None) or getattr(result, "message", None) or "No error details provided" ) - logger.error(f"Creative agent {agent.name} returned FAILED status. " f"Error: {error_msg}") + logger.error(f"Creative agent {agent.name} returned FAILED status. Error: {error_msg}") debug_info = getattr(result, "debug_info", None) if debug_info: logger.debug(f"Debug info: {debug_info}") diff --git a/src/core/main.py b/src/core/main.py index ae564eadf..6c016333d 100644 --- a/src/core/main.py +++ b/src/core/main.py @@ -213,9 +213,7 @@ def load_tasks_from_db(): # --- Adapter Configuration --- # Get adapter from config, fallback to mock -SELECTED_ADAPTER = ( - (config.get("ad_server", {}).get("adapter") or "mock") if config else "mock" -).lower() # noqa: F841 - used below for adapter selection +SELECTED_ADAPTER = ((config.get("ad_server", {}).get("adapter") or "mock") if config else "mock").lower() # noqa: F841 - used below for adapter selection AVAILABLE_ADAPTERS = ["mock", "gam", "kevel", "triton", "triton_digital"] # --- In-Memory State (already initialized above, just adding context_map) --- diff --git a/src/core/schemas.py b/src/core/schemas.py index eb1ab8744..980c1d3ae 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -67,6 +67,11 @@ CppPricingOption, CpvPricingOption, FlatRatePricingOption, + GeoCountry, + GeoMetro, + GeoPostalArea, + GeoRegion, + TargetingOverlay, VcpmPricingOption, # V3: consolidated from VcpmAuctionPricingOption/VcpmFixedRatePricingOption ) @@ -74,6 +79,9 @@ from adcp.types import CreativeAsset as LibraryCreativeAsset from adcp.types import CreativeAssignment as LibraryCreativeAssignment from adcp.types import DeliveryMeasurement as LibraryDeliveryMeasurement + +# V3: Structured geo targeting types +from adcp.types import FrequencyCap as LibraryFrequencyCap from adcp.types import Measurement as LibraryMeasurement from adcp.types import Product as LibraryProduct from adcp.types import Property as LibraryProperty @@ -837,14 +845,13 @@ def convert_format_ids_to_formats(format_ids: list[str], tenant_id: str | None = return formats -class FrequencyCap(BaseModel): - """Simple frequency capping configuration. +class FrequencyCap(LibraryFrequencyCap): + """Frequency capping extending AdCP library type with scope. - Provides basic impression suppression at the media buy or package level. - More sophisticated frequency management is handled by the AXE layer. + Inherits suppress_minutes: float from library. + Adds scope field for media buy vs package level capping. """ - suppress_minutes: int = Field(..., gt=0, description="Suppress impressions for this many minutes after serving") scope: Literal["media_buy", "package"] = Field("media_buy", description="Apply at media buy or package level") @@ -858,32 +865,36 @@ class TargetingCapability(BaseModel): axe_signal: bool | None = False # Whether this is an AXE signal dimension -class Targeting(BaseModel): - """Comprehensive targeting options for media buys. +class Targeting(TargetingOverlay): + """Targeting extending AdCP TargetingOverlay with internal dimensions. - All fields are optional and can be combined for precise audience targeting. - Platform adapters will map these to their specific targeting capabilities. - Uses any_of/none_of pattern for consistent include/exclude across all dimensions. + Inherits v3 structured geo fields from library: + - geo_countries, geo_regions, geo_metros, geo_postal_areas + - frequency_cap, axe_include_segment, axe_exclude_segment - Note: Some targeting dimensions are managed-only and cannot be set via overlay. - These are typically used for AXE signal integration. + Adds exclusion extensions, internal dimensions, and a legacy normalizer + that converts flat DB fields to v3 structured format. """ - # Geographic targeting - aligned with OpenRTB (overlay access) - geo_country_any_of: list[str] | None = None # ISO country codes: ["US", "CA", "GB"] - geo_country_none_of: list[str] | None = None - - geo_region_any_of: list[str] | None = None # Region codes: ["NY", "CA", "ON"] - geo_region_none_of: list[str] | None = None + # --- Inherited from TargetingOverlay (7 fields): --- + # geo_countries: list[GeoCountry] | None + # geo_regions: list[GeoRegion] | None + # geo_metros: list[GeoMetro] | None + # geo_postal_areas: list[GeoPostalArea] | None + # frequency_cap: FrequencyCap | None (overridden below) + # axe_include_segment: str | None + # axe_exclude_segment: str | None - geo_metro_any_of: list[str] | None = None # Metro/DMA codes: ["501", "803"] - geo_metro_none_of: list[str] | None = None + # Override frequency_cap to use our extended FrequencyCap with scope + frequency_cap: FrequencyCap | None = None - geo_city_any_of: list[str] | None = None # City names: ["New York", "Los Angeles"] - geo_city_none_of: list[str] | None = None + # --- Geo exclusion extensions (not in library) --- + geo_countries_exclude: list[GeoCountry] | None = None + geo_regions_exclude: list[GeoRegion] | None = None + geo_metros_exclude: list[GeoMetro] | None = None + geo_postal_areas_exclude: list[GeoPostalArea] | None = None - geo_zip_any_of: list[str] | None = None # Postal codes: ["10001", "90210"] - geo_zip_none_of: list[str] | None = None + # --- Internal dimensions (unchanged) --- # Device and platform targeting device_type_any_of: list[str] | None = None # ["mobile", "desktop", "tablet", "ctv", "audio", "dooh"] @@ -913,13 +924,6 @@ class Targeting(BaseModel): media_type_any_of: list[str] | None = None # ["video", "audio", "display", "native"] media_type_none_of: list[str] | None = None - # Frequency control - frequency_cap: FrequencyCap | None = None # Impression limits per user/period - - # AXE segment targeting (AdCP 3.0.3) - axe_include_segment: str | None = None # AXE segment ID to include for targeting - axe_exclude_segment: str | None = None # AXE segment ID to exclude from targeting - # Connection type targeting connection_type_any_of: list[int] | None = None # OpenRTB connection types connection_type_none_of: list[int] | None = None @@ -937,6 +941,147 @@ class Targeting(BaseModel): updated_at: datetime | None = Field(None, description="Internal: Last update timestamp") metadata: dict[str, Any] | None = Field(None, description="Internal: Additional metadata") + @model_validator(mode="before") + @classmethod + def normalize_legacy_geo(cls, values: Any) -> Any: + """Convert flat DB geo fields to v3 structured format. + + Handles reconstruction from legacy database JSON where fields were stored as: + - geo_country_any_of: ["US", "CA"] → geo_countries: [GeoCountry("US"), ...] + - geo_region_any_of: ["CA", "NY"] → geo_regions: [GeoRegion("US-CA"), ...] (if already ISO 3166-2) + - geo_metro_any_of: ["501"] → geo_metros: [{system: "nielsen_dma", values: ["501"]}] + - geo_zip_any_of: ["10001"] → geo_postal_areas: [{system: "us_zip", values: ["10001"]}] + - *_none_of variants → *_exclude variants + """ + if not isinstance(values, dict): + return values + + # Country: flat list → GeoCountry strings + if "geo_country_any_of" in values and "geo_countries" not in values: + v = values.pop("geo_country_any_of") + if v: + values["geo_countries"] = v # GeoCountry is RootModel[str], accepts plain strings + if "geo_country_none_of" in values and "geo_countries_exclude" not in values: + v = values.pop("geo_country_none_of") + if v: + values["geo_countries_exclude"] = v + + # Region: flat list → GeoRegion strings (assumes already ISO 3166-2 format) + if "geo_region_any_of" in values and "geo_regions" not in values: + v = values.pop("geo_region_any_of") + if v: + values["geo_regions"] = v # GeoRegion is RootModel[str] + if "geo_region_none_of" in values and "geo_regions_exclude" not in values: + v = values.pop("geo_region_none_of") + if v: + values["geo_regions_exclude"] = v + + # Metro: flat list → structured {system, values} + if "geo_metro_any_of" in values and "geo_metros" not in values: + v = values.pop("geo_metro_any_of") + if v: + values["geo_metros"] = [{"system": "nielsen_dma", "values": v}] + if "geo_metro_none_of" in values and "geo_metros_exclude" not in values: + v = values.pop("geo_metro_none_of") + if v: + values["geo_metros_exclude"] = [{"system": "nielsen_dma", "values": v}] + + # Zip/Postal: flat list → structured {system, values} + if "geo_zip_any_of" in values and "geo_postal_areas" not in values: + v = values.pop("geo_zip_any_of") + if v: + values["geo_postal_areas"] = [{"system": "us_zip", "values": v}] + if "geo_zip_none_of" in values and "geo_postal_areas_exclude" not in values: + v = values.pop("geo_zip_none_of") + if v: + values["geo_postal_areas_exclude"] = [{"system": "us_zip", "values": v}] + + # Remove city fields (no longer supported, no adapter ever used them) + values.pop("geo_city_any_of", None) + values.pop("geo_city_none_of", None) + + return values + + # --- Backward-compatible properties for adapters (remove after salesagent-oee/fwm) --- + + @property + def geo_country_any_of(self) -> list[str] | None: + """Legacy accessor: extract flat country codes from geo_countries.""" + if not self.geo_countries: + return None + return [c.root if hasattr(c, "root") else str(c) for c in self.geo_countries] + + @property + def geo_country_none_of(self) -> list[str] | None: + """Legacy accessor: extract flat country codes from geo_countries_exclude.""" + if not self.geo_countries_exclude: + return None + return [c.root if hasattr(c, "root") else str(c) for c in self.geo_countries_exclude] + + @property + def geo_region_any_of(self) -> list[str] | None: + """Legacy accessor: extract flat region codes from geo_regions.""" + if not self.geo_regions: + return None + return [r.root if hasattr(r, "root") else str(r) for r in self.geo_regions] + + @property + def geo_region_none_of(self) -> list[str] | None: + """Legacy accessor: extract flat region codes from geo_regions_exclude.""" + if not self.geo_regions_exclude: + return None + return [r.root if hasattr(r, "root") else str(r) for r in self.geo_regions_exclude] + + @property + def geo_metro_any_of(self) -> list[str] | None: + """Legacy accessor: flatten metro values from geo_metros.""" + if not self.geo_metros: + return None + result = [] + for metro in self.geo_metros: + result.extend(metro.values) + return result or None + + @property + def geo_metro_none_of(self) -> list[str] | None: + """Legacy accessor: flatten metro values from geo_metros_exclude.""" + if not self.geo_metros_exclude: + return None + result = [] + for metro in self.geo_metros_exclude: + result.extend(metro.values) + return result or None + + @property + def geo_zip_any_of(self) -> list[str] | None: + """Legacy accessor: flatten postal values from geo_postal_areas.""" + if not self.geo_postal_areas: + return None + result = [] + for area in self.geo_postal_areas: + result.extend(area.values) + return result or None + + @property + def geo_zip_none_of(self) -> list[str] | None: + """Legacy accessor: flatten postal values from geo_postal_areas_exclude.""" + if not self.geo_postal_areas_exclude: + return None + result = [] + for area in self.geo_postal_areas_exclude: + result.extend(area.values) + return result or None + + @property + def geo_city_any_of(self) -> None: + """Legacy accessor: city targeting removed in v3.""" + return None + + @property + def geo_city_none_of(self) -> None: + """Legacy accessor: city targeting removed in v3.""" + return None + def model_dump(self, **kwargs): """Override to provide AdCP-compliant responses while preserving internal fields.""" # Default to excluding internal and managed fields for AdCP compliance @@ -1250,19 +1395,18 @@ def serialize_format_ids_for_json(self, format_ids: list) -> list: result.append(FormatId(agent_url=url(DEFAULT_AGENT_URL), id=fmt["id"])) else: raise ValueError(f"Invalid format dict: {fmt}") - else: - # Other object types (like FormatReference) - if hasattr(fmt, "agent_url") and hasattr(fmt, "id"): - result.append(FormatId(agent_url=url(str(fmt.agent_url)), id=fmt.id)) - elif hasattr(fmt, "format_id"): - from src.core.format_cache import upgrade_legacy_format_id + # Other object types (like FormatReference) + elif hasattr(fmt, "agent_url") and hasattr(fmt, "id"): + result.append(FormatId(agent_url=url(str(fmt.agent_url)), id=fmt.id)) + elif hasattr(fmt, "format_id"): + from src.core.format_cache import upgrade_legacy_format_id - try: - result.append(upgrade_legacy_format_id(fmt.format_id)) - except ValueError: - result.append(FormatId(agent_url=url(DEFAULT_AGENT_URL), id=fmt.format_id)) - else: - raise ValueError(f"Cannot serialize format: {fmt}") + try: + result.append(upgrade_legacy_format_id(fmt.format_id)) + except ValueError: + result.append(FormatId(agent_url=url(DEFAULT_AGENT_URL), id=fmt.format_id)) + else: + raise ValueError(f"Cannot serialize format: {fmt}") return result diff --git a/src/core/tools/products.py b/src/core/tools/products.py index aea1202ef..ad3496dc7 100644 --- a/src/core/tools/products.py +++ b/src/core/tools/products.py @@ -206,15 +206,14 @@ async def _get_products_impl( elif hasattr(brand_manifest_unwrapped, "__str__") and str(brand_manifest_unwrapped).startswith("http"): # brand_manifest is AnyUrl object from Pydantic offering = f"Brand at {brand_manifest_unwrapped}" - else: - # brand_manifest is a BrandManifest object or dict - # Per AdCP spec: either name OR url is required - if hasattr(brand_manifest_unwrapped, "name") and brand_manifest_unwrapped.name: - offering = brand_manifest_unwrapped.name - elif hasattr(brand_manifest_unwrapped, "url") and brand_manifest_unwrapped.url: - offering = f"Brand at {brand_manifest_unwrapped.url}" - elif isinstance(brand_manifest_unwrapped, dict): - offering = brand_manifest_unwrapped.get("name") or brand_manifest_unwrapped.get("url", "") + # brand_manifest is a BrandManifest object or dict + # Per AdCP spec: either name OR url is required + elif hasattr(brand_manifest_unwrapped, "name") and brand_manifest_unwrapped.name: + offering = brand_manifest_unwrapped.name + elif hasattr(brand_manifest_unwrapped, "url") and brand_manifest_unwrapped.url: + offering = f"Brand at {brand_manifest_unwrapped.url}" + elif isinstance(brand_manifest_unwrapped, dict): + offering = brand_manifest_unwrapped.get("name") or brand_manifest_unwrapped.get("url", "") # Check brand_manifest_policy from tenant settings brand_manifest_policy = tenant.get("brand_manifest_policy", "require_auth") @@ -755,7 +754,9 @@ async def _get_products_impl( is_supported = pricing_model in supported_models inner.supported = is_supported # type: ignore[union-attr] if not is_supported: - inner.unsupported_reason = f"Current adapter does not support {pricing_model.upper()} pricing" # type: ignore[union-attr] + inner.unsupported_reason = ( # type: ignore[union-attr] + f"Current adapter does not support {pricing_model.upper()} pricing" # type: ignore[union-attr] + ) except Exception as e: logger.warning(f"Failed to annotate pricing options with adapter support: {e}") diff --git a/src/core/tools/properties.py b/src/core/tools/properties.py index 35e1a555a..c7a3e6491 100644 --- a/src/core/tools/properties.py +++ b/src/core/tools/properties.py @@ -110,7 +110,7 @@ def _list_authorized_properties_impl( if not publisher_domains: empty_response_data: dict[str, Any] = {"publisher_domains": []} empty_response_data["portfolio_description"] = ( - "No publisher partnerships are currently configured. " "Publishers can be added via the Admin UI." + "No publisher partnerships are currently configured. Publishers can be added via the Admin UI." ) response = ListAuthorizedPropertiesResponse(**empty_response_data) diff --git a/src/core/validation_helpers.py b/src/core/validation_helpers.py index 59dc7bf54..5d326947e 100644 --- a/src/core/validation_helpers.py +++ b/src/core/validation_helpers.py @@ -140,7 +140,7 @@ def format_validation_error(validation_error: ValidationError, context: str = "r except (TypeError, ValueError): input_repr = repr(input_val) error_details.append( - f" • {field_path}: Extra field not allowed by AdCP spec.\n" f" Received value: {input_repr}" + f" • {field_path}: Extra field not allowed by AdCP spec.\n Received value: {input_repr}" ) else: error_details.append(f" • {field_path}: Extra field not allowed by AdCP spec") diff --git a/src/services/ai/agents/review_agent.py b/src/services/ai/agents/review_agent.py index 8fb365740..0fdc497ba 100644 --- a/src/services/ai/agents/review_agent.py +++ b/src/services/ai/agents/review_agent.py @@ -116,7 +116,7 @@ async def review_creative_async( result = await agent.run(prompt) # pydantic-ai 1.x uses .output for structured data output = result.output - logger.info(f"Creative review result: decision={output.decision}, " f"confidence={output.confidence}") + logger.info(f"Creative review result: decision={output.decision}, confidence={output.confidence}") return output except Exception as e: diff --git a/src/services/dynamic_pricing_service.py b/src/services/dynamic_pricing_service.py index 9c75b236b..6bd714569 100644 --- a/src/services/dynamic_pricing_service.py +++ b/src/services/dynamic_pricing_service.py @@ -253,32 +253,31 @@ def _update_pricing_options(self, product: Product, pricing: dict) -> None: new_guidance = PriceGuidance(p25=None, p50=None, p75=updated_p75, p90=None) cpm_option.price_guidance = new_guidance # type: ignore[union-attr] logger.debug(f"Updated existing CPM pricing option for {product.product_id}") - else: - # Create new CPM pricing option with price_guidance - # V3: floor_price at top level, price_guidance only for percentiles - if floor_cpm is not None: - price_guidance_obj = ( - PriceGuidance( - p25=None, - p50=None, - p75=recommended_cpm, # p75 is the recommended value - p90=None, - ) - if recommended_cpm is not None - else None + # Create new CPM pricing option with price_guidance + # V3: floor_price at top level, price_guidance only for percentiles + elif floor_cpm is not None: + price_guidance_obj = ( + PriceGuidance( + p25=None, + p50=None, + p75=recommended_cpm, # p75 is the recommended value + p90=None, ) + if recommended_cpm is not None + else None + ) - new_option = PricingOption( # type: ignore[call-arg] - pricing_option_id=f"{product.product_id}_dynamic_cpm", - pricing_model=PricingModel.CPM, - floor_price=floor_cpm, # V3: floor moved to top-level - currency=pricing.get("currency", "USD"), - price_guidance=price_guidance_obj, - min_spend_per_package=None, - supported=None, - unsupported_reason=None, - ) - # Pydantic validates PricingOption against discriminated union at runtime - # mypy doesn't understand this is compatible with CpmPricingOption - product.pricing_options.append(new_option) # type: ignore[arg-type] - logger.debug(f"Created new CPM pricing option for {product.product_id}") + new_option = PricingOption( # type: ignore[call-arg] + pricing_option_id=f"{product.product_id}_dynamic_cpm", + pricing_model=PricingModel.CPM, + floor_price=floor_cpm, # V3: floor moved to top-level + currency=pricing.get("currency", "USD"), + price_guidance=price_guidance_obj, + min_spend_per_package=None, + supported=None, + unsupported_reason=None, + ) + # Pydantic validates PricingOption against discriminated union at runtime + # mypy doesn't understand this is compatible with CpmPricingOption + product.pricing_options.append(new_option) # type: ignore[arg-type] + logger.debug(f"Created new CPM pricing option for {product.product_id}") diff --git a/src/services/media_buy_status_scheduler.py b/src/services/media_buy_status_scheduler.py index 0c376a669..d36dd71e2 100644 --- a/src/services/media_buy_status_scheduler.py +++ b/src/services/media_buy_status_scheduler.py @@ -93,9 +93,7 @@ async def _update_statuses(self) -> None: old_status = media_buy.status media_buy.status = new_status updated_count += 1 - logger.info( - f"Updated media buy {media_buy.media_buy_id} status: " f"{old_status} -> {new_status}" - ) + logger.info(f"Updated media buy {media_buy.media_buy_id} status: {old_status} -> {new_status}") if updated_count > 0: session.commit() diff --git a/src/services/policy_service.py b/src/services/policy_service.py index 3a28e9395..ac8412eca 100644 --- a/src/services/policy_service.py +++ b/src/services/policy_service.py @@ -433,20 +433,19 @@ def _update_currencies(session, tenant_id: str, currencies: list[CurrencyLimitDa # Delete currency if currency_data.currency_code in existing_limits: session.delete(existing_limits[currency_data.currency_code]) + # Update or create currency + elif currency_data.currency_code in existing_limits: + # Update existing + limit = existing_limits[currency_data.currency_code] + limit.min_package_budget = currency_data.min_package_budget + limit.max_daily_package_spend = currency_data.max_daily_package_spend + limit.updated_at = datetime.now(UTC) else: - # Update or create currency - if currency_data.currency_code in existing_limits: - # Update existing - limit = existing_limits[currency_data.currency_code] - limit.min_package_budget = currency_data.min_package_budget - limit.max_daily_package_spend = currency_data.max_daily_package_spend - limit.updated_at = datetime.now(UTC) - else: - # Create new - limit = CurrencyLimit( - tenant_id=tenant_id, - currency_code=currency_data.currency_code, - min_package_budget=currency_data.min_package_budget, - max_daily_package_spend=currency_data.max_daily_package_spend, - ) - session.add(limit) + # Create new + limit = CurrencyLimit( + tenant_id=tenant_id, + currency_code=currency_data.currency_code, + min_package_budget=currency_data.min_package_budget, + max_daily_package_spend=currency_data.max_daily_package_spend, + ) + session.add(limit) diff --git a/tests/e2e/test_a2a_regression_prevention.py b/tests/e2e/test_a2a_regression_prevention.py index f053b189e..5118a0ca1 100644 --- a/tests/e2e/test_a2a_regression_prevention.py +++ b/tests/e2e/test_a2a_regression_prevention.py @@ -207,9 +207,9 @@ def test_tool_context_creation_method_exists(self): handler = AdCPRequestHandler() # Method should exist - assert hasattr( - handler, "_create_tool_context_from_a2a" - ), "Handler should have _create_tool_context_from_a2a method" + assert hasattr(handler, "_create_tool_context_from_a2a"), ( + "Handler should have _create_tool_context_from_a2a method" + ) assert callable(handler._create_tool_context_from_a2a), "_create_tool_context_from_a2a should be callable" @@ -242,9 +242,9 @@ def test_no_redirect_on_agent_card_endpoints(self): if response.status_code == 200: # Should be 200, not a redirect (301, 302, etc.) - assert ( - 200 <= response.status_code < 300 - ), f"Endpoint {endpoint} returned redirect: {response.status_code}" + assert 200 <= response.status_code < 300, ( + f"Endpoint {endpoint} returned redirect: {response.status_code}" + ) # Should return JSON assert response.headers.get("content-type", "").startswith("application/json") diff --git a/tests/e2e/test_a2a_webhook_payload_types.py b/tests/e2e/test_a2a_webhook_payload_types.py index 661277308..dcbe051c7 100644 --- a/tests/e2e/test_a2a_webhook_payload_types.py +++ b/tests/e2e/test_a2a_webhook_payload_types.py @@ -458,9 +458,9 @@ async def test_webhook_payload_type_matches_status( if status in final_states: assert payload_type == "Task", f"Final state '{status}' should use Task payload, got {payload_type}" elif status in intermediate_states: - assert ( - payload_type == "TaskStatusUpdateEvent" - ), f"Intermediate state '{status}' should use TaskStatusUpdateEvent payload, got {payload_type}" + assert payload_type == "TaskStatusUpdateEvent", ( + f"Intermediate state '{status}' should use TaskStatusUpdateEvent payload, got {payload_type}" + ) # Unknown states are logged but not asserted diff --git a/tests/e2e/test_delivery_webhooks_e2e.py b/tests/e2e/test_delivery_webhooks_e2e.py index 08f70662b..2af00ac78 100644 --- a/tests/e2e/test_delivery_webhooks_e2e.py +++ b/tests/e2e/test_delivery_webhooks_e2e.py @@ -287,20 +287,20 @@ async def test_daily_delivery_webhook_end_to_end( sleep(poll_interval) elapsed += poll_interval - assert ( - received - ), "Expected at least one delivery report webhook. Check connectivity and DELIVERY_WEBHOOK_INTERVAL." + assert received, ( + "Expected at least one delivery report webhook. Check connectivity and DELIVERY_WEBHOOK_INTERVAL." + ) if received: webhook_payload = received[0] # Verify webhook payload structure (MCP webhook format) - assert ( - webhook_payload.get("status") == "completed" - ), f"Expected status 'completed', got {webhook_payload.get('status')}" - assert ( - webhook_payload.get("task_id") == media_buy_id - ), f"Expected task_id '{media_buy_id}', got {webhook_payload.get('task_id')}" + assert webhook_payload.get("status") == "completed", ( + f"Expected status 'completed', got {webhook_payload.get('status')}" + ) + assert webhook_payload.get("task_id") == media_buy_id, ( + f"Expected task_id '{media_buy_id}', got {webhook_payload.get('task_id')}" + ) assert "timestamp" in webhook_payload, "Missing timestamp in webhook payload" result = webhook_payload.get("result") or {} @@ -312,7 +312,7 @@ async def test_daily_delivery_webhook_end_to_end( assert media_buy_deliveries[0]["media_buy_id"] == media_buy_id # Verify scheduling metadata - assert ( - result.get("notification_type") == "scheduled" - ), f"Expected notification_type 'scheduled', got {result.get('notification_type')}" + assert result.get("notification_type") == "scheduled", ( + f"Expected notification_type 'scheduled', got {result.get('notification_type')}" + ) assert "next_expected_at" in result, "Missing next_expected_at in result" diff --git a/tests/e2e/test_landing_pages.py b/tests/e2e/test_landing_pages.py index 21c08e4d0..bc98961b2 100644 --- a/tests/e2e/test_landing_pages.py +++ b/tests/e2e/test_landing_pages.py @@ -101,9 +101,9 @@ def test_landing_page_contains_mcp_endpoint(self): has_mcp = 'href="/mcp' in content or "mcp endpoint" in content is_pending = "pending configuration" in content or "not configured" in content - assert ( - has_mcp or is_pending - ), "Landing page should either show MCP endpoint or pending configuration message" + assert has_mcp or is_pending, ( + "Landing page should either show MCP endpoint or pending configuration message" + ) except (requests.ConnectionError, requests.Timeout): pytest.skip(f"Server not running at {base_url}") @@ -132,9 +132,9 @@ def test_landing_page_contains_a2a_endpoint(self): has_a2a = 'href="/a2a' in content or "a2a endpoint" in content is_pending = "pending configuration" in content or "not configured" in content - assert ( - has_a2a or is_pending - ), "Landing page should either show A2A endpoint or pending configuration message" + assert has_a2a or is_pending, ( + "Landing page should either show A2A endpoint or pending configuration message" + ) except (requests.ConnectionError, requests.Timeout): pytest.skip(f"Server not running at {base_url}") @@ -158,9 +158,9 @@ def test_approximated_header_precedence_for_admin(self): ) # Should route based on Apx-Incoming-Host (admin domain -> login redirect) - assert ( - response.status_code == 302 - ), f"Proxied admin domain should redirect to login (302), got {response.status_code}" + assert response.status_code == 302, ( + f"Proxied admin domain should redirect to login (302), got {response.status_code}" + ) location = response.headers.get("Location", "") assert "/login" in location, f"Proxied admin domain should redirect to /login, got {location}" @@ -234,9 +234,9 @@ def test_list_creative_formats_with_auth(self): ) # Should succeed with auth - assert ( - response.status_code == 200 - ), f"list_creative_formats with auth should succeed, got {response.status_code}" + assert response.status_code == 200, ( + f"list_creative_formats with auth should succeed, got {response.status_code}" + ) except (requests.ConnectionError, requests.Timeout): pytest.skip(f"MCP server not running at {base_url}") @@ -295,9 +295,9 @@ def test_list_authorized_properties_with_auth(self): ) # Should succeed with auth - assert ( - response.status_code == 200 - ), f"list_authorized_properties with auth should succeed, got {response.status_code}" + assert response.status_code == 200, ( + f"list_authorized_properties with auth should succeed, got {response.status_code}" + ) except (requests.ConnectionError, requests.Timeout): pytest.skip(f"MCP server not running at {base_url}") @@ -410,9 +410,9 @@ def test_get_products_filters_pricing_for_anonymous(self): # pricing_options should be empty or missing for anonymous users pricing_options = product.get("pricing_options", []) - assert ( - len(pricing_options) == 0 - ), f"Anonymous users should not see pricing, got {len(pricing_options)} options" + assert len(pricing_options) == 0, ( + f"Anonymous users should not see pricing, got {len(pricing_options)} options" + ) # Verify no other sensitive pricing fields leak through sensitive_fields = ["cost", "rate", "price", "cpm", "cpc", "vcpm"] @@ -458,9 +458,9 @@ def test_accuweather_landing_page(self): allow_redirects=True, ) - assert ( - response.status_code == 200 - ), f"AccuWeather landing page should return 200, got {response.status_code}" + assert response.status_code == 200, ( + f"AccuWeather landing page should return 200, got {response.status_code}" + ) content = response.content.decode("utf-8").lower() @@ -495,9 +495,9 @@ def test_test_agent_landing_page(self): content = response.content.decode("utf-8").lower() # Should contain agent endpoints - assert ( - 'href="/mcp' in content or 'href="/a2a' in content - ), "test-agent landing page should contain agent endpoints" + assert 'href="/mcp' in content or 'href="/a2a' in content, ( + "test-agent landing page should contain agent endpoints" + ) except requests.RequestException as e: pytest.skip(f"Could not reach production URL: {e}") diff --git a/tests/integration/test_a2a_response_compliance.py b/tests/integration/test_a2a_response_compliance.py index 2dd67192b..d720edfc9 100644 --- a/tests/integration/test_a2a_response_compliance.py +++ b/tests/integration/test_a2a_response_compliance.py @@ -364,9 +364,9 @@ def test_all_response_types_have_str_method(self): for response_cls in response_types: # All our response adapters should have __str__ - assert hasattr( - response_cls, "__str__" - ), f"{response_cls.__name__} must have __str__() for human-readable messages" + assert hasattr(response_cls, "__str__"), ( + f"{response_cls.__name__} must have __str__() for human-readable messages" + ) @pytest.mark.integration diff --git a/tests/integration/test_a2a_response_message_fields.py b/tests/integration/test_a2a_response_message_fields.py index 0b47c6e59..0b2e1e2d0 100644 --- a/tests/integration/test_a2a_response_message_fields.py +++ b/tests/integration/test_a2a_response_message_fields.py @@ -300,9 +300,9 @@ def test_all_response_types_have_str_or_message(self): # For now, just check the class definition has_message_field = "message" in response_cls.model_fields - assert ( - has_str_method or has_message_field - ), f"{response_cls.__name__} must have either __str__ method or .message field for A2A compatibility" + assert has_str_method or has_message_field, ( + f"{response_cls.__name__} must have either __str__ method or .message field for A2A compatibility" + ) @pytest.mark.integration @@ -333,6 +333,6 @@ async def test_skill_error_has_message_field(self, handler, sample_principal): assert "message" in result or "error" in result, "Error response must have message or error field" except Exception as e: # Errors are expected for invalid params - assert "message" not in str(e) or "AttributeError" not in str( - e - ), "Should not get AttributeError when handling skill errors" + assert "message" not in str(e) or "AttributeError" not in str(e), ( + "Should not get AttributeError when handling skill errors" + ) diff --git a/tests/integration/test_adapter_factory.py b/tests/integration/test_adapter_factory.py index 19c47d57b..d4cccdbc4 100644 --- a/tests/integration/test_adapter_factory.py +++ b/tests/integration/test_adapter_factory.py @@ -252,9 +252,9 @@ def test_get_adapter_instantiates_all_adapter_types(self, setup_adapters): # Verify correct adapter type expected_class = adapter_type_map[adapter_type] - assert isinstance( - adapter, expected_class - ), f"Expected {expected_class.__name__}, got {type(adapter).__name__}" + assert isinstance(adapter, expected_class), ( + f"Expected {expected_class.__name__}, got {type(adapter).__name__}" + ) # Verify dry_run mode was set assert adapter.dry_run is True, f"dry_run not set correctly for {adapter_type}" @@ -311,9 +311,9 @@ def test_gam_adapter_requires_network_code(self, setup_adapters): # Verify it's actually a GAM adapter, not mock fallback from src.adapters.google_ad_manager import GoogleAdManager - assert isinstance( - adapter, GoogleAdManager - ), f"Expected GAM adapter, got {type(adapter).__name__}. Check tenant/adapter_config setup." + assert isinstance(adapter, GoogleAdManager), ( + f"Expected GAM adapter, got {type(adapter).__name__}. Check tenant/adapter_config setup." + ) # Verify network_code was passed correctly assert hasattr(adapter, "network_code"), "GAM adapter missing network_code attribute" diff --git a/tests/integration/test_database_health_integration.py b/tests/integration/test_database_health_integration.py index e3fc8b021..57ae14d4a 100644 --- a/tests/integration/test_database_health_integration.py +++ b/tests/integration/test_database_health_integration.py @@ -117,9 +117,9 @@ def test_health_check_database_access_errors(self, integration_db): health = check_database_health() # Should handle error gracefully - assert ( - health["status"] == "error" - ), f"Should report error status for database connection failure, got: {health['status']}" + assert health["status"] == "error", ( + f"Should report error status for database connection failure, got: {health['status']}" + ) assert len(health["schema_issues"]) > 0, "Should report schema issues for failed connection" # Error should be descriptive diff --git a/tests/integration/test_delivery_webhooks_force.py b/tests/integration/test_delivery_webhooks_force.py index 3fbd0a5fd..c5278524f 100644 --- a/tests/integration/test_delivery_webhooks_force.py +++ b/tests/integration/test_delivery_webhooks_force.py @@ -85,7 +85,6 @@ async def fake_send_notification(*args, **kwargs): ) as mock_send, patch("src.services.delivery_webhook_scheduler._get_media_buy_delivery_impl", return_value=mock_response), ): - # 2. Insert a fake log entry simulating a report sent today with get_db_session() as session: # Use the same logic as scheduler to calculate "today" for reporting diff --git a/tests/integration/test_duplicate_product_validation.py b/tests/integration/test_duplicate_product_validation.py index 5aefdad43..539971b55 100644 --- a/tests/integration/test_duplicate_product_validation.py +++ b/tests/integration/test_duplicate_product_validation.py @@ -97,9 +97,9 @@ async def test_duplicate_product_in_packages_rejected(self, integration_db): error_msg = result.errors[0].message assert "duplicate" in error_msg.lower(), f"Error should mention 'duplicate': {error_msg}" assert "prod_test_1" in error_msg, f"Error should mention 'prod_test_1': {error_msg}" - assert ( - "each product can only be used once" in error_msg.lower() - ), f"Error should say 'each product can only be used once': {error_msg}" + assert "each product can only be used once" in error_msg.lower(), ( + f"Error should say 'each product can only be used once': {error_msg}" + ) @pytest.mark.asyncio async def test_multiple_duplicate_products_all_listed(self, integration_db): diff --git a/tests/integration/test_gam_pricing_models_integration.py b/tests/integration/test_gam_pricing_models_integration.py index 306297d92..6fc492a25 100644 --- a/tests/integration/test_gam_pricing_models_integration.py +++ b/tests/integration/test_gam_pricing_models_integration.py @@ -411,9 +411,9 @@ async def test_gam_cpm_guaranteed_creates_standard_line_item(setup_gam_tenant_wi if is_external_service_response_error(response): pytest.skip(f"External creative agent unavailable: {response.errors}") - assert ( - not hasattr(response, "errors") or response.errors is None or response.errors == [] - ), f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + assert not hasattr(response, "errors") or response.errors is None or response.errors == [], ( + f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + ) assert response.media_buy_id is not None # In dry-run mode, the response should succeed @@ -468,9 +468,9 @@ async def test_gam_cpc_creates_price_priority_line_item_with_clicks_goal(setup_g if is_external_service_response_error(response): pytest.skip(f"External creative agent unavailable: {response.errors}") - assert ( - not hasattr(response, "errors") or response.errors is None or response.errors == [] - ), f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + assert not hasattr(response, "errors") or response.errors is None or response.errors == [], ( + f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + ) assert response.media_buy_id is not None # In real GAM mode, line item would have: @@ -526,9 +526,9 @@ async def test_gam_vcpm_creates_standard_line_item_with_viewable_impressions(set if is_external_service_response_error(response): pytest.skip(f"External creative agent unavailable: {response.errors}") - assert ( - not hasattr(response, "errors") or response.errors is None or response.errors == [] - ), f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + assert not hasattr(response, "errors") or response.errors is None or response.errors == [], ( + f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + ) assert response.media_buy_id is not None # In real GAM mode, line item would have: @@ -585,9 +585,9 @@ async def test_gam_flat_rate_calculates_cpd_correctly(setup_gam_tenant_with_all_ if is_external_service_response_error(response): pytest.skip(f"External creative agent unavailable: {response.errors}") - assert ( - not hasattr(response, "errors") or response.errors is None or response.errors == [] - ), f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + assert not hasattr(response, "errors") or response.errors is None or response.errors == [], ( + f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + ) assert response.media_buy_id is not None # In real GAM mode, line item would have: @@ -655,9 +655,9 @@ async def test_gam_multi_package_mixed_pricing_models(setup_gam_tenant_with_all_ if is_external_service_response_error(response): pytest.skip(f"External creative agent unavailable: {response.errors}") - assert ( - not hasattr(response, "errors") or response.errors is None or response.errors == [] - ), f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + assert not hasattr(response, "errors") or response.errors is None or response.errors == [], ( + f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + ) assert response.media_buy_id is not None # Each package should create a line item with correct pricing: diff --git a/tests/integration/test_gam_pricing_restriction.py b/tests/integration/test_gam_pricing_restriction.py index 1c97a853c..c5d3bb4e0 100644 --- a/tests/integration/test_gam_pricing_restriction.py +++ b/tests/integration/test_gam_pricing_restriction.py @@ -327,9 +327,9 @@ async def test_gam_rejects_cpcv_pricing_model(setup_gam_tenant_with_non_cpm_prod # Check error indicates CPCV/pricing model rejection assert response.errors, "Expected error messages in CreateMediaBuyError" error_msg = " ".join([err.message.lower() for err in response.errors]) - assert ( - "cpcv" in error_msg or "pricing" in error_msg or "not supported" in error_msg or "gam" in error_msg - ), f"Expected pricing/GAM error, got: {error_msg}" + assert "cpcv" in error_msg or "pricing" in error_msg or "not supported" in error_msg or "gam" in error_msg, ( + f"Expected pricing/GAM error, got: {error_msg}" + ) @pytest.mark.requires_db @@ -378,9 +378,9 @@ async def test_gam_accepts_cpm_pricing_model(setup_gam_tenant_with_non_cpm_produ if is_external_service_response_error(response): pytest.skip(f"External creative agent unavailable: {response.errors}") - assert ( - not hasattr(response, "errors") or response.errors is None or response.errors == [] - ), f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + assert not hasattr(response, "errors") or response.errors is None or response.errors == [], ( + f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + ) assert response.media_buy_id is not None @@ -432,9 +432,9 @@ async def test_gam_rejects_cpp_from_multi_pricing_product(setup_gam_tenant_with_ # Check error indicates CPP/pricing model rejection assert response.errors, "Expected error messages in CreateMediaBuyError" error_msg = " ".join([err.message.lower() for err in response.errors]) - assert ( - "cpp" in error_msg or "pricing" in error_msg or "not supported" in error_msg or "gam" in error_msg - ), f"Expected pricing/GAM error, got: {error_msg}" + assert "cpp" in error_msg or "pricing" in error_msg or "not supported" in error_msg or "gam" in error_msg, ( + f"Expected pricing/GAM error, got: {error_msg}" + ) @pytest.mark.requires_db @@ -483,7 +483,7 @@ async def test_gam_accepts_cpm_from_multi_pricing_product(setup_gam_tenant_with_ if is_external_service_response_error(response): pytest.skip(f"External creative agent unavailable: {response.errors}") - assert ( - not hasattr(response, "errors") or response.errors is None or response.errors == [] - ), f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + assert not hasattr(response, "errors") or response.errors is None or response.errors == [], ( + f"Media buy creation failed: {response.errors if hasattr(response, 'errors') else 'unknown error'}" + ) assert response.media_buy_id is not None diff --git a/tests/integration/test_inventory_profile_security.py b/tests/integration/test_inventory_profile_security.py index e799eed1b..48bbdb2d8 100644 --- a/tests/integration/test_inventory_profile_security.py +++ b/tests/integration/test_inventory_profile_security.py @@ -313,6 +313,6 @@ def test_profile_updates_only_affect_same_tenant_products(self, tenant_a, tenant profile_b_fresh = session.scalars(stmt).first() assert product_b_fresh.inventory_profile_id == profile_b, "Product B should still reference profile_b" - assert ( - profile_b_fresh.format_ids == original_formats_b - ), "Profile B format_ids should be unchanged (verified from fresh query)" + assert profile_b_fresh.format_ids == original_formats_b, ( + "Profile B format_ids should be unchanged (verified from fresh query)" + ) diff --git a/tests/integration/test_list_creative_formats_params.py b/tests/integration/test_list_creative_formats_params.py index fe8113f03..2b0f4a2bc 100644 --- a/tests/integration/test_list_creative_formats_params.py +++ b/tests/integration/test_list_creative_formats_params.py @@ -116,9 +116,9 @@ async def mock_list_formats(tenant_id): # All returned formats should be video type if len(formats) > 0: - assert all( - f.type == FormatCategory.video or f.type == "video" for f in formats - ), "All formats should be video type" + assert all(f.type == FormatCategory.video or f.type == "video" for f in formats), ( + "All formats should be video type" + ) # Note: Test may return empty list if mock registry not working - this is OK for integration test @@ -190,9 +190,9 @@ async def mock_list_formats(tenant_id): # Should only return the requested formats (that exist) target_ids = ["display_300x250", "display_728x90"] returned_ids = [f.format_id.id if hasattr(f.format_id, "id") else f.format_id for f in formats] - assert all( - (f.format_id.id if hasattr(f.format_id, "id") else f.format_id) in target_ids for f in formats - ), "All formats should be in target list" + assert all((f.format_id.id if hasattr(f.format_id, "id") else f.format_id) in target_ids for f in formats), ( + "All formats should be in target list" + ) # At least one of the target formats should exist assert len(formats) > 0, "Should return at least one format if they exist" @@ -263,9 +263,9 @@ async def mock_list_formats(tenant_id): # Should return only display formats with width >= 500 (Display 728x90) if len(formats) > 0: - assert all( - (f.type == FormatCategory.display or f.type == "display") for f in formats - ), "All formats should be display type" + assert all((f.type == FormatCategory.display or f.type == "display") for f in formats), ( + "All formats should be display type" + ) assert len(formats) == 1, "Should only return Display 728x90" assert formats[0].name == "Display 728x90" diff --git a/tests/integration/test_notification_urls_exist.py b/tests/integration/test_notification_urls_exist.py index 0c4c14d8d..99c90627a 100644 --- a/tests/integration/test_notification_urls_exist.py +++ b/tests/integration/test_notification_urls_exist.py @@ -85,7 +85,9 @@ def test_all_slack_notification_urls_are_valid_routes(self, app_routes, slack_no # Check if route exists (exact match or as a prefix) route_exists = any( - route == flask_route or route.startswith(flask_route + "/") or + route == flask_route + or route.startswith(flask_route + "/") + or # Handle both /tenant//workflows and /tenant//workflows route.replace("= 2 - ), f"Expected at least 2 internal-only fields, got {len(internal_only_fields)}" + assert len(internal_only_fields) >= 2, ( + f"Expected at least 2 internal-only fields, got {len(internal_only_fields)}" + ) def test_signal_adcp_compliance(self): """Test that Signal model complies with AdCP get-signals-response schema.""" @@ -991,15 +991,15 @@ def test_signal_adcp_compliance(self): # Verify field count expectations (flexible to allow AdCP spec evolution) assert len(adcp_response) >= 8, f"AdCP response should have at least 8 core fields, got {len(adcp_response)}" - assert len(internal_response) >= len( - adcp_response - ), "Internal response should have at least as many fields as external response" + assert len(internal_response) >= len(adcp_response), ( + "Internal response should have at least as many fields as external response" + ) # Verify internal response has more fields than external (due to internal fields) internal_only_fields = set(internal_response.keys()) - set(adcp_response.keys()) - assert ( - len(internal_only_fields) >= 3 - ), f"Expected at least 3 internal-only fields, got {len(internal_only_fields)}" + assert len(internal_only_fields) >= 3, ( + f"Expected at least 3 internal-only fields, got {len(internal_only_fields)}" + ) def test_package_adcp_compliance(self): """Test that Package model complies with AdCP package schema.""" @@ -1077,15 +1077,15 @@ def test_package_adcp_compliance(self): # Package has 1 required field (package_id) + any optional fields that are set # We set several optional fields above, so expect at least 1 field assert len(adcp_response) >= 1, f"AdCP response should have at least required fields, got {len(adcp_response)}" - assert len(internal_response) >= len( - adcp_response - ), "Internal response should have at least as many fields as external response" + assert len(internal_response) >= len(adcp_response), ( + "Internal response should have at least as many fields as external response" + ) # Verify internal response has more fields than external (due to internal fields) internal_only_fields = set(internal_response.keys()) - set(adcp_response.keys()) - assert ( - len(internal_only_fields) >= 3 - ), f"Expected at least 3 internal-only fields, got {len(internal_only_fields)}" + assert len(internal_only_fields) >= 3, ( + f"Expected at least 3 internal-only fields, got {len(internal_only_fields)}" + ) def test_package_ignores_invalid_fields(self): """Test that Package schema ignores fields that don't exist in AdCP spec. @@ -1117,12 +1117,14 @@ def test_package_ignores_invalid_fields(self): def test_targeting_adcp_compliance(self): """Test that Targeting model complies with AdCP targeting schema.""" - # Create targeting with both public and managed/internal fields + from adcp.types import TargetingOverlay + + # Create targeting with v3 structured geo fields and internal fields targeting = Targeting( - geo_country_any_of=["US", "CA"], - geo_region_any_of=["CA", "NY"], - geo_metro_any_of=["803", "501"], - geo_zip_any_of=["10001", "90210"], + geo_countries=["US", "CA"], + geo_regions=["US-CA", "US-NY"], + geo_metros=[{"system": "nielsen_dma", "values": ["803", "501"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001", "90210"]}], audiences_any_of=["segment_1", "segment_2"], signals=["auto_intenders_q1_2025", "sports_enthusiasts"], device_type_any_of=["desktop", "mobile", "tablet"], @@ -1135,15 +1137,18 @@ def test_targeting_adcp_compliance(self): metadata={"campaign_type": "awareness"}, # Internal ) + # Verify isinstance — Targeting IS a TargetingOverlay + assert isinstance(targeting, TargetingOverlay) + # Test AdCP-compliant model_dump (external response) adcp_response = targeting.model_dump() - # Verify AdCP fields are present (all targeting fields are optional in AdCP) + # Verify v3 structured geo fields are present adcp_optional_fields = [ - "geo_country_any_of", - "geo_region_any_of", - "geo_metro_any_of", - "geo_zip_any_of", + "geo_countries", + "geo_regions", + "geo_metros", + "geo_postal_areas", "audiences_any_of", "signals", "device_type_any_of", @@ -1151,7 +1156,6 @@ def test_targeting_adcp_compliance(self): "browser_any_of", ] for field in adcp_optional_fields: - # Field should be in response even if null (AdCP spec pattern) if getattr(targeting, field) is not None: assert field in adcp_response, f"AdCP optional field '{field}' missing from response" @@ -1166,10 +1170,11 @@ def test_targeting_adcp_compliance(self): for field in managed_internal_fields: assert field not in adcp_response, f"Managed/internal field '{field}' exposed in AdCP response" - # Verify AdCP-specific requirements - if adcp_response.get("geo_country_any_of"): - for country in adcp_response["geo_country_any_of"]: - assert len(country) == 2, "Country codes must be 2-letter ISO codes" + # Verify v3 geo structure + if adcp_response.get("geo_countries"): + for country in adcp_response["geo_countries"]: + # GeoCountry serializes as a plain string (RootModel) + assert isinstance(country, str) and len(country) == 2, "Country codes must be 2-letter ISO codes" if adcp_response.get("device_type_any_of"): valid_devices = ["desktop", "mobile", "tablet", "connected_tv", "smart_speaker"] @@ -1192,21 +1197,21 @@ def test_targeting_adcp_compliance(self): assert field in internal_response, f"Managed/internal field '{field}' missing from internal response" # Test managed fields are accessible internally - assert ( - internal_response["key_value_pairs"]["aee_segment"] == "high_value" - ), "Managed field should be in internal response" + assert internal_response["key_value_pairs"]["aee_segment"] == "high_value", ( + "Managed field should be in internal response" + ) # Verify field count expectations (flexible - targeting has many optional fields) assert len(adcp_response) >= 9, f"AdCP response should have at least 9 fields, got {len(adcp_response)}" - assert len(internal_response) >= len( - adcp_response - ), "Internal response should have at least as many fields as external response" + assert len(internal_response) >= len(adcp_response), ( + "Internal response should have at least as many fields as external response" + ) # Verify internal response has more fields than external (due to managed/internal fields) internal_only_fields = set(internal_response.keys()) - set(adcp_response.keys()) - assert ( - len(internal_only_fields) >= 4 - ), f"Expected at least 4 internal/managed-only fields, got {len(internal_only_fields)}" + assert len(internal_only_fields) >= 4, ( + f"Expected at least 4 internal/managed-only fields, got {len(internal_only_fields)}" + ) def test_budget_adcp_compliance(self): """Test that Budget model complies with AdCP budget schema.""" @@ -1280,9 +1285,9 @@ def test_creative_policy_adcp_compliance(self): assert isinstance(adcp_response["templates_available"], bool), "templates_available must be boolean" # Verify field count (CreativePolicy is simple, count should be stable) - assert ( - len(adcp_response) == 3 - ), f"CreativePolicy response should have exactly 3 fields, got {len(adcp_response)}" + assert len(adcp_response) == 3, ( + f"CreativePolicy response should have exactly 3 fields, got {len(adcp_response)}" + ) def test_creative_status_adcp_compliance(self): """Test that CreativeApprovalStatus model complies with AdCP creative-status schema.""" @@ -1312,9 +1317,9 @@ def test_creative_status_adcp_compliance(self): assert adcp_response["status"] in valid_statuses, f"Invalid status value: {adcp_response['status']}" # Verify field count (flexible - optional fields vary) - assert ( - len(adcp_response) >= 3 - ), f"CreativeStatus response should have at least 3 core fields, got {len(adcp_response)}" + assert len(adcp_response) >= 3, ( + f"CreativeStatus response should have at least 3 core fields, got {len(adcp_response)}" + ) def test_creative_assignment_adcp_compliance(self): """Test that CreativeAssignment model complies with AdCP creative-assignment schema.""" @@ -1357,9 +1362,9 @@ def test_creative_assignment_adcp_compliance(self): # Verify AdCP-specific requirements if adcp_response.get("rotation_type"): valid_rotations = ["weighted", "sequential", "even"] - assert ( - adcp_response["rotation_type"] in valid_rotations - ), f"Invalid rotation_type: {adcp_response['rotation_type']}" + assert adcp_response["rotation_type"] in valid_rotations, ( + f"Invalid rotation_type: {adcp_response['rotation_type']}" + ) if adcp_response.get("weight") is not None: assert adcp_response["weight"] >= 0, "Weight must be non-negative" @@ -1368,9 +1373,9 @@ def test_creative_assignment_adcp_compliance(self): assert 0 <= adcp_response["percentage_goal"] <= 100, "Percentage goal must be 0-100" # Verify field count (flexible - optional fields vary) - assert ( - len(adcp_response) >= 4 - ), f"CreativeAssignment response should have at least 4 core fields, got {len(adcp_response)}" + assert len(adcp_response) >= 4, ( + f"CreativeAssignment response should have at least 4 core fields, got {len(adcp_response)}" + ) def test_sync_creatives_request_adcp_compliance(self): """Test that SyncCreativesRequest model complies with AdCP v2.4 sync-creatives schema.""" @@ -1695,9 +1700,9 @@ def test_list_creatives_response_adcp_compliance(self): assert field in adcp_response, f"Required field '{field}' missing from response" # Verify we have at least the required fields (and possibly some optional ones) - assert len(adcp_response) >= len( - required_fields - ), f"Response should have at least {len(required_fields)} required fields, got {len(adcp_response)}" + assert len(adcp_response) >= len(required_fields), ( + f"Response should have at least {len(required_fields)} required fields, got {len(adcp_response)}" + ) def test_create_media_buy_response_adcp_compliance(self): """Test that CreateMediaBuyResponse complies with AdCP create-media-buy-response schema. @@ -1770,9 +1775,9 @@ def test_create_media_buy_response_adcp_compliance(self): assert isinstance(error_via_union, CreateMediaBuyError) # Verify field count for success response - assert ( - len(adcp_response) >= 3 - ), f"CreateMediaBuySuccess should have at least 3 required fields, got {len(adcp_response)}" + assert len(adcp_response) >= 3, ( + f"CreateMediaBuySuccess should have at least 3 required fields, got {len(adcp_response)}" + ) def test_get_products_response_adcp_compliance(self): """Test that GetProductsResponse complies with AdCP get-products-response schema.""" @@ -1857,9 +1862,9 @@ def test_get_products_response_adcp_compliance(self): # Verify __str__() provides appropriate empty message assert str(empty_response) == "No products matched your requirements." # Allow 2 or 3 fields (status is optional and may not be present, message removed) - assert ( - len(empty_adcp_response) >= 2 and len(empty_adcp_response) <= 3 - ), f"GetProductsResponse should have 2-3 fields (status optional), got {len(empty_adcp_response)}" + assert len(empty_adcp_response) >= 2 and len(empty_adcp_response) <= 3, ( + f"GetProductsResponse should have 2-3 fields (status optional), got {len(empty_adcp_response)}" + ) def test_list_creative_formats_response_adcp_compliance(self): """Test that ListCreativeFormatsResponse complies with AdCP list-creative-formats-response schema.""" @@ -1911,9 +1916,9 @@ def test_list_creative_formats_response_adcp_compliance(self): # Verify field count - only required fields + non-None optional fields # formats is required; errors and creative_agents are omitted (None values) - assert ( - len(adcp_response) >= 1 - ), f"ListCreativeFormatsResponse should have at least required fields, got {len(adcp_response)}" + assert len(adcp_response) >= 1, ( + f"ListCreativeFormatsResponse should have at least required fields, got {len(adcp_response)}" + ) def test_update_media_buy_response_adcp_compliance(self): """Test that UpdateMediaBuyResponse complies with AdCP update-media-buy-response schema. @@ -1966,9 +1971,9 @@ def test_update_media_buy_response_adcp_compliance(self): assert "buyer_ref" not in adcp_error, "Error response cannot have buyer_ref" # Verify field count for success response (media_buy_id, buyer_ref are required) - assert ( - len(adcp_response) >= 2 - ), f"UpdateMediaBuySuccess should have at least 2 required fields, got {len(adcp_response)}" + assert len(adcp_response) >= 2, ( + f"UpdateMediaBuySuccess should have at least 2 required fields, got {len(adcp_response)}" + ) def test_get_media_buy_delivery_request_adcp_compliance(self): """Test that GetMediaBuyDeliveryRequest complies with AdCP get-media-buy-delivery-request schema.""" @@ -2002,9 +2007,9 @@ def test_get_media_buy_delivery_request_adcp_compliance(self): # AdCP MediaBuyStatus enum: pending_activation, active, paused, completed valid_statuses = ["pending_activation", "active", "paused", "completed"] if isinstance(adcp_request["status_filter"], str): - assert ( - adcp_request["status_filter"] in valid_statuses - ), f"Invalid status: {adcp_request['status_filter']}" + assert adcp_request["status_filter"] in valid_statuses, ( + f"Invalid status: {adcp_request['status_filter']}" + ) elif isinstance(adcp_request["status_filter"], list): for status in adcp_request["status_filter"]: assert status in valid_statuses, f"Invalid status in array: {status}" @@ -2167,15 +2172,15 @@ def test_get_media_buy_delivery_response_adcp_compliance(self): ) empty_adcp_response = empty_response.model_dump() - assert ( - empty_adcp_response["media_buy_deliveries"] == [] - ), "Empty media_buy_deliveries list should be empty array" + assert empty_adcp_response["media_buy_deliveries"] == [], ( + "Empty media_buy_deliveries list should be empty array" + ) # Verify field count - required fields + non-None optional fields # reporting_period, currency, media_buy_deliveries are required; aggregated_totals set; errors=None omitted - assert ( - len(adcp_response) >= 3 - ), f"GetMediaBuyDeliveryResponse should have at least 3 required fields, got {len(adcp_response)}" + assert len(adcp_response) >= 3, ( + f"GetMediaBuyDeliveryResponse should have at least 3 required fields, got {len(adcp_response)}" + ) def test_property_identifier_adcp_compliance(self): """Test that PropertyIdentifier complies with AdCP property identifier schema.""" @@ -2258,7 +2263,6 @@ def test_list_authorized_properties_request_adcp_compliance(self): """Test that ListAuthorizedPropertiesRequest complies with AdCP list-authorized-properties-request schema.""" # Create request with optional fields per spec # Per AdCP spec: context, ext, publisher_domains, property_tags are all optional - # Note: ListAuthorizedPropertiesRequest was removed from adcp 3.2.0, we define it locally request = ListAuthorizedPropertiesRequest(publisher_domains=["example.com", "news.example.com"]) # Test AdCP-compliant response - use exclude_none=False to see all fields @@ -2434,13 +2438,15 @@ def test_update_media_buy_request_adcp_compliance(self): # ✅ VERIFY ADCP COMPLIANCE: OneOf constraint satisfied assert "media_buy_id" in adcp_response_id, "media_buy_id must be present" assert adcp_response_id["media_buy_id"] is not None, "media_buy_id must not be None" - assert ( - "buyer_ref" not in adcp_response_id or adcp_response_id["buyer_ref"] is None - ), "buyer_ref must be None when media_buy_id is provided" + assert "buyer_ref" not in adcp_response_id or adcp_response_id["buyer_ref"] is None, ( + "buyer_ref must be None when media_buy_id is provided" + ) # Test AdCP-compliant request with buyer_ref (oneOf option 2) adcp_request_ref = UpdateMediaBuyRequest( - buyer_ref="br_67890", paused=True, start_time=datetime(2025, 3, 1, 0, 0, 0, tzinfo=UTC) # adcp 2.12.0+ + buyer_ref="br_67890", + paused=True, + start_time=datetime(2025, 3, 1, 0, 0, 0, tzinfo=UTC), # adcp 2.12.0+ ) adcp_response_ref = adcp_request_ref.model_dump() @@ -2448,9 +2454,9 @@ def test_update_media_buy_request_adcp_compliance(self): # ✅ VERIFY ADCP COMPLIANCE: OneOf constraint satisfied assert "buyer_ref" in adcp_response_ref, "buyer_ref must be present" assert adcp_response_ref["buyer_ref"] is not None, "buyer_ref must not be None" - assert ( - "media_buy_id" not in adcp_response_ref or adcp_response_ref["media_buy_id"] is None - ), "media_buy_id must be None when buyer_ref is provided" + assert "media_buy_id" not in adcp_response_ref or adcp_response_ref["media_buy_id"] is None, ( + "media_buy_id must be None when buyer_ref is provided" + ) # ✅ VERIFY ADCP COMPLIANCE: Optional fields present when provided optional_fields = ["paused", "start_time", "end_time", "budget", "packages"] # adcp 2.12.0+ @@ -2815,9 +2821,9 @@ def test_get_signals_response_adcp_compliance(self): # Verify field count (signals is required, errors is optional) # Per AdCP PR #113, protocol fields removed from domain responses - assert ( - len(adcp_response) >= 1 - ), f"GetSignalsResponse should have at least 1 core field (signals), got {len(adcp_response)}" + assert len(adcp_response) >= 1, ( + f"GetSignalsResponse should have at least 1 core field (signals), got {len(adcp_response)}" + ) # Test with all fields signal_data = { @@ -2849,9 +2855,9 @@ def test_activate_signal_response_adcp_compliance(self): assert "signal_id" in adcp_response # Verify field count (domain fields only: signal_id, activation_details, errors) - assert ( - len(adcp_response) >= 1 - ), f"ActivateSignalResponse should have at least 1 core field, got {len(adcp_response)}" + assert len(adcp_response) >= 1, ( + f"ActivateSignalResponse should have at least 1 core field, got {len(adcp_response)}" + ) # Test with activation details (domain data) full_response = ActivateSignalResponse( diff --git a/tests/unit/test_auth_bearer_header.py b/tests/unit/test_auth_bearer_header.py index 4543ccdbe..d2b8ddee6 100644 --- a/tests/unit/test_auth_bearer_header.py +++ b/tests/unit/test_auth_bearer_header.py @@ -91,7 +91,7 @@ def test_get_principal_from_context_accepts_authorization_bearer( # Assert: Should have extracted principal from Bearer token assert principal_id == "test_principal_id", ( - "Authorization: Bearer should be accepted! " "Currently only x-adcp-auth is checked in auth.py:343" + "Authorization: Bearer should be accepted! Currently only x-adcp-auth is checked in auth.py:343" ) @patch("src.core.auth.get_http_headers") diff --git a/tests/unit/test_axe_segment_targeting.py b/tests/unit/test_axe_segment_targeting.py index 7224576b0..4cec8a67a 100644 --- a/tests/unit/test_axe_segment_targeting.py +++ b/tests/unit/test_axe_segment_targeting.py @@ -121,7 +121,7 @@ def test_update_media_buy_request_with_axe_segments(): assert len(request.packages) == 1 assert request.packages[0].targeting_overlay is not None assert request.packages[0].targeting_overlay.axe_include_segment == "x8dj3k" - assert request.packages[0].targeting_overlay.geo_country_any_of == ["US", "CA"] + assert len(request.packages[0].targeting_overlay.geo_countries) == 2 # Verify serialization data = request.model_dump() @@ -147,22 +147,22 @@ def test_axe_segments_survive_roundtrip(): # Verify fields survived assert reconstructed.axe_include_segment == "x8dj3k" assert reconstructed.axe_exclude_segment == "y9kl4m" - assert reconstructed.geo_country_any_of == ["US"] + assert len(reconstructed.geo_countries) == 1 def test_axe_segments_with_other_targeting_dimensions(): """Test that AXE segments work alongside other targeting dimensions.""" targeting = Targeting( geo_country_any_of=["US"], - geo_region_any_of=["NY", "CA"], + geo_region_any_of=["US-NY", "US-CA"], device_type_any_of=["mobile", "desktop"], axe_include_segment="x8dj3k", axe_exclude_segment="y9kl4m", ) # Verify all fields are present - assert targeting.geo_country_any_of == ["US"] - assert targeting.geo_region_any_of == ["NY", "CA"] + assert len(targeting.geo_countries) == 1 + assert len(targeting.geo_regions) == 2 assert targeting.device_type_any_of == ["mobile", "desktop"] assert targeting.axe_include_segment == "x8dj3k" assert targeting.axe_exclude_segment == "y9kl4m" diff --git a/tests/unit/test_brand_manifest_rootmodel.py b/tests/unit/test_brand_manifest_rootmodel.py index ec385f3c6..386582132 100644 --- a/tests/unit/test_brand_manifest_rootmodel.py +++ b/tests/unit/test_brand_manifest_rootmodel.py @@ -24,9 +24,9 @@ def test_brand_manifest_rootmodel_unwrapping(): assert hasattr(req.brand_manifest, "root"), "brand_manifest should have .root attribute" # The wrapper does NOT have .name directly - assert ( - not hasattr(req.brand_manifest, "name") or req.brand_manifest.name is None - ), "brand_manifest wrapper should not have .name directly accessible" + assert not hasattr(req.brand_manifest, "name") or req.brand_manifest.name is None, ( + "brand_manifest wrapper should not have .name directly accessible" + ) # But .root does have .name assert req.brand_manifest.root.name == "Test Brand" @@ -50,13 +50,12 @@ def test_brand_manifest_extraction_logic(): offering = f"Brand at {brand_manifest}" elif hasattr(brand_manifest, "__str__") and str(brand_manifest).startswith("http"): offering = f"Brand at {brand_manifest}" - else: - if hasattr(brand_manifest, "name") and brand_manifest.name: - offering = brand_manifest.name - elif hasattr(brand_manifest, "url") and brand_manifest.url: - offering = f"Brand at {brand_manifest.url}" - elif isinstance(brand_manifest, dict): - offering = brand_manifest.get("name") or brand_manifest.get("url", "") + elif hasattr(brand_manifest, "name") and brand_manifest.name: + offering = brand_manifest.name + elif hasattr(brand_manifest, "url") and brand_manifest.url: + offering = f"Brand at {brand_manifest.url}" + elif isinstance(brand_manifest, dict): + offering = brand_manifest.get("name") or brand_manifest.get("url", "") assert offering == "Test Brand", f"Expected 'Test Brand', got '{offering}'" diff --git a/tests/unit/test_creative_format_validation_bug.py b/tests/unit/test_creative_format_validation_bug.py index 1633710ee..e2fb69661 100644 --- a/tests/unit/test_creative_format_validation_bug.py +++ b/tests/unit/test_creative_format_validation_bug.py @@ -85,7 +85,8 @@ def test_validate_with_format_id_object_mismatch(self): """Verify validation correctly detects format mismatch.""" # Creative has a different format than product supports creative_format_id = FormatId( - agent_url="https://creative.adcontextprotocol.org/", id="video_300x250" # Different format + agent_url="https://creative.adcontextprotocol.org/", + id="video_300x250", # Different format ) # Product only supports display format diff --git a/tests/unit/test_gam_pricing_compatibility.py b/tests/unit/test_gam_pricing_compatibility.py index d10bd437b..45d75e308 100644 --- a/tests/unit/test_gam_pricing_compatibility.py +++ b/tests/unit/test_gam_pricing_compatibility.py @@ -11,9 +11,9 @@ class TestCompatibilityMatrix: def test_cpm_compatible_with_all_types(self): """CPM should work with all line item types.""" for line_item_type in ["STANDARD", "SPONSORSHIP", "NETWORK", "PRICE_PRIORITY", "BULK", "HOUSE"]: - assert PricingCompatibility.is_compatible( - line_item_type, "cpm" - ), f"CPM should be compatible with {line_item_type}" + assert PricingCompatibility.is_compatible(line_item_type, "cpm"), ( + f"CPM should be compatible with {line_item_type}" + ) def test_vcpm_compatible_with_standard_only(self): """VCPM should only work with STANDARD line items.""" @@ -21,9 +21,9 @@ def test_vcpm_compatible_with_standard_only(self): # VCPM NOT compatible with other types for line_item_type in ["SPONSORSHIP", "NETWORK", "PRICE_PRIORITY", "BULK", "HOUSE"]: - assert not PricingCompatibility.is_compatible( - line_item_type, "vcpm" - ), f"VCPM should NOT be compatible with {line_item_type}" + assert not PricingCompatibility.is_compatible(line_item_type, "vcpm"), ( + f"VCPM should NOT be compatible with {line_item_type}" + ) def test_cpc_compatible_types(self): """CPC should work with STANDARD, SPONSORSHIP, NETWORK, PRICE_PRIORITY.""" @@ -31,14 +31,14 @@ def test_cpc_compatible_types(self): incompatible = {"BULK", "HOUSE"} for line_item_type in compatible: - assert PricingCompatibility.is_compatible( - line_item_type, "cpc" - ), f"CPC should be compatible with {line_item_type}" + assert PricingCompatibility.is_compatible(line_item_type, "cpc"), ( + f"CPC should be compatible with {line_item_type}" + ) for line_item_type in incompatible: - assert not PricingCompatibility.is_compatible( - line_item_type, "cpc" - ), f"CPC should NOT be compatible with {line_item_type}" + assert not PricingCompatibility.is_compatible(line_item_type, "cpc"), ( + f"CPC should NOT be compatible with {line_item_type}" + ) def test_flat_rate_compatible_types(self): """FLAT_RATE (→CPD) should work with SPONSORSHIP and NETWORK only.""" @@ -46,14 +46,14 @@ def test_flat_rate_compatible_types(self): incompatible = {"STANDARD", "PRICE_PRIORITY", "BULK", "HOUSE"} for line_item_type in compatible: - assert PricingCompatibility.is_compatible( - line_item_type, "flat_rate" - ), f"FLAT_RATE should be compatible with {line_item_type}" + assert PricingCompatibility.is_compatible(line_item_type, "flat_rate"), ( + f"FLAT_RATE should be compatible with {line_item_type}" + ) for line_item_type in incompatible: - assert not PricingCompatibility.is_compatible( - line_item_type, "flat_rate" - ), f"FLAT_RATE should NOT be compatible with {line_item_type}" + assert not PricingCompatibility.is_compatible(line_item_type, "flat_rate"), ( + f"FLAT_RATE should NOT be compatible with {line_item_type}" + ) class TestLineItemTypeSelection: diff --git a/tests/unit/test_gam_workflow_packages.py b/tests/unit/test_gam_workflow_packages.py index d8b98096b..0e99c6107 100644 --- a/tests/unit/test_gam_workflow_packages.py +++ b/tests/unit/test_gam_workflow_packages.py @@ -124,9 +124,9 @@ def test_manual_approval_returns_packages_with_package_ids( # Assert - Package IDs must match input packages returned_ids = {pkg.package_id for pkg in response.packages} expected_ids = {pkg.package_id for pkg in sample_packages} - assert ( - returned_ids == expected_ids - ), f"Package IDs don't match. Got {returned_ids}, expected {expected_ids}" + assert returned_ids == expected_ids, ( + f"Package IDs don't match. Got {returned_ids}, expected {expected_ids}" + ) # Assert - Other required fields assert response.buyer_ref == sample_request.buyer_ref, "buyer_ref must be preserved" diff --git a/tests/unit/test_incremental_sync_stale_marking.py b/tests/unit/test_incremental_sync_stale_marking.py index e11cdee34..d2e2ddf53 100644 --- a/tests/unit/test_incremental_sync_stale_marking.py +++ b/tests/unit/test_incremental_sync_stale_marking.py @@ -45,8 +45,7 @@ def test_incremental_sync_should_skip_stale_marking_in_source(): has_full_mode_check = 'sync_mode == "full"' in preceding_lines or "sync_mode == 'full'" in preceding_lines assert has_full_mode_check, ( - f"_mark_stale_inventory should only be called when sync_mode == 'full'.\n" - f"Preceding lines:\n{preceding_lines}" + f"_mark_stale_inventory should only be called when sync_mode == 'full'.\nPreceding lines:\n{preceding_lines}" ) diff --git a/tests/unit/test_mcp_tool_imports.py b/tests/unit/test_mcp_tool_imports.py index c042047a9..2434a009f 100644 --- a/tests/unit/test_mcp_tool_imports.py +++ b/tests/unit/test_mcp_tool_imports.py @@ -30,9 +30,9 @@ def test_get_products_dependencies_exist(self): from src.core import schema_helpers # Check that the function exists in the schema_helpers module - assert hasattr( - schema_helpers, "create_get_products_request" - ), "create_get_products_request not found in schema_helpers module" + assert hasattr(schema_helpers, "create_get_products_request"), ( + "create_get_products_request not found in schema_helpers module" + ) # Verify it's callable assert callable(schema_helpers.create_get_products_request), "create_get_products_request should be callable" diff --git a/tests/unit/test_mcp_tool_schemas.py b/tests/unit/test_mcp_tool_schemas.py index dc4b21160..6e9e2a601 100644 --- a/tests/unit/test_mcp_tool_schemas.py +++ b/tests/unit/test_mcp_tool_schemas.py @@ -18,19 +18,19 @@ def test_get_products_uses_typed_parameters(self): params = sig.parameters # Check brand_manifest uses BrandManifest type - assert "BrandManifest" in str( - params["brand_manifest"].annotation - ), f"brand_manifest should use BrandManifest type, got {params['brand_manifest'].annotation}" + assert "BrandManifest" in str(params["brand_manifest"].annotation), ( + f"brand_manifest should use BrandManifest type, got {params['brand_manifest'].annotation}" + ) # Check filters uses ProductFilters type - assert "ProductFilters" in str( - params["filters"].annotation - ), f"filters should use ProductFilters type, got {params['filters'].annotation}" + assert "ProductFilters" in str(params["filters"].annotation), ( + f"filters should use ProductFilters type, got {params['filters'].annotation}" + ) # Check context uses ContextObject type - assert "ContextObject" in str( - params["context"].annotation - ), f"context should use ContextObject type, got {params['context'].annotation}" + assert "ContextObject" in str(params["context"].annotation), ( + f"context should use ContextObject type, got {params['context'].annotation}" + ) def test_sync_creatives_uses_typed_parameters(self): """sync_creatives should use CreativeAsset, ValidationMode, etc.""" @@ -40,19 +40,19 @@ def test_sync_creatives_uses_typed_parameters(self): params = sig.parameters # Check creatives uses CreativeAsset type - assert "CreativeAsset" in str( - params["creatives"].annotation - ), f"creatives should use CreativeAsset type, got {params['creatives'].annotation}" + assert "CreativeAsset" in str(params["creatives"].annotation), ( + f"creatives should use CreativeAsset type, got {params['creatives'].annotation}" + ) # Check validation_mode uses ValidationMode type - assert "ValidationMode" in str( - params["validation_mode"].annotation - ), f"validation_mode should use ValidationMode type, got {params['validation_mode'].annotation}" + assert "ValidationMode" in str(params["validation_mode"].annotation), ( + f"validation_mode should use ValidationMode type, got {params['validation_mode'].annotation}" + ) # Check context uses ContextObject type - assert "ContextObject" in str( - params["context"].annotation - ), f"context should use ContextObject type, got {params['context'].annotation}" + assert "ContextObject" in str(params["context"].annotation), ( + f"context should use ContextObject type, got {params['context'].annotation}" + ) def test_list_creatives_uses_typed_parameters(self): """list_creatives should use CreativeFilters, Sort, Pagination types.""" @@ -62,17 +62,17 @@ def test_list_creatives_uses_typed_parameters(self): params = sig.parameters # Check filters uses CreativeFilters type - assert "CreativeFilters" in str( - params["filters"].annotation - ), f"filters should use CreativeFilters type, got {params['filters'].annotation}" + assert "CreativeFilters" in str(params["filters"].annotation), ( + f"filters should use CreativeFilters type, got {params['filters'].annotation}" + ) # Check sort uses Sort type assert "Sort" in str(params["sort"].annotation), f"sort should use Sort type, got {params['sort'].annotation}" # Check pagination uses Pagination type - assert "Pagination" in str( - params["pagination"].annotation - ), f"pagination should use Pagination type, got {params['pagination'].annotation}" + assert "Pagination" in str(params["pagination"].annotation), ( + f"pagination should use Pagination type, got {params['pagination'].annotation}" + ) def test_create_media_buy_uses_typed_parameters(self): """create_media_buy should use BrandManifest, PackageRequest, etc.""" @@ -82,19 +82,19 @@ def test_create_media_buy_uses_typed_parameters(self): params = sig.parameters # Check brand_manifest uses BrandManifest type (or str for URL) - assert "BrandManifest" in str( - params["brand_manifest"].annotation - ), f"brand_manifest should use BrandManifest type, got {params['brand_manifest'].annotation}" + assert "BrandManifest" in str(params["brand_manifest"].annotation), ( + f"brand_manifest should use BrandManifest type, got {params['brand_manifest'].annotation}" + ) # Check packages uses PackageRequest type - assert "PackageRequest" in str( - params["packages"].annotation - ), f"packages should use PackageRequest type, got {params['packages'].annotation}" + assert "PackageRequest" in str(params["packages"].annotation), ( + f"packages should use PackageRequest type, got {params['packages'].annotation}" + ) # Check targeting_overlay uses TargetingOverlay type - assert "TargetingOverlay" in str( - params["targeting_overlay"].annotation - ), f"targeting_overlay should use TargetingOverlay type, got {params['targeting_overlay'].annotation}" + assert "TargetingOverlay" in str(params["targeting_overlay"].annotation), ( + f"targeting_overlay should use TargetingOverlay type, got {params['targeting_overlay'].annotation}" + ) def test_update_media_buy_uses_typed_parameters(self): """update_media_buy should use TargetingOverlay, PackageUpdate types. @@ -107,14 +107,14 @@ def test_update_media_buy_uses_typed_parameters(self): params = sig.parameters # Check targeting_overlay uses TargetingOverlay type - assert "TargetingOverlay" in str( - params["targeting_overlay"].annotation - ), f"targeting_overlay should use TargetingOverlay type, got {params['targeting_overlay'].annotation}" + assert "TargetingOverlay" in str(params["targeting_overlay"].annotation), ( + f"targeting_overlay should use TargetingOverlay type, got {params['targeting_overlay'].annotation}" + ) # Check packages uses PackageUpdate type (V3: was Packages) - assert "PackageUpdate" in str( - params["packages"].annotation - ), f"packages should use PackageUpdate type (V3), got {params['packages'].annotation}" + assert "PackageUpdate" in str(params["packages"].annotation), ( + f"packages should use PackageUpdate type (V3), got {params['packages'].annotation}" + ) def test_list_creative_formats_uses_typed_parameters(self): """list_creative_formats should use FormatCategory, FormatId, etc.""" @@ -124,19 +124,19 @@ def test_list_creative_formats_uses_typed_parameters(self): params = sig.parameters # Check type uses FormatCategory enum - assert "FormatCategory" in str( - params["type"].annotation - ), f"type should use FormatCategory type, got {params['type'].annotation}" + assert "FormatCategory" in str(params["type"].annotation), ( + f"type should use FormatCategory type, got {params['type'].annotation}" + ) # Check format_ids uses FormatId type - assert "FormatId" in str( - params["format_ids"].annotation - ), f"format_ids should use FormatId type, got {params['format_ids'].annotation}" + assert "FormatId" in str(params["format_ids"].annotation), ( + f"format_ids should use FormatId type, got {params['format_ids'].annotation}" + ) # Check asset_types uses AssetContentType type - assert "AssetContentType" in str( - params["asset_types"].annotation - ), f"asset_types should use AssetContentType type, got {params['asset_types'].annotation}" + assert "AssetContentType" in str(params["asset_types"].annotation), ( + f"asset_types should use AssetContentType type, got {params['asset_types'].annotation}" + ) def test_get_media_buy_delivery_uses_typed_parameters(self): """get_media_buy_delivery should use ContextObject type.""" @@ -146,9 +146,9 @@ def test_get_media_buy_delivery_uses_typed_parameters(self): params = sig.parameters # Check context uses ContextObject type - assert "ContextObject" in str( - params["context"].annotation - ), f"context should use ContextObject type, got {params['context'].annotation}" + assert "ContextObject" in str(params["context"].annotation), ( + f"context should use ContextObject type, got {params['context'].annotation}" + ) def test_update_performance_index_uses_typed_parameters(self): """update_performance_index should use ContextObject type.""" @@ -158,9 +158,9 @@ def test_update_performance_index_uses_typed_parameters(self): params = sig.parameters # Check context uses ContextObject type - assert "ContextObject" in str( - params["context"].annotation - ), f"context should use ContextObject type, got {params['context'].annotation}" + assert "ContextObject" in str(params["context"].annotation), ( + f"context should use ContextObject type, got {params['context'].annotation}" + ) def test_list_authorized_properties_uses_typed_parameters(self): """list_authorized_properties should use ContextObject type.""" @@ -170,9 +170,9 @@ def test_list_authorized_properties_uses_typed_parameters(self): params = sig.parameters # Check context uses ContextObject type - assert "ContextObject" in str( - params["context"].annotation - ), f"context should use ContextObject type, got {params['context'].annotation}" + assert "ContextObject" in str(params["context"].annotation), ( + f"context should use ContextObject type, got {params['context'].annotation}" + ) class TestMCPToolSchemaNotUntyped: diff --git a/tests/unit/test_naming_parameter_bug.py b/tests/unit/test_naming_parameter_bug.py index 61f7bf7bd..668951026 100644 --- a/tests/unit/test_naming_parameter_bug.py +++ b/tests/unit/test_naming_parameter_bug.py @@ -59,7 +59,11 @@ def test_keyword_arg_works_correctly(self): # Correct usage - using keyword argument context = build_order_name_context( - request, packages, start_time, end_time, tenant_gemini_key=gemini_key # Correct! + request, + packages, + start_time, + end_time, + tenant_gemini_key=gemini_key, # Correct! ) assert "brand_name" in context diff --git a/tests/unit/test_order_approval_service.py b/tests/unit/test_order_approval_service.py index 24fac73f0..d5b3cf9d7 100644 --- a/tests/unit/test_order_approval_service.py +++ b/tests/unit/test_order_approval_service.py @@ -278,6 +278,6 @@ def post_side_effect(*args, **kwargs): # Verify retry logic works - should be at least 3 attempts # Note: Due to test pollution in full suite, may see 4 calls, but minimum is 3 assert call_counter["count"] >= 3, f"Expected at least 3 retry attempts, got {call_counter['count']}" - assert ( - call_counter["count"] <= 4 - ), f"Expected at most 4 retry attempts (3 + 1 pollution), got {call_counter['count']}" + assert call_counter["count"] <= 4, ( + f"Expected at most 4 retry attempts (3 + 1 pollution), got {call_counter['count']}" + ) diff --git a/tests/unit/test_raw_function_parameter_validation.py b/tests/unit/test_raw_function_parameter_validation.py index 62a9373b3..0b4ce841d 100644 --- a/tests/unit/test_raw_function_parameter_validation.py +++ b/tests/unit/test_raw_function_parameter_validation.py @@ -52,9 +52,9 @@ def test_get_products_raw_parameters_valid(self): # Verify all should-be-in-helper params are actually in helper missing_in_helper = should_be_in_helper - helper_params - assert ( - not missing_in_helper - ), f"get_products_raw has parameters not in helper and not documented as valid: {missing_in_helper}" + assert not missing_in_helper, ( + f"get_products_raw has parameters not in helper and not documented as valid: {missing_in_helper}" + ) def test_all_raw_functions_have_context_parameter(self): """All _raw functions should accept a ctx parameter.""" diff --git a/tests/unit/test_schema_library_inheritance.py b/tests/unit/test_schema_library_inheritance.py index 102b4bd17..cb2ee21b4 100644 --- a/tests/unit/test_schema_library_inheritance.py +++ b/tests/unit/test_schema_library_inheritance.py @@ -41,9 +41,9 @@ def test_get_products_response_extends_library(self): from src.core.schemas import GetProductsResponse - assert issubclass( - GetProductsResponse, LibraryResponse - ), "GetProductsResponse must extend library type to inherit correct field types." + assert issubclass(GetProductsResponse, LibraryResponse), ( + "GetProductsResponse must extend library type to inherit correct field types." + ) def test_simple_types_extend_library(self): """Simple types with matching fields must extend library types.""" @@ -57,9 +57,9 @@ def test_simple_types_extend_library(self): from src.core.schemas import AggregatedTotals, DeliveryMeasurement, Measurement, Pagination assert issubclass(Measurement, LibraryMeasurement), "Measurement must extend library type." - assert issubclass( - DeliveryMeasurement, LibraryDeliveryMeasurement - ), "DeliveryMeasurement must extend library type." + assert issubclass(DeliveryMeasurement, LibraryDeliveryMeasurement), ( + "DeliveryMeasurement must extend library type." + ) assert issubclass(AggregatedTotals, LibraryAggregatedTotals), "AggregatedTotals must extend library type." # Pagination for list responses uses page-based pagination (limit/offset/total_pages) assert issubclass(Pagination, LibraryResponsePagination), "Pagination must extend library response pagination." @@ -93,9 +93,9 @@ def test_property_identifier_is_library_type(self): from src.core.schemas import PropertyIdentifier # PropertyIdentifier should be the property-specific library type (alias) - assert ( - PropertyIdentifier is PropertySpecificIdentifier - ), "PropertyIdentifier must be the property-specific Identifier type." + assert PropertyIdentifier is PropertySpecificIdentifier, ( + "PropertyIdentifier must be the property-specific Identifier type." + ) def test_document_schemas_not_extending_library(self): """Document which schemas exist in library but aren't extended locally. diff --git a/tests/unit/test_sync_creatives_format_validation.py b/tests/unit/test_sync_creatives_format_validation.py index 0c4f6536b..740d23c58 100644 --- a/tests/unit/test_sync_creatives_format_validation.py +++ b/tests/unit/test_sync_creatives_format_validation.py @@ -400,7 +400,6 @@ async def mock_list_all_formats(tenant_id=None): async def mock_get_format(agent_url, format_id): if "offline.example.com" in agent_url: raise ConnectionError("Connection refused") - return None # Format not found mock_registry = Mock() mock_registry.list_all_formats = mock_list_all_formats diff --git a/tests/unit/test_task_management_tools.py b/tests/unit/test_task_management_tools.py index 5094db583..143cd3f92 100644 --- a/tests/unit/test_task_management_tools.py +++ b/tests/unit/test_task_management_tools.py @@ -68,7 +68,6 @@ def test_list_tasks_returns_tasks(self, mock_db_session, sample_tenant, sample_w patch("src.core.main.set_current_tenant"), patch("src.core.main.get_db_session", return_value=mock_db_session), ): - mock_get_principal.return_value = ("principal_123", sample_tenant) result = list_tasks_fn(context=Mock()) @@ -92,7 +91,6 @@ def test_list_tasks_filters_by_status(self, mock_db_session, sample_tenant, samp patch("src.core.main.set_current_tenant"), patch("src.core.main.get_db_session", return_value=mock_db_session), ): - mock_get_principal.return_value = ("principal_123", sample_tenant) result = list_tasks_fn(status="requires_approval", context=Mock()) @@ -152,7 +150,6 @@ def test_get_task_returns_task_details(self, mock_db_session, sample_tenant, sam patch("src.core.main.set_current_tenant"), patch("src.core.main.get_db_session", return_value=mock_db_session), ): - mock_get_principal.return_value = ("principal_123", sample_tenant) result = get_task_fn(task_id="step_123", context=Mock()) @@ -171,7 +168,6 @@ def test_get_task_not_found_raises_error(self, mock_db_session, sample_tenant): patch("src.core.main.set_current_tenant"), patch("src.core.main.get_db_session", return_value=mock_db_session), ): - mock_get_principal.return_value = ("principal_123", sample_tenant) with pytest.raises(ValueError, match="not found"): @@ -228,7 +224,6 @@ def test_complete_task_updates_status(self, mock_db_session, sample_tenant, samp patch("src.core.main.set_current_tenant"), patch("src.core.main.get_db_session", return_value=mock_db_session), ): - mock_get_principal.return_value = ("principal_123", sample_tenant) result = complete_task_fn(task_id="step_123", status="completed", context=Mock()) @@ -245,7 +240,6 @@ def test_complete_task_rejects_invalid_status(self, mock_db_session, sample_tena patch("src.core.main.get_principal_from_context") as mock_get_principal, patch("src.core.main.set_current_tenant"), ): - mock_get_principal.return_value = ("principal_123", sample_tenant) with pytest.raises(ValueError, match="Invalid status"): From e8971f4a507372dc71d517d04cf0bcca9cca1a37 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 10 Feb 2026 18:40:34 +0100 Subject: [PATCH 02/18] fix: clean up targeting_overlay types and storage key mismatch - Remove Union[Targeting, Any] hack on MediaPackage.targeting_overlay, now that Targeting extends TargetingOverlay (salesagent-81n) - Override targeting_overlay on PackageRequest to use Targeting type ensuring request JSON gets full validation and legacy normalizer - Remove unused Union import from schemas.py - Fix storage key mismatch: media_buy_update now writes "targeting_overlay" instead of "targeting" (salesagent-dzr) - Add fallback reader in media_buy_create for pre-fix data stored under the old "targeting" key --- src/core/schemas.py | 7 +- src/core/tools/media_buy_create.py | 12 ++- src/core/tools/media_buy_update.py | 2 +- tests/unit/test_targeting_storage_key.py | 98 ++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_targeting_storage_key.py diff --git a/src/core/schemas.py b/src/core/schemas.py index 980c1d3ae..3957c7fe9 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -4,7 +4,7 @@ # --- V2.3 Pydantic Models (Bearer Auth, Restored & Complete) --- # --- MCP Status System (AdCP PR #77) --- from enum import Enum -from typing import Any, Literal, TypeAlias, Union +from typing import Any, Literal, TypeAlias from adcp import Error from adcp.types import CreateMediaBuyRequest as LibraryCreateMediaBuyRequest @@ -2604,6 +2604,8 @@ class PackageRequest(LibraryPackageRequest): description="Internal: List of creative IDs to assign (alternative to full creatives objects)", exclude=True, ) + # Override library TargetingOverlay -> our Targeting with internal fields + legacy normalizer + targeting_overlay: Targeting | None = None @model_validator(mode="before") @classmethod @@ -2999,8 +3001,7 @@ class MediaPackage(BaseModel): # Accept library FormatId (not our extended FormatId) to avoid validation errors # when Product from library returns LibraryFormatId instances format_ids: list[LibraryFormatId] # FormatId objects per AdCP spec - # Accept both Targeting (internal) and TargetingOverlay (adcp library) for compatibility - targeting_overlay: Union["Targeting", Any] | None = None + targeting_overlay: Targeting | None = None buyer_ref: str | None = None # Optional buyer reference from request package product_id: str | None = None # Product ID for this package budget: float | None = None # Budget allocation in the currency specified by the pricing option diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index b4c88cfb0..4f1e42d53 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -88,6 +88,7 @@ def validate_agent_url(url: str | None) -> bool: PackageRequest, Principal, Product, + Targeting, ) from src.core.schemas import ( url as make_url, @@ -664,11 +665,13 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, } # Get targeting_overlay from package_config if present + # Fallback to "targeting" key for data written before salesagent-dzr fix targeting_overlay = None - if "targeting_overlay" in package_config and package_config["targeting_overlay"]: + targeting_raw = package_config.get("targeting_overlay") or package_config.get("targeting") + if targeting_raw: from src.core.schemas import Targeting - targeting_overlay = Targeting(**package_config["targeting_overlay"]) + targeting_overlay = Targeting(**targeting_raw) # Create MediaPackage object (what adapters expect) # Note: Product model has 'formats' not 'format_ids' @@ -2850,10 +2853,11 @@ def _has_supported_key(url: str | None, fid: str, keys: set = product_format_key cpm=cpm, impressions=int(total_budget / cpm * 1000), format_ids=cast(list[Any], format_ids_to_use), - targeting_overlay=( + targeting_overlay=cast( + "Targeting | None", matching_package.targeting_overlay if matching_package and hasattr(matching_package, "targeting_overlay") - else None + else None, ), buyer_ref=package_buyer_ref, product_id=pkg_product.product_id, # Include product_id diff --git a/src/core/tools/media_buy_update.py b/src/core/tools/media_buy_update.py index 7b662f47c..c1685c950 100644 --- a/src/core/tools/media_buy_update.py +++ b/src/core/tools/media_buy_update.py @@ -1152,7 +1152,7 @@ def normalize_url(url: str | None) -> str | None: else pkg_update.targeting_overlay ) - media_package.package_config["targeting"] = targeting_dict + media_package.package_config["targeting_overlay"] = targeting_dict # Flag the JSON field as modified so SQLAlchemy persists it attributes.flag_modified(media_package, "package_config") session.commit() diff --git a/tests/unit/test_targeting_storage_key.py b/tests/unit/test_targeting_storage_key.py new file mode 100644 index 000000000..928dc3edc --- /dev/null +++ b/tests/unit/test_targeting_storage_key.py @@ -0,0 +1,98 @@ +"""Unit tests for targeting_overlay storage key consistency. + +Regression tests for salesagent-dzr: media_buy_update stored targeting under +"targeting" key but media_buy_create reads "targeting_overlay" key, causing +silent data loss on round-trip. +""" + +from unittest.mock import MagicMock + + +def _make_media_package_row(package_config: dict) -> MagicMock: + """Create a mock MediaBuyPackage DB row with given package_config.""" + row = MagicMock() + row.package_config = dict(package_config) # mutable copy + row.package_id = "pkg_001" + row.media_buy_id = "mb_001" + return row + + +class TestTargetingStorageKey: + """Verify targeting_overlay uses the correct key in package_config.""" + + def test_update_stores_under_targeting_overlay_key(self): + """media_buy_update must store targeting at 'targeting_overlay', not 'targeting'.""" + + # We can't easily call the full impl, so verify the storage key directly + # by checking the source code pattern. Instead, build a minimal scenario: + # Create a package_config dict, simulate what update does, and check the key. + from src.core.schemas import Targeting + + targeting = Targeting(geo_country_any_of=["US"]) + targeting_dict = targeting.model_dump(exclude_none=True) + + # Simulate what media_buy_update SHOULD do + package_config: dict = {"product_id": "prod_1"} + package_config["targeting_overlay"] = targeting_dict + + # The key must be "targeting_overlay", not "targeting" + assert "targeting_overlay" in package_config + assert "targeting" not in package_config + + def test_create_reads_targeting_overlay_key(self): + """media_buy_create reads from 'targeting_overlay' key in package_config.""" + from src.core.schemas import Targeting + + targeting = Targeting(geo_country_any_of=["US"], device_type_any_of=["mobile"]) + targeting_dict = targeting.model_dump(exclude_none=True) + + package_config = {"targeting_overlay": targeting_dict} + + # Simulate what media_buy_create does (line 669-672) + targeting_overlay = None + if "targeting_overlay" in package_config and package_config["targeting_overlay"]: + targeting_overlay = Targeting(**package_config["targeting_overlay"]) + + assert targeting_overlay is not None + assert targeting_overlay.device_type_any_of == ["mobile"] + + def test_create_reads_targeting_fallback_key(self): + """media_buy_create falls back to 'targeting' key for existing data.""" + from src.core.schemas import Targeting + + targeting = Targeting(geo_country_any_of=["US"], device_type_any_of=["desktop"]) + targeting_dict = targeting.model_dump(exclude_none=True) + + # Legacy data stored under "targeting" key + package_config = {"targeting": targeting_dict} + + # Simulate what media_buy_create SHOULD do with fallback + targeting_overlay = None + raw = package_config.get("targeting_overlay") or package_config.get("targeting") + if raw: + targeting_overlay = Targeting(**raw) + + assert targeting_overlay is not None + assert targeting_overlay.device_type_any_of == ["desktop"] + + def test_roundtrip_update_then_reconstruct(self): + """Targeting survives: update stores → create reads (roundtrip).""" + from src.core.schemas import Targeting + + # Step 1: update stores targeting + original = Targeting( + geo_country_any_of=["US", "CA"], + device_type_any_of=["mobile"], + ) + targeting_dict = original.model_dump(exclude_none=True) + + package_config: dict = {"product_id": "prod_1"} + package_config["targeting_overlay"] = targeting_dict # correct key + + # Step 2: create reads targeting + raw = package_config.get("targeting_overlay") or package_config.get("targeting") + assert raw is not None + reconstructed = Targeting(**raw) + + assert reconstructed.device_type_any_of == ["mobile"] + assert len(reconstructed.geo_countries) == 2 From 46e399500b416ec56baca225686f0db8502fe4b5 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 10 Feb 2026 19:55:39 +0100 Subject: [PATCH 03/18] refactor: update non-GAM adapters to read v3 structured geo fields - mock_ad_server: use geo_countries/geo_regions/geo_metros in dry-run logging - kevel: read v3 structured fields in _build_targeting, remove geo_city_any_of, add int() cast for FreqCapDuration (suppress_minutes is now float) - triton_digital: read v3 structured fields in _build_targeting - xandr: read geo_countries/geo_regions from top-level dict keys in _create_targeting_profile (was nested under "geo") - Add 16 regression tests for v3 geo field consumption Resolves: salesagent-fwm --- src/adapters/kevel.py | 29 ++-- src/adapters/mock_ad_server.py | 12 +- src/adapters/triton_digital.py | 14 +- src/adapters/xandr.py | 22 ++- tests/unit/test_adapter_v3_geo_fields.py | 174 +++++++++++++++++++++++ 5 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 tests/unit/test_adapter_v3_geo_fields.py diff --git a/src/adapters/kevel.py b/src/adapters/kevel.py index cfb6c67e6..3f48e3a5b 100644 --- a/src/adapters/kevel.py +++ b/src/adapters/kevel.py @@ -106,17 +106,18 @@ def _build_targeting(self, targeting_overlay): kevel_targeting = {} - # Geographic targeting + # Geographic targeting (v3 structured fields) geo = {} - if targeting_overlay.geo_country_any_of: - geo["countries"] = targeting_overlay.geo_country_any_of - if targeting_overlay.geo_region_any_of: - geo["regions"] = targeting_overlay.geo_region_any_of - if targeting_overlay.geo_metro_any_of: - # Convert string metro codes to integers - geo["metros"] = [int(m) for m in targeting_overlay.geo_metro_any_of] - if targeting_overlay.geo_city_any_of: - geo["cities"] = targeting_overlay.geo_city_any_of + if targeting_overlay.geo_countries: + geo["countries"] = [c.root if hasattr(c, "root") else str(c) for c in targeting_overlay.geo_countries] + if targeting_overlay.geo_regions: + geo["regions"] = [r.root if hasattr(r, "root") else str(r) for r in targeting_overlay.geo_regions] + if targeting_overlay.geo_metros: + # Extract metro values from structured objects and convert to integers + metro_values = [] + for metro in targeting_overlay.geo_metros: + metro_values.extend(metro.values) + geo["metros"] = [int(m) for m in metro_values] if geo: kevel_targeting["geo"] = geo @@ -292,7 +293,7 @@ def create_media_buy( if getattr(freq_cap, "scope", None) == "package": self.log(" 'FreqCap': 1, # Suppress after 1 impression") self.log( - f" 'FreqCapDuration': {max(1, freq_cap.suppress_minutes // 60)}, # {freq_cap.suppress_minutes} minutes" + f" 'FreqCapDuration': {int(max(1, freq_cap.suppress_minutes // 60))}, # {freq_cap.suppress_minutes} minutes" ) self.log(" 'FreqCapType': 1 # per user") @@ -351,9 +352,9 @@ def create_media_buy( # Kevel's FreqCap = 1 impression # FreqCapDuration in hours, convert from minutes flight_payload["FreqCap"] = 1 - flight_payload["FreqCapDuration"] = max( - 1, freq_cap.suppress_minutes // 60 - ) # Convert to hours, minimum 1 + flight_payload["FreqCapDuration"] = int( + max(1, freq_cap.suppress_minutes // 60) + ) # Convert to hours, minimum 1 (int for Kevel API) flight_payload["FreqCapType"] = 1 # 1 = per user (cookie-based) flight_response = requests.post(f"{self.base_url}/flight", headers=self.headers, json=flight_payload) diff --git a/src/adapters/mock_ad_server.py b/src/adapters/mock_ad_server.py index f948269ff..d8d4df4c2 100644 --- a/src/adapters/mock_ad_server.py +++ b/src/adapters/mock_ad_server.py @@ -789,12 +789,12 @@ def _create_media_buy_immediate( for package in packages: if package.targeting_overlay: targeting = package.targeting_overlay - if getattr(targeting, "geo_country_any_of", None): - self.log(f" 'countries': {targeting.geo_country_any_of},") - if getattr(targeting, "geo_region_any_of", None): - self.log(f" 'regions': {targeting.geo_region_any_of},") - if getattr(targeting, "geo_metro_any_of", None): - self.log(f" 'metros': {targeting.geo_metro_any_of},") + if targeting.geo_countries: + self.log(f" 'countries': {targeting.geo_countries},") + if targeting.geo_regions: + self.log(f" 'regions': {targeting.geo_regions},") + if targeting.geo_metros: + self.log(f" 'metros': {targeting.geo_metros},") if getattr(targeting, "key_value_pairs", None): self.log(f" 'key_values': {targeting.key_value_pairs},") if getattr(targeting, "media_type_any_of", None): diff --git a/src/adapters/triton_digital.py b/src/adapters/triton_digital.py index a68b65fd1..a00d107a7 100644 --- a/src/adapters/triton_digital.py +++ b/src/adapters/triton_digital.py @@ -91,13 +91,15 @@ def _build_targeting(self, targeting_overlay): triton_targeting = {} - # Geographic targeting (audio market focused) + # Geographic targeting (v3 structured fields, audio market focused) targeting_obj = {} - if targeting_overlay.geo_country_any_of: - targeting_obj["countries"] = targeting_overlay.geo_country_any_of - if targeting_overlay.geo_region_any_of: - targeting_obj["states"] = targeting_overlay.geo_region_any_of - if targeting_overlay.geo_metro_any_of: + if targeting_overlay.geo_countries: + targeting_obj["countries"] = [ + c.root if hasattr(c, "root") else str(c) for c in targeting_overlay.geo_countries + ] + if targeting_overlay.geo_regions: + targeting_obj["states"] = [r.root if hasattr(r, "root") else str(r) for r in targeting_overlay.geo_regions] + if targeting_overlay.geo_metros: # Map to audio market names if possible targeting_obj["markets"] = [] # Would need metro-to-market mapping diff --git a/src/adapters/xandr.py b/src/adapters/xandr.py index 58950e4a5..e27a6403a 100644 --- a/src/adapters/xandr.py +++ b/src/adapters/xandr.py @@ -650,20 +650,18 @@ def _create_targeting_profile(self, targeting: dict[str, Any]) -> int: } } - # Map targeting to Xandr format - if "geo" in targeting: - geo = targeting["geo"] - if "countries" in geo: - profile_data["profile"]["country_targets"] = geo["countries"] - if "regions" in geo: - profile_data["profile"]["region_targets"] = geo["regions"] - if "cities" in geo: - profile_data["profile"]["city_targets"] = geo["cities"] - - if "device_types" in targeting: + # Map v3 targeting fields to Xandr format + if "geo_countries" in targeting: + profile_data["profile"]["country_targets"] = targeting["geo_countries"] + if "geo_regions" in targeting: + profile_data["profile"]["region_targets"] = targeting["geo_regions"] + + if "device_type_any_of" in targeting: # Map to Xandr device types - convert to strings for API device_map = {"desktop": "1", "mobile": "2", "tablet": "3", "ctv": "4"} - profile_data["profile"]["device_type_targets"] = [device_map.get(d, "1") for d in targeting["device_types"]] + profile_data["profile"]["device_type_targets"] = [ + device_map.get(d, "1") for d in targeting["device_type_any_of"] + ] response = self._make_request("POST", "/profile", profile_data) return response["response"]["profile"]["id"] diff --git a/tests/unit/test_adapter_v3_geo_fields.py b/tests/unit/test_adapter_v3_geo_fields.py new file mode 100644 index 000000000..2a08433bf --- /dev/null +++ b/tests/unit/test_adapter_v3_geo_fields.py @@ -0,0 +1,174 @@ +"""Tests for non-GAM adapter v3 geo field consumption. + +Regression tests for salesagent-fwm: ensures all non-GAM adapters read +v3 structured geo fields (geo_countries, geo_regions, geo_metros) instead +of the legacy flat fields (geo_country_any_of, geo_region_any_of, etc.). +""" + +from unittest.mock import MagicMock + +from src.core.schemas import FrequencyCap, Targeting + + +def _make_principal(adapter_key: str = "kevel") -> MagicMock: + """Create a minimal mock Principal for adapter construction.""" + principal = MagicMock() + principal.get_adapter_id.return_value = "12345" + principal.name = "test_principal" + principal.principal_id = "test_001" + principal.platform_mappings = {adapter_key: {"advertiser_id": "12345"}} + return principal + + +class TestKevelV3GeoFields: + """Test Kevel adapter reads v3 structured geo fields.""" + + def _make_kevel(self): + from src.adapters.kevel import Kevel + + principal = _make_principal("kevel") + config = {"network_id": "1", "api_key": "test"} + return Kevel(config, principal, dry_run=True) + + def test_build_targeting_v3_geo_countries(self): + kevel = self._make_kevel() + targeting = Targeting(geo_countries=["US", "CA"]) + result = kevel._build_targeting(targeting) + assert result["geo"]["countries"] == ["US", "CA"] + + def test_build_targeting_v3_geo_regions(self): + kevel = self._make_kevel() + targeting = Targeting(geo_countries=["US"], geo_regions=["US-NY", "US-CA"]) + result = kevel._build_targeting(targeting) + assert result["geo"]["regions"] == ["US-NY", "US-CA"] + + def test_build_targeting_v3_geo_metros_cast_to_int(self): + kevel = self._make_kevel() + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501", "803"]}], + ) + result = kevel._build_targeting(targeting) + assert result["geo"]["metros"] == [501, 803] + + def test_build_targeting_no_city_field(self): + """geo_city_any_of was removed in v3; _build_targeting should not reference it.""" + kevel = self._make_kevel() + targeting = Targeting(geo_countries=["US"]) + result = kevel._build_targeting(targeting) + # No "cities" key in result since city targeting was removed + assert "cities" not in result.get("geo", {}) + + def test_freq_cap_duration_is_int(self): + """suppress_minutes is float after salesagent-rlb; FreqCapDuration must be int.""" + freq_cap = FrequencyCap(suppress_minutes=120.0, scope="package") + result = int(max(1, freq_cap.suppress_minutes // 60)) + assert isinstance(result, int) + assert result == 2 + + def test_freq_cap_duration_fractional_hours(self): + """Partial hours should floor to nearest int.""" + freq_cap = FrequencyCap(suppress_minutes=90.0, scope="package") + result = int(max(1, freq_cap.suppress_minutes // 60)) + assert isinstance(result, int) + assert result == 1 + + def test_freq_cap_duration_minimum_one(self): + """FreqCapDuration must be at least 1 hour.""" + freq_cap = FrequencyCap(suppress_minutes=30.0, scope="package") + result = int(max(1, freq_cap.suppress_minutes // 60)) + assert isinstance(result, int) + assert result == 1 + + +class TestTritonV3GeoFields: + """Test Triton Digital adapter reads v3 structured geo fields.""" + + def _make_triton(self): + from src.adapters.triton_digital import TritonDigital + + principal = _make_principal("triton") + config = {"auth_token": "test"} + return TritonDigital(config, principal, dry_run=True) + + def test_build_targeting_v3_geo_countries(self): + triton = self._make_triton() + targeting = Targeting(geo_countries=["US", "CA"]) + result = triton._build_targeting(targeting) + assert result["targeting"]["countries"] == ["US", "CA"] + + def test_build_targeting_v3_geo_regions(self): + triton = self._make_triton() + targeting = Targeting(geo_countries=["US"], geo_regions=["US-NY", "US-CA"]) + result = triton._build_targeting(targeting) + assert result["targeting"]["states"] == ["US-NY", "US-CA"] + + def test_build_targeting_v3_geo_metros(self): + triton = self._make_triton() + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + ) + result = triton._build_targeting(targeting) + # Triton maps metros to markets (empty list since no mapping exists) + assert "markets" in result["targeting"] + + +class TestXandrV3GeoFields: + """Test Xandr adapter reads v3 field names from targeting dict.""" + + def test_targeting_dict_has_v3_country_field(self): + """model_dump() produces geo_countries, not nested geo.countries.""" + targeting = Targeting(geo_countries=["US", "CA"]) + targeting_dict = targeting.model_dump(exclude_none=True) + assert "geo_countries" in targeting_dict + assert targeting_dict["geo_countries"] == ["US", "CA"] + + def test_targeting_dict_has_v3_region_field(self): + targeting = Targeting(geo_countries=["US"], geo_regions=["US-NY"]) + targeting_dict = targeting.model_dump(exclude_none=True) + assert "geo_regions" in targeting_dict + + def test_create_targeting_profile_reads_v3_fields(self): + """_create_targeting_profile should read geo_countries/geo_regions from dict.""" + from src.adapters.xandr import XandrAdapter + + targeting = Targeting(geo_countries=["US", "CA"], geo_regions=["US-NY"]) + targeting_dict = targeting.model_dump(exclude_none=True) + + # Call _create_targeting_profile via unbound method with mock self + mock _make_request + mock_self = MagicMock(spec=XandrAdapter) + mock_self._make_request.return_value = {"response": {"profile": {"id": 999}}} + + profile_id = XandrAdapter._create_targeting_profile(mock_self, targeting_dict) + assert profile_id == 999 + + # Verify the POST call included country/region targets + call_args = mock_self._make_request.call_args + profile_data = call_args[0][2] # positional: method, endpoint, data + assert profile_data["profile"]["country_targets"] == ["US", "CA"] + assert profile_data["profile"]["region_targets"] == ["US-NY"] + + +class TestMockAdapterV3GeoFields: + """Test mock adapter uses v3 field names in logging.""" + + def test_targeting_geo_countries_accessible(self): + """Targeting.geo_countries works for mock adapter's logging.""" + targeting = Targeting(geo_countries=["US", "CA"]) + assert targeting.geo_countries is not None + assert len(targeting.geo_countries) == 2 + + def test_targeting_geo_regions_accessible(self): + targeting = Targeting(geo_countries=["US"], geo_regions=["US-NY", "US-CA"]) + assert targeting.geo_regions is not None + assert len(targeting.geo_regions) == 2 + + def test_targeting_geo_metros_accessible(self): + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501", "803"]}], + ) + assert targeting.geo_metros is not None + assert len(targeting.geo_metros) == 1 + assert targeting.geo_metros[0].values == ["501", "803"] From 2425718dda9270ef852422dbee277e0525b514f7 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 10 Feb 2026 21:00:21 +0100 Subject: [PATCH 04/18] refactor: rewrite overlay validation with explicit field-to-dimension mapping Replace _any_of/_none_of suffix-stripping heuristic with FIELD_TO_DIMENSION dict for v3 structured field names. Both inclusion and exclusion geo fields are now mapped explicitly. --- src/services/targeting_capabilities.py | 56 ++++++++++++-- tests/unit/test_overlay_validation_v3.py | 96 ++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_overlay_validation_v3.py diff --git a/src/services/targeting_capabilities.py b/src/services/targeting_capabilities.py index 1bb89d1d5..2ae667927 100644 --- a/src/services/targeting_capabilities.py +++ b/src/services/targeting_capabilities.py @@ -92,20 +92,62 @@ def get_aee_signal_dimensions() -> list[str]: return [name for name, cap in TARGETING_CAPABILITIES.items() if cap.axe_signal] +# Explicit mapping from Targeting field names to capability dimension names. +# v3 structured fields (geo_countries, geo_regions, etc.) map directly without +# suffix-stripping. Both inclusion and exclusion variants map to the same +# capability so exclusion fields are validated alongside inclusion fields. +FIELD_TO_DIMENSION: dict[str, str] = { + # v3 geo fields — inclusion + "geo_countries": "geo_country", + "geo_regions": "geo_region", + "geo_metros": "geo_metro", + "geo_postal_areas": "geo_zip", + # v3 geo fields — exclusion + "geo_countries_exclude": "geo_country", + "geo_regions_exclude": "geo_region", + "geo_metros_exclude": "geo_metro", + "geo_postal_areas_exclude": "geo_zip", + # Device / OS / Browser + "device_type_any_of": "device_type", + "device_type_none_of": "device_type", + "os_any_of": "os", + "os_none_of": "os", + "browser_any_of": "browser", + "browser_none_of": "browser", + # Content + "content_cat_any_of": "content_category", + "content_cat_none_of": "content_category", + # Media + "media_type_any_of": "media_type", + "media_type_none_of": "media_type", + # Audience + "audiences_any_of": "audience_segment", + "audiences_none_of": "audience_segment", + # Frequency capping + "frequency_cap": "frequency_cap", + # Managed-only fields + "key_value_pairs": "key_value_pairs", + # Custom + "custom": "custom", +} + + def validate_overlay_targeting(targeting: dict[str, Any]) -> list[str]: - """ - Validate that targeting only uses allowed overlay dimensions. + """Validate that targeting only uses allowed overlay dimensions. + + Uses an explicit field-to-dimension mapping (FIELD_TO_DIMENSION) instead of + suffix-stripping heuristics. Both inclusion and exclusion field variants + are mapped so that exclusion fields are validated alongside their inclusion + counterparts. Returns list of violations (managed-only dimensions used). """ violations = [] - managed_only = get_managed_only_dimensions() + managed_only = set(get_managed_only_dimensions()) for key in targeting: - # Check base dimension (remove _any_of/_none_of suffix) - base_dimension = key.replace("_any_of", "").replace("_none_of", "") - - if base_dimension in managed_only: + dimension = FIELD_TO_DIMENSION.get(key) + if dimension and dimension in managed_only: violations.append(f"{key} is managed-only and cannot be set via overlay") return violations diff --git a/tests/unit/test_overlay_validation_v3.py b/tests/unit/test_overlay_validation_v3.py new file mode 100644 index 000000000..ce891a507 --- /dev/null +++ b/tests/unit/test_overlay_validation_v3.py @@ -0,0 +1,96 @@ +"""Tests for validate_overlay_targeting with v3 field names. + +Regression tests for salesagent-9nd: ensures overlay validation works with +v3 structured field names (geo_countries, geo_regions, etc.) without +_any_of/_none_of suffix-stripping. +""" + +from src.services.targeting_capabilities import validate_overlay_targeting + + +class TestV3GeoFieldsPassValidation: + """v3 geo inclusion fields should not produce violations.""" + + def test_geo_countries_no_violation(self): + violations = validate_overlay_targeting({"geo_countries": ["US", "CA"]}) + assert violations == [] + + def test_geo_regions_no_violation(self): + violations = validate_overlay_targeting({"geo_regions": ["US-NY"]}) + assert violations == [] + + def test_geo_metros_no_violation(self): + violations = validate_overlay_targeting({"geo_metros": [{"system": "nielsen_dma", "values": ["501"]}]}) + assert violations == [] + + def test_geo_postal_areas_no_violation(self): + violations = validate_overlay_targeting({"geo_postal_areas": ["90210"]}) + assert violations == [] + + +class TestV3GeoExclusionFieldsValidated: + """v3 geo exclusion fields must also be validated (not silently ignored).""" + + def test_geo_countries_exclude_no_violation(self): + violations = validate_overlay_targeting({"geo_countries_exclude": ["RU"]}) + assert violations == [] + + def test_geo_regions_exclude_no_violation(self): + violations = validate_overlay_targeting({"geo_regions_exclude": ["US-TX"]}) + assert violations == [] + + def test_geo_metros_exclude_no_violation(self): + violations = validate_overlay_targeting({"geo_metros_exclude": [{"system": "nielsen_dma", "values": ["501"]}]}) + assert violations == [] + + def test_geo_postal_areas_exclude_no_violation(self): + violations = validate_overlay_targeting({"geo_postal_areas_exclude": ["90210"]}) + assert violations == [] + + +class TestManagedOnlyFieldsCaught: + """Managed-only fields must produce violations.""" + + def test_key_value_pairs_violation(self): + violations = validate_overlay_targeting({"key_value_pairs": {"foo": "bar"}}) + assert len(violations) == 1 + assert "key_value_pairs" in violations[0] + assert "managed-only" in violations[0] + + def test_mixed_overlay_and_managed(self): + """Valid overlay fields alongside managed-only should only flag managed-only.""" + violations = validate_overlay_targeting( + {"geo_countries": ["US"], "device_type_any_of": ["mobile"], "key_value_pairs": {"foo": "bar"}} + ) + assert len(violations) == 1 + assert "key_value_pairs" in violations[0] + + +class TestSuffixStrippingRemoved: + """No _any_of/_none_of suffix-stripping heuristic remains.""" + + def test_device_type_any_of_no_violation(self): + """Fields still using _any_of suffix should work via explicit mapping.""" + violations = validate_overlay_targeting({"device_type_any_of": ["mobile"]}) + assert violations == [] + + def test_os_none_of_no_violation(self): + """Fields using _none_of suffix should work via explicit mapping.""" + violations = validate_overlay_targeting({"os_none_of": ["android"]}) + assert violations == [] + + +class TestEdgeCases: + """Edge cases for the validation function.""" + + def test_empty_targeting_no_violations(self): + violations = validate_overlay_targeting({}) + assert violations == [] + + def test_frequency_cap_no_violation(self): + violations = validate_overlay_targeting({"frequency_cap": {"suppress_minutes": 60}}) + assert violations == [] + + def test_custom_field_no_violation(self): + violations = validate_overlay_targeting({"custom": {"key": "value"}}) + assert violations == [] From 4e70981c14b1df01e6399b95a5743eff6a244c05 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 10 Feb 2026 21:36:30 +0100 Subject: [PATCH 05/18] feat: add geo system validation to TargetingCapabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add validate_geo_systems() method that checks geo_metros and geo_postal_areas (both include and exclude) against the adapter's declared system support. Returns descriptive errors naming the unsupported system and listing supported alternatives. Unknown/custom systems are rejected by default — no adapter currently handles them, so silent pass-through would mask failures. --- src/adapters/base.py | 66 +++++- tests/unit/test_validate_geo_systems.py | 256 ++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_validate_geo_systems.py diff --git a/src/adapters/base.py b/src/adapters/base.py index efeeba412..7bf7e1261 100644 --- a/src/adapters/base.py +++ b/src/adapters/base.py @@ -1,7 +1,12 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from src.core.schemas import Targeting from pydantic import BaseModel, ConfigDict, Field from rich.console import Console @@ -49,6 +54,65 @@ class TargetingCapabilities: fr_code_postal: bool = False # French postal code au_postcode: bool = False # Australian postcode + # Maps from AdCP enum value → dataclass field name. + _METRO_FIELDS: ClassVar[tuple[str, ...]] = ( + "nielsen_dma", + "eurostat_nuts2", + "uk_itl1", + "uk_itl2", + ) + _POSTAL_FIELDS: ClassVar[tuple[str, ...]] = ( + "us_zip", + "us_zip_plus_four", + "gb_outward", + "gb_full", + "ca_fsa", + "ca_full", + "de_plz", + "fr_code_postal", + "au_postcode", + ) + + def validate_geo_systems(self, targeting: Targeting) -> list[str]: + """Validate that targeting geo systems are supported by this adapter. + + Checks both include and exclude fields for geo_metros and geo_postal_areas. + Returns list of errors naming the unsupported system and supported alternatives. + """ + errors: list[str] = [] + + # Collect all metro items from include + exclude + metros: list[Any] = [] + if targeting.geo_metros: + metros.extend(targeting.geo_metros) + if targeting.geo_metros_exclude: + metros.extend(targeting.geo_metros_exclude) + + if metros: + supported = [f for f in self._METRO_FIELDS if getattr(self, f)] + for metro in metros: + system = metro.system.value if hasattr(metro.system, "value") else str(metro.system) + if not getattr(self, system, False): + alt = ", ".join(supported) if supported else "none" + errors.append(f"Unsupported metro system '{system}'. This adapter supports: {alt}") + + # Collect all postal items from include + exclude + postals: list[Any] = [] + if targeting.geo_postal_areas: + postals.extend(targeting.geo_postal_areas) + if targeting.geo_postal_areas_exclude: + postals.extend(targeting.geo_postal_areas_exclude) + + if postals: + supported = [f for f in self._POSTAL_FIELDS if getattr(self, f)] + for area in postals: + system = area.system.value if hasattr(area.system, "value") else str(area.system) + if not getattr(self, system, False): + alt = ", ".join(supported) if supported else "none" + errors.append(f"Unsupported postal system '{system}'. This adapter supports: {alt}") + + return errors + @dataclass class AdapterCapabilities: diff --git a/tests/unit/test_validate_geo_systems.py b/tests/unit/test_validate_geo_systems.py new file mode 100644 index 000000000..44721d3da --- /dev/null +++ b/tests/unit/test_validate_geo_systems.py @@ -0,0 +1,256 @@ +"""Tests for TargetingCapabilities.validate_geo_systems(). + +Regression tests for salesagent-xy0: ensures adapter geo system validation +checks both include and exclude fields and returns descriptive error messages. +""" + +from src.adapters.base import TargetingCapabilities +from src.core.schemas import Targeting + + +class TestEmptyTargeting: + """No geo fields → no errors.""" + + def test_empty_targeting(self): + caps = TargetingCapabilities() + targeting = Targeting() + assert caps.validate_geo_systems(targeting) == [] + + def test_only_countries(self): + caps = TargetingCapabilities(geo_countries=True) + targeting = Targeting(geo_countries=["US"]) + assert caps.validate_geo_systems(targeting) == [] + + +class TestMetroSystemValidation: + """Metro system checks for geo_metros and geo_metros_exclude.""" + + def test_supported_metro_system_no_error(self): + caps = TargetingCapabilities(nielsen_dma=True) + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + ) + assert caps.validate_geo_systems(targeting) == [] + + def test_unsupported_metro_system_error(self): + caps = TargetingCapabilities(nielsen_dma=True) + targeting = Targeting( + geo_countries=["GB"], + geo_metros=[{"system": "uk_itl1", "values": ["TLG"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 1 + assert "uk_itl1" in errors[0] + assert "nielsen_dma" in errors[0] + + def test_unsupported_metro_exclude_error(self): + caps = TargetingCapabilities(nielsen_dma=True) + targeting = Targeting( + geo_countries=["DE"], + geo_metros_exclude=[{"system": "eurostat_nuts2", "values": ["DE1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 1 + assert "eurostat_nuts2" in errors[0] + + def test_multiple_unsupported_metro_systems(self): + caps = TargetingCapabilities() # no metro support at all + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_metros_exclude=[{"system": "eurostat_nuts2", "values": ["DE1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 2 + + def test_no_adapter_metro_support_lists_none(self): + """When adapter supports no metro systems, error says 'none'.""" + caps = TargetingCapabilities() + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 1 + assert "none" in errors[0] + + def test_custom_metro_system_rejected(self): + """Custom metro system is rejected unless adapter explicitly supports it.""" + caps = TargetingCapabilities(nielsen_dma=True) + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "custom", "values": ["CUSTOM_1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 1 + assert "custom" in errors[0] + assert "nielsen_dma" in errors[0] + + +class TestPostalSystemValidation: + """Postal system checks for geo_postal_areas and geo_postal_areas_exclude.""" + + def test_supported_postal_system_no_error(self): + caps = TargetingCapabilities(us_zip=True) + targeting = Targeting( + geo_countries=["US"], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + ) + assert caps.validate_geo_systems(targeting) == [] + + def test_unsupported_postal_system_error(self): + caps = TargetingCapabilities(us_zip=True) + targeting = Targeting( + geo_countries=["GB"], + geo_postal_areas=[{"system": "gb_outward", "values": ["SW1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 1 + assert "gb_outward" in errors[0] + assert "us_zip" in errors[0] + + def test_unsupported_postal_exclude_error(self): + caps = TargetingCapabilities(us_zip=True) + targeting = Targeting( + geo_countries=["DE"], + geo_postal_areas_exclude=[{"system": "de_plz", "values": ["10115"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 1 + assert "de_plz" in errors[0] + + def test_no_adapter_postal_support_lists_none(self): + caps = TargetingCapabilities() + targeting = Targeting( + geo_countries=["US"], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 1 + assert "none" in errors[0] + + +class TestMixedValidation: + """Both metro and postal validation in a single call.""" + + def test_both_metro_and_postal_errors(self): + caps = TargetingCapabilities(geo_countries=True) + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 2 + + def test_all_supported_no_errors(self): + caps = TargetingCapabilities( + geo_countries=True, + nielsen_dma=True, + us_zip=True, + ) + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + ) + assert caps.validate_geo_systems(targeting) == [] + + def test_include_and_exclude_both_checked(self): + """Both include and exclude fields contribute errors.""" + caps = TargetingCapabilities(geo_countries=True) + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_metros_exclude=[{"system": "uk_itl1", "values": ["TLG"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + geo_postal_areas_exclude=[{"system": "gb_outward", "values": ["SW1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert len(errors) == 4 + + +class TestErrorMessageFormat: + """Error messages include the unsupported system and supported alternatives.""" + + def test_error_names_unsupported_system(self): + caps = TargetingCapabilities(nielsen_dma=True) + targeting = Targeting( + geo_countries=["DE"], + geo_metros=[{"system": "eurostat_nuts2", "values": ["DE1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert "eurostat_nuts2" in errors[0] + + def test_error_names_supported_alternatives(self): + caps = TargetingCapabilities(nielsen_dma=True, uk_itl1=True) + targeting = Targeting( + geo_countries=["DE"], + geo_metros=[{"system": "eurostat_nuts2", "values": ["DE1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert "nielsen_dma" in errors[0] + assert "uk_itl1" in errors[0] + + def test_error_format_matches_spec(self): + """Error format: "Unsupported metro system ''. This adapter supports: ".""" + caps = TargetingCapabilities(nielsen_dma=True) + targeting = Targeting( + geo_countries=["DE"], + geo_metros=[{"system": "eurostat_nuts2", "values": ["DE1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert errors[0].startswith("Unsupported metro system") + assert "This adapter supports:" in errors[0] + + def test_postal_error_format_matches_spec(self): + caps = TargetingCapabilities(us_zip=True) + targeting = Targeting( + geo_countries=["GB"], + geo_postal_areas=[{"system": "gb_outward", "values": ["SW1"]}], + ) + errors = caps.validate_geo_systems(targeting) + assert errors[0].startswith("Unsupported postal system") + assert "This adapter supports:" in errors[0] + + +def _custom_metro_targeting() -> Targeting: + return Targeting( + geo_countries=["US"], + geo_metros=[{"system": "custom", "values": ["CUSTOM_1"]}], + ) + + +class TestNoAdapterSupportsCustomMetro: + """Verify that every real adapter rejects custom metro systems. + + Each adapter's get_targeting_capabilities() declares what it supports. + None currently declare custom metro support, so custom must be rejected. + """ + + def test_gam_rejects_custom_metro(self): + from src.adapters.google_ad_manager import GoogleAdManager + + caps = GoogleAdManager.get_targeting_capabilities(None) + errors = caps.validate_geo_systems(_custom_metro_targeting()) + assert any("custom" in e for e in errors) + + def test_kevel_rejects_custom_metro(self): + """Kevel inherits base default (geo_countries only).""" + caps = TargetingCapabilities(geo_countries=True) # base default + errors = caps.validate_geo_systems(_custom_metro_targeting()) + assert any("custom" in e for e in errors) + + def test_triton_rejects_custom_metro(self): + """Triton inherits base default (geo_countries only).""" + caps = TargetingCapabilities(geo_countries=True) # base default + errors = caps.validate_geo_systems(_custom_metro_targeting()) + assert any("custom" in e for e in errors) + + def test_mock_rejects_custom_metro(self): + from src.adapters.mock_ad_server import MockAdServer + + caps = MockAdServer.get_targeting_capabilities(None) + errors = caps.validate_geo_systems(_custom_metro_targeting()) + assert any("custom" in e for e in errors) From 39eef9b17744955ec2a8a26868ec20202957925f Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 11 Feb 2026 01:03:21 +0100 Subject: [PATCH 06/18] fix: reject city targeting in overlay validation instead of silently dropping Buyer sending geo_city_any_of in targeting_overlay was silently accepted by validate_overlay_targeting (not in FIELD_TO_DIMENSION) and later silently dropped by Targeting._normalize_legacy_fields. Changes: - Add access='removed' level to TargetingCapability - Change geo_city from access='overlay' to access='removed' - Add geo_city_any_of/geo_city_none_of to FIELD_TO_DIMENSION - Update validate_overlay_targeting to reject removed dimensions - Add get_removed_dimensions() helper Closes salesagent-hfz --- src/core/schemas.py | 2 +- src/services/targeting_capabilities.py | 21 +++++++- tests/unit/test_city_targeting_rejected.py | 61 ++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_city_targeting_rejected.py diff --git a/src/core/schemas.py b/src/core/schemas.py index 3957c7fe9..cab2442f7 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -859,7 +859,7 @@ class TargetingCapability(BaseModel): """Defines targeting dimension capabilities and restrictions.""" dimension: str # e.g., "geo_country", "key_value" - access: Literal["overlay", "managed_only", "both"] = "overlay" + access: Literal["overlay", "managed_only", "both", "removed"] = "overlay" description: str | None = None allowed_values: list[str] | None = None # For restricted value sets axe_signal: bool | None = False # Whether this is an AXE signal dimension diff --git a/src/services/targeting_capabilities.py b/src/services/targeting_capabilities.py index 2ae667927..59ac56595 100644 --- a/src/services/targeting_capabilities.py +++ b/src/services/targeting_capabilities.py @@ -17,7 +17,11 @@ ), "geo_region": TargetingCapability(dimension="geo_region", access="overlay", description="State/province targeting"), "geo_metro": TargetingCapability(dimension="geo_metro", access="overlay", description="Metro/DMA targeting"), - "geo_city": TargetingCapability(dimension="geo_city", access="overlay", description="City-level targeting"), + "geo_city": TargetingCapability( + dimension="geo_city", + access="removed", + description="City-level targeting (removed in v3, no adapter supports it)", + ), "geo_zip": TargetingCapability(dimension="geo_zip", access="overlay", description="Postal code targeting"), # Device targeting - available for overlay "device_type": TargetingCapability( @@ -87,6 +91,11 @@ def get_managed_only_dimensions() -> list[str]: return [name for name, cap in TARGETING_CAPABILITIES.items() if cap.access == "managed_only"] +def get_removed_dimensions() -> list[str]: + """Get list of dimensions that have been removed.""" + return [name for name, cap in TARGETING_CAPABILITIES.items() if cap.access == "removed"] + + def get_aee_signal_dimensions() -> list[str]: """Get list of dimensions used for AEE signals.""" return [name for name, cap in TARGETING_CAPABILITIES.items() if cap.axe_signal] @@ -125,6 +134,9 @@ def get_aee_signal_dimensions() -> list[str]: "audiences_none_of": "audience_segment", # Frequency capping "frequency_cap": "frequency_cap", + # Removed fields (city targeting removed in v3) + "geo_city_any_of": "geo_city", + "geo_city_none_of": "geo_city", # Managed-only fields "key_value_pairs": "key_value_pairs", # Custom @@ -144,10 +156,15 @@ def validate_overlay_targeting(targeting: dict[str, Any]) -> list[str]: """ violations = [] managed_only = set(get_managed_only_dimensions()) + removed = set(get_removed_dimensions()) for key in targeting: dimension = FIELD_TO_DIMENSION.get(key) - if dimension and dimension in managed_only: + if not dimension: + continue + if dimension in managed_only: violations.append(f"{key} is managed-only and cannot be set via overlay") + elif dimension in removed: + violations.append(f"{key} is not supported (targeting dimension '{dimension}' has been removed)") return violations diff --git a/tests/unit/test_city_targeting_rejected.py b/tests/unit/test_city_targeting_rejected.py new file mode 100644 index 000000000..0f8f775fa --- /dev/null +++ b/tests/unit/test_city_targeting_rejected.py @@ -0,0 +1,61 @@ +"""Tests for city targeting rejection. + +Regression tests for salesagent-hfz: ensures geo_city_any_of/geo_city_none_of +sent in targeting_overlay are caught by validate_overlay_targeting instead of +being silently dropped. +""" + +from src.services.targeting_capabilities import ( + TARGETING_CAPABILITIES, + get_overlay_dimensions, + validate_overlay_targeting, +) + + +class TestCityFieldsRejected: + """geo_city_any_of and geo_city_none_of must produce violations.""" + + def test_geo_city_any_of_violation(self): + violations = validate_overlay_targeting({"geo_city_any_of": ["New York"]}) + assert len(violations) == 1 + assert "geo_city_any_of" in violations[0] + + def test_geo_city_none_of_violation(self): + violations = validate_overlay_targeting({"geo_city_none_of": ["Los Angeles"]}) + assert len(violations) == 1 + assert "geo_city_none_of" in violations[0] + + def test_both_city_fields_produce_two_violations(self): + violations = validate_overlay_targeting({"geo_city_any_of": ["NYC"], "geo_city_none_of": ["LA"]}) + assert len(violations) == 2 + + def test_city_error_mentions_removed(self): + """Error message should indicate city targeting is removed/not supported.""" + violations = validate_overlay_targeting({"geo_city_any_of": ["NYC"]}) + assert "removed" in violations[0].lower() or "not supported" in violations[0].lower() + + +class TestCityMixedWithValidFields: + """Valid overlay fields alongside city fields should only flag city.""" + + def test_valid_geo_plus_city_only_city_flagged(self): + violations = validate_overlay_targeting({"geo_countries": ["US"], "geo_city_any_of": ["NYC"]}) + assert len(violations) == 1 + assert "geo_city_any_of" in violations[0] + + def test_device_plus_city_only_city_flagged(self): + violations = validate_overlay_targeting({"device_type_any_of": ["mobile"], "geo_city_none_of": ["LA"]}) + assert len(violations) == 1 + assert "geo_city_none_of" in violations[0] + + +class TestGeoCityDimensionRemoved: + """geo_city dimension should not appear in overlay dimensions.""" + + def test_geo_city_not_in_overlay_dimensions(self): + overlay = get_overlay_dimensions() + assert "geo_city" not in overlay + + def test_geo_city_access_is_removed(self): + cap = TARGETING_CAPABILITIES["geo_city"] + assert cap.access == "removed" From e87b0defa8f2a6bed32ae53f94daed27951ced10 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 11 Feb 2026 01:08:14 +0100 Subject: [PATCH 07/18] =?UTF-8?q?fix:=20harden=20legacy=20normalizer=20?= =?UTF-8?q?=E2=80=94=20bare=20regions,=20both-present=20guard,=20city=20fl?= =?UTF-8?q?ag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes in normalize_legacy_geo(): - Convert bare US state codes to ISO 3166-2 ("CA" → "US-CA") - Drop v2 keys when v3 already present (prevents model_extra leak) - Set _had_city_targeting flag instead of silently dropping city fields Closes salesagent-uca --- src/core/schemas.py | 33 ++++- tests/unit/test_targeting_normalizer.py | 156 ++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_targeting_normalizer.py diff --git a/src/core/schemas.py b/src/core/schemas.py index cab2442f7..1e0038ec5 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -961,44 +961,63 @@ def normalize_legacy_geo(cls, values: Any) -> Any: v = values.pop("geo_country_any_of") if v: values["geo_countries"] = v # GeoCountry is RootModel[str], accepts plain strings + elif "geo_country_any_of" in values: + values.pop("geo_country_any_of") if "geo_country_none_of" in values and "geo_countries_exclude" not in values: v = values.pop("geo_country_none_of") if v: values["geo_countries_exclude"] = v + elif "geo_country_none_of" in values: + values.pop("geo_country_none_of") - # Region: flat list → GeoRegion strings (assumes already ISO 3166-2 format) + # Region: flat list → GeoRegion strings + # Legacy data stores bare US state codes ("CA"); GeoRegion requires ISO 3166-2 ("US-CA"). + # All legacy records are US-only, so bare codes without "-" get a "US-" prefix. if "geo_region_any_of" in values and "geo_regions" not in values: v = values.pop("geo_region_any_of") if v: - values["geo_regions"] = v # GeoRegion is RootModel[str] + values["geo_regions"] = [r if "-" in r else f"US-{r}" for r in v] + elif "geo_region_any_of" in values: + values.pop("geo_region_any_of") if "geo_region_none_of" in values and "geo_regions_exclude" not in values: v = values.pop("geo_region_none_of") if v: - values["geo_regions_exclude"] = v + values["geo_regions_exclude"] = [r if "-" in r else f"US-{r}" for r in v] + elif "geo_region_none_of" in values: + values.pop("geo_region_none_of") # Metro: flat list → structured {system, values} if "geo_metro_any_of" in values and "geo_metros" not in values: v = values.pop("geo_metro_any_of") if v: values["geo_metros"] = [{"system": "nielsen_dma", "values": v}] + elif "geo_metro_any_of" in values: + values.pop("geo_metro_any_of") if "geo_metro_none_of" in values and "geo_metros_exclude" not in values: v = values.pop("geo_metro_none_of") if v: values["geo_metros_exclude"] = [{"system": "nielsen_dma", "values": v}] + elif "geo_metro_none_of" in values: + values.pop("geo_metro_none_of") # Zip/Postal: flat list → structured {system, values} if "geo_zip_any_of" in values and "geo_postal_areas" not in values: v = values.pop("geo_zip_any_of") if v: values["geo_postal_areas"] = [{"system": "us_zip", "values": v}] + elif "geo_zip_any_of" in values: + values.pop("geo_zip_any_of") if "geo_zip_none_of" in values and "geo_postal_areas_exclude" not in values: v = values.pop("geo_zip_none_of") if v: values["geo_postal_areas_exclude"] = [{"system": "us_zip", "values": v}] + elif "geo_zip_none_of" in values: + values.pop("geo_zip_none_of") - # Remove city fields (no longer supported, no adapter ever used them) - values.pop("geo_city_any_of", None) - values.pop("geo_city_none_of", None) + # City targeting removed in v3. Set a transient flag so downstream consumers + # (e.g. GAM build_targeting) can raise an explicit error instead of silently ignoring. + if values.pop("geo_city_any_of", None) or values.pop("geo_city_none_of", None): + values["_had_city_targeting"] = True return values @@ -1095,6 +1114,7 @@ def model_dump(self, **kwargs): "created_at", "updated_at", "metadata", # Internal fields + "_had_city_targeting", # Transient normalizer signal } ) kwargs["exclude"] = exclude @@ -1105,6 +1125,7 @@ def model_dump_internal(self, **kwargs): """Dump including internal and managed fields for database storage and internal processing.""" # Don't exclude internal fields or managed fields kwargs.pop("exclude", None) # Remove any exclude parameter + kwargs["exclude"] = {"_had_city_targeting"} # Always exclude transient flags return super().model_dump(**kwargs) def dict(self, **kwargs): diff --git a/tests/unit/test_targeting_normalizer.py b/tests/unit/test_targeting_normalizer.py new file mode 100644 index 000000000..d55b563a8 --- /dev/null +++ b/tests/unit/test_targeting_normalizer.py @@ -0,0 +1,156 @@ +"""Tests for Targeting.normalize_legacy_geo() model validator. + +Regression tests for salesagent-uca: ensures the legacy normalizer correctly +converts bare region codes to ISO 3166-2, drops v2 keys when v3 present, +and sets _had_city_targeting flag for city fields. +""" + +from src.core.schemas import Targeting + + +class TestBareRegionCodeConversion: + """Bare US state codes must be converted to ISO 3166-2 format.""" + + def test_bare_codes_get_us_prefix(self): + t = Targeting(**{"geo_region_any_of": ["CA", "NY"]}) + assert t.geo_regions is not None + codes = [r.root if hasattr(r, "root") else str(r) for r in t.geo_regions] + assert codes == ["US-CA", "US-NY"] + + def test_already_iso_codes_unchanged(self): + t = Targeting(**{"geo_region_any_of": ["US-CA", "US-NY"]}) + codes = [r.root if hasattr(r, "root") else str(r) for r in t.geo_regions] + assert codes == ["US-CA", "US-NY"] + + def test_mixed_bare_and_iso(self): + t = Targeting(**{"geo_region_any_of": ["CA", "US-NY"]}) + codes = [r.root if hasattr(r, "root") else str(r) for r in t.geo_regions] + assert codes == ["US-CA", "US-NY"] + + def test_exclude_variant_converted(self): + t = Targeting(**{"geo_region_none_of": ["TX", "FL"]}) + assert t.geo_regions_exclude is not None + codes = [r.root if hasattr(r, "root") else str(r) for r in t.geo_regions_exclude] + assert codes == ["US-TX", "US-FL"] + + +class TestBothPresentGuard: + """When both v2 and v3 keys present, v2 must be dropped (no model_extra leak).""" + + def test_country_v2_dropped_when_v3_present(self): + t = Targeting(**{"geo_country_any_of": ["US"], "geo_countries": ["CA"]}) + # v3 preserved + codes = [c.root if hasattr(c, "root") else str(c) for c in t.geo_countries] + assert codes == ["CA"] + # v2 not in model_extra + assert "geo_country_any_of" not in t.model_extra + + def test_country_exclude_v2_dropped(self): + t = Targeting(**{"geo_country_none_of": ["RU"], "geo_countries_exclude": ["CN"]}) + codes = [c.root if hasattr(c, "root") else str(c) for c in t.geo_countries_exclude] + assert codes == ["CN"] + assert "geo_country_none_of" not in t.model_extra + + def test_region_v2_dropped_when_v3_present(self): + t = Targeting(**{"geo_region_any_of": ["CA"], "geo_regions": ["US-NY"]}) + codes = [r.root if hasattr(r, "root") else str(r) for r in t.geo_regions] + assert codes == ["US-NY"] + assert "geo_region_any_of" not in t.model_extra + + def test_region_exclude_v2_dropped(self): + t = Targeting(**{"geo_region_none_of": ["TX"], "geo_regions_exclude": ["US-FL"]}) + codes = [r.root if hasattr(r, "root") else str(r) for r in t.geo_regions_exclude] + assert codes == ["US-FL"] + assert "geo_region_none_of" not in t.model_extra + + def test_metro_v2_dropped_when_v3_present(self): + v3_metros = [{"system": "nielsen_dma", "values": ["501"]}] + t = Targeting(**{"geo_metro_any_of": ["600"], "geo_metros": v3_metros}) + assert len(t.geo_metros) == 1 + assert "geo_metro_any_of" not in t.model_extra + + def test_metro_exclude_v2_dropped(self): + v3 = [{"system": "nielsen_dma", "values": ["501"]}] + t = Targeting(**{"geo_metro_none_of": ["600"], "geo_metros_exclude": v3}) + assert len(t.geo_metros_exclude) == 1 + assert "geo_metro_none_of" not in t.model_extra + + def test_zip_v2_dropped_when_v3_present(self): + v3 = [{"system": "us_zip", "values": ["10001"]}] + t = Targeting(**{"geo_zip_any_of": ["90210"], "geo_postal_areas": v3}) + assert len(t.geo_postal_areas) == 1 + assert "geo_zip_any_of" not in t.model_extra + + def test_zip_exclude_v2_dropped(self): + v3 = [{"system": "us_zip", "values": ["90210"]}] + t = Targeting(**{"geo_zip_none_of": ["10001"], "geo_postal_areas_exclude": v3}) + assert len(t.geo_postal_areas_exclude) == 1 + assert "geo_zip_none_of" not in t.model_extra + + def test_empty_v2_list_also_dropped(self): + t = Targeting(**{"geo_country_any_of": [], "geo_countries": ["US"]}) + codes = [c.root if hasattr(c, "root") else str(c) for c in t.geo_countries] + assert codes == ["US"] + assert "geo_country_any_of" not in t.model_extra + + def test_empty_v2_without_v3_does_not_set_v3(self): + t = Targeting(**{"geo_country_any_of": []}) + assert t.geo_countries is None + + +class TestCityTargetingFlag: + """City fields must set _had_city_targeting flag instead of being silently dropped.""" + + def test_city_any_of_sets_flag(self): + t = Targeting(**{"geo_city_any_of": ["Chicago"]}) + assert getattr(t, "_had_city_targeting", False) is True + + def test_city_none_of_sets_flag(self): + t = Targeting(**{"geo_city_none_of": ["LA"]}) + assert getattr(t, "_had_city_targeting", False) is True + + def test_both_city_fields_set_flag(self): + t = Targeting(**{"geo_city_any_of": ["NYC"], "geo_city_none_of": ["LA"]}) + assert getattr(t, "_had_city_targeting", False) is True + + def test_no_city_fields_no_flag(self): + t = Targeting(**{"geo_countries": ["US"]}) + assert getattr(t, "_had_city_targeting", False) is False + + def test_flag_excluded_from_model_dump(self): + t = Targeting(**{"geo_city_any_of": ["Chicago"], "geo_countries": ["US"]}) + d = t.model_dump() + assert "_had_city_targeting" not in d + + def test_flag_excluded_from_model_dump_internal(self): + t = Targeting(**{"geo_city_any_of": ["Chicago"], "geo_countries": ["US"]}) + d = t.model_dump_internal() + assert "_had_city_targeting" not in d + + def test_flag_accessible_via_getattr(self): + t = Targeting(**{"geo_city_any_of": ["NYC"]}) + assert getattr(t, "_had_city_targeting", False) is True + + +class TestRoundtrip: + """model_dump → Targeting(**data) should not leak v2 keys.""" + + def test_roundtrip_no_v2_keys(self): + t1 = Targeting(**{"geo_country_any_of": ["US"], "geo_region_any_of": ["CA"]}) + d = t1.model_dump(exclude_none=True) + # No v2 keys in output + assert "geo_country_any_of" not in d + assert "geo_region_any_of" not in d + # Reconstruct + t2 = Targeting(**d) + d2 = t2.model_dump(exclude_none=True) + assert d2 == d + + def test_roundtrip_city_flag_not_persisted(self): + t1 = Targeting(**{"geo_city_any_of": ["NYC"], "geo_countries": ["US"]}) + d = t1.model_dump(exclude_none=True) + assert "_had_city_targeting" not in d + assert "geo_city_any_of" not in d + # Reconstruct — no flag on the new object + t2 = Targeting(**d) + assert getattr(t2, "_had_city_targeting", False) is False From 4bbb29f8bebc2469e7f23820ddc768578d0b112d Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 11 Feb 2026 01:35:18 +0100 Subject: [PATCH 08/18] feat: update GAM targeting manager for v3 structured geo fields - build_targeting() uses geo_countries, geo_regions, geo_metros (v3) - _lookup_region_id() accepts ISO 3166-2 format ("US-CA") - GeoMetro system validation: nielsen_dma required, others rejected - GeoPostalArea handled: raises until static mapping implemented - _had_city_targeting flag triggers explicit ValueError - Exclusion fields (geo_*_exclude) processed into GAM excluded locations - validate_targeting() updated to use v3 field accessors - int() cast on FrequencyCap suppress_minutes arithmetic for GAM API --- src/adapters/gam/managers/orders.py | 9 +- src/adapters/gam/managers/targeting.py | 160 ++++++++------- tests/unit/test_gam_targeting_v3.py | 261 +++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 77 deletions(-) create mode 100644 tests/unit/test_gam_targeting_v3.py diff --git a/src/adapters/gam/managers/orders.py b/src/adapters/gam/managers/orders.py index f1b86b50a..077c471b5 100644 --- a/src/adapters/gam/managers/orders.py +++ b/src/adapters/gam/managers/orders.py @@ -941,16 +941,17 @@ def log(msg): # AdCP: suppress_minutes (e.g., 60 = 1 hour) # GAM: maxImpressions=1, numTimeUnits=X, timeUnit="MINUTE"/"HOUR"/"DAY" - # Determine best GAM time unit + # Determine best GAM time unit (int() cast needed because + # suppress_minutes is float after salesagent-rlb, GAM API expects int) if freq_cap.suppress_minutes < 60: time_unit = "MINUTE" - num_time_units = freq_cap.suppress_minutes + num_time_units = int(freq_cap.suppress_minutes) elif freq_cap.suppress_minutes < 1440: # Less than 24 hours time_unit = "HOUR" - num_time_units = freq_cap.suppress_minutes // 60 + num_time_units = int(freq_cap.suppress_minutes // 60) else: time_unit = "DAY" - num_time_units = freq_cap.suppress_minutes // 1440 + num_time_units = int(freq_cap.suppress_minutes // 1440) frequency_caps.append( { diff --git a/src/adapters/gam/managers/targeting.py b/src/adapters/gam/managers/targeting.py index d37db8f36..7b867ac48 100644 --- a/src/adapters/gam/managers/targeting.py +++ b/src/adapters/gam/managers/targeting.py @@ -569,16 +569,21 @@ def _build_groups_custom_targeting_structure(self, targeting_config: dict[str, A } def _lookup_region_id(self, region_code: str) -> str | None: - """Look up region ID across all countries. + """Look up region ID, accepting ISO 3166-2 format ("US-CA") or bare codes. Args: - region_code: The region code to look up + region_code: Region code in ISO 3166-2 ("US-CA") or bare ("CA") format Returns: GAM region ID if found, None otherwise """ - # First check if we have country context (not implemented yet) - # For now, search across all countries + # ISO 3166-2 format: use country prefix for direct lookup + if "-" in region_code: + country, region = region_code.split("-", 1) + country_regions = self.geo_region_map.get(country, {}) + return country_regions.get(region) + + # Bare code: search across all countries (backward compat) for _country, regions in self.geo_region_map.items(): if region_code in regions: return regions[region_code] @@ -614,10 +619,12 @@ def validate_targeting(self, targeting_overlay) -> list[str]: if targeting_overlay.media_type_any_of and "audio" in targeting_overlay.media_type_any_of: unsupported.append("Audio media type not supported by Google Ad Manager") - # City and postal targeting require GAM API lookups (not implemented) - if targeting_overlay.geo_city_any_of or targeting_overlay.geo_city_none_of: - unsupported.append("City targeting requires GAM geo service integration (not implemented)") - if targeting_overlay.geo_zip_any_of or targeting_overlay.geo_zip_none_of: + # City targeting removed in v3; check transient flag from normalizer + if getattr(targeting_overlay, "_had_city_targeting", False): + unsupported.append("City targeting is not supported (removed in v3)") + + # Postal code targeting requires GAM geo service integration (not implemented) + if targeting_overlay.geo_postal_areas or targeting_overlay.geo_postal_areas_exclude: unsupported.append("Postal code targeting requires GAM geo service integration (not implemented)") # GAM supports all other standard targeting dimensions @@ -644,95 +651,102 @@ def build_targeting(self, targeting_overlay) -> dict[str, Any]: # Geographic targeting geo_targeting: dict[str, Any] = {} - # Build targeted locations - only for supported geo features + # City targeting removed in v3; check transient flag from normalizer + if getattr(targeting_overlay, "_had_city_targeting", False): + raise ValueError( + "City targeting requested but not supported (removed in v3). " + "Use geo_metros for metropolitan area targeting instead." + ) + + # Postal code targeting not implemented in static mapping - fail loudly + if targeting_overlay.geo_postal_areas: + raise ValueError( + f"Postal code targeting requested but not implemented in GAM static mapping. " + f"Cannot fulfill buyer contract for postal areas: {targeting_overlay.geo_postal_areas}." + ) + if targeting_overlay.geo_postal_areas_exclude: + raise ValueError( + f"Postal code exclusion requested but not implemented in GAM static mapping. " + f"Cannot fulfill buyer contract for excluded postal areas: {targeting_overlay.geo_postal_areas_exclude}." + ) + + # Build targeted locations if any( [ - targeting_overlay.geo_country_any_of, - targeting_overlay.geo_region_any_of, - targeting_overlay.geo_metro_any_of, + targeting_overlay.geo_countries, + targeting_overlay.geo_regions, + targeting_overlay.geo_metros, ] ): geo_targeting["targetedLocations"] = [] - # Map countries - if targeting_overlay.geo_country_any_of: - for country in targeting_overlay.geo_country_any_of: - if country in self.geo_country_map: - geo_targeting["targetedLocations"].append({"id": self.geo_country_map[country]}) + # Map countries (GeoCountry.root → plain string) + if targeting_overlay.geo_countries: + for country in targeting_overlay.geo_countries: + code = country.root if hasattr(country, "root") else str(country) + if code in self.geo_country_map: + geo_targeting["targetedLocations"].append({"id": self.geo_country_map[code]}) else: - logger.warning(f"Country code '{country}' not in GAM mapping") + logger.warning(f"Country code '{code}' not in GAM mapping") - # Map regions - if targeting_overlay.geo_region_any_of: - for region in targeting_overlay.geo_region_any_of: - region_id = self._lookup_region_id(region) + # Map regions (GeoRegion.root → ISO 3166-2 string) + if targeting_overlay.geo_regions: + for region in targeting_overlay.geo_regions: + code = region.root if hasattr(region, "root") else str(region) + region_id = self._lookup_region_id(code) if region_id: geo_targeting["targetedLocations"].append({"id": region_id}) else: - logger.warning(f"Region code '{region}' not in GAM mapping") - - # Map metros (DMAs) - if targeting_overlay.geo_metro_any_of: - for metro in targeting_overlay.geo_metro_any_of: - if metro in self.geo_metro_map: - geo_targeting["targetedLocations"].append({"id": self.geo_metro_map[metro]}) - else: - logger.warning(f"Metro code '{metro}' not in GAM mapping") - - # City and postal code targeting not supported - fail loudly - if targeting_overlay.geo_city_any_of: - raise ValueError( - f"City targeting requested but not supported. " - f"Cannot fulfill buyer contract for cities: {targeting_overlay.geo_city_any_of}. " - f"Use geo_metro_any_of for metropolitan area targeting instead." - ) - if targeting_overlay.geo_zip_any_of: - raise ValueError( - f"Postal code targeting requested but not supported. " - f"Cannot fulfill buyer contract for postal codes: {targeting_overlay.geo_zip_any_of}. " - f"Use geo_metro_any_of for metropolitan area targeting instead." - ) - - # Build excluded locations - only for supported geo features + logger.warning(f"Region code '{code}' not in GAM mapping") + + # Map metros (GeoMetro: validate system, extract values) + if targeting_overlay.geo_metros: + for metro in targeting_overlay.geo_metros: + if metro.system.value != "nielsen_dma": + raise ValueError( + f"Unsupported metro system '{metro.system.value}'. GAM only supports nielsen_dma." + ) + for dma_code in metro.values: + if dma_code in self.geo_metro_map: + geo_targeting["targetedLocations"].append({"id": self.geo_metro_map[dma_code]}) + else: + logger.warning(f"Metro code '{dma_code}' not in GAM mapping") + + # Build excluded locations if any( [ - targeting_overlay.geo_country_none_of, - targeting_overlay.geo_region_none_of, - targeting_overlay.geo_metro_none_of, + targeting_overlay.geo_countries_exclude, + targeting_overlay.geo_regions_exclude, + targeting_overlay.geo_metros_exclude, ] ): geo_targeting["excludedLocations"] = [] # Map excluded countries - if targeting_overlay.geo_country_none_of: - for country in targeting_overlay.geo_country_none_of: - if country in self.geo_country_map: - geo_targeting["excludedLocations"].append({"id": self.geo_country_map[country]}) + if targeting_overlay.geo_countries_exclude: + for country in targeting_overlay.geo_countries_exclude: + code = country.root if hasattr(country, "root") else str(country) + if code in self.geo_country_map: + geo_targeting["excludedLocations"].append({"id": self.geo_country_map[code]}) # Map excluded regions - if targeting_overlay.geo_region_none_of: - for region in targeting_overlay.geo_region_none_of: - region_id = self._lookup_region_id(region) + if targeting_overlay.geo_regions_exclude: + for region in targeting_overlay.geo_regions_exclude: + code = region.root if hasattr(region, "root") else str(region) + region_id = self._lookup_region_id(code) if region_id: geo_targeting["excludedLocations"].append({"id": region_id}) # Map excluded metros - if targeting_overlay.geo_metro_none_of: - for metro in targeting_overlay.geo_metro_none_of: - if metro in self.geo_metro_map: - geo_targeting["excludedLocations"].append({"id": self.geo_metro_map[metro]}) - - # City and postal code exclusions not supported - fail loudly - if targeting_overlay.geo_city_none_of: - raise ValueError( - f"City exclusion requested but not supported. " - f"Cannot fulfill buyer contract for excluded cities: {targeting_overlay.geo_city_none_of}." - ) - if targeting_overlay.geo_zip_none_of: - raise ValueError( - f"Postal code exclusion requested but not supported. " - f"Cannot fulfill buyer contract for excluded postal codes: {targeting_overlay.geo_zip_none_of}." - ) + if targeting_overlay.geo_metros_exclude: + for metro in targeting_overlay.geo_metros_exclude: + if metro.system.value != "nielsen_dma": + raise ValueError( + f"Unsupported metro system '{metro.system.value}'. GAM only supports nielsen_dma." + ) + for dma_code in metro.values: + if dma_code in self.geo_metro_map: + geo_targeting["excludedLocations"].append({"id": self.geo_metro_map[dma_code]}) if geo_targeting: gam_targeting["geoTargeting"] = geo_targeting diff --git a/tests/unit/test_gam_targeting_v3.py b/tests/unit/test_gam_targeting_v3.py new file mode 100644 index 000000000..0c3e59255 --- /dev/null +++ b/tests/unit/test_gam_targeting_v3.py @@ -0,0 +1,261 @@ +"""Tests for GAM targeting manager v3 structured field support. + +Regression tests for salesagent-oee: ensures GAM targeting manager correctly +processes v3 structured geo fields (geo_countries, geo_regions, geo_metros, +geo_postal_areas) and their exclusion variants, handles _had_city_targeting +flag, and applies int() cast to FrequencyCap float arithmetic. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from src.adapters.gam.managers.targeting import GAMTargetingManager +from src.core.schemas import Targeting + + +@pytest.fixture +def gam_manager(): + """Create a GAMTargetingManager with test geo mappings, bypassing DB/file I/O.""" + with patch("src.core.database.database_session.get_db_session") as mock_session: + mock_db = MagicMock() + mock_session.return_value.__enter__.return_value = mock_db + mock_config = MagicMock() + mock_config.axe_include_key = None + mock_config.axe_exclude_key = None + mock_config.axe_macro_key = None + mock_config.custom_targeting_keys = {} + mock_db.scalars.return_value.first.return_value = mock_config + + manager = GAMTargetingManager.__new__(GAMTargetingManager) + manager.tenant_id = "test" + manager.gam_client = None + manager.axe_include_key = None + manager.axe_exclude_key = None + manager.axe_macro_key = None + manager.custom_targeting_key_ids = {} + # Test geo mappings + manager.geo_country_map = {"US": "2840", "CA": "2124", "GB": "2826"} + manager.geo_region_map = { + "US": {"CA": "21137", "NY": "21167", "TX": "21176"}, + "GB": {"ENG": "20339"}, + } + manager.geo_metro_map = {"501": "1003374", "803": "1003389"} + return manager + + +class TestBuildTargetingGeoCountries: + """v3 geo_countries → GAM targeted/excluded locations.""" + + def test_countries_targeted(self, gam_manager): + targeting = Targeting(geo_countries=["US", "CA"]) + result = gam_manager.build_targeting(targeting) + locations = result["geoTargeting"]["targetedLocations"] + ids = [loc["id"] for loc in locations] + assert "2840" in ids # US + assert "2124" in ids # CA + + def test_countries_excluded(self, gam_manager): + targeting = Targeting(geo_countries=["US"], geo_countries_exclude=["GB"]) + result = gam_manager.build_targeting(targeting) + excluded = result["geoTargeting"]["excludedLocations"] + assert any(loc["id"] == "2826" for loc in excluded) + + def test_unknown_country_skipped(self, gam_manager): + targeting = Targeting(geo_countries=["ZZ"]) + result = gam_manager.build_targeting(targeting) + # No targeted locations since ZZ is unknown + geo = result.get("geoTargeting", {}) + locations = geo.get("targetedLocations", []) + assert len(locations) == 0 + + +class TestBuildTargetingGeoRegions: + """v3 geo_regions (ISO 3166-2) → GAM targeted/excluded locations.""" + + def test_iso_region_targeted(self, gam_manager): + targeting = Targeting(geo_countries=["US"], geo_regions=["US-CA"]) + result = gam_manager.build_targeting(targeting) + locations = result["geoTargeting"]["targetedLocations"] + ids = [loc["id"] for loc in locations] + assert "21137" in ids # US-CA + + def test_region_excluded(self, gam_manager): + targeting = Targeting(geo_countries=["US"], geo_regions_exclude=["US-NY"]) + result = gam_manager.build_targeting(targeting) + excluded = result["geoTargeting"]["excludedLocations"] + assert any(loc["id"] == "21167" for loc in excluded) + + def test_unknown_region_skipped(self, gam_manager): + targeting = Targeting(geo_countries=["US"], geo_regions=["US-ZZ"]) + result = gam_manager.build_targeting(targeting) + geo = result.get("geoTargeting", {}) + # Only country location, no region + locations = geo.get("targetedLocations", []) + region_ids = [loc["id"] for loc in locations if loc["id"] != "2840"] + assert len(region_ids) == 0 + + +class TestLookupRegionIdISO: + """_lookup_region_id must accept ISO 3166-2 format.""" + + def test_iso_format_splits(self, gam_manager): + assert gam_manager._lookup_region_id("US-CA") == "21137" + + def test_iso_format_gb(self, gam_manager): + assert gam_manager._lookup_region_id("GB-ENG") == "20339" + + def test_bare_code_still_works(self, gam_manager): + """Backward compat: bare region code searched across all countries.""" + assert gam_manager._lookup_region_id("CA") == "21137" + + def test_unknown_returns_none(self, gam_manager): + assert gam_manager._lookup_region_id("US-ZZ") is None + + def test_unknown_country_returns_none(self, gam_manager): + assert gam_manager._lookup_region_id("XX-CA") is None + + +class TestBuildTargetingGeoMetros: + """v3 geo_metros (structured GeoMetro) → GAM targeted/excluded locations.""" + + def test_nielsen_dma_targeted(self, gam_manager): + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501", "803"]}], + ) + result = gam_manager.build_targeting(targeting) + locations = result["geoTargeting"]["targetedLocations"] + ids = [loc["id"] for loc in locations] + assert "1003374" in ids # DMA 501 + assert "1003389" in ids # DMA 803 + + def test_nielsen_dma_excluded(self, gam_manager): + targeting = Targeting( + geo_countries=["US"], + geo_metros_exclude=[{"system": "nielsen_dma", "values": ["501"]}], + ) + result = gam_manager.build_targeting(targeting) + excluded = result["geoTargeting"]["excludedLocations"] + assert any(loc["id"] == "1003374" for loc in excluded) + + def test_unsupported_metro_system_raises(self, gam_manager): + targeting = Targeting( + geo_countries=["GB"], + geo_metros=[{"system": "uk_itl1", "values": ["TLG"]}], + ) + with pytest.raises(ValueError, match="nielsen_dma"): + gam_manager.build_targeting(targeting) + + def test_unsupported_metro_system_in_exclude_raises(self, gam_manager): + targeting = Targeting( + geo_countries=["GB"], + geo_metros_exclude=[{"system": "eurostat_nuts2", "values": ["DE1"]}], + ) + with pytest.raises(ValueError, match="nielsen_dma"): + gam_manager.build_targeting(targeting) + + def test_unknown_dma_code_skipped(self, gam_manager): + targeting = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["999"]}], + ) + result = gam_manager.build_targeting(targeting) + geo = result.get("geoTargeting", {}) + locations = geo.get("targetedLocations", []) + # Only country, no metro (999 not in map) + metro_ids = [loc["id"] for loc in locations if loc["id"] != "2840"] + assert len(metro_ids) == 0 + + +class TestBuildTargetingGeoPostalAreas: + """v3 geo_postal_areas → raises ValueError (GAM zip not in static mapping).""" + + def test_us_zip_raises_not_implemented(self, gam_manager): + targeting = Targeting( + geo_countries=["US"], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + ) + with pytest.raises(ValueError, match="[Pp]ostal"): + gam_manager.build_targeting(targeting) + + def test_unsupported_postal_system_raises(self, gam_manager): + targeting = Targeting( + geo_countries=["GB"], + geo_postal_areas=[{"system": "gb_outward", "values": ["SW1"]}], + ) + with pytest.raises(ValueError, match="[Pp]ostal"): + gam_manager.build_targeting(targeting) + + def test_postal_exclude_raises(self, gam_manager): + targeting = Targeting( + geo_countries=["US"], + geo_postal_areas_exclude=[{"system": "us_zip", "values": ["90210"]}], + ) + with pytest.raises(ValueError, match="[Pp]ostal"): + gam_manager.build_targeting(targeting) + + +class TestBuildTargetingCityRemoved: + """_had_city_targeting flag must trigger ValueError in build_targeting.""" + + def test_city_flag_raises(self, gam_manager): + targeting = Targeting(geo_countries=["US"], geo_city_any_of=["Chicago"]) + assert getattr(targeting, "_had_city_targeting", False) is True + with pytest.raises(ValueError, match="[Cc]ity"): + gam_manager.build_targeting(targeting) + + def test_no_city_flag_no_error(self, gam_manager): + targeting = Targeting(geo_countries=["US"]) + result = gam_manager.build_targeting(targeting) + # Should succeed without city error + assert "geoTargeting" in result + + +class TestValidateTargetingV3: + """validate_targeting uses v3 fields, not v2.""" + + def test_city_flag_reported(self, gam_manager): + targeting = Targeting(geo_city_any_of=["NYC"]) + unsupported = gam_manager.validate_targeting(targeting) + assert any("city" in u.lower() for u in unsupported) + + def test_postal_areas_reported(self, gam_manager): + targeting = Targeting( + geo_countries=["US"], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + ) + unsupported = gam_manager.validate_targeting(targeting) + assert any("postal" in u.lower() for u in unsupported) + + +class TestFrequencyCapIntCast: + """suppress_minutes float arithmetic must produce int for GAM API.""" + + def test_hours_cast_to_int(self): + """suppress_minutes=120.0 (2 hours) → numTimeUnits must be int 2.""" + from src.core.schemas import FrequencyCap + + cap = FrequencyCap(suppress_minutes=120.0) + # Simulate the GAM conversion logic + num_time_units = int(cap.suppress_minutes // 60) + assert isinstance(num_time_units, int) + assert num_time_units == 2 + + def test_days_cast_to_int(self): + """suppress_minutes=2880.0 (2 days) → numTimeUnits must be int 2.""" + from src.core.schemas import FrequencyCap + + cap = FrequencyCap(suppress_minutes=2880.0) + num_time_units = int(cap.suppress_minutes // 1440) + assert isinstance(num_time_units, int) + assert num_time_units == 2 + + def test_minutes_stays_int(self): + """suppress_minutes=30 (minutes) → numTimeUnits should be int.""" + from src.core.schemas import FrequencyCap + + cap = FrequencyCap(suppress_minutes=30) + num_time_units = int(cap.suppress_minutes) + assert isinstance(num_time_units, int) + assert num_time_units == 30 From 024a6c4a8ae97e2f69cebe4831d71c1c51b5f552 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 11 Feb 2026 01:58:00 +0100 Subject: [PATCH 09/18] refactor: rename v2 flat geo fields to v3 structured names codebase-wide Remove backward-compat properties (geo_country_any_of, etc.) from Targeting class since all adapters now use v3 fields directly. Update 28 files: - Test fixtures, builders, conftest to use geo_countries/geo_regions/geo_metros - TypeScript types updated with structured metro/postal types - Database seeds, HTML templates, docs, examples updated - Legacy normalizer preserved for DB backward compatibility Closes #1006 (salesagent-1hn) --- docs/adapters/mock/README.md | 4 +- docs/development/architecture.md | 2 +- docs/development/contributing.md | 4 +- examples/upstream_product_catalog_server.py | 8 +- examples/upstream_with_implementation.py | 10 +-- scripts/setup/init_database.py | 10 +-- src/core/database/database.py | 4 +- src/core/schemas.py | 80 ------------------- static/js/generated-types.d.ts | 18 ++--- templates/add_inventory_profile.html | 2 +- templates/edit_inventory_profile.html | 2 +- tests/conftest.py | 2 +- .../e2e/test_adcp_reference_implementation.py | 2 +- tests/e2e/test_adcp_schema_compliance.py | 4 +- tests/e2e/test_creative_assignment_e2e.py | 6 +- tests/fixtures/builders.py | 15 ++-- tests/fixtures/test_products.json | 2 +- tests/helpers/README.md | 2 +- tests/integration/test_mock_adapter.py | 2 +- .../integration/test_workflow_with_server.py | 2 +- .../test_create_media_buy_v24.py | 2 +- tests/manual/test_gam_supported_only.py | 10 +-- tests/ui/test_product_creation_integration.py | 2 +- tests/unit/test_axe_segment_targeting.py | 16 ++-- tests/unit/test_gam_axe_segment_targeting.py | 14 ++-- tests/unit/test_pydantic_schema_alignment.py | 2 +- tests/unit/test_targeting_storage_key.py | 8 +- 27 files changed, 76 insertions(+), 159 deletions(-) diff --git a/docs/adapters/mock/README.md b/docs/adapters/mock/README.md index f160d49d0..c86ec9ee1 100644 --- a/docs/adapters/mock/README.md +++ b/docs/adapters/mock/README.md @@ -300,8 +300,8 @@ async def test_targeting_capabilities(): flight_start_date="2025-10-10", flight_end_date="2025-10-11", targeting_overlay={ - "geo_country_any_of": ["US", "CA"], - "geo_region_any_of": ["CA", "NY"], + "geo_countries": ["US", "CA"], + "geo_regions": ["US-CA", "US-NY"], "device_type_any_of": ["mobile", "tablet"], "os_any_of": ["ios", "android"], "browser_any_of": ["chrome", "safari"], diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 4754b0438..2fb62963a 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -174,7 +174,7 @@ async def get_products( # AdCP Request { "targeting_overlay": { - "geo_country_any_of": ["US"], + "geo_countries": ["US"], "signals": ["sports_enthusiasts", "auto_intenders"] } } diff --git a/docs/development/contributing.md b/docs/development/contributing.md index ba2576ea7..2340d14b0 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -233,9 +233,9 @@ Each adapter translates AdCP targeting to platform-specific format: def _translate_targeting(self, overlay): platform_targeting = {} - if "geo_country_any_of" in overlay: + if "geo_countries" in overlay: platform_targeting["location"] = { - "countries": overlay["geo_country_any_of"] + "countries": overlay["geo_countries"] } if "signals" in overlay: diff --git a/examples/upstream_product_catalog_server.py b/examples/upstream_product_catalog_server.py index 7ffae7dd3..52b47b575 100755 --- a/examples/upstream_product_catalog_server.py +++ b/examples/upstream_product_catalog_server.py @@ -38,7 +38,7 @@ ], "targeting_template": { "content_cat_any_of": ["sports", "basketball", "football"], - "geo_country_any_of": ["US", "CA"], + "geo_countries": ["US", "CA"], }, "delivery_type": "guaranteed", "is_fixed_price": False, @@ -59,7 +59,7 @@ ], "targeting_template": { "content_cat_any_of": ["finance", "business", "investing"], - "geo_country_any_of": ["US"], + "geo_countries": ["US"], }, "delivery_type": "guaranteed", "is_fixed_price": True, @@ -80,7 +80,7 @@ ], "targeting_template": { "content_cat_any_of": ["news", "politics", "world_news"], - "geo_country_any_of": ["US", "UK", "CA", "AU"], + "geo_countries": ["US", "UK", "CA", "AU"], }, "delivery_type": "non_guaranteed", "is_fixed_price": False, @@ -98,7 +98,7 @@ "specs": {"title_length": 50, "description_length": 100}, } ], - "targeting_template": {"geo_country_any_of": ["US", "CA", "UK"]}, + "targeting_template": {"geo_countries": ["US", "CA", "UK"]}, "delivery_type": "non_guaranteed", "is_fixed_price": True, "cpm": 8.0, diff --git a/examples/upstream_with_implementation.py b/examples/upstream_with_implementation.py index b3b2ac72c..7fdba473d 100644 --- a/examples/upstream_with_implementation.py +++ b/examples/upstream_with_implementation.py @@ -31,7 +31,7 @@ "delivery_options": {"hosted": {}}, } ], - "targeting_template": {"geo_country_any_of": ["US", "CA", "UK", "AU"]}, + "targeting_template": {"geo_countries": ["US", "CA", "UK", "AU"]}, "delivery_type": "non_guaranteed", "is_fixed_price": True, "cpm": 2.50, @@ -56,7 +56,7 @@ "delivery_options": {"hosted": {}}, } ], - "targeting_template": {"geo_country_any_of": ["US", "CA", "UK", "AU"]}, + "targeting_template": {"geo_countries": ["US", "CA", "UK", "AU"]}, "delivery_type": "non_guaranteed", "is_fixed_price": True, "cpm": 1.75, @@ -82,7 +82,7 @@ } ], "targeting_template": { - "geo_country_any_of": ["US", "CA", "UK", "AU"], + "geo_countries": ["US", "CA", "UK", "AU"], "device_type_any_of": ["desktop", "tablet"], # Not great on mobile }, "delivery_type": "non_guaranteed", @@ -109,7 +109,7 @@ "delivery_options": {"hosted": {}}, } ], - "targeting_template": {"geo_country_any_of": ["US", "CA", "UK", "AU"]}, + "targeting_template": {"geo_countries": ["US", "CA", "UK", "AU"]}, "delivery_type": "non_guaranteed", "is_fixed_price": False, "price_guidance": {"floor": 10.0, "p50": 15.0, "p75": 20.0}, @@ -139,7 +139,7 @@ "delivery_options": {"hosted": {}}, } ], - "targeting_template": {"content_cat_any_of": ["sports"], "geo_country_any_of": ["US"]}, + "targeting_template": {"content_cat_any_of": ["sports"], "geo_countries": ["US"]}, "delivery_type": "guaranteed", "is_fixed_price": False, "price_guidance": {"floor": 8.0, "p50": 12.0, "p75": 15.0}, diff --git a/scripts/setup/init_database.py b/scripts/setup/init_database.py index e3f010d7d..b839ea6cb 100644 --- a/scripts/setup/init_database.py +++ b/scripts/setup/init_database.py @@ -168,7 +168,7 @@ def init_db(exit_on_error=False): "id": "display_300x250", } ], - targeting_template={"geo_country_any_of": ["US"]}, + targeting_template={"geo_countries": ["US"]}, delivery_type="guaranteed", is_fixed_price=False, price_guidance={"floor": 10.0, "p50": 15.0, "p75": 20.0}, @@ -270,7 +270,7 @@ def init_db(exit_on_error=False): ], "targeting_template": { "content_cat_any_of": ["news", "politics"], - "geo_country_any_of": ["US"], + "geo_countries": ["US"], }, "delivery_type": "guaranteed", "is_fixed_price": False, @@ -280,7 +280,7 @@ def init_db(exit_on_error=False): "placement_ids": ["news_300x250_atf", "news_300x250_btf"], "ad_unit_path": "/1234/news/display", "key_values": {"section": "news", "tier": "premium"}, - "targeting": {"content_cat_any_of": ["news", "politics"], "geo_country_any_of": ["US"]}, + "targeting": {"content_cat_any_of": ["news", "politics"], "geo_countries": ["US"]}, }, }, { @@ -293,7 +293,7 @@ def init_db(exit_on_error=False): "id": "display_728x90", } ], - "targeting_template": {"geo_country_any_of": ["US", "CA"]}, + "targeting_template": {"geo_countries": ["US", "CA"]}, "delivery_type": "non_guaranteed", "is_fixed_price": True, "cpm": 2.5, @@ -302,7 +302,7 @@ def init_db(exit_on_error=False): "placement_ids": ["ros_728x90_all"], "ad_unit_path": "/1234/run_of_site/leaderboard", "key_values": {"tier": "standard"}, - "targeting": {"geo_country_any_of": ["US", "CA"]}, + "targeting": {"geo_countries": ["US", "CA"]}, }, }, ] diff --git a/src/core/database/database.py b/src/core/database/database.py index d889f98f4..1139a852d 100644 --- a/src/core/database/database.py +++ b/src/core/database/database.py @@ -259,7 +259,7 @@ def init_db(exit_on_error=False): "min_cpm": 5.0, "max_frequency": 3, "allow_adult_content": False, - "targeting": {"geo_country_any_of": ["US", "CA"]}, + "targeting": {"geo_countries": ["US", "CA"]}, }, "property_tags": ["all_inventory"], # Required per AdCP spec "pricing_option": { @@ -280,7 +280,7 @@ def init_db(exit_on_error=False): } ], "targeting_template": { - "targeting": {"geo_country_any_of": ["US", "CA"]}, + "targeting": {"geo_countries": ["US", "CA"]}, }, "property_tags": ["all_inventory"], # Required per AdCP spec "pricing_option": { diff --git a/src/core/schemas.py b/src/core/schemas.py index 1e0038ec5..9ed77ea8a 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -1021,86 +1021,6 @@ def normalize_legacy_geo(cls, values: Any) -> Any: return values - # --- Backward-compatible properties for adapters (remove after salesagent-oee/fwm) --- - - @property - def geo_country_any_of(self) -> list[str] | None: - """Legacy accessor: extract flat country codes from geo_countries.""" - if not self.geo_countries: - return None - return [c.root if hasattr(c, "root") else str(c) for c in self.geo_countries] - - @property - def geo_country_none_of(self) -> list[str] | None: - """Legacy accessor: extract flat country codes from geo_countries_exclude.""" - if not self.geo_countries_exclude: - return None - return [c.root if hasattr(c, "root") else str(c) for c in self.geo_countries_exclude] - - @property - def geo_region_any_of(self) -> list[str] | None: - """Legacy accessor: extract flat region codes from geo_regions.""" - if not self.geo_regions: - return None - return [r.root if hasattr(r, "root") else str(r) for r in self.geo_regions] - - @property - def geo_region_none_of(self) -> list[str] | None: - """Legacy accessor: extract flat region codes from geo_regions_exclude.""" - if not self.geo_regions_exclude: - return None - return [r.root if hasattr(r, "root") else str(r) for r in self.geo_regions_exclude] - - @property - def geo_metro_any_of(self) -> list[str] | None: - """Legacy accessor: flatten metro values from geo_metros.""" - if not self.geo_metros: - return None - result = [] - for metro in self.geo_metros: - result.extend(metro.values) - return result or None - - @property - def geo_metro_none_of(self) -> list[str] | None: - """Legacy accessor: flatten metro values from geo_metros_exclude.""" - if not self.geo_metros_exclude: - return None - result = [] - for metro in self.geo_metros_exclude: - result.extend(metro.values) - return result or None - - @property - def geo_zip_any_of(self) -> list[str] | None: - """Legacy accessor: flatten postal values from geo_postal_areas.""" - if not self.geo_postal_areas: - return None - result = [] - for area in self.geo_postal_areas: - result.extend(area.values) - return result or None - - @property - def geo_zip_none_of(self) -> list[str] | None: - """Legacy accessor: flatten postal values from geo_postal_areas_exclude.""" - if not self.geo_postal_areas_exclude: - return None - result = [] - for area in self.geo_postal_areas_exclude: - result.extend(area.values) - return result or None - - @property - def geo_city_any_of(self) -> None: - """Legacy accessor: city targeting removed in v3.""" - return None - - @property - def geo_city_none_of(self) -> None: - """Legacy accessor: city targeting removed in v3.""" - return None - def model_dump(self, **kwargs): """Override to provide AdCP-compliant responses while preserving internal fields.""" # Default to excluding internal and managed fields for AdCP compliance diff --git a/static/js/generated-types.d.ts b/static/js/generated-types.d.ts index 0bb0c32f7..fb45a1900 100644 --- a/static/js/generated-types.d.ts +++ b/static/js/generated-types.d.ts @@ -537,16 +537,14 @@ export interface PricingModel { } export interface Targeting { - geo_country_any_of?: string[] | null; - geo_country_none_of?: string[] | null; - geo_region_any_of?: string[] | null; - geo_region_none_of?: string[] | null; - geo_metro_any_of?: string[] | null; - geo_metro_none_of?: string[] | null; - geo_city_any_of?: string[] | null; - geo_city_none_of?: string[] | null; - geo_zip_any_of?: string[] | null; - geo_zip_none_of?: string[] | null; + geo_countries?: string[] | null; + geo_countries_exclude?: string[] | null; + geo_regions?: string[] | null; + geo_regions_exclude?: string[] | null; + geo_metros?: Array<{system: string; values: string[]}> | null; + geo_metros_exclude?: Array<{system: string; values: string[]}> | null; + geo_postal_areas?: Array<{system: string; values: string[]}> | null; + geo_postal_areas_exclude?: Array<{system: string; values: string[]}> | null; device_type_any_of?: string[] | null; device_type_none_of?: string[] | null; os_any_of?: string[] | null; diff --git a/templates/add_inventory_profile.html b/templates/add_inventory_profile.html index 34ec023d3..cc8dfb96a 100644 --- a/templates/add_inventory_profile.html +++ b/templates/add_inventory_profile.html @@ -215,7 +215,7 @@

Optional. Products using this profile can inherit or override these targeting rules. diff --git a/templates/edit_inventory_profile.html b/templates/edit_inventory_profile.html index 40ea25171..ecdc21ba2 100644 --- a/templates/edit_inventory_profile.html +++ b/templates/edit_inventory_profile.html @@ -215,7 +215,7 @@

Optional. Products using this profile can inherit or override these targeting rules. diff --git a/tests/conftest.py b/tests/conftest.py index d4f8d60ec..bf7c4cb58 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -552,7 +552,7 @@ def sample_media_buy_request(): "total_budget": 10000.0, "flight_start_date": "2025-02-01", "flight_end_date": "2025-02-28", - "targeting_overlay": {"geo_country_any_of": ["US"], "device_type_any_of": ["desktop", "mobile"]}, + "targeting_overlay": {"geo_countries": ["US"], "device_type_any_of": ["desktop", "mobile"]}, } diff --git a/tests/e2e/test_adcp_reference_implementation.py b/tests/e2e/test_adcp_reference_implementation.py index 5558910bc..35c7db4df 100644 --- a/tests/e2e/test_adcp_reference_implementation.py +++ b/tests/e2e/test_adcp_reference_implementation.py @@ -174,7 +174,7 @@ async def test_complete_campaign_lifecycle_with_webhooks( end_time=end_time, brand_manifest={"name": "Nike Air Jordan 2025 Basketball Shoes"}, targeting_overlay={ - "geo_country_any_of": ["US", "CA"], + "geo_countries": ["US", "CA"], }, webhook_url=webhook_server["url"], # Async notifications! context={"e2e": "create_media_buy"}, diff --git a/tests/e2e/test_adcp_schema_compliance.py b/tests/e2e/test_adcp_schema_compliance.py index f4b98f8a6..e851696b6 100644 --- a/tests/e2e/test_adcp_schema_compliance.py +++ b/tests/e2e/test_adcp_schema_compliance.py @@ -200,7 +200,7 @@ async def test_create_media_buy_compliance( "total_budget": 1000.0, "flight_start_date": "2025-02-01", "flight_end_date": "2025-02-28", - "targeting": {"geo_country_any_of": ["US"], "device_type_any_of": ["mobile", "desktop"]}, + "targeting": {"geo_countries": ["US"], "device_type_any_of": ["mobile", "desktop"]}, } try: @@ -219,7 +219,7 @@ async def test_targeting_schema_compliance( # Test various targeting combinations targeting_examples = [ - {"geo_country_any_of": ["US", "CA"]}, + {"geo_countries": ["US", "CA"]}, {"device_type_any_of": ["mobile"], "os_any_of": ["iOS"]}, {"content_cat_any_of": ["IAB1"], "keywords_any_of": ["sports"]}, {"signals": ["auto_intenders_q1_2025"]}, diff --git a/tests/e2e/test_creative_assignment_e2e.py b/tests/e2e/test_creative_assignment_e2e.py index 4c7a0c966..5b253b518 100644 --- a/tests/e2e/test_creative_assignment_e2e.py +++ b/tests/e2e/test_creative_assignment_e2e.py @@ -115,7 +115,7 @@ async def test_creative_sync_with_assignment_in_single_call( end_time=end_time, brand_manifest={"name": "Creative Assignment Test Campaign"}, targeting_overlay={ - "geo_country_any_of": ["US"], + "geo_countries": ["US"], }, ) @@ -326,14 +326,14 @@ async def test_multiple_creatives_multiple_packages(self, docker_services_e2e, l "product_id": product_id, "pricing_option_id": "cpm_option_1", "budget": 5000.0, - "targeting_overlay": {"geo_country_any_of": ["US"]}, + "targeting_overlay": {"geo_countries": ["US"]}, }, { "buyer_ref": pkg2_ref, "product_id": product_id, "pricing_option_id": "cpm_option_1", "budget": 5000.0, - "targeting_overlay": {"geo_country_any_of": ["CA"]}, + "targeting_overlay": {"geo_countries": ["CA"]}, }, ] diff --git a/tests/fixtures/builders.py b/tests/fixtures/builders.py index 39760f355..4d731b9f0 100644 --- a/tests/fixtures/builders.py +++ b/tests/fixtures/builders.py @@ -170,13 +170,12 @@ def with_geo( ): """Add geographic targeting.""" if countries: - self.targeting["geo_country_any_of"] = countries + self.targeting["geo_countries"] = countries if regions: - self.targeting["geo_region_any_of"] = regions - if cities: - self.targeting["geo_city_any_of"] = cities + self.targeting["geo_regions"] = regions + # cities parameter ignored: city targeting removed in v3 if zip_codes: - self.targeting["geo_zip_any_of"] = zip_codes + self.targeting["geo_postal_areas"] = [{"system": "us_zip", "values": zip_codes}] return self def with_demographics( @@ -227,13 +226,13 @@ def build(self) -> dict: def build_minimal(self) -> dict: """Build minimal targeting for testing.""" - return {"geo_country_any_of": ["US"], "device_type_any_of": ["desktop", "mobile"]} + return {"geo_countries": ["US"], "device_type_any_of": ["desktop", "mobile"]} def build_comprehensive(self) -> dict: """Build comprehensive targeting for testing.""" return { - "geo_country_any_of": ["US", "CA"], - "geo_region_any_of": ["CA", "NY", "TX"], + "geo_countries": ["US", "CA"], + "geo_regions": ["US-CA", "US-NY", "US-TX"], "demo_age_range_any_of": ["25-34", "35-44"], "demo_gender_any_of": ["all"], "device_type_any_of": ["desktop", "mobile", "tablet"], diff --git a/tests/fixtures/test_products.json b/tests/fixtures/test_products.json index 48cf342f1..bde642482 100644 --- a/tests/fixtures/test_products.json +++ b/tests/fixtures/test_products.json @@ -10,7 +10,7 @@ "min_spend": 10000, "countries": ["US", "CA"], "targeting_template": { - "geo_country_any_of": ["US", "CA"], + "geo_countries": ["US", "CA"], "device_type_any_of": ["desktop", "tablet"], "daypart_presets": ["prime_time"] }, diff --git a/tests/helpers/README.md b/tests/helpers/README.md index 0d57d8c6d..e672a1e3c 100644 --- a/tests/helpers/README.md +++ b/tests/helpers/README.md @@ -104,7 +104,7 @@ pkg = create_test_package_request( budget=5000.0, pricing_option_id="premium_cpm", creative_ids=["creative_1", "creative_2"], - targeting_overlay={"geo_country_any_of": ["US", "CA"]}, + targeting_overlay={"geo_countries": ["US", "CA"]}, ) # Use in CreateMediaBuyRequest diff --git a/tests/integration/test_mock_adapter.py b/tests/integration/test_mock_adapter.py index 0150d1595..046a52f0d 100644 --- a/tests/integration/test_mock_adapter.py +++ b/tests/integration/test_mock_adapter.py @@ -130,7 +130,7 @@ ], "delivery_type": "guaranteed", "targeting_overlay": { - "geo_country_any_of": ["United States", "Canada"], + "geo_countries": ["United States", "Canada"], "device_type_any_of": ["desktop", "tablet"], "key_value_pairs": {"content_category": ["sports", "news"], "audience_segment": ["sports_enthusiasts"]}, "dayparting": { diff --git a/tests/integration/test_workflow_with_server.py b/tests/integration/test_workflow_with_server.py index e046b5f5c..27b6eac80 100644 --- a/tests/integration/test_workflow_with_server.py +++ b/tests/integration/test_workflow_with_server.py @@ -73,7 +73,7 @@ async def test_workflow_with_manual_approval(): "total_budget": 10000.0, "flight_start_date": "2025-02-01", "flight_end_date": "2025-02-28", - "targeting_overlay": {"geo_country_any_of": ["US"]}, + "targeting_overlay": {"geo_countries": ["US"]}, } }, ) diff --git a/tests/integration_v2/test_create_media_buy_v24.py b/tests/integration_v2/test_create_media_buy_v24.py index af2854037..914aa1e49 100644 --- a/tests/integration_v2/test_create_media_buy_v24.py +++ b/tests/integration_v2/test_create_media_buy_v24.py @@ -296,7 +296,7 @@ async def test_create_media_buy_with_targeting_overlay_mcp(self, setup_test_tena pricing_option_id=setup_test_tenant["pricing_option_id_eur"], # Required field budget=8000.0, # Float budget, currency from pricing_option targeting_overlay=TargetingOverlay( - geo_country_any_of=["US", "CA"], + geo_countries=["US", "CA"], ), ) ] diff --git a/tests/manual/test_gam_supported_only.py b/tests/manual/test_gam_supported_only.py index e4fe17024..5c56bb43f 100644 --- a/tests/manual/test_gam_supported_only.py +++ b/tests/manual/test_gam_supported_only.py @@ -109,9 +109,9 @@ def test_geo_targeting(self): po_number="GEO_SUPPORTED", total_budget=1.00, targeting_overlay=Targeting( - geo_country_any_of=["US"], - geo_region_any_of=["CA", "NY"], - geo_metro_any_of=["501", "803"], # NYC, LA + geo_countries=["US"], + geo_regions=["US-CA", "US-NY"], + geo_metros=[{"system": "nielsen_dma", "values": ["501", "803"]}], ), ) @@ -225,8 +225,8 @@ def test_combined_supported(self): po_number="GEO_AEE_COMBINED", total_budget=3.00, targeting_overlay=Targeting( - geo_country_any_of=["US"], - geo_region_any_of=["CA"], + geo_countries=["US"], + geo_regions=["US-CA"], key_value_pairs=key_value_pairs if key_value_pairs else None, ), ) diff --git a/tests/ui/test_product_creation_integration.py b/tests/ui/test_product_creation_integration.py index 9e66377c5..fd33aedff 100644 --- a/tests/ui/test_product_creation_integration.py +++ b/tests/ui/test_product_creation_integration.py @@ -327,7 +327,7 @@ def test_list_products_json_parsing(client, test_tenant, integration_db): countries=["US", "CA"], price_guidance={"min": 10.0, "max": 20.0}, delivery_type="guaranteed", - targeting_template={"geo_country_any_of": ["US", "CA"]}, + targeting_template={"geo_countries": ["US", "CA"]}, ) session.commit() diff --git a/tests/unit/test_axe_segment_targeting.py b/tests/unit/test_axe_segment_targeting.py index 4cec8a67a..03387b310 100644 --- a/tests/unit/test_axe_segment_targeting.py +++ b/tests/unit/test_axe_segment_targeting.py @@ -12,7 +12,7 @@ def test_targeting_has_axe_segment_fields(): """Test that Targeting class includes axe_include_segment and axe_exclude_segment fields.""" targeting = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], axe_include_segment="x8dj3k", axe_exclude_segment="y9kl4m", ) @@ -29,7 +29,7 @@ def test_targeting_has_axe_segment_fields(): def test_targeting_axe_segments_are_optional(): """Test that AXE segment fields are optional.""" - targeting = Targeting(geo_country_any_of=["US"]) + targeting = Targeting(geo_countries=["US"]) # Should not raise validation error assert targeting.axe_include_segment is None @@ -49,7 +49,7 @@ def test_package_targeting_overlay_supports_axe_segments(): budget=1000.0, # Required per AdCP spec pricing_option_id="pricing_1", # Required per AdCP spec targeting_overlay={ - "geo_country_any_of": ["US"], + "geo_countries": ["US"], "axe_include_segment": "x8dj3k", }, ) @@ -79,7 +79,7 @@ def test_create_media_buy_request_with_axe_segments(): budget=1000.0, # Required per AdCP spec pricing_option_id="pricing_1", # Required per AdCP spec targeting_overlay={ - "geo_country_any_of": ["US"], + "geo_countries": ["US"], "axe_include_segment": "x8dj3k", "axe_exclude_segment": "y9kl4m", }, @@ -110,7 +110,7 @@ def test_update_media_buy_request_with_axe_segments(): AdCPPackageUpdate( package_id="pkg_123", targeting_overlay=Targeting( - geo_country_any_of=["US", "CA"], + geo_countries=["US", "CA"], axe_include_segment="x8dj3k", ), ) @@ -133,7 +133,7 @@ def test_axe_segments_survive_roundtrip(): """Test that AXE segment fields survive serialization/deserialization roundtrip.""" # Create targeting with AXE segments original = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], axe_include_segment="x8dj3k", axe_exclude_segment="y9kl4m", ) @@ -153,8 +153,8 @@ def test_axe_segments_survive_roundtrip(): def test_axe_segments_with_other_targeting_dimensions(): """Test that AXE segments work alongside other targeting dimensions.""" targeting = Targeting( - geo_country_any_of=["US"], - geo_region_any_of=["US-NY", "US-CA"], + geo_countries=["US"], + geo_regions=["US-NY", "US-CA"], device_type_any_of=["mobile", "desktop"], axe_include_segment="x8dj3k", axe_exclude_segment="y9kl4m", diff --git a/tests/unit/test_gam_axe_segment_targeting.py b/tests/unit/test_gam_axe_segment_targeting.py index bc994dba5..7a1b74636 100644 --- a/tests/unit/test_gam_axe_segment_targeting.py +++ b/tests/unit/test_gam_axe_segment_targeting.py @@ -55,7 +55,7 @@ def test_axe_include_segment_translates_to_custom_targeting(mock_adapter_config_ manager = GAMTargetingManager("tenant_123", gam_client=mock_gam_client) targeting_overlay = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], axe_include_segment="x8dj3k", ) @@ -88,7 +88,7 @@ def test_axe_exclude_segment_translates_to_negative_custom_targeting(mock_adapte manager = GAMTargetingManager("tenant_123", gam_client=mock_gam_client) targeting_overlay = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], axe_exclude_segment="y9kl4m", ) @@ -120,7 +120,7 @@ def test_axe_segments_both_include_and_exclude(mock_adapter_config_three_keys): manager = GAMTargetingManager("tenant_123", gam_client=mock_gam_client) targeting_overlay = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], axe_include_segment="x8dj3k", axe_exclude_segment="y9kl4m", ) @@ -160,7 +160,7 @@ def test_axe_segments_combine_with_other_custom_targeting(mock_adapter_config_th # Test AXE segments work correctly - custom GAM key-values require numeric IDs # which is a different code path. Just test AXE alone here. targeting_overlay = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], axe_include_segment="x8dj3k", ) @@ -189,7 +189,7 @@ def test_axe_segments_optional(mock_adapter_config_three_keys): manager = GAMTargetingManager("tenant_123") targeting_overlay = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], # No axe_include_segment or axe_exclude_segment ) @@ -209,7 +209,7 @@ def test_axe_include_segment_fails_if_key_not_configured(mock_adapter_config_no_ manager = GAMTargetingManager("tenant_123") targeting_overlay = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], axe_include_segment="x8dj3k", ) @@ -229,7 +229,7 @@ def test_axe_exclude_segment_fails_if_key_not_configured(mock_adapter_config_no_ manager = GAMTargetingManager("tenant_123") targeting_overlay = Targeting( - geo_country_any_of=["US"], + geo_countries=["US"], axe_exclude_segment="y9kl4m", ) diff --git a/tests/unit/test_pydantic_schema_alignment.py b/tests/unit/test_pydantic_schema_alignment.py index 1fdee0846..156b753e3 100644 --- a/tests/unit/test_pydantic_schema_alignment.py +++ b/tests/unit/test_pydantic_schema_alignment.py @@ -153,7 +153,7 @@ def generate_example_value(field_type: str, field_name: str = "", field_spec: di } if "targeting" in field_name.lower(): return { - "geo_country_any_of": ["US"], + "geo_countries": ["US"], } if field_spec and "properties" in field_spec: # Generate a minimal object with required fields diff --git a/tests/unit/test_targeting_storage_key.py b/tests/unit/test_targeting_storage_key.py index 928dc3edc..fb36d9dec 100644 --- a/tests/unit/test_targeting_storage_key.py +++ b/tests/unit/test_targeting_storage_key.py @@ -28,7 +28,7 @@ def test_update_stores_under_targeting_overlay_key(self): # Create a package_config dict, simulate what update does, and check the key. from src.core.schemas import Targeting - targeting = Targeting(geo_country_any_of=["US"]) + targeting = Targeting(geo_countries=["US"]) targeting_dict = targeting.model_dump(exclude_none=True) # Simulate what media_buy_update SHOULD do @@ -43,7 +43,7 @@ def test_create_reads_targeting_overlay_key(self): """media_buy_create reads from 'targeting_overlay' key in package_config.""" from src.core.schemas import Targeting - targeting = Targeting(geo_country_any_of=["US"], device_type_any_of=["mobile"]) + targeting = Targeting(geo_countries=["US"], device_type_any_of=["mobile"]) targeting_dict = targeting.model_dump(exclude_none=True) package_config = {"targeting_overlay": targeting_dict} @@ -60,7 +60,7 @@ def test_create_reads_targeting_fallback_key(self): """media_buy_create falls back to 'targeting' key for existing data.""" from src.core.schemas import Targeting - targeting = Targeting(geo_country_any_of=["US"], device_type_any_of=["desktop"]) + targeting = Targeting(geo_countries=["US"], device_type_any_of=["desktop"]) targeting_dict = targeting.model_dump(exclude_none=True) # Legacy data stored under "targeting" key @@ -81,7 +81,7 @@ def test_roundtrip_update_then_reconstruct(self): # Step 1: update stores targeting original = Targeting( - geo_country_any_of=["US", "CA"], + geo_countries=["US", "CA"], device_type_any_of=["mobile"], ) targeting_dict = original.model_dump(exclude_none=True) From 4ac1e1f13f7b9819edbd3afb1c36668debc76fb0 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 11 Feb 2026 02:14:01 +0100 Subject: [PATCH 10/18] fix: reject unknown targeting fields via model_extra inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pydantic's extra='allow' on Targeting silently accepts bogus fields — they land in model_extra but validate_overlay_targeting() skips unmapped keys. Add validate_unknown_targeting_fields() that checks model_extra and reports unrecognized fields at the media_buy_create validation boundary. Known model fields (including managed-only like axe_include_segment) and v2 field names consumed by the normalizer are not affected — they are real model attributes, not model_extra entries. --- src/core/tools/media_buy_create.py | 14 +++- src/services/targeting_capabilities.py | 18 +++++ tests/unit/test_unknown_targeting_fields.py | 89 +++++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_unknown_targeting_fields.py diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index 4f1e42d53..48a9c424c 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -1900,15 +1900,23 @@ def unwrap_po(po: Any) -> Any: if req.packages: for pkg in req.packages: if hasattr(pkg, "targeting_overlay") and pkg.targeting_overlay: - from src.services.targeting_capabilities import validate_overlay_targeting + from src.services.targeting_capabilities import ( + validate_overlay_targeting, + validate_unknown_targeting_fields, + ) + + # Reject unknown targeting fields (typos, bogus names) via model_extra + unknown_violations = validate_unknown_targeting_fields(pkg.targeting_overlay) - # Convert to dict for validation - TargetingOverlay always has model_dump + # Convert to dict for dimension-access validation targeting_data: dict[str, Any] = ( pkg.targeting_overlay.model_dump(exclude_none=True) if hasattr(pkg.targeting_overlay, "model_dump") else dict(pkg.targeting_overlay) # Fallback for dict-like objects ) - violations = validate_overlay_targeting(targeting_data) + access_violations = validate_overlay_targeting(targeting_data) + + violations = unknown_violations + access_violations if violations: error_msg = f"Targeting validation failed: {'; '.join(violations)}" raise ValueError(error_msg) diff --git a/src/services/targeting_capabilities.py b/src/services/targeting_capabilities.py index 59ac56595..118277f61 100644 --- a/src/services/targeting_capabilities.py +++ b/src/services/targeting_capabilities.py @@ -144,6 +144,24 @@ def get_aee_signal_dimensions() -> list[str]: } +def validate_unknown_targeting_fields(targeting_obj: Any) -> list[str]: + """Reject unknown fields in a Targeting object via model_extra inspection. + + Pydantic's extra='allow' accepts any field — unknown buyer fields (typos, + bogus names) land in model_extra. This function checks model_extra and + reports them as unknown targeting fields. + + This is separate from validate_overlay_targeting() which checks access + control (managed-only vs overlay) on *known* fields. + + Returns list of violation messages for unknown fields. + """ + model_extra = getattr(targeting_obj, "model_extra", None) + if not model_extra: + return [] + return [f"{key} is not a recognized targeting field" for key in model_extra] + + def validate_overlay_targeting(targeting: dict[str, Any]) -> list[str]: """Validate that targeting only uses allowed overlay dimensions. diff --git a/tests/unit/test_unknown_targeting_fields.py b/tests/unit/test_unknown_targeting_fields.py new file mode 100644 index 000000000..e3c562a9e --- /dev/null +++ b/tests/unit/test_unknown_targeting_fields.py @@ -0,0 +1,89 @@ +"""Tests for unknown targeting field rejection. + +Regression tests for salesagent-duu: ensures unknown buyer-submitted targeting +fields (typos, bogus fields) are caught via model_extra inspection rather than +silently accepted by Pydantic's extra='allow'. +""" + +from src.core.schemas import Targeting + + +class TestModelExtraDetectsUnknownFields: + """model_extra should contain only truly unknown fields.""" + + def test_unknown_field_in_model_extra(self): + t = Targeting(totally_bogus="hello", geo_countries=["US"]) + assert "totally_bogus" in t.model_extra + + def test_known_field_not_in_model_extra(self): + """Known model fields must not appear in model_extra.""" + t = Targeting(geo_countries=["US"], device_type_any_of=["mobile"]) + assert t.model_extra == {} + + def test_managed_field_not_in_model_extra(self): + """Managed-only fields are real model fields, not extra.""" + t = Targeting(axe_include_segment="foo", key_value_pairs={"k": "v"}) + assert t.model_extra == {} + + def test_v2_normalized_field_not_in_model_extra(self): + """v2 field names consumed by normalizer should not leak to model_extra.""" + t = Targeting(geo_country_any_of=["CA"]) + assert t.model_extra == {} + assert t.geo_countries is not None + + def test_multiple_unknown_fields(self): + t = Targeting(bogus_one="a", bogus_two="b") + assert "bogus_one" in t.model_extra + assert "bogus_two" in t.model_extra + + +class TestValidateUnknownTargetingFields: + """validate_unknown_targeting_fields should report model_extra keys.""" + + def test_rejects_unknown_field(self): + from src.services.targeting_capabilities import validate_unknown_targeting_fields + + t = Targeting(totally_bogus="hello", geo_countries=["US"]) + violations = validate_unknown_targeting_fields(t) + assert len(violations) == 1 + assert "totally_bogus" in violations[0] + + def test_accepts_all_known_fields(self): + from src.services.targeting_capabilities import validate_unknown_targeting_fields + + t = Targeting(geo_countries=["US"], device_type_any_of=["mobile"]) + violations = validate_unknown_targeting_fields(t) + assert violations == [] + + def test_accepts_managed_fields(self): + """Managed fields are known model fields — they should NOT be flagged here. + (They are caught separately by validate_overlay_targeting's access checks.)""" + from src.services.targeting_capabilities import validate_unknown_targeting_fields + + t = Targeting(key_value_pairs={"k": "v"}, axe_include_segment="seg") + violations = validate_unknown_targeting_fields(t) + assert violations == [] + + def test_accepts_v2_normalized_fields(self): + """v2 fields converted by normalizer should not be flagged.""" + from src.services.targeting_capabilities import validate_unknown_targeting_fields + + t = Targeting(geo_country_any_of=["US"]) + violations = validate_unknown_targeting_fields(t) + assert violations == [] + + def test_error_message_names_fields(self): + from src.services.targeting_capabilities import validate_unknown_targeting_fields + + t = Targeting(bogus_one="a", bogus_two="b") + violations = validate_unknown_targeting_fields(t) + assert len(violations) == 2 + field_names = {v.split(" ")[0] for v in violations} + assert field_names == {"bogus_one", "bogus_two"} + + def test_empty_targeting_no_violations(self): + from src.services.targeting_capabilities import validate_unknown_targeting_fields + + t = Targeting() + violations = validate_unknown_targeting_fields(t) + assert violations == [] From 02feda9f69bb7521451ec620803d7f0306964354 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 11 Feb 2026 10:28:22 +0100 Subject: [PATCH 11/18] fix: default Targeting.model_dump to mode='json' and add v3 geo test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeting.model_dump() and model_dump_internal() produced MetroAreaSystem and PostalCodeSystem enum objects instead of strings, causing TypeError when psycopg2 called json.dumps() for JSONB storage. Any buyer sending geo_metros or geo_postal_areas targeting would crash the create/update media buy flow at session.commit(). Fix: kwargs.setdefault("mode", "json") in both methods — one line each. New test files (41 tests): - test_v3_geo_targeting.py: type construction, model_dump JSON safety, FrequencyCap inheritance, exclusion field serialization - test_v3_targeting_roundtrip.py: construct/dump/reconstruct identity, DB storage simulation (json.dumps/loads), legacy normalizer roundtrip, FrequencyCap scope roundtrip --- src/core/schemas.py | 2 + tests/unit/test_v3_geo_targeting.py | 213 +++++++++++++++++++ tests/unit/test_v3_targeting_roundtrip.py | 241 ++++++++++++++++++++++ 3 files changed, 456 insertions(+) create mode 100644 tests/unit/test_v3_geo_targeting.py create mode 100644 tests/unit/test_v3_targeting_roundtrip.py diff --git a/src/core/schemas.py b/src/core/schemas.py index 9ed77ea8a..3ce077f21 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -1023,6 +1023,7 @@ def normalize_legacy_geo(cls, values: Any) -> Any: def model_dump(self, **kwargs): """Override to provide AdCP-compliant responses while preserving internal fields.""" + kwargs.setdefault("mode", "json") # Default to excluding internal and managed fields for AdCP compliance exclude = kwargs.get("exclude", set()) if isinstance(exclude, set): @@ -1043,6 +1044,7 @@ def model_dump(self, **kwargs): def model_dump_internal(self, **kwargs): """Dump including internal and managed fields for database storage and internal processing.""" + kwargs.setdefault("mode", "json") # Don't exclude internal fields or managed fields kwargs.pop("exclude", None) # Remove any exclude parameter kwargs["exclude"] = {"_had_city_targeting"} # Always exclude transient flags diff --git a/tests/unit/test_v3_geo_targeting.py b/tests/unit/test_v3_geo_targeting.py new file mode 100644 index 000000000..5dbb9415c --- /dev/null +++ b/tests/unit/test_v3_geo_targeting.py @@ -0,0 +1,213 @@ +"""Tests for v3 structured geo targeting types, serialization, and inheritance. + +Covers: +- GeoCountry/GeoRegion (RootModel[str]) construction +- GeoMetro/GeoPostalArea (structured with system enum) construction +- Targeting model_dump JSON safety (regression: Bug A — MetroAreaSystem enum serialization) +- Targeting model_dump_internal JSON safety +- FrequencyCap inheritance from library type + scope field +- Exclusion field construction and serialization +""" + +import json + +from adcp.types import ( + FrequencyCap as LibraryFrequencyCap, +) +from adcp.types import ( + GeoCountry, + GeoMetro, + GeoPostalArea, + GeoRegion, + TargetingOverlay, +) + +from src.core.schemas import FrequencyCap, Targeting + + +# --------------------------------------------------------------------------- +# Geo Type Construction +# --------------------------------------------------------------------------- +class TestGeoTypeConstruction: + def test_geo_country_root_model_string(self): + c = GeoCountry("US") + assert c.root == "US" + + def test_geo_region_iso_format(self): + r = GeoRegion("US-CA") + assert r.root == "US-CA" + + def test_geo_metro_structured(self): + m = GeoMetro(system="nielsen_dma", values=["501", "803"]) + assert m.system.value == "nielsen_dma" + assert m.values == ["501", "803"] + + def test_geo_postal_area_structured(self): + p = GeoPostalArea(system="us_zip", values=["10001", "90210"]) + assert p.system.value == "us_zip" + assert p.values == ["10001", "90210"] + + +# --------------------------------------------------------------------------- +# Targeting V3 Construction +# --------------------------------------------------------------------------- +class TestTargetingV3Construction: + def test_construct_with_all_v3_geo_fields(self): + """All 4 inclusion + 4 exclusion geo fields + device + freq_cap.""" + t = Targeting( + geo_countries=["US", "CA"], + geo_regions=["US-CA", "US-NY"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + geo_countries_exclude=["RU"], + geo_regions_exclude=["US-TX"], + geo_metros_exclude=[{"system": "nielsen_dma", "values": ["803"]}], + geo_postal_areas_exclude=[{"system": "us_zip", "values": ["90210"]}], + device_type_any_of=["mobile", "desktop"], + frequency_cap={"max_impressions": 5, "suppress_minutes": 60}, + ) + assert len(t.geo_countries) == 2 + assert len(t.geo_regions) == 2 + assert len(t.geo_metros) == 1 + assert len(t.geo_postal_areas) == 1 + assert len(t.geo_countries_exclude) == 1 + assert len(t.geo_regions_exclude) == 1 + assert len(t.geo_metros_exclude) == 1 + assert len(t.geo_postal_areas_exclude) == 1 + assert t.device_type_any_of == ["mobile", "desktop"] + assert t.frequency_cap.max_impressions == 5 + + def test_isinstance_targeting_overlay(self): + t = Targeting(geo_countries=["US"]) + assert isinstance(t, TargetingOverlay) + + def test_exclusion_fields_in_model_dump(self): + t = Targeting( + geo_countries=["US"], + geo_countries_exclude=["RU"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_metros_exclude=[{"system": "nielsen_dma", "values": ["803"]}], + ) + d = t.model_dump(exclude_none=True) + assert "geo_countries_exclude" in d + assert "geo_metros_exclude" in d + assert d["geo_countries_exclude"] == ["RU"] + + def test_non_geo_fields_unchanged(self): + """Device, audience, and signal fields preserved through construction.""" + t = Targeting( + device_type_any_of=["mobile", "ctv"], + audiences_any_of=["seg_123"], + content_cat_any_of=["IAB1"], + ) + assert t.device_type_any_of == ["mobile", "ctv"] + assert t.audiences_any_of == ["seg_123"] + assert t.content_cat_any_of == ["IAB1"] + + +# --------------------------------------------------------------------------- +# Targeting model_dump Serialization (Bug A regression tests) +# --------------------------------------------------------------------------- +class TestTargetingModelDumpSerialization: + def test_model_dump_json_safe(self): + """json.dumps(t.model_dump()) must succeed — regression for Bug A.""" + t = Targeting( + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + geo_countries=["US"], + ) + d = t.model_dump(exclude_none=True) + # Must not raise TypeError for enum objects + json.dumps(d) + + def test_model_dump_internal_json_safe(self): + """json.dumps(t.model_dump_internal()) must succeed — regression for Bug A.""" + t = Targeting( + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + geo_countries=["US"], + key_value_pairs={"k": "v"}, + ) + d = t.model_dump_internal(exclude_none=True) + json.dumps(d) + + def test_model_dump_geo_country_is_string(self): + t = Targeting(geo_countries=["US", "CA"]) + d = t.model_dump(exclude_none=True) + assert d["geo_countries"] == ["US", "CA"] + assert isinstance(d["geo_countries"][0], str) + + def test_model_dump_geo_metro_system_is_string(self): + """System field must serialize as string, not MetroAreaSystem enum.""" + t = Targeting(geo_metros=[{"system": "nielsen_dma", "values": ["501"]}]) + d = t.model_dump(exclude_none=True) + assert isinstance(d["geo_metros"][0]["system"], str) + assert d["geo_metros"][0]["system"] == "nielsen_dma" + + def test_model_dump_geo_postal_system_is_string(self): + """System field must serialize as string, not PostalCodeSystem enum.""" + t = Targeting(geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}]) + d = t.model_dump(exclude_none=True) + assert isinstance(d["geo_postal_areas"][0]["system"], str) + assert d["geo_postal_areas"][0]["system"] == "us_zip" + + def test_model_dump_exclude_none(self): + t = Targeting(geo_countries=["US"]) + d = t.model_dump(exclude_none=True) + assert "geo_regions" not in d + assert "geo_metros" not in d + assert "frequency_cap" not in d + + def test_model_dump_excludes_managed_fields(self): + t = Targeting(geo_countries=["US"], key_value_pairs={"k": "v"}) + d = t.model_dump(exclude_none=True) + assert "key_value_pairs" not in d + + def test_model_dump_internal_includes_managed_fields(self): + t = Targeting(geo_countries=["US"], key_value_pairs={"k": "v"}) + d = t.model_dump_internal(exclude_none=True) + assert "key_value_pairs" in d + assert d["key_value_pairs"] == {"k": "v"} + + def test_model_dump_mode_override(self): + """Explicit mode='python' still works when caller needs it.""" + t = Targeting(geo_metros=[{"system": "nielsen_dma", "values": ["501"]}]) + d = t.model_dump(exclude_none=True, mode="python") + # In python mode, system is the enum object + assert not isinstance(d["geo_metros"][0]["system"], str) + + +# --------------------------------------------------------------------------- +# FrequencyCap Inheritance +# --------------------------------------------------------------------------- +class TestFrequencyCapInheritance: + def test_isinstance_library_freq_cap(self): + fc = FrequencyCap(max_impressions=5, suppress_minutes=60) + assert isinstance(fc, LibraryFrequencyCap) + + def test_scope_field_preserved(self): + fc = FrequencyCap(max_impressions=5, suppress_minutes=60, scope="package") + assert fc.scope == "package" + + def test_scope_default_media_buy(self): + fc = FrequencyCap(max_impressions=5, suppress_minutes=60) + assert fc.scope == "media_buy" + + def test_suppress_minutes_accepts_float(self): + fc = FrequencyCap(max_impressions=5, suppress_minutes=60.5) + assert fc.suppress_minutes == 60.5 + + def test_suppress_minutes_accepts_int(self): + fc = FrequencyCap(max_impressions=5, suppress_minutes=60) + assert isinstance(fc.suppress_minutes, float) + + def test_model_dump_includes_scope(self): + fc = FrequencyCap(max_impressions=5, suppress_minutes=60, scope="package") + d = fc.model_dump() + assert d["scope"] == "package" + + def test_freq_cap_in_targeting(self): + t = Targeting(frequency_cap={"max_impressions": 5, "suppress_minutes": 60, "scope": "package"}) + assert t.frequency_cap.scope == "package" + assert isinstance(t.frequency_cap, FrequencyCap) + assert isinstance(t.frequency_cap, LibraryFrequencyCap) diff --git a/tests/unit/test_v3_targeting_roundtrip.py b/tests/unit/test_v3_targeting_roundtrip.py new file mode 100644 index 000000000..5c7d662e5 --- /dev/null +++ b/tests/unit/test_v3_targeting_roundtrip.py @@ -0,0 +1,241 @@ +"""Roundtrip tests for v3 structured geo targeting. + +Proves data survives: +- construct -> model_dump -> reconstruct -> model_dump (identity) +- construct -> json.dumps -> json.loads -> reconstruct (DB storage simulation) +- legacy flat -> normalizer -> v3 structured -> dump -> reconstruct (migration path) +- FrequencyCap with scope through dump -> reconstruct cycle +""" + +import json + +from src.core.schemas import FrequencyCap, Targeting + + +def _roundtrip(t: Targeting, *, internal: bool = False) -> Targeting: + """Dump a Targeting, reconstruct from dict, return the new instance.""" + if internal: + d = t.model_dump_internal(exclude_none=True) + else: + d = t.model_dump(exclude_none=True) + return Targeting(**d) + + +def _json_roundtrip(t: Targeting, *, internal: bool = False) -> Targeting: + """Simulate DB JSONB storage: model_dump -> json.dumps -> json.loads -> reconstruct.""" + if internal: + d = t.model_dump_internal(exclude_none=True) + else: + d = t.model_dump(exclude_none=True) + raw = json.loads(json.dumps(d)) + return Targeting(**raw) + + +# --------------------------------------------------------------------------- +# V3 Construct Roundtrip +# --------------------------------------------------------------------------- +class TestV3ConstructRoundtrip: + def test_full_v3_roundtrip(self): + """construct -> dump -> reconstruct -> dump matches.""" + t = Targeting( + geo_countries=["US", "CA"], + geo_regions=["US-CA", "US-NY"], + geo_metros=[{"system": "nielsen_dma", "values": ["501", "803"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + geo_countries_exclude=["RU"], + geo_regions_exclude=["US-TX"], + geo_metros_exclude=[{"system": "nielsen_dma", "values": ["602"]}], + geo_postal_areas_exclude=[{"system": "us_zip", "values": ["90210"]}], + device_type_any_of=["mobile", "desktop"], + frequency_cap={"max_impressions": 5, "suppress_minutes": 60, "scope": "package"}, + ) + d1 = t.model_dump(exclude_none=True) + t2 = _roundtrip(t) + d2 = t2.model_dump(exclude_none=True) + assert d1 == d2 + + def test_geo_metro_roundtrip(self): + t = Targeting(geo_metros=[{"system": "nielsen_dma", "values": ["501"]}]) + t2 = _roundtrip(t) + assert t2.geo_metros[0].system.value == "nielsen_dma" + assert t2.geo_metros[0].values == ["501"] + + def test_geo_postal_area_roundtrip(self): + t = Targeting(geo_postal_areas=[{"system": "us_zip", "values": ["10001", "90210"]}]) + t2 = _roundtrip(t) + assert t2.geo_postal_areas[0].system.value == "us_zip" + assert t2.geo_postal_areas[0].values == ["10001", "90210"] + + def test_exclusion_fields_roundtrip(self): + t = Targeting( + geo_countries_exclude=["RU", "CN"], + geo_metros_exclude=[{"system": "nielsen_dma", "values": ["803"]}], + ) + t2 = _roundtrip(t) + d1 = t.model_dump(exclude_none=True) + d2 = t2.model_dump(exclude_none=True) + assert d1 == d2 + + def test_mixed_targeting_roundtrip(self): + """Geo + device + freq_cap + audiences all survive roundtrip.""" + t = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + device_type_any_of=["mobile"], + browser_any_of=["chrome"], + audiences_any_of=["seg_123"], + frequency_cap={"max_impressions": 3, "suppress_minutes": 30}, + ) + t2 = _roundtrip(t) + d1 = t.model_dump(exclude_none=True) + d2 = t2.model_dump(exclude_none=True) + assert d1 == d2 + + +# --------------------------------------------------------------------------- +# DB Storage Simulation +# --------------------------------------------------------------------------- +class TestDBStorageSimulation: + def test_json_dumps_model_dump(self): + """json.dumps(t.model_dump()) must succeed — DB write proof.""" + t = Targeting( + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + geo_countries=["US"], + ) + result = json.dumps(t.model_dump(exclude_none=True)) + assert isinstance(result, str) + + def test_json_roundtrip(self): + """json.dumps -> json.loads -> Targeting(**data) -> json.dumps -> match.""" + t = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + geo_postal_areas=[{"system": "us_zip", "values": ["10001"]}], + ) + d1 = t.model_dump(exclude_none=True) + s1 = json.dumps(d1, sort_keys=True) + t2 = _json_roundtrip(t) + d2 = t2.model_dump(exclude_none=True) + s2 = json.dumps(d2, sort_keys=True) + assert s1 == s2 + + def test_model_dump_internal_json_roundtrip(self): + """Internal dump -> json -> reconstruct -> match.""" + t = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + key_value_pairs={"k": "v"}, + ) + d1 = t.model_dump_internal(exclude_none=True) + s1 = json.dumps(d1, sort_keys=True) + t2 = _json_roundtrip(t, internal=True) + d2 = t2.model_dump_internal(exclude_none=True) + s2 = json.dumps(d2, sort_keys=True) + assert s1 == s2 + + def test_manual_approval_flow(self): + """Targeting -> model_dump -> store -> Targeting(**raw) -> MediaPackage roundtrip.""" + t = Targeting( + geo_countries=["US"], + geo_metros=[{"system": "nielsen_dma", "values": ["501"]}], + device_type_any_of=["mobile"], + ) + # Simulate DB write (what media_buy_create does) + stored = t.model_dump_internal(exclude_none=True) + stored_json = json.dumps(stored) + + # Simulate DB read + reconstruction + raw = json.loads(stored_json) + t_reconstructed = Targeting(**raw) + + assert t_reconstructed.geo_countries[0].root == "US" + assert t_reconstructed.geo_metros[0].system.value == "nielsen_dma" + assert t_reconstructed.device_type_any_of == ["mobile"] + + def test_exclusion_survives_json_roundtrip(self): + t = Targeting( + geo_countries_exclude=["RU"], + geo_metros_exclude=[{"system": "nielsen_dma", "values": ["803"]}], + ) + t2 = _json_roundtrip(t) + d1 = t.model_dump(exclude_none=True) + d2 = t2.model_dump(exclude_none=True) + assert d1 == d2 + + +# --------------------------------------------------------------------------- +# Legacy Normalizer Roundtrip +# --------------------------------------------------------------------------- +class TestLegacyNormalizerRoundtrip: + def test_flat_country_to_v3_roundtrip(self): + """v2 flat geo_country_any_of -> normalizer -> v3 -> dump -> reconstruct -> same.""" + t = Targeting(geo_country_any_of=["US", "CA"]) + assert t.geo_countries is not None + d1 = t.model_dump(exclude_none=True) + + # Reconstruct from dump (simulates DB read) + t2 = Targeting(**d1) + d2 = t2.model_dump(exclude_none=True) + assert d1 == d2 + + def test_flat_metro_to_structured_roundtrip(self): + """v2 flat geo_metro_any_of -> normalizer -> structured GeoMetro -> roundtrip stable.""" + t = Targeting(geo_metro_any_of=["501", "803"]) + assert t.geo_metros is not None + d1 = t.model_dump(exclude_none=True) + + t2 = Targeting(**d1) + d2 = t2.model_dump(exclude_none=True) + assert d1 == d2 + + def test_flat_zip_to_structured_roundtrip(self): + """v2 flat geo_zip_any_of -> normalizer -> structured GeoPostalArea -> roundtrip stable.""" + t = Targeting(geo_zip_any_of=["10001", "90210"]) + assert t.geo_postal_areas is not None + d1 = t.model_dump(exclude_none=True) + + t2 = Targeting(**d1) + d2 = t2.model_dump(exclude_none=True) + assert d1 == d2 + + def test_bare_region_codes_roundtrip(self): + """Bare 'CA' -> normalizer prefixes 'US-CA' -> roundtrip stable.""" + t = Targeting(geo_region_any_of=["CA", "NY"]) + assert t.geo_regions is not None + d1 = t.model_dump(exclude_none=True) + # Normalizer should have prefixed with US- + assert all(r.startswith("US-") for r in d1["geo_regions"]) + + t2 = Targeting(**d1) + d2 = t2.model_dump(exclude_none=True) + assert d1 == d2 + + +# --------------------------------------------------------------------------- +# FrequencyCap Roundtrip +# --------------------------------------------------------------------------- +class TestFrequencyCapRoundtrip: + def test_freq_cap_scope_roundtrip(self): + """scope='package' survives dump -> reconstruct.""" + t = Targeting(frequency_cap={"max_impressions": 5, "suppress_minutes": 60, "scope": "package"}) + d1 = t.model_dump(exclude_none=True) + t2 = _roundtrip(t) + d2 = t2.model_dump(exclude_none=True) + assert d1["frequency_cap"]["scope"] == "package" + assert d1 == d2 + + def test_freq_cap_suppress_float_roundtrip(self): + """Float suppress_minutes value survives roundtrip.""" + t = Targeting(frequency_cap={"max_impressions": 3, "suppress_minutes": 45.5, "scope": "media_buy"}) + t2 = _json_roundtrip(t) + assert t2.frequency_cap.suppress_minutes == 45.5 + + def test_freq_cap_json_roundtrip(self): + """FrequencyCap through JSON storage roundtrip.""" + t = Targeting(frequency_cap={"max_impressions": 10, "suppress_minutes": 120, "scope": "package"}) + t2 = _json_roundtrip(t) + assert t2.frequency_cap.max_impressions == 10 + assert t2.frequency_cap.suppress_minutes == 120.0 + assert t2.frequency_cap.scope == "package" + assert isinstance(t2.frequency_cap, FrequencyCap) From a0692a3d6c013d05c1acc0d0d8b75154f456ce0e Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 11 Feb 2026 11:04:00 +0100 Subject: [PATCH 12/18] refactor: remove unrelated chore changes from feature branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts formatting-only changes (else→elif flattening, f-string merging), CLAUDE.md refactor, Makefile, ruff config additions, and .claude tooling files that were unrelated to #1006. Keeps only the int() cast fix in orders.py (required because FrequencyCap.suppress_minutes is now float from library inheritance). --- .claude/agents/qc-validator.md | 96 ---- .claude/commands/research.md | 83 ---- .claude/rules/patterns/code-patterns.md | 59 --- .claude/rules/patterns/mcp-patterns.md | 49 -- .claude/rules/patterns/testing-patterns.md | 50 -- .claude/rules/workflows/beads-workflow.md | 89 ---- .claude/rules/workflows/bug-reporting.md | 66 --- .claude/rules/workflows/quality-gates.md | 61 --- .claude/rules/workflows/research-workflow.md | 88 ---- .claude/rules/workflows/session-completion.md | 57 --- .../subagent-implementation-guide.md | 67 --- .claude/rules/workflows/tdd-workflow.md | 91 ---- .gitignore | 4 - CLAUDE.md | 463 +++++++++++++++--- Makefile | 27 - pyproject.toml | 63 +-- scripts/deploy/run_all_services.py | 6 +- scripts/gam_prerequisites_check.py | 1 - scripts/ops/migrate.py | 3 +- src/adapters/gam/managers/creatives.py | 2 +- src/adapters/gam/managers/orders.py | 16 +- src/adapters/gam_reporting_service.py | 58 +-- src/adapters/test_scenario_parser.py | 2 - src/admin/app.py | 4 +- src/admin/blueprints/inventory.py | 3 +- src/admin/blueprints/products.py | 28 +- src/admin/blueprints/publisher_partners.py | 4 +- src/admin/tests/integration/test_admin_app.py | 4 +- src/core/auth.py | 5 +- src/core/creative_agent_registry.py | 2 +- src/core/main.py | 4 +- src/core/tools/products.py | 21 +- src/core/tools/properties.py | 2 +- src/core/validation_helpers.py | 2 +- src/services/ai/agents/review_agent.py | 2 +- src/services/dynamic_pricing_service.py | 53 +- src/services/media_buy_status_scheduler.py | 4 +- src/services/policy_service.py | 31 +- 38 files changed, 545 insertions(+), 1125 deletions(-) delete mode 100644 .claude/agents/qc-validator.md delete mode 100644 .claude/commands/research.md delete mode 100644 .claude/rules/patterns/code-patterns.md delete mode 100644 .claude/rules/patterns/mcp-patterns.md delete mode 100644 .claude/rules/patterns/testing-patterns.md delete mode 100644 .claude/rules/workflows/beads-workflow.md delete mode 100644 .claude/rules/workflows/bug-reporting.md delete mode 100644 .claude/rules/workflows/quality-gates.md delete mode 100644 .claude/rules/workflows/research-workflow.md delete mode 100644 .claude/rules/workflows/session-completion.md delete mode 100644 .claude/rules/workflows/subagent-implementation-guide.md delete mode 100644 .claude/rules/workflows/tdd-workflow.md delete mode 100644 Makefile diff --git a/.claude/agents/qc-validator.md b/.claude/agents/qc-validator.md deleted file mode 100644 index 3acb77555..000000000 --- a/.claude/agents/qc-validator.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: QC Validator -description: Validates task completion against acceptance criteria, quality gates, and AdCP compliance. Use after completing a beads task to verify everything meets standards before closing. -color: green -tools: - - Bash - - Read - - Grep - - Glob ---- - -# QC Validator Agent - -You are a quality control validator for the Prebid Sales Agent project. Your job is to verify that completed work meets all quality standards before a beads task can be closed. - -## Modes - -### Task Completion Mode (Default) -Fast validation for closing a single beads task. Run when someone says "validate task ". - -### Full Validation Mode -Comprehensive check before merging to main. Run when someone says "full validation". - -## Task Completion Validation - -### Step 1: Read the Task -```bash -bd show -``` -Extract: -- Acceptance criteria from description -- Type (feature/bug/task) -- Any notes or design fields - -### Step 2: Verify Acceptance Criteria -For each acceptance criterion in the task description: -- Check if it's implemented (search codebase) -- Check if it's tested (search test files) -- Mark as PASS or FAIL with evidence - -### Step 3: Run Quality Gates -```bash -make quality -``` -Must pass cleanly. Report any failures. - -### Step 4: Check AdCP Compliance (if applicable) -If the task touches schemas, models, or protocol: -```bash -uv run pytest tests/unit/test_adcp_contract.py -v -``` - -### Step 5: Verify Git State -```bash -git status -git diff --stat -``` -Check: -- All changes are committed (or staged) -- No unintended files modified -- Commit message follows conventional commits format - -### Step 6: Report - -Output a validation report: - -``` -## QC Validation Report: - -### Acceptance Criteria -- [ ] Criterion 1: PASS/FAIL — evidence -- [ ] Criterion 2: PASS/FAIL — evidence - -### Quality Gates -- [ ] ruff format: PASS/FAIL -- [ ] ruff check: PASS/FAIL -- [ ] mypy: PASS/FAIL -- [ ] unit tests: PASS/FAIL - -### AdCP Compliance -- [ ] Contract tests: PASS/FAIL/N/A - -### Git State -- [ ] Changes committed: YES/NO -- [ ] Commit message format: PASS/FAIL - -### Verdict: PASS / FAIL -``` - -## Full Validation Mode - -Runs everything above plus: -1. `make quality-full` (includes integration and e2e tests) -2. Verifies all open beads tasks are either completed or have clear follow-up issues -3. Checks `bd list --status=in_progress` for abandoned work -4. Verifies `bd sync --from-main` has been run diff --git a/.claude/commands/research.md b/.claude/commands/research.md deleted file mode 100644 index a97bd82e6..000000000 --- a/.claude/commands/research.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: research -description: Research a beads task before implementation -arguments: - - name: task_id - description: The beads task ID to research (e.g., beads-001) - required: true ---- - -# Research Task: $ARGUMENTS - -## Instructions - -You are researching beads task **$ARGUMENTS** before implementation begins. - -### Step 1: Read the Task -Run `bd show $ARGUMENTS` to get the full task description, acceptance criteria, and any design notes. - -### Step 2: Explore the Codebase -Based on the task requirements: -1. Search for relevant code using Grep and Glob -2. Read the files that will need to be modified -3. Check existing tests for the affected area -4. Look for similar implementations to follow as patterns - -### Step 3: Check Documentation (Doc-First Rule) -If the task involves external libraries: -- Use Ref MCP to search library documentation -- Use DeepWiki MCP to ask questions about GitHub repos -- Check CLAUDE.md for project-specific patterns -- Check `/docs` directory for detailed documentation - -### Step 4: Engineering Checklist -Run these checks against your findings. Each one should produce a concrete answer, not a shrug. - -1. **DRY**: Does similar logic already exist? Search for functions doing comparable work. Extend, don't duplicate. -2. **Library idioms**: How does the primary library (Pydantic, SQLAlchemy, FastMCP, etc.) solve this? Check docs via Ref/DeepWiki before hand-rolling. -3. **Data flow trace**: Walk one concrete example from system boundary (buyer JSON) → Pydantic parsing → logic layer → data layer (DB write/read) → response serialization. Trace both the success path and a failure/rejection path. Note where types change or could break. -4. **Consistency**: How do the language/framework and this codebase solve the same class of problem? Check both — codebase conventions take precedence unless they're a clear antipattern. -5. **Test infrastructure**: What fixtures, factories, helpers already exist in `tests/`? What's reusable vs needs new? - -### Step 5: Integrate Findings -Weave the checklist answers into your analysis. They should shape your architecture decisions and implementation plan — not appear as a standalone section in the artifact. - -### Step 6: Identify Architecture Decisions -Based on your research: -- What CLAUDE.md patterns apply? -- Are there multiple valid approaches? -- What are the risks or edge cases? - -### Step 7: Create Research Artifact -Create a research file at `.claude/research/$ARGUMENTS.md` with: - -```markdown -# Research: [task title from bd show] - -## Task -$ARGUMENTS: [description] - -## Findings -- [Key findings from codebase exploration] - -## Relevant Code -- `path/to/file.py:line` — [what it does] - -## CLAUDE.md Patterns -- [Which critical patterns apply and how] - -## Architecture Decisions -- [Decisions and rationale] - -## Implementation Plan -1. [First step] -2. [Second step] -3. [...] - -## Risks & Edge Cases -- [Potential issues to watch for] -``` - -### Step 8: Update the Task -Run `bd label add $ARGUMENTS research:complete` if research is sufficient. -Run `bd label add $ARGUMENTS research:blocked` if there are unresolved questions, and add notes explaining what's blocked. diff --git a/.claude/rules/patterns/code-patterns.md b/.claude/rules/patterns/code-patterns.md deleted file mode 100644 index 3bee7a46b..000000000 --- a/.claude/rules/patterns/code-patterns.md +++ /dev/null @@ -1,59 +0,0 @@ -# Code Patterns - -Reference patterns for writing code in this project. Read this when implementing new features or modifying existing code. - -## SQLAlchemy 2.0 (MANDATORY for new code) -```python -from sqlalchemy import select - -# Use this -stmt = select(Model).filter_by(field=value) -instance = session.scalars(stmt).first() - -# Not this (deprecated) -instance = session.query(Model).filter_by(field=value).first() -``` - -## Database JSON Fields -```python -from src.core.database.json_type import JSONType - -class MyModel(Base): - config: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) -``` - -## Import Patterns -```python -# Always use absolute imports -from src.core.schemas import Principal -from src.core.database.database_session import get_db_session -from src.adapters import get_adapter -``` - -## No Quiet Failures -```python -# WRONG - Silent failure -if not self.supports_feature: - logger.warning("Skipping...") - -# CORRECT - Explicit failure -if not self.supports_feature and feature_requested: - raise FeatureNotSupportedException("Cannot fulfill contract") -``` - -## Code Style -- Use `uv` for dependencies -- Run `pre-commit run --all-files` -- Use type hints -- No hardcoded external system IDs (use config/database) -- No testing against production systems - -## Type Checking -```bash -uv run mypy src/core/your_file.py --config-file=mypy.ini -``` - -When modifying code: -1. Fix mypy errors in files you change -2. Use SQLAlchemy 2.0 `Mapped[]` annotations for new models -3. Use `| None` instead of `Optional[]` (Python 3.10+) diff --git a/.claude/rules/patterns/mcp-patterns.md b/.claude/rules/patterns/mcp-patterns.md deleted file mode 100644 index 0e8975845..000000000 --- a/.claude/rules/patterns/mcp-patterns.md +++ /dev/null @@ -1,49 +0,0 @@ -# MCP & A2A Patterns - -Reference patterns for working with MCP tools and A2A integration. Read this when adding or modifying tools. - -## MCP Client Usage -```python -from fastmcp.client import Client -from fastmcp.client.transports import StreamableHttpTransport - -headers = {"x-adcp-auth": "your_token"} -transport = StreamableHttpTransport(url="http://localhost:8000/mcp/", headers=headers) -client = Client(transport=transport) - -async with client: - products = await client.tools.get_products(brief="video ads") - result = await client.tools.create_media_buy(product_ids=["prod_1"], ...) -``` - -## CLI Testing -```bash -# List available tools -uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools - -# Get a real token from Admin UI -> Advertisers -> API Token -uvx adcp http://localhost:8000/mcp/ --auth get_products '{"brief":"video"}' -``` - -## Shared Implementation Pattern (Critical Pattern #5) -All tools use shared `_tool_name_impl()` called by both MCP and A2A paths: - -```python -# main.py -def _create_media_buy_impl(...) -> CreateMediaBuyResponse: - return response - -@mcp.tool() -def create_media_buy(...) -> CreateMediaBuyResponse: - return _create_media_buy_impl(...) - -# tools.py -def create_media_buy_raw(...) -> CreateMediaBuyResponse: - from src.core.main import _create_media_buy_impl - return _create_media_buy_impl(...) -``` - -## Access Points (via nginx at http://localhost:8000) -- Admin UI: `/admin/` or `/tenant/default` -- MCP Server: `/mcp/` -- A2A Server: `/a2a` diff --git a/.claude/rules/patterns/testing-patterns.md b/.claude/rules/patterns/testing-patterns.md deleted file mode 100644 index 87b810b85..000000000 --- a/.claude/rules/patterns/testing-patterns.md +++ /dev/null @@ -1,50 +0,0 @@ -# Testing Patterns - -Reference patterns for writing tests. Read this when adding or modifying tests. - -## Test Organization -- **tests/unit/**: Fast, isolated (mock external deps only) -- **tests/integration/**: Real PostgreSQL database -- **tests/e2e/**: Full system tests -- **tests/ui/**: Admin UI tests - -## Database Fixtures -```python -# Integration tests - use integration_db -@pytest.mark.requires_db -def test_something(integration_db): - with get_db_session() as session: - # Test with real PostgreSQL - pass - -# Unit tests - mock the database -def test_something(): - with patch('src.core.database.database_session.get_db_session') as mock_db: - # Test with mocked database - pass -``` - -## Quality Rules -- Max 10 mocks per test file (pre-commit enforces) -- AdCP compliance test for all client-facing models -- Test YOUR code, not Python built-ins -- Never skip tests - fix the issue (`skip_ci` for rare exceptions only) -- Roundtrip test required for any operation using `apply_testing_hooks()` - -## Testing Workflow (Before Commit) -```bash -# ALL changes -make quality - -# Refactorings (shared impl, moving code, imports) -uv run pytest tests/integration/ -x - -# Critical changes (protocol, schema updates) -uv run pytest tests/ -x -``` - -**Pre-commit hooks can't catch import errors** - You must run tests for refactorings! - -## Also See -- `.claude/rules/workflows/tdd-workflow.md` — Red-Green-Refactor cycle -- `.claude/rules/workflows/quality-gates.md` — Quality gate commands diff --git a/.claude/rules/workflows/beads-workflow.md b/.claude/rules/workflows/beads-workflow.md deleted file mode 100644 index d6e47c9fe..000000000 --- a/.claude/rules/workflows/beads-workflow.md +++ /dev/null @@ -1,89 +0,0 @@ -# Beads Workflow - -## 4-Step Loop - -### 1. Find & Review -```bash -bd ready # Show tasks ready to work (no blockers) -bd show # Read full description, acceptance criteria -``` - -Choose a task based on: -- Priority (P0 > P1 > P2 > P3 > P4) -- Dependencies (prefer unblocking other tasks) -- Logical ordering (setup before implementation) - -### 2. Validate Requirements - -Before writing code, verify you understand: - -**From the task itself:** -- What are the acceptance criteria? -- What does "done" look like? -- Are there dependencies or blocked tasks? - -**From CLAUDE.md (7 critical patterns):** -- Does this touch schemas? → Check AdCP pattern (#1) -- Does this add routes? → Check route conflict pattern (#2) -- Does this touch the database? → PostgreSQL only (#3) -- Does this serialize models? → Check nested serialization (#4) -- Does this add a tool? → Shared impl pattern (#5) -- Does this touch JavaScript? → script_root pattern (#6) -- Does this change validation? → Environment-based pattern (#7) - -**From existing code:** -- Read the files you'll modify -- Check existing tests for the area -- Look for similar implementations to follow - -**Decision checklist before implementing:** -- [ ] I understand the acceptance criteria -- [ ] I've read CLAUDE.md patterns relevant to this task -- [ ] I've read the existing code I'll modify -- [ ] I've checked for existing tests -- [ ] I know what "done" looks like - -### 3. Claim & Work -```bash -bd update --status=in_progress -``` - -Implement following TDD workflow (see tdd-workflow.md): -1. Write failing test -2. Make it pass -3. Refactor -4. Run `make quality` - -### 4. Verify & Close - -**QC validation before closure:** -- [ ] `make quality` passes -- [ ] Acceptance criteria from task description are met -- [ ] No regressions in existing tests -- [ ] Changes committed with conventional commit message - -```bash -bd close -``` - -## Creating New Tasks - -For discovered work: -```bash -bd create --title="..." --type=task|bug|feature --priority=2 -``` - -**Priority scale**: 0=critical, 1=high, 2=medium, 3=low, 4=backlog - -For dependent work: -```bash -bd dep add # child depends on parent -``` - -## Task Status Flow - -``` -pending → in_progress → completed (via bd close) -``` - -Use `bd blocked` to see tasks waiting on dependencies. diff --git a/.claude/rules/workflows/bug-reporting.md b/.claude/rules/workflows/bug-reporting.md deleted file mode 100644 index beb0a17a1..000000000 --- a/.claude/rules/workflows/bug-reporting.md +++ /dev/null @@ -1,66 +0,0 @@ -# Bug Reporting & Fix Workflow - -## When You Find a Bug - -### 1. Create Beads Issue -```bash -bd create --title="Bug: " --type=bug --priority=<0-4> -``` - -Include in the description: -- **Observed behavior**: What actually happens -- **Expected behavior**: What should happen -- **Reproduction steps**: How to trigger it -- **Affected area**: Which files/components - -### 2. Validate Against Patterns - -Before fixing, check: -- Does this violate a CLAUDE.md critical pattern? -- Is this an AdCP spec compliance issue? (Check `tests/unit/test_adcp_contract.py`) -- Is this a regression from a recent change? (Check `git log --oneline -20`) - -### 3. Write Regression Test - -**Always write the test FIRST:** -```bash -# Write the failing test -uv run pytest tests/unit/test_.py::test_ -x -# Confirm it fails for the right reason -``` - -The test should: -- Demonstrate the bug clearly -- Be minimal (test one thing) -- Follow existing test patterns in the file - -### 4. Fix the Bug - -- Fix the root cause, not symptoms -- Keep the fix minimal and focused -- Don't refactor surrounding code (separate PR) - -### 5. Quality Gates -```bash -make quality -``` - -Verify: -- New test passes -- No existing tests broken -- Formatting and linting clean - -### 6. Close and Commit -```bash -bd close -git add -git commit -m "fix: " -``` - -## Bug Priority Guide - -- **P0 (critical)**: Data loss, security vulnerability, complete feature broken -- **P1 (high)**: Major feature degraded, blocking other work -- **P2 (medium)**: Feature works but incorrectly in some cases -- **P3 (low)**: Minor issue, workaround exists -- **P4 (backlog)**: Cosmetic, edge case, nice-to-have fix diff --git a/.claude/rules/workflows/quality-gates.md b/.claude/rules/workflows/quality-gates.md deleted file mode 100644 index fc3a73740..000000000 --- a/.claude/rules/workflows/quality-gates.md +++ /dev/null @@ -1,61 +0,0 @@ -# Quality Gates - -## When to Run - -Run quality gates **before any commit** and **before closing a beads task**. - -## Quick Check (Every Change) - -```bash -make quality -``` - -This runs: -1. `ruff format --check .` — formatting -2. `ruff check .` — linting (includes C90 complexity and PLR refactor rules) -3. `mypy src/ --config-file=mypy.ini` — type checking -4. `pytest tests/unit/ -x` — unit tests (fail-fast) - -## Full Check (Before Merge) - -```bash -make quality-full -``` - -Runs everything above plus `./run_all_tests.sh ci` (integration + e2e with PostgreSQL). - -## Common Violations - -### Ruff Rules to Watch -- **C901** (complexity > 10): Break function into smaller pieces -- **PLR0912** (branches > 12): Simplify conditional logic -- **PLR0913** (args > 5): Use dataclass/config object for parameters -- **PLR0915** (statements > 50): Extract helper functions - -### Pre-commit Hooks (11 active) -The project has 11 pre-commit hooks that catch: -- Route conflicts -- SQLAlchemy 1.x patterns -- Star imports -- Excessive mocks in tests -- Documentation link breakage -- Import usage issues - -Run manually: `pre-commit run --all-files` - -**Important**: Pre-commit hooks can't catch import errors. After refactoring or moving code, always run `uv run pytest tests/unit/ -x` to verify. - -## AdCP Contract Compliance - -For any schema changes, run: -```bash -uv run pytest tests/unit/test_adcp_contract.py -v -``` - -## Fix Formatting/Linting Issues - -```bash -make lint-fix -``` - -This runs `ruff format .` then `ruff check --fix .`. diff --git a/.claude/rules/workflows/research-workflow.md b/.claude/rules/workflows/research-workflow.md deleted file mode 100644 index 69a228a5b..000000000 --- a/.claude/rules/workflows/research-workflow.md +++ /dev/null @@ -1,88 +0,0 @@ -# Research Workflow - -## When Research is Needed - -Before implementing a beads task, research is warranted when: -- The task involves unfamiliar code paths -- External library APIs need verification (doc-first rule) -- Architecture decisions need to be made -- The acceptance criteria are ambiguous - -## 3-Path Flow - -### Path 1: Research Complete -Research answers all questions. Implementation can proceed. -```bash -bd label add research:complete -``` - -### Path 2: Research Blocked -Research reveals missing information or external blockers. -```bash -bd label add research:blocked -bd update --notes="Blocked because: " -``` - -### Path 3: No Research Needed -Task is clear and well-defined. Skip to implementation. - -## Research Process - -### 1. Explore the Codebase -- Read files involved in the change -- Trace execution paths -- Check existing tests for examples -- Look for similar implementations - -### 2. Check Documentation -**Doc-First Rule**: For external libraries, check docs before relying on training data. - -Available documentation sources: -- **Ref MCP**: Search library docs (FastMCP, SQLAlchemy, Flask, Pydantic, etc.) -- **DeepWiki MCP**: Ask questions about GitHub repositories -- **CLAUDE.md**: Project patterns and architecture -- **`/docs` directory**: Detailed project documentation - -### 3. Record Findings - -Create a research artifact: -``` -.claude/research/.md -``` - -Structure: -```markdown -# Research: - -## Task -: - -## Findings -- Key finding 1 -- Key finding 2 - -## Relevant Code -- `path/to/file.py:line` — description -- `path/to/other.py:line` — description - -## Architecture Decisions -- Decision 1: - -## Implementation Notes -- Start with: -- Watch out for: -``` - -### 4. Update Task -```bash -bd label add research:complete -bd update --notes="Research complete. See .claude/research/.md" -``` - -## Tips - -- Keep research artifacts concise (not exhaustive documentation) -- Focus on what the implementer needs to know -- Include specific file paths and line numbers -- Note any risks or edge cases discovered -- If research takes > 15 minutes, it's probably too broad — narrow scope diff --git a/.claude/rules/workflows/session-completion.md b/.claude/rules/workflows/session-completion.md deleted file mode 100644 index 651cc380f..000000000 --- a/.claude/rules/workflows/session-completion.md +++ /dev/null @@ -1,57 +0,0 @@ -# Session Completion Checklist - -## Before Saying "Done" or "Complete" - -Run through this checklist in order: - -### Step 1: Check Incomplete Work -```bash -bd list --status=in_progress -``` -Review any tasks still in progress. Either complete them or file follow-up issues. - -### Step 2: File Issues for Remaining Work -For anything discovered but not completed: -```bash -bd create --title="..." --type=task --priority=2 -``` -Include enough context for the next session to pick up the work. - -### Step 3: Run Quality Gates -```bash -make quality -``` -All checks must pass. If they fail, fix the issues before committing. - -### Step 4: Close Completed Tasks -```bash -bd close ... -``` -Close all beads tasks that were fully completed this session. - -### Step 5: Commit and Sync -```bash -git add -bd sync --from-main -git commit -m "feat/fix/refactor: description" -``` - -**Important**: This is an ephemeral branch. No `git push`. Code is merged to main locally. - -### Step 6: Verify Clean State -```bash -git status -bd list --status=open -``` -Confirm: -- Working tree is clean (or only has expected untracked files) -- All completed tasks are closed -- Any remaining open tasks have clear descriptions - -## Ephemeral Branch Workflow - -This project uses ephemeral branches: -- Work happens on feature branches -- Branches are merged to main **locally** (not pushed) -- `bd sync --from-main` pulls beads updates from main before final commit -- No upstream tracking — don't run `git push` diff --git a/.claude/rules/workflows/subagent-implementation-guide.md b/.claude/rules/workflows/subagent-implementation-guide.md deleted file mode 100644 index f2c621670..000000000 --- a/.claude/rules/workflows/subagent-implementation-guide.md +++ /dev/null @@ -1,67 +0,0 @@ -# Subagent Implementation Guide - -## When to Use Subagents - -Use the Task tool with subagents when: -- **Parallelizing independent work**: Multiple files to explore, multiple beads tasks to create -- **Protecting context**: Large search results that would clutter the main conversation -- **Specialized agents**: QC validation, deep research, code exploration - -## Subagent Types - -### Explore Agent (`subagent_type=Explore`) -Use for codebase exploration: -- Finding files by patterns -- Searching for code keywords -- Understanding how features work -- Tracing execution paths - -### General Purpose Agent (`subagent_type=general-purpose`) -Use for complex, multi-step tasks: -- Researching questions across multiple files -- Executing multi-step analysis -- When you need all tools available - -### QC Validator Agent (`.claude/agents/qc-validator.md`) -Use after completing a beads task: -- Validates acceptance criteria -- Runs quality gates -- Checks AdCP compliance -- Verifies git state - -## Implementation Pattern - -When a beads task has a design field or detailed acceptance criteria: - -### 1. Plan the Work -Read the beads task design/description for implementation guidance: -```bash -bd show -``` - -### 2. Parallelize Where Possible -Launch independent subagents simultaneously: -``` -Task 1: Explore agent — find related code patterns -Task 2: Explore agent — find existing tests -``` - -### 3. Implement Sequentially -After research, implement in order: -1. Write tests (TDD) -2. Write implementation -3. Run quality gates - -### 4. Validate with QC Agent -Before closing the beads task, run the QC validator: -- Checks all acceptance criteria -- Runs `make quality` -- Verifies AdCP compliance if applicable - -## Tips - -- **Be specific in prompts**: Tell subagents exactly what to find/do -- **Include file paths**: When you know which files to check -- **Set expectations**: Tell the agent whether to write code or just research -- **Use background agents**: For long-running tasks that don't block your work -- **Don't duplicate work**: If you delegate research, don't also search yourself diff --git a/.claude/rules/workflows/tdd-workflow.md b/.claude/rules/workflows/tdd-workflow.md deleted file mode 100644 index b77873721..000000000 --- a/.claude/rules/workflows/tdd-workflow.md +++ /dev/null @@ -1,91 +0,0 @@ -# TDD Workflow - -## Red-Green-Refactor Cycle - -### 1. Red — Write Failing Test - -Before writing implementation code: -1. Understand requirements from beads task + CLAUDE.md patterns -2. Write a test that describes the desired behavior -3. Run it and confirm it fails: - -```bash -uv run pytest tests/unit/test_.py::test_ -x -v -``` - -**Test organization:** -- `tests/unit/` — Fast, isolated (mock external deps only) -- `tests/integration/` — Real PostgreSQL database -- `tests/e2e/` — Full system tests - -### 2. Green — Make It Pass - -Write the minimum code to make the test pass: -- Follow CLAUDE.md critical patterns -- Use existing patterns from surrounding code -- Don't add extras not covered by tests - -```bash -uv run pytest tests/unit/test_.py::test_ -x -v -``` - -### 3. Refactor — Clean Up - -With passing tests as safety net: -- Remove duplication -- Improve naming -- Simplify logic -- Ensure CLAUDE.md patterns are followed - -```bash -make quality # Full quality gate after refactoring -``` - -## Requirements Sources - -For salesagent, requirements come from: -1. **Beads task description** — acceptance criteria -2. **CLAUDE.md** — 7 critical architecture patterns -3. **AdCP spec** — protocol compliance (`tests/unit/test_adcp_contract.py`) -4. **Existing test patterns** — conventions in `tests/unit/` - -## Sacred Rule - -**NEVER adjust tests to match code.** - -If a test fails after implementation: -- The implementation is wrong, OR -- The test requirements were wrong (update requirements first, then test, then code) - -Tests define the contract. Code fulfills it. - -## Common Test Patterns - -### Unit Test (Mock External Deps) -```python -def test_something(): - with patch('src.core.database.database_session.get_db_session') as mock_db: - # Test with mocked database - pass -``` - -### Integration Test (Real PostgreSQL) -```python -@pytest.mark.requires_db -def test_something(integration_db): - with get_db_session() as session: - # Test with real PostgreSQL - pass -``` - -### AdCP Compliance -```python -# Run after any schema changes -uv run pytest tests/unit/test_adcp_contract.py -v -``` - -## Quality Rules -- Max 10 mocks per test file -- Test YOUR code, not Python built-ins -- Roundtrip test required for `apply_testing_hooks()` -- Never use `skip_ci` without explicit justification diff --git a/.gitignore b/.gitignore index edb0f5423..b4908501d 100644 --- a/.gitignore +++ b/.gitignore @@ -121,7 +121,3 @@ test_*[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/ .env.test test_sync.py .env.bak* - -# Agentic coding — local-only files -.claude/research/ -.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index c78588d7a..004e31913 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,23 +1,24 @@ # Prebid Sales Agent - Development Guide -## Rules +## 🤖 For Claude (AI Assistant) +This guide helps you work effectively with the Prebid Sales Agent codebase maintained under Prebid.org. Key principles: + +### Working with This Codebase 1. **Always read before writing** - Use Read/Glob to understand existing patterns -2. **Test your changes** - Run `make quality` before committing -3. **Follow the 7 patterns below** - They are non-negotiable -4. **Doc-first** - Search docs via MCP servers (Ref, DeepWiki) before relying on training data for external libraries (FastMCP, SQLAlchemy, adcp, Flask, Pydantic, etc.) +2. **Test your changes** - Run `uv run pytest tests/unit/ -x` before committing +3. **Follow the patterns** - 7 critical patterns below are non-negotiable +4. **When stuck** - Check `/docs` for detailed explanations 5. **Pre-commit hooks are your friend** - They catch most issues automatically -6. **Conventional commits** - PRs need prefixes (`feat:`, `fix:`, `docs:`, `refactor:`, `perf:`, `chore:`) to pass `.github/workflows/pr-title-check.yml` and appear in release notes +6. **Name your PRs correctly** - they need to pass .github/workflows/pr-title-check.yml -### What to Avoid -- Don't use `session.query()` (use `select()` + `scalars()`) -- Don't duplicate library schemas (extend with inheritance) -- Don't hardcode URLs in JavaScript (use `scriptRoot`) -- Don't bypass pre-commit hooks without good reason -- Don't skip tests to make CI pass (fix the underlying issue) -- Never add `# noqa` comments without explaining why in a code comment - -### Key Files +### Common Task Patterns +- **Adding a new AdCP tool**: Extend library schema → Add `_impl()` function → Add MCP wrapper → Add A2A raw function → Add tests +- **Fixing a route issue**: Check for conflicts with `grep -r "@.*route.*your/path"` → Use `url_for()` in Python, `scriptRoot` in JavaScript +- **Modifying schemas**: Verify against AdCP spec → Update Pydantic model → Run `pytest tests/unit/test_adcp_contract.py` +- **Database changes**: Use SQLAlchemy 2.0 `select()` → Use `JSONType` for JSON → Create migration with `alembic revision` + +### Key Files to Know - `src/core/main.py` - MCP tools and `_impl()` functions - `src/core/tools.py` - A2A raw functions - `src/core/schemas.py` - Pydantic models (AdCP-compliant) @@ -25,15 +26,29 @@ - `src/adapters/gam/` - GAM implementation - `tests/unit/test_adcp_contract.py` - Schema compliance tests -### Common Task Patterns -- **Adding a new AdCP tool**: Extend library schema -> Add `_impl()` function -> Add MCP wrapper -> Add A2A raw function -> Add tests -- **Fixing a route issue**: Check for conflicts with `grep -r "@.*route.*your/path"` -> Use `url_for()` in Python, `scriptRoot` in JavaScript -- **Modifying schemas**: Verify against AdCP spec -> Update Pydantic model -> Run `pytest tests/unit/test_adcp_contract.py` -- **Database changes**: Use SQLAlchemy 2.0 `select()` -> Use `JSONType` for JSON -> Create migration with `alembic revision` +### What to Avoid +- ❌ Don't use `session.query()` (use `select()` + `scalars()`) +- ❌ Don't duplicate library schemas (extend with inheritance) +- ❌ Don't hardcode URLs in JavaScript (use `scriptRoot`) +- ❌ Don't bypass pre-commit hooks without good reason +- ❌ Don't skip tests to make CI pass (fix the underlying issue) + +### Commit Messages & PR Titles +**Use Conventional Commits format** - release-please uses this to generate changelogs. + +PR titles should use one of these prefixes: +- `feat: Add new feature` - New functionality (appears in "Features" section) +- `fix: Fix bug description` - Bug fixes (appears in "Bug Fixes" section) +- `docs: Update documentation` - Documentation changes +- `refactor: Restructure code` - Code refactoring (appears in "Code Refactoring" section) +- `perf: Improve performance` - Performance improvements +- `chore: Update dependencies` - Maintenance tasks (hidden from changelog) + +**Without a prefix, commits won't appear in release notes!** The code will still be released, but the change won't be documented in the changelog. --- -## Critical Architecture Patterns +## 🚨 Critical Architecture Patterns ### 1. AdCP Schema: Extend Library Schemas **MANDATORY**: Use `adcp` library schemas via inheritance, never duplicate. @@ -46,13 +61,25 @@ class Product(LibraryProduct): implementation_config: dict[str, Any] | None = Field(default=None, exclude=True) ``` -Rules: Extend library schemas for domain objects needing internal fields. Mark internal fields with `exclude=True`. Run `pytest tests/unit/test_adcp_contract.py` before commit. +**Rules:** +- Extend library schemas for domain objects needing internal fields +- Mark internal fields with `exclude=True` +- Run `pytest tests/unit/test_adcp_contract.py` before commit +- Never bypass `--no-verify` without manual schema validation ### 2. Flask: Prevent Route Conflicts -Pre-commit hook detects duplicate routes. Run manually: `uv run python .pre-commit-hooks/check_route_conflicts.py` +**Pre-commit hook detects duplicate routes** - Run manually: `uv run python .pre-commit-hooks/check_route_conflicts.py` + +When adding routes: +- Search existing: `grep -r "@.*route.*your/path"` +- Deprecate properly with early return, not comments ### 3. Database: PostgreSQL Only -No SQLite support. Use `JSONType` for all JSON columns (not plain `JSON`). Use SQLAlchemy 2.0 patterns: `select()` + `scalars()`, not `query()`. +**No SQLite support** - Production uses PostgreSQL exclusively. + +- Use `JSONType` for all JSON columns (not plain `JSON`) +- Use SQLAlchemy 2.0 patterns: `select()` + `scalars()`, not `query()` +- All tests require PostgreSQL: `./run_all_tests.sh ci` ### 4. Pydantic: Explicit Nested Serialization Parent models must override `model_dump()` to serialize nested children: @@ -68,82 +95,386 @@ class GetCreativesResponse(AdCPBaseModel): return result ``` -Why: Pydantic doesn't auto-call custom `model_dump()` on nested models. +**Why**: Pydantic doesn't auto-call custom `model_dump()` on nested models. ### 5. MCP/A2A: Shared Implementations -All tools use shared `_tool_name_impl()` function called by both MCP and A2A paths. See `.claude/rules/patterns/mcp-patterns.md` for full example. +All tools use shared `_tool_name_impl()` function called by both MCP and A2A paths. + +```python +# main.py +def _create_media_buy_impl(...) -> CreateMediaBuyResponse: + # Real implementation + return response + +@mcp.tool() +def create_media_buy(...) -> CreateMediaBuyResponse: + return _create_media_buy_impl(...) + +# tools.py +def create_media_buy_raw(...) -> CreateMediaBuyResponse: + from src.core.main import _create_media_buy_impl + return _create_media_buy_impl(...) +``` ### 6. JavaScript: Use request.script_root +**All JS must support reverse proxy deployments:** + ```javascript -const scriptRoot = '{{ request.script_root }}' || ''; +const scriptRoot = '{{ request.script_root }}' || ''; // e.g., '/admin' or '' const apiUrl = scriptRoot + '/api/endpoint'; +fetch(apiUrl, { credentials: 'same-origin' }); ``` + Never hardcode `/api/endpoint` - breaks with nginx prefix. ### 7. Schema Validation: Environment-Based -- **Production**: `ENVIRONMENT=production` -> `extra="ignore"` (forward compatible) -- **Development/CI**: Default -> `extra="forbid"` (strict validation) +- **Production**: `ENVIRONMENT=production` → `extra="ignore"` (forward compatible) +- **Development/CI**: Default → `extra="forbid"` (strict validation) + +--- + +## Project Overview + +Python-based Prebid Sales Agent with: +- **MCP Server**: FastMCP tools for AI agents (via nginx at `/mcp/`) +- **Admin UI**: Google OAuth secured interface (via nginx at `/admin/` or `/tenant/`) +- **A2A Server**: python-a2a agent-to-agent communication (via nginx at `/a2a`) +- **Multi-Tenant**: Database-backed isolation with subdomain routing +- **PostgreSQL**: Production-ready with Docker deployment + +All services are accessed through the nginx proxy at **http://localhost:8000**. + +--- + +## Key Patterns + +### SQLAlchemy 2.0 (MANDATORY for new code) +```python +from sqlalchemy import select + +# Use this +stmt = select(Model).filter_by(field=value) +instance = session.scalars(stmt).first() + +# Not this (deprecated) +instance = session.query(Model).filter_by(field=value).first() +``` + +### Database JSON Fields +```python +from src.core.database.json_type import JSONType + +class MyModel(Base): + config: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) +``` + +### Import Patterns +```python +# Always use absolute imports +from src.core.schemas import Principal +from src.core.database.database_session import get_db_session +from src.adapters import get_adapter +``` + +### No Quiet Failures +```python +# ❌ WRONG - Silent failure +if not self.supports_feature: + logger.warning("Skipping...") + +# ✅ CORRECT - Explicit failure +if not self.supports_feature and feature_requested: + raise FeatureNotSupportedException("Cannot fulfill contract") +``` --- -## Commands +## Common Operations + +### Running Locally + +```bash +# Clone and start +git clone https://github.com/prebid/salesagent.git +cd salesagent +docker compose up -d # Build and start all services +docker compose logs -f # View logs (Ctrl+C to exit) +docker compose down # Stop + +# Migrations run automatically on startup +``` + +**Access at http://localhost:8000:** +- Admin UI: `/admin/` or `/tenant/default` +- MCP Server: `/mcp/` +- A2A Server: `/a2a` + +**Test login:** Click "Log in to Dashboard" button (password: `test123`) +**Test MCP interface:** ```bash -make quality # Format + lint + mypy + unit tests (before every commit) -make quality-full # Above + integration/e2e with PostgreSQL -make lint-fix # Auto-fix formatting and lint issues -make test-fast # Unit tests only (fail-fast) +uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools ``` -### Git Workflow -Never push directly to main. Work on feature branches, create PR, merge via GitHub UI. +**Note:** `docker compose` builds from local source. For a clean rebuild: `docker compose build --no-cache` + +### Testing +```bash +./run_all_tests.sh ci # Full suite with PostgreSQL (matches CI) +./run_all_tests.sh quick # Fast iteration (skips database tests) + +# Manual pytest +uv run pytest tests/unit/ # Unit tests only +uv run pytest tests/integration/ # Integration tests +uv run pytest tests/e2e/ # E2E tests + +# AdCP compliance (MANDATORY before commit) +uv run pytest tests/unit/test_adcp_contract.py -v +``` ### Database Migrations ```bash -uv run alembic revision -m "description" # Create migration uv run python scripts/ops/migrate.py # Run migrations locally +uv run alembic revision -m "description" # Create migration + +# In Docker (migrations run automatically, but can be run manually): +docker compose exec admin-ui python scripts/ops/migrate.py +``` + +**Never modify existing migrations after commit!** + +### Tenant Setup Dependencies +``` +Tenant → CurrencyLimit (USD required for budget validation) + → PropertyTag ("all_inventory" required for property_tags references) + → Products (require BOTH) +``` + +--- + +## Testing Guidelines + +### Test Organization +- **tests/unit/**: Fast, isolated (mock external deps only) +- **tests/integration/**: Real PostgreSQL database +- **tests/e2e/**: Full system tests +- **tests/ui/**: Admin UI tests + +### Database Fixtures +```python +# Integration tests - use integration_db +@pytest.mark.requires_db +def test_something(integration_db): + with get_db_session() as session: + # Test with real PostgreSQL + pass + +# Unit tests - mock the database +def test_something(): + with patch('src.core.database.database_session.get_db_session') as mock_db: + # Test with mocked database + pass +``` + +### Quality Rules +- Max 10 mocks per test file (pre-commit enforces) +- AdCP compliance test for all client-facing models +- Test YOUR code, not Python built-ins +- Never skip tests - fix the issue (`skip_ci` for rare exceptions only) +- Roundtrip test required for any operation using `apply_testing_hooks()` + +### Testing Workflow (Before Commit) +```bash +# ALL changes +uv run pytest tests/unit/ -x +python -c "from src.core.tools import your_import" # Verify imports + +# Refactorings (shared impl, moving code, imports) +uv run pytest tests/integration/ -x + +# Critical changes (protocol, schema updates) +uv run pytest tests/ -x +``` + +**Pre-commit hooks can't catch import errors** - You must run tests for refactorings! + +--- + +## Development Best Practices + +### Code Style +- Use `uv` for dependencies +- Run `pre-commit run --all-files` +- Use type hints +- No hardcoded external system IDs (use config/database) +- No testing against production systems + +### Type Checking +```bash +uv run mypy src/core/your_file.py --config-file=mypy.ini +``` + +When modifying code: +1. Fix mypy errors in files you change +2. Use SQLAlchemy 2.0 `Mapped[]` annotations for new models +3. Use `| None` instead of `Optional[]` (Python 3.10+) + +--- + +## Configuration + +### Secrets (.env.secrets - REQUIRED) +```bash +GEMINI_API_KEY=your-key +GOOGLE_CLIENT_ID=your-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-secret +SUPER_ADMIN_EMAILS=user@example.com +GAM_OAUTH_CLIENT_ID=your-gam-id.apps.googleusercontent.com +GAM_OAUTH_CLIENT_SECRET=your-gam-secret +APPROXIMATED_API_KEY=your-approximated-api-key ``` -Never modify existing migrations after commit. + +### Database Schema +- **Core**: tenants, principals, products, media_buys, creatives, audit_logs +- **Workflow**: workflow_steps, object_workflow_mappings +- **Deprecated**: tasks, human_tasks (DO NOT USE) + +--- + +## Adapter Support + +### GAM Adapter +**Supported Pricing**: CPM, VCPM, CPC, FLAT_RATE + +- Automatic line item type selection based on pricing + guarantees +- FLAT_RATE → SPONSORSHIP with CPD translation +- VCPM → STANDARD only (GAM requirement) +- See `docs/adapters/` for compatibility matrix + +### Mock Adapter +**Supported**: All AdCP pricing models (CPM, VCPM, CPCV, CPP, CPC, CPV, FLAT_RATE) +- All currencies, simulates appropriate metrics +- Used for testing and development --- -## Decision Tree +## Deployment + +### Environments +- **Local Dev**: `docker compose up -d` → http://localhost:8000 (builds from source) +- **Production**: Deploy to your preferred hosting platform + +**Local Dev Notes:** +- Test mode enabled by default (`ADCP_AUTH_TEST_MODE=true`) +- Test credentials: Click "Log in to Dashboard" button (password: `test123`) + +### Git Workflow (MANDATORY) +**Never push directly to main** + +1. Work on feature branches: `git checkout -b feature/name` +2. Create PR: `gh pr create` +3. Merge via GitHub UI + +### Hosting Options +This app can be hosted anywhere: +- Docker (recommended) - Any Docker-compatible platform +- Kubernetes - Full k8s manifests supported +- Cloud Providers - AWS, GCP, Azure, DigitalOcean +- Platform Services - Fly.io, Heroku, Railway, Render -**Adding a feature**: Search existing code -> Read patterns -> Design with critical patterns -> TDD (`.claude/rules/workflows/tdd-workflow.md`) -> `make quality` -> Commit +See `docs/deployment.md` for platform-specific guides. -**Fixing a bug**: Read code path -> Write failing test (`.claude/rules/workflows/bug-reporting.md`) -> Fix -> `make quality` -> Commit +--- -**Refactoring**: Verify tests pass -> Small incremental changes -> `make quality` after each -> For imports: `python -c "from module import thing"` -> For shared impl: `uv run pytest tests/integration/ -x` +## Documentation -**"How does X work?"**: `Grep` for code -> Read implementation -> Check `tests/unit/test_*X*.py` -> Explain with file:line references +**Detailed docs in `/docs`:** +- `ARCHITECTURE.md` - System architecture +- `SETUP.md` - Initial setup guide +- `DEVELOPMENT.md` - Development workflow +- `testing/` - Testing patterns and case studies +- `TROUBLESHOOTING.md` - Common issues +- `security.md` - Security guidelines +- `deployment.md` - Deployment guides +- `adapters/` - Adapter-specific documentation --- -## Self-Improvement +## Quick Reference + +### MCP Client +```python +from fastmcp.client import Client +from fastmcp.client.transports import StreamableHttpTransport + +headers = {"x-adcp-auth": "your_token"} +transport = StreamableHttpTransport(url="http://localhost:8000/mcp/", headers=headers) +client = Client(transport=transport) + +async with client: + products = await client.tools.get_products(brief="video ads") + result = await client.tools.create_media_buy(product_ids=["prod_1"], ...) +``` + +### CLI Testing +```bash +# List available tools +uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools + +# Get a real token from Admin UI → Advertisers → API Token +uvx adcp http://localhost:8000/mcp/ --auth get_products '{"brief":"video"}' +``` -When something goes wrong (test failure you caused, pattern violation, rework): -1. Analyze what happened and why -2. Check if a CLAUDE.md rule or pattern would have prevented it -3. If yes, suggest the addition (do not modify CLAUDE.md without permission) +### Admin UI +- Local: http://localhost:8000/admin/ (or `/tenant/default`) +- Production: Configure based on your hosting --- -## Reference Docs - -**Load on demand** — read these when working in the relevant area: - -| When working on... | Read | -|---|---| -| Writing new code | `.claude/rules/patterns/code-patterns.md` | -| Writing tests | `.claude/rules/patterns/testing-patterns.md` | -| MCP/A2A tools | `.claude/rules/patterns/mcp-patterns.md` | -| Quality gates | `.claude/rules/workflows/quality-gates.md` | -| TDD workflow | `.claude/rules/workflows/tdd-workflow.md` | -| Bug fixes | `.claude/rules/workflows/bug-reporting.md` | -| Research | `.claude/rules/workflows/research-workflow.md` | -| Subagents | `.claude/rules/workflows/subagent-implementation-guide.md` | -| Adapters | `docs/adapters/` | -| Deployment | `docs/deployment/` | -| Architecture | `docs/development/architecture.md` | -| Setup | `docs/quickstart.md` | -| Troubleshooting | `docs/development/troubleshooting.md` | +## Decision Tree for Claude + +**User asks to add a new feature:** +1. Search existing code: `Glob` for similar features +2. Read relevant files to understand patterns +3. Design solution following critical patterns +4. Write tests first (TDD) +5. Implement feature +6. Run tests: `uv run pytest tests/unit/ -x` +7. Commit with clear message + +**User reports a bug:** +1. Reproduce: Read the code path +2. Write failing test that demonstrates bug +3. Fix the code +4. Verify test passes +5. Check for similar issues in codebase +6. Commit fix with test + +**User asks "how does X work?"** +1. Search for X: Use `Grep` to find relevant code +2. Read the implementation +3. Check tests for examples: `tests/unit/test_*X*.py` +4. Explain with code references (file:line) +5. Link to relevant docs if they exist + +**User asks to refactor code:** +1. Verify tests exist and pass +2. Make small, incremental changes +3. Run tests after each change: `uv run pytest tests/unit/ -x` +4. For import changes, verify: `python -c "from module import thing"` +5. For shared implementations, run integration tests: `uv run pytest tests/integration/ -x` + +**User asks about best practices:** +1. Check this CLAUDE.md for patterns +2. Check `/docs` for detailed guidelines +3. Look at recent code for current conventions +4. When in doubt, follow the 7 critical patterns above + +--- + +## Support + +- Documentation: `/docs` directory +- Test examples: `/tests` directory +- Adapter implementations: `/src/adapters` directory +- Issues: File on GitHub repository diff --git a/Makefile b/Makefile deleted file mode 100644 index 9301f3124..000000000 --- a/Makefile +++ /dev/null @@ -1,27 +0,0 @@ -.PHONY: quality quality-full lint-fix lint typecheck test-fast test-full - -quality: - uv run ruff format --check . - uv run ruff check . - uv run mypy src/ --config-file=mypy.ini - uv run pytest tests/unit/ -x - -quality-full: - $(MAKE) quality - ./run_all_tests.sh ci - -lint-fix: - uv run ruff format . - uv run ruff check --fix . - -lint: - uv run ruff check . - -typecheck: - uv run mypy src/ --config-file=mypy.ini - -test-fast: - uv run pytest tests/unit/ -x - -test-full: - ./run_all_tests.sh ci diff --git a/pyproject.toml b/pyproject.toml index f8141c511..43737a271 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,53 +134,28 @@ exclude = [ [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade - "C90", # mccabe complexity - "PLR", # pylint refactor + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade ] ignore = [ - # Justified suppressions — each must explain WHY it's ignored, not just WHAT it is. - # Do not add new entries without a justification comment. - "E501", # Handled by ruff formatter; manual enforcement would conflict - "E402", # Flask app (admin_ui.py) requires imports after app config; 3 occurrences - "E722", # 12 bare excepts in error-recovery paths (adapters, auth); TODO: add specific exceptions - "F821", # 4 false positives from conditional imports and TYPE_CHECKING blocks - "F403", # 8 star imports in __init__.py re-exports; TODO: replace with explicit imports - "F405", # Consequence of F403 — will resolve when F403 is fixed - "E741", # 3 uses of `l` variable in loops; renaming would hurt readability in math-heavy code - "B904", # 47 raise-without-from in adapter error handling; TODO: add `from e` systematically - "B027", # 6 empty methods in BaseAdapter — intentional optional interface, not abstract - "F841", # 5 remaining unused vars in complex destructuring; each reviewed, kept for clarity - "PLR2004", # 158 magic values — too noisy for signal; numeric constants in config/tests are readable - "PLR0911", # 67 functions with many returns — mostly adapter dispatch and validation; gradual fix + "E501", # line too long (handled by formatter) + "E402", # module import not at top of file + "E722", # bare except + "F821", # undefined name + "F403", # star imports (TODO: fix by replacing with specific imports) + "F405", # undefined from star imports (related to F403) + "E741", # ambiguous variable names (l, I, O) + "B904", # raise without from (TODO: fix exception handling) + "B027", # empty method in abstract class (TODO: add @abstractmethod) + "F841", # unused variables (mostly fixed, remaining are complex) + "UP038", # Use (int, float) instead of int | float in isinstance for compatibility ] -[tool.ruff.lint.mccabe] -max-complexity = 10 - -[tool.ruff.lint.pylint] -max-args = 5 -max-branches = 12 -max-statements = 50 - [tool.ruff.lint.per-file-ignores] -"tests/*" = ["E722", "F821", "PLR0912", "PLR0913", "PLR0915", "PLR1714", "C901"] +"tests/*" = ["E722", "F821"] "admin_ui.py" = ["E402", "E722"] -# Existing complexity violations — new files in these dirs are still checked -# Remove entries as files are refactored to comply -"src/adapters/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] -"src/admin/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] -"src/core/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] -"src/services/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] -"src/a2a_server/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] -"scripts/*" = ["C901", "PLR0912", "PLR0913", "PLR0915"] -".pre-commit-hooks/*" = ["C901"] -"examples/*" = ["C901", "PLR0913", "PLR0915"] -"src/admin/blueprints/creatives.py" = ["PLR1714"] -"src/core/tools/media_buy_create.py" = ["PLR0133"] diff --git a/scripts/deploy/run_all_services.py b/scripts/deploy/run_all_services.py index bbc1e18c8..5242704f3 100644 --- a/scripts/deploy/run_all_services.py +++ b/scripts/deploy/run_all_services.py @@ -74,9 +74,9 @@ def check_database_health(): if db_url: try: config = DatabaseConfig.get_db_config() - print(f"Parsed host: {config.get('host', 'NOT SET')}") - print(f"Parsed port: {config.get('port', 'NOT SET')}") - print(f"Parsed database: {config.get('database', 'NOT SET')}") + print(f'Parsed host: {config.get("host", "NOT SET")}') + print(f'Parsed port: {config.get("port", "NOT SET")}') + print(f'Parsed database: {config.get("database", "NOT SET")}') except Exception as e: print(f"⚠️ Could not parse database config: {e}") diff --git a/scripts/gam_prerequisites_check.py b/scripts/gam_prerequisites_check.py index c4c8edf63..f03b312eb 100644 --- a/scripts/gam_prerequisites_check.py +++ b/scripts/gam_prerequisites_check.py @@ -8,7 +8,6 @@ Returns: Exit code 0 if all prerequisites met, 1 otherwise """ - import os import sys diff --git a/scripts/ops/migrate.py b/scripts/ops/migrate.py index 817db293b..b49aace8a 100644 --- a/scripts/ops/migrate.py +++ b/scripts/ops/migrate.py @@ -4,9 +4,8 @@ import sys from pathlib import Path -from alembic.config import Config - from alembic import command +from alembic.config import Config def run_migrations(exit_on_error=True): diff --git a/src/adapters/gam/managers/creatives.py b/src/adapters/gam/managers/creatives.py index 38be9eef9..1ed8ec937 100644 --- a/src/adapters/gam/managers/creatives.py +++ b/src/adapters/gam/managers/creatives.py @@ -934,7 +934,7 @@ def _add_tracking_urls_to_creative(self, creative: dict[str, Any], asset: dict[s else: # No landing page to embed - ignore click tracker to avoid broken redirect logger.warning( - "Click tracker has {REDIRECT_URL} macro but no landing page provided. Click tracker ignored." + "Click tracker has {REDIRECT_URL} macro but no landing page provided. " "Click tracker ignored." ) elif original_destination: # Click tracker missing {REDIRECT_URL} - would lose landing page diff --git a/src/adapters/gam/managers/orders.py b/src/adapters/gam/managers/orders.py index 077c471b5..0a5f5be8f 100644 --- a/src/adapters/gam/managers/orders.py +++ b/src/adapters/gam/managers/orders.py @@ -708,10 +708,13 @@ def log(msg): ) # If filtering removed all placeholders, keep originals (fail-safe) # This shouldn't happen if creatives have valid dimensions - # No creatives in package - placeholders are optional - # Allow empty array if no format_ids and no creatives - elif not creative_placeholders: - log(" [yellow]No creatives and no format_ids - line item will have no creative placeholders[/yellow]") + else: + # No creatives in package - placeholders are optional + # Allow empty array if no format_ids and no creatives + if not creative_placeholders: + log( + " [yellow]No creatives and no format_ids - line item will have no creative placeholders[/yellow]" + ) # Determine goal type and units goal_type = impl_config.get("primary_goal_type", "LIFETIME") @@ -802,7 +805,8 @@ def log(msg): # GAM CPD = cost per day (e.g., $10/day for 10-day campaign) if pricing_model == "flat_rate": campaign_days = (end_time - start_time).days - campaign_days = max(campaign_days, 1) # Minimum 1 day for same-day campaigns + if campaign_days < 1: + campaign_days = 1 # Minimum 1 day for same-day campaigns cpd_rate = rate / campaign_days log(f" FLAT_RATE: ${rate:,.2f} total cost / {campaign_days} days → ${cpd_rate:,.2f} CPD") @@ -942,7 +946,7 @@ def log(msg): # GAM: maxImpressions=1, numTimeUnits=X, timeUnit="MINUTE"/"HOUR"/"DAY" # Determine best GAM time unit (int() cast needed because - # suppress_minutes is float after salesagent-rlb, GAM API expects int) + # suppress_minutes is float after library type inheritance, GAM API expects int) if freq_cap.suppress_minutes < 60: time_unit = "MINUTE" num_time_units = int(freq_cap.suppress_minutes) diff --git a/src/adapters/gam_reporting_service.py b/src/adapters/gam_reporting_service.py index 4c9aa0e95..3577fa5a1 100644 --- a/src/adapters/gam_reporting_service.py +++ b/src/adapters/gam_reporting_service.py @@ -202,29 +202,30 @@ def _get_report_config( start_date = (now - timedelta(days=90)).replace(hour=0, minute=0, second=0, microsecond=0) end_date = now granularity = "total" - # Include DATE dimension for time-series data - elif date_range == "today": - # Today by hour - need both DATE and HOUR dimensions for hourly reporting - dimensions = ["DATE", "HOUR"] + base_dimensions - start_date = now.replace(hour=0, minute=0, second=0, microsecond=0) - end_date = now - granularity = "hourly" - elif date_range == "this_month": - # This month by day - dimensions = ["DATE"] + base_dimensions - start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - end_date = now - granularity = "daily" - else: # lifetime - # Lifetime by day - limit based on whether we're getting detailed dimensions - dimensions = ["DATE"] + base_dimensions - # Reduce to 30 days if we have ad unit or country dimensions to avoid timeouts - if include_country or include_ad_unit: - start_date = (now - timedelta(days=30)).replace(hour=0, minute=0, second=0, microsecond=0) - else: - start_date = (now - timedelta(days=90)).replace(hour=0, minute=0, second=0, microsecond=0) - end_date = now - granularity = "daily" + else: + # Include DATE dimension for time-series data + if date_range == "today": + # Today by hour - need both DATE and HOUR dimensions for hourly reporting + dimensions = ["DATE", "HOUR"] + base_dimensions + start_date = now.replace(hour=0, minute=0, second=0, microsecond=0) + end_date = now + granularity = "hourly" + elif date_range == "this_month": + # This month by day + dimensions = ["DATE"] + base_dimensions + start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end_date = now + granularity = "daily" + else: # lifetime + # Lifetime by day - limit based on whether we're getting detailed dimensions + dimensions = ["DATE"] + base_dimensions + # Reduce to 30 days if we have ad unit or country dimensions to avoid timeouts + if include_country or include_ad_unit: + start_date = (now - timedelta(days=30)).replace(hour=0, minute=0, second=0, microsecond=0) + else: + start_date = (now - timedelta(days=90)).replace(hour=0, minute=0, second=0, microsecond=0) + end_date = now + granularity = "daily" return dimensions, start_date, end_date, granularity @@ -583,11 +584,12 @@ def _calculate_data_validity(self, date_range: str, requested_tz: str = "America else: # Yesterday's data should be complete data_valid_until = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59) - # Same as this_month for the most recent data - elif now.hour < 7: - data_valid_until = (now - timedelta(days=2)).replace(hour=23, minute=59, second=59) - else: - data_valid_until = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59) + else: # lifetime + # Same as this_month for the most recent data + if now.hour < 7: + data_valid_until = (now - timedelta(days=2)).replace(hour=23, minute=59, second=59) + else: + data_valid_until = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59) return data_valid_until diff --git a/src/adapters/test_scenario_parser.py b/src/adapters/test_scenario_parser.py index 164bb54f5..d0746b050 100644 --- a/src/adapters/test_scenario_parser.py +++ b/src/adapters/test_scenario_parser.py @@ -25,8 +25,6 @@ class TestScenario: """Parsed test scenario from keyword parsing.""" - __test__ = False # Not a pytest test class — prevents PytestCollectionWarning - # Timing control delay_seconds: int | None = None use_async: bool = False diff --git a/src/admin/app.py b/src/admin/app.py index be26e8cf8..292e94351 100644 --- a/src/admin/app.py +++ b/src/admin/app.py @@ -298,7 +298,9 @@ def log_auth_cookies(response): parts = cookie.split(";") cookie_name = parts[0].split("=")[0] if parts else "unknown" attrs = "; ".join(p.strip() for p in parts[1:] if p.strip()) - logger.warning(f"[SESSION_DEBUG] Set-Cookie on {request.path}: name={cookie_name}, attrs=[{attrs}]") + logger.warning( + f"[SESSION_DEBUG] Set-Cookie on {request.path}: " f"name={cookie_name}, attrs=[{attrs}]" + ) else: # Only log if session was modified from flask import session diff --git a/src/admin/blueprints/inventory.py b/src/admin/blueprints/inventory.py index 854630b97..bd212d03a 100644 --- a/src/admin/blueprints/inventory.py +++ b/src/admin/blueprints/inventory.py @@ -1350,7 +1350,8 @@ def size_sort_key(s): sorted_sizes = sorted(sizes, key=size_sort_key) logger.info( - f"Extracted {len(sorted_sizes)} unique sizes from {len(items)} inventory items for tenant {tenant_id}" + f"Extracted {len(sorted_sizes)} unique sizes from " + f"{len(items)} inventory items for tenant {tenant_id}" ) return jsonify({"sizes": sorted_sizes, "count": len(sorted_sizes)}) diff --git a/src/admin/blueprints/products.py b/src/admin/blueprints/products.py index 33d4bdd86..3e3b9a11d 100644 --- a/src/admin/blueprints/products.py +++ b/src/admin/blueprints/products.py @@ -455,9 +455,7 @@ def list_products(tenant_id): formats_data = ( product.format_ids if isinstance(product.format_ids, list) - else json.loads(product.format_ids) - if product.format_ids - else [] + else json.loads(product.format_ids) if product.format_ids else [] ) # Debug: Log raw formats data @@ -533,16 +531,12 @@ def list_products(tenant_id): "countries": ( product.countries if isinstance(product.countries, list) - else json.loads(product.countries) - if product.countries - else [] + else json.loads(product.countries) if product.countries else [] ), "implementation_config": ( product.implementation_config if isinstance(product.implementation_config, dict) - else json.loads(product.implementation_config) - if product.implementation_config - else {} + else json.loads(product.implementation_config) if product.implementation_config else {} ), "created_at": product.created_at if hasattr(product, "created_at") else None, "inventory_details": inventory_details.get( @@ -1970,18 +1964,14 @@ def edit_product(tenant_id, product_id): implementation_config = ( product.implementation_config if isinstance(product.implementation_config, dict) - else json.loads(product.implementation_config) - if product.implementation_config - else {} + else json.loads(product.implementation_config) if product.implementation_config else {} ) # Parse targeting_template - build from implementation_config if not set targeting_template = ( product.targeting_template if isinstance(product.targeting_template, dict) - else json.loads(product.targeting_template) - if product.targeting_template - else {} + else json.loads(product.targeting_template) if product.targeting_template else {} ) # If targeting_template doesn't have key_value_pairs but implementation_config has custom_targeting_keys, @@ -2000,16 +1990,12 @@ def edit_product(tenant_id, product_id): "formats": ( product.format_ids if isinstance(product.format_ids, list) - else json.loads(product.format_ids) - if product.format_ids - else [] + else json.loads(product.format_ids) if product.format_ids else [] ), "countries": ( product.countries if isinstance(product.countries, list) - else json.loads(product.countries) - if product.countries - else [] + else json.loads(product.countries) if product.countries else [] ), "implementation_config": implementation_config, "targeting_template": targeting_template, diff --git a/src/admin/blueprints/publisher_partners.py b/src/admin/blueprints/publisher_partners.py index a74d30531..9ea61a14b 100644 --- a/src/admin/blueprints/publisher_partners.py +++ b/src/admin/blueprints/publisher_partners.py @@ -307,7 +307,9 @@ def sync_publisher_partners(tenant_id: str) -> Response | tuple[Response, int]: session.commit() else: - logger.info(f"Fetched real properties from {domain}: {domain_properties_created} created") + logger.info( + f"Fetched real properties from {domain}: " f"{domain_properties_created} created" + ) return jsonify( { diff --git a/src/admin/tests/integration/test_admin_app.py b/src/admin/tests/integration/test_admin_app.py index e3949b431..1ce55e968 100644 --- a/src/admin/tests/integration/test_admin_app.py +++ b/src/admin/tests/integration/test_admin_app.py @@ -182,7 +182,9 @@ def test_tenant_dashboard(self, mock_get_db_session, mock_require_tenant_access, mock_session.query.return_value.filter_by.return_value.first.return_value = mock_tenant mock_session.query.return_value.filter_by.return_value.count.return_value = 0 mock_session.query.return_value.filter_by.return_value.filter.return_value.all.return_value = [] - mock_session.query.return_value.join.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [] + mock_session.query.return_value.join.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = ( + [] + ) response = client.get("/tenant/tenant_123") # Will redirect due to decorator, but shows route exists diff --git a/src/core/auth.py b/src/core/auth.py index afdccc08b..122a181c6 100644 --- a/src/core/auth.py +++ b/src/core/auth.py @@ -65,8 +65,9 @@ def get_principal_from_token(token: str, tenant_id: str | None = None) -> str | logger.debug("Token not found in tenant '%s'", tenant_id) return None - elif _VERBOSE_AUTH_LOG: - logger.info("Found principal '%s' in tenant '%s'", principal.principal_id, tenant_id) + else: + if _VERBOSE_AUTH_LOG: + logger.info("Found principal '%s' in tenant '%s'", principal.principal_id, tenant_id) else: # No tenant specified - search globally by token logger.debug("No tenant specified - searching globally by token") diff --git a/src/core/creative_agent_registry.py b/src/core/creative_agent_registry.py index f19dd7a47..a78c12e3a 100644 --- a/src/core/creative_agent_registry.py +++ b/src/core/creative_agent_registry.py @@ -320,7 +320,7 @@ async def _fetch_formats_from_agent( error_msg = ( getattr(result, "error", None) or getattr(result, "message", None) or "No error details provided" ) - logger.error(f"Creative agent {agent.name} returned FAILED status. Error: {error_msg}") + logger.error(f"Creative agent {agent.name} returned FAILED status. " f"Error: {error_msg}") debug_info = getattr(result, "debug_info", None) if debug_info: logger.debug(f"Debug info: {debug_info}") diff --git a/src/core/main.py b/src/core/main.py index 6c016333d..ae564eadf 100644 --- a/src/core/main.py +++ b/src/core/main.py @@ -213,7 +213,9 @@ def load_tasks_from_db(): # --- Adapter Configuration --- # Get adapter from config, fallback to mock -SELECTED_ADAPTER = ((config.get("ad_server", {}).get("adapter") or "mock") if config else "mock").lower() # noqa: F841 - used below for adapter selection +SELECTED_ADAPTER = ( + (config.get("ad_server", {}).get("adapter") or "mock") if config else "mock" +).lower() # noqa: F841 - used below for adapter selection AVAILABLE_ADAPTERS = ["mock", "gam", "kevel", "triton", "triton_digital"] # --- In-Memory State (already initialized above, just adding context_map) --- diff --git a/src/core/tools/products.py b/src/core/tools/products.py index ad3496dc7..aea1202ef 100644 --- a/src/core/tools/products.py +++ b/src/core/tools/products.py @@ -206,14 +206,15 @@ async def _get_products_impl( elif hasattr(brand_manifest_unwrapped, "__str__") and str(brand_manifest_unwrapped).startswith("http"): # brand_manifest is AnyUrl object from Pydantic offering = f"Brand at {brand_manifest_unwrapped}" - # brand_manifest is a BrandManifest object or dict - # Per AdCP spec: either name OR url is required - elif hasattr(brand_manifest_unwrapped, "name") and brand_manifest_unwrapped.name: - offering = brand_manifest_unwrapped.name - elif hasattr(brand_manifest_unwrapped, "url") and brand_manifest_unwrapped.url: - offering = f"Brand at {brand_manifest_unwrapped.url}" - elif isinstance(brand_manifest_unwrapped, dict): - offering = brand_manifest_unwrapped.get("name") or brand_manifest_unwrapped.get("url", "") + else: + # brand_manifest is a BrandManifest object or dict + # Per AdCP spec: either name OR url is required + if hasattr(brand_manifest_unwrapped, "name") and brand_manifest_unwrapped.name: + offering = brand_manifest_unwrapped.name + elif hasattr(brand_manifest_unwrapped, "url") and brand_manifest_unwrapped.url: + offering = f"Brand at {brand_manifest_unwrapped.url}" + elif isinstance(brand_manifest_unwrapped, dict): + offering = brand_manifest_unwrapped.get("name") or brand_manifest_unwrapped.get("url", "") # Check brand_manifest_policy from tenant settings brand_manifest_policy = tenant.get("brand_manifest_policy", "require_auth") @@ -754,9 +755,7 @@ async def _get_products_impl( is_supported = pricing_model in supported_models inner.supported = is_supported # type: ignore[union-attr] if not is_supported: - inner.unsupported_reason = ( # type: ignore[union-attr] - f"Current adapter does not support {pricing_model.upper()} pricing" # type: ignore[union-attr] - ) + inner.unsupported_reason = f"Current adapter does not support {pricing_model.upper()} pricing" # type: ignore[union-attr] except Exception as e: logger.warning(f"Failed to annotate pricing options with adapter support: {e}") diff --git a/src/core/tools/properties.py b/src/core/tools/properties.py index c7a3e6491..35e1a555a 100644 --- a/src/core/tools/properties.py +++ b/src/core/tools/properties.py @@ -110,7 +110,7 @@ def _list_authorized_properties_impl( if not publisher_domains: empty_response_data: dict[str, Any] = {"publisher_domains": []} empty_response_data["portfolio_description"] = ( - "No publisher partnerships are currently configured. Publishers can be added via the Admin UI." + "No publisher partnerships are currently configured. " "Publishers can be added via the Admin UI." ) response = ListAuthorizedPropertiesResponse(**empty_response_data) diff --git a/src/core/validation_helpers.py b/src/core/validation_helpers.py index 5d326947e..59dc7bf54 100644 --- a/src/core/validation_helpers.py +++ b/src/core/validation_helpers.py @@ -140,7 +140,7 @@ def format_validation_error(validation_error: ValidationError, context: str = "r except (TypeError, ValueError): input_repr = repr(input_val) error_details.append( - f" • {field_path}: Extra field not allowed by AdCP spec.\n Received value: {input_repr}" + f" • {field_path}: Extra field not allowed by AdCP spec.\n" f" Received value: {input_repr}" ) else: error_details.append(f" • {field_path}: Extra field not allowed by AdCP spec") diff --git a/src/services/ai/agents/review_agent.py b/src/services/ai/agents/review_agent.py index 0fdc497ba..8fb365740 100644 --- a/src/services/ai/agents/review_agent.py +++ b/src/services/ai/agents/review_agent.py @@ -116,7 +116,7 @@ async def review_creative_async( result = await agent.run(prompt) # pydantic-ai 1.x uses .output for structured data output = result.output - logger.info(f"Creative review result: decision={output.decision}, confidence={output.confidence}") + logger.info(f"Creative review result: decision={output.decision}, " f"confidence={output.confidence}") return output except Exception as e: diff --git a/src/services/dynamic_pricing_service.py b/src/services/dynamic_pricing_service.py index 6bd714569..9c75b236b 100644 --- a/src/services/dynamic_pricing_service.py +++ b/src/services/dynamic_pricing_service.py @@ -253,31 +253,32 @@ def _update_pricing_options(self, product: Product, pricing: dict) -> None: new_guidance = PriceGuidance(p25=None, p50=None, p75=updated_p75, p90=None) cpm_option.price_guidance = new_guidance # type: ignore[union-attr] logger.debug(f"Updated existing CPM pricing option for {product.product_id}") - # Create new CPM pricing option with price_guidance - # V3: floor_price at top level, price_guidance only for percentiles - elif floor_cpm is not None: - price_guidance_obj = ( - PriceGuidance( - p25=None, - p50=None, - p75=recommended_cpm, # p75 is the recommended value - p90=None, + else: + # Create new CPM pricing option with price_guidance + # V3: floor_price at top level, price_guidance only for percentiles + if floor_cpm is not None: + price_guidance_obj = ( + PriceGuidance( + p25=None, + p50=None, + p75=recommended_cpm, # p75 is the recommended value + p90=None, + ) + if recommended_cpm is not None + else None ) - if recommended_cpm is not None - else None - ) - new_option = PricingOption( # type: ignore[call-arg] - pricing_option_id=f"{product.product_id}_dynamic_cpm", - pricing_model=PricingModel.CPM, - floor_price=floor_cpm, # V3: floor moved to top-level - currency=pricing.get("currency", "USD"), - price_guidance=price_guidance_obj, - min_spend_per_package=None, - supported=None, - unsupported_reason=None, - ) - # Pydantic validates PricingOption against discriminated union at runtime - # mypy doesn't understand this is compatible with CpmPricingOption - product.pricing_options.append(new_option) # type: ignore[arg-type] - logger.debug(f"Created new CPM pricing option for {product.product_id}") + new_option = PricingOption( # type: ignore[call-arg] + pricing_option_id=f"{product.product_id}_dynamic_cpm", + pricing_model=PricingModel.CPM, + floor_price=floor_cpm, # V3: floor moved to top-level + currency=pricing.get("currency", "USD"), + price_guidance=price_guidance_obj, + min_spend_per_package=None, + supported=None, + unsupported_reason=None, + ) + # Pydantic validates PricingOption against discriminated union at runtime + # mypy doesn't understand this is compatible with CpmPricingOption + product.pricing_options.append(new_option) # type: ignore[arg-type] + logger.debug(f"Created new CPM pricing option for {product.product_id}") diff --git a/src/services/media_buy_status_scheduler.py b/src/services/media_buy_status_scheduler.py index d36dd71e2..0c376a669 100644 --- a/src/services/media_buy_status_scheduler.py +++ b/src/services/media_buy_status_scheduler.py @@ -93,7 +93,9 @@ async def _update_statuses(self) -> None: old_status = media_buy.status media_buy.status = new_status updated_count += 1 - logger.info(f"Updated media buy {media_buy.media_buy_id} status: {old_status} -> {new_status}") + logger.info( + f"Updated media buy {media_buy.media_buy_id} status: " f"{old_status} -> {new_status}" + ) if updated_count > 0: session.commit() diff --git a/src/services/policy_service.py b/src/services/policy_service.py index ac8412eca..3a28e9395 100644 --- a/src/services/policy_service.py +++ b/src/services/policy_service.py @@ -433,19 +433,20 @@ def _update_currencies(session, tenant_id: str, currencies: list[CurrencyLimitDa # Delete currency if currency_data.currency_code in existing_limits: session.delete(existing_limits[currency_data.currency_code]) - # Update or create currency - elif currency_data.currency_code in existing_limits: - # Update existing - limit = existing_limits[currency_data.currency_code] - limit.min_package_budget = currency_data.min_package_budget - limit.max_daily_package_spend = currency_data.max_daily_package_spend - limit.updated_at = datetime.now(UTC) else: - # Create new - limit = CurrencyLimit( - tenant_id=tenant_id, - currency_code=currency_data.currency_code, - min_package_budget=currency_data.min_package_budget, - max_daily_package_spend=currency_data.max_daily_package_spend, - ) - session.add(limit) + # Update or create currency + if currency_data.currency_code in existing_limits: + # Update existing + limit = existing_limits[currency_data.currency_code] + limit.min_package_budget = currency_data.min_package_budget + limit.max_daily_package_spend = currency_data.max_daily_package_spend + limit.updated_at = datetime.now(UTC) + else: + # Create new + limit = CurrencyLimit( + tenant_id=tenant_id, + currency_code=currency_data.currency_code, + min_package_budget=currency_data.min_package_budget, + max_daily_package_spend=currency_data.max_daily_package_spend, + ) + session.add(limit) From 8e753e7097bb0281ce15e72997fa616be390617e Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 11 Feb 2026 12:36:20 +0100 Subject: [PATCH 13/18] feat: add geo inclusion/exclusion same-value overlap validation Implement AdCP SHOULD requirement from adcp PR #1010: reject requests where the same value appears in both inclusion and exclusion targeting at the same geo level (countries, regions, metros, postal_areas). - Add validate_geo_overlap() to targeting_capabilities.py - Wire into _create_media_buy_impl validation alongside overlay checks - Handle both simple (RootModel[str]) and structured (system+values) types - 19 new unit tests covering all geo levels and edge cases --- src/core/tools/media_buy_create.py | 12 +- src/services/targeting_capabilities.py | 86 ++++++++++ tests/unit/test_geo_overlap_validation.py | 198 ++++++++++++++++++++++ 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_geo_overlap_validation.py diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index 48a9c424c..db3491aa4 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -1901,6 +1901,7 @@ def unwrap_po(po: Any) -> Any: for pkg in req.packages: if hasattr(pkg, "targeting_overlay") and pkg.targeting_overlay: from src.services.targeting_capabilities import ( + validate_geo_overlap, validate_overlay_targeting, validate_unknown_targeting_fields, ) @@ -1916,7 +1917,10 @@ def unwrap_po(po: Any) -> Any: ) access_violations = validate_overlay_targeting(targeting_data) - violations = unknown_violations + access_violations + # Reject same-value geo inclusion/exclusion overlap (AdCP SHOULD requirement) + geo_overlap_violations = validate_geo_overlap(targeting_data) + + violations = unknown_violations + access_violations + geo_overlap_violations if violations: error_msg = f"Targeting validation failed: {'; '.join(violations)}" raise ValueError(error_msg) @@ -2713,9 +2717,9 @@ def _has_supported_key(url: str | None, fid: str, keys: set = product_format_key # Merge dimensions from product's format_ids if request format_ids don't have them # This handles the case where buyer specifies format_id but not dimensions # Build lookup of product format dimensions by (normalized_url, id) - product_format_dimensions: dict[tuple[str | None, str], tuple[int | None, int | None, float | None]] = ( - {} - ) + product_format_dimensions: dict[ + tuple[str | None, str], tuple[int | None, int | None, float | None] + ] = {} if pkg_product.format_ids: for fmt in pkg_product.format_ids: # pkg_product.format_ids are dicts from database JSONB diff --git a/src/services/targeting_capabilities.py b/src/services/targeting_capabilities.py index 118277f61..156dcd566 100644 --- a/src/services/targeting_capabilities.py +++ b/src/services/targeting_capabilities.py @@ -186,3 +186,89 @@ def validate_overlay_targeting(targeting: dict[str, Any]) -> list[str]: violations.append(f"{key} is not supported (targeting dimension '{dimension}' has been removed)") return violations + + +# Geo inclusion/exclusion field pairs for same-value overlap detection. +# Per adcp PR #1010: sellers SHOULD reject when the same value appears in both +# the inclusion and exclusion field at the same level. +_GEO_SIMPLE_PAIRS: list[tuple[str, str]] = [ + ("geo_countries", "geo_countries_exclude"), + ("geo_regions", "geo_regions_exclude"), +] +_GEO_STRUCTURED_PAIRS: list[tuple[str, str]] = [ + ("geo_metros", "geo_metros_exclude"), + ("geo_postal_areas", "geo_postal_areas_exclude"), +] + + +def _extract_simple_values(items: list) -> set[str]: + """Extract string values from a list of GeoCountry/GeoRegion (RootModel[str]) or plain strings.""" + result: set[str] = set() + for item in items: + if hasattr(item, "root"): + result.add(str(item.root)) + else: + result.add(str(item)) + return result + + +def _extract_system_values(items: list) -> dict[str, set[str]]: + """Extract {system: set(values)} from a list of GeoMetro/GeoPostalArea objects or dicts.""" + by_system: dict[str, set[str]] = {} + for item in items: + if hasattr(item, "system"): + system = str(item.system.value) if hasattr(item.system, "value") else str(item.system) + vals = set(item.values) if hasattr(item, "values") else set() + elif isinstance(item, dict): + raw_sys = item.get("system", "") + system = str(raw_sys.value) if hasattr(raw_sys, "value") else str(raw_sys) + vals = set(item.get("values", [])) + else: + continue + by_system.setdefault(system, set()).update(vals) + return by_system + + +def validate_geo_overlap(targeting: dict[str, Any]) -> list[str]: + """Reject same-value overlap between geo inclusion and exclusion fields. + + Per AdCP spec (adcp PR #1010): sellers SHOULD reject requests where the + same value appears in both the inclusion and exclusion field at the same + level (e.g., geo_countries: ["US"] with geo_countries_exclude: ["US"]). + + Returns list of violation messages. + """ + violations: list[str] = [] + + # Simple fields: countries, regions (RootModel[str] or plain strings) + for include_field, exclude_field in _GEO_SIMPLE_PAIRS: + include_vals = targeting.get(include_field) + exclude_vals = targeting.get(exclude_field) + if not include_vals or not exclude_vals: + continue + inc_set = _extract_simple_values(include_vals) + exc_set = _extract_simple_values(exclude_vals) + overlap = sorted(inc_set & exc_set) + if overlap: + violations.append( + f"{include_field}/{exclude_field} conflict: " + f"values {', '.join(overlap)} appear in both inclusion and exclusion" + ) + + # Structured fields: metros, postal_areas (system + values) + for include_field, exclude_field in _GEO_STRUCTURED_PAIRS: + include_vals = targeting.get(include_field) + exclude_vals = targeting.get(exclude_field) + if not include_vals or not exclude_vals: + continue + inc_by_system = _extract_system_values(include_vals) + exc_by_system = _extract_system_values(exclude_vals) + for system in sorted(set(inc_by_system) & set(exc_by_system)): + overlap = sorted(inc_by_system[system] & exc_by_system[system]) + if overlap: + violations.append( + f"{include_field}/{exclude_field} conflict in system '{system}': " + f"values {', '.join(overlap)} appear in both inclusion and exclusion" + ) + + return violations diff --git a/tests/unit/test_geo_overlap_validation.py b/tests/unit/test_geo_overlap_validation.py new file mode 100644 index 000000000..38fafa1c4 --- /dev/null +++ b/tests/unit/test_geo_overlap_validation.py @@ -0,0 +1,198 @@ +"""Tests for geo inclusion/exclusion same-value overlap validation. + +Implements the AdCP SHOULD requirement from adcp PR #1010: +> Sellers SHOULD reject requests where the same value appears in both +> the inclusion and exclusion field at the same level. + +Beads: salesagent-suj +""" + +from src.services.targeting_capabilities import validate_geo_overlap + + +class TestCountryOverlap: + """Same country in geo_countries and geo_countries_exclude.""" + + def test_same_country_rejected(self): + targeting = { + "geo_countries": ["US", "CA"], + "geo_countries_exclude": ["US"], + } + violations = validate_geo_overlap(targeting) + assert len(violations) == 1 + assert "US" in violations[0] + assert "geo_countries" in violations[0] + + def test_multiple_overlapping_countries(self): + targeting = { + "geo_countries": ["US", "CA", "GB"], + "geo_countries_exclude": ["US", "GB"], + } + violations = validate_geo_overlap(targeting) + assert len(violations) == 1 # One violation message for the field pair + assert "US" in violations[0] + assert "GB" in violations[0] + + def test_no_overlap_passes(self): + targeting = { + "geo_countries": ["US", "CA"], + "geo_countries_exclude": ["GB", "DE"], + } + violations = validate_geo_overlap(targeting) + assert violations == [] + + def test_include_only_passes(self): + targeting = {"geo_countries": ["US", "CA"]} + violations = validate_geo_overlap(targeting) + assert violations == [] + + def test_exclude_only_passes(self): + targeting = {"geo_countries_exclude": ["US"]} + violations = validate_geo_overlap(targeting) + assert violations == [] + + +class TestRegionOverlap: + """Same region in geo_regions and geo_regions_exclude.""" + + def test_same_region_rejected(self): + targeting = { + "geo_regions": ["US-CA", "US-NY"], + "geo_regions_exclude": ["US-CA"], + } + violations = validate_geo_overlap(targeting) + assert len(violations) == 1 + assert "US-CA" in violations[0] + assert "geo_regions" in violations[0] + + def test_no_overlap_passes(self): + targeting = { + "geo_regions": ["US-CA", "US-NY"], + "geo_regions_exclude": ["US-TX"], + } + violations = validate_geo_overlap(targeting) + assert violations == [] + + +class TestMetroOverlap: + """Same metro code within same system in geo_metros and geo_metros_exclude.""" + + def test_same_system_same_value_rejected(self): + targeting = { + "geo_metros": [{"system": "nielsen_dma", "values": ["501", "502"]}], + "geo_metros_exclude": [{"system": "nielsen_dma", "values": ["501"]}], + } + violations = validate_geo_overlap(targeting) + assert len(violations) == 1 + assert "501" in violations[0] + assert "geo_metros" in violations[0] + + def test_different_systems_no_conflict(self): + """Different metro systems can have the same code without conflict.""" + targeting = { + "geo_metros": [{"system": "nielsen_dma", "values": ["501"]}], + "geo_metros_exclude": [{"system": "ofcom_itv", "values": ["501"]}], + } + violations = validate_geo_overlap(targeting) + assert violations == [] + + def test_same_system_no_overlap(self): + targeting = { + "geo_metros": [{"system": "nielsen_dma", "values": ["501", "502"]}], + "geo_metros_exclude": [{"system": "nielsen_dma", "values": ["503"]}], + } + violations = validate_geo_overlap(targeting) + assert violations == [] + + def test_multiple_systems_overlap_in_one(self): + """Overlap detected only within the matching system.""" + targeting = { + "geo_metros": [ + {"system": "nielsen_dma", "values": ["501", "502"]}, + {"system": "ofcom_itv", "values": ["100"]}, + ], + "geo_metros_exclude": [ + {"system": "nielsen_dma", "values": ["501"]}, + {"system": "ofcom_itv", "values": ["200"]}, + ], + } + violations = validate_geo_overlap(targeting) + assert len(violations) == 1 + assert "501" in violations[0] + assert "nielsen_dma" in violations[0] + + +class TestPostalAreaOverlap: + """Same postal code within same system in geo_postal_areas and geo_postal_areas_exclude.""" + + def test_same_system_same_value_rejected(self): + targeting = { + "geo_postal_areas": [{"system": "us_zip", "values": ["10001", "10002"]}], + "geo_postal_areas_exclude": [{"system": "us_zip", "values": ["10001"]}], + } + violations = validate_geo_overlap(targeting) + assert len(violations) == 1 + assert "10001" in violations[0] + assert "geo_postal_areas" in violations[0] + + def test_different_systems_no_conflict(self): + targeting = { + "geo_postal_areas": [{"system": "us_zip", "values": ["10001"]}], + "geo_postal_areas_exclude": [{"system": "uk_postcode", "values": ["10001"]}], + } + violations = validate_geo_overlap(targeting) + assert violations == [] + + def test_no_overlap_passes(self): + targeting = { + "geo_postal_areas": [{"system": "us_zip", "values": ["10001"]}], + "geo_postal_areas_exclude": [{"system": "us_zip", "values": ["90210"]}], + } + violations = validate_geo_overlap(targeting) + assert violations == [] + + +class TestMultipleLevelOverlap: + """Overlaps at multiple geo levels produce multiple violations.""" + + def test_country_and_region_overlap(self): + targeting = { + "geo_countries": ["US"], + "geo_countries_exclude": ["US"], + "geo_regions": ["US-CA"], + "geo_regions_exclude": ["US-CA"], + } + violations = validate_geo_overlap(targeting) + assert len(violations) == 2 + + +class TestEdgeCases: + """Edge cases for geo overlap validation.""" + + def test_empty_targeting(self): + violations = validate_geo_overlap({}) + assert violations == [] + + def test_none_values_ignored(self): + targeting = { + "geo_countries": None, + "geo_countries_exclude": None, + } + violations = validate_geo_overlap(targeting) + assert violations == [] + + def test_empty_lists_no_overlap(self): + targeting = { + "geo_countries": [], + "geo_countries_exclude": [], + } + violations = validate_geo_overlap(targeting) + assert violations == [] + + def test_non_geo_fields_ignored(self): + targeting = { + "device_type_any_of": ["mobile"], + "content_cat_any_of": ["IAB1"], + } + violations = validate_geo_overlap(targeting) + assert violations == [] From 544812316309c7730fa354a039d5e3ca73f81a5f Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 12 Feb 2026 01:58:07 +0100 Subject: [PATCH 14/18] fix: replace _had_city_targeting transient attr with proper Pydantic Field - Use Field(default=False, exclude=True) instead of manually managed underscore-prefixed attribute for the city targeting signal - Fix CreateMediaBuyRequest.packages to use our PackageRequest (with Targeting) instead of library PackageRequest (with TargetingOverlay) - Add code annotations for Brian's review on targeting_capabilities.py - Add validate_geo_systems field-tuple sync test --- src/adapters/base.py | 6 +- src/adapters/gam/managers/targeting.py | 14 +-- src/core/schemas.py | 95 +++++++------------ src/services/targeting_capabilities.py | 93 +++++++++--------- .../test_create_media_buy_v24.py | 7 +- tests/unit/test_gam_targeting_v3.py | 6 +- tests/unit/test_targeting_normalizer.py | 24 ++--- tests/unit/test_validate_geo_systems.py | 31 ++++++ 8 files changed, 144 insertions(+), 132 deletions(-) diff --git a/src/adapters/base.py b/src/adapters/base.py index 7bf7e1261..8a62ed1eb 100644 --- a/src/adapters/base.py +++ b/src/adapters/base.py @@ -79,6 +79,8 @@ def validate_geo_systems(self, targeting: Targeting) -> list[str]: Checks both include and exclude fields for geo_metros and geo_postal_areas. Returns list of errors naming the unsupported system and supported alternatives. """ + from src.core.validation_helpers import resolve_enum_value + errors: list[str] = [] # Collect all metro items from include + exclude @@ -91,7 +93,7 @@ def validate_geo_systems(self, targeting: Targeting) -> list[str]: if metros: supported = [f for f in self._METRO_FIELDS if getattr(self, f)] for metro in metros: - system = metro.system.value if hasattr(metro.system, "value") else str(metro.system) + system = resolve_enum_value(metro.system) if not getattr(self, system, False): alt = ", ".join(supported) if supported else "none" errors.append(f"Unsupported metro system '{system}'. This adapter supports: {alt}") @@ -106,7 +108,7 @@ def validate_geo_systems(self, targeting: Targeting) -> list[str]: if postals: supported = [f for f in self._POSTAL_FIELDS if getattr(self, f)] for area in postals: - system = area.system.value if hasattr(area.system, "value") else str(area.system) + system = resolve_enum_value(area.system) if not getattr(self, system, False): alt = ", ".join(supported) if supported else "none" errors.append(f"Unsupported postal system '{system}'. This adapter supports: {alt}") diff --git a/src/adapters/gam/managers/targeting.py b/src/adapters/gam/managers/targeting.py index 7b867ac48..287e3299b 100644 --- a/src/adapters/gam/managers/targeting.py +++ b/src/adapters/gam/managers/targeting.py @@ -10,6 +10,8 @@ import os from typing import Any +from pydantic import RootModel + logger = logging.getLogger(__name__) @@ -620,7 +622,7 @@ def validate_targeting(self, targeting_overlay) -> list[str]: unsupported.append("Audio media type not supported by Google Ad Manager") # City targeting removed in v3; check transient flag from normalizer - if getattr(targeting_overlay, "_had_city_targeting", False): + if targeting_overlay.had_city_targeting: unsupported.append("City targeting is not supported (removed in v3)") # Postal code targeting requires GAM geo service integration (not implemented) @@ -652,7 +654,7 @@ def build_targeting(self, targeting_overlay) -> dict[str, Any]: geo_targeting: dict[str, Any] = {} # City targeting removed in v3; check transient flag from normalizer - if getattr(targeting_overlay, "_had_city_targeting", False): + if targeting_overlay.had_city_targeting: raise ValueError( "City targeting requested but not supported (removed in v3). " "Use geo_metros for metropolitan area targeting instead." @@ -683,7 +685,7 @@ def build_targeting(self, targeting_overlay) -> dict[str, Any]: # Map countries (GeoCountry.root → plain string) if targeting_overlay.geo_countries: for country in targeting_overlay.geo_countries: - code = country.root if hasattr(country, "root") else str(country) + code = country.root if isinstance(country, RootModel) else str(country) if code in self.geo_country_map: geo_targeting["targetedLocations"].append({"id": self.geo_country_map[code]}) else: @@ -692,7 +694,7 @@ def build_targeting(self, targeting_overlay) -> dict[str, Any]: # Map regions (GeoRegion.root → ISO 3166-2 string) if targeting_overlay.geo_regions: for region in targeting_overlay.geo_regions: - code = region.root if hasattr(region, "root") else str(region) + code = region.root if isinstance(region, RootModel) else str(region) region_id = self._lookup_region_id(code) if region_id: geo_targeting["targetedLocations"].append({"id": region_id}) @@ -725,14 +727,14 @@ def build_targeting(self, targeting_overlay) -> dict[str, Any]: # Map excluded countries if targeting_overlay.geo_countries_exclude: for country in targeting_overlay.geo_countries_exclude: - code = country.root if hasattr(country, "root") else str(country) + code = country.root if isinstance(country, RootModel) else str(country) if code in self.geo_country_map: geo_targeting["excludedLocations"].append({"id": self.geo_country_map[code]}) # Map excluded regions if targeting_overlay.geo_regions_exclude: for region in targeting_overlay.geo_regions_exclude: - code = region.root if hasattr(region, "root") else str(region) + code = region.root if isinstance(region, RootModel) else str(region) region_id = self._lookup_region_id(code) if region_id: geo_targeting["excludedLocations"].append({"id": region_id}) diff --git a/src/core/schemas.py b/src/core/schemas.py index 3ce077f21..642b8cb45 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -865,6 +865,27 @@ class TargetingCapability(BaseModel): axe_signal: bool | None = False # Whether this is an AXE signal dimension +# Mapping from legacy v2 geo fields to v3 structured fields. +# Each tuple: (v2_field_name, v3_field_name, transform_fn_or_None). +# transform_fn receives the truthy list value and returns the v3 value. +# None means passthrough (value used as-is). +def _prefix_us_regions(v: list[str]) -> list[str]: + """Legacy DB stores bare US state codes; GeoRegion requires ISO 3166-2.""" + return [r if "-" in r else f"US-{r}" for r in v] + + +_LEGACY_GEO_FIELDS: list[tuple[str, str, Any]] = [ + ("geo_country_any_of", "geo_countries", None), + ("geo_country_none_of", "geo_countries_exclude", None), + ("geo_region_any_of", "geo_regions", _prefix_us_regions), + ("geo_region_none_of", "geo_regions_exclude", _prefix_us_regions), + ("geo_metro_any_of", "geo_metros", lambda v: [{"system": "nielsen_dma", "values": v}]), + ("geo_metro_none_of", "geo_metros_exclude", lambda v: [{"system": "nielsen_dma", "values": v}]), + ("geo_zip_any_of", "geo_postal_areas", lambda v: [{"system": "us_zip", "values": v}]), + ("geo_zip_none_of", "geo_postal_areas_exclude", lambda v: [{"system": "us_zip", "values": v}]), +] + + class Targeting(TargetingOverlay): """Targeting extending AdCP TargetingOverlay with internal dimensions. @@ -941,6 +962,11 @@ class Targeting(TargetingOverlay): updated_at: datetime | None = Field(None, description="Internal: Last update timestamp") metadata: dict[str, Any] | None = Field(None, description="Internal: Additional metadata") + # Transient normalizer signal: set by normalize_legacy_geo when city targeting + # fields are encountered in legacy data. Consumed by adapters (e.g. GAM + # build_targeting) to raise an explicit error instead of silently ignoring. + had_city_targeting: bool = Field(default=False, exclude=True) + @model_validator(mode="before") @classmethod def normalize_legacy_geo(cls, values: Any) -> Any: @@ -948,7 +974,7 @@ def normalize_legacy_geo(cls, values: Any) -> Any: Handles reconstruction from legacy database JSON where fields were stored as: - geo_country_any_of: ["US", "CA"] → geo_countries: [GeoCountry("US"), ...] - - geo_region_any_of: ["CA", "NY"] → geo_regions: [GeoRegion("US-CA"), ...] (if already ISO 3166-2) + - geo_region_any_of: ["CA", "NY"] → geo_regions: [GeoRegion("US-CA"), ...] - geo_metro_any_of: ["501"] → geo_metros: [{system: "nielsen_dma", values: ["501"]}] - geo_zip_any_of: ["10001"] → geo_postal_areas: [{system: "us_zip", values: ["10001"]}] - *_none_of variants → *_exclude variants @@ -956,68 +982,17 @@ def normalize_legacy_geo(cls, values: Any) -> Any: if not isinstance(values, dict): return values - # Country: flat list → GeoCountry strings - if "geo_country_any_of" in values and "geo_countries" not in values: - v = values.pop("geo_country_any_of") - if v: - values["geo_countries"] = v # GeoCountry is RootModel[str], accepts plain strings - elif "geo_country_any_of" in values: - values.pop("geo_country_any_of") - if "geo_country_none_of" in values and "geo_countries_exclude" not in values: - v = values.pop("geo_country_none_of") - if v: - values["geo_countries_exclude"] = v - elif "geo_country_none_of" in values: - values.pop("geo_country_none_of") - - # Region: flat list → GeoRegion strings - # Legacy data stores bare US state codes ("CA"); GeoRegion requires ISO 3166-2 ("US-CA"). - # All legacy records are US-only, so bare codes without "-" get a "US-" prefix. - if "geo_region_any_of" in values and "geo_regions" not in values: - v = values.pop("geo_region_any_of") - if v: - values["geo_regions"] = [r if "-" in r else f"US-{r}" for r in v] - elif "geo_region_any_of" in values: - values.pop("geo_region_any_of") - if "geo_region_none_of" in values and "geo_regions_exclude" not in values: - v = values.pop("geo_region_none_of") - if v: - values["geo_regions_exclude"] = [r if "-" in r else f"US-{r}" for r in v] - elif "geo_region_none_of" in values: - values.pop("geo_region_none_of") - - # Metro: flat list → structured {system, values} - if "geo_metro_any_of" in values and "geo_metros" not in values: - v = values.pop("geo_metro_any_of") - if v: - values["geo_metros"] = [{"system": "nielsen_dma", "values": v}] - elif "geo_metro_any_of" in values: - values.pop("geo_metro_any_of") - if "geo_metro_none_of" in values and "geo_metros_exclude" not in values: - v = values.pop("geo_metro_none_of") - if v: - values["geo_metros_exclude"] = [{"system": "nielsen_dma", "values": v}] - elif "geo_metro_none_of" in values: - values.pop("geo_metro_none_of") - - # Zip/Postal: flat list → structured {system, values} - if "geo_zip_any_of" in values and "geo_postal_areas" not in values: - v = values.pop("geo_zip_any_of") - if v: - values["geo_postal_areas"] = [{"system": "us_zip", "values": v}] - elif "geo_zip_any_of" in values: - values.pop("geo_zip_any_of") - if "geo_zip_none_of" in values and "geo_postal_areas_exclude" not in values: - v = values.pop("geo_zip_none_of") - if v: - values["geo_postal_areas_exclude"] = [{"system": "us_zip", "values": v}] - elif "geo_zip_none_of" in values: - values.pop("geo_zip_none_of") + for v2_key, v3_key, transform in _LEGACY_GEO_FIELDS: + if v2_key not in values: + continue + v = values.pop(v2_key) + if v and v3_key not in values: + values[v3_key] = transform(v) if transform else v # City targeting removed in v3. Set a transient flag so downstream consumers # (e.g. GAM build_targeting) can raise an explicit error instead of silently ignoring. if values.pop("geo_city_any_of", None) or values.pop("geo_city_none_of", None): - values["_had_city_targeting"] = True + values["had_city_targeting"] = True return values @@ -1035,7 +1010,6 @@ def model_dump(self, **kwargs): "created_at", "updated_at", "metadata", # Internal fields - "_had_city_targeting", # Transient normalizer signal } ) kwargs["exclude"] = exclude @@ -1047,7 +1021,6 @@ def model_dump_internal(self, **kwargs): kwargs.setdefault("mode", "json") # Don't exclude internal fields or managed fields kwargs.pop("exclude", None) # Remove any exclude parameter - kwargs["exclude"] = {"_had_city_targeting"} # Always exclude transient flags return super().model_dump(**kwargs) def dict(self, **kwargs): diff --git a/src/services/targeting_capabilities.py b/src/services/targeting_capabilities.py index 156dcd566..97142abbf 100644 --- a/src/services/targeting_capabilities.py +++ b/src/services/targeting_capabilities.py @@ -3,6 +3,13 @@ Defines which targeting dimensions are available for overlay vs managed-only access. This is critical for AEE (Ad Effectiveness Engine) integration. + +AdCP TargetingOverlay defines: geo_countries, geo_regions, geo_metros, +geo_postal_areas, frequency_cap, property_list, axe_include_segment, +axe_exclude_segment. Everything else here is a seller extension — standard +ad-server dimensions (device, OS, browser, media type, audience) that AdCP +does not yet define but that adapters actively support. These are candidates +for upstream inclusion in AdCP. """ from typing import Any @@ -11,19 +18,21 @@ # Define targeting capabilities for the platform TARGETING_CAPABILITIES: dict[str, TargetingCapability] = { - # Geographic targeting - available for overlay + # ── AdCP-defined dimensions ────────────────────────────────────────── + # These map directly to fields on adcp.types.TargetingOverlay. "geo_country": TargetingCapability( dimension="geo_country", access="overlay", description="Country-level targeting using ISO 3166-1 alpha-2 codes" ), "geo_region": TargetingCapability(dimension="geo_region", access="overlay", description="State/province targeting"), "geo_metro": TargetingCapability(dimension="geo_metro", access="overlay", description="Metro/DMA targeting"), - "geo_city": TargetingCapability( - dimension="geo_city", - access="removed", - description="City-level targeting (removed in v3, no adapter supports it)", - ), "geo_zip": TargetingCapability(dimension="geo_zip", access="overlay", description="Postal code targeting"), - # Device targeting - available for overlay + "frequency_cap": TargetingCapability( + dimension="frequency_cap", access="overlay", description="Impression frequency limits" + ), + # ── Seller extensions ──────────────────────────────────────────────── + # Standard ad-server dimensions not yet in AdCP TargetingOverlay. + # Adapters (GAM, Kevel, Triton, Xandr) actively consume these. + # Candidates for upstream AdCP inclusion. "device_type": TargetingCapability( dimension="device_type", access="overlay", @@ -35,7 +44,6 @@ ), "os": TargetingCapability(dimension="os", access="overlay", description="Operating system targeting"), "browser": TargetingCapability(dimension="browser", access="overlay", description="Browser targeting"), - # Content targeting - available for overlay "content_category": TargetingCapability( dimension="content_category", access="overlay", description="IAB content category targeting" ), @@ -45,22 +53,23 @@ "content_rating": TargetingCapability( dimension="content_rating", access="overlay", description="Content rating targeting" ), - # Media targeting - available for overlay "media_type": TargetingCapability( dimension="media_type", access="overlay", description="Media type targeting", allowed_values=["video", "display", "native", "audio", "dooh"], ), - # Audience targeting - available for overlay "audience_segment": TargetingCapability( dimension="audience_segment", access="overlay", description="Third-party audience segments" ), - # Frequency capping - available for overlay - "frequency_cap": TargetingCapability( - dimension="frequency_cap", access="overlay", description="Impression frequency limits" + "custom": TargetingCapability(dimension="custom", access="both", description="Platform-specific custom targeting"), + # ── Removed dimensions ─────────────────────────────────────────────── + "geo_city": TargetingCapability( + dimension="geo_city", + access="removed", + description="City-level targeting (removed in v3, no adapter supports it)", ), - # AEE Signal Dimensions - MANAGED ONLY + # ── Managed-only (AEE signal integration) ──────────────────────────── "key_value_pairs": TargetingCapability( dimension="key_value_pairs", access="managed_only", @@ -76,8 +85,6 @@ "aee_context": TargetingCapability( dimension="aee_context", access="managed_only", description="AEE contextual signals", axe_signal=True ), - # Platform-specific - both overlay and managed - "custom": TargetingCapability(dimension="custom", access="both", description="Platform-specific custom targeting"), } @@ -102,45 +109,46 @@ def get_aee_signal_dimensions() -> list[str]: # Explicit mapping from Targeting field names to capability dimension names. -# v3 structured fields (geo_countries, geo_regions, etc.) map directly without -# suffix-stripping. Both inclusion and exclusion variants map to the same -# capability so exclusion fields are validated alongside inclusion fields. +# Used by validate_overlay_targeting() to check access control (managed-only +# vs overlay) on known fields. Both inclusion and exclusion variants map to +# the same capability dimension. +# +# AdCP TargetingOverlay defines only the geo fields, frequency_cap, axe +# segments, and property_list. The device/OS/browser/media/audience fields +# are seller extensions carried forward from the original seller engine — +# standard ad-server dimensions that adapters actively support but AdCP has +# not yet adopted. See module docstring for details. FIELD_TO_DIMENSION: dict[str, str] = { - # v3 geo fields — inclusion + # ── AdCP-defined fields (from adcp.types.TargetingOverlay) ─────────── "geo_countries": "geo_country", "geo_regions": "geo_region", "geo_metros": "geo_metro", "geo_postal_areas": "geo_zip", - # v3 geo fields — exclusion + "frequency_cap": "frequency_cap", + # ── Geo exclusion extensions (PR #1006, not yet in AdCP) ───────────── "geo_countries_exclude": "geo_country", "geo_regions_exclude": "geo_region", "geo_metros_exclude": "geo_metro", "geo_postal_areas_exclude": "geo_zip", - # Device / OS / Browser + # ── Seller extensions (not in AdCP, consumed by adapters) ──────────── "device_type_any_of": "device_type", "device_type_none_of": "device_type", "os_any_of": "os", "os_none_of": "os", "browser_any_of": "browser", "browser_none_of": "browser", - # Content "content_cat_any_of": "content_category", "content_cat_none_of": "content_category", - # Media "media_type_any_of": "media_type", "media_type_none_of": "media_type", - # Audience "audiences_any_of": "audience_segment", "audiences_none_of": "audience_segment", - # Frequency capping - "frequency_cap": "frequency_cap", - # Removed fields (city targeting removed in v3) + "custom": "custom", + # ── Removed dimensions ─────────────────────────────────────────────── "geo_city_any_of": "geo_city", "geo_city_none_of": "geo_city", - # Managed-only fields + # ── Managed-only (not exposed via overlay) ─────────────────────────── "key_value_pairs": "key_value_pairs", - # Custom - "custom": "custom", } @@ -202,26 +210,23 @@ def validate_overlay_targeting(targeting: dict[str, Any]) -> list[str]: def _extract_simple_values(items: list) -> set[str]: - """Extract string values from a list of GeoCountry/GeoRegion (RootModel[str]) or plain strings.""" - result: set[str] = set() - for item in items: - if hasattr(item, "root"): - result.add(str(item.root)) - else: - result.add(str(item)) - return result + """Extract string values from a list of plain strings (post-model_dump geo_countries/geo_regions).""" + return {str(item) for item in items} def _extract_system_values(items: list) -> dict[str, set[str]]: """Extract {system: set(values)} from a list of GeoMetro/GeoPostalArea objects or dicts.""" + from adcp.types import GeoMetro, GeoPostalArea + + from src.core.validation_helpers import resolve_enum_value + by_system: dict[str, set[str]] = {} for item in items: - if hasattr(item, "system"): - system = str(item.system.value) if hasattr(item.system, "value") else str(item.system) - vals = set(item.values) if hasattr(item, "values") else set() + if isinstance(item, (GeoMetro, GeoPostalArea)): + system = resolve_enum_value(item.system) + vals = set(item.values) elif isinstance(item, dict): - raw_sys = item.get("system", "") - system = str(raw_sys.value) if hasattr(raw_sys, "value") else str(raw_sys) + system = resolve_enum_value(item.get("system", "")) vals = set(item.get("values", [])) else: continue diff --git a/tests/integration_v2/test_create_media_buy_v24.py b/tests/integration_v2/test_create_media_buy_v24.py index 914aa1e49..8d00f24e6 100644 --- a/tests/integration_v2/test_create_media_buy_v24.py +++ b/tests/integration_v2/test_create_media_buy_v24.py @@ -19,11 +19,10 @@ from datetime import UTC, datetime, timedelta import pytest -from adcp.types import TargetingOverlay from sqlalchemy import delete, select from src.core.database.database_session import get_db_session -from src.core.schemas import PackageRequest +from src.core.schemas import PackageRequest, Targeting from tests.integration_v2.conftest import add_required_setup_data, create_test_product_with_pricing pytestmark = [pytest.mark.integration, pytest.mark.requires_db, pytest.mark.asyncio] @@ -288,14 +287,14 @@ async def test_create_media_buy_with_targeting_overlay_mcp(self, setup_test_tena from src.core.tools.media_buy_create import _create_media_buy_impl - # Create PackageRequest with nested TargetingOverlay object + # Create PackageRequest with nested Targeting object packages = [ PackageRequest( buyer_ref="pkg_targeting_test", product_id=setup_test_tenant["product_id_eur"], # Use EUR product pricing_option_id=setup_test_tenant["pricing_option_id_eur"], # Required field budget=8000.0, # Float budget, currency from pricing_option - targeting_overlay=TargetingOverlay( + targeting_overlay=Targeting( geo_countries=["US", "CA"], ), ) diff --git a/tests/unit/test_gam_targeting_v3.py b/tests/unit/test_gam_targeting_v3.py index 0c3e59255..d212bcb51 100644 --- a/tests/unit/test_gam_targeting_v3.py +++ b/tests/unit/test_gam_targeting_v3.py @@ -2,7 +2,7 @@ Regression tests for salesagent-oee: ensures GAM targeting manager correctly processes v3 structured geo fields (geo_countries, geo_regions, geo_metros, -geo_postal_areas) and their exclusion variants, handles _had_city_targeting +geo_postal_areas) and their exclusion variants, handles had_city_targeting flag, and applies int() cast to FrequencyCap float arithmetic. """ @@ -197,11 +197,11 @@ def test_postal_exclude_raises(self, gam_manager): class TestBuildTargetingCityRemoved: - """_had_city_targeting flag must trigger ValueError in build_targeting.""" + """had_city_targeting flag must trigger ValueError in build_targeting.""" def test_city_flag_raises(self, gam_manager): targeting = Targeting(geo_countries=["US"], geo_city_any_of=["Chicago"]) - assert getattr(targeting, "_had_city_targeting", False) is True + assert targeting.had_city_targeting is True with pytest.raises(ValueError, match="[Cc]ity"): gam_manager.build_targeting(targeting) diff --git a/tests/unit/test_targeting_normalizer.py b/tests/unit/test_targeting_normalizer.py index d55b563a8..d6def07a5 100644 --- a/tests/unit/test_targeting_normalizer.py +++ b/tests/unit/test_targeting_normalizer.py @@ -2,7 +2,7 @@ Regression tests for salesagent-uca: ensures the legacy normalizer correctly converts bare region codes to ISO 3166-2, drops v2 keys when v3 present, -and sets _had_city_targeting flag for city fields. +and sets had_city_targeting flag for city fields. """ from src.core.schemas import Targeting @@ -99,37 +99,37 @@ def test_empty_v2_without_v3_does_not_set_v3(self): class TestCityTargetingFlag: - """City fields must set _had_city_targeting flag instead of being silently dropped.""" + """City fields must set had_city_targeting flag instead of being silently dropped.""" def test_city_any_of_sets_flag(self): t = Targeting(**{"geo_city_any_of": ["Chicago"]}) - assert getattr(t, "_had_city_targeting", False) is True + assert t.had_city_targeting is True def test_city_none_of_sets_flag(self): t = Targeting(**{"geo_city_none_of": ["LA"]}) - assert getattr(t, "_had_city_targeting", False) is True + assert t.had_city_targeting is True def test_both_city_fields_set_flag(self): t = Targeting(**{"geo_city_any_of": ["NYC"], "geo_city_none_of": ["LA"]}) - assert getattr(t, "_had_city_targeting", False) is True + assert t.had_city_targeting is True def test_no_city_fields_no_flag(self): t = Targeting(**{"geo_countries": ["US"]}) - assert getattr(t, "_had_city_targeting", False) is False + assert t.had_city_targeting is False def test_flag_excluded_from_model_dump(self): t = Targeting(**{"geo_city_any_of": ["Chicago"], "geo_countries": ["US"]}) d = t.model_dump() - assert "_had_city_targeting" not in d + assert "had_city_targeting" not in d def test_flag_excluded_from_model_dump_internal(self): t = Targeting(**{"geo_city_any_of": ["Chicago"], "geo_countries": ["US"]}) d = t.model_dump_internal() - assert "_had_city_targeting" not in d + assert "had_city_targeting" not in d - def test_flag_accessible_via_getattr(self): + def test_flag_accessible_as_attribute(self): t = Targeting(**{"geo_city_any_of": ["NYC"]}) - assert getattr(t, "_had_city_targeting", False) is True + assert t.had_city_targeting is True class TestRoundtrip: @@ -149,8 +149,8 @@ def test_roundtrip_no_v2_keys(self): def test_roundtrip_city_flag_not_persisted(self): t1 = Targeting(**{"geo_city_any_of": ["NYC"], "geo_countries": ["US"]}) d = t1.model_dump(exclude_none=True) - assert "_had_city_targeting" not in d + assert "had_city_targeting" not in d assert "geo_city_any_of" not in d # Reconstruct — no flag on the new object t2 = Targeting(**d) - assert getattr(t2, "_had_city_targeting", False) is False + assert t2.had_city_targeting is False diff --git a/tests/unit/test_validate_geo_systems.py b/tests/unit/test_validate_geo_systems.py index 44721d3da..4441d194e 100644 --- a/tests/unit/test_validate_geo_systems.py +++ b/tests/unit/test_validate_geo_systems.py @@ -4,9 +4,40 @@ checks both include and exclude fields and returns descriptive error messages. """ +import dataclasses + from src.adapters.base import TargetingCapabilities from src.core.schemas import Targeting +# Non-system boolean fields (geo_countries/geo_regions are top-level geo, +# not metro/postal system identifiers). +_NON_SYSTEM_FIELDS = {"geo_countries", "geo_regions"} + + +class TestFieldTupleSync: + """_METRO_FIELDS and _POSTAL_FIELDS must cover all system boolean fields.""" + + def test_tuples_cover_all_system_fields(self): + """Every bool field except geo_countries/geo_regions must be in one tuple.""" + bool_fields = {f.name for f in dataclasses.fields(TargetingCapabilities) if f.type is bool or f.type == "bool"} + system_fields = bool_fields - _NON_SYSTEM_FIELDS + tuple_fields = set(TargetingCapabilities._METRO_FIELDS) | set(TargetingCapabilities._POSTAL_FIELDS) + assert system_fields == tuple_fields, ( + f"Mismatch — fields not in tuples: {system_fields - tuple_fields}, " + f"tuple entries not in dataclass: {tuple_fields - system_fields}" + ) + + def test_no_overlap_between_tuples(self): + """Metro and postal tuples must not share entries.""" + overlap = set(TargetingCapabilities._METRO_FIELDS) & set(TargetingCapabilities._POSTAL_FIELDS) + assert not overlap, f"Fields in both tuples: {overlap}" + + def test_tuple_entries_are_valid_field_names(self): + """Every tuple entry must name an actual dataclass field.""" + all_field_names = {f.name for f in dataclasses.fields(TargetingCapabilities)} + for name in TargetingCapabilities._METRO_FIELDS + TargetingCapabilities._POSTAL_FIELDS: + assert name in all_field_names, f"'{name}' is in a tuple but not a dataclass field" + class TestEmptyTargeting: """No geo fields → no errors.""" From 1a23caaf4ce30fff30c8046c64c721b83614ca7b Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 12 Feb 2026 02:00:28 +0100 Subject: [PATCH 15/18] test: add geo overlap validation integration test Tests that the full targeting validation chain correctly rejects geo_countries with both inclusion and exclusion values for the same country code. --- .../test_targeting_validation_chain.py | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 tests/integration/test_targeting_validation_chain.py diff --git a/tests/integration/test_targeting_validation_chain.py b/tests/integration/test_targeting_validation_chain.py new file mode 100644 index 000000000..f42ce672d --- /dev/null +++ b/tests/integration/test_targeting_validation_chain.py @@ -0,0 +1,191 @@ +"""Integration tests for targeting validation chain wiring in create_media_buy. + +Tests that validate_geo_overlap composes correctly through the real +_create_media_buy_impl call path — Pydantic parsing → model_dump → validation +→ error response. No mocked validators. + +Note: validate_overlay_targeting (managed-only) and validate_unknown_targeting_fields +are effectively guarded by the Pydantic model layer (Targeting.model_dump excludes +managed-only fields; extra="forbid" rejects unknown fields before validators run). + +Covers: salesagent-31v (PR review #10). +""" + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest + +from src.core.database.database_session import get_db_session +from src.core.database.models import CurrencyLimit, PricingOption, Principal, Product, PropertyTag +from src.core.schemas import CreateMediaBuyError, CreateMediaBuyRequest +from src.core.tool_context import ToolContext +from src.core.tools.media_buy_create import _create_media_buy_impl +from tests.helpers.adcp_factories import create_test_package_request +from tests.utils.database_helpers import create_tenant_with_timestamps + +pytestmark = pytest.mark.requires_db + +TENANT_ID = "test_targeting_validation" + + +def _future_dates() -> tuple[str, str]: + tomorrow = datetime.now(UTC) + timedelta(days=1) + end = tomorrow + timedelta(days=30) + return tomorrow.strftime("%Y-%m-%dT00:00:00Z"), end.strftime("%Y-%m-%dT23:59:59Z") + + +@pytest.fixture +def targeting_tenant(integration_db): + """Create minimal tenant with one product — enough to reach targeting validation.""" + with get_db_session() as session: + tenant = create_tenant_with_timestamps( + tenant_id=TENANT_ID, + name="Targeting Validation Publisher", + subdomain="targeting-val", + ad_server="mock", + ) + session.add(tenant) + session.flush() + + session.add( + PropertyTag( + tenant_id=TENANT_ID, + tag_id="all_inventory", + name="All Inventory", + description="All inventory", + ) + ) + session.add( + CurrencyLimit( + tenant_id=TENANT_ID, + currency_code="USD", + max_daily_package_spend=Decimal("50000.00"), + ) + ) + session.add( + Principal( + tenant_id=TENANT_ID, + principal_id="test_adv", + name="Test Advertiser", + access_token="test_token", + platform_mappings={"mock": {"advertiser_id": "mock_adv_1"}}, + ) + ) + + product = Product( + tenant_id=TENANT_ID, + product_id="prod_display", + name="Display Ads", + description="Standard display", + format_ids=[{"agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250"}], + delivery_type="guaranteed", + targeting_template={}, + implementation_config={}, + property_tags=["all_inventory"], + ) + session.add(product) + session.flush() + + session.add( + PricingOption( + tenant_id=TENANT_ID, + product_id="prod_display", + pricing_model="cpm", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + ) + session.commit() + + yield TENANT_ID + + +def _make_context() -> ToolContext: + return ToolContext( + context_id="test_ctx", + tenant_id=TENANT_ID, + principal_id="test_adv", + tool_name="create_media_buy", + request_timestamp=datetime.now(UTC), + testing_context={"dry_run": True, "test_session_id": "test_targeting"}, + ) + + +@pytest.mark.requires_db +async def test_geo_overlap_rejected_through_full_path(targeting_tenant): + """Same country in include and exclude → validation error via real wiring.""" + start, end = _future_dates() + request = CreateMediaBuyRequest( + buyer_ref="buyer_overlap", + brand_manifest={"name": "https://example.com/brand"}, + packages=[ + create_test_package_request( + product_id="prod_display", + buyer_ref="pkg_overlap", + budget=5000.0, + pricing_option_id="cpm_usd_fixed", + targeting_overlay={ + "geo_countries": ["US"], + "geo_countries_exclude": ["US"], + }, + ) + ], + start_time=start, + end_time=end, + ) + + response, status = await _create_media_buy_impl( + buyer_ref=request.buyer_ref, + brand_manifest=request.brand_manifest, + packages=request.packages, + start_time=request.start_time, + end_time=request.end_time, + ctx=_make_context(), + context=None, + ) + + assert isinstance(response, CreateMediaBuyError), f"Expected error response, got {type(response).__name__}" + error_text = response.errors[0].message + assert "geo_countries/geo_countries_exclude conflict" in error_text + assert "US" in error_text + + +@pytest.mark.requires_db +async def test_geo_metro_overlap_rejected_through_full_path(targeting_tenant): + """Same metro DMA in include and exclude → validation error via real wiring.""" + start, end = _future_dates() + request = CreateMediaBuyRequest( + buyer_ref="buyer_metro", + brand_manifest={"name": "https://example.com/brand"}, + packages=[ + create_test_package_request( + product_id="prod_display", + buyer_ref="pkg_metro", + budget=5000.0, + pricing_option_id="cpm_usd_fixed", + targeting_overlay={ + "geo_metros": [{"system": "nielsen_dma", "values": ["501", "803"]}], + "geo_metros_exclude": [{"system": "nielsen_dma", "values": ["501"]}], + }, + ) + ], + start_time=start, + end_time=end, + ) + + response, status = await _create_media_buy_impl( + buyer_ref=request.buyer_ref, + brand_manifest=request.brand_manifest, + packages=request.packages, + start_time=request.start_time, + end_time=request.end_time, + ctx=_make_context(), + context=None, + ) + + assert isinstance(response, CreateMediaBuyError), f"Expected error response, got {type(response).__name__}" + error_text = response.errors[0].message + assert "geo_metros/geo_metros_exclude conflict" in error_text + assert "501" in error_text From 39c40dcbb73808b5b47d0f0f6b54adc3e27cb3ea Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 12 Feb 2026 02:09:59 +0100 Subject: [PATCH 16/18] fix: override CreateMediaBuyRequest.packages to use local PackageRequest The library's PackageRequest parses targeting_overlay as TargetingOverlay, but our MediaPackage expects Targeting (our extended type with the legacy normalizer). Override packages to use our PackageRequest which properly references Targeting instead of TargetingOverlay. --- src/core/schemas.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/schemas.py b/src/core/schemas.py index 642b8cb45..a8a6fcf4d 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -2635,8 +2635,9 @@ class CreateMediaBuyRequest(LibraryCreateMediaBuyRequest): - reporting_webhook: dict (webhook configuration) """ - # Note: packages field uses LibraryPackageRequest from parent class. - # Internal fields (pricing_model, impressions) are accessed via getattr() for backward compatibility. + # Override packages to use our PackageRequest (which overrides targeting_overlay + # to Targeting instead of library TargetingOverlay, enabling the legacy normalizer). + packages: list[PackageRequest] | None = None @model_validator(mode="after") def validate_timezone_aware(self): From 61e888ff6b7276197b6fa511064bd7ce3b9c0dee Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Fri, 13 Feb 2026 14:42:49 +0100 Subject: [PATCH 17/18] fix: add missing resolve_enum_value helper and fix mypy type errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add resolve_enum_value() to validation_helpers.py — extracts .value from enum members or returns strings as-is. Referenced by base.py and targeting_capabilities.py but never defined, causing 30 unit test failures and 2 mypy errors. Also add type: ignore[assignment] for intentional PackageRequest subclass overrides in schemas.py and media_buy_create.py. --- src/core/schemas.py | 2 +- src/core/tools/media_buy_create.py | 2 +- src/core/validation_helpers.py | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/schemas.py b/src/core/schemas.py index a8a6fcf4d..7b04cb5b2 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -2637,7 +2637,7 @@ class CreateMediaBuyRequest(LibraryCreateMediaBuyRequest): # Override packages to use our PackageRequest (which overrides targeting_overlay # to Targeting instead of library TargetingOverlay, enabling the legacy normalizer). - packages: list[PackageRequest] | None = None + packages: list[PackageRequest] | None = None # type: ignore[assignment] @model_validator(mode="after") def validate_timezone_aware(self): diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index db3491aa4..d8eb16aaa 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -1966,7 +1966,7 @@ def unwrap_po(po: Any) -> Any: testing_ctx=testing_ctx, ) # Replace packages with updated versions (functional approach) - req.packages = cast(list[AdcpPackageRequest], updated_packages) + req.packages = cast(list[AdcpPackageRequest], updated_packages) # type: ignore[assignment] logger.info("[INLINE_CREATIVE_DEBUG] Updated req.packages with creative_ids") if uploaded_ids: logger.info(f"Successfully uploaded creatives for {len(uploaded_ids)} packages: {uploaded_ids}") diff --git a/src/core/validation_helpers.py b/src/core/validation_helpers.py index 59dc7bf54..7cf6cda35 100644 --- a/src/core/validation_helpers.py +++ b/src/core/validation_helpers.py @@ -8,12 +8,20 @@ import concurrent.futures import json import logging +from enum import Enum from pydantic import ValidationError logger = logging.getLogger(__name__) +def resolve_enum_value(value: str | Enum) -> str: + """Return the string value of an enum member, or the string itself.""" + if isinstance(value, Enum): + return str(value.value) + return str(value) + + def run_async_in_sync_context(coroutine): """ Helper to run async coroutines from sync code, handling event loop conflicts. From bc01c3ce6f52a4cf95efd7dfd2234011a587b174 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sat, 14 Feb 2026 00:32:56 +0100 Subject: [PATCH 18/18] chore: upgrade cryptography and pillow, ignore diskcache vuln Upgrade cryptography 46.0.3 -> 46.0.5 (GHSA-r6ph-v2qm-q3c2) and pillow 12.0.0 -> 12.1.1 (GHSA-cfh3-3jmp-rvhc). Add diskcache GHSA-w8v5-vhqr-4h9v to ignore list (no fix available, transitive from fastmcp, tracked upstream). --- .github/workflows/test.yml | 6 +- uv.lock | 225 ++++++++++++++++++------------------- 2 files changed, 115 insertions(+), 116 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7f8d82fd..c07f200f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,8 +28,10 @@ jobs: # GHSA-7gcm-g887-7qv7: protobuf DoS vulnerability (CVE-2026-0994) # No fix available yet - affects all versions through 6.33.4 # Transitive dependency from google-ads, google-api-core, logfire, a2a-sdk - # Remove --ignore-vulns when protobuf releases a patched version - run: uvx uv-secure --ignore-vulns GHSA-7gcm-g887-7qv7 + # GHSA-w8v5-vhqr-4h9v: diskcache vulnerability - no patch available + # Transitive dependency from fastmcp -> py-key-value-aio -> diskcache + # Tracked upstream: https://github.com/jlowin/fastmcp/issues/3166 + run: uvx uv-secure --ignore-vulns GHSA-7gcm-g887-7qv7,GHSA-w8v5-vhqr-4h9v smoke-tests: name: Smoke Tests (Fast Import Checks) diff --git a/uv.lock b/uv.lock index a935124d9..28982f132 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -866,58 +866,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] [[package]] @@ -2819,71 +2816,71 @@ wheels = [ [[package]] name = "pillow" -version = "12.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] [[package]]