rapidpro-api 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.
Files changed (65) hide show
  1. rapidpro_api/__init__.py +0 -0
  2. rapidpro_api/contact_processors.py +343 -0
  3. rapidpro_api/contact_processors_pl.py +106 -0
  4. rapidpro_api/datasets/BaseDataset.py +551 -0
  5. rapidpro_api/datasets/ContactsDataset.py +31 -0
  6. rapidpro_api/datasets/DeltaLakeDataset.py +270 -0
  7. rapidpro_api/datasets/FieldsDataset.py +31 -0
  8. rapidpro_api/datasets/FlowsDataset.py +31 -0
  9. rapidpro_api/datasets/GroupsDataset.py +31 -0
  10. rapidpro_api/datasets/MessagesDataset.py +31 -0
  11. rapidpro_api/datasets/RawDataset.py +649 -0
  12. rapidpro_api/datasets/RunsDataset.py +32 -0
  13. rapidpro_api/datasets/__init__.py +8 -0
  14. rapidpro_api/field_processors.py +34 -0
  15. rapidpro_api/flow_processors.py +140 -0
  16. rapidpro_api/group_processors.py +101 -0
  17. rapidpro_api/message_processors.py +110 -0
  18. rapidpro_api/run_processors_pl.py +87 -0
  19. rapidpro_api/stats/__init__.py +5 -0
  20. rapidpro_api/stats/download_stats.py +112 -0
  21. rapidpro_api/stats/download_stats_list.py +115 -0
  22. rapidpro_api/stats/process_stats.py +318 -0
  23. rapidpro_api/stats/process_stats_list.py +266 -0
  24. rapidpro_api/stats/stats.py +643 -0
  25. rapidpro_api/telegram.py +37 -0
  26. rapidpro_api/time_utils.py +224 -0
  27. rapidpro_api/ureport_processors_pl.py +331 -0
  28. rapidpro_api/utils.py +72 -0
  29. rapidpro_api/validators.py +123 -0
  30. rapidpro_api/version.py +8 -0
  31. rapidpro_api/workspaces.py +278 -0
  32. rapidpro_api-0.1.0.dist-info/METADATA +95 -0
  33. rapidpro_api-0.1.0.dist-info/RECORD +65 -0
  34. rapidpro_api-0.1.0.dist-info/WHEEL +5 -0
  35. rapidpro_api-0.1.0.dist-info/top_level.txt +2 -0
  36. tests/__init__.py +0 -0
  37. tests/datasets/__init__.py +0 -0
  38. tests/datasets/test_base_dataset.py +528 -0
  39. tests/datasets/test_contacts_dataset.py +27 -0
  40. tests/datasets/test_fields_dataset.py +27 -0
  41. tests/datasets/test_flows_dataset.py +27 -0
  42. tests/datasets/test_groups_dataset.py +27 -0
  43. tests/datasets/test_messages_dataset.py +27 -0
  44. tests/datasets/test_raw_dataset.py +400 -0
  45. tests/datasets/test_runs_dataset.py +27 -0
  46. tests/stats/__init__.py +0 -0
  47. tests/stats/test_download_stats.py +186 -0
  48. tests/stats/test_download_stats_list.py +366 -0
  49. tests/stats/test_process_stats.py +0 -0
  50. tests/stats/test_process_stats_list.py +531 -0
  51. tests/stats/test_stats.py +711 -0
  52. tests/test_contact_processors.py +199 -0
  53. tests/test_contact_processors_pl.py +188 -0
  54. tests/test_field_processors.py +101 -0
  55. tests/test_flow_processor.py +90 -0
  56. tests/test_group_processors.py +190 -0
  57. tests/test_message_processors.py +129 -0
  58. tests/test_run_processors_pl.py +248 -0
  59. tests/test_telegram.py +61 -0
  60. tests/test_time_utils.py +318 -0
  61. tests/test_ureport_processors_pl.py +198 -0
  62. tests/test_utils.py +182 -0
  63. tests/test_validators.py +127 -0
  64. tests/test_version.py +20 -0
  65. tests/test_workspaces.py +576 -0
@@ -0,0 +1,34 @@
1
+ import copy
2
+
3
+ def process_field(field, metadata:dict=None):
4
+ """
5
+ Processes a field object. Merges with metadata.
6
+
7
+ Args:
8
+ field (dict): The field object to process.
9
+ metadata (dict, optional): Additional metadata to merge with the field object.
10
+
11
+ Returns:
12
+ dict: The processed field object.
13
+ Example:
14
+ group ={
15
+ "key": "reporters",
16
+ "name": "Reporters",
17
+ "type": text,
18
+ }
19
+ metadata = {
20
+ "workspace_code": "test_workspace"
21
+ }
22
+ processed_group = process_group(group, metadata)
23
+ # will return {
24
+ "key": "reporters",
25
+ "name": "Reporters",
26
+ "type": text,
27
+ "workspace_code": "test_workspace"
28
+ }
29
+ """
30
+ result = copy.deepcopy(field)
31
+ # Merge with metadata
32
+ if metadata:
33
+ result.update(metadata)
34
+ return result
@@ -0,0 +1,140 @@
1
+ import copy
2
+ import logging
3
+ from .time_utils import get_iso_date_strings
4
+
5
+ def process_flow(flow, metadata=None):
6
+ """
7
+ Process a flow object to extract relevant information.
8
+
9
+ Args:
10
+ flow (dict): The flow object containing details about the flow.
11
+
12
+ Returns:
13
+ dict: Processed flow information with relevant fields.
14
+ Example:
15
+ >>> flow = {
16
+ ... "uuid": "12345",
17
+ ... "name": "Sample Flow",
18
+ "type": "message",
19
+ "archived": False,
20
+ ... "labels": [],
21
+ ... "expires": 4320,
22
+ ... "created_on": "2023-10-01T12:00:00Z",
23
+ ... "runs": {
24
+ ... "active": 1,
25
+ ... "completed": 2,
26
+ ... "waiting": 0,
27
+ ... "interrupted": 0,
28
+ ... "expired": 0,
29
+ ... "failed": 0
30
+ ... }
31
+ ... "results": []
32
+ ... }
33
+ >>> metadata = {"source": "API"}
34
+ >>> processed_flow = process_flow(flow, metadata)
35
+ >>> print(processed_flow)
36
+ {
37
+ 'uuid': '12345',
38
+ 'name': 'Sample Flow',
39
+ 'type': 'message',
40
+ 'archived': False,
41
+ 'labels': [],
42
+ 'expires': 4320,
43
+ 'created_on': '2023-10-01T12:00:00Z',
44
+ 'created_on_year': '2023',
45
+ 'created_on_month': '2023-10',
46
+ 'created_on_day': '2023-10-01',
47
+ 'active': 1,
48
+ 'completed': 2,
49
+ 'waiting': 0,
50
+ 'interrupted': 0,
51
+ 'expired': 0,
52
+ 'failed': 0,
53
+ 'source': 'API'
54
+ }
55
+
56
+ """
57
+ # Validate the contact is a dictionary
58
+ if not isinstance(flow, dict):
59
+ logging.error("Contact is not a dictionary")
60
+ raise ValueError("Contact is not a dictionary")
61
+
62
+ # Validate the contact has a uuid
63
+ if not flow.get("uuid"):
64
+ logging.warning("Contact does not have a uuid")
65
+
66
+ # flattern runs
67
+ processed = copy.deepcopy(flow)
68
+ processed.update(flow.get("runs", {}))
69
+ # remove runs, node_uuids and results
70
+ processed.pop("runs", None)
71
+
72
+ # add metadata if provided
73
+ if metadata:
74
+ processed.update(metadata)
75
+ # add the dates for created_ond
76
+ try:
77
+ y, m, d = get_iso_date_strings(flow.get("created_on", ""))
78
+ processed["created_on_year"] = y
79
+ processed["created_on_month"] = m
80
+ processed["created_on_day"] = d
81
+ except (ValueError, KeyError) as e:
82
+ logging.warning("created_on (%s) is invalid for uuid %s. Error: %s", flow.get("created_on", "<not found>"), flow.get("uuid", ""), e)
83
+ return processed
84
+
85
+
86
+ def process_flow_results(flow, metadata=None):
87
+ """
88
+ Process the results of a flow to extract relevant information.
89
+ Args:
90
+ flow (dict): The flow object containing details about the flow.
91
+ metadata (dict, optional): Additional metadata to include in the processed results.
92
+ Returns:
93
+ list: Processed flow results with relevant fields.
94
+ Example:
95
+ >>> flow = {
96
+ "uuid": "12345",
97
+ "name": "Sample Flow",
98
+ "type": "message",
99
+ "archived: False,
100
+ "labels": [],
101
+ "expires": 4320,
102
+ "created_on": "2023-10-01T12:00:00Z",
103
+ "results": [
104
+ {
105
+ "key": "report",
106
+ "name": "Report",
107
+ "categories": ["Report", "Other"],
108
+ "node_uuids": ["12345"]
109
+ }
110
+ {
111
+ "key": "report",
112
+ "name": "Report",
113
+ "categories": ["Report", "Other"],
114
+ "node_uuids": ["12345"]
115
+ }
116
+ }
117
+ ]
118
+ >>> metadata = {"source": "API"}
119
+ >>> processed_results = process_flow_results(flow, metadata)
120
+ >>> print(processed_results)
121
+ [
122
+ {
123
+ 'uuid': '12345',
124
+ 'name': 'Sample Flow',
125
+ 'key': 'report',
126
+ 'name': 'Report',
127
+ 'categories': ['Report', 'Other'],
128
+ 'node_uuids': ['12345'],
129
+ 'source': 'API'
130
+ }
131
+ ]
132
+ """
133
+ processed_results = []
134
+ for result in flow.get("results", []):
135
+ processed_result = process_flow(result, metadata)
136
+ # add the flow uuid and name to the result
137
+ processed_result["uuid"] = flow.get("uuid", "")
138
+ processed_result["name"] = flow.get("name", "")
139
+ processed_results.append(processed_result)
140
+ return processed_results
@@ -0,0 +1,101 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ @file group_processors.py
4
+ @brief Group processors
5
+ @details
6
+ This module contains functions to process groups in RapidPro API format.
7
+ """
8
+ import logging
9
+ import copy
10
+ from datetime import datetime
11
+
12
+ def is_in_groups(groups, groups_to_check):
13
+ """
14
+ For each group in the groups_check list checks if the contact is in the group.
15
+
16
+ Args:
17
+ groups (list): A list of groups in RapidPro API format, a dictionary with the group name and uuid. (e.g. [{"name": "group1", "uuid": "12345678-1234-1234-1234-123456789012"}]).
18
+ groups_to_check (list): A list of groups to check if the contact is in (e.g. ["group1", "group2"]).
19
+ Returns:
20
+ dict : A dictionary with the group name as key and a boolean value as value. The value is True if the contact is in the group, False otherwise.
21
+ Example:
22
+ contact_groups = [{"name": "group1", "uuid": "12345678-1234-1234-1234-123456789011"}, {"name": "group2", "uuid": "12345678-1234-1234-1234-123456789012"}]
23
+ groups_to_check = ["group1", "group3"]
24
+ is_contact_in_groups(contact_groups, groups_to_check)
25
+ # will return {"group1": True, "group3": False}
26
+ """
27
+
28
+ if groups_to_check is None:
29
+ return {}
30
+
31
+ if not isinstance(groups_to_check, list):
32
+ logging.error("Contact groups is not a list")
33
+ raise ValueError("groups_to_check is not a list")
34
+
35
+ groups_checked = {}
36
+ # get groups names
37
+ groups_names = [group["name"] for group in groups]
38
+ for group in groups_to_check:
39
+ if group in groups_names:
40
+ groups_checked[group] = True
41
+ else:
42
+ groups_checked[group] = False
43
+ return groups_checked
44
+
45
+
46
+ def process_group(group, metadata:dict=None, ingestion_date:datetime=None):
47
+ """
48
+ Processes a group object. Merges with metadata.
49
+
50
+ Args:
51
+ group (dict): The group object to process.
52
+ metadata (dict, optional): Additional metadata to merge with the group object.
53
+ ingestion_date (datetime, optional): The date when the group was ingested. If provided, it will add fields for year, month, and day.
54
+
55
+ Returns:
56
+ dict: The processed group object.
57
+ Example:
58
+ group ={
59
+ "uuid": "5f05311e-8f81-4a67-a5b5-1501b6d6496a",
60
+ "name": "Reporters",
61
+ "query": null,
62
+ "status": "ready",
63
+ "system": false,
64
+ "count": 315
65
+ }
66
+ metadata = {
67
+ "workspace_code": "test_workspace"
68
+ }
69
+ processed_group = process_group(group, metadata)
70
+ # will return {
71
+ "uuid": "5f05311e-8f81-4a67-a5b5-1501b6d6496a",
72
+ "name": "Reporters",
73
+ "query": null,
74
+ "status": "ready",
75
+ "system": false,
76
+ "count": 315,
77
+ "workspace_code": "test_workspace"
78
+ "ingestion_year": 2023,
79
+ "ingestion_month": "2023-10",
80
+ "ingestion_day": "2023-10-01"
81
+ }
82
+ """
83
+ result = copy.deepcopy(group)
84
+
85
+ # Add the ingestion date if provided
86
+ if ingestion_date:
87
+ result['ingestion_year'] = ingestion_date.year
88
+ result['ingestion_month'] = ingestion_date.strftime('%Y-%m')
89
+ result['ingestion_day'] = ingestion_date.strftime('%Y-%m-%d')
90
+ result['ingestion_date'] = ingestion_date
91
+ else:
92
+ # If no ingestion date is provided, set default values
93
+ result['ingestion_year'] = None
94
+ result['ingestion_month'] = None
95
+ result['ingestion_day'] = None
96
+ result['ingestion_date'] = None
97
+
98
+ # Merge with metadata
99
+ if metadata:
100
+ result.update(metadata)
101
+ return result
@@ -0,0 +1,110 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ This module processes message objects from RapidPro API.
4
+ It flattens nested fields, checks for labels, and merges metadata.
5
+ """
6
+ import logging
7
+ from .contact_processors import get_contact_urn_type
8
+
9
+
10
+ def is_in_labels(message_labels, labels=None):
11
+ """
12
+ Checks if any of the specified labels are present in the message's labels.
13
+
14
+ Args:
15
+ message_labels (list of dict): A list of label dictionaries, each expected to have a "name" key.
16
+ labels (list of str, optional): A list of label names to check for presence in message_labels.
17
+
18
+ Returns:
19
+ list of dict : with the label name as key and True if the label in `labels` is present in `message_labels`, False otherwise.
20
+ Example:
21
+ message_labels = [{"name": "Important"}, {"name": "Urgent"}]
22
+ labels = ["Important", "Urgent", "NotInMessage"]
23
+ is_in_labels(message_labels, labels)
24
+ # will return {"Important": True, "Urgent": True, "NotInMessage": False}
25
+ """
26
+ if not labels:
27
+ return {}
28
+ message_label_names = {label.get("name") for label in message_labels or []}
29
+ return {label: label in message_label_names for label in labels}
30
+
31
+
32
+ def flatten(obj, prefix):
33
+ """
34
+ Flattens a dictionary by prefixing its keys.
35
+ This function takes a dictionary and a prefix string, and returns a new dictionary
36
+ where each key is prefixed with the provided prefix followed by an underscore.
37
+ Args:
38
+ obj (dict): The dictionary to flatten.
39
+ prefix (str): The prefix to add to each key.
40
+ Returns:
41
+ dict : A new dictionary with flattened keys.
42
+ Example:
43
+ flatten({"name": "Alice", "age": 30}, "user")
44
+ # will return {"user_name": "Alice", "user_age": 30}
45
+ """
46
+ if not isinstance(obj, dict):
47
+ return {}
48
+ return {f"{prefix}_{k}": v for k, v in obj.items()}
49
+
50
+ def process_message(message, labels=None, metadata=None):
51
+ """
52
+ Processes a message object:
53
+ - Flattens contact, channel, and flow fields.
54
+ - Removes attachments, quick_replies, and labels.
55
+ - Adds is_in_labels attribute.
56
+ - Merges with metadata.
57
+ - Adds is_broadcast attribute.
58
+ Args:
59
+ message (dict): The message object to process.
60
+ metadata (dict, optional): Additional metadata to merge into the result.
61
+ labels (list of str, optional): Labels to check against the message's labels.
62
+ Returns:
63
+ dict : Processed message with flattened fields and additional attributes.
64
+ Example:
65
+ message = {
66
+ "id": 123,
67
+ "contact": {"uuid": "d33e9ad5-5c35-414c-abd4-e7451c69ff1d", "name": "Bob McFlow"},
68
+ "channel": {"uuid": "channel-uuid"},
69
+ "flow": {"uuid": "flow-uuid"},
70
+ "labels": [{"name": "Important", "uuid": "d33e9ad5-5c35-414c-abd4-e7451c69ff1d""},
71
+ {"name": "Urgent", "uuid": "d33e9ad5-5c35-414c-abd4-e7451c69ff1e"}],
72
+ "text": "Hello, this is a test message.",
73
+ "attachments": [{"content_type": "image/jpeg", "url": "http://example.com/image.jpg"}],
74
+ "quick_replies": [{"title": "Reply", "payload": "reply_payload"}],
75
+ "urn": "tel:+1234567890",
76
+ "broadcast": 123
77
+ }
78
+ metadata = {"workspace_code": "test_workspace"}
79
+ labels = ["Important", "Urgent", "NotInMessage"]
80
+ processed_message = process_message(message, metadata, labels)
81
+ # will return a processed message with flattened fields and additional attributes.
82
+
83
+ """
84
+ result = dict(message) # shallow copy
85
+
86
+ # Flatten fields
87
+ for field in ("contact", "channel", "flow"):
88
+ if field in result and isinstance(result[field], dict):
89
+ # Flatten the field with the prefix
90
+ result.update(flatten(result[field], field))
91
+ result.pop(field, None)
92
+
93
+ # Add is_in_labels
94
+ if labels:
95
+ result.update(is_in_labels(message.get("labels", None), labels))
96
+
97
+ # Process urn if present
98
+ # Add the urn type to the root object
99
+ try:
100
+ result["urn_type"] = get_contact_urn_type(result.get("urn",))[0]
101
+ except ValueError:
102
+ result["urn_type"] = None
103
+ logging.debug("Message %s has no URN. Set as None", result.get("id", "<ID not found>"))
104
+ # Remove attachments and quick_replies
105
+ # Add is_broadcast
106
+ result["is_broadcast"] = bool(message.get("broadcast"))
107
+ # Merge with metadata
108
+ if metadata:
109
+ result.update(metadata)
110
+ return result
@@ -0,0 +1,87 @@
1
+ import polars as pl
2
+ from rapidpro_api.utils import flatten_pl_struct
3
+ from rapidpro_api.contact_processors import get_contact_urn_type
4
+
5
+
6
+
7
+ def process_runs_pl(run_batch: list, metadata: dict = None) -> pl.DataFrame:
8
+ """
9
+ Process a batch of runs using native Polars operations.
10
+ Args:
11
+ run_batch (list): A list of run dicts as returned by the RapidPro API.
12
+ metadata (dict): Optional metadata columns to add to each run.
13
+ Returns:
14
+ pl.DataFrame: A Polars DataFrame of processed runs.
15
+
16
+ """
17
+ if not run_batch:
18
+ return pl.DataFrame()
19
+
20
+ # First of all, convert the values into a list of dicts:
21
+ for run in run_batch:
22
+ # If values is empty, we create a dummy entry to maintain structure
23
+ values = run.get("values", {})
24
+
25
+ if not values:
26
+ run["run_responses"] = 0
27
+ run["values"] = [{
28
+ "key": "empty_run",
29
+ "name": "**empty_run**",
30
+ "value": "",
31
+ "category": "",
32
+ "node": "",
33
+ "time": ""
34
+ }
35
+ ]
36
+ # Convert values to a list of dicts if it's not already
37
+ elif isinstance(values, dict):
38
+ run["run_responses"] = len(values)
39
+ value_array = []
40
+ for key, value in values.items():
41
+ value["key"] = key # Add the key to the value dict
42
+ value_array.append(value)
43
+ run["values"] = value_array
44
+ # Convert to Polars DataFrame
45
+ df = pl.DataFrame(run_batch, infer_schema_length=None)
46
+ # Apply flatten_pl_struct to flow and contact columns
47
+ df = flatten_pl_struct(df, "flow")
48
+ df = flatten_pl_struct(df, "contact")
49
+
50
+ if "contact_urn" in df.columns:
51
+ df = df.with_columns([
52
+ pl.col("contact_urn").map_elements(
53
+ lambda x: get_contact_urn_type(x) if x else None,
54
+ return_dtype=pl.Utf8
55
+ ).alias("urn_type")
56
+ ])
57
+ # Process created_on date components
58
+
59
+ if "created_on" in df.columns:
60
+ df = df.with_columns([
61
+ pl.col("created_on").str.to_datetime().dt.year().cast(pl.Utf8).alias("created_on_year"),
62
+ pl.col("created_on").str.to_datetime().dt.strftime("%Y-%m").alias("created_on_month"),
63
+ pl.col("created_on").str.to_datetime().dt.strftime("%Y-%m-%d").alias("created_on_day")
64
+ ])
65
+
66
+ # exited_on can be null in all the cols which makes it a bit tricker to parse.
67
+ if "exited_on" in df.columns:
68
+ # ensure exited_on is string and not null
69
+ df = df.with_columns(pl.col("exited_on").fill_null("").cast(pl.Utf8))
70
+ # create date components, allowing for empty strings
71
+ df = df.with_columns([
72
+ pl.col("exited_on").str.to_datetime(format="%Y-%m-%dT%H:%M:%S%.fZ", strict=False).dt.year().cast(pl.Utf8).alias("exited_on_year"),
73
+ pl.col("exited_on").str.to_datetime(format="%Y-%m-%dT%H:%M:%S%.fZ", strict=False).dt.strftime("%Y-%m").alias("exited_on_month"),
74
+ pl.col("exited_on").str.to_datetime(format="%Y-%m-%dT%H:%M:%S%.fZ", strict=False).dt.strftime("%Y-%m-%d").alias("exited_on_day")
75
+ ])
76
+
77
+
78
+ # Explode and normalize the values column
79
+ if "values" in df.columns:
80
+ df = df.explode("values").unnest("values")
81
+
82
+ # Add metadata columns if provided
83
+ if metadata and isinstance(metadata, dict):
84
+ for k, v in metadata.items():
85
+ df = df.with_columns(pl.lit(v).alias(k))
86
+
87
+ return df
@@ -0,0 +1,5 @@
1
+ from .stats import Stats
2
+ from .download_stats import DownloadStats
3
+ from .download_stats_list import DownloadStatsList
4
+ from .process_stats import ProcessStats
5
+ from .process_stats_list import ProcessStatsList
@@ -0,0 +1,112 @@
1
+ import logging
2
+ import pandas as pd
3
+ from typing import List, Dict, Any, Optional, Tuple
4
+ from deltalake import write_deltalake, DeltaTable
5
+ from rapidpro_api.stats import Stats
6
+
7
+ class DownloadStats:
8
+ """
9
+ Encapsulates statistics for a single workspace download.
10
+
11
+ Example usage:
12
+ # Initialize workspace and parameters
13
+ workspace = some_workspace_object # Your workspace object
14
+ max_retries = 3
15
+ data_folder = "/path/to/data"
16
+
17
+ # Create download stats tracker
18
+ download_stats = DownloadStats(workspace, max_retries, data_folder)
19
+
20
+ # Track download attempts
21
+ for attempt in range(1, max_retries + 1):
22
+ download_stats.update_attempt(attempt)
23
+ try:
24
+ # Perform download operation here
25
+ # ... download logic ...
26
+
27
+ # On success
28
+ download_stats.record_success()
29
+ download_stats.set_comment("Download completed successfully")
30
+ break
31
+
32
+ except Exception as e:
33
+ if attempt == max_retries:
34
+ # Final failure
35
+ download_stats.record_failure(str(e))
36
+ download_stats.set_comment("Max retries exceeded")
37
+
38
+ # Get final statistics
39
+ stats_dict = download_stats.to_dict()
40
+ print(f"Download completed: {download_stats.completed}")
41
+ print(f"Stats: {stats_dict}")
42
+ """
43
+
44
+ def __init__(self, ws, retries: int, base_folder: str):
45
+ """
46
+ Initialize stats for a single workspace download.
47
+
48
+ Args:
49
+ ws: Workspace object
50
+ retries: Maximum number of retries
51
+ base_folder: Base folder for data storage
52
+ """
53
+ self.stats = Stats()
54
+ self.ws_code = ws.code
55
+ self.completed = False
56
+
57
+ # Set initial attributes
58
+ self.stats.set_attribute("workspace_code", ws.code)
59
+ self.stats.set_attribute("workspace_active", ws.active)
60
+ self.stats.set_attribute("workspace_vendor", ws.vendor)
61
+ self.stats.set_attribute("data_folder", base_folder)
62
+ self.stats.set_attribute("max_retries", retries)
63
+ self.stats.set_attribute("completed", "False")
64
+ self.stats.set_attribute("comments", "")
65
+ self.stats.start_timer("download_time")
66
+ self.stats.set_attribute("download_attemps", 0)
67
+ self.stats.set_attribute("exception", "")
68
+ self.stats.set_attribute("exception_type", "")
69
+
70
+ def update_attempt(self, attempt: int):
71
+ """Update the download attempt count."""
72
+ self.stats.set_attribute("download_attemps", attempt)
73
+
74
+ def record_success(self):
75
+ """Record a successful download completion."""
76
+ self.completed = True
77
+ self.stats.set_attribute("completed", "True")
78
+ self.stats.stop_timer("download_time")
79
+
80
+ def record_failure(self, error_msg: str = "", exception_type: type = None):
81
+ """Record a failed download."""
82
+ self.completed = False
83
+ self.stats.set_attribute("completed", "False")
84
+ self.stats.stop_timer("download_time")
85
+ if error_msg:
86
+ self.set_exception(error_msg, exception_type)
87
+
88
+ def set_comment(self, comment: str):
89
+ """Set a comment for this download."""
90
+ self.stats.set_attribute("comments", comment)
91
+
92
+ def set_exception(self, comment: str, exception_type: type = None):
93
+ """Set a comment for this download."""
94
+ self.stats.set_attribute("exception", comment)
95
+ if exception_type:
96
+ self.stats.set_attribute("exception_type", str(exception_type))
97
+
98
+
99
+ def to_dict(self) -> Dict[str, Any]:
100
+ """Convert stats to dictionary."""
101
+ return self.stats.to_dict()
102
+
103
+ def to_delta(self, delta_path: str):
104
+ """
105
+ Save the stats to a Delta Lake table.
106
+
107
+ Args:
108
+ delta_path: Path to the Delta Lake table
109
+ """
110
+ df = pd.DataFrame([self.to_dict()])
111
+ write_deltalake(table_or_uri=delta_path, data=df, mode="append")
112
+