"""Разбор производственного календаря xmlcalendar.ru (JSON на год) — без БД и сети (D30). Файл: https://xmlcalendar.ru/data/ru/{год}/calendar.json. В months[].days — дни месяца через запятую: выходные и праздники, «+» — выходной по переносу, «*» — сокращённый рабочий день (так помечаются и рабочие субботы). Нерабочие дни — всё из списка без «*», остальные дни года рабочие. """ from __future__ import annotations import json import re from dataclasses import dataclass from datetime import date, timedelta from typing import Any SOURCE = "xmlcalendar.ru" _DAY = re.compile(r"^(\d{1,2})([*+]?)$") _SHORT = "*" _YEARS = range(2000, 2101) class CalendarFormatError(ValueError): """Файл не похож на календарь xmlcalendar.ru; текст — для пользователя.""" @dataclass(frozen=True) class ParsedCalendar: year: int non_working: tuple[date, ...] shortened: tuple[date, ...] @property def weekday_holidays(self) -> tuple[date, ...]: """Нерабочие дни с понедельника по пятницу: праздники и переносы.""" return tuple(d for d in self.non_working if d.weekday() < 5) @property def working_weekends(self) -> tuple[date, ...]: """Рабочие субботы и воскресенья.""" return weekend_workdays(self.year, set(self.non_working)) def weekend_workdays(year: int, non_working: set[date]) -> tuple[date, ...]: day, end, result = date(year, 1, 1), date(year, 12, 31), [] while day <= end: if day.weekday() >= 5 and day not in non_working: result.append(day) day += timedelta(days=1) return tuple(result) def parse_calendar_json(content: bytes) -> ParsedCalendar: try: data = json.loads(content.decode("utf-8-sig")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise CalendarFormatError( "Файл не читается как JSON. Нужен файл calendar.json с xmlcalendar.ru." ) from exc if not isinstance(data, dict): raise CalendarFormatError( "Это не календарь xmlcalendar.ru: нет года и месяцев." ) year = data.get("year") if not isinstance(year, int) or year not in _YEARS: raise CalendarFormatError("В файле нет года или он неверный.") months = data.get("months") if not isinstance(months, list) or len(months) != 12: raise CalendarFormatError(f"Календарь на {year}: должно быть 12 месяцев.") non_working: set[date] = set() shortened: set[date] = set() seen_months: set[int] = set() for item in months: month = _month(item, year) if month in seen_months: raise CalendarFormatError(f"Календарь на {year}: месяц {month} дважды.") seen_months.add(month) for token in str(item.get("days") or "").split(","): token = token.strip() if not token: continue match = _DAY.match(token) if match is None: raise CalendarFormatError( f"Календарь на {year}, месяц {month}: непонятный день «{token}»." ) try: day = date(year, month, int(match.group(1))) except ValueError as exc: raise CalendarFormatError( f"Календарь на {year}: нет даты {match.group(1)}.{month:02d}." ) from exc (shortened if match.group(2) == _SHORT else non_working).add(day) _check_total(data.get("statistic"), year, len(non_working)) return ParsedCalendar(year, tuple(sorted(non_working)), tuple(sorted(shortened))) def _month(item: Any, year: int) -> int: month = item.get("month") if isinstance(item, dict) else None if not isinstance(month, int) or not 1 <= month <= 12: raise CalendarFormatError(f"Календарь на {year}: неверный номер месяца.") return month def _check_total(statistic: Any, year: int, count: int) -> None: """Итог «holidays» в файле — число нерабочих дней года; если он есть, должен сойтись с разобранным списком.""" expected = statistic.get("holidays") if isinstance(statistic, dict) else None if isinstance(expected, int) and expected != count: raise CalendarFormatError( f"Календарь на {year}: нерабочих дней в списке {count}, а в итоге " f"файла {expected}. Файл повреждён или формат изменился." )