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,551 @@
1
+ import logging
2
+ import re
3
+ from datetime import datetime
4
+ import pandas as pd
5
+ import time
6
+
7
+ from temba_client.v2 import TembaClient
8
+ from . import RawDataset
9
+ from ..workspaces import Workspace
10
+ from ..time_utils import yesterday_end_of_day, get_date_range
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+ class BaseDataset:
19
+ """
20
+ Convention structure for the raw dataset:
21
+ Raw dataset assumes this "<name>/raw/<workspace_code>/<yyyy>/<yyyy-mm-dd>" as the partitioning scheme.
22
+ where name is something like contacts, flows, runs, messages, etc.
23
+ The workspace_code is the code of the workspace
24
+ The yyyy-mm-dd is the date range of the data.
25
+
26
+ When all the data is downloaded the first date is set to 2014-01-01 and the last date is set to the end of
27
+ the last period (end of day 14 or end of last day of the month)
28
+
29
+ """
30
+
31
+ DEFAULT_BATCH_SIZE = 100000 # 100k
32
+ """
33
+ Default batch size for saving a file in the raw dataset. It will create files with this number of records.
34
+ """
35
+
36
+ INIT_DATE = datetime(2014, 1, 1, 0, 0, 0) # January 1st, 2014
37
+ """
38
+ First date of the dataset. RapidPro started around 2014, so any workspace should not have data before this date.
39
+ """
40
+
41
+ def __init__(self, name,
42
+ workspace:Workspace,
43
+ base_folder:str="./datasets",
44
+ storage_options:dict=None,
45
+ raw_dataset_batch_size:int=None):
46
+ """
47
+ Initialize a BaseDataset object.
48
+
49
+ Args:
50
+ name: The name of the dataset (e.g. contacts, flows, runs, messages, etc.)
51
+ workspace: The workspace object associated with the dataset
52
+ base_folder: The base folder where the dataset will be stored
53
+ raw_dataset_batch_size: The batch size for saving a raw dataset (defaults to 100k)
54
+
55
+ """
56
+
57
+ self.name = name
58
+ self.workspace = workspace
59
+ self.base_folder = base_folder
60
+ self.dataset_base_folder = f"{self.base_folder}/{self.name}"
61
+ self.raw_dataset_batch_size = raw_dataset_batch_size or BaseDataset.DEFAULT_BATCH_SIZE
62
+ self.storage_options = storage_options
63
+ self.raw_dataset = RawDataset("raw", base_folder=self.dataset_base_folder, storage_options=self.storage_options)
64
+ self.rapidpro_client = TembaClient(self.workspace.host, self.workspace.token)
65
+ # this is the method that will be used to download the data by default.
66
+ self.rapidpro_client_method = "get_" + name
67
+
68
+ def __str__(self):
69
+ return f"{self.__class__.__name__}({self.name}, {self.workspace.code}, {self.base_folder}, {self.raw_dataset_batch_size}, {self.rapidpro_client_method})"
70
+
71
+ def __repr__(self):
72
+ return self.__str__()
73
+
74
+
75
+ def _is_valid_ws_partition(self, ws_partition:list):
76
+ """
77
+ Validates that the partition has the format None, [], [<yyyy>], or [<yyyy>, <yyyy-mm-dd>]
78
+ returns True if the partition is valid, False otherwise.
79
+ Args:
80
+ ws_partition: The partition to validate.
81
+ Returns:
82
+ True if the partition is valid, False otherwise.
83
+ Example:
84
+ >>> ds = BaseDataset(name="contacts", workspace=None)
85
+ >>> ds._validate_is_ws_partition(["2023"])
86
+ True
87
+ >>> ds._validate_is_ws_partition(["2023", "2023-10-01"])
88
+ True
89
+ >>> ds._validate_is_ws_partition(["2023", "2023-10-01", "2023-10-02"])
90
+ False
91
+ """
92
+ if ws_partition is None or len(ws_partition) == 0:
93
+ return True
94
+ if len(ws_partition) > 2:
95
+ return False
96
+ # If we reach here, at least 1 element
97
+ # check if the first element is a year using a regexp
98
+ if re.match(r"^\d{4}$", ws_partition[0]):
99
+ # TODO check if the year of the second element is the same as the first element
100
+ # TODO check if the second element is a valid date
101
+ if len(ws_partition) == 2:
102
+ # check if the first element is a year and the second element is a date using a regexp
103
+ return bool(re.match(r"^\d{4}-\d{2}-\d{2}$", ws_partition[1]))
104
+ else:
105
+ # check if the first element is a year
106
+ return True
107
+ else:
108
+ return False
109
+
110
+
111
+ def _add_ws_to_partition(self, ws_partition:list=None) -> list:
112
+ """
113
+ Add the workspace code to the partition. By convention ws_partition is a partition within the workspace
114
+ for example ["2023","2023-10-01"] and this function will add the workspace code
115
+ as the first element of the partition.
116
+ This function assumes that the dataset is partitioned following this schema:
117
+ workspace.code / yyyy / yyyy-mm-dd
118
+ where the workspace code is the first element of the partition.
119
+ ws_needs to be set.
120
+
121
+ Args:
122
+ partition: has to be either None, [], [yyyy] or [yyyy, yyyy-mm-dd].
123
+ Returns:
124
+ The partition list.
125
+ Example:
126
+ >>> ds = BaseDataset(name="contacts", workspace=Workspace(code="code"))
127
+ >>> ds._add_ws_to_partition(partition=["2023", "2023-10-01"])
128
+ ['code', '2023', '2023-10-01']
129
+ >>> ds._compute_partition(partition=None)
130
+ ['workspace_code']
131
+ >>> ds._compute_partition(partition=["whatever])
132
+ ValueError: partition is not valid. It should be None, [], [yyyy], or [yyyy, yyyy-mm-dd]
133
+ """
134
+ if not self._is_valid_ws_partition(ws_partition):
135
+ raise ValueError("partition is not valid. It should be None, [], [yyyy], or [yyyy, yyyy-mm-dd]")
136
+ if ws_partition is None or len(ws_partition) == 0:
137
+ return [self.workspace.code]
138
+ else:
139
+ return [self.workspace.code] + ws_partition
140
+
141
+
142
+ def raw_dataset_files(self, ws_partition:list=None, as_dataframe=False)-> [str, pd.DataFrame]:
143
+ """
144
+ Get the list of files in the raw dataset for the specified partition.
145
+ If the partition is None, it will return all the files in the dataset.
146
+ If as_dataframe is True, it will return the files as a dataframe.
147
+ If as_dataframe is False, it will return the files as a list.
148
+ Args:
149
+ ws_partition: The partition. If workspace is set, then you do not need to add the workspace files e.g. ["2023","2023-10-01"]
150
+ as_dataframe: Whether to return the files as a dataframe or a list
151
+ Returns:
152
+ The files in the partition
153
+ Raises:
154
+ FileNotFoundError: If the partition is not found or if it is empty.
155
+ """
156
+ # check if the partition is valid
157
+ partition = self._add_ws_to_partition(ws_partition)
158
+ return self.raw_dataset.files(partition=partition, as_dataframe=as_dataframe)
159
+
160
+ def get_last_downloaded_date(self, ws_partition:list=None) -> datetime:
161
+ """
162
+ Get the last date downloaded for the specified partition in the current workspace.
163
+ self.workspace needs to be set.
164
+ If the partition is None, it will return the last date downloaded for all partitions.
165
+ Args:
166
+ ws_partition: The partition within the workspace to filter the files e.g. ["2023","2023-10-01"]
167
+ Returns:
168
+ datetime with last date downloaded for the partition.
169
+ None if there are no files in the partition
170
+ """
171
+ try:
172
+ files_df = self.raw_dataset_files( ws_partition=ws_partition, as_dataframe=True)
173
+ except FileNotFoundError:
174
+ logging.warning("get_last_downloaded_date: Folder not found for partition %s in workspace %s", ws_partition, self.workspace.code)
175
+ return None
176
+ if files_df.empty:
177
+ logging.warning("get_last_downloaded_date: No files found for partition %s in workspace %s", ws_partition, self.workspace.code)
178
+ return None
179
+ # In the files_df the following columns exist
180
+ # level1 = workspace
181
+ # level2 = year [2023,2024, etc]
182
+ # level3 = yyyy-mm-dd string [2023-10-01, 2023-10-02, etc]
183
+ # We have to get the most recent date in the level3
184
+ files_df = files_df[files_df["level3"].notna()]
185
+ files_df["level3"] = pd.to_datetime(files_df["level3"], format="%Y-%m-%d")
186
+ files_df = files_df.sort_values(by=["level3"], ascending=False)
187
+ # get the last date downloaded
188
+ last_date = files_df.iloc[0]["level3"]
189
+ last_date = last_date.replace(hour=0, minute=0, second=0, microsecond=0)
190
+ # get the last date downloaded in the format yyyy-mm-dd
191
+ return last_date
192
+
193
+
194
+
195
+ def download_dated_dataset(self, clear:bool=False, continue_if_exists:bool=True, after_date:datetime=None, before_date:datetime=None, frequency:str="D") -> bool:
196
+ """
197
+ downloads a dataset from RapidPro that is date bounded.
198
+ If after_date is not set, it will download since the first date in the dataset.
199
+ If before_date is not set, it will download until the previous completed period. For example, if it is day, then it will download until yesterday end of day. If it is week, it will download until the last
200
+ completed week, if it is month end, it will download until the last completed month, and if it is year, it will download until the last completed year.
201
+
202
+ Note: if you want to force the download till now, just add one day, month or year to the before_date. For example, if you want to download until today, set before_date to yesterday_end_of_day() + 1 day.
203
+
204
+ If continue_if_exists is True, it will continue downloading from the last date downloaded (including that date). For example, if the after_date is set to 2023-01-01 and the last date downloaded is 2023-10-01
205
+ it will download from 2023-10-01 to the before_date (if set).
206
+
207
+ If continue_if_exists is False, it will reset each partition in the period and re-download.
208
+ The partition will be stored within [workspace_code, yyyy, yyyy-mm-dd] where the yyyy-mm-dd is the starting date of the range range.
209
+
210
+
211
+ Args:
212
+ clear: If True, it will clear each partition before downloading. If False, it will continue adding batches to the partition.
213
+ continue_if_exists: If False, it will reset any existing partition in the date range otherwise it will continue downloading the last partition if it is not marked as completed (is_last_batch=False in 00_metadata.json)
214
+ after_date: The date to start the download from. If not set will download since ever (self.INIT_DATE)
215
+ before_date: The last date to download. If not set, it will use yesterday.
216
+ frequency: The frequency of the data to download. It can be either day ("D"), week ("W"), month end ("ME") or year ("Y"). Default is "D" (daily).
217
+
218
+ Returns:
219
+ True if was able to finish. Exception if there was some issue.
220
+ Raises:
221
+ ValueError: If the after_date is greater than the before_date or if the partition is not valid.
222
+ ValueError: If the frequency is not one of the following: D, W, ME, Y
223
+ Example:
224
+
225
+ >>> ds = BaseDataset(name="contacts", workspace=Workspace(code="code"))
226
+ >>> ds.download_dated_dataset(clear=True, after_date="2023-10-01", before_date="2023-10-31", frequency="D")
227
+ It will download the "contacts" dataset for the workspace "code" from 2023-10-01 to 2023-10-31 in a daily frequency.
228
+ If a partition exists, it will be cleared before downloading.
229
+
230
+ >>> ds.download_dated_dataset(continue_if_exists=True, after_date="2023-10-01", before_date="2023-10-15", frequency="M")
231
+ It will download the "contacts" dataset for the workspace "code" from 2023-10-01 to 2023-10-15 in a monthly frequency.
232
+ Because continue_if_exists is True, it will continue downloading from the last partition fully downloaded.
233
+ Because the frequency is set to ME (month end), it will download the data for the month of September 2023.
234
+ """
235
+ logging.info("%s - %s Called download_dated_dataset (clear: %s, continue_if_exists=%s, after_date %s, before_date %s, frequency %s)", self.name, self.workspace.code, clear, continue_if_exists, after_date, before_date, frequency)
236
+ # check that after_date is not greater than before_date
237
+ if after_date and before_date and after_date > before_date:
238
+ raise ValueError("after_date cannot be greater than before_date")
239
+
240
+ if frequency not in ["D", "W", "ME", "Y"]:
241
+ raise ValueError("frequency must be one of the following: D, W, ME, Y")
242
+
243
+ if continue_if_exists and after_date is None:
244
+ # get the last date downloaded
245
+ last_date = self.get_last_downloaded_date()
246
+ if last_date is None:
247
+ logging.warning("%s: No previous data downloaded. Set %s as after_date", self.workspace.code, after_date)
248
+ else:
249
+ logging.info("%s: Setting after_date to the last date downloaded: %s", self.workspace.code, last_date)
250
+ after_date = last_date
251
+ # check that the last date is not greater than the before_date
252
+ if before_date and last_date > before_date:
253
+ raise ValueError(f"last_date downloaded ({last_date}) cannot be greater than before_date ({before_date})")
254
+ # set the after_date to the last date downloaded
255
+
256
+ # Set the default value for the after_date and before_date
257
+ after_date = after_date or self.INIT_DATE
258
+
259
+ yesterday = yesterday_end_of_day()
260
+ end_loop = before_date or yesterday # this date is when the end of the date range will be set to.
261
+
262
+ last_date_downloaded = None
263
+ logging.info("%s download_dated_dataset: Starting download loop (continue_if_exists: %s, after_date: %s, before_date: %s, yesterday=%s, end_loop=%s, frequency: %s)", self.name, continue_if_exists, after_date, before_date, yesterday, end_loop, frequency)
264
+
265
+ #Log all the periods that will be computed
266
+ logging.info("%s -> Periods to be downloaded:", self.workspace.code)
267
+ for i_date in pd.date_range(start=after_date, end=end_loop, freq=frequency):
268
+ # get the partition name
269
+ i_after_date, i_before_date = get_date_range(a_date=i_date, frequency=frequency)
270
+ ws_partition = [i_after_date.strftime("%Y"), i_after_date.strftime("%Y-%m-%d")]
271
+
272
+ i_after_date_str = i_after_date.strftime("%Y-%m-%dT%H:%M:%SZ")
273
+ i_before_date_str = i_before_date.strftime("%Y-%m-%dT%H:%M:%SZ")
274
+ logging.info(" Period: %s -> %s (%s): %s [freq: %s] after: %s, before: %s]", self.name, self.workspace.code, self.workspace.host, ws_partition, frequency, i_after_date_str, i_before_date_str)
275
+
276
+
277
+ for i_date in pd.date_range(start=after_date, end=end_loop, freq=frequency):
278
+ # get the partition name
279
+ i_after_date, i_before_date = get_date_range(a_date=i_date, frequency=frequency)
280
+ ws_partition = [i_after_date.strftime("%Y"), i_after_date.strftime("%Y-%m-%d")]
281
+
282
+ i_after_date_str = i_after_date.strftime("%Y-%m-%dT%H:%M:%SZ")
283
+ i_before_date_str = i_before_date.strftime("%Y-%m-%dT%H:%M:%SZ")
284
+
285
+ logging.info("Downloading %s -> %s (%s): %s [freq: %s] after: %s, before: %s]", self.name, self.workspace.code, self.workspace.host, ws_partition, frequency, i_after_date_str, i_before_date_str)
286
+ # set beggining and end of the day
287
+
288
+ self._downloader(ws_partition=ws_partition, clear=clear, continue_if_exists=continue_if_exists,
289
+ rapidpro_client_kwargs={"after": i_after_date_str,
290
+ "before": i_before_date_str})
291
+ last_date_downloaded = i_after_date
292
+ logging.info("%s: %s [%s] Download_dated_dataset: Finished download loop. Last period downloaded = %s [freq: %s ]", self.name, self.workspace.code, self.workspace.vendor, last_date_downloaded, frequency)
293
+ return True
294
+
295
+
296
+
297
+ def download_full_dataset(self, clear:bool=False, continue_if_exists:bool=False) -> bool:
298
+ """
299
+ Download the full dataset from RapidPro.
300
+ It will download the full dataset in [workspace_code, yyyy, yyyy-mm-dd] where the yyyy-mm-dd date is today's day. The download will include all the data.
301
+ If the partition already exist and clear is False, it will return True if the partition is already completed.
302
+ If the partition is not completed, and continue_if_exists is True, it will continue adding batches to the partition.
303
+ If the partition is not completed and continue_if_exists is False, it will raise a ValueError.
304
+
305
+ Args:
306
+ partition: The partition to store the download.
307
+ clear: If True, it will clear the partition before downloading.
308
+ continue_if_exists: If True, it will continue adding batches to the partition if it already exists. Ignored if clear is True.
309
+ Returns:
310
+ True if was able to finish. Exception if there was some issue.
311
+ Raises:
312
+ ValueError: If the partition exists, is not completed, and both flags, clear and continue_if_exists, are False (so it cannot do anything with a partition that is not empty).
313
+ """
314
+ today = datetime.now().strftime("%Y-%m-%d")
315
+ todays_year = datetime.now().strftime("%Y")
316
+ # set the default value for the partition
317
+
318
+ ws_partition = [todays_year, today]
319
+
320
+ return self._downloader(ws_partition=ws_partition, clear=clear, continue_if_exists=continue_if_exists,)
321
+
322
+
323
+ def _downloader(self, ws_partition:list = None, clear:bool = False,
324
+ continue_if_exists:bool = False,
325
+ rapidpro_client_method:str = None,
326
+ rapidpro_client_args:list=None,
327
+ rapidpro_client_kwargs:dict=None) -> bool:
328
+ """
329
+ Download the dataset from RapidPro.
330
+ partition must include self.workspace.code as the first element.
331
+ Args:
332
+ ws_partition: The partition within the workspace to store the downloaded data e.g. ["2023","2023-10-01"]
333
+ clear: If True, it will clear the partition before downloading.
334
+ continue_if_exists: If True, it will continue adding batches to the partition if it already exists. Ignored if clear is True.
335
+ rapidpro_client_method: The method to use to download the data from RapidPro. If not set it will use the default method (self.rapidpro_client_method, which is initialized to get_<self.name>. f.i. if name is contacts, it will use get_contacts)
336
+ rapidpro_client_args: The arguments to pass to the method.
337
+ rapidpro_client_kwargs: The keyword arguments to pass to the method.
338
+ Returns:
339
+ True if it was able to complete the download, or if the partition is already completed.
340
+ Raises:
341
+ ValueError: If the partition exists and is not completed and both flags, clear and continue_if_exists, are False (so it cannot do anything with a partition that is not empty).
342
+ Example:
343
+ >>> ds = BaseDataset(name="contacts", workspace=Workspace(code="code"))
344
+ >>> ds._downloader(ws_partition=["2023", "2023-10-01"], clear=True)
345
+ >>> ds._downloader(ws_partition=["2023", "2023-10-01"], clear=False, rapidpro_client_kwargs={"after_date": "2023-10-01T00:00:00Z", "before_date": "2023-10-01T23:59:59Z"})
346
+ """
347
+ rapidpro_client_method = rapidpro_client_method or self.rapidpro_client_method
348
+ rapidpro_client_args = rapidpro_client_args or []
349
+ rapidpro_client_kwargs = rapidpro_client_kwargs or {}
350
+
351
+ partition = self._add_ws_to_partition(ws_partition=ws_partition)
352
+
353
+ logging.info("Downloading %s -> %s [%s] with %s method (clear %s, continue_if_exists %s).", self.name, partition ,self.workspace.code, rapidpro_client_method, clear,continue_if_exists)
354
+ resume_cursor = None
355
+ # Check if the partition is empty
356
+ if not self.raw_dataset.is_empty(partition=partition):
357
+ # If not empty and clear is True => clear the partition
358
+ if clear:
359
+ logging.info("%s: %s [%s] Clearing partition %s before download.", self.name, self.workspace.code, self.workspace.vendor, partition)
360
+ self.raw_dataset.clear(partition=partition)
361
+ else:
362
+ logging.info("%s: %s [%s] Partition %s already exists and not empty", self.name, self.workspace.code, self.workspace.vendor, partition)
363
+ # Check if the partition is marked as completed
364
+ if self.raw_dataset.has_final_batch(partition=partition):
365
+ logging.info("%s: %s [%s] Partition %s is already marked as completed. Returning", self.name, self.workspace.code, self.workspace.vendor, partition)
366
+ return True
367
+ else:
368
+ # Here clear is False, and continue_if_exists is False => raise an error because
369
+ # partition is not marked as completed and we are not allowed to continue.
370
+ if not continue_if_exists:
371
+ logging.error("%s: %s [%s] Partition %s already exists and is not empty. Use clear=True to clear the partition or continue_if_exists=True to continue adding batches.", self.name, self.workspace.code, self.workspace.vendor, partition)
372
+ raise ValueError(f"Partition {partition} in {self.workspace.code} already exists and is not empty. Set clear=True to clear the partition or continue_if_exists=True to continue adding batches.")
373
+ # We are allowed to continue downloading, so we will get the last cursor from the metadata
374
+ logging.info("Partition %s is not marked as completed. Continuing download.", self.workspace.code)
375
+ # get the cursor metadata from the workspace
376
+ metadata = self.raw_dataset.get_metadata(partition=partition)
377
+ # add cursor to the metadata
378
+ if metadata and "resume_cursor" in metadata:
379
+ resume_cursor = metadata["resume_cursor"]
380
+ logging.info("Using last cursor %s from metadata for partition %s", resume_cursor, partition)
381
+ else:
382
+ logging.info("No cursor found in metadata for partition %s. Starting from the beginning. Clearing partition", partition)
383
+ self.raw_dataset.clear(partition=partition)
384
+ resume_cursor = None
385
+ # get the rapidpro client method
386
+ method = getattr(self.rapidpro_client, rapidpro_client_method)
387
+ # Check if the method is callable
388
+ if not callable(method):
389
+ raise ValueError(f"{method} is not a callable method in {self.rapidpro_client.__class__.__name__}")
390
+
391
+ # Counters
392
+ saved_items = 0
393
+ records_downloaded = 0
394
+ download_batch_number = 0
395
+ save_batch_number = 0
396
+ raw_data = []
397
+ logging.info("%s -> %s (%s): %s %s args: %s kwargs: %s, resume_cursor %s", self.name, self.workspace.code, self.workspace.vendor, ws_partition, rapidpro_client_method, rapidpro_client_args, rapidpro_client_kwargs, resume_cursor)
398
+ iterator = method(*rapidpro_client_args, **rapidpro_client_kwargs).iterfetches(retry_on_rate_exceed=True, resume_cursor=resume_cursor)
399
+ for batch in iterator:
400
+ logging.info("%s -> %s (%s): %s %s Downloaded %d items. Partition total %d. In memory %d records.", self.name, self.workspace.code, self.workspace.vendor, ws_partition, rapidpro_client_method, len(batch), records_downloaded, len(raw_data))
401
+ records_downloaded += len(batch)
402
+ download_batch_number += 1
403
+ for item in batch:
404
+ item_dict = item.serialize()
405
+ raw_data.append(item_dict)
406
+ if len(raw_data) >= self.raw_dataset_batch_size:
407
+ saved_items += len(raw_data)
408
+ logging.info("%s: Saving batch %d in partition: %s. Partition total: %d items", self.workspace.code, save_batch_number, partition, saved_items)
409
+ self.raw_dataset.append_save(raw_data, partition=partition, resume_cursor=iterator.get_cursor(), is_final_batch=False)
410
+ raw_data = []
411
+ save_batch_number += 1
412
+ # Save any remaining data
413
+ if raw_data:
414
+ saved_items += len(raw_data)
415
+ logging.info("%s (%s): Saving **FINAL BATCH** in partition %s. Batch # %s. Total in partition: %d. Last batch: %d items", self.workspace.code, self.workspace.vendor, partition, save_batch_number, saved_items, len(raw_data))
416
+ self.raw_dataset.append_save(raw_data, partition=partition, is_final_batch=True)
417
+ # Successfull download
418
+ return True
419
+
420
+ def iter_dated_batch_raw_files(self, after_date: datetime=None, before_date: datetime=None, with_file_info:bool=False) -> [list, dict]:
421
+ """
422
+ Iterate over the existing dataset file batches.
423
+ Args:
424
+ after_date: The date to start the iteration from. If not set will iterate from the first date in the dataset.
425
+ before_date: The date to end the iteration. If not set will iterate until the last date in the dataset.
426
+ with_file_info: If True, it will yield a tuple with the file info and the raw data. If False, it will yield only the raw data.
427
+ Yields:
428
+ A tuple with the file info (dict) and the raw data (list of dict where each dict is a record from RproAPI) if with_file_info is True, otherwise
429
+ it will yield only the raw data.
430
+ Returns:
431
+ A generator that yields batches of data (list of dicts).
432
+ Raises:
433
+ FileNotFoundError: If no data has been downloaded yet or if the workspace is not found.
434
+ Example:
435
+ >>> ds = BaseDataset(name="contacts", workspace=Workspace(code="code"))
436
+ >>> for file_info, raw_data in ds.iter_dated_batch_raw_files(after_date=datetime(2023, 10, 1), before_date=datetime(2023, 10, 31), with_file_info=True):
437
+ >>> print(file_info) # file info will be a dict of RawDataset file info
438
+ >>> print(raw_data) # raw_data will be a list of dicts with the raw data from RapidPro API
439
+ >>> # process the raw_data here
440
+ >>> # e.g. process_contacts_pl(raw_data, metadata=metadata)
441
+ >>> # also getting the index of the file (explore the whole dataset)
442
+ >>> for index, (file_info, raw_data) in enumerate(ds.iter_dated_batch_raw_files(with_file_info=True)):
443
+ >>> print(f"Processing file {index+1}: {file_info['path']}")
444
+ """
445
+ if after_date is None:
446
+ after_date = self.INIT_DATE
447
+ if before_date is None:
448
+ before_date = datetime.now()
449
+
450
+ after_date_str = after_date.strftime('%Y-%m-%d')
451
+ before_date_str = before_date.strftime('%Y-%m-%d')
452
+
453
+
454
+ logging.info("[%s] Processing workspace %s from %s until %s...",
455
+ self.workspace.code, self.workspace.code, after_date_str, before_date_str)
456
+
457
+ # get the list of files
458
+ files_df = self.raw_dataset.files(partition=[self.workspace.code], as_dataframe=True)
459
+
460
+ # print (files_df.head())
461
+ if files_df.empty:
462
+ logging.warning("[%s] No files found for workspace %s",
463
+ self.workspace.code, self.workspace.code)
464
+ return
465
+ num_files = len(files_df)
466
+ logging.info("[%s] Found %s files for workspace %s",
467
+ self.workspace.code, num_files, self.workspace.code)
468
+
469
+ for index, row in files_df.iterrows():
470
+ # the following cols are expected in the row
471
+ # level1 workspace.code
472
+ # level2 yyyy
473
+ # level3 yyyy-mm-dd
474
+ # path is the path to the file
475
+ # filesize
476
+
477
+ file_path = row['path']
478
+ if row['level3'] < after_date_str:
479
+ logging.info("%s - %s: [%s/%s] Skipping file %s as it is earlier than the after date (first date) %s",
480
+ self.name, self.workspace.code, index, num_files, file_path, after_date_str)
481
+ continue
482
+ if row['level3'] > before_date_str:
483
+ logging.info("%s - %s: [%s/%s] Skipping file %s as it is newer than the before date (last date) %s",
484
+ self.name, self.workspace.code, index, num_files, file_path, before_date_str)
485
+ continue
486
+
487
+ logging.info("%s - %s: [%s/%s] Iterating file %s (%s bytes)...",
488
+ self.name, self.workspace.code, index, num_files, file_path, row['filesize'])
489
+ raw_data = self.raw_dataset.get_file_data(file_path)
490
+ if with_file_info:
491
+ yield row, raw_data
492
+ else:
493
+ yield raw_data
494
+
495
+ def iter_last_date_raw_files(self, with_file_info:bool=False) -> [list, dict]:
496
+ """
497
+ Iterate over the last partition date files in the dataset [workspace_code, yyyy, yyyy-mm-dd].
498
+ This will iterate over the last partition downloaded.
499
+ This is useful for processing datasets that are downloaded with download_full_dataset.
500
+ If you download -> process one period at a time, you can use this method to iterate over the last partition date files.
501
+
502
+ Note: This method assumes that the last partition is already downloaded and marked as completed.
503
+
504
+ Args:
505
+ with_file_info: If True, it will yield a tuple with the file info and the raw data. If False, it will yield only the raw data.
506
+
507
+ Yields:
508
+ A tuple with the file info and the batch with the raw data contained in the file if with_file_info is True, otherwise
509
+ it will yield only the batches raw data.
510
+ Returns:
511
+ A generator that yields batches of data.
512
+ Raises:
513
+ FileNotFoundError: If no data has been downloaded yet or if the last partition is not found.
514
+ """
515
+ last_date = self.get_last_downloaded_date()
516
+ logging.info("%s - %s : Last date downloaded for %s is %s", self.name, self.workspace.code, self.workspace.code, last_date)
517
+ if last_date is None:
518
+ logging.error("%s - %s: No data downloaded yet. Cannot iterate over last date files.", self.name, self.workspace.code)
519
+ raise FileNotFoundError(f"{self.name} - {self.workspace.code}: No data downloaded yet. Cannot iterate over last date files.")
520
+ if not self.raw_dataset.has_final_batch(partition=[self.workspace.code, last_date.strftime("%Y"), last_date.strftime("%Y-%m-%d")]):
521
+ logging.warning("%s - %s: Last partition %s is not yet completed. However, will be iterating over last date files.", self.name, self.workspace, last_date.strftime("%Y-%m-%d"))
522
+ # get files in partition
523
+ files_df = self.raw_dataset.files(partition=[self.workspace.code, last_date.strftime("%Y"), last_date.strftime("%Y-%m-%d")], as_dataframe=True)
524
+ if files_df.empty:
525
+ logging.warning("%s - %s: No files found for last partition %s", self.name, self.workspace.code, last_date.strftime("%Y-%m-%d"))
526
+ return
527
+ # iterate over the files in the partition
528
+ for index, row in files_df.iterrows():
529
+ logging.info("%s - %s: [%s/%s] Iterating file %s (%s bytes)...",
530
+ self.name, self.workspace.code, index+1, len(files_df), row['path'], row['filesize'])
531
+ if with_file_info:
532
+ yield row, self.raw_dataset.get_file_data(row['path'])
533
+ else:
534
+ yield self.raw_dataset.get_file_data(row['path'])
535
+
536
+
537
+ def clear_partition(self, ws_partition:list=None):
538
+ """
539
+ Clear the partition in the raw dataset.
540
+ Args:
541
+ ws_partition: The workspace partition to clear. If None, it will clear the whole dataset.
542
+ Returns:
543
+ True if the partition was cleared, False if it was already empty.
544
+ Raises:
545
+ ValueError: If the partition is not valid.
546
+ Example:
547
+ >>> ds = BaseDataset(name="contacts", workspace=Workspace(code="code"))
548
+ >>> ds.clear_partition(ws_partition=["2023", "2023-10-01"])
549
+ """
550
+ partition = self._add_ws_to_partition(ws_partition=ws_partition)
551
+ return self.raw_dataset.clear(partition=partition)
@@ -0,0 +1,31 @@
1
+ from . import BaseDataset
2
+ from ..workspaces import Workspace
3
+
4
+
5
+ class ContactsDataset(BaseDataset):
6
+ """
7
+ A class to handle the Contacts dataset.
8
+
9
+ This class inherits from BaseDataset and is used to manage the Contacts 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="contacts",
28
+ workspace=workspace,
29
+ base_folder=base_folder,
30
+ storage_options=storage_options,
31
+ raw_dataset_batch_size=raw_dataset_batch_size)