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/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/adapters/base.py b/src/adapters/base.py
index efeeba412..8a62ed1eb 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,67 @@ 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.
+ """
+ from src.core.validation_helpers import resolve_enum_value
+
+ 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 = 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}")
+
+ # 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 = 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}")
+
+ return errors
+
@dataclass
class AdapterCapabilities:
diff --git a/src/adapters/gam/managers/orders.py b/src/adapters/gam/managers/orders.py
index c02dc3d9d..0a5f5be8f 100644
--- a/src/adapters/gam/managers/orders.py
+++ b/src/adapters/gam/managers/orders.py
@@ -945,16 +945,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 library type inheritance, 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 57420e84d..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__)
@@ -525,9 +527,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
@@ -571,16 +571,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]
@@ -616,10 +621,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 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)
+ 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
@@ -646,95 +653,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 targeting_overlay.had_city_targeting:
+ 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 isinstance(country, RootModel) 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 isinstance(region, RootModel) 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 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_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 isinstance(region, RootModel) 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/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/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 eb1ab8744..7b04cb5b2 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
@@ -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")
@@ -852,38 +859,63 @@ 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
-class Targeting(BaseModel):
- """Comprehensive targeting options for media buys.
+# 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]
- 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.
- Note: Some targeting dimensions are managed-only and cannot be set via overlay.
- These are typically used for AXE signal integration.
- """
+_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.
- # 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
+ Inherits v3 structured geo fields from library:
+ - geo_countries, geo_regions, geo_metros, geo_postal_areas
+ - frequency_cap, axe_include_segment, axe_exclude_segment
- geo_region_any_of: list[str] | None = None # Region codes: ["NY", "CA", "ON"]
- geo_region_none_of: list[str] | None = None
+ Adds exclusion extensions, internal dimensions, and a legacy normalizer
+ that converts flat DB fields to v3 structured format.
+ """
+
+ # --- 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 +945,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,8 +962,43 @@ 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")
+ # 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:
+ """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"), ...]
+ - 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
+
+ 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
+
+ return values
+
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):
@@ -958,6 +1018,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
return super().model_dump(**kwargs)
@@ -1250,19 +1311,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
@@ -2460,6 +2520,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
@@ -2573,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 # type: ignore[assignment]
@model_validator(mode="after")
def validate_timezone_aware(self):
@@ -2855,8 +2918,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..d8eb16aaa 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'
@@ -1897,15 +1900,27 @@ 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_geo_overlap,
+ 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)
+
+ # 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)
@@ -1951,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}")
@@ -2702,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
@@ -2850,10 +2865,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/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.
diff --git a/src/services/targeting_capabilities.py b/src/services/targeting_capabilities.py
index 1bb89d1d5..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,15 +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="overlay", description="City-level targeting"),
"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",
@@ -31,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"
),
@@ -41,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",
@@ -72,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"),
}
@@ -87,25 +98,182 @@ 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]
-def validate_overlay_targeting(targeting: dict[str, Any]) -> list[str]:
+# Explicit mapping from Targeting field names to capability dimension names.
+# 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] = {
+ # ── AdCP-defined fields (from adcp.types.TargetingOverlay) ───────────
+ "geo_countries": "geo_country",
+ "geo_regions": "geo_region",
+ "geo_metros": "geo_metro",
+ "geo_postal_areas": "geo_zip",
+ "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",
+ # ── 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_cat_any_of": "content_category",
+ "content_cat_none_of": "content_category",
+ "media_type_any_of": "media_type",
+ "media_type_none_of": "media_type",
+ "audiences_any_of": "audience_segment",
+ "audiences_none_of": "audience_segment",
+ "custom": "custom",
+ # ── Removed dimensions ───────────────────────────────────────────────
+ "geo_city_any_of": "geo_city",
+ "geo_city_none_of": "geo_city",
+ # ── Managed-only (not exposed via overlay) ───────────────────────────
+ "key_value_pairs": "key_value_pairs",
+}
+
+
+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.
"""
- Validate that targeting only uses allowed overlay dimensions.
+ 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.
+
+ 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())
+ removed = set(get_removed_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 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
+
+
+# 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 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 isinstance(item, (GeoMetro, GeoPostalArea)):
+ system = resolve_enum_value(item.system)
+ vals = set(item.values)
+ elif isinstance(item, dict):
+ system = resolve_enum_value(item.get("system", ""))
+ 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/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_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_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/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/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_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_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_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(" 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
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/conftest.py b/tests/integration_v2/conftest.py
index 233d4efe6..d68554050 100644
--- a/tests/integration_v2/conftest.py
+++ b/tests/integration_v2/conftest.py
@@ -483,13 +483,12 @@ def add_required_setup_data(session, tenant_id: str):
},
)
session.add(principal)
- else:
- # Update existing principal to include kevel mapping if missing
- if existing_principal.platform_mappings and "kevel" not in existing_principal.platform_mappings:
- existing_principal.platform_mappings["kevel"] = {
- "advertiser_id": f"kevel_adv_{existing_principal.principal_id}"
- }
- attributes.flag_modified(existing_principal, "platform_mappings")
+ # Update existing principal to include kevel mapping if missing
+ elif existing_principal.platform_mappings and "kevel" not in existing_principal.platform_mappings:
+ existing_principal.platform_mappings["kevel"] = {
+ "advertiser_id": f"kevel_adv_{existing_principal.principal_id}"
+ }
+ attributes.flag_modified(existing_principal, "platform_mappings")
# Create GAMInventory if not exists - CRITICAL for inventory sync status validation
stmt_inventory = select(GAMInventory).filter_by(tenant_id=tenant_id)
diff --git a/tests/integration_v2/test_a2a_error_responses.py b/tests/integration_v2/test_a2a_error_responses.py
index b03c3e226..c6258dcb3 100644
--- a/tests/integration_v2/test_a2a_error_responses.py
+++ b/tests/integration_v2/test_a2a_error_responses.py
@@ -302,9 +302,9 @@ async def test_create_media_buy_success_has_no_errors_field(self, handler, test_
# CRITICAL ASSERTIONS: Success response
assert artifact_data["success"] is True, "success must be True for successful operation"
- assert (
- artifact_data.get("errors") is None or len(artifact_data.get("errors", [])) == 0
- ), "errors field must be None or empty array for success"
+ assert artifact_data.get("errors") is None or len(artifact_data.get("errors", [])) == 0, (
+ "errors field must be None or empty array for success"
+ )
assert "media_buy_id" in artifact_data, "Success response must include media_buy_id"
assert artifact_data["media_buy_id"] is not None, "media_buy_id must not be None for success"
diff --git a/tests/integration_v2/test_a2a_skill_invocation.py b/tests/integration_v2/test_a2a_skill_invocation.py
index b4ba3c59e..43f671a89 100644
--- a/tests/integration_v2/test_a2a_skill_invocation.py
+++ b/tests/integration_v2/test_a2a_skill_invocation.py
@@ -187,7 +187,9 @@ async def test_natural_language_get_products(
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -237,7 +239,9 @@ async def test_explicit_skill_get_products(
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -292,7 +296,9 @@ async def test_explicit_skill_get_products_a2a_spec(
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -351,7 +357,9 @@ async def test_explicit_skill_create_media_buy(
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -422,7 +430,9 @@ async def test_explicit_skill_create_media_buy_manual_approval(
handler._get_auth_token = MagicMock(return_value=sample_principal["access_token"])
# Mock tenant detection
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
from src.a2a_server import adcp_a2a_server
@@ -468,7 +478,9 @@ async def test_hybrid_invocation(self, handler, sample_tenant, sample_principal,
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -511,7 +523,9 @@ async def test_multiple_skill_invocations(self, handler, sample_tenant, sample_p
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -757,7 +771,9 @@ async def test_list_creative_formats_skill(
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -820,7 +836,9 @@ async def test_list_authorized_properties_skill(self, handler, sample_tenant, sa
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -854,7 +872,9 @@ async def test_sync_creatives_skill(self, handler, sample_tenant, sample_princip
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -896,7 +916,9 @@ async def test_list_creatives_skill(self, handler, sample_tenant, sample_princip
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -929,7 +951,9 @@ async def test_update_performance_index_skill(self, handler, sample_tenant, samp
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -960,7 +984,9 @@ async def test_get_media_buy_delivery_skill(self, handler, sample_tenant, sample
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -991,7 +1017,9 @@ async def test_approve_creative_skill(self, handler, sample_tenant, sample_princ
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -1021,7 +1049,9 @@ async def test_get_media_buy_status_skill(self, handler, sample_tenant, sample_p
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
@@ -1051,7 +1081,9 @@ async def test_optimize_media_buy_skill(self, handler, sample_tenant, sample_pri
# Mock tenant detection - provide Host header so real functions can find tenant in database
# Use actual tenant subdomain from fixture
- with (patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,):
+ with (
+ patch("src.a2a_server.adcp_a2a_server.get_principal_from_token") as mock_get_principal,
+ ):
mock_get_principal.return_value = sample_principal["principal_id"]
# Mock request headers to provide Host header for subdomain detection
# Use actual subdomain from sample_tenant so get_tenant_by_subdomain() can find it in DB
diff --git a/tests/integration_v2/test_admin_ui_data_validation.py b/tests/integration_v2/test_admin_ui_data_validation.py
index 55cd565f5..7d269bae3 100644
--- a/tests/integration_v2/test_admin_ui_data_validation.py
+++ b/tests/integration_v2/test_admin_ui_data_validation.py
@@ -230,9 +230,9 @@ def test_principals_list_no_duplicates_with_relationships(
# Principals page renders successfully
# Actual display depends on template and filters
# Just verify page contains principal-related content
- assert (
- "principal" in html.lower() or "advertiser" in html.lower()
- ), "Principals page should contain principal/advertiser-related content"
+ assert "principal" in html.lower() or "advertiser" in html.lower(), (
+ "Principals page should contain principal/advertiser-related content"
+ )
class TestInventoryDataValidation:
@@ -274,9 +274,9 @@ def test_inventory_browser_no_duplicate_ad_units(
# Inventory page renders successfully even if empty
# This test just verifies the page loads without errors
# The actual inventory sync would require GAM adapter integration
- assert (
- "inventory" in html.lower() or "ad units" in html.lower()
- ), "Inventory page should contain inventory-related content"
+ assert "inventory" in html.lower() or "ad units" in html.lower(), (
+ "Inventory page should contain inventory-related content"
+ )
class TestDashboardDataValidation:
@@ -398,9 +398,9 @@ def test_media_buys_list_no_duplicates_with_packages(
# Media buy should appear exactly once (not 3 times for 3 packages)
count = html.count("test_mb_duplicate_check")
- assert (
- count == 1
- ), f"Media buy appears {count} times in HTML (expected 1). Check for joinedload() without .unique() bug."
+ assert count == 1, (
+ f"Media buy appears {count} times in HTML (expected 1). Check for joinedload() without .unique() bug."
+ )
def test_media_buys_list_shows_all_statuses(
self, authenticated_admin_session, test_tenant_with_data, integration_db
@@ -516,9 +516,9 @@ def test_workflows_list_no_duplicate_steps(
# Workflows page renders successfully
# Actual workflow display depends on filters/status
# Just verify page contains workflow-related content
- assert (
- "workflow" in html.lower() or "step" in html.lower() or "task" in html.lower()
- ), "Workflows page should contain workflow-related content"
+ assert "workflow" in html.lower() or "step" in html.lower() or "task" in html.lower(), (
+ "Workflows page should contain workflow-related content"
+ )
# NOTE: TestAuthorizedPropertiesDataValidation tests removed - authorized_properties_list.html
diff --git a/tests/integration_v2/test_create_media_buy_v24.py b/tests/integration_v2/test_create_media_buy_v24.py
index af2854037..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,15 +287,15 @@ 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(
- geo_country_any_of=["US", "CA"],
+ targeting_overlay=Targeting(
+ geo_countries=["US", "CA"],
),
)
]
diff --git a/tests/integration_v2/test_mcp_tools_audit.py b/tests/integration_v2/test_mcp_tools_audit.py
index e698377c7..115d5a0e2 100644
--- a/tests/integration_v2/test_mcp_tools_audit.py
+++ b/tests/integration_v2/test_mcp_tools_audit.py
@@ -231,9 +231,9 @@ def test_media_buy_delivery_data_field_consistency(self):
if field_name in internal_dict:
internal_value = internal_dict[field_name]
# Values should be compatible (allowing for type conversions)
- assert type(external_value) is type(
- internal_value
- ), f"Field '{field_name}' type mismatch: {type(external_value)} vs {type(internal_value)}"
+ assert type(external_value) is type(internal_value), (
+ f"Field '{field_name}' type mismatch: {type(external_value)} vs {type(internal_value)}"
+ )
else:
# MediaBuyDeliveryData doesn't have model_dump_internal, so model_dump() is used
# This means we need to ensure model_dump() produces reconstruction-compatible output
@@ -471,8 +471,8 @@ def test_testing_hooks_data_preservation(self):
else:
assert modified_value == original_value, f"Date value changed for '{key}'"
else:
- assert (
- modified_value == original_value
- ), f"Value changed for '{key}': {original_value} → {modified_value}"
+ assert modified_value == original_value, (
+ f"Value changed for '{key}': {original_value} → {modified_value}"
+ )
print("✅ Testing hooks preserve essential data correctly")
diff --git a/tests/integration_v2/test_schema_database_mapping.py b/tests/integration_v2/test_schema_database_mapping.py
index 042cffcc5..1c03f1635 100644
--- a/tests/integration_v2/test_schema_database_mapping.py
+++ b/tests/integration_v2/test_schema_database_mapping.py
@@ -221,9 +221,9 @@ def test_all_database_models_have_required_fields(self):
db_columns = {column.name for column in model_class.__table__.columns}
for field in required_fields:
- assert (
- field in db_columns
- ), f"Required field '{field}' missing from {model_class.__name__} database model"
+ assert field in db_columns, (
+ f"Required field '{field}' missing from {model_class.__name__} database model"
+ )
def test_pydantic_model_field_access_patterns(self):
"""Test patterns for safely accessing Pydantic model fields."""
diff --git a/tests/integration_v2/test_tool_result_format.py b/tests/integration_v2/test_tool_result_format.py
index dabc0aad3..e25b46497 100644
--- a/tests/integration_v2/test_tool_result_format.py
+++ b/tests/integration_v2/test_tool_result_format.py
@@ -184,6 +184,6 @@ async def test_tool_result_content_differs_from_structured(mcp_client):
# Text should be human-readable (not necessarily shorter - empty results can have longer messages)
assert "product" in text_content.lower(), "Text should describe products"
# Common patterns: "Found N products" or "No products"
- assert any(
- phrase in text_content.lower() for phrase in ["found", "no products", "products matching"]
- ), "Text should have human-readable summary"
+ assert any(phrase in text_content.lower() for phrase in ["found", "no products", "products matching"]), (
+ "Text should have human-readable summary"
+ )
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_a2a_auth_optional.py b/tests/unit/test_a2a_auth_optional.py
index d6410fa39..d423dec09 100644
--- a/tests/unit/test_a2a_auth_optional.py
+++ b/tests/unit/test_a2a_auth_optional.py
@@ -41,7 +41,6 @@ async def test_list_creative_formats_with_auth(self):
patch("src.a2a_server.adcp_a2a_server.core_list_creative_formats_tool") as mock_tool,
patch.object(self.handler, "_create_tool_context_from_a2a") as mock_create_context,
):
-
mock_tool.return_value = {"formats": []}
mock_create_context.return_value = MagicMock()
@@ -81,7 +80,6 @@ async def test_list_authorized_properties_with_auth(self):
patch("src.a2a_server.adcp_a2a_server.core_list_authorized_properties_tool") as mock_tool,
patch.object(self.handler, "_create_tool_context_from_a2a") as mock_create_context,
):
-
mock_tool.return_value = {"publisher_domains": []}
mock_create_context.return_value = MagicMock()
@@ -122,7 +120,6 @@ async def test_get_products_with_auth(self):
patch("src.a2a_server.adcp_a2a_server.core_get_products_tool") as mock_tool,
patch.object(self.handler, "_create_tool_context_from_a2a") as mock_create_context,
):
-
mock_tool.return_value = {"products": []}
mock_create_context.return_value = MagicMock()
diff --git a/tests/unit/test_a2a_function_call_validation.py b/tests/unit/test_a2a_function_call_validation.py
index db13f8bab..8b6589e07 100644
--- a/tests/unit/test_a2a_function_call_validation.py
+++ b/tests/unit/test_a2a_function_call_validation.py
@@ -187,7 +187,9 @@ def test_tool_context_creation_does_not_fail(self):
# This tests the integration without mocking everything
try:
# Mock only the external dependencies, not the function calls themselves
- with (pytest.MonkeyPatch().context() as m,):
+ with (
+ pytest.MonkeyPatch().context() as m,
+ ):
# Mock external auth functions (updated signature: token, tenant_id)
m.setattr(
"src.a2a_server.adcp_a2a_server.get_principal_from_token",
@@ -246,7 +248,9 @@ def test_core_function_can_be_called_with_mock_context(self):
async def test_call():
# Mock the database and other external dependencies
- with (pytest.MonkeyPatch().context() as m,):
+ with (
+ pytest.MonkeyPatch().context() as m,
+ ):
# Mock database session and queries
m.setattr("src.core.main.get_db_session", lambda: Mock())
diff --git a/tests/unit/test_a2a_parameter_mapping.py b/tests/unit/test_a2a_parameter_mapping.py
index e57620ace..fe1e35212 100644
--- a/tests/unit/test_a2a_parameter_mapping.py
+++ b/tests/unit/test_a2a_parameter_mapping.py
@@ -64,9 +64,9 @@ def test_update_media_buy_uses_packages_parameter(self):
# Verify packages data is passed through (may have additional fields from Pydantic serialization)
assert len(call_kwargs["packages"]) == len(parameters["packages"]), "Package count should match"
- assert (
- call_kwargs["packages"][0]["package_id"] == parameters["packages"][0]["package_id"]
- ), "Package ID should match"
+ assert call_kwargs["packages"][0]["package_id"] == parameters["packages"][0]["package_id"], (
+ "Package ID should match"
+ )
# Should NOT use legacy 'updates' parameter
assert "updates" not in call_kwargs, "Should not pass legacy 'updates' parameter to core function"
@@ -149,9 +149,9 @@ def test_update_media_buy_validates_required_parameters(self):
# Error message should mention required parameter
error_message = str(exc_info.value).lower()
- assert (
- "media_buy_id" in error_message or "buyer_ref" in error_message
- ), "Error message should mention required parameter"
+ assert "media_buy_id" in error_message or "buyer_ref" in error_message, (
+ "Error message should mention required parameter"
+ )
def test_get_media_buy_delivery_uses_plural_media_buy_ids(self):
"""
diff --git a/tests/unit/test_adapter_packages_fix.py b/tests/unit/test_adapter_packages_fix.py
index f149d2ac7..fd263d7cf 100644
--- a/tests/unit/test_adapter_packages_fix.py
+++ b/tests/unit/test_adapter_packages_fix.py
@@ -340,9 +340,9 @@ def test_xandr_returns_packages_with_package_ids_and_line_item_ids(
# Assert - Each package must have package_id (AdCP spec requirement)
# Note: platform_line_item_id is internal tracking data, not part of AdCP Package spec
for i, pkg in enumerate(response.packages):
- assert (
- hasattr(pkg, "package_id") and pkg.package_id is not None
- ), f"Xandr package {i} missing package_id"
+ assert hasattr(pkg, "package_id") and pkg.package_id is not None, (
+ f"Xandr package {i} missing package_id"
+ )
# Assert - Package IDs must match input packages
returned_ids = {pkg.package_id for pkg in response.packages}
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"]
diff --git a/tests/unit/test_adcp_contract.py b/tests/unit/test_adcp_contract.py
index 259f198aa..30d04f4b2 100644
--- a/tests/unit/test_adcp_contract.py
+++ b/tests/unit/test_adcp_contract.py
@@ -222,9 +222,9 @@ def test_schema_validation_matches_library(self):
# Basic field values should match
assert (lib_req.brief is None) == (our_req.brief is None), f"brief mismatch for {case}"
- assert (lib_req.brand_manifest is None) == (
- our_req.brand_manifest is None
- ), f"brand_manifest mismatch for {case}"
+ assert (lib_req.brand_manifest is None) == (our_req.brand_manifest is None), (
+ f"brand_manifest mismatch for {case}"
+ )
class TestAdCPContract:
@@ -902,9 +902,9 @@ def test_creative_adcp_compliance(self):
# Verify internal response has more fields than external
internal_only_fields = set(internal_response.keys()) - set(adcp_response.keys())
- assert (
- len(internal_only_fields) >= 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..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",
),
)
@@ -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()
@@ -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",
)
@@ -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_countries=["US"],
+ geo_regions=["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_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"
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_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_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_targeting_v3.py b/tests/unit/test_gam_targeting_v3.py
new file mode 100644
index 000000000..d212bcb51
--- /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 targeting.had_city_targeting 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
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_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 == []
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_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 == []
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_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_targeting_normalizer.py b/tests/unit/test_targeting_normalizer.py
new file mode 100644
index 000000000..d6def07a5
--- /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 t.had_city_targeting is True
+
+ def test_city_none_of_sets_flag(self):
+ t = Targeting(**{"geo_city_none_of": ["LA"]})
+ 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 t.had_city_targeting is True
+
+ def test_no_city_fields_no_flag(self):
+ t = Targeting(**{"geo_countries": ["US"]})
+ 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
+
+ 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_as_attribute(self):
+ t = Targeting(**{"geo_city_any_of": ["NYC"]})
+ assert t.had_city_targeting 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 t2.had_city_targeting is False
diff --git a/tests/unit/test_targeting_storage_key.py b/tests/unit/test_targeting_storage_key.py
new file mode 100644
index 000000000..fb36d9dec
--- /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_countries=["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_countries=["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_countries=["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_countries=["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
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"):
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 == []
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)
diff --git a/tests/unit/test_validate_geo_systems.py b/tests/unit/test_validate_geo_systems.py
new file mode 100644
index 000000000..4441d194e
--- /dev/null
+++ b/tests/unit/test_validate_geo_systems.py
@@ -0,0 +1,287 @@
+"""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.
+"""
+
+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."""
+
+ 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)
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]]