Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/adloop/ads/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Google Ads enum introspection — pulls valid enum names from the SDK.

Avoids hardcoded enum sets in validators. The google-ads SDK ships the
canonical list for every enum at the API version we're pinned to; this
module surfaces those lists directly so AdLoop validation stays in sync
with whatever the SDK supports without us maintaining a parallel copy.

Usage:
from adloop.ads.enums import enum_names
_VALID_TYPES = enum_names("ConversionActionTypeEnum")

The result is a frozenset of member name strings (e.g. {"AD_CALL", ...}),
with sentinel values UNSPECIFIED + UNKNOWN dropped by default.
"""
from __future__ import annotations

import functools


@functools.lru_cache(maxsize=None)
def _enum_introspection_client():
"""Memoized no-auth GoogleAdsClient used purely for enum introspection.

The client constructor doesn't make any network calls and doesn't
validate credentials beyond requiring SOMETHING in the developer-token
field — perfect for reading the bundled enum protos. We cache it so
every enum_names() call after the first is essentially free.
"""
from google.ads.googleads.client import GoogleAdsClient

from adloop.ads.client import GOOGLE_ADS_API_VERSION

return GoogleAdsClient(
credentials=None,
developer_token="adloop-enum-introspection-not-used",
use_proto_plus=True,
version=GOOGLE_ADS_API_VERSION,
)


@functools.lru_cache(maxsize=None)
def enum_names(
enum_attr: str, *, exclude_unspecified: bool = True
) -> frozenset[str]:
"""Return all member names of a Google Ads enum, as a frozenset.

Args:
enum_attr: the attribute on ``client.enums``, e.g.
``"ConversionActionTypeEnum"`` or ``"AssetFieldTypeEnum"``.
exclude_unspecified: when True (default) drops the sentinel values
``UNSPECIFIED`` and ``UNKNOWN`` — those are protobuf defaults,
never valid for user input.

Raises:
AttributeError: if ``enum_attr`` doesn't exist on ``client.enums``.

Caching: the result is memoized for the lifetime of the process, so
repeated calls return the same frozenset instance.
"""
enum_cls = getattr(_enum_introspection_client().enums, enum_attr)
return frozenset(
m.name for m in enum_cls
if not exclude_unspecified or m.name not in ("UNSPECIFIED", "UNKNOWN")
)
83 changes: 83 additions & 0 deletions tests/test_ads_enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Tests for the dynamic Google Ads enum introspection helper."""
from __future__ import annotations

import pytest

from adloop.ads.enums import enum_names, _enum_introspection_client


class TestEnumNames:
def test_returns_frozenset(self):
result = enum_names("ConversionActionTypeEnum")
assert isinstance(result, frozenset)

def test_excludes_unspecified_and_unknown_by_default(self):
result = enum_names("ConversionActionTypeEnum")
assert "UNSPECIFIED" not in result
assert "UNKNOWN" not in result

def test_can_include_unspecified_when_requested(self):
result = enum_names(
"ConversionActionTypeEnum", exclude_unspecified=False
)
assert "UNSPECIFIED" in result
assert "UNKNOWN" in result

def test_known_conversion_action_types_present(self):
result = enum_names("ConversionActionTypeEnum")
# The members AdLoop has built tooling around must always exist.
for required in (
"AD_CALL", "WEBSITE_CALL", "WEBPAGE", "WEBPAGE_CODELESS",
"GOOGLE_ANALYTICS_4_CUSTOM",
):
assert required in result, f"missing {required} from SDK enum"

def test_call_conversion_reporting_state_complete(self):
result = enum_names("CallConversionReportingStateEnum")
assert "DISABLED" in result
assert "USE_ACCOUNT_LEVEL_CALL_CONVERSION_ACTION" in result
assert "USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION" in result

def test_attribution_models_complete(self):
result = enum_names("AttributionModelEnum")
# The two attribution models AdLoop documents in conversion-action
# docstrings must always be valid.
assert "GOOGLE_ADS_LAST_CLICK" in result
assert "GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN" in result

def test_counting_types_minimal_pair(self):
result = enum_names("ConversionActionCountingTypeEnum")
assert result == frozenset({"ONE_PER_CLICK", "MANY_PER_CLICK"})

def test_promotion_extension_occasion_complete(self):
result = enum_names("PromotionExtensionOccasionEnum")
# Common occasions BGI / users will reach for
for required in (
"BLACK_FRIDAY", "CYBER_MONDAY", "CHRISTMAS",
"MOTHERS_DAY", "FATHERS_DAY", "BACK_TO_SCHOOL",
):
assert required in result, f"missing {required}"

def test_promotion_discount_modifier(self):
result = enum_names("PromotionExtensionDiscountModifierEnum")
assert "UP_TO" in result

def test_unknown_enum_raises(self):
with pytest.raises(AttributeError):
enum_names("ThisEnumDoesNotExist")

def test_lru_cache_returns_same_instance(self):
a = enum_names("ConversionActionCountingTypeEnum")
b = enum_names("ConversionActionCountingTypeEnum")
assert a is b, "expected memoized identical frozenset"

def test_introspection_client_memoized(self):
c1 = _enum_introspection_client()
c2 = _enum_introspection_client()
assert c1 is c2


# Note: tests asserting that downstream modules (conversion_actions, write)
# use the dynamic enum sets live in those modules' own test files (see
# tests/test_conversion_actions.py and tests/test_ads_extensions.py once the
# follow-up PRs land). Keeping this file focused on the helper itself.