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,270 @@
1
+ from deltalake import DeltaTable, write_deltalake
2
+ import polars as pl
3
+ from typing import Optional, Dict, Any, List, Union
4
+ import logging
5
+
6
+ from rapidpro_api.workspaces import Workspace
7
+
8
+ class DeltaLakeDataset:
9
+ """Delta Lake manager with partition support.
10
+ This class provides methods to read and write Delta tables with partitioning support.
11
+ """
12
+ def __init__(
13
+ self,
14
+ name: str,
15
+ table_name: str,
16
+ workspace: Workspace,
17
+ base_folder: str = "./datasets",
18
+ partition_cols: Optional[List[str]] = None,
19
+ storage_options: Optional[Dict[str, Any]] = None
20
+
21
+ ):
22
+ """
23
+ Initialize Delta Lake manager with partition support.
24
+ The resulting delta table path will be:
25
+ `<base_folder>/<name>/<table_name>`
26
+ Example for the dataset named contacts and table_name processed:
27
+ - Local: `./datasets/contacts/processed`
28
+ - S3: `s3://my-bucket/contacts/processed`
29
+
30
+ Args:
31
+ name: Name of the dataset (contacts, flows, etc.)
32
+ table_name: Name of the Delta table within the dataset (e.g., "processed", "aggregated")
33
+ workspace: Workspace object (e.g., RapidPro workspace)
34
+ base_folder: Path to Delta table. Defaults to "./datasets".
35
+ Examples:
36
+ - Local: "/tmp/my_table"
37
+ - S3: "s3://my-bucket/my-table"
38
+ - Azure: "az://container/my-table"
39
+ - Google Cloud: "gs://bucket/my-table"
40
+ partition_cols: List of column names to partition by. If None, defaults to workspace code.
41
+ Example: ["workspace_code", "created_on_year", "created_on_month"]
42
+ storage_options: Additional storage options (optional, overrides env vars)
43
+ Example:
44
+ - For S3: {"key": "my-access
45
+ - For Azure: {"az_storage_account_name": "myaccount", "az_storage_account_key": "mykey"}
46
+ - For Google Cloud: {"gcp_project": "my-project", "gcp_credentials": "/path/to/credentials.json"}
47
+ """
48
+ self.name = name
49
+ self.workspace = workspace
50
+ self.table_name = table_name
51
+ self.base_folder = base_folder
52
+ self.table_path = f"{base_folder}/{name}/{table_name}"
53
+ self.partition_cols = partition_cols or [workspace.code]
54
+ self.storage_options = storage_options
55
+ self.table = None
56
+
57
+
58
+ def get_table(self) -> Optional[DeltaTable]:
59
+ """
60
+ Get whole dataset Delta table
61
+ Returns:
62
+ DeltaTable: Delta table object or empty if table doesn't exist
63
+ Raises:
64
+ Exception: If table cannot be loaded
65
+ """
66
+ if self.table is None:
67
+ try:
68
+ if self.storage_options:
69
+ self.table = DeltaTable(self.table_path, storage_options=self.storage_options)
70
+ else:
71
+ self.table = DeltaTable(self.table_path)
72
+ except Exception as e:
73
+ logging.error(f"Table {self.table_path} doesn't exist yet or error: {e}")
74
+ return None
75
+ return self.table
76
+
77
+ def write_dataframe(
78
+ self,
79
+ df: pl.DataFrame,
80
+ mode: str = "overwrite",
81
+ partition_cols: Optional[List[str]] = None,
82
+ predicate: str = None,
83
+ **write_kwargs
84
+ ):
85
+ """
86
+ Write DataFrame to Delta table with partition support.
87
+
88
+ Args:
89
+ df: Polars DataFrame to write
90
+ mode: Write mode ('append', 'overwrite', 'error', 'ignore'). default is 'overwrite'.
91
+ - 'append': Add to existing data
92
+ - 'overwrite': Replace existing data
93
+ - 'error': Raise error if data exists
94
+ - 'ignore': Do nothing if data exists
95
+ partition_cols: List of columns to partition by. If None, uses instance partition columns.
96
+ Example: ["workspace_code", "created_on_year", "created_on_month"]
97
+ partition_cols: Override instance partition columns
98
+ **write_kwargs: Additional arguments for write_deltalake
99
+ """
100
+ partition_columns = partition_cols or self.partition_cols
101
+
102
+ # Validate partition columns exist in DataFrame
103
+ if partition_columns:
104
+ missing_cols = [col for col in partition_columns if col not in df.columns]
105
+ if missing_cols:
106
+ raise ValueError(f"Partition columns {missing_cols} not found in DataFrame")
107
+
108
+ # Prepare write arguments
109
+ write_args = {
110
+ "table_or_uri": self.table_path,
111
+ "data": df,
112
+ "mode": mode,
113
+ "partition_by": partition_columns if partition_columns else None,
114
+ "storage_options": self.storage_options,
115
+ **write_kwargs
116
+ }
117
+
118
+ write_deltalake(**write_args)
119
+ self.table = None # Reset to reload
120
+
121
+ def read_as_polars(
122
+ self,
123
+ partition_filter: Optional[Union[List[tuple], Dict[str, Any]]] = None,
124
+ columns: Optional[List[str]] = None
125
+ ) -> pl.DataFrame:
126
+ """
127
+ Read Delta table as Polars DataFrame with partition filtering.
128
+
129
+ Args:
130
+ partition_filter: Filter partitions using various formats:
131
+ - List of tuples: [("year", "=", "2023"), ("month", "in", ["01", "02"])]
132
+ - Dict: {"year": "2023", "month": ["01", "02"]}
133
+ columns: List of columns to read (None for all)
134
+
135
+ Returns:
136
+ pl.DataFrame: Filtered data
137
+ """
138
+ table = self.get_table()
139
+ if not table:
140
+ return pl.DataFrame()
141
+
142
+ try:
143
+ # Prepare filters
144
+ filters = None
145
+ if partition_filter:
146
+ if isinstance(partition_filter, dict):
147
+ filters = []
148
+ for col, values in partition_filter.items():
149
+ if isinstance(values, list):
150
+ filters.append((col, "in", values))
151
+ else:
152
+ filters.append((col, "=", str(values)))
153
+ else:
154
+ filters = partition_filter
155
+
156
+ # Try to use Polars' native DeltaLake scan if available (polars >=0.20.16)
157
+ try:
158
+ scan = pl.scan_deltalake(
159
+ self.table_path,
160
+ storage_options=self.storage_options,
161
+ filters=filters,
162
+ columns=columns,
163
+ )
164
+ return scan.collect()
165
+ except Exception as e:
166
+ logging.error(f"Polars native DeltaLake scan failed: {e}")
167
+
168
+ # Fallback to pandas
169
+ if filters:
170
+ pandas_df = table.to_pandas(filters=filters, columns=columns)
171
+ else:
172
+ pandas_df = table.to_pandas(columns=columns)
173
+ return pl.from_pandas(pandas_df)
174
+
175
+ except Exception as e:
176
+ logging.error(f"Error reading partitioned data: {e}")
177
+ return pl.DataFrame()
178
+
179
+ def list_partitions(self) -> List[Dict[str, str]]:
180
+ """
181
+ List all partitions in the Delta table.
182
+
183
+ Returns:
184
+ List of partition dictionaries
185
+ """
186
+ table = self.get_table()
187
+ if not table:
188
+ return []
189
+
190
+ try:
191
+ # Get partition columns
192
+ if hasattr(table, 'metadata') and table.metadata():
193
+ partition_cols = table.metadata().partition_columns
194
+ if not partition_cols:
195
+ return []
196
+
197
+ # Read distinct partition values
198
+ df = pl.from_pandas(table.to_pandas(columns=partition_cols))
199
+ partitions = df.select(partition_cols).unique().to_dicts()
200
+ return partitions
201
+ else:
202
+ return []
203
+
204
+ except Exception as e:
205
+ logging.error(f"Error listing partitions: {e}")
206
+ return []
207
+
208
+ def optimize_table(self, partition_filter: Optional[Dict[str, Any]] = None):
209
+ """
210
+ Optimize Delta table (compact small files).
211
+
212
+ Args:
213
+ partition_filter: Only optimize specific partitions
214
+ """
215
+ table = self.get_table()
216
+ if not table:
217
+ return
218
+
219
+ try:
220
+ if partition_filter:
221
+ # Convert filter to proper format and optimize specific partitions
222
+ filter_expr = " AND ".join([
223
+ f"{k} = '{v}'" if isinstance(v, str) else f"{k} = {v}"
224
+ for k, v in partition_filter.items()
225
+ ])
226
+ table.optimize.compact(partition_filters=filter_expr)
227
+ else:
228
+ table.optimize.compact()
229
+
230
+ except Exception as e:
231
+ logging.error(f"Error optimizing table: {e}")
232
+
233
+ def vacuum_table(self, retention_hours: int = 168):
234
+ """
235
+ Vacuum Delta table (remove old files).
236
+
237
+ Args:
238
+ retention_hours: Hours to retain (default 168 = 7 days)
239
+ """
240
+ table = self.get_table()
241
+ if not table:
242
+ return
243
+
244
+ try:
245
+ table.vacuum(retention_hours)
246
+ except Exception as e:
247
+ logging.error(f"Error vacuuming table: {e}")
248
+
249
+ def get_partition_info(self) -> Dict[str, Any]:
250
+ """
251
+ Get information about table partitioning.
252
+
253
+ Returns:
254
+ Dict with partition information
255
+ """
256
+ table = self.get_table()
257
+ if not table:
258
+ return {}
259
+
260
+ try:
261
+ metadata = table.metadata()
262
+ return {
263
+ "partition_columns": metadata.partition_columns if metadata else [],
264
+ "total_files": len(table.files()),
265
+ "table_version": table.version(),
266
+ "schema": table.schema().to_pyarrow() if hasattr(table.schema(), 'to_pyarrow') else str(table.schema())
267
+ }
268
+ except Exception as e:
269
+ logging.error(f"Error getting partition info: {e}")
270
+ return {}
@@ -0,0 +1,31 @@
1
+ from . import BaseDataset
2
+ from ..workspaces import Workspace
3
+
4
+
5
+ class FieldsDataset(BaseDataset):
6
+ """
7
+ A class to handle the Fields dataset.
8
+
9
+ This class inherits from BaseDataset and is used to manage the Fields dataset.
10
+ It provides methods to download, process, and manage the dataset.
11
+ """
12
+
13
+ def __init__(self, workspace:Workspace = None,
14
+ base_folder: str = "./datasets",
15
+ storage_options: dict = None,
16
+ raw_dataset_batch_size: int = None):
17
+ """
18
+ Initialize a Flow Dataset object.
19
+
20
+ Args:
21
+ workspace: The workspace object associated with the dataset (RapidPro workspace).
22
+ base_folder: The base folder where the dataset will be stored.
23
+ storage_options: Additional options for the storage backend (e.g., fsspec compatible).
24
+ raw_dataset_batch_size: The batch size for downloading the raw dataset.
25
+ """
26
+
27
+ super().__init__(name="fields",
28
+ workspace=workspace,
29
+ base_folder=base_folder,
30
+ storage_options=storage_options,
31
+ raw_dataset_batch_size=raw_dataset_batch_size)
@@ -0,0 +1,31 @@
1
+ from . import BaseDataset
2
+ from ..workspaces import Workspace
3
+
4
+
5
+ class FlowsDataset(BaseDataset):
6
+ """
7
+ A class to handle the Flows dataset.
8
+
9
+ This class inherits from BaseDataset and is used to manage the Flows dataset.
10
+ It provides methods to download, process, and manage the dataset.
11
+ """
12
+
13
+ def __init__(self, workspace:Workspace = None,
14
+ base_folder: str = "./datasets",
15
+ storage_options: dict = None,
16
+ raw_dataset_batch_size: int = None):
17
+ """
18
+ Initialize a ContactsDataset object.
19
+
20
+ Args:
21
+ workspace: The workspace object associated with the dataset (RapidPro workspace).
22
+ base_folder: The base folder where the dataset will be stored.
23
+ storage_options: Additional options for the storage backend (e.g., fsspec compatible).
24
+ raw_dataset_batch_size: The batch size for downloading the raw dataset.
25
+ """
26
+
27
+ super().__init__(name="flows",
28
+ workspace=workspace,
29
+ base_folder=base_folder,
30
+ storage_options=storage_options,
31
+ raw_dataset_batch_size=raw_dataset_batch_size)
@@ -0,0 +1,31 @@
1
+ from . import BaseDataset
2
+ from ..workspaces import Workspace
3
+
4
+
5
+ class GroupsDataset(BaseDataset):
6
+ """
7
+ A class to handle the Groups dataset.
8
+
9
+ This class inherits from BaseDataset and is used to manage the Groups dataset.
10
+ It provides methods to download, process, and manage the dataset.
11
+ """
12
+
13
+ def __init__(self,
14
+ workspace:Workspace = None,
15
+ base_folder: str = "./datasets",
16
+ storage_options: dict = None,
17
+ raw_dataset_batch_size: int = None):
18
+ """
19
+ Initialize a ContactsDataset object.
20
+
21
+ Args:
22
+ workspace: The workspace object associated with the dataset (RapidPro workspace).
23
+ base_folder: The base folder where the dataset will be stored.
24
+ fs: The file system object to use for file operations (fsspec compatible). Uses local file system by default.
25
+ """
26
+
27
+ super().__init__(name="groups",
28
+ workspace=workspace,
29
+ base_folder=base_folder,
30
+ storage_options=storage_options,
31
+ raw_dataset_batch_size=raw_dataset_batch_size)
@@ -0,0 +1,31 @@
1
+ from . import BaseDataset
2
+ from ..workspaces import Workspace
3
+
4
+
5
+ class MessagesDataset(BaseDataset):
6
+ """
7
+ A class to handle the Messages dataset.
8
+
9
+ This class inherits from BaseDataset and is used to manage the Messages dataset.
10
+ It provides methods to download, process, and manage the dataset.
11
+ """
12
+
13
+ def __init__(self, workspace:Workspace = None,
14
+ base_folder: str = "./datasets",
15
+ storage_options: dict = None,
16
+ raw_dataset_batch_size: int = None):
17
+ """
18
+ Initialize a MessagesDataset object.
19
+
20
+ Args:
21
+ workspace: The workspace object associated with the dataset (RapidPro workspace).
22
+ base_folder: The base folder where the dataset will be stored.
23
+ Everything will be stored within the <base_folder>/messages folder.
24
+ fs: The file system object to use for file operations (fsspec compatible). Uses local file system by default.
25
+ """
26
+
27
+ super().__init__(name="messages",
28
+ workspace=workspace,
29
+ base_folder=base_folder,
30
+ storage_options=storage_options,
31
+ raw_dataset_batch_size=raw_dataset_batch_size)