diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..ea6a1d1 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1 @@ +# заглушка \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..018603c --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python cache +**/__pycache__/ +*.py[cod] +*.pyo + +# Частые кэши инструментов +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.cache/ + +# Кэш приложения +/cache-dir/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index e69de29..f955f6c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,3 @@ +# hhcli + +description = "Неофициальный CLI-клиент для поиска работы и откликов на hh.ru" diff --git a/hhcli/__init__.py b/hhcli/__init__.py new file mode 100644 index 0000000..a49d4e0 --- /dev/null +++ b/hhcli/__init__.py @@ -0,0 +1,17 @@ +from hhcli.ui import ( # noqa: F401 + AVAILABLE_THEMES, + ConfigScreen, + CssManager, + HHCliApp, + HHCliThemeBase, + list_themes, +) + +__all__ = [ + "CssManager", + "HHCliThemeBase", + "AVAILABLE_THEMES", + "list_themes", + "ConfigScreen", + "HHCliApp", +] diff --git a/hhcli/client.py b/hhcli/client.py new file mode 100644 index 0000000..1597afa --- /dev/null +++ b/hhcli/client.py @@ -0,0 +1,361 @@ +import webbrowser +import threading +from time import sleep +from datetime import datetime, timedelta + +import requests +from flask import Flask, request, render_template_string + +from hhcli.database import ( + save_or_update_profile, load_profile, delete_profile, + log_to_db, get_last_sync_timestamp, set_last_sync_timestamp, + upsert_negotiation_history +) +from .constants import ApiErrorReason, LogSource + +API_BASE_URL = "https://api.hh.ru" +OAUTH_URL = "https://hh.ru/oauth" +REDIRECT_URI = "http://127.0.0.1:9037/oauth_callback" + + +class HHApiClient: + """ + Клиент для взаимодействия с API HeadHunter. + Управляет аутентификацией и выполняет запросы. + """ + def __init__(self): + self.access_token = None + self.refresh_token = None + self.token_expires_at = None + self.profile_name = None + + def load_profile_data(self, profile_name: str): + profile_data = load_profile(profile_name) + if not profile_data: + raise ValueError(f"Профиль '{profile_name}' не найден.") + self.profile_name = profile_data['profile_name'] + self.access_token = profile_data['access_token'] + self.refresh_token = profile_data['refresh_token'] + self.token_expires_at = profile_data['expires_at'] + + def is_authenticated(self) -> bool: + return (self.access_token is not None and + self.token_expires_at > datetime.now()) + + def _save_token(self, token_data: dict, user_info: dict): + expires_in = token_data.get("expires_in", 3600) + expires_at = datetime.now() + timedelta(seconds=expires_in) + save_or_update_profile( + self.profile_name, user_info, token_data, expires_at + ) + self.access_token = token_data["access_token"] + self.refresh_token = token_data["refresh_token"] + self.token_expires_at = expires_at + + def _refresh_token(self): + if not self.refresh_token: + msg = (f"Нет refresh_token для обновления " + f"профиля '{self.profile_name}'.") + log_to_db("ERROR", LogSource.API_CLIENT, msg) + raise Exception(msg) + log_to_db("INFO", LogSource.API_CLIENT, + f"Токен для профиля '{self.profile_name}' истек, обновляю...") + payload = { + "grant_type": "refresh_token", + "refresh_token": self.refresh_token + } + response = requests.post(f"{OAUTH_URL}/token", data=payload) + try: + response.raise_for_status() + except requests.HTTPError as e: + log_to_db("ERROR", LogSource.API_CLIENT, + f"Ошибка обновления токена: {e.response.text}") + raise e + new_token_data = response.json() + user_info = load_profile(self.profile_name) + self._save_token(new_token_data, user_info) + log_to_db("INFO", LogSource.API_CLIENT, "Токен успешно обновлен.") + + def authorize(self, profile_name: str): + self.profile_name = profile_name + + PROXY_BASE_URL = "https://hh.ether-memory.com" + PROXY_CONFIG_URL = f"{PROXY_BASE_URL}/api/get_config" + PROXY_EXCHANGE_URL = f"{PROXY_BASE_URL}/api/exchange_code" + + try: + print("Получение конфигурации с сервера...") + config_resp = requests.get(PROXY_CONFIG_URL) + config_resp.raise_for_status() + public_client_id = config_resp.json()["client_id"] + except requests.RequestException as e: + print(f"Критическая ошибка: не удалось получить конфигурацию с сервера: {e}") + log_to_db("ERROR", LogSource.OAUTH, f"Не удалось получить Client ID с прокси-сервера: {e}") + return # Прерываем авторизацию + + auth_url = (f"{OAUTH_URL}/authorize?response_type=code&" + f"client_id={public_client_id}&redirect_uri={REDIRECT_URI}") + server_shutdown_event = threading.Event() + app = Flask(__name__) + + @app.route("/oauth_callback") + def oauth_callback(): + code = request.args.get("code") + if not code: + return "Ошибка: не удалось получить код авторизации.", 400 + try: + proxy_payload = {"code": code} + response = requests.post(PROXY_EXCHANGE_URL, json=proxy_payload) + response.raise_for_status() + token_data = response.json() + + headers = { + "Authorization": f"Bearer {token_data['access_token']}" + } + user_info_resp = requests.get( + f"{API_BASE_URL}/me", headers=headers + ) + user_info_resp.raise_for_status() + self._save_token(token_data, user_info_resp.json()) + server_shutdown_event.set() + return render_template_string( + "
Можете закрыть эту вкладку " + "и вернуться в терминал.
" + ) + except requests.RequestException as e: + log_to_db("ERROR", LogSource.OAUTH, f"Ошибка при получении токена: {e}") + return f"Произошла ошибка при получении токена: {e}", 500 + + server_thread = threading.Thread( + target=lambda: app.run(port=9037, debug=False) + ) + server_thread.daemon = True + server_thread.start() + print("Сейчас в вашем браузере откроется страница " + "для входа в аккаунт hh.ru...") + sleep(1) + webbrowser.open(auth_url) + print("Ожидание успешной аутентификации...") + server_shutdown_event.wait() + + def _request(self, method: str, endpoint: str, **kwargs): + if not self.is_authenticated(): + try: + self._refresh_token() + except Exception as e: + msg = ("Не удалось обновить токен. " + "Авторизация не удалась. Ошибка: {e}") + log_to_db("ERROR", LogSource.API_CLIENT, msg) + raise ConnectionError( + "Не удалось обновить токен. Попробуйте пере-авторизоваться." + ) from e + headers = kwargs.setdefault("headers", {}) + headers["Authorization"] = f"Bearer {self.access_token}" + url = f"{API_BASE_URL}{endpoint}" + try: + response = requests.request(method, url, **kwargs) + response.raise_for_status() + if response.status_code in (201, 204): + return None + return response.json() + except requests.HTTPError as e: + if e.response.status_code == 401: + log_to_db( + "WARN", LogSource.API_CLIENT, + f"Получен 401 Unauthorized для {endpoint}. " + f"Попытка обновить токен." + ) + try: + self._refresh_token() + headers["Authorization"] = f"Bearer {self.access_token}" + response = requests.request(method, url, **kwargs) + response.raise_for_status() + if response.status_code in (201, 204): + return None + return response.json() + except Exception as refresh_e: + msg = ("Повторная попытка обновления токена не удалась. " + f"Ошибка: {refresh_e}") + log_to_db("ERROR", LogSource.API_CLIENT, msg) + raise ConnectionError( + "Не удалось обновить токен. " + "Попробуйте пере-авторизоваться." + ) from refresh_e + else: + log_to_db( + "ERROR", LogSource.API_CLIENT, + f"HTTP ошибка для {method} {endpoint}: " + f"{e.response.status_code} {e.response.text}" + ) + raise e + + def get_my_resumes(self): + return self._request("GET", "/resumes/mine") + + def get_similar_vacancies( + self, resume_id: str, page: int = 0, per_page: int = 50 + ): + params = {"page": page, "per_page": per_page} + data = self._request( + "GET", f"/resumes/{resume_id}/similar_vacancies", params=params + ) + data.setdefault("pages", data.get("found", 0) // per_page + 1) + return data + + def search_vacancies( + self, config: dict, page: int = 0, per_page: int = 50 + ): + """ + Выполняет поиск вакансий по параметрам из конфигурации профиля. + """ + positive_keywords = config.get('text_include', []) + positive_str = " OR ".join(f'"{kw}"' for kw in positive_keywords) + + negative_keywords = config.get('negative', []) + negative_str = " OR ".join(f'"{kw}"' for kw in negative_keywords) + + text_query = "" + if positive_str: + text_query = f"({positive_str})" + + if negative_str: + if text_query: + text_query += f" NOT ({negative_str})" + else: + text_query = f"NOT ({negative_str})" + + params = { + "text": text_query, + "area": config.get('area_id'), + "professional_role": config.get('role_ids_config', []), + "search_field": config.get('search_field'), + "period": config.get('period'), + "order_by": "publication_time", + "page": page, + "per_page": per_page + } + + if config.get('work_format') and config['work_format'] != "ANY": + params['work_format'] = config['work_format'] + + params = {k: v for k, v in params.items() if v} + + return self._request("GET", "/vacancies", params=params) + + def get_vacancy_details(self, vacancy_id: str): + return self._request("GET", f"/vacancies/{vacancy_id}") + + def get_dictionaries(self): + """Загружает общие справочники hh.ru.""" + log_to_db("INFO", LogSource.API_CLIENT, "Запрос общих справочников...") + return self._request("GET", "/dictionaries") + + def get_areas(self): + """Возвращает полный список регионов hh.ru.""" + log_to_db("INFO", LogSource.API_CLIENT, "Запрос справочника регионов...") + return self._request("GET", "/areas") + + def get_professional_roles(self): + """Возвращает справочник профессиональных ролей hh.ru.""" + log_to_db( + "INFO", LogSource.API_CLIENT, "Запрос справочника профессиональных ролей..." + ) + return self._request("GET", "/professional_roles") + + def sync_negotiation_history(self): + log_to_db( + "INFO", LogSource.SYNC_ENGINE, + f"Запуск синхронизации истории откликов " + f"для профиля '{self.profile_name}'." + ) + last_sync = get_last_sync_timestamp(self.profile_name) + params = {"order_by": "updated_at", "per_page": 100} + if last_sync: + params["date_from"] = last_sync.isoformat() + log_to_db( + "INFO", LogSource.SYNC_ENGINE, + f"Найдена последняя синхронизация: {last_sync}. " + f"Загружаем обновления." + ) + all_items = [] + page = 0 + while True: + params["page"] = page + try: + log_to_db( + "INFO", LogSource.SYNC_ENGINE, + f"Запрос страницы {page} истории откликов..." + ) + data = self._request("GET", "/negotiations", params=params) + items = data.get("items", []) + all_items.extend(items) + if page >= data.get("pages", 0) - 1: + break + page += 1 + except requests.HTTPError as e: + log_to_db( + "ERROR", LogSource.SYNC_ENGINE, + f"Ошибка при загрузке истории откликов: {e}" + ) + return + if all_items: + log_to_db( + "INFO", LogSource.SYNC_ENGINE, + f"Получено {len(all_items)} обновленных записей. " + f"Сохранение в БД..." + ) + upsert_negotiation_history(all_items, self.profile_name) + log_to_db("INFO", LogSource.SYNC_ENGINE, "Сохранение завершено.") + else: + log_to_db( + "INFO", LogSource.SYNC_ENGINE, + "Новых обновлений в истории откликов не найдено." + ) + set_last_sync_timestamp(self.profile_name, datetime.now()) + log_to_db("INFO", LogSource.SYNC_ENGINE, "Синхронизация успешно завершена.") + + def apply_to_vacancy( + self, resume_id: str, vacancy_id: str, + message: str = "" + ) -> tuple[bool, str]: + payload = { + "resume_id": resume_id, + "vacancy_id": vacancy_id, + "message": message + } + try: + self._request("POST", "/negotiations", data=payload) + log_to_db( + "INFO", LogSource.API_CLIENT, + f"Успешный отклик на вакансию {vacancy_id} " + f"с резюме {resume_id}." + ) + return True, ApiErrorReason.APPLIED + except requests.HTTPError as e: + reason = ApiErrorReason.UNKNOWN_API_ERROR + try: + error_data = e.response.json() + if "errors" in error_data and error_data["errors"]: + first_error = error_data["errors"][0] + reason = first_error.get("value", first_error.get("type")) + elif "description" in error_data: + reason = error_data["description"] + except Exception: + reason = f"http_{e.response.status_code}" + + log_to_db( + "WARN", LogSource.API_CLIENT, + f"API отклонил отклик на {vacancy_id}. " + f"Причина: {reason}. Детали: {e.response.text}" + ) + return False, reason + except requests.RequestException as e: + log_to_db("ERROR", LogSource.API_CLIENT, + f"Сетевая ошибка при отклике на {vacancy_id}: {e}") + return False, ApiErrorReason.NETWORK_ERROR + + def logout(self, profile_name: str): + delete_profile(profile_name) + print(f"Профиль '{profile_name}' удален.") + if self.profile_name == profile_name: + self.access_token = None \ No newline at end of file diff --git a/hhcli/constants.py b/hhcli/constants.py new file mode 100644 index 0000000..c863243 --- /dev/null +++ b/hhcli/constants.py @@ -0,0 +1,136 @@ +from enum import Enum + + +class SearchMode(Enum): + """Режимы поиска вакансий.""" + AUTO = "auto" + MANUAL = "manual" + + +class AppStateKeys: + """Ключи для таблицы состояния приложения app_state.""" + ACTIVE_PROFILE = "active_profile" + AREAS_HASH = "areas_hash" + AREAS_UPDATED_AT = "areas_updated_at" + PROFESSIONAL_ROLES_HASH = "professional_roles_hash" + PROFESSIONAL_ROLES_UPDATED_AT = "professional_roles_updated_at" + LAST_NEGOTIATION_SYNC_PREFIX = "last_negotiation_sync_" + + +class ConfigKeys: + """Ключи для конфигурации профиля.""" + TEXT_INCLUDE = "text_include" + NEGATIVE = "negative" + WORK_FORMAT = "work_format" + AREA_ID = "area_id" + SEARCH_FIELD = "search_field" + PERIOD = "period" + ROLE_IDS_CONFIG = "role_ids_config" + COVER_LETTER = "cover_letter" + SKIP_APPLIED_IN_SAME_COMPANY = "skip_applied_in_same_company" + DEDUPLICATE_BY_NAME_AND_COMPANY = "deduplicate_by_name_and_company" + STRIKETHROUGH_APPLIED_VAC = "strikethrough_applied_vac" + STRIKETHROUGH_APPLIED_VAC_NAME = "strikethrough_applied_vac_name" + THEME = "theme" + VACANCY_LEFT_PANE_PERCENT = "vacancy_left_pane_percent" + VACANCY_COL_INDEX_WIDTH = "vacancy_col_index_width" + VACANCY_COL_TITLE_WIDTH = "vacancy_col_title_width" + VACANCY_COL_COMPANY_WIDTH = "vacancy_col_company_width" + VACANCY_COL_PREVIOUS_WIDTH = "vacancy_col_previous_width" + HISTORY_LEFT_PANE_PERCENT = "history_left_pane_percent" + HISTORY_COL_INDEX_WIDTH = "history_col_index_width" + HISTORY_COL_TITLE_WIDTH = "history_col_title_width" + HISTORY_COL_COMPANY_WIDTH = "history_col_company_width" + HISTORY_COL_STATUS_WIDTH = "history_col_status_width" + HISTORY_COL_SENT_WIDTH = "history_col_sent_width" + HISTORY_COL_DATE_WIDTH = "history_col_date_width" + +LAYOUT_WIDTH_KEYS: tuple[str, ...] = ( + ConfigKeys.VACANCY_LEFT_PANE_PERCENT, + ConfigKeys.VACANCY_COL_INDEX_WIDTH, + ConfigKeys.VACANCY_COL_TITLE_WIDTH, + ConfigKeys.VACANCY_COL_COMPANY_WIDTH, + ConfigKeys.VACANCY_COL_PREVIOUS_WIDTH, + ConfigKeys.HISTORY_LEFT_PANE_PERCENT, + ConfigKeys.HISTORY_COL_INDEX_WIDTH, + ConfigKeys.HISTORY_COL_TITLE_WIDTH, + ConfigKeys.HISTORY_COL_COMPANY_WIDTH, + ConfigKeys.HISTORY_COL_STATUS_WIDTH, + ConfigKeys.HISTORY_COL_SENT_WIDTH, + ConfigKeys.HISTORY_COL_DATE_WIDTH, +) + +class ApiErrorReason: + """ + Строковые идентификаторы причин ответа API при отклике на вакансию. + """ + APPLIED = "applied" + ALREADY_APPLIED = "already_applied" + TEST_REQUIRED = "test_required" + QUESTIONS_REQUIRED = "questions_required" + NEGOTIATIONS_FORBIDDEN = "negotiations_forbidden" + RESUME_NOT_PUBLISHED = "resume_not_published" + CONDITIONS_NOT_MET = "conditions_not_met" + NOT_FOUND = "not_found" + BAD_ARGUMENT = "bad_argument" + UNKNOWN_API_ERROR = "unknown_api_error" + NETWORK_ERROR = "network_error" + + +ERROR_REASON_LABELS: dict[str, str] = { + ApiErrorReason.APPLIED: "Отклик отправлен", + ApiErrorReason.ALREADY_APPLIED: "Вы уже откликались", + ApiErrorReason.TEST_REQUIRED: "Требуется пройти тест", + ApiErrorReason.QUESTIONS_REQUIRED: "Требуются ответы на вопросы", + ApiErrorReason.NEGOTIATIONS_FORBIDDEN: "Работодатель запретил отклики", + ApiErrorReason.RESUME_NOT_PUBLISHED: "Резюме не опубликовано", + ApiErrorReason.CONDITIONS_NOT_MET: "Не выполнены условия", + ApiErrorReason.NOT_FOUND: "Вакансия в архиве", + ApiErrorReason.BAD_ARGUMENT: "Некорректные параметры", + ApiErrorReason.UNKNOWN_API_ERROR: "Неизвестная ошибка API", + ApiErrorReason.NETWORK_ERROR: "Ошибка сети", +} + +DELIVERED_STATUS_CODES: frozenset[str] = frozenset({ + "applied", + "responded", + "response", + "invited", + "interview", + "interview_assigned", + "interview_scheduled", + "offer", + "offer_made", + "rejected", + "declined", + "canceled", + "cancelled", + "discard", + "employer_viewed", + "viewed", + "seen", + "in_progress", + "considering", + "processing", + "accepted", + "hired", +}) + +FAILED_STATUS_CODES: frozenset[str] = frozenset({"failed"}) + + +class LogSource: + """Источники для логирования в базу данных.""" + API_CLIENT = "APIClient" + OAUTH = "OAuth" + SYNC_ENGINE = "SyncEngine" + CONFIG_SCREEN = "ConfigScreen" + MAIN = "Main" + REFERENCE_DATA = "ReferenceData" + VACANCY_LIST_FETCH = "VacancyListFetch" + VACANCY_LIST_SCREEN = "VacancyListScreen" + CACHE = "Cache" + RESUME_SCREEN = "ResumeScreen" + SEARCH_MODE_SCREEN = "SearchModeScreen" + PROFILE_SCREEN = "ProfileScreen" + TUI = "TUI" diff --git a/hhcli/database.py b/hhcli/database.py new file mode 100644 index 0000000..0d4f99a --- /dev/null +++ b/hhcli/database.py @@ -0,0 +1,1113 @@ +import os +import json +from datetime import datetime, timedelta +from typing import Any, Iterable, Sequence + +from platformdirs import user_data_dir +from sqlalchemy import ( + create_engine, + MetaData, + Table, + Column, + Integer, + String, + DateTime, + Text, + Boolean, + ForeignKey, + insert, + select, + delete, + update, + text as sa_text, + UniqueConstraint, +) +from sqlalchemy.sql import func +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from .constants import ( + AppStateKeys, + ConfigKeys, + DELIVERED_STATUS_CODES, + ERROR_REASON_LABELS, + FAILED_STATUS_CODES, + LogSource, + LAYOUT_WIDTH_KEYS, +) + +APP_NAME = "hhcli" +APP_AUTHOR = "fovendor" +DATA_DIR = user_data_dir(APP_NAME, APP_AUTHOR) +DB_FILENAME = "hhcli_v2.sqlite" +DB_PATH = os.path.join(DATA_DIR, DB_FILENAME) + +engine = None +metadata = MetaData() + + +def _normalize_status(value: Any) -> str: + return str(value or "").strip().lower() + + +def _status_was_delivered(status: Any) -> bool: + code = _normalize_status(status) + if not code: + return False + if code in FAILED_STATUS_CODES: + return False + if code in DELIVERED_STATUS_CODES: + return True + for prefix in ("applied", "response", "responded", "invited", "offer"): + if code.startswith(prefix): + return True + return False + + +def get_default_config() -> dict[str, Any]: + """Возвращает стандартную конфигурацию поиска для нового профиля.""" + + default_cover_letter = """Здравствуйте! + +Описание вашей вакансии показалось мне интересным, хотелось бы подробнее узнать о требованиях к кандидату и о предстоящих задачах. + +Коротко о себе: +... + +Буду рад обсудить, как мой опыт может быть для вас полезен. + +С уважением, +Имя Фамилия ++7 (000) 000-00-00 | Tg: @nickname | e-mail@gmail.com""" + + return { + ConfigKeys.TEXT_INCLUDE: ["Python developer", "Backend developer"], + ConfigKeys.NEGATIVE: [ + "старший", "senior", "ведущий", "Middle", "ETL", "BI", "ML", + "Data Scientist", "CV", "NLP", "Unity", "Unreal", "C#", "C++" + ], + ConfigKeys.WORK_FORMAT: "REMOTE", + ConfigKeys.AREA_ID: "113", + ConfigKeys.SEARCH_FIELD: "name", + ConfigKeys.PERIOD: "3", + ConfigKeys.ROLE_IDS_CONFIG: [ + "96", "104", "107", "112", "113", "114", "116", "121", "124", + "125", "126" + ], + ConfigKeys.COVER_LETTER: default_cover_letter, + ConfigKeys.SKIP_APPLIED_IN_SAME_COMPANY: False, + ConfigKeys.DEDUPLICATE_BY_NAME_AND_COMPANY: True, + ConfigKeys.STRIKETHROUGH_APPLIED_VAC: True, + ConfigKeys.STRIKETHROUGH_APPLIED_VAC_NAME: True, + ConfigKeys.THEME: "hhcli-base", + ConfigKeys.VACANCY_LEFT_PANE_PERCENT: 60, + ConfigKeys.VACANCY_COL_INDEX_WIDTH: 6, + ConfigKeys.VACANCY_COL_TITLE_WIDTH: 46, + ConfigKeys.VACANCY_COL_COMPANY_WIDTH: 28, + ConfigKeys.VACANCY_COL_PREVIOUS_WIDTH: 20, + ConfigKeys.HISTORY_LEFT_PANE_PERCENT: 55, + ConfigKeys.HISTORY_COL_INDEX_WIDTH: 6, + ConfigKeys.HISTORY_COL_TITLE_WIDTH: 38, + ConfigKeys.HISTORY_COL_COMPANY_WIDTH: 24, + ConfigKeys.HISTORY_COL_STATUS_WIDTH: 16, + ConfigKeys.HISTORY_COL_SENT_WIDTH: 4, + ConfigKeys.HISTORY_COL_DATE_WIDTH: 16, + } + + +profiles = Table( + "profiles", metadata, + Column("profile_name", String, primary_key=True), + Column("hh_user_id", String, unique=True, nullable=False), + Column("email", String), + Column("access_token", String, nullable=False), + Column("refresh_token", String, nullable=False), + Column("expires_at", DateTime, nullable=False), +) + +profile_configs = Table( + "profile_configs", metadata, + Column("profile_name", String, + ForeignKey('profiles.profile_name', ondelete='CASCADE'), + primary_key=True), + Column("work_format", String), + Column("area_id", String), + Column("search_field", String), + Column("period", String), + Column("cover_letter", Text), + Column("theme", String, nullable=False, server_default="hhcli-base"), + Column("skip_applied_in_same_company", Boolean, nullable=False, + default=False), + Column("deduplicate_by_name_and_company", Boolean, nullable=False, + default=True), + Column("strikethrough_applied_vac", Boolean, nullable=False, default=True), + Column("strikethrough_applied_vac_name", Boolean, nullable=False, + default=True), + Column("vacancy_left_pane_percent", Integer, nullable=False, server_default="60"), + Column("vacancy_col_index_width", Integer, nullable=False, server_default="6"), + Column("vacancy_col_title_width", Integer, nullable=False, server_default="46"), + Column("vacancy_col_company_width", Integer, nullable=False, server_default="28"), + Column("vacancy_col_previous_width", Integer, nullable=False, server_default="20"), + Column("history_left_pane_percent", Integer, nullable=False, server_default="55"), + Column("history_col_index_width", Integer, nullable=False, server_default="6"), + Column("history_col_title_width", Integer, nullable=False, server_default="38"), + Column("history_col_company_width", Integer, nullable=False, server_default="24"), + Column("history_col_status_width", Integer, nullable=False, server_default="16"), + Column("history_col_sent_width", Integer, nullable=False, server_default="4"), + Column("history_col_date_width", Integer, nullable=False, server_default="16"), +) + +config_negative_keywords = Table( + "config_negative_keywords", metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("profile_name", String, + ForeignKey('profiles.profile_name', ondelete='CASCADE'), + nullable=False, index=True), + Column("keyword", String, nullable=False) +) + +config_positive_keywords = Table( + "config_positive_keywords", metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("profile_name", String, + ForeignKey('profiles.profile_name', ondelete='CASCADE'), + nullable=False, index=True), + Column("keyword", String, nullable=False) +) + +config_professional_roles = Table( + "config_professional_roles", metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("profile_name", String, + ForeignKey('profiles.profile_name', ondelete='CASCADE'), + nullable=False, index=True), + Column("role_id", String, nullable=False) +) + +app_state = Table( + "app_state", metadata, + Column("key", String, primary_key=True), + Column("value", String) +) + +app_logs = Table( + "app_logs", metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("timestamp", DateTime, server_default=func.now()), + Column("level", String(10), nullable=False), + Column("source", String(50)), + Column("message", Text) +) + +negotiation_history = Table( + "negotiation_history", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("vacancy_id", String, nullable=False), + Column("profile_name", String, nullable=False), + Column("resume_id", String, nullable=False, default=""), + Column("resume_title", String), + Column("vacancy_title", String), + Column("employer_name", String), + Column("status", String), + Column("reason", String), + Column("was_delivered", Boolean, nullable=False, server_default="0"), + Column("applied_at", DateTime, nullable=False), + UniqueConstraint("vacancy_id", "resume_id", name="uq_negotiation_vacancy_resume"), +) + +vacancy_cache = Table( + "vacancy_cache", metadata, + Column("vacancy_id", String, primary_key=True), + Column("json_data", Text, nullable=False), + Column("cached_at", DateTime, nullable=False) +) + +dictionaries_cache = Table( + "dictionaries_cache", metadata, + Column("name", String, primary_key=True), + Column("json_data", Text, nullable=False), + Column("cached_at", DateTime, nullable=False) +) + +areas = Table( + "areas", metadata, + Column("id", String, primary_key=True), + Column("parent_id", String, nullable=True, index=True), + Column("name", String, nullable=False), + Column("full_name", String, nullable=False), + Column("search_name", String, nullable=False, index=True), + Column("level", Integer, nullable=False, default=0), + Column("sort_order", Integer, nullable=False, default=0), +) + +professional_roles_catalog = Table( + "professional_roles", metadata, + Column("id", String, primary_key=True), + Column("name", String, nullable=False), + Column("full_name", String, nullable=False), + Column("search_name", String, nullable=False, index=True), + Column("category_id", String, nullable=False, index=True), + Column("category_name", String, nullable=False), + Column("category_order", Integer, nullable=False, default=0), + Column("role_order", Integer, nullable=False, default=0), + Column("sort_order", Integer, nullable=False, default=0), +) + +def save_vacancy_to_cache(vacancy_id: str, vacancy_data: dict): + """Сохраняет JSON-данные вакансии в кэш в виде текста.""" + if not engine: + return + + json_string = json.dumps(vacancy_data, ensure_ascii=False) + + values = { + "vacancy_id": vacancy_id, + "json_data": json_string, + "cached_at": datetime.now() + } + stmt = sqlite_insert(vacancy_cache).values(values) + stmt = stmt.on_conflict_do_update( + index_elements=['vacancy_id'], + set_={ + "json_data": stmt.excluded.json_data, + "cached_at": stmt.excluded.cached_at + } + ) + with engine.connect() as connection: + connection.execute(stmt) + connection.commit() + +def save_dictionary_to_cache(name: str, data: dict): + """Сохраняет справочник в кэш.""" + if not engine: + return + + json_string = json.dumps(data, ensure_ascii=False) + values = { + "name": name, + "json_data": json_string, + "cached_at": datetime.now() + } + stmt = sqlite_insert(dictionaries_cache).values(values) + stmt = stmt.on_conflict_do_update( + index_elements=['name'], + set_={ + "json_data": stmt.excluded.json_data, + "cached_at": stmt.excluded.cached_at + } + ) + with engine.connect() as connection: + connection.execute(stmt) + connection.commit() + +def get_dictionary_from_cache(name: str, max_age_days: int = 7) -> dict | None: + """Извлекает справочник из кэша, если он не устарел.""" + if not engine: + return None + + age_limit = datetime.now() - timedelta(days=max_age_days) + with engine.connect() as connection: + stmt = select(dictionaries_cache.c.json_data).where( + dictionaries_cache.c.name == name, + dictionaries_cache.c.cached_at >= age_limit + ) + result = connection.execute(stmt).scalar_one_or_none() + if result: + return json.loads(result) + return None + +def get_vacancy_from_cache(vacancy_id: str) -> dict | None: + """ + Извлекает данные вакансии из кэша, если они не старше 7 дней. + Возвращает dict или None. + """ + if not engine: + return None + + seven_days_ago = datetime.now() - timedelta(days=7) + with engine.connect() as connection: + stmt = select(vacancy_cache.c.json_data).where( + vacancy_cache.c.vacancy_id == vacancy_id, + vacancy_cache.c.cached_at >= seven_days_ago + ) + result = connection.execute(stmt).scalar_one_or_none() + + if result: + return json.loads(result) + return None + +def _upsert_app_state(connection, key: str, value: str) -> None: + stmt = sqlite_insert(app_state).values(key=key, value=value) + stmt = stmt.on_conflict_do_update(index_elements=["key"], set_=dict(value=value)) + connection.execute(stmt) + + +def get_app_state_value(key: str) -> str | None: + """Возвращает значение из таблицы состояния приложения.""" + if not engine: + return None + with engine.connect() as connection: + stmt = select(app_state.c.value).where(app_state.c.key == key) + return connection.execute(stmt).scalar_one_or_none() + +def set_app_state_value(key: str, value: str) -> None: + """Сохраняет ключ-значение в таблицу состояния приложения.""" + if not engine: + return + with engine.begin() as connection: + _upsert_app_state(connection, key, value) + +def replace_areas(records: Sequence[dict[str, Any]], *, data_hash: str) -> None: + """Полностью заменяет таблицу регионов на переданные данные.""" + if not engine: + return + prepared = [ + { + "id": str(item["id"]), + "parent_id": str(item["parent_id"]) if item.get("parent_id") else None, + "name": item["name"], + "full_name": item["full_name"], + "search_name": item["search_name"], + "level": int(item.get("level", 0)), + "sort_order": int(item.get("sort_order", index)), + } + for index, item in enumerate(records) + ] + with engine.begin() as connection: + connection.execute(areas.delete()) + if prepared: + connection.execute(areas.insert(), prepared) + timestamp = datetime.now().isoformat() + _upsert_app_state(connection, AppStateKeys.AREAS_HASH, data_hash) + _upsert_app_state(connection, AppStateKeys.AREAS_UPDATED_AT, timestamp) + +def replace_professional_roles(records: Sequence[dict[str, Any]], *, data_hash: str) -> None: + """Полностью заменяет таблицу профессиональных ролей на переданные данные.""" + if not engine: + return + prepared: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + duplicate_count = 0 + for item in records: + rid = str(item["id"]) + if rid in seen_ids: + duplicate_count += 1 + continue + seen_ids.add(rid) + prepared.append( + { + "id": rid, + "name": item["name"], + "full_name": item["full_name"], + "search_name": item["search_name"], + "category_id": str(item["category_id"]), + "category_name": item["category_name"], + "category_order": int(item.get("category_order", 0)), + "role_order": int(item.get("role_order", 0)), + "sort_order": len(prepared), + } + ) + with engine.begin() as connection: + connection.execute(professional_roles_catalog.delete()) + if prepared: + connection.execute(professional_roles_catalog.insert(), prepared) + timestamp = datetime.now().isoformat() + _upsert_app_state(connection, AppStateKeys.PROFESSIONAL_ROLES_HASH, data_hash) + _upsert_app_state(connection, AppStateKeys.PROFESSIONAL_ROLES_UPDATED_AT, timestamp) + if duplicate_count: + log_to_db( + "WARN", + LogSource.REFERENCE_DATA, + f"Получено дубликатов профессиональных ролей: {duplicate_count}", + ) + +def list_areas() -> list[dict[str, Any]]: + """Возвращает список всех регионов в порядке сортировки.""" + if not engine: + return [] + with engine.connect() as connection: + stmt = ( + select( + areas.c.id, + areas.c.parent_id, + areas.c.name, + areas.c.full_name, + areas.c.search_name, + areas.c.level, + areas.c.sort_order, + ) + .order_by(areas.c.sort_order) + ) + rows = connection.execute(stmt).fetchall() + return [dict(row._mapping) for row in rows] + +def list_professional_roles() -> list[dict[str, Any]]: + """Возвращает все профессиональные роли в порядке категорий и ролей.""" + if not engine: + return [] + with engine.connect() as connection: + stmt = ( + select( + professional_roles_catalog.c.id, + professional_roles_catalog.c.name, + professional_roles_catalog.c.full_name, + professional_roles_catalog.c.search_name, + professional_roles_catalog.c.category_id, + professional_roles_catalog.c.category_name, + professional_roles_catalog.c.category_order, + professional_roles_catalog.c.role_order, + professional_roles_catalog.c.sort_order, + ) + .order_by( + professional_roles_catalog.c.category_order, + professional_roles_catalog.c.role_order, + ) + ) + rows = connection.execute(stmt).fetchall() + return [dict(row._mapping) for row in rows] + +def get_area_full_name(area_id: str) -> str | None: + """Возвращает полное название региона по ID.""" + if not engine: + return None + with engine.connect() as connection: + stmt = select(areas.c.full_name).where(areas.c.id == str(area_id)) + return connection.execute(stmt).scalar_one_or_none() + +def get_professional_roles_by_ids(role_ids: Sequence[str]) -> list[dict[str, Any]]: + """Возвращает данные по списку ID профессиональных ролей, сохраняя порядок входных данных.""" + if not engine or not role_ids: + return [] + normalized_ids = [str(rid) for rid in role_ids] + with engine.connect() as connection: + stmt = select( + professional_roles_catalog.c.id, + professional_roles_catalog.c.name, + professional_roles_catalog.c.full_name, + professional_roles_catalog.c.category_name, + ).where(professional_roles_catalog.c.id.in_(normalized_ids)) + rows = connection.execute(stmt).fetchall() + mapped = {row._mapping["id"]: dict(row._mapping) for row in rows} + return [mapped[rid] for rid in normalized_ids if rid in mapped] + +def init_db(): + global engine + os.makedirs(DATA_DIR, exist_ok=True) + print(f"INFO: База данных используется по пути: {DB_PATH}") + engine = create_engine(f"sqlite:///{DB_PATH}") + metadata.create_all(engine) + ensure_schema_upgrades() + +def ensure_schema_upgrades() -> None: + """Гарантирует наличие новых колонок в существующей БД.""" + if not engine: + return + + defaults = get_default_config() + default_theme = defaults[ConfigKeys.THEME] + + with engine.begin() as connection: + columns = { + row[1] + for row in connection.execute( + sa_text("PRAGMA table_info(profile_configs)") + ) + } + if "theme" not in columns: + connection.execute(sa_text("ALTER TABLE profile_configs ADD COLUMN theme TEXT")) + connection.execute( + sa_text("UPDATE profile_configs SET theme = :theme WHERE theme IS NULL"), + {"theme": default_theme}, + ) + columns.add("theme") + + percent_columns = [ + ("vacancy_left_pane_percent", defaults[ConfigKeys.VACANCY_LEFT_PANE_PERCENT]), + ("history_left_pane_percent", defaults[ConfigKeys.HISTORY_LEFT_PANE_PERCENT]), + ] + width_columns = [ + ("vacancy_col_index_width", defaults[ConfigKeys.VACANCY_COL_INDEX_WIDTH]), + ("vacancy_col_title_width", defaults[ConfigKeys.VACANCY_COL_TITLE_WIDTH]), + ("vacancy_col_company_width", defaults[ConfigKeys.VACANCY_COL_COMPANY_WIDTH]), + ("vacancy_col_previous_width", defaults[ConfigKeys.VACANCY_COL_PREVIOUS_WIDTH]), + ("history_col_index_width", defaults[ConfigKeys.HISTORY_COL_INDEX_WIDTH]), + ("history_col_title_width", defaults[ConfigKeys.HISTORY_COL_TITLE_WIDTH]), + ("history_col_company_width", defaults[ConfigKeys.HISTORY_COL_COMPANY_WIDTH]), + ("history_col_status_width", defaults[ConfigKeys.HISTORY_COL_STATUS_WIDTH]), + ("history_col_sent_width", defaults[ConfigKeys.HISTORY_COL_SENT_WIDTH]), + ("history_col_date_width", defaults[ConfigKeys.HISTORY_COL_DATE_WIDTH]), + ] + + added_columns: set[str] = set() + combined_columns = percent_columns + width_columns + for column_name, default_value in combined_columns: + if column_name not in columns: + connection.execute( + sa_text(f"ALTER TABLE profile_configs ADD COLUMN {column_name} INTEGER") + ) + connection.execute( + sa_text( + f"UPDATE profile_configs SET {column_name} = :value WHERE {column_name} IS NULL" + ), + {"value": default_value}, + ) + columns.add(column_name) + added_columns.add(column_name) + + added_width_columns = {name for name, _ in width_columns if name in added_columns} + + if added_width_columns: + percent_to_width_map = { + "vacancy_col_index_percent": "vacancy_col_index_width", + "vacancy_col_title_percent": "vacancy_col_title_width", + "vacancy_col_company_percent": "vacancy_col_company_width", + "vacancy_col_previous_percent": "vacancy_col_previous_width", + "history_col_index_percent": "history_col_index_width", + "history_col_title_percent": "history_col_title_width", + "history_col_company_percent": "history_col_company_width", + "history_col_status_percent": "history_col_status_width", + "history_col_date_percent": "history_col_date_width", + } + present_percent_columns = [ + name for name in percent_to_width_map if name in columns + ] + if present_percent_columns: + order_vacancy = [ + "vacancy_col_index_percent", + "vacancy_col_title_percent", + "vacancy_col_company_percent", + "vacancy_col_previous_percent", + ] + order_history = [ + "history_col_index_percent", + "history_col_title_percent", + "history_col_company_percent", + "history_col_status_percent", + "history_col_date_percent", + ] + + def _convert(percent_values: dict[str, int], keys: list[str]) -> dict[str, int]: + active_keys = [key for key in keys if key in percent_values] + if not active_keys: + return {} + total = sum(int(percent_values.get(key, 0) or 0) for key in active_keys) + if total <= 0: + total = len(active_keys) + percent_values = {key: 1 for key in active_keys} + widths: dict[str, int] = {} + remaining = 100 + for key in active_keys[:-1]: + percent = int(percent_values.get(key, 0) or 0) + width = max(1, round(percent / total * 100)) + widths[key] = width + remaining -= width + last_key = active_keys[-1] + widths[last_key] = max(1, remaining) + return widths + + select_columns_list = ["profile_name", *present_percent_columns] + select_columns = ", ".join(select_columns_list) + rows = connection.execute( + sa_text(f"SELECT {select_columns} FROM profile_configs") + ).fetchall() + for row in rows: + data = dict(row._mapping) + vacancy_percent_values = {key: data.get(key) for key in order_vacancy if key in data} + history_percent_values = {key: data.get(key) for key in order_history if key in data} + vacancy_widths = _convert(vacancy_percent_values, order_vacancy) + history_widths = _convert(history_percent_values, order_history) + params = { + "profile_name": data["profile_name"], + "vacancy_col_index_width": vacancy_widths.get( + "vacancy_col_index_percent", defaults[ConfigKeys.VACANCY_COL_INDEX_WIDTH] + ), + "vacancy_col_title_width": vacancy_widths.get( + "vacancy_col_title_percent", defaults[ConfigKeys.VACANCY_COL_TITLE_WIDTH] + ), + "vacancy_col_company_width": vacancy_widths.get( + "vacancy_col_company_percent", defaults[ConfigKeys.VACANCY_COL_COMPANY_WIDTH] + ), + "vacancy_col_previous_width": vacancy_widths.get( + "vacancy_col_previous_percent", defaults[ConfigKeys.VACANCY_COL_PREVIOUS_WIDTH] + ), + "history_col_index_width": history_widths.get( + "history_col_index_percent", defaults[ConfigKeys.HISTORY_COL_INDEX_WIDTH] + ), + "history_col_title_width": history_widths.get( + "history_col_title_percent", defaults[ConfigKeys.HISTORY_COL_TITLE_WIDTH] + ), + "history_col_company_width": history_widths.get( + "history_col_company_percent", defaults[ConfigKeys.HISTORY_COL_COMPANY_WIDTH] + ), + "history_col_status_width": history_widths.get( + "history_col_status_percent", defaults[ConfigKeys.HISTORY_COL_STATUS_WIDTH] + ), + "history_col_sent_width": history_widths.get( + "history_col_sent_percent", defaults[ConfigKeys.HISTORY_COL_SENT_WIDTH] + ), + "history_col_date_width": history_widths.get( + "history_col_date_percent", defaults[ConfigKeys.HISTORY_COL_DATE_WIDTH] + ), + } + connection.execute( + sa_text( + """ +UPDATE profile_configs +SET vacancy_col_index_width = :vacancy_col_index_width, + vacancy_col_title_width = :vacancy_col_title_width, + vacancy_col_company_width = :vacancy_col_company_width, + vacancy_col_previous_width = :vacancy_col_previous_width, + history_col_index_width = :history_col_index_width, + history_col_title_width = :history_col_title_width, + history_col_company_width = :history_col_company_width, + history_col_status_width = :history_col_status_width, + history_col_sent_width = :history_col_sent_width, + history_col_date_width = :history_col_date_width +WHERE profile_name = :profile_name +""" + ), + params, + ) + + history_info = list( + connection.execute(sa_text("PRAGMA table_info(negotiation_history)")) + ) + + history_columns = {row[1] for row in history_info} + if "was_delivered" not in history_columns: + connection.execute( + sa_text( + "ALTER TABLE negotiation_history" + " ADD COLUMN was_delivered INTEGER NOT NULL DEFAULT 0" + ) + ) + history_columns.add("was_delivered") + + # Приводим статус и причину в истории откликов к значениям API. + status_replacements = { + "Отклик": "applied", + "отклик": "applied", + "ОТКЛИК": "applied", + "Отказ": "rejected", + "отказ": "rejected", + "ОТКАЗ": "rejected", + "Собеседование": "invited", + "собеседование": "invited", + "СОБЕСЕДОВАНИЕ": "invited", + "Приглашение на собеседование": "invited", + "приглашение на собеседование": "invited", + "ПРИГЛАШЕНИЕ НА СОБЕСЕДОВАНИЕ": "invited", + "Назначено собеседование": "invited", + "назначено собеседование": "invited", + "НАЗНАЧЕНО СОБЕСЕДОВАНИЕ": "invited", + "Собес": "invited", + "собес": "invited", + "Игнор": "ignored", + "игнор": "ignored", + "ИГНОР": "ignored", + "Тест": "test_required", + "тест": "test_required", + "Вопросы": "questions_required", + "вопросы": "questions_required", + } + for src, dst in status_replacements.items(): + connection.execute( + sa_text( + "UPDATE negotiation_history SET status = :dst WHERE status = :src" + ), + {"src": src, "dst": dst}, + ) + + connection.execute( + sa_text( + "UPDATE negotiation_history SET status = LOWER(status) " + "WHERE status IS NOT NULL AND status <> LOWER(status)" + ) + ) + + reason_labels_inverted = { + label: code for code, label in ERROR_REASON_LABELS.items() + } + for label, code in reason_labels_inverted.items(): + connection.execute( + sa_text( + "UPDATE negotiation_history SET reason = :code WHERE reason = :label" + ), + {"label": label, "code": code}, + ) + + rows = connection.execute( + sa_text("SELECT id, status FROM negotiation_history") + ).fetchall() + for row in rows: + delivered_flag = 1 if _status_was_delivered(row[1]) else 0 + connection.execute( + sa_text( + "UPDATE negotiation_history SET was_delivered = :flag WHERE id = :id" + ), + {"flag": delivered_flag, "id": row[0]}, + ) + + history_columns = {row[1] for row in history_info} + + needs_rebuild = bool(history_info) and ( + "id" not in history_columns or "resume_id" not in history_columns + ) + + if needs_rebuild: + connection.execute( + sa_text( + """ +CREATE TABLE negotiation_history_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vacancy_id TEXT NOT NULL, + profile_name TEXT NOT NULL, + resume_id TEXT NOT NULL DEFAULT '', + resume_title TEXT, + vacancy_title TEXT, + employer_name TEXT, + status TEXT, + reason TEXT, + was_delivered INTEGER NOT NULL DEFAULT 0, + applied_at DATETIME NOT NULL, + UNIQUE(vacancy_id, resume_id) +) + """ + ) + ) + + if "resume_id" in history_columns: + connection.execute( + sa_text( + """ +INSERT INTO negotiation_history_new ( + vacancy_id, profile_name, resume_id, resume_title, + vacancy_title, employer_name, status, reason, was_delivered, applied_at +) +SELECT + vacancy_id, + profile_name, + COALESCE(resume_id, ''), + COALESCE(resume_title, ''), + vacancy_title, + employer_name, + status, + reason, + 0, + applied_at +FROM negotiation_history + """ + ) + ) + else: + connection.execute( + sa_text( + """ +INSERT INTO negotiation_history_new ( + vacancy_id, profile_name, resume_id, resume_title, + vacancy_title, employer_name, status, reason, was_delivered, applied_at +) +SELECT + vacancy_id, + profile_name, + '', + '', + vacancy_title, + employer_name, + status, + reason, + 0, + applied_at +FROM negotiation_history + """ + ) + ) + + connection.execute(sa_text("DROP TABLE negotiation_history")) + connection.execute( + sa_text("ALTER TABLE negotiation_history_new RENAME TO negotiation_history") + ) + + connection.execute( + sa_text( + "CREATE INDEX IF NOT EXISTS idx_negotiation_profile_resume " + "ON negotiation_history(profile_name, resume_id, applied_at DESC)" + ) + ) + +def log_to_db(level: str, source: str, message: str): + if not engine: + return + with engine.connect() as connection: + stmt = insert(app_logs).values( + level=level, source=source, message=message) + connection.execute(stmt) + connection.commit() + +def record_apply_action( + vacancy_id: str, + profile_name: str, + resume_id: str | None, + resume_title: str | None, + vacancy_title: str, + employer_name: str, + status: str, + reason: str | None): + normalized_status = _normalize_status(status) + normalized_reason = _normalize_status(reason) or None + values = { + "vacancy_id": vacancy_id, + "profile_name": profile_name, + "resume_id": str(resume_id or "").strip(), + "resume_title": (resume_title or "").strip(), + "vacancy_title": vacancy_title, + "employer_name": employer_name, + "status": normalized_status or None, + "reason": normalized_reason, + "was_delivered": 1 if _status_was_delivered(normalized_status) else 0, + "applied_at": datetime.now(), + } + stmt = sqlite_insert(negotiation_history).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=['vacancy_id', 'resume_id'], + set_={ + "profile_name": values["profile_name"], + "resume_title": values["resume_title"], + "vacancy_title": values["vacancy_title"], + "employer_name": values["employer_name"], + "status": values["status"], + "reason": values["reason"], + "was_delivered": values["was_delivered"], + "applied_at": values["applied_at"], + } + ) + with engine.connect() as connection: + connection.execute(stmt) + connection.commit() + +def get_full_negotiation_history_for_profile(profile_name: str) -> list[dict]: + with engine.connect() as connection: + stmt = select(negotiation_history).where( + negotiation_history.c.profile_name == profile_name + ).order_by(negotiation_history.c.applied_at.desc()) + result = connection.execute(stmt).fetchall() + return [dict(row._mapping) for row in result] + + +def get_negotiation_history_for_resume( + profile_name: str, resume_id: str +) -> list[dict]: + if not engine: + return [] + resume_key = str(resume_id or "").strip() + with engine.connect() as connection: + stmt = ( + select(negotiation_history) + .where(negotiation_history.c.profile_name == profile_name) + .where(negotiation_history.c.resume_id == resume_key) + .order_by(negotiation_history.c.applied_at.desc()) + ) + result = connection.execute(stmt).fetchall() + return [dict(row._mapping) for row in result] + +def get_last_sync_timestamp(profile_name: str) -> datetime | None: + key = f"{AppStateKeys.LAST_NEGOTIATION_SYNC_PREFIX}{profile_name}" + value = get_app_state_value(key) + if value: + return datetime.fromisoformat(value) + return None + +def set_last_sync_timestamp(profile_name: str, timestamp: datetime): + key = f"{AppStateKeys.LAST_NEGOTIATION_SYNC_PREFIX}{profile_name}" + set_app_state_value(key, timestamp.isoformat()) + +def upsert_negotiation_history(negotiations: list[dict], profile_name: str): + if not negotiations: + return + with engine.connect() as connection: + for item in negotiations: + vacancy = item.get('vacancy', {}) + if not vacancy or not vacancy.get('id'): + continue + resume_info = item.get("resume") or {} + state_info = item.get("state") or {} + state_id = state_info.get("id") or state_info.get("code") + status_value = ( + str(state_id).strip() + if state_id + else str(state_info.get("name") or item.get("status") or "unknown").strip() + ) + status_value = status_value.lower() + values = { + "vacancy_id": vacancy['id'], + "profile_name": profile_name, + "resume_id": str(resume_info.get("id") or "").strip(), + "resume_title": (resume_info.get("title") or "").strip(), + "vacancy_title": vacancy.get('name'), + "employer_name": vacancy.get('employer', {}).get('name'), + "status": status_value, + "reason": None, + "was_delivered": 1 if _status_was_delivered(status_value) else 0, + "applied_at": datetime.fromisoformat( + item['updated_at'].replace("Z", "+00:00")), + } + stmt = sqlite_insert(negotiation_history).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=['vacancy_id', 'resume_id'], + set_={ + "profile_name": values["profile_name"], + "resume_title": values["resume_title"], + "vacancy_title": values["vacancy_title"], + "employer_name": values["employer_name"], + "status": values["status"], + "was_delivered": values["was_delivered"], + "applied_at": values["applied_at"], + } + ) + connection.execute(stmt) + connection.commit() + +def save_or_update_profile( + profile_name: str, user_info: dict, + token_data: dict, expires_at: datetime): + """ + Создает новый профиль с конфигурацией по умолчанию или обновляет + токены для существующего. + """ + with engine.connect() as connection, connection.begin(): + stmt = select(profiles).where(profiles.c.hh_user_id == user_info['id']) + existing = connection.execute(stmt).first() + + profile_values = { + "hh_user_id": user_info['id'], "email": user_info.get('email', ''), + "access_token": token_data["access_token"], + "refresh_token": token_data["refresh_token"], + "expires_at": expires_at + } + + if existing: + connection.execute(update(profiles).where( + profiles.c.hh_user_id == user_info['id'] + ).values(profile_name=profile_name, **profile_values)) + else: + connection.execute(insert(profiles).values( + profile_name=profile_name, **profile_values)) + + defaults = get_default_config() + + config_main = {k: v for k, v in defaults.items() if k not in + [ConfigKeys.TEXT_INCLUDE, ConfigKeys.NEGATIVE, ConfigKeys.ROLE_IDS_CONFIG]} + config_main["profile_name"] = profile_name + connection.execute(insert(profile_configs).values(config_main)) + + pos_keywords = [{"profile_name": profile_name, "keyword": kw} + for kw in defaults[ConfigKeys.TEXT_INCLUDE]] + if pos_keywords: + connection.execute(insert(config_positive_keywords), pos_keywords) + + neg_keywords = [{"profile_name": profile_name, "keyword": kw} + for kw in defaults[ConfigKeys.NEGATIVE]] + if neg_keywords: + connection.execute(insert(config_negative_keywords), neg_keywords) + + roles = [{"profile_name": profile_name, "role_id": r_id} + for r_id in defaults[ConfigKeys.ROLE_IDS_CONFIG]] + if roles: + connection.execute(insert(config_professional_roles), roles) + +def load_profile(profile_name: str) -> dict | None: + with engine.connect() as connection: + stmt = select(profiles).where(profiles.c.profile_name == profile_name) + result = connection.execute(stmt).first() + if result: + return dict(result._mapping) + return None + +def delete_profile(profile_name: str): + with engine.connect() as connection: + stmt = delete(profiles).where(profiles.c.profile_name == profile_name) + connection.execute(stmt) + connection.commit() + +def get_all_profiles() -> list[dict]: + with engine.connect() as connection: + stmt = select(profiles) + result = connection.execute(stmt).fetchall() + return [dict(row._mapping) for row in result] + +def set_active_profile(profile_name: str): + with engine.connect() as connection: + stmt = sqlite_insert(app_state).values( + key=AppStateKeys.ACTIVE_PROFILE, value=profile_name) + stmt = stmt.on_conflict_do_update( + index_elements=['key'], set_=dict(value=profile_name)) + connection.execute(stmt) + connection.commit() + +def get_active_profile_name() -> str | None: + with engine.connect() as connection: + stmt = select(app_state.c.value).where( + app_state.c.key == AppStateKeys.ACTIVE_PROFILE) + return connection.execute(stmt).scalar_one_or_none() + +def load_profile_config(profile_name: str) -> dict: + """Загружает полную конфигурацию из всех связанных таблиц.""" + with engine.connect() as connection: + stmt_main = select(profile_configs).where( + profile_configs.c.profile_name == profile_name) + result = connection.execute(stmt_main).first() + if not result: + return get_default_config() + + config = dict(result._mapping) + defaults = get_default_config() + config.setdefault(ConfigKeys.THEME, defaults[ConfigKeys.THEME]) + for key in LAYOUT_WIDTH_KEYS: + config.setdefault(key, defaults[key]) + + stmt_pos_keywords = select(config_positive_keywords.c.keyword).where( + config_positive_keywords.c.profile_name == profile_name) + config[ConfigKeys.TEXT_INCLUDE] = connection.execute(stmt_pos_keywords).scalars().all() + + stmt_keywords = select(config_negative_keywords.c.keyword).where( + config_negative_keywords.c.profile_name == profile_name) + config[ConfigKeys.NEGATIVE] = connection.execute(stmt_keywords).scalars().all() + + stmt_roles = select(config_professional_roles.c.role_id).where( + config_professional_roles.c.profile_name == profile_name) + config[ConfigKeys.ROLE_IDS_CONFIG] = connection.execute(stmt_roles).scalars().all() + + return config + +def save_profile_config(profile_name: str, config: dict): + """Сохраняет полную конфигурацию в связанные таблицы.""" + with engine.connect() as connection, connection.begin(): + positive_keywords = config.pop(ConfigKeys.TEXT_INCLUDE, []) + negative_keywords = config.pop(ConfigKeys.NEGATIVE, []) + role_ids = config.pop(ConfigKeys.ROLE_IDS_CONFIG, []) + + if config: + connection.execute(update(profile_configs).where( + profile_configs.c.profile_name == profile_name + ).values(**config)) + + connection.execute(delete(config_positive_keywords).where( + config_positive_keywords.c.profile_name == profile_name)) + if positive_keywords: + connection.execute(insert(config_positive_keywords), + [{"profile_name": profile_name, "keyword": kw} + for kw in positive_keywords]) + + connection.execute(delete(config_negative_keywords).where( + config_negative_keywords.c.profile_name == profile_name)) + if negative_keywords: + connection.execute(insert(config_negative_keywords), + [{"profile_name": profile_name, "keyword": kw} + for kw in negative_keywords]) + + connection.execute(delete(config_professional_roles).where( + config_professional_roles.c.profile_name == profile_name)) + if role_ids: + connection.execute(insert(config_professional_roles), + [{"profile_name": profile_name, "role_id": r_id} + for r_id in role_ids]) diff --git a/hhcli/main.py b/hhcli/main.py new file mode 100644 index 0000000..f3f71e8 --- /dev/null +++ b/hhcli/main.py @@ -0,0 +1,61 @@ +import sys +from hhcli.client import HHApiClient +from hhcli.database import init_db, set_active_profile, get_active_profile_name, log_to_db +from hhcli.ui.tui import HHCliApp + +def run(): + """Главная функция-запускатор и диспетчер команд.""" + init_db() + log_to_db("INFO", "Main", "Запуск приложения hhcli.") + + args = sys.argv[1:] + + if "--auth" in args: + try: + profile_index = args.index("--auth") + 1 + profile_name = args[profile_index] + log_to_db("INFO", "Main", f"Обнаружена команда --auth для профиля '{profile_name}'.") + print(f"Запуск аутентификации для профиля: '{profile_name}'") + + client = HHApiClient() + client.authorize(profile_name) + set_active_profile(profile_name) + + log_to_db("INFO", "Main", f"Профиль '{profile_name}' успешно создан и активирован.") + print(f"Профиль '{profile_name}' успешно создан и активирован.") + except IndexError: + log_to_db("ERROR", "Main", "Команда --auth вызвана без имени профиля.") + print("Ошибка: после --auth необходимо указать имя профиля. Например: hhcli --auth my_account") + + log_to_db("INFO", "Main", "Приложение hhcli завершило работу после аутентификации.") + return + + active_profile = get_active_profile_name() + if not active_profile: + log_to_db("WARN", "Main", "Активный профиль не найден. Вывод подсказки и завершение.") + print("Активный профиль не выбран. Пожалуйста, сначала войдите в аккаунт:") + print(" hhcli --auth <имя_профиля>") + return + + client = HHApiClient() + try: + log_to_db("INFO", "Main", f"Профиль '{active_profile}' активен. Загрузка данных профиля.") + client.load_profile_data(active_profile) + except ValueError as e: + log_to_db("ERROR", "Main", f"Ошибка загрузки профиля '{active_profile}': {e}") + print(f"Ошибка: {e}") + return + + app = HHCliApp(client=client) + + log_to_db("INFO", "Main", "Запуск TUI.") + result = app.run() + + if result: + log_to_db("ERROR", "Main", f"TUI завершился с ошибкой: {result}") + print(f"\n[ОШИБКА] {result}") + + log_to_db("INFO", "Main", "Приложение hhcli завершило работу.") + +if __name__ == "__main__": + run() \ No newline at end of file diff --git a/hhcli/reference_data.py b/hhcli/reference_data.py new file mode 100644 index 0000000..947e520 --- /dev/null +++ b/hhcli/reference_data.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import hashlib +import json +from typing import TYPE_CHECKING, Any, Iterable + +from .database import ( + get_app_state_value, + log_to_db, + replace_areas, + replace_professional_roles, +) +from .constants import AppStateKeys, LogSource + +if TYPE_CHECKING: + from .client import HHApiClient + + +def _normalize(text: str | None) -> str: + return " ".join(str(text or "").lower().split()) + + +def _clean(text: str | None) -> str: + return str(text or "").strip() + + +def _hash_payload(payload: Any) -> str: + serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _flatten_areas( + nodes: list[dict[str, Any]], + *, + parent_id: str | None = None, + path: Iterable[str] | None = None, + level: int = 0, + counter: list[int] | None = None, +) -> list[dict[str, Any]]: + path = list(path or []) + counter = counter or [0] + flattened: list[dict[str, Any]] = [] + + for node in nodes: + counter[0] += 1 + area_id = str(node.get("id")) + name = _clean(node.get("name")) + current_path = path + [name] + full_name = " / ".join(current_path) + flattened.append( + { + "id": area_id, + "parent_id": parent_id, + "name": name, + "full_name": full_name, + "search_name": _normalize(f"{full_name} {area_id}"), + "level": level, + "sort_order": counter[0], + } + ) + children = node.get("areas") or [] + if children: + flattened.extend( + _flatten_areas( + children, + parent_id=area_id, + path=current_path, + level=level + 1, + counter=counter, + ) + ) + return flattened + + +def _flatten_professional_roles(payload: dict[str, Any]) -> list[dict[str, Any]]: + if isinstance(payload, dict): + categories = ( + payload.get("categories") + or payload.get("items") + or payload.get("data") + or [] + ) + elif isinstance(payload, list): + categories = payload + else: + categories = [] + + if not categories: + keys = [] + if isinstance(payload, dict): + keys = list(payload.keys()) + log_to_db( + "ERROR", + LogSource.REFERENCE_DATA, + f"Справочник профессиональных ролей пуст или формат неизвестен: тип {type(payload).__name__}, ключи={keys}", + ) + return [] + + flattened: list[dict[str, Any]] = [] + sort_counter = 0 + + for category_order, category in enumerate(categories): + category_id = str(category.get("id")) + category_name = _clean(category.get("name")) + roles = category.get("roles") or [] + for role_order, role in enumerate(roles): + sort_counter += 1 + role_id = str(role.get("id")) + role_name = _clean(role.get("name")) + full_name = f"{category_name} — {role_name}" if category_name else role_name + flattened.append( + { + "id": role_id, + "name": role_name, + "full_name": full_name, + "search_name": _normalize(f"{category_name} {role_name} {role_id}"), + "category_id": category_id, + "category_name": category_name, + "category_order": category_order, + "role_order": role_order, + "sort_order": sort_counter, + } + ) + return flattened + + +def sync_areas(client: "HHApiClient") -> bool: + """Синхронизирует справочник регионов с API hh.ru. Возвращает True, если произошли изменения.""" + data = client.get_areas() + payload_hash = _hash_payload(data) + if payload_hash == get_app_state_value(AppStateKeys.AREAS_HASH): + return False + + flattened = _flatten_areas(data) + replace_areas(flattened, data_hash=payload_hash) + log_to_db("INFO", LogSource.REFERENCE_DATA, f"Загружено {len(flattened)} записей регионов.") + return True + + +def sync_professional_roles(client: "HHApiClient") -> bool: + """Синхронизирует справочник профессиональных ролей. Возвращает True, если были изменения.""" + data = client.get_professional_roles() + payload_hash = _hash_payload(data) + if payload_hash == get_app_state_value(AppStateKeys.PROFESSIONAL_ROLES_HASH): + return False + + flattened = _flatten_professional_roles(data) + if not flattened: + return False + replace_professional_roles(flattened, data_hash=payload_hash) + log_to_db("INFO", LogSource.REFERENCE_DATA, f"Загружено {len(flattened)} профессиональных ролей.") + return True + + +def ensure_reference_data(client: "HHApiClient") -> dict[str, bool]: + """ + Гарантирует наличие актуальных данных по регионам и профессиональным ролям. + Возвращает словарь с флагами обновления. + """ + updated_areas = sync_areas(client) + updated_roles = sync_professional_roles(client) + return {"areas": updated_areas, "professional_roles": updated_roles} \ No newline at end of file diff --git a/hhcli/ui/__init__.py b/hhcli/ui/__init__.py new file mode 100644 index 0000000..9f3989a --- /dev/null +++ b/hhcli/ui/__init__.py @@ -0,0 +1,19 @@ +"""UI package for hhcli.""" + +from .css_manager import CssManager +from .theme import ( + AVAILABLE_THEMES, + HHCliThemeBase, + list_themes, +) +from .config_screen import ConfigScreen +from .tui import HHCliApp + +__all__ = [ + "CssManager", + "HHCliThemeBase", + "AVAILABLE_THEMES", + "list_themes", + "ConfigScreen", + "HHCliApp", +] diff --git a/hhcli/ui/config_screen.py b/hhcli/ui/config_screen.py new file mode 100644 index 0000000..fc0d537 --- /dev/null +++ b/hhcli/ui/config_screen.py @@ -0,0 +1,789 @@ +from dataclasses import dataclass +from typing import Any, ClassVar + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll, Horizontal, Center +from textual.screen import Screen, ModalScreen +from textual.widgets import ( + Button, + Footer, + Header, + Input, + Label, + Static, + Switch, + TextArea, + Select, + SelectionList, +) +from textual.widgets._selection_list import Selection + +from ..database import ( + list_areas, + list_professional_roles, + load_profile_config, + log_to_db, + save_profile_config, + get_default_config, +) +from ..reference_data import ensure_reference_data +from ..constants import ConfigKeys, LogSource + +COLUMN_WIDTH_MAX = 200 + + +def _normalize(text: str | None) -> str: + if not text: + return "" + return " ".join(str(text).lower().split()) + + +def _select_value(widget: Select) -> str | None: + """Return a plain value for Select widgets, normalizing the blank sentinel.""" + value = widget.value + if value is None or value is Select.BLANK: + return None + return value + + +def _set_select_value(widget: Select, value: str | None) -> None: + """Safely restore saved values to Select widgets, clearing when empty.""" + if value: + widget.value = value + else: + widget.clear() + + +@dataclass +class AreaOption: + id: str + label: str + search_text: str + + +@dataclass +class RoleOption: + id: str + label: str + search_text: str + + +@dataclass(frozen=True) +class LayoutField: + label: str + input_id: str + config_key: str + min_value: int = 1 + max_value: int = 100 + + @property + def selector(self) -> str: + return f"#{self.input_id}" + + +@dataclass(frozen=True) +class LayoutSectionDef: + title: str + fields: tuple[LayoutField, ...] + css_class: str + + +class AreaPickerDialog(ModalScreen[str | None]): + """Диалог выбора региона/города.""" + + BINDINGS = [ + Binding("escape", "cancel", "Отмена"), + Binding("enter", "apply", "Применить"), + ] + + def __init__(self, options: list[AreaOption], selected: str | None) -> None: + super().__init__() + self.options = options + self.selected_id = selected + self._filtered: list[AreaOption] = [] + + def compose(self) -> ComposeResult: + with Vertical(classes="picker"): + yield Static("Выберите регион", classes="picker__title") + yield Input(placeholder="Начните вводить название...", id="picker-search") + yield SelectionList(id="picker-list") + yield Static("[dim]Пробел — выбрать, Enter — применить[/dim]", classes="picker__hint") + with Horizontal(classes="picker__buttons"): + yield Button("Применить", id="picker-apply", variant="primary") + yield Button("Очистить", id="picker-clear", variant="warning") + yield Button("Отмена", id="picker-cancel") + + def on_mount(self) -> None: + self._search = self.query_one("#picker-search", Input) + self._list = self.query_one("#picker-list", SelectionList) + self._refresh("") + self.set_timer(0, lambda: self._search.focus()) + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "picker-search": + self._refresh(event.value) + + def on_selection_list_option_selected( + self, event: SelectionList.OptionSelected + ) -> None: + event.stop() + if event.selection_list.id == "picker-list": + value = str(event.option.value) + self.selected_id = None if self.selected_id == value else value + self._refresh(self._search.value) + + def on_selection_list_selection_toggled( + self, event: SelectionList.SelectionToggled + ) -> None: + if event.selection_list.id != "picker-list": + return + value = str(event.selection.value) + selected_values = {str(val) for val in event.selection_list.selected} + self.selected_id = value if value in selected_values else None + self._refresh(self._search.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "picker-apply": + self.dismiss(self.selected_id) + elif event.button.id == "picker-clear": + self.selected_id = None + self._refresh(self._search.value) + elif event.button.id == "picker-cancel": + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_apply(self) -> None: + self.dismiss(self.selected_id) + + def _refresh(self, query: str) -> None: + normalized = _normalize(query) + if normalized: + candidates = [ + option + for option in self.options + if normalized in option.search_text + ][:200] + else: + candidates = self.options[:200] + self._filtered = candidates + self._list.deselect_all() + self._list.clear_options() + for option in candidates: + self._list.add_option( + Selection( + f"{option.label} [dim]({option.id})[/]", + option.id, + initial_state=(option.id == self.selected_id), + ) + ) + if self._list.option_count: + self._list.highlighted = 0 + + +class RolePickerDialog(ModalScreen[list[str] | None]): + """Диалог выбора профессиональных ролей.""" + + BINDINGS = [ + Binding("escape", "cancel", "Отмена"), + Binding("enter", "apply", "Применить"), + ] + + def __init__(self, options: list[RoleOption], selected: list[str]) -> None: + super().__init__() + self.options = options + self.selected_ids = set(selected) + self._filtered: list[RoleOption] = [] + + def compose(self) -> ComposeResult: + with Vertical(classes="picker"): + yield Static("Выберите профессиональные роли", classes="picker__title") + yield Input(placeholder="Поиск по названию или категории...", id="picker-search") + yield SelectionList(id="picker-list") + yield Static("[dim]Пробел — выбрать/снять, Enter — подтвердить[/dim]", classes="picker__hint") + with Horizontal(classes="picker__buttons"): + yield Button("Применить", id="picker-apply", variant="primary") + yield Button("Очистить", id="picker-clear", variant="warning") + yield Button("Отмена", id="picker-cancel") + + def on_mount(self) -> None: + self._search = self.query_one("#picker-search", Input) + self._list = self.query_one("#picker-list", SelectionList) + self._refresh("") + self.set_timer(0, lambda: self._search.focus()) + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "picker-search": + self._refresh(event.value) + + def on_selection_list_selection_toggled( + self, event: SelectionList.SelectionToggled + ) -> None: + if event.selection_list.id != "picker-list": + return + self._toggle_value(str(event.selection.value)) + + def on_selection_list_option_selected( + self, event: SelectionList.OptionSelected + ) -> None: + event.stop() + if event.selection_list.id == "picker-list": + self._toggle_value(str(event.option.value)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "picker-apply": + self.dismiss(sorted(self.selected_ids)) + elif event.button.id == "picker-clear": + self.selected_ids.clear() + self._refresh(self._search.value) + elif event.button.id == "picker-cancel": + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_apply(self) -> None: + self.dismiss(sorted(self.selected_ids)) + + def _refresh(self, query: str) -> None: + normalized = _normalize(query) + if normalized: + candidates = [ + option + for option in self.options + if normalized in option.search_text + ][:400] + else: + candidates = self.options[:400] + self._filtered = candidates + self._list.clear_options() + for option in candidates: + self._list.add_option( + Selection( + option.label, + option.id, + initial_state=(option.id in self.selected_ids), + ) + ) + if self._list.option_count: + self._list.highlighted = 0 + + def _toggle_value(self, value: str) -> None: + if value in self.selected_ids: + self.selected_ids.remove(value) + else: + self.selected_ids.add(value) + + +class ConfigUnsavedChangesDialog(ModalScreen[str | None]): + """Подтверждение выхода при наличии несохранённых изменений.""" + + BINDINGS = [ + Binding("escape", "cancel", "Отмена"), + ] + + def compose(self) -> ComposeResult: + with Center(id="config-confirm-center"): + with Vertical(id="config-confirm-dialog", classes="config-confirm") as dialog: + dialog.border_title = "Подтверждение" + dialog.styles.border_title_align = "left" + yield Static( + "Сохранить внесённые изменения перед выходом?", + classes="config-confirm__message", + expand=True, + ) + with Horizontal(classes="config-confirm__buttons"): + yield Button("Да", id="confirm-save", variant="success") + yield Button("Нет", id="confirm-discard", classes="decline") + yield Button("Отмена", id="confirm-cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + mapping = { + "confirm-save": "save", + "confirm-discard": "discard", + "confirm-cancel": "cancel", + } + decision = mapping.get(event.button.id or "") + if decision: + self.dismiss(decision) + + def action_cancel(self) -> None: + self.dismiss("cancel") + + +class ConfigScreen(Screen): + """Экран для редактирования конфигурации профиля.""" + + BINDINGS = [ + Binding("escape", "cancel", "Назад"), + Binding("ctrl+s", "save_config", "Сохранить"), + ] + + LAYOUT_SECTIONS: ClassVar[tuple[LayoutSectionDef, ...]] = ( + LayoutSectionDef( + "Экран поиска вакансий", + ( + LayoutField( + "Ширина панели \"Вакансии\" (%)", + "vacancy_pane_percent", + ConfigKeys.VACANCY_LEFT_PANE_PERCENT, + min_value=10, + max_value=90, + ), + LayoutField( + "Колонка \"№\" (симв.)", + "vacancy_col_index_width", + ConfigKeys.VACANCY_COL_INDEX_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + LayoutField( + "Колонка \"Название\" (симв.)", + "vacancy_col_title_width", + ConfigKeys.VACANCY_COL_TITLE_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + LayoutField( + "Колонка \"Компания\" (симв.)", + "vacancy_col_company_width", + ConfigKeys.VACANCY_COL_COMPANY_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + LayoutField( + "Колонка \"Откликался\" (симв.)", + "vacancy_col_previous_width", + ConfigKeys.VACANCY_COL_PREVIOUS_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + ), + "display-settings-group--vacancy", + ), + LayoutSectionDef( + "Экран истории откликов", + ( + LayoutField( + "Ширина панели \"История\" (%)", + "history_pane_percent", + ConfigKeys.HISTORY_LEFT_PANE_PERCENT, + min_value=10, + max_value=90, + ), + LayoutField( + "Колонка \"№\" (симв.)", + "history_col_index_width", + ConfigKeys.HISTORY_COL_INDEX_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + LayoutField( + "Колонка \"Название\" (симв.)", + "history_col_title_width", + ConfigKeys.HISTORY_COL_TITLE_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + LayoutField( + "Колонка \"Компания\" (симв.)", + "history_col_company_width", + ConfigKeys.HISTORY_COL_COMPANY_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + LayoutField( + "Колонка \"Статус\" (симв.)", + "history_col_status_width", + ConfigKeys.HISTORY_COL_STATUS_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + LayoutField( + "Колонка \"✉\" (симв.)", + "history_col_sent_width", + ConfigKeys.HISTORY_COL_SENT_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + LayoutField( + "Колонка \"Дата отклика\" (симв.)", + "history_col_date_width", + ConfigKeys.HISTORY_COL_DATE_WIDTH, + max_value=COLUMN_WIDTH_MAX, + ), + ), + "display-settings-group--history", + ), + ) + + LAYOUT_FIELDS: ClassVar[tuple[LayoutField, ...]] = tuple( + field for section in LAYOUT_SECTIONS for field in section.fields + ) + + def __init__(self) -> None: + super().__init__() + self._quit_binding_q = None + self._quit_binding_cyrillic = None + self._areas: list[AreaOption] = [] + self._roles: list[RoleOption] = [] + self._selected_area_id: str | None = None + self._selected_role_ids: list[str] = [] + self._initial_config: dict[str, Any] = {} + self._form_loaded = False + self._confirm_dialog_active = False + + def compose(self) -> ComposeResult: + with Vertical(id="config_screen"): + yield Header(show_clock=True, name="hh-cli - Настройки") + with VerticalScroll(id="config-form"): + yield Static("Параметры поиска", classes="header") + yield Label("Ключевые слова для поиска (через запятую):") + yield Input(id="text_include", placeholder='Python developer, Backend developer') + yield Label("Исключающие слова (через запятую):") + yield Input(id="negative", placeholder="senior, C++, DevOps, аналитик") + + yield Label("Формат работы:") + yield Select([], id="work_format") + + with Horizontal(id="pickers-container"): + with Vertical(id="area-picker-container", classes="picker-group"): + yield Label("Регион / город поиска:", classes="summary-label") + yield Static("-", id="area_summary", classes="value-display") + yield Button("Выбрать регион", id="area_picker") + + with Vertical(id="role-picker-container", classes="picker-group"): + yield Label("Профессиональные роли:", classes="summary-label") + yield Static("-", id="roles_summary", classes="value-display") + yield Button("Выбрать роли", id="roles_picker") + + yield Label("Область поиска:") + yield Select( + [ + ("В названии вакансии", "name"), + ("В названии компании", "company_name"), + ("В описании вакансии", "description"), + ], + id="search_field", + ) + + yield Label("Период публикации (дней, 1-30):") + yield Input(id="period", placeholder="3") + + yield Static("Отображение и отклики", classes="header") + yield Horizontal( + Switch(id="skip_applied_in_same_company"), + Label("Пропускать компании, куда уже был отклик", classes="switch-label"), + classes="switch-container", + ) + yield Horizontal( + Switch(id="deduplicate_by_name_and_company"), + Label("Убирать дубли по 'Название+Компания'", classes="switch-label"), + classes="switch-container", + ) + yield Horizontal( + Switch(id="strikethrough_applied_vac"), + Label("Зачеркивать вакансии по точному ID", classes="switch-label"), + classes="switch-container", + ) + yield Horizontal( + Switch(id="strikethrough_applied_vac_name"), + Label("Зачеркивать вакансии по 'Название+Компания'", classes="switch-label"), + classes="switch-container", + ) + + yield Static("Оформление", classes="header") + yield Label("Тема интерфейса:") + yield Select([], id="theme") + + yield Static("Настройки отображения экранов", classes="header") + with Horizontal(id="display-settings-container", classes="display-settings-grid"): + for section in self.LAYOUT_SECTIONS: + with Vertical(classes=f"display-settings-group {section.css_class}") as group: + group.border_title = section.title + group.styles.border_title_align = "left" + for field in section.fields: + yield self._make_layout_row(field.label, field.input_id) + + yield Static("Сопроводительное письмо", classes="header") + yield TextArea(id="cover_letter", language="markdown") + + yield Button("Сохранить и выйти", variant="success", id="save-button") + + yield Footer() + + def on_mount(self) -> None: + """При монтировании экрана удаляем глобальные биндинги выхода.""" + self._quit_binding_q = self.app._bindings.keys.pop("q", None) + self._quit_binding_cyrillic = self.app._bindings.keys.pop("й", None) + + self.run_worker(self._load_data_worker, thread=True) + + def on_unmount(self) -> None: + """При размонтировании экрана восстанавливаем глобальные биндинги.""" + if self._quit_binding_q: + self.app._bindings.keys['q'] = self._quit_binding_q + if self._quit_binding_cyrillic: + self.app._bindings.keys['й'] = self._quit_binding_cyrillic + + def _load_data_worker(self) -> None: + """ + Эта часть выполняется в фоновом потоке, загружает данные, не трогая виджеты. + """ + profile_name = self.app.client.profile_name + config = load_profile_config(profile_name) + work_formats = self.app.dictionaries.get("work_format", []) + areas = list_areas() + roles = list_professional_roles() + if not areas or not roles: + try: + ensure_reference_data(self.app.client) + except Exception as exc: + log_to_db("ERROR", LogSource.CONFIG_SCREEN, f"Не удалось обновить справочники: {exc}") + pass + areas = list_areas() + roles = list_professional_roles() + + if not areas: + self.app.call_from_thread( + self.app.notify, + "Не удалось загрузить справочник городов.", + severity="warning", + ) + log_to_db("WARN", LogSource.CONFIG_SCREEN, "Справочник городов недоступен") + if not roles: + self.app.call_from_thread( + self.app.notify, + "Не удалось загрузить справочник профессиональных ролей.", + severity="warning", + ) + log_to_db("WARN", LogSource.CONFIG_SCREEN, "Справочник профессиональных ролей недоступен") + + self.app.call_from_thread(self._populate_form, config, work_formats, areas, roles) + + def _populate_form( + self, + config: dict, + work_formats: list, + areas: list[dict], + roles: list[dict], + ) -> None: + """ + Эта часть выполняется в основном потоке, обновляет виджеты. + """ + defaults = get_default_config() + self.query_one("#work_format", Select).set_options( + [(item["name"], item["id"]) for item in work_formats] + ) + + self.query_one("#text_include", Input).value = ", ".join(config.get(ConfigKeys.TEXT_INCLUDE, [])) + self.query_one("#negative", Input).value = ", ".join(config.get(ConfigKeys.NEGATIVE, [])) + _set_select_value(self.query_one("#work_format", Select), config.get(ConfigKeys.WORK_FORMAT)) + _set_select_value(self.query_one("#search_field", Select), config.get(ConfigKeys.SEARCH_FIELD)) + self.query_one("#period", Input).value = config.get(ConfigKeys.PERIOD, "") + self.query_one("#cover_letter", TextArea).load_text(config.get(ConfigKeys.COVER_LETTER, "")) + self.query_one("#skip_applied_in_same_company", Switch).value = config.get(ConfigKeys.SKIP_APPLIED_IN_SAME_COMPANY, False) + self.query_one("#deduplicate_by_name_and_company", Switch).value = config.get(ConfigKeys.DEDUPLICATE_BY_NAME_AND_COMPANY, True) + self.query_one("#strikethrough_applied_vac", Switch).value = config.get(ConfigKeys.STRIKETHROUGH_APPLIED_VAC, True) + self.query_one("#strikethrough_applied_vac_name", Switch).value = config.get(ConfigKeys.STRIKETHROUGH_APPLIED_VAC_NAME, True) + + self._areas = [ + AreaOption( + id=str(area["id"]), + label=area["full_name"], + search_text=_normalize(f"{area['full_name']} {area['name']} {area['id']}"), + ) + for area in areas + ] + self._roles = [ + RoleOption( + id=str(role["id"]), + label=f"{role['category_name']} — {role['name']}", + search_text=_normalize(f"{role['category_name']} {role['name']} {role['id']}"), + ) + for role in roles + ] + + self._selected_area_id = config.get(ConfigKeys.AREA_ID) or None + raw_roles = config.get(ConfigKeys.ROLE_IDS_CONFIG, []) + self._selected_role_ids = [str(rid) for rid in raw_roles if str(rid)] + + self._update_area_summary() + self._update_roles_summary() + + theme_select = self.query_one("#theme", Select) + themes = sorted(self.app.css_manager.themes.values(), key=lambda t: t._name) + theme_select.set_options( + [ + (self._beautify_theme_name(theme._name), theme._name) + for theme in themes + ] + ) + theme_select.value = config.get(ConfigKeys.THEME, "hhcli-base") + self._populate_layout_settings(config, defaults) + self._initial_config = self._current_form_config() + self._form_loaded = True + + def _update_area_summary(self) -> None: + summary_widget = self.query_one("#area_summary", Static) + if not self._selected_area_id: + summary_widget.update("[dim]Не выбрано[/dim]") + return + label = self._find_area_label(self._selected_area_id) + summary_widget.update(label or "[dim]Не выбрано[/dim]") + + def _update_roles_summary(self) -> None: + summary_widget = self.query_one("#roles_summary", Static) + if not self._selected_role_ids: + summary_widget.update("[dim]Не выбрано[/dim]") + return + labels = self._find_role_labels(self._selected_role_ids) + if not labels: + summary_widget.update("[dim]Не выбрано[/dim]") + return + if len(labels) > 3: + summary_widget.update(", ".join(labels[:3]) + f" [+ ещё {len(labels) - 3}]") + else: + summary_widget.update(", ".join(labels)) + + def _find_area_label(self, area_id: str) -> str | None: + for option in self._areas: + if option.id == area_id: + return option.label + return None + + def _find_role_labels(self, role_ids: list[str]) -> list[str]: + cache = {option.id: option.label for option in self._roles} + return [cache[rid] for rid in role_ids if rid in cache] + + @staticmethod + def _beautify_theme_name(theme_name: str) -> str: + name = theme_name.removeprefix("hhcli-").replace("-", " ") + return name.title() or theme_name + + def _make_layout_row(self, label_text: str, input_id: str) -> Horizontal: + return Horizontal( + Label(label_text, classes="display-settings-label"), + Input(id=input_id, classes="display-settings-input"), + classes="display-settings-row", + ) + + def _parse_int_value( + self, + selector: str, + fallback: int, + *, + min_value: int = 1, + max_value: int = 100, + ) -> int: + raw = self.query_one(selector, Input).value.strip() + try: + value = int(raw) + except (TypeError, ValueError): + value = fallback + return max(min_value, min(max_value, value)) + + def _current_form_config(self) -> dict[str, Any]: + """Возвращает текущее состояние формы в формате конфигурации.""" + def parse_list(text: str) -> list[str]: + return [item.strip() for item in text.split(",") if item.strip()] + + defaults = get_default_config() + initial = getattr(self, "_initial_config", {}) + + def int_setting(selector: str, key: str, *, min_value: int = 1, max_value: int = 100) -> int: + fallback = initial.get(key, defaults[key]) + return self._parse_int_value(selector, fallback, min_value=min_value, max_value=max_value) + + config_snapshot = { + ConfigKeys.TEXT_INCLUDE: parse_list(self.query_one("#text_include", Input).value), + ConfigKeys.NEGATIVE: parse_list(self.query_one("#negative", Input).value), + ConfigKeys.ROLE_IDS_CONFIG: list(self._selected_role_ids), + ConfigKeys.WORK_FORMAT: _select_value(self.query_one("#work_format", Select)), + ConfigKeys.AREA_ID: self._selected_area_id or "", + ConfigKeys.SEARCH_FIELD: _select_value(self.query_one("#search_field", Select)), + ConfigKeys.PERIOD: self.query_one("#period", Input).value, + ConfigKeys.COVER_LETTER: self.query_one("#cover_letter", TextArea).text, + ConfigKeys.SKIP_APPLIED_IN_SAME_COMPANY: self.query_one("#skip_applied_in_same_company", Switch).value, + ConfigKeys.DEDUPLICATE_BY_NAME_AND_COMPANY: self.query_one("#deduplicate_by_name_and_company", Switch).value, + ConfigKeys.STRIKETHROUGH_APPLIED_VAC: self.query_one("#strikethrough_applied_vac", Switch).value, + ConfigKeys.STRIKETHROUGH_APPLIED_VAC_NAME: self.query_one("#strikethrough_applied_vac_name", Switch).value, + ConfigKeys.THEME: self.query_one("#theme", Select).value or "hhcli-base", + } + for field in self.LAYOUT_FIELDS: + config_snapshot[field.config_key] = int_setting( + field.selector, + field.config_key, + min_value=field.min_value, + max_value=field.max_value, + ) + return config_snapshot + + def _populate_layout_settings(self, config: dict[str, Any], defaults: dict[str, Any]) -> None: + for field in self.LAYOUT_FIELDS: + value = config.get(field.config_key, defaults[field.config_key]) + self.query_one(field.selector, Input).value = str(value) + + def _has_unsaved_changes(self) -> bool: + if not self._form_loaded: + return False + return self._current_form_config() != self._initial_config + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "save-button": + self.action_save_config() + elif event.button.id == "area_picker": + self._open_area_picker() + elif event.button.id == "roles_picker": + self._open_roles_picker() + + def _open_area_picker(self) -> None: + if not self._areas: + self.app.notify("Справочник городов пуст.", severity="warning") + return + self.app.push_screen( + AreaPickerDialog(self._areas, self._selected_area_id), + self._on_area_picker_closed, + ) + + def _open_roles_picker(self) -> None: + if not self._roles: + self.app.notify("Справочник ролей пуст.", severity="warning") + return + self.app.push_screen( + RolePickerDialog(self._roles, self._selected_role_ids), + self._on_roles_picker_closed, + ) + + def _on_area_picker_closed(self, area_id: str | None) -> None: + self._selected_area_id = area_id + self._update_area_summary() + + def _on_roles_picker_closed(self, role_ids: list[str] | None) -> None: + if role_ids is None: + return + self._selected_role_ids = role_ids + self._update_roles_summary() + + def action_cancel(self) -> None: + """Закрыть экран, при необходимости спросив подтверждение.""" + if not self._has_unsaved_changes(): + self.dismiss(False) + return + if self._confirm_dialog_active: + return + self._confirm_dialog_active = True + self.app.push_screen( + ConfigUnsavedChangesDialog(), + self._on_unsaved_dialog_closed, + ) + + def action_save_config(self) -> None: + """Собрать данные с формы и сохранить в БД.""" + profile_name = self.app.client.profile_name + config = self._current_form_config() + + save_profile_config(profile_name, config) + self.app.css_manager.set_theme(config[ConfigKeys.THEME]) + self.app.notify("Настройки успешно сохранены.", title="Успех", severity="information") + self.dismiss(True) + + def _on_unsaved_dialog_closed(self, decision: str | None) -> None: + self._confirm_dialog_active = False + if decision == "save": + self.action_save_config() + elif decision == "discard": + self.dismiss(False) diff --git a/hhcli/ui/css_manager.py b/hhcli/ui/css_manager.py new file mode 100644 index 0000000..e7f26d4 --- /dev/null +++ b/hhcli/ui/css_manager.py @@ -0,0 +1,111 @@ +from __future__ import annotations +import sys +from pathlib import Path +from typing import Optional, Type, Union +from uuid import uuid4 + +from platformdirs import user_cache_dir + +from .theme import ( + AVAILABLE_THEMES, + HHCliThemeBase, +) + +_cache_root = Path(user_cache_dir("hhcli")) + + +if getattr(sys, "frozen", False): + BASE_PATH = Path(sys._MEIPASS) / "hhcli" / "ui" # type: ignore[attr-defined] +else: + BASE_PATH = Path(__file__).parent + + +def _generate_random_id() -> str: + return uuid4().hex + + +class CssManager: + """Отвечает за генерацию и кэширование итогового CSS.""" + + base_css: Path = BASE_PATH / "styles.tcss" + themes: dict[str, HHCliThemeBase] = { + name: theme_cls() + for name, theme_cls in AVAILABLE_THEMES.items() + } + + def __init__( + self, + theme: Optional[HHCliThemeBase] = None, + cache_path: Path = _cache_root, + ) -> None: + self.theme: HHCliThemeBase = theme or self.themes["hhcli-base"] + self.cache_path = cache_path + self.stylesheets: Path = self.cache_path / "stylesheets" + self.css_file: Path = self.cache_path / "hhcli.tcss" + + self.cache_path.mkdir(parents=True, exist_ok=True) + self.stylesheets.mkdir(parents=True, exist_ok=True) + + if not self.css_file.exists(): + self.write("") + + self.refresh_css() + + def read_css(self) -> str: + return self.css_file.read_text() + + def refresh_css(self) -> None: + css = self.theme.to_css() + with open(self.base_css, "r", encoding="utf8") as base: + css = css + "\n" + base.read() + + for sheet in sorted(self.stylesheets.glob("*.tcss")): + with open(sheet, "r", encoding="utf8") as extra: + css = css + "\n" + extra.read() + + self.write(css) + + def add_theme(self, theme: Type[HHCliThemeBase]) -> None: + instance = theme() + self.themes[instance._name] = instance + self.refresh_css() + + def set_theme(self, theme: Union[str, Type[HHCliThemeBase]]) -> None: + if isinstance(theme, str): + selected = self.themes.get(theme) + if selected is None: + raise ValueError(f"Тема '{theme}' не зарегистрирована.") + self.theme = selected + else: + self.theme = theme() + self.themes[self.theme._name] = self.theme + + self.refresh_css() + + def inject_css(self, css: str, *, _id: Optional[str] = None) -> str: + uuid = _id or _generate_random_id() + css_file = self.stylesheets / f"{uuid}.tcss" + with open(css_file, "w", encoding="utf8") as handle: + handle.write(css) + self.refresh_css() + return uuid + + def unject_css(self, _id: str) -> bool: + css_file = self.stylesheets / f"{_id}.tcss" + if not css_file.exists(): + return False + css_file.unlink() + self.refresh_css() + return True + + def is_active(self, _id: str) -> bool: + return (self.stylesheets / f"{_id}.tcss").exists() + + def write(self, css: str) -> None: + with open(self.css_file, "w", encoding="utf8") as handle: + handle.write(css) + + def cleanup(self) -> None: + for sheet in self.stylesheets.glob("*.tcss"): + sheet.unlink(missing_ok=True) + self.refresh_css() diff --git a/hhcli/ui/styles.tcss b/hhcli/ui/styles.tcss new file mode 100644 index 0000000..e0befb1 --- /dev/null +++ b/hhcli/ui/styles.tcss @@ -0,0 +1,626 @@ +/* --- 1. Глобальные стили и стили по-умолчанию --- */ + +* { + scrollbar-size: 1 1; + scrollbar-background: $background3; + scrollbar-color: $primary; + scrollbar-background-hover: $background2; + scrollbar-color-hover: $secondary; + link-color: $primary; +} + +Screen { + background: $background1; + color: $foreground2; +} + +Static, Label { + color: $foreground3; +} + +Markdown { + background: $background2; + color: $foreground3; + padding: 0 1; + border: round $background3; + border-title-align: left; +} + +LoadingIndicator, +.loading-indicator { + color: $secondary; +} + + +/* --- 2. Основная структура приложения --- */ + +#vacancy_screen, +#config_screen { + height: 100%; +} + +Footer { + background: $background2; + color: $foreground1; + dock: bottom; + height: auto; + min-height: 1; + padding: 0 1; +} + +Footer > .footer--description { + color: $foreground2; +} + +Footer > .footer--key { + background: $background3 60%; + color: $foreground3; + text-style: bold; +} + +Footer > .footer--highlight { + background: $primary 35%; + color: $foreground3; + text-style: bold; +} + +Footer > .footer--highlight-key { + background: $primary; + color: $background1; + text-style: bold; +} + + +/* --- 3. Экран просмотра вакансий --- */ + +.pane { + background: $background2; + border: round $background3; + border-title-color: $foreground1; + border-title-background: $background3; + border-title-align: left; + padding: 1 0 0 0; + margin: 0; + height: 1fr; +} + +#vacancy_layout { + height: 1fr; + padding: 0; +} + +#vacancy_panel { + margin-left: 0; +} + +#details_panel { + padding: 0 0 0 0; +} + +#details_pane { + padding: 0 0 0 0; + background: transparent; +} + +#vacancy_details { + background: transparent; + border: none; + padding: 0; +} + +#history_details { + background: transparent; + border: none; + padding: 0; +} + +#vacancy_list_header { + background: transparent; + color: $foreground2; + padding: 0 0 0 4; + text-style: bold; +} + +#history_list_header { + background: transparent; + color: $foreground2; + padding: 0 0 0 1; + text-style: bold; +} + + +/* --- 4. Виджеты для выбора (Списки и Таблицы) --- */ + +SelectionList { + background: transparent; + padding: 0; + height: 1fr; + border: none; +} + +SelectionList:focus { + border: none; +} + +SelectionList > .selection-list--button, +SelectionList > .selection-list--button-highlighted, +SelectionList > .selection-list--button-selected, +SelectionList > .selection-list--button-selected-highlighted { + background: transparent; + border: none; + padding: 0 1; +} + +SelectionList > .selection-list--button-highlighted, +SelectionList > .selection-list--button-selected-highlighted { + background: $background3 35%; +} + +SelectionList > .selection-list--button-selected, +SelectionList > .selection-list--button-selected-highlighted { + color: $primary; +} + +DataTable { + background: transparent; + color: $foreground3; + border: none; + scrollbar-color: $primary; + scrollbar-background: $background3 60%; +} + +DataTable:focus { + border: none; +} + +DataTable > .datatable--header { + background: $background3; + color: $foreground1; +} + +#resume_table, +#profile_table { + min-height: 20; +} + + +/* --- 5. Экран настроек и формы --- */ + +#config-form { + padding: 1 2; + background: $background2; + border: round $background3; +} + +#config-form > .header { + background: $background3; + color: $foreground1; + padding: 1; + margin: 1 0; + text-style: bold; + text-align: center; +} + +/* Элементы формы: Поля ввода */ +Input, +TextArea { + background: $background2; + color: $foreground3; + border: round $background3; + padding: 0; +} + +Input:focus, +TextArea:focus { + border: round $primary; +} + +TextArea { + min-height: 12; + scrollbar-color: $secondary; +} + +#cover_letter { + height: 15; +} + +/* Элементы формы: Переключатели */ +Switch { + background: $background1; + border: round $background3; +} + +Switch:focus { + border: round $primary; +} + +.switch-container { + align-vertical: middle; + padding: 0 1; + background: transparent; + height: auto; +} + +.switch-label { + height: 100%; + content-align-vertical: middle; +} + +.switch-container > Switch { + background: transparent; + width: auto; + margin-right: 2; +} + +/* Элементы формы: Выпадающие списки */ +Select { + background: transparent; + padding: 0; +} + +SelectCurrent { + background: $background2; + color: $foreground3; + border: round $background3; + padding: 0 1; +} + +SelectCurrent Static#label { + color: $foreground2; + background: transparent; +} + +SelectCurrent.-has-value Static#label { + color: $foreground3; +} + +SelectCurrent .arrow { + color: $foreground2; + background: transparent; +} + +Select:focus > SelectCurrent { + border: round $primary; +} + +Select > SelectOverlay { + width: 1fr; +} + +SelectOverlay { + background: $background2; + color: $foreground3; + border: round $background3; + padding: 0 1; +} + +SelectOverlay:focus { + border: round $primary; +} + +SelectOverlay > .option-list--option { + background: transparent; + color: $foreground3; +} + +SelectOverlay > .option-list--option-highlighted, +SelectOverlay > .option-list--option-hover, +SelectOverlay:focus > .option-list--option-highlighted, +SelectOverlay > .option-list--option-hover-highlighted { + background: $background3 40%; + color: $foreground1; +} + +SelectOverlay > .option-list--option-disabled, +SelectOverlay > .option-list--option-highlighted-disabled, +SelectOverlay > .option-list--option-hover-disabled, +SelectOverlay > .option-list--option-hover-highlighted-disabled { + color: $foreground2 50%; + background: transparent; +} + +/* Элементы формы: Контейнеры выбора региона/ролей */ +.summary-label { + margin: 1 0 0 0; +} + +.value-display { + padding: 0 0 1 0; + color: $foreground3; +} + +#pickers-container { + layout: horizontal; + grid-size: 2; + grid-gutter-horizontal: 2; + height: auto; + margin: 1 0; +} + +.picker-group { + height: auto; + padding: 0 1 1 1; + border: round $background3; + width: 1fr; + background: $background2; +} + +.picker-group .summary-label { + margin-bottom: 1; +} + +.picker-group .value-display { + height: 3; + min-height: 3; + content-align-vertical: top; +} + +.picker-group Button { + width: 100%; +} + +/* Элементы формы: Модальные окна выбора */ +.picker { + background: $background2; + border: round $background3; + padding: 1; + height: auto; +} + +.picker__title { + text-style: bold; + padding-bottom: 1; +} + +.picker__buttons { + padding-top: 1; +} + +.picker__hint { + color: $foreground2; + padding-top: 1; +} + +/* Настройки отображения экранов */ + +#display-settings-container { + height: 22; + layout: grid; + grid-size: 2; + grid-columns: 1fr 1fr; + grid-gutter: 0 2; + margin: 1 0; +} + +.display-settings-group { + height: 26; + padding: 1; + border: round $background3; + border-title-color: $foreground1; + border-title-background: $background3; + width: 1fr; + background: $background2; +} + +.display-settings-row { + layout: horizontal; + grid-gutter-horizontal: 2; + + height: 3; + align: left middle; +} + +.display-settings-label { + width: 1fr; + height: 100%; + color: $foreground1; + content-align: left middle; +} + +.display-settings-input { + width: 12; + background: $background2; + border: round $background3; + text-align: right; + color: $foreground3; + margin: 0; +} + +.display-settings-input:focus { + border: round $primary; +} + +/* --- 6. Модальное подтверждение настроек --- */ + +ConfigUnsavedChangesDialog, +ApplyConfirmationDialog { + layer: modal; + align: center middle; + background: rgba(0, 0, 0, 0.65); +} + +ConfigUnsavedChangesDialog #config-confirm-center { + width: 30%; + height: 20%; +} + +ApplyConfirmationDialog #config-confirm-center { + width: 30%; + height: 29%; +} + +ConfigUnsavedChangesDialog #config-confirm-dialog, +ApplyConfirmationDialog #config-confirm-dialog { + border-title-color: $primary; + background: $background2; + border: round $primary; +} + +ConfigUnsavedChangesDialog #config-confirm-dialog > .config-confirm__message, +ApplyConfirmationDialog #config-confirm-dialog > .config-confirm__message { + color: $foreground3; + text-style: bold; + text-align: center; + content-align: center middle; + width: 100%; + padding: 1 0; +} + +ConfigUnsavedChangesDialog .config-confirm__buttons, +ApplyConfirmationDialog .config-confirm__buttons { + width: 100%; + content-align-horizontal: center; + align: center bottom; + padding-bottom: 1; +} + +ConfigUnsavedChangesDialog .config-confirm__buttons > Button, +ApplyConfirmationDialog .config-confirm__buttons > Button { + min-width: 16; + margin: 0 1; +} + +ApplyConfirmationDialog #apply_confirm_input { + content-align: center middle; + width: 80%; + margin: 0 0 1 0; +} + +ApplyConfirmationDialog #apply_confirm_error { + min-height: 2; + color: $red; + text-align: center; + content-align: center middle; + padding-bottom: 1; +} + +#history_list { + background: transparent; + border: none; + padding: 0; + height: 1fr; +} + +#history_list .option-list--option, +#history_list .option-list--option-hover { + background: transparent; + padding: 0 1; +} + +#history_list .option-list--option-highlighted, +#history_list .option-list--option-hover-highlighted { + background: $background3 35%; +} + + +/* --- 7. Компоненты --- */ + +/* Кнопки */ +Button { + background: $background2; + color: $foreground1; + border: round $background3; + text-style: bold; +} + +Button:hover { + background: $background2; + color: $foreground3; + border: round $primary; + text-style: bold; +} + +Button:focus { + border: round $foreground1; +} + +Button.-success { + margin: 1 0 0 0; + background: $background2; + color: $green; + border: round $background3; +} + +Button.-success:hover { + border: round $green; + color: $green; +} + +Button.-success:focus { + color: $foreground3; + border: round $green; +} + +Button.decline { + margin: 1 0 0 0; + background: $background2; + color: $red; + border: round $background3; +} + +Button.decline:hover { + border: round $red; + color: $red; +} + +Button.decline:focus { + color: $foreground3; + border: round $red; +} + +/* Пагинация */ +Pagination { + width: 100%; + height: auto; + align: center middle; + padding-top: 1; +} + +Pagination > Button { + min-width: 3; + width: auto; + height: 1; + border: none; + padding: 0 1; + margin: 0 1; + background: $background3; +} + +Pagination > Button:hover { + background: $secondary 40%; + border: none; +} + +Pagination > Button.-primary, +Pagination > Button.-primary:hover { + background: $primary; + color: $background1; + text-style: bold; +} + +Pagination > Button:focus { + border: none; + background: $primary; + color: $background1; +} + + +/* --- 8. Уведомления --- */ + +Notification { + background: $background3; + color: $foreground3; + border: round $primary; +} + +Notification.-information { + border: round $secondary; +} + +Notification.-warning { + border: round $yellow; +} + +Notification.-error { + border: round $red; +} diff --git a/hhcli/ui/theme.py b/hhcli/ui/theme.py new file mode 100644 index 0000000..876c234 --- /dev/null +++ b/hhcli/ui/theme.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ThemeDefinition: + """Упрощённое представление темы для внешнего использования.""" + + name: str + colors: dict[str, str] + + +class HHCliThemeBase: + """Базовая тема hhcli. Совместима с API dooit.""" + + _name: str = "hhcli-base" + + # background colors + background1: str = "#2E3440" # Darkest + background2: str = "#3B4252" # Lighter + background3: str = "#434C5E" # Lightest + + # foreground colors + foreground1: str = "#D8DEE9" # Darkest + foreground2: str = "#E5E9F0" # Lighter + foreground3: str = "#ECEFF4" # Lightest + + # other colors + red: str = "#BF616A" + orange: str = "#D08770" + yellow: str = "#EBCB8B" + green: str = "#A3BE8C" + blue: str = "#81A1C1" + purple: str = "#B48EAD" + magenta: str = "#B48EAD" + cyan: str = "#8FBCBB" + + # accent colors + primary: str = cyan + secondary: str = blue + + @classmethod + def to_css(cls) -> str: + """Конвертирует тему в набор CSS-переменных.""" + return ( + f"$background1: {cls.background1};\n" + f"$background2: {cls.background2};\n" + f"$background3: {cls.background3};\n\n" + f"$foreground1: {cls.foreground1};\n" + f"$foreground2: {cls.foreground2};\n" + f"$foreground3: {cls.foreground3};\n\n" + f"$red: {cls.red};\n" + f"$orange: {cls.orange};\n" + f"$yellow: {cls.yellow};\n" + f"$green: {cls.green};\n" + f"$blue: {cls.blue};\n" + f"$purple: {cls.purple};\n" + f"$magenta: {cls.magenta};\n" + f"$cyan: {cls.cyan};\n\n" + f"$primary: {cls.primary};\n" + f"$secondary: {cls.secondary};\n" + ) + + @classmethod + def definition(cls) -> ThemeDefinition: + """Возвращает сериализованное представление темы.""" + return ThemeDefinition( + name=cls._name, + colors={ + "background1": cls.background1, + "background2": cls.background2, + "background3": cls.background3, + "foreground1": cls.foreground1, + "foreground2": cls.foreground2, + "foreground3": cls.foreground3, + "red": cls.red, + "orange": cls.orange, + "yellow": cls.yellow, + "green": cls.green, + "blue": cls.blue, + "purple": cls.purple, + "magenta": cls.magenta, + "cyan": cls.cyan, + "primary": cls.primary, + "secondary": cls.secondary, + }, + ) + + +class Nord(HHCliThemeBase): + """Стандартная тема в стиле dooit.""" + + _name = "hhcli-nord" + + +class SolarizedDark(HHCliThemeBase): + """Альтернативная тема по мотивам solarized dark.""" + + _name = "hhcli-solarized-dark" + + background1 = "#002b36" + background2 = "#073642" + background3 = "#0a3946" + + foreground1 = "#839496" + foreground2 = "#93a1a1" + foreground3 = "#eee8d5" + + red = "#dc322f" + orange = "#cb4b16" + yellow = "#b58900" + green = "#859900" + blue = "#268bd2" + purple = "#6c71c4" + magenta = "#d33682" + cyan = "#2aa198" + + primary = cyan + secondary = blue + + +AVAILABLE_THEMES: dict[str, type[HHCliThemeBase]] = { + theme._name: theme + for theme in ( + HHCliThemeBase, + Nord, + SolarizedDark, + ) +} + + +def list_themes() -> list[ThemeDefinition]: + """Возвращает список доступных тем.""" + return [theme.definition() for theme in AVAILABLE_THEMES.values()] diff --git a/hhcli/ui/tui.py b/hhcli/ui/tui.py new file mode 100644 index 0000000..9fb6166 --- /dev/null +++ b/hhcli/ui/tui.py @@ -0,0 +1,1692 @@ +import html +import random +from datetime import datetime, timedelta +from typing import Iterable, Optional + +import html2text +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Center, Horizontal, Vertical, VerticalScroll +from textual.events import Key, MouseDown +from textual.screen import Screen, ModalScreen +from textual.timer import Timer +from textual.message import Message +from textual.widgets import ( + DataTable, + Footer, + Header, + Input, + LoadingIndicator, + Markdown, + SelectionList, + Static, + Button, +) +from textual.widgets._option_list import OptionList, Option +from textual.widgets._selection_list import Selection +from rich.text import Text + +from ..database import ( + get_full_negotiation_history_for_profile, + get_negotiation_history_for_resume, + get_default_config, + load_profile_config, + log_to_db, + record_apply_action, + save_vacancy_to_cache, + set_active_profile, + get_vacancy_from_cache, + get_dictionary_from_cache, + save_dictionary_to_cache, + get_all_profiles, +) +from ..reference_data import ensure_reference_data +from ..constants import ( + ApiErrorReason, + ConfigKeys, + DELIVERED_STATUS_CODES, + ERROR_REASON_LABELS, + FAILED_STATUS_CODES, + LogSource, + SearchMode, +) + +from .config_screen import ConfigScreen +from .css_manager import CssManager +from .widgets import Pagination + +CSS_MANAGER = CssManager() +MAX_COLUMN_WIDTH = 200 +IGNORED_AFTER_DAYS = 4 + + +def _clamp(value: int, min_value: int, max_value: int) -> int: + return max(min_value, min(max_value, value)) + + +def _normalize_width_map( + width_map: dict[str, int], + order: list[str], + *, + max_value: int | None = None, +) -> dict[str, int]: + """Переводит сохранённые значения ширины в допустимые пределы.""" + normalized: dict[str, int] = {} + for key in order: + raw = width_map.get(key, 0) + try: + value = int(raw) + except (TypeError, ValueError): + value = 0 + normalized_value = max(1, value) + if max_value is not None: + normalized_value = min(max_value, normalized_value) + normalized[key] = normalized_value + return normalized + +FAILED_REASON_SHORT_LABELS: dict[str, str] = { + ApiErrorReason.TEST_REQUIRED: "Тест", + ApiErrorReason.QUESTIONS_REQUIRED: "Вопросы", + ApiErrorReason.ALREADY_APPLIED: "Дубль", + ApiErrorReason.NEGOTIATIONS_FORBIDDEN: "Запрет", + ApiErrorReason.RESUME_NOT_PUBLISHED: "Резюме", + ApiErrorReason.CONDITIONS_NOT_MET: "Не подходит", + ApiErrorReason.NOT_FOUND: "Архив", + ApiErrorReason.BAD_ARGUMENT: "Ошибка", + ApiErrorReason.UNKNOWN_API_ERROR: "Ошибка", + ApiErrorReason.NETWORK_ERROR: "Сеть", +} + +STATUS_DISPLAY_MAP: dict[str, str] = { + "applied": "Отклик", + "invited": "Собес", + "interview": "Собес", + "interview_assigned": "Собес", + "interview_scheduled": "Собес", + "offer": "Оффер", + "offer_made": "Оффер", + "rejected": "Отказ", + "declined": "Отказ", + "canceled": "Отказ", + "cancelled": "Отказ", + "discard": "Отказ", + "employer_viewed": "Просмотр", + "viewed": "Просмотр", + "seen": "Просмотр", + "in_progress": "В работе", + "considering": "В работе", + "processing": "В работе", + "responded": "Ответ", + "response": "Отклик", + "answered": "Ответ", + "ignored": "Игнор", + "hired": "Выход", + "accepted": "Принят", + "test_required": "Тест", + "questions_required": "Вопросы", +} + + +class VacancySelectionList(SelectionList[str]): + """Selection list that ignores pointer toggles.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._allow_toggle = False + + def toggle_current(self) -> None: + """Toggle the highlighted option via code (used by hotkeys).""" + if self.highlighted is None: + return + self._allow_toggle = True + self.action_select() + if self._allow_toggle: + self._allow_toggle = False + + def action_select(self) -> None: + if not self._allow_toggle: + return + super().action_select() + + def _on_option_list_option_selected( + self, event: OptionList.OptionSelected + ) -> None: + if self._allow_toggle: + self._allow_toggle = False + super()._on_option_list_option_selected(event) + return + + event.stop() + self._allow_toggle = False + if event.option_index != self.highlighted: + self.highlighted = event.option_index + else: + self.post_message( + self.SelectionHighlighted(self, event.option_index) + ) + + def on_mouse_down(self, event: MouseDown) -> None: + if event.button != 1: + event.stop() + return + self.focus() + + +class HistoryOptionList(OptionList): + """Option list without toggle markers, used for read-only history.""" + + def on_mouse_down(self, event: MouseDown) -> None: + if event.button != 1: + event.stop() + return + self.focus() + + +def _normalize(text: Optional[str]) -> str: + if not text: + return "" + return " ".join(str(text).lower().split()) + +def _normalize_status_code(status: Optional[str]) -> str: + return (status or "").strip().lower() + + +def _normalize_reason_code(reason: Optional[str]) -> str: + return (reason or "").strip().lower() + + +def _now_for(dt: datetime) -> datetime: + return datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now() + + +def _set_loader_visible(container: Screen, loader_id: str, visible: bool) -> None: + """Toggle a loading indicator within the given container.""" + container.query_one(f"#{loader_id}", LoadingIndicator).display = visible + + +def _is_ignored(applied_at: Optional[datetime]) -> bool: + if not isinstance(applied_at, datetime): + return False + return (_now_for(applied_at) - applied_at) > timedelta(days=IGNORED_AFTER_DAYS) + + +def _is_delivered(status: Optional[str]) -> bool: + code = _normalize_status_code(status) + if not code: + return False + if code in FAILED_STATUS_CODES: + return False + if code in DELIVERED_STATUS_CODES: + return True + for prefix in ("applied", "response", "responded", "invited", "offer"): + if code.startswith(prefix): + return True + return False + + +def _is_failed(status: Optional[str]) -> bool: + code = _normalize_status_code(status) + return code in FAILED_STATUS_CODES + + +def _format_history_status( + status: Optional[str], + reason: Optional[str], + applied_at: Optional[datetime], +) -> str: + code = _normalize_status_code(status) + if not code: + return "-" + + if code == "failed": + reason_code = _normalize_reason_code(reason) + if reason_code in FAILED_REASON_SHORT_LABELS: + return FAILED_REASON_SHORT_LABELS[reason_code] + if reason_code in ERROR_REASON_LABELS: + return ERROR_REASON_LABELS[reason_code] + return reason or "Ошибка" + + if code in {"applied", "response"}: + if _is_ignored(applied_at): + return STATUS_DISPLAY_MAP.get("ignored", "Игнор") + return STATUS_DISPLAY_MAP.get(code, "Отклик") + + if code in STATUS_DISPLAY_MAP: + return STATUS_DISPLAY_MAP[code] + + if status: + return str(status).replace("_", " ").title() + return "-" + + +def _collect_delivered( + history: list[dict], +) -> tuple[set[str], set[str], set[str]]: + """ + Возвращает: + delivered_ids — id вакансий, куда отклик действительно ушёл + delivered_keys — ключи "title|employer" для delivered_ids + delivered_employers — нормализованные названия компаний + """ + processed_vacancies: dict[str, dict] = {} + + for h in history: + vid = str(h.get("vacancy_id") or "") + if not vid: + continue + + status = h.get("status") + updated_at = h.get("applied_at") + + if vid not in processed_vacancies: + processed_vacancies[vid] = { + "last_status": status, + "last_updated_at": updated_at, + "has_been_delivered": _is_delivered(status), + "title": h.get("vacancy_title"), + "employer": h.get("employer_name"), + } + else: + if updated_at and updated_at > processed_vacancies[vid]["last_updated_at"]: + processed_vacancies[vid]["last_status"] = status + processed_vacancies[vid]["last_updated_at"] = updated_at + + if not processed_vacancies[vid]["has_been_delivered"] and _is_delivered(status): + processed_vacancies[vid]["has_been_delivered"] = True + + delivered_ids: set[str] = set() + delivered_keys: set[str] = set() + delivered_employers: set[str] = set() + + for vid, data in processed_vacancies.items(): + is_successfully_delivered = ( + data["has_been_delivered"] and not _is_failed(data["last_status"]) + ) + if is_successfully_delivered: + delivered_ids.add(vid) + + title = _normalize(data["title"]) + employer = _normalize(data["employer"]) + + key = f"{title}|{employer}" + if key.strip('|'): + delivered_keys.add(key) + + if employer: + delivered_employers.add(employer) + + return delivered_ids, delivered_keys, delivered_employers + + +class ApplyConfirmationDialog(ModalScreen[str | None]): + """Модальное окно подтверждения отправки откликов.""" + + BINDINGS = [ + Binding("escape", "cancel", "Отмена", show=True, key_display="Esc"), + ] + + def __init__(self, count: int) -> None: + super().__init__() + self.count = count + self.confirm_code = str(random.randint(1000, 9999)) + + def compose(self) -> ComposeResult: + with Center(id="config-confirm-center"): + with Vertical(id="config-confirm-dialog", classes="config-confirm") as dialog: + dialog.border_title = "Подтверждение" + dialog.styles.border_title_align = "left" + yield Static( + "Если вы уверены, что хотите отправить отклики в выбранные компании, " + f"введите число: [b green]{self.confirm_code}[/]", + classes="config-confirm__message", + expand=True, + ) + yield Static("", id="apply_confirm_error") + yield Center( + Input( + placeholder="Введите число здесь...", + id="apply_confirm_input", + ) + ) + with Horizontal(classes="config-confirm__buttons"): + yield Button("Отправить", id="confirm-submit", variant="success") + yield Button("Сброс", id="confirm-reset", classes="decline") + yield Button("Отмена", id="confirm-cancel") + + def on_mount(self) -> None: + self.query_one(Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + self._attempt_submit(event.value, event.input) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "confirm-submit": + input_widget = self.query_one("#apply_confirm_input", Input) + self._attempt_submit(input_widget.value, input_widget) + elif event.button.id == "confirm-reset": + self.dismiss("reset") + elif event.button.id == "confirm-cancel": + self.dismiss("cancel") + + def action_cancel(self) -> None: + self.dismiss("cancel") + + def _attempt_submit(self, value: str, input_widget: Input) -> None: + if value.strip() == self.confirm_code: + self.dismiss("submit") + return + self.query_one("#apply_confirm_error", Static).update( + "[b red]Неверное число. Попробуйте ещё раз.[/b red]" + ) + input_widget.value = "" + input_widget.focus() + + +class VacancyListScreen(Screen): + """Список вакансий + детали справа.""" + + BINDINGS = [ + Binding("escape", "app.pop_screen", "Назад"), + Binding("_", "toggle_select", "Выбор", show=True, key_display="Space"), + Binding("a", "apply_for_selected", "Откликнуться"), + Binding("ф", "apply_for_selected", "Откликнуться (RU)", show=False), + Binding("h", "open_history", "История", show=True), + Binding("р", "open_history", "История (RU)", show=False), + Binding("c", "edit_config", "Настройки", show=True), + Binding("с", "edit_config", "Настройки (RU)", show=False), + Binding("left", "prev_page", "Предыдущая страница", show=False), + Binding("right", "next_page", "Следующая страница", show=False), + ] + _debounce_timer: Optional[Timer] = None + + PER_PAGE = 50 + COLUMN_KEYS = ["index", "title", "company", "previous"] + + def __init__( + self, + resume_id: str, + search_mode: SearchMode, + config_snapshot: Optional[dict] = None, + *, + resume_title: str | None = None, + ) -> None: + super().__init__() + self.vacancies: list[dict] = [] + self.vacancies_by_id: dict[str, dict] = {} + self.resume_id = resume_id + self.resume_title = (resume_title or "").strip() + self.selected_vacancies: set[str] = set() + self._pending_details_id: Optional[str] = None + self.current_page = 0 + self.total_pages = 1 + self.search_mode = search_mode + self.config_snapshot = config_snapshot or {} + + self.html_converter = html2text.HTML2Text() + self.html_converter.body_width = 0 + self.html_converter.ignore_links = False + self.html_converter.ignore_images = True + self.html_converter.mark_code = True + + defaults = get_default_config() + self._vacancy_left_percent = defaults[ConfigKeys.VACANCY_LEFT_PANE_PERCENT] + self._vacancy_column_widths = _normalize_width_map( + { + "index": defaults[ConfigKeys.VACANCY_COL_INDEX_WIDTH], + "title": defaults[ConfigKeys.VACANCY_COL_TITLE_WIDTH], + "company": defaults[ConfigKeys.VACANCY_COL_COMPANY_WIDTH], + "previous": defaults[ConfigKeys.VACANCY_COL_PREVIOUS_WIDTH], + }, + self.COLUMN_KEYS, + max_value=MAX_COLUMN_WIDTH, + ) + + @staticmethod + def _format_segment( + content: str | None, + width: int, + *, + style: str | None = None, + strike: bool = False, + ) -> Text: + segment = Text(content or "", no_wrap=True, overflow="ellipsis") + segment.truncate(width, overflow="ellipsis") + if strike: + segment.stylize("strike", 0, len(segment)) + if style: + segment.stylize(style, 0, len(segment)) + padding = max(0, width - segment.cell_len) + if padding: + segment.append(" " * padding) + return segment + + def _reload_vacancy_layout_preferences(self) -> None: + config = load_profile_config(self.app.client.profile_name) + defaults = get_default_config() + self._vacancy_left_percent = _clamp( + int(config.get(ConfigKeys.VACANCY_LEFT_PANE_PERCENT, defaults[ConfigKeys.VACANCY_LEFT_PANE_PERCENT])), + 10, + 90, + ) + vacancy_width_values = { + "index": _clamp( + int(config.get(ConfigKeys.VACANCY_COL_INDEX_WIDTH, defaults[ConfigKeys.VACANCY_COL_INDEX_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + "title": _clamp( + int(config.get(ConfigKeys.VACANCY_COL_TITLE_WIDTH, defaults[ConfigKeys.VACANCY_COL_TITLE_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + "company": _clamp( + int(config.get(ConfigKeys.VACANCY_COL_COMPANY_WIDTH, defaults[ConfigKeys.VACANCY_COL_COMPANY_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + "previous": _clamp( + int(config.get(ConfigKeys.VACANCY_COL_PREVIOUS_WIDTH, defaults[ConfigKeys.VACANCY_COL_PREVIOUS_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + } + self._vacancy_column_widths = _normalize_width_map( + vacancy_width_values, self.COLUMN_KEYS, max_value=MAX_COLUMN_WIDTH + ) + + def _apply_vacancy_workspace_widths(self) -> None: + try: + vacancy_panel = self.query_one("#vacancy_panel") + details_panel = self.query_one("#details_panel") + except Exception: + return + vacancy_panel.styles.width = f"{self._vacancy_left_percent}%" + right_percent = max(5, 100 - self._vacancy_left_percent) + details_panel.styles.width = f"{right_percent}%" + + def _update_vacancy_header(self) -> None: + try: + header = self.query_one("#vacancy_list_header", Static) + except Exception: + return + header.update( + self._build_row_text( + index="№", + title="Название вакансии", + company="Компания", + previous="Откликался", + index_style="bold", + title_style="bold", + company_style="bold", + previous_style="bold", + ) + ) + + @staticmethod + def _selection_values(options: Iterable[Selection | str]) -> set[str]: + values: set[str] = set() + for option in options: + value = getattr(option, "value", option) + if value and value != "__none__": + values.add(str(value)) + return values + + def _update_selected_from_list(self, selection_list: SelectionList) -> None: + self.selected_vacancies = self._selection_values( + selection_list.selected + ) + + def _build_row_text( + self, + *, + index: str, + title: str, + company: str | None, + previous: str, + strike: bool = False, + index_style: str | None = None, + title_style: str | None = None, + company_style: str | None = "dim", + previous_style: str | None = None, + ) -> Text: + strike_style = "#8c8c8c" if strike else None + widths = self._vacancy_column_widths + + index_segment = self._format_segment( + index, widths["index"], style=index_style + ) + title_segment = self._format_segment( + title, + widths["title"], + style=strike_style or title_style, + strike=strike, + ) + company_segment = self._format_segment( + company, + widths["company"], + style=strike_style or company_style, + strike=strike, + ) + previous_segment = self._format_segment( + previous, + widths["previous"], + style=strike_style or previous_style, + strike=strike, + ) + + return Text.assemble( + index_segment, + Text(" "), + title_segment, + Text(" "), + company_segment, + Text(" "), + previous_segment, + ) + + def compose(self) -> ComposeResult: + with Vertical(id="vacancy_screen"): + yield Header(show_clock=True, name="hh-cli") + with Horizontal(id="vacancy_layout"): + with Vertical( + id="vacancy_panel", classes="pane" + ) as vacancy_panel: + vacancy_panel.border_title = "Вакансии" + vacancy_panel.styles.border_title_align = "left" + yield Static(id="vacancy_list_header") + yield VacancySelectionList(id="vacancy_list") + yield Pagination() + with Vertical( + id="details_panel", classes="pane" + ) as details_panel: + details_panel.border_title = "Детали" + details_panel.styles.border_title_align = "left" + with VerticalScroll(id="details_pane"): + yield Markdown( + "[dim]Выберите вакансию слева, " + "чтобы увидеть детали.[/dim]", + id="vacancy_details", + ) + yield LoadingIndicator(id="vacancy_loader") + yield Footer() + + def on_mount(self) -> None: + self._reload_vacancy_layout_preferences() + self._apply_vacancy_workspace_widths() + self._update_vacancy_header() + self._fetch_and_refresh_vacancies(page=0) + + def on_screen_resume(self) -> None: + """При возврате фокусируем список вакансий без принудительного обновления.""" + self.query_one(VacancySelectionList).focus() + + def _fetch_and_refresh_vacancies(self, page: int) -> None: + """Запускает воркер для загрузки вакансий и обновления UI.""" + self.current_page = page + self._reload_vacancy_layout_preferences() + self._apply_vacancy_workspace_widths() + self._update_vacancy_header() + _set_loader_visible(self, "vacancy_loader", True) + self.query_one(VacancySelectionList).clear_options() + self.query_one(VacancySelectionList).add_option( + Selection("Загрузка вакансий...", "__none__", disabled=True) + ) + self.run_worker( + self._fetch_worker(page), exclusive=True, thread=True + ) + + async def _fetch_worker(self, page: int) -> None: + """Воркер, выполняющий API-запрос.""" + try: + if self.search_mode == SearchMode.MANUAL: + self.config_snapshot = load_profile_config(self.app.client.profile_name) + + if self.search_mode == SearchMode.AUTO: + result = self.app.client.get_similar_vacancies( + self.resume_id, page=page, per_page=self.PER_PAGE + ) + else: + result = self.app.client.search_vacancies( + self.config_snapshot, page=page, per_page=self.PER_PAGE + ) + items = (result or {}).get("items", []) + pages = (result or {}).get("pages", 1) + self.app.call_from_thread(self._on_vacancies_loaded, items, pages) + except Exception as e: + log_to_db("ERROR", LogSource.VACANCY_LIST_FETCH, f"Ошибка загрузки: {e}") + self.app.notify(f"Ошибка загрузки: {e}", severity="error") + + def _on_vacancies_loaded(self, items: list, pages: int) -> None: + """Обработчик успешной загрузки данных.""" + profile_name = self.app.client.profile_name + config = load_profile_config(profile_name) + + filtered_items = items + if config.get(ConfigKeys.DEDUPLICATE_BY_NAME_AND_COMPANY, True): + seen_keys = set() + unique_vacancies = [] + for vac in items: + name = _normalize(vac.get("name")) + employer = vac.get("employer") or {} + emp_key = _normalize(employer.get("id") or employer.get("name")) + key = f"{name}|{emp_key}" + + if key not in seen_keys: + seen_keys.add(key) + unique_vacancies.append(vac) + + num_removed = len(items) - len(unique_vacancies) + if num_removed > 0: + self.app.notify(f"Удалено дублей: {num_removed}", title="Фильтрация") + + filtered_items = unique_vacancies + + self.vacancies = filtered_items + self.vacancies_by_id = {v["id"]: v for v in filtered_items} + self.total_pages = pages + + pagination = self.query_one(Pagination) + pagination.update_state(self.current_page, self.total_pages) + + self._refresh_vacancy_list() + _set_loader_visible(self, "vacancy_loader", False) + + def _refresh_vacancy_list(self) -> None: + """Перерисовывает список вакансий, сохраняя текущий фокус.""" + vacancy_list = self.query_one(VacancySelectionList) + highlighted_pos = vacancy_list.highlighted + + vacancy_list.clear_options() + + if not self.vacancies: + vacancy_list.add_option( + Selection("Вакансии не найдены.", "__none__", disabled=True) + ) + return + + profile_name = self.app.client.profile_name + config = load_profile_config(profile_name) + history = get_full_negotiation_history_for_profile(profile_name) + + delivered_ids, delivered_keys, delivered_employers = \ + _collect_delivered(history) + + start_offset = self.current_page * self.PER_PAGE + + for idx, vac in enumerate(self.vacancies): + raw_name = vac["name"] + strike = False + + if (config.get(ConfigKeys.STRIKETHROUGH_APPLIED_VAC) and + vac["id"] in delivered_ids): + strike = True + + if not strike and config.get(ConfigKeys.STRIKETHROUGH_APPLIED_VAC_NAME): + employer_data = vac.get("employer") or {} + key = (f"{_normalize(vac['name'])}|" + f"{_normalize(employer_data.get('name'))}") + if key in delivered_keys: + strike = True + + employer_name = (vac.get("employer") or {}).get("name") or "-" + normalized_employer = _normalize(employer_name) + previous_company = bool( + normalized_employer and normalized_employer in delivered_employers + ) + previous_label = "да" if previous_company else "нет" + previous_style = "green" if previous_company else "dim" + + row_text = self._build_row_text( + index=f"#{start_offset + idx + 1}", + title=raw_name, + company=employer_name, + previous=previous_label, + strike=strike, + index_style="bold", + previous_style=previous_style, + ) + vacancy_list.add_option(Selection(row_text, vac["id"])) + + if highlighted_pos is not None and \ + highlighted_pos < vacancy_list.option_count: + vacancy_list.highlighted = highlighted_pos + else: + vacancy_list.highlighted = 0 if vacancy_list.option_count else None + + vacancy_list.focus() + self._update_selected_from_list(vacancy_list) + + if vacancy_list.option_count and vacancy_list.highlighted is not None: + focused_option = vacancy_list.get_option_at_index( + vacancy_list.highlighted + ) + if focused_option.value not in (None, "__none__"): + self.load_vacancy_details(str(focused_option.value)) + + def on_selection_list_selection_highlighted( + self, event: SelectionList.SelectionHighlighted + ) -> None: + if self._debounce_timer: + self._debounce_timer.stop() + vacancy_id = event.selection.value + if not vacancy_id or vacancy_id == "__none__": + return + self._debounce_timer = self.set_timer( + 0.2, lambda vid=str(vacancy_id): self.load_vacancy_details(vid) + ) + + def on_selection_list_selection_toggled( + self, event: SelectionList.SelectionToggled + ) -> None: + self._update_selected_from_list(event.selection_list) + + def load_vacancy_details(self, vacancy_id: Optional[str]) -> None: + if not vacancy_id: + return + self._pending_details_id = vacancy_id + log_to_db("INFO", LogSource.VACANCY_LIST_SCREEN, + f"Просмотр деталей: {vacancy_id}") + self.update_vacancy_details(vacancy_id) + + def update_vacancy_details(self, vacancy_id: str) -> None: + cached = get_vacancy_from_cache(vacancy_id) + if cached: + log_to_db("INFO", LogSource.CACHE, f"Кэш попадание: {vacancy_id}") + _set_loader_visible(self, "vacancy_loader", False) + self.display_vacancy_details(cached, vacancy_id) + return + + log_to_db("INFO", LogSource.CACHE, + f"Нет в кэше, тянем из API: {vacancy_id}") + _set_loader_visible(self, "vacancy_loader", True) + self.query_one("#vacancy_details", Markdown).update("") + self.run_worker( + self.fetch_vacancy_details(vacancy_id), + exclusive=True, thread=True + ) + + async def fetch_vacancy_details(self, vacancy_id: str) -> None: + try: + details = self.app.client.get_vacancy_details(vacancy_id) + save_vacancy_to_cache(vacancy_id, details) + self.app.call_from_thread( + self.display_vacancy_details, details, vacancy_id + ) + except Exception as exc: + log_to_db("ERROR", LogSource.VACANCY_LIST_SCREEN, + f"Ошибка деталей {vacancy_id}: {exc}") + self.app.call_from_thread( + self.query_one("#vacancy_details").update, + f"Ошибка загрузки: {exc}" + ) + self.app.call_from_thread( + _set_loader_visible, + self, + "vacancy_loader", + False, + ) + + def display_vacancy_details(self, details: dict, vacancy_id: str) -> None: + if self._pending_details_id != vacancy_id: + return + + salary_line = f"**Зарплата:** N/A\n\n" + salary_data = details.get("salary") + if salary_data: + s_from = salary_data.get("from") + s_to = salary_data.get("to") + currency = (salary_data.get("currency") or "").upper() + gross_str = " (до вычета налогов)" if salary_data.get("gross") else "" + + parts = [] + if s_from: + parts.append(f"от {s_from:,}".replace(",", " ")) + if s_to: + parts.append(f"до {s_to:,}".replace(",", " ")) + + if parts: + salary_str = " ".join(parts) + salary_line = (f"**Зарплата:** {salary_str} {currency}{gross_str}\n\n") + + desc_html = details.get("description", "") + desc_md = self.html_converter.handle(html.unescape(desc_html)).strip() + skills = details.get("key_skills") or [] + skills_text = "* " + "\n* ".join( + s["name"] for s in skills + ) if skills else "Не указаны" + + doc = ( + f"## {details['name']}\n\n" + f"**Компания:** {details['employer']['name']}\n\n" + f"**Ссылка:** {details['alternate_url']}\n\n" + f"{salary_line}" + f"**Ключевые навыки:**\n{skills_text}\n\n" + f"**Описание:**\n\n{desc_md}\n" + ) + self.query_one("#vacancy_details").update(doc) + _set_loader_visible(self, "vacancy_loader", False) + self.query_one("#details_pane").scroll_home(animate=False) + + def action_toggle_select(self) -> None: + self._toggle_current_selection() + + def on_key(self, event: Key) -> None: + if event.key != "space": + return + event.prevent_default() + event.stop() + self._toggle_current_selection() + + def _toggle_current_selection(self) -> None: + selection_list = self.query_one(VacancySelectionList) + if selection_list.highlighted is None: + return + selection = selection_list.get_option_at_index( + selection_list.highlighted + ) + if selection.value in (None, "__none__"): + return + selection_list.toggle_current() + log_to_db("INFO", LogSource.VACANCY_LIST_SCREEN, + f"Переключили выбор: {selection.value}") + + def action_apply_for_selected(self) -> None: + if not self.selected_vacancies: + selection_list = self.query_one(SelectionList) + self._update_selected_from_list(selection_list) + if not self.selected_vacancies: + self.app.notify( + "Нет выбранных вакансий.", + title="Внимание", severity="warning" + ) + return + self.app.push_screen( + ApplyConfirmationDialog(len(self.selected_vacancies)), + self.on_apply_confirmed + ) + + def action_edit_config(self) -> None: + """Открыть экран редактирования конфигурации из списка вакансий.""" + self.app.push_screen(ConfigScreen(), self._on_config_screen_closed) + + def action_open_history(self) -> None: + """Показать историю откликов для текущего резюме.""" + self.app.push_screen( + NegotiationHistoryScreen( + resume_id=self.resume_id, + resume_title=self.resume_title, + ) + ) + + def _on_config_screen_closed(self, saved: bool | None) -> None: + """После закрытия настроек сохраняем выбор и при необходимости обновляем данные.""" + self.query_one(VacancySelectionList).focus() + if not saved: + return + self.app.notify("Обновление списка вакансий...", timeout=1.5) + self._fetch_and_refresh_vacancies(self.current_page) + + def on_apply_confirmed(self, decision: str | None) -> None: + selection_list = self.query_one(VacancySelectionList) + selection_list.focus() + + if decision == "reset": + self.selected_vacancies.clear() + selection_list.deselect_all() + self._update_selected_from_list(selection_list) + self.app.notify("Выбор вакансий сброшен.", title="Сброс", severity="information") + self._fetch_and_refresh_vacancies(self.current_page) + return + + if decision != "submit": + return + + if not self.selected_vacancies: + return + + self.app.notify( + f"Отправка {len(self.selected_vacancies)} откликов...", + title="В процессе", timeout=2 + ) + self.run_worker(self.run_apply_worker(), thread=True) + + async def run_apply_worker(self) -> None: + profile_name = self.app.client.profile_name + cover_letter = load_profile_config(profile_name).get(ConfigKeys.COVER_LETTER, "") + + for vacancy_id in list(self.selected_vacancies): + v = self.vacancies_by_id.get(vacancy_id, {}) + ok, reason_code = self.app.client.apply_to_vacancy( + resume_id=self.resume_id, + vacancy_id=vacancy_id, message=cover_letter + ) + vac_title = v.get("name", vacancy_id) + emp = (v.get("employer") or {}).get("name") + + human_readable_reason = ERROR_REASON_LABELS.get( + reason_code, reason_code + ) + + if ok: + self.app.call_from_thread( + self.app.notify, f"[OK] {vac_title}", + title="Отклик отправлен" + ) + record_apply_action( + vacancy_id, + profile_name, + self.resume_id, + self.resume_title, + vac_title, + emp, + ApiErrorReason.APPLIED, + None, + ) + else: + self.app.call_from_thread( + self.app.notify, + f"[Ошибка: {human_readable_reason}] {vac_title}", + title="Отклик не удался", severity="error", timeout=2 + ) + record_apply_action( + vacancy_id, + profile_name, + self.resume_id, + self.resume_title, + vac_title, + emp, + "failed", + reason_code, + ) + + def finalize() -> None: + self.app.notify("Все отклики обработаны.", title="Готово") + self.selected_vacancies.clear() + selection_list = self.query_one(SelectionList) + selection_list.deselect_all() + self._update_selected_from_list(selection_list) + self._refresh_vacancy_list() + + self.app.call_from_thread(finalize) + + def action_prev_page(self) -> None: + """Переключиться на предыдущую страницу.""" + if self.current_page > 0: + self._fetch_and_refresh_vacancies(self.current_page - 1) + + def action_next_page(self) -> None: + """Переключиться на следующую страницу.""" + if self.current_page < self.total_pages - 1: + self._fetch_and_refresh_vacancies(self.current_page + 1) + + def on_pagination_page_changed( + self, message: Pagination.PageChanged + ) -> None: + """Обработчик нажатия на кнопку пагинации.""" + self._fetch_and_refresh_vacancies(message.page) + + +class NegotiationHistoryScreen(Screen): + """Экран просмотра истории откликов.""" + + BINDINGS = [ + Binding("escape", "app.pop_screen", "Назад"), + Binding("c", "edit_config", "Настройки", show=True), + Binding("с", "edit_config", "Настройки (RU)", show=False), + ] + + COLUMN_KEYS = ["index", "title", "company", "status", "sent", "date"] + + def __init__(self, resume_id: str, resume_title: str | None = None) -> None: + super().__init__() + self.resume_id = str(resume_id or "") + self.resume_title = (resume_title or "").strip() + self.history: list[dict] = [] + self.history_by_vacancy: dict[str, dict] = {} + self._pending_details_id: Optional[str] = None + self._debounce_timer: Optional[Timer] = None + + self.html_converter = html2text.HTML2Text() + self.html_converter.body_width = 0 + self.html_converter.ignore_links = False + self.html_converter.ignore_images = True + self.html_converter.mark_code = True + + def compose(self) -> ComposeResult: + with Vertical(id="history_screen"): + yield Header(show_clock=True, name="hh-cli") + if self.resume_title: + yield Static( + f"Резюме: [b cyan]{self.resume_title}[/b cyan]\n", + id="history_resume_label", + ) + with Horizontal(id="history_layout"): + with Vertical(id="history_panel", classes="pane") as history_panel: + history_panel.border_title = "История откликов" + history_panel.styles.border_title_align = "left" + yield Static(id="history_list_header") + yield HistoryOptionList(id="history_list") + with Vertical(id="history_details_panel", classes="pane") as details_panel: + details_panel.border_title = "Детали" + details_panel.styles.border_title_align = "left" + with VerticalScroll(id="history_details_pane"): + yield Markdown( + "[dim]Выберите отклик слева, чтобы увидеть детали.[/dim]", + id="history_details", + ) + yield LoadingIndicator(id="history_loader") + yield Footer() + + def on_mount(self) -> None: + self._reload_history_layout_preferences() + self._apply_history_workspace_widths() + self._update_history_header() + self._refresh_history() + + def on_screen_resume(self) -> None: + self._reload_history_layout_preferences() + self._apply_history_workspace_widths() + self._update_history_header() + self.query_one(HistoryOptionList).focus() + + def _reload_history_layout_preferences(self) -> None: + config = load_profile_config(self.app.client.profile_name) + defaults = get_default_config() + self._history_left_percent = _clamp( + int(config.get(ConfigKeys.HISTORY_LEFT_PANE_PERCENT, defaults[ConfigKeys.HISTORY_LEFT_PANE_PERCENT])), + 10, + 90, + ) + history_width_values = { + "index": _clamp( + int(config.get(ConfigKeys.HISTORY_COL_INDEX_WIDTH, defaults[ConfigKeys.HISTORY_COL_INDEX_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + "title": _clamp( + int(config.get(ConfigKeys.HISTORY_COL_TITLE_WIDTH, defaults[ConfigKeys.HISTORY_COL_TITLE_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + "company": _clamp( + int(config.get(ConfigKeys.HISTORY_COL_COMPANY_WIDTH, defaults[ConfigKeys.HISTORY_COL_COMPANY_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + "status": _clamp( + int(config.get(ConfigKeys.HISTORY_COL_STATUS_WIDTH, defaults[ConfigKeys.HISTORY_COL_STATUS_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + "sent": _clamp( + int(config.get(ConfigKeys.HISTORY_COL_SENT_WIDTH, defaults[ConfigKeys.HISTORY_COL_SENT_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + "date": _clamp( + int(config.get(ConfigKeys.HISTORY_COL_DATE_WIDTH, defaults[ConfigKeys.HISTORY_COL_DATE_WIDTH])), + 1, + MAX_COLUMN_WIDTH, + ), + } + self._history_column_widths = _normalize_width_map( + history_width_values, self.COLUMN_KEYS, max_value=MAX_COLUMN_WIDTH + ) + + def _apply_history_workspace_widths(self) -> None: + try: + history_panel = self.query_one("#history_panel") + details_panel = self.query_one("#history_details_panel") + except Exception: + return + history_panel.styles.width = f"{self._history_left_percent}%" + details_panel.styles.width = f"{max(5, 100 - self._history_left_percent)}%" + + def _update_history_header(self) -> None: + try: + header = self.query_one("#history_list_header", Static) + except Exception: + return + header.update(self._build_header_text()) + + def _refresh_history(self) -> None: + self._reload_history_layout_preferences() + self._apply_history_workspace_widths() + header = self.query_one("#history_list_header", Static) + header.update(self._build_header_text()) + + option_list = self.query_one(HistoryOptionList) + option_list.clear_options() + + profile_name = self.app.client.profile_name + raw_entries = get_negotiation_history_for_resume(profile_name, self.resume_id) + + entries: list[dict] = [] + for item in raw_entries: + display_status = _format_history_status( + item.get("status"), + item.get("reason"), + item.get("applied_at"), + ) + enriched = dict(item) + enriched["status_display"] = display_status + enriched["sent_display"] = "да" if bool(item.get("was_delivered")) else "нет" + entries.append(enriched) + + self.history = entries + self.history_by_vacancy = { + str(item.get("vacancy_id")): item for item in entries if item.get("vacancy_id") + } + + if not entries: + option_list.add_option( + Option("История откликов пуста.", "__none__", disabled=True) + ) + self.query_one("#history_details", Markdown).update( + "[dim]Нет данных для отображения.[/dim]" + ) + _set_loader_visible(self, "history_loader", False) + return + + for idx, entry in enumerate(entries, start=1): + vacancy_id = str(entry.get("vacancy_id") or "") + title = entry.get("vacancy_title") or vacancy_id + company = entry.get("employer_name") or "-" + applied_label = self._format_date(entry.get("applied_at")) + status_label = entry.get("status_display") or "-" + sent_label = entry.get("sent_display") or ("да" if entry.get("was_delivered") else "нет") + + row_text = self._build_row_text( + index=f"#{idx}", + title=title, + company=company, + status=status_label, + delivered=sent_label, + applied=applied_label, + ) + option_list.add_option(Option(row_text, vacancy_id)) + + option_list.highlighted = 0 if option_list.option_count else None + option_list.focus() + + if option_list.option_count and option_list.highlighted is not None: + focused_option = option_list.get_option_at_index(option_list.highlighted) + if focused_option and focused_option.id not in (None, "__none__"): + self.load_vacancy_details(str(focused_option.id)) + + def _build_header_text(self) -> Text: + widths = self._history_column_widths + return Text.assemble( + VacancyListScreen._format_segment("№", widths["index"], style="bold"), + Text(" "), + VacancyListScreen._format_segment( + "Название вакансии", widths["title"], style="bold" + ), + Text(" "), + VacancyListScreen._format_segment("Компания", widths["company"], style="bold"), + Text(" "), + VacancyListScreen._format_segment("Статус", widths["status"], style="bold"), + Text(" "), + VacancyListScreen._format_segment("✉", widths["sent"], style="bold"), + Text(" "), + VacancyListScreen._format_segment( + "Дата отклика", widths["date"], style="bold" + ), + ) + + def _build_row_text( + self, + *, + index: str, + title: str, + company: str, + status: str, + delivered: str, + applied: str, + ) -> Text: + widths = self._history_column_widths + return Text.assemble( + VacancyListScreen._format_segment(index, widths["index"], style="bold"), + Text(" "), + VacancyListScreen._format_segment(title, widths["title"]), + Text(" "), + VacancyListScreen._format_segment(company, widths["company"]), + Text(" "), + VacancyListScreen._format_segment(status, widths["status"]), + Text(" "), + VacancyListScreen._format_segment(delivered, widths["sent"]), + Text(" "), + VacancyListScreen._format_segment(applied, widths["date"]), + ) + + @staticmethod + def _format_datetime(value: datetime | str | None) -> str: + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M") + if isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d %H:%M") + except ValueError: + return value + return "-" + + @staticmethod + def _format_date(value: datetime | str | None) -> str: + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d") + if isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d") + except ValueError: + return value.split(" ")[0] + return "-" + + def on_option_list_option_highlighted( + self, event: OptionList.OptionHighlighted + ) -> None: + if self._debounce_timer: + self._debounce_timer.stop() + vacancy_id = event.option.id + if not vacancy_id or vacancy_id == "__none__": + return + self._debounce_timer = self.set_timer( + 0.2, lambda vid=str(vacancy_id): self.load_vacancy_details(vid) + ) + + def load_vacancy_details(self, vacancy_id: Optional[str]) -> None: + if not vacancy_id: + return + self._pending_details_id = vacancy_id + _set_loader_visible(self, "history_loader", True) + self.query_one("#history_details", Markdown).update("") + + cached = get_vacancy_from_cache(vacancy_id) + if cached: + self.display_history_details(cached, vacancy_id) + _set_loader_visible(self, "history_loader", False) + return + + self.run_worker( + self.fetch_history_details(vacancy_id), + exclusive=True, + thread=True, + ) + + async def fetch_history_details(self, vacancy_id: str) -> None: + try: + details = self.app.client.get_vacancy_details(vacancy_id) + save_vacancy_to_cache(vacancy_id, details) + self.app.call_from_thread( + self.display_history_details, + details, + vacancy_id, + ) + except Exception as exc: + log_to_db("ERROR", LogSource.VACANCY_LIST_SCREEN, f"Ошибка деталей {vacancy_id}: {exc}") + self.app.call_from_thread(self._display_details_error, f"Ошибка загрузки: {exc}") + + def display_history_details(self, details: dict, vacancy_id: str) -> None: + if self._pending_details_id != vacancy_id: + return + + record = self.history_by_vacancy.get(vacancy_id, {}) + + salary_line = "N/A" + salary_data = details.get("salary") + if salary_data: + s_from = salary_data.get("from") + s_to = salary_data.get("to") + currency = (salary_data.get("currency") or "").upper() + gross_str = " (до вычета налогов)" if salary_data.get("gross") else "" + + parts = [] + if s_from: + parts.append(f"от {s_from:,}".replace(",", " ")) + if s_to: + parts.append(f"до {s_to:,}".replace(",", " ")) + if parts: + salary_line = f"{' '.join(parts)} {currency}{gross_str}" + + desc_html = details.get("description", "") + desc_md = self.html_converter.handle(html.unescape(desc_html)).strip() + skills = details.get("key_skills") or [] + skills_text = "* " + "\n* ".join( + s["name"] for s in skills + ) if skills else "Не указаны" + + applied_label = self._format_datetime(record.get("applied_at")) + status_label = record.get("status_display") or _format_history_status( + record.get("status"), + record.get("reason"), + record.get("applied_at"), + ) + reason_label = "" + if _normalize_status_code(record.get("status")) == "failed": + reason_code = _normalize_reason_code(record.get("reason")) + if reason_code in ERROR_REASON_LABELS: + reason_label = ERROR_REASON_LABELS[reason_code] + elif record.get("reason"): + reason_label = str(record.get("reason")) + sent_label = "да" if bool(record.get("was_delivered")) else "нет" + + company_name = details.get("employer", {}).get("name") or record.get("employer_name") or "-" + link = details.get("alternate_url") or "—" + + doc = ( + f"## {details.get('name', record.get('vacancy_title', vacancy_id))}\n\n" + f"**Компания:** {company_name}\n\n" + f"**Ссылка:** {link}\n\n" + f"**Зарплата:** {salary_line}\n\n" + f"**Ключевые навыки:**\n{skills_text}\n\n" + f"**Дата и время отклика:** {applied_label}\n\n" + f"**Статус:** {status_label}\n\n" + f"**✉:** {sent_label}\n\n" + ) + if reason_label: + doc += f"**Причина:** {reason_label}\n\n" + doc += "**Описание:**\n\n" + if desc_md: + doc += f"{desc_md}\n" + else: + doc += "[dim]Описание вакансии недоступно.[/dim]\n" + self.query_one("#history_details").update(doc) + _set_loader_visible(self, "history_loader", False) + self.query_one("#history_details_pane").scroll_home(animate=False) + + def action_edit_config(self) -> None: + self.app.push_screen(ConfigScreen(), self._on_config_closed) + + def _on_config_closed(self, _: bool | None) -> None: + self.query_one(HistoryOptionList).focus() + + def _display_details_error(self, message: str) -> None: + self.query_one("#history_details", Markdown).update(message) + _set_loader_visible(self, "history_loader", False) + +class ResumeSelectionScreen(Screen): + """Выбор резюме.""" + + def __init__(self, resume_data: dict) -> None: + super().__init__() + self.resume_data = resume_data + self.index_to_resume_id: list[str] = [] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True, name="hh-cli") + yield DataTable(id="resume_table", cursor_type="row") + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("Должность", "Ссылка") + self.index_to_resume_id.clear() + + items = self.resume_data.get("items", []) + if not items: + table.add_row("[b]У вас нет ни одного резюме.[/b]") + return + + for r in items: + table.add_row(f"[bold green]{r.get('title')}[/bold green]", + r.get("alternate_url")) + self.index_to_resume_id.append(r.get("id")) + + def on_data_table_row_selected(self, _: DataTable.RowSelected) -> None: + table = self.query_one(DataTable) + idx = table.cursor_row + if idx is None or idx < 0 or idx >= len(self.index_to_resume_id): + return + resume_id = self.index_to_resume_id[idx] + resume_title = "" + for r in self.resume_data.get("items", []): + if r.get("id") == resume_id: + resume_title = r.get("title") or "" + break + log_to_db("INFO", LogSource.RESUME_SCREEN, + f"Выбрано резюме: {resume_id} '{resume_title}'") + self.app.push_screen( + SearchModeScreen( + resume_id=resume_id, + resume_title=resume_title, is_root_screen=False + ) + ) + + +class SearchModeScreen(Screen): + """Выбор режима поиска: авто или ручной.""" + + BINDINGS = [ + Binding("1", "run_search('auto')", "Авто", show=False), + Binding("2", "run_search('manual')", "Ручной", show=False), + Binding("c", "edit_config", "Настройки", show=True), + Binding("с", "edit_config", "Настройки (RU)", show=False), + Binding("escape", "handle_escape", "Назад/Выход", show=True), + ] + + def __init__( + self, resume_id: str, resume_title: str, is_root_screen: bool = False + ) -> None: + super().__init__() + self.resume_id = resume_id + self.resume_title = resume_title + self.is_root_screen = is_root_screen + + def compose(self) -> ComposeResult: + yield Header(show_clock=True, name="hh-cli") + yield Static( + f"Выбрано резюме: [b cyan]{self.resume_title}[/b cyan]\n" + ) + yield Static("[b]Выберите способ поиска вакансий:[/b]") + yield Static(" [yellow]1)[/] Автоматический (рекомендации hh.ru)") + yield Static(" [yellow]2)[/] Ручной (поиск по ключевым словам)") + yield Footer() + + def action_handle_escape(self) -> None: + if self.is_root_screen: + self.app.exit() + else: + self.app.pop_screen() + + def action_edit_config(self) -> None: + """Открыть экран редактирования конфигурации.""" + self.app.push_screen(ConfigScreen()) + + def action_run_search(self, mode: str) -> None: + log_to_db("INFO", LogSource.SEARCH_MODE_SCREEN, f"Выбран режим '{mode}'") + search_mode_enum = SearchMode(mode) + + if search_mode_enum == SearchMode.AUTO: + self.app.push_screen( + VacancyListScreen( + resume_id=self.resume_id, + search_mode=SearchMode.AUTO, + resume_title=self.resume_title, + ) + ) + else: + cfg = load_profile_config(self.app.client.profile_name) + self.app.push_screen( + VacancyListScreen( + resume_id=self.resume_id, + search_mode=SearchMode.MANUAL, + config_snapshot=cfg, + resume_title=self.resume_title, + ) + ) + + +class ProfileSelectionScreen(Screen): + """Выбор профиля, если их несколько.""" + + def __init__(self, all_profiles: list[dict]) -> None: + super().__init__() + self.all_profiles = all_profiles + self.index_to_profile: list[str] = [] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True, name="hh-cli") + yield Static("[b]Выберите профиль:[/b]\n") + yield DataTable(id="profile_table", cursor_type="row") + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("Имя профиля", "Email") + self.index_to_profile.clear() + for p in self.all_profiles: + table.add_row( + f"[bold green]{p['profile_name']}[/bold green]", p["email"] + ) + self.index_to_profile.append(p["profile_name"]) + + def on_data_table_row_selected(self, _: DataTable.RowSelected) -> None: + table = self.query_one(DataTable) + idx = table.cursor_row + if idx is None or idx < 0 or idx >= len(self.index_to_profile): + return + profile_name = self.index_to_profile[idx] + log_to_db("INFO", LogSource.PROFILE_SCREEN, f"Выбран профиль '{profile_name}'") + set_active_profile(profile_name) + self.dismiss(profile_name) + + +class HHCliApp(App): + """Основное TUI-приложение.""" + + CSS_PATH = CSS_MANAGER.css_file + ENABLE_COMMAND_PALETTE = False + + BINDINGS = [ + Binding("q", "quit", "Выход", show=True, priority=True), + Binding("й", "quit", "Выход (RU)", show=False, priority=True), + ] + + def __init__(self, client) -> None: + super().__init__(watch_css=True) + self.client = client + self.dictionaries = {} + self.css_manager = CSS_MANAGER + self.title = "hh-cli" + + async def on_mount(self) -> None: + log_to_db("INFO", LogSource.TUI, "Приложение смонтировано") + all_profiles = get_all_profiles() + + if not all_profiles: + self.exit( + "В базе не найдено ни одного профиля. " + "Войдите через --auth <имя_профиля>." + ) + return + + if len(all_profiles) == 1: + profile_name = all_profiles[0]["profile_name"] + log_to_db( + "INFO", LogSource.TUI, + f"Найден один профиль '{profile_name}', " + f"используется автоматически." + ) + set_active_profile(profile_name) + await self.proceed_with_profile(profile_name) + else: + log_to_db("INFO", LogSource.TUI, + "Найдено несколько профилей — показ выбора.") + self.push_screen( + ProfileSelectionScreen(all_profiles), self.on_profile_selected + ) + + async def on_profile_selected( + self, selected_profile: Optional[str] + ) -> None: + if not selected_profile: + log_to_db("INFO", LogSource.TUI, "Выбор профиля отменён, выходим.") + self.exit() + return + log_to_db("INFO", LogSource.TUI, + f"Выбран профиль '{selected_profile}' из списка.") + await self.proceed_with_profile(selected_profile) + + async def proceed_with_profile(self, profile_name: str) -> None: + try: + self.client.load_profile_data(profile_name) + self.sub_title = f"Профиль: {profile_name}" + profile_config = load_profile_config(profile_name) + self.css_manager.set_theme(profile_config.get(ConfigKeys.THEME, "hhcli-base")) + + self.run_worker( + self.cache_dictionaries, thread=True, name="DictCacheWorker" + ) + + self.app.notify( + "Синхронизация истории откликов...", + title="Синхронизация", timeout=2 + ) + self.run_worker( + self.client.sync_negotiation_history, + thread=True, name="SyncWorker" + ) + + log_to_db("INFO", LogSource.TUI, f"Загрузка резюме для '{profile_name}'") + resumes = self.client.get_my_resumes() + items = (resumes or {}).get("items") or [] + if len(items) == 1: + r = items[0] + self.push_screen( + SearchModeScreen( + resume_id=r["id"], + resume_title=r["title"], is_root_screen=True + ) + ) + else: + self.push_screen(ResumeSelectionScreen(resume_data=resumes)) + except Exception as exc: + log_to_db("ERROR", LogSource.TUI, + f"Критическая ошибка профиля/резюме: {exc}") + self.exit(result=exc) + + async def cache_dictionaries(self) -> None: + """Проверяет кэш справочников и обновляет его.""" + cached_dicts = get_dictionary_from_cache("main_dictionaries") + if cached_dicts: + log_to_db("INFO", LogSource.TUI, "Справочники загружены из кэша.") + self.dictionaries = cached_dicts + else: + log_to_db( + "INFO", LogSource.TUI, + "Кэш справочников пуст/устарел. Запрос к API..." + ) + try: + live_dicts = self.client.get_dictionaries() + save_dictionary_to_cache("main_dictionaries", live_dicts) + self.dictionaries = live_dicts + log_to_db("INFO", LogSource.TUI, + "Справочники успешно закэшированы.") + except Exception as e: + log_to_db("ERROR", LogSource.TUI, + f"Не удалось загрузить справочники: {e}") + self.app.notify( + "Ошибка загрузки справочников!", severity="error" + ) + return + + try: + updates = ensure_reference_data(self.client) + if updates.get("areas"): + log_to_db("INFO", LogSource.TUI, "Справочник регионов обновлён.") + if updates.get("professional_roles"): + log_to_db( + "INFO", LogSource.TUI, + "Справочник профессиональных ролей обновлён." + ) + except Exception as exc: + log_to_db( + "ERROR", LogSource.TUI, + f"Не удалось обновить справочники регионов/ролей: {exc}" + ) + + def action_quit(self) -> None: + log_to_db("INFO", LogSource.TUI, "Пользователь запросил выход.") + self.css_manager.cleanup() + self.exit() diff --git a/hhcli/ui/widgets.py b/hhcli/ui/widgets.py new file mode 100644 index 0000000..6441df3 --- /dev/null +++ b/hhcli/ui/widgets.py @@ -0,0 +1,103 @@ +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.message import Message +from textual.reactive import reactive +from textual.widgets import Button + +class PaginationButton(Button): + """ + Кнопки для виджета пагинации для полной изоляции стилей от стандартных кнопок. + """ + pass + +class Pagination(Horizontal): + """Виджет пагинации.""" + + class PageChanged(Message): + """Сообщение о смене страницы.""" + def __init__(self, page: int) -> None: + super().__init__() + self.page = page + + current_page = reactive(0) + total_pages = reactive(1) + + def on_mount(self) -> None: + self._rebuild_controls() + + def watch_current_page(self, old_page: int, new_page: int) -> None: + self._rebuild_controls() + + def watch_total_pages(self, old_total: int, new_total: int) -> None: + self._rebuild_controls() + + def update_state(self, current: int, total: int) -> None: + """Обновляет состояние пагинации.""" + self.total_pages = total + self.current_page = current + + def _rebuild_controls(self) -> None: + """Пересобирает кнопки управления.""" + # Используем .remove() для безопасного удаления, если виджет еще не смонтирован + self.remove_children() + + if self.total_pages <= 1: + return + + pages_to_render = [] + if self.total_pages <= 3: + pages_to_render = list(range(self.total_pages)) + elif self.current_page == 0: + pages_to_render = [0, 1, 2] + elif self.current_page == self.total_pages - 1: + pages_to_render = [self.total_pages - 3, self.total_pages - 2, self.total_pages - 1] + else: + pages_to_render = [self.current_page - 1, self.current_page, self.current_page + 1] + + widgets = [] + # Кнопки "назад" + widgets.append(PaginationButton("<<", id="first", disabled=self.current_page == 0)) + widgets.append(PaginationButton("<", id="prev", disabled=self.current_page == 0)) + + # Кнопки с номерами страниц + for page in pages_to_render: + is_current = page == self.current_page + widgets.append( + PaginationButton( + str(page + 1), + id=f"page_{page}", + variant="primary" if is_current else "default", + disabled=is_current, + ) + ) + + # Кнопки "вперед" + widgets.append(PaginationButton(">", id="next", disabled=self.current_page >= self.total_pages - 1)) + widgets.append(PaginationButton(">>", id="last", disabled=self.current_page >= self.total_pages - 1)) + + self.mount_all(widgets) + + def on_button_pressed(self, event: Button.Pressed) -> None: + # Проверяем, что кнопка принадлежит нашему типу, чтобы избежать случайных срабатываний + if not isinstance(event.button, PaginationButton): + return + + event.stop() + button_id = event.button.id + if not button_id: + return + + actions = { + "first": 0, "last": self.total_pages - 1, + "prev": self.current_page - 1, "next": self.current_page + 1, + } + target_page = actions.get(button_id) + + if target_page is None and button_id.startswith("page_"): + try: + target_page = int(button_id.split("_")[1]) + except (ValueError, IndexError): + return + + if target_page is not None and 0 <= target_page < self.total_pages: + self.post_message(self.PageChanged(page=target_page)) \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..46ff4b7 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,927 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, + {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, + {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, + {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "flask" +version = "3.1.2" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, + {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "greenlet" +version = "3.2.4" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" +files = [ + {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, + {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, + {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, + {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, + {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, + {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, + {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, + {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "html2text" +version = "2025.4.15" +description = "Turn HTML into equivalent Markdown-structured text." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc"}, + {file = "html2text-2025.4.15.tar.gz", hash = "sha256:948a645f8f0bc3abe7fd587019a2197a12436cd73d0d4908af95bfc8da337588"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +description = "Links recognition library with FULL unicode support." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, + {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, +] + +[package.dependencies] +uc-micro-py = "*" + +[package.extras] +benchmark = ["pytest", "pytest-benchmark"] +dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] +doc = ["myst-parser", "sphinx", "sphinx-book-theme"] +test = ["coverage", "pytest", "pytest-cov"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} +mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +description = "Collection of plugins for markdown-it-py" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"}, + {file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"}, +] + +[package.dependencies] +markdown-it-py = ">=1.0.0,<4.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["myst-parser", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "14.1.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, + {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "ruff" +version = "0.1.15" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, + {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, + {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, + {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, + {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win32.whl", hash = "sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win_amd64.whl", hash = "sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win32.whl", hash = "sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win_amd64.whl", hash = "sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win32.whl", hash = "sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win_amd64.whl", hash = "sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b"}, + {file = "sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc"}, + {file = "sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "textual" +version = "0.43.2" +description = "Modern Text User Interface framework" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "textual-0.43.2-py3-none-any.whl", hash = "sha256:b6a3340738e3c2223049bb6a4fbce059e4f942a4480b8fd146b816ce5228a8ec"}, + {file = "textual-0.43.2.tar.gz", hash = "sha256:7f4f84f1ae753aa39290659dc0bb0aab06abb7e37aa3041349c86940698c6b54"}, +] + +[package.dependencies] +importlib-metadata = ">=4.11.3" +markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} +rich = ">=13.3.3" +typing-extensions = ">=4.4.0,<5.0.0" + +[package.extras] +syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree_sitter_languages (>=1.7.0)"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.11\""} + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +description = "Micro subset of unicode data files for linkify-it-py projects." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"}, + {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"}, +] + +[package.extras] +test = ["coverage", "pytest", "pytest-cov"] + +[[package]] +name = "urllib3" +version = "2.5.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "werkzeug" +version = "3.1.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "ff8b14db5f010ea17f276663c60312ee7d45dfe6c069dafbd8b73498c7cf242d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42a8b63 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[tool.poetry] +name = "hhcli" +version = "0.4.0" +description = "Неофициальный CLI-клиент для поиска работы и откликов на hh.ru." +authors = ["Your Name