From 97e027f5b3e658c172f34a2484a9bdcf8bd9acce Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 11:07:01 +0200 Subject: [PATCH 01/28] =?UTF-8?q?docs:=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD-=D1=81=D0=BF=D0=B5=D1=86=D0=B8=D1=84=D1=96=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D1=96=D1=8F=20TUI=20+=20=D1=80=D0=B5=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D1=96?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-10-tui-restructure-design.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-10-tui-restructure-design.md 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. Видалення старих файлів From dbb44153297b546e98b6327d9feccb0eb2cb684f Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 11:15:41 +0200 Subject: [PATCH 02/28] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D1=96=D0=B7=D0=B0=D1=86=D1=96=D1=97=20TUI=20?= =?UTF-8?q?+=20=D1=80=D0=B5=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D1=96=D1=8F=20(4=20chunks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-10-tui-restructure.md | 1696 +++++++++++++++++ 1 file changed, 1696 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-10-tui-restructure.md 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/` | From 62f79664ef7ed5377fb8e12c2adffcb92f0defe9 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 11:23:16 +0200 Subject: [PATCH 03/28] =?UTF-8?q?refactor:=20=D1=81=D1=82=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D0=B8=20sinks/base.py=20=D0=B7=20ABC=20?= =?UTF-8?q?=D1=82=D0=B0=20sanitize=5Fdf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- olap_tool/sinks/__init__.py | 2 ++ olap_tool/sinks/base.py | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 olap_tool/sinks/__init__.py create mode 100644 olap_tool/sinks/base.py diff --git a/olap_tool/sinks/__init__.py b/olap_tool/sinks/__init__.py new file mode 100644 index 0000000..94e33fb --- /dev/null +++ b/olap_tool/sinks/__init__.py @@ -0,0 +1,2 @@ +# Subpackage sinks — аналітичні сховища (ClickHouse, DuckDB, PostgreSQL). +# Публічний API буде визначено після перенесення всіх sink-класів. 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: + """Закрити з'єднання/ресурси.""" From 6768ebfb679f1d5161bf4d40e6ce3249c99d6d47 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 11:26:55 +0200 Subject: [PATCH 04/28] =?UTF-8?q?refactor:=20=D1=81=D1=82=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D0=B8=20sinks/clickhouse.py=20(=D0=BF=D0=BE?= =?UTF-8?q?=D0=B3=D0=BB=D0=B8=D0=BD=D0=B0=D1=94=20clickhouse=5Fexport.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Об'єднує ClickHouseSink з sinks.py та всю допоміжну логіку з clickhouse_export.py в один модуль olap_tool/sinks/clickhouse.py. Імпортує AnalyticsSink та sanitize_df з .base, ClickHouseConfig з ..config. Co-Authored-By: Claude Sonnet 4.6 --- olap_tool/sinks/clickhouse.py | 352 ++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 olap_tool/sinks/clickhouse.py diff --git a/olap_tool/sinks/clickhouse.py b/olap_tool/sinks/clickhouse.py new file mode 100644 index 0000000..aa96f7b --- /dev/null +++ b/olap_tool/sinks/clickhouse.py @@ -0,0 +1,352 @@ +""" +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 .base import AnalyticsSink, sanitize_df, _safe_column_name # noqa: F401 + +if TYPE_CHECKING: + from ..config import ClickHouseConfig + + +# --------------------------------------------------------------------------- +# Type mapping: pandas dtype -> ClickHouse type +# --------------------------------------------------------------------------- + +def _pandas_dtype_to_ch(dtype) -> str: + """Конвертує pandas dtype у ClickHouse-тип.""" + dtype_str = str(dtype) + if dtype_str.startswith("int"): + return "Int64" + if dtype_str.startswith("uint"): + return "UInt64" + if dtype_str.startswith("float"): + return "Float64" + if dtype_str in ("bool", "boolean"): + return "UInt8" + if dtype_str.startswith("datetime"): + return "DateTime" + if dtype_str.startswith("date"): + return "Date" + # object, string, category → String + return "Nullable(String)" + + +# --------------------------------------------------------------------------- +# Database & table management +# --------------------------------------------------------------------------- + +def ensure_database(client, database: str) -> None: + """Створює базу даних якщо не існує.""" + client.command(f"CREATE DATABASE IF NOT EXISTS `{database}`") + + +def _build_create_table_sql(database: str, table: str, df: pd.DataFrame) -> str: + """Генерує DDL для створення таблиці зі схемою з DataFrame.""" + columns_ddl = [f" `{col}` {_pandas_dtype_to_ch(df[col].dtype)}" for col in df.columns] + columns_str = ",\n".join(columns_ddl) + + # Використовуємо year_num/week_num як ключ сортування якщо вони є в DataFrame. + order_cols = [c for c in ("year_num", "week_num") if c in df.columns] + order_by = ", ".join(f"`{c}`" for c in order_cols) if order_cols else "tuple()" + + return ( + f"CREATE TABLE IF NOT EXISTS `{database}`.`{table}`\n" + f"(\n{columns_str}\n" + f") ENGINE = MergeTree()\n" + f"ORDER BY ({order_by})" + ) + + +def ensure_table(client, database: str, table: str, df: pd.DataFrame) -> None: + """Створює таблицю якщо не існує, зі схемою з DataFrame.""" + client.command(_build_create_table_sql(database, table, df)) + + +def get_table_schema(client, database: str, table: str) -> dict[str, str]: + """Повертає {column_name: ch_type} для існуючої таблиці.""" + result = client.query( + "SELECT name, type FROM system.columns " + "WHERE database = {db:String} AND table = {tbl:String}", + parameters={"db": database, "tbl": table}, + ) + return {row[0]: row[1] for row in result.result_rows} + + +# --------------------------------------------------------------------------- +# Schema alignment: приводимо DataFrame під схему таблиці +# --------------------------------------------------------------------------- + +def _coerce_col_to_ch_type(series: pd.Series, ch_type: str) -> pd.Series: + """Конвертує pandas Series у тип, сумісний із ClickHouse-стовпцем.""" + if "Int" in ch_type or "UInt" in ch_type: + # Nullable Int64 — без проміжного float64, точність не втрачається + return pd.to_numeric(series, errors="coerce").astype(pd.Int64Dtype()) + if "Float" in ch_type: + return pd.to_numeric(series, errors="coerce").astype("float64") + if "String" in ch_type: + # Векторизована конвертація: astype(str) → виправляємо NaN-позиції → object + null_mask = series.isna() + result = series.astype(str).astype(object) + result[null_mask] = None + return result + if "DateTime" in ch_type or "Date" in ch_type: + return pd.to_datetime(series, errors="coerce") + return series + + +def _align_df_to_table( + client, database: str, table: str, df: pd.DataFrame, + schema: Optional[dict] = None, +) -> pd.DataFrame: + """ + Вирівнює DataFrame під схему ClickHouse-таблиці: + - Пропускає колонки яких немає в таблиці + - Конвертує типи під реальну CH-схему + - Додає нові колонки до таблиці якщо їх ще немає + + schema: якщо передано — не робить зайвий запит до system.columns. + """ + from ..utils import print_warning + + if schema is None: + schema = get_table_schema(client, database, table) + + # Нові колонки в df яких ще немає в таблиці — додаємо через ALTER TABLE + for col in (col for col in df.columns if col not in schema): + ch_type = _pandas_dtype_to_ch(df[col].dtype) + try: + client.command( + f"ALTER TABLE `{database}`.`{table}` " + f"ADD COLUMN IF NOT EXISTS `{col}` {ch_type}" + ) + schema[col] = ch_type + except Exception as e: + print_warning(f"Не вдалося додати колонку `{col}`: {e} — пропускаємо") + + # Залишаємо тільки колонки що є в таблиці, конвертуємо типи + aligned_cols = [col for col in df.columns if col in schema] + df_aligned = df[aligned_cols].copy() + + for col in aligned_cols: + try: + df_aligned[col] = _coerce_col_to_ch_type(df_aligned[col], schema[col]) + except Exception: + pass # якщо конвертація не вдалася — залишаємо як є + + return df_aligned + + +# --------------------------------------------------------------------------- +# Upsert: delete existing week then insert +# --------------------------------------------------------------------------- + +def _delete_period( + client, database: str, table: str, year: int, week: int, + schema: Optional[dict] = None, +) -> None: + """ + Видаляє рядки за (year, week) перед вставкою для ідемпотентності. + Використовує lightweight DELETE (ClickHouse 22.8+) — не мутація, + виконується швидко і не блокує паралельні потоки. + """ + if schema is None: + schema = get_table_schema(client, database, table) + + conditions = [] + if "year_num" in schema: + conditions.append(f"year_num = {year}") + if "week_num" in schema: + conditions.append(f"week_num = {week}") + + if conditions: + where = " AND ".join(conditions) + client.command(f"DELETE FROM `{database}`.`{table}` WHERE {where}") + + +# --------------------------------------------------------------------------- +# Client factory +# --------------------------------------------------------------------------- + +def create_client(config: "ClickHouseConfig"): + """Створює та повертає clickhouse_connect клієнт.""" + try: + import clickhouse_connect + except ImportError: + raise ImportError( + "clickhouse-connect не встановлено. " + "Виконайте: pip install clickhouse-connect" + ) + return clickhouse_connect.get_client( + host=config.host, + port=config.port, + username=config.username, + password=config.password, + secure=config.secure, + connect_timeout=30, + send_receive_timeout=600, + compress="lz4", + ) + + +# --------------------------------------------------------------------------- +# Main export function +# --------------------------------------------------------------------------- + +def export_to_clickhouse( + df: pd.DataFrame, + config: "ClickHouseConfig", + year: int, + week: int, + client=None, + schema: Optional[dict] = None, + silent: bool = False, +) -> int: + """ + Завантажує DataFrame у ClickHouse. + + Args: + client: Якщо передано — DDL пропускається, з'єднання не закривається. + Використовується для batch-режиму (thread-local клієнти). + schema: Якщо передано — пропускає запит до system.columns. + silent: Якщо True — не друкує progress/success повідомлення. + Помилки та попередження виводяться завжди. + + Returns: + Кількість завантажених рядків. + """ + from ..utils import print_success, print_warning, print_error, print_progress + + def _log(fn, msg): + if not silent: + fn(msg) + + if df is None or len(df) == 0: + _log(print_warning, "DataFrame порожній — пропускаємо завантаження у ClickHouse") + return 0 + + own_client = client is None + if own_client: + _log(print_progress, f"Підключення до ClickHouse ({config.host}:{config.port})...") + try: + client = create_client(config) + except Exception as e: + print_error(f"Не вдалося підключитися до ClickHouse: {e}") + return 0 + + df_clean = sanitize_df(df) + + try: + if own_client: + _log(print_progress, f"Перевірка бази даних `{config.database}`...") + ensure_database(client, config.database) + _log(print_progress, f"Перевірка таблиці `{config.database}`.`{config.table}`...") + ensure_table(client, config.database, config.table, df_clean) + + if schema is None: + schema = get_table_schema(client, config.database, config.table) + + _log(print_progress, f"Очищення даних за {year}-{week:02d}...") + _delete_period(client, config.database, config.table, year, week, schema=schema) + + df_clean = _align_df_to_table( + client, config.database, config.table, df_clean, schema=schema + ) + + row_count = len(df_clean) + _log(print_progress, f"Завантаження {row_count} рядків у ClickHouse...") + client.insert_df( + table=config.table, + df=df_clean, + database=config.database, + ) + + _log( + print_success, + f"Дані завантажено у ClickHouse: " + f"`{config.database}`.`{config.table}` " + f"({row_count} рядків, тиждень {year}-{week:02d})", + ) + return row_count + + except Exception as e: + print_error(f"Помилка при завантаженні у ClickHouse: {e}") + return 0 + finally: + if own_client and client is not None: + try: + 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 ..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 From 4499c27e301ce4a1a7106ecc5e0bb0bbde95492e Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 11:29:34 +0200 Subject: [PATCH 05/28] =?UTF-8?q?refactor:=20=D1=81=D1=82=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D0=B8=20sinks/duckdb.py,=20sinks/postgresql.?= =?UTF-8?q?py,=20=D0=BE=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D0=B8=20=5F=5Finit?= =?UTF-8?q?=5F=5F.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- olap_tool/sinks/__init__.py | 9 +- olap_tool/sinks/duckdb.py | 311 ++++++++++++++++++++++++++++++++++ olap_tool/sinks/postgresql.py | 205 ++++++++++++++++++++++ 3 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 olap_tool/sinks/duckdb.py create mode 100644 olap_tool/sinks/postgresql.py diff --git a/olap_tool/sinks/__init__.py b/olap_tool/sinks/__init__.py index 94e33fb..1b7afe0 100644 --- a/olap_tool/sinks/__init__.py +++ b/olap_tool/sinks/__init__.py @@ -1,2 +1,7 @@ -# Subpackage sinks — аналітичні сховища (ClickHouse, DuckDB, PostgreSQL). -# Публічний API буде визначено після перенесення всіх sink-класів. +"""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/duckdb.py b/olap_tool/sinks/duckdb.py new file mode 100644 index 0000000..d93b674 --- /dev/null +++ b/olap_tool/sinks/duckdb.py @@ -0,0 +1,311 @@ +""" +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 ..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 + + +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("'", "''") + "'" + + +# --------------------------------------------------------------------------- +# 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 ..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 diff --git a/olap_tool/sinks/postgresql.py b/olap_tool/sinks/postgresql.py new file mode 100644 index 0000000..7311c0a --- /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 ..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 ..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 From 3e01239ed8ab1ef0cb1d5204b0f8489822c8701b Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 11:32:42 +0200 Subject: [PATCH 06/28] =?UTF-8?q?refactor:=20=D0=B2=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B8=20sinks.py=20=D1=82=D0=B0=20clickhouse?= =?UTF-8?q?=5Fexport.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Старі плоскі файли замінені пакетом sinks/. Імпорт у import_xlsx_to_clickhouse.py оновлено на olap_tool.sinks.clickhouse. Co-Authored-By: Claude Sonnet 4.6 --- import_xlsx_to_clickhouse.py | 2 +- olap_tool/clickhouse_export.py | 289 ---------------- olap_tool/sinks.py | 601 --------------------------------- 3 files changed, 1 insertion(+), 891 deletions(-) delete mode 100644 olap_tool/clickhouse_export.py delete mode 100644 olap_tool/sinks.py diff --git a/import_xlsx_to_clickhouse.py b/import_xlsx_to_clickhouse.py index c73d16d..9c7c44a 100644 --- a/import_xlsx_to_clickhouse.py +++ b/import_xlsx_to_clickhouse.py @@ -46,7 +46,7 @@ from rich import box from olap_tool.config import load_clickhouse_from_env -from olap_tool.clickhouse_export import ( +from olap_tool.sinks.clickhouse import ( export_to_clickhouse, create_client, ensure_database, diff --git a/olap_tool/clickhouse_export.py b/olap_tool/clickhouse_export.py deleted file mode 100644 index 1c799f8..0000000 --- a/olap_tool/clickhouse_export.py +++ /dev/null @@ -1,289 +0,0 @@ -""" -ClickHouse Export Module - -Завантажує DataFrame у ClickHouse: - - Автоматично створює базу даних якщо не існує - - Автоматично створює таблицю зі схемою з DataFrame якщо не існує - - Ідемпотентна вставка: видаляє рядки за (year_num, week_num) перед вставкою - - Schema evolution: пропускає колонки яких немає в таблиці, - конвертує типи під реальну схему таблиці -""" - -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 - -if TYPE_CHECKING: - from .config import ClickHouseConfig - - -# --------------------------------------------------------------------------- -# Type mapping: pandas dtype -> ClickHouse type -# --------------------------------------------------------------------------- - -def _pandas_dtype_to_ch(dtype) -> str: - """Конвертує pandas dtype у ClickHouse-тип.""" - dtype_str = str(dtype) - if dtype_str.startswith("int"): - return "Int64" - if dtype_str.startswith("uint"): - return "UInt64" - if dtype_str.startswith("float"): - return "Float64" - if dtype_str in ("bool", "boolean"): - return "UInt8" - if dtype_str.startswith("datetime"): - return "DateTime" - if dtype_str.startswith("date"): - return "Date" - # object, string, category → String - return "Nullable(String)" - - -# --------------------------------------------------------------------------- -# Database & table management -# --------------------------------------------------------------------------- - -def ensure_database(client, database: str) -> None: - """Створює базу даних якщо не існує.""" - client.command(f"CREATE DATABASE IF NOT EXISTS `{database}`") - - -def _build_create_table_sql(database: str, table: str, df: pd.DataFrame) -> str: - """Генерує DDL для створення таблиці зі схемою з DataFrame.""" - columns_ddl = [f" `{col}` {_pandas_dtype_to_ch(df[col].dtype)}" for col in df.columns] - columns_str = ",\n".join(columns_ddl) - - # Використовуємо year_num/week_num як ключ сортування якщо вони є в DataFrame. - order_cols = [c for c in ("year_num", "week_num") if c in df.columns] - order_by = ", ".join(f"`{c}`" for c in order_cols) if order_cols else "tuple()" - - return ( - f"CREATE TABLE IF NOT EXISTS `{database}`.`{table}`\n" - f"(\n{columns_str}\n" - f") ENGINE = MergeTree()\n" - f"ORDER BY ({order_by})" - ) - - -def ensure_table(client, database: str, table: str, df: pd.DataFrame) -> None: - """Створює таблицю якщо не існує, зі схемою з DataFrame.""" - client.command(_build_create_table_sql(database, table, df)) - - -def get_table_schema(client, database: str, table: str) -> dict[str, str]: - """Повертає {column_name: ch_type} для існуючої таблиці.""" - result = client.query( - "SELECT name, type FROM system.columns " - "WHERE database = {db:String} AND table = {tbl:String}", - parameters={"db": database, "tbl": table}, - ) - return {row[0]: row[1] for row in result.result_rows} - - -# --------------------------------------------------------------------------- -# Schema alignment: приводимо DataFrame під схему таблиці -# --------------------------------------------------------------------------- - -def _coerce_col_to_ch_type(series: pd.Series, ch_type: str) -> pd.Series: - """Конвертує pandas Series у тип, сумісний із ClickHouse-стовпцем.""" - if "Int" in ch_type or "UInt" in ch_type: - # Nullable Int64 — без проміжного float64, точність не втрачається - return pd.to_numeric(series, errors="coerce").astype(pd.Int64Dtype()) - if "Float" in ch_type: - return pd.to_numeric(series, errors="coerce").astype("float64") - if "String" in ch_type: - # Векторизована конвертація: astype(str) → виправляємо NaN-позиції → object - null_mask = series.isna() - result = series.astype(str).astype(object) - result[null_mask] = None - return result - if "DateTime" in ch_type or "Date" in ch_type: - return pd.to_datetime(series, errors="coerce") - return series - - -def _align_df_to_table( - client, database: str, table: str, df: pd.DataFrame, - schema: Optional[dict] = None, -) -> pd.DataFrame: - """ - Вирівнює DataFrame під схему ClickHouse-таблиці: - - Пропускає колонки яких немає в таблиці - - Конвертує типи під реальну CH-схему - - Додає нові колонки до таблиці якщо їх ще немає - - schema: якщо передано — не робить зайвий запит до system.columns. - """ - if schema is None: - schema = get_table_schema(client, database, table) - - # Нові колонки в df яких ще немає в таблиці — додаємо через ALTER TABLE - for col in (col for col in df.columns if col not in schema): - ch_type = _pandas_dtype_to_ch(df[col].dtype) - try: - client.command( - f"ALTER TABLE `{database}`.`{table}` " - f"ADD COLUMN IF NOT EXISTS `{col}` {ch_type}" - ) - schema[col] = ch_type - except Exception as e: - print_warning(f"Не вдалося додати колонку `{col}`: {e} — пропускаємо") - - # Залишаємо тільки колонки що є в таблиці, конвертуємо типи - aligned_cols = [col for col in df.columns if col in schema] - df_aligned = df[aligned_cols].copy() - - for col in aligned_cols: - try: - df_aligned[col] = _coerce_col_to_ch_type(df_aligned[col], schema[col]) - except Exception: - pass # якщо конвертація не вдалася — залишаємо як є - - return df_aligned - - -# --------------------------------------------------------------------------- -# Upsert: delete existing week then insert -# --------------------------------------------------------------------------- - -def _delete_period( - client, database: str, table: str, year: int, week: int, - schema: Optional[dict] = None, -) -> None: - """ - Видаляє рядки за (year, week) перед вставкою для ідемпотентності. - Використовує lightweight DELETE (ClickHouse 22.8+) — не мутація, - виконується швидко і не блокує паралельні потоки. - """ - if schema is None: - schema = get_table_schema(client, database, table) - - conditions = [] - if "year_num" in schema: - conditions.append(f"year_num = {year}") - if "week_num" in schema: - conditions.append(f"week_num = {week}") - - if conditions: - where = " AND ".join(conditions) - client.command(f"DELETE FROM `{database}`.`{table}` WHERE {where}") - - -# --------------------------------------------------------------------------- -# Client factory -# --------------------------------------------------------------------------- - -def create_client(config: "ClickHouseConfig"): - """Створює та повертає clickhouse_connect клієнт.""" - try: - import clickhouse_connect - except ImportError: - raise ImportError( - "clickhouse-connect не встановлено. " - "Виконайте: pip install clickhouse-connect" - ) - return clickhouse_connect.get_client( - host=config.host, - port=config.port, - username=config.username, - password=config.password, - secure=config.secure, - connect_timeout=30, - send_receive_timeout=600, - compress="lz4", - ) - - -# --------------------------------------------------------------------------- -# Main export function -# --------------------------------------------------------------------------- - -def export_to_clickhouse( - df: pd.DataFrame, - config: "ClickHouseConfig", - year: int, - week: int, - client=None, - schema: Optional[dict] = None, - silent: bool = False, -) -> int: - """ - Завантажує DataFrame у ClickHouse. - - Args: - client: Якщо передано — DDL пропускається, з'єднання не закривається. - Використовується для batch-режиму (thread-local клієнти). - schema: Якщо передано — пропускає запит до system.columns. - silent: Якщо True — не друкує progress/success повідомлення. - Помилки та попередження виводяться завжди. - - Returns: - Кількість завантажених рядків. - """ - def _log(fn, msg): - if not silent: - fn(msg) - - if df is None or len(df) == 0: - _log(print_warning, "DataFrame порожній — пропускаємо завантаження у ClickHouse") - return 0 - - own_client = client is None - if own_client: - _log(print_progress, f"Підключення до ClickHouse ({config.host}:{config.port})...") - try: - client = create_client(config) - except Exception as e: - print_error(f"Не вдалося підключитися до ClickHouse: {e}") - return 0 - - df_clean = sanitize_df(df) - - try: - if own_client: - _log(print_progress, f"Перевірка бази даних `{config.database}`...") - ensure_database(client, config.database) - _log(print_progress, f"Перевірка таблиці `{config.database}`.`{config.table}`...") - ensure_table(client, config.database, config.table, df_clean) - - if schema is None: - schema = get_table_schema(client, config.database, config.table) - - _log(print_progress, f"Очищення даних за {year}-{week:02d}...") - _delete_period(client, config.database, config.table, year, week, schema=schema) - - df_clean = _align_df_to_table( - client, config.database, config.table, df_clean, schema=schema - ) - - row_count = len(df_clean) - _log(print_progress, f"Завантаження {row_count} рядків у ClickHouse...") - client.insert_df( - table=config.table, - df=df_clean, - database=config.database, - ) - - _log( - print_success, - f"Дані завантажено у ClickHouse: " - f"`{config.database}`.`{config.table}` " - f"({row_count} рядків, тиждень {year}-{week:02d})", - ) - return row_count - - except Exception as e: - print_error(f"Помилка при завантаженні у ClickHouse: {e}") - return 0 - finally: - if own_client and client is not None: - try: - client.close() - except Exception: - pass 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 From c4784b453b5a16ca09b1950b6710da2e468916f4 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 11:46:04 +0200 Subject: [PATCH 07/28] =?UTF-8?q?refactor:=20=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D1=96=D1=8F=20olap=5Ftool/=20=E2=86=92=20core/,=20connection/,?= =?UTF-8?q?=20data/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переміщено плоскі модулі в тематичні субпакети: - core/: config, cli, runner, periods, profiles, scheduler, compression, progress, utils - connection/: connection, auth, security, prompt - data/: queries, exporter Оновлено крос-пакетні імпорти та sinks/*.py (..config → ..core.config, ..utils → ..core.utils). Оновлено olap_tool/__init__.py і кореневий olap.py. Co-Authored-By: Claude Sonnet 4.6 --- olap.py | 14 ++++++++------ olap_tool/__init__.py | 6 +++++- olap_tool/connection/__init__.py | 0 olap_tool/{ => connection}/auth.py | 2 +- olap_tool/{ => connection}/connection.py | 4 ++-- olap_tool/{ => connection}/prompt.py | 2 +- olap_tool/{ => connection}/security.py | 2 +- olap_tool/core/__init__.py | 0 olap_tool/{ => core}/cli.py | 0 olap_tool/{ => core}/compression.py | 0 olap_tool/{ => core}/config.py | 0 olap_tool/{ => core}/periods.py | 0 olap_tool/{ => core}/profiles.py | 0 olap_tool/{ => core}/progress.py | 0 olap_tool/{ => core}/runner.py | 8 ++++---- olap_tool/{ => core}/scheduler.py | 2 -- olap_tool/{ => core}/utils.py | 0 olap_tool/data/__init__.py | 0 olap_tool/{ => data}/exporter.py | 6 +++--- olap_tool/{ => data}/queries.py | 8 ++++---- olap_tool/sinks/clickhouse.py | 8 ++++---- olap_tool/sinks/duckdb.py | 4 ++-- olap_tool/sinks/postgresql.py | 4 ++-- 23 files changed, 37 insertions(+), 33 deletions(-) create mode 100644 olap_tool/connection/__init__.py rename olap_tool/{ => connection}/auth.py (99%) rename olap_tool/{ => connection}/connection.py (99%) rename olap_tool/{ => connection}/prompt.py (95%) rename olap_tool/{ => connection}/security.py (98%) create mode 100644 olap_tool/core/__init__.py rename olap_tool/{ => core}/cli.py (100%) rename olap_tool/{ => core}/compression.py (100%) rename olap_tool/{ => core}/config.py (100%) rename olap_tool/{ => core}/periods.py (100%) rename olap_tool/{ => core}/profiles.py (100%) rename olap_tool/{ => core}/progress.py (100%) rename olap_tool/{ => core}/runner.py (98%) rename olap_tool/{ => core}/scheduler.py (99%) rename olap_tool/{ => core}/utils.py (100%) create mode 100644 olap_tool/data/__init__.py rename olap_tool/{ => data}/exporter.py (97%) rename olap_tool/{ => data}/queries.py (98%) diff --git a/olap.py b/olap.py index 62c7d1b..5dff2e6 100644 --- a/olap.py +++ b/olap.py @@ -1,16 +1,18 @@ import sys +import os 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 +if hasattr(sys.stdout, "reconfigure"): + 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 +from olap_tool.core.runner import main if __name__ == "__main__": raise SystemExit(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 99% rename from olap_tool/auth.py rename to olap_tool/connection/auth.py index df9b151..00ed8f9 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 diff --git a/olap_tool/connection.py b/olap_tool/connection/connection.py similarity index 99% rename from olap_tool/connection.py rename to olap_tool/connection/connection.py index 9516145..8d0d790 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,7 +19,7 @@ ) if TYPE_CHECKING: - from .config import SecretsConfig + from ..core.config import SecretsConfig # Константи для методів автентифікації AUTH_SSPI = "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 98% rename from olap_tool/security.py rename to olap_tool/connection/security.py index 178bd3f..f93993a 100644 --- a/olap_tool/security.py +++ b/olap_tool/connection/security.py @@ -7,7 +7,7 @@ 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: 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 100% rename from olap_tool/progress.py rename to olap_tool/core/progress.py diff --git a/olap_tool/runner.py b/olap_tool/core/runner.py similarity index 98% rename from olap_tool/runner.py rename to olap_tool/core/runner.py index ba135cf..9ebe0b1 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 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..49ad040 100644 --- a/olap_tool/scheduler.py +++ b/olap_tool/core/scheduler.py @@ -316,5 +316,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/exporter.py b/olap_tool/data/exporter.py similarity index 97% rename from olap_tool/exporter.py rename to olap_tool/data/exporter.py index bd61f44..c61fd86 100644 --- a/olap_tool/exporter.py +++ b/olap_tool/data/exporter.py @@ -7,11 +7,11 @@ import pandas as pd import xlsxwriter # type: ignore -from .utils import print_progress, convert_dotnet_to_python -from . import progress +from ..core.utils import print_progress, convert_dotnet_to_python +from ..core import progress if TYPE_CHECKING: - from .config import ExcelHeaderConfig, XlsxConfig + from ..core.config import ExcelHeaderConfig, XlsxConfig def export_csv_stream( diff --git a/olap_tool/queries.py b/olap_tool/data/queries.py similarity index 98% rename from olap_tool/queries.py rename to olap_tool/data/queries.py index 3203207..6be28e6 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, @@ -18,10 +18,10 @@ ensure_dir, ) from .exporter import export_csv_stream, export_xlsx_dataframe, export_xlsx_stream -from . import progress +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): @@ -342,7 +342,7 @@ def run_dax_query( # Analytics sinks (ClickHouse, DuckDB, тощо) if sinks: - from .sinks import sanitize_df as _sanitize + 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 diff --git a/olap_tool/sinks/clickhouse.py b/olap_tool/sinks/clickhouse.py index aa96f7b..99739de 100644 --- a/olap_tool/sinks/clickhouse.py +++ b/olap_tool/sinks/clickhouse.py @@ -22,7 +22,7 @@ from .base import AnalyticsSink, sanitize_df, _safe_column_name # noqa: F401 if TYPE_CHECKING: - from ..config import ClickHouseConfig + from ..core.config import ClickHouseConfig # --------------------------------------------------------------------------- @@ -123,7 +123,7 @@ def _align_df_to_table( schema: якщо передано — не робить зайвий запит до system.columns. """ - from ..utils import print_warning + from ..core.utils import print_warning if schema is None: schema = get_table_schema(client, database, table) @@ -231,7 +231,7 @@ def export_to_clickhouse( Returns: Кількість завантажених рядків. """ - from ..utils import print_success, print_warning, print_error, print_progress + from ..core.utils import print_success, print_warning, print_error, print_progress def _log(fn, msg): if not silent: @@ -313,7 +313,7 @@ def __init__(self, config: "ClickHouseConfig", client=None): self._schema: dict | None = None def setup(self, df: pd.DataFrame) -> None: - from ..utils import print_progress + from ..core.utils import print_progress if self._own_client: print_progress( f"Підключення до ClickHouse ({self._config.host}:{self._config.port})..." diff --git a/olap_tool/sinks/duckdb.py b/olap_tool/sinks/duckdb.py index d93b674..40e19e5 100644 --- a/olap_tool/sinks/duckdb.py +++ b/olap_tool/sinks/duckdb.py @@ -18,7 +18,7 @@ from .base import AnalyticsSink, sanitize_df if TYPE_CHECKING: - from ..config import DuckDBConfig + from ..core.config import DuckDBConfig # --------------------------------------------------------------------------- @@ -190,7 +190,7 @@ def _query(self, sql: str) -> dict: return resp.json() def setup(self, df: pd.DataFrame) -> None: - from ..utils import print_progress, print_warning + 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)}' diff --git a/olap_tool/sinks/postgresql.py b/olap_tool/sinks/postgresql.py index 7311c0a..71a6159 100644 --- a/olap_tool/sinks/postgresql.py +++ b/olap_tool/sinks/postgresql.py @@ -18,7 +18,7 @@ from .base import AnalyticsSink, sanitize_df if TYPE_CHECKING: - from ..config import PostgreSQLConfig + from ..core.config import PostgreSQLConfig # --------------------------------------------------------------------------- @@ -99,7 +99,7 @@ def _refresh_schema(self) -> None: self._schema = {row[0]: row[1] for row in rows} def setup(self, df: pd.DataFrame) -> None: - from ..utils import print_progress, print_warning + from ..core.utils import print_progress, print_warning print_progress( f"Перевірка таблиці PostgreSQL {self._full_table()} " f"({self._config.host}:{self._config.port})..." From 05ef0c992257ad702ecd1012fa673ffe6f20ae03 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 12:08:38 +0200 Subject: [PATCH 08/28] =?UTF-8?q?feat:=20scripts/import=5Fxlsx.py=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D0=B1'=D1=94=D0=B4=D0=BD=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=D1=96=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20XLSX=20(CH/Duck?= =?UTF-8?q?DB/PG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замінює два окремі скрипти (import_xlsx_to_clickhouse.py, import_xlsx_to_duckdb.py) єдиним scripts/import_xlsx.py з аргументом --target ch|duck|pg. ClickHouse використовує thread-local sinks, DuckDB і PostgreSQL — один спільний sink на весь запуск. Co-Authored-By: Claude Sonnet 4.6 --- import_xlsx_to_clickhouse.py | 349 -------------------------- import_xlsx_to_duckdb.py | 286 --------------------- scripts/__init__.py | 0 scripts/import_xlsx.py | 473 +++++++++++++++++++++++++++++++++++ 4 files changed, 473 insertions(+), 635 deletions(-) delete mode 100644 import_xlsx_to_clickhouse.py delete mode 100644 import_xlsx_to_duckdb.py create mode 100644 scripts/__init__.py create mode 100644 scripts/import_xlsx.py diff --git a/import_xlsx_to_clickhouse.py b/import_xlsx_to_clickhouse.py deleted file mode 100644 index 9c7c44a..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.sinks.clickhouse 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/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..bc596e5 --- /dev/null +++ b/scripts/import_xlsx.py @@ -0,0 +1,473 @@ +#!/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 (один sink на потік) +""" + +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() + + +def _get_ch_sink(cfg_kwargs: dict): + """Повертає thread-local ClickHouseSink; створює якщо ще немає.""" + 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)) + # setup вже викликаний у main() з першим df — тут не викликаємо + _ch_local.sink = sink + with _ch_sinks_lock: + _ch_all_sinks.append(sink) + return _ch_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_shared( + file_path: Path, + year: int, + week: int, + sink, + sheet, +) -> tuple[int, bool, float]: + """ + Воркер для DuckDB та PostgreSQL. + Використовує один спільний 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() + + # ── Пошук файлів ─────────────────────────────────────────────────────── + 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 з першого файлу ───────────────── + with console.status(f"[cyan]Ініціалізація {target.upper()}...[/cyan]", spinner="dots"): + try: + df_init = _read_excel(files[0][0], sheet) + + 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[0][1] + df_init_clean["week_num"] = files[0][2] + init_sink.setup(df_init_clean) + init_sink.close() + sink = None # воркери використовують thread-local sinks + + elif target == "duckdb": + from olap_tool.sinks import DuckDBSink, sanitize_df + sink = DuckDBSink(cfg) + if not df_init.empty: + df_init_clean = sanitize_df(df_init.copy()) + df_init_clean["year_num"] = files[0][1] + df_init_clean["week_num"] = files[0][2] + sink.setup(df_init_clean) + + else: # postgresql + from olap_tool.sinks import PostgreSQLSink, sanitize_df + sink = PostgreSQLSink(cfg) + if not df_init.empty: + df_init_clean = sanitize_df(df_init.copy()) + df_init_clean["year_num"] = files[0][1] + df_init_clean["week_num"] = files[0][2] + sink.setup(df_init_clean) + + 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 + } + 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 + else: + 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()) From 1f3cd0592d135b56d85fc1fd164a637c73650aef Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 12:13:59 +0200 Subject: [PATCH 09/28] =?UTF-8?q?feat:=20Textual=20TUI=20=E2=80=94=20app.p?= =?UTF-8?q?y,=20main=5Fmenu,=20olap=5Fexport,=20xlsx=5Fimport;=20=D0=BE?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D0=B8=20olap.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Додано textual>=0.70.0 до requirements.txt (виправлено також злиплий рядок rich+pyarrow) - TUIStream у utils.py: перехоплює stdout, очищує ANSI, пише у RichLog потокобезпечно - olap_tool/tui/: OlapApp + MainMenuScreen + OlapExportScreen + XlsxImportScreen - olap.py: без аргументів → TUI, з аргументами → CLI (sys.exit(main())) Co-Authored-By: Claude Sonnet 4.6 --- olap.py | 22 ++-- olap_tool/core/utils.py | 36 ++++++ olap_tool/tui/__init__.py | 0 olap_tool/tui/app.py | 60 ++++++++++ olap_tool/tui/screens/__init__.py | 0 olap_tool/tui/screens/main_menu.py | 34 ++++++ olap_tool/tui/screens/olap_export.py | 163 +++++++++++++++++++++++++++ olap_tool/tui/screens/xlsx_import.py | 122 ++++++++++++++++++++ olap_tool/tui/widgets/__init__.py | 0 requirements.txt | 4 +- 10 files changed, 432 insertions(+), 9 deletions(-) create mode 100644 olap_tool/tui/__init__.py create mode 100644 olap_tool/tui/app.py create mode 100644 olap_tool/tui/screens/__init__.py create mode 100644 olap_tool/tui/screens/main_menu.py create mode 100644 olap_tool/tui/screens/olap_export.py create mode 100644 olap_tool/tui/screens/xlsx_import.py create mode 100644 olap_tool/tui/widgets/__init__.py diff --git a/olap.py b/olap.py index 5dff2e6..fa4fdc0 100644 --- a/olap.py +++ b/olap.py @@ -1,18 +1,24 @@ +#!/usr/bin/env python3 +""" +OLAP Export Tool — точка входу. + +Без аргументів → запускає Textual TUI. +З аргументами → CLI режим. +""" import sys -import os from dotenv import load_dotenv load_dotenv() -# Гарантуємо UTF-8 вивід для консолі if hasattr(sys.stdout, "reconfigure"): 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] + sys.stdout.reconfigure(encoding="utf-8") except Exception: pass -from olap_tool.core.runner import main - -if __name__ == "__main__": - raise SystemExit(main()) +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()) diff --git a/olap_tool/core/utils.py b/olap_tool/core/utils.py index 41bd882..95cbccd 100644 --- a/olap_tool/core/utils.py +++ b/olap_tool/core/utils.py @@ -115,6 +115,42 @@ def format_time(seconds: float): return f"{seconds:.2f} сек" +# --------------------------------------------------------------------------- +# TUI stdout redirect +# --------------------------------------------------------------------------- +import re as _re +import io as _io + +_ANSI_ESCAPE = _re.compile(r"\x1b\[[0-9;]*m") + + +class TUIStream: + """ + Замінює sys.stdout під час роботи TUI. + Перехоплює всі print() виклики та пише чистий текст у Textual RichLog. + Потокобезпечний через app.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): + raise _io.UnsupportedOperation("no fileno") + + def convert_dotnet_to_python(value): """Конвертує .NET типи (через pythonnet) у серіалізовані Python значення для запису в CSV/XLSX.""" try: diff --git a/olap_tool/tui/__init__.py b/olap_tool/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/olap_tool/tui/app.py b/olap_tool/tui/app.py new file mode 100644 index 0000000..5da36a3 --- /dev/null +++ b/olap_tool/tui/app.py @@ -0,0 +1,60 @@ +"""Головний 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.--highlight { + background: $primary; + color: $text; +} + +#log-panel { + height: 1fr; + border: solid $accent; + margin: 1; +} + +.form-container { + width: 40; + 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()) diff --git a/olap_tool/tui/screens/__init__.py b/olap_tool/tui/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/olap_tool/tui/screens/main_menu.py b/olap_tool/tui/screens/main_menu.py new file mode 100644 index 0000000..0fac893 --- /dev/null +++ b/olap_tool/tui/screens/main_menu.py @@ -0,0 +1,34 @@ +"""Головний екран меню.""" +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Footer, Header, ListItem, ListView, Label + + +class MainMenuScreen(Screen): + """Головне меню програми.""" + + BINDINGS = [("q", "quit", "Вийти")] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield ListView( + ListItem(Label("Експорт з OLAP куба"), id="export"), + ListItem(Label("Імпорт XLSX в аналітику"), id="import"), + ListItem(Label("Вийти"), id="quit"), + 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() diff --git a/olap_tool/tui/screens/olap_export.py b/olap_tool/tui/screens/olap_export.py new file mode 100644 index 0000000..9b5a2ed --- /dev/null +++ b/olap_tool/tui/screens/olap_export.py @@ -0,0 +1,163 @@ +"""Екран експорту даних з OLAP куба.""" +from __future__ import annotations + +import asyncio +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, Footer, Header, Input, Label, RichLog, Select + + +def _list_profiles() -> list[tuple[str, str]]: + """Повертає список доступних профілів як (value, label).""" + profiles_dir = Path("profiles") + if not profiles_dir.exists(): + return [] + return [(p.stem, 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"): + yield Label("Профіль:", classes="field-label") + profiles = _list_profiles() + if profiles: + yield Select(profiles, id="profile-select", allow_blank=True, prompt="(без профілю)") + else: + yield Select([("", "(немає профілів)")], 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]: + argv = ["olap.py"] + + profile_widget = self.query_one("#profile-select", Select) + if profile_widget.value and profile_widget.value is not Select.BLANK: + argv += ["--profile", str(profile_widget.value)] + + 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": + if hasattr(self, "_worker"): + self._worker.cancel() + + 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 + self._worker = self.run_worker(self._do_export(argv), exclusive=True, name="olap-export") + + async def _do_export(self, argv: list[str]) -> None: + 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: + from olap_tool.core.runner import main as runner_main + from olap_tool.core.utils import TUIStream + log = self.query_one("#export-log", RichLog) + stream = TUIStream(self.app, log) + old_stdout = sys.stdout + old_argv = sys.argv + sys.stdout = stream + 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 diff --git a/olap_tool/tui/screens/xlsx_import.py b/olap_tool/tui/screens/xlsx_import.py new file mode 100644 index 0000000..7fe2cf3 --- /dev/null +++ b/olap_tool/tui/screens/xlsx_import.py @@ -0,0 +1,122 @@ +"""Екран імпорту XLSX файлів в аналітичне сховище.""" +from __future__ import annotations + +import importlib.util +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 olap_tool.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"): + 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]: + 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: + import asyncio + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._run_import_sync, script_args) + + def _run_import_sync(self, script_args: list[str]) -> None: + log = self.query_one("#import-log", RichLog) + stream = TUIStream(self.app, log) + old_stdout = sys.stdout + old_argv = sys.argv + sys.stdout = stream + sys.argv = script_args + try: + script_path = Path(__file__).parent.parent.parent.parent / "scripts" / "import_xlsx.py" + spec = importlib.util.spec_from_file_location("import_xlsx", script_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.main() + self.app.call_from_thread(log.write, "[bold green]✓ Імпорт завершено[/bold green]") + except SystemExit: + pass + 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_done) + + def _on_done(self) -> None: + self.query_one("#run-btn", Button).disabled = False + self.query_one("#cancel-btn", Button).disabled = True diff --git a/olap_tool/tui/widgets/__init__.py b/olap_tool/tui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index aaf9608..d65d054 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, панелі, таблиці +textual>=0.70.0 # TUI фреймворк для інтерактивного меню +pyarrow>=14.0.0 From 717183695a5c2dfc605c8fcf2c4c785077a470c2 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 12:19:42 +0200 Subject: [PATCH 10/28] =?UTF-8?q?fix:=20TUIStream.flush(),=20PG=20thread-s?= =?UTF-8?q?afety=20=D0=B2=20import=5Fxlsx,=20profiles=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TUIStream.flush() тепер дренує _buf замість no-op, щоб не втрачати останній рядок - PostgreSQLSink у scripts/import_xlsx.py переведено на thread-local патерн (_pg_local/_get_pg_sink/_process_pg), аналогічно ClickHouse; оновлено коментарі - _list_profiles() прив'язано до Path(__file__) замість CWD, щоб profiles/ знаходились незалежно від робочої директорії Co-Authored-By: Claude Sonnet 4.6 --- olap_tool/core/utils.py | 6 +- olap_tool/tui/screens/olap_export.py | 5 +- scripts/import_xlsx.py | 91 ++++++++++++++++++++++++++-- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/olap_tool/core/utils.py b/olap_tool/core/utils.py index 95cbccd..b0305c2 100644 --- a/olap_tool/core/utils.py +++ b/olap_tool/core/utils.py @@ -145,7 +145,11 @@ def write(self, text: str) -> None: self._app.call_from_thread(self._log.write, clean) def flush(self) -> None: - pass + if self._buf: + clean = _ANSI_ESCAPE.sub("", self._buf) + self._buf = "" + if clean: + self._app.call_from_thread(self._log.write, clean) def fileno(self): raise _io.UnsupportedOperation("no fileno") diff --git a/olap_tool/tui/screens/olap_export.py b/olap_tool/tui/screens/olap_export.py index 9b5a2ed..02511e3 100644 --- a/olap_tool/tui/screens/olap_export.py +++ b/olap_tool/tui/screens/olap_export.py @@ -13,7 +13,10 @@ def _list_profiles() -> list[tuple[str, str]]: """Повертає список доступних профілів як (value, label).""" - profiles_dir = Path("profiles") + # Корінь проєкту = чотири рівні вгору від цього файлу + # olap_tool/tui/screens/olap_export.py → olap_tool/tui/screens → olap_tool/tui → olap_tool → project root + project_root = Path(__file__).parent.parent.parent.parent + profiles_dir = project_root / "profiles" if not profiles_dir.exists(): return [] return [(p.stem, p.stem) for p in sorted(profiles_dir.glob("*.yaml"))] diff --git a/scripts/import_xlsx.py b/scripts/import_xlsx.py index bc596e5..5ad27b1 100644 --- a/scripts/import_xlsx.py +++ b/scripts/import_xlsx.py @@ -10,7 +10,7 @@ Підтримувані цілі (--target): ch / clickhouse — ClickHouse (thread-local з'єднання на кожен воркер) duck / duckdb — DuckDB REST API (один спільний sink, thread-safe) - pg / postgresql — PostgreSQL через COPY FROM STDIN (один sink на потік) + pg / postgresql — PostgreSQL через COPY FROM STDIN (thread-local з'єднання на кожен воркер) """ import sys @@ -88,6 +88,28 @@ def _get_ch_sink(cfg_kwargs: dict): return _ch_local.sink +# --------------------------------------------------------------------------- +# Thread-local сховище для PostgreSQL (одне з'єднання на потік) +# PostgreSQLSink НЕ є thread-safe — psycopg2 з'єднання не можна шерити між потоками +# --------------------------------------------------------------------------- +_pg_local = threading.local() +_pg_all_sinks: list = [] +_pg_sinks_lock = threading.Lock() + + +def _get_pg_sink(cfg_kwargs: dict): + """Повертає thread-local PostgreSQLSink; створює якщо ще немає.""" + 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)) + # setup вже викликаний у main() з першим df — тут не викликаємо + _pg_local.sink = sink + with _pg_sinks_lock: + _pg_all_sinks.append(sink) + return _pg_local.sink + + # --------------------------------------------------------------------------- # Файловий пошук # --------------------------------------------------------------------------- @@ -161,6 +183,41 @@ def _process_ch( 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, @@ -169,9 +226,8 @@ def _process_shared( sheet, ) -> tuple[int, bool, float]: """ - Воркер для DuckDB та PostgreSQL. - Використовує один спільний sink (їх внутрішня реалізація thread-safe або - захищена блокуваннями). + Воркер для DuckDB. + Використовує один спільний sink (внутрішня реалізація thread-safe). """ t0 = time.monotonic() try: @@ -340,12 +396,21 @@ def main() -> int: else: # postgresql from olap_tool.sinks import PostgreSQLSink, sanitize_df - sink = PostgreSQLSink(cfg) + from dataclasses import fields as dc_fields + + # Зберігаємо 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[0][1] df_init_clean["week_num"] = files[0][2] - sink.setup(df_init_clean) + init_sink.setup(df_init_clean) + init_sink.close() + sink = None # воркери використовують thread-local sinks except Exception as e: console.print(f"[red]❌ Помилка ініціалізації: {e}[/red]") @@ -382,6 +447,13 @@ def main() -> int: ): (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( @@ -433,6 +505,13 @@ def main() -> int: s.close() except Exception: pass + elif target == "postgresql": + # Закриваємо всі thread-local sinks + for s in _pg_all_sinks: + try: + s.close() + except Exception: + pass else: try: sink.close() From 2be632396ad15fe9df777a93f5eb2557a46e8329 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 12:22:02 +0200 Subject: [PATCH 11/28] =?UTF-8?q?fix:=20Pylance=20type=20errors=20?= =?UTF-8?q?=E2=80=94=20None=20guard=20=D0=B4=D0=BB=D1=8F=20spec.loader,=20?= =?UTF-8?q?type:=20ignore=20=D0=B4=D0=BB=D1=8F=20reconfigure,=20assert=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20cfg=20narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- olap.py | 2 +- olap_tool/tui/screens/xlsx_import.py | 4 +++- scripts/import_xlsx.py | 13 +++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/olap.py b/olap.py index fa4fdc0..f6528a2 100644 --- a/olap.py +++ b/olap.py @@ -12,7 +12,7 @@ if hasattr(sys.stdout, "reconfigure"): try: - sys.stdout.reconfigure(encoding="utf-8") + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] except Exception: pass diff --git a/olap_tool/tui/screens/xlsx_import.py b/olap_tool/tui/screens/xlsx_import.py index 7fe2cf3..8e98af9 100644 --- a/olap_tool/tui/screens/xlsx_import.py +++ b/olap_tool/tui/screens/xlsx_import.py @@ -104,8 +104,10 @@ def _run_import_sync(self, script_args: list[str]) -> None: try: script_path = Path(__file__).parent.parent.parent.parent / "scripts" / "import_xlsx.py" spec = importlib.util.spec_from_file_location("import_xlsx", script_path) + if spec is None or spec.loader is None: + raise ImportError(f"Не вдалося завантажити: {script_path}") mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) + spec.loader.exec_module(mod) # type: ignore[union-attr] mod.main() self.app.call_from_thread(log.write, "[bold green]✓ Імпорт завершено[/bold green]") except SystemExit: diff --git a/scripts/import_xlsx.py b/scripts/import_xlsx.py index 5ad27b1..c63b193 100644 --- a/scripts/import_xlsx.py +++ b/scripts/import_xlsx.py @@ -387,6 +387,8 @@ def main() -> int: 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()) @@ -396,8 +398,10 @@ def main() -> int: 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) @@ -513,10 +517,11 @@ def main() -> int: except Exception: pass else: - try: - sink.close() - except Exception: - pass + if sink is not None: + try: + sink.close() + except Exception: + pass # ── Підсумок ─────────────────────────────────────────────────────────── elapsed_total = time.monotonic() - start_time From f3a69c95d665710b9da74daf4fe757084ffc2786 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 12:38:53 +0200 Subject: [PATCH 12/28] =?UTF-8?q?fix:=20=D0=B2=D0=B8=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D1=8F=20=D0=B7=D0=B0=20code=20re?= =?UTF-8?q?view=20PR=20#5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BLOCKER: thread-local CH/PG sinks тепер викликають setup() при створенні (зберігаємо _ch_setup_df / _pg_setup_df під час ініціалізації) - asyncio.get_event_loop() → get_running_loop() в TUI screens - query_one() виноситься на головний потік, передається як аргумент - DuckDBSink._refresh_schema(): try/except з інформативним RuntimeError - _duck_value() видалено (мертвий код — вставка через Parquet, не VALUES) Co-Authored-By: Claude Sonnet 4.6 --- olap_tool/sinks/duckdb.py | 47 +++++----------------------- olap_tool/tui/screens/olap_export.py | 12 +++---- olap_tool/tui/screens/xlsx_import.py | 12 +++---- scripts/import_xlsx.py | 30 ++++++++++++++---- 4 files changed, 44 insertions(+), 57 deletions(-) diff --git a/olap_tool/sinks/duckdb.py b/olap_tool/sinks/duckdb.py index 40e19e5..6c551ac 100644 --- a/olap_tool/sinks/duckdb.py +++ b/olap_tool/sinks/duckdb.py @@ -104,43 +104,6 @@ def _to_str(v): 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("'", "''") + "'" - - # --------------------------------------------------------------------------- # DuckDB sink # --------------------------------------------------------------------------- @@ -236,8 +199,14 @@ def setup(self, df: pd.DataFrame) -> None: 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") + 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"]} diff --git a/olap_tool/tui/screens/olap_export.py b/olap_tool/tui/screens/olap_export.py index 02511e3..3046466 100644 --- a/olap_tool/tui/screens/olap_export.py +++ b/olap_tool/tui/screens/olap_export.py @@ -125,22 +125,22 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self._worker.cancel() def _start_export(self) -> None: + # Отримуємо log на головному потоці — query_one небезпечний з executor threads 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 - self._worker = self.run_worker(self._do_export(argv), exclusive=True, name="olap-export") + self._worker = self.run_worker(self._do_export(argv, log), exclusive=True, name="olap-export") - async def _do_export(self, argv: list[str]) -> None: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._run_export_sync, argv) + async def _do_export(self, argv: list[str], log: RichLog) -> None: + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._run_export_sync, argv, log) - def _run_export_sync(self, argv: list[str]) -> None: + def _run_export_sync(self, argv: list[str], log: RichLog) -> None: from olap_tool.core.runner import main as runner_main from olap_tool.core.utils import TUIStream - log = self.query_one("#export-log", RichLog) stream = TUIStream(self.app, log) old_stdout = sys.stdout old_argv = sys.argv diff --git a/olap_tool/tui/screens/xlsx_import.py b/olap_tool/tui/screens/xlsx_import.py index 8e98af9..5f93e0b 100644 --- a/olap_tool/tui/screens/xlsx_import.py +++ b/olap_tool/tui/screens/xlsx_import.py @@ -81,21 +81,21 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self._worker.cancel() def _start_import(self) -> None: + # Отримуємо log на головному потоці — query_one небезпечний з executor threads 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") + self._worker = self.run_worker(self._do_import(script_args, log), exclusive=True, name="xlsx-import") - async def _do_import(self, script_args: list[str]) -> None: + async def _do_import(self, script_args: list[str], log: RichLog) -> None: import asyncio - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._run_import_sync, script_args) + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._run_import_sync, script_args, log) - def _run_import_sync(self, script_args: list[str]) -> None: - log = self.query_one("#import-log", RichLog) + def _run_import_sync(self, script_args: list[str], log: RichLog) -> None: stream = TUIStream(self.app, log) old_stdout = sys.stdout old_argv = sys.argv diff --git a/scripts/import_xlsx.py b/scripts/import_xlsx.py index c63b193..48f627a 100644 --- a/scripts/import_xlsx.py +++ b/scripts/import_xlsx.py @@ -73,15 +73,21 @@ _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; створює якщо ще немає.""" +def _get_ch_sink(cfg_kwargs: dict) -> "ClickHouseSink": + """Повертає 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)) - # setup вже викликаний у main() з першим df — тут не викликаємо + 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) @@ -95,15 +101,21 @@ def _get_ch_sink(cfg_kwargs: dict): _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; створює якщо ще немає.""" +def _get_pg_sink(cfg_kwargs: dict) -> "PostgreSQLSink": + """Повертає 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)) - # setup вже викликаний у main() з першим df — тут не викликаємо + 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) @@ -382,6 +394,9 @@ def main() -> int: df_init_clean["year_num"] = files[0][1] df_init_clean["week_num"] = files[0][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 @@ -413,6 +428,9 @@ def main() -> int: df_init_clean["year_num"] = files[0][1] df_init_clean["week_num"] = files[0][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 From 2e6eb853dd6d51bcda24184b41127891f716ebab Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 12:50:09 +0200 Subject: [PATCH 13/28] =?UTF-8?q?fix:=20edge=20case=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=88=D0=B8=D0=B9=20XLSX=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D1=96=D0=B9,=20=5Fch=5Fsetup=5Fdf=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D1=88=D0=B0=D1=94=D1=82=D1=8C=D1=81=D1=8F=20?= =?UTF-8?q?None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шукаємо перший непорожній файл для ініціалізації sink замість завжди брати files[0]. Без цього thread-local CH/PG sinks не отримували б setup_df і _client залишався None → тихий AttributeError у воркер-потоці. Co-Authored-By: Claude Sonnet 4.6 --- scripts/import_xlsx.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/import_xlsx.py b/scripts/import_xlsx.py index 48f627a..24b4b9c 100644 --- a/scripts/import_xlsx.py +++ b/scripts/import_xlsx.py @@ -373,10 +373,17 @@ def main() -> int: console.print(f"\n[yellow]DRY RUN завершено. Файлів: {len(files)}[/yellow]") return 0 - # ── Ініціалізація sink та CREATE TABLE з першого файлу ───────────────── + # ── Ініціалізація sink та CREATE TABLE з першого непорожнього файлу ────── + # Перший файл може бути порожнім → шукаємо перший з даними для setup() with console.status(f"[cyan]Ініціалізація {target.upper()}...[/cyan]", spinner="dots"): try: - df_init = _read_excel(files[0][0], sheet) + 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 @@ -391,8 +398,8 @@ def main() -> int: 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[0][1] - df_init_clean["week_num"] = files[0][2] + 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 @@ -407,8 +414,8 @@ def main() -> int: sink = DuckDBSink(cfg) if not df_init.empty: df_init_clean = sanitize_df(df_init.copy()) - df_init_clean["year_num"] = files[0][1] - df_init_clean["week_num"] = files[0][2] + 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 @@ -425,8 +432,8 @@ def main() -> int: init_sink = PostgreSQLSink(cfg) if not df_init.empty: df_init_clean = sanitize_df(df_init.copy()) - df_init_clean["year_num"] = files[0][1] - df_init_clean["week_num"] = files[0][2] + 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 From 11146ca361a3c332347d13cd06a78a9a668fda35 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 12:55:36 +0200 Subject: [PATCH 14/28] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=B8=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=20=D0=BD=D0=B5=D0=B2=D0=B0=D0=BB=D1=96=D0=B4?= =?UTF-8?q?=D0=BD=D1=96=20return=20type=20annotations=20=D1=83=20=5Fget=5F?= =?UTF-8?q?ch=5Fsink/=5Fget=5Fpg=5Fsink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/import_xlsx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/import_xlsx.py b/scripts/import_xlsx.py index 24b4b9c..bc11e58 100644 --- a/scripts/import_xlsx.py +++ b/scripts/import_xlsx.py @@ -76,7 +76,7 @@ _ch_setup_df: "Optional[pd.DataFrame]" = None # зберігається під час init -def _get_ch_sink(cfg_kwargs: dict) -> "ClickHouseSink": +def _get_ch_sink(cfg_kwargs: dict): """Повертає thread-local ClickHouseSink. setup() викликається для кожного нового sink — операція ідемпотентна @@ -104,7 +104,7 @@ def _get_ch_sink(cfg_kwargs: dict) -> "ClickHouseSink": _pg_setup_df: "Optional[pd.DataFrame]" = None # зберігається під час init -def _get_pg_sink(cfg_kwargs: dict) -> "PostgreSQLSink": +def _get_pg_sink(cfg_kwargs: dict): """Повертає thread-local PostgreSQLSink. setup() викликається для кожного нового sink — операція ідемпотентна From 8e6878e952bed1f4f8ffbc2befd1e4f5fd2754c9 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Tue, 10 Mar 2026 12:58:04 +0200 Subject: [PATCH 15/28] =?UTF-8?q?fix:=20=D0=B2=D0=B8=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=20(label,=20value)=20=D1=83=20Select-=D0=BE=D0=BF?= =?UTF-8?q?=D1=86=D1=96=D1=8F=D1=85=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Textual Select очікує (label, value), а не (value, label). Некоректний порядок призводив до InvalidSelectValueError при запуску. Co-Authored-By: Claude Sonnet 4.6 --- olap_tool/tui/screens/olap_export.py | 35 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/olap_tool/tui/screens/olap_export.py b/olap_tool/tui/screens/olap_export.py index 3046466..58ca63d 100644 --- a/olap_tool/tui/screens/olap_export.py +++ b/olap_tool/tui/screens/olap_export.py @@ -12,7 +12,7 @@ def _list_profiles() -> list[tuple[str, str]]: - """Повертає список доступних профілів як (value, label).""" + """Повертає список доступних профілів як (label, value) для Textual Select.""" # Корінь проєкту = чотири рівні вгору від цього файлу # olap_tool/tui/screens/olap_export.py → olap_tool/tui/screens → olap_tool/tui → olap_tool → project root project_root = Path(__file__).parent.parent.parent.parent @@ -22,28 +22,29 @@ def _list_profiles() -> list[tuple[str, str]]: return [(p.stem, p.stem) for p in sorted(profiles_dir.glob("*.yaml"))] +# Textual Select очікує (label, value) FORMAT_OPTIONS = [ - ("xlsx", "XLSX"), - ("csv", "CSV"), - ("both", "XLSX + CSV"), - ("ch", "ClickHouse"), - ("duck", "DuckDB"), - ("pg", "PostgreSQL"), + ("XLSX", "xlsx"), + ("CSV", "csv"), + ("XLSX + CSV", "both"), + ("ClickHouse", "ch"), + ("DuckDB", "duck"), + ("PostgreSQL", "pg"), ] PERIOD_OPTIONS = [ - ("last-weeks", "Останні N тижнів"), - ("current-month", "Поточний місяць"), - ("last-month", "Попередній місяць"), - ("current-quarter", "Поточний квартал"), - ("last-quarter", "Попередній квартал"), - ("year-to-date", "З початку року"), - ("manual", "Ручний діапазон"), + ("Останні N тижнів", "last-weeks"), + ("Поточний місяць", "current-month"), + ("Попередній місяць", "last-month"), + ("Поточний квартал", "current-quarter"), + ("Попередній квартал", "last-quarter"), + ("З початку року", "year-to-date"), + ("Ручний діапазон", "manual"), ] COMPRESS_OPTIONS = [ - ("none", "Без стиснення"), - ("zip", "ZIP архів"), + ("Без стиснення", "none"), + ("ZIP архів", "zip"), ] @@ -61,7 +62,7 @@ def compose(self) -> ComposeResult: if profiles: yield Select(profiles, id="profile-select", allow_blank=True, prompt="(без профілю)") else: - yield Select([("", "(немає профілів)")], id="profile-select", allow_blank=True, prompt="(без профілю)") + yield Select([("(немає профілів)", "")], id="profile-select", allow_blank=True, prompt="(без профілю)") yield Label("Формат:", classes="field-label") yield Select(FORMAT_OPTIONS, id="format-select", value="xlsx") From 29633cea5d791f45f34d0d3bc825fb19c794e897 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Wed, 11 Mar 2026 16:28:18 +0200 Subject: [PATCH 16/28] feat: Add new TUI screens for XLSX import, OLAP export, and credentials, alongside connection authentication and prompting utilities. --- olap_tool/connection/auth.py | 31 ++++++++++++-- olap_tool/connection/prompt.py | 31 ++++++++++++++ olap_tool/tui/screens/credentials.py | 63 ++++++++++++++++++++++++++++ olap_tool/tui/screens/olap_export.py | 2 +- olap_tool/tui/screens/xlsx_import.py | 2 +- 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 olap_tool/tui/screens/credentials.py diff --git a/olap_tool/connection/auth.py b/olap_tool/connection/auth.py index 00ed8f9..5337726 100644 --- a/olap_tool/connection/auth.py +++ b/olap_tool/connection/auth.py @@ -80,9 +80,34 @@ def load_credentials( import getpass from colorama import Fore - mp_retry = getpass.getpass( - f"{Fore.CYAN}Введіть майстер-пароль для розшифрування: {Fore.RESET}" - ) + import sys + if hasattr(sys, "stdout") and hasattr(sys.stdout, "_app"): + # TUI mode + app = getattr(sys.stdout, "_app") + import threading + event = threading.Event() + res_mp = [None] + def show_mp_dialog(): + try: + from olap_tool.tui.screens.credentials import CredentialsDialog + def cb(res: tuple[str, str] | None): + if res: + res_mp[0] = res[1] # dialog returns (login, pwd) + event.set() + dialog = CredentialsDialog( + message="Введіть майстер-пароль для розшифрування:", + ask_login=False + ) + app.push_screen(dialog, cb) + except Exception: + event.set() + app.call_from_thread(show_mp_dialog) + event.wait() + mp_retry = res_mp[0] + else: + mp_retry = getpass.getpass( + 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( diff --git a/olap_tool/connection/prompt.py b/olap_tool/connection/prompt.py index 3f32a36..3ec9c4c 100644 --- a/olap_tool/connection/prompt.py +++ b/olap_tool/connection/prompt.py @@ -6,6 +6,37 @@ def prompt_credentials(with_domain: bool = False, domain: Optional[str] = None): + import sys + if hasattr(sys, "stdout") and hasattr(sys.stdout, "_app"): + # TUI mode + app = getattr(sys.stdout, "_app") + import threading + event = threading.Event() + result_store = [None, None] + + def show_dialog(): + try: + from olap_tool.tui.screens.credentials import CredentialsDialog + def cb(res: tuple[str, str] | None): + if res: + result_store[0], result_store[1] = res + event.set() + app.push_screen(CredentialsDialog(domain=domain), cb) + except Exception as e: + print_info(f"Помилка виклику TUI діалогу: {e}") + event.set() + + app.call_from_thread(show_dialog) + event.wait() + + username, password = result_store[0], result_store[1] + if with_domain and username and domain: + if "\\" not in username and not username.startswith(f"{domain}\\"): + username = f"{domain}\\{username}" + print_info(f"Використовуємо повне ім'я користувача: {username}") + return username, password + + # CLI mode print_info("Введіть облікові дані для підключення до OLAP:") username = input(f"{Fore.CYAN}Ім'я користувача: {Fore.RESET}") password = getpass.getpass(f"{Fore.CYAN}Пароль: {Fore.RESET}") diff --git a/olap_tool/tui/screens/credentials.py b/olap_tool/tui/screens/credentials.py new file mode 100644 index 0000000..c540fcc --- /dev/null +++ b/olap_tool/tui/screens/credentials.py @@ -0,0 +1,63 @@ +"""Екран для введення облікових даних в TUI.""" +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Label + + +class CredentialsDialog(ModalScreen[tuple[str, str] | None]): + """Діалогове вікно для запиту логіна та пароля.""" + + DEFAULT_CSS = """ + CredentialsDialog { + align: center middle; + } + #cred-dialog { + padding: 1 2; + width: 50; + height: auto; + border: thick $primary; + background: $surface; + } + #cred-dialog Label { + margin-bottom: 1; + } + #cred-dialog Input { + margin-bottom: 1; + } + #cred-buttons { + width: 100%; + align: center middle; + } + #cred-buttons Button { + margin: 0 1; + } + """ + + def __init__(self, domain: str | None = None, message: str = "Введіть облікові дані для підключення до OLAP:", ask_login: bool = True) -> None: + super().__init__() + self.domain = domain + self.message = message + self.ask_login = ask_login + + def compose(self) -> ComposeResult: + with Vertical(id="cred-dialog"): + yield Label(self.message) + if self.domain and self.ask_login: + yield Label(f"Домен: {self.domain}", classes="text-muted") + if self.ask_login: + yield Input(placeholder="Логін", id="login-input") + yield Input(placeholder="Пароль", password=True, id="password-input") + with Horizontal(id="cred-buttons"): + yield Button("ОК", variant="primary", id="ok-btn") + yield Button("Скасувати", variant="error", id="cancel-btn") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "ok-btn": + login = "" + if self.ask_login: + login = self.query_one("#login-input", Input).value.strip() + pwd = self.query_one("#password-input", Input).value + self.dismiss((login, pwd)) + elif event.button.id == "cancel-btn": + self.dismiss(None) diff --git a/olap_tool/tui/screens/olap_export.py b/olap_tool/tui/screens/olap_export.py index 58ca63d..7b3f71d 100644 --- a/olap_tool/tui/screens/olap_export.py +++ b/olap_tool/tui/screens/olap_export.py @@ -51,7 +51,7 @@ def _list_profiles() -> list[tuple[str, str]]: class OlapExportScreen(Screen): """Екран: Експорт з OLAP куба.""" - BINDINGS = [("escape", "pop_screen", "Назад")] + BINDINGS = [("escape", "app.pop_screen", "Назад")] def compose(self) -> ComposeResult: yield Header(show_clock=True) diff --git a/olap_tool/tui/screens/xlsx_import.py b/olap_tool/tui/screens/xlsx_import.py index 5f93e0b..7182b50 100644 --- a/olap_tool/tui/screens/xlsx_import.py +++ b/olap_tool/tui/screens/xlsx_import.py @@ -16,7 +16,7 @@ class XlsxImportScreen(Screen): """Екран: Імпорт XLSX в аналітику.""" - BINDINGS = [("escape", "pop_screen", "Назад")] + BINDINGS = [("escape", "app.pop_screen", "Назад")] def compose(self) -> ComposeResult: yield Header(show_clock=True) From a9a5b988c1374e37a9d80db24d6d1020350f6ae1 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Wed, 11 Mar 2026 17:44:21 +0200 Subject: [PATCH 17/28] feat: Introduce a Textual TUI application for OLAP export, including CSV/XLSX streaming, connection management, and progress display. --- olap.py | 9 +- olap_tool/connection/auth.py | 21 +- olap_tool/connection/connection.py | 136 ++++++++++--- olap_tool/connection/security.py | 67 +++---- olap_tool/core/progress.py | 15 +- olap_tool/core/utils.py | 19 +- olap_tool/data/exporter.py | 287 ++++++++------------------- olap_tool/data/queries.py | 227 ++++++++------------- olap_tool/tui/app.py | 63 +++++- olap_tool/tui/screens/credentials.py | 40 +++- olap_tool/tui/screens/olap_export.py | 90 +++++++-- olap_tool/tui/screens/xlsx_import.py | 66 ++++-- 12 files changed, 575 insertions(+), 465 deletions(-) diff --git a/olap.py b/olap.py index f6528a2..a833998 100644 --- a/olap.py +++ b/olap.py @@ -17,8 +17,15 @@ pass if len(sys.argv) == 1: + import os from olap_tool.tui.app import OlapApp - OlapApp().run() + try: + OlapApp().run() + except KeyboardInterrupt: + pass + finally: + # Примусово завершуємо всі фонові потоки (наприклад, завислі запити до БД) + os._exit(0) else: from olap_tool.core.runner import main sys.exit(main()) diff --git a/olap_tool/connection/auth.py b/olap_tool/connection/auth.py index 5337726..5e6cd48 100644 --- a/olap_tool/connection/auth.py +++ b/olap_tool/connection/auth.py @@ -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, @@ -119,17 +128,23 @@ def cb(res: tuple[str, str] | None): 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/connection.py b/olap_tool/connection/connection.py index 8d0d790..a53a7c4 100644 --- a/olap_tool/connection/connection.py +++ b/olap_tool/connection/connection.py @@ -25,6 +25,60 @@ 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/connection/security.py b/olap_tool/connection/security.py index f93993a..e902905 100644 --- a/olap_tool/connection/security.py +++ b/olap_tool/connection/security.py @@ -11,54 +11,41 @@ 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/progress.py b/olap_tool/core/progress.py index 8cdbc78..e275f18 100644 --- a/olap_tool/core/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/core/utils.py b/olap_tool/core/utils.py index b0305c2..b50e885 100644 --- a/olap_tool/core/utils.py +++ b/olap_tool/core/utils.py @@ -131,22 +131,35 @@ class TUIStream: Потокобезпечний через app.call_from_thread(). """ - def __init__(self, app, log_widget): + def __init__(self, app, log_widget, status_widget): self._app = app self._log = log_widget + self._status = status_widget self._buf = "" def write(self, text: str) -> None: self._buf += text + + # If the text explicitly starts with \r, it's a progress update + # We process it right away and don't buffer it to RichLog + if "\r" in text: + clean = _ANSI_ESCAPE.sub("", text).replace("\r", "").strip() + if clean: + self._app.call_from_thread(self._status.update, clean) + self._buf = "" # Clear buffer so we don't accidentally push this to log + return + while "\n" in self._buf: line, self._buf = self._buf.split("\n", 1) - clean = _ANSI_ESCAPE.sub("", line) + # Remove ANSI codes and clean standard log lines + clean = _ANSI_ESCAPE.sub("", line).strip() if clean: + self._app.call_from_thread(self._status.update, "") # Clear status on real log message self._app.call_from_thread(self._log.write, clean) def flush(self) -> None: if self._buf: - clean = _ANSI_ESCAPE.sub("", self._buf) + clean = _ANSI_ESCAPE.sub("", self._buf).strip() self._buf = "" if clean: self._app.call_from_thread(self._log.write, clean) diff --git a/olap_tool/data/exporter.py b/olap_tool/data/exporter.py index c61fd86..0524c31 100644 --- a/olap_tool/data/exporter.py +++ b/olap_tool/data/exporter.py @@ -14,164 +14,48 @@ from ..core.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 - ) +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: - 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( - { + 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, + 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, @@ -181,46 +65,51 @@ def export_xlsx_stream( "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 + }) + 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 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 + 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/data/queries.py b/olap_tool/data/queries.py index 6be28e6..a016bc3 100644 --- a/olap_tool/data/queries.py +++ b/olap_tool/data/queries.py @@ -17,7 +17,7 @@ convert_dotnet_to_python, ensure_dir, ) -from .exporter import export_csv_stream, export_xlsx_dataframe, export_xlsx_stream +# CsvStreamWriter / XlsxStreamWriter are imported lazily inside run_dax_query from ..core import progress if TYPE_CHECKING: @@ -172,7 +172,7 @@ def run_dax_query( 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 +181,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,69 +236,74 @@ 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("Експорт/збереження отриманих даних (потоковий режим)...") + while True: + chunk = cursor.fetchmany(chunk_size) + if not chunk: + break + + converted_chunk = [] + for row in chunk: + converted_chunk.append([convert_dotnet_to_python(v) for v in row]) + + df_chunk = pd.DataFrame(converted_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) + is_first_chunk = False + + 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 diff --git a/olap_tool/tui/app.py b/olap_tool/tui/app.py index 5da36a3..b169f44 100644 --- a/olap_tool/tui/app.py +++ b/olap_tool/tui/app.py @@ -4,14 +4,31 @@ from .screens.main_menu import MainMenuScreen CSS = """ +/* Business Theme Colors (VS Code inspired) */ +$primary: #007acc; +$secondary: #005999; +$accent: #007acc; +$warning: #d7ba7d; +$error: #c586c0; +$success: #89d185; + +$background: #1e1e1e; +$surface: #252526; +$panel: #2d2d30; +$panel-light: #3e3e42; + +$text: #d4d4d4; +$text-muted: #9cdcfe; + Screen { - background: $surface; + background: $background; } ListView { width: 60; margin: 2 4; border: solid $primary; + background: $surface; } ListItem { @@ -20,30 +37,54 @@ ListItem.--highlight { background: $primary; - color: $text; + color: #ffffff; + text-style: bold; } #log-panel { - height: 1fr; - border: solid $accent; - margin: 1; + width: 1fr; + height: 100%; + border: solid $primary; + border-title-color: $text-muted; + border-title-style: bold; + background: $surface; + margin: 1 2 1 1; } .form-container { - width: 40; - height: auto; - border: solid $primary; - margin: 1; - padding: 1; + width: 45; + height: 100%; + border: solid $panel-light; + border-title-color: $text; + border-title-style: bold; + background: $surface; + margin: 1 1 1 2; + padding: 1 2; + overflow-y: auto; +} + +.status-bar { + dock: bottom; + height: 1; + margin: 0 1; + color: $accent; + text-style: bold; } Label.field-label { margin-top: 1; color: $text-muted; + text-style: bold; +} + +Input, Select { + margin-bottom: 1; + width: 100%; } Button { - margin: 1 0; + width: 100%; + margin-top: 1; } """ diff --git a/olap_tool/tui/screens/credentials.py b/olap_tool/tui/screens/credentials.py index c540fcc..a95a251 100644 --- a/olap_tool/tui/screens/credentials.py +++ b/olap_tool/tui/screens/credentials.py @@ -14,22 +14,34 @@ class CredentialsDialog(ModalScreen[tuple[str, str] | None]): } #cred-dialog { padding: 1 2; - width: 50; + width: 60; height: auto; - border: thick $primary; + border: solid $primary; + border-title-color: $text; + border-title-style: bold; background: $surface; } #cred-dialog Label { margin-bottom: 1; + color: $text; } #cred-dialog Input { + width: 100%; margin-bottom: 1; + background: $panel; + border: none; + } + #cred-dialog Input:focus { + border: tall $primary; } #cred-buttons { width: 100%; + height: auto; + margin-top: 1; align: center middle; } #cred-buttons Button { + width: 22; margin: 0 1; } """ @@ -41,7 +53,8 @@ def __init__(self, domain: str | None = None, message: str = "Введіть о self.ask_login = ask_login def compose(self) -> ComposeResult: - with Vertical(id="cred-dialog"): + with Vertical(id="cred-dialog") as dialog: + dialog.border_title = "Авторизація" yield Label(self.message) if self.domain and self.ask_login: yield Label(f"Домен: {self.domain}", classes="text-muted") @@ -49,15 +62,22 @@ def compose(self) -> ComposeResult: yield Input(placeholder="Логін", id="login-input") yield Input(placeholder="Пароль", password=True, id="password-input") with Horizontal(id="cred-buttons"): - yield Button("ОК", variant="primary", id="ok-btn") - yield Button("Скасувати", variant="error", id="cancel-btn") + yield Button("✓ ОК", variant="primary", id="ok-btn") + yield Button("✕ Скасувати", variant="error", id="cancel-btn") + + def on_input_submitted(self, event: "Input.Submitted") -> None: + """Натискання Enter у полі вводу підтверджує форму.""" + self._submit() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "ok-btn": - login = "" - if self.ask_login: - login = self.query_one("#login-input", Input).value.strip() - pwd = self.query_one("#password-input", Input).value - self.dismiss((login, pwd)) + self._submit() elif event.button.id == "cancel-btn": self.dismiss(None) + + def _submit(self) -> None: + login = "" + if self.ask_login: + login = self.query_one("#login-input", Input).value.strip() + pwd = self.query_one("#password-input", Input).value + self.dismiss((login, pwd)) diff --git a/olap_tool/tui/screens/olap_export.py b/olap_tool/tui/screens/olap_export.py index 7b3f71d..b8c95fd 100644 --- a/olap_tool/tui/screens/olap_export.py +++ b/olap_tool/tui/screens/olap_export.py @@ -5,10 +5,11 @@ import sys from pathlib import Path +from textual import on from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, Label, RichLog, Select +from textual.widgets import Button, Footer, Header, Input, Label, RichLog, Select, LoadingIndicator, Static def _list_profiles() -> list[tuple[str, str]]: @@ -56,7 +57,8 @@ class OlapExportScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) with Horizontal(): - with Vertical(classes="form-container"): + with Vertical(classes="form-container") as form: + form.border_title = "Параметри Експорту" yield Label("Профіль:", classes="field-label") profiles = _list_profiles() if profiles: @@ -70,19 +72,52 @@ def compose(self) -> ComposeResult: 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 Label("Значення N (тижні) або YYYY-WW:YYYY-WW:", classes="field-label", id="period-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) + yield Button("▶ Запустити", variant="primary", id="run-btn") + yield Button("■ Зупинити експорт", variant="error", id="cancel-btn", disabled=True) + yield Button("↩ Назад", id="back-btn") - with Vertical(id="log-panel"): + with Vertical(id="log-panel") as log_panel: + log_panel.border_title = "Журнал виконання" yield RichLog(id="export-log", highlight=True, markup=True, wrap=True) + yield Static("", id="export-status", classes="status-bar") + yield LoadingIndicator(id="export-loading") yield Footer() + def on_mount(self) -> None: + self.query_one("#export-loading", LoadingIndicator).display = False + self._toggle_period_input() + + @on(Select.Changed, "#period-type-select") + def _on_period_type_changed(self, event: Select.Changed) -> None: + self._toggle_period_input() + + def _toggle_period_input(self) -> None: + period_type = self.query_one("#period-type-select", Select).value + period_input = self.query_one("#period-value-input", Input) + period_label = self.query_one("#period-label", Label) + + # Types that don't need input + no_input_types = ["current-month", "last-month", "current-quarter", "last-quarter", "year-to-date"] + + if period_type in no_input_types: + period_input.display = False + period_label.display = False + else: + period_input.display = True + period_label.display = True + if period_type == "last-weeks": + period_label.update("Значення N (тижні):") + period_input.placeholder = "Наприклад: 4" + elif period_type == "manual": + period_label.update("Значення YYYY-WW:YYYY-WW:") + period_input.placeholder = "Наприклад: 2024-01:2024-04" + def _build_argv(self) -> list[str]: argv = ["olap.py"] @@ -124,44 +159,71 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "cancel-btn": if hasattr(self, "_worker"): self._worker.cancel() + elif event.button.id == "back-btn": + self.app.pop_screen() def _start_export(self) -> None: # Отримуємо log на головному потоці — query_one небезпечний з executor threads log = self.query_one("#export-log", RichLog) + status = self.query_one("#export-status", Static) log.clear() + status.update("") 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 - self._worker = self.run_worker(self._do_export(argv, log), exclusive=True, name="olap-export") + self.query_one("#export-loading", LoadingIndicator).display = True + self._worker = self.run_worker(self._do_export(argv, log, status), exclusive=True, name="olap-export") - async def _do_export(self, argv: list[str], log: RichLog) -> None: + async def _do_export(self, argv: list[str], log: RichLog, status: Static) -> None: loop = asyncio.get_running_loop() - await loop.run_in_executor(None, self._run_export_sync, argv, log) + await loop.run_in_executor(None, self._run_export_sync, argv, log, status) - def _run_export_sync(self, argv: list[str], log: RichLog) -> None: + def _run_export_sync(self, argv: list[str], log: RichLog, status: Static) -> None: from olap_tool.core.runner import main as runner_main from olap_tool.core.utils import TUIStream - stream = TUIStream(self.app, log) + stream = TUIStream(self.app, log, status) old_stdout = sys.stdout + old_stderr = sys.stderr old_argv = sys.argv sys.stdout = stream + sys.stderr = stream sys.argv = argv + success = False try: result = runner_main() + success = (result == 0) msg = ( "[bold green]✓ Завершено успішно[/bold green]" - if result == 0 + if success 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]") + success = False finally: sys.argv = old_argv sys.stdout = old_stdout - self.app.call_from_thread(self._on_export_done) + sys.stderr = old_stderr + self.app.call_from_thread(self._on_export_done, success) - def _on_export_done(self) -> None: + def _on_export_done(self, success: bool = False) -> None: self.query_one("#run-btn", Button).disabled = False self.query_one("#cancel-btn", Button).disabled = True + self.query_one("#export-loading", LoadingIndicator).display = False + self.query_one("#export-status", Static).update("") + + if success: + self.app.notify( + "Експорт завершено успішно ✔", + title="Готово", + severity="information", + ) + else: + self.app.notify( + "Експорт завершився з помилкою. Перевірте журнал …", + title="Помилка", + severity="error", + ) + diff --git a/olap_tool/tui/screens/xlsx_import.py b/olap_tool/tui/screens/xlsx_import.py index 7182b50..d89039e 100644 --- a/olap_tool/tui/screens/xlsx_import.py +++ b/olap_tool/tui/screens/xlsx_import.py @@ -8,7 +8,7 @@ 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 textual.widgets import Button, Checkbox, Footer, Header, Input, Label, RadioButton, RadioSet, RichLog, LoadingIndicator, Static from olap_tool.core.utils import TUIStream @@ -21,7 +21,8 @@ class XlsxImportScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) with Horizontal(): - with Vertical(classes="form-container"): + with Vertical(classes="form-container") as form: + form.border_title = "Параметри Імпорту" yield Label("Ціль:", classes="field-label") with RadioSet(id="target-radio"): yield RadioButton("ClickHouse", id="target-ch", value=True) @@ -42,13 +43,20 @@ def compose(self) -> ComposeResult: yield Checkbox("Dry Run (без запису)", id="dry-run-check") - yield Button("Запустити", variant="primary", id="run-btn") - yield Button("Скасувати", variant="error", id="cancel-btn", disabled=True) + yield Button("▶ Запустити", variant="primary", id="run-btn") + yield Button("■ Зупинити імпорт", variant="error", id="cancel-btn", disabled=True) + yield Button("↩ Назад", id="back-btn") - with Vertical(id="log-panel"): + with Vertical(id="log-panel") as log_panel: + log_panel.border_title = "Журнал виконання" yield RichLog(id="import-log", highlight=True, markup=True, wrap=True) + yield Static("", id="import-status", classes="status-bar") + yield LoadingIndicator(id="import-loading") yield Footer() + def on_mount(self) -> None: + self.query_one("#import-loading", LoadingIndicator).display = False + def _get_target(self) -> str: radio = self.query_one("#target-radio", RadioSet) pressed = radio.pressed_button @@ -79,46 +87,72 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "cancel-btn": if hasattr(self, "_worker"): self._worker.cancel() + elif event.button.id == "back-btn": + self.app.pop_screen() def _start_import(self) -> None: # Отримуємо log на головному потоці — query_one небезпечний з executor threads log = self.query_one("#import-log", RichLog) + status = self.query_one("#import-status", Static) log.clear() + status.update("") 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, log), exclusive=True, name="xlsx-import") + self.query_one("#import-loading", LoadingIndicator).display = True + self._worker = self.run_worker(self._do_import(script_args, log, status), exclusive=True, name="xlsx-import") - async def _do_import(self, script_args: list[str], log: RichLog) -> None: + async def _do_import(self, script_args: list[str], log: RichLog, status: Static) -> None: import asyncio loop = asyncio.get_running_loop() - await loop.run_in_executor(None, self._run_import_sync, script_args, log) + await loop.run_in_executor(None, self._run_import_sync, script_args, log, status) - def _run_import_sync(self, script_args: list[str], log: RichLog) -> None: - stream = TUIStream(self.app, log) + def _run_import_sync(self, script_args: list[str], log: RichLog, status: Static) -> None: + stream = TUIStream(self.app, log, status) old_stdout = sys.stdout old_argv = sys.argv sys.stdout = stream sys.argv = script_args + success = False try: - script_path = Path(__file__).parent.parent.parent.parent / "scripts" / "import_xlsx.py" + script_path = ( + Path(__file__).parent.parent.parent.parent / "scripts" / "import_xlsx.py" + ) spec = importlib.util.spec_from_file_location("import_xlsx", script_path) if spec is None or spec.loader is None: raise ImportError(f"Не вдалося завантажити: {script_path}") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) # type: ignore[union-attr] mod.main() - self.app.call_from_thread(log.write, "[bold green]✓ Імпорт завершено[/bold green]") - except SystemExit: - pass + self.app.call_from_thread( + log.write, "[bold green]✓ Імпорт завершено[/bold green]" + ) + success = True + except SystemExit as se: + success = (se.code == 0) if se.code is not None else True except Exception as exc: self.app.call_from_thread(log.write, f"[bold red]✗ Помилка: {exc}[/bold red]") + success = False finally: sys.argv = old_argv sys.stdout = old_stdout - self.app.call_from_thread(self._on_done) + self.app.call_from_thread(self._on_done, success) - def _on_done(self) -> None: + def _on_done(self, success: bool = False) -> None: self.query_one("#run-btn", Button).disabled = False self.query_one("#cancel-btn", Button).disabled = True + self.query_one("#import-loading", LoadingIndicator).display = False + self.query_one("#import-status", Static).update("") + + if success: + self.app.notify( + "Імпорт завершено успішно ✔", title="Готово", severity="information" + ) + else: + self.app.notify( + "Імпорт завершився з помилкою. Перевірте журнал …", + title="Помилка", + severity="error", + ) + From 47f621bf2aa2ac3bd4773c4401937cb0ab2d2a22 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:12:01 +0200 Subject: [PATCH 18/28] =?UTF-8?q?docs:=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD-=D1=81=D0=BF=D0=B5=D1=86=D0=B8=D1=84=D1=96=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D1=96=D1=8F=20Console=20UI=20(InquirerPy=20+=20rich)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-03-16-console-ui-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-console-ui-design.md 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 | From 7231b46adbc418cc30319dec9f00b179ab8f4ee4 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:18:46 +0200 Subject: [PATCH 19/28] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D1=96=D0=B7=D0=B0=D1=86=D1=96=D1=97=20Consol?= =?UTF-8?q?e=20UI=20(InquirerPy=20+=20rich)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-03-16-console-ui.md | 805 ++++++++++++++++++ 1 file changed, 805 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-console-ui.md 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). From b6e3dd8c83a2831fc9b3fbbc99e135a0fdd8ea07 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:21:06 +0200 Subject: [PATCH 20/28] =?UTF-8?q?refactor:=20=D0=B2=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B8=20TUI=20(textual),=20TUIStream;=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B4=D0=B0=D1=82=D0=B8=20InquirerPy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .vscode/settings.json | 2 +- olap_tool/core/utils.py | 53 ------- olap_tool/tui/__init__.py | 0 olap_tool/tui/app.py | 101 ------------ olap_tool/tui/screens/__init__.py | 0 olap_tool/tui/screens/credentials.py | 83 ---------- olap_tool/tui/screens/main_menu.py | 34 ---- olap_tool/tui/screens/olap_export.py | 229 --------------------------- olap_tool/tui/screens/xlsx_import.py | 158 ------------------ olap_tool/tui/widgets/__init__.py | 0 requirements.txt | 2 +- 11 files changed, 2 insertions(+), 660 deletions(-) delete mode 100644 olap_tool/tui/__init__.py delete mode 100644 olap_tool/tui/app.py delete mode 100644 olap_tool/tui/screens/__init__.py delete mode 100644 olap_tool/tui/screens/credentials.py delete mode 100644 olap_tool/tui/screens/main_menu.py delete mode 100644 olap_tool/tui/screens/olap_export.py delete mode 100644 olap_tool/tui/screens/xlsx_import.py delete mode 100644 olap_tool/tui/widgets/__init__.py 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/olap_tool/core/utils.py b/olap_tool/core/utils.py index b50e885..41bd882 100644 --- a/olap_tool/core/utils.py +++ b/olap_tool/core/utils.py @@ -115,59 +115,6 @@ def format_time(seconds: float): return f"{seconds:.2f} сек" -# --------------------------------------------------------------------------- -# TUI stdout redirect -# --------------------------------------------------------------------------- -import re as _re -import io as _io - -_ANSI_ESCAPE = _re.compile(r"\x1b\[[0-9;]*m") - - -class TUIStream: - """ - Замінює sys.stdout під час роботи TUI. - Перехоплює всі print() виклики та пише чистий текст у Textual RichLog. - Потокобезпечний через app.call_from_thread(). - """ - - def __init__(self, app, log_widget, status_widget): - self._app = app - self._log = log_widget - self._status = status_widget - self._buf = "" - - def write(self, text: str) -> None: - self._buf += text - - # If the text explicitly starts with \r, it's a progress update - # We process it right away and don't buffer it to RichLog - if "\r" in text: - clean = _ANSI_ESCAPE.sub("", text).replace("\r", "").strip() - if clean: - self._app.call_from_thread(self._status.update, clean) - self._buf = "" # Clear buffer so we don't accidentally push this to log - return - - while "\n" in self._buf: - line, self._buf = self._buf.split("\n", 1) - # Remove ANSI codes and clean standard log lines - clean = _ANSI_ESCAPE.sub("", line).strip() - if clean: - self._app.call_from_thread(self._status.update, "") # Clear status on real log message - self._app.call_from_thread(self._log.write, clean) - - def flush(self) -> None: - if self._buf: - clean = _ANSI_ESCAPE.sub("", self._buf).strip() - self._buf = "" - if clean: - self._app.call_from_thread(self._log.write, clean) - - def fileno(self): - raise _io.UnsupportedOperation("no fileno") - - def convert_dotnet_to_python(value): """Конвертує .NET типи (через pythonnet) у серіалізовані Python значення для запису в CSV/XLSX.""" try: diff --git a/olap_tool/tui/__init__.py b/olap_tool/tui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/olap_tool/tui/app.py b/olap_tool/tui/app.py deleted file mode 100644 index b169f44..0000000 --- a/olap_tool/tui/app.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Головний Textual застосунок.""" -from textual.app import App - -from .screens.main_menu import MainMenuScreen - -CSS = """ -/* Business Theme Colors (VS Code inspired) */ -$primary: #007acc; -$secondary: #005999; -$accent: #007acc; -$warning: #d7ba7d; -$error: #c586c0; -$success: #89d185; - -$background: #1e1e1e; -$surface: #252526; -$panel: #2d2d30; -$panel-light: #3e3e42; - -$text: #d4d4d4; -$text-muted: #9cdcfe; - -Screen { - background: $background; -} - -ListView { - width: 60; - margin: 2 4; - border: solid $primary; - background: $surface; -} - -ListItem { - padding: 1 2; -} - -ListItem.--highlight { - background: $primary; - color: #ffffff; - text-style: bold; -} - -#log-panel { - width: 1fr; - height: 100%; - border: solid $primary; - border-title-color: $text-muted; - border-title-style: bold; - background: $surface; - margin: 1 2 1 1; -} - -.form-container { - width: 45; - height: 100%; - border: solid $panel-light; - border-title-color: $text; - border-title-style: bold; - background: $surface; - margin: 1 1 1 2; - padding: 1 2; - overflow-y: auto; -} - -.status-bar { - dock: bottom; - height: 1; - margin: 0 1; - color: $accent; - text-style: bold; -} - -Label.field-label { - margin-top: 1; - color: $text-muted; - text-style: bold; -} - -Input, Select { - margin-bottom: 1; - width: 100%; -} - -Button { - width: 100%; - margin-top: 1; -} -""" - - -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()) diff --git a/olap_tool/tui/screens/__init__.py b/olap_tool/tui/screens/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/olap_tool/tui/screens/credentials.py b/olap_tool/tui/screens/credentials.py deleted file mode 100644 index a95a251..0000000 --- a/olap_tool/tui/screens/credentials.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Екран для введення облікових даних в TUI.""" -from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical -from textual.screen import ModalScreen -from textual.widgets import Button, Input, Label - - -class CredentialsDialog(ModalScreen[tuple[str, str] | None]): - """Діалогове вікно для запиту логіна та пароля.""" - - DEFAULT_CSS = """ - CredentialsDialog { - align: center middle; - } - #cred-dialog { - padding: 1 2; - width: 60; - height: auto; - border: solid $primary; - border-title-color: $text; - border-title-style: bold; - background: $surface; - } - #cred-dialog Label { - margin-bottom: 1; - color: $text; - } - #cred-dialog Input { - width: 100%; - margin-bottom: 1; - background: $panel; - border: none; - } - #cred-dialog Input:focus { - border: tall $primary; - } - #cred-buttons { - width: 100%; - height: auto; - margin-top: 1; - align: center middle; - } - #cred-buttons Button { - width: 22; - margin: 0 1; - } - """ - - def __init__(self, domain: str | None = None, message: str = "Введіть облікові дані для підключення до OLAP:", ask_login: bool = True) -> None: - super().__init__() - self.domain = domain - self.message = message - self.ask_login = ask_login - - def compose(self) -> ComposeResult: - with Vertical(id="cred-dialog") as dialog: - dialog.border_title = "Авторизація" - yield Label(self.message) - if self.domain and self.ask_login: - yield Label(f"Домен: {self.domain}", classes="text-muted") - if self.ask_login: - yield Input(placeholder="Логін", id="login-input") - yield Input(placeholder="Пароль", password=True, id="password-input") - with Horizontal(id="cred-buttons"): - yield Button("✓ ОК", variant="primary", id="ok-btn") - yield Button("✕ Скасувати", variant="error", id="cancel-btn") - - def on_input_submitted(self, event: "Input.Submitted") -> None: - """Натискання Enter у полі вводу підтверджує форму.""" - self._submit() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "ok-btn": - self._submit() - elif event.button.id == "cancel-btn": - self.dismiss(None) - - def _submit(self) -> None: - login = "" - if self.ask_login: - login = self.query_one("#login-input", Input).value.strip() - pwd = self.query_one("#password-input", Input).value - self.dismiss((login, pwd)) diff --git a/olap_tool/tui/screens/main_menu.py b/olap_tool/tui/screens/main_menu.py deleted file mode 100644 index 0fac893..0000000 --- a/olap_tool/tui/screens/main_menu.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Головний екран меню.""" -from textual.app import ComposeResult -from textual.screen import Screen -from textual.widgets import Footer, Header, ListItem, ListView, Label - - -class MainMenuScreen(Screen): - """Головне меню програми.""" - - BINDINGS = [("q", "quit", "Вийти")] - - def compose(self) -> ComposeResult: - yield Header(show_clock=True) - yield ListView( - ListItem(Label("Експорт з OLAP куба"), id="export"), - ListItem(Label("Імпорт XLSX в аналітику"), id="import"), - ListItem(Label("Вийти"), id="quit"), - 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() diff --git a/olap_tool/tui/screens/olap_export.py b/olap_tool/tui/screens/olap_export.py deleted file mode 100644 index b8c95fd..0000000 --- a/olap_tool/tui/screens/olap_export.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Екран експорту даних з OLAP куба.""" -from __future__ import annotations - -import asyncio -import sys -from pathlib import Path - -from textual import on -from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical -from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, Label, RichLog, Select, LoadingIndicator, Static - - -def _list_profiles() -> list[tuple[str, str]]: - """Повертає список доступних профілів як (label, value) для Textual Select.""" - # Корінь проєкту = чотири рівні вгору від цього файлу - # olap_tool/tui/screens/olap_export.py → olap_tool/tui/screens → olap_tool/tui → olap_tool → project root - project_root = Path(__file__).parent.parent.parent.parent - profiles_dir = project_root / "profiles" - if not profiles_dir.exists(): - return [] - return [(p.stem, p.stem) for p in sorted(profiles_dir.glob("*.yaml"))] - - -# Textual Select очікує (label, value) -FORMAT_OPTIONS = [ - ("XLSX", "xlsx"), - ("CSV", "csv"), - ("XLSX + CSV", "both"), - ("ClickHouse", "ch"), - ("DuckDB", "duck"), - ("PostgreSQL", "pg"), -] - -PERIOD_OPTIONS = [ - ("Останні N тижнів", "last-weeks"), - ("Поточний місяць", "current-month"), - ("Попередній місяць", "last-month"), - ("Поточний квартал", "current-quarter"), - ("Попередній квартал", "last-quarter"), - ("З початку року", "year-to-date"), - ("Ручний діапазон", "manual"), -] - -COMPRESS_OPTIONS = [ - ("Без стиснення", "none"), - ("ZIP архів", "zip"), -] - - -class OlapExportScreen(Screen): - """Екран: Експорт з OLAP куба.""" - - BINDINGS = [("escape", "app.pop_screen", "Назад")] - - def compose(self) -> ComposeResult: - yield Header(show_clock=True) - with Horizontal(): - with Vertical(classes="form-container") as form: - form.border_title = "Параметри Експорту" - yield Label("Профіль:", classes="field-label") - profiles = _list_profiles() - if profiles: - yield Select(profiles, id="profile-select", allow_blank=True, prompt="(без профілю)") - else: - yield Select([("(немає профілів)", "")], 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", id="period-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) - yield Button("↩ Назад", id="back-btn") - - with Vertical(id="log-panel") as log_panel: - log_panel.border_title = "Журнал виконання" - yield RichLog(id="export-log", highlight=True, markup=True, wrap=True) - yield Static("", id="export-status", classes="status-bar") - yield LoadingIndicator(id="export-loading") - yield Footer() - - def on_mount(self) -> None: - self.query_one("#export-loading", LoadingIndicator).display = False - self._toggle_period_input() - - @on(Select.Changed, "#period-type-select") - def _on_period_type_changed(self, event: Select.Changed) -> None: - self._toggle_period_input() - - def _toggle_period_input(self) -> None: - period_type = self.query_one("#period-type-select", Select).value - period_input = self.query_one("#period-value-input", Input) - period_label = self.query_one("#period-label", Label) - - # Types that don't need input - no_input_types = ["current-month", "last-month", "current-quarter", "last-quarter", "year-to-date"] - - if period_type in no_input_types: - period_input.display = False - period_label.display = False - else: - period_input.display = True - period_label.display = True - if period_type == "last-weeks": - period_label.update("Значення N (тижні):") - period_input.placeholder = "Наприклад: 4" - elif period_type == "manual": - period_label.update("Значення YYYY-WW:YYYY-WW:") - period_input.placeholder = "Наприклад: 2024-01:2024-04" - - def _build_argv(self) -> list[str]: - argv = ["olap.py"] - - profile_widget = self.query_one("#profile-select", Select) - if profile_widget.value and profile_widget.value is not Select.BLANK: - argv += ["--profile", str(profile_widget.value)] - - 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": - if hasattr(self, "_worker"): - self._worker.cancel() - elif event.button.id == "back-btn": - self.app.pop_screen() - - def _start_export(self) -> None: - # Отримуємо log на головному потоці — query_one небезпечний з executor threads - log = self.query_one("#export-log", RichLog) - status = self.query_one("#export-status", Static) - log.clear() - status.update("") - 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 - self.query_one("#export-loading", LoadingIndicator).display = True - self._worker = self.run_worker(self._do_export(argv, log, status), exclusive=True, name="olap-export") - - async def _do_export(self, argv: list[str], log: RichLog, status: Static) -> None: - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, self._run_export_sync, argv, log, status) - - def _run_export_sync(self, argv: list[str], log: RichLog, status: Static) -> None: - from olap_tool.core.runner import main as runner_main - from olap_tool.core.utils import TUIStream - stream = TUIStream(self.app, log, status) - old_stdout = sys.stdout - old_stderr = sys.stderr - old_argv = sys.argv - sys.stdout = stream - sys.stderr = stream - sys.argv = argv - success = False - try: - result = runner_main() - success = (result == 0) - msg = ( - "[bold green]✓ Завершено успішно[/bold green]" - if success - 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]") - success = False - finally: - sys.argv = old_argv - sys.stdout = old_stdout - sys.stderr = old_stderr - self.app.call_from_thread(self._on_export_done, success) - - def _on_export_done(self, success: bool = False) -> None: - self.query_one("#run-btn", Button).disabled = False - self.query_one("#cancel-btn", Button).disabled = True - self.query_one("#export-loading", LoadingIndicator).display = False - self.query_one("#export-status", Static).update("") - - if success: - self.app.notify( - "Експорт завершено успішно ✔", - title="Готово", - severity="information", - ) - else: - self.app.notify( - "Експорт завершився з помилкою. Перевірте журнал …", - title="Помилка", - severity="error", - ) - diff --git a/olap_tool/tui/screens/xlsx_import.py b/olap_tool/tui/screens/xlsx_import.py deleted file mode 100644 index d89039e..0000000 --- a/olap_tool/tui/screens/xlsx_import.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Екран імпорту XLSX файлів в аналітичне сховище.""" -from __future__ import annotations - -import importlib.util -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, LoadingIndicator, Static - -from olap_tool.core.utils import TUIStream - - -class XlsxImportScreen(Screen): - """Екран: Імпорт XLSX в аналітику.""" - - BINDINGS = [("escape", "app.pop_screen", "Назад")] - - def compose(self) -> ComposeResult: - yield Header(show_clock=True) - with Horizontal(): - with Vertical(classes="form-container") as form: - form.border_title = "Параметри Імпорту" - 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) - yield Button("↩ Назад", id="back-btn") - - with Vertical(id="log-panel") as log_panel: - log_panel.border_title = "Журнал виконання" - yield RichLog(id="import-log", highlight=True, markup=True, wrap=True) - yield Static("", id="import-status", classes="status-bar") - yield LoadingIndicator(id="import-loading") - yield Footer() - - def on_mount(self) -> None: - self.query_one("#import-loading", LoadingIndicator).display = False - - 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]: - 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() - elif event.button.id == "back-btn": - self.app.pop_screen() - - def _start_import(self) -> None: - # Отримуємо log на головному потоці — query_one небезпечний з executor threads - log = self.query_one("#import-log", RichLog) - status = self.query_one("#import-status", Static) - log.clear() - status.update("") - 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.query_one("#import-loading", LoadingIndicator).display = True - self._worker = self.run_worker(self._do_import(script_args, log, status), exclusive=True, name="xlsx-import") - - async def _do_import(self, script_args: list[str], log: RichLog, status: Static) -> None: - import asyncio - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, self._run_import_sync, script_args, log, status) - - def _run_import_sync(self, script_args: list[str], log: RichLog, status: Static) -> None: - stream = TUIStream(self.app, log, status) - old_stdout = sys.stdout - old_argv = sys.argv - sys.stdout = stream - sys.argv = script_args - success = False - try: - script_path = ( - Path(__file__).parent.parent.parent.parent / "scripts" / "import_xlsx.py" - ) - spec = importlib.util.spec_from_file_location("import_xlsx", script_path) - if spec is None or spec.loader is None: - raise ImportError(f"Не вдалося завантажити: {script_path}") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) # type: ignore[union-attr] - mod.main() - self.app.call_from_thread( - log.write, "[bold green]✓ Імпорт завершено[/bold green]" - ) - success = True - except SystemExit as se: - success = (se.code == 0) if se.code is not None else True - except Exception as exc: - self.app.call_from_thread(log.write, f"[bold red]✗ Помилка: {exc}[/bold red]") - success = False - finally: - sys.argv = old_argv - sys.stdout = old_stdout - self.app.call_from_thread(self._on_done, success) - - def _on_done(self, success: bool = False) -> None: - self.query_one("#run-btn", Button).disabled = False - self.query_one("#cancel-btn", Button).disabled = True - self.query_one("#import-loading", LoadingIndicator).display = False - self.query_one("#import-status", Static).update("") - - if success: - self.app.notify( - "Імпорт завершено успішно ✔", title="Готово", severity="information" - ) - else: - self.app.notify( - "Імпорт завершився з помилкою. Перевірте журнал …", - title="Помилка", - severity="error", - ) - diff --git a/olap_tool/tui/widgets/__init__.py b/olap_tool/tui/widgets/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/requirements.txt b/requirements.txt index d65d054..b577a58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,5 +22,5 @@ psycopg2-binary>=2.9.0 # Для завантаження даних у Post 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, панелі, таблиці -textual>=0.70.0 # TUI фреймворк для інтерактивного меню +InquirerPy>=0.3.4 # Консольне інтерактивне меню зі стрілковою навігацією pyarrow>=14.0.0 From 1b14de50441f452d2f9c2a0415f826fb97e35bda Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:24:16 +0200 Subject: [PATCH 21/28] =?UTF-8?q?fix:=20=D0=B2=D0=B8=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D0=B8=20=D0=BC=D0=B5=D1=80=D1=82=D0=B2=D1=96=20TUI?= =?UTF-8?q?=20=D0=B3=D1=96=D0=BB=D0=BA=D0=B8=20=D0=B7=20auth.py,=20prompt.?= =?UTF-8?q?py;=20=D0=BE=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D0=B8=20olap.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- olap.py | 13 +++---------- olap_tool/connection/auth.py | 27 +-------------------------- olap_tool/connection/prompt.py | 31 ------------------------------- 3 files changed, 4 insertions(+), 67 deletions(-) diff --git a/olap.py b/olap.py index a833998..cc7e218 100644 --- a/olap.py +++ b/olap.py @@ -2,7 +2,7 @@ """ OLAP Export Tool — точка входу. -Без аргументів → запускає Textual TUI. +Без аргументів → консольне інтерактивне меню. З аргументами → CLI режим. """ import sys @@ -17,15 +17,8 @@ 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) + from olap_tool.ui.menu import run + run() else: from olap_tool.core.runner import main sys.exit(main()) diff --git a/olap_tool/connection/auth.py b/olap_tool/connection/auth.py index 5e6cd48..b9b5252 100644 --- a/olap_tool/connection/auth.py +++ b/olap_tool/connection/auth.py @@ -89,32 +89,7 @@ def load_credentials( import getpass from colorama import Fore - import sys - if hasattr(sys, "stdout") and hasattr(sys.stdout, "_app"): - # TUI mode - app = getattr(sys.stdout, "_app") - import threading - event = threading.Event() - res_mp = [None] - def show_mp_dialog(): - try: - from olap_tool.tui.screens.credentials import CredentialsDialog - def cb(res: tuple[str, str] | None): - if res: - res_mp[0] = res[1] # dialog returns (login, pwd) - event.set() - dialog = CredentialsDialog( - message="Введіть майстер-пароль для розшифрування:", - ask_login=False - ) - app.push_screen(dialog, cb) - except Exception: - event.set() - app.call_from_thread(show_mp_dialog) - event.wait() - mp_retry = res_mp[0] - else: - mp_retry = getpass.getpass( + mp_retry = getpass.getpass( f"{Fore.CYAN}Введіть майстер-пароль для розшифрування: {Fore.RESET}" ) base_secret_retry = f"{machine_id}:{mp_retry}" if mp_retry else machine_id diff --git a/olap_tool/connection/prompt.py b/olap_tool/connection/prompt.py index 3ec9c4c..3f32a36 100644 --- a/olap_tool/connection/prompt.py +++ b/olap_tool/connection/prompt.py @@ -6,37 +6,6 @@ def prompt_credentials(with_domain: bool = False, domain: Optional[str] = None): - import sys - if hasattr(sys, "stdout") and hasattr(sys.stdout, "_app"): - # TUI mode - app = getattr(sys.stdout, "_app") - import threading - event = threading.Event() - result_store = [None, None] - - def show_dialog(): - try: - from olap_tool.tui.screens.credentials import CredentialsDialog - def cb(res: tuple[str, str] | None): - if res: - result_store[0], result_store[1] = res - event.set() - app.push_screen(CredentialsDialog(domain=domain), cb) - except Exception as e: - print_info(f"Помилка виклику TUI діалогу: {e}") - event.set() - - app.call_from_thread(show_dialog) - event.wait() - - username, password = result_store[0], result_store[1] - if with_domain and username and domain: - if "\\" not in username and not username.startswith(f"{domain}\\"): - username = f"{domain}\\{username}" - print_info(f"Використовуємо повне ім'я користувача: {username}") - return username, password - - # CLI mode print_info("Введіть облікові дані для підключення до OLAP:") username = input(f"{Fore.CYAN}Ім'я користувача: {Fore.RESET}") password = getpass.getpass(f"{Fore.CYAN}Пароль: {Fore.RESET}") From e267ddee513cad83ffb0c80f0ee4ae1676ed0994 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:26:21 +0200 Subject: [PATCH 22/28] =?UTF-8?q?feat:=20olap=5Ftool/ui=20=E2=80=94=20?= =?UTF-8?q?=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BD=D0=B5=20=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8E=20=D1=82=D0=B0=20wizard=20OLAP=20Export=20(InquirerPy=20?= =?UTF-8?q?+=20rich)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- olap_tool/ui/__init__.py | 1 + olap_tool/ui/menu.py | 68 ++++++++++++ olap_tool/ui/olap_export.py | 210 ++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 olap_tool/ui/__init__.py create mode 100644 olap_tool/ui/menu.py create mode 100644 olap_tool/ui/olap_export.py 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..18487ac --- /dev/null +++ b/olap_tool/ui/olap_export.py @@ -0,0 +1,210 @@ +"""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": "з початку року", +} + + +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]") From 385f4fa3b9d795f45fe7f4ec60bf661fb6325815 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:27:40 +0200 Subject: [PATCH 23/28] =?UTF-8?q?fix:=20=D0=B4=D0=BE=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=20'manual'=20=D0=B4=D0=BE=20=5FPERIOD=5FLABELS,=20guard?= =?UTF-8?q?=20None=20result=20=D1=83=20olap=5Fexport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- olap_tool/ui/olap_export.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/olap_tool/ui/olap_export.py b/olap_tool/ui/olap_export.py index 18487ac..d8e6ed1 100644 --- a/olap_tool/ui/olap_export.py +++ b/olap_tool/ui/olap_export.py @@ -96,6 +96,7 @@ def _show_summary(params: dict[str, str]) -> None: "current-quarter": "поточний квартал", "last-quarter": "попередній квартал", "year-to-date": "з початку року", + "manual": "ручний діапазон", } @@ -204,7 +205,7 @@ def run_wizard() -> None: finally: sys.argv = old_argv - if result == 0: + if (result or 0) == 0: console.print("\n[bold green]✓ Завершено успішно[/bold green]") else: console.print(f"\n[bold red]✗ Завершено з помилкою (код {result})[/bold red]") From cc72227b050db3c90534aae3718ae230a9724d5f Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:29:40 +0200 Subject: [PATCH 24/28] feat: wizard XLSX Import (InquirerPy + rich) Co-Authored-By: Claude Sonnet 4.6 --- olap_tool/ui/xlsx_import.py | 179 ++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 olap_tool/ui/xlsx_import.py 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 From 6def558cf00735c30449833c34600b3cf5b4d69a Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:35:33 +0200 Subject: [PATCH 25/28] =?UTF-8?q?fix:=20pyrightconfig.json=20(reportPrivat?= =?UTF-8?q?eImportUsage=20none),=20type:=20ignore=20=D0=B4=D0=BB=D1=8F=20q?= =?UTF-8?q?uoting=20=D1=83=20exporter.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- olap_tool/data/exporter.py | 2 +- pyrightconfig.json | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 pyrightconfig.json diff --git a/olap_tool/data/exporter.py b/olap_tool/data/exporter.py index 0524c31..d0c35b0 100644 --- a/olap_tool/data/exporter.py +++ b/olap_tool/data/exporter.py @@ -37,7 +37,7 @@ def write_chunk(self, df: pd.DataFrame): encoding=self.encoding, index=False, header=self.is_first, - quoting=self.quoting, + quoting=self.quoting, # type: ignore[arg-type] na_rep="" ) self.is_first = False 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" +} From c68f652badd3507defd39d462c3f90b52cb96964 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:38:59 +0200 Subject: [PATCH 26/28] =?UTF-8?q?fix:=20=D1=96=D0=BD=D1=96=D1=86=D1=96?= =?UTF-8?q?=D0=B0=D0=BB=D1=96=D0=B7=D1=83=D0=B2=D0=B0=D1=82=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=BC=D1=96=D0=BD=D0=BD=D1=96=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=20try=20(sinks,=20saved=5Fargv,=20cursor,=20=5Fch/=5Fpg=5Fcfg?= =?UTF-8?q?=5Fkwargs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- olap_tool/core/runner.py | 3 ++- olap_tool/core/scheduler.py | 5 ++--- olap_tool/data/queries.py | 3 ++- scripts/import_xlsx.py | 3 +++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/olap_tool/core/runner.py b/olap_tool/core/runner.py index 9ebe0b1..90261b2 100644 --- a/olap_tool/core/runner.py +++ b/olap_tool/core/runner.py @@ -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/core/scheduler.py b/olap_tool/core/scheduler.py index 49ad040..4e969fa 100644 --- a/olap_tool/core/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: diff --git a/olap_tool/data/queries.py b/olap_tool/data/queries.py index a016bc3..3362f2d 100644 --- a/olap_tool/data/queries.py +++ b/olap_tool/data/queries.py @@ -309,7 +309,7 @@ def run_dax_query( return None finally: # Закриваємо курсор, щоб звільнити XmlReader на з'єднанні - if 'cursor' in locals() and cursor is not None: + if cursor is not None: try: cursor.close() except Exception: @@ -340,6 +340,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/scripts/import_xlsx.py b/scripts/import_xlsx.py index bc11e58..6f5186a 100644 --- a/scripts/import_xlsx.py +++ b/scripts/import_xlsx.py @@ -357,6 +357,9 @@ def main() -> int: 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: From 0296fdac47a806ad60ce9956447ca049283369d9 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:39:35 +0200 Subject: [PATCH 27/28] =?UTF-8?q?fix:=20cursor=20=3D=20None=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=20=D0=B4=D1=80=D1=83=D0=B3=D0=B8=D0=BC=20try?= =?UTF-8?q?-=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=BC=20=D1=83=20queries.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- olap_tool/data/queries.py | 1 + 1 file changed, 1 insertion(+) diff --git a/olap_tool/data/queries.py b/olap_tool/data/queries.py index 3362f2d..c638cb3 100644 --- a/olap_tool/data/queries.py +++ b/olap_tool/data/queries.py @@ -168,6 +168,7 @@ def run_dax_query( print_progress("Виконання запиту до OLAP-кубу...") query_start_time = _time.time() + cursor = None spinner_thread = None try: cursor = connection.cursor() From 14a16a153d65b4a032fbc0650c326980cc9006d5 Mon Sep 17 00:00:00 2001 From: Yevhenii Starychenko Date: Mon, 16 Mar 2026 08:50:16 +0200 Subject: [PATCH 28/28] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D0=BC=D1=96=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B8=20cursor.fetchmany()=20=D0=BD=D0=B0=20=D1=96?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=B0=D1=86=D1=96=D1=8E=20fetchone()=20?= =?UTF-8?q?=E2=80=94=20=D0=B1=D0=B0=D0=B3=20pyadomd=20=D1=80=D1=83=D0=B9?= =?UTF-8?q?=D0=BD=D1=83=D1=94=20XmlReader=20=D1=81=D1=82=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- olap_tool/data/queries.py | 46 +++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/olap_tool/data/queries.py b/olap_tool/data/queries.py index c638cb3..62a26b9 100644 --- a/olap_tool/data/queries.py +++ b/olap_tool/data/queries.py @@ -247,17 +247,18 @@ def run_dax_query( is_first_chunk = True print_progress("Експорт/збереження отриманих даних (потоковий режим)...") - while True: - chunk = cursor.fetchmany(chunk_size) - if not chunk: - break - - converted_chunk = [] - for row in chunk: - converted_chunk.append([convert_dotnet_to_python(v) for v in row]) - - df_chunk = pd.DataFrame(converted_chunk, columns=renamed_columns) - + # Використовуємо пряму ітерацію 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: @@ -268,7 +269,6 @@ def run_dax_query( 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: @@ -281,6 +281,28 @@ def run_dax_query( 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: