Source code for episia.dhis2.periods

"""
episia.dhis2.periods - DHIS2 period utilities.

Provides helpers to build period strings compatible with the DHIS2
analytics API.
"""
from __future__ import annotations

import re
from typing import List


[docs] def period_range(start: str, end: str) -> str: """ Build a semicolon-separated DHIS2 period string from *start* to *end* (inclusive). Supports monthly, weekly, and quarterly period formats. Args: start: Start period string. Examples: ``"202401"``, ``"2024W01"``, ``"2024Q1"``. end: End period string in the same format as *start*. Returns: Semicolon-separated period string, e.g. ``"202401;202402;202403"``. Raises: ValueError: if *start* and *end* use different formats, or if the format is not recognised, or if *start* is after *end*. Examples:: from episia.dhis2 import period_range # Monthly period_range("202401", "202406") # → "202401;202402;202403;202404;202405;202406" # Weekly period_range("2024W01", "2024W04") # → "2024W01;2024W02;2024W03;2024W04" # Quarterly period_range("2024Q1", "2024Q4") # → "2024Q1;2024Q2;2024Q3;2024Q4" """ periods = _generate_periods(start, end) if not periods: raise ValueError( f"No periods generated between '{start}' and '{end}'. " "Make sure start <= end." ) return ";".join(periods)
# Internal helpers _MONTHLY_RE = re.compile(r"^(\d{4})(0[1-9]|1[0-2])$") _WEEKLY_RE = re.compile(r"^(\d{4})W(\d{1,2})$") _QUARTERLY_RE = re.compile(r"^(\d{4})Q([1-4])$") def _detect_format(period: str) -> str: """Return 'monthly', 'weekly', or 'quarterly'; raise ValueError otherwise.""" if _MONTHLY_RE.match(period): return "monthly" if _WEEKLY_RE.match(period): return "weekly" if _QUARTERLY_RE.match(period): return "quarterly" raise ValueError( f"Unrecognised DHIS2 period format: '{period}'. " "Expected monthly (YYYYMM), weekly (YYYYWnn), or quarterly (YYYYQn)." ) def _generate_periods(start: str, end: str) -> List[str]: fmt_start = _detect_format(start) fmt_end = _detect_format(end) if fmt_start != fmt_end: raise ValueError( f"start ('{start}') and end ('{end}') use different period formats: " f"'{fmt_start}' vs '{fmt_end}'." ) if fmt_start == "monthly": return _monthly_range(start, end) if fmt_start == "weekly": return _weekly_range(start, end) return _quarterly_range(start, end) def _monthly_range(start: str, end: str) -> List[str]: sy, sm = int(start[:4]), int(start[4:]) ey, em = int(end[:4]), int(end[4:]) if (sy, sm) > (ey, em): raise ValueError(f"start '{start}' is after end '{end}'.") result: List[str] = [] y, m = sy, sm while (y, m) <= (ey, em): result.append(f"{y}{m:02d}") m += 1 if m > 12: m = 1 y += 1 return result def _weekly_range(start: str, end: str) -> List[str]: """ Generate ISO-week period strings from start to end. Uses the ISO 8601 week calendar: week 1 is the week containing the first Thursday of the year (same as Python's datetime.isocalendar). """ import datetime def _parse_week(s: str): m = _WEEKLY_RE.match(s) year, week = int(m.group(1)), int(m.group(2)) # ISO weeks are 1-based; validate range max_week = _iso_weeks_in_year(year) if not (1 <= week <= max_week): raise ValueError( f"Week {week} is out of range for year {year} " f"(max {max_week} ISO weeks)." ) return year, week def _iso_weeks_in_year(year: int) -> int: # The last day of the ISO year is the Thursday of the last week. # Dec 28 is always in the last ISO week of its year. return datetime.date(year, 12, 28).isocalendar()[1] def _next_week(year: int, week: int): max_week = _iso_weeks_in_year(year) week += 1 if week > max_week: week = 1 year += 1 return year, week sy, sw = _parse_week(start) ey, ew = _parse_week(end) if (sy, sw) > (ey, ew): raise ValueError(f"start '{start}' is after end '{end}'.") result: List[str] = [] y, w = sy, sw while (y, w) <= (ey, ew): result.append(f"{y}W{w:02d}") y, w = _next_week(y, w) return result def _quarterly_range(start: str, end: str) -> List[str]: sy, sq = int(start[:4]), int(start[5]) ey, eq = int(end[:4]), int(end[5]) if (sy, sq) > (ey, eq): raise ValueError(f"start '{start}' is after end '{end}'.") result: List[str] = [] y, q = sy, sq while (y, q) <= (ey, eq): result.append(f"{y}Q{q}") q += 1 if q > 4: q = 1 y += 1 return result