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,649 @@
1
+ import json
2
+ import gzip
3
+ from fsspec.implementations.local import LocalFileSystem
4
+ from fsspec.core import url_to_fs
5
+ import pandas as pd
6
+ import logging
7
+
8
+
9
+ """
10
+ A class for managing raw datasets. A raw dataset is considered a dataset that is not processed and resides in its json format.
11
+
12
+ A raw dataset is composed by a name and optionally a list of partitions that end up setting the path where the file with the data is saved.
13
+
14
+ A dataset is a folder. And a partition is a subfolder. For example if the dataset name is "contacts" and the partitions are ["africa", "kenya"].
15
+ The dataset will be saved in the following path `contacts/africa/kenya`. Other partitions can be ["africa", "nigeria"] or ["europe", "spain"] which would result in
16
+ "contacts/africa/nigeria" and "contacts/europe/spain" respectively.
17
+
18
+ The data in a dataset can be stored in batches. Following the example above,
19
+ if the `RawDataset.append_save` is called for that dataset and partition with a batch of data, then the data
20
+ will be saved in the following path: contacts/region1/country1/batch-0.json.
21
+
22
+ Future calls to RawDataset.append will increment the batch number. For example, if the next call to append_save is called with the same dataset and partition,
23
+ then the data will be saved in the following path: contacts/region1/country1/batch-1.json.
24
+
25
+ The data can be saved in compressed format or not. If the data is saved in compressed
26
+ format, then the file will be saved as: contacts/region1/country1/batch-0.json.gzip
27
+
28
+ Also, very basic metadata is saved in a file called 00_metadata.json. This file contains the last part number
29
+ and the partition name. The last part number is used to know what is the last part number that was added.
30
+
31
+ The RawDataset class is compatible with [fsspec](https://filesystem-spec.readthedocs.io/en/latest/).
32
+ This means that it can be used with any filesystem that is compatible with fsspec such as Azure Blob, S3, Google Cloud Storage, etc.
33
+ By default, the RawDataset class uses the local filesystem.
34
+
35
+ Example:
36
+ # Create a dataset
37
+ data = [{
38
+ "name": "John Doe",
39
+ "age": 30,
40
+ "city": "Nairobi"
41
+ }]
42
+
43
+ dataset = RawDataset(base_folder="data_raw/")
44
+ dataset.append_save("contacts", data, ["africa", "kenya"], zip=True)
45
+
46
+ data[0]["city"] = "Lagos"
47
+ dataset.append_save("contacts", data, ["africa", "nigeria"], zip=True)
48
+ data[0]["city"] = "Madrid"
49
+ dataset.append_save("contacts", data, ["europe", "spain"], zip=True)
50
+ dataset.files("contacts")
51
+ >>>
52
+
53
+ dataset.clear("contacts", ["africa", "kenya"])
54
+ dataset.clear("contacts")
55
+ dataset.files("contacts")
56
+
57
+
58
+ """
59
+
60
+
61
+ class RawDataset:
62
+ """
63
+ Class for managing raw / original / datasets
64
+
65
+ It is compatible with fspec.
66
+ If when instantiated no fs is provided, then the local filesystem is used
67
+
68
+ Each dataset is a folder and each partition is a subfolder
69
+ The data is saved in the following format:
70
+ {base_folder}/dataset_name/partition_level1/partition_level2/batch-0.[json|zip]
71
+
72
+ """
73
+ metadata_filename = "00_metadata.json"
74
+
75
+ def __init__(self, name, base_folder="./datasets", storage_options=None):
76
+ """
77
+ Initialize the DatasetSaver.
78
+
79
+ Args:
80
+ dataset_name (str): Name of the dataset to save.
81
+ base_folder (str): Base folder for saving datasets.
82
+ storage_options (dict, optional): Additional options for the filesystem. Defaults to None.
83
+ Example:
84
+ dataset = RawDataset("contacts", base_folder="raw/")
85
+ # Example using az blob storage
86
+ dataset = RawDataset("contacts", base_folder="az://mycontainer/data_raw/", storage_options={"account_name": "myaccount", "account_key": "mykey"})
87
+ # You can also use the default ENV variables AZURE_STORAGE_*
88
+ dataset = RawDataset("contacts", base_folder="az://mycontainer/data_raw/")
89
+ # Example using s3 storage
90
+ dataset = RawDataset("contacts", base_folder="s3://mybucket/data_raw/")
91
+
92
+ """
93
+ self.dataset_name = name
94
+ self.base_folder = base_folder
95
+ # build the dataset folder path
96
+ self.dataset_folder = RawDataset.build_dataset_folder(base_folder, name)
97
+ # Create the fsspec filesystem object
98
+ # From the base_folder (url) create the filesystem object (fsspec.core.url_to_fs(url, **kwargs)[source]
99
+ # get the list of available filesystems from fsspec
100
+ # https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec
101
+
102
+ fs, _ = url_to_fs(self.dataset_folder, storage_options=storage_options or {})
103
+ self.fs = fs
104
+ if not fs:
105
+ self.fs = LocalFileSystem()
106
+
107
+ @classmethod
108
+ def build_dataset_folder(cls, base_folder: str, dataset_name: str):
109
+ """
110
+ Build the dataset folder path.
111
+ Args:
112
+ base_folder (str): Base folder for saving datasets.
113
+ dataset_name (str): Name of the dataset to save.
114
+ Returns:
115
+ str: The full path to the dataset folder.
116
+ """
117
+ # if base_folder ends with / then separator = ""
118
+ # else separator = "/"
119
+ separator = "" if base_folder.endswith("/") else "/"
120
+ return base_folder + separator + dataset_name
121
+
122
+ @classmethod
123
+ def build_partition_folder(cls, dataset_folder: str, partition: list = None):
124
+ """
125
+ Build the partition folder path.
126
+ This is useful when creating the full path for a partition.
127
+ Args:
128
+ dataset_folder (str): The base folder for the dataset.
129
+ partition (list, optional): Lists the partition the data belongs to.
130
+ Returns:
131
+ str: The full path to the partition folder.
132
+ """
133
+ if partition is None:
134
+ partition = []
135
+ # The final path will be dataset_folder + partition1 / partition2 ...
136
+ folder = dataset_folder
137
+ for sub_partition in partition:
138
+ folder += "/" + sub_partition
139
+ return folder
140
+
141
+
142
+ def get_partition_folder(self, partition: list = None):
143
+ """
144
+ Get the folder for a partition
145
+ Args:
146
+ partition (list, optional): Lists the partition the data belongs to.
147
+ Returns:
148
+ str: The folder for the partition
149
+ """
150
+ return RawDataset.build_partition_folder(self.dataset_folder, partition)
151
+
152
+ def exists(self, partition: list = None):
153
+ """
154
+ Check if the dataset or partition folder exists.
155
+ Args:
156
+ partition (list, optional): partition whose folder you want to check if exists .
157
+ Returns:
158
+ bool: True if the dataset or partition exists, False otherwise
159
+ """
160
+ folder = self.get_partition_folder(partition)
161
+ # check if the folder exists
162
+ return self.fs.exists(folder)
163
+
164
+
165
+ def is_empty(self, partition: list = None):
166
+ """
167
+ Check if the partition is empty
168
+ Args:
169
+ partition (list, optional): Lists the partition the data belongs to.
170
+ Returns:
171
+ bool: True if the partition is empty, False otherwise
172
+ Raises:
173
+ FileNotFoundError: If the partition or dataset does not exist or has been cleared.
174
+ """
175
+ # There are two options
176
+ # 1. The folder does not exist, then the partition is empty
177
+ if self.exists(partition) is False:
178
+ return True
179
+ # 2. The folder exists, then we need to check if the last batch number is 0
180
+ get_last_batch_number = self.get_last_batch_number(partition)
181
+ if get_last_batch_number == 0:
182
+ return True
183
+ else:
184
+ return False
185
+
186
+
187
+ def get_metadata(self, partition: list = None):
188
+ """
189
+ Get the metadata for a partition
190
+ Args:
191
+ partition (list, optional): Lists the partition the data belongs to.
192
+ Returns:
193
+ dict: Metadata for the partition
194
+ Raises:
195
+ FileNotFoundError: If the partition or dataset does not exist or has been cleared.
196
+ Example:
197
+ >>> dataset.get_metadata(["partition1", "partition2"])
198
+ # Returns the metadata dict for the partition dataset_name/partition1/partition2
199
+ Example metadata:
200
+ {
201
+ "last_batch": 1,
202
+ "is_final_batch": False,
203
+ "partition": ["partition1", "partition2"],
204
+ "resume_cursor": None
205
+ }
206
+ """
207
+
208
+ folder = self.get_partition_folder(partition)
209
+
210
+ # check if the folder exists
211
+ if self.fs.exists(folder):
212
+ # Get the metadata file
213
+ metadata_file = folder + "/" + self.metadata_filename
214
+ # check if the metadata file exists
215
+ if self.fs.exists(metadata_file):
216
+ logging.debug("Metadata file exists: %s", metadata_file)
217
+ with self.fs.open(metadata_file, "r") as f:
218
+ # load the json
219
+ metadata = json.load(f)
220
+ return metadata
221
+ else:
222
+ raise FileNotFoundError(f"Metadata file does not exist: {metadata_file}")
223
+ else:
224
+ raise FileNotFoundError(f"Partition/Dataset does not exist or has been cleared. The folder {folder} does not exist")
225
+
226
+ def _save_metadata(self, metadata: dict, partition: list = None, ):
227
+ """
228
+ Updates the metadata file for the partition with the values. Assumes the values has the correct schema.
229
+ """
230
+ folder = self.get_partition_folder(partition)
231
+ metadata_file = folder + "/" + self.metadata_filename
232
+ logging.debug("Saving metadata file: %s", metadata_file)
233
+ with self.fs.open(metadata_file, "w") as f:
234
+ json.dump(metadata, f)
235
+
236
+
237
+ def has_final_batch(self, partition: list = None):
238
+ """
239
+ Check if the partition has a final batch
240
+ Args:
241
+ partition (list, optional): Lists the partition the data belongs to.
242
+ Returns:
243
+ bool: True if the partition has a final batch, False otherwise
244
+ """
245
+ metadata = self.get_metadata(partition)
246
+ return metadata.get("is_final_batch", False)
247
+
248
+ def set_is_final_batch(self, new_value:bool, partition: list = None):
249
+ """
250
+ Sets the 'is_final_batch' flag in the metadata for the specified partition.
251
+ Args:
252
+ new_value (bool): The value to set for the 'is_final_batch' flag.
253
+ partition (list, optional): The partition for which to set the flag. Defaults to None.
254
+ Example:
255
+ dataset.set_is_final_batch(True, ["partition_level1", "partition_level2"])
256
+ """
257
+
258
+ metadata = self.get_metadata(partition=partition)
259
+ metadata['is_final_batch'] = new_value
260
+ self._save_metadata(metadata, partition=partition)
261
+
262
+
263
+ def get_last_batch_number(self, partition: list = None):
264
+ """
265
+ Get the last batch number for a partition.
266
+ Checks if the metadata file exists and if it does, then it gets the last batch number from there.
267
+ If the metadata file does not exist, then it checks if there are any batch-XXX.XXX files in the folder.
268
+ Args:
269
+ partition (list, optional): Lists the partition the data belongs to.
270
+ Returns:
271
+ int: Last batch number for the partition. Or 0 if the partition is empty.
272
+ """
273
+ files = []
274
+ last_batch = 0
275
+
276
+ # if the partition does not exist, then the partition is empty
277
+ if self.exists(partition) is False:
278
+ return last_batch
279
+ folder = self.get_partition_folder(partition)
280
+
281
+ try: # Count the number of files in the folder
282
+ files = self.fs.ls(folder)
283
+
284
+ # If the folder is empty, then we can create the first part
285
+ # If the folder is not empty, then we need to get the last part number
286
+ logging.debug("Files in folder: %s", files)
287
+ if len(files) != 0:
288
+ # There should be a special file called 00_metadata.json
289
+ # that contains the last part number
290
+ # This is used to know what is the last part number
291
+ # if the folder has more than 1 part, then we need to load the last partfile
292
+ metadata_file = folder + "/" + self.metadata_filename
293
+ if self.fs.exists(metadata_file):
294
+ logging.debug("Metadata file exists: %s", metadata_file)
295
+ with self.fs.open(metadata_file, "r") as f:
296
+ # load the json
297
+ metadata = json.load(f)
298
+ # get the last part number
299
+ logging.debug("Metadata: %s", metadata)
300
+ last_batch = metadata["last_batch"]
301
+ else:
302
+ logging.debug("Metadata file does not exist: %s", metadata_file)
303
+ logging.debug("Validating batch-x files in folder")
304
+ for file in files:
305
+ if "batch-" in file:
306
+ last_batch = max(last_batch, int(file.split("batch-")[1].split(".")[0]))
307
+ except FileNotFoundError:
308
+ # On blobs folder do not actually exist till a file is added
309
+ pass
310
+ return last_batch
311
+
312
+ def append_save(self, data:dict, partition: list = None, zip: bool = True, resume_cursor=None, is_final_batch=False):
313
+ """
314
+ Save a dict based dataset as json or gzip
315
+ The data is saved in the dataset folder
316
+
317
+ Args:
318
+ partition (list, optional): Lists the partition the data belongs to.
319
+ zip (bool, optional): Whether to save the file in compressed format or not.
320
+ final_batch (bool, optional): Whether to save the file as final batch or not.
321
+ """
322
+ folder = self.get_partition_folder(partition)
323
+
324
+ # Create the directory for the dataset if does not exist
325
+ if not self.fs.exists(folder):
326
+ self.fs.mkdir(folder, recursive=True)
327
+
328
+ # Get the last batch number
329
+ last_batch = self.get_last_batch_number(partition)
330
+ logging.debug("Last existing batch number: %s", last_batch)
331
+
332
+ # Create the new file
333
+ new_batch = last_batch + 1
334
+ new_batch_str = str(new_batch).zfill(9)
335
+ # The new file will be called batch-<number padded with 0>.[gzip|json]
336
+ logging.info("Saving partition %s in %s, zip=%s, items=%s, batch=%s, resume_cursor=%s", partition, folder, zip, len(data), new_batch, resume_cursor)
337
+ if zip:
338
+ new_file = folder + "/batch-" + new_batch_str + ".gzip"
339
+ # Save the zipped file
340
+ with self.fs.open(new_file, "wb") as f:
341
+ # Save the file as json
342
+ # compress the file
343
+ with gzip.GzipFile(fileobj=f, mode='wb') as gz:
344
+ gz.write(json.dumps(data).encode("utf-8"))
345
+ else:
346
+ new_file = folder + "/batch-" + new_batch_str + ".json"
347
+ # Save the file
348
+ with self.fs.open(new_file, "w") as f:
349
+ # Save the file as json
350
+ json.dump(data, f)
351
+
352
+ # Save the metadata file
353
+ metadata = {
354
+ "last_batch": new_batch,
355
+ "is_final_batch": is_final_batch,
356
+ "partition": partition,
357
+ "resume_cursor": resume_cursor
358
+ }
359
+ self._save_metadata(metadata=metadata, partition=partition)
360
+
361
+
362
+ def clear_save(self, data:dict, partition: list = None, zip: bool = True, is_final_batch=False):
363
+ """
364
+ First clear the dataset/partition and then save the dict in the partition.
365
+
366
+ Args:
367
+ data (dict): Data to save.
368
+ partition (list, optional): Lists the partition the data belongs to.
369
+ zip (bool, optional): Whether to save the file in compressed format or not.
370
+ is_final_batch (bool, optional): Whether to save the file as final batch or not.
371
+ Example:
372
+ >>> dataset.clear_save({"foo": "bar"}, ["partition1", "partition2"], zip=True, is_final_batch=True)
373
+ # The partition will be cleared and then the data will be saved in the following path:
374
+ # dataset_name/partition1/partition2/batch-1.gzip will be created.
375
+ # in 00_metadata.json the attribute last_batch will be set to 1 and is_final_batch will be set to True
376
+ """
377
+ # Clear the dataset/partition
378
+ self.clear(partition)
379
+ # Save the dataset
380
+ self.append_save(data, partition=partition, zip=zip, is_final_batch=is_final_batch)
381
+
382
+ def clear(self, partition: list = None):
383
+ """
384
+ Clear the dataset or a partition
385
+
386
+ Args:
387
+ partition (list, optional): Partition to clear. If not set, the whole dataset will be cleared.
388
+ Example:
389
+ dataset.clear(["partition1", "partition2"])
390
+ dataset_name/partition1/partition2 will be cleaned.
391
+ dataset.clear("dataset_name") will clear the whole dataset_name.
392
+
393
+ """
394
+ folder = self.get_partition_folder(partition)
395
+ # Remove the folder
396
+ logging.debug("Requested to clear folder %s", folder)
397
+ # check if the folder exists
398
+ if self.fs.exists(folder):
399
+ # remove the folder
400
+ self.fs.rm(folder, recursive=True)
401
+ logging.debug("Removed folder: %s", folder)
402
+ else:
403
+ logging.debug("Folder %s does not exist", folder)
404
+
405
+ def clear_many_partitions(self, partitions: list):
406
+ """
407
+ Clear multiple partitions
408
+
409
+ Args:
410
+ partitions (list): List of partitions to clear.
411
+ Example:
412
+ dataset.clear_many([["partition1", "partition2"], ["partition3", "partition4"]])
413
+ # dataset_name/partition1/partition2 and dataset_name/partition3/partition4 will be cleaned.
414
+ """
415
+ for partition in partitions:
416
+ self.clear(partition)
417
+
418
+
419
+ def partitions(self, within_partition:list=None, as_dataframe: bool = False):
420
+ """
421
+ Get the partitions of a dataset
422
+
423
+ Args:
424
+ as_dataframe (bool, optional): If True, return the partitions as a pandas dataframe. If False, return the partitions as a list of dicts.
425
+ Returns:
426
+ list: List of dicts with partition info in the dataset.
427
+ For example:
428
+ [
429
+ {
430
+ "dataset_name": "dataset_name",
431
+ "level1": "partition1",
432
+ "level2": "partition2"
433
+ "list": [
434
+ "partition1",
435
+ "partition2"
436
+ ],
437
+ "path": "/home/user/dataset_name/partition1/partition2",
438
+ },
439
+ ...
440
+ ]
441
+ if as_dataframe is True, then it will return a pandas dataframe with the same information.
442
+ Raises:
443
+ FileNotFoundError: If the partition or dataset does not exist.
444
+ Example:
445
+ # will return all partitions in the dataset_name.
446
+ ds.partitions()
447
+ ds.partitions(as_dataframe=True)
448
+ # subset of partitions within the dataset_name
449
+ ds.partitions(within_partition=["partition1", "partition2"])
450
+ """
451
+ files = self.files(partition=within_partition, as_dataframe=True)
452
+ if files.empty:
453
+ logging.debug("No files found in the dataset or partition.")
454
+ return pd.DataFrame() if as_dataframe else []
455
+ # files is a dataframe. We drop the columns that are file specific path, filename, zip and filesize
456
+ files = files.drop(columns=["path", "filename", "zip", "filesize"], errors='ignore')
457
+ # Now we need to get unique records in partition_path column
458
+ partitions = files.drop_duplicates(subset=["partition_path"])
459
+ if as_dataframe:
460
+ return partitions.reset_index(drop=True)
461
+ else:
462
+ return partitions.to_dict(orient='records')
463
+
464
+
465
+ def files(self, partition: list = None, as_dataframe: bool = False):
466
+ """
467
+ Get the files of a dataset or a partition
468
+
469
+ Args:
470
+ partition (list, optional): List of partitions to get. If not set, the whole dataset will be returned.
471
+ as_dataframe (bool, optional): If True, return the files as a pandas dataframe. If False, return the files as a list of dicts.
472
+ Returns:
473
+ list: List of dicts with file info in the dataset or partition.
474
+ For example:
475
+ [
476
+ {
477
+ "dataset_name": "dataset_name",
478
+ "level1": "partition1",
479
+ "level2": "partition2",
480
+ "partition": [
481
+ "partition1",
482
+ "partition2"
483
+ ],
484
+ "dataset_path": "/dataset_name/",
485
+ partition_path: "/dataset_name/partition1/partition2
486
+ "filename": "batch-0.json",
487
+ "path": "/home/user/dataset_name/partition1/partition2/batch-0.json",
488
+ "zip": True,
489
+ "filesize": 1024
490
+ },
491
+ ...
492
+ ]
493
+ if as_dataframe is True, then it will return a pandas dataframe with the same information.
494
+ Raises:
495
+ FileNotFoundError: If the partition or dataset does not exist.
496
+ Example:
497
+ ds.files(partition=["partition1", "partition2"])
498
+ files within dataset_name/partition1/partition2 will be returned.
499
+ ds.files() will return the whole dataset_name.
500
+ """
501
+ folder = self.get_partition_folder(partition)
502
+
503
+ if partition is None:
504
+ partition = []
505
+
506
+ # check if the folder exists
507
+ if self.fs.exists(folder):
508
+ # Get all files recursively using fs.find which is more efficient
509
+ if hasattr(self.fs, 'find'):
510
+ # Some filesystems like S3FileSystem have 'find' method
511
+ files = self.fs.find(folder, maxdepth=None)
512
+ elif hasattr(self.fs, 'glob'):
513
+ # Others may have 'glob' method
514
+ files = self.fs.glob(f"{folder}/**")
515
+ else:
516
+ # Fallback to manual recursion if necessary
517
+ files = []
518
+ def list_recursively(path):
519
+ for file_path in self.fs.ls(path):
520
+ if self.fs.isdir(file_path):
521
+ list_recursively(file_path)
522
+ files.append(file_path)
523
+
524
+ list_recursively(folder)
525
+ else:
526
+ # if the folder does not exist, then raise an error
527
+ raise FileNotFoundError(f"Partition/Dataset does not exist or has been cleared. The folder {folder} does not exist")
528
+
529
+ # Now we create a pandas dataframe with the files including these columns
530
+ # dataset_name, level1, level2, ... , leveln, path, filesize
531
+ # The path is the full path of the file
532
+ # The filesize is the size of the file in bytes
533
+ # The level1, level2, ... , leveln are the partitions
534
+ # The dataset_name is the name of the dataset
535
+ # Create a list to store file information
536
+ file_info = []
537
+ # Process each file in the directory
538
+ for file_path in files:
539
+ file_info_dict = {}
540
+ # if it is the metadata file, then skip it
541
+ if file_path.endswith(self.metadata_filename):
542
+ continue
543
+
544
+ # ignore .DS_Store files
545
+ if file_path.endswith(".DS_Store"):
546
+ continue
547
+
548
+ # Add dataset name
549
+ file_info_dict["dataset_name"] = self.dataset_name
550
+
551
+ # Add partition levels if they exist
552
+ for i, sub_part in enumerate(partition):
553
+ file_info_dict[f"level{i+1}"] = sub_part
554
+
555
+ # Remove from the file_path anything that is before the dataset name enclosed in /
556
+ # This is to get the relative path of the file
557
+ # For example if the file_path is /home/user/dataset_name/partition1/partition2/batch-0.json
558
+ # then the relative path will be partition1/partition2/batch-0.json
559
+ dataset_path = f"/{self.dataset_name}/"
560
+ relative_path = file_path.split(dataset_path, 1)[-1] if dataset_path in file_path else file_path
561
+
562
+ # Split by '/' to get all partition levels
563
+ path_parts = relative_path.split('/')
564
+ # Skip the last part if it's a file
565
+ if '.' in path_parts[-1]:
566
+ file_info_dict["filename"] = path_parts[-1]
567
+ path_parts = path_parts[:-1]
568
+
569
+ # Add any additional partition levels found in the path
570
+ for i, part in enumerate(path_parts):
571
+ level_key = f"level{i+1}"
572
+ if level_key not in file_info_dict:
573
+ file_info_dict[level_key] = part
574
+
575
+ file_info_dict["partition"] = path_parts
576
+ file_info_dict["dataset_path"] = dataset_path
577
+ file_info_dict["partition_path"] = dataset_path + "/".join(path_parts)
578
+ file_info_dict["path"] = file_path
579
+ file_info_dict["zip"] = file_path.endswith(".gzip")
580
+ file_info_dict["filesize"] = self.fs.info(file_path)["size"]
581
+
582
+
583
+ file_info.append(file_info_dict)
584
+
585
+ if as_dataframe:
586
+ # Create a pandas DataFrame from the collected information
587
+ return pd.DataFrame(file_info)
588
+ return file_info
589
+
590
+ def get_file_data(self, file_path):
591
+ """
592
+ Get the data from a file
593
+ Args:
594
+ file_path (str): Path to the file.
595
+ Returns:
596
+ dict: Data from the file.
597
+ """
598
+ # Check if the file exists
599
+ if not self.fs.exists(file_path):
600
+ raise FileNotFoundError(f"File does not exist. The file {file_path} does not exist")
601
+
602
+ # Check if the file is compressed
603
+ if file_path.endswith(".gzip"):
604
+ with self.fs.open(file_path, "rb") as f:
605
+ # decompress the file
606
+ with gzip.GzipFile(fileobj=f, mode='rb') as gz:
607
+ data = gz.read()
608
+ return json.loads(data)
609
+ else:
610
+ with self.fs.open(file_path, "r") as f:
611
+ data = f.read()
612
+ return json.loads(data)
613
+
614
+ def download(self, local_base_folder: str, partition: list = None,):
615
+ """
616
+ Download the files of a dataset or a partition to a local folder.
617
+ If the dataset is already local, it will create a copy on the new local_base_folder.
618
+ If the dataset is in a cloud environment, it will download the files to the local_base_folder.
619
+ If the partition is not set, then the whole dataset will be downloaded.
620
+
621
+ The local folder will have the same structure as the dataset.
622
+ The base folder will be created if it does not exist.
623
+
624
+ The files will be downloaded in the following format:
625
+ {local_base_folder}/{dataset_name(f.i raw)}/{partition_level1}/{partition_level2}/batch-0.[json|gzip]
626
+
627
+ Args:
628
+ partition (list, optional): Specify a partition to download. If not set, the whole dataset will be downloaded.
629
+ output_folder (str, optional): Folder where the files will be downloaded. Defaults to "./".
630
+ Returns:
631
+ local_file_path
632
+ """
633
+ # Remote folder with the dataset to download
634
+ remote_folder = self.get_partition_folder(partition) + "/"
635
+
636
+ # get the local folder path
637
+ local_dataset_path = RawDataset.build_dataset_folder(local_base_folder, self.dataset_name)
638
+ local_partition_path = RawDataset.build_partition_folder(local_dataset_path, partition)
639
+ # Create the local folder if it does not exist
640
+ # Create the LocalFileSystem object
641
+ local_fs = LocalFileSystem()
642
+ if not local_fs.exists(local_partition_path):
643
+ local_fs.mkdir(local_partition_path, recursive=True)
644
+
645
+ logging.info("%s RawDataset::download Downloading %s to %s", self.dataset_name, remote_folder, local_partition_path)
646
+ self.fs.get(remote_folder, local_partition_path, recursive=True)
647
+
648
+ return local_partition_path
649
+
@@ -0,0 +1,32 @@
1
+ from . import BaseDataset
2
+ from ..workspaces import Workspace
3
+
4
+
5
+ class RunsDataset(BaseDataset):
6
+ """
7
+ A class to handle the Runs dataset.
8
+
9
+ This class inherits from BaseDataset and is used to manage the Runs 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
+ storage_options: Additional options for the storage backend (e.g., fsspec compatible).
25
+ raw_dataset_batch_size: The batch size for downloading the raw dataset.
26
+ """
27
+
28
+ super().__init__(name="runs",
29
+ workspace=workspace,
30
+ base_folder=base_folder,
31
+ storage_options=storage_options,
32
+ raw_dataset_batch_size=raw_dataset_batch_size)
@@ -0,0 +1,8 @@
1
+ from .RawDataset import RawDataset
2
+ from .BaseDataset import BaseDataset
3
+ from .ContactsDataset import ContactsDataset
4
+ from .MessagesDataset import MessagesDataset
5
+ from .FlowsDataset import FlowsDataset
6
+ from .RunsDataset import RunsDataset
7
+ from .GroupsDataset import GroupsDataset
8
+ from .FieldsDataset import FieldsDataset