feat(export): add DOCX export of §22 forecast report (#959)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m49s
Deploy / build-worker (push) Successful in 3m27s
Deploy / deploy (push) Successful in 1m13s
CI / backend-tests (pull_request) Successful in 6m4s
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m49s
Deploy / build-worker (push) Successful in 3m27s
Deploy / deploy (push) Successful in 1m13s
CI / backend-tests (pull_request) Successful in 6m4s
render_report_docx (python-docx) mirrors report_md/report_pdf section order & content, reuses report_pdf pure helpers (DRY), graceful on thin/empty report. Widen /forecast/export format Literal to include docx → Word attachment. Add python-docx dep + regenerate uv.lock (uv sync --frozen passes). Part of #959.
This commit is contained in:
parent
858b5f4511
commit
3a89287c6b
6 changed files with 812 additions and 7 deletions
|
|
@ -44,6 +44,7 @@ from app.services.analysis_runs.repository import (
|
|||
persist_analysis_run,
|
||||
)
|
||||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
||||
from app.services.exporters.report_docx import render_report_docx
|
||||
from app.services.exporters.report_md import (
|
||||
render_report_markdown,
|
||||
render_report_telegram_summary,
|
||||
|
|
@ -1323,16 +1324,21 @@ def export_parcel_forecast(
|
|||
cad_num: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
format: Annotated[
|
||||
Literal["md", "json", "tg"],
|
||||
Query(description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | tg (сводка)"),
|
||||
Literal["md", "json", "tg", "docx"],
|
||||
Query(
|
||||
description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
|
||||
"tg (сводка) | docx (Word-документ)"
|
||||
),
|
||||
] = "md",
|
||||
) -> Response:
|
||||
"""Экспорт §22-форсайта участка — Markdown / JSON (файл) или TG-сводка (#959, EPIC export).
|
||||
"""Экспорт §22-форсайта участка — Markdown / JSON / DOCX (файл) или TG-сводка (#959).
|
||||
|
||||
Самый дешёвый, БЕЗ-runtime-dep срез экспорта: читает ПОСЛЕДНИЙ §22-ран schema_version
|
||||
"1.0" (тот же блоб, что отдаёт GET /{cad}/forecast inline) и отдаёт его в нужной форме.
|
||||
Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
||||
/{cad}/forecast inline) и отдаёт его в нужной форме.
|
||||
• format=md → `render_report_markdown(run.result)` — attachment (.md, чистый Markdown).
|
||||
• format=json → сырой `run.result` — attachment (.json, download-вариант inline-чтения).
|
||||
• format=docx → `render_report_docx(run.result)` — attachment (.docx, Word-документ,
|
||||
зеркало содержания md/pdf; python-docx).
|
||||
• format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
||||
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
||||
и отправить, а не файл-download).
|
||||
|
|
@ -1344,10 +1350,10 @@ def export_parcel_forecast(
|
|||
|
||||
Args:
|
||||
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
||||
format: "md" (default) | "json" | "tg".
|
||||
format: "md" (default) | "json" | "tg" | "docx".
|
||||
|
||||
Returns:
|
||||
Response — для md/json attachment с Content-Disposition (имя
|
||||
Response — для md/json/docx attachment с Content-Disposition (имя
|
||||
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
||||
"""
|
||||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||
|
|
@ -1365,6 +1371,19 @@ def export_parcel_forecast(
|
|||
today = _dt.date.today().strftime("%Y-%m-%d")
|
||||
base_name = f"gendesign_forecast_{cad_safe}_{today}"
|
||||
|
||||
# docx — байты (Word OOXML), а не строка: отдельной веткой (зеркало tg early-return),
|
||||
# чтобы md/json string-ветки остались байт-в-байт прежними.
|
||||
if format == "docx":
|
||||
return Response(
|
||||
content=render_report_docx(run.result),
|
||||
media_type=(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
),
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{base_name}.docx"'
|
||||
},
|
||||
)
|
||||
|
||||
if format == "json":
|
||||
payload = json.dumps(run.result, ensure_ascii=False, default=str)
|
||||
media_type = "application/json"
|
||||
|
|
|
|||
441
backend/app/services/exporters/report_docx.py
Normal file
441
backend/app/services/exporters/report_docx.py
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
"""§22 DOCX-экспортёр итогового советующего отчёта Site Finder v2 (python-docx).
|
||||
|
||||
#959 (EPIC export) — завершает набор форматов выгрузки §22-форсайта (md/json/tg уже
|
||||
есть): рендерит `SiteFinderReport` (#987) в редактируемый Word-документ (.docx) и
|
||||
возвращает БАЙТЫ — готовые для
|
||||
`Response(media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document")`.
|
||||
Аддитивный двойник PDF/Markdown-экспортёров (`report_pdf` / `report_md`): то же
|
||||
содержание и ТОТ ЖЕ порядок секций (зеркало `report_md`).
|
||||
|
||||
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
|
||||
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
|
||||
`_fmt` — строковое форматирование ячейки/None→"—", `_level_ru`, `_as_dict`/`_as_list`,
|
||||
`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы
|
||||
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные
|
||||
микро-билдеры (заголовки/абзацы/таблицы python-docx).
|
||||
|
||||
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, БЕЗ БД/сети: только ПОТРЕБЛЯЕТ уже-собранный отчёт (его
|
||||
наполняет сборщик #988) и раскладывает по секциям. Принимает КАК `SiteFinderReport`-
|
||||
инстанс, ТАК и его `as_dict()`-словарь (нормализуется через `_normalize`).
|
||||
|
||||
GRACEFUL (дух всего форсайт-стека): частичный/пустой/мусорный отчёт ВАЛИДЕН — пустая
|
||||
секция рисует «нет данных», экспортёр НИКОГДА не падает (нет KeyError на тонком отчёте)
|
||||
и возвращает минимальный валидный .docx. Отчёт СОВЕТУЮЩИЙ: после титула — заметный
|
||||
advisory-дисклеймер (оценка не основание для инвест-решения).
|
||||
|
||||
`python-docx` (PyPI `python-docx`, import `docx`) — чистый Python (lxml), без нативных
|
||||
системных библиотек: Dockerfile не трогаем. Импортируется ЛОКАЛЬНО внутри функции
|
||||
(зеркало WeasyPrint в `report_pdf` — не нужен при импорте модуля).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.services.exporters.report_pdf import (
|
||||
_ADVISORY_MARKER,
|
||||
_DASH,
|
||||
_NO_DATA,
|
||||
_as_dict,
|
||||
_as_list,
|
||||
_fmt,
|
||||
_future_supply_pairs,
|
||||
_level_ru,
|
||||
_normalize,
|
||||
_scenario_deficit_index,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Только для аннотаций: тяжёлый python-docx импортируем ЛОКАЛЬНО в render_report_docx
|
||||
# (как WeasyPrint в report_pdf), чтобы импорт модуля был дешёвым и не требовал dep.
|
||||
from docx.document import Document as _DocxDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Named-константы: заголовки (зеркало report_md — те же восемь секций §22) ──
|
||||
|
||||
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт"
|
||||
_TITLE_SUMMARY: str = "Сводка"
|
||||
_TITLE_MARKET_NOW: str = "Рынок сейчас"
|
||||
_TITLE_FUTURE_MARKET: str = "Будущий рынок"
|
||||
_TITLE_SCENARIOS: str = "Сценарии"
|
||||
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
|
||||
_TITLE_SCORING: str = "Скоринг"
|
||||
_TITLE_CONFIDENCE: str = "Уверенность"
|
||||
|
||||
# Краткий RU-дисклеймер (формулировка из ТЗ #959) — после титула, виден сразу.
|
||||
_ADVISORY_DISCLAIMER: str = "Оценка advisory — не основание для инвест-решения."
|
||||
|
||||
# Стиль таблиц python-docx (встроенный «Table Grid» — есть в любом дефолтном Document,
|
||||
# рисует видимые границы; не требует кастомного шаблона). Зеркало house-style соседей.
|
||||
_TABLE_STYLE: str = "Table Grid"
|
||||
|
||||
# Сколько конкурентов выводить в таблицу (top-N — зеркало report_md._COMPETITORS_TOP_N).
|
||||
_COMPETITORS_TOP_N: int = 5
|
||||
|
||||
# Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария
|
||||
# (зеркало report_md._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index).
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# docx-специфичные микро-билдеры. Все принимают `doc`/контейнер python-docx и пишут
|
||||
# в него (мутируют документ). Хелперы нормализации/форматирования — импортированы из
|
||||
# report_pdf, не дублируются (DRY). Числа/None уже причёсаны `_fmt` → str.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _add_kv_lines(doc: _DocxDocument, pairs: list[tuple[str, Any]]) -> None:
|
||||
"""Карточка «**метка:** значение» построчно (bullet-абзацы). Пустой → «нет данных».
|
||||
|
||||
Метка — статичная RU-строка (bold-run), значение — через `_fmt` (None → "—").
|
||||
Зеркало `report_md._md_kv_lines` (там Markdown-буллеты; здесь — docx-абзацы).
|
||||
"""
|
||||
if not pairs:
|
||||
doc.add_paragraph(_NO_DATA)
|
||||
return
|
||||
for label, value in pairs:
|
||||
para = doc.add_paragraph(style="List Bullet")
|
||||
run = para.add_run(f"{label}: ")
|
||||
run.bold = True
|
||||
para.add_run(_fmt(value))
|
||||
|
||||
|
||||
def _add_table(doc: _DocxDocument, headers: list[str], rows: list[list[Any]]) -> None:
|
||||
"""Таблица: шапка (статичные RU-метки, bold) + строки данных (через `_fmt`). Graceful.
|
||||
|
||||
Пустой `rows` → одна строка-заглушка «нет данных» под шапкой (таблица всё равно
|
||||
валидна). Все ячейки данных проходят `_fmt` (str, None → "—"). Зеркало
|
||||
`report_md._md_table` (GFM-таблица) — здесь python-docx Table со стилем «Table Grid».
|
||||
"""
|
||||
table = doc.add_table(rows=1, cols=len(headers))
|
||||
table.style = _TABLE_STYLE
|
||||
header_cells = table.rows[0].cells
|
||||
for idx, header in enumerate(headers):
|
||||
cell = header_cells[idx]
|
||||
cell.text = ""
|
||||
run = cell.paragraphs[0].add_run(header)
|
||||
run.bold = True
|
||||
|
||||
if not rows:
|
||||
empty_cells = table.add_row().cells
|
||||
empty_cells[0].text = _NO_DATA
|
||||
return
|
||||
|
||||
for row in rows:
|
||||
cells = table.add_row().cells
|
||||
for idx in range(len(headers)):
|
||||
# Строка может оказаться короче шапки (defensive) — недостающее → "—".
|
||||
value = row[idx] if idx < len(row) else None
|
||||
cells[idx].text = _fmt(value)
|
||||
|
||||
|
||||
def _add_kv_table(doc: _DocxDocument, data: dict[str, Any]) -> None:
|
||||
"""Таблица «Показатель → Значение» из плоского dict. Пустой → «нет данных».
|
||||
|
||||
Ключи — данные (тоже через `_fmt`). Зеркало `report_md._md_kv_table`.
|
||||
"""
|
||||
rows = [[str(key), value] for key, value in data.items()]
|
||||
_add_table(doc, ["Показатель", "Значение"], rows)
|
||||
|
||||
|
||||
def _add_heading(doc: _DocxDocument, text: str, level: int) -> None:
|
||||
"""Заголовок секции (python-docx Heading level). Тонкая обёртка для единообразия."""
|
||||
doc.add_heading(text, level=level)
|
||||
|
||||
|
||||
def _join_horizons(values: list[Any]) -> Any:
|
||||
"""Свернуть список горизонтов в «6, 12, 18» или None (→ `_fmt` отдаст "—"). PURE."""
|
||||
return ", ".join(str(v) for v in values) if values else None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Построители секций — по одной на содержательную секцию §22, тот же порядок/набор,
|
||||
# что и в report_md/report_pdf. Каждый graceful (пустая секция → «нет данных», не
|
||||
# падает) и ПИШЕТ в переданный `doc` (мутирует документ).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_header(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||
"""Титул + advisory-дисклеймер + карточка meta (cad/район/горизонты/дата/схема)."""
|
||||
meta = _as_dict(report.get("meta"))
|
||||
schema_version = report.get("schema_version") or meta.get("schema_version")
|
||||
|
||||
doc.add_heading(_TITLE_DOC, level=0)
|
||||
disclaimer = doc.add_paragraph(style="Intense Quote")
|
||||
run = disclaimer.add_run(f"{_ADVISORY_DISCLAIMER} {_ADVISORY_MARKER}")
|
||||
run.bold = True
|
||||
|
||||
meta_pairs: list[tuple[str, Any]] = [
|
||||
("Кадастровый номер", meta.get("cad_num")),
|
||||
("Район", meta.get("district")),
|
||||
("Сегмент", meta.get("segment")),
|
||||
("Горизонты (мес)", _join_horizons(_as_list(meta.get("horizons")))),
|
||||
("Сформировано", meta.get("generated_at")),
|
||||
("Версия схемы", schema_version),
|
||||
]
|
||||
_add_kv_lines(doc, meta_pairs)
|
||||
|
||||
|
||||
def _build_summary(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||
"""§22.1 «Сводка»: headline (bold) + вердикт + общая уверенность + ключевые числа."""
|
||||
exec_summary = _as_dict(report.get("exec_summary"))
|
||||
headline = exec_summary.get("headline")
|
||||
verdict = exec_summary.get("verdict")
|
||||
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||||
overall_conf = _level_ru(exec_summary.get("overall_confidence"))
|
||||
|
||||
_add_heading(doc, _TITLE_SUMMARY, level=1)
|
||||
headline_para = doc.add_paragraph()
|
||||
headline_para.add_run(_fmt(headline)).bold = True
|
||||
doc.add_paragraph(_fmt(verdict))
|
||||
conf_para = doc.add_paragraph()
|
||||
conf_para.add_run(f"Общая уверенность: {overall_conf}").italic = True
|
||||
|
||||
_add_heading(doc, "Ключевые числа", level=2)
|
||||
_add_kv_table(doc, key_numbers)
|
||||
|
||||
|
||||
def _build_market_now(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||
"""§22.2 «Рынок сейчас»: резюме + метрики + слои предложения + конкуренты (top-N)."""
|
||||
market_now = _as_dict(report.get("market_now"))
|
||||
metrics = _as_dict(market_now.get("market_metrics"))
|
||||
supply = _as_dict(market_now.get("supply_layers"))
|
||||
competitors = _as_list(market_now.get("competitors"))
|
||||
|
||||
comp_rows = [
|
||||
[c.get("comm_name"), c.get("obj_id"), c.get("relevance_weight")]
|
||||
for c in competitors[:_COMPETITORS_TOP_N]
|
||||
if isinstance(c, dict)
|
||||
]
|
||||
|
||||
_add_heading(doc, _TITLE_MARKET_NOW, level=1)
|
||||
doc.add_paragraph(_fmt(market_now.get("summary")))
|
||||
_add_heading(doc, "Метрики рынка", level=2)
|
||||
_add_kv_table(doc, metrics)
|
||||
_add_heading(doc, "Слои предложения", level=2)
|
||||
_add_kv_table(doc, supply)
|
||||
_add_heading(doc, "Конкуренты", level=2)
|
||||
_add_table(doc, ["ЖК", "ID", "Релевантность"], comp_rows)
|
||||
|
||||
|
||||
def _build_future_market(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||
"""§22.3 «Будущий рынок»: таблица по горизонтам + давление предложения + сценарии."""
|
||||
future = _as_dict(report.get("future_market"))
|
||||
forecasts = _as_list(future.get("forecasts_by_horizon"))
|
||||
scenarios_summary = _as_dict(future.get("scenarios_summary"))
|
||||
|
||||
forecast_headers = [
|
||||
"Горизонт, мес",
|
||||
"Индекс дефицита",
|
||||
"Месяцев запаса",
|
||||
"Спрос",
|
||||
"Предложение",
|
||||
"Ставка",
|
||||
"Уверенность",
|
||||
]
|
||||
forecast_rows = [
|
||||
[
|
||||
f.get("horizon_months"),
|
||||
f.get("deficit_index"),
|
||||
f.get("months_of_inventory"),
|
||||
f.get("projected_demand_units"),
|
||||
f.get("projected_supply_units"),
|
||||
f.get("rate_future"),
|
||||
_level_ru(f.get("confidence")),
|
||||
]
|
||||
for f in forecasts
|
||||
if isinstance(f, dict)
|
||||
]
|
||||
|
||||
_add_heading(doc, _TITLE_FUTURE_MARKET, level=1)
|
||||
doc.add_paragraph(_fmt(future.get("summary")))
|
||||
_add_heading(doc, "Прогноз по горизонтам", level=2)
|
||||
_add_table(doc, forecast_headers, forecast_rows)
|
||||
_add_heading(doc, "Давление будущего предложения", level=2)
|
||||
_add_kv_table(doc, _future_supply_pairs(future.get("future_supply")))
|
||||
_add_heading(doc, "Разброс сценариев (индекс дефицита)", level=2)
|
||||
_add_kv_table(doc, scenarios_summary)
|
||||
|
||||
|
||||
def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||
"""§22.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
|
||||
scenarios = _as_dict(report.get("scenarios"))
|
||||
by_scenario = _as_dict(scenarios.get("by_scenario"))
|
||||
|
||||
rows: list[list[Any]] = []
|
||||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
rate_path = _as_dict(data.get("rate_path"))
|
||||
rate_str = (
|
||||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||
)
|
||||
rows.append([name, _scenario_deficit_index(data), rate_str, data.get("advisory")])
|
||||
|
||||
headers = [
|
||||
"Сценарий",
|
||||
f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)",
|
||||
"Траектория ставки",
|
||||
"Advisory",
|
||||
]
|
||||
_add_heading(doc, _TITLE_SCENARIOS, level=1)
|
||||
doc.add_paragraph(_fmt(scenarios.get("summary")))
|
||||
_add_table(doc, headers, rows)
|
||||
|
||||
|
||||
def _build_product_tz(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||
"""§22.4 «Продукт ТЗ»: класс + квартирография (mix) + коммерция + USP + причины."""
|
||||
product = _as_dict(report.get("product_tz"))
|
||||
mix = _as_list(product.get("mix"))
|
||||
commercial = _as_dict(product.get("commercial"))
|
||||
usp = _as_list(product.get("usp"))
|
||||
reasons = _as_list(product.get("reasons"))
|
||||
|
||||
head_pairs: list[tuple[str, Any]] = [
|
||||
("Резюме", product.get("summary")),
|
||||
("Рекомендованный класс", product.get("obj_class")),
|
||||
]
|
||||
mix_rows = [
|
||||
[m.get("bucket"), m.get("pct"), m.get("obj_class"), m.get("deficit_index")]
|
||||
for m in mix
|
||||
if isinstance(m, dict)
|
||||
]
|
||||
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
|
||||
reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)]
|
||||
|
||||
_add_heading(doc, _TITLE_PRODUCT_TZ, level=1)
|
||||
_add_kv_lines(doc, head_pairs)
|
||||
_add_heading(doc, "Квартирография (mix)", level=2)
|
||||
_add_table(doc, ["Формат", "Доля, %", "Класс", "Индекс дефицита"], mix_rows)
|
||||
_add_heading(doc, "Коммерция", level=2)
|
||||
_add_kv_table(doc, commercial)
|
||||
_add_heading(doc, "USP-ниши", level=2)
|
||||
_add_table(doc, ["Сегмент", "USP"], usp_rows)
|
||||
_add_heading(doc, "Причины (§16)", level=2)
|
||||
_add_table(doc, ["Почему", "Advisory"], reason_rows)
|
||||
|
||||
|
||||
def _build_scoring(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||
"""§22.6 «Скоринг»: продуктовые скоры (#985) + спец-индексы (#986) + overall."""
|
||||
scoring = _as_dict(report.get("scoring"))
|
||||
product_scores = _as_dict(scoring.get("product_scores"))
|
||||
special_indices = _as_dict(scoring.get("special_indices"))
|
||||
|
||||
scores = _as_dict(product_scores.get("scores"))
|
||||
score_rows = [[name, _as_dict(payload).get("value")] for name, payload in scores.items()]
|
||||
|
||||
indices = _as_dict(special_indices.get("indices"))
|
||||
index_rows = [
|
||||
[name, _as_dict(payload).get("value"), _as_dict(payload).get("label")]
|
||||
for name, payload in indices.items()
|
||||
]
|
||||
|
||||
_add_heading(doc, _TITLE_SCORING, level=1)
|
||||
overall_para = doc.add_paragraph()
|
||||
overall_para.add_run(f"Итоговый скор (overall): {_fmt(scoring.get('overall'))}").italic = True
|
||||
_add_heading(doc, "Продуктовые скоры", level=2)
|
||||
_add_table(doc, ["Скор", "Значение"], score_rows)
|
||||
_add_heading(doc, "Специальные индексы", level=2)
|
||||
_add_table(doc, ["Индекс", "Значение", "Метка"], index_rows)
|
||||
|
||||
|
||||
def _build_confidence(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||
"""§22.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица)."""
|
||||
confidence = _as_dict(report.get("confidence"))
|
||||
level = _level_ru(confidence.get("level"))
|
||||
rationale = confidence.get("rationale")
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
|
||||
# Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive:
|
||||
# если значение — dict, раскладываем на value/level/note; иначе кладём как есть.
|
||||
factor_rows: list[list[Any]] = []
|
||||
for name, payload in factors.items():
|
||||
if isinstance(payload, dict):
|
||||
factor_rows.append(
|
||||
[name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")]
|
||||
)
|
||||
else:
|
||||
factor_rows.append([name, payload, _DASH, _DASH])
|
||||
|
||||
_add_heading(doc, _TITLE_CONFIDENCE, level=1)
|
||||
level_para = doc.add_paragraph()
|
||||
level_para.add_run(f"Уровень: {level}").italic = True
|
||||
doc.add_paragraph(_fmt(rationale))
|
||||
_add_heading(doc, "Факторы уверенности", level=2)
|
||||
_add_table(doc, ["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)
|
||||
|
||||
|
||||
# Реестр построителей. Порядок = порядок блоков в документе (зеркало report_md:
|
||||
# Сводка → Рынок сейчас → Будущий рынок → Сценарии → Продукт ТЗ → Скоринг →
|
||||
# Уверенность; header идёт отдельно первым).
|
||||
_SECTION_BUILDERS: tuple[Any, ...] = (
|
||||
_build_summary,
|
||||
_build_market_now,
|
||||
_build_future_market,
|
||||
_build_scenarios,
|
||||
_build_product_tz,
|
||||
_build_scoring,
|
||||
_build_confidence,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Публичный API — рендер отчёта в DOCX-байты (без файлового I/O на диск).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def render_report_docx(report: Any) -> bytes:
|
||||
"""§22 Отрендерить `SiteFinderReport` (#987) в Word-документ (.docx) и вернуть БАЙТЫ.
|
||||
|
||||
Титул + advisory-дисклеймер + карточка meta, далее семь содержательных секций §22
|
||||
(Сводка / Рынок сейчас / Будущий рынок / Сценарии / Продукт ТЗ / Скоринг /
|
||||
Уверенность) — содержание и порядок ЗЕРКАЛЯТ `report_md`/`report_pdf`. Числа
|
||||
форматируются через импортированный `_fmt` (None → "—"), уровни уверенности — RU-
|
||||
метками. Заголовки python-docx (Heading 0-2), таблицы со стилем «Table Grid».
|
||||
|
||||
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
|
||||
его `as_dict()`-словарь (нормализуется через импортированный `_normalize`). GRACEFUL:
|
||||
частичный/пустой/мусорный отчёт → секции с «нет данных», НИКОГДА не падает (нет
|
||||
KeyError на тонком отчёте) — возвращает минимальный валидный .docx.
|
||||
|
||||
`python-docx` импортируется ЛОКАЛЬНО (как WeasyPrint в `report_pdf`): не нужен при
|
||||
импорте модуля. Документ собирается в памяти (`io.BytesIO`) — без файлового I/O.
|
||||
|
||||
Args:
|
||||
report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь (или мусор → {}).
|
||||
|
||||
Returns:
|
||||
Непустые DOCX-байты (OOXML — это zip, начинаются с `b"PK"`), готовые для
|
||||
`Response(media_type=
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")`.
|
||||
"""
|
||||
# python-docx импортируем локально — тяжёлый (lxml); не нужен при импорте модуля.
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"python-docx не установлен. Добавь 'python-docx>=1.1.0' в pyproject.toml."
|
||||
) from exc
|
||||
|
||||
data = _normalize(report)
|
||||
doc = Document()
|
||||
|
||||
_build_header(doc, data)
|
||||
for builder in _SECTION_BUILDERS:
|
||||
builder(doc, data)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
doc.save(buffer)
|
||||
docx_bytes = buffer.getvalue()
|
||||
|
||||
meta = _as_dict(data.get("meta"))
|
||||
logger.info(
|
||||
"render_report_docx: cad_num=%s sections=%d size=%d bytes advisory=%s",
|
||||
meta.get("cad_num"),
|
||||
len(_SECTION_BUILDERS),
|
||||
len(docx_bytes),
|
||||
data.get("advisory"),
|
||||
)
|
||||
return docx_bytes
|
||||
|
|
@ -25,6 +25,7 @@ dependencies = [
|
|||
"jinja2>=3.1.0",
|
||||
"ezdxf>=1.3.0",
|
||||
"openpyxl>=3.1.0",
|
||||
"python-docx>=1.1.0", # DOCX export (import name `docx`) — §22 forecast report (#959)
|
||||
"pandas>=2.2.0",
|
||||
"numpy>=2.0.0",
|
||||
"scikit-learn>=1.5.0",
|
||||
|
|
|
|||
|
|
@ -471,6 +471,53 @@ def test_export_forecast_tg_no_run_returns_404() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_export_forecast_docx_returns_word_document() -> None:
|
||||
"""format=docx → 200 wordprocessingml + attachment .docx, тело — байты .docx (PK)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
fake_run = MagicMock()
|
||||
fake_run.result = _REPORT_FIXTURE
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=docx")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == (
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
cd = resp.headers["content-disposition"]
|
||||
assert "attachment;" in cd
|
||||
assert cd.endswith('.docx"')
|
||||
# cad-двоеточия санитизированы в имени файла.
|
||||
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
|
||||
# OOXML/.docx — zip: первые 2 байта «PK».
|
||||
assert resp.content[:2] == b"PK"
|
||||
# Читаем именно §22-схему "1.0".
|
||||
_, kwargs = lrf.call_args
|
||||
assert kwargs.get("schema_version") == "1.0"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_export_forecast_docx_no_run_returns_404() -> None:
|
||||
"""format=docx + рана нет → 404 (как md/json export-вариант, НЕ 202/500)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", return_value=None):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=docx")
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "посчитан" in resp.json()["detail"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_export_forecast_no_run_returns_404() -> None:
|
||||
"""Рана нет (latest_run_for → None) → 404 (download-вариант, НЕ 202/500)."""
|
||||
from app.core.db import get_db
|
||||
|
|
|
|||
202
backend/tests/services/exporters/test_report_docx.py
Normal file
202
backend/tests/services/exporters/test_report_docx.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""Unit-тесты §22 DOCX-экспортёра (#959, EPIC export) — `render_report_docx`.
|
||||
|
||||
Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт):
|
||||
• реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) рендерится
|
||||
в НЕПУСТЫЕ байты, начинающиеся с `b"PK"` (OOXML/.docx — это zip-контейнер);
|
||||
• документ открываемый python-docx и несёт заголовки всех секций + advisory-дисклеймер
|
||||
+ кадастр из meta;
|
||||
• экспортёр принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь;
|
||||
• пустой / частичный / мусорный отчёт → валидный минимальный .docx БЕЗ падения (graceful).
|
||||
|
||||
Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало
|
||||
test_report_md.py) — на случай side-effect'ов импорта пакета.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from docx import Document
|
||||
|
||||
from app.services.exporters.report_docx import (
|
||||
_ADVISORY_DISCLAIMER,
|
||||
_TITLE_CONFIDENCE,
|
||||
_TITLE_FUTURE_MARKET,
|
||||
_TITLE_MARKET_NOW,
|
||||
_TITLE_PRODUCT_TZ,
|
||||
_TITLE_SCENARIOS,
|
||||
_TITLE_SCORING,
|
||||
_TITLE_SUMMARY,
|
||||
render_report_docx,
|
||||
)
|
||||
from app.services.forecasting.report import (
|
||||
ReportExecSummary,
|
||||
ReportMeta,
|
||||
SiteFinderReport,
|
||||
)
|
||||
|
||||
# Реальный fixture: frontend mock §22-форсайта — берём вложенный `report` (форма #987
|
||||
# `as_dict()`). Так тест не разойдётся с реальной формой отчёта.
|
||||
_FIXTURE_PATH = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ "frontend"
|
||||
/ "src"
|
||||
/ "lib"
|
||||
/ "mocks"
|
||||
/ "parcel-forecast.json"
|
||||
)
|
||||
|
||||
# OOXML / .docx — это zip-контейнер: первые два байта — сигнатура zip «PK».
|
||||
_ZIP_SIGNATURE = b"PK"
|
||||
|
||||
_EXPECTED_TITLES: tuple[str, ...] = (
|
||||
_TITLE_SUMMARY,
|
||||
_TITLE_MARKET_NOW,
|
||||
_TITLE_FUTURE_MARKET,
|
||||
_TITLE_SCENARIOS,
|
||||
_TITLE_PRODUCT_TZ,
|
||||
_TITLE_SCORING,
|
||||
_TITLE_CONFIDENCE,
|
||||
)
|
||||
|
||||
|
||||
def _fixture_report() -> dict[str, Any]:
|
||||
"""`report`-словарь из frontend-мока §22 (форма SiteFinderReport.as_dict())."""
|
||||
data = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
report = data["report"]
|
||||
assert isinstance(report, dict)
|
||||
return report
|
||||
|
||||
|
||||
def _docx_text(docx_bytes: bytes) -> str:
|
||||
"""Весь текст документа (абзацы + ячейки таблиц) одной строкой — для ассертов."""
|
||||
doc = Document(io.BytesIO(docx_bytes))
|
||||
parts: list[str] = [p.text for p in doc.paragraphs]
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
parts.extend(cell.text for cell in row.cells)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ── Реальный fixture-отчёт: байты-сигнатура + структура + ключевые значения ──────
|
||||
|
||||
|
||||
class TestFixtureReportRender:
|
||||
def test_returns_non_empty_bytes(self) -> None:
|
||||
out = render_report_docx(_fixture_report())
|
||||
assert isinstance(out, bytes)
|
||||
# Нетривиальный документ — далеко не пустой.
|
||||
assert len(out) > 1000
|
||||
|
||||
def test_starts_with_zip_signature(self) -> None:
|
||||
# OOXML/.docx — zip: первые 2 байта обязаны быть «PK».
|
||||
out = render_report_docx(_fixture_report())
|
||||
assert out[:2] == _ZIP_SIGNATURE
|
||||
|
||||
def test_openable_by_python_docx(self) -> None:
|
||||
# Результат — валидный .docx (открывается python-docx без ошибки).
|
||||
out = render_report_docx(_fixture_report())
|
||||
doc = Document(io.BytesIO(out))
|
||||
assert len(doc.paragraphs) > 0
|
||||
|
||||
def test_advisory_disclaimer_present(self) -> None:
|
||||
text = _docx_text(render_report_docx(_fixture_report()))
|
||||
assert _ADVISORY_DISCLAIMER in text
|
||||
|
||||
def test_all_section_headers_present(self) -> None:
|
||||
text = _docx_text(render_report_docx(_fixture_report()))
|
||||
for title in _EXPECTED_TITLES:
|
||||
assert title in text, f"отсутствует заголовок секции «{title}»"
|
||||
|
||||
def test_meta_cad_num_present(self) -> None:
|
||||
text = _docx_text(render_report_docx(_fixture_report()))
|
||||
# Кадастр из meta фикстуры попадает в карточку.
|
||||
assert "66:41:0702048:27" in text
|
||||
|
||||
def test_headline_present(self) -> None:
|
||||
report = _fixture_report()
|
||||
headline = report["exec_summary"]["headline"]
|
||||
text = _docx_text(render_report_docx(report))
|
||||
assert headline.split(".")[0] in text
|
||||
|
||||
|
||||
# ── Вход: dataclass-инстанс ИЛИ as_dict()-словарь ───────────────────────────────
|
||||
|
||||
|
||||
class TestInputForms:
|
||||
def test_accepts_dataclass_instance(self) -> None:
|
||||
report = SiteFinderReport(
|
||||
exec_summary=ReportExecSummary(headline="Строить комфорт-класс."),
|
||||
meta=ReportMeta(cad_num="66:41:0000000:1", horizons=[12]),
|
||||
)
|
||||
text = _docx_text(render_report_docx(report))
|
||||
assert "Строить комфорт" in text
|
||||
assert "66:41:0000000:1" in text
|
||||
|
||||
def test_dataclass_and_dict_equivalent_text(self) -> None:
|
||||
# Байты могут расходиться (zip-метаданные/порядок), но ТЕКСТ — идентичен.
|
||||
report = SiteFinderReport(
|
||||
exec_summary=ReportExecSummary(headline="Один заголовок."),
|
||||
meta=ReportMeta(cad_num="66:41:0000000:9"),
|
||||
)
|
||||
assert _docx_text(render_report_docx(report)) == _docx_text(
|
||||
render_report_docx(report.as_dict())
|
||||
)
|
||||
|
||||
|
||||
# ── Graceful: пустой / частичный / мусорный вход → валидный .docx без падения ────
|
||||
|
||||
|
||||
class TestGraceful:
|
||||
def test_empty_dict_does_not_crash(self) -> None:
|
||||
# Пустой отчёт {} → валидный .docx (no KeyError), сигнатура PK, есть дисклеймер.
|
||||
out = render_report_docx({})
|
||||
assert isinstance(out, bytes)
|
||||
assert out[:2] == _ZIP_SIGNATURE
|
||||
text = _docx_text(out)
|
||||
assert _ADVISORY_DISCLAIMER in text
|
||||
assert "нет данных" in text
|
||||
# Все секции всё равно отрисованы (стабильный контракт).
|
||||
for title in _EXPECTED_TITLES:
|
||||
assert title in text
|
||||
|
||||
def test_none_does_not_crash(self) -> None:
|
||||
# None → нормализуется в {} → валидный минимальный .docx, не падает.
|
||||
out = render_report_docx(None)
|
||||
assert isinstance(out, bytes)
|
||||
assert out[:2] == _ZIP_SIGNATURE
|
||||
assert _ADVISORY_DISCLAIMER in _docx_text(out)
|
||||
|
||||
def test_default_report_does_not_crash(self) -> None:
|
||||
# Полностью дефолтный (пустой) SiteFinderReport — валидный .docx.
|
||||
out = render_report_docx(SiteFinderReport())
|
||||
assert out[:2] == _ZIP_SIGNATURE
|
||||
text = _docx_text(out)
|
||||
assert _ADVISORY_DISCLAIMER in text
|
||||
assert "нет данных" in text
|
||||
|
||||
def test_garbage_input_returns_valid_docx(self) -> None:
|
||||
# Мусор (int / строка / list) → нормализуется в {} → валидный .docx, не падает.
|
||||
for junk in (123, "not a report", []):
|
||||
out = render_report_docx(junk)
|
||||
assert isinstance(out, bytes)
|
||||
assert out[:2] == _ZIP_SIGNATURE
|
||||
|
||||
def test_partial_report_some_sections(self) -> None:
|
||||
report = SiteFinderReport(
|
||||
exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."),
|
||||
meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]),
|
||||
)
|
||||
out = render_report_docx(report)
|
||||
assert out[:2] == _ZIP_SIGNATURE
|
||||
text = _docx_text(out)
|
||||
assert "Тонкий анализ" in text
|
||||
assert "66:41:0000000:2" in text
|
||||
# Незаполненные секции рисуют «нет данных», но .docx валиден.
|
||||
assert "нет данных" in text
|
||||
95
backend/uv.lock
generated
95
backend/uv.lock
generated
|
|
@ -578,6 +578,7 @@ dependencies = [
|
|||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyproj" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "redis" },
|
||||
{ name = "rosreestr2coord" },
|
||||
|
|
@ -620,6 +621,7 @@ requires-dist = [
|
|||
{ name = "pydantic", specifier = ">=2.7.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.3.0" },
|
||||
{ name = "pyproj", specifier = ">=3.6.0" },
|
||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
{ name = "redis", specifier = ">=5.0.0" },
|
||||
{ name = "rosreestr2coord", specifier = ">=5.0.0" },
|
||||
|
|
@ -971,6 +973,86 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.11"
|
||||
|
|
@ -1756,6 +1838,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-docx"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue