You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

680 lines
25 KiB

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
}