diff --git a/.vscode/settings.json b/.vscode/settings.json index a18e70a..e203dbf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { - "python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe", + "python.defaultInterpreterPath": "c:/git/olap-export-tool/.venv/Scripts/python.exe", "python.analysis.typeCheckingMode": "basic" } diff --git a/docs/superpowers/plans/2026-03-10-tui-restructure.md b/docs/superpowers/plans/2026-03-10-tui-restructure.md new file mode 100644 index 0000000..37415b4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-10-tui-restructure.md @@ -0,0 +1,1696 @@ +# OLAP Export Tool — TUI + Реструктуризація Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Реорганізувати пакет по підпакетах (`core/`, `connection/`, `data/`, `sinks/`), об'єднати batch-скрипти імпорту в `scripts/import_xlsx.py`, та реалізувати повноцінний Textual TUI як основний інтерфейс. + +**Architecture:** Плаский `olap_tool/` розбивається на 4 підпакети. `olap_tool/__init__.py` реекспортує публічні символи для зворотної сумісності. `olap.py` без аргументів запускає Textual TUI; з аргументами — звичайний CLI. TUI виконує операції у Textual Worker з перехопленням stdout через `TUIStream`. + +**Tech Stack:** Python 3.8–3.13, Textual ≥ 0.70, colorama, Rich (вже у залежностях через Textual). + +--- + +## Chunk 1: Restructure sinks/ package + +### Task 1.1: Створити sinks/base.py + +**Files:** +- Create: `olap_tool/sinks/__init__.py` +- Create: `olap_tool/sinks/base.py` + +- [ ] **Step 1: Написати smoke-тест (повинен ВПАСТИ)** + +```bash +python -c "from olap_tool.sinks.base import AnalyticsSink, sanitize_df; print('OK')" +``` +Очікується: `ModuleNotFoundError` + +- [ ] **Step 2: Створити `olap_tool/sinks/__init__.py` (порожній)** + +```python +# Реекспорти — будуть додані після створення підмодулів +``` + +- [ ] **Step 3: Створити `olap_tool/sinks/base.py`** + +Скопіювати з `olap_tool/sinks.py` наступні блоки: +- всі `import` на початку (numpy, pandas, abc, io, re, threading, datetime) +- функції `_safe_column_name()` та `sanitize_df()` +- клас `AnalyticsSink` (ABC) з усіма abstractmethod + +```python +""" +Analytics Sink абстракція та спільні утиліти. +""" +from __future__ import annotations + +import datetime +import io +import re +import threading +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd + + +def _safe_column_name(name: str) -> str: + """Перетворює назву колонки у безпечний SQL-ідентифікатор.""" + safe = re.sub(r"[^\w]", "_", name, flags=re.UNICODE) + safe = re.sub(r"_+", "_", safe).strip("_") + if not safe: + safe = "col" + if safe[0].isdigit(): + safe = "c_" + safe + return safe + + +def sanitize_df(df: pd.DataFrame) -> pd.DataFrame: + """Оброблює inf/NaN та перетворює колонки на безпечні імена.""" + df = df.copy() + df.rename(columns={col: _safe_column_name(col) for col in df.columns}, inplace=True) + float_cols = df.select_dtypes(include=["float64", "float32"]).columns + if len(float_cols) > 0: + df[float_cols] = df[float_cols].replace([np.inf, -np.inf], np.nan) + return df + + +class AnalyticsSink(ABC): + """ABC для всіх аналітичних сховищ.""" + + @abstractmethod + def setup(self, df: pd.DataFrame) -> None: + """CREATE TABLE IF NOT EXISTS на основі схеми df.""" + ... + + @abstractmethod + def delete_period(self, year: int, week: int) -> None: + """Ідемпотентне видалення рядків для year_num/week_num.""" + ... + + @abstractmethod + def insert(self, df: pd.DataFrame, year: int, week: int) -> int: + """Вставка рядків. Повертає кількість вставлених рядків.""" + ... + + @abstractmethod + def close(self) -> None: + """Закриття з'єднання/сесії.""" + ... +``` + +- [ ] **Step 4: Запустити smoke-тест (повинен ПРОЙТИ)** + +```bash +python -c "from olap_tool.sinks.base import AnalyticsSink, sanitize_df; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 5: Commit** + +```bash +git add olap_tool/sinks/__init__.py olap_tool/sinks/base.py +git commit -m "refactor: створити sinks/base.py з ABC та sanitize_df" +``` + +--- + +### Task 1.2: Створити sinks/clickhouse.py + +**Files:** +- Create: `olap_tool/sinks/clickhouse.py` + +- [ ] **Step 1: Smoke-тест (повинен ВПАСТИ)** + +```bash +python -c "from olap_tool.sinks.clickhouse import ClickHouseSink; print('OK')" +``` + +- [ ] **Step 2: Створити `olap_tool/sinks/clickhouse.py`** + +Об'єднати `ClickHouseSink` з `olap_tool/sinks.py` та всю логіку з `olap_tool/clickhouse_export.py`. Файл `clickhouse_export.py` зникає після цього кроку. + +```python +""" +ClickHouse sink — поєднує ClickHouseSink та clickhouse_export логіку. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pandas as pd + +from .base import AnalyticsSink, sanitize_df + +if TYPE_CHECKING: + from ..core.config import ClickHouseConfig +``` + +Далі скопіювати з `clickhouse_export.py`: +- `_pandas_dtype_to_ch()` (маппінг pandas dtype → ClickHouse тип) +- `_ensure_database()`, `_ensure_table()`, `_align_schema()`, `_coerce_df_to_schema()` +- Всю логіку `export_to_clickhouse()` + +Далі скопіювати клас `ClickHouseSink` з `sinks.py` (рядки де він визначений), замінивши: +```python +# Старо (sinks.py): +from .clickhouse_export import ( + _pandas_dtype_to_ch, + _ensure_database, + _ensure_table, + _align_schema, + _coerce_df_to_schema, + _get_client, + export_to_clickhouse, +) +# Ново — все в одному файлі, немає зовнішніх імпортів +``` + +- [ ] **Step 3: Запустити smoke-тест** + +```bash +python -c "from olap_tool.sinks.clickhouse import ClickHouseSink; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add olap_tool/sinks/clickhouse.py +git commit -m "refactor: створити sinks/clickhouse.py (поглинає clickhouse_export.py)" +``` + +--- + +### Task 1.3: Створити sinks/duckdb.py та sinks/postgresql.py + +**Files:** +- Create: `olap_tool/sinks/duckdb.py` +- Create: `olap_tool/sinks/postgresql.py` + +- [ ] **Step 1: Smoke-тест (повинен ВПАСТИ)** + +```bash +python -c "from olap_tool.sinks.duckdb import DuckDBSink; from olap_tool.sinks.postgresql import PostgreSQLSink; print('OK')" +``` + +- [ ] **Step 2: Створити `olap_tool/sinks/duckdb.py`** + +Скопіювати клас `DuckDBSink` з `olap_tool/sinks.py`. Замінити всі відносні імпорти: + +```python +"""DuckDB sink — HTTP REST API.""" +from __future__ import annotations + +import io +from typing import TYPE_CHECKING + +import pandas as pd + +from .base import AnalyticsSink, sanitize_df + +if TYPE_CHECKING: + from ..core.config import DuckDBConfig + +# Далі повний клас DuckDBSink як є в sinks.py +``` + +- [ ] **Step 3: Створити `olap_tool/sinks/postgresql.py`** + +Скопіювати клас `PostgreSQLSink` з `olap_tool/sinks.py`. Замінити імпорти: + +```python +"""PostgreSQL sink — psycopg2 COPY FROM STDIN.""" +from __future__ import annotations + +import io +from typing import TYPE_CHECKING + +import pandas as pd + +from .base import AnalyticsSink, sanitize_df + +if TYPE_CHECKING: + from ..core.config import PostgreSQLConfig + +# Далі повний клас PostgreSQLSink як є в sinks.py +``` + +- [ ] **Step 4: Оновити `olap_tool/sinks/__init__.py`** + +```python +"""Analytics sinks package.""" +from .base import AnalyticsSink, sanitize_df +from .clickhouse import ClickHouseSink +from .duckdb import DuckDBSink +from .postgresql import PostgreSQLSink + +__all__ = [ + "AnalyticsSink", + "sanitize_df", + "ClickHouseSink", + "DuckDBSink", + "PostgreSQLSink", +] +``` + +- [ ] **Step 5: Запустити smoke-тест** + +```bash +python -c "from olap_tool.sinks import AnalyticsSink, sanitize_df, ClickHouseSink, DuckDBSink, PostgreSQLSink; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 6: Commit** + +```bash +git add olap_tool/sinks/duckdb.py olap_tool/sinks/postgresql.py olap_tool/sinks/__init__.py +git commit -m "refactor: створити sinks/duckdb.py, sinks/postgresql.py, оновити __init__.py" +``` + +--- + +### Task 1.4: Видалити старі файли sinks.py та clickhouse_export.py + +**Files:** +- Delete: `olap_tool/sinks.py` +- Delete: `olap_tool/clickhouse_export.py` + +- [ ] **Step 1: Перевірити що нічого більше не імпортує старі модулі** + +```bash +grep -r "from .sinks import\|from olap_tool.sinks import\|from .clickhouse_export\|from olap_tool.clickhouse_export" --include="*.py" /c/git/olap-export-tool/ +``` +Очікується: знайти тільки `runner.py` та `import_xlsx_to_*.py` (вони будуть оновлені в наступних чанках) + +- [ ] **Step 2: Видалити файли** + +```bash +git rm olap_tool/sinks.py olap_tool/clickhouse_export.py +``` + +- [ ] **Step 3: Тимчасово оновити `olap_tool/runner.py` — тільки рядок імпорту sinks** + +Знайти рядок: +```python +from .sinks import ClickHouseSink, DuckDBSink, PostgreSQLSink +``` +Замінити на: +```python +from .sinks import ClickHouseSink, DuckDBSink, PostgreSQLSink # буде оновлено в Chunk 2 +``` +(Поки залишаємо `.sinks` — це тимчасово спрацює поки runner.py ще в olap_tool/) + +- [ ] **Step 4: Перевірити що старий CLI ще працює** + +```bash +python -c "from olap_tool.runner import main; print('OK')" +``` +Очікується: `OK` (або помилка тільки через відсутність .NET, але не ImportError) + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: видалити sinks.py та clickhouse_export.py" +``` + +--- + +## Chunk 2: Restructure core/, connection/, data/ + +### Task 2.1: Створити підпакети та перенести файли + +**Files:** +- Create: `olap_tool/core/__init__.py` +- Create: `olap_tool/connection/__init__.py` +- Create: `olap_tool/data/__init__.py` +- Move + update: 9 файлів з `olap_tool/` в підпакети + +- [ ] **Step 1: Створити порожні `__init__.py` для підпакетів** + +```bash +touch olap_tool/core/__init__.py olap_tool/connection/__init__.py olap_tool/data/__init__.py +``` + +- [ ] **Step 2: Перенести файли до `core/`** + +Скопіювати (не видаляти ще) ці файли в `olap_tool/core/`: +- `config.py`, `cli.py`, `runner.py`, `periods.py`, `profiles.py` +- `scheduler.py`, `compression.py`, `progress.py`, `utils.py` + +```bash +cp olap_tool/config.py olap_tool/core/config.py +cp olap_tool/cli.py olap_tool/core/cli.py +cp olap_tool/runner.py olap_tool/core/runner.py +cp olap_tool/periods.py olap_tool/core/periods.py +cp olap_tool/profiles.py olap_tool/core/profiles.py +cp olap_tool/scheduler.py olap_tool/core/scheduler.py +cp olap_tool/compression.py olap_tool/core/compression.py +cp olap_tool/progress.py olap_tool/core/progress.py +cp olap_tool/utils.py olap_tool/core/utils.py +``` + +- [ ] **Step 3: Перенести файли до `connection/`** + +```bash +cp olap_tool/connection.py olap_tool/connection/connection.py +cp olap_tool/auth.py olap_tool/connection/auth.py +cp olap_tool/security.py olap_tool/connection/security.py +cp olap_tool/prompt.py olap_tool/connection/prompt.py +``` + +- [ ] **Step 4: Перенести файли до `data/`** + +```bash +cp olap_tool/queries.py olap_tool/data/queries.py +cp olap_tool/exporter.py olap_tool/data/exporter.py +``` + +- [ ] **Step 5: Commit копій (checkpoint)** + +```bash +git add olap_tool/core/ olap_tool/connection/ olap_tool/data/ +git commit -m "refactor: копії файлів у підпакети (імпорти ще не оновлені)" +``` + +--- + +### Task 2.2: Оновити імпорти в core/ + +**Files:** +- Modify: `olap_tool/core/runner.py` +- Verify: `olap_tool/core/config.py`, `cli.py`, `utils.py`, `progress.py`, `compression.py`, `periods.py`, `profiles.py`, `scheduler.py` + +- [ ] **Step 1: Перевірити які файли core/ потребують змін** + +```bash +grep -n "^from \." olap_tool/core/runner.py +``` + +- [ ] **Step 2: Оновити `olap_tool/core/runner.py` — тільки блок імпортів** + +Знайти старі відносні імпорти та замінити: + +```python +# СТАРО: +from .connection import connect_to_olap, get_connection_string, AUTH_SSPI +from .queries import get_available_weeks, generate_year_week_pairs, run_dax_query +from .auth import delete_credentials, get_current_windows_user, auth_username +from .sinks import ClickHouseSink, DuckDBSink, PostgreSQLSink + +# НОВО: +from ..connection.connection import connect_to_olap, get_connection_string, AUTH_SSPI +from ..data.queries import get_available_weeks, generate_year_week_pairs, run_dax_query +from ..connection.auth import delete_credentials, get_current_windows_user, auth_username +from ..sinks import ClickHouseSink, DuckDBSink, PostgreSQLSink +``` + +Всі інші імпорти в runner.py (`from .utils`, `from .config`, `from .progress`, `from .cli`, `from . import periods`, `from .compression`, `from .profiles`, `from .scheduler`) залишаються БЕЗ ЗМІН — вони в тому ж пакеті `core/`. + +- [ ] **Step 3: Оновити TYPE_CHECKING імпорти в `olap_tool/core/progress.py`** + +```python +# progress.py imports from .utils — залишається незмінним (обидва в core/) +``` + +- [ ] **Step 4: Smoke-тест для core/** + +```bash +python -c "from olap_tool.core.config import build_config, AppConfig; print('OK')" +python -c "from olap_tool.core.utils import print_info, print_error; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 5: Commit** + +```bash +git add olap_tool/core/runner.py +git commit -m "refactor: оновити імпорти в core/runner.py" +``` + +--- + +### Task 2.3: Оновити імпорти в connection/ + +**Files:** +- Modify: `olap_tool/connection/connection.py` +- Modify: `olap_tool/connection/auth.py` +- Modify: `olap_tool/connection/security.py` +- Modify: `olap_tool/connection/prompt.py` + +- [ ] **Step 1: Оновити `olap_tool/connection/connection.py`** + +```python +# СТАРО: +from .auth import (save_credentials, load_credentials, ...) +from .prompt import prompt_credentials +from .utils import (print_info, print_info_detail, ...) + +# НОВО: +from .auth import (save_credentials, load_credentials, ...) # залишається +from .prompt import prompt_credentials # залишається +from ..core.utils import (print_info, print_info_detail, ...) # ЗМІНИТИ +``` + +- [ ] **Step 2: Оновити `olap_tool/connection/auth.py`** + +```python +# СТАРО: +from .security import (get_machine_id, ...) +from .utils import print_info, print_error + +# НОВО: +from .security import (get_machine_id, ...) # залишається +from ..core.utils import print_info, print_error # ЗМІНИТИ +``` + +- [ ] **Step 3: Оновити `olap_tool/connection/security.py`** + +```python +# СТАРО: +from .utils import print_info, print_warning, print_error + +# НОВО: +from ..core.utils import print_info, print_warning, print_error +``` + +- [ ] **Step 4: Оновити `olap_tool/connection/prompt.py`** + +```python +# СТАРО: +from .utils import ... # (будь-які utils імпорти) + +# НОВО: +from ..core.utils import ... +``` + +- [ ] **Step 5: Smoke-тест для connection/** + +```bash +python -c "from olap_tool.connection.auth import save_credentials, load_credentials; print('OK')" +python -c "from olap_tool.connection.security import get_machine_id; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 6: Commit** + +```bash +git add olap_tool/connection/ +git commit -m "refactor: оновити імпорти в connection/" +``` + +--- + +### Task 2.4: Оновити імпорти в data/ + +**Files:** +- Modify: `olap_tool/data/queries.py` +- Modify: `olap_tool/data/exporter.py` + +- [ ] **Step 1: Оновити `olap_tool/data/queries.py`** + +```python +# СТАРО: +from .utils import (print_info, print_warning, ...) +from .exporter import export_csv_stream, export_xlsx_dataframe, export_xlsx_stream +from . import progress +# TYPE_CHECKING: +from .config import QueryConfig, ExportConfig, XlsxConfig, CsvConfig, ExcelHeaderConfig, PathsConfig + +# НОВО: +from ..core.utils import (print_info, print_warning, ...) +from .exporter import export_csv_stream, export_xlsx_dataframe, export_xlsx_stream # залишається +from ..core import progress +# TYPE_CHECKING: +from ..core.config import QueryConfig, ExportConfig, XlsxConfig, CsvConfig, ExcelHeaderConfig, PathsConfig +``` + +Також оновити TYPE_CHECKING імпорт `AnalyticsSink`: +```python +if TYPE_CHECKING: + from ..sinks.base import AnalyticsSink +``` + +- [ ] **Step 2: Оновити `olap_tool/data/exporter.py`** + +```python +# СТАРО: +from .utils import print_progress, convert_dotnet_to_python +from . import progress +# TYPE_CHECKING: +from .config import ExcelHeaderConfig, XlsxConfig + +# НОВО: +from ..core.utils import print_progress, convert_dotnet_to_python +from ..core import progress +# TYPE_CHECKING: +from ..core.config import ExcelHeaderConfig, XlsxConfig +``` + +- [ ] **Step 3: Smoke-тест для data/** + +```bash +python -c "from olap_tool.data.exporter import export_csv_stream; print('OK')" +``` +Очікується: `OK` (або помилка тільки від відсутності .NET, але не ImportError) + +- [ ] **Step 4: Commit** + +```bash +git add olap_tool/data/ +git commit -m "refactor: оновити імпорти в data/" +``` + +--- + +### Task 2.5: Оновити sinks/ — виправити імпорт config + +**Files:** +- Modify: `olap_tool/sinks/clickhouse.py` +- Modify: `olap_tool/sinks/duckdb.py` +- Modify: `olap_tool/sinks/postgresql.py` + +- [ ] **Step 1: Оновити TYPE_CHECKING імпорти в кожному sink-файлі** + +У кожному файлі замінити: +```python +# СТАРО (якщо є): +from ..config import ClickHouseConfig # або DuckDBConfig / PostgreSQLConfig + +# НОВО: +from ..core.config import ClickHouseConfig # або відповідний тип +``` + +- [ ] **Step 2: Smoke-тест всіх sinks з новою структурою** + +```bash +python -c "from olap_tool.sinks import AnalyticsSink, ClickHouseSink, DuckDBSink, PostgreSQLSink; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add olap_tool/sinks/ +git commit -m "refactor: виправити config-імпорти в sinks/" +``` + +--- + +### Task 2.6: Оновити olap_tool/__init__.py та видалити старі файли + +**Files:** +- Modify: `olap_tool/__init__.py` +- Delete: старі файли з кореня olap_tool/ + +- [ ] **Step 1: Оновити `olap_tool/__init__.py`** + +```python +""" +OLAP Export Tool package. + +Реекспортує публічні символи для зворотної сумісності. +""" +from .core.runner import main +from .sinks import AnalyticsSink, sanitize_df, ClickHouseSink, DuckDBSink, PostgreSQLSink + +__all__ = [ + "main", + "AnalyticsSink", + "sanitize_df", + "ClickHouseSink", + "DuckDBSink", + "PostgreSQLSink", +] +``` + +- [ ] **Step 2: Видалити старі файли з кореня olap_tool/** + +```bash +git rm olap_tool/config.py olap_tool/cli.py olap_tool/runner.py +git rm olap_tool/periods.py olap_tool/profiles.py olap_tool/scheduler.py +git rm olap_tool/compression.py olap_tool/progress.py olap_tool/utils.py +git rm olap_tool/connection.py olap_tool/auth.py olap_tool/security.py olap_tool/prompt.py +git rm olap_tool/queries.py olap_tool/exporter.py +``` + +- [ ] **Step 3: Фінальний smoke-тест всієї структури** + +```bash +python -c "from olap_tool import main; print('main OK')" +python -c "from olap_tool.core.runner import main; print('core.runner OK')" +python -c "from olap_tool.connection.connection import AUTH_SSPI; print('connection OK')" +python -c "from olap_tool.data.queries import generate_year_week_pairs; print('data OK')" +python -c "from olap_tool.sinks import ClickHouseSink; print('sinks OK')" +``` +Очікується: всі `OK` + +- [ ] **Step 4: Оновити olap.py (тимчасово — для сумісності до Chunk 4)** + +```python +import sys +import os +from dotenv import load_dotenv + +load_dotenv() + +if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8': + sys.stdout.reconfigure(encoding='utf-8') + +from olap_tool.core.runner import main +sys.exit(main()) +``` + +- [ ] **Step 5: Commit** + +```bash +git add olap_tool/__init__.py olap.py +git commit -m "refactor: завершити реструктуризацію olap_tool/, оновити __init__.py та olap.py" +``` + +--- + +## Chunk 3: scripts/import_xlsx.py + +### Task 3.1: Створити об'єднаний скрипт імпорту + +**Files:** +- Create: `scripts/__init__.py` (порожній) +- Create: `scripts/import_xlsx.py` +- Delete: `import_xlsx_to_clickhouse.py`, `import_xlsx_to_duckdb.py` + +- [ ] **Step 1: Smoke-тест (повинен ВПАСТИ)** + +```bash +python scripts/import_xlsx.py --help +``` +Очікується: `No such file or directory` + +- [ ] **Step 2: Створити `scripts/__init__.py`** + +```python +``` + +- [ ] **Step 3: Створити `scripts/import_xlsx.py`** + +Об'єднання логіки з обох старих скриптів. Спільна частина (file discovery, Excel reading, Rich UI, ThreadPoolExecutor) — одна реалізація: + +```python +#!/usr/bin/env python3 +""" +Паралельний імпорт XLSX файлів в аналітичне сховище. + +Використання: + python scripts/import_xlsx.py --target ch --dir result/ --workers 4 + python scripts/import_xlsx.py --target duck --year 2025 --week 10 + python scripts/import_xlsx.py --target pg --dry-run +""" +from __future__ import annotations + +import argparse +import os +import re +import sys +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +# Додаємо корінь проєкту в sys.path щоб імпортувати olap_tool +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dotenv import load_dotenv + +load_dotenv() + +import pandas as pd +from rich.console import Console +from rich.panel import Panel +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeElapsedColumn, +) +from rich.table import Table + +from olap_tool.core.config import ClickHouseConfig, DuckDBConfig, PostgreSQLConfig +from olap_tool.sinks import ClickHouseSink, DuckDBSink, PostgreSQLSink, AnalyticsSink + +console = Console() + +# --- Спільні утиліти --- + +FILENAME_PATTERN = re.compile(r"^(\d{4})-(\d{2})\.xlsx$") + + +def find_xlsx_files( + directory: str, + year_filter: int | None, + week_filter: int | None, +) -> list[tuple[Path, int, int]]: + """Знаходить XLSX файли формату YYYY-WW.xlsx з опційною фільтрацією.""" + result = [] + for path in sorted(Path(directory).rglob("*.xlsx")): + m = FILENAME_PATTERN.match(path.name) + if not m: + continue + year, week = int(m.group(1)), int(m.group(2)) + if year_filter and year != year_filter: + continue + if week_filter and week != week_filter: + continue + result.append((path, year, week)) + return result + + +def read_excel(path: Path, sheet: int = 0) -> pd.DataFrame: + """Читає XLSX через calamine (швидко) або openpyxl (fallback).""" + try: + return pd.read_excel(path, sheet_name=sheet, engine="calamine") + except Exception: + return pd.read_excel(path, sheet_name=sheet, engine="openpyxl") + + +# --- ClickHouse специфіка --- + +_ch_local = threading.local() + + +def _get_ch_client(cfg: ClickHouseConfig): + """Повертає thread-local ClickHouse клієнт.""" + import clickhouse_connect + if not hasattr(_ch_local, "client"): + _ch_local.client = clickhouse_connect.get_client( + host=cfg.host, + port=cfg.port, + database=cfg.database, + username=cfg.user, + password=cfg.password, + secure=cfg.secure, + ) + return _ch_local.client + + +def _close_ch_clients(): + """Закриває thread-local клієнти після завершення пула.""" + if hasattr(_ch_local, "client"): + try: + _ch_local.client.close() + except Exception: + pass + + +# --- Головна логіка --- + +def build_sink(target: str) -> AnalyticsSink: + """Будує sink на основі --target та змінних середовища.""" + if target in ("ch", "clickhouse"): + cfg = ClickHouseConfig( + host=os.getenv("CH_HOST", "localhost"), + port=int(os.getenv("CH_PORT", "8123")), + database=os.getenv("CH_DATABASE", "default"), + table=os.getenv("CH_TABLE", "olap_data"), + user=os.getenv("CH_USER", "default"), + password=os.getenv("CH_PASSWORD", ""), + secure=os.getenv("CH_SECURE", "false").lower() == "true", + enabled=True, + ) + return ClickHouseSink(cfg) + elif target in ("duck", "duckdb"): + cfg = DuckDBConfig( + url=os.getenv("DUCK_URL", "https://analytics.lwhs.xyz"), + api_key=os.getenv("DUCK_API_KEY", ""), + table=os.getenv("DUCK_TABLE", "olap_data"), + batch_size=int(os.getenv("DUCK_BATCH_SIZE", "10000")), + enabled=True, + ) + return DuckDBSink(cfg) + elif target in ("pg", "postgresql"): + cfg = PostgreSQLConfig( + host=os.getenv("PG_HOST", "localhost"), + port=int(os.getenv("PG_PORT", "5432")), + database=os.getenv("PG_DATABASE", "postgres"), + schema=os.getenv("PG_SCHEMA", "public"), + table=os.getenv("PG_TABLE", "olap_data"), + user=os.getenv("PG_USER", "postgres"), + password=os.getenv("PG_PASSWORD", ""), + sslmode=os.getenv("PG_SSLMODE", "require"), + enabled=True, + ) + return PostgreSQLSink(cfg) + else: + raise ValueError(f"Невідомий target: {target}") + + +def process_file( + args_tuple: tuple[Path, int, int, AnalyticsSink, bool, int], +) -> tuple[str, int]: + """Обробляє один файл: read → delete → insert.""" + path, year, week, sink, dry_run, sheet = args_tuple + df = read_excel(path, sheet) + if dry_run: + return str(path.name), len(df) + sink.delete_period(year, week) + rows = sink.insert(df, year, week) + return str(path.name), rows + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Паралельний імпорт XLSX в аналітичне сховище" + ) + parser.add_argument( + "--target", + required=True, + choices=["ch", "clickhouse", "duck", "duckdb", "pg", "postgresql"], + help="Ціль: ch/clickhouse, duck/duckdb, pg/postgresql", + ) + parser.add_argument("--dir", default="result", help="Директорія з XLSX файлами") + parser.add_argument("--year", type=int, help="Фільтр по року") + parser.add_argument("--week", type=int, help="Фільтр по тижню") + parser.add_argument("--sheet", type=int, default=0, help="Індекс листа Excel (0=перший)") + parser.add_argument("--workers", type=int, default=4, help="Кількість потоків") + parser.add_argument("--dry-run", action="store_true", help="Тільки читання, без запису") + args = parser.parse_args() + + console.print(Panel(f"[bold]Імпорт XLSX → {args.target.upper()}[/bold]", expand=False)) + + files = find_xlsx_files(args.dir, args.year, args.week) + if not files: + console.print("[yellow]Файли не знайдено[/yellow]") + return + + console.print(f"Знайдено файлів: [bold]{len(files)}[/bold]") + + # Ініціалізація sink (setup на першому файлі) + sink = build_sink(args.target) + first_df = read_excel(files[0][0], args.sheet) + sink.setup(first_df) + + total_rows = 0 + results_table = Table("Файл", "Рядків", title="Результати") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TaskProgressColumn(), + TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task("Обробка...", total=len(files)) + + work_items = [ + (path, year, week, sink, args.dry_run, args.sheet) + for path, year, week in files + ] + + with ThreadPoolExecutor(max_workers=args.workers) as pool: + futures = {pool.submit(process_file, item): item for item in work_items} + for future in as_completed(futures): + try: + name, rows = future.result() + total_rows += rows + results_table.add_row(name, str(rows)) + except Exception as exc: + item = futures[future] + results_table.add_row(str(item[0].name), f"[red]ПОМИЛКА: {exc}[/red]") + finally: + progress.advance(task) + + sink.close() + + console.print(results_table) + mode = "[yellow]DRY RUN[/yellow]" if args.dry_run else "[green]записано[/green]" + console.print( + Panel( + f"Файлів: [bold]{len(files)}[/bold] | " + f"Рядків: [bold]{total_rows:,}[/bold] | {mode}", + title="Підсумок", + expand=False, + ) + ) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Перевірити що скрипт відкривається без помилок** + +```bash +python scripts/import_xlsx.py --help +``` +Очікується: вивід usage/help + +- [ ] **Step 5: Видалити старі скрипти** + +```bash +git rm import_xlsx_to_clickhouse.py import_xlsx_to_duckdb.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/ +git commit -m "feat: scripts/import_xlsx.py — об'єднаний імпорт XLSX (CH/DuckDB/PG)" +``` + +--- + +## Chunk 4: TUI implementation + +### Task 4.1: Підготовка — залежності та log handler + +**Files:** +- Modify: `requirements.txt` +- Modify: `olap_tool/core/utils.py` + +- [ ] **Step 1: Додати textual до `requirements.txt`** + +Додати після рядка з `rich`: +``` +textual>=0.70.0 +``` + +- [ ] **Step 2: Встановити textual** + +```bash +pip install "textual>=0.70.0" +``` + +- [ ] **Step 3: Додати TUIStream та stdout-redirect в `olap_tool/core/utils.py`** + +Додати в кінець файлу: + +```python +# --------------------------------------------------------------------------- +# TUI stdout redirect +# --------------------------------------------------------------------------- +import re as _re + +_ANSI_ESCAPE = _re.compile(r"\x1b\[[0-9;]*m") + + +class TUIStream: + """ + Замінює sys.stdout під час роботи TUI. + Перехоплює всі print() виклики та пише чистий текст у Textual RichLog. + Потокобезпечний через call_from_thread. + """ + + def __init__(self, app, log_widget): + self._app = app + self._log = log_widget + self._buf = "" + + def write(self, text: str) -> None: + self._buf += text + while "\n" in self._buf: + line, self._buf = self._buf.split("\n", 1) + clean = _ANSI_ESCAPE.sub("", line) + if clean: + self._app.call_from_thread(self._log.write, clean) + + def flush(self) -> None: + pass + + def fileno(self): + import io as _io + raise _io.UnsupportedOperation("no fileno") +``` + +- [ ] **Step 4: Smoke-тест** + +```bash +python -c "from olap_tool.core.utils import TUIStream; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 5: Commit** + +```bash +git add requirements.txt olap_tool/core/utils.py +git commit -m "feat: додати textual до залежностей, TUIStream у utils.py" +``` + +--- + +### Task 4.2: TUI package — app.py та main_menu.py + +**Files:** +- Create: `olap_tool/tui/__init__.py` +- Create: `olap_tool/tui/screens/__init__.py` +- Create: `olap_tool/tui/widgets/__init__.py` +- Create: `olap_tool/tui/app.py` +- Create: `olap_tool/tui/screens/main_menu.py` + +- [ ] **Step 1: Smoke-тест (повинен ВПАСТИ)** + +```bash +python -c "from olap_tool.tui.app import OlapApp; print('OK')" +``` + +- [ ] **Step 2: Створити порожні `__init__.py`** + +```bash +touch olap_tool/tui/__init__.py olap_tool/tui/screens/__init__.py olap_tool/tui/widgets/__init__.py +``` + +- [ ] **Step 3: Створити `olap_tool/tui/screens/main_menu.py`** + +```python +"""Головний екран меню.""" +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Footer, Header, ListItem, ListView, Label + + +MENU_ITEMS = [ + ("export", "Експорт з OLAP куба"), + ("import", "Імпорт XLSX в аналітику"), + ("quit", "Вийти"), +] + + +class MainMenuScreen(Screen): + """Головне меню програми.""" + + BINDINGS = [("q", "quit", "Вийти")] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield ListView( + *[ListItem(Label(label), id=item_id) for item_id, label in MENU_ITEMS], + id="main-menu", + ) + yield Footer() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item_id = event.item.id + if item_id == "export": + from .olap_export import OlapExportScreen + self.app.push_screen(OlapExportScreen()) + elif item_id == "import": + from .xlsx_import import XlsxImportScreen + self.app.push_screen(XlsxImportScreen()) + elif item_id == "quit": + self.app.exit() + + def action_quit(self) -> None: + self.app.exit() +``` + +- [ ] **Step 4: Створити `olap_tool/tui/app.py`** + +```python +"""Головний Textual застосунок.""" +from textual.app import App + +from .screens.main_menu import MainMenuScreen + +CSS = """ +Screen { + background: $surface; +} + +ListView { + width: 60; + margin: 2 4; + border: solid $primary; +} + +ListItem { + padding: 1 2; +} + +ListItem:hover { + background: $primary 20%; +} + +ListItem.--highlight { + background: $primary; + color: $text; +} + +#log-panel { + height: 1fr; + border: solid $accent; + margin: 1; +} + +.form-container { + width: 1fr; + height: auto; + border: solid $primary; + margin: 1; + padding: 1; +} + +Label.field-label { + margin-top: 1; + color: $text-muted; +} + +Button { + margin: 1 0; +} +""" + + +class OlapApp(App): + """OLAP Export Tool — головний застосунок.""" + + TITLE = "OLAP Export Tool" + SUB_TITLE = "v2.0" + CSS = CSS + BINDINGS = [("q", "quit", "Вийти")] + + def on_mount(self) -> None: + self.push_screen(MainMenuScreen()) +``` + +- [ ] **Step 5: Smoke-тест** + +```bash +python -c "from olap_tool.tui.app import OlapApp; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 6: Commit** + +```bash +git add olap_tool/tui/ +git commit -m "feat: TUI app.py та main_menu.py" +``` + +--- + +### Task 4.3: TUI — екран OLAP Export + +**Files:** +- Create: `olap_tool/tui/screens/olap_export.py` + +- [ ] **Step 1: Smoke-тест (повинен ВПАСТИ)** + +```bash +python -c "from olap_tool.tui.screens.olap_export import OlapExportScreen; print('OK')" +``` + +- [ ] **Step 2: Створити `olap_tool/tui/screens/olap_export.py`** + +```python +"""Екран експорту даних з OLAP куба.""" +from __future__ import annotations + +import sys +from pathlib import Path + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import ( + Button, + Checkbox, + Footer, + Header, + Input, + Label, + RichLog, + Select, + SelectionList, +) +from textual.worker import Worker, get_current_worker + +from ...core.utils import TUIStream + + +def _list_profiles() -> list[str]: + """Повертає список доступних профілів.""" + profiles_dir = Path("profiles") + if not profiles_dir.exists(): + return [] + return [p.stem for p in sorted(profiles_dir.glob("*.yaml"))] + + +FORMAT_OPTIONS = [ + ("xlsx", "XLSX"), + ("csv", "CSV"), + ("both", "XLSX + CSV"), + ("ch", "ClickHouse"), + ("duck", "DuckDB"), + ("pg", "PostgreSQL"), +] + +PERIOD_OPTIONS = [ + ("last-weeks", "Останні N тижнів"), + ("current-month", "Поточний місяць"), + ("last-month", "Попередній місяць"), + ("current-quarter", "Поточний квартал"), + ("last-quarter", "Попередній квартал"), + ("year-to-date", "З початку року"), + ("manual", "Ручний діапазон"), +] + +COMPRESS_OPTIONS = [ + ("none", "Без стиснення"), + ("zip", "ZIP архів"), +] + + +class OlapExportScreen(Screen): + """Екран: Експорт з OLAP куба.""" + + BINDINGS = [("escape", "pop_screen", "Назад")] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(): + with Vertical(classes="form-container", id="export-form"): + yield Label("Профіль:", classes="field-label") + profiles = _list_profiles() + yield Select( + [(p, p) for p in profiles] or [("(немає профілів)", "")], + id="profile-select", + allow_blank=True, + prompt="(без профілю)", + ) + + yield Label("Формат:", classes="field-label") + yield Select(FORMAT_OPTIONS, id="format-select", value="xlsx") + + yield Label("Період:", classes="field-label") + yield Select(PERIOD_OPTIONS, id="period-type-select", value="last-weeks") + + yield Label("Значення (N тижнів або YYYY-WW:YYYY-WW):", classes="field-label") + yield Input(placeholder="4", id="period-value-input", value="4") + + yield Label("Стиснення:", classes="field-label") + yield Select(COMPRESS_OPTIONS, id="compress-select", value="none") + + yield Button("Запустити", variant="primary", id="run-btn") + yield Button("Скасувати", variant="error", id="cancel-btn", disabled=True) + + with Vertical(id="log-panel"): + yield RichLog(id="export-log", highlight=True, markup=True, wrap=True) + yield Footer() + + def _build_argv(self) -> list[str]: + """Будує список аргументів CLI з форми.""" + argv = ["olap.py"] + + profile = self.query_one("#profile-select", Select).value + if profile and profile != Select.BLANK: + argv += ["--profile", str(profile)] + + fmt = self.query_one("#format-select", Select).value + if fmt: + argv += ["--format", str(fmt)] + + period_type = self.query_one("#period-type-select", Select).value + period_value = self.query_one("#period-value-input", Input).value.strip() + + if period_type == "last-weeks": + argv += ["--last-weeks", period_value or "4"] + elif period_type == "current-month": + argv.append("--current-month") + elif period_type == "last-month": + argv.append("--last-month") + elif period_type == "current-quarter": + argv.append("--current-quarter") + elif period_type == "last-quarter": + argv.append("--last-quarter") + elif period_type == "year-to-date": + argv.append("--year-to-date") + elif period_type == "manual" and period_value: + argv += ["--period", period_value] + + compress = self.query_one("#compress-select", Select).value + if compress and compress != "none": + argv += ["--compress", str(compress)] + + return argv + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "run-btn": + self._start_export() + elif event.button.id == "cancel-btn": + self._cancel_export() + + def _start_export(self) -> None: + log = self.query_one("#export-log", RichLog) + log.clear() + argv = self._build_argv() + log.write(f"[dim]Команда: {' '.join(argv)}[/dim]") + + self.query_one("#run-btn", Button).disabled = True + self.query_one("#cancel-btn", Button).disabled = False + + # run_worker з корутиною — виконується в asyncio event loop + # runner_main() блокує потік, тому _do_export запускає його в executor + self._worker = self.run_worker( + self._do_export(argv), exclusive=True, name="olap-export" + ) + # Примітка: _do_export використовує run_in_executor для блокуючого виклику + + def _cancel_export(self) -> None: + if hasattr(self, "_worker") and self._worker.state.is_running: + self._worker.cancel() + + async def _do_export(self, argv: list[str]) -> None: + """Виконує експорт у окремому потоці (executor) з перехопленням stdout.""" + import asyncio + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._run_export_sync, argv) + + def _run_export_sync(self, argv: list[str]) -> None: + """Синхронний блок — виконується в executor потоці.""" + from ...core.runner import main as runner_main + log = self.query_one("#export-log", RichLog) + stream = TUIStream(self.app, log) + old_stdout = sys.stdout + sys.stdout = stream + old_argv = sys.argv + sys.argv = argv + try: + result = runner_main() + msg = ( + "[bold green]✓ Завершено успішно[/bold green]" + if result == 0 + else f"[bold red]✗ Завершено з кодом {result}[/bold red]" + ) + self.app.call_from_thread(log.write, msg) + except Exception as exc: + self.app.call_from_thread(log.write, f"[bold red]✗ Помилка: {exc}[/bold red]") + finally: + sys.argv = old_argv + sys.stdout = old_stdout + self.app.call_from_thread(self._on_export_done) + + def _on_export_done(self) -> None: + self.query_one("#run-btn", Button).disabled = False + self.query_one("#cancel-btn", Button).disabled = True +``` + +- [ ] **Step 3: Smoke-тест** + +```bash +python -c "from olap_tool.tui.screens.olap_export import OlapExportScreen; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add olap_tool/tui/screens/olap_export.py +git commit -m "feat: TUI екран OlapExportScreen" +``` + +--- + +### Task 4.4: TUI — екран XLSX Import + +**Files:** +- Create: `olap_tool/tui/screens/xlsx_import.py` + +- [ ] **Step 1: Smoke-тест (повинен ВПАСТИ)** + +```bash +python -c "from olap_tool.tui.screens.xlsx_import import XlsxImportScreen; print('OK')" +``` + +- [ ] **Step 2: Створити `olap_tool/tui/screens/xlsx_import.py`** + +```python +"""Екран імпорту XLSX файлів в аналітичне сховище.""" +from __future__ import annotations + +import sys +from pathlib import Path + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import ( + Button, + Checkbox, + Footer, + Header, + Input, + Label, + RadioButton, + RadioSet, + RichLog, +) + +from ...core.utils import TUIStream + + +class XlsxImportScreen(Screen): + """Екран: Імпорт XLSX в аналітику.""" + + BINDINGS = [("escape", "pop_screen", "Назад")] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(): + with Vertical(classes="form-container", id="import-form"): + yield Label("Ціль:", classes="field-label") + with RadioSet(id="target-radio"): + yield RadioButton("ClickHouse", id="target-ch", value=True) + yield RadioButton("DuckDB", id="target-duck") + yield RadioButton("PostgreSQL", id="target-pg") + + yield Label("Директорія з XLSX:", classes="field-label") + yield Input(placeholder="result/", id="dir-input", value="result/") + + yield Label("Рік (опційно):", classes="field-label") + yield Input(placeholder="2025", id="year-input") + + yield Label("Тиждень (опційно):", classes="field-label") + yield Input(placeholder="10", id="week-input") + + yield Label("Workers:", classes="field-label") + yield Input(placeholder="4", id="workers-input", value="4") + + yield Checkbox("Dry Run (без запису)", id="dry-run-check") + + yield Button("Запустити", variant="primary", id="run-btn") + yield Button("Скасувати", variant="error", id="cancel-btn", disabled=True) + + with Vertical(id="log-panel"): + yield RichLog(id="import-log", highlight=True, markup=True, wrap=True) + yield Footer() + + def _get_target(self) -> str: + radio = self.query_one("#target-radio", RadioSet) + pressed = radio.pressed_button + if pressed and pressed.id: + return pressed.id.replace("target-", "") + return "ch" + + def _build_script_args(self) -> list[str]: + """Будує список аргументів для scripts/import_xlsx.py.""" + target = self._get_target() + directory = self.query_one("#dir-input", Input).value.strip() or "result/" + year = self.query_one("#year-input", Input).value.strip() + week = self.query_one("#week-input", Input).value.strip() + workers = self.query_one("#workers-input", Input).value.strip() or "4" + dry_run = self.query_one("#dry-run-check", Checkbox).value + + args = ["scripts/import_xlsx.py", "--target", target, "--dir", directory, "--workers", workers] + if year: + args += ["--year", year] + if week: + args += ["--week", week] + if dry_run: + args.append("--dry-run") + return args + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "run-btn": + self._start_import() + elif event.button.id == "cancel-btn": + if hasattr(self, "_worker"): + self._worker.cancel() + + def _start_import(self) -> None: + log = self.query_one("#import-log", RichLog) + log.clear() + script_args = self._build_script_args() + log.write(f"[dim]Команда: python {' '.join(script_args)}[/dim]") + + self.query_one("#run-btn", Button).disabled = True + self.query_one("#cancel-btn", Button).disabled = False + + self._worker = self.run_worker( + self._do_import(script_args), exclusive=True, name="xlsx-import" + ) + + async def _do_import(self, script_args: list[str]) -> None: + """Виконує імпорт через scripts/import_xlsx.main().""" + log = self.query_one("#import-log", RichLog) + stream = TUIStream(self.app, log) + old_stdout = sys.stdout + sys.stdout = stream + try: + # Запускаємо scripts/import_xlsx.main() з підміненим sys.argv + import importlib.util + spec = importlib.util.spec_from_file_location( + "import_xlsx", Path("scripts/import_xlsx.py") + ) + mod = importlib.util.module_from_spec(spec) + old_argv = sys.argv + sys.argv = script_args + try: + spec.loader.exec_module(mod) + mod.main() + self.app.call_from_thread( + log.write, "[bold green]✓ Імпорт завершено[/bold green]" + ) + finally: + sys.argv = old_argv + except SystemExit: + pass + except Exception as exc: + self.app.call_from_thread( + log.write, f"[bold red]✗ Помилка: {exc}[/bold red]" + ) + finally: + sys.stdout = old_stdout + self.app.call_from_thread(self._on_done) + + def _on_done(self) -> None: + self.query_one("#run-btn", Button).disabled = False + self.query_one("#cancel-btn", Button).disabled = True +``` + +- [ ] **Step 3: Smoke-тест** + +```bash +python -c "from olap_tool.tui.screens.xlsx_import import XlsxImportScreen; print('OK')" +``` +Очікується: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add olap_tool/tui/screens/xlsx_import.py +git commit -m "feat: TUI екран XlsxImportScreen" +``` + +--- + +### Task 4.5: Фінал — оновити olap.py + +**Files:** +- Modify: `olap.py` + +- [ ] **Step 1: Оновити `olap.py`** + +```python +#!/usr/bin/env python3 +""" +OLAP Export Tool — точка входу. + +Без аргументів → запускає Textual TUI. +З аргументами → CLI режим (сумісний з попередньою поведінкою). +""" +import sys +import os +from dotenv import load_dotenv + +load_dotenv() + +# UTF-8 консоль на Windows +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +if len(sys.argv) == 1: + from olap_tool.tui.app import OlapApp + OlapApp().run() +else: + from olap_tool.core.runner import main + sys.exit(main()) +``` + +- [ ] **Step 2: Перевірити що CLI режим ще запускається** + +```bash +python olap.py --help +``` +Очікується: виведення help без помилок + +- [ ] **Step 3: Перевірити що TUI запускається (Ctrl+C для виходу)** + +```bash +python olap.py +``` +Очікується: відкривається Textual вікно з головним меню + +- [ ] **Step 4: Оновити `CLAUDE.md` — розділ про структуру** + +Оновити секцію Architecture → Key files: +``` +- `olap_tool/core/` — config, cli, runner, utils, progress, periods, profiles, scheduler, compression +- `olap_tool/connection/` — connection, auth, security, prompt +- `olap_tool/data/` — queries, exporter +- `olap_tool/sinks/` — base (ABC), clickhouse, duckdb, postgresql +- `olap_tool/tui/` — Textual TUI (app, screens, widgets) +- `scripts/import_xlsx.py` — об'єднаний batch-імпорт +``` + +- [ ] **Step 5: Фінальний повний smoke-тест** + +```bash +python -c " +from olap_tool import main +from olap_tool.core.runner import main as core_main +from olap_tool.core.config import AppConfig +from olap_tool.core.utils import TUIStream +from olap_tool.connection.connection import AUTH_SSPI +from olap_tool.data.queries import generate_year_week_pairs +from olap_tool.sinks import AnalyticsSink, ClickHouseSink, DuckDBSink, PostgreSQLSink +from olap_tool.tui.app import OlapApp +from olap_tool.tui.screens.main_menu import MainMenuScreen +from olap_tool.tui.screens.olap_export import OlapExportScreen +from olap_tool.tui.screens.xlsx_import import XlsxImportScreen +print('ALL OK') +" +``` +Очікується: `ALL OK` + +- [ ] **Step 6: Commit** + +```bash +git add olap.py CLAUDE.md +git commit -m "feat: TUI детектування в olap.py — без аргументів запускає Textual" +``` + +--- + +## Підсумок змін + +| Старий шлях | Новий шлях | +|------------|-----------| +| `olap_tool/sinks.py` | `olap_tool/sinks/base.py` + `clickhouse.py` + `duckdb.py` + `postgresql.py` | +| `olap_tool/clickhouse_export.py` | поглинуто в `olap_tool/sinks/clickhouse.py` | +| `olap_tool/config.py` | `olap_tool/core/config.py` | +| `olap_tool/runner.py` | `olap_tool/core/runner.py` | +| `olap_tool/utils.py` | `olap_tool/core/utils.py` | +| `olap_tool/cli.py` | `olap_tool/core/cli.py` | +| `olap_tool/connection.py` | `olap_tool/connection/connection.py` | +| `olap_tool/auth.py` | `olap_tool/connection/auth.py` | +| `olap_tool/queries.py` | `olap_tool/data/queries.py` | +| `olap_tool/exporter.py` | `olap_tool/data/exporter.py` | +| `import_xlsx_to_clickhouse.py` + `import_xlsx_to_duckdb.py` | `scripts/import_xlsx.py` | +| *(новий)* | `olap_tool/tui/app.py` + `screens/` + `widgets/` | diff --git a/docs/superpowers/plans/2026-03-16-console-ui.md b/docs/superpowers/plans/2026-03-16-console-ui.md new file mode 100644 index 0000000..5ce5c21 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-console-ui.md @@ -0,0 +1,805 @@ +# Console UI Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Замінити Textual TUI на консольний інтерактивний інтерфейс (InquirerPy + rich) зі стрілковою навігацією. + +**Architecture:** Новий пакет `olap_tool/ui/` з трьома модулями: `menu.py` (головне меню), `olap_export.py` (wizard OLAP Export), `xlsx_import.py` (wizard XLSX Import). Entry point `olap.py` без аргументів запускає `ui.menu.run()`. CLI режим не змінюється. + +**Tech Stack:** `InquirerPy` (arrow-key select/input), `rich` (panels, tables, status — вже в requirements), `colorama` (вже є). + +--- + +## File Map + +| Дія | Файл | Відповідальність | +|-----|------|-----------------| +| Видалити | `olap_tool/tui/` | весь TUI пакет | +| Змінити | `olap_tool/core/utils.py` | прибрати `TUIStream` | +| Змінити | `requirements.txt` | прибрати `textual`, додати `InquirerPy` | +| Змінити | `olap.py` | запускати `ui.menu` замість TUI | +| Створити | `olap_tool/ui/__init__.py` | пустий init | +| Створити | `olap_tool/ui/menu.py` | цикл головного меню | +| Створити | `olap_tool/ui/olap_export.py` | wizard OLAP Export | +| Створити | `olap_tool/ui/xlsx_import.py` | wizard XLSX Import | + +--- + +## Chunk 1: Cleanup — видалити TUI, оновити залежності + +### Task 1: Оновити requirements.txt + +**Files:** +- Modify: `requirements.txt` + +- [ ] **Step 1: Видалити рядок `textual`, додати `InquirerPy`** + +Знайти рядок: +``` +textual>=0.70.0 # TUI фреймворк для інтерактивного меню +``` +Замінити на: +``` +InquirerPy>=0.3.4 # Консольне інтерактивне меню зі стрілковою навігацією +``` + +- [ ] **Step 2: Встановити нову залежність** + +```bash +pip install InquirerPy +``` + +Очікуваний вивід: `Successfully installed InquirerPy-...` + +- [ ] **Step 3: Перевірити імпорт** + +```bash +python -c "from InquirerPy import inquirer; print('InquirerPy OK')" +``` + +Очікуваний вивід: `InquirerPy OK` + +--- + +### Task 2: Видалити TUIStream з utils.py + +**Files:** +- Modify: `olap_tool/core/utils.py` + +- [ ] **Step 1: Видалити клас TUIStream і його імпорти** + +Знайти і видалити весь блок від коментаря до кінця класу: +```python +# --------------------------------------------------------------------------- +# TUI stdout redirect +# --------------------------------------------------------------------------- +import re as _re +import io as _io + +_ANSI_ESCAPE = _re.compile(r"\x1b\[[0-9;]*m") + + +class TUIStream: + ... + def fileno(self): + raise _io.UnsupportedOperation("no fileno") +``` + +- [ ] **Step 2: Перевірити, що utils.py імпортується** + +```bash +python -c "from olap_tool.core.utils import print_info, init_utils; print('utils OK')" +``` + +Очікуваний вивід: `utils OK` + +--- + +### Task 3: Видалити пакет olap_tool/tui/ + +**Files:** +- Delete: `olap_tool/tui/` (весь каталог) + +- [ ] **Step 1: Видалити каталог** + +```bash +rm -rf olap_tool/tui/ +``` + +- [ ] **Step 2: Перевірити, що основні модулі ще імпортуються** + +```bash +python -c "from olap_tool.core.runner import main; from olap_tool.sinks import ClickHouseSink; print('core OK')" +``` + +Очікуваний вивід: `core OK` + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: видалити TUI (textual), TUIStream; додати InquirerPy" +``` + +--- + +## Chunk 2: Головне меню і OLAP Export wizard + +### Task 4: Створити olap_tool/ui/__init__.py + +**Files:** +- Create: `olap_tool/ui/__init__.py` + +- [ ] **Step 1: Створити порожній init** + +```python +"""Консольний інтерактивний UI (InquirerPy + rich).""" +``` + +--- + +### Task 5: Створити olap_tool/ui/menu.py + +**Files:** +- Create: `olap_tool/ui/menu.py` + +- [ ] **Step 1: Написати модуль** + +```python +"""Головне меню консольного UI.""" +from __future__ import annotations + +from InquirerPy import inquirer +from InquirerPy.separator import Separator +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +console = Console() + + +def _print_header() -> None: + """Виводить заголовок програми.""" + try: + from dotenv import dotenv_values + from pathlib import Path + env = dotenv_values(Path(__file__).parent.parent.parent / ".env") + server = env.get("OLAP_SERVER") or env.get("SERVER") or "—" + auth = env.get("OLAP_AUTH_METHOD") or env.get("AUTH_METHOD") or "SSPI" + except Exception: + server, auth = "—", "—" + + text = Text() + text.append("OLAP Export Tool\n", style="bold cyan") + text.append(f"Сервер: ", style="dim") + text.append(server, style="cyan") + text.append(f" · Auth: ", style="dim") + text.append(auth, style="cyan") + + console.print(Panel(text, border_style="cyan", padding=(0, 2))) + + +def run() -> None: + """Запускає цикл головного меню.""" + _print_header() + + while True: + try: + action = inquirer.select( + message="Оберіть дію:", + choices=[ + {"name": "Експорт з OLAP куба", "value": "export"}, + {"name": "Імпорт XLSX в аналітику", "value": "import"}, + Separator(), + {"name": "Вийти", "value": "quit"}, + ], + default="export", + ).execute() + except KeyboardInterrupt: + console.print("\n[dim]До побачення.[/dim]") + return + + if action == "export": + try: + from .olap_export import run_wizard as export_wizard + export_wizard() + except KeyboardInterrupt: + console.print("\n[yellow]Скасовано.[/yellow]") + elif action == "import": + try: + from .xlsx_import import run_wizard as import_wizard + import_wizard() + except KeyboardInterrupt: + console.print("\n[yellow]Скасовано.[/yellow]") + elif action == "quit": + console.print("[dim]До побачення.[/dim]") + return +``` + +- [ ] **Step 2: Перевірити імпорт** + +```bash +python -c "from olap_tool.ui.menu import run; print('menu OK')" +``` + +Очікуваний вивід: `menu OK` + +--- + +### Task 6: Створити olap_tool/ui/olap_export.py + +**Files:** +- Create: `olap_tool/ui/olap_export.py` + +- [ ] **Step 1: Написати модуль** + +```python +"""Wizard: Експорт з OLAP куба.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from InquirerPy import inquirer +from InquirerPy.base.control import Choice +from InquirerPy.separator import Separator +from InquirerPy.validator import EmptyInputValidator +from prompt_toolkit.validation import ValidationError, Validator +from rich.console import Console +from rich.table import Table + +console = Console() + + +# ─── Validators ────────────────────────────────────────────────────────────── + +class WeeksValidator(Validator): + def validate(self, document): + text = document.text.strip() + if not text.isdigit() or not (1 <= int(text) <= 520): + raise ValidationError( + message="Введіть ціле число від 1 до 520", + cursor_position=len(text), + ) + + +class ManualPeriodValidator(Validator): + _PATTERN = re.compile(r"^\d{4}-\d{2}:\d{4}-\d{2}$") + + def validate(self, document): + text = document.text.strip() + if not self._PATTERN.match(text): + raise ValidationError( + message="Формат: YYYY-WW:YYYY-WW (наприклад 2025-01:2025-12)", + cursor_position=len(text), + ) + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def _list_profiles() -> list[Choice]: + """Повертає список профілів для InquirerPy fuzzy-select.""" + profiles_dir = Path(__file__).parent.parent.parent / "profiles" + choices: list[Choice] = [Choice(value="", name="(без профілю)")] + if profiles_dir.exists(): + for p in sorted(profiles_dir.glob("*.yaml")): + choices.append(Choice(value=p.stem, name=p.stem)) + return choices + + +def _show_summary(params: dict[str, str]) -> None: + table = Table(show_header=False, border_style="cyan", box=None, padding=(0, 1)) + table.add_column(style="dim cyan", no_wrap=True) + table.add_column(style="white") + for key, value in params.items(): + table.add_row(key, value) + console.print() + console.print(table) + console.print() + + +# ─── Wizard ────────────────────────────────────────────────────────────────── + +FORMAT_CHOICES = [ + Choice(value="xlsx", name="XLSX"), + Choice(value="csv", name="CSV"), + Choice(value="both", name="XLSX + CSV"), + Separator(), + Choice(value="ch", name="ClickHouse"), + Choice(value="duck", name="DuckDB"), + Choice(value="pg", name="PostgreSQL"), +] + +PERIOD_CHOICES = [ + Choice(value="last-weeks", name="Останні N тижнів"), + Choice(value="current-month", name="Поточний місяць"), + Choice(value="last-month", name="Попередній місяць"), + Choice(value="current-quarter", name="Поточний квартал"), + Choice(value="last-quarter", name="Попередній квартал"), + Choice(value="year-to-date", name="З початку року"), + Choice(value="manual", name="Ручний діапазон YYYY-WW:YYYY-WW"), +] + +COMPRESS_CHOICES = [ + Choice(value="none", name="Без стиснення"), + Choice(value="zip", name="ZIP архів"), +] + +_PERIOD_LABELS = { + "last-weeks": "last-weeks", + "current-month": "поточний місяць", + "last-month": "попередній місяць", + "current-quarter": "поточний квартал", + "last-quarter": "попередній квартал", + "year-to-date": "з початку року", +} + + +def run_wizard() -> None: + """Інтерактивний wizard OLAP Export.""" + console.rule("[cyan]Експорт з OLAP куба[/cyan]") + + # 1. Профіль + profile: str = inquirer.fuzzy( + message="Профіль:", + choices=_list_profiles(), + default="", + max_height="40%", + ).execute() + + # 2. Формат + fmt: str = inquirer.select( + message="Формат виводу:", + choices=FORMAT_CHOICES, + default="xlsx", + ).execute() + + # 3. Тип періоду + period_type: str = inquirer.select( + message="Тип періоду:", + choices=PERIOD_CHOICES, + default="last-weeks", + ).execute() + + # 4. Значення (тільки для last-weeks і manual) + period_value: str = "" + if period_type == "last-weeks": + period_value = inquirer.text( + message="Кількість тижнів:", + default="4", + validate=WeeksValidator(), + ).execute() + elif period_type == "manual": + period_value = inquirer.text( + message="Діапазон (YYYY-WW:YYYY-WW):", + validate=ManualPeriodValidator(), + ).execute() + + # 5. Стиснення + compress: str = inquirer.select( + message="Стиснення:", + choices=COMPRESS_CHOICES, + default="none", + ).execute() + + # 6. Підсумок + period_label = _PERIOD_LABELS.get(period_type, period_type) + if period_value: + period_label = f"{period_label} ({period_value})" + summary = { + "Профіль": profile or "(без профілю)", + "Формат": fmt, + "Період": period_label, + "Стиснення": compress, + } + _show_summary(summary) + + # 7. Підтвердження + confirmed: bool = inquirer.confirm( + message="Запустити?", + default=True, + ).execute() + + if not confirmed: + console.print("[yellow]Скасовано.[/yellow]") + return + + # 8. Будуємо argv і запускаємо + argv = ["olap.py"] + if profile: + argv += ["--profile", profile] + argv += ["--format", fmt] + + if period_type == "last-weeks": + argv += ["--last-weeks", period_value or "4"] + elif period_type == "current-month": + argv.append("--current-month") + elif period_type == "last-month": + argv.append("--last-month") + elif period_type == "current-quarter": + argv.append("--current-quarter") + elif period_type == "last-quarter": + argv.append("--last-quarter") + elif period_type == "year-to-date": + argv.append("--year-to-date") + elif period_type == "manual" and period_value: + argv += ["--period", period_value] + + if compress != "none": + argv += ["--compress", compress] + + console.print(f"[dim]▶ {' '.join(argv)}[/dim]\n") + + from olap_tool.core.runner import main as runner_main + old_argv = sys.argv + sys.argv = argv + try: + result = runner_main() + except SystemExit as e: + result = e.code if isinstance(e.code, int) else 0 + finally: + sys.argv = old_argv + + if result == 0: + console.print("\n[bold green]✓ Завершено успішно[/bold green]") + else: + console.print(f"\n[bold red]✗ Завершено з помилкою (код {result})[/bold red]") +``` + +- [ ] **Step 2: Перевірити імпорт** + +```bash +python -c "from olap_tool.ui.olap_export import run_wizard; print('olap_export OK')" +``` + +Очікуваний вивід: `olap_export OK` + +- [ ] **Step 3: Commit** + +```bash +git add olap_tool/ui/ +git commit -m "feat: olap_tool/ui — головне меню та wizard OLAP Export (InquirerPy + rich)" +``` + +--- + +## Chunk 3: XLSX Import wizard і оновлення entry point + +### Task 7: Створити olap_tool/ui/xlsx_import.py + +**Files:** +- Create: `olap_tool/ui/xlsx_import.py` + +- [ ] **Step 1: Написати модуль** + +```python +"""Wizard: Імпорт XLSX в аналітичне сховище.""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from InquirerPy import inquirer +from InquirerPy.base.control import Choice +from prompt_toolkit.validation import ValidationError, Validator +from rich.console import Console +from rich.table import Table + +console = Console() + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + + +# ─── Validators ────────────────────────────────────────────────────────────── + +class YearValidator(Validator): + def validate(self, document): + text = document.text.strip() + if text == "": + return # Опціонально + if not text.isdigit() or not (2000 <= int(text) <= 2099): + raise ValidationError( + message="Рік: 4-цифрове число 2000–2099, або порожньо", + cursor_position=len(text), + ) + + +class WeekValidator(Validator): + def validate(self, document): + text = document.text.strip() + if text == "": + return # Опціонально + if not text.isdigit() or not (1 <= int(text) <= 53): + raise ValidationError( + message="Тиждень: число 1–53, або порожньо", + cursor_position=len(text), + ) + + +class WorkersValidator(Validator): + def validate(self, document): + text = document.text.strip() + if not text.isdigit() or not (1 <= int(text) <= 32): + raise ValidationError( + message="Workers: ціле число 1–32", + cursor_position=len(text), + ) + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +TARGET_CHOICES = [ + Choice(value="ch", name="ClickHouse"), + Choice(value="duck", name="DuckDB"), + Choice(value="pg", name="PostgreSQL"), +] + + +def _show_summary(params: dict[str, str]) -> None: + table = Table(show_header=False, border_style="cyan", box=None, padding=(0, 1)) + table.add_column(style="dim cyan", no_wrap=True) + table.add_column(style="white") + for key, value in params.items(): + table.add_row(key, value) + console.print() + console.print(table) + console.print() + + +# ─── Wizard ────────────────────────────────────────────────────────────────── + +def run_wizard() -> None: + """Інтерактивний wizard XLSX Import.""" + console.rule("[cyan]Імпорт XLSX в аналітику[/cyan]") + + # 1. Ціль + target: str = inquirer.select( + message="Ціль:", + choices=TARGET_CHOICES, + default="ch", + ).execute() + + # 2. Директорія + directory: str = inquirer.text( + message="Директорія з XLSX:", + default="result/", + ).execute() + + # 3. Рік (опційно) + year: str = inquirer.text( + message="Рік (Enter — всі роки):", + default="", + validate=YearValidator(), + ).execute() + + # 4. Тиждень (опційно) + week: str = inquirer.text( + message="Тиждень (Enter — всі тижні):", + default="", + validate=WeekValidator(), + ).execute() + + # 5. Workers + workers: str = inquirer.text( + message="Workers (паралельні потоки):", + default="4", + validate=WorkersValidator(), + ).execute() + + # 6. Dry run + dry_run: bool = inquirer.confirm( + message="Dry run (без запису в БД)?", + default=False, + ).execute() + + # 7. Підсумок + summary = { + "Ціль": target, + "Директорія": directory, + "Рік": year or "(всі)", + "Тиждень": week or "(всі)", + "Workers": workers, + "Dry Run": "так" if dry_run else "ні", + } + _show_summary(summary) + + # 8. Підтвердження + confirmed: bool = inquirer.confirm( + message="Запустити?", + default=True, + ).execute() + + if not confirmed: + console.print("[yellow]Скасовано.[/yellow]") + return + + # 9. Будуємо argv і запускаємо через importlib + script_args = [ + "scripts/import_xlsx.py", + "--target", target, + "--dir", directory, + "--workers", workers, + ] + if year: + script_args += ["--year", year] + if week: + script_args += ["--week", week] + if dry_run: + script_args.append("--dry-run") + + console.print(f"[dim]▶ python {' '.join(script_args)}[/dim]\n") + + script_path = _PROJECT_ROOT / "scripts" / "import_xlsx.py" + spec = importlib.util.spec_from_file_location("import_xlsx", script_path) + if spec is None or spec.loader is None: + console.print(f"[red]✗ Не вдалося завантажити: {script_path}[/red]") + return + + old_argv = sys.argv + sys.argv = script_args + try: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + mod.main() + console.print("\n[bold green]✓ Імпорт завершено[/bold green]") + except SystemExit as e: + if e.code not in (0, None): + console.print(f"\n[bold red]✗ Завершено з кодом {e.code}[/bold red]") + else: + console.print("\n[bold green]✓ Імпорт завершено[/bold green]") + except Exception as exc: + console.print(f"\n[bold red]✗ Помилка: {exc}[/bold red]") + finally: + sys.argv = old_argv +``` + +- [ ] **Step 2: Перевірити імпорт** + +```bash +python -c "from olap_tool.ui.xlsx_import import run_wizard; print('xlsx_import OK')" +``` + +Очікуваний вивід: `xlsx_import OK` + +--- + +### Task 8: Оновити olap.py + +**Files:** +- Modify: `olap.py` + +- [ ] **Step 1: Замінити TUI-логіку на ui.menu** + +Поточний вміст: +```python +#!/usr/bin/env python3 +""" +OLAP Export Tool — точка входу. + +Без аргументів → запускає Textual TUI. +З аргументами → CLI режим. +""" +import sys +from dotenv import load_dotenv + +load_dotenv() + +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except Exception: + pass + +if len(sys.argv) == 1: + import os + from olap_tool.tui.app import OlapApp + try: + OlapApp().run() + except KeyboardInterrupt: + pass + finally: + # Примусово завершуємо всі фонові потоки (наприклад, завислі запити до БД) + os._exit(0) +else: + from olap_tool.core.runner import main + sys.exit(main()) +``` + +Замінити на: +```python +#!/usr/bin/env python3 +""" +OLAP Export Tool — точка входу. + +Без аргументів → консольне інтерактивне меню. +З аргументами → CLI режим. +""" +import sys +from dotenv import load_dotenv + +load_dotenv() + +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except Exception: + pass + +if len(sys.argv) == 1: + from olap_tool.ui.menu import run + run() +else: + from olap_tool.core.runner import main + sys.exit(main()) +``` + +- [ ] **Step 2: Перевірити синтаксис** + +```bash +python -c "import ast; ast.parse(open('olap.py').read()); print('olap.py syntax OK')" +``` + +Очікуваний вивід: `olap.py syntax OK` + +- [ ] **Step 3: Перевірити CLI режим (без підключення до OLAP)** + +```bash +python olap.py --help +``` + +Очікуваний вивід: usage message (список CLI аргументів). + +- [ ] **Step 4: Commit** + +```bash +git add olap.py olap_tool/ui/xlsx_import.py +git commit -m "feat: xlsx_import wizard + оновити olap.py (запуск ui.menu)" +``` + +--- + +## Chunk 4: Фінальна перевірка і cleanup + +### Task 9: Повна перевірка імпортів і чистота коду + +- [ ] **Step 1: Переконатися, що textual не залишився ніде** + +```bash +grep -r "textual\|TUIStream\|from olap_tool.tui" olap_tool/ olap.py scripts/ --include="*.py" +``` + +Очікуваний вивід: порожній (жодних знайдених рядків). + +- [ ] **Step 2: Перевірити всі нові модулі UI** + +```bash +python -c " +from olap_tool.ui.menu import run +from olap_tool.ui.olap_export import run_wizard +from olap_tool.ui.xlsx_import import run_wizard as import_wizard +print('All UI modules OK') +" +``` + +Очікуваний вивід: `All UI modules OK` + +- [ ] **Step 3: Перевірити CLI режим** + +```bash +python olap.py --list-profiles +``` + +Очікуваний вивід: список профілів або повідомлення "профілів не знайдено". + +- [ ] **Step 4: Фінальний commit** + +```bash +git add -A +git commit -m "chore: фінальна перевірка — Console UI готовий, TUI видалено" +``` + +- [ ] **Step 5: Оновити пам'ять** + +Оновити `MEMORY.md`: замінити згадки про TUI на Console UI (InquirerPy + rich). diff --git a/docs/superpowers/specs/2026-03-10-tui-restructure-design.md b/docs/superpowers/specs/2026-03-10-tui-restructure-design.md new file mode 100644 index 0000000..7dfa3d7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-10-tui-restructure-design.md @@ -0,0 +1,174 @@ +# OLAP Export Tool — TUI + Реструктуризація + +**Дата:** 2026-03-10 +**Статус:** Затверджено + +## Мета + +Реорганізувати кодову базу по підпакетах, об'єднати batch-скрипти імпорту в один, додати підтримку PostgreSQL в скрипт імпорту, та реалізувати повноцінний Textual TUI як основний інтерфейс. + +--- + +## 1. Нова структура файлів + +``` +olap-export-tool/ +├── olap.py # точка входу: TUI якщо без аргументів, CLI якщо з аргументами +│ +├── olap_tool/ +│ ├── __init__.py +│ │ +│ ├── core/ # базова інфраструктура +│ │ ├── __init__.py +│ │ ├── config.py +│ │ ├── cli.py +│ │ ├── runner.py +│ │ ├── periods.py +│ │ ├── profiles.py +│ │ ├── scheduler.py +│ │ ├── compression.py +│ │ ├── progress.py +│ │ └── utils.py +│ │ +│ ├── connection/ # OLAP підключення та автентифікація +│ │ ├── __init__.py +│ │ ├── connection.py +│ │ ├── auth.py +│ │ ├── security.py +│ │ └── prompt.py +│ │ +│ ├── data/ # DAX запити та файловий експорт +│ │ ├── __init__.py +│ │ ├── queries.py +│ │ └── exporter.py +│ │ +│ ├── sinks/ # аналітичні сховища +│ │ ├── __init__.py +│ │ ├── base.py # AnalyticsSink ABC + sanitize_df() +│ │ ├── clickhouse.py # ClickHouseSink (поглинає clickhouse_export.py) +│ │ ├── duckdb.py # DuckDBSink +│ │ └── postgresql.py # PostgreSQLSink +│ │ +│ └── tui/ # Textual TUI +│ ├── __init__.py +│ ├── app.py # головний OlapApp : App +│ ├── screens/ +│ │ ├── __init__.py +│ │ ├── main_menu.py # головне меню +│ │ ├── olap_export.py # екран експорту з OLAP +│ │ └── xlsx_import.py # екран імпорту XLSX +│ └── widgets/ +│ ├── __init__.py +│ ├── log_panel.py # RichLog з перехопленням print_* +│ └── progress_bar.py # обгортка для прогрес-бару +│ +└── scripts/ # batch-утиліти + └── import_xlsx.py # об'єднаний імпорт XLSX → CH / DuckDB / PG +``` + +**Видаляються:** +- `import_xlsx_to_clickhouse.py` (корінь) +- `import_xlsx_to_duckdb.py` (корінь) +- `olap_tool/clickhouse_export.py` (поглинається `sinks/clickhouse.py`) +- `olap_tool/sinks.py` (розбивається на `sinks/base.py`, `sinks/clickhouse.py`, `sinks/duckdb.py`, `sinks/postgresql.py`) + +--- + +## 2. TUI екрани та навігація + +### Головне меню +``` +┌─────────────────────────────────────────────┐ +│ OLAP Export Tool v2.0 │ +├─────────────────────────────────────────────┤ +│ │ +│ > Експорт з OLAP куба │ +│ Імпорт XLSX в аналітику │ +│ Налаштування │ +│ Вийти │ +│ │ +└─────────────────────────────────────────────┘ +``` + +### Екран "Експорт з OLAP" +Форма з полями: +- Профіль (Select зі списку `profiles/*.yaml`) +- Формат: xlsx / csv / both / ch / duck / pg +- Період: last-weeks N / current-month / manual range +- Стиснення: none / zip +- Кнопка "Запустити" → праворуч `RichLog` з живим виводом операції +- Кнопка "Скасувати" (з'являється під час виконання) + +### Екран "Імпорт XLSX" +Форма з полями: +- Ціль: ClickHouse / DuckDB / PostgreSQL (RadioSet) +- Директорія з файлами (Input + Browse) +- Рік, тиждень (опційні фільтри) +- Workers: 1–16 +- Dry-run: Checkbox +- Кнопка "Запустити" → `RichLog` з прогресом +- Кнопка "Скасувати" + +### Навігація +- `q` / `Escape` → назад / вихід +- `Tab` / `Enter` → між елементами форми +- Під час виконання операції — форма блокується, активна кнопка "Скасувати" + +--- + +## 3. Технічні рішення + +### Детектування режиму в `olap.py` +```python +if len(sys.argv) == 1: + from olap_tool.tui.app import OlapApp + OlapApp().run() +else: + from olap_tool.core.runner import main + sys.exit(main()) +``` + +### Перехоплення `print_*` для TUI +`utils.py` отримує `set_log_handler(fn: Callable[[str], None] | None)`. Коли TUI активний, всі виклики `print_error/warning/success/progress` пишуть у `RichLog`-віджет через handler. CLI-режим залишається без змін (handler = None → stdout). + +### Textual Workers +Операції (експорт, імпорт) виконуються в `Worker` Textual: +```python +self.run_worker(self.run_export(), exclusive=True) +``` +Cancellation через `worker.cancel()` по кнопці "Скасувати". + +### `scripts/import_xlsx.py` — об'єднаний скрипт +```bash +python scripts/import_xlsx.py --target ch --dir result/ --workers 4 +python scripts/import_xlsx.py --target duck --year 2025 --week 10 +python scripts/import_xlsx.py --target pg --dry-run +``` +- Спільна логіка: file discovery, Excel reading (calamine + openpyxl fallback), Rich progress +- Thread-local client для ClickHouse; shared Session для DuckDB та PostgreSQL + +### Реімпорти після реструктуризації +- Всі відносні імпорти оновлюються до нових шляхів +- `olap_tool/__init__.py` реекспортує публічні символи для зворотної сумісності зовнішнього коду +- `sinks/__init__.py` реекспортує: `AnalyticsSink`, `sanitize_df`, `ClickHouseSink`, `DuckDBSink`, `PostgreSQLSink` + +--- + +## 4. Залежності + +Нові пакети для додавання в `requirements.txt`: +- `textual>=0.70.0` — TUI фреймворк + +--- + +## 5. Порядок реалізації + +1. Реструктуризація `sinks/` (base.py, clickhouse.py, duckdb.py, postgresql.py) +2. Реструктуризація `core/`, `connection/`, `data/` +3. Оновлення всіх імпортів +4. `scripts/import_xlsx.py` +5. TUI: `tui/app.py` + `screens/main_menu.py` +6. TUI: `screens/olap_export.py` + log handler в `utils.py` +7. TUI: `screens/xlsx_import.py` +8. Оновлення `olap.py` — детектування режиму +9. Видалення старих файлів diff --git a/docs/superpowers/specs/2026-03-16-console-ui-design.md b/docs/superpowers/specs/2026-03-16-console-ui-design.md new file mode 100644 index 0000000..8bc77d8 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-console-ui-design.md @@ -0,0 +1,115 @@ +# Console UI — Design Spec +**Date:** 2026-03-16 +**Branch:** feature/tui-restructure +**Status:** Approved + +## Context + +The Textual TUI (`olap_tool/tui/`) is being removed in favour of a lightweight +console interactive interface. The package restructuring (`core/`, `connection/`, +`data/`, `sinks/`) introduced in the same branch is preserved as-is. + +## Goals + +- Replace Textual TUI with an arrow-key console menu +- Keep full CLI mode (`python olap.py `) unchanged +- Maintain the same two user workflows: OLAP Export and XLSX Import +- Remove `textual` dependency; add `InquirerPy` + +## Libraries + +| Library | Role | +|---------|------| +| `rich` (already in requirements) | Panel header, param summary table, status messages | +| `InquirerPy` (new) | Arrow-key select, fuzzy profile search, text/number inputs, confirm | + +## File Structure + +**Deleted:** +- `olap_tool/tui/` (entire package) +- `TUIStream` class from `olap_tool/core/utils.py` + +**Added:** +- `olap_tool/ui/__init__.py` +- `olap_tool/ui/menu.py` — main menu loop +- `olap_tool/ui/olap_export.py` — OLAP Export wizard +- `olap_tool/ui/xlsx_import.py` — XLSX Import wizard + +**Modified:** +- `olap.py` — replace TUI launch with `ui.menu` launch +- `requirements.txt` — remove `textual`, add `InquirerPy` + +## Entry Point Logic + +``` +python olap.py → launch ui.menu (interactive) +python olap.py → runner.main() CLI mode (unchanged) +``` + +## UX Flow + +### Startup + +Rich `Panel` header showing tool name and current connection info (server from `.env`), +followed by InquirerPy `select` for main menu. + +``` +╭─────────────────────────────────────╮ +│ OLAP Export Tool │ +│ Підключення: · SSPI │ +╰─────────────────────────────────────╯ + +? Оберіть дію: + ❯ Експорт з OLAP куба + Імпорт XLSX в аналітику + ──────────────────────── + Вийти +``` + +After each operation, return to main menu (no exit unless user chooses "Вийти"). + +### OLAP Export Wizard (step-by-step) + +1. **Профіль** — fuzzy-search select, options from `profiles/*.yaml` + "(без профілю)" +2. **Формат** — select: XLSX / CSV / XLSX+CSV / ClickHouse / DuckDB / PostgreSQL +3. **Тип періоду** — select: Останні N тижнів / Поточний місяць / Попередній місяць / Поточний квартал / Попередній квартал / З початку року / Ручний діапазон +4. **Значення** — text input (shown only for "last-weeks" and "manual"): + - last-weeks: integer > 0, default "4" + - manual: format `YYYY-WW:YYYY-WW` +5. **Стиснення** — select: Без стиснення / ZIP архів +6. **Підсумок** — `rich.Table` with all selected params +7. **Підтвердження** — `inquirer.confirm` "Запустити? [Y/n]" +8. Run `runner.main()` with patched `sys.argv`; output flows directly to console + +### XLSX Import Wizard (step-by-step) + +1. **Ціль** — select: ClickHouse / DuckDB / PostgreSQL +2. **Директорія** — text input, default "result/" +3. **Рік** — text input, optional (empty = all), validated as 4-digit year if provided +4. **Тиждень** — text input, optional (empty = all), validated as 1-53 if provided +5. **Workers** — number input, default 4, range 1–32 +6. **Dry Run** — confirm "Dry run (без запису)? [y/N]" +7. **Підсумок** — `rich.Table` with all selected params +8. **Підтвердження** — `inquirer.confirm` "Запустити? [Y/n]" +9. Run `scripts/import_xlsx.py` via `importlib` (same as TUI did) + +## Error Handling + +| Situation | Behaviour | +|-----------|-----------| +| `KeyboardInterrupt` in wizard | Return to main menu | +| `KeyboardInterrupt` in main menu | Clean exit | +| Empty `profiles/` directory | Show only "(без профілю)" option | +| `runner.main()` returns non-zero | Print `[red]✗ Завершено з помилкою (код N)[/red]`, offer return to menu | +| Import script raises exception | Print error message, return to menu | +| Invalid text input | InquirerPy `validate` callback, inline error message | + +## Validation Rules + +| Field | Rule | +|-------|------| +| Кількість тижнів | Integer, 1–520 | +| Ручний діапазон | Regex `\d{4}-\d{2}:\d{4}-\d{2}` | +| Рік (import) | Integer 2000–2099, or empty | +| Тиждень (import) | Integer 1–53, or empty | +| Workers | Integer 1–32 | diff --git a/import_xlsx_to_clickhouse.py b/import_xlsx_to_clickhouse.py deleted file mode 100644 index c73d16d..0000000 --- a/import_xlsx_to_clickhouse.py +++ /dev/null @@ -1,349 +0,0 @@ -#!/usr/bin/env python -""" -Імпорт існуючих Excel-файлів у ClickHouse (паралельний режим). - -Використання: - python import_xlsx_to_clickhouse.py # всі файли, 4 воркери - python import_xlsx_to_clickhouse.py --workers 8 # 8 паралельних воркерів - python import_xlsx_to_clickhouse.py --year 2025 # тільки 2025 рік - python import_xlsx_to_clickhouse.py --year 2025 --week 10 # тільки тиждень 10 - python import_xlsx_to_clickhouse.py --dry-run # показати файли без завантаження -""" - -import sys -import argparse -import re -import threading -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -from typing import Optional - -from dotenv import load_dotenv -load_dotenv() - -try: - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] -except Exception: - pass - -import pandas as pd -from rich.console import Console -from rich.panel import Panel -from rich.progress import ( - BarColumn, - MofNCompleteColumn, - Progress, - SpinnerColumn, - TaskProgressColumn, - TextColumn, - TimeElapsedColumn, - TimeRemainingColumn, -) -from rich.table import Table -from rich.text import Text -from rich import box - -from olap_tool.config import load_clickhouse_from_env -from olap_tool.clickhouse_export import ( - export_to_clickhouse, - create_client, - ensure_database, - ensure_table, - get_table_schema, - sanitize_df, -) -from olap_tool.utils import init_utils - -init_utils(ascii_logs=False) - -console = Console() - -# --------------------------------------------------------------------------- -# Excel engine: calamine (Rust) з fallback на openpyxl -# --------------------------------------------------------------------------- -try: - import python_calamine # noqa: F401 - _EXCEL_ENGINE = "calamine" -except ImportError: - _EXCEL_ENGINE = "openpyxl" - -# --------------------------------------------------------------------------- -# Thread-local клієнти: одне з'єднання на потік, не перевідкривається -# --------------------------------------------------------------------------- -_thread_local = threading.local() -_all_clients: list = [] -_all_clients_lock = threading.Lock() - - -def _get_thread_client(cfg): - if not hasattr(_thread_local, "client") or _thread_local.client is None: - client = create_client(cfg) - _thread_local.client = client - with _all_clients_lock: - _all_clients.append(client) - return _thread_local.client - - -# --------------------------------------------------------------------------- -# Файловий пошук -# --------------------------------------------------------------------------- - -def find_xlsx_files( - base_dir: Path, - year: Optional[int], - week: Optional[int], -) -> list[tuple[Path, int, int]]: - pattern = re.compile(r"^(\d{4})-(\d{2})\.xlsx$") - results = [] - for f in sorted(base_dir.rglob("*.xlsx")): - m = pattern.match(f.name) - if not m: - continue - y, w = int(m.group(1)), int(m.group(2)) - if year is not None and y != year: - continue - if week is not None and w != week: - continue - results.append((f, y, w)) - return results - - -def _read_excel(file_path: Path, sheet) -> pd.DataFrame: - try: - return pd.read_excel(str(file_path), sheet_name=sheet, engine=_EXCEL_ENGINE) - except Exception: - if _EXCEL_ENGINE != "openpyxl": - return pd.read_excel(str(file_path), sheet_name=sheet, engine="openpyxl") - raise - - -# --------------------------------------------------------------------------- -# Worker -# --------------------------------------------------------------------------- - -def process_file( - file_path: Path, - year: int, - week: int, - cfg, - sheet, - cached_schema: Optional[dict], -) -> tuple[int, bool, float]: - """ - Повертає (row_count, success, elapsed_sec). - Весь вивід заглушено — rich progress відображає стан у головному потоці. - """ - t0 = time.monotonic() - try: - df = _read_excel(file_path, sheet) - except Exception as e: - return 0, False, time.monotonic() - t0 - - if df.empty: - return 0, True, time.monotonic() - t0 - - # Інжектуємо year_num/week_num із назви файлу — потрібно для ідемпотентного DELETE - df["year_num"] = year - df["week_num"] = week - - client = _get_thread_client(cfg) - rows = export_to_clickhouse( - df, cfg, year=year, week=week, - client=client, - schema=cached_schema, - silent=True, # воркери мовчать — вивід тільки через rich - ) - return rows, rows >= 0, time.monotonic() - t0 - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main(): - parser = argparse.ArgumentParser( - description="Паралельний імпорт Excel файлів OLAP-експорту у ClickHouse" - ) - parser.add_argument("--dir", default="result", help="Базова директорія") - parser.add_argument("--year", type=int, default=None, help="Фільтр за роком") - parser.add_argument("--week", type=int, default=None, help="Фільтр за тижнем") - parser.add_argument("--sheet", default="0", help="Аркуш Excel (назва або індекс)") - parser.add_argument("--workers", type=int, default=4, help="Паралельних воркерів") - parser.add_argument("--dry-run", action="store_true", help="Показати файли без завантаження") - args = parser.parse_args() - - base_dir = Path(args.dir) - if not base_dir.exists(): - console.print(f"[red]❌ Директорія не знайдена: {base_dir}[/red]") - return 1 - - cfg = load_clickhouse_from_env() - - # ── Заголовок ────────────────────────────────────────────────────────── - info = Table.grid(padding=(0, 2)) - info.add_column(style="cyan") - info.add_column(style="white") - info.add_row("Директорія", str(base_dir.resolve())) - info.add_row("ClickHouse", f"{cfg.host}:{cfg.port} → {cfg.database}.{cfg.table}") - info.add_row("Excel engine", _EXCEL_ENGINE) - if args.year: - info.add_row("Рік", str(args.year)) - if args.week: - info.add_row("Тиждень", str(args.week)) - if not args.dry_run: - info.add_row("Воркери", str(args.workers)) - - console.print() - console.print(Panel( - info, - title="[bold cyan]ІМПОРТ EXCEL → CLICKHOUSE[/bold cyan]", - border_style="cyan", - expand=False, - )) - console.print() - - # ── Пошук файлів ─────────────────────────────────────────────────────── - files = find_xlsx_files(base_dir, args.year, args.week) - if not files: - console.print("[yellow]⚠️ Файлів не знайдено за вказаними параметрами[/yellow]") - return 0 - - console.print(f" [cyan]Знайдено файлів:[/cyan] [white bold]{len(files)}[/white bold]\n") - - if args.dry_run: - for i, (fp, y, w) in enumerate(files, 1): - console.print(f" [dim]{i:>4}.[/dim] [white]{fp}[/white] [yellow]({y}-{w:02d})[/yellow]") - console.print(f"\n[yellow]DRY RUN завершено. Файлів: {len(files)}[/yellow]") - return 0 - - # Sheet: int або str - sheet: str | int = args.sheet - try: - sheet = int(sheet) - except (ValueError, TypeError): - pass - - # ── Ініціалізація: БД + таблиця + схема — один раз ──────────────────── - with console.status("[cyan]Ініціалізація ClickHouse...[/cyan]", spinner="dots"): - try: - init_client = create_client(cfg) - ensure_database(init_client, cfg.database) - df_init = _read_excel(files[0][0], sheet) - if not df_init.empty: - df_init["year_num"] = files[0][1] - df_init["week_num"] = files[0][2] - ensure_table(init_client, cfg.database, cfg.table, sanitize_df(df_init)) - cached_schema = get_table_schema(init_client, cfg.database, cfg.table) - init_client.close() - except Exception as e: - console.print(f"[red]❌ Помилка ініціалізації: {e}[/red]") - return 1 - - console.print( - f" [green]✅ Ініціалізовано[/green] " - f"[dim]схема: {len(cached_schema)} колонок[/dim]\n" - ) - - # ── Паралельне завантаження з rich progress bar ──────────────────────── - total = len(files) - total_rows = 0 - errors = 0 - start_time = time.monotonic() - - progress = Progress( - SpinnerColumn(), - BarColumn(bar_width=36), - MofNCompleteColumn(), - TaskProgressColumn(), - TextColumn("[dim]•[/dim]"), - TimeElapsedColumn(), - TextColumn("[dim]•[/dim] ETA"), - TimeRemainingColumn(), - console=console, - transient=False, - ) - - task_id = progress.add_task("", total=total) - - with progress: - with ThreadPoolExecutor(max_workers=args.workers) as executor: - futures = { - executor.submit( - process_file, - fp, y, w, cfg, sheet, cached_schema, - ): (fp, y, w) - for fp, y, w in files - } - - for future in as_completed(futures): - fp, y, w = futures[future] - try: - rows, success, elapsed = future.result() - except Exception as e: - rows, success, elapsed = 0, False, 0.0 - progress.console.print( - f" [red]❌ {y}-{w:02d}[/red] [dim]{fp.name}[/dim] " - f"[red]{e}[/red]" - ) - - total_rows += rows - if not success: - errors += 1 - - # Один рядок на завершений файл - icon = "[green]✅[/green]" if success else "[red]❌[/red]" - rows_str = f"[white]{rows:>7,}[/white] рядків" if rows > 0 else "[dim] порожній[/dim]" - progress.console.print( - f" {icon} [cyan]{y}-{w:02d}[/cyan] " - f"{rows_str} " - f"[dim]{elapsed:.1f}с[/dim]" - ) - - # Оновлюємо лічильник рядків у progress description - elapsed_total = time.monotonic() - start_time - rate = (total_rows / elapsed_total) if elapsed_total > 0 else 0 - progress.update( - task_id, - advance=1, - description=( - f"[white bold]{total_rows:,}[/white bold] рядків " - f"[dim]{rate:,.0f} рядків/с[/dim]" - ), - ) - - # Закриваємо thread-local клієнти - for client in _all_clients: - try: - client.close() - except Exception: - pass - - # ── Підсумок ─────────────────────────────────────────────────────────── - elapsed_total = time.monotonic() - start_time - rate_files = total / elapsed_total if elapsed_total > 0 else 0 - rate_rows = total_rows / elapsed_total if elapsed_total > 0 else 0 - - summary = Table.grid(padding=(0, 2)) - summary.add_column(style="cyan") - summary.add_column(style="white bold") - summary.add_row("Файлів оброблено", f"{total - errors}/{total}") - summary.add_row("Рядків завантажено", f"{total_rows:,}") - summary.add_row("Час", f"{elapsed_total:.1f} с") - summary.add_row("Швидкість", f"{rate_files:.1f} файл/с · {rate_rows:,.0f} рядків/с") - if errors: - summary.add_row("[red]Помилок[/red]", f"[red]{errors}[/red]") - - border = "green" if not errors else "yellow" - title = "[bold green]✅ Імпорт завершено[/bold green]" if not errors else "[bold yellow]⚠️ Завершено з помилками[/bold yellow]" - - console.print() - console.print(Panel(summary, title=title, border_style=border, expand=False)) - console.print() - - return 0 if not errors else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/import_xlsx_to_duckdb.py b/import_xlsx_to_duckdb.py deleted file mode 100644 index c8c78bb..0000000 --- a/import_xlsx_to_duckdb.py +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python -""" -Імпорт існуючих Excel-файлів у DuckDB через REST API (паралельний режим). - -Використання: - python import_xlsx_to_duckdb.py # всі файли, 4 воркери - python import_xlsx_to_duckdb.py --workers 8 # 8 паралельних воркерів - python import_xlsx_to_duckdb.py --year 2025 # тільки 2025 рік - python import_xlsx_to_duckdb.py --year 2025 --week 10 # тільки тиждень 10 - python import_xlsx_to_duckdb.py --dry-run # показати файли без завантаження -""" - -import sys -import argparse -import re -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -from typing import Optional - -from dotenv import load_dotenv -load_dotenv() - -try: - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] -except Exception: - pass - -import pandas as pd -from rich.console import Console -from rich.panel import Panel -from rich.progress import ( - BarColumn, - MofNCompleteColumn, - Progress, - SpinnerColumn, - TaskProgressColumn, - TextColumn, - TimeElapsedColumn, - TimeRemainingColumn, -) -from rich.table import Table - -from olap_tool.config import load_duckdb_from_env -from olap_tool.sinks import DuckDBSink, sanitize_df -from olap_tool.utils import init_utils - -init_utils(ascii_logs=False) - -console = Console() - -# --------------------------------------------------------------------------- -# Excel engine: calamine (Rust) з fallback на openpyxl -# --------------------------------------------------------------------------- -try: - import python_calamine # noqa: F401 - _EXCEL_ENGINE = "calamine" -except ImportError: - _EXCEL_ENGINE = "openpyxl" - - -# --------------------------------------------------------------------------- -# Файловий пошук (аналог import_xlsx_to_clickhouse.py) -# --------------------------------------------------------------------------- - -def find_xlsx_files( - base_dir: Path, - year: Optional[int], - week: Optional[int], -) -> list[tuple[Path, int, int]]: - pattern = re.compile(r"^(\d{4})-(\d{2})\.xlsx$") - results = [] - for f in sorted(base_dir.rglob("*.xlsx")): - m = pattern.match(f.name) - if not m: - continue - y, w = int(m.group(1)), int(m.group(2)) - if year is not None and y != year: - continue - if week is not None and w != week: - continue - results.append((f, y, w)) - return results - - -def _read_excel(file_path: Path, sheet) -> pd.DataFrame: - try: - return pd.read_excel(str(file_path), sheet_name=sheet, engine=_EXCEL_ENGINE) - except Exception: - if _EXCEL_ENGINE != "openpyxl": - return pd.read_excel(str(file_path), sheet_name=sheet, engine="openpyxl") - raise - - -# --------------------------------------------------------------------------- -# Worker — використовує спільний DuckDBSink (requests.Session thread-safe) -# --------------------------------------------------------------------------- - -def process_file( - file_path: Path, - year: int, - week: int, - sink: DuckDBSink, -) -> tuple[int, bool, float]: - """Повертає (row_count, success, elapsed_sec).""" - t0 = time.monotonic() - try: - df = _read_excel(file_path, 0) - except Exception: - return 0, False, time.monotonic() - t0 - - if df.empty: - return 0, True, time.monotonic() - t0 - - df = sanitize_df(df) - df["year_num"] = year - df["week_num"] = week - - try: - sink.delete_period(year, week) - rows = sink.insert(df, year=year, week=week) - success = rows > 0 or df.empty - return rows, success, time.monotonic() - t0 - except Exception: - return 0, False, time.monotonic() - t0 - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main(): - parser = argparse.ArgumentParser( - description="Паралельний імпорт Excel файлів OLAP-експорту у DuckDB" - ) - parser.add_argument("--dir", default="result", help="Базова директорія") - parser.add_argument("--year", type=int, default=None, help="Фільтр за роком") - parser.add_argument("--week", type=int, default=None, help="Фільтр за тижнем") - parser.add_argument("--workers", type=int, default=4, help="Паралельних воркерів") - parser.add_argument("--dry-run", action="store_true", help="Показати файли без завантаження") - args = parser.parse_args() - - base_dir = Path(args.dir) - if not base_dir.exists(): - console.print(f"[red]❌ Директорія не знайдена: {base_dir}[/red]") - return 1 - - cfg = load_duckdb_from_env() - - info = Table.grid(padding=(0, 2)) - info.add_column(style="cyan") - info.add_column(style="white") - info.add_row("Директорія", str(base_dir.resolve())) - info.add_row("DuckDB URL", cfg.url) - info.add_row("Таблиця", cfg.table) - info.add_row("Excel engine", _EXCEL_ENGINE) - if args.year is not None: - info.add_row("Рік", str(args.year)) - if args.week is not None: - info.add_row("Тиждень", str(args.week)) - if not args.dry_run: - info.add_row("Воркери", str(args.workers)) - - console.print() - console.print(Panel( - info, - title="[bold cyan]ІМПОРТ EXCEL → DUCKDB[/bold cyan]", - border_style="cyan", - expand=False, - )) - console.print() - - files = find_xlsx_files(base_dir, args.year, args.week) - if not files: - console.print("[yellow]⚠️ Файлів не знайдено за вказаними параметрами[/yellow]") - return 0 - - console.print(f" [cyan]Знайдено файлів:[/cyan] [white bold]{len(files)}[/white bold]\n") - - if args.dry_run: - for i, (fp, y, w) in enumerate(files, 1): - console.print(f" [dim]{i:>4}.[/dim] [white]{fp}[/white] [yellow]({y}-{w:02d})[/yellow]") - console.print(f"\n[yellow]DRY RUN завершено. Файлів: {len(files)}[/yellow]") - return 0 - - # Ініціалізація: CREATE TABLE з першого файлу - with console.status("[cyan]Ініціалізація DuckDB...[/cyan]", spinner="dots"): - try: - sink = DuckDBSink(cfg) - df_init = _read_excel(files[0][0], 0) - if not df_init.empty: - df_init = sanitize_df(df_init) - df_init["year_num"] = files[0][1] - df_init["week_num"] = files[0][2] - sink.setup(df_init) - except Exception as e: - console.print(f"[red]❌ Помилка ініціалізації: {e}[/red]") - return 1 - - console.print(" [green]✅ Ініціалізовано[/green]\n") - - total = len(files) - total_rows = 0 - errors = 0 - start_time = time.monotonic() - - progress = Progress( - SpinnerColumn(), - BarColumn(bar_width=36), - MofNCompleteColumn(), - TaskProgressColumn(), - TextColumn("[dim]•[/dim]"), - TimeElapsedColumn(), - TextColumn("[dim]•[/dim] ETA"), - TimeRemainingColumn(), - console=console, - transient=False, - ) - task_id = progress.add_task("", total=total) - - with progress: - with ThreadPoolExecutor(max_workers=args.workers) as executor: - futures = { - executor.submit(process_file, fp, y, w, sink): (fp, y, w) - for fp, y, w in files - } - for future in as_completed(futures): - fp, y, w = futures[future] - try: - rows, success, elapsed = future.result() - except Exception as e: - rows, success, elapsed = 0, False, 0.0 - progress.console.print( - f" [red]❌ {y}-{w:02d}[/red] [dim]{fp.name}[/dim] [red]{e}[/red]" - ) - - total_rows += rows - if not success: - errors += 1 - - icon = "[green]✅[/green]" if success else "[red]❌[/red]" - rows_str = f"[white]{rows:>7,}[/white] рядків" if rows > 0 else "[dim] порожній[/dim]" - progress.console.print( - f" {icon} [cyan]{y}-{w:02d}[/cyan] {rows_str} [dim]{elapsed:.1f}с[/dim]" - ) - - elapsed_total = time.monotonic() - start_time - rate = (total_rows / elapsed_total) if elapsed_total > 0 else 0 - progress.update( - task_id, - advance=1, - description=( - f"[white bold]{total_rows:,}[/white bold] рядків " - f"[dim]{rate:,.0f} рядків/с[/dim]" - ), - ) - - sink.close() - - elapsed_total = time.monotonic() - start_time - rate_files = total / elapsed_total if elapsed_total > 0 else 0 - rate_rows = total_rows / elapsed_total if elapsed_total > 0 else 0 - - summary = Table.grid(padding=(0, 2)) - summary.add_column(style="cyan") - summary.add_column(style="white bold") - summary.add_row("Файлів оброблено", f"{total - errors}/{total}") - summary.add_row("Рядків завантажено", f"{total_rows:,}") - summary.add_row("Час", f"{elapsed_total:.1f} с") - summary.add_row("Швидкість", f"{rate_files:.1f} файл/с · {rate_rows:,.0f} рядків/с") - if errors: - summary.add_row("[red]Помилок[/red]", f"[red]{errors}[/red]") - - border = "green" if not errors else "yellow" - title = "[bold green]✅ Імпорт завершено[/bold green]" if not errors else "[bold yellow]⚠️ Завершено з помилками[/bold yellow]" - - console.print() - console.print(Panel(summary, title=title, border_style=border, expand=False)) - console.print() - - return 0 if not errors else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/olap.py b/olap.py index 62c7d1b..cc7e218 100644 --- a/olap.py +++ b/olap.py @@ -1,16 +1,24 @@ +#!/usr/bin/env python3 +""" +OLAP Export Tool — точка входу. + +Без аргументів → консольне інтерактивне меню. +З аргументами → CLI режим. +""" import sys from dotenv import load_dotenv load_dotenv() -# Гарантуємо UTF-8 вивід для консолі -try: - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] -except Exception: - pass - -from olap_tool.runner import main +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except Exception: + pass -if __name__ == "__main__": - raise SystemExit(main()) +if len(sys.argv) == 1: + from olap_tool.ui.menu import run + run() +else: + from olap_tool.core.runner import main + sys.exit(main()) diff --git a/olap_tool/__init__.py b/olap_tool/__init__.py index 3d58b34..878e689 100644 --- a/olap_tool/__init__.py +++ b/olap_tool/__init__.py @@ -1 +1,5 @@ -# Package for OLAP export tool modularized components +"""OLAP Export Tool package.""" +from .core.runner import main +from .sinks import AnalyticsSink, sanitize_df, ClickHouseSink, DuckDBSink, PostgreSQLSink + +__all__ = ["main", "AnalyticsSink", "sanitize_df", "ClickHouseSink", "DuckDBSink", "PostgreSQLSink"] diff --git a/olap_tool/connection/__init__.py b/olap_tool/connection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/olap_tool/auth.py b/olap_tool/connection/auth.py similarity index 74% rename from olap_tool/auth.py rename to olap_tool/connection/auth.py index df9b151..b9b5252 100644 --- a/olap_tool/auth.py +++ b/olap_tool/connection/auth.py @@ -8,7 +8,7 @@ encrypt_credentials, decrypt_credentials, ) -from .utils import print_info, print_error +from ..core.utils import print_info, print_error auth_username: str | None = None @@ -57,14 +57,23 @@ def load_credentials( cred_path = Path(credentials_file) if not cred_path.exists(): return None, None + + # Перевіряємо, чи файл не порожній (нульова довжина) + if cred_path.stat().st_size == 0: + print_error("Файл облікових даних порожний (порожньо). Буде видалено.") + return None, None + try: if encrypted: with open(cred_path, "rb") as f: content = f.read().split(b"\n", 1) if len(content) < 2: - print_error("Невірний формат файлу облікових даних") + print_error("Невірний формат файлу облікових даних (відсутній блок солі). Файл пошкоджено.") return None, None salt, encrypted_data = content + if not salt or not encrypted_data: + print_error("Файл облікових даних пошкоджено (порожній сіль або дані). Буде видалено.") + return None, None machine_id = get_machine_id() mp = get_master_password( use_master_password=use_master_password, @@ -81,8 +90,8 @@ def load_credentials( from colorama import Fore mp_retry = getpass.getpass( - f"{Fore.CYAN}Введіть майстер-пароль для розшифрування: {Fore.RESET}" - ) + f"{Fore.CYAN}Введіть майстер-пароль для розшифрування: {Fore.RESET}" + ) base_secret_retry = f"{machine_id}:{mp_retry}" if mp_retry else machine_id key_retry, _ = generate_encryption_key(base_secret_retry, salt) username, password = decrypt_credentials( @@ -94,17 +103,23 @@ def load_credentials( print_info("Облікові дані успішно розшифровано") auth_username = username return username, password + # Не вдалося розшифрувати — даємо інформативну пораду print_error( - "Не вдалося розшифрувати облікові дані. Перевірте налаштування майстер-пароля." + "Не вдалося розшифрувати облікові дані. " + "Можливі причини: 1) змінилось ім'я машини/користувача; " + "2) файл пошкоджено; 3) змінився майстер-пароль." ) return None, None else: with open(cred_path, "r") as f: content = f.read().strip() if ":" not in content: - print_error("Невірний формат файлу облікових даних") + print_error("Невірний формат файлу облікових даних (відсутній роздільник ':'). Файл пошкоджено.") return None, None username, password = content.split(":", 1) + if not username or not password: + print_error("Файл облікових даних містить порожній логін або пароль.") + return None, None auth_username = username return username, password except Exception as e: diff --git a/olap_tool/connection.py b/olap_tool/connection/connection.py similarity index 70% rename from olap_tool/connection.py rename to olap_tool/connection/connection.py index 9516145..a53a7c4 100644 --- a/olap_tool/connection.py +++ b/olap_tool/connection/connection.py @@ -9,7 +9,7 @@ get_current_windows_user, ) from .prompt import prompt_credentials -from .utils import ( +from ..core.utils import ( print_info, print_info_detail, print_success, @@ -19,12 +19,66 @@ ) if TYPE_CHECKING: - from .config import SecretsConfig + from ..core.config import SecretsConfig # Константи для методів автентифікації AUTH_SSPI = "SSPI" AUTH_LOGIN = "LOGIN" +# Ключові слова, що ідентифікують мережеві/серверні помилки (не пов'язані з паролем) +_NETWORK_ERROR_KEYWORDS = ( + "timeout", + "timed out", + "network", + "host", + "connection refused", + "unreachable", + "server not found", + "server was not found", + "cannot open", + "no route", + "transport", + "socket", +) + +# Ключові слова, що ідентифікують явні помилки авторизації +_AUTH_ERROR_KEYWORDS = ( + "logon failure", + "incorrect password", + "access denied", + "invalid credentials", + "authentication failed", + "login failed", + "unauthorized", + "wrong password", + "bad user", + "incorrect login", +) + + +def _is_auth_error(exc: Exception) -> bool: + """ + Визначає, чи є виняток результатом хибної авторизації + (невірний логін/пароль), а не мережевої проблеми. + + Повертає True тільки якщо текст помилки явно містить ознаки + помилки автентифікації. У всіх інших випадках (включно з невідомими + помилками) повертає False, щоб НЕ скасовувати кешований пароль. + """ + msg = str(exc).lower() + + # Якщо в повідомленні є ознака мережевої помилки — точно не auth-помилка + if any(kw in msg for kw in _NETWORK_ERROR_KEYWORDS): + return False + + # Якщо в повідомленні є явна ознака помилки авторизації + if any(kw in msg for kw in _AUTH_ERROR_KEYWORDS): + return True + + # За замовчуванням — невідома помилка. НЕ вважаємо, що пароль хибний. + return False + + def _escape_conn_str_value(value: str) -> str: """Обгортає значення у подвійні лапки якщо воно містить спецсимволи connection string.""" @@ -123,14 +177,9 @@ def get_connection_string(secrets: "SecretsConfig"): } else: print_warning( - "Облікові дані не вказані. Використовуємо Windows-автентифікацію (SSPI)." + "Облікові дані не вказані. Операцію скасовано." ) - connection_string += "Integrated Security=SSPI;" - auth_details = { - "Метод автентифікації": "Windows-автентифікація (SSPI) - автоматично", - "Поточний користувач": get_current_windows_user(), - "Причина": "Облікові дані не вказані", - } + return None, None else: print_warning( f"Невідомий метод автентифікації '{auth_method}'. Використовуємо SSPI." @@ -181,6 +230,27 @@ def fetchall(self): rows.append(row) return rows + def fetchmany(self, size=None): + if not self.reader: + return [] + if size is None: + size = self.reader.FieldCount # Fallback + + rows = [] + import System # type: ignore + + while len(rows) < size and self.reader.Read(): + row = [ + ( + self.reader.GetValue(i) + if not isinstance(self.reader.GetValue(i), System.DBNull) + else None + ) + for i in range(self.reader.FieldCount) + ] + rows.append(row) + return rows + def fetchone(self): if not self.reader or not self.reader.Read(): return None @@ -268,6 +338,9 @@ def connect_to_olap( if connection_string is None: connection_string, auth_details = get_connection_string(secrets) + if connection_string is None: + return None + if auth_details is None: auth_details = {} auth_method = auth_details.get("Метод автентифікації", "") @@ -293,28 +366,42 @@ def connect_to_olap( ) return connection except Exception as pyadomd_error: - print_warning(f"Не вдалося підключитися через Pyadomd: {pyadomd_error}") - - if OleDbConnection is not None and OleDbCommand is not None: - print_info( - "Використовуємо підключення через OleDbConnection для автентифікації за логіном/паролем" - ) + if _is_auth_error(pyadomd_error): + # Явно хибний логін/пароль: видаляємо кеш і просимо новий + print_warning(f"Помилка автентифікації через Pyadomd: {pyadomd_error}") + print_warning("Кешований пароль хибний. Запит нових облікових даних.") + delete_credentials(credentials_file=secrets.credentials_file) + else: + # Мережева помилка або невідома: НЕ чіпаємо кеш + print_warning(f"Помилка з'єднання через Pyadomd: {pyadomd_error}") + print_warning( + "Схоже на мережеву помилку або збій сервера. Кеш облікових даних збережено." + ) + # Спробуємо OleDb як резервний (не через помилку паролю) + if OleDbConnection is not None and OleDbCommand is not None: + print_info("Спробуємо підключення через OleDb як резервний...") + oledb_connection, cursor = connect_using_oledb( + connection_string, auth_details, OleDbConnection, OleDbCommand, secrets + ) + if oledb_connection and cursor: + return OleDbConnectionWrapper(oledb_connection, cursor) + print_error("Не вдалося встановити підключення. Перевірте мережу або стан сервера.") + return None + + elif OleDbConnection is not None and OleDbCommand is not None: + # PyAdomd недоступний, спробуємо лишень OleDb напряму + print_info("Pyadomd недоступний. Використовуємо OleDbConnection для автентифікації за логіном/паролем") oledb_connection, cursor = connect_using_oledb( connection_string, auth_details, OleDbConnection, OleDbCommand, secrets ) - if oledb_connection and cursor: return OleDbConnectionWrapper(oledb_connection, cursor) else: - print_error( - "OleDb провайдер недоступний. Для LOGIN потрібен Pyadomd або MSOLAP (System.Data.OleDb)." - ) + print_error("OleDb провайдер недоступний. Для LOGIN потрібен Pyadomd або MSOLAP (System.Data.OleDb).") + # Якщо ми тут — або явна auth-помилка, або OleDb теж впав з auth-помилкою if retry_count > 0: - print_warning( - "Не вдалося підключитися. Спробуйте ввести облікові дані ще раз." - ) - delete_credentials(credentials_file=secrets.credentials_file) + print_warning("Не вдалося підключитися. Введіть облікові дані ще раз.") new_username, new_password = prompt_credentials( with_domain=True, domain=secrets.domain ) @@ -338,10 +425,11 @@ def connect_to_olap( secrets, adomd_dll_path, new_connection_string, new_auth_details, retry_count - 1, ) + else: + print_warning("Авторизацію скасовано.") + return None - print_error( - "Не вдалося встановити підключення через OleDb після повторних спроб." - ) + print_error("Не вдалося встановити підключення після повторних спроб.") return None else: # Для SSPI diff --git a/olap_tool/prompt.py b/olap_tool/connection/prompt.py similarity index 95% rename from olap_tool/prompt.py rename to olap_tool/connection/prompt.py index 8774264..3f32a36 100644 --- a/olap_tool/prompt.py +++ b/olap_tool/connection/prompt.py @@ -2,7 +2,7 @@ from typing import Optional from colorama import Fore -from .utils import print_info +from ..core.utils import print_info def prompt_credentials(with_domain: bool = False, domain: Optional[str] = None): diff --git a/olap_tool/security.py b/olap_tool/connection/security.py similarity index 62% rename from olap_tool/security.py rename to olap_tool/connection/security.py index 178bd3f..e902905 100644 --- a/olap_tool/security.py +++ b/olap_tool/connection/security.py @@ -7,58 +7,45 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from .utils import print_info, print_warning, print_error +from ..core.utils import print_info, print_warning, print_error def get_machine_id() -> str: + """ + Генерує стабільний ідентифікатор пристрою, що не змінюється залежно від + типу терміналу (Git Bash, CMD, PowerShell, планувальник). + Використовує platform.node() замість змінних середовища, які можуть + відрізнятися або бути відсутніми в різних оточеннях. + """ try: - identifiers: list[str] = [] - # OS-identity vars — не є конфігурацією додатку - computer_name = os.environ.get("COMPUTERNAME", "") - if computer_name: - identifiers.append(computer_name) - user_domain = os.environ.get("USERDOMAIN", "") - if user_domain: - identifiers.append(user_domain) - username = os.environ.get("USERNAME", "") - if username: - identifiers.append(username) - windows_dir = os.environ.get("WINDIR", "") - if windows_dir: - identifiers.append(windows_dir) - system_drive = os.environ.get("SystemDrive", "") - if system_drive: - identifiers.append(system_drive) - try: - import subprocess - - volume_info = subprocess.run( - f"vol {system_drive}", shell=True, capture_output=True, text=True - ) - if volume_info.returncode == 0: - for line in volume_info.stdout.strip().split("\n"): - if "Serial Number" in line or "Серійний номер" in line: - identifiers.append(line.strip()) - except Exception: - pass - import hashlib + import platform + import getpass - unique_id = "-".join(identifiers) - if not unique_id: - unique_id = "windows-fallback" - print_warning( - "Не вдалося отримати стабільні ідентифікатори системи, використовуємо запасний варіант" - ) - return hashlib.md5(unique_id.encode()).hexdigest() + hostname = platform.node() or "unknown_host" + username = _safe_getuser() + unique_id = f"{hostname.lower()}-{username.lower()}" + return hashlib.md5(unique_id.encode("utf-8")).hexdigest() except Exception as e: print_warning(f"Не вдалося отримати унікальний ідентифікатор пристрою: {e}") import hashlib + fallback = f"user-{os.environ.get('USERNAME', 'unknown')}" + return hashlib.md5(fallback.encode("utf-8")).hexdigest() + - fallback = ( - f"user-{os.environ.get('USERNAME', '')}-{os.environ.get('WINDIR', '')}" +def _safe_getuser() -> str: + """Безпечно отримує ім'я поточного користувача, обходячи баг `getpass.getuser()` у Git Bash.""" + import getpass + try: + return getpass.getuser() + except Exception: + # getpass.getuser() може впасти у деяких середовищах (особливо в Git Bash на Windows) + return ( + os.environ.get("USERNAME") + or os.environ.get("USER") + or os.environ.get("LOGNAME") + or "unknown_user" ) - return hashlib.md5(fallback.encode()).hexdigest() def generate_encryption_key( diff --git a/olap_tool/core/__init__.py b/olap_tool/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/olap_tool/cli.py b/olap_tool/core/cli.py similarity index 100% rename from olap_tool/cli.py rename to olap_tool/core/cli.py diff --git a/olap_tool/compression.py b/olap_tool/core/compression.py similarity index 100% rename from olap_tool/compression.py rename to olap_tool/core/compression.py diff --git a/olap_tool/config.py b/olap_tool/core/config.py similarity index 100% rename from olap_tool/config.py rename to olap_tool/core/config.py diff --git a/olap_tool/periods.py b/olap_tool/core/periods.py similarity index 100% rename from olap_tool/periods.py rename to olap_tool/core/periods.py diff --git a/olap_tool/profiles.py b/olap_tool/core/profiles.py similarity index 100% rename from olap_tool/profiles.py rename to olap_tool/core/profiles.py diff --git a/olap_tool/progress.py b/olap_tool/core/progress.py similarity index 93% rename from olap_tool/progress.py rename to olap_tool/core/progress.py index 8cdbc78..e275f18 100644 --- a/olap_tool/progress.py +++ b/olap_tool/core/progress.py @@ -151,6 +151,7 @@ def get_progress_info(self): def loading_spinner(description: str, estimated_time: float | None = None): global animation_running + animation_running = True spinner = itertools.cycle(SPINNER_FRAMES) start_time = time.time() @@ -163,8 +164,11 @@ def loading_spinner(description: str, estimated_time: float | None = None): sys.stdout.write(message) sys.stdout.flush() time.sleep(0.1) - sys.stdout.write("\r" + " " * (len(message) + 2) + "\r") - sys.stdout.flush() + # Don't print empty clears in TUI mode to avoid status bar stutter + if not hasattr(sys.stdout, "_app"): + sys.stdout.write("\r" + " " * (len(message) + 2) + "\r") + sys.stdout.write("\n") + sys.stdout.flush() def streaming_spinner( @@ -190,7 +194,10 @@ def streaming_spinner( sys.stdout.flush() last_message = message time.sleep(interval_s) - sys.stdout.write("\r" + " " * (len(last_message) + 2) + "\r") + if hasattr(sys.stdout, "_app"): + sys.stdout.write(f"\r{Fore.BLUE}[{get_current_time()}] {COUNTDOWN_ICON} Завершено: {rows_fn()} рядків\n") + else: + sys.stdout.write("\r" + " " * (len(last_message) + 2) + "\r") sys.stdout.flush() @@ -202,4 +209,4 @@ def countdown_timer(seconds: int): ) sys.stdout.flush() time.sleep(1) - print() + sys.stdout.write("\n") diff --git a/olap_tool/runner.py b/olap_tool/core/runner.py similarity index 97% rename from olap_tool/runner.py rename to olap_tool/core/runner.py index ba135cf..90261b2 100644 --- a/olap_tool/runner.py +++ b/olap_tool/core/runner.py @@ -15,16 +15,16 @@ init_utils, ) from .config import build_config -from .connection import connect_to_olap, get_connection_string, AUTH_SSPI -from .queries import get_available_weeks, generate_year_week_pairs, run_dax_query -from .auth import delete_credentials, get_current_windows_user, auth_username +from ..connection.connection import connect_to_olap, get_connection_string, AUTH_SSPI +from ..data.queries import get_available_weeks, generate_year_week_pairs, run_dax_query +from ..connection.auth import delete_credentials, get_current_windows_user, auth_username from .progress import TimeTracker, countdown_timer, init_display from .cli import parse_arguments, validate_arguments from . import periods from .compression import compress_files from .profiles import load_profile, print_profiles_list from .scheduler import start_scheduler, daemon_mode -from .sinks import ClickHouseSink, DuckDBSink, PostgreSQLSink +from ..sinks import ClickHouseSink, DuckDBSink, PostgreSQLSink CURRENT_YEAR = datetime.datetime.now().year @@ -105,6 +105,7 @@ def main(argv: list[str] | None = None) -> int: print_error("Не вдалося підключитися до OLAP. Програма завершує роботу.") return 1 + sinks: list = [] try: available_weeks = get_available_weeks(connection) @@ -354,7 +355,7 @@ def main(argv: list[str] | None = None) -> int: print_warning("Не було створено жодного файлу") finally: - for sink in (sinks if 'sinks' in locals() else []): + for sink in sinks: try: sink.close() except Exception: diff --git a/olap_tool/scheduler.py b/olap_tool/core/scheduler.py similarity index 99% rename from olap_tool/scheduler.py rename to olap_tool/core/scheduler.py index 6942e28..4e969fa 100644 --- a/olap_tool/scheduler.py +++ b/olap_tool/core/scheduler.py @@ -131,9 +131,9 @@ def run_scheduled_task(profile_name: str) -> None: print() print_info(f"Запуск задачі: {profile_name} о {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + saved_argv: list = sys.argv.copy() try: # Підготовка аргументів для runner - saved_argv = sys.argv.copy() sys.argv = ['olap.py', '--profile', profile_name] # Виконання основної функції @@ -151,8 +151,7 @@ def run_scheduled_task(profile_name: str) -> None: print_error(f"Помилка виконання задачі '{profile_name}': {e}") finally: # Відновлення argv на випадок помилки - if 'saved_argv' in locals(): - sys.argv = saved_argv + sys.argv = saved_argv def start_scheduler(profile_name: str, schedule_spec: str) -> int: @@ -316,5 +315,3 @@ def daemon_mode(profiles: List[str]) -> int: print_info("Daemon режим зупинено") return 0 - - diff --git a/olap_tool/utils.py b/olap_tool/core/utils.py similarity index 100% rename from olap_tool/utils.py rename to olap_tool/core/utils.py diff --git a/olap_tool/data/__init__.py b/olap_tool/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/olap_tool/data/exporter.py b/olap_tool/data/exporter.py new file mode 100644 index 0000000..d0c35b0 --- /dev/null +++ b/olap_tool/data/exporter.py @@ -0,0 +1,115 @@ +import csv +import math +import threading +from pathlib import Path +from typing import TYPE_CHECKING, Tuple + +import pandas as pd +import xlsxwriter # type: ignore + +from ..core.utils import print_progress, convert_dotnet_to_python +from ..core import progress + +if TYPE_CHECKING: + from ..core.config import ExcelHeaderConfig, XlsxConfig + + +class CsvStreamWriter: + def __init__(self, file_path: Path, delimiter: str, encoding: str, quoting_mode: str): + self.file_path = file_path + self.delimiter = delimiter + self.encoding = encoding + if quoting_mode == "all": + self.quoting = csv.QUOTE_ALL + elif quoting_mode == "nonnumeric": + self.quoting = csv.QUOTE_NONNUMERIC + else: + self.quoting = csv.QUOTE_MINIMAL + self.is_first = True + self.row_count = 0 + + def write_chunk(self, df: pd.DataFrame): + df_replaced = df.replace([math.inf, -math.inf], None) + df_replaced.to_csv( + str(self.file_path), + mode='w' if self.is_first else 'a', + sep=self.delimiter, + encoding=self.encoding, + index=False, + header=self.is_first, + quoting=self.quoting, # type: ignore[arg-type] + na_rep="" + ) + self.is_first = False + self.row_count += len(df) + + def close(self): + pass + + +class XlsxStreamWriter: + def __init__(self, file_path: Path, sheet_name: str, excel_header: "ExcelHeaderConfig", xlsx_config: "XlsxConfig"): + self.file_path_str = str(file_path) + self.xlsx_config = xlsx_config + self.workbook = xlsxwriter.Workbook(self.file_path_str, {"constant_memory": True}) + self.worksheet = self.workbook.add_worksheet(sheet_name) + + if not xlsx_config.min_format: + self.header_format = self.workbook.add_format({ + "bold": True, + "font_name": "Arial", + "font_size": excel_header.font_size, + "font_color": excel_header.font_color, + "bg_color": excel_header.color, + "align": "center", + "valign": "vcenter", + "text_wrap": True, + "border": 1, + }) + else: + self.header_format = None + + self.is_first = True + self.row_idx = 1 + self.row_count = 0 + self.col_max_lengths = {} + + def write_chunk(self, df: pd.DataFrame): + if self.is_first: + if self.header_format: + self.worksheet.write_row(0, 0, list(df.columns), self.header_format) + else: + self.worksheet.write_row(0, 0, list(df.columns)) + self.is_first = False + + for row_data in df.itertuples(index=False): + safe_row = [] + for col_idx, cell_value in enumerate(row_data): + if isinstance(cell_value, float) and (math.isnan(cell_value) or math.isinf(cell_value)): + safe_row.append(None) + else: + safe_row.append(cell_value) + + # Track max length for column sizing if needed + if not self.xlsx_config.min_format: + str_len = len(str(cell_value)) if cell_value is not None else 0 + if col_idx not in self.col_max_lengths or str_len > self.col_max_lengths[col_idx]: + self.col_max_lengths[col_idx] = str_len + + self.worksheet.write_row(self.row_idx, 0, safe_row) + self.row_idx += 1 + + self.row_count += len(df) + + def close(self): + if not self.xlsx_config.min_format: + # We must apply columns widths based on tracked lengths + for col_idx, max_len in self.col_max_lengths.items(): + # We need to consider the header length as well, but we don't have access to the exact header string length here easily unless we tracked it. + # Just use max_len + 2, capped at 100. + column_width = min(max_len + 2, 100) + self.worksheet.set_column(col_idx, col_idx, column_width) + self.worksheet.freeze_panes(1, 0) + + self.workbook.close() + return self.row_count, Path(self.file_path_str).stat().st_size diff --git a/olap_tool/queries.py b/olap_tool/data/queries.py similarity index 60% rename from olap_tool/queries.py rename to olap_tool/data/queries.py index 3203207..62a26b9 100644 --- a/olap_tool/queries.py +++ b/olap_tool/data/queries.py @@ -7,7 +7,7 @@ import pandas as pd -from .utils import ( +from ..core.utils import ( print_info, print_warning, print_error, @@ -17,11 +17,11 @@ convert_dotnet_to_python, ensure_dir, ) -from .exporter import export_csv_stream, export_xlsx_dataframe, export_xlsx_stream -from . import progress +# CsvStreamWriter / XlsxStreamWriter are imported lazily inside run_dax_query +from ..core import progress if TYPE_CHECKING: - from .config import QueryConfig, ExportConfig, XlsxConfig, CsvConfig, ExcelHeaderConfig, PathsConfig, ClickHouseConfig + from ..core.config import QueryConfig, ExportConfig, XlsxConfig, CsvConfig, ExcelHeaderConfig, PathsConfig, ClickHouseConfig def generate_year_week_pairs(start_period, end_period, available_weeks): @@ -168,11 +168,12 @@ def run_dax_query( print_progress("Виконання запиту до OLAP-кубу...") query_start_time = _time.time() + cursor = None spinner_thread = None try: cursor = connection.cursor() cursor.execute(query) - estimated_query_time = 120 + estimated_query_time = query_config.timeout spinner_thread = threading.Thread( target=progress.loading_spinner, args=("Отримання даних з OLAP кубу", estimated_query_time), @@ -181,110 +182,52 @@ def run_dax_query( export_format = export_config.format.upper() force_csv_only = export_config.force_csv_only - streaming_xlsx = xlsx_config.streaming sink_only = export_format in ("CH", "CLICKHOUSE", "DUCK", "DUCKDB", "PG", "POSTGRESQL") - # Стрімінговий XLSX (НЕ для режиму clickhouse) - if export_format in ("XLSX", "BOTH") and not force_csv_only and streaming_xlsx and not sink_only: - progress.animation_running = False - spinner_thread.join(timeout=1.0) - xlsx_path = year_dir / f"{year_num}-{week_num:02d}.xlsx" - row_count, xlsx_size = export_xlsx_stream( - cursor, xlsx_path, f"{year_num}-{week_num:02d}", - excel_header, xlsx_config, - ) - query_duration = _time.time() - query_start_time - print_success( - f"Запит виконано за {format_time(query_duration)}. Отримано {row_count} рядків даних." - ) - print_success( - f"Дані експортовано у файл: {Fore.WHITE}{str(xlsx_path)} {Fore.YELLOW}(рядків: {row_count})" - ) - cursor.close() - if export_format == "BOTH": - csv_path = year_dir / f"{year_num}-{week_num:02d}.csv" - cursor = connection.cursor() - cursor.execute(query) - export_csv_stream( - cursor, csv_path, - csv_config.delimiter, csv_config.encoding, csv_config.quoting, - ) - cursor.close() - print_success( - f"Дані додатково експортовано у файл: {Fore.WHITE}{str(csv_path)}" - ) - return str(xlsx_path) + needs_xlsx = (export_format in ["XLSX", "BOTH"]) and not force_csv_only and not sink_only + needs_csv = (export_format in ["CSV", "BOTH"] or force_csv_only) and not sink_only - if (export_format == "CSV" or force_csv_only) and not sink_only: - csv_path = year_dir / f"{year_num}-{week_num:02d}.csv" - row_count = export_csv_stream( - cursor, csv_path, - csv_config.delimiter, csv_config.encoding, csv_config.quoting, - ) - progress.animation_running = False - spinner_thread.join(timeout=1.0) - query_duration = _time.time() - query_start_time - print_success( - f"Запит виконано за {format_time(query_duration)}. Отримано {row_count} рядків даних." - ) - print_success( - f"Дані експортовано у файл: {Fore.WHITE}{str(csv_path)} {Fore.YELLOW}(рядків: {row_count})" - ) - cursor.close() - return str(csv_path) + from .exporter import CsvStreamWriter, XlsxStreamWriter - # DataFrame-based export - rows = cursor.fetchall() - progress.animation_running = False - spinner_thread.join(timeout=1.0) - columns = [desc[0] for desc in cursor.description] - query_duration = _time.time() - query_start_time - print_success( - f"Запит виконано за {format_time(query_duration)}. Отримано {len(rows)} рядків даних." - ) - cursor.close() + xlsx_writer = None + csv_writer = None + exported_files = [] - converted_rows = [] - for row in rows: - converted_row = [convert_dotnet_to_python(value) for value in row] - converted_rows.append(converted_row) + if needs_xlsx: + xlsx_path = year_dir / f"{year_num}-{week_num:02d}.xlsx" + xlsx_writer = XlsxStreamWriter(xlsx_path, f"{year_num}-{week_num:02d}", excel_header, xlsx_config) + exported_files.append(str(xlsx_path)) - df = pd.DataFrame(converted_rows, columns=columns) - if len(df) == 0: - print_warning(f"Запит не повернув даних для періоду {reporting_period}") - return [] + if needs_csv: + csv_path = year_dir / f"{year_num}-{week_num:02d}.csv" + csv_writer = CsvStreamWriter(csv_path, csv_config.delimiter, csv_config.encoding, csv_config.quoting) + exported_files.append(str(csv_path)) + + raw_columns = [desc[0] for desc in cursor.description] - print_progress("Обробка результатів запиту...") pattern = re.compile(r"(\w+)\[([^\]]+)\]") - renamed_columns = {} potential_names = {} - for col in df.columns: + for col in raw_columns: m = pattern.match(col) column_name = m.group(2) if m else col.strip("[]") - potential_names[column_name] = ( - False if column_name in potential_names else True - ) - for col in df.columns: + potential_names[column_name] = False if column_name in potential_names else True + + renamed_columns = [] + duplicate_columns = [] + for col in raw_columns: m = pattern.match(col) if m: column_name = m.group(2) - if potential_names[column_name]: - renamed_columns[col] = column_name + if potential_names.get(column_name, True): + renamed_columns.append(column_name) + else: + renamed_columns.append(col) + duplicate_columns.append(col) else: - renamed_columns[col] = col.strip("[]") + renamed_columns.append(col.strip("[]")) - duplicate_columns = [] - for col in df.columns: - m = pattern.match(col) - if not m: - continue - key = m.group(2) - if key in potential_names and not potential_names[key]: - duplicate_columns.append(col) if duplicate_columns: - print_warning( - "Деякі стовпці не були перейменовані через потенційне дублювання:" - ) + print_warning("Деякі стовпці не були перейменовані через потенційне дублювання:") for col in duplicate_columns: match = re.match(r"(\w+)\[([^\]]+)\]", col) if match: @@ -294,75 +237,102 @@ def run_dax_query( else: print_info("Усі стовпці успішно перейменовано") - df.rename(columns=renamed_columns, inplace=True) + progress.animation_running = False + spinner_thread.join(timeout=1.0) + query_duration = _time.time() - query_start_time + print_success(f"Запит виконано за {format_time(query_duration)}.") - if export_format not in ["XLSX", "CSV", "BOTH", "CH", "CLICKHOUSE", "DUCK", "DUCKDB", "PG", "POSTGRESQL"]: - print_warning( - f"Невідомий формат експорту: {export_format}. Використовуємо XLSX." - ) - export_format = "XLSX" - export_xlsx_flag = export_format in ["XLSX", "BOTH"] and not sink_only - export_csv_flag = export_format in ["CSV", "BOTH"] and not sink_only - exported_files = [] - if export_xlsx_flag and not force_csv_only: - xlsx_path = year_dir / f"{year_num}-{week_num:02d}.xlsx" - xlsx_size = export_xlsx_dataframe( - df, xlsx_path, f"{year_num}-{week_num:02d}", - excel_header, xlsx_config, - ) - exported_files.append((str(xlsx_path), xlsx_size)) - if export_csv_flag or force_csv_only: - csv_path = year_dir / f"{year_num}-{week_num:02d}.csv" - df_replaced = df.replace([math.inf, -math.inf], None) - quoting_mode = csv_config.quoting.lower() - df_replaced.to_csv( - str(csv_path), - sep=csv_config.delimiter, - encoding=csv_config.encoding, - index=False, - quoting=( - csv.QUOTE_MINIMAL - if quoting_mode == "minimal" - else ( - csv.QUOTE_ALL if quoting_mode == "all" else csv.QUOTE_NONNUMERIC - ) - ), - na_rep="", - ) - exported_files.append((str(csv_path), Path(csv_path).stat().st_size)) + chunk_size = 50000 + total_rows = 0 + is_first_chunk = True + + print_progress("Експорт/збереження отриманих даних (потоковий режим)...") + # Використовуємо пряму ітерацію fetchone()-генератора: + # fetchmany() має баг у pyadomd — кожен виклик next(self.fetchone()) створює + # новий генератор, що руйнує стан XmlReader після ~50000 рядків. + raw_chunk: list = [] + for row in cursor.fetchone(): + raw_chunk.append([convert_dotnet_to_python(v) for v in row]) + if len(raw_chunk) < chunk_size: + continue + + df_chunk = pd.DataFrame(raw_chunk, columns=renamed_columns) + raw_chunk = [] + + if xlsx_writer: + xlsx_writer.write_chunk(df_chunk) + if csv_writer: + csv_writer.write_chunk(df_chunk) + + if sinks: + from ..sinks import sanitize_df as _sanitize + df_for_sinks = _sanitize(df_chunk) + df_for_sinks["year_num"] = year_num + df_for_sinks["week_num"] = week_num + for sink in sinks: + try: + if is_first_chunk: + sink.setup(df_for_sinks) + sink.delete_period(year_num, week_num) + sink.insert(df_for_sinks, year=year_num, week=week_num) + except Exception as e: + print_error(f"Помилка sink {type(sink).__name__}: {e}") + + total_rows += len(df_chunk) + is_first_chunk = False + + # Останній неповний chunk + if raw_chunk: + df_chunk = pd.DataFrame(raw_chunk, columns=renamed_columns) + if xlsx_writer: + xlsx_writer.write_chunk(df_chunk) + if csv_writer: + csv_writer.write_chunk(df_chunk) + if sinks: + from ..sinks import sanitize_df as _sanitize + df_for_sinks = _sanitize(df_chunk) + df_for_sinks["year_num"] = year_num + df_for_sinks["week_num"] = week_num + for sink in sinks: + try: + if is_first_chunk: + sink.setup(df_for_sinks) + sink.delete_period(year_num, week_num) + sink.insert(df_for_sinks, year=year_num, week=week_num) + except Exception as e: + print_error(f"Помилка sink {type(sink).__name__}: {e}") + total_rows += len(df_chunk) + + for filepath in exported_files: + file_size_bytes = 0 + if xlsx_writer and filepath == xlsx_writer.file_path_str: + _, file_size_bytes = xlsx_writer.close() + elif csv_writer and filepath == str(csv_writer.file_path): + csv_writer.close() + file_size_bytes = Path(filepath).stat().st_size - for filepath, file_size_bytes in exported_files: if file_size_bytes < 1024 * 1024: file_size = f"{file_size_bytes / 1024:.1f} КБ" else: file_size = f"{file_size_bytes / (1024 * 1024):.2f} МБ" print_success( - f"Дані експортовано у файл: {Fore.WHITE}{filepath} {Fore.YELLOW}({file_size}, {len(df)} рядків)" + f"Дані експортовано у файл: {Fore.WHITE}{filepath} {Fore.YELLOW}({file_size}, {total_rows} рядків)" ) - # Analytics sinks (ClickHouse, DuckDB, тощо) - if sinks: - from .sinks import sanitize_df as _sanitize - df_for_sinks = _sanitize(df) - df_for_sinks["year_num"] = year_num - df_for_sinks["week_num"] = week_num - for sink in sinks: - try: - sink.setup(df_for_sinks) - sink.delete_period(year_num, week_num) - sink.insert(df_for_sinks, year=year_num, week=week_num) - except Exception as e: - print_error(f"Помилка sink {type(sink).__name__}: {e}") + if total_rows == 0: + print_warning(f"Запит не повернув даних для періоду {reporting_period}") + return [] if sink_only: return None - return exported_files[0][0] if exported_files else None + + return exported_files[0] if exported_files else None except Exception as e: print_error(f"Помилка при виконанні запиту: {e}") return None finally: # Закриваємо курсор, щоб звільнити XmlReader на з'єднанні - if 'cursor' in locals() and cursor is not None: + if cursor is not None: try: cursor.close() except Exception: @@ -393,6 +363,7 @@ def get_available_weeks(connection): 'Calendar'[week_num] ASC /* END QUERY BUILDER */ """ + cursor = None try: cursor = connection.cursor() cursor.execute(query) diff --git a/olap_tool/exporter.py b/olap_tool/exporter.py deleted file mode 100644 index bd61f44..0000000 --- a/olap_tool/exporter.py +++ /dev/null @@ -1,226 +0,0 @@ -import csv -import math -import threading -from pathlib import Path -from typing import TYPE_CHECKING, Tuple - -import pandas as pd -import xlsxwriter # type: ignore - -from .utils import print_progress, convert_dotnet_to_python -from . import progress - -if TYPE_CHECKING: - from .config import ExcelHeaderConfig, XlsxConfig - - -def export_csv_stream( - cursor, csv_path: Path, delimiter: str, encoding: str, quoting_mode: str -) -> int: - import re - - if quoting_mode == "all": - quoting = csv.QUOTE_ALL - elif quoting_mode == "nonnumeric": - quoting = csv.QUOTE_NONNUMERIC - else: - quoting = csv.QUOTE_MINIMAL - - raw_columns = [desc[0] for desc in cursor.description] - pattern = re.compile(r"(\w+)\[([^\]]+)\]") - potential_names: dict[str, bool] = {} - for col in raw_columns: - match = pattern.match(col) - column_name = match.group(2) if match else col.strip("[]") - potential_names[column_name] = ( - False if column_name in potential_names else True - ) - - renamed_columns: list[str] = [] - for col in raw_columns: - match = pattern.match(col) - if match: - column_name = match.group(2) - renamed_columns.append( - column_name if potential_names.get(column_name, True) else col - ) - else: - renamed_columns.append(col.strip("[]")) - - row_count = 0 - with open(csv_path, "w", encoding=encoding, newline="") as f: - writer = csv.writer(f, delimiter=delimiter, quoting=quoting) - writer.writerow(renamed_columns) - while True: - row = cursor.fetchone() - if row is None: - break - converted_row = [] - for val in row: - py_val = convert_dotnet_to_python(val) - if isinstance(py_val, float) and (math.isnan(py_val) or math.isinf(py_val)): - py_val = None - converted_row.append(py_val) - writer.writerow(converted_row) - row_count += 1 - return row_count - - -def export_xlsx_dataframe( - df: pd.DataFrame, - file_path: Path, - sheet_name: str, - excel_header: "ExcelHeaderConfig", - xlsx_config: "XlsxConfig", -) -> int: - print_progress(f"Експорт даних у Excel-файл {file_path}...") - file_path_str = str(file_path) - workbook = xlsxwriter.Workbook(file_path_str, {"constant_memory": True}) - worksheet = workbook.add_worksheet(sheet_name) - header_format = workbook.add_format( - { - "bold": True, - "font_name": "Arial", - "font_size": excel_header.font_size, - "font_color": excel_header.font_color, - "bg_color": excel_header.color, - "align": "center", - "valign": "vcenter", - "text_wrap": True, - "border": 1, - } - ) - worksheet.write_row(0, 0, list(df.columns), header_format) - - if xlsx_config.streaming: - for row_idx, row_data in enumerate(df.itertuples(index=False), start=1): - safe_row = [] - for cell_value in row_data: - if isinstance(cell_value, float) and ( - math.isnan(cell_value) or math.isinf(cell_value) - ): - safe_row.append(None) - else: - safe_row.append(cell_value) - worksheet.write_row(row_idx, 0, safe_row) - else: - values = df.values.tolist() - for row_idx, row_data in enumerate(values, start=1): - safe_row = [] - for cell_value in row_data: - if isinstance(cell_value, float) and ( - math.isnan(cell_value) or math.isinf(cell_value) - ): - safe_row.append(None) - else: - safe_row.append(cell_value) - worksheet.write_row(row_idx, 0, safe_row) - - if not xlsx_config.min_format: - for col_num, column in enumerate(df.columns): - max_length = max( - len(str(column)), - (df.iloc[:, col_num].astype(str).str.len().max() if len(df) > 0 else 0), - ) - column_width = min(max_length + 2, 100) - worksheet.set_column(col_num, col_num, column_width) - worksheet.freeze_panes(1, 0) - - workbook.close() - return Path(file_path_str).stat().st_size - - -def export_xlsx_stream( - cursor, - file_path: Path, - sheet_name: str, - excel_header: "ExcelHeaderConfig", - xlsx_config: "XlsxConfig", -) -> Tuple[int, int]: - """ - Стрімінговий експорт у XLSX без проміжного DataFrame. - Повертає (row_count, file_size_bytes). - """ - import re as _re - - file_path_str = str(file_path) - workbook = xlsxwriter.Workbook(file_path_str, {"constant_memory": True}) - worksheet = workbook.add_worksheet(sheet_name) - - header_cells = [desc[0] for desc in cursor.description] - - pattern = _re.compile(r"(\w+)\[([^\]]+)\]") - potential_names: dict[str, bool] = {} - for col in header_cells: - match = pattern.match(col) - column_name = match.group(2) if match else col.strip("[]") - potential_names[column_name] = False if column_name in potential_names else True - - renamed_columns: list[str] = [] - for col in header_cells: - match = pattern.match(col) - if match: - column_name = match.group(2) - renamed_columns.append( - column_name if potential_names.get(column_name, True) else col - ) - else: - renamed_columns.append(col.strip("[]")) - - if xlsx_config.min_format: - worksheet.write_row(0, 0, renamed_columns) - else: - header_format = workbook.add_format( - { - "bold": True, - "font_name": "Arial", - "font_size": excel_header.font_size, - "font_color": excel_header.font_color, - "bg_color": excel_header.color, - "align": "center", - "valign": "vcenter", - "text_wrap": True, - "border": 1, - } - ) - worksheet.write_row(0, 0, renamed_columns, header_format) - - row_count = 0 - row_idx = 1 - stop_event = threading.Event() - spinner_thread = threading.Thread( - target=progress.streaming_spinner, - args=( - f"Експорт даних у Excel-файл {file_path} (streaming)", - stop_event, - lambda: row_count, - ), - ) - spinner_thread.start() - try: - while True: - row = cursor.fetchone() - if row is None: - break - safe_row = [] - for val in row: - py_val = convert_dotnet_to_python(val) - if isinstance(py_val, float) and (math.isnan(py_val) or math.isinf(py_val)): - py_val = None - safe_row.append(py_val) - worksheet.write_row(row_idx, 0, safe_row) - row_idx += 1 - row_count += 1 - finally: - stop_event.set() - try: - spinner_thread.join(timeout=1.0) - except Exception: - pass - - if not xlsx_config.min_format: - worksheet.freeze_panes(1, 0) - - workbook.close() - file_size_bytes = Path(file_path_str).stat().st_size - return row_count, file_size_bytes diff --git a/olap_tool/sinks.py b/olap_tool/sinks.py deleted file mode 100644 index 3a2b873..0000000 --- a/olap_tool/sinks.py +++ /dev/null @@ -1,601 +0,0 @@ -""" -Analytics Sink абстракція. - -Всі аналітичні сховища реалізують AnalyticsSink: - - ClickHouseSink — адаптер навколо clickhouse_export.py - - DuckDBSink — HTTP REST API (https://analytics.lwhs.xyz) -""" -from __future__ import annotations - -import datetime -import io -import re -import threading -from abc import ABC, abstractmethod - -import numpy as np -import pandas as pd - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .config import ClickHouseConfig - from .config import DuckDBConfig - from .config import PostgreSQLConfig - - -# --------------------------------------------------------------------------- -# Shared utilities (перенесено з clickhouse_export.py) -# --------------------------------------------------------------------------- - -def _safe_column_name(name: str) -> str: - """Перетворює назву колонки у безпечний SQL-ідентифікатор.""" - safe = re.sub(r"[^\w]", "_", name, flags=re.UNICODE) - safe = re.sub(r"_+", "_", safe).strip("_") - if not safe: - safe = "col" - if safe[0].isdigit(): - safe = "c_" + safe - return safe - - -def sanitize_df(df: pd.DataFrame) -> pd.DataFrame: - """Оброблює inf/NaN та перетворює колонки на безпечні імена.""" - df = df.copy() - df.rename(columns={col: _safe_column_name(col) for col in df.columns}, inplace=True) - float_cols = df.select_dtypes(include=["float64", "float32"]).columns - if len(float_cols) > 0: - df[float_cols] = df[float_cols].replace([np.inf, -np.inf], np.nan) - return df - - -# --------------------------------------------------------------------------- -# Abstract base -# --------------------------------------------------------------------------- - -class AnalyticsSink(ABC): - """Інтерфейс для аналітичного сховища.""" - - @abstractmethod - def setup(self, df: pd.DataFrame) -> None: - """Створити схему/таблицю якщо не існує.""" - - @abstractmethod - def delete_period(self, year: int, week: int) -> None: - """Видалити рядки за (year_num, week_num) для ідемпотентності.""" - - @abstractmethod - def insert(self, df: pd.DataFrame, year: int, week: int) -> int: - """Вставити рядки. Повертає кількість завантажених рядків.""" - - @abstractmethod - def close(self) -> None: - """Закрити з'єднання/ресурси.""" - - -# --------------------------------------------------------------------------- -# ClickHouse sink -# --------------------------------------------------------------------------- - -class ClickHouseSink(AnalyticsSink): - """ - Адаптер навколо clickhouse_export.py. - Підтримує batch-режим: якщо client передано ззовні — не закриває з'єднання. - """ - - def __init__(self, config: "ClickHouseConfig", client=None): - self._config = config - self._client = client # зовнішній клієнт (batch-режим) - self._own_client = client is None - self._schema: dict | None = None - - def setup(self, df: pd.DataFrame) -> None: - from .clickhouse_export import ( - create_client, ensure_database, ensure_table, get_table_schema, - ) - from .utils import print_progress - if self._own_client: - print_progress( - f"Підключення до ClickHouse ({self._config.host}:{self._config.port})..." - ) - self._client = create_client(self._config) - ensure_database(self._client, self._config.database) - ensure_table(self._client, self._config.database, self._config.table, df) - self._schema = get_table_schema( - self._client, self._config.database, self._config.table - ) - - def delete_period(self, year: int, week: int) -> None: - from .clickhouse_export import _delete_period - _delete_period( - self._client, self._config.database, self._config.table, - year, week, schema=self._schema, - ) - - def insert(self, df: pd.DataFrame, year: int, week: int) -> int: - from .clickhouse_export import export_to_clickhouse, get_table_schema - if self._schema is None: - self._schema = get_table_schema( - self._client, self._config.database, self._config.table - ) - return export_to_clickhouse( - df, self._config, - year=year, week=week, - client=self._client, - schema=self._schema, - ) - - def close(self) -> None: - if self._own_client and self._client is not None: - try: - self._client.close() - except Exception: - pass - self._client = None - - -# --------------------------------------------------------------------------- -# DuckDB sink (HTTP REST API) -# --------------------------------------------------------------------------- - -def _pandas_dtype_to_duck(dtype) -> str: - """Конвертує pandas dtype у DuckDB SQL тип.""" - dtype_str = str(dtype) - if dtype_str.startswith("int") or dtype_str.startswith("uint"): - return "BIGINT" - if dtype_str.startswith("float"): - return "DOUBLE" - if dtype_str in ("bool", "boolean"): - return "BOOLEAN" - if dtype_str.startswith("datetime"): - return "TIMESTAMP" - if dtype_str.startswith("date"): - return "DATE" - return "VARCHAR" - - - -_EXCEL_EPOCH = datetime.date(1899, 12, 30) -_DT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2}:\d{2})?$") - - -def _to_excel_serial(v) -> int | None: - """Конвертує datetime/date/datetime-рядок в Excel serial number (int).""" - if isinstance(v, datetime.datetime): - return (v.date() - _EXCEL_EPOCH).days - if isinstance(v, datetime.date): - return (v - _EXCEL_EPOCH).days - if isinstance(v, str) and _DT_RE.match(v): - try: - dt = datetime.datetime.strptime(v[:19], "%Y-%m-%d %H:%M:%S") - return (dt.date() - _EXCEL_EPOCH).days - except ValueError: - return None - return None - - -def _normalize_bigint_date_cols(df: pd.DataFrame, schema: dict[str, str]) -> pd.DataFrame: - """Конвертує рядкові datetime-колонки у BIGINT-схемі до Excel serial number.""" - for col in df.columns: - if schema.get(col) != "BIGINT": - continue - # Перевіряємо рядковий dtype (object або pd.StringDtype з calamine) - dtype_str = str(df[col].dtype) - if dtype_str not in ("object", "str", "string"): - continue - sample = df[col].dropna() - if sample.empty: - continue - first = sample.iloc[0] - if not isinstance(first, str) or not _DT_RE.match(first): - continue - df = df.copy() - df[col] = df[col].apply( - lambda v: _to_excel_serial(v) if pd.notna(v) else None - ) - return df - - -def _align_df_to_schema(df: pd.DataFrame, schema: dict[str, str]) -> pd.DataFrame: - """Приводить типи DataFrame до відповідності схеми DuckDB перед Parquet-upload. - - - VARCHAR у схемі → конвертує числові колонки до рядка - - BIGINT у схемі + рядкові datetime → вже оброблено _normalize_bigint_date_cols - """ - df = df.copy() - for col in df.columns: - duck_type = schema.get(col, "VARCHAR") - dtype_str = str(df[col].dtype) - if duck_type == "VARCHAR" and dtype_str not in ("object", "str", "string"): - # int64/float64 → str (напр. articul: 31262066 → '31262066') - def _to_str(v): - if pd.isnull(v): - return None - if isinstance(v, float) and v == int(v): - return str(int(v)) - return str(v) - df[col] = df[col].apply(_to_str) - return df - - -def _duck_value(v) -> str: - """Серіалізує Python-значення у SQL-літерал для DuckDB VALUES.""" - import math - import pandas as pd - - # None, NaT та float NaN → NULL - if v is None: - return "NULL" - try: - if pd.isnull(v): - return "NULL" - except (TypeError, ValueError): - pass - - # bool перед int (bool є підкласом int) - if isinstance(v, bool): - return "TRUE" if v else "FALSE" - - # Числа (Python int/float та numpy scalar types) - try: - import numpy as np - if isinstance(v, (np.integer, np.floating)): - if isinstance(v, np.floating) and math.isnan(float(v)): - return "NULL" - return str(v.item()) # .item() конвертує у Python native type - except ImportError: - pass - - if isinstance(v, (int, float)): - if isinstance(v, float) and math.isnan(v): - return "NULL" - return str(v) - - # Рядки та datetime — екрануємо одинарні лапки - return "'" + str(v).replace("'", "''") + "'" - - -class DuckDBSink(AnalyticsSink): - """ - Завантажує DataFrame у DuckDB через REST API. - - API: - POST /execute {"statements": [...]} — DDL/DML - POST /query {"sql": "..."} — SELECT (для DESCRIBE) - - Ідемпотентність: DELETE WHERE year_num=X AND week_num=Y → batch INSERT. - """ - - def __init__(self, config: "DuckDBConfig"): - import threading - self._config = config - self._session = self._make_session() - self._schema: dict[str, str] | None = None - self._schema_lock = threading.Lock() - - def _make_session(self): - import requests - s = requests.Session() - s.headers.update({ - "X-API-Key": self._config.api_key, - }) - return s - - def _execute(self, statements: list[str]) -> dict: - resp = self._session.post( - f"{self._config.url}/execute", - json={"statements": statements}, - timeout=600, - ) - if not resp.ok: - raise Exception(f"HTTP {resp.status_code}: {resp.text[:500]}") - return resp.json() - - def _query(self, sql: str) -> dict: - resp = self._session.post( - f"{self._config.url}/query", - json={"sql": sql}, - timeout=60, - ) - resp.raise_for_status() - return resp.json() - - def setup(self, df: pd.DataFrame) -> None: - from .utils import print_progress, print_warning - print_progress(f"Перевірка таблиці DuckDB `{self._config.table}`...") - cols_ddl = ", ".join( - f'"{col}" {_pandas_dtype_to_duck(df[col].dtype)}' - for col in df.columns - ) - self._execute([ - f'CREATE TABLE IF NOT EXISTS "{self._config.table}" ({cols_ddl})' - ]) - self._refresh_schema() - with self._schema_lock: - schema = dict(self._schema) # type: ignore[arg-type] - for col in df.columns: - if col not in schema: - dtype = _pandas_dtype_to_duck(df[col].dtype) - try: - self._execute([ - f'ALTER TABLE "{self._config.table}" ' - f'ADD COLUMN IF NOT EXISTS "{col}" {dtype}' - ]) - with self._schema_lock: - if self._schema is not None: - self._schema[col] = dtype - except Exception as e: - print_warning(f"Не вдалося додати колонку `{col}`: {e} — пропускаємо") - else: - # Якщо схема BIGINT, але в DataFrame є нечислові рядки → змінюємо на VARCHAR - if schema.get(col) == "BIGINT": - dtype_str = str(df[col].dtype) - if dtype_str in ("object", "str", "string"): - sample = df[col].dropna() - if not sample.empty and isinstance(sample.iloc[0], str) and not _DT_RE.match(str(sample.iloc[0])): - try: - self._execute([ - f'ALTER TABLE "{self._config.table}" ' - f'ALTER COLUMN "{col}" TYPE VARCHAR' - ]) - with self._schema_lock: - if self._schema is not None: - self._schema[col] = "VARCHAR" - print_warning(f"Колонку `{col}` змінено BIGINT → VARCHAR (нечислові значення)") - except Exception as e: - print_warning(f"Не вдалося змінити тип `{col}`: {e}") - - def _refresh_schema(self) -> None: - result = self._query(f'DESCRIBE "{self._config.table}"') - col_idx = result["columns"].index("column_name") - type_idx = result["columns"].index("column_type") - with self._schema_lock: - self._schema = {row[col_idx]: row[type_idx] for row in result["rows"]} - - def delete_period(self, year: int, week: int) -> None: - if self._schema is None: - self._refresh_schema() - with self._schema_lock: - schema: dict[str, str] = dict(self._schema) if self._schema is not None else {} - # Видаляємо тільки якщо обидва ключі є в схемі — інакше ризик знищити весь рік - if "year_num" in schema and "week_num" in schema: - self._execute([ - f'DELETE FROM "{self._config.table}" ' - f'WHERE year_num = {year} AND week_num = {week}' - ]) - - def _upload_parquet(self, df: pd.DataFrame, _retries: int = 3) -> int: - """Завантажує DataFrame у DuckDB через /upload (Parquet, mode=append).""" - import io - import time as _time - buf = io.BytesIO() - df.to_parquet(buf, index=False) - parquet_bytes = buf.getvalue() - - last_exc: Exception | None = None - for attempt in range(_retries): - try: - resp = self._session.post( - f"{self._config.url}/upload", - files={"file": ("data.parquet", parquet_bytes, "application/octet-stream")}, - data={"table": self._config.table, "mode": "append"}, - timeout=120, - ) - if not resp.ok: - err = Exception(f"HTTP {resp.status_code}: {resp.text[:500]}") - # 4xx (крім 429 Too Many Requests) — одразу piднімаємо, без retry - if 400 <= resp.status_code < 500 and resp.status_code != 429: - raise err - last_exc = err - else: - return resp.json().get("total_rows", 0) - except Exception as exc: - last_exc = exc - if attempt < _retries - 1: - _time.sleep(2 ** attempt) - raise last_exc # type: ignore[misc] - - def insert(self, df: pd.DataFrame, year: int, week: int) -> int: - if df is None or len(df) == 0: - return 0 - - df = sanitize_df(df) # замінює inf/-inf → NaN перед серіалізацією - - with self._schema_lock: - schema = dict(self._schema) if self._schema else {} - if schema: - cols = [c for c in df.columns if c in schema] - df = df[cols] - df = _normalize_bigint_date_cols(df, schema) - df = _align_df_to_schema(df, schema) - - if df.empty: - return 0 - - self._upload_parquet(df) - return len(df) - - def close(self) -> None: - try: - self._session.close() - except Exception: - pass - - -# --------------------------------------------------------------------------- -# PostgreSQL sink (psycopg2 + COPY FROM STDIN) -# --------------------------------------------------------------------------- - -def _pandas_dtype_to_pg(dtype) -> str: - """Конвертує pandas dtype у PostgreSQL SQL тип.""" - dtype_str = str(dtype) - if dtype_str.startswith("int") or dtype_str.startswith("uint"): - return "BIGINT" - if dtype_str.startswith("float"): - return "DOUBLE PRECISION" - if dtype_str in ("bool", "boolean"): - return "BOOLEAN" - if dtype_str.startswith("datetime"): - return "TIMESTAMP" - if dtype_str.startswith("date"): - return "DATE" - return "TEXT" - -class PostgreSQLSink(AnalyticsSink): - """ - Завантажує DataFrame у PostgreSQL через COPY FROM STDIN. - - Ідемпотентність: DELETE WHERE year_num=X AND week_num=Y → COPY FROM STDIN CSV. - SSL: sslmode=require (шифрування без перевірки self-signed сертифікату). - - NOT thread-safe: psycopg2-з'єднання не підтримують спільне використання між - потоками. Для batch-скриптів з threading створюйте окремий екземпляр на кожен потік. - """ - - def __init__(self, config: "PostgreSQLConfig"): - self._config = config - self._conn = None - self._schema: dict[str, str] | None = None - self._schema_lock = threading.Lock() - - def _get_conn(self): - """Повертає активне з'єднання, створює нове якщо потрібно.""" - import psycopg2 - if self._conn is None or self._conn.closed: - self._conn = psycopg2.connect( - host=self._config.host, - port=self._config.port, - dbname=self._config.database, - user=self._config.user, - password=self._config.password, - sslmode=self._config.ssl_mode, - ) - self._conn.autocommit = False - return self._conn - - def _full_table(self) -> str: - """Повертає повну назву таблиці з схемою: "schema"."table".""" - return f'"{self._config.schema}"."{self._config.table}"' - - def _refresh_schema(self) -> None: - """Читає поточну схему таблиці з information_schema.""" - conn = self._get_conn() - with conn.cursor() as cur: - cur.execute( - """ - SELECT column_name, data_type - FROM information_schema.columns - WHERE table_schema = %s AND table_name = %s - ORDER BY ordinal_position - """, - (self._config.schema, self._config.table), - ) - rows = cur.fetchall() - with self._schema_lock: - self._schema = {row[0]: row[1] for row in rows} - - def setup(self, df: pd.DataFrame) -> None: - from .utils import print_progress, print_warning - print_progress( - f"Перевірка таблиці PostgreSQL {self._full_table()} " - f"({self._config.host}:{self._config.port})..." - ) - conn = self._get_conn() - cols_ddl = ", ".join( - f'"{col}" {_pandas_dtype_to_pg(df[col].dtype)}' - for col in df.columns - ) - try: - with conn.cursor() as cur: - cur.execute( - f"CREATE TABLE IF NOT EXISTS {self._full_table()} ({cols_ddl})" - ) - conn.commit() - except Exception: - conn.rollback() - raise - self._refresh_schema() - with self._schema_lock: - schema = dict(self._schema) if self._schema is not None else {} - - # Додаємо нові колонки яких немає в таблиці - for col in df.columns: - if col not in schema: - dtype = _pandas_dtype_to_pg(df[col].dtype) - try: - with conn.cursor() as cur: - cur.execute( - f'ALTER TABLE {self._full_table()} ' - f'ADD COLUMN IF NOT EXISTS "{col}" {dtype}' - ) - conn.commit() - with self._schema_lock: - if self._schema is not None: - self._schema[col] = dtype - except Exception as e: - conn.rollback() - print_warning(f"Не вдалося додати колонку `{col}`: {e} — пропускаємо") - - def delete_period(self, year: int, week: int) -> None: - if self._schema is None: - self._refresh_schema() - with self._schema_lock: - schema = dict(self._schema) if self._schema is not None else {} - if "year_num" not in schema or "week_num" not in schema: - return - conn = self._get_conn() - try: - with conn.cursor() as cur: - cur.execute( - f"DELETE FROM {self._full_table()} " - f"WHERE year_num = %s AND week_num = %s", - (year, week), - ) - conn.commit() - except Exception: - conn.rollback() - raise - - def insert(self, df: pd.DataFrame, year: int, week: int) -> int: - if df is None or len(df) == 0: - return 0 - - # Фільтруємо до колонок що є в таблиці - with self._schema_lock: - schema = dict(self._schema) if self._schema else {} - if schema: - cols = [c for c in df.columns if c in schema] - df = df[cols] - - if df.empty: - return 0 - - # DataFrame → CSV у пам'яті; \N як sentinel для NULL - # (порожній рядок '' зберігається як '', а не як NULL) - buf = io.StringIO() - df.to_csv(buf, index=False, header=False, na_rep="\\N") - buf.seek(0) - - col_list = ", ".join(f'"{c}"' for c in df.columns) - copy_sql = ( - f"COPY {self._full_table()} ({col_list}) " - r"FROM STDIN WITH (FORMAT CSV, NULL '\N')" - ) - - conn = self._get_conn() - try: - with conn.cursor() as cur: - cur.copy_expert(copy_sql, buf) - conn.commit() - except Exception: - conn.rollback() - raise - return len(df) - - def close(self) -> None: - if self._conn is not None: - try: - self._conn.close() - except Exception: - pass - self._conn = None diff --git a/olap_tool/sinks/__init__.py b/olap_tool/sinks/__init__.py new file mode 100644 index 0000000..1b7afe0 --- /dev/null +++ b/olap_tool/sinks/__init__.py @@ -0,0 +1,7 @@ +"""Analytics sinks package.""" +from .base import AnalyticsSink, sanitize_df +from .clickhouse import ClickHouseSink +from .duckdb import DuckDBSink +from .postgresql import PostgreSQLSink + +__all__ = ["AnalyticsSink", "sanitize_df", "ClickHouseSink", "DuckDBSink", "PostgreSQLSink"] diff --git a/olap_tool/sinks/base.py b/olap_tool/sinks/base.py new file mode 100644 index 0000000..48dcd62 --- /dev/null +++ b/olap_tool/sinks/base.py @@ -0,0 +1,64 @@ +""" +Analytics Sink базовий модуль. + +Містить: + - _safe_column_name() — утиліта для безпечних SQL-ідентифікаторів + - sanitize_df() — очищення DataFrame перед завантаженням + - AnalyticsSink — абстрактний базовий клас для всіх аналітичних сховищ +""" +from __future__ import annotations + +import re +from abc import ABC, abstractmethod + +import numpy as np +import pandas as pd + + +# --------------------------------------------------------------------------- +# Shared utilities (перенесено з sinks.py) +# --------------------------------------------------------------------------- + +def _safe_column_name(name: str) -> str: + """Перетворює назву колонки у безпечний SQL-ідентифікатор.""" + safe = re.sub(r"[^\w]", "_", name, flags=re.UNICODE) + safe = re.sub(r"_+", "_", safe).strip("_") + if not safe: + safe = "col" + if safe[0].isdigit(): + safe = "c_" + safe + return safe + + +def sanitize_df(df: pd.DataFrame) -> pd.DataFrame: + """Оброблює inf/NaN та перетворює колонки на безпечні імена.""" + df = df.copy() + df.rename(columns={col: _safe_column_name(col) for col in df.columns}, inplace=True) + float_cols = df.select_dtypes(include=["float64", "float32"]).columns + if len(float_cols) > 0: + df[float_cols] = df[float_cols].replace([np.inf, -np.inf], np.nan) + return df + + +# --------------------------------------------------------------------------- +# Abstract base +# --------------------------------------------------------------------------- + +class AnalyticsSink(ABC): + """Інтерфейс для аналітичного сховища.""" + + @abstractmethod + def setup(self, df: pd.DataFrame) -> None: + """Створити схему/таблицю якщо не існує.""" + + @abstractmethod + def delete_period(self, year: int, week: int) -> None: + """Видалити рядки за (year_num, week_num) для ідемпотентності.""" + + @abstractmethod + def insert(self, df: pd.DataFrame, year: int, week: int) -> int: + """Вставити рядки. Повертає кількість завантажених рядків.""" + + @abstractmethod + def close(self) -> None: + """Закрити з'єднання/ресурси.""" diff --git a/olap_tool/clickhouse_export.py b/olap_tool/sinks/clickhouse.py similarity index 76% rename from olap_tool/clickhouse_export.py rename to olap_tool/sinks/clickhouse.py index 1c799f8..99739de 100644 --- a/olap_tool/clickhouse_export.py +++ b/olap_tool/sinks/clickhouse.py @@ -1,25 +1,28 @@ """ -ClickHouse Export Module - -Завантажує DataFrame у ClickHouse: - - Автоматично створює базу даних якщо не існує - - Автоматично створює таблицю зі схемою з DataFrame якщо не існує - - Ідемпотентна вставка: видаляє рядки за (year_num, week_num) перед вставкою - - Schema evolution: пропускає колонки яких немає в таблиці, - конвертує типи під реальну схему таблиці +ClickHouse sink — поєднує ClickHouseSink та clickhouse_export логіку. + +Містить: + - _pandas_dtype_to_ch() — маппінг pandas dtype → ClickHouse тип + - ensure_database() — CREATE DATABASE IF NOT EXISTS + - ensure_table() — CREATE TABLE IF NOT EXISTS зі схемою з DataFrame + - get_table_schema() — читає {col: ch_type} з system.columns + - _coerce_col_to_ch_type() — конвертує Series у тип CH-стовпця + - _align_df_to_table() — вирівнює DataFrame під схему таблиці + - _delete_period() — lightweight DELETE за (year_num, week_num) + - create_client() — фабрика clickhouse_connect клієнта + - export_to_clickhouse() — головна функція завантаження + - ClickHouseSink — реалізація AnalyticsSink для ClickHouse """ - from __future__ import annotations from typing import TYPE_CHECKING, Optional import pandas as pd -from .utils import print_success, print_warning, print_error, print_progress -from .sinks import sanitize_df, _safe_column_name # shared utilities +from .base import AnalyticsSink, sanitize_df, _safe_column_name # noqa: F401 if TYPE_CHECKING: - from .config import ClickHouseConfig + from ..core.config import ClickHouseConfig # --------------------------------------------------------------------------- @@ -120,6 +123,8 @@ def _align_df_to_table( schema: якщо передано — не робить зайвий запит до system.columns. """ + from ..core.utils import print_warning + if schema is None: schema = get_table_schema(client, database, table) @@ -226,6 +231,8 @@ def export_to_clickhouse( Returns: Кількість завантажених рядків. """ + from ..core.utils import print_success, print_warning, print_error, print_progress + def _log(fn, msg): if not silent: fn(msg) @@ -287,3 +294,59 @@ def _log(fn, msg): client.close() except Exception: pass + + +# --------------------------------------------------------------------------- +# ClickHouse sink +# --------------------------------------------------------------------------- + +class ClickHouseSink(AnalyticsSink): + """ + Адаптер навколо clickhouse_export логіки (тепер вбудованої у цей модуль). + Підтримує batch-режим: якщо client передано ззовні — не закриває з'єднання. + """ + + def __init__(self, config: "ClickHouseConfig", client=None): + self._config = config + self._client = client # зовнішній клієнт (batch-режим) + self._own_client = client is None + self._schema: dict | None = None + + def setup(self, df: pd.DataFrame) -> None: + from ..core.utils import print_progress + if self._own_client: + print_progress( + f"Підключення до ClickHouse ({self._config.host}:{self._config.port})..." + ) + self._client = create_client(self._config) + ensure_database(self._client, self._config.database) + ensure_table(self._client, self._config.database, self._config.table, df) + self._schema = get_table_schema( + self._client, self._config.database, self._config.table + ) + + def delete_period(self, year: int, week: int) -> None: + _delete_period( + self._client, self._config.database, self._config.table, + year, week, schema=self._schema, + ) + + def insert(self, df: pd.DataFrame, year: int, week: int) -> int: + if self._schema is None: + self._schema = get_table_schema( + self._client, self._config.database, self._config.table + ) + return export_to_clickhouse( + df, self._config, + year=year, week=week, + client=self._client, + schema=self._schema, + ) + + def close(self) -> None: + if self._own_client and self._client is not None: + try: + self._client.close() + except Exception: + pass + self._client = None diff --git a/olap_tool/sinks/duckdb.py b/olap_tool/sinks/duckdb.py new file mode 100644 index 0000000..6c551ac --- /dev/null +++ b/olap_tool/sinks/duckdb.py @@ -0,0 +1,280 @@ +""" +DuckDB sink — завантаження DataFrame через HTTP REST API. + +API: + POST /execute {"statements": [...]} — DDL/DML + POST /query {"sql": "..."} — SELECT (для DESCRIBE) + POST /upload multipart/form-data — Parquet upload (mode=append) +""" +from __future__ import annotations + +import datetime +import re +import threading +from typing import TYPE_CHECKING + +import pandas as pd + +from .base import AnalyticsSink, sanitize_df + +if TYPE_CHECKING: + from ..core.config import DuckDBConfig + + +# --------------------------------------------------------------------------- +# Утиліти для типів та конвертації +# --------------------------------------------------------------------------- + +def _pandas_dtype_to_duck(dtype) -> str: + """Конвертує pandas dtype у DuckDB SQL тип.""" + dtype_str = str(dtype) + if dtype_str.startswith("int") or dtype_str.startswith("uint"): + return "BIGINT" + if dtype_str.startswith("float"): + return "DOUBLE" + if dtype_str in ("bool", "boolean"): + return "BOOLEAN" + if dtype_str.startswith("datetime"): + return "TIMESTAMP" + if dtype_str.startswith("date"): + return "DATE" + return "VARCHAR" + + +_EXCEL_EPOCH = datetime.date(1899, 12, 30) +_DT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2}:\d{2})?$") + + +def _to_excel_serial(v) -> int | None: + """Конвертує datetime/date/datetime-рядок в Excel serial number (int).""" + if isinstance(v, datetime.datetime): + return (v.date() - _EXCEL_EPOCH).days + if isinstance(v, datetime.date): + return (v - _EXCEL_EPOCH).days + if isinstance(v, str) and _DT_RE.match(v): + try: + dt = datetime.datetime.strptime(v[:19], "%Y-%m-%d %H:%M:%S") + return (dt.date() - _EXCEL_EPOCH).days + except ValueError: + return None + return None + + +def _normalize_bigint_date_cols(df: pd.DataFrame, schema: dict[str, str]) -> pd.DataFrame: + """Конвертує рядкові datetime-колонки у BIGINT-схемі до Excel serial number.""" + for col in df.columns: + if schema.get(col) != "BIGINT": + continue + # Перевіряємо рядковий dtype (object або pd.StringDtype з calamine) + dtype_str = str(df[col].dtype) + if dtype_str not in ("object", "str", "string"): + continue + sample = df[col].dropna() + if sample.empty: + continue + first = sample.iloc[0] + if not isinstance(first, str) or not _DT_RE.match(first): + continue + df = df.copy() + df[col] = df[col].apply( + lambda v: _to_excel_serial(v) if pd.notna(v) else None + ) + return df + + +def _align_df_to_schema(df: pd.DataFrame, schema: dict[str, str]) -> pd.DataFrame: + """Приводить типи DataFrame до відповідності схеми DuckDB перед Parquet-upload. + + - VARCHAR у схемі → конвертує числові колонки до рядка + - BIGINT у схемі + рядкові datetime → вже оброблено _normalize_bigint_date_cols + """ + df = df.copy() + for col in df.columns: + duck_type = schema.get(col, "VARCHAR") + dtype_str = str(df[col].dtype) + if duck_type == "VARCHAR" and dtype_str not in ("object", "str", "string"): + # int64/float64 → str (напр. articul: 31262066 → '31262066') + def _to_str(v): + if pd.isnull(v): + return None + if isinstance(v, float) and v == int(v): + return str(int(v)) + return str(v) + df[col] = df[col].apply(_to_str) + return df + + +# --------------------------------------------------------------------------- +# DuckDB sink +# --------------------------------------------------------------------------- + +class DuckDBSink(AnalyticsSink): + """ + Завантажує DataFrame у DuckDB через REST API. + + API: + POST /execute {"statements": [...]} — DDL/DML + POST /query {"sql": "..."} — SELECT (для DESCRIBE) + + Ідемпотентність: DELETE WHERE year_num=X AND week_num=Y → batch INSERT. + """ + + def __init__(self, config: "DuckDBConfig"): + self._config = config + self._session = self._make_session() + self._schema: dict[str, str] | None = None + self._schema_lock = threading.Lock() + + def _make_session(self): + import requests + s = requests.Session() + s.headers.update({ + "X-API-Key": self._config.api_key, + }) + return s + + def _execute(self, statements: list[str]) -> dict: + resp = self._session.post( + f"{self._config.url}/execute", + json={"statements": statements}, + timeout=600, + ) + if not resp.ok: + raise Exception(f"HTTP {resp.status_code}: {resp.text[:500]}") + return resp.json() + + def _query(self, sql: str) -> dict: + resp = self._session.post( + f"{self._config.url}/query", + json={"sql": sql}, + timeout=60, + ) + resp.raise_for_status() + return resp.json() + + def setup(self, df: pd.DataFrame) -> None: + from ..core.utils import print_progress, print_warning + print_progress(f"Перевірка таблиці DuckDB `{self._config.table}`...") + cols_ddl = ", ".join( + f'"{col}" {_pandas_dtype_to_duck(df[col].dtype)}' + for col in df.columns + ) + self._execute([ + f'CREATE TABLE IF NOT EXISTS "{self._config.table}" ({cols_ddl})' + ]) + self._refresh_schema() + with self._schema_lock: + schema = dict(self._schema) # type: ignore[arg-type] + for col in df.columns: + if col not in schema: + dtype = _pandas_dtype_to_duck(df[col].dtype) + try: + self._execute([ + f'ALTER TABLE "{self._config.table}" ' + f'ADD COLUMN IF NOT EXISTS "{col}" {dtype}' + ]) + with self._schema_lock: + if self._schema is not None: + self._schema[col] = dtype + except Exception as e: + print_warning(f"Не вдалося додати колонку `{col}`: {e} — пропускаємо") + else: + # Якщо схема BIGINT, але в DataFrame є нечислові рядки → змінюємо на VARCHAR + if schema.get(col) == "BIGINT": + dtype_str = str(df[col].dtype) + if dtype_str in ("object", "str", "string"): + sample = df[col].dropna() + if not sample.empty and isinstance(sample.iloc[0], str) and not _DT_RE.match(str(sample.iloc[0])): + try: + self._execute([ + f'ALTER TABLE "{self._config.table}" ' + f'ALTER COLUMN "{col}" TYPE VARCHAR' + ]) + with self._schema_lock: + if self._schema is not None: + self._schema[col] = "VARCHAR" + print_warning(f"Колонку `{col}` змінено BIGINT → VARCHAR (нечислові значення)") + except Exception as e: + print_warning(f"Не вдалося змінити тип `{col}`: {e}") + + def _refresh_schema(self) -> None: + result = self._query(f'DESCRIBE "{self._config.table}"') + try: + col_idx = result["columns"].index("column_name") + type_idx = result["columns"].index("column_type") + except (KeyError, ValueError) as exc: + raise RuntimeError( + f"Несподіваний формат відповіді DESCRIBE від DuckDB API: {exc}. " + f"Колонки відповіді: {result.get('columns', '?')}" + ) from exc + with self._schema_lock: + self._schema = {row[col_idx]: row[type_idx] for row in result["rows"]} + + def delete_period(self, year: int, week: int) -> None: + if self._schema is None: + self._refresh_schema() + with self._schema_lock: + schema: dict[str, str] = dict(self._schema) if self._schema is not None else {} + # Видаляємо тільки якщо обидва ключі є в схемі — інакше ризик знищити весь рік + if "year_num" in schema and "week_num" in schema: + self._execute([ + f'DELETE FROM "{self._config.table}" ' + f'WHERE year_num = {year} AND week_num = {week}' + ]) + + def _upload_parquet(self, df: pd.DataFrame, _retries: int = 3) -> int: + """Завантажує DataFrame у DuckDB через /upload (Parquet, mode=append).""" + import io + import time as _time + buf = io.BytesIO() + df.to_parquet(buf, index=False) + parquet_bytes = buf.getvalue() + + last_exc: Exception | None = None + for attempt in range(_retries): + try: + resp = self._session.post( + f"{self._config.url}/upload", + files={"file": ("data.parquet", parquet_bytes, "application/octet-stream")}, + data={"table": self._config.table, "mode": "append"}, + timeout=120, + ) + if not resp.ok: + err = Exception(f"HTTP {resp.status_code}: {resp.text[:500]}") + # 4xx (крім 429 Too Many Requests) — одразу piднімаємо, без retry + if 400 <= resp.status_code < 500 and resp.status_code != 429: + raise err + last_exc = err + else: + return resp.json().get("total_rows", 0) + except Exception as exc: + last_exc = exc + if attempt < _retries - 1: + _time.sleep(2 ** attempt) + raise last_exc # type: ignore[misc] + + def insert(self, df: pd.DataFrame, year: int, week: int) -> int: + if df is None or len(df) == 0: + return 0 + + df = sanitize_df(df) # замінює inf/-inf → NaN перед серіалізацією + + with self._schema_lock: + schema = dict(self._schema) if self._schema else {} + if schema: + cols = [c for c in df.columns if c in schema] + df = df[cols] + df = _normalize_bigint_date_cols(df, schema) + df = _align_df_to_schema(df, schema) + + if df.empty: + return 0 + + self._upload_parquet(df) + return len(df) + + def close(self) -> None: + try: + self._session.close() + except Exception: + pass diff --git a/olap_tool/sinks/postgresql.py b/olap_tool/sinks/postgresql.py new file mode 100644 index 0000000..71a6159 --- /dev/null +++ b/olap_tool/sinks/postgresql.py @@ -0,0 +1,205 @@ +""" +PostgreSQL sink — завантаження DataFrame через COPY FROM STDIN. + +Ідемпотентність: DELETE WHERE year_num=X AND week_num=Y → COPY FROM STDIN CSV. +SSL: sslmode=require (шифрування без перевірки self-signed сертифікату). + +NOT thread-safe: psycopg2-з'єднання не підтримують спільне використання між +потоками. Для batch-скриптів з threading створюйте окремий екземпляр на кожен потік. +""" +from __future__ import annotations + +import io +import threading +from typing import TYPE_CHECKING + +import pandas as pd + +from .base import AnalyticsSink, sanitize_df + +if TYPE_CHECKING: + from ..core.config import PostgreSQLConfig + + +# --------------------------------------------------------------------------- +# Утиліти для типів +# --------------------------------------------------------------------------- + +def _pandas_dtype_to_pg(dtype) -> str: + """Конвертує pandas dtype у PostgreSQL SQL тип.""" + dtype_str = str(dtype) + if dtype_str.startswith("int") or dtype_str.startswith("uint"): + return "BIGINT" + if dtype_str.startswith("float"): + return "DOUBLE PRECISION" + if dtype_str in ("bool", "boolean"): + return "BOOLEAN" + if dtype_str.startswith("datetime"): + return "TIMESTAMP" + if dtype_str.startswith("date"): + return "DATE" + return "TEXT" + + +# --------------------------------------------------------------------------- +# PostgreSQL sink +# --------------------------------------------------------------------------- + +class PostgreSQLSink(AnalyticsSink): + """ + Завантажує DataFrame у PostgreSQL через COPY FROM STDIN. + + Ідемпотентність: DELETE WHERE year_num=X AND week_num=Y → COPY FROM STDIN CSV. + SSL: sslmode=require (шифрування без перевірки self-signed сертифікату). + + NOT thread-safe: psycopg2-з'єднання не підтримують спільне використання між + потоками. Для batch-скриптів з threading створюйте окремий екземпляр на кожен потік. + """ + + def __init__(self, config: "PostgreSQLConfig"): + self._config = config + self._conn = None + self._schema: dict[str, str] | None = None + self._schema_lock = threading.Lock() + + def _get_conn(self): + """Повертає активне з'єднання, створює нове якщо потрібно.""" + import psycopg2 + if self._conn is None or self._conn.closed: + self._conn = psycopg2.connect( + host=self._config.host, + port=self._config.port, + dbname=self._config.database, + user=self._config.user, + password=self._config.password, + sslmode=self._config.ssl_mode, + ) + self._conn.autocommit = False + return self._conn + + def _full_table(self) -> str: + """Повертає повну назву таблиці з схемою: "schema"."table".""" + return f'"{self._config.schema}"."{self._config.table}"' + + def _refresh_schema(self) -> None: + """Читає поточну схему таблиці з information_schema.""" + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + """ + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s + ORDER BY ordinal_position + """, + (self._config.schema, self._config.table), + ) + rows = cur.fetchall() + with self._schema_lock: + self._schema = {row[0]: row[1] for row in rows} + + def setup(self, df: pd.DataFrame) -> None: + from ..core.utils import print_progress, print_warning + print_progress( + f"Перевірка таблиці PostgreSQL {self._full_table()} " + f"({self._config.host}:{self._config.port})..." + ) + conn = self._get_conn() + cols_ddl = ", ".join( + f'"{col}" {_pandas_dtype_to_pg(df[col].dtype)}' + for col in df.columns + ) + try: + with conn.cursor() as cur: + cur.execute( + f"CREATE TABLE IF NOT EXISTS {self._full_table()} ({cols_ddl})" + ) + conn.commit() + except Exception: + conn.rollback() + raise + self._refresh_schema() + with self._schema_lock: + schema = dict(self._schema) if self._schema is not None else {} + + # Додаємо нові колонки яких немає в таблиці + for col in df.columns: + if col not in schema: + dtype = _pandas_dtype_to_pg(df[col].dtype) + try: + with conn.cursor() as cur: + cur.execute( + f'ALTER TABLE {self._full_table()} ' + f'ADD COLUMN IF NOT EXISTS "{col}" {dtype}' + ) + conn.commit() + with self._schema_lock: + if self._schema is not None: + self._schema[col] = dtype + except Exception as e: + conn.rollback() + print_warning(f"Не вдалося додати колонку `{col}`: {e} — пропускаємо") + + def delete_period(self, year: int, week: int) -> None: + if self._schema is None: + self._refresh_schema() + with self._schema_lock: + schema = dict(self._schema) if self._schema is not None else {} + if "year_num" not in schema or "week_num" not in schema: + return + conn = self._get_conn() + try: + with conn.cursor() as cur: + cur.execute( + f"DELETE FROM {self._full_table()} " + f"WHERE year_num = %s AND week_num = %s", + (year, week), + ) + conn.commit() + except Exception: + conn.rollback() + raise + + def insert(self, df: pd.DataFrame, year: int, week: int) -> int: + if df is None or len(df) == 0: + return 0 + + # Фільтруємо до колонок що є в таблиці + with self._schema_lock: + schema = dict(self._schema) if self._schema else {} + if schema: + cols = [c for c in df.columns if c in schema] + df = df[cols] + + if df.empty: + return 0 + + # DataFrame → CSV у пам'яті; \N як sentinel для NULL + # (порожній рядок '' зберігається як '', а не як NULL) + buf = io.StringIO() + df.to_csv(buf, index=False, header=False, na_rep="\\N") + buf.seek(0) + + col_list = ", ".join(f'"{c}"' for c in df.columns) + copy_sql = ( + f"COPY {self._full_table()} ({col_list}) " + r"FROM STDIN WITH (FORMAT CSV, NULL '\N')" + ) + + conn = self._get_conn() + try: + with conn.cursor() as cur: + cur.copy_expert(copy_sql, buf) + conn.commit() + except Exception: + conn.rollback() + raise + return len(df) + + def close(self) -> None: + if self._conn is not None: + try: + self._conn.close() + except Exception: + pass + self._conn = None diff --git a/olap_tool/ui/__init__.py b/olap_tool/ui/__init__.py new file mode 100644 index 0000000..05b7a5c --- /dev/null +++ b/olap_tool/ui/__init__.py @@ -0,0 +1 @@ +"""Консольний інтерактивний UI (InquirerPy + rich).""" diff --git a/olap_tool/ui/menu.py b/olap_tool/ui/menu.py new file mode 100644 index 0000000..541633e --- /dev/null +++ b/olap_tool/ui/menu.py @@ -0,0 +1,68 @@ +"""Головне меню консольного UI.""" +from __future__ import annotations + +from InquirerPy import inquirer +from InquirerPy.separator import Separator +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +console = Console() + + +def _print_header() -> None: + """Виводить заголовок програми.""" + try: + from dotenv import dotenv_values + from pathlib import Path + env = dotenv_values(Path(__file__).parent.parent.parent / ".env") + server = env.get("OLAP_SERVER") or env.get("SERVER") or "—" + auth = env.get("OLAP_AUTH_METHOD") or env.get("AUTH_METHOD") or "SSPI" + except Exception: + server, auth = "—", "—" + + text = Text() + text.append("OLAP Export Tool\n", style="bold cyan") + text.append(f"Сервер: ", style="dim") + text.append(server, style="cyan") + text.append(f" · Auth: ", style="dim") + text.append(auth, style="cyan") + + console.print(Panel(text, border_style="cyan", padding=(0, 2))) + + +def run() -> None: + """Запускає цикл головного меню.""" + _print_header() + + while True: + try: + action = inquirer.select( + message="Оберіть дію:", + choices=[ + {"name": "Експорт з OLAP куба", "value": "export"}, + {"name": "Імпорт XLSX в аналітику", "value": "import"}, + Separator(), + {"name": "Вийти", "value": "quit"}, + ], + default="export", + ).execute() + except KeyboardInterrupt: + console.print("\n[dim]До побачення.[/dim]") + return + + if action == "export": + try: + from .olap_export import run_wizard as export_wizard + export_wizard() + except KeyboardInterrupt: + console.print("\n[yellow]Скасовано.[/yellow]") + elif action == "import": + try: + from .xlsx_import import run_wizard as import_wizard + import_wizard() + except KeyboardInterrupt: + console.print("\n[yellow]Скасовано.[/yellow]") + elif action == "quit": + console.print("[dim]До побачення.[/dim]") + return diff --git a/olap_tool/ui/olap_export.py b/olap_tool/ui/olap_export.py new file mode 100644 index 0000000..d8e6ed1 --- /dev/null +++ b/olap_tool/ui/olap_export.py @@ -0,0 +1,211 @@ +"""Wizard: Експорт з OLAP куба.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from InquirerPy import inquirer +from InquirerPy.base.control import Choice +from InquirerPy.separator import Separator +from prompt_toolkit.validation import ValidationError, Validator +from rich.console import Console +from rich.table import Table + +console = Console() + + +# ─── Validators ────────────────────────────────────────────────────────────── + +class WeeksValidator(Validator): + def validate(self, document): + text = document.text.strip() + if not text.isdigit() or not (1 <= int(text) <= 520): + raise ValidationError( + message="Введіть ціле число від 1 до 520", + cursor_position=len(text), + ) + + +class ManualPeriodValidator(Validator): + _PATTERN = re.compile(r"^\d{4}-\d{2}:\d{4}-\d{2}$") + + def validate(self, document): + text = document.text.strip() + if not self._PATTERN.match(text): + raise ValidationError( + message="Формат: YYYY-WW:YYYY-WW (наприклад 2025-01:2025-12)", + cursor_position=len(text), + ) + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def _list_profiles() -> list[Choice]: + """Повертає список профілів для InquirerPy fuzzy-select.""" + profiles_dir = Path(__file__).parent.parent.parent / "profiles" + choices: list[Choice] = [Choice(value="", name="(без профілю)")] + if profiles_dir.exists(): + for p in sorted(profiles_dir.glob("*.yaml")): + choices.append(Choice(value=p.stem, name=p.stem)) + return choices + + +def _show_summary(params: dict[str, str]) -> None: + table = Table(show_header=False, border_style="cyan", box=None, padding=(0, 1)) + table.add_column(style="dim cyan", no_wrap=True) + table.add_column(style="white") + for key, value in params.items(): + table.add_row(key, value) + console.print() + console.print(table) + console.print() + + +# ─── Wizard ────────────────────────────────────────────────────────────────── + +FORMAT_CHOICES = [ + Choice(value="xlsx", name="XLSX"), + Choice(value="csv", name="CSV"), + Choice(value="both", name="XLSX + CSV"), + Separator(), + Choice(value="ch", name="ClickHouse"), + Choice(value="duck", name="DuckDB"), + Choice(value="pg", name="PostgreSQL"), +] + +PERIOD_CHOICES = [ + Choice(value="last-weeks", name="Останні N тижнів"), + Choice(value="current-month", name="Поточний місяць"), + Choice(value="last-month", name="Попередній місяць"), + Choice(value="current-quarter", name="Поточний квартал"), + Choice(value="last-quarter", name="Попередній квартал"), + Choice(value="year-to-date", name="З початку року"), + Choice(value="manual", name="Ручний діапазон YYYY-WW:YYYY-WW"), +] + +COMPRESS_CHOICES = [ + Choice(value="none", name="Без стиснення"), + Choice(value="zip", name="ZIP архів"), +] + +_PERIOD_LABELS = { + "last-weeks": "last-weeks", + "current-month": "поточний місяць", + "last-month": "попередній місяць", + "current-quarter": "поточний квартал", + "last-quarter": "попередній квартал", + "year-to-date": "з початку року", + "manual": "ручний діапазон", +} + + +def run_wizard() -> None: + """Інтерактивний wizard OLAP Export.""" + console.rule("[cyan]Експорт з OLAP куба[/cyan]") + + # 1. Профіль + profile: str = inquirer.fuzzy( + message="Профіль:", + choices=_list_profiles(), + default="", + max_height="40%", + ).execute() + + # 2. Формат + fmt: str = inquirer.select( + message="Формат виводу:", + choices=FORMAT_CHOICES, + default="xlsx", + ).execute() + + # 3. Тип періоду + period_type: str = inquirer.select( + message="Тип періоду:", + choices=PERIOD_CHOICES, + default="last-weeks", + ).execute() + + # 4. Значення (тільки для last-weeks і manual) + period_value: str = "" + if period_type == "last-weeks": + period_value = inquirer.text( + message="Кількість тижнів:", + default="4", + validate=WeeksValidator(), + ).execute() + elif period_type == "manual": + period_value = inquirer.text( + message="Діапазон (YYYY-WW:YYYY-WW):", + validate=ManualPeriodValidator(), + ).execute() + + # 5. Стиснення + compress: str = inquirer.select( + message="Стиснення:", + choices=COMPRESS_CHOICES, + default="none", + ).execute() + + # 6. Підсумок + period_label = _PERIOD_LABELS.get(period_type, period_type) + if period_value: + period_label = f"{period_label} ({period_value})" + summary = { + "Профіль": profile or "(без профілю)", + "Формат": fmt, + "Період": period_label, + "Стиснення": compress, + } + _show_summary(summary) + + # 7. Підтвердження + confirmed: bool = inquirer.confirm( + message="Запустити?", + default=True, + ).execute() + + if not confirmed: + console.print("[yellow]Скасовано.[/yellow]") + return + + # 8. Будуємо argv і запускаємо + argv = ["olap.py"] + if profile: + argv += ["--profile", profile] + argv += ["--format", fmt] + + if period_type == "last-weeks": + argv += ["--last-weeks", period_value or "4"] + elif period_type == "current-month": + argv.append("--current-month") + elif period_type == "last-month": + argv.append("--last-month") + elif period_type == "current-quarter": + argv.append("--current-quarter") + elif period_type == "last-quarter": + argv.append("--last-quarter") + elif period_type == "year-to-date": + argv.append("--year-to-date") + elif period_type == "manual" and period_value: + argv += ["--period", period_value] + + if compress != "none": + argv += ["--compress", compress] + + console.print(f"[dim]▶ {' '.join(argv)}[/dim]\n") + + from olap_tool.core.runner import main as runner_main + old_argv = sys.argv + sys.argv = argv + try: + result = runner_main() + except SystemExit as e: + result = e.code if isinstance(e.code, int) else 0 + finally: + sys.argv = old_argv + + if (result or 0) == 0: + console.print("\n[bold green]✓ Завершено успішно[/bold green]") + else: + console.print(f"\n[bold red]✗ Завершено з помилкою (код {result})[/bold red]") diff --git a/olap_tool/ui/xlsx_import.py b/olap_tool/ui/xlsx_import.py new file mode 100644 index 0000000..8079c89 --- /dev/null +++ b/olap_tool/ui/xlsx_import.py @@ -0,0 +1,179 @@ +"""Wizard: Імпорт XLSX в аналітичне сховище.""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from InquirerPy import inquirer +from InquirerPy.base.control import Choice +from prompt_toolkit.validation import ValidationError, Validator +from rich.console import Console +from rich.table import Table + +console = Console() + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + + +# ─── Validators ────────────────────────────────────────────────────────────── + +class YearValidator(Validator): + def validate(self, document): + text = document.text.strip() + if text == "": + return # Опціонально + if not text.isdigit() or not (2000 <= int(text) <= 2099): + raise ValidationError( + message="Рік: 4-цифрове число 2000–2099, або порожньо", + cursor_position=len(text), + ) + + +class WeekValidator(Validator): + def validate(self, document): + text = document.text.strip() + if text == "": + return # Опціонально + if not text.isdigit() or not (1 <= int(text) <= 53): + raise ValidationError( + message="Тиждень: число 1–53, або порожньо", + cursor_position=len(text), + ) + + +class WorkersValidator(Validator): + def validate(self, document): + text = document.text.strip() + if not text.isdigit() or not (1 <= int(text) <= 32): + raise ValidationError( + message="Workers: ціле число 1–32", + cursor_position=len(text), + ) + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +TARGET_CHOICES = [ + Choice(value="ch", name="ClickHouse"), + Choice(value="duck", name="DuckDB"), + Choice(value="pg", name="PostgreSQL"), +] + + +def _show_summary(params: dict[str, str]) -> None: + table = Table(show_header=False, border_style="cyan", box=None, padding=(0, 1)) + table.add_column(style="dim cyan", no_wrap=True) + table.add_column(style="white") + for key, value in params.items(): + table.add_row(key, value) + console.print() + console.print(table) + console.print() + + +# ─── Wizard ────────────────────────────────────────────────────────────────── + +def run_wizard() -> None: + """Інтерактивний wizard XLSX Import.""" + console.rule("[cyan]Імпорт XLSX в аналітику[/cyan]") + + # 1. Ціль + target: str = inquirer.select( + message="Ціль:", + choices=TARGET_CHOICES, + default="ch", + ).execute() + + # 2. Директорія + directory: str = inquirer.text( + message="Директорія з XLSX:", + default="result/", + ).execute() + + # 3. Рік (опційно) + year: str = inquirer.text( + message="Рік (Enter — всі роки):", + default="", + validate=YearValidator(), + ).execute() + + # 4. Тиждень (опційно) + week: str = inquirer.text( + message="Тиждень (Enter — всі тижні):", + default="", + validate=WeekValidator(), + ).execute() + + # 5. Workers + workers: str = inquirer.text( + message="Workers (паралельні потоки):", + default="4", + validate=WorkersValidator(), + ).execute() + + # 6. Dry run + dry_run: bool = inquirer.confirm( + message="Dry run (без запису в БД)?", + default=False, + ).execute() + + # 7. Підсумок + summary = { + "Ціль": target, + "Директорія": directory, + "Рік": year or "(всі)", + "Тиждень": week or "(всі)", + "Workers": workers, + "Dry Run": "так" if dry_run else "ні", + } + _show_summary(summary) + + # 8. Підтвердження + confirmed: bool = inquirer.confirm( + message="Запустити?", + default=True, + ).execute() + + if not confirmed: + console.print("[yellow]Скасовано.[/yellow]") + return + + # 9. Будуємо argv і запускаємо через importlib + script_args = [ + "scripts/import_xlsx.py", + "--target", target, + "--dir", directory, + "--workers", workers, + ] + if year: + script_args += ["--year", year] + if week: + script_args += ["--week", week] + if dry_run: + script_args.append("--dry-run") + + console.print(f"[dim]▶ python {' '.join(script_args)}[/dim]\n") + + script_path = _PROJECT_ROOT / "scripts" / "import_xlsx.py" + spec = importlib.util.spec_from_file_location("import_xlsx", script_path) + if spec is None or spec.loader is None: + console.print(f"[red]✗ Не вдалося завантажити: {script_path}[/red]") + return + + old_argv = sys.argv + sys.argv = script_args + try: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + mod.main() + console.print("\n[bold green]✓ Імпорт завершено[/bold green]") + except SystemExit as e: + if e.code not in (0, None): + console.print(f"\n[bold red]✗ Завершено з кодом {e.code}[/bold red]") + else: + console.print("\n[bold green]✓ Імпорт завершено[/bold green]") + except Exception as exc: + console.print(f"\n[bold red]✗ Помилка: {exc}[/bold red]") + finally: + sys.argv = old_argv diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..9d3fbb8 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,5 @@ +{ + "venvPath": ".", + "venv": ".venv", + "reportPrivateImportUsage": "none" +} diff --git a/requirements.txt b/requirements.txt index aaf9608..b577a58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,4 +21,6 @@ requests>=2.28.0 # Для завантаження даних у DuckD psycopg2-binary>=2.9.0 # Для завантаження даних у PostgreSQL через COPY FROM STDIN openpyxl>=3.0.0 # Для читання Excel-файлів (import_xlsx_to_clickhouse.py) python-calamine>=0.1.7 # Rust-based Excel reader, 3-10x швидший за openpyxl -rich>=13.0.0 # Красивий термінальний UI: progress bar, панелі, таблиціpyarrow>=14.0.0 +rich>=13.0.0 # Красивий термінальний UI: progress bar, панелі, таблиці +InquirerPy>=0.3.4 # Консольне інтерактивне меню зі стрілковою навігацією +pyarrow>=14.0.0 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/import_xlsx.py b/scripts/import_xlsx.py new file mode 100644 index 0000000..6f5186a --- /dev/null +++ b/scripts/import_xlsx.py @@ -0,0 +1,585 @@ +#!/usr/bin/env python3 +""" +Паралельний імпорт XLSX файлів в аналітичне сховище. + +Використання: + python scripts/import_xlsx.py --target ch --dir result/ --workers 4 + python scripts/import_xlsx.py --target duck --year 2025 --week 10 + python scripts/import_xlsx.py --target pg --dry-run + +Підтримувані цілі (--target): + ch / clickhouse — ClickHouse (thread-local з'єднання на кожен воркер) + duck / duckdb — DuckDB REST API (один спільний sink, thread-safe) + pg / postgresql — PostgreSQL через COPY FROM STDIN (thread-local з'єднання на кожен воркер) +""" + +import sys +import os +import argparse +import re +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Optional + +# Додаємо корінь проєкту до sys.path, щоб можна було імпортувати olap_tool +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from dotenv import load_dotenv +load_dotenv(_PROJECT_ROOT / ".env") + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] +except Exception: + pass + +import pandas as pd +from rich.console import Console +from rich.panel import Panel +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) +from rich.table import Table + +from olap_tool.core.utils import init_utils + +init_utils(ascii_logs=False) + +console = Console() + +# --------------------------------------------------------------------------- +# Excel engine: calamine (Rust) з fallback на openpyxl +# --------------------------------------------------------------------------- +try: + import python_calamine # noqa: F401 + _EXCEL_ENGINE = "calamine" +except ImportError: + _EXCEL_ENGINE = "openpyxl" + +# --------------------------------------------------------------------------- +# Thread-local сховище для ClickHouse (одне з'єднання на потік) +# --------------------------------------------------------------------------- +_ch_local = threading.local() +_ch_all_sinks: list = [] +_ch_sinks_lock = threading.Lock() +_ch_setup_df: "Optional[pd.DataFrame]" = None # зберігається під час init + + +def _get_ch_sink(cfg_kwargs: dict): + """Повертає thread-local ClickHouseSink. + + setup() викликається для кожного нового sink — операція ідемпотентна + (CREATE TABLE IF NOT EXISTS), але необхідна для ініціалізації self._client. + """ + if not hasattr(_ch_local, "sink") or _ch_local.sink is None: + from olap_tool.sinks import ClickHouseSink + from olap_tool.core.config import ClickHouseConfig + sink = ClickHouseSink(ClickHouseConfig(**cfg_kwargs)) + if _ch_setup_df is not None: + sink.setup(_ch_setup_df) # ідемпотентно; ініціалізує self._client + _ch_local.sink = sink + with _ch_sinks_lock: + _ch_all_sinks.append(sink) + return _ch_local.sink + + +# --------------------------------------------------------------------------- +# Thread-local сховище для PostgreSQL (одне з'єднання на потік) +# PostgreSQLSink НЕ є thread-safe — psycopg2 з'єднання не можна шерити між потоками +# --------------------------------------------------------------------------- +_pg_local = threading.local() +_pg_all_sinks: list = [] +_pg_sinks_lock = threading.Lock() +_pg_setup_df: "Optional[pd.DataFrame]" = None # зберігається під час init + + +def _get_pg_sink(cfg_kwargs: dict): + """Повертає thread-local PostgreSQLSink. + + setup() викликається для кожного нового sink — операція ідемпотентна + (CREATE TABLE IF NOT EXISTS), але необхідна для встановлення з'єднання. + """ + if not hasattr(_pg_local, "sink") or _pg_local.sink is None: + from olap_tool.sinks import PostgreSQLSink + from olap_tool.core.config import PostgreSQLConfig + sink = PostgreSQLSink(PostgreSQLConfig(**cfg_kwargs)) + if _pg_setup_df is not None: + sink.setup(_pg_setup_df) # ідемпотентно; ініціалізує з'єднання + _pg_local.sink = sink + with _pg_sinks_lock: + _pg_all_sinks.append(sink) + return _pg_local.sink + + +# --------------------------------------------------------------------------- +# Файловий пошук +# --------------------------------------------------------------------------- + +def find_xlsx_files( + base_dir: Path, + year: Optional[int], + week: Optional[int], +) -> list[tuple[Path, int, int]]: + """Рекурсивно знаходить файли формату YYYY-WW.xlsx.""" + pattern = re.compile(r"^(\d{4})-(\d{2})\.xlsx$") + results = [] + for f in sorted(base_dir.rglob("*.xlsx")): + m = pattern.match(f.name) + if not m: + continue + y, w = int(m.group(1)), int(m.group(2)) + if year is not None and y != year: + continue + if week is not None and w != week: + continue + results.append((f, y, w)) + return results + + +def _read_excel(file_path: Path, sheet) -> pd.DataFrame: + """Читає Excel-файл через calamine з fallback на openpyxl.""" + try: + return pd.read_excel(str(file_path), sheet_name=sheet, engine=_EXCEL_ENGINE) + except Exception: + if _EXCEL_ENGINE != "openpyxl": + return pd.read_excel(str(file_path), sheet_name=sheet, engine="openpyxl") + raise + + +# --------------------------------------------------------------------------- +# Workers +# --------------------------------------------------------------------------- + +def _process_ch( + file_path: Path, + year: int, + week: int, + cfg_kwargs: dict, + sheet, +) -> tuple[int, bool, float]: + """ + Воркер для ClickHouse. + Кожен потік отримує власний sink через thread-local storage. + """ + t0 = time.monotonic() + try: + df = _read_excel(file_path, sheet) + except Exception: + return 0, False, time.monotonic() - t0 + + if df.empty: + return 0, True, time.monotonic() - t0 + + from olap_tool.sinks import sanitize_df + df = sanitize_df(df) + df["year_num"] = year + df["week_num"] = week + + sink = _get_ch_sink(cfg_kwargs) + try: + sink.delete_period(year, week) + rows = sink.insert(df, year=year, week=week) + return rows, rows >= 0, time.monotonic() - t0 + except Exception: + return 0, False, time.monotonic() - t0 + + +def _process_pg( + file_path: Path, + year: int, + week: int, + cfg_kwargs: dict, + sheet, +) -> tuple[int, bool, float]: + """ + Воркер для PostgreSQL. + Кожен потік отримує власний sink через thread-local storage, + оскільки PostgreSQLSink НЕ є thread-safe. + """ + t0 = time.monotonic() + try: + df = _read_excel(file_path, sheet) + except Exception: + return 0, False, time.monotonic() - t0 + + if df.empty: + return 0, True, time.monotonic() - t0 + + from olap_tool.sinks import sanitize_df + df = sanitize_df(df) + df["year_num"] = year + df["week_num"] = week + + sink = _get_pg_sink(cfg_kwargs) + try: + sink.delete_period(year, week) + rows = sink.insert(df, year=year, week=week) + return rows, rows >= 0, time.monotonic() - t0 + except Exception: + return 0, False, time.monotonic() - t0 + + +def _process_shared( + file_path: Path, + year: int, + week: int, + sink, + sheet, +) -> tuple[int, bool, float]: + """ + Воркер для DuckDB. + Використовує один спільний sink (внутрішня реалізація thread-safe). + """ + t0 = time.monotonic() + try: + df = _read_excel(file_path, sheet) + except Exception: + return 0, False, time.monotonic() - t0 + + if df.empty: + return 0, True, time.monotonic() - t0 + + from olap_tool.sinks import sanitize_df + df = sanitize_df(df) + df["year_num"] = year + df["week_num"] = week + + try: + sink.delete_period(year, week) + rows = sink.insert(df, year=year, week=week) + success = rows > 0 or df.empty + return rows, success, time.monotonic() - t0 + except Exception: + return 0, False, time.monotonic() - t0 + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> int: + parser = argparse.ArgumentParser( + description="Паралельний імпорт Excel файлів OLAP-експорту в аналітичне сховище", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Приклади:\n" + " python scripts/import_xlsx.py --target ch --dir result/\n" + " python scripts/import_xlsx.py --target duck --year 2025 --week 10\n" + " python scripts/import_xlsx.py --target pg --dry-run\n" + ), + ) + parser.add_argument( + "--target", "-t", + required=True, + choices=["ch", "clickhouse", "duck", "duckdb", "pg", "postgresql"], + help="Ціль імпорту: ch/clickhouse | duck/duckdb | pg/postgresql", + ) + parser.add_argument("--dir", default="result", help="Базова директорія з XLSX файлами") + parser.add_argument("--year", type=int, default=None, help="Фільтр за роком") + parser.add_argument("--week", type=int, default=None, help="Фільтр за тижнем") + parser.add_argument("--sheet", default="0", help="Аркуш Excel (назва або індекс)") + parser.add_argument("--workers", type=int, default=4, help="Паралельних воркерів") + parser.add_argument("--dry-run", action="store_true", help="Показати файли без завантаження") + args = parser.parse_args() + + # Нормалізуємо target + target = args.target.lower() + if target in ("ch", "clickhouse"): + target = "clickhouse" + elif target in ("duck", "duckdb"): + target = "duckdb" + elif target in ("pg", "postgresql"): + target = "postgresql" + + base_dir = Path(args.dir) + if not base_dir.exists(): + console.print(f"[red]❌ Директорія не знайдена: {base_dir}[/red]") + return 1 + + # Sheet: int або str + sheet: str | int = args.sheet + try: + sheet = int(sheet) + except (ValueError, TypeError): + pass + + # ── Завантаження конфігурації з env ──────────────────────────────────── + from olap_tool.core.config import ( + load_clickhouse_from_env, + load_duckdb_from_env, + load_postgres_from_env, + ) + + if target == "clickhouse": + cfg = load_clickhouse_from_env() + target_label = f"ClickHouse {cfg.host}:{cfg.port} → {cfg.database}.{cfg.table}" + target_title = "[bold cyan]ІМПОРТ EXCEL → CLICKHOUSE[/bold cyan]" + elif target == "duckdb": + cfg = load_duckdb_from_env() + target_label = f"DuckDB {cfg.url} → {cfg.table}" + target_title = "[bold cyan]ІМПОРТ EXCEL → DUCKDB[/bold cyan]" + else: # postgresql + cfg = load_postgres_from_env() + target_label = ( + f"PostgreSQL {cfg.host}:{cfg.port} → " + f"{cfg.database}/{cfg.schema}.{cfg.table}" + ) + target_title = "[bold cyan]ІМПОРТ EXCEL → POSTGRESQL[/bold cyan]" + + # ── Заголовок ────────────────────────────────────────────────────────── + info = Table.grid(padding=(0, 2)) + info.add_column(style="cyan") + info.add_column(style="white") + info.add_row("Директорія", str(base_dir.resolve())) + info.add_row("Ціль", target_label) + info.add_row("Excel engine", _EXCEL_ENGINE) + if args.year is not None: + info.add_row("Рік", str(args.year)) + if args.week is not None: + info.add_row("Тиждень", str(args.week)) + if not args.dry_run: + info.add_row("Воркери", str(args.workers)) + if args.dry_run: + info.add_row("Режим", "[yellow]DRY RUN[/yellow]") + + console.print() + console.print(Panel(info, title=target_title, border_style="cyan", expand=False)) + console.print() + + _ch_cfg_kwargs: dict = {} + _pg_cfg_kwargs: dict = {} + + # ── Пошук файлів ─────────────────────────────────────────────────────── + files = find_xlsx_files(base_dir, args.year, args.week) + if not files: + console.print("[yellow]⚠️ Файлів не знайдено за вказаними параметрами[/yellow]") + return 0 + + console.print(f" [cyan]Знайдено файлів:[/cyan] [white bold]{len(files)}[/white bold]\n") + + if args.dry_run: + for i, (fp, y, w) in enumerate(files, 1): + console.print( + f" [dim]{i:>4}.[/dim] [white]{fp}[/white] [yellow]({y}-{w:02d})[/yellow]" + ) + console.print(f"\n[yellow]DRY RUN завершено. Файлів: {len(files)}[/yellow]") + return 0 + + # ── Ініціалізація sink та CREATE TABLE з першого непорожнього файлу ────── + # Перший файл може бути порожнім → шукаємо перший з даними для setup() + with console.status(f"[cyan]Ініціалізація {target.upper()}...[/cyan]", spinner="dots"): + try: + df_init = pd.DataFrame() + init_file_idx = 0 + for _i, (_fp, _y, _w) in enumerate(files): + df_init = _read_excel(_fp, sheet) + if not df_init.empty: + init_file_idx = _i + break + + if target == "clickhouse": + from olap_tool.sinks import ClickHouseSink, sanitize_df + from olap_tool.core.config import ClickHouseConfig + from dataclasses import fields as dc_fields + + # Зберігаємо cfg як dict для передачі у thread-local фабрику + _ch_cfg_kwargs = { + f.name: getattr(cfg, f.name) for f in dc_fields(cfg) + } + # Ініціалізаційний sink (не thread-local — тільки для setup) + init_sink = ClickHouseSink(ClickHouseConfig(**_ch_cfg_kwargs)) + if not df_init.empty: + df_init_clean = sanitize_df(df_init.copy()) + df_init_clean["year_num"] = files[init_file_idx][1] + df_init_clean["week_num"] = files[init_file_idx][2] + init_sink.setup(df_init_clean) + # Зберігаємо df для ініціалізації thread-local sinks + global _ch_setup_df + _ch_setup_df = df_init_clean + init_sink.close() + sink = None # воркери використовують thread-local sinks + + elif target == "duckdb": + from olap_tool.sinks import DuckDBSink, sanitize_df + from olap_tool.core.config import DuckDBConfig + assert isinstance(cfg, DuckDBConfig) + sink = DuckDBSink(cfg) + if not df_init.empty: + df_init_clean = sanitize_df(df_init.copy()) + df_init_clean["year_num"] = files[init_file_idx][1] + df_init_clean["week_num"] = files[init_file_idx][2] + sink.setup(df_init_clean) + + else: # postgresql + from olap_tool.sinks import PostgreSQLSink, sanitize_df + from olap_tool.core.config import PostgreSQLConfig + from dataclasses import fields as dc_fields + + assert isinstance(cfg, PostgreSQLConfig) + # Зберігаємо cfg як dict для передачі у thread-local фабрику + _pg_cfg_kwargs = { + f.name: getattr(cfg, f.name) for f in dc_fields(cfg) + } + # Ініціалізаційний sink (не thread-local — тільки для setup) + init_sink = PostgreSQLSink(cfg) + if not df_init.empty: + df_init_clean = sanitize_df(df_init.copy()) + df_init_clean["year_num"] = files[init_file_idx][1] + df_init_clean["week_num"] = files[init_file_idx][2] + init_sink.setup(df_init_clean) + # Зберігаємо df для ініціалізації thread-local sinks + global _pg_setup_df + _pg_setup_df = df_init_clean + init_sink.close() + sink = None # воркери використовують thread-local sinks + + except Exception as e: + console.print(f"[red]❌ Помилка ініціалізації: {e}[/red]") + return 1 + + console.print(" [green]✅ Ініціалізовано[/green]\n") + + # ── Паралельне завантаження з rich progress bar ──────────────────────── + total = len(files) + total_rows = 0 + errors = 0 + start_time = time.monotonic() + + progress = Progress( + SpinnerColumn(), + BarColumn(bar_width=36), + MofNCompleteColumn(), + TaskProgressColumn(), + TextColumn("[dim]•[/dim]"), + TimeElapsedColumn(), + TextColumn("[dim]•[/dim] ETA"), + TimeRemainingColumn(), + console=console, + transient=False, + ) + task_id = progress.add_task("", total=total) + + with progress: + with ThreadPoolExecutor(max_workers=args.workers) as executor: + if target == "clickhouse": + futures = { + executor.submit( + _process_ch, fp, y, w, _ch_cfg_kwargs, sheet + ): (fp, y, w) + for fp, y, w in files + } + elif target == "postgresql": + futures = { + executor.submit( + _process_pg, fp, y, w, _pg_cfg_kwargs, sheet + ): (fp, y, w) + for fp, y, w in files + } + else: + futures = { + executor.submit( + _process_shared, fp, y, w, sink, sheet + ): (fp, y, w) + for fp, y, w in files + } + + for future in as_completed(futures): + fp, y, w = futures[future] + try: + rows, success, elapsed = future.result() + except Exception as e: + rows, success, elapsed = 0, False, 0.0 + progress.console.print( + f" [red]❌ {y}-{w:02d}[/red] [dim]{fp.name}[/dim] [red]{e}[/red]" + ) + + total_rows += rows + if not success: + errors += 1 + + icon = "[green]✅[/green]" if success else "[red]❌[/red]" + rows_str = ( + f"[white]{rows:>7,}[/white] рядків" + if rows > 0 + else "[dim] порожній[/dim]" + ) + progress.console.print( + f" {icon} [cyan]{y}-{w:02d}[/cyan] {rows_str} [dim]{elapsed:.1f}с[/dim]" + ) + + elapsed_total = time.monotonic() - start_time + rate = (total_rows / elapsed_total) if elapsed_total > 0 else 0 + progress.update( + task_id, + advance=1, + description=( + f"[white bold]{total_rows:,}[/white bold] рядків " + f"[dim]{rate:,.0f} рядків/с[/dim]" + ), + ) + + # ── Закриваємо з'єднання ─────────────────────────────────────────────── + if target == "clickhouse": + # Закриваємо всі thread-local sinks + for s in _ch_all_sinks: + try: + s.close() + except Exception: + pass + elif target == "postgresql": + # Закриваємо всі thread-local sinks + for s in _pg_all_sinks: + try: + s.close() + except Exception: + pass + else: + if sink is not None: + try: + sink.close() + except Exception: + pass + + # ── Підсумок ─────────────────────────────────────────────────────────── + elapsed_total = time.monotonic() - start_time + rate_files = total / elapsed_total if elapsed_total > 0 else 0 + rate_rows = total_rows / elapsed_total if elapsed_total > 0 else 0 + + summary = Table.grid(padding=(0, 2)) + summary.add_column(style="cyan") + summary.add_column(style="white bold") + summary.add_row("Ціль", target.upper()) + summary.add_row("Файлів оброблено", f"{total - errors}/{total}") + summary.add_row("Рядків завантажено", f"{total_rows:,}") + summary.add_row("Час", f"{elapsed_total:.1f} с") + summary.add_row("Швидкість", f"{rate_files:.1f} файл/с · {rate_rows:,.0f} рядків/с") + if errors: + summary.add_row("[red]Помилок[/red]", f"[red]{errors}[/red]") + + border = "green" if not errors else "yellow" + title = ( + "[bold green]✅ Імпорт завершено[/bold green]" + if not errors + else "[bold yellow]⚠️ Завершено з помилками[/bold yellow]" + ) + + console.print() + console.print(Panel(summary, title=title, border_style=border, expand=False)) + console.print() + + return 0 if not errors else 1 + + +if __name__ == "__main__": + raise SystemExit(main())