import re from dataclasses import dataclass, field from typing import Dict, Any, Optional, List, Tuple SUPPORTED_LANGS = ("ru", "en", "ar") # Default templates matching the specification in Коллекции дат.md # Tuple structure: (display_template, display_template_negative, display_template_age) DEFAULT_TEMPLATES: Dict[str, Dict[str, Tuple[Optional[str], Optional[str], Optional[str]]]] = { "exact": { "ar": ("{value} هـ", "{abs_value} ق.هـ", "{value} سنة"), "ru": ("{value} г.", "{abs_value} г. до хиджры", "{value} г."), "en": ("{value} AH", "{abs_value} BH", "{value} years"), }, "range": { "ar": ("{min}–{max} هـ", "{abs_min} ق.هـ – {max} هـ", "{min}–{max} سنة"), "ru": ("{min}–{max} гг.", "{abs_min} г. до хиджры – {max} г.", "{min}–{max} лет"), "en": ("{min}–{max} AH", "{abs_min} BH – {max} AH", "{min}–{max} years"), }, "circa": { "ar": ("حوالي {value} هـ", "حوالي {abs_value} ق.هـ", "حوالي {value} سنة"), "ru": ("около {value} г.", "около {abs_value} г. до хиджры", "около {value} лет"), "en": ("circa {value} AH", "circa {abs_value} BH", "circa {value} years"), }, "greater_than": { "ar": ("بعد {min} هـ", "بعد {abs_min} ق.هـ", "أكثر من {min} سنة"), "ru": ("после {min} г.", "после {abs_min} г. до хиджры", "более {min} лет"), "en": ("after {min} AH", "after {abs_min} BH", "more than {min} years"), }, "less_than": { "ar": ("قبل {max} هـ", "قبل {abs_max} ق.هـ", "أقل من {max} سنة"), "ru": ("до {max} г.", "до {abs_max} г. до хиджры", "менее {max} лет"), "en": ("before {max} AH", "before {abs_max} BH", "less than {max} years"), }, "alternatives": { "ar": ("{min} أو {max}", None, "{min} أو {max} سنة"), "ru": ("{min} или {max}", None, "{min} или {max} лет"), "en": ("{min} or {max}", None, "{min} or {max} years"), }, "alternatives_range": { "ar": ("{range1} أو {range2}", None, "{range1} أو {range2} سنة"), "ru": ("{range1} или {range2}", None, "{range1} или {range2} лет"), "en": ("{range1} or {range2}", None, "{range1} or {range2} years"), }, "context": { "ar": ("", "", ""), "ru": ("", "", ""), "en": ("", "", ""), }, } DEFAULT_CONTEXT_TAGS: Dict[str, Dict[str, str]] = { "#hijra_habasha": { "ar": "وُلِدَ فِي الْحَبَشَةِ فِي فَتْرَةِ الْهِجْرَةِ الْأُولَى", "ru": "Родился в Абиссинии во время первого переселения", "en": "Born in Abyssinia during the first migration", }, "#early_islam": { "ar": "صدر الإسلام", "ru": "Ранний ислам", "en": "Early Islam", }, "#umayyad_era": { "ar": "العصر الأموي", "ru": "Эпоха Омейядов", "en": "Umayyad era", }, } # Regex patterns for strict validation RE_EXACT = re.compile(r"^-?\d+$") RE_RANGE = re.compile(r"^(-?\d+)\.\.(-?\d+)$") RE_CIRCA = re.compile(r"^~(-?\d+)$") RE_GREATER = re.compile(r"^>(-?\d+)$") RE_LESS = re.compile(r"^<(-?\d+)$") RE_ALT_EXACT = re.compile(r"^-?\d+(?:;-?\d+)+$") RE_ALT_RANGE = re.compile(r"^-?\d+\.\.-?\d+(?:;-?\d+\.\.-?\d+)+$") RE_CONTEXT = re.compile(r"^#([a-zA-Z0-9_]+)$") class CanonicalDateValidationError(ValueError): def __init__(self, message: str, suggestion: Optional[str] = None): super().__init__(message) self.message = message self.suggestion = suggestion @dataclass class ParsedCanonicalDate: raw_value: str is_valid: bool = True parse_type: str = "exact" value: Optional[int] = None value_min: Optional[int] = None value_max: Optional[int] = None values_list: List[int] = field(default_factory=list) abs_value: Optional[int] = None is_bh: bool = False is_mixed: bool = False context_key: Optional[str] = None error: Optional[str] = None suggestion: Optional[str] = None translations: List[Dict[str, Any]] = field(default_factory=list) _threshold: Optional[int] = None _parts: List[int] = field(default_factory=list) _ranges: List[Tuple[int, int]] = field(default_factory=list) @property def canonical(self) -> str: return self.raw_value @property def type(self) -> str: return self.parse_type def get(self, key: str, default: Any = None) -> Any: return getattr(self, key, default) def __getitem__(self, key: str) -> Any: return getattr(self, key) def normalize_lang_code(lang: Optional[str]) -> str: if not lang: return "en" code = lang.split(",")[0].split(";")[0].split("-")[0].split("_")[0].strip().lower() if code in SUPPORTED_LANGS: return code return "en" def suggest_canonical_fix(raw: str) -> Optional[str]: """ Attempts to auto-fix common operator mistakes described in the standard: - '170-193' or '170 – 193' or '170...193' -> '170..193' - '±100' or '≈100' or '~ 100' -> '~100' - '> 80' -> '>80', '< 150' -> '<150' - '131, 140' or '131; 140' or '131 или 140' or '131 or 140' -> '131;140' """ if not raw or not isinstance(raw, str): return None s = raw.strip() if not s: return None # Context tag cleanup if s.startswith("#"): cleaned_key = re.sub(r"[^a-zA-Z0-9_]", "_", s[1:].strip()).strip("_").lower() return f"#{cleaned_key}" if cleaned_key else None # Replace approximate symbols if s.startswith(("±", "≈")): s = "~" + s[1:].strip() elif s.startswith("~"): s = "~" + s[1:].strip() # Replace greater/less spacing or words s = re.sub(r"^(?:больше|more\s+than|after|بعد)\s*", ">", s, flags=re.IGNORECASE) s = re.sub(r"^(?:меньше|до|less\s+than|before|قبل)\s*", "<", s, flags=re.IGNORECASE) s = re.sub(r"([><~])\s+(?=-?\d)", r"\1", s) # Replace alternative separators ('или', 'or', 'أو', '∨', ',') with ';' s = re.sub(r"\s*(?:или|or|أو|∨|,|،|;)\s*", ";", s, flags=re.IGNORECASE) # Replace range separators ('...', '–', '—', or '-' between two positive numbers) s = re.sub(r"\s*\.\.\.+\s*", "..", s) s = re.sub(r"\s*[–—]\s*", "..", s) s = re.sub(r"(?<=\d)\s*-\s*(?=\d)", "..", s) s = re.sub(r"\s*\.\.\s*", "..", s) return s if s != raw.strip() else None def _join_multi_items(items: List[str], lang: str) -> str: if not items: return "" if len(items) == 1: return items[0] if lang == "ar": if len(items) == 2: return f"{items[0]} أو {items[1]}" return "، ".join(items[:-1]) + f" أو {items[-1]}" elif lang == "ru": if len(items) == 2: return f"{items[0]} или {items[1]}" return ", ".join(items[:-1]) + f" или {items[-1]}" else: if len(items) == 2: return f"{items[0]} or {items[1]}" return ", ".join(items[:-1]) + f" or {items[-1]}" def _format_single_range(r_min: int, r_max: int, lang: str, for_age: bool = False, with_unit: bool = True) -> str: if for_age: base = f"{r_min}–{r_max}" if not with_unit: return base unit = {"ar": "سنة", "ru": "лет", "en": "years"}.get(lang, "years") return f"{base} {unit}" # Hijri year range if r_min < 0 and r_max >= 0: abs_min = abs(r_min) if lang == "ar": return f"{abs_min} ق.هـ – {r_max} هـ" elif lang == "ru": return f"{abs_min} г. до хиджры – {r_max} г." else: return f"{abs_min} BH – {r_max} AH" elif r_min < 0 and r_max < 0: abs_min = abs(r_min) abs_max = abs(r_max) if not with_unit: return f"{abs_min}–{abs_max}" if lang == "ar": return f"{abs_min}–{abs_max} ق.هـ" elif lang == "ru": return f"{abs_min}–{abs_max} гг. до хиджры" else: return f"{abs_min}–{abs_max} BH" else: base = f"{r_min}–{r_max}" if not with_unit: return base if lang == "ar": return f"{base} هـ" elif lang == "ru": return f"{base} гг." else: return f"{base} AH" def canonical_to_parts(canonical: str) -> List[int]: return [int(p) for p in canonical.split(";") if p.strip()] def canonical_to_ranges(canonical: str) -> List[Tuple[int, int]]: res: List[Tuple[int, int]] = [] for chunk in canonical.split(";"): if ".." in chunk: a, b = chunk.split("..", 1) res.append((int(a), int(b))) return res def parse_canonical_date( raw_value: Any, category: Optional[str] = None, date_type: Optional[str] = None, raise_on_error: bool = False, ) -> ParsedCanonicalDate: """ Validates and parses a canonical date/age string according to the Russian specification. Returns a ParsedCanonicalDate object with `is_valid`, `raw_value`, `parse_type`, `value`, `value_min`, `value_max`, `values_list`, `error`, `suggestion`, and `translations`. """ cat = category or date_type or "birth" is_age = (cat == "age") if raw_value is None: return ParsedCanonicalDate(raw_value="", is_valid=False, parse_type="unparsed", error="Empty value") canonical = str(raw_value).strip() if not canonical or canonical.lower() in ("null", "none", "undefined"): return ParsedCanonicalDate(raw_value="", is_valid=False, parse_type="unparsed", error="Empty value") suggestion = suggest_canonical_fix(canonical) def _invalid(err_msg: str, custom_suggestion: Optional[str] = None) -> ParsedCanonicalDate: sug = custom_suggestion if custom_suggestion is not None else suggestion if seg_msg := (f"{err_msg} (Did you mean '{sug}'?)" if sug and sug != canonical else err_msg): pass if raise_on_error: raise CanonicalDateValidationError(seg_msg, suggestion=sug) return ParsedCanonicalDate( raw_value=canonical, is_valid=False, parse_type="unparsed", error=seg_msg, suggestion=sug, ) # 1. Exact value (e.g. "143", "-5", "0") if RE_EXACT.match(canonical): val = int(canonical) if is_age and val < 0: return _invalid("Negative values (Before Hijra) are not allowed for age.", str(abs(val))) is_bh = val < 0 parsed = ParsedCanonicalDate( raw_value=canonical, is_valid=True, parse_type="exact", value=val, value_min=val, value_max=val, values_list=[val], abs_value=abs(val), is_bh=is_bh, is_mixed=False, context_key=None, ) parsed.translations = build_translations_for_parsed(parsed, category=cat) return parsed # 2. Range (e.g. "170..193", "-3..5") m_range = RE_RANGE.match(canonical) if m_range: v_min = int(m_range.group(1)) v_max = int(m_range.group(2)) if is_age and (v_min < 0 or v_max < 0): return _invalid("Negative values are not allowed for age ranges.", f"{abs(v_min)}..{abs(v_max)}") if v_min > v_max: return _invalid( f"Range minimum ({v_min}) cannot be greater than maximum ({v_max}).", f"{v_max}..{v_min}", ) is_mixed = (v_min < 0 and v_max >= 0) is_bh = (v_min < 0 and not is_mixed) parsed = ParsedCanonicalDate( raw_value=canonical, is_valid=True, parse_type="range", value=None, value_min=v_min, value_max=v_max, values_list=[v_min, v_max], abs_value=None, is_bh=is_bh, is_mixed=is_mixed, context_key=None, ) parsed.translations = build_translations_for_parsed(parsed, category=cat) return parsed # 3. Circa / Approximate (e.g. "~100", "~-10") m_circa = RE_CIRCA.match(canonical) if m_circa: val = int(m_circa.group(1)) if is_age and val < 0: return _invalid("Negative values are not allowed for age.", f"~{abs(val)}") parsed = ParsedCanonicalDate( raw_value=canonical, is_valid=True, parse_type="circa", value=val, value_min=val, value_max=val, values_list=[val], abs_value=abs(val), is_bh=val < 0, is_mixed=False, context_key=None, ) parsed.translations = build_translations_for_parsed(parsed, category=cat) return parsed # 4. Greater than (e.g. ">80", ">-5") m_gt = RE_GREATER.match(canonical) if m_gt: val = int(m_gt.group(1)) if is_age and val < 0: return _invalid("Negative values are not allowed for age.", f">{abs(val)}") parsed = ParsedCanonicalDate( raw_value=canonical, is_valid=True, parse_type="greater_than", value=val, value_min=val + 1, value_max=None, values_list=[val], abs_value=abs(val), is_bh=val < 0, is_mixed=False, context_key=None, _threshold=val, ) parsed.translations = build_translations_for_parsed(parsed, category=cat) return parsed # 5. Less than (e.g. "<150", "<-10") m_lt = RE_LESS.match(canonical) if m_lt: val = int(m_lt.group(1)) if is_age and val < 0: return _invalid("Negative values are not allowed for age.", f"<{abs(val)}") parsed = ParsedCanonicalDate( raw_value=canonical, is_valid=True, parse_type="less_than", value=val, value_min=None, value_max=val - 1, values_list=[val], abs_value=abs(val), is_bh=val < 0, is_mixed=False, context_key=None, _threshold=val, ) parsed.translations = build_translations_for_parsed(parsed, category=cat) return parsed # 6. Alternatives (2 or 3+ exact numbers separated by ';', e.g. "131;140" or "131;140;142") if RE_ALT_EXACT.match(canonical): parts = [int(p) for p in canonical.split(";")] if is_age and any(p < 0 for p in parts): return _invalid( "Negative values are not allowed for age alternatives.", ";".join(str(abs(p)) for p in parts), ) v_type = "alternatives" if len(parts) == 2 else "alternatives_multi" parsed = ParsedCanonicalDate( raw_value=canonical, is_valid=True, parse_type=v_type, value=None, value_min=min(parts), value_max=max(parts), values_list=parts, abs_value=None, is_bh=min(parts) < 0, is_mixed=False, context_key=None, _parts=parts, ) parsed.translations = build_translations_for_parsed(parsed, category=cat) return parsed # 7. Alternatives Range (2 or 3+ ranges separated by ';', e.g. "75..76;68..69") if RE_ALT_RANGE.match(canonical): raw_ranges = canonical.split(";") ranges: List[Tuple[int, int]] = [] all_nums: List[int] = [] for r_str in raw_ranges: a_str, b_str = r_str.split("..") a, b = int(a_str), int(b_str) if is_age and (a < 0 or b < 0): return _invalid("Negative values are not allowed for age ranges.") if a > b: return _invalid(f"Range minimum ({a}) cannot be greater than maximum ({b}) in '{r_str}'.") ranges.append((a, b)) all_nums.extend([a, b]) v_type = "alternatives_range" if len(ranges) == 2 else "alternatives_range_multi" parsed = ParsedCanonicalDate( raw_value=canonical, is_valid=True, parse_type=v_type, value=None, value_min=min(all_nums), value_max=max(all_nums), values_list=all_nums, abs_value=None, is_bh=min(all_nums) < 0, is_mixed=False, context_key=None, _ranges=ranges, ) parsed.translations = build_translations_for_parsed(parsed, category=cat) return parsed # 8. Context tag (e.g. "#hijra_habasha") m_ctx = RE_CONTEXT.match(canonical) if m_ctx: if is_age: return _invalid("Context tags (#key) are only allowed for birth/death years, not for age.") ctx_key = m_ctx.group(1).lower() normalized_tag = f"#{ctx_key}" parsed = ParsedCanonicalDate( raw_value=normalized_tag, is_valid=True, parse_type="context", value=None, value_min=None, value_max=None, values_list=[], abs_value=None, is_bh=False, is_mixed=False, context_key=ctx_key, ) parsed.translations = build_translations_for_parsed(parsed, category=cat) return parsed msg = ( f"Invalid canonical format '{canonical}'. " "Allowed formats: exact (143, -5), range (170..193), circa (~100), " "greater/less (>80, <150), alternatives (131;140), range alternatives (75..76;68..69), " "or context (#hijra_habasha)." ) return _invalid(msg) def render_date_from_parsed( parsed: Any, date_type: str = "birth", lang: str = "en", translations_override: Optional[List[Dict[str, Any]]] = None, ) -> str: """ Renders the human-readable localized date/age string for a given parsed canonical object, `date_type` ('birth', 'death', or 'age'), and language ('ru', 'en', 'ar'). """ lang_code = normalize_lang_code(lang) v_type = parsed["parse_type"] if isinstance(parsed, dict) and "parse_type" in parsed else parsed.get("type") canonical_str = parsed.get("raw_value") or parsed.get("canonical") or "" if translations_override: for t in translations_override: if isinstance(t, dict) and t.get("language_code") == lang_code: txt = t.get("text") or t.get("title") or t.get("context_translation") if txt: return str(txt) # 1. Context tag if v_type == "context": tag_key = canonical_str if canonical_str.startswith("#") else f"#{parsed.get('context_key', '')}" default_ctx = DEFAULT_CONTEXT_TAGS.get(tag_key, {}) return default_ctx.get(lang_code, tag_key) is_age = (date_type == "age") # 2. Multi-item ready strings (3+ alternatives) if v_type == "alternatives_multi": parts = [str(p) for p in (parsed.get("_parts") or canonical_to_parts(canonical_str))] joined = _join_multi_items(parts, lang_code) if is_age: age_unit = {"ar": "سنة", "ru": "лет", "en": "years"}[lang_code] return f"{joined} {age_unit}" return joined if v_type == "alternatives_range_multi": ranges = parsed.get("_ranges") or canonical_to_ranges(canonical_str) if is_age: age_items = [_format_single_range(a, b, lang_code, for_age=True, with_unit=False) for a, b in ranges] joined_age = _join_multi_items(age_items, lang_code) age_unit = {"ar": "سنة", "ru": "лет", "en": "years"}[lang_code] return f"{joined_age} {age_unit}" hijri_items = [_format_single_range(a, b, lang_code, for_age=False, with_unit=False) for a, b in ranges] return _join_multi_items(hijri_items, lang_code) # 3. Standard template rendering tpl_tuple = DEFAULT_TEMPLATES.get(v_type, DEFAULT_TEMPLATES["exact"]).get(lang_code) disp, disp_neg, disp_age = tpl_tuple if tpl_tuple else ("{value}", None, "{value}") is_neg_or_mixed = bool(parsed.get("is_bh") or parsed.get("is_mixed")) if is_age: template = disp_age elif is_neg_or_mixed: template = disp_neg or disp else: template = disp if not template: return canonical_str # Special case: range where both min and max are negative (e.g. -10..-5) v_min = parsed.get("value_min") v_max = parsed.get("value_max") if v_type == "range" and not is_age and v_min is not None and v_max is not None and v_min < 0 and v_max < 0: return _format_single_range(v_min, v_max, lang_code, for_age=False, with_unit=True) abs_val = parsed.get("abs_value") if v_type == "greater_than": threshold = parsed.get("_threshold") if threshold is None: threshold = (v_min - 1) if v_min is not None else 0 sub_min = threshold sub_abs_min = abs(threshold) sub_max = v_max sub_abs_max = abs(v_max) if v_max is not None else None sub_val = threshold elif v_type == "less_than": threshold = parsed.get("_threshold") if threshold is None: threshold = (v_max + 1) if v_max is not None else 0 sub_max = threshold sub_abs_max = abs(threshold) sub_min = v_min sub_abs_min = abs(v_min) if v_min is not None else None sub_val = threshold elif v_type == "alternatives": parts = parsed.get("_parts") or canonical_to_parts(canonical_str) sub_min = parts[0] if len(parts) > 0 else v_min sub_max = parts[1] if len(parts) > 1 else v_max sub_abs_min = abs(sub_min) if sub_min is not None else None sub_abs_max = abs(sub_max) if sub_max is not None else None sub_val = sub_min else: sub_min = v_min sub_max = v_max sub_abs_min = abs(v_min) if v_min is not None else None sub_abs_max = abs(v_max) if v_max is not None else None sub_val = v_min if v_min is not None else v_max range1_str = "" range2_str = "" if v_type == "alternatives_range": ranges = parsed.get("_ranges") or canonical_to_ranges(canonical_str) if len(ranges) >= 2: if is_age: range1_str = _format_single_range(ranges[0][0], ranges[0][1], lang_code, for_age=True, with_unit=False) range2_str = _format_single_range(ranges[1][0], ranges[1][1], lang_code, for_age=True, with_unit=False) else: range1_str = _format_single_range(ranges[0][0], ranges[0][1], lang_code, for_age=False, with_unit=True) range2_str = _format_single_range(ranges[1][0], ranges[1][1], lang_code, for_age=False, with_unit=True) return template.format( value=sub_val if sub_val is not None else "", abs_value=abs_val if abs_val is not None else (abs(sub_val) if sub_val is not None else ""), min=sub_min if sub_min is not None else "", max=sub_max if sub_max is not None else "", abs_min=sub_abs_min if sub_abs_min is not None else "", abs_max=sub_abs_max if sub_abs_max is not None else "", range1=range1_str, range2=range2_str, ) def build_translations_for_parsed( parsed: Any, category: str = "birth", custom_context_translations: Optional[Dict[str, str]] = None, ) -> List[Dict[str, str]]: """ Generates the 3 language translation objects (ru, en, ar) in standard `[{"language_code": "ru", "text": "..."}, ...]` format stored on `YearValue.translations`. """ translations: List[Dict[str, str]] = [] v_type = parsed.get("parse_type") or parsed.get("type") for lang in SUPPORTED_LANGS: if v_type == "context" and custom_context_translations and custom_context_translations.get(lang): txt = custom_context_translations[lang] else: txt = render_date_from_parsed(parsed, date_type=category, lang=lang) translations.append({ "language_code": lang, "text": txt, }) return translations def format_canonical_date( raw_value: Any, category: str = "birth", lang: str = "ru", date_type: Optional[str] = None, ) -> Optional[str]: """ Formats a canonical date/age string into the requested language ('ru', 'en', 'ar'). Returns raw string as fallback if unparseable. """ if raw_value is None or str(raw_value).strip() == "": return None cat = date_type or category or "birth" raw_str = str(raw_value).strip() parsed = parse_canonical_date(raw_str, category=cat) if not parsed.is_valid: return raw_str return render_date_from_parsed(parsed, date_type=cat, lang=lang) def format_canonical_for_all_langs( raw_value: Any, date_type: str = "birth", translations_override: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, str]: """ Returns a dictionary of formatted strings for all supported languages: {'ru': ..., 'en': ..., 'ar': ...}. """ if raw_value is None or str(raw_value).strip() == "": return {} raw_str = str(raw_value).strip() parsed = parse_canonical_date(raw_str, category=date_type) if not parsed.is_valid: return {lang: raw_str for lang in SUPPORTED_LANGS} return { lang: render_date_from_parsed(parsed, date_type=date_type, lang=lang, translations_override=translations_override) for lang in SUPPORTED_LANGS }