findly.unified-reporting-sdk 0.6.17__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.
Files changed (32) hide show
  1. findly/__init__.py +0 -0
  2. findly/unified_reporting_sdk/__init__.py +10 -0
  3. findly/unified_reporting_sdk/data_sources/__init__.py +0 -0
  4. findly/unified_reporting_sdk/data_sources/common/__init__.py +0 -0
  5. findly/unified_reporting_sdk/data_sources/common/common_parser.py +213 -0
  6. findly/unified_reporting_sdk/data_sources/common/date_range_helper.py +33 -0
  7. findly/unified_reporting_sdk/data_sources/common/reports_client.py +116 -0
  8. findly/unified_reporting_sdk/data_sources/common/where_string_comparison.py +149 -0
  9. findly/unified_reporting_sdk/data_sources/fb_ads/__init__.py +0 -0
  10. findly/unified_reporting_sdk/data_sources/fb_ads/fb_ads_client.py +608 -0
  11. findly/unified_reporting_sdk/data_sources/fb_ads/fb_ads_query_args_parser.py +828 -0
  12. findly/unified_reporting_sdk/data_sources/fb_ads/metadata/action_breakdowns.csv +11 -0
  13. findly/unified_reporting_sdk/data_sources/fb_ads/metadata/breakdowns.csv +44 -0
  14. findly/unified_reporting_sdk/data_sources/fb_ads/metadata/dimensions.jsonl +75 -0
  15. findly/unified_reporting_sdk/data_sources/fb_ads/metadata/fields.csv +135 -0
  16. findly/unified_reporting_sdk/data_sources/fb_ads/metadata/metrics.jsonl +102 -0
  17. findly/unified_reporting_sdk/data_sources/ga4/__init__.py +0 -0
  18. findly/unified_reporting_sdk/data_sources/ga4/ga4_client.py +1127 -0
  19. findly/unified_reporting_sdk/data_sources/ga4/ga4_query_args_parser.py +751 -0
  20. findly/unified_reporting_sdk/data_sources/ga4/metadata/dimensions.jsonl +109 -0
  21. findly/unified_reporting_sdk/data_sources/gsc/__init__.py +0 -0
  22. findly/unified_reporting_sdk/data_sources/gsc/gsc_client.py +0 -0
  23. findly/unified_reporting_sdk/data_sources/gsc/gsc_service.py +55 -0
  24. findly/unified_reporting_sdk/protos/.gitignore +3 -0
  25. findly/unified_reporting_sdk/protos/__init__.py +5 -0
  26. findly/unified_reporting_sdk/urs.py +87 -0
  27. findly/unified_reporting_sdk/util/__init__.py +0 -0
  28. findly/unified_reporting_sdk/util/create_numeric_string_series.py +16 -0
  29. findly_unified_reporting_sdk-0.6.17.dist-info/LICENSE +674 -0
  30. findly_unified_reporting_sdk-0.6.17.dist-info/METADATA +99 -0
  31. findly_unified_reporting_sdk-0.6.17.dist-info/RECORD +32 -0
  32. findly_unified_reporting_sdk-0.6.17.dist-info/WHEEL +4 -0
@@ -0,0 +1,608 @@
1
+ import backoff
2
+ import logging
3
+ import pandas as pd
4
+ from typing import List, Dict, Optional, Tuple, Union
5
+ from aiocache import cached, Cache
6
+
7
+ from facebook_business.api import FacebookAdsApi, FacebookSession
8
+ from facebook_business.adobjects.adaccount import AdAccount
9
+ from facebook_business.adobjects.adsinsights import AdsInsights
10
+ from facebook_business.adobjects.user import User
11
+ from facebook_business.adobjects.business import Business
12
+
13
+ from findly.unified_reporting_sdk.data_sources.common.reports_client import (
14
+ ReportsClient,
15
+ )
16
+ from findly.unified_reporting_sdk.data_sources.fb_ads.fb_ads_query_args_parser import (
17
+ FbAdsQueryArgsParser,
18
+ DATE,
19
+ YEARMONTH,
20
+ )
21
+ from findly.unified_reporting_sdk.protos.findly_semantic_layer_pb2 import (
22
+ DimensionType,
23
+ Dimension,
24
+ Metric,
25
+ QueryArgs,
26
+ )
27
+ from itertools import islice
28
+ import json
29
+ from facebook_business.exceptions import (
30
+ FacebookUnavailablePropertyException,
31
+ )
32
+
33
+ LOGGER = logging.getLogger(__name__)
34
+
35
+ logging.getLogger("backoff").setLevel(logging.WARNING)
36
+
37
+ REPORT_TIMEOUT = 30 # Maximum timeout for the report to complete
38
+
39
+ ATTRIBUTION_SETTING = "attribution_setting"
40
+
41
+ FB_PARSER = FbAdsQueryArgsParser.default()
42
+
43
+ DEFAULT_PAGE_SIZE_FOR_FB_ADS_PAGINATED_CALL = 1000
44
+ DEFAULT_LIMIT_FOR_ADS_INSIGHTS_CURSOR = 1000
45
+
46
+
47
+ class FbAdsClient(ReportsClient):
48
+ """
49
+ Attributes:
50
+ _api(FacebookAdsApi)
51
+ """
52
+
53
+ _api: FacebookAdsApi
54
+
55
+ def __init__(
56
+ self,
57
+ token: str,
58
+ client_id: str,
59
+ client_secret: str,
60
+ ):
61
+ """
62
+ Initialize the FbAdsClient with an access token.
63
+
64
+ Args:
65
+ token (str): The access token for the Facebook Ads API.
66
+ """
67
+ api_version = None
68
+ proxies = None
69
+ timeout = None
70
+ debug = False
71
+
72
+ session = FacebookSession(client_id, client_secret, token, proxies, timeout)
73
+ self._api = FacebookAdsApi(session, api_version, enable_debug_logger=debug)
74
+
75
+ async def list_all_ad_accounts(self, **kwargs: str) -> Optional[List[str]]:
76
+ """
77
+ List all ad accounts for the user.
78
+
79
+ Returns:
80
+ List[str]: A list of AdAccount Ids.
81
+ """
82
+ try:
83
+ # fbid='me' is used to refer to the currently authenticated user.
84
+ client = User(fbid="me", api=self._api)
85
+ ad_accounts = client.get_ad_accounts()
86
+
87
+ # Now get the ad_accont_ids
88
+ ad_account_ids = [account["id"] for account in ad_accounts]
89
+
90
+ LOGGER.info(
91
+ {
92
+ "msg": "user_ad_accounts",
93
+ "ids": ad_account_ids,
94
+ }
95
+ )
96
+
97
+ # Fetch all businesses associated with the user
98
+ business_fields = ["id", "name"]
99
+ businesses = client.get_businesses(fields=business_fields)
100
+
101
+ # Define fields to fetch for ad accounts
102
+ ad_account_fields = ["id", "name"]
103
+
104
+ # For each business, fetch and print all ad accounts
105
+ for business in businesses:
106
+ business_id = business["id"]
107
+ business_name = business["name"]
108
+
109
+ # Get the business object
110
+ business_entity = Business(fbid=business_id, api=self._api)
111
+
112
+ # Fetch all owned ad accounts for the business
113
+ # These are ad accounts that the business has created and has full control over.
114
+ owned_ad_accounts = business_entity.get_owned_ad_accounts(
115
+ fields=ad_account_fields
116
+ )
117
+ owned_ad_accounts_ids = [
118
+ account[AdAccount.Field.id] for account in owned_ad_accounts
119
+ ]
120
+ LOGGER.info(
121
+ {
122
+ "msg": "Owned ad accounts",
123
+ "business_name": business_name,
124
+ "business_id": business_id,
125
+ "ids": owned_ad_accounts_ids,
126
+ }
127
+ )
128
+ ad_account_ids.extend(owned_ad_accounts_ids)
129
+
130
+ # Fetch all client ad accounts for the business
131
+ # This method retrieves ad accounts that are managed by the business entity but are owned by another entity (client).
132
+ # In this scenario, the business entity typically has been granted access to manage these accounts on behalf of the client.
133
+ client_ad_accounts = business_entity.get_client_ad_accounts(
134
+ fields=ad_account_fields
135
+ )
136
+ client_ad_accounts_ids = [
137
+ account[AdAccount.Field.id] for account in client_ad_accounts
138
+ ]
139
+ LOGGER.info(
140
+ {
141
+ "msg": "Client ad accounts",
142
+ "business_name": business_name,
143
+ "business_id": business_id,
144
+ "ids": client_ad_accounts_ids,
145
+ }
146
+ )
147
+ ad_account_ids.extend(client_ad_accounts_ids)
148
+ return ad_account_ids
149
+
150
+ except Exception as e:
151
+ LOGGER.error(
152
+ {
153
+ "msg": "Error listing ad accounts",
154
+ "error": str(e),
155
+ }
156
+ )
157
+ return None
158
+
159
+ async def list_property_ids(self, **kwargs: str) -> Optional[List[str]]:
160
+ return await self.list_all_ad_accounts()
161
+
162
+ @backoff.on_exception(backoff.expo, Exception, max_time=60, max_tries=3)
163
+ @cached(ttl=300, cache=Cache.MEMORY) # Cache results for 5 minutes
164
+ async def _decorated_query_from_parts(
165
+ self,
166
+ ad_account_id: str,
167
+ metrics_names: List[str],
168
+ dimensions: Optional[List[Dimension]] = None,
169
+ time_ranges: Optional[
170
+ List[Dict[str, str]]
171
+ ] = None, # If not specified, defaults to last 30 days
172
+ sort: Optional[List[str]] = None,
173
+ filtering: Optional[List[Dict[str, str]]] = None,
174
+ limit: Union[int, str] = DEFAULT_LIMIT_FOR_ADS_INSIGHTS_CURSOR,
175
+ time_increment: Optional[str] = None,
176
+ level: AdsInsights.Level = AdsInsights.Level.account,
177
+ page_size: int = DEFAULT_PAGE_SIZE_FOR_FB_ADS_PAGINATED_CALL,
178
+ **kwargs: str,
179
+ ) -> Optional[Tuple[List[AdsInsights], Optional[Dict]]]:
180
+ """
181
+ Query the Facebook Ads API for insights.
182
+
183
+ Args:
184
+ ad_account_id (str): The ad account ID.
185
+ metrics_names (List[str]): The Metric fields to include in the response.
186
+ dimensions (Optional[List[Dimension]]): The dimensions that can be fields, breakdowns, action breakdowns...
187
+ time_ranges (Optional[List[Dict[str, str]]]): The time ranges to apply.
188
+ sort (Optional[List[str]]): The sort order to apply.
189
+ filtering (Optional[List[Dict[str, str]]): The filters to apply.
190
+ level (AdsInsights.Level): The level of insights to return.
191
+
192
+ Returns:
193
+ Optional[Tuple[List[AdsInsights], Optional[Dict]]]: A tuple of AdsInsights List and Summary, or None if an error occurred.
194
+ """
195
+ try:
196
+ limit = int(limit)
197
+ except ValueError as e:
198
+ LOGGER.error(
199
+ {
200
+ "msg": "fb_ads_query_failed",
201
+ "error": str(e),
202
+ }
203
+ )
204
+ return None
205
+ except TypeError as e:
206
+ LOGGER.error(
207
+ {
208
+ "msg": "fb_ads_query_failed",
209
+ "error": str(e),
210
+ }
211
+ )
212
+ return None
213
+
214
+ fields = list(metrics_names)
215
+ params = {"level": level, "default_summary": "true"}
216
+ if dimensions:
217
+ fields_dimensions = [
218
+ dimension.name
219
+ for dimension in dimensions
220
+ if dimension is not None
221
+ and dimension.type == DimensionType.FB_ADS_FIELD
222
+ ]
223
+ fields.extend(fields_dimensions)
224
+ # Following the Facebook Ads API documentation,
225
+ # Note: The attribution_setting field only returns values when use_unified_attribution_setting is set to true.
226
+ # https://developers.facebook.com/docs/marketing-api/reference/ads-insights/
227
+ if ATTRIBUTION_SETTING in fields_dimensions:
228
+ params["use_unified_attribution_setting"] = "true"
229
+
230
+ params["breakdowns"] = [
231
+ dimension.name
232
+ for dimension in dimensions
233
+ if dimension is not None
234
+ and dimension.type == DimensionType.FB_ADS_BREAKDOWN
235
+ ]
236
+ params["action_breakdowns"] = [
237
+ dimension.name
238
+ for dimension in dimensions
239
+ if dimension is not None
240
+ and dimension.type == DimensionType.FB_ADS_ACTION_BREAKDOWN
241
+ ]
242
+
243
+ if DATE in [dimension.name for dimension in dimensions]:
244
+ params["time_increment"] = "1"
245
+ elif YEARMONTH in [dimension.name for dimension in dimensions]:
246
+ params["time_increment"] = "monthly"
247
+
248
+ # in Fb ads, multiple time_ranges and time_increment don't work together
249
+ # So if we have multiple time_ranges, we give preference to time_ranges
250
+ # if we have a single one, we use singular date range and time_increment
251
+ if time_ranges:
252
+ if len(time_ranges) > 1:
253
+ params["time_ranges"] = time_ranges
254
+ else:
255
+ params["time_range"] = time_ranges[0]
256
+ # TODO (jutogashi): check if we want to add this back
257
+ # if time_increment:
258
+ # params["time_increment"] = time_increment
259
+ if sort:
260
+ params["sort"] = sort
261
+ if filtering:
262
+ params["filtering"] = filtering
263
+
264
+ params["limit"] = page_size
265
+
266
+ try:
267
+ insights_cursor = AdAccount(fbid=ad_account_id, api=self._api).get_insights(
268
+ fields=fields,
269
+ params=params,
270
+ )
271
+ summary: Optional[Dict] = None
272
+ try:
273
+ summary_text = insights_cursor.summary()
274
+ # the string has <Summary> before the JSON string
275
+ # This splits on "> "and takes the second part, which is the JSON string
276
+ summary_json = summary_text.split("> ")[1]
277
+ summary = json.loads(summary_json)
278
+ except FacebookUnavailablePropertyException as e:
279
+ LOGGER.info(
280
+ {
281
+ "msg": "error_getting_summary",
282
+ "error": str(e),
283
+ }
284
+ )
285
+ insights_cursor_slice = islice(insights_cursor, limit)
286
+ insights = list(insights_cursor_slice)
287
+
288
+ return insights, summary
289
+
290
+ except Exception as e:
291
+ LOGGER.info(
292
+ {
293
+ "msg": "Error querying Facebook Ads API",
294
+ "ad_account_id": ad_account_id,
295
+ "error": str(e),
296
+ }
297
+ )
298
+ raise e
299
+
300
+ @cached(ttl=300, cache=Cache.MEMORY) # Cache results for 5 minutes
301
+ async def _decorated_query(
302
+ self,
303
+ query_args: QueryArgs,
304
+ property_id: str,
305
+ **kwargs: str,
306
+ ) -> Optional[Tuple[List[pd.DataFrame], List[pd.DataFrame]]]:
307
+ params = await FB_PARSER.parse_query_args_to_request_params(
308
+ query_args=query_args,
309
+ property_id=property_id,
310
+ )
311
+ insights = None
312
+ try:
313
+ insights, summary = await self._decorated_query_from_parts(**params)
314
+
315
+ except Exception as e:
316
+ LOGGER.error(
317
+ {
318
+ "msg": "fb_ads_query_failed",
319
+ "error": str(e),
320
+ }
321
+ )
322
+ raise e
323
+
324
+ if insights is None:
325
+ return None
326
+
327
+ generated_result_df = await FB_PARSER.parse_report_response_to_dataframe(
328
+ report_response=insights,
329
+ query_args=query_args,
330
+ )
331
+
332
+ generated_totals_df = FB_PARSER.parse_totals_to_dataframe(
333
+ summary=summary,
334
+ query_args=query_args,
335
+ )
336
+ return generated_result_df, generated_totals_df
337
+
338
+ async def query(
339
+ self,
340
+ query_args: QueryArgs,
341
+ property_id: str,
342
+ **kwargs: str,
343
+ ) -> Optional[Tuple[List[pd.DataFrame], List[pd.DataFrame]]]:
344
+ return await self._decorated_query(
345
+ query_args=query_args,
346
+ property_id=property_id,
347
+ **kwargs,
348
+ )
349
+
350
+ @cached(
351
+ ttl=3600, cache=Cache.MEMORY, skip_cache_func=lambda x: x is None
352
+ ) # Cache results for 1 hour
353
+ async def _decorated_get_dimension_values(
354
+ self,
355
+ dimension: Dimension,
356
+ top_n: int,
357
+ property_id: str,
358
+ level: AdsInsights.Level = AdsInsights.Level.account,
359
+ **kwargs: str,
360
+ ) -> Optional[List[str]]:
361
+ """
362
+ Get the top N values for a specified dimension from the Facebook Ads API.
363
+
364
+ This method queries the Facebook Ads API for insights, with the specified dimension.
365
+ It then returns the top N values for the dimension.
366
+
367
+ Args:
368
+ dimension (Dimension): the dimension.
369
+ top_n (int): The number of top values to return.
370
+ property_id (str): The ad account ID.
371
+ level (AdsInsights.Level): TODO describe level,
372
+
373
+ Returns:
374
+ Optional[List[str]]: A list of the top N values for the dimension, or None if an error occurred.
375
+ """
376
+ LOGGER.info(
377
+ {
378
+ "msg": "get_fb_ads_dimension_values",
379
+ "dimension_name": dimension.name,
380
+ "dimension_type": dimension.type,
381
+ "top_n": top_n,
382
+ "level": level,
383
+ }
384
+ )
385
+
386
+ try:
387
+ if dimension.type == DimensionType.FB_ADS_BREAKDOWN:
388
+ response = await self._decorated_query_from_parts(
389
+ ad_account_id=property_id,
390
+ metrics_names=["impressions"],
391
+ dimensions=[dimension],
392
+ sort=["impressions_descending"],
393
+ level=level,
394
+ )
395
+ if response is None:
396
+ LOGGER.info(
397
+ {
398
+ "msg": "no_response_for_query",
399
+ "dimension_metadata_name": dimension.name,
400
+ "dimension_type": dimension.type,
401
+ "top_n": top_n,
402
+ }
403
+ )
404
+ return None
405
+
406
+ result = [insight[dimension.name] for insight in response[0][:top_n]]
407
+
408
+ elif dimension.type == DimensionType.FB_ADS_ACTION_BREAKDOWN:
409
+ response = await self._decorated_query_from_parts(
410
+ ad_account_id=property_id,
411
+ metrics_names=["actions"],
412
+ dimensions=[dimension],
413
+ level=level,
414
+ )
415
+ if response is None:
416
+ LOGGER.info(
417
+ {
418
+ "msg": "no_response_for_query",
419
+ "dimension_metadata_name": dimension.name,
420
+ "dimension_type": dimension.type,
421
+ "top_n": top_n,
422
+ }
423
+ )
424
+ return None
425
+ response_list = response[0]
426
+ actions_dict: Dict[str, int] = {}
427
+ if len(response_list) != 1:
428
+ logging.warning(
429
+ {
430
+ "msg": "response_length_not_1_for_action_breakdown",
431
+ "dimension_metadata_name": dimension.name,
432
+ "response_length": len(response_list),
433
+ }
434
+ )
435
+ # If the response_list length is 0, return an empty list
436
+ # If the response_list length is > 1, return the top N values for the first element
437
+ # but keep a warning in the logs, so we know that something is wrong
438
+ if len(response_list) == 0:
439
+ return []
440
+ for action in response_list[0]["actions"]:
441
+ if dimension.name in action:
442
+ if (
443
+ action[dimension.name] not in actions_dict
444
+ or int(action["value"])
445
+ > actions_dict[action[dimension.name]]
446
+ ):
447
+ actions_dict[action[dimension.name]] = int(action["value"])
448
+
449
+ top_actions = sorted(
450
+ actions_dict.items(), key=lambda item: item[1], reverse=True
451
+ )[:top_n]
452
+
453
+ result = [action[0] for action in top_actions]
454
+
455
+ elif dimension.type == DimensionType.FB_ADS_FIELD:
456
+ response = await self._decorated_query_from_parts(
457
+ ad_account_id=property_id,
458
+ metrics_names=[], # No metrics needed for this query
459
+ dimensions=[dimension],
460
+ sort=[f"{dimension.name}_descending"],
461
+ level=AdsInsights.Level.ad,
462
+ )
463
+
464
+ if response is None:
465
+ LOGGER.info(
466
+ {
467
+ "msg": "no_response_for_query",
468
+ "dimension_metadata_name": dimension.name,
469
+ "dimension_type": dimension.type,
470
+ "top_n": top_n,
471
+ }
472
+ )
473
+ return None
474
+
475
+ result = [insight[dimension.name] for insight in response[0][:top_n]]
476
+ result = list(set(result)) # Remove duplicates
477
+
478
+ else:
479
+ LOGGER.info(
480
+ {
481
+ "msg": "dimension_type_not_supported",
482
+ "dimension_metadata_name": dimension.name,
483
+ "top_n": top_n,
484
+ }
485
+ )
486
+ return None
487
+
488
+ LOGGER.info(
489
+ {
490
+ "msg": "get_fb_ads_dimension_values_successful",
491
+ "dimension_metadata_name": dimension.name,
492
+ "top_n": top_n,
493
+ }
494
+ )
495
+ return result
496
+
497
+ except Exception as e:
498
+ LOGGER.info(
499
+ {
500
+ "msg": "error_getting_fb_ads_dimension_values",
501
+ "dimension_metadata_name": dimension.name,
502
+ "top_n": top_n,
503
+ "error": str(e),
504
+ }
505
+ )
506
+ return None
507
+
508
+ async def get_dimension_values(
509
+ self,
510
+ dimension: Dimension,
511
+ top_n: int,
512
+ property_id: str,
513
+ level: AdsInsights.Level = AdsInsights.Level.account,
514
+ **kwargs: str,
515
+ ) -> Optional[List[str]]:
516
+ return await self._decorated_get_dimension_values(
517
+ dimension=dimension,
518
+ top_n=top_n,
519
+ property_id=property_id,
520
+ level=level,
521
+ **kwargs,
522
+ )
523
+
524
+ async def list_metrics(
525
+ self, property_id: str, **kwargs: str
526
+ ) -> Optional[List[Metric]]:
527
+ LOGGER.info(
528
+ {
529
+ "msg": "Getting metrics from json",
530
+ "ad_account_id": property_id,
531
+ }
532
+ )
533
+ try:
534
+ metrics_list = FB_PARSER.parse_metrics()
535
+ return metrics_list
536
+ except Exception as e:
537
+ LOGGER.error(f"An unexpected error occurred parsing Metrics: {e}")
538
+ return None
539
+
540
+ async def list_dimensions(
541
+ self, property_id: str, **kwargs: str
542
+ ) -> Optional[List[Dimension]]:
543
+ LOGGER.info(
544
+ {
545
+ "msg": "Getting dimensions from json",
546
+ "ad_account_id": property_id,
547
+ }
548
+ )
549
+ try:
550
+ dimensions_list = FB_PARSER.parse_dimensions()
551
+ return dimensions_list
552
+ except Exception as e:
553
+ LOGGER.error(f"An unexpected error occurred parsing dimensions: {e}")
554
+ return None
555
+
556
+ async def get_metric_from_name(
557
+ self, metric_name: str, property_id: str, **kwargs: str
558
+ ) -> Optional[Metric]:
559
+ fb_metrics_list: Optional[List[Metric]] = await self.list_metrics(
560
+ property_id=property_id,
561
+ )
562
+
563
+ if fb_metrics_list is None:
564
+ LOGGER.info(
565
+ {
566
+ "msg": "no_fb_ads_metric_found",
567
+ }
568
+ )
569
+ return None
570
+
571
+ for metric in fb_metrics_list:
572
+ if metric.name == metric_name:
573
+ LOGGER.info(
574
+ {
575
+ "msg": "metric_found",
576
+ "metric": metric_name,
577
+ }
578
+ )
579
+ return metric
580
+
581
+ return None
582
+
583
+ async def get_dimension_from_name(
584
+ self, dimension_name: str, property_id: str, **kwargs: str
585
+ ) -> Optional[Dimension]:
586
+ fb_dimensions_list: Optional[List[Dimension]] = await self.list_dimensions(
587
+ property_id=property_id,
588
+ )
589
+
590
+ if fb_dimensions_list is None:
591
+ LOGGER.info(
592
+ {
593
+ "msg": "no_fb_ads_dimensions_found",
594
+ }
595
+ )
596
+ return None
597
+
598
+ for dimension in fb_dimensions_list:
599
+ if dimension.name == dimension_name:
600
+ LOGGER.info(
601
+ {
602
+ "msg": "dimension_found",
603
+ "dimension": dimension_name,
604
+ }
605
+ )
606
+ return dimension
607
+
608
+ return None