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.
@@ -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
+ )