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,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
|