digital-analytics-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- digital_analytics_toolkit/__init__.py +81 -0
- digital_analytics_toolkit/bq_functions.py +97 -0
- digital_analytics_toolkit/free_form_report_GA4.py +155 -0
- digital_analytics_toolkit/funnel_report_GA4.py +269 -0
- digital_analytics_toolkit/ga4_admin_functions.py +176 -0
- digital_analytics_toolkit/ga4_audience.py +576 -0
- digital_analytics_toolkit/gtm_functions.py +468 -0
- digital_analytics_toolkit/utils.py +94 -0
- digital_analytics_toolkit-0.1.0.dist-info/METADATA +292 -0
- digital_analytics_toolkit-0.1.0.dist-info/RECORD +13 -0
- digital_analytics_toolkit-0.1.0.dist-info/WHEEL +5 -0
- digital_analytics_toolkit-0.1.0.dist-info/licenses/LICENSE +55 -0
- digital_analytics_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ga4_audience.py
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
A friendly Python wrapper around the GA4 Admin API's
|
|
6
|
+
``properties.audiences.create`` endpoint.
|
|
7
|
+
|
|
8
|
+
Docs this wrapper is based on:
|
|
9
|
+
- https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1alpha/properties.audiences/create
|
|
10
|
+
- https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1alpha/properties.audiences (Audience resource + all nested types)
|
|
11
|
+
|
|
12
|
+
It uses Google's official client library (``google-analytics-admin``), which
|
|
13
|
+
implements the same v1alpha ``Audience`` resource described in the REST docs,
|
|
14
|
+
so you get retries / auth handling for free instead of hand-rolling HTTP calls.
|
|
15
|
+
|
|
16
|
+
Install:
|
|
17
|
+
pip install google-analytics-admin google-auth
|
|
18
|
+
|
|
19
|
+
--------------------------------------------------------------------------
|
|
20
|
+
QUICK START
|
|
21
|
+
--------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
from ga4_audience import GA4AudienceClient
|
|
24
|
+
|
|
25
|
+
client = GA4AudienceClient(credentials_path="service_account.json")
|
|
26
|
+
|
|
27
|
+
audience = client.create_audience(
|
|
28
|
+
property_id="123456789",
|
|
29
|
+
display_name="7-Day Purchasers",
|
|
30
|
+
description="Users who purchased in the last session, evaluated across all sessions.",
|
|
31
|
+
membership_duration_days=30,
|
|
32
|
+
include_conditions=[
|
|
33
|
+
{"type": "event", "event_name": "purchase"},
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
print(audience.name)
|
|
37
|
+
|
|
38
|
+
--------------------------------------------------------------------------
|
|
39
|
+
CONDITION SPEC MINI-LANGUAGE
|
|
40
|
+
--------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
Conditions are expressed as plain Python dicts so callers don't need to know
|
|
43
|
+
the underlying protobuf classes. Leaf condition types:
|
|
44
|
+
|
|
45
|
+
{"type": "string", "field_name": "deviceCategory", "match_type": "EXACT",
|
|
46
|
+
"value": "mobile", "case_sensitive": False}
|
|
47
|
+
|
|
48
|
+
{"type": "in_list", "field_name": "country",
|
|
49
|
+
"values": ["India", "United States"], "case_sensitive": False}
|
|
50
|
+
|
|
51
|
+
{"type": "numeric", "field_name": "sessionCount", "operation": "GREATER_THAN",
|
|
52
|
+
"value": 5}
|
|
53
|
+
|
|
54
|
+
{"type": "between", "field_name": "sessionCount", "from_value": 1, "to_value": 10}
|
|
55
|
+
|
|
56
|
+
{"type": "event", "event_name": "purchase",
|
|
57
|
+
"parameters": [{"type": "numeric", "field_name": "sessionCount", "operation": "GREATER_THAN",
|
|
58
|
+
"value": 5}}
|
|
59
|
+
|
|
60
|
+
Any leaf dict may additionally include:
|
|
61
|
+
"at_any_point_in_time": bool (only valid with scope=ACROSS_ALL_SESSIONS)
|
|
62
|
+
"in_any_n_day_period": int (only valid with scope=ACROSS_ALL_SESSIONS, <=60)
|
|
63
|
+
|
|
64
|
+
--------------------------------------------------------------------------
|
|
65
|
+
HOW AND / OR NESTING ACTUALLY WORKS (GA4 API restriction)
|
|
66
|
+
--------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
The GA4 API's AudienceFilterExpression is NOT a freely-nestable AND/OR tree.
|
|
69
|
+
The real rules (straight from the API docs) are:
|
|
70
|
+
|
|
71
|
+
- The top-level expression of a simple filter MUST be an and_group.
|
|
72
|
+
- That and_group's children MUST each be an or_group (never a bare leaf).
|
|
73
|
+
- An or_group's children MUST be leaves (a single condition or a "not") --
|
|
74
|
+
it can never contain a nested and_group or or_group.
|
|
75
|
+
- not_expression can only wrap a single leaf dimension/metric condition.
|
|
76
|
+
- Inside an event's "parameters", the rules are different again: it's a
|
|
77
|
+
single flat and_group of leaves only -- OR is not allowed there at all.
|
|
78
|
+
|
|
79
|
+
This wrapper handles all of that bookkeeping for you automatically:
|
|
80
|
+
|
|
81
|
+
include_conditions=[
|
|
82
|
+
cond_A, # -> AND'ed automatically
|
|
83
|
+
{"or": [cond_B, cond_C]}, # -> AND'ed with the above, B/C OR'ed together
|
|
84
|
+
{"not": cond_D}, # -> AND'ed, D negated
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
Every item you put in ``include_conditions`` / ``exclude_conditions`` is
|
|
88
|
+
AND'ed together automatically -- you do NOT need (and cannot use) an
|
|
89
|
+
explicit {"and": [...]} wrapper; just add more list items instead. Each
|
|
90
|
+
item can be a plain leaf condition, a {"not": cond} negation, or an
|
|
91
|
+
{"or": [cond, cond, ...]} group of leaves (which cannot itself contain
|
|
92
|
+
nested "and"/"or"). Event "parameters" lists follow the stricter flat-AND
|
|
93
|
+
rule above (no "or" inside them).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
from __future__ import annotations
|
|
97
|
+
|
|
98
|
+
import re
|
|
99
|
+
from dataclasses import dataclass
|
|
100
|
+
from numbers import Number
|
|
101
|
+
from typing import Any, Dict, List, Optional, Union
|
|
102
|
+
|
|
103
|
+
from google.analytics.admin_v1alpha import (
|
|
104
|
+
Audience,
|
|
105
|
+
AudienceDimensionOrMetricFilter,
|
|
106
|
+
AudienceEventFilter,
|
|
107
|
+
AudienceEventTrigger,
|
|
108
|
+
AudienceFilterClause,
|
|
109
|
+
AudienceFilterExpression,
|
|
110
|
+
AudienceFilterExpressionList,
|
|
111
|
+
AudienceFilterScope,
|
|
112
|
+
AudienceSimpleFilter,
|
|
113
|
+
AnalyticsAdminServiceClient,
|
|
114
|
+
CreateAudienceRequest,
|
|
115
|
+
)
|
|
116
|
+
from google.oauth2 import service_account
|
|
117
|
+
from google.oauth2.credentials import Credentials as UserCredentials
|
|
118
|
+
|
|
119
|
+
_SCOPES = ["https://www.googleapis.com/auth/analytics.edit"]
|
|
120
|
+
|
|
121
|
+
_SCOPE_MAP = {
|
|
122
|
+
"WITHIN_SAME_EVENT": AudienceFilterScope.AUDIENCE_FILTER_SCOPE_WITHIN_SAME_EVENT,
|
|
123
|
+
"WITHIN_SAME_SESSION": AudienceFilterScope.AUDIENCE_FILTER_SCOPE_WITHIN_SAME_SESSION,
|
|
124
|
+
"ACROSS_ALL_SESSIONS": AudienceFilterScope.AUDIENCE_FILTER_SCOPE_ACROSS_ALL_SESSIONS,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_LOG_CONDITION_MAP = {
|
|
128
|
+
"AUDIENCE_JOINED": AudienceEventTrigger.LogCondition.AUDIENCE_JOINED,
|
|
129
|
+
"AUDIENCE_MEMBERSHIP_RENEWED": AudienceEventTrigger.LogCondition.AUDIENCE_MEMBERSHIP_RENEWED,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_EXCLUSION_MODE_MAP = {
|
|
133
|
+
"EXCLUDE_TEMPORARILY": Audience.AudienceExclusionDurationMode.EXCLUDE_TEMPORARILY,
|
|
134
|
+
"EXCLUDE_PERMANENTLY": Audience.AudienceExclusionDurationMode.EXCLUDE_PERMANENTLY,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_MATCH_TYPE_MAP = {
|
|
138
|
+
"EXACT": AudienceDimensionOrMetricFilter.StringFilter.MatchType.EXACT,
|
|
139
|
+
"BEGINS_WITH": AudienceDimensionOrMetricFilter.StringFilter.MatchType.BEGINS_WITH,
|
|
140
|
+
"ENDS_WITH": AudienceDimensionOrMetricFilter.StringFilter.MatchType.ENDS_WITH,
|
|
141
|
+
"CONTAINS": AudienceDimensionOrMetricFilter.StringFilter.MatchType.CONTAINS,
|
|
142
|
+
"FULL_REGEXP": AudienceDimensionOrMetricFilter.StringFilter.MatchType.FULL_REGEXP,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_OPERATION_MAP = {
|
|
146
|
+
"EQUAL": AudienceDimensionOrMetricFilter.NumericFilter.Operation.EQUAL,
|
|
147
|
+
"LESS_THAN": AudienceDimensionOrMetricFilter.NumericFilter.Operation.LESS_THAN,
|
|
148
|
+
"GREATER_THAN": AudienceDimensionOrMetricFilter.NumericFilter.Operation.GREATER_THAN,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class GA4AudienceError(ValueError):
|
|
153
|
+
"""Raised when a condition spec or argument is invalid."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------
|
|
157
|
+
# Auth / client construction
|
|
158
|
+
# --------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def build_admin_client(
|
|
161
|
+
credentials_path: Optional[str] = None,
|
|
162
|
+
credentials_info: Optional[Dict[str, Any]] = None,
|
|
163
|
+
access_token: Optional[str] = None,
|
|
164
|
+
) -> AnalyticsAdminServiceClient:
|
|
165
|
+
"""Build an AnalyticsAdminServiceClient from one of three credential sources.
|
|
166
|
+
|
|
167
|
+
Exactly one of the following should be provided:
|
|
168
|
+
- credentials_path: path to a service-account JSON key file
|
|
169
|
+
- credentials_info: a dict already containing service-account JSON key data
|
|
170
|
+
- access_token: a short-lived OAuth2 access token (e.g. from a user's
|
|
171
|
+
existing OAuth flow) with the analytics.edit scope
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
AnalyticsAdminServiceClient
|
|
176
|
+
"""
|
|
177
|
+
provided = [credentials_path, credentials_info, access_token]
|
|
178
|
+
if sum(p is not None for p in provided) != 1:
|
|
179
|
+
raise GA4AudienceError(
|
|
180
|
+
"Provide exactly one of credentials_path, credentials_info, or access_token."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if credentials_path is not None:
|
|
184
|
+
creds = service_account.Credentials.from_service_account_file(
|
|
185
|
+
credentials_path, scopes=_SCOPES
|
|
186
|
+
)
|
|
187
|
+
elif credentials_info is not None:
|
|
188
|
+
creds = service_account.Credentials.from_service_account_info(
|
|
189
|
+
credentials_info, scopes=_SCOPES
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
creds = UserCredentials(token=access_token, scopes=_SCOPES)
|
|
193
|
+
|
|
194
|
+
return AnalyticsAdminServiceClient(credentials=creds)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --------------------------------------------------------------------------
|
|
198
|
+
# Condition-spec -> protobuf translation
|
|
199
|
+
# --------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def _numeric_value(value: Union[int, float]):
|
|
202
|
+
if isinstance(value, bool):
|
|
203
|
+
raise GA4AudienceError("Numeric filter value cannot be a bool.")
|
|
204
|
+
if isinstance(value, int):
|
|
205
|
+
return AudienceDimensionOrMetricFilter.NumericValue(int64_value=value)
|
|
206
|
+
if isinstance(value, Number):
|
|
207
|
+
return AudienceDimensionOrMetricFilter.NumericValue(double_value=float(value))
|
|
208
|
+
raise GA4AudienceError(f"Numeric filter value must be a number, got {value!r}.")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _build_dimension_or_metric_filter(spec: Dict[str, Any]) -> AudienceDimensionOrMetricFilter:
|
|
212
|
+
field_name = spec.get("field_name")
|
|
213
|
+
if not field_name:
|
|
214
|
+
raise GA4AudienceError(f"Condition missing 'field_name': {spec}")
|
|
215
|
+
|
|
216
|
+
kwargs: Dict[str, Any] = {"field_name": field_name}
|
|
217
|
+
if "at_any_point_in_time" in spec:
|
|
218
|
+
kwargs["at_any_point_in_time"] = spec["at_any_point_in_time"]
|
|
219
|
+
if "in_any_n_day_period" in spec:
|
|
220
|
+
n = spec["in_any_n_day_period"]
|
|
221
|
+
if n > 60:
|
|
222
|
+
raise GA4AudienceError("'in_any_n_day_period' cannot exceed 60 days.")
|
|
223
|
+
kwargs["in_any_n_day_period"] = n
|
|
224
|
+
|
|
225
|
+
cond_type = spec["type"]
|
|
226
|
+
|
|
227
|
+
if cond_type == "string":
|
|
228
|
+
match_type = spec.get("match_type", "EXACT").upper()
|
|
229
|
+
if match_type not in _MATCH_TYPE_MAP:
|
|
230
|
+
raise GA4AudienceError(f"Unknown match_type '{match_type}'.")
|
|
231
|
+
kwargs["string_filter"] = AudienceDimensionOrMetricFilter.StringFilter(
|
|
232
|
+
match_type=_MATCH_TYPE_MAP[match_type],
|
|
233
|
+
value=spec["value"],
|
|
234
|
+
case_sensitive=spec.get("case_sensitive", False),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
elif cond_type == "in_list":
|
|
238
|
+
values = spec.get("values")
|
|
239
|
+
if not values:
|
|
240
|
+
raise GA4AudienceError("'in_list' condition requires a non-empty 'values' list.")
|
|
241
|
+
kwargs["in_list_filter"] = AudienceDimensionOrMetricFilter.InListFilter(
|
|
242
|
+
values=values,
|
|
243
|
+
case_sensitive=spec.get("case_sensitive", False),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
elif cond_type == "numeric":
|
|
247
|
+
operation = spec.get("operation", "EQUAL").upper()
|
|
248
|
+
if operation not in _OPERATION_MAP:
|
|
249
|
+
raise GA4AudienceError(f"Unknown operation '{operation}'.")
|
|
250
|
+
kwargs["numeric_filter"] = AudienceDimensionOrMetricFilter.NumericFilter(
|
|
251
|
+
operation=_OPERATION_MAP[operation],
|
|
252
|
+
value=_numeric_value(spec["value"]),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
elif cond_type == "between":
|
|
256
|
+
kwargs["between_filter"] = AudienceDimensionOrMetricFilter.BetweenFilter(
|
|
257
|
+
from_value=_numeric_value(spec["from_value"]),
|
|
258
|
+
to_value=_numeric_value(spec["to_value"]),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
else:
|
|
262
|
+
raise GA4AudienceError(
|
|
263
|
+
f"Unknown leaf condition type '{cond_type}'. Expected one of: "
|
|
264
|
+
"string, in_list, numeric, between, event, and, or, not."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return AudienceDimensionOrMetricFilter(**kwargs)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _build_leaf_expression(spec: Dict[str, Any], allow_event: bool) -> AudienceFilterExpression:
|
|
271
|
+
"""Build a single leaf AudienceFilterExpression: dimension_or_metric_filter,
|
|
272
|
+
event_filter, or not_expression(dimension_or_metric_filter).
|
|
273
|
+
|
|
274
|
+
A "leaf" is anything that's legal *inside* an or_group (or inside an
|
|
275
|
+
event's parameter and_group, when allow_event=False). It can never be an
|
|
276
|
+
'and' or 'or' group itself -- those are only legal one level up.
|
|
277
|
+
"""
|
|
278
|
+
if not isinstance(spec, dict):
|
|
279
|
+
raise GA4AudienceError(f"Condition must be a dict, got {spec!r}.")
|
|
280
|
+
|
|
281
|
+
if "and" in spec or "or" in spec:
|
|
282
|
+
raise GA4AudienceError(
|
|
283
|
+
"'and'/'or' cannot appear here -- this position only accepts a single "
|
|
284
|
+
"leaf condition (string/in_list/numeric/between/event) or a 'not' wrapper."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if "not" in spec:
|
|
288
|
+
inner = spec["not"]
|
|
289
|
+
if "and" in inner or "or" in inner or "not" in inner or inner.get("type") == "event":
|
|
290
|
+
raise GA4AudienceError(
|
|
291
|
+
"A 'not' expression can only wrap a single leaf dimension/metric condition "
|
|
292
|
+
"(not another 'and'/'or'/'not'/event)."
|
|
293
|
+
)
|
|
294
|
+
return AudienceFilterExpression(
|
|
295
|
+
not_expression=AudienceFilterExpression(
|
|
296
|
+
dimension_or_metric_filter=_build_dimension_or_metric_filter(inner)
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
cond_type = spec.get("type")
|
|
301
|
+
if cond_type == "event":
|
|
302
|
+
if not allow_event:
|
|
303
|
+
raise GA4AudienceError(
|
|
304
|
+
"Nested event filters are not supported inside event parameters."
|
|
305
|
+
)
|
|
306
|
+
return AudienceFilterExpression(event_filter=_build_event_filter(spec))
|
|
307
|
+
|
|
308
|
+
return AudienceFilterExpression(dimension_or_metric_filter=_build_dimension_or_metric_filter(spec))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _build_event_filter(spec: Dict[str, Any]) -> AudienceEventFilter:
|
|
312
|
+
event_name = spec.get("event_name")
|
|
313
|
+
if not event_name:
|
|
314
|
+
raise GA4AudienceError(f"'event' condition missing 'event_name': {spec}")
|
|
315
|
+
|
|
316
|
+
kwargs: Dict[str, Any] = {"event_name": event_name}
|
|
317
|
+
|
|
318
|
+
params = spec.get("parameters")
|
|
319
|
+
if params:
|
|
320
|
+
# Per the API docs: event_parameter_filter_expression must be a single
|
|
321
|
+
# and_group whose children are dimension_or_metric_filter / not_expression
|
|
322
|
+
# ONLY -- no or_group, no event_filter (ANDs of ORs are not supported here).
|
|
323
|
+
for p in params:
|
|
324
|
+
if "or" in p:
|
|
325
|
+
raise GA4AudienceError(
|
|
326
|
+
"Event parameter filters must be a flat AND of conditions -- 'or' "
|
|
327
|
+
"is not supported inside event parameters (GA4 API restriction)."
|
|
328
|
+
)
|
|
329
|
+
kwargs["event_parameter_filter_expression"] = AudienceFilterExpression(
|
|
330
|
+
and_group=AudienceFilterExpressionList(
|
|
331
|
+
filter_expressions=[_build_leaf_expression(p, allow_event=False) for p in params]
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return AudienceEventFilter(**kwargs)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _build_top_level_and_child(spec: Dict[str, Any]) -> AudienceFilterExpression:
|
|
339
|
+
"""Build one child of the top-level and_group.
|
|
340
|
+
|
|
341
|
+
Per the API, the top-level and_group may ONLY contain or_group children.
|
|
342
|
+
So every item the caller puts in include_conditions/exclude_conditions
|
|
343
|
+
becomes its own or_group -- a singleton [leaf] for a plain condition, or
|
|
344
|
+
the actual OR'd list for an {"or": [...]} spec.
|
|
345
|
+
"""
|
|
346
|
+
if not isinstance(spec, dict):
|
|
347
|
+
raise GA4AudienceError(f"Condition must be a dict, got {spec!r}.")
|
|
348
|
+
|
|
349
|
+
if "and" in spec:
|
|
350
|
+
raise GA4AudienceError(
|
|
351
|
+
"'and' is not a valid combinator here: items in include_conditions / "
|
|
352
|
+
"exclude_conditions are already AND'ed together automatically -- just "
|
|
353
|
+
"add them as separate list entries instead of wrapping in {'and': [...]}."
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if "or" in spec:
|
|
357
|
+
children = spec["or"]
|
|
358
|
+
if not children:
|
|
359
|
+
raise GA4AudienceError("'or' condition requires a non-empty list.")
|
|
360
|
+
for c in children:
|
|
361
|
+
if "and" in c or "or" in c:
|
|
362
|
+
raise GA4AudienceError(
|
|
363
|
+
"An 'or' group cannot itself contain nested 'and'/'or' groups "
|
|
364
|
+
"(GA4 API restriction)."
|
|
365
|
+
)
|
|
366
|
+
leaf_exprs = [_build_leaf_expression(c, allow_event=True) for c in children]
|
|
367
|
+
else:
|
|
368
|
+
# A plain leaf or a 'not' -- wrap as a singleton or_group, since the
|
|
369
|
+
# top-level and_group can only directly contain or_group children.
|
|
370
|
+
leaf_exprs = [_build_leaf_expression(spec, allow_event=True)]
|
|
371
|
+
|
|
372
|
+
return AudienceFilterExpression(
|
|
373
|
+
or_group=AudienceFilterExpressionList(filter_expressions=leaf_exprs)
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _build_filter_clause(
|
|
378
|
+
conditions: List[Dict[str, Any]],
|
|
379
|
+
clause_type: str,
|
|
380
|
+
scope: str,
|
|
381
|
+
) -> AudienceFilterClause:
|
|
382
|
+
if scope not in _SCOPE_MAP:
|
|
383
|
+
raise GA4AudienceError(f"scope must be one of {list(_SCOPE_MAP)}, got '{scope}'.")
|
|
384
|
+
if clause_type not in ("INCLUDE", "EXCLUDE"):
|
|
385
|
+
raise GA4AudienceError("clause_type must be 'INCLUDE' or 'EXCLUDE'.")
|
|
386
|
+
if not conditions:
|
|
387
|
+
raise GA4AudienceError("conditions list cannot be empty.")
|
|
388
|
+
|
|
389
|
+
# Top level of a simple filter's expression must be an and_group, and per
|
|
390
|
+
# the API, and_group may only contain or_group children (never bare leaves).
|
|
391
|
+
top_expression = AudienceFilterExpression(
|
|
392
|
+
and_group=AudienceFilterExpressionList(
|
|
393
|
+
filter_expressions=[_build_top_level_and_child(c) for c in conditions]
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return AudienceFilterClause(
|
|
398
|
+
clause_type=(
|
|
399
|
+
AudienceFilterClause.AudienceClauseType.INCLUDE
|
|
400
|
+
if clause_type == "INCLUDE"
|
|
401
|
+
else AudienceFilterClause.AudienceClauseType.EXCLUDE
|
|
402
|
+
),
|
|
403
|
+
simple_filter=AudienceSimpleFilter(
|
|
404
|
+
scope=_SCOPE_MAP[scope],
|
|
405
|
+
filter_expression=top_expression,
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# --------------------------------------------------------------------------
|
|
411
|
+
# High level client
|
|
412
|
+
# --------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
@dataclass
|
|
415
|
+
class GA4AudienceClient:
|
|
416
|
+
"""Convenience wrapper for creating GA4 Audiences.
|
|
417
|
+
|
|
418
|
+
Provide credentials via ONE of `credentials_path`, `credentials_info`,
|
|
419
|
+
`access_token`, or pass a pre-built `admin_client`.
|
|
420
|
+
Note: Currently And Group & Or Group are not working
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
credentials_path: Optional[str] = None
|
|
424
|
+
credentials_info: Optional[Dict[str, Any]] = None
|
|
425
|
+
access_token: Optional[str] = None
|
|
426
|
+
admin_client: Optional[AnalyticsAdminServiceClient] = None
|
|
427
|
+
|
|
428
|
+
def __post_init__(self):
|
|
429
|
+
if self.admin_client is None:
|
|
430
|
+
self.admin_client = build_admin_client(
|
|
431
|
+
credentials_path=self.credentials_path,
|
|
432
|
+
credentials_info=self.credentials_info,
|
|
433
|
+
access_token=self.access_token,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def create_audience(
|
|
437
|
+
self,
|
|
438
|
+
property_id: Union[str, int],
|
|
439
|
+
display_name: str,
|
|
440
|
+
description: str,
|
|
441
|
+
membership_duration_days: int = 30,
|
|
442
|
+
include_conditions: Optional[List[Dict[str, Any]]] = None,
|
|
443
|
+
exclude_conditions: Optional[List[Dict[str, Any]]] = None,
|
|
444
|
+
scope: str = "ACROSS_ALL_SESSIONS",
|
|
445
|
+
exclusion_duration_mode: Optional[str] = None,
|
|
446
|
+
event_trigger: Optional[Dict[str, str]] = None,
|
|
447
|
+
dry_run: bool = False,
|
|
448
|
+
) -> Audience:
|
|
449
|
+
"""Create a GA4 Audience.
|
|
450
|
+
|
|
451
|
+
Parameters
|
|
452
|
+
----------
|
|
453
|
+
property_id : the numeric GA4 property id (e.g. "123456789" or 123456789)
|
|
454
|
+
display_name : required, shown in the GA4 UI
|
|
455
|
+
description : required
|
|
456
|
+
membership_duration_days : how long a user stays in the audience (max 540)
|
|
457
|
+
include_conditions : list of condition-spec dicts (see module docstring),
|
|
458
|
+
ANDed together into one INCLUDE filter clause. At least one of
|
|
459
|
+
include_conditions/exclude_conditions is required.
|
|
460
|
+
exclude_conditions : same shape, becomes an EXCLUDE filter clause
|
|
461
|
+
scope : one of "WITHIN_SAME_EVENT", "WITHIN_SAME_SESSION",
|
|
462
|
+
"ACROSS_ALL_SESSIONS" (default). Applies to include AND exclude clauses.
|
|
463
|
+
Use WITHIN_SAME_EVENT/SESSION scope if your conditions must all be true
|
|
464
|
+
in a single event/session.
|
|
465
|
+
exclusion_duration_mode : "EXCLUDE_TEMPORARILY" or "EXCLUDE_PERMANENTLY",
|
|
466
|
+
required by the API if exclude_conditions is set.
|
|
467
|
+
event_trigger : optional dict {"event_name": str, "log_condition":
|
|
468
|
+
"AUDIENCE_JOINED" | "AUDIENCE_MEMBERSHIP_RENEWED"} to log an event
|
|
469
|
+
when a user joins.
|
|
470
|
+
dry_run : if True, builds and returns the Audience request payload
|
|
471
|
+
without calling the API (useful for testing/inspection).
|
|
472
|
+
|
|
473
|
+
Returns
|
|
474
|
+
-------
|
|
475
|
+
google.analytics.admin_v1alpha.types.Audience
|
|
476
|
+
The created Audience (with `.name` populated), or -- if dry_run --
|
|
477
|
+
the Audience object that *would* be sent.
|
|
478
|
+
"""
|
|
479
|
+
if not display_name:
|
|
480
|
+
raise GA4AudienceError("display_name is required.")
|
|
481
|
+
if not description:
|
|
482
|
+
raise GA4AudienceError("description is required.")
|
|
483
|
+
if not (1 <= membership_duration_days <= 540):
|
|
484
|
+
raise GA4AudienceError("membership_duration_days must be between 1 and 540.")
|
|
485
|
+
if not include_conditions and not exclude_conditions:
|
|
486
|
+
raise GA4AudienceError("Provide at least one of include_conditions / exclude_conditions.")
|
|
487
|
+
if exclude_conditions and not exclusion_duration_mode:
|
|
488
|
+
raise GA4AudienceError(
|
|
489
|
+
"exclusion_duration_mode is required when exclude_conditions is set "
|
|
490
|
+
"('EXCLUDE_TEMPORARILY' or 'EXCLUDE_PERMANENTLY')."
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
filter_clauses = []
|
|
494
|
+
if include_conditions:
|
|
495
|
+
filter_clauses.append(_build_filter_clause(include_conditions, "INCLUDE", scope))
|
|
496
|
+
if exclude_conditions:
|
|
497
|
+
filter_clauses.append(_build_filter_clause(exclude_conditions, "EXCLUDE", scope))
|
|
498
|
+
|
|
499
|
+
audience_kwargs: Dict[str, Any] = {
|
|
500
|
+
"display_name": display_name,
|
|
501
|
+
"description": description,
|
|
502
|
+
"membership_duration_days": membership_duration_days,
|
|
503
|
+
"filter_clauses": filter_clauses,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if exclusion_duration_mode:
|
|
507
|
+
if exclusion_duration_mode not in _EXCLUSION_MODE_MAP:
|
|
508
|
+
raise GA4AudienceError(
|
|
509
|
+
f"exclusion_duration_mode must be one of {list(_EXCLUSION_MODE_MAP)}."
|
|
510
|
+
)
|
|
511
|
+
audience_kwargs["exclusion_duration_mode"] = _EXCLUSION_MODE_MAP[exclusion_duration_mode]
|
|
512
|
+
|
|
513
|
+
if event_trigger:
|
|
514
|
+
event_name = event_trigger.get("event_name")
|
|
515
|
+
log_condition = event_trigger.get("log_condition", "AUDIENCE_JOINED").upper()
|
|
516
|
+
if not event_name:
|
|
517
|
+
raise GA4AudienceError("event_trigger requires 'event_name'.")
|
|
518
|
+
if log_condition not in _LOG_CONDITION_MAP:
|
|
519
|
+
raise GA4AudienceError(f"log_condition must be one of {list(_LOG_CONDITION_MAP)}.")
|
|
520
|
+
audience_kwargs["event_trigger"] = AudienceEventTrigger(
|
|
521
|
+
event_name=event_name,
|
|
522
|
+
log_condition=_LOG_CONDITION_MAP[log_condition],
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
audience = Audience(**audience_kwargs)
|
|
526
|
+
|
|
527
|
+
parent = f"properties/{property_id}" if not str(property_id).startswith("properties/") else str(property_id)
|
|
528
|
+
request = CreateAudienceRequest(parent=parent, audience=audience)
|
|
529
|
+
|
|
530
|
+
if dry_run:
|
|
531
|
+
return audience
|
|
532
|
+
|
|
533
|
+
return self.admin_client.create_audience(request=request)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# --------------------------------------------------------------------------
|
|
537
|
+
# One-off convenience functions (no class instantiation needed)
|
|
538
|
+
# --------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
def create_audience(
|
|
541
|
+
property_id: Union[str, int],
|
|
542
|
+
display_name: str,
|
|
543
|
+
description: str,
|
|
544
|
+
membership_duration_days: int = 30,
|
|
545
|
+
include_conditions: Optional[List[Dict[str, Any]]] = None,
|
|
546
|
+
exclude_conditions: Optional[List[Dict[str, Any]]] = None,
|
|
547
|
+
scope: str = "ACROSS_ALL_SESSIONS",
|
|
548
|
+
exclusion_duration_mode: Optional[str] = None,
|
|
549
|
+
event_trigger: Optional[Dict[str, str]] = None,
|
|
550
|
+
credentials_path: Optional[str] = None,
|
|
551
|
+
credentials_info: Optional[Dict[str, Any]] = None,
|
|
552
|
+
access_token: Optional[str] = None,
|
|
553
|
+
dry_run: bool = False,
|
|
554
|
+
) -> Audience:
|
|
555
|
+
"""Functional one-shot version of GA4AudienceClient.create_audience.
|
|
556
|
+
|
|
557
|
+
Builds a client from the given credentials and creates the audience in
|
|
558
|
+
a single call. See GA4AudienceClient.create_audience for parameter docs.
|
|
559
|
+
"""
|
|
560
|
+
client = GA4AudienceClient(
|
|
561
|
+
credentials_path=credentials_path,
|
|
562
|
+
credentials_info=credentials_info,
|
|
563
|
+
access_token=access_token,
|
|
564
|
+
)
|
|
565
|
+
return client.create_audience(
|
|
566
|
+
property_id=property_id,
|
|
567
|
+
display_name=display_name,
|
|
568
|
+
description=description,
|
|
569
|
+
membership_duration_days=membership_duration_days,
|
|
570
|
+
include_conditions=include_conditions,
|
|
571
|
+
exclude_conditions=exclude_conditions,
|
|
572
|
+
scope=scope,
|
|
573
|
+
exclusion_duration_mode=exclusion_duration_mode,
|
|
574
|
+
event_trigger=event_trigger,
|
|
575
|
+
dry_run=dry_run,
|
|
576
|
+
)
|