test
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
output
|
||||
data
|
||||
./data/
|
||||
./output/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.db
|
||||
*.sqlite
|
||||
*.log
|
||||
*.bak
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
*.DS_Store
|
||||
*.idea/
|
||||
*.vscode/
|
||||
*.ipynb_checkpoints/
|
||||
./.venv/
|
||||
.venv/
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
284
main.py
Normal file
284
main.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Программа 17: Заполнение формы по выявленным-1
|
||||
Графический интерфейс (tkinter) + запуск обработки.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
from pathlib import Path
|
||||
|
||||
from services.data_loader import load_ki, load_table2, build_mif_index
|
||||
from services.form_filler import fill_form
|
||||
from services.output_generator import make_filename, combine_forms, generate_table4
|
||||
|
||||
# ─────────────────────────────── Цвета / стиль ──────────────────────────── #
|
||||
|
||||
BG = "#1C3557" # тёмно-синий фон
|
||||
BG_FRAME = "#243F6B" # фон панелей
|
||||
FG = "#FFFFFF" # белый текст
|
||||
FG_DIM = "#A8C4E0" # приглушённый текст
|
||||
BTN_RUN = "#2E7D32" # зелёная кнопка «Запустить»
|
||||
BTN_HELP = "#1565C0" # синяя кнопка «Справка»
|
||||
FONT = ("Arial", 11)
|
||||
FONT_H = ("Arial", 13, "bold")
|
||||
FONT_LOG = ("Courier New", 9)
|
||||
|
||||
# ─────────────────────────────── Основная логика ────────────────────────── #
|
||||
|
||||
def run_processing(table1_path: str, table2_path: str, mif_dir: str,
|
||||
template_path: str, output_dir: str,
|
||||
log_callback, progress_callback, done_callback):
|
||||
"""Выполняется в отдельном потоке."""
|
||||
try:
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log_callback("[1/4] Загрузка исходных данных...")
|
||||
progress_callback(10)
|
||||
ki = load_ki(table1_path)
|
||||
t2_rows = load_table2(table2_path)
|
||||
mif_idx = build_mif_index(mif_dir)
|
||||
log_callback(f" КИ: {next(iter(ki.values()), '?')}")
|
||||
log_callback(f" Таблица 2: {len(t2_rows)} строк")
|
||||
log_callback(f" MIF-файлов: {len(mif_idx)}")
|
||||
|
||||
log_callback("\n[2/4] Генерация форм...")
|
||||
generated, errors = [], []
|
||||
total = sum(1 for r in t2_rows if r[0])
|
||||
|
||||
for i, t2_row in enumerate(t2_rows, 1):
|
||||
zu_file = t2_row[0] if t2_row[0] else ""
|
||||
kv = t2_row[1] if t2_row[1] else ""
|
||||
if not zu_file:
|
||||
continue
|
||||
filename = make_filename(str(kv), str(zu_file))
|
||||
out_path = out / filename
|
||||
log_callback(f" [{len(generated)+len(errors)+1}/{total}] {filename} ...", end="")
|
||||
try:
|
||||
fill_form(template_path, str(out_path), ki, t2_row, mif_idx)
|
||||
generated.append({"name": filename, "path": str(out_path),
|
||||
"kv": str(kv), "zu_file": str(zu_file)})
|
||||
log_callback(" OK")
|
||||
except Exception as exc:
|
||||
log_callback(f" ОШИБКА: {exc}")
|
||||
errors.append((filename, str(exc)))
|
||||
progress_callback(10 + int(70 * (len(generated) + len(errors)) / max(total, 1)))
|
||||
|
||||
log_callback(f"\n Создано: {len(generated)}, ошибок: {len(errors)}")
|
||||
|
||||
if generated:
|
||||
log_callback("\n[3/4] Создание объединённого файла...")
|
||||
progress_callback(82)
|
||||
combined = out / "ВСЕ_ФОРМЫ.xlsx"
|
||||
try:
|
||||
combine_forms([r["path"] for r in generated], str(combined))
|
||||
log_callback(f" → {combined}")
|
||||
except Exception as exc:
|
||||
log_callback(f" ОШИБКА объединения: {exc}")
|
||||
|
||||
log_callback("\n[4/4] Создание Таблицы 4...")
|
||||
progress_callback(95)
|
||||
t4 = out / "Таблица4_Перечень_форм.xlsx"
|
||||
generate_table4(generated, str(t4))
|
||||
log_callback(f" → {t4}")
|
||||
|
||||
progress_callback(100)
|
||||
done_callback(generated, errors, str(out))
|
||||
|
||||
except Exception as exc:
|
||||
log_callback(f"\nКРИТИЧЕСКАЯ ОШИБКА:\n{traceback.format_exc()}")
|
||||
done_callback([], [(str(exc), str(exc))], output_dir)
|
||||
|
||||
# ─────────────────────────────── GUI ────────────────────────────────────── #
|
||||
|
||||
class App(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("Программа 17 — Выявленные-1")
|
||||
self.geometry("700x600")
|
||||
self.minsize(600, 550)
|
||||
self.configure(bg=BG)
|
||||
self._build_ui()
|
||||
|
||||
# ── Построение интерфейса ────────────────────────────────────────────── #
|
||||
|
||||
def _build_ui(self):
|
||||
# Заголовок
|
||||
tk.Label(self, text="Программа 17: Заполнение формы по Выявленным-1",
|
||||
font=FONT_H, bg=BG, fg=FG).pack(pady=(14, 8))
|
||||
|
||||
# Карточка с полями ввода
|
||||
card = tk.Frame(self, bg=BG_FRAME, bd=0)
|
||||
card.pack(fill=tk.X, padx=18, pady=4)
|
||||
|
||||
self.var_t1 = self._add_field(card, "Таблица 1 (КИ):", "xlsx", 0)
|
||||
self.var_t2 = self._add_field(card, "Таблица 2 (данные):", "xlsx", 1)
|
||||
self.var_mif = self._add_field(card, "Папка MIF:", "dir", 2)
|
||||
self.var_template = self._add_field(card, "Шаблон XLSX:", "xlsx", 3)
|
||||
self.var_output = self._add_field(card, "Папка вывода:", "dir", 4)
|
||||
|
||||
# Дефолты
|
||||
base = Path(__file__).parent
|
||||
data = base / "data" / "Исх.данные"
|
||||
self.var_t1.set(str(data / "Таблица1 КИ-Неустроева.xlsx"))
|
||||
self.var_t2.set(str(data / "Таблица 2 Перечень выявленных исх.данные.xlsx"))
|
||||
self.var_mif.set(str(data / "1. 74 34 0400007 ГК вымпел-73"))
|
||||
self.var_template.set(str(base / "data" / "ШАБЛОН ВЫЯВЛЕННЫЕ-1.xlsx"))
|
||||
self.var_output.set(str(base / "output"))
|
||||
|
||||
# Прогресс-бар
|
||||
pf = tk.Frame(self, bg=BG)
|
||||
pf.pack(fill=tk.X, padx=18, pady=(10, 2))
|
||||
self.progress = ttk.Progressbar(pf, length=600, mode="determinate")
|
||||
self.progress.pack(fill=tk.X)
|
||||
self.progress_label = tk.Label(pf, text="Готов к работе",
|
||||
font=("Arial", 9), bg=BG, fg=FG_DIM)
|
||||
self.progress_label.pack(anchor="w")
|
||||
|
||||
# Лог
|
||||
lf = tk.Frame(self, bg=BG)
|
||||
lf.pack(fill=tk.BOTH, expand=True, padx=18, pady=(4, 4))
|
||||
tk.Label(lf, text="Журнал:", font=("Arial", 9), bg=BG, fg=FG_DIM).pack(anchor="w")
|
||||
self.log_text = tk.Text(lf, font=FONT_LOG, bg="#0D1F33", fg="#C8E6FF",
|
||||
height=10, state=tk.DISABLED, wrap=tk.WORD)
|
||||
sb = tk.Scrollbar(lf, command=self.log_text.yview)
|
||||
self.log_text.configure(yscrollcommand=sb.set)
|
||||
sb.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.log_text.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Кнопки
|
||||
bf = tk.Frame(self, bg=BG)
|
||||
bf.pack(fill=tk.X, padx=18, pady=(6, 12))
|
||||
tk.Button(bf, text="Справка", command=self._show_help,
|
||||
font=FONT, bg=BTN_HELP, fg=FG,
|
||||
padx=14, pady=8, relief=tk.FLAT).pack(side=tk.LEFT)
|
||||
self.btn_run = tk.Button(bf, text="▶ Запустить",
|
||||
command=self._start, font=FONT_H,
|
||||
bg=BTN_RUN, fg=FG, padx=20, pady=8, relief=tk.FLAT)
|
||||
self.btn_run.pack(side=tk.RIGHT)
|
||||
|
||||
def _add_field(self, parent, label: str, kind: str, row_idx: int):
|
||||
"""Добавить строку: метка + поле + кнопка «...»."""
|
||||
var = tk.StringVar()
|
||||
tk.Label(parent, text=label, font=FONT, bg=BG_FRAME, fg=FG,
|
||||
anchor="w").grid(row=row_idx * 2, column=0, columnspan=3,
|
||||
sticky="w", padx=12, pady=(8, 0))
|
||||
entry = tk.Entry(parent, textvariable=var, font=FONT, width=55)
|
||||
entry.grid(row=row_idx * 2 + 1, column=0, padx=(12, 4), pady=(0, 2), sticky="ew")
|
||||
btn = tk.Button(parent, text="…", font=FONT, width=3,
|
||||
command=lambda k=kind, v=var: self._browse(k, v))
|
||||
btn.grid(row=row_idx * 2 + 1, column=1, padx=(0, 12), pady=(0, 2))
|
||||
parent.columnconfigure(0, weight=1)
|
||||
return var
|
||||
|
||||
def _browse(self, kind: str, var: tk.StringVar):
|
||||
if kind == "dir":
|
||||
path = filedialog.askdirectory(title="Выберите папку")
|
||||
else:
|
||||
path = filedialog.askopenfilename(
|
||||
title="Выберите файл",
|
||||
filetypes=[("Excel файлы", "*.xlsx *.xls"), ("Все файлы", "*.*")]
|
||||
)
|
||||
if path:
|
||||
var.set(path)
|
||||
|
||||
# ── Запуск ──────────────────────────────────────────────────────────── #
|
||||
|
||||
def _start(self):
|
||||
t1 = self.var_t1.get().strip()
|
||||
t2 = self.var_t2.get().strip()
|
||||
mif = self.var_mif.get().strip()
|
||||
tmpl = self.var_template.get().strip()
|
||||
out = self.var_output.get().strip()
|
||||
|
||||
missing = [name for name, v in [("Таблица 1", t1), ("Таблица 2", t2),
|
||||
("Папка MIF", mif), ("Шаблон", tmpl),
|
||||
("Папка вывода", out)] if not v]
|
||||
if missing:
|
||||
messagebox.showerror("Ошибка", "Не заполнены поля:\n• " + "\n• ".join(missing))
|
||||
return
|
||||
|
||||
self._log_clear()
|
||||
self._set_progress(0, "Запуск...")
|
||||
self.btn_run.config(state=tk.DISABLED)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=run_processing,
|
||||
args=(t1, t2, mif, tmpl, out,
|
||||
self._log_append,
|
||||
lambda v: self.after(0, self._set_progress, v, None),
|
||||
self._on_done),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# ── Обратные вызовы из потока ────────────────────────────────────────── #
|
||||
|
||||
def _log_append(self, text: str, end: str = "\n"):
|
||||
def _do():
|
||||
self.log_text.config(state=tk.NORMAL)
|
||||
self.log_text.insert(tk.END, text + end)
|
||||
self.log_text.see(tk.END)
|
||||
self.log_text.config(state=tk.DISABLED)
|
||||
self.after(0, _do)
|
||||
|
||||
def _log_clear(self):
|
||||
self.log_text.config(state=tk.NORMAL)
|
||||
self.log_text.delete("1.0", tk.END)
|
||||
self.log_text.config(state=tk.DISABLED)
|
||||
|
||||
def _set_progress(self, value: int, label_text=None):
|
||||
self.progress["value"] = value
|
||||
if label_text is not None:
|
||||
self.progress_label.config(text=label_text)
|
||||
elif value == 100:
|
||||
self.progress_label.config(text="Готово!")
|
||||
|
||||
def _on_done(self, generated: list, errors: list, out_dir: str):
|
||||
def _do():
|
||||
self.btn_run.config(state=tk.NORMAL)
|
||||
if errors and not generated:
|
||||
messagebox.showerror("Ошибка", f"Обработка завершилась с ошибками:\n{errors[0][1]}")
|
||||
return
|
||||
msg = (
|
||||
f"Обработка завершена!\n\n"
|
||||
f"Создано форм: {len(generated)}\n"
|
||||
f"Ошибок: {len(errors)}\n\n"
|
||||
f"Результаты в папке:\n{out_dir}"
|
||||
)
|
||||
if errors:
|
||||
msg += f"\n\nФайлы с ошибками ({len(errors)}):\n" + \
|
||||
"\n".join(f"• {n}" for n, _ in errors[:10])
|
||||
messagebox.showinfo("Готово", msg)
|
||||
self.after(0, _do)
|
||||
|
||||
# ── Справка ─────────────────────────────────────────────────────────── #
|
||||
|
||||
def _show_help(self):
|
||||
messagebox.showinfo("Справка", """\
|
||||
Программа 17: Заполнение формы по Выявленным-1
|
||||
|
||||
Поля ввода:
|
||||
• Таблица 1 — файл xlsx с данными кадастрового инженера
|
||||
• Таблица 2 — файл xlsx со списком выявленных объектов
|
||||
• Папка MIF — папка с MIF-файлами контуров (*.mif)
|
||||
• Шаблон — файл шаблона XLSX
|
||||
• Папка вывода — куда сохранять результаты
|
||||
|
||||
Результаты:
|
||||
• Отдельный xlsx для каждой строки Таблицы 2
|
||||
• ВСЕ_ФОРМЫ.xlsx — все формы на одном листе
|
||||
• Таблица4_Перечень_форм.xlsx — перечень файлов
|
||||
|
||||
Шрифт всех ячеек: Times New Roman 11 pt.
|
||||
""")
|
||||
|
||||
|
||||
# ─────────────────────────────── Точка входа ────────────────────────────── #
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = App()
|
||||
app.mainloop()
|
||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[project]
|
||||
name = "kadastr-17"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"openpyxl>=3.1",
|
||||
]
|
||||
229
read_mif_file.py
Normal file
229
read_mif_file.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Tuple, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class MifFile:
|
||||
name: str
|
||||
path: str
|
||||
headers: Dict[str, Any] = field(default_factory=dict)
|
||||
features: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _unquote(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('"') and s.endswith('"'):
|
||||
return s[1:-1]
|
||||
return s
|
||||
|
||||
|
||||
def parse_mif(file_path: str) -> Union[MifFile, List[MifFile]]:
|
||||
"""
|
||||
Примитивный парсер MIF-файла. Обрабатывает заголовки (Version, Charset, Delimiter,
|
||||
CoordSys, Columns) и простые объекты в блоке Data: Region, Text, Center/Centre, Pen, Brush.
|
||||
|
||||
Не претендует на полную спецификацию MapInfo MIF, но удобен для быстрого извлечения структур.
|
||||
"""
|
||||
p = Path(file_path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(file_path)
|
||||
|
||||
# If a directory is passed, parse all .mif files inside and return a list
|
||||
if p.is_dir():
|
||||
return load_mif_files(str(p))
|
||||
|
||||
mif = MifFile(name=p.name, path=str(p.resolve()))
|
||||
lines = [ln.rstrip('\n') for ln in p.read_text(encoding='utf-8', errors='replace').splitlines()]
|
||||
|
||||
i = 0
|
||||
n = len(lines)
|
||||
# parse header until "Data"
|
||||
while i < n:
|
||||
line = lines[i].strip()
|
||||
if not line:
|
||||
i += 1
|
||||
continue
|
||||
up = line.upper()
|
||||
if up.startswith('VERSION'):
|
||||
mif.headers['version'] = line.split(None, 1)[1].strip() if len(line.split()) > 1 else None
|
||||
i += 1
|
||||
continue
|
||||
if up.startswith('CHARSET'):
|
||||
# Charset "WindowsCyrillic"
|
||||
rest = line.split(None, 1)[1] if len(line.split()) > 1 else ''
|
||||
mif.headers['charset'] = _unquote(rest)
|
||||
i += 1
|
||||
continue
|
||||
if up.startswith('DELIMITER'):
|
||||
rest = line.split(None, 1)[1] if len(line.split()) > 1 else ''
|
||||
mif.headers['delimiter'] = _unquote(rest)
|
||||
i += 1
|
||||
continue
|
||||
if up.startswith('COORDSYS'):
|
||||
mif.headers['coordsys'] = line[len('CoordSys'):].strip()
|
||||
i += 1
|
||||
continue
|
||||
if up.startswith('COLUMNS'):
|
||||
# next N lines are column definitions
|
||||
parts = line.split()
|
||||
cnt = int(parts[1]) if len(parts) > 1 else 0
|
||||
i += 1
|
||||
cols: List[str] = []
|
||||
for _ in range(cnt):
|
||||
if i >= n:
|
||||
break
|
||||
cols.append(lines[i].strip())
|
||||
i += 1
|
||||
mif.headers['columns'] = cols
|
||||
continue
|
||||
if up == 'DATA':
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
|
||||
# parse data features
|
||||
while i < n:
|
||||
raw = lines[i].lstrip()
|
||||
if not raw:
|
||||
i += 1
|
||||
continue
|
||||
parts = raw.split(None, 1)
|
||||
typ = parts[0]
|
||||
if typ.upper() == 'REGION':
|
||||
feat: Dict[str, Any] = {'type': 'Region', 'props': {}}
|
||||
# Region <N_polygons> — количество контуров (полигонов) в объекте
|
||||
try:
|
||||
n_polygons = int(parts[1]) if len(parts) > 1 else 1
|
||||
except (ValueError, IndexError):
|
||||
n_polygons = 1
|
||||
i += 1
|
||||
|
||||
all_points: List[Tuple[float, float]] = []
|
||||
contours: List[List[Tuple[float, float]]] = []
|
||||
|
||||
for _poly in range(n_polygons):
|
||||
# Первая строка каждого контура — количество точек
|
||||
if i >= n:
|
||||
break
|
||||
cnt_line = lines[i].strip()
|
||||
try:
|
||||
pts_cnt = int(cnt_line)
|
||||
i += 1
|
||||
except ValueError:
|
||||
pts_cnt = 0
|
||||
|
||||
poly_pts: List[Tuple[float, float]] = []
|
||||
for _ in range(pts_cnt):
|
||||
if i >= n:
|
||||
break
|
||||
xy = lines[i].strip().split()
|
||||
if len(xy) >= 2:
|
||||
try:
|
||||
poly_pts.append((float(xy[0]), float(xy[1])))
|
||||
except ValueError:
|
||||
pass
|
||||
i += 1
|
||||
|
||||
if poly_pts:
|
||||
contours.append(poly_pts)
|
||||
all_points.extend(poly_pts)
|
||||
|
||||
# Если один контур — points == список точек (как было раньше)
|
||||
# Если несколько — points == точки всех контуров подряд, contours — отдельно
|
||||
feat['props']['points'] = all_points
|
||||
feat['props']['contours'] = contours # List[List[(x,y)]]
|
||||
|
||||
# Читаем атрибуты (Pen, Brush, Center) до начала следующего объекта
|
||||
while i < n:
|
||||
t = lines[i].lstrip()
|
||||
if not t:
|
||||
i += 1
|
||||
continue
|
||||
up2 = t.upper()
|
||||
if up2.startswith('PEN'):
|
||||
feat['props']['pen'] = t[len('Pen'):].strip()
|
||||
i += 1
|
||||
continue
|
||||
if up2.startswith('BRUSH'):
|
||||
feat['props']['brush'] = t[len('Brush'):].strip()
|
||||
i += 1
|
||||
continue
|
||||
if up2.startswith('CENTER') or up2.startswith('CENTRE'):
|
||||
rest = t.split(None, 1)[1] if len(t.split()) > 1 else ''
|
||||
coords = rest.split()
|
||||
try:
|
||||
cx = float(coords[0]); cy = float(coords[1])
|
||||
feat['props']['center'] = (cx, cy)
|
||||
except Exception:
|
||||
pass
|
||||
i += 1
|
||||
continue
|
||||
if up2.split()[0] in ('REGION', 'TEXT', 'POINT', 'PLINE', 'LINE'):
|
||||
break
|
||||
i += 1
|
||||
|
||||
mif.features.append(feat)
|
||||
continue
|
||||
|
||||
if typ.upper() == 'TEXT':
|
||||
feat = {'type': 'Text', 'props': {}}
|
||||
# next line: quoted string
|
||||
i += 1
|
||||
if i < n:
|
||||
txt_line = lines[i].strip()
|
||||
feat['props']['text'] = _unquote(txt_line)
|
||||
i += 1
|
||||
# next line: bounding coordinates (often 4 numbers)
|
||||
if i < n:
|
||||
coords = lines[i].strip().split()
|
||||
try:
|
||||
nums = [float(x) for x in coords]
|
||||
feat['props']['bbox'] = nums
|
||||
except Exception:
|
||||
pass
|
||||
i += 1
|
||||
# optional Font line
|
||||
if i < n and lines[i].lstrip().upper().startswith('FONT'):
|
||||
feat['props']['font'] = lines[i].lstrip()[len('Font'):].strip()
|
||||
i += 1
|
||||
|
||||
mif.features.append(feat)
|
||||
continue
|
||||
|
||||
# simple POINT parser
|
||||
if typ.upper() == 'POINT':
|
||||
feat = {'type': 'Point', 'props': {}}
|
||||
rest = parts[1] if len(parts) > 1 else ''
|
||||
coords = rest.split()
|
||||
try:
|
||||
feat['props']['xy'] = (float(coords[0]), float(coords[1]))
|
||||
except Exception:
|
||||
feat['props']['xy'] = None
|
||||
i += 1
|
||||
mif.features.append(feat)
|
||||
continue
|
||||
|
||||
# unknown / unsupported object: skip one line
|
||||
i += 1
|
||||
|
||||
return mif
|
||||
|
||||
|
||||
def load_mif_files(dir_path: str) -> List[MifFile]:
|
||||
"""Найти все .mif файлы в директории (рекурсивно) и распарсить их в объекты MifFile."""
|
||||
root = Path(dir_path)
|
||||
if not root.exists():
|
||||
raise FileNotFoundError(dir_path)
|
||||
|
||||
mif_files: List[MifFile] = []
|
||||
for p in root.rglob('*.mif'):
|
||||
try:
|
||||
mif_files.append(parse_mif(str(p)))
|
||||
except Exception:
|
||||
# при ошибке парсинга — пропустить файл
|
||||
continue
|
||||
return mif_files
|
||||
|
||||
|
||||
__all__ = ["MifFile", "parse_mif", "load_mif_files"]
|
||||
49
read_one_file_xlsx.py
Normal file
49
read_one_file_xlsx.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
def read_xlsx(file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Прочитать .xlsx файл и вернуть простой объект со всеми данными.
|
||||
|
||||
Возвращаемая структура:
|
||||
{
|
||||
'name': <file name>,
|
||||
'path': <abs path>,
|
||||
'sheets': [
|
||||
{'name': <sheet name>, 'rows': [[cell_value, ...], ...]},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Читает документ сверху-вниз, сохраняя значения ячеек в их питоновских типах.
|
||||
"""
|
||||
p = Path(file_path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(file_path)
|
||||
|
||||
# try openpyxl first
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except Exception as e:
|
||||
raise RuntimeError("openpyxl is required to read .xlsx files") from e
|
||||
|
||||
wb = load_workbook(filename=str(p), data_only=True)
|
||||
out = {"name": p.name, "path": str(p.resolve()), "sheets": []}
|
||||
for sheet in wb.worksheets:
|
||||
rows: List[List[Any]] = []
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
rows.append(list(row))
|
||||
out["sheets"].append({"name": sheet.title, "rows": rows})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys, json
|
||||
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else "data/Исх.данные/Таблица1 КИ-Неустроева.xlsx"
|
||||
try:
|
||||
data = read_xlsx(target)
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
||||
except Exception as exc:
|
||||
print('Error:', exc)
|
||||
32
read_path.py
Normal file
32
read_path.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
def list_files_in_dir(dir_path: str, recursive: bool = False) -> List[str]:
|
||||
"""
|
||||
Возвращает список файлов в директории с их полными путями.
|
||||
|
||||
Args:
|
||||
dir_path: путь к директории (строка или Path-подобный).
|
||||
recursive: если True, включает файлы во всех поддиректориях.
|
||||
|
||||
Returns:
|
||||
Список путей (строк) к файлам.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: если директория не найдена.
|
||||
"""
|
||||
p = Path(dir_path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"Directory not found: {dir_path}")
|
||||
|
||||
if recursive:
|
||||
files = [str(f.resolve()) for f in p.rglob("*") if f.is_file()]
|
||||
else:
|
||||
files = [str(f.resolve()) for f in p.iterdir() if f.is_file()]
|
||||
|
||||
return files
|
||||
|
||||
|
||||
__all__ = ["list_files_in_dir"]
|
||||
|
||||
1
services/__init__.py
Normal file
1
services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services package
|
||||
46
services/data_loader.py
Normal file
46
services/data_loader.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Загрузка исходных данных: Таблица 1 (КИ), Таблица 2, индекс MIF-файлов."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
def load_ki(path: str) -> dict:
|
||||
"""Таблица 1: {название_поля → значение}."""
|
||||
wb = load_workbook(path, data_only=True)
|
||||
ws = wb.active
|
||||
ki = {}
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
if row[0] is not None and row[1] is not None:
|
||||
ki[str(row[0]).strip()] = str(row[1]).strip()
|
||||
return ki
|
||||
|
||||
|
||||
def ki_val(ki: dict, keyword: str) -> str:
|
||||
"""Найти значение в словаре КИ по подстроке ключа (без учёта регистра)."""
|
||||
for k, v in ki.items():
|
||||
if keyword.lower() in k.lower():
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
def load_table2(path: str) -> list:
|
||||
"""Таблица 2: список строк (кортежи). Заголовок и пустые строки пропускаются."""
|
||||
wb = load_workbook(path, data_only=True)
|
||||
ws = wb.active
|
||||
rows = []
|
||||
for i, row in enumerate(ws.iter_rows(values_only=True)):
|
||||
if i == 0:
|
||||
continue
|
||||
if all(v is None for v in row[:5]):
|
||||
continue
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def build_mif_index(mif_dir: str) -> dict:
|
||||
"""Индекс MIF-файлов: stem → полный путь."""
|
||||
index = {}
|
||||
for p in Path(mif_dir).rglob("*.mif"):
|
||||
index[p.stem] = str(p)
|
||||
return index
|
||||
195
services/form_filler.py
Normal file
195
services/form_filler.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Заполнение одной формы XLSX по шаблону."""
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font
|
||||
|
||||
from services.data_loader import ki_val
|
||||
from services.mif_processor import get_contours, build_coord_rows
|
||||
|
||||
# ─────────────────────────────── Константы ──────────────────────────────── #
|
||||
|
||||
BASE_FONT = Font(name="Times New Roman", size=11)
|
||||
|
||||
# Позиции строк шаблона (1-based, до вставки доп. строк координат)
|
||||
TMPL = dict(
|
||||
KV=2, # A2: кадастровый квартал
|
||||
DATE=5, # F5: дата подготовки
|
||||
FIO=11, # F11: ФИО КИ
|
||||
SNILS=12, # F12: СНИЛС
|
||||
PHONE=13, # F13: телефон
|
||||
EMAIL=15, # A15: email + адрес
|
||||
SRO=17, # A17: наименование СРО
|
||||
REGNR=19, # A19: рег. номер
|
||||
SOGL=24, # A24: соглашение
|
||||
LOC_ZU=29, # F29: местоположение ЗУ
|
||||
AREA_ZU=31, # F31: площадь ЗУ
|
||||
OBOSN_ZU=33, # F33: обоснование ЗУ
|
||||
ZU_COORD_START=37, # первая строка данных координат ЗУ
|
||||
ZU_COORD_PRE=5, # кол-во предзаполненных строк (37-41)
|
||||
LOC_OKS=54, # F54: местоположение ОКС (до смещения)
|
||||
OBOSN_OKS=58, # F58: обоснование ОКС (до смещения)
|
||||
OKS_COORD_START=62, # первая строка данных ОКС (до смещения)
|
||||
OKS_COORD_PRE=1, # кол-во предзаполненных строк ОКС
|
||||
)
|
||||
|
||||
# ──────────────────────────── Вспомогательные ───────────────────────────── #
|
||||
|
||||
def _sv(ws, row: int, col: int, value, font=None):
|
||||
cell = ws.cell(row=row, column=col)
|
||||
cell.value = value
|
||||
if font:
|
||||
cell.font = copy.copy(font)
|
||||
|
||||
|
||||
def _safe_insert_rows(ws, idx: int, amount: int):
|
||||
"""
|
||||
Вставить строки с корректным сдвигом слиянных ячеек.
|
||||
openpyxl's ws.insert_rows() имеет известный баг: не сдвигает все merge-диапазоны.
|
||||
Решение: вручную снимаем/пересчитываем merge до вызова insert_rows.
|
||||
"""
|
||||
# Собираем все диапазоны, которые пересекаются с точкой вставки или ниже неё
|
||||
to_readjust = []
|
||||
for rng in list(ws.merged_cells.ranges):
|
||||
if rng.max_row >= idx:
|
||||
to_readjust.append((rng.min_row, rng.min_col, rng.max_row, rng.max_col))
|
||||
ws.merged_cells.remove(rng)
|
||||
|
||||
ws.insert_rows(idx, amount)
|
||||
|
||||
# Восстанавливаем диапазоны с поправкой
|
||||
for min_r, min_c, max_r, max_c in to_readjust:
|
||||
new_min_r = min_r + amount if min_r >= idx else min_r
|
||||
new_max_r = max_r + amount if max_r >= idx else max_r
|
||||
ws.merge_cells(
|
||||
start_row=new_min_r, start_column=min_c,
|
||||
end_row=new_max_r, end_column=max_c,
|
||||
)
|
||||
|
||||
|
||||
def _copy_row_style(ws, src_row: int, dst_row: int):
|
||||
for col in range(1, ws.max_column + 1):
|
||||
src = ws.cell(row=src_row, column=col)
|
||||
dst = ws.cell(row=dst_row, column=col)
|
||||
if src.has_style:
|
||||
dst.font = copy.copy(src.font)
|
||||
dst.border = copy.copy(src.border)
|
||||
dst.fill = copy.copy(src.fill)
|
||||
dst.number_format = src.number_format
|
||||
dst.alignment = copy.copy(src.alignment)
|
||||
|
||||
|
||||
def _insert_coord_rows(ws, coord_data: list, start_row: int, pre_allocated: int) -> int:
|
||||
"""Вставить строки координат. Возвращает кол-во добавленных строк (смещение)."""
|
||||
needed = len(coord_data)
|
||||
extra = max(0, needed - pre_allocated)
|
||||
if extra > 0:
|
||||
_safe_insert_rows(ws, start_row + pre_allocated, extra)
|
||||
for i in range(extra):
|
||||
_copy_row_style(ws, start_row, start_row + pre_allocated + i)
|
||||
|
||||
F = BASE_FONT
|
||||
for i, entry in enumerate(coord_data):
|
||||
row_idx = start_row + i
|
||||
if entry is None:
|
||||
continue
|
||||
num, x_str, y_str, method, mt = entry
|
||||
_sv(ws, row_idx, 1, num, F)
|
||||
_sv(ws, row_idx, 3, x_str, F)
|
||||
_sv(ws, row_idx, 4, y_str, F)
|
||||
_sv(ws, row_idx, 5, method, F)
|
||||
_sv(ws, row_idx, 6, mt, F)
|
||||
|
||||
return extra
|
||||
|
||||
|
||||
def _apply_font_all(ws):
|
||||
"""Применить Times New Roman 11 ко всем заполненным ячейкам листа."""
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value is not None:
|
||||
old = cell.font
|
||||
cell.font = Font(
|
||||
name="Times New Roman", size=11,
|
||||
bold=old.bold if old else False,
|
||||
italic=old.italic if old else False,
|
||||
underline=old.underline if old else None,
|
||||
)
|
||||
|
||||
# ─────────────────────────────── Публичная функция ──────────────────────── #
|
||||
|
||||
def fill_form(template_path: str, output_path: str,
|
||||
ki: dict, t2_row: tuple, mif_index: dict):
|
||||
"""Заполнить шаблон данными одной строки Таблицы 2 и сохранить файл."""
|
||||
|
||||
def col(i, default=None):
|
||||
return t2_row[i] if len(t2_row) > i and t2_row[i] is not None else default
|
||||
|
||||
zu_file = col(0, "")
|
||||
kv = col(1, "")
|
||||
date_prep = col(2)
|
||||
sogl = col(3, "")
|
||||
loc_zu = col(4, "")
|
||||
area_zu = col(5, "")
|
||||
err_zu = col(6, "")
|
||||
obosn_zu = col(7, "")
|
||||
method_zu = col(8, "")
|
||||
mt_zu = col(9, "")
|
||||
oks_file = col(10, "")
|
||||
loc_oks = col(11, "")
|
||||
obosn_oks = col(12, "")
|
||||
method_oks = col(13, "")
|
||||
mt_oks = col(14, "")
|
||||
|
||||
wb = load_workbook(template_path)
|
||||
ws = wb.active
|
||||
F = BASE_FONT
|
||||
T = TMPL
|
||||
|
||||
# Шапка
|
||||
_sv(ws, T["KV"], 1, str(kv), F)
|
||||
# Дата: конвертируем datetime в строку dd.mm.yyyy
|
||||
if isinstance(date_prep, datetime.datetime):
|
||||
date_str = date_prep.strftime("%d.%m.%Y")
|
||||
else:
|
||||
date_str = str(date_prep) if date_prep else ""
|
||||
_sv(ws, T["DATE"], 6, date_str, F)
|
||||
|
||||
# Раздел 2: КИ
|
||||
_sv(ws, T["FIO"], 6, ki_val(ki, "ФИО"), F)
|
||||
_sv(ws, T["SNILS"], 6, ki_val(ki, "Страховой"), F)
|
||||
_sv(ws, T["PHONE"], 6, ki_val(ki, "Контактный"), F)
|
||||
_sv(ws, T["EMAIL"], 1, ki_val(ki, "Адрес электронной почты"), F)
|
||||
_sv(ws, T["SRO"], 1, ki_val(ki, "Наименование саморегулируемой"), F)
|
||||
_sv(ws, T["REGNR"], 1, ki_val(ki, "Уникальный"), F)
|
||||
|
||||
# Раздел 3: Основания
|
||||
_sv(ws, T["SOGL"], 1, sogl, F)
|
||||
|
||||
# Раздел 4: ЗУ
|
||||
_sv(ws, T["LOC_ZU"], 6, loc_zu, F)
|
||||
area_str = f"{area_zu}±{err_zu}" if (area_zu or err_zu) else ""
|
||||
_sv(ws, T["AREA_ZU"], 6, area_str, F)
|
||||
_sv(ws, T["OBOSN_ZU"], 6, obosn_zu, F)
|
||||
|
||||
# Раздел 5: координаты ЗУ
|
||||
zu_path = mif_index.get(str(zu_file).strip())
|
||||
zu_contours = get_contours(zu_path) if zu_path else []
|
||||
zu_coord_data = build_coord_rows(zu_contours, str(method_zu), mt_zu)
|
||||
zu_offset = _insert_coord_rows(ws, zu_coord_data, T["ZU_COORD_START"], T["ZU_COORD_PRE"])
|
||||
|
||||
# Разделы 7-9: ОКС (со смещением)
|
||||
ofs = zu_offset
|
||||
_sv(ws, T["LOC_OKS"] + ofs, 6, loc_oks or "-", F)
|
||||
_sv(ws, T["OBOSN_OKS"] + ofs, 6, obosn_oks or "-", F)
|
||||
|
||||
oks_path = mif_index.get(str(oks_file).strip()) if oks_file else None
|
||||
oks_contours = get_contours(oks_path) if oks_path else []
|
||||
oks_coord_data = build_coord_rows(oks_contours, str(method_oks or ""), mt_oks)
|
||||
if oks_coord_data:
|
||||
_insert_coord_rows(ws, oks_coord_data, T["OKS_COORD_START"] + ofs, T["OKS_COORD_PRE"])
|
||||
|
||||
_apply_font_all(ws)
|
||||
wb.save(output_path)
|
||||
59
services/mif_processor.py
Normal file
59
services/mif_processor.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Извлечение контуров из MIF-файлов и формирование строк координат."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# read_mif_file лежит в корне проекта — добавляем его в путь поиска
|
||||
_PROJECT_ROOT = str(Path(__file__).parent.parent)
|
||||
if _PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT_ROOT)
|
||||
|
||||
from read_mif_file import parse_mif # noqa: E402
|
||||
|
||||
|
||||
def get_contours(mif_path: str) -> list:
|
||||
"""
|
||||
Извлечь список контуров (Region) из MIF-файла.
|
||||
Возвращает: [[(x, y), ...], ...] — список контуров.
|
||||
Region может содержать несколько полигонов (contours).
|
||||
"""
|
||||
result = parse_mif(mif_path)
|
||||
if isinstance(result, list):
|
||||
return []
|
||||
all_contours = []
|
||||
for feat in result.features:
|
||||
if feat["type"] == "Region":
|
||||
# contours — список полигонов (исправленный парсер)
|
||||
for poly in feat["props"].get("contours", []):
|
||||
if poly:
|
||||
all_contours.append(list(poly))
|
||||
return all_contours
|
||||
|
||||
|
||||
def build_coord_rows(contours: list, method: str, mt) -> list:
|
||||
"""
|
||||
Сформировать список строк для раздела координат.
|
||||
|
||||
Каждый элемент: (номер, X_str, Y_str, method, mt)
|
||||
или None — пустой разделитель между контурами.
|
||||
|
||||
Правила:
|
||||
- Первая координата MIF → X (колонка C), вторая → Y (D).
|
||||
- Все точки нумеруются подряд по всем контурам.
|
||||
- Последняя (замыкающая) точка контура получает номер первой точки этого контура.
|
||||
"""
|
||||
rows = []
|
||||
global_num = 1
|
||||
for ci, contour in enumerate(contours):
|
||||
if ci > 0:
|
||||
rows.append(None)
|
||||
if not contour:
|
||||
continue
|
||||
first_num = global_num
|
||||
for mx, my in contour[:-1]:
|
||||
rows.append((global_num, f"{mx:.2f}", f"{my:.2f}", method, mt))
|
||||
global_num += 1
|
||||
if len(contour) >= 2:
|
||||
mx, my = contour[-1]
|
||||
rows.append((first_num, f"{mx:.2f}", f"{my:.2f}", method, mt))
|
||||
return rows
|
||||
80
services/output_generator.py
Normal file
80
services/output_generator.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Генерация итоговых файлов: объединённая форма и Таблица 4."""
|
||||
|
||||
import copy
|
||||
import re
|
||||
|
||||
from openpyxl import load_workbook, Workbook
|
||||
from openpyxl.styles import Font
|
||||
|
||||
from services.form_filler import BASE_FONT
|
||||
|
||||
|
||||
def make_filename(kv: str, zu_file: str) -> str:
|
||||
"""
|
||||
Формат: {кв_через_подчёркивание}_{zu_file}.xlsx
|
||||
Пример: 74_34_0400007_выявл1_{вымпел_100}.xlsx
|
||||
"""
|
||||
kv_clean = str(kv).replace(":", "_")
|
||||
safe = re.sub(r'[<>"/\\|?*]', "_", str(zu_file))
|
||||
return f"{kv_clean}_{safe}.xlsx"
|
||||
|
||||
|
||||
def combine_forms(form_paths: list, combined_path: str):
|
||||
"""Объединить все формы в один лист."""
|
||||
combined_wb = Workbook()
|
||||
combined_ws = combined_wb.active
|
||||
combined_ws.title = "Все формы"
|
||||
|
||||
current_row = 1
|
||||
for path in form_paths:
|
||||
src_wb = load_workbook(path, data_only=True)
|
||||
src_ws = src_wb.active
|
||||
last_cell = None
|
||||
for src_row in src_ws.iter_rows():
|
||||
for cell in src_row:
|
||||
dst = combined_ws.cell(
|
||||
row=current_row + cell.row - 1,
|
||||
column=cell.column,
|
||||
)
|
||||
dst.value = cell.value
|
||||
if cell.has_style:
|
||||
dst.font = copy.copy(cell.font)
|
||||
dst.border = copy.copy(cell.border)
|
||||
dst.fill = copy.copy(cell.fill)
|
||||
dst.number_format = cell.number_format
|
||||
dst.alignment = copy.copy(cell.alignment)
|
||||
last_cell = cell
|
||||
if last_cell:
|
||||
src_row_dim = src_ws.row_dimensions[last_cell.row]
|
||||
combined_ws.row_dimensions[current_row + last_cell.row - 1].height = src_row_dim.height
|
||||
|
||||
current_row += src_ws.max_row
|
||||
|
||||
if form_paths:
|
||||
src_wb = load_workbook(form_paths[0])
|
||||
src_ws = src_wb.active
|
||||
for col_letter, col_dim in src_ws.column_dimensions.items():
|
||||
combined_ws.column_dimensions[col_letter].width = col_dim.width
|
||||
|
||||
combined_wb.save(combined_path)
|
||||
|
||||
|
||||
def generate_table4(generated: list, output_path: str):
|
||||
"""Перечень заполненных форм (Таблица 4)."""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Перечень форм"
|
||||
F = BASE_FONT
|
||||
headers = ["№ п/п", "Кадастровый квартал", "Название файла ЗУ", "Имя файла", "Путь"]
|
||||
for col_idx, h in enumerate(headers, 1):
|
||||
ws.cell(row=1, column=col_idx, value=h).font = Font(
|
||||
name="Times New Roman", size=11, bold=True
|
||||
)
|
||||
for i, rec in enumerate(generated, 1):
|
||||
row = i + 1
|
||||
ws.cell(row=row, column=1, value=i).font = copy.copy(F)
|
||||
ws.cell(row=row, column=2, value=rec["kv"]).font = copy.copy(F)
|
||||
ws.cell(row=row, column=3, value=rec["zu_file"]).font = copy.copy(F)
|
||||
ws.cell(row=row, column=4, value=rec["name"]).font = copy.copy(F)
|
||||
ws.cell(row=row, column=5, value=rec["path"]).font = copy.copy(F)
|
||||
wb.save(output_path)
|
||||
35
uv.lock
generated
Normal file
35
uv.lock
generated
Normal file
@@ -0,0 +1,35 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "et-xmlfile"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kadastr-17"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "openpyxl" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "openpyxl", specifier = ">=3.1" }]
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "et-xmlfile" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user