rapidpro-api 0.1.1__tar.gz → 0.2.1__tar.gz
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-0.1.1 → rapidpro_api-0.2.1}/PKG-INFO +2 -1
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/contact_processors.py +9 -15
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/BaseDataset.py +64 -26
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/RawDataset.py +56 -1
- rapidpro_api-0.2.1/rapidpro_api/datasets/StatisticsDataset.py +281 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/__init__.py +4 -1
- rapidpro_api-0.2.1/rapidpro_api/statistics_processors.py +75 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/ureport_processors_pl.py +3 -3
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/version.py +1 -1
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/PKG-INFO +2 -1
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/SOURCES.txt +4 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/requires.txt +1 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/setup.py +1 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_base_dataset.py +50 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_raw_dataset.py +41 -1
- rapidpro_api-0.2.1/tests/datasets/test_statistics_dataset.py +159 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_process_stats_list.py +6 -8
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_contact_processors.py +6 -10
- rapidpro_api-0.2.1/tests/test_statistics_processors.py +214 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_ureport_processors_pl.py +1 -1
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/README.md +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/__init__.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/contact_processors_pl.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/ContactsDataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/DeltaLakeDataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/FieldsDataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/FlowsDataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/GroupsDataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/MessagesDataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/RunsDataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/field_processors.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/flow_processors.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/group_processors.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/message_processors.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/run_processors_pl.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/__init__.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/download_stats.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/download_stats_list.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/process_stats.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/process_stats_list.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/stats.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/telegram.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/time_utils.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/utils.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/validators.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/workspaces.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/dependency_links.txt +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/top_level.txt +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/setup.cfg +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/__init__.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/__init__.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_contacts_dataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_fields_dataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_flows_dataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_groups_dataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_messages_dataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_runs_dataset.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/__init__.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_download_stats.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_download_stats_list.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_process_stats.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_stats.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_contact_processors_pl.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_field_processors.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_flow_processor.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_group_processors.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_message_processors.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_run_processors_pl.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_telegram.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_time_utils.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_utils.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_validators.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_version.py +0 -0
- {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_workspaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rapidpro-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A library for extracting and transforming data from RapidPro
|
|
5
5
|
Home-page: https://github.com/unicef/magasin-primero-paquet
|
|
6
6
|
Author: merlos
|
|
@@ -20,6 +20,7 @@ Requires-Dist: fsspec==2025.3.2
|
|
|
20
20
|
Requires-Dist: pyarrow==18.1.0
|
|
21
21
|
Requires-Dist: adlfs==2024.12.0
|
|
22
22
|
Requires-Dist: deltalake==0.25.5
|
|
23
|
+
Requires-Dist: ijson==3.3.0
|
|
23
24
|
Provides-Extra: dev
|
|
24
25
|
Requires-Dist: pytest==8.3.5; extra == "dev"
|
|
25
26
|
Requires-Dist: pytest-cov==6.1.1; extra == "dev"
|
|
@@ -96,11 +96,9 @@ def get_age_group(born: int):
|
|
|
96
96
|
Returns:
|
|
97
97
|
str: The age group as a string. Possible values are:
|
|
98
98
|
- "None" if the input is invalid, empty, or represents a negative age.
|
|
99
|
-
- "0-
|
|
100
|
-
- "
|
|
101
|
-
- "
|
|
102
|
-
- "25-30" for ages between 25 and 30 (inclusive).
|
|
103
|
-
- "31-34" for ages between 31 and 34 (inclusive).
|
|
99
|
+
- "0-17" for ages between 0 and 17 (inclusive).
|
|
100
|
+
- "18-24" for ages between 18 and 24 (inclusive).
|
|
101
|
+
- "25-34" for ages between 25 and 34 (inclusive).
|
|
104
102
|
- "35+" for ages 35 and above.
|
|
105
103
|
"""
|
|
106
104
|
if not born:
|
|
@@ -119,16 +117,12 @@ def get_age_group(born: int):
|
|
|
119
117
|
return "None"
|
|
120
118
|
if age < 0:
|
|
121
119
|
return "None"
|
|
122
|
-
elif 0 <= age <=
|
|
123
|
-
return "0-
|
|
124
|
-
elif
|
|
125
|
-
return "
|
|
126
|
-
elif
|
|
127
|
-
return "
|
|
128
|
-
elif 25 <= age <= 30:
|
|
129
|
-
return "25-30"
|
|
130
|
-
elif 31 <= age <= 34:
|
|
131
|
-
return "31-34"
|
|
120
|
+
elif 0 <= age <= 17:
|
|
121
|
+
return "0-17"
|
|
122
|
+
elif 18 <= age <= 24:
|
|
123
|
+
return "18-24"
|
|
124
|
+
elif 25 <= age <= 34:
|
|
125
|
+
return "25-34"
|
|
132
126
|
else:
|
|
133
127
|
return "35+"
|
|
134
128
|
def parse_born(born: str) -> Union[int, None]:
|
|
@@ -417,30 +417,10 @@ class BaseDataset:
|
|
|
417
417
|
# Successfull download
|
|
418
418
|
return True
|
|
419
419
|
|
|
420
|
-
def
|
|
420
|
+
def _iter_dated_file_rows(self, after_date: datetime=None, before_date: datetime=None):
|
|
421
421
|
"""
|
|
422
|
-
|
|
423
|
-
|
|
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']}")
|
|
422
|
+
Yield (index, row, file_path, num_files) for each raw file of the workspace within the date range.
|
|
423
|
+
Shared by iter_dated_batch_raw_files and iter_dated_batch_raw_files_stream.
|
|
444
424
|
"""
|
|
445
425
|
if after_date is None:
|
|
446
426
|
after_date = self.INIT_DATE
|
|
@@ -450,13 +430,12 @@ class BaseDataset:
|
|
|
450
430
|
after_date_str = after_date.strftime('%Y-%m-%d')
|
|
451
431
|
before_date_str = before_date.strftime('%Y-%m-%d')
|
|
452
432
|
|
|
453
|
-
|
|
454
433
|
logging.info("[%s] Processing workspace %s from %s until %s...",
|
|
455
434
|
self.workspace.code, self.workspace.code, after_date_str, before_date_str)
|
|
456
435
|
|
|
457
436
|
# get the list of files
|
|
458
437
|
files_df = self.raw_dataset.files(partition=[self.workspace.code], as_dataframe=True)
|
|
459
|
-
|
|
438
|
+
|
|
460
439
|
# print (files_df.head())
|
|
461
440
|
if files_df.empty:
|
|
462
441
|
logging.warning("[%s] No files found for workspace %s",
|
|
@@ -483,7 +462,35 @@ class BaseDataset:
|
|
|
483
462
|
logging.info("%s - %s: [%s/%s] Skipping file %s as it is newer than the before date (last date) %s",
|
|
484
463
|
self.name, self.workspace.code, index, num_files, file_path, before_date_str)
|
|
485
464
|
continue
|
|
486
|
-
|
|
465
|
+
|
|
466
|
+
yield index, row, file_path, num_files
|
|
467
|
+
|
|
468
|
+
def iter_dated_batch_raw_files(self, after_date: datetime=None, before_date: datetime=None, with_file_info:bool=False) -> [list, dict]:
|
|
469
|
+
"""
|
|
470
|
+
Iterate over the existing dataset file batches.
|
|
471
|
+
Args:
|
|
472
|
+
after_date: The date to start the iteration from. If not set will iterate from the first date in the dataset.
|
|
473
|
+
before_date: The date to end the iteration. If not set will iterate until the last date in the dataset.
|
|
474
|
+
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.
|
|
475
|
+
Yields:
|
|
476
|
+
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
|
|
477
|
+
it will yield only the raw data.
|
|
478
|
+
Returns:
|
|
479
|
+
A generator that yields batches of data (list of dicts).
|
|
480
|
+
Raises:
|
|
481
|
+
FileNotFoundError: If no data has been downloaded yet or if the workspace is not found.
|
|
482
|
+
Example:
|
|
483
|
+
>>> ds = BaseDataset(name="contacts", workspace=Workspace(code="code"))
|
|
484
|
+
>>> 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):
|
|
485
|
+
>>> print(file_info) # file info will be a dict of RawDataset file info
|
|
486
|
+
>>> print(raw_data) # raw_data will be a list of dicts with the raw data from RapidPro API
|
|
487
|
+
>>> # process the raw_data here
|
|
488
|
+
>>> # e.g. process_contacts_pl(raw_data, metadata=metadata)
|
|
489
|
+
>>> # also getting the index of the file (explore the whole dataset)
|
|
490
|
+
>>> for index, (file_info, raw_data) in enumerate(ds.iter_dated_batch_raw_files(with_file_info=True)):
|
|
491
|
+
>>> print(f"Processing file {index+1}: {file_info['path']}")
|
|
492
|
+
"""
|
|
493
|
+
for index, row, file_path, num_files in self._iter_dated_file_rows(after_date, before_date):
|
|
487
494
|
logging.info("%s - %s: [%s/%s] Iterating file %s (%s bytes)...",
|
|
488
495
|
self.name, self.workspace.code, index, num_files, file_path, row['filesize'])
|
|
489
496
|
raw_data = self.raw_dataset.get_file_data(file_path)
|
|
@@ -491,6 +498,37 @@ class BaseDataset:
|
|
|
491
498
|
yield row, raw_data
|
|
492
499
|
else:
|
|
493
500
|
yield raw_data
|
|
501
|
+
|
|
502
|
+
def iter_dated_batch_raw_files_stream(self, after_date: datetime=None, before_date: datetime=None,
|
|
503
|
+
with_file_info:bool=False, chunk_size:int=None):
|
|
504
|
+
"""
|
|
505
|
+
Like iter_dated_batch_raw_files, but never materializes a full file's records in memory:
|
|
506
|
+
each file is parsed lazily (via RawDataset.iter_file_records), record by record.
|
|
507
|
+
Args:
|
|
508
|
+
after_date: The date to start the iteration from. If not set will iterate from the first date in the dataset.
|
|
509
|
+
before_date: The date to end the iteration. If not set will iterate until the last date in the dataset.
|
|
510
|
+
with_file_info: If True, it will yield a tuple with the file info and the records. If False, it will yield only the records.
|
|
511
|
+
chunk_size (int, optional): If set, records within a file are grouped into lists of at most this size.
|
|
512
|
+
If not set, individual records are yielded one at a time.
|
|
513
|
+
Yields:
|
|
514
|
+
A tuple with the file info (dict) and a generator of records (or record chunks, if chunk_size is set)
|
|
515
|
+
if with_file_info is True, otherwise just the generator.
|
|
516
|
+
Raises:
|
|
517
|
+
FileNotFoundError: If no data has been downloaded yet or if the workspace is not found.
|
|
518
|
+
Example:
|
|
519
|
+
>>> ds = BaseDataset(name="contacts", workspace=Workspace(code="code"))
|
|
520
|
+
>>> for file_info, chunks in ds.iter_dated_batch_raw_files_stream(with_file_info=True, chunk_size=10000):
|
|
521
|
+
>>> for chunk in chunks:
|
|
522
|
+
>>> process_ureport_contacts_pl(chunk, metadata=metadata)
|
|
523
|
+
"""
|
|
524
|
+
for index, row, file_path, num_files in self._iter_dated_file_rows(after_date, before_date):
|
|
525
|
+
logging.info("%s - %s: [%s/%s] Streaming file %s (%s bytes)...",
|
|
526
|
+
self.name, self.workspace.code, index, num_files, file_path, row['filesize'])
|
|
527
|
+
records = self.raw_dataset.iter_file_records(file_path, chunk_size=chunk_size)
|
|
528
|
+
if with_file_info:
|
|
529
|
+
yield row, records
|
|
530
|
+
else:
|
|
531
|
+
yield records
|
|
494
532
|
|
|
495
533
|
def iter_last_date_raw_files(self, with_file_info:bool=False) -> [list, dict]:
|
|
496
534
|
"""
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import gzip
|
|
3
|
+
import ijson
|
|
3
4
|
from fsspec.implementations.local import LocalFileSystem
|
|
4
5
|
from fsspec.core import url_to_fs
|
|
5
6
|
import pandas as pd
|
|
@@ -610,7 +611,61 @@ class RawDataset:
|
|
|
610
611
|
with self.fs.open(file_path, "r") as f:
|
|
611
612
|
data = f.read()
|
|
612
613
|
return json.loads(data)
|
|
613
|
-
|
|
614
|
+
|
|
615
|
+
def _stream_file_records(self, file_path):
|
|
616
|
+
"""
|
|
617
|
+
Lazily parse the top-level JSON array of a file, one record at a time.
|
|
618
|
+
Args:
|
|
619
|
+
file_path (str): Path to the file.
|
|
620
|
+
Yields:
|
|
621
|
+
dict: Each record (array item) found in the file.
|
|
622
|
+
Raises:
|
|
623
|
+
FileNotFoundError: If the file does not exist.
|
|
624
|
+
"""
|
|
625
|
+
if not self.fs.exists(file_path):
|
|
626
|
+
raise FileNotFoundError(f"File does not exist. The file {file_path} does not exist")
|
|
627
|
+
|
|
628
|
+
if file_path.endswith(".gzip"):
|
|
629
|
+
with self.fs.open(file_path, "rb") as f:
|
|
630
|
+
with gzip.GzipFile(fileobj=f, mode='rb') as gz:
|
|
631
|
+
yield from ijson.items(gz, "item")
|
|
632
|
+
else:
|
|
633
|
+
with self.fs.open(file_path, "rb") as f:
|
|
634
|
+
yield from ijson.items(f, "item")
|
|
635
|
+
|
|
636
|
+
def iter_file_records(self, file_path, chunk_size:int=None):
|
|
637
|
+
"""
|
|
638
|
+
Iterate over the records of a file (top-level JSON array) without loading the whole file into memory.
|
|
639
|
+
Args:
|
|
640
|
+
file_path (str): Path to the file.
|
|
641
|
+
chunk_size (int, optional): If set, records are grouped into lists of at most this size. If not set (default),
|
|
642
|
+
each record is yielded individually.
|
|
643
|
+
Yields:
|
|
644
|
+
dict: A single record, if chunk_size is None.
|
|
645
|
+
list: A list of up to chunk_size records, if chunk_size is set.
|
|
646
|
+
Raises:
|
|
647
|
+
FileNotFoundError: If the file does not exist.
|
|
648
|
+
Example:
|
|
649
|
+
>>> ds = RawDataset("contacts", base_folder="raw/")
|
|
650
|
+
>>> for record in ds.iter_file_records("raw/contacts/batch-000000001.gzip"):
|
|
651
|
+
>>> print(record) # one record (dict) at a time
|
|
652
|
+
>>> for chunk in ds.iter_file_records("raw/contacts/batch-000000001.gzip", chunk_size=10000):
|
|
653
|
+
>>> print(len(chunk)) # a list of up to 10000 records at a time
|
|
654
|
+
"""
|
|
655
|
+
records = self._stream_file_records(file_path)
|
|
656
|
+
if chunk_size is None:
|
|
657
|
+
yield from records
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
chunk = []
|
|
661
|
+
for record in records:
|
|
662
|
+
chunk.append(record)
|
|
663
|
+
if len(chunk) >= chunk_size:
|
|
664
|
+
yield chunk
|
|
665
|
+
chunk = []
|
|
666
|
+
if chunk:
|
|
667
|
+
yield chunk
|
|
668
|
+
|
|
614
669
|
def download(self, local_base_folder: str, partition: list = None,):
|
|
615
670
|
"""
|
|
616
671
|
Download the files of a dataset or a partition to a local folder.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .BaseDataset import BaseDataset
|
|
8
|
+
from ..time_utils import get_date_range, yesterday_end_of_day
|
|
9
|
+
from ..workspaces import Workspace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StatisticsDataset(BaseDataset):
|
|
13
|
+
"""
|
|
14
|
+
Dataset for RapidPro daily per-channel statistics.
|
|
15
|
+
|
|
16
|
+
This endpoint is not exposed by the temba client used elsewhere in the package,
|
|
17
|
+
so downloads are performed with direct HTTP requests against the RapidPro API.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
REQUEST_TIMEOUT = 60
|
|
21
|
+
|
|
22
|
+
def __init__(self, workspace: Workspace = None,
|
|
23
|
+
base_folder: str = "./datasets",
|
|
24
|
+
storage_options: dict = None,
|
|
25
|
+
raw_dataset_batch_size: int = None):
|
|
26
|
+
"""
|
|
27
|
+
Initialize a statistics dataset handler.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
workspace (Workspace, optional): Workspace containing host/token metadata used
|
|
31
|
+
to authenticate against RapidPro.
|
|
32
|
+
base_folder (str, optional): Root folder where dataset files are stored.
|
|
33
|
+
The statistics raw dataset is stored under ``<base_folder>/statistics/raw``.
|
|
34
|
+
storage_options (dict, optional): Optional fsspec storage options for remote
|
|
35
|
+
filesystems (for example Azure Blob or S3 credentials).
|
|
36
|
+
raw_dataset_batch_size (int, optional): Included for consistency with the
|
|
37
|
+
base dataset API. Statistics downloads are saved per period as a single
|
|
38
|
+
final batch.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
None
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: Propagated from base classes if workspace/partition operations
|
|
45
|
+
receive invalid values.
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> ws = Workspace(code="rw", host="https://app.rapidpro.io", token="token")
|
|
49
|
+
>>> ds = StatisticsDataset(workspace=ws, base_folder="./datasets")
|
|
50
|
+
>>> ds.name
|
|
51
|
+
'statistics'
|
|
52
|
+
"""
|
|
53
|
+
super().__init__(name="statistics",
|
|
54
|
+
workspace=workspace,
|
|
55
|
+
base_folder=base_folder,
|
|
56
|
+
storage_options=storage_options,
|
|
57
|
+
raw_dataset_batch_size=raw_dataset_batch_size)
|
|
58
|
+
|
|
59
|
+
def _statistics_url(self) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Build the RapidPro statistics endpoint URL for the current workspace.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
None
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
str: Absolute URL to ``/api/v2/statistics.json`` for the workspace host.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
AttributeError: If ``self.workspace`` is missing required ``host`` field.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
>>> ds._statistics_url()
|
|
74
|
+
'https://app.rapidpro.io/api/v2/statistics.json'
|
|
75
|
+
"""
|
|
76
|
+
host = self.workspace.host.rstrip("/")
|
|
77
|
+
if not host.startswith("http://") and not host.startswith("https://"):
|
|
78
|
+
host = f"https://{host}"
|
|
79
|
+
return f"{host}/api/v2/statistics.json"
|
|
80
|
+
|
|
81
|
+
def _download_statistics(self, since_date: datetime, until_date: datetime) -> list:
|
|
82
|
+
"""
|
|
83
|
+
Download statistics rows from RapidPro for a specific date range.
|
|
84
|
+
|
|
85
|
+
The endpoint is called with ``since`` as an inclusive date and ``until`` as an
|
|
86
|
+
exclusive date in ``YYYY-MM-DD`` format.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
since_date (datetime): Inclusive start date for the request.
|
|
90
|
+
until_date (datetime): Exclusive end date for the request.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
list: Value of the ``results`` array from the API response, or an empty list
|
|
94
|
+
when the response does not include results.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
requests.HTTPError: If the API returns a non-success status code.
|
|
98
|
+
requests.RequestException: For transport-level request failures.
|
|
99
|
+
ValueError: If JSON parsing fails in the underlying response parser.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
>>> from datetime import datetime
|
|
103
|
+
>>> rows = ds._download_statistics(datetime(2026, 3, 1), datetime(2026, 4, 1))
|
|
104
|
+
>>> isinstance(rows, list)
|
|
105
|
+
True
|
|
106
|
+
"""
|
|
107
|
+
response = requests.get(
|
|
108
|
+
self._statistics_url(),
|
|
109
|
+
headers={"Authorization": f"Token {self.workspace.token}"},
|
|
110
|
+
params={
|
|
111
|
+
"since": since_date.strftime("%Y-%m-%d"),
|
|
112
|
+
"until": until_date.strftime("%Y-%m-%d"),
|
|
113
|
+
},
|
|
114
|
+
timeout=self.REQUEST_TIMEOUT,
|
|
115
|
+
)
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
payload = response.json() or {}
|
|
118
|
+
return payload.get("results") or []
|
|
119
|
+
|
|
120
|
+
def _download_period(self,
|
|
121
|
+
ws_partition: list,
|
|
122
|
+
since_date: datetime,
|
|
123
|
+
until_date: datetime,
|
|
124
|
+
clear: bool = False,
|
|
125
|
+
continue_if_exists: bool = True) -> bool:
|
|
126
|
+
"""
|
|
127
|
+
Download and store one statistics period into a workspace partition.
|
|
128
|
+
|
|
129
|
+
This method enforces partition state rules:
|
|
130
|
+
- clear existing partition when ``clear=True``
|
|
131
|
+
- skip completed partitions when ``continue_if_exists=True``
|
|
132
|
+
- redownload incomplete partitions when ``continue_if_exists=True``
|
|
133
|
+
- raise when partition is incomplete and continuation is not allowed
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
ws_partition (list): Workspace-relative partition, usually
|
|
137
|
+
``["YYYY", "YYYY-MM-DD"]``.
|
|
138
|
+
since_date (datetime): Inclusive start date for the API request.
|
|
139
|
+
until_date (datetime): Exclusive end date for the API request.
|
|
140
|
+
clear (bool, optional): Whether to clear the partition before download.
|
|
141
|
+
continue_if_exists (bool, optional): Whether to continue processing when
|
|
142
|
+
partition data already exists.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
bool: ``True`` when the period is either successfully saved or intentionally
|
|
146
|
+
skipped because it is already complete.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If partition exists and is incomplete while
|
|
150
|
+
``continue_if_exists=False``.
|
|
151
|
+
requests.HTTPError: If the API request fails with a non-success code.
|
|
152
|
+
requests.RequestException: For transport-level request failures.
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
>>> ds._download_period(
|
|
156
|
+
... ws_partition=["2026", "2026-03-01"],
|
|
157
|
+
... since_date=datetime(2026, 3, 1),
|
|
158
|
+
... until_date=datetime(2026, 4, 1),
|
|
159
|
+
... clear=False,
|
|
160
|
+
... continue_if_exists=True,
|
|
161
|
+
... )
|
|
162
|
+
True
|
|
163
|
+
"""
|
|
164
|
+
partition = self._add_ws_to_partition(ws_partition=ws_partition)
|
|
165
|
+
|
|
166
|
+
if not self.raw_dataset.is_empty(partition=partition):
|
|
167
|
+
if clear:
|
|
168
|
+
logging.info("%s: %s [%s] Clearing partition %s before statistics download.", self.name, self.workspace.code, self.workspace.vendor, partition)
|
|
169
|
+
self.raw_dataset.clear(partition=partition)
|
|
170
|
+
elif self.raw_dataset.has_final_batch(partition=partition):
|
|
171
|
+
logging.info("%s: %s [%s] Partition %s is already marked as completed. Skipping.", self.name, self.workspace.code, self.workspace.vendor, partition)
|
|
172
|
+
return True
|
|
173
|
+
elif continue_if_exists:
|
|
174
|
+
logging.info("%s: %s [%s] Partition %s is incomplete. Clearing and redownloading period.", self.name, self.workspace.code, self.workspace.vendor, partition)
|
|
175
|
+
self.raw_dataset.clear(partition=partition)
|
|
176
|
+
else:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"Partition {partition} in {self.workspace.code} already exists and is not empty. "
|
|
179
|
+
"Set clear=True to clear the partition or continue_if_exists=True to continue adding batches."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
results = self._download_statistics(since_date=since_date, until_date=until_date)
|
|
183
|
+
# Only mark the partition as final when the full period has already elapsed.
|
|
184
|
+
# Partial periods (e.g. the current month) are saved without the final-batch
|
|
185
|
+
# flag so subsequent weekly runs will redownload and update them.
|
|
186
|
+
is_final_batch = until_date <= datetime.now()
|
|
187
|
+
logging.info("%s: %s [%s] Saving statistics period %s -> %s (%d records, final=%s).",
|
|
188
|
+
self.name,
|
|
189
|
+
self.workspace.code,
|
|
190
|
+
self.workspace.vendor,
|
|
191
|
+
since_date.strftime("%Y-%m-%d"),
|
|
192
|
+
until_date.strftime("%Y-%m-%d"),
|
|
193
|
+
len(results),
|
|
194
|
+
is_final_batch)
|
|
195
|
+
self.raw_dataset.append_save(results, partition=partition, is_final_batch=is_final_batch)
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
def download_dated_dataset(self,
|
|
199
|
+
clear: bool = False,
|
|
200
|
+
continue_if_exists: bool = True,
|
|
201
|
+
after_date: datetime = None,
|
|
202
|
+
before_date: datetime = None,
|
|
203
|
+
frequency: str = "ME") -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Download statistics in dated partitions (monthly by default).
|
|
206
|
+
|
|
207
|
+
This overload replaces the base class downloader because the underlying
|
|
208
|
+
RapidPro client does not provide a ``get_statistics`` iterator. It performs
|
|
209
|
+
one HTTP request per period and stores each period as a final raw batch in the
|
|
210
|
+
partition ``[workspace_code, YYYY, YYYY-MM-DD]``.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
clear (bool, optional): Whether to clear each target partition before
|
|
214
|
+
downloading the corresponding period.
|
|
215
|
+
continue_if_exists (bool, optional): If ``True``, already completed
|
|
216
|
+
partitions are skipped and incomplete partitions are redownloaded.
|
|
217
|
+
If ``False``, incomplete existing partitions raise ``ValueError``.
|
|
218
|
+
after_date (datetime, optional): Start date for iteration. If not provided,
|
|
219
|
+
defaults to ``BaseDataset.INIT_DATE`` or the last downloaded date when
|
|
220
|
+
``continue_if_exists=True``.
|
|
221
|
+
before_date (datetime, optional): End boundary for iteration. If not
|
|
222
|
+
provided, defaults to yesterday end-of-day.
|
|
223
|
+
frequency (str, optional): One of ``D``, ``W``, ``ME``, or ``Y``.
|
|
224
|
+
Defaults to ``ME`` (month-end periods).
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
bool: ``True`` when all periods are processed successfully.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
ValueError: If ``after_date`` is greater than ``before_date``.
|
|
231
|
+
ValueError: If ``frequency`` is not one of ``D``, ``W``, ``ME``, ``Y``.
|
|
232
|
+
ValueError: If ``continue_if_exists=True`` and the last downloaded date is
|
|
233
|
+
later than ``before_date``.
|
|
234
|
+
requests.HTTPError: If an API request fails with a non-success status code.
|
|
235
|
+
requests.RequestException: For transport-level request failures.
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
>>> ds.download_dated_dataset(
|
|
239
|
+
... after_date=datetime(2026, 1, 1),
|
|
240
|
+
... before_date=datetime(2026, 3, 31, 23, 59, 59),
|
|
241
|
+
... frequency="ME",
|
|
242
|
+
... continue_if_exists=True,
|
|
243
|
+
... )
|
|
244
|
+
True
|
|
245
|
+
"""
|
|
246
|
+
if after_date and before_date and after_date > before_date:
|
|
247
|
+
raise ValueError("after_date cannot be greater than before_date")
|
|
248
|
+
|
|
249
|
+
if frequency not in ["D", "W", "ME", "Y"]:
|
|
250
|
+
raise ValueError("frequency must be one of the following: D, W, ME, Y")
|
|
251
|
+
|
|
252
|
+
if continue_if_exists and after_date is None:
|
|
253
|
+
last_date = self.get_last_downloaded_date()
|
|
254
|
+
if last_date:
|
|
255
|
+
after_date = last_date
|
|
256
|
+
if before_date and last_date and last_date > before_date:
|
|
257
|
+
raise ValueError(f"last_date downloaded ({last_date}) cannot be greater than before_date ({before_date})")
|
|
258
|
+
|
|
259
|
+
after_date = after_date or self.INIT_DATE
|
|
260
|
+
end_loop = before_date or yesterday_end_of_day()
|
|
261
|
+
|
|
262
|
+
# Use period-start frequencies so that the current incomplete period
|
|
263
|
+
# (e.g. the month in progress) is always included in the iteration.
|
|
264
|
+
# With "ME" the last generated date would be the previous month-end,
|
|
265
|
+
# leaving the current partial month out of the loop entirely.
|
|
266
|
+
_PERIOD_START_FREQ = {"ME": "MS", "Y": "YS", "W": "W-MON", "D": "D"}
|
|
267
|
+
iter_freq = _PERIOD_START_FREQ.get(frequency, frequency)
|
|
268
|
+
|
|
269
|
+
for i_date in pd.date_range(start=after_date, end=end_loop, freq=iter_freq):
|
|
270
|
+
i_after_date, i_before_date = get_date_range(a_date=i_date, frequency=frequency)
|
|
271
|
+
ws_partition = [i_after_date.strftime("%Y"), i_after_date.strftime("%Y-%m-%d")]
|
|
272
|
+
until_exclusive = i_before_date + timedelta(microseconds=1)
|
|
273
|
+
self._download_period(
|
|
274
|
+
ws_partition=ws_partition,
|
|
275
|
+
since_date=i_after_date,
|
|
276
|
+
until_date=until_exclusive,
|
|
277
|
+
clear=clear,
|
|
278
|
+
continue_if_exists=continue_if_exists,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return True
|
|
@@ -5,4 +5,7 @@ from .MessagesDataset import MessagesDataset
|
|
|
5
5
|
from .FlowsDataset import FlowsDataset
|
|
6
6
|
from .RunsDataset import RunsDataset
|
|
7
7
|
from .GroupsDataset import GroupsDataset
|
|
8
|
-
from .FieldsDataset import FieldsDataset
|
|
8
|
+
from .FieldsDataset import FieldsDataset
|
|
9
|
+
from . import StatisticsDataset as _StatisticsDatasetModule
|
|
10
|
+
|
|
11
|
+
StatisticsDataset = _StatisticsDatasetModule.StatisticsDataset
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
STATISTICS_COUNT_FIELDS = (
|
|
5
|
+
"text:in",
|
|
6
|
+
"text:out",
|
|
7
|
+
"voice:in",
|
|
8
|
+
"voice:out",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_count_key(key):
|
|
13
|
+
return key.replace(":", "_")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def process_statistic(statistic, metadata=None):
|
|
17
|
+
"""
|
|
18
|
+
Process a single RapidPro statistics day entry into flat per-channel rows.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
statistic (dict): Daily statistics entry returned by the RapidPro API.
|
|
22
|
+
metadata (dict, optional): Additional metadata merged into each output row.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
list: One flattened row per channel for the given date.
|
|
26
|
+
"""
|
|
27
|
+
if not isinstance(statistic, dict):
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
date = statistic.get("date")
|
|
31
|
+
channels = statistic.get("channels") or {}
|
|
32
|
+
if not isinstance(channels, dict):
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
processed = []
|
|
36
|
+
for channel_uuid, channel_stats in channels.items():
|
|
37
|
+
if not isinstance(channel_stats, dict):
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
row = {
|
|
41
|
+
"date": date,
|
|
42
|
+
"date_month": date[:7] if date else None, # "YYYY-MM"
|
|
43
|
+
"date_year": date[:4] if date else None, # "YYYY"
|
|
44
|
+
"channel_uuid": channel_uuid,
|
|
45
|
+
"channel_type": channel_stats.get("type"),
|
|
46
|
+
}
|
|
47
|
+
for field in STATISTICS_COUNT_FIELDS:
|
|
48
|
+
row[_normalize_count_key(field)] = channel_stats.get(field, 0)
|
|
49
|
+
|
|
50
|
+
if metadata:
|
|
51
|
+
row.update(copy.deepcopy(metadata))
|
|
52
|
+
|
|
53
|
+
processed.append(row)
|
|
54
|
+
|
|
55
|
+
return processed
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def process_statistics(stats: list, metadata: dict = None):
|
|
59
|
+
"""
|
|
60
|
+
Process a RapidPro statistics ``results`` array into flat per-channel rows.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
stats (list): List of daily statistics entries from the RapidPro API response.
|
|
64
|
+
metadata (dict, optional): Additional metadata merged into each output row.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
list: Flattened statistics rows.
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(stats, list):
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
processed = []
|
|
73
|
+
for statistic in stats:
|
|
74
|
+
processed.extend(process_statistic(statistic, metadata))
|
|
75
|
+
return processed
|
|
@@ -176,7 +176,7 @@ def process_ureport_contacts_pl(contacts, fields: list = None, groups:list = Non
|
|
|
176
176
|
# "metadata_col1": "value1",
|
|
177
177
|
# "field1": "value1",
|
|
178
178
|
# "field2": "value2",
|
|
179
|
-
# "age_group": "0-
|
|
179
|
+
# "age_group": "0-17",
|
|
180
180
|
#. "registration_date": "2023-01-01",
|
|
181
181
|
# "country": "Country A",
|
|
182
182
|
#. "state": "State A",
|
|
@@ -205,7 +205,7 @@ def process_ureport_contacts_pl(contacts, fields: list = None, groups:list = Non
|
|
|
205
205
|
|
|
206
206
|
# merge fields with the ureport specific fields
|
|
207
207
|
fields = fields or []
|
|
208
|
-
fields.extend([registration_field, state_field, district_field, ward_field, born_field, gender_field])
|
|
208
|
+
fields.extend([registration_field, state_field, district_field, ward_field, born_field, gender_field, country_field])
|
|
209
209
|
logging.debug("Fields after merging with ureport specific fields: %s", fields)
|
|
210
210
|
|
|
211
211
|
# merge groups with the ureport specific fields
|
|
@@ -233,7 +233,7 @@ def process_ureport_contacts_pl(contacts, fields: list = None, groups:list = Non
|
|
|
233
233
|
|
|
234
234
|
# cast any of the columns that are null to utf8
|
|
235
235
|
cast_operations = []
|
|
236
|
-
utf8_cols = ['registration_date', 'state', 'district', 'ward', 'gender', 'born']
|
|
236
|
+
utf8_cols = ['registration_date', 'country', 'state', 'district', 'ward', 'gender', 'born']
|
|
237
237
|
bool_cols = ['is_ureporter']
|
|
238
238
|
|
|
239
239
|
for col in utf8_cols:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rapidpro-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A library for extracting and transforming data from RapidPro
|
|
5
5
|
Home-page: https://github.com/unicef/magasin-primero-paquet
|
|
6
6
|
Author: merlos
|
|
@@ -20,6 +20,7 @@ Requires-Dist: fsspec==2025.3.2
|
|
|
20
20
|
Requires-Dist: pyarrow==18.1.0
|
|
21
21
|
Requires-Dist: adlfs==2024.12.0
|
|
22
22
|
Requires-Dist: deltalake==0.25.5
|
|
23
|
+
Requires-Dist: ijson==3.3.0
|
|
23
24
|
Provides-Extra: dev
|
|
24
25
|
Requires-Dist: pytest==8.3.5; extra == "dev"
|
|
25
26
|
Requires-Dist: pytest-cov==6.1.1; extra == "dev"
|