Files
multiarch-docker-images/automatic-pdf-renamer/app/period.py
T

752 lines
25 KiB
Python

"""Period computation module for Automatic PDF Renamer.
Provides period parsing from filenames and computation from emission dates
per template rules (PRD section 4.1, FR4.2, FR4.3).
Supports custom period format templates with placeholders:
- {year}, {year_next}, {year_previous}
- {month}, {month_name}, {month_abbr}
- {day}
- {quarter}, {semester}, {week}
"""
import re
from dataclasses import dataclass
from datetime import date, datetime
from enum import Enum
from typing import Any
from dateutil.relativedelta import relativedelta
class PeriodGranularity(Enum):
"""Granularity of period computation (PRD section 4.1)."""
DAY = "day"
WEEK = "week"
MONTH = "month"
QUARTER = "quarter"
SEMESTER = "semester"
YEAR = "year"
class PeriodOffset(Enum):
"""Offset for period computation (PRD section 4.1)."""
CURRENT = "current"
PREVIOUS = "previous"
FOLLOWING = "following"
@dataclass
class PeriodRule:
"""Template period rule for period computation."""
granularity: PeriodGranularity
offset: PeriodOffset = PeriodOffset.CURRENT
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PeriodRule":
"""Create a PeriodRule from a dict."""
return cls(
granularity=PeriodGranularity(data.get("granularity", "month")),
offset=PeriodOffset(data.get("offset", "current")),
)
# French month names mapping
_FRENCH_MONTHS = {
"Janvier": 1,
"F\u00e9vrier": 2,
"Mars": 3,
"Avril": 4,
"Mai": 5,
"Juin": 6,
"Juillet": 7,
"Ao\u00fbt": 8,
"Septembre": 9,
"Octobre": 10,
"Novembre": 11,
"D\u00e9cembre": 12,
# English fallbacks
"January": 1,
"February": 2,
"March": 3,
"April": 4,
"May": 5,
"June": 6,
"July": 7,
"August": 8,
"September": 9,
"October": 10,
"November": 11,
"December": 12,
}
# Month name to full French name for output
_FRENCH_MONTH_NAMES = {
"01": "Janvier", "02": "F\u00e9vrier", "03": "Mars", "04": "Avril", "05": "Mai", "06": "Juin",
"07": "Juillet", "08": "Ao\u00fbt", "09": "Septembre", "10": "Octobre", "11": "Novembre", "12": "D\u00e9cembre",
}
# Month abbreviation mapping
_MONTH_ABBR = {
"01": "Jan", "02": "Feb", "03": "Mar", "04": "Apr", "05": "May", "06": "Jun",
"07": "Jul", "08": "Aug", "09": "Sep", "10": "Oct", "11": "Nov", "12": "Dec",
}
# Quarter pattern - matches Q3 2025, T3 2025, 3 2025, Q3-2025, trimestre 3 2025, 3T2025, etc.
_QUARTER_PATTERN = re.compile(
r"(?:Q|T|trimestre|quarter)?[\s\-]?(\d{1,2})[\s\-]?(?:Q|T|trimestre|quarter)?[\s\-]?(\d{4})\b|\b(\d{1,2})[TQ]?(\d{4})\b",
re.IGNORECASE,
)
# Year pattern
_YEAR_PATTERN = re.compile(r"\b(\d{4})\b")
# Month-year patterns (various formats)
_MONTH_YEAR_PATTERNS = [
# "ao\u00fbt 2025", "August 2025" - French/English month name + year
re.compile(
rf"\b([{''.join(_FRENCH_MONTHS.keys())}]+)\s+(\d{{4}})\b",
re.IGNORECASE,
),
# "2025-08", "2025/08", "08-2025"
re.compile(r"(\d{4})[-/](\d{1,2})\b"),
re.compile(r"\b(\d{1,2})[-/](\d{4})\b"),
# "Aug 2025", "Aug-2025"
re.compile(r"\b([A-Za-z]{3})[-\s]?(\d{4})\b"),
# "2025 08", "08 2025"
re.compile(r"\b(\d{4})\s+(\d{1,2})\b"),
re.compile(r"\b(\d{1,2})\s+(\d{4})\b"),
]
# Date pattern (YYYY-MM-DD, DD/MM/YYYY, etc.)
_DATE_PATTERN = re.compile(
r"(\d{4})[-/](\d{1,2})[-/](\d{1,2})|(\d{1,2})[-/](\d{1,2})[-/](\d{4})"
)
def _parse_french_month(month_str: str) -> int | None:
"""Parse a French or English month name to month number."""
month_lower = month_str.lower().strip()
return _FRENCH_MONTHS.get(month_lower)
def _parse_month_abbr(abbr: str) -> int | None:
"""Parse a 3-letter month abbreviation to month number."""
abbr_upper = abbr.upper()
for num, name in _MONTH_ABBR.items():
if name.upper() == abbr_upper:
return num
return None
def parse_period_from_filename(filename: str) -> str | None:
"""Parse a period string from a filename.
Tries to extract period from the filename, looking at various positions.
Supports formats:
- Month-year: "ao\u00fbt 2025", "August 2025", "Aug 2025", "2025-08"
- Quarter: "Q3 2025", "T3 2025", "3 2025", "Q3-2025"
- Year: "2025"
- Date: "2025-08-31", "31/08/2025"
Args:
filename: The filename to parse (without path).
Returns:
Parsed period string (e.g., "ao\u00fbt 2025", "Q3 2025", "2025"), or None.
"""
# Get the filename without path
basename = filename.split("/")[-1].split("\\")[-1]
# Remove extension
name = basename.rsplit(".", 1)[0]
# First, try to find quarter patterns anywhere in the filename
quarter_match = _QUARTER_PATTERN.search(name)
if quarter_match:
# Handle different group patterns
groups = quarter_match.groups()
quarter_num = None
year = None
for g in groups:
if g and g.isdigit() and len(g) <= 2:
q = int(g)
if 1 <= q <= 4 and quarter_num is None:
quarter_num = q
elif g and g.isdigit() and len(g) == 4:
year = int(g)
if quarter_num and year:
return format_quarter(quarter_num, year)
# Try to find month-year patterns anywhere in the filename
for pattern in _MONTH_YEAR_PATTERNS:
match = pattern.search(name)
if match:
groups = match.groups()
year = None
month = None
for g in groups:
if g and g.isdigit() and len(g) == 4:
year = int(g)
elif g and g.isdigit() and len(g) <= 2:
if month is None:
month = int(g)
elif g:
# Try as month name (French or English)
parsed = _parse_french_month(g)
if parsed:
month = parsed
else:
parsed = _parse_month_abbr(g)
if parsed:
month = parsed
# If we found a year and a month, return it
if year is not None and month is not None:
return format_month_year(month, year)
# Try to find date patterns (YYYY-MM-DD, DD/MM/YYYY, etc.)
date_match = _DATE_PATTERN.search(name)
if date_match:
groups = date_match.groups()
year = None
month = None
day = None
for g in groups:
if g and len(g) == 4:
year = int(g)
elif g and len(g) <= 2:
if day is None:
day = int(g)
else:
month = int(g)
if year and month:
return format_month_year(month, year)
elif year:
return str(year)
# Try simple year pattern anywhere
year_match = _YEAR_PATTERN.search(name)
if year_match:
return year_match.group(1)
return None
def _parse_date_string(date_str: str) -> date:
"""Parse a date string into a date object.
Supports various formats: YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, etc.
Args:
date_str: The date string to parse.
Returns:
date object.
Raises:
ValueError: If the date string cannot be parsed.
"""
if isinstance(date_str, date):
return date_str
# Try ISO format first
try:
return datetime.fromisoformat(date_str).date()
except ValueError:
pass
# Try other common formats
formats = [
"%Y-%m-%d",
"%d/%m/%Y",
"%m/%d/%Y",
"%d-%m-%Y",
"%Y/%m/%d",
"%Y-%m",
"%m-%Y",
"%Y",
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt).date()
except ValueError:
continue
# Try to parse month name + year (e.g., "August 2025", "ao\u00fbt 2025")
month_year_match = re.match(
rf"^([{''.join(_FRENCH_MONTHS.keys())}]+)\s+(\d{{4}})$",
date_str.strip(),
re.IGNORECASE
)
if month_year_match:
month_name = month_year_match.group(1)
year = int(month_year_match.group(2))
month = _parse_french_month(month_name)
if month:
return date(year, month, 1)
# Try year only
if re.match(r"^\d{4}$", date_str):
return date(int(date_str), 1, 1)
raise ValueError(f"Cannot parse date string: {date_str}")
def compute_period_from_date(
emission_date: str | date,
rule: PeriodRule,
) -> str:
"""Compute period from emission date and template rule.
Args:
emission_date: The document emission date (ISO format YYYY-MM-DD or date object).
rule: PeriodRule with granularity and offset.
Returns:
Human-readable period string.
"""
dt = _parse_date_string(emission_date)
if rule.granularity == PeriodGranularity.DAY:
return f"{dt.day:02d} {_FRENCH_MONTH_NAMES[dt.month]} {dt.year:04d}"
elif rule.granularity == PeriodGranularity.MONTH:
if rule.offset == PeriodOffset.CURRENT:
return format_month_year(dt.month, dt.year)
elif rule.offset == PeriodOffset.PREVIOUS:
prev_month = dt + relativedelta(months=-1)
return format_month_year(prev_month.month, prev_month.year)
else: # FOLLOWING
next_month = dt + relativedelta(months=1)
return format_month_year(next_month.month, next_month.year)
elif rule.granularity == PeriodGranularity.QUARTER:
quarter = (dt.month - 1) // 3 + 1
if rule.offset == PeriodOffset.CURRENT:
return format_quarter(quarter, dt.year)
elif rule.offset == PeriodOffset.PREVIOUS:
prev_quarter = quarter - 1
prev_year = dt.year
if prev_quarter < 1:
prev_quarter = 4
prev_year -= 1
return format_quarter(prev_quarter, prev_year)
else: # FOLLOWING
next_quarter = quarter + 1
next_year = dt.year
if next_quarter > 4:
next_quarter = 1
next_year += 1
return format_quarter(next_quarter, next_year)
elif rule.granularity == PeriodGranularity.SEMESTER:
semester = (dt.month - 1) // 6 + 1
if rule.offset == PeriodOffset.CURRENT:
return f"S{semester} {dt.year:04d}"
elif rule.offset == PeriodOffset.PREVIOUS:
prev_semester = semester - 1
prev_year = dt.year
if prev_semester < 1:
prev_semester = 2
prev_year -= 1
return f"S{prev_semester} {prev_year}"
else: # FOLLOWING
next_semester = semester + 1
next_year = dt.year
if next_semester > 2:
next_semester = 1
next_year += 1
return f"S{next_semester} {next_year:04d}"
elif rule.granularity == PeriodGranularity.WEEK:
# ISO week number
iso_year, iso_week, _ = dt.isocalendar()
if rule.offset == PeriodOffset.CURRENT:
return f"Week {iso_week:02d} {iso_year:04d}"
elif rule.offset == PeriodOffset.PREVIOUS:
prev_date = dt - relativedelta(weeks=1)
prev_year, prev_week, _ = prev_date.isocalendar()
return f"Week {prev_week:02d} {prev_year:04d}"
else: # FOLLOWING
next_date = dt + relativedelta(weeks=1)
next_year, next_week, _ = next_date.isocalendar()
return f"Week {next_week:02d} {next_year:04d}"
elif rule.granularity == PeriodGranularity.YEAR:
if rule.offset == PeriodOffset.CURRENT:
return str(dt.year)
elif rule.offset == PeriodOffset.PREVIOUS:
return str(dt.year - 1)
else: # FOLLOWING
return str(dt.year + 1)
else:
raise ValueError(f"Unknown granularity: {rule.granularity}")
def format_period_from_emission_date(
emission_date: str | date,
period_format: dict[str, Any],
) -> str:
"""Format period from emission date using custom period format configuration.
The period_format dict contains:
- granularity: day, week, month, quarter, semester, year
- format: Custom template string with placeholders
- offset: current, previous, following
Supported placeholders in format template:
- {year}: Current year
- {year_next}: Next year
- {year_previous}: Previous year
- {month}: Month number (1-12)
- {month_name}: Full month name (French)
- {month_abbr}: 3-letter month abbreviation
- {day}: Day of month (1-31)
- {quarter}: Quarter number (1-4)
- {semester}: Semester number (1-2)
- {week}: ISO week number (1-53)
Args:
emission_date: The document emission date (string or date object).
period_format: Dict with granularity, format, and offset keys.
Returns:
Formatted period string with placeholders replaced.
Raises:
ValueError: If granularity is unknown or format template is invalid.
"""
dt = _parse_date_string(emission_date)
granularity = period_format.get("granularity", "month")
format_template = period_format.get("format", "")
offset = period_format.get("offset", "current")
# Determine the base date based on offset
if offset == "previous":
# Date is at the end of the period, so we need the previous period
if granularity == "day":
base_date = dt - relativedelta(days=1)
elif granularity == "week":
base_date = dt - relativedelta(weeks=1)
elif granularity == "month":
base_date = dt - relativedelta(months=1)
elif granularity == "quarter":
quarter = (dt.month - 1) // 3 + 1
if quarter == 1:
base_date = date(dt.year - 1, 12, 1)
else:
base_date = date(dt.year, (quarter - 1) * 3, 1)
elif granularity == "semester":
semester = (dt.month - 1) // 6 + 1
if semester == 1:
base_date = date(dt.year - 1, 12, 1)
else:
base_date = date(dt.year, 6, 1)
elif granularity == "year":
base_date = date(dt.year - 1, 1, 1)
else:
base_date = dt
elif offset == "following":
# Date is at the beginning of the period, so we need the following period
if granularity == "day":
base_date = dt + relativedelta(days=1)
elif granularity == "week":
base_date = dt + relativedelta(weeks=1)
elif granularity == "month":
base_date = dt + relativedelta(months=1)
elif granularity == "quarter":
quarter = (dt.month - 1) // 3 + 1
if quarter == 4:
base_date = date(dt.year + 1, 1, 1)
else:
base_date = date(dt.year, (quarter + 1) * 3, 1)
elif granularity == "semester":
semester = (dt.month - 1) // 6 + 1
if semester == 2:
base_date = date(dt.year + 1, 1, 1)
else:
base_date = date(dt.year, 6, 1)
elif granularity == "year":
base_date = date(dt.year + 1, 1, 1)
else:
base_date = dt
else: # current
# Date is in the middle of the period, use the date as-is
base_date = dt
# Calculate period values based on granularity
period_values: dict[str, Any] = {}
if granularity == "day":
period_values["year"] = f"{base_date.year:04d}"
period_values["month"] = f"{base_date.month:02d}"
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{base_date.month:02d}"]
period_values["month_abbr"] = _MONTH_ABBR[f"{base_date.month:02d}"]
period_values["day"] = f"{base_date.day:02d}"
period_values["quarter"] = (base_date.month - 1) // 3 + 1
period_values["semester"] = (base_date.month - 1) // 6 + 1
_, period_values["week"], _ = base_date.isocalendar()
elif granularity == "week":
_, period_values["week"], _ = base_date.isocalendar()
period_values["year"] = f"{base_date.year:04d}"
period_values["month"] = f"{base_date.month:02d}"
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{base_date.month:02d}"]
period_values["month_abbr"] = _MONTH_ABBR[f"{base_date.month:02d}"]
period_values["day"] = f"{base_date.day:02d}"
period_values["quarter"] = (base_date.month - 1) // 3 + 1
period_values["semester"] = (base_date.month - 1) // 6 + 1
elif granularity == "month":
period_values["year"] = f"{base_date.year:04d}"
period_values["month"] = f"{base_date.month:02d}"
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{base_date.month:02d}"]
period_values["month_abbr"] = _MONTH_ABBR[f"{base_date.month:02d}"]
period_values["day"] = f"{1:02d}" # Default to first day of month
period_values["quarter"] = (base_date.month - 1) // 3 + 1
period_values["semester"] = (base_date.month - 1) // 6 + 1
_, period_values["week"], _ = base_date.isocalendar()
elif granularity == "quarter":
quarter = (base_date.month - 1) // 3 + 1
period_values["quarter"] = quarter
period_values["year"] = f"{base_date.year:04d}"
period_values["month"] = f"{(quarter - 1) * 3 + 1:02d}" # First month of quarter
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{period_values['month']}"]
period_values["month_abbr"] = _MONTH_ABBR[f"{period_values['month']}"]
period_values["day"] = f"{1:02d}"
period_values["semester"] = (quarter - 1) // 2 + 1
_, period_values["week"], _ = base_date.isocalendar()
elif granularity == "semester":
semester = (base_date.month - 1) // 6 + 1
period_values["semester"] = semester
period_values["year"] = f"{base_date.year:04d}"
period_values["month"] = f"{(semester - 1) * 6 + 1:02d}" # First month of semester
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{period_values['month']}"]
period_values["month_abbr"] = _MONTH_ABBR[f"{period_values['month']}"]
period_values["day"] = f"{1:02d}"
period_values["quarter"] = (period_values["month"] - 1) // 3 + 1
_, period_values["week"], _ = base_date.isocalendar()
elif granularity == "year":
period_values["year"] = f"{base_date.year:04d}"
period_values["month"] = f"{1:02d}"
period_values["month_name"] = "janvier"
period_values["month_abbr"] = "Jan"
period_values["day"] = f"{1:02d}"
period_values["quarter"] = 1
period_values["semester"] = 1
_, period_values["week"], _ = base_date.isocalendar()
else:
raise ValueError(f"Unknown granularity: {granularity}")
# Add year_next and year_previous
period_values["year_next"] = f"{int(period_values['year']) + 1:04d}"
period_values["year_previous"] = f"{int(period_values['year']) - 1:04d}"
# Format the template
try:
return format_template.format(**period_values)
except KeyError as e:
missing = str(e).strip("'")
raise ValueError(f"Unknown placeholder '{missing}' in format template: {format_template}")
def get_period_key_values(
emission_date: str | date,
period_format: dict[str, Any],
) -> dict[str, str]:
"""Return period placeholders, preserving the source date's day of month."""
separator = "\x1f"
values = format_period_from_emission_date(
emission_date,
{**period_format, "format": separator.join(
("{year}", "{month}", "{month_name}", "{day}")
)},
).split(separator)
year, month, month_name, _ = values
return {
"year": year,
"month": month,
"month_name": month_name,
"day": f"{_parse_date_string(emission_date).day:02d}",
}
def format_month_year(month: int, year: int, use_french: bool = True) -> str:
"""Format month and year as a human-readable string.
Uses French month names as per PRD by default.
Args:
month: Month number (1-12).
year: Year number.
use_french: If True, use French month names; otherwise use English.
Returns:
Formatted string (e.g., "ao\u00fbt 2025" or "August 2025").
"""
if month < 1 or month > 12:
raise ValueError(f"Invalid month: {month}")
if use_french:
month_name = _FRENCH_MONTH_NAMES[month]
else:
month_name = _MONTH_ABBR[month]
return f"{month_name} {year:04d}"
def format_quarter(quarter: int, year: int) -> str:
"""Format quarter and year as a human-readable string.
Args:
quarter: Quarter number (1-4).
year: Year number.
Returns:
Formatted string (e.g., "Q3 2025").
"""
if quarter < 1 or quarter > 4:
raise ValueError(f"Invalid quarter: {quarter}")
return f"Q{quarter} {year:04d}"
def get_period(
filename: str | None = None,
emission_date: str | date | None = None,
rule: PeriodRule | None = None,
) -> tuple[str, bool]:
"""Get the period for a document, trying filename first, then fallback to rule.
This is the main entry point for period computation.
Args:
filename: The document filename (optional).
emission_date: The document emission date (optional).
rule: The template period rule (optional, required if emission_date provided).
Returns:
Tuple of (period_string, is_from_filename).
If period is parsed from filename, is_from_filename is True.
If computed from rule, is_from_filename is False.
Raises:
ValueError: If no period can be determined and no fallback is possible.
"""
# Try to parse from filename first
if filename:
period = parse_period_from_filename(filename)
if period:
return (period, True)
# Fallback to rule-based computation
if emission_date and rule:
period = compute_period_from_date(emission_date, rule)
return (period, False)
# If we have emission_date but no rule, use month-year as fallback
if emission_date:
dt = _parse_date_string(emission_date)
return (format_month_year(dt.month, dt.year), False)
raise ValueError("Cannot determine period: no filename, emission_date, or rule provided")
def validate_period_consistency(
period: str,
emission_date: str | date,
rule: PeriodRule,
) -> tuple[bool, str | None]:
"""Validate that a period is consistent with emission date and template rule.
Args:
period: The period string to validate.
emission_date: The document emission date.
rule: The template period rule.
Returns:
Tuple of (is_consistent, error_message).
If consistent, error_message is None.
"""
dt = _parse_date_string(emission_date)
# Compute expected period from rule
expected_period = compute_period_from_date(dt, rule)
# Normalize both periods for comparison
# This is a simple comparison; may need enhancement for different formats
if period.lower() == expected_period.lower():
return (True, None)
# Try to parse both and compare
try:
parsed = _parse_period_string(period)
expected_parsed = _parse_period_string(expected_period)
if parsed == expected_parsed:
return (True, None)
except ValueError:
pass
return (
False,
f"Period '{period}' is inconsistent with emission_date '{emission_date}' "
f"and rule {rule.granularity.value}/{rule.offset.value}. "
f"Expected: {expected_period}",
)
def _parse_period_string(period: str) -> tuple[int, int, int | None]:
"""Parse a period string into (year, month, quarter) tuple.
Returns:
(year, month, quarter) where month and quarter may be None.
"""
period_lower = period.lower()
# Try year only
year_match = _YEAR_PATTERN.search(period_lower)
if year_match:
year = int(year_match.group(1))
return (year, None, None)
# Try quarter
quarter_match = _QUARTER_PATTERN.search(period_lower)
if quarter_match:
quarter = int(quarter_match.group(1))
year = int(quarter_match.group(2))
return (year, None, quarter)
# Try month-year
for pattern in _MONTH_YEAR_PATTERNS:
match = pattern.search(period_lower)
if match:
groups = match.groups()
year = None
month = None
for g in groups:
if g and g.isdigit() and len(g) == 4:
year = int(g)
elif g and g.isdigit() and len(g) <= 2:
month = int(g)
elif g:
parsed_month = _parse_french_month(g)
if parsed_month:
month = parsed_month
else:
parsed_abbr = _parse_month_abbr(g)
if parsed_abbr:
month = parsed_abbr
if year and month:
return (year, month, None)
raise ValueError(f"Cannot parse period string: {period}")