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.
Files changed (74) hide show
  1. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/PKG-INFO +2 -1
  2. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/contact_processors.py +9 -15
  3. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/BaseDataset.py +64 -26
  4. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/RawDataset.py +56 -1
  5. rapidpro_api-0.2.1/rapidpro_api/datasets/StatisticsDataset.py +281 -0
  6. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/__init__.py +4 -1
  7. rapidpro_api-0.2.1/rapidpro_api/statistics_processors.py +75 -0
  8. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/ureport_processors_pl.py +3 -3
  9. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/version.py +1 -1
  10. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/PKG-INFO +2 -1
  11. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/SOURCES.txt +4 -0
  12. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/requires.txt +1 -0
  13. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/setup.py +1 -0
  14. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_base_dataset.py +50 -0
  15. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_raw_dataset.py +41 -1
  16. rapidpro_api-0.2.1/tests/datasets/test_statistics_dataset.py +159 -0
  17. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_process_stats_list.py +6 -8
  18. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_contact_processors.py +6 -10
  19. rapidpro_api-0.2.1/tests/test_statistics_processors.py +214 -0
  20. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_ureport_processors_pl.py +1 -1
  21. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/README.md +0 -0
  22. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/__init__.py +0 -0
  23. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/contact_processors_pl.py +0 -0
  24. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/ContactsDataset.py +0 -0
  25. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/DeltaLakeDataset.py +0 -0
  26. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/FieldsDataset.py +0 -0
  27. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/FlowsDataset.py +0 -0
  28. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/GroupsDataset.py +0 -0
  29. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/MessagesDataset.py +0 -0
  30. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/datasets/RunsDataset.py +0 -0
  31. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/field_processors.py +0 -0
  32. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/flow_processors.py +0 -0
  33. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/group_processors.py +0 -0
  34. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/message_processors.py +0 -0
  35. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/run_processors_pl.py +0 -0
  36. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/__init__.py +0 -0
  37. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/download_stats.py +0 -0
  38. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/download_stats_list.py +0 -0
  39. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/process_stats.py +0 -0
  40. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/process_stats_list.py +0 -0
  41. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/stats/stats.py +0 -0
  42. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/telegram.py +0 -0
  43. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/time_utils.py +0 -0
  44. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/utils.py +0 -0
  45. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/validators.py +0 -0
  46. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api/workspaces.py +0 -0
  47. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/dependency_links.txt +0 -0
  48. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/rapidpro_api.egg-info/top_level.txt +0 -0
  49. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/setup.cfg +0 -0
  50. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/__init__.py +0 -0
  51. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/__init__.py +0 -0
  52. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_contacts_dataset.py +0 -0
  53. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_fields_dataset.py +0 -0
  54. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_flows_dataset.py +0 -0
  55. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_groups_dataset.py +0 -0
  56. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_messages_dataset.py +0 -0
  57. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/datasets/test_runs_dataset.py +0 -0
  58. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/__init__.py +0 -0
  59. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_download_stats.py +0 -0
  60. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_download_stats_list.py +0 -0
  61. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_process_stats.py +0 -0
  62. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/stats/test_stats.py +0 -0
  63. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_contact_processors_pl.py +0 -0
  64. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_field_processors.py +0 -0
  65. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_flow_processor.py +0 -0
  66. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_group_processors.py +0 -0
  67. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_message_processors.py +0 -0
  68. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_run_processors_pl.py +0 -0
  69. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_telegram.py +0 -0
  70. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_time_utils.py +0 -0
  71. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_utils.py +0 -0
  72. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_validators.py +0 -0
  73. {rapidpro_api-0.1.1 → rapidpro_api-0.2.1}/tests/test_version.py +0 -0
  74. {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.1.1
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-14" for ages between 0 and 14 (inclusive).
100
- - "15-19" for ages between 15 and 19 (inclusive).
101
- - "20-24" for ages between 20 and 24 (inclusive).
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 <= 14:
123
- return "0-14"
124
- elif 15 <= age <= 19:
125
- return "15-19"
126
- elif 20 <= age <= 24:
127
- return "20-24"
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 iter_dated_batch_raw_files(self, after_date: datetime=None, before_date: datetime=None, with_file_info:bool=False) -> [list, dict]:
420
+ def _iter_dated_file_rows(self, after_date: datetime=None, before_date: datetime=None):
421
421
  """
422
- Iterate over the existing dataset file batches.
423
- Args:
424
- after_date: The date to start the iteration from. If not set will iterate from the first date in the dataset.
425
- before_date: The date to end the iteration. If not set will iterate until the last date in the dataset.
426
- with_file_info: If True, it will yield a tuple with the file info and the raw data. If False, it will yield only the raw data.
427
- Yields:
428
- A tuple with the file info (dict) and the raw data (list of dict where each dict is a record from RproAPI) if with_file_info is True, otherwise
429
- it will yield only the raw data.
430
- Returns:
431
- A generator that yields batches of data (list of dicts).
432
- Raises:
433
- FileNotFoundError: If no data has been downloaded yet or if the workspace is not found.
434
- Example:
435
- >>> ds = BaseDataset(name="contacts", workspace=Workspace(code="code"))
436
- >>> for file_info, raw_data in ds.iter_dated_batch_raw_files(after_date=datetime(2023, 10, 1), before_date=datetime(2023, 10, 31), with_file_info=True):
437
- >>> print(file_info) # file info will be a dict of RawDataset file info
438
- >>> print(raw_data) # raw_data will be a list of dicts with the raw data from RapidPro API
439
- >>> # process the raw_data here
440
- >>> # e.g. process_contacts_pl(raw_data, metadata=metadata)
441
- >>> # also getting the index of the file (explore the whole dataset)
442
- >>> for index, (file_info, raw_data) in enumerate(ds.iter_dated_batch_raw_files(with_file_info=True)):
443
- >>> print(f"Processing file {index+1}: {file_info['path']}")
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-14",
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:
@@ -2,7 +2,7 @@
2
2
  """
3
3
  Version
4
4
  """
5
- __version__ = "0.1.1"
5
+ __version__ = "0.2.1"
6
6
 
7
7
  def get_version():
8
8
  return __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rapidpro-api
3
- Version: 0.1.1
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"