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.
- rapidpro_api/__init__.py +0 -0
- rapidpro_api/contact_processors.py +343 -0
- rapidpro_api/contact_processors_pl.py +106 -0
- rapidpro_api/datasets/BaseDataset.py +551 -0
- rapidpro_api/datasets/ContactsDataset.py +31 -0
- rapidpro_api/datasets/DeltaLakeDataset.py +270 -0
- rapidpro_api/datasets/FieldsDataset.py +31 -0
- rapidpro_api/datasets/FlowsDataset.py +31 -0
- rapidpro_api/datasets/GroupsDataset.py +31 -0
- rapidpro_api/datasets/MessagesDataset.py +31 -0
- rapidpro_api/datasets/RawDataset.py +649 -0
- rapidpro_api/datasets/RunsDataset.py +32 -0
- rapidpro_api/datasets/__init__.py +8 -0
- rapidpro_api/field_processors.py +34 -0
- rapidpro_api/flow_processors.py +140 -0
- rapidpro_api/group_processors.py +101 -0
- rapidpro_api/message_processors.py +110 -0
- rapidpro_api/run_processors_pl.py +87 -0
- rapidpro_api/stats/__init__.py +5 -0
- rapidpro_api/stats/download_stats.py +112 -0
- rapidpro_api/stats/download_stats_list.py +115 -0
- rapidpro_api/stats/process_stats.py +318 -0
- rapidpro_api/stats/process_stats_list.py +266 -0
- rapidpro_api/stats/stats.py +643 -0
- rapidpro_api/telegram.py +37 -0
- rapidpro_api/time_utils.py +224 -0
- rapidpro_api/ureport_processors_pl.py +331 -0
- rapidpro_api/utils.py +72 -0
- rapidpro_api/validators.py +123 -0
- rapidpro_api/version.py +8 -0
- rapidpro_api/workspaces.py +278 -0
- rapidpro_api-0.1.0.dist-info/METADATA +95 -0
- rapidpro_api-0.1.0.dist-info/RECORD +65 -0
- rapidpro_api-0.1.0.dist-info/WHEEL +5 -0
- rapidpro_api-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/datasets/__init__.py +0 -0
- tests/datasets/test_base_dataset.py +528 -0
- tests/datasets/test_contacts_dataset.py +27 -0
- tests/datasets/test_fields_dataset.py +27 -0
- tests/datasets/test_flows_dataset.py +27 -0
- tests/datasets/test_groups_dataset.py +27 -0
- tests/datasets/test_messages_dataset.py +27 -0
- tests/datasets/test_raw_dataset.py +400 -0
- tests/datasets/test_runs_dataset.py +27 -0
- tests/stats/__init__.py +0 -0
- tests/stats/test_download_stats.py +186 -0
- tests/stats/test_download_stats_list.py +366 -0
- tests/stats/test_process_stats.py +0 -0
- tests/stats/test_process_stats_list.py +531 -0
- tests/stats/test_stats.py +711 -0
- tests/test_contact_processors.py +199 -0
- tests/test_contact_processors_pl.py +188 -0
- tests/test_field_processors.py +101 -0
- tests/test_flow_processor.py +90 -0
- tests/test_group_processors.py +190 -0
- tests/test_message_processors.py +129 -0
- tests/test_run_processors_pl.py +248 -0
- tests/test_telegram.py +61 -0
- tests/test_time_utils.py +318 -0
- tests/test_ureport_processors_pl.py +198 -0
- tests/test_utils.py +182 -0
- tests/test_validators.py +127 -0
- tests/test_version.py +20 -0
- 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,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
|
+
|