From a919af32539be014cf1712667c5a99902ec9257f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 17:48:24 +0000 Subject: [PATCH 1/5] docs: adicionar CLAUDE.md com guia para assistentes de IA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documenta estrutura do repositório, stack, comandos essenciais, arquitetura do código, convenções de idioma/estilo e regras de segurança para orientar assistentes de IA que trabalham no projeto. https://claude.ai/code/session_01XNKttuRDZ8bXMTm7ApjRDu --- CLAUDE.md | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..911a58b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +Guia para assistentes de IA que trabalham neste repositório. + +## Visão geral do projeto + +**CleanTelegram** é um script Python de propósito único que automatiza a limpeza de uma conta Telegram via [Telethon](https://github.com/LonamiWebs/Telethon). Ele apaga históricos de conversa (usuários/bots) e sai de grupos/canais. + +Este é um projeto **destrutivo por design** — qualquer alteração deve preservar os mecanismos de segurança existentes (`--dry-run`, confirmação interativa `"APAGAR TUDO"`). + +## Estrutura do repositório + +``` +CleanTelegram/ +├── clean_telegram.py # Script principal (ponto de entrada único) +├── requirements.txt # Dependências Python (telethon, python-dotenv) +├── .env.example # Template de variáveis de ambiente +├── .gitignore # Ignora .venv, .env, *.session, __pycache__ +└── README.md # Documentação do projeto (pt-BR) +``` + +Não há subdiretórios de código, testes, CI/CD ou configuração de linting. + +## Stack tecnológica + +| Componente | Tecnologia | +| ----------------- | ----------------------------------- | +| Linguagem | Python 3.10+ | +| Cliente Telegram | Telethon 1.42.0 | +| Variáveis de amb. | python-dotenv 1.2.1 | +| Runtime assíncrono| asyncio (stdlib) | +| Gerenciador deps | pip + requirements.txt | + +## Comandos essenciais + +```bash +# Criar e ativar virtualenv +python -m venv .venv +source .venv/bin/activate + +# Instalar dependências +pip install -r requirements.txt + +# Copiar e configurar variáveis de ambiente +cp .env.example .env +# Editar .env com API_ID e API_HASH (https://my.telegram.org) + +# Execução em modo seguro (dry-run — não altera nada) +python clean_telegram.py --dry-run + +# Execução real (pede confirmação "APAGAR TUDO") +python clean_telegram.py + +# Pular confirmação interativa +python clean_telegram.py --yes + +# Limitar quantidade de diálogos processados +python clean_telegram.py --limit 10 + +# Ver todas as opções +python clean_telegram.py --help +``` + +## Arquitetura do código (`clean_telegram.py`) + +O script segue um fluxo linear assíncrono: + +1. **`main()`** — Entry-point: configura logging, carrega `.env`, parseia argumentos, pede confirmação e itera sobre os diálogos. +2. **`_process_dialog()`** — Roteador que escolhe a ação correta conforme o tipo da entidade: + - `Channel` → `leave_channel()` (canais e megagrupos) + - `Chat` → `leave_legacy_chat()` (grupos legados), com fallback via `client.delete_dialog()` + - `User` / bots → `delete_dialog()` (apaga histórico) + - Tipo desconhecido → `client.delete_dialog()` (fallback genérico) +3. **Funções auxiliares:** + - `env_int()` — Lê variável de ambiente obrigatória como int + - `safe_sleep()` — Delay entre operações (0.35s) para evitar rate limit + - `delete_dialog()` — Apaga histórico via `DeleteHistoryRequest` + - `leave_channel()` — Sai de canal/megagrupo via `LeaveChannelRequest` + - `leave_legacy_chat()` — Sai de grupo legado via `DeleteChatUserRequest` + +### Tratamento de erros + +- **`FloodWaitError`**: Retry com backoff exponencial (até 5 tentativas). Aguarda o tempo indicado pela API antes de tentar novamente. +- **`RPCError`**: Loga o erro e pula o diálogo. +- **`Exception` genérica**: Catch-all com log completo do traceback. + +## Convenções do projeto + +### Idioma + +- **Documentação, comentários, docstrings e mensagens de log**: português brasileiro (pt-BR). +- **Nomes de variáveis, funções e parâmetros**: inglês (padrão Python). +- **Mensagens de commit**: português é aceitável, mas o prefixo segue Conventional Commits em inglês (`feat:`, `fix:`, `docs:`, `chore:`). + +### Estilo de código + +- Sem linter ou formatter configurado — manter consistência com o código existente. +- Type hints nas assinaturas de funções. +- Docstrings em português para todas as funções. +- Keyword-only arguments para flags booleanas (ex.: `*, dry_run: bool`). +- Logging via `logger` (módulo `logging`), não `print()` (exceto para interação direta com o usuário). + +### Segurança — regras invioláveis + +1. **Nunca remover ou enfraquecer** o mecanismo de confirmação `"APAGAR TUDO"`. +2. **Nunca remover** a flag `--dry-run` — ela é a principal proteção do usuário. +3. **Nunca fazer commit de arquivos `.env`** ou `*.session` — contêm credenciais sensíveis. +4. **Manter rate-limit handling** — sem ele, a conta do usuário pode ser temporariamente bloqueada pela API do Telegram. +5. **Preservar delays entre operações** (`safe_sleep`) para reduzir risco de flood. + +## Variáveis de ambiente + +| Variável | Obrigatória | Descrição | +| -------------- | ----------- | -------------------------------------------- | +| `API_ID` | Sim | ID da aplicação Telegram (inteiro) | +| `API_HASH` | Sim | Hash da aplicação Telegram (string hex) | +| `SESSION_NAME` | Não | Nome do arquivo de sessão (padrão: `session`)| + +## Testes + +Não há framework de testes configurado. O modo `--dry-run` é o mecanismo atual de verificação segura. Se testes forem adicionados no futuro: +- Usar `pytest` como framework. +- Mockar chamadas ao Telethon/Telegram API (nunca fazer chamadas reais em testes). +- Colocar testes em um diretório `tests/`. + +## Dependências + +Fixadas por versão exata em `requirements.txt`: +- **telethon==1.42.0** — Cliente Telegram (MTProto) +- **python-dotenv==1.2.1** — Carregamento de `.env` + +Para adicionar dependências, atualizar `requirements.txt` com versão exata (ex.: `pacote==X.Y.Z`). From ac7bd9aa368e3c993ffaff41646c4c8da9187a83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 18:13:03 +0000 Subject: [PATCH 2/5] docs: adicionar documento de Fluxo UX completo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cria docs/UX_FLOW.md com diagramas (Mermaid + ASCII) que mapeiam toda a jornada do usuário: setup inicial, autenticação, modos de execução (dry-run/--yes/padrão), roteamento por tipo de entidade, tratamento de erros (FloodWait/RPC) e ciclo de vida da sessão. https://claude.ai/code/session_01XNKttuRDZ8bXMTm7ApjRDu --- docs/UX_FLOW.md | 297 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 docs/UX_FLOW.md diff --git a/docs/UX_FLOW.md b/docs/UX_FLOW.md new file mode 100644 index 0000000..2493a69 --- /dev/null +++ b/docs/UX_FLOW.md @@ -0,0 +1,297 @@ +# Fluxo UX — CleanTelegram + +Documento que mapeia a jornada completa do usuário ao interagir com o script `clean_telegram.py`. + +--- + +## 1. Visão geral dos fluxos + +``` +┌─────────────────────────────────────────────────────┐ +│ JORNADA DO USUÁRIO │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ +│ │ Setup │──▶│Execução │──▶│ Processamento │ │ +│ │ Inicial │ │ & Auth │ │ de Diálogos │ │ +│ └──────────┘ └──────────┘ └────────────────┘ │ +│ │ +│ 3 modos de execução: │ +│ --dry-run Simulação segura (nenhuma alteração) │ +│ --yes Execução sem confirmação interativa │ +│ (padrão) Execução com confirmação "APAGAR TUDO"│ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Fluxo de Setup Inicial (primeira vez) + +``` + USUÁRIO SISTEMA + ─────── ────── + │ + │ python -m venv .venv + │ source .venv/bin/activate + │ pip install -r requirements.txt + ├──────────────────────────────────▶ Instala telethon + python-dotenv + │ + │ cp .env.example .env + │ (edita .env com API_ID e API_HASH) + ├──────────────────────────────────▶ Configura credenciais + │ + │ python clean_telegram.py --dry-run + ├──────────────────────────────────▶ Primeira execução + │ │ + │ ◀── Telethon pede telefone ───────┤ + │ Digita +55 11 9xxxx-xxxx │ + ├──────────────────────────────────▶ │ + │ │ + │ ◀── Telethon pede código ─────────┤ + │ Digita código recebido no Telegram│ + ├──────────────────────────────────▶ │ + │ │ + │ ◀── (se 2FA) pede senha ──────────┤ + │ Digita senha 2FA │ + ├──────────────────────────────────▶ │ + │ │ + │ ├──▶ Salva session.session + │ │ + │ ◀── Dry-run: lista diálogos ──────┤ + │ (nenhuma alteração feita) │ + ▼ ▼ +``` + +> **Nota:** Após o primeiro login, o arquivo `session.session` é reutilizado automaticamente. O fluxo de autenticação não se repete. + +--- + +## 3. Fluxo Principal de Execução + +```mermaid +flowchart TD + A[python clean_telegram.py] --> B{Variáveis .env ok?} + B -- "API_ID ou API_HASH faltando" --> C[/"SystemExit: Faltou ... no .env"/] + B -- "OK" --> D{Qual modo?} + + D -- "--dry-run" --> G[Conecta ao Telegram] + D -- "--yes" --> G + D -- "padrão" --> E["Exibe: Digite 'APAGAR TUDO' para confirmar"] + + E --> F{Usuário digitou\n'APAGAR TUDO'?} + F -- "Não" --> F1[/"Cancelado."/] + F -- "Sim" --> G + + G --> H[Login via TelegramClient] + H --> I["Log: Logado como @username"] + I --> J[Itera sobre diálogos] + + J --> K{Limite atingido?\n--limit N} + K -- "Sim" --> L["Log: Concluído. Diálogos processados: N"] + K -- "Não / sem limite" --> M[Próximo diálogo] + + M --> N[_process_dialog] + N --> O{Tipo da entidade} + + O -- "Channel" --> P["SAIR de canal/megagrupo\nLeaveChannelRequest"] + O -- "Chat" --> Q["SAIR de grupo legado\nDeleteChatUserRequest"] + O -- "User / Bot" --> R["APAGAR conversa\nDeleteHistoryRequest"] + O -- "Desconhecido" --> S["APAGAR diálogo\nclient.delete_dialog()"] + + Q -- "RPCError" --> Q1["Fallback:\nclient.delete_dialog()"] + + P --> T["safe_sleep(0.35s)"] + Q --> T + Q1 --> T + R --> T + S --> T + + T --> J + + style C fill:#d32f2f,color:#fff + style F1 fill:#f57c00,color:#fff + style L fill:#388e3c,color:#fff +``` + +--- + +## 4. Fluxo de Tratamento de Erros (por diálogo) + +```mermaid +flowchart TD + A[_process_dialog] --> B{Resultado} + + B -- "Sucesso" --> C["safe_sleep(0.35s)\n→ próximo diálogo"] + + B -- "FloodWaitError" --> D{Tentativa < 5?} + D -- "Sim" --> E["Log: Rate limit. Aguardando Xs...\nawait asyncio.sleep(wait_s)"] + E --> A + D -- "Não (5 tentativas)" --> F["Log: Max retries atingido\n→ pula diálogo"] + + B -- "RPCError" --> G["Log: RPCError + traceback\n→ pula diálogo"] + + B -- "Exception genérica" --> H["Log: Erro inesperado + traceback\n→ pula diálogo"] + + F --> I[Próximo diálogo] + G --> I + H --> I + C --> I + + style C fill:#388e3c,color:#fff + style F fill:#d32f2f,color:#fff + style G fill:#f57c00,color:#fff + style H fill:#f57c00,color:#fff +``` + +--- + +## 5. Fluxo do modo `--dry-run` + +``` +┌─────────────────────────────────────────────────────────┐ +│ MODO DRY-RUN │ +│ │ +│ Tudo funciona igual ao modo real, EXCETO: │ +│ │ +│ ✓ Confirmação "APAGAR TUDO" é IGNORADA (não pede) │ +│ ✓ Diálogos são iterados normalmente │ +│ ✓ Tipo de cada entidade é identificado │ +│ ✓ Logs são emitidos (SAIR / APAGAR) │ +│ ✗ Nenhuma request destrutiva é enviada ao Telegram │ +│ ✗ LeaveChannelRequest → NÃO executado │ +│ ✗ DeleteChatUserRequest → NÃO executado │ +│ ✗ DeleteHistoryRequest → NÃO executado │ +│ ✗ client.delete_dialog() → NÃO executado │ +│ │ +│ O usuário vê exatamente o que SERIA feito. │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. Mapa de decisões do roteador `_process_dialog` + +``` + entity recebida + │ + ▼ + ┌─────────────┐ Sim ┌───────────────────────┐ + │ é Channel? │────────────▶│ leave_channel() │ + └──────┬──────┘ │ LeaveChannelRequest │ + │ Não └───────────────────────┘ + ▼ + ┌─────────────┐ Sim ┌───────────────────────┐ + │ é Chat? │────────────▶│ leave_legacy_chat() │ + └──────┬──────┘ │ DeleteChatUserRequest │ + │ Não │ │ │ + │ │ RPCError? │ + │ │ ▼ Sim │ + │ │ client.delete_dialog()│ + │ └───────────────────────┘ + ▼ + ┌─────────────┐ Sim ┌───────────────────────┐ + │ é User/Bot? │────────────▶│ delete_dialog() │ + └──────┬──────┘ │ DeleteHistoryRequest │ + │ Não └───────────────────────┘ + ▼ + ┌─────────────────┐ ┌───────────────────────┐ + │ Tipo │────────▶│ client.delete_dialog() │ + │ desconhecido │ │ (fallback genérico) │ + └─────────────────┘ └───────────────────────┘ +``` + +--- + +## 7. Tabela de estados do terminal (o que o usuário vê) + +| Fase | Saída no terminal | Origem | +|------|-------------------|--------| +| Credenciais ausentes | `Faltou API_ID no .env` | `env_int()` / `main()` | +| Confirmação | `ATENÇÃO: isso vai apagar conversas...` | `main()` via `print()` | +| Cancelado | `Cancelado.` | `main()` via `print()` | +| Login | `Logado como: @user (id=123)` | `logger.info` | +| Canal/megagrupo | `[1] SAIR de canal/megagrupo: NomeCanal` | `logger.info` | +| Grupo legado | `[2] SAIR de grupo legado (Chat): NomeGrupo` | `logger.info` | +| Conversa user/bot | `[3] APAGAR conversa: NomeUsuario` | `logger.info` | +| Tipo desconhecido | `[4] APAGAR diálogo (tipo desconhecido): ...` | `logger.info` | +| Rate limit | `Rate limit (FloodWait)... Aguardando Xs` | `logger.warning` | +| Max retries | `Max retries atingido; pulando 'NomeDialogo'` | `logger.error` | +| Erro RPC | `RPCError em 'NomeDialogo'` | `logger.exception` | +| Erro genérico | `Erro inesperado em 'NomeDialogo'` | `logger.exception` | +| Conclusão | `Concluído. Diálogos processados: N` | `logger.info` | + +--- + +## 8. Cenários de uso típicos + +### 8.1 Primeiro uso (cauteloso) +```bash +# 1. Setup +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # editar com API_ID e API_HASH + +# 2. Testar com dry-run +python clean_telegram.py --dry-run + +# 3. Testar com poucos diálogos +python clean_telegram.py --dry-run --limit 5 + +# 4. Executar de verdade (poucos diálogos) +python clean_telegram.py --limit 5 +# → Digita "APAGAR TUDO" + +# 5. Executar em tudo +python clean_telegram.py +# → Digita "APAGAR TUDO" +``` + +### 8.2 Uso automatizado (script/cron) +```bash +python clean_telegram.py --yes +# Pula confirmação interativa — usar com cuidado! +``` + +### 8.3 Debugging de rate limit +```bash +python clean_telegram.py --limit 3 +# Observar logs de FloodWaitError +# Ajustar --limit conforme necessário +``` + +--- + +## 9. Diagrama de ciclo de vida da sessão + +``` + Primeira execução Execuções seguintes + ────────────────── ─────────────────── + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Sem sessão │ │ session. │ + │ local │ │ session │ + └──────┬───────┘ │ existe │ + │ └──────┬───────┘ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Telefone │ │ Login │ + │ + Código │ │ automático │ + │ + (2FA) │ │ │ + └──────┬───────┘ └──────┬───────┘ + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Cria │ │ Reutiliza │ + │ session. │ │ sessão │ + │ session │ │ existente │ + └──────┬───────┘ └──────┬───────┘ + │ │ + └──────────┬───────────────────┘ + ▼ + ┌──────────────┐ + │ Executa │ + │ limpeza │ + └──────────────┘ +``` + +> **Importante:** O arquivo `*.session` contém credenciais de autenticação e **nunca** deve ser commitado no repositório. From d58617eef524f11656ffe6be2a7321c02e826b4b Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Sat, 7 Feb 2026 15:20:00 -0300 Subject: [PATCH 3/5] docs: fix markdownlint on code fences --- CLAUDE.md | 2 +- docs/UX_FLOW.md | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 911a58b..efe7059 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Este é um projeto **destrutivo por design** — qualquer alteração deve prese ## Estrutura do repositório -``` +```text CleanTelegram/ ├── clean_telegram.py # Script principal (ponto de entrada único) ├── requirements.txt # Dependências Python (telethon, python-dotenv) diff --git a/docs/UX_FLOW.md b/docs/UX_FLOW.md index 2493a69..eaf73a8 100644 --- a/docs/UX_FLOW.md +++ b/docs/UX_FLOW.md @@ -6,7 +6,7 @@ Documento que mapeia a jornada completa do usuário ao interagir com o script `c ## 1. Visão geral dos fluxos -``` +```text ┌─────────────────────────────────────────────────────┐ │ JORNADA DO USUÁRIO │ │ │ @@ -26,7 +26,7 @@ Documento que mapeia a jornada completa do usuário ao interagir com o script `c ## 2. Fluxo de Setup Inicial (primeira vez) -``` +```text USUÁRIO SISTEMA ─────── ────── │ @@ -146,7 +146,7 @@ flowchart TD ## 5. Fluxo do modo `--dry-run` -``` +```text ┌─────────────────────────────────────────────────────────┐ │ MODO DRY-RUN │ │ │ @@ -170,7 +170,7 @@ flowchart TD ## 6. Mapa de decisões do roteador `_process_dialog` -``` +```text entity recebida │ ▼ @@ -224,6 +224,7 @@ flowchart TD ## 8. Cenários de uso típicos ### 8.1 Primeiro uso (cauteloso) + ```bash # 1. Setup python -m venv .venv && source .venv/bin/activate @@ -246,12 +247,14 @@ python clean_telegram.py ``` ### 8.2 Uso automatizado (script/cron) + ```bash python clean_telegram.py --yes # Pula confirmação interativa — usar com cuidado! ``` ### 8.3 Debugging de rate limit + ```bash python clean_telegram.py --limit 3 # Observar logs de FloodWaitError @@ -262,7 +265,7 @@ python clean_telegram.py --limit 3 ## 9. Diagrama de ciclo de vida da sessão -``` +```text Primeira execução Execuções seguintes ────────────────── ─────────────────── │ │ From 61cb796925149d2553865bef03a328de160f66fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 18:23:13 +0000 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20reescrever=20README=20com=20documen?= =?UTF-8?q?ta=C3=A7=C3=A3o=20completa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aviso destrutivo em destaque (blockquote) - Funcionalidades detalhadas com APIs usadas - Instalação passo a passo (Linux/macOS/Windows) - Exemplos de uso com saída esperada do dry-run - Tabela de referência de flags (--dry-run, --yes, --limit) - Tabela de variáveis de ambiente - Seção "Como funciona" com diagrama de fluxo ASCII - Tabela de tratamento de erros - Guia de primeira execução (autenticação Telethon) - Estrutura do projeto atualizada - Tabela de dependências com links - FAQ com 5 perguntas frequentes (collapsible) https://claude.ai/code/session_01XNKttuRDZ8bXMTm7ApjRDu --- README.md | 206 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 187 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3ead001..c4e9b5e 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,223 @@ # CleanTelegram -**Tags:** telegram, telethon, python, cleanup, automation, privacy, destructive +> Automação para limpar sua conta Telegram: apaga conversas, sai de grupos e canais. -**Atenção:** este projeto automatiza ações destrutivas na sua conta Telegram (apagar conversas e sair de grupos/canais). Use **por sua conta e risco**. Recomendo testar primeiro com `--dry-run`. +**Tags:** `telegram` `telethon` `python` `cleanup` `automation` `privacy` -## O que faz +--- -- Apaga diálogos (conversas) com usuários/bots. -- Sai de **grupos** e **canais**. -- (Opcional) arquiva e silencia o que não dá para “bloquear”. +> **ATENÇÃO — AÇÃO DESTRUTIVA** +> +> Este script **apaga conversas** e **sai de grupos/canais** da sua conta Telegram. +> As ações são **irreversíveis**. Teste sempre com `--dry-run` antes de executar de verdade. -> Observação: Telegram não tem um “bloquear grupo” de verdade (bloqueio é para **usuários**). Para grupos/canais, o equivalente prático é **sair**; e/ou **arquivar + silenciar**. +--- + +## Funcionalidades + +- Apaga histórico de conversas com **usuários** e **bots** (com revogação quando possível) +- Sai de **canais** e **megagrupos** (`LeaveChannelRequest`) +- Sai de **grupos legados** (`DeleteChatUserRequest`) com fallback automático +- Modo **`--dry-run`** — simula a execução sem alterar nada +- Confirmação obrigatória **`"APAGAR TUDO"`** para evitar execução acidental +- **Rate-limit handling** — retry automático com backoff em caso de `FloodWaitError` +- Delay entre operações (`0.35s`) para proteger contra bloqueio temporário da API + +> **Nota:** O Telegram não oferece "bloqueio" de grupos/canais — apenas de usuários. +> Para grupos e canais, o equivalente prático é **sair**. ## Requisitos -- Python 3.10+ -- Credenciais do Telegram API: `API_ID` e `API_HASH` - - Pegue em: https://my.telegram.org +- **Python 3.10** ou superior +- Credenciais da **Telegram API**: `API_ID` e `API_HASH` + - Obtenha em: [https://my.telegram.org](https://my.telegram.org) ## Instalação ```bash +# 1. Clonar o repositório +git clone https://github.com/prof-ramos/CleanTelegram.git +cd CleanTelegram + +# 2. Criar e ativar virtualenv python -m venv .venv -source .venv/bin/activate +source .venv/bin/activate # Linux / macOS +# .venv\Scripts\activate # Windows + +# 3. Instalar dependências pip install -r requirements.txt + +# 4. Configurar variáveis de ambiente cp .env.example .env ``` -Edite `.env` com `API_ID` e `API_HASH`. +Edite o arquivo `.env` com suas credenciais: + +```ini +API_ID=123456 +API_HASH=abc123def456... +SESSION_NAME=session # opcional (padrão: session) +``` ## Uso -Dry-run (recomendado): +### Modo seguro (dry-run) — recomendado para começar + +Simula a execução e mostra o que **seria** feito, sem alterar nada: ```bash python clean_telegram.py --dry-run ``` -Executar de verdade: +Saída esperada: + +``` +2025-01-15 10:30:00 INFO: Logado como: @seuuser (id=123456789) +2025-01-15 10:30:01 INFO: [1] SAIR de canal/megagrupo: Canal Exemplo +2025-01-15 10:30:01 INFO: [2] APAGAR conversa: João Silva +2025-01-15 10:30:01 INFO: [3] SAIR de grupo legado (Chat): Grupo Antigo +... +2025-01-15 10:30:02 INFO: Concluído. Diálogos processados: 3 +``` + +### Execução real + +Pede confirmação interativa antes de executar: ```bash python clean_telegram.py ``` -Opções úteis: +``` +ATENÇÃO: isso vai apagar conversas e sair de grupos/canais. +Digite 'APAGAR TUDO' para confirmar: APAGAR TUDO +``` + +### Limitar quantidade de diálogos + +Processa apenas os primeiros N diálogos (útil para testar de forma gradual): ```bash -python clean_telegram.py --help +python clean_telegram.py --limit 5 +``` + +### Pular confirmação interativa + +Para uso em scripts ou automação (usar com cuidado): + +```bash +python clean_telegram.py --yes +``` + +## Referência de flags + +| Flag | Descrição | Padrão | +|------|-----------|--------| +| `--dry-run` | Simula a execução sem alterar nada | Desativado | +| `--yes` | Pula a confirmação interativa `"APAGAR TUDO"` | Desativado | +| `--limit N` | Processa no máximo N diálogos (`0` = todos) | `0` | +| `--help` | Exibe ajuda com todas as opções | — | + +## Variáveis de ambiente + +| Variável | Obrigatória | Descrição | +|----------|:-----------:|-----------| +| `API_ID` | Sim | ID da aplicação Telegram (inteiro) | +| `API_HASH` | Sim | Hash da aplicação Telegram (string hex) | +| `SESSION_NAME` | Não | Nome do arquivo de sessão (padrão: `session`) | + +## Como funciona + +O script segue um fluxo linear assíncrono: + ``` +main() + │ + ├─ Carrega .env e valida credenciais + ├─ Pede confirmação "APAGAR TUDO" (se necessário) + ├─ Conecta ao Telegram via TelegramClient + │ + └─ Para cada diálogo: + │ + ├─ Channel (canal/megagrupo) → LeaveChannelRequest + ├─ Chat (grupo legado) → DeleteChatUserRequest + │ └─ fallback: client.delete_dialog() + ├─ User / Bot → DeleteHistoryRequest + └─ Tipo desconhecido → client.delete_dialog() + │ + └─ safe_sleep(0.35s) +``` + +### Tratamento de erros + +| Erro | Comportamento | +|------|---------------| +| `FloodWaitError` | Retry automático (até 5 tentativas), aguardando o tempo indicado pela API | +| `RPCError` | Loga o erro com traceback e pula para o próximo diálogo | +| `Exception` genérica | Loga o erro completo e pula para o próximo diálogo | + +## Primeira execução + +Na primeira vez que rodar o script, o Telethon solicitará: + +1. **Número de telefone** — no formato internacional (ex.: `+5511999999999`) +2. **Código de verificação** — recebido no próprio Telegram +3. **Senha 2FA** — se autenticação em duas etapas estiver ativada + +Após o login, um arquivo `session.session` é criado localmente. Nas execuções seguintes, o login é automático. + +> **Segurança:** O arquivo `*.session` contém credenciais de autenticação. +> Ele está no `.gitignore` e **nunca** deve ser commitado ou compartilhado. + +## Estrutura do projeto + +``` +CleanTelegram/ +├── clean_telegram.py # Script principal (ponto de entrada único) +├── requirements.txt # Dependências (telethon, python-dotenv) +├── .env.example # Template de variáveis de ambiente +├── .gitignore # Ignora .venv, .env, *.session, __pycache__ +├── CLAUDE.md # Guia para assistentes de IA +├── docs/ +│ └── UX_FLOW.md # Fluxo UX detalhado com diagramas +└── README.md # Este arquivo +``` + +## Dependências + +| Pacote | Versão | Finalidade | +|--------|--------|------------| +| [Telethon](https://github.com/LonamiWebs/Telethon) | 1.42.0 | Cliente Telegram (MTProto) | +| [python-dotenv](https://github.com/theskumar/python-dotenv) | 1.2.1 | Carregamento de variáveis do `.env` | + +## Perguntas frequentes + +
+Posso recuperar conversas apagadas? + +Não. A ação é irreversível. O script usa `DeleteHistoryRequest` com `revoke=True`, que apaga o histórico para ambos os lados quando possível. Use sempre `--dry-run` antes. +
+ +
+O script pode bloquear minha conta? + +O script inclui delays entre operações (`0.35s`) e retry automático em caso de `FloodWaitError` para minimizar esse risco. Ainda assim, executar em contas com muitos diálogos pode gerar rate limits temporários do Telegram. Use `--limit` para processar em lotes menores. +
+ +
+E se eu receber "FloodWaitError"? + +O script trata automaticamente: aguarda o tempo exigido pela API e tenta novamente (até 5 vezes). Se persistir, o diálogo é pulado e o script continua. Você pode re-executar depois para processar os diálogos restantes. +
+ +
+Posso escolher quais conversas apagar? + +Atualmente não. O script processa **todos** os diálogos da conta (ou os primeiros N com `--limit`). Filtragem seletiva ainda não foi implementada. +
-## Notas +
+Como deslogar / trocar de conta? -- Na primeira execução, o Telethon vai pedir o **número** e o **código** (e 2FA, se houver) e salvará uma sessão local em `session.session`. -- Pode haver limitações/erros por rate limit do Telegram; o script tenta ser cuidadoso. +Delete o arquivo `session.session` (ou o nome configurado em `SESSION_NAME`). Na próxima execução, o Telethon pedirá login novamente. +
From 768c02130740951ff45f49e77c9677922faeb9db Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Sat, 7 Feb 2026 20:39:59 -0300 Subject: [PATCH 5/5] feat: adicionar login via QR code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona suporte a login via QR code como alternativa ao login por telefone/código. O QR code é exibido diretamente no terminal usando ASCII. Mudanças: - Nova flag --qr-login para ativar login via QR code - Função display_qr_code() para exibir QR code em ASCII no terminal - Função login_with_qr() para gerenciar o fluxo de login via QR code - Regeneração automática do QR code ao expirar (timeout 10s) - Tratamento de SessionPasswordNeededError para contas com 2FA - Sessão separada (_qr.session) para não interferir com sessão principal - Adicionada dependência qrcode>=8.0,<9.0 Co-Authored-By: Claude Opus 4.6 --- .claude/commands/lint.md | 111 +++ .claude/commands/test.md | 73 ++ .claude/settings.json | 151 ++++ .env.example | 4 +- .flake8 | 13 + .gitignore | 149 +++- .mcp.json | 78 ++ CLAUDE.md | 309 +++++--- README.md | 8 +- clean_telegram.py => clean_telegram.py.legacy | 0 docs/UX_FLOW.md | 143 +++- pyproject.toml | 144 ++++ requirements-dev.txt | 16 + requirements.txt | 6 +- src/clean_telegram/__init__.py | 14 + src/clean_telegram/__main__.py | 210 ++++++ src/clean_telegram/client.py | 124 ++++ src/clean_telegram/utils.py | 101 +++ tests/__init__.py | 1 + tests/conftest.py | 150 ++++ tests/test_client.py | 243 +++++++ tests/test_main.py | 562 +++++++++++++++ tests/test_utils.py | 179 +++++ uv.lock | 670 ++++++++++++++++++ 24 files changed, 3330 insertions(+), 129 deletions(-) create mode 100644 .claude/commands/lint.md create mode 100644 .claude/commands/test.md create mode 100644 .claude/settings.json create mode 100644 .flake8 create mode 100644 .mcp.json rename clean_telegram.py => clean_telegram.py.legacy (100%) create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 src/clean_telegram/__init__.py create mode 100644 src/clean_telegram/__main__.py create mode 100644 src/clean_telegram/client.py create mode 100644 src/clean_telegram/utils.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py create mode 100644 tests/test_main.py create mode 100644 tests/test_utils.py create mode 100644 uv.lock diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000..3fb416b --- /dev/null +++ b/.claude/commands/lint.md @@ -0,0 +1,111 @@ +# Python Linter + +Run Python code linting and formatting tools. + +## Purpose + +This command helps you maintain code quality using Python's best linting and formatting tools. + +## Usage + +``` +/lint +``` + +## What this command does + +1. **Runs multiple linters** (flake8, pylint, black, isort) +2. **Provides detailed feedback** on code quality issues +3. **Auto-fixes formatting** where possible +4. **Checks type hints** if mypy is configured + +## Example Commands + +### Black (code formatting) +```bash +# Format all Python files +black . + +# Check formatting without changing files +black --check . + +# Format specific file +black src/main.py +``` + +### flake8 (style guide enforcement) +```bash +# Check all Python files +flake8 . + +# Check specific directory +flake8 src/ + +# Check with specific rules +flake8 --max-line-length=88 . +``` + +### isort (import sorting) +```bash +# Sort imports in all files +isort . + +# Check import sorting +isort --check-only . + +# Sort imports in specific file +isort src/main.py +``` + +### pylint (comprehensive linting) +```bash +# Run pylint on all files +pylint src/ + +# Run with specific score threshold +pylint --fail-under=8.0 src/ + +# Generate detailed report +pylint --output-format=html src/ > pylint_report.html +``` + +### mypy (type checking) +```bash +# Check types in all files +mypy . + +# Check specific module +mypy src/models.py + +# Check with strict mode +mypy --strict src/ +``` + +## Configuration Files + +Most projects benefit from configuration files: + +### .flake8 +```ini +[flake8] +max-line-length = 88 +exclude = .git,__pycache__,venv +ignore = E203,W503 +``` + +### pyproject.toml +```toml +[tool.black] +line-length = 88 + +[tool.isort] +profile = "black" +``` + +## Best Practices + +- Run linters before committing code +- Use consistent formatting across the project +- Fix linting issues promptly +- Configure linters to match your team's style +- Use type hints for better code documentation \ No newline at end of file diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000..64cb56c --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,73 @@ +# Test Runner + +Run Python tests with pytest, unittest, or other testing frameworks. + +## Purpose + +This command helps you run Python tests effectively with proper configuration and reporting. + +## Usage + +``` +/test +``` + +## What this command does + +1. **Detects test framework** (pytest, unittest, nose2) +2. **Runs appropriate tests** with proper configuration +3. **Provides coverage reporting** if available +4. **Shows clear test results** with failure details + +## Example Commands + +### pytest (recommended) +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=html + +# Run specific test file +pytest tests/test_models.py + +# Run with verbose output +pytest -v + +# Run tests matching pattern +pytest -k "test_user" +``` + +### unittest +```bash +# Run all tests +python -m unittest discover + +# Run specific test file +python -m unittest tests.test_models + +# Run with verbose output +python -m unittest -v +``` + +### Django tests +```bash +# Run all Django tests +python manage.py test + +# Run specific app tests +python manage.py test myapp + +# Run with coverage +coverage run --source='.' manage.py test +coverage report +``` + +## Best Practices + +- Write tests for all critical functionality +- Use descriptive test names +- Keep tests isolated and independent +- Mock external dependencies +- Aim for high test coverage (80%+) \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2714f4d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,151 @@ +{ + "permissions": { + "allow": [ + "Bash", + "Edit", + "MultiEdit", + "Write", + "Bash(python:*)", + "Bash(pytest:*)", + "Bash(pip:*)", + "Bash(uv:*)", + "Bash(uv pip:*)", + "Bash(black:*)", + "Bash(isort:*)", + "Bash(flake8:*)", + "Bash(mypy:*)", + "Bash(git:*)" + ], + "deny": [ + "Bash(curl:*)", + "Bash(wget:*)", + "Bash(rm -rf:*)" + ], + "defaultMode": "dontAsk" + }, + "env": { + "BASH_DEFAULT_TIMEOUT_MS": "60000", + "BASH_MAX_OUTPUT_LENGTH": "20000", + "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1", + "PYTHONPATH": "." + }, + "includeCoAuthoredBy": true, + "cleanupPeriodDays": 30, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '\"\\(.tool_input.command) - \\(.tool_input.description // \"No description\")\"' >> ~/.claude/bash-command-log.txt" + } + ] + }, + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "FILE=$(echo $STDIN_JSON | jq -r '.tool_input.file_path // \"\"); CONTENT=$(echo $STDIN_JSON | jq -r '.tool_input.content // \"\"); if [[ \"$FILE\" =~ \\.py$ ]] && echo \"$CONTENT\" | grep -q 'print('; then echo 'Warning: print() statements should be replaced with logging' >&2; exit 2; fi" + } + ] + }, + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "FILE=$(echo $STDIN_JSON | jq -r '.tool_input.file_path // \"\"'); if [[ \"$FILE\" == \"requirements.txt\" ]] || [[ \"$FILE\" == \"pyproject.toml\" ]] || [[ \"$FILE\" == \"setup.py\" ]]; then echo 'Checking for vulnerable dependencies...'; if command -v safety >/dev/null 2>&1; then safety check; elif command -v pip-audit >/dev/null 2>&1; then pip-audit; else echo 'No security audit tool found. Install safety or pip-audit'; fi; fi", + "timeout": 60 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "FILE=$(echo $STDIN_JSON | jq -r '.tool_input.file_path // \"\"'); if [[ \"$FILE\" =~ \\.py$ ]]; then black \"$FILE\" 2>/dev/null || echo 'Black formatting skipped (not installed)'; fi", + "timeout": 30 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "FILE=$(echo $STDIN_JSON | jq -r '.tool_input.file_path // \"\"'); if [[ \"$FILE\" =~ \\.py$ ]]; then isort \"$FILE\" 2>/dev/null || echo 'isort skipped (not installed)'; fi", + "timeout": 30 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "FILE=$(echo $STDIN_JSON | jq -r '.tool_input.file_path // \"\"'); if [[ \"$FILE\" =~ \\.py$ ]]; then RESULT=$(flake8 \"$FILE\" 2>&1); if [ $? -ne 0 ] && command -v flake8 >/dev/null 2>&1; then echo \"Flake8 linting issues found: $RESULT\" >&2; exit 2; fi; fi", + "timeout": 30 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "FILE=$(echo $STDIN_JSON | jq -r '.tool_input.file_path // \"\"'); if [[ \"$FILE\" =~ \\.py$ ]]; then RESULT=$(mypy \"$FILE\" 2>&1); if [ $? -ne 0 ] && command -v mypy >/dev/null 2>&1; then echo \"MyPy type checking issues found: $RESULT\" >&2; exit 2; fi; fi", + "timeout": 30 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "FILE=$(echo $STDIN_JSON | jq -r '.tool_input.file_path // \"\"'); if [[ \"$FILE\" =~ \\.py$ && \"$FILE\" != *\"test_\"* && \"$FILE\" != *\"_test.py\" ]]; then DIR=$(dirname \"$FILE\"); BASENAME=$(basename \"$FILE\" .py); for TEST_FILE in \"$DIR/test_$BASENAME.py\" \"$DIR/${BASENAME}_test.py\" \"tests/test_$BASENAME.py\"; do if [ -f \"$TEST_FILE\" ]; then echo \"Running tests for $TEST_FILE...\"; if command -v pytest >/dev/null 2>&1; then pytest \"$TEST_FILE\" -v; elif python -m pytest \"$TEST_FILE\" 2>/dev/null; then python -m pytest \"$TEST_FILE\" -v; else python -m unittest \"$TEST_FILE\" 2>/dev/null || echo 'No test runner found'; fi; break; fi; done; fi", + "timeout": 60 + } + ] + } + ], + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "echo \"Claude Code notification: $(date)\" >> ~/.claude/notifications.log" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "if [[ -f requirements.txt || -f pyproject.toml || -f setup.py ]] && [[ $(git status --porcelain | grep '\\.py$') ]]; then echo 'Running linter on changed Python files...'; if command -v flake8 >/dev/null 2>&1; then flake8 $(git diff --name-only --diff-filter=ACMR | grep '\\.py$'); elif command -v pylint >/dev/null 2>&1; then pylint $(git diff --name-only --diff-filter=ACMR | grep '\\.py$'); else echo 'No Python linter found (flake8/pylint)'; fi; fi", + "timeout": 60 + } + ] + }, + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "if [[ -f requirements.txt || -f pyproject.toml || -f setup.py ]] && [[ $(git status --porcelain | grep '\\.py$') ]]; then echo 'Running type checking on changed files...'; if command -v mypy >/dev/null 2>&1; then mypy $(git diff --name-only --diff-filter=ACMR | grep '\\.py$') || echo 'Type checking completed with issues'; else echo 'MyPy not found for type checking'; fi; fi", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/.env.example b/.env.example index 8b81cc8..14e08a9 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,7 @@ API_ID=123456 API_HASH=your_api_hash_here -# Optional: session file name (default: session) +# Optional: session file name or path +# - Nome simples (ex.: session) salva em ~/.clean_telegram/ +# - Caminho absoluto/relativo permite personalizar local SESSION_NAME=session diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4d5d05f --- /dev/null +++ b/.flake8 @@ -0,0 +1,13 @@ +[flake8] +max-line-length = 100 +exclude = + .git, + __pycache__, + .venv, + venv, + build, + dist, + *.egg-info +ignore = E203, W503 +per-file-ignores = + __init__.py:F401 diff --git a/.gitignore b/.gitignore index 68f77c5..5728deb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,151 @@ -.venv/ +# Byte-compiled / optimized / DLL files __pycache__/ -*.pyc +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# poetry +poetry.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments .env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Telegram session files *.session *.session-journal + +# OS +.DS_Store +Thumbs.db + +# Debug files +*_debug.md +.debug* diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a0944ce --- /dev/null +++ b/.mcp.json @@ -0,0 +1,78 @@ +{ + "mcpServers": { + "python-sdk": { + "name": "Python SDK", + "description": "Official Python SDK with FastMCP for rapid MCP development", + "command": "python", + "args": ["-m", "python_sdk.server"], + "env": {} + }, + "docker": { + "name": "Docker MCP", + "description": "Isolated code execution via Docker containers", + "command": "python", + "args": ["-m", "mcp_server_docker"], + "env": {} + }, + "jupyter": { + "name": "Jupyter MCP", + "description": "MCP integration for interactive Jupyter notebooks", + "command": "python", + "args": ["-m", "server_jupyter"], + "env": {} + }, + "postgresql": { + "name": "PostgreSQL MCP", + "description": "Natural language queries to PostgreSQL databases", + "command": "python", + "args": ["-m", "server_postgres"], + "env": { + "DATABASE_URL": "" + } + }, + "opik": { + "name": "Opik MCP", + "description": "Observability for LLM apps with tracing and metrics", + "command": "python", + "args": ["-m", "opik_mcp"], + "env": {} + }, + "memory-bank": { + "name": "Memory Bank MCP", + "description": "Centralized memory system for AI agents", + "command": "server-memory", + "args": [], + "env": {} + }, + "sequential-thinking": { + "name": "Sequential Thinking MCP", + "description": "Helps LLMs decompose complex tasks into logical steps", + "command": "code-reasoning", + "args": [], + "env": {} + }, + "brave-search": { + "name": "Brave Search MCP", + "description": "Privacy-focused web search tool", + "command": "server-brave-search", + "args": [], + "env": {} + }, + "google-maps": { + "name": "Google Maps MCP", + "description": "Integrates Google Maps for geolocation and directions", + "command": "server-google-maps", + "args": [], + "env": { + "GOOGLE_MAPS_API_KEY": "" + } + }, + "deep-graph": { + "name": "Deep Graph MCP (Code Graph)", + "description": "Transforms source code into semantic graphs via DeepGraph", + "command": "mcp-code-graph", + "args": [], + "env": {} + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index efe7059..62d8476 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,132 +1,259 @@ # CLAUDE.md -Guia para assistentes de IA que trabalham neste repositório. +Este arquivo fornece orientação para o Claude Code (claude.ai/code) ao trabalhar com código neste repositório. -## Visão geral do projeto +## Project Overview -**CleanTelegram** é um script Python de propósito único que automatiza a limpeza de uma conta Telegram via [Telethon](https://github.com/LonamiWebs/Telethon). Ele apaga históricos de conversa (usuários/bots) e sai de grupos/canais. +CleanTelegram é um projeto Python que automatiza a limpeza de contas Telegram usando a biblioteca Telethon. O projeto segue práticas modernas de desenvolvimento Python com estrutura baseada em `src/` e ferramentas de qualidade configuradas. -Este é um projeto **destrutivo por design** — qualquer alteração deve preservar os mecanismos de segurança existentes (`--dry-run`, confirmação interativa `"APAGAR TUDO"`). +## Development Commands -## Estrutura do repositório +### Environment Management (UV) +Recomendamos usar **UV** como gerenciador de pacotes para este projeto: -```text -CleanTelegram/ -├── clean_telegram.py # Script principal (ponto de entrada único) -├── requirements.txt # Dependências Python (telethon, python-dotenv) -├── .env.example # Template de variáveis de ambiente -├── .gitignore # Ignora .venv, .env, *.session, __pycache__ -└── README.md # Documentação do projeto (pt-BR) +```bash +# Instalar UV (se ainda não tiver) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Criar ambiente virtual com UV +uv venv + +# Ativar ambiente +source .venv/bin/activate # Linux/Mac +# ou +.venv\Scripts\activate # Windows + +# Instalar dependências +uv pip install -e ".[dev]" + +# Ou sem UV: +pip install -e ".[dev]" ``` -Não há subdiretórios de código, testes, CI/CD ou configuração de linting. +### Running the Application -## Stack tecnológica +```bash +# Executar com módulo Python +python -m clean_telegram --help -| Componente | Tecnologia | -| ----------------- | ----------------------------------- | -| Linguagem | Python 3.10+ | -| Cliente Telegram | Telethon 1.42.0 | -| Variáveis de amb. | python-dotenv 1.2.1 | -| Runtime assíncrono| asyncio (stdlib) | -| Gerenciador deps | pip + requirements.txt | +# Dry-run (testar sem alterações) +python -m clean_telegram --dry-run -## Comandos essenciais +# Executar com limitação de diálogos +python -m clean_telegram --limit 10 + +# Execução completa (requer confirmação) +python -m clean_telegram + +# Execução sem confirmação +python -m clean_telegram --yes +``` + +### Testing Commands ```bash -# Criar e ativar virtualenv -python -m venv .venv -source .venv/bin/activate +# pytest - Run all tests +pytest -# Instalar dependências -pip install -r requirements.txt +# Run with coverage +pytest --cov=src/clean_telegram --cov-report=html -# Copiar e configurar variáveis de ambiente -cp .env.example .env -# Editar .env com API_ID e API_HASH (https://my.telegram.org) +# Run specific test file +pytest tests/test_client.py + +# Run with verbose output +pytest -v + +# Run tests matching pattern +pytest -k "test_delete" +``` + +### Code Quality Commands -# Execução em modo seguro (dry-run — não altera nada) -python clean_telegram.py --dry-run +```bash +# Format code with Black +black src/ tests/ + +# Check formatting without changes +black --check src/ tests/ + +# Sort imports +isort src/ tests/ -# Execução real (pede confirmação "APAGAR TUDO") -python clean_telegram.py +# Check import sorting +isort --check-only src/ tests/ -# Pular confirmação interativa -python clean_telegram.py --yes +# Run linting with Flake8 +flake8 src/ tests/ -# Limitar quantidade de diálogos processados -python clean_telegram.py --limit 10 +# Type checking with MyPy +mypy src/ -# Ver todas as opções -python clean_telegram.py --help +# Run all quality checks at once +black --check src/ tests/ && isort --check-only src/ tests/ && flake8 src/ tests/ && mypy src/ ``` -## Arquitetura do código (`clean_telegram.py`) +## Technology Stack -O script segue um fluxo linear assíncrono: +### Core Technologies +- **Python 3.10+** - Linguagem primária +- **Telethon** - Biblioteca para interagir com Telegram MTProto API +- **python-dotenv** - Gerenciamento de variáveis de ambiente -1. **`main()`** — Entry-point: configura logging, carrega `.env`, parseia argumentos, pede confirmação e itera sobre os diálogos. -2. **`_process_dialog()`** — Roteador que escolhe a ação correta conforme o tipo da entidade: - - `Channel` → `leave_channel()` (canais e megagrupos) - - `Chat` → `leave_legacy_chat()` (grupos legados), com fallback via `client.delete_dialog()` - - `User` / bots → `delete_dialog()` (apaga histórico) - - Tipo desconhecido → `client.delete_dialog()` (fallback genérico) -3. **Funções auxiliares:** - - `env_int()` — Lê variável de ambiente obrigatória como int - - `safe_sleep()` — Delay entre operações (0.35s) para evitar rate limit - - `delete_dialog()` — Apaga histórico via `DeleteHistoryRequest` - - `leave_channel()` — Sai de canal/megagrupo via `LeaveChannelRequest` - - `leave_legacy_chat()` — Sai de grupo legado via `DeleteChatUserRequest` +### Development Tools +- **UV** - Gerenciador de pacotes (recomendado) +- **pytest** - Framework de testes +- **pytest-asyncio** - Suporte para testes assíncronos +- **pytest-cov** - Relatório de cobertura de testes -### Tratamento de erros +### Code Quality Tools +- **Black** - Formatador de código +- **isort** - Ordenador de imports +- **Flake8** - Linter (guia de estilo PEP 8) +- **MyPy** - Verificador de tipos estáticos -- **`FloodWaitError`**: Retry com backoff exponencial (até 5 tentativas). Aguarda o tempo indicado pela API antes de tentar novamente. -- **`RPCError`**: Loga o erro e pula o diálogo. -- **`Exception` genérica**: Catch-all com log completo do traceback. +## Project Structure -## Convenções do projeto +``` +CleanTelegram/ +├── src/ +│ └── clean_telegram/ +│ ├── __init__.py # Pacote principal com exports +│ ├── __main__.py # Entry-point do CLI +│ ├── client.py # Funções de interação com Telegram +│ └── utils.py # Funções utilitárias +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # Configuração pytest +│ ├── test_client.py # Testes do módulo client +│ └── test_utils.py # Testes do módulo utils +├── .env.example # Exemplo de variáveis de ambiente +├── .flake8 # Configuração do Flake8 +├── pyproject.toml # Configuração do projeto (moderno) +├── requirements.txt # Dependências de produção +├── requirements-dev.txt # Dependências de desenvolvimento +├── README.md # Documentação do projeto +└── CLAUDE.md # Este arquivo +``` -### Idioma +## Naming Conventions +- **Files/Modules**: `snake_case` (ex: `client.py`, `test_utils.py`) +- **Classes**: `PascalCase` (ex: `TelegramClient`) +- **Functions/Variables**: `snake_case` (ex: `process_dialog`, `env_int`) +- **Constants**: `UPPER_SNAKE_CASE` (ex: `API_ID`) +- **Private methods**: Prefixo `_` (ex: `_private_method`) + +## Python Guidelines + +### Type Hints +Use type hints para parâmetros de função e valores de retorno: +```python +async def process_dialog( + client: TelegramClient, + entity: Union[Channel, Chat, User], + title: str, + index: int, + *, + dry_run: bool, +) -> bool: + """Processa um diálogo do Telegram.""" +``` -- **Documentação, comentários, docstrings e mensagens de log**: português brasileiro (pt-BR). -- **Nomes de variáveis, funções e parâmetros**: inglês (padrão Python). -- **Mensagens de commit**: português é aceitável, mas o prefixo segue Conventional Commits em inglês (`feat:`, `fix:`, `docs:`, `chore:`). +### Code Style +- Siga PEP 8 +- Limite de linha: 100 caracteres +- Use docstrings para módulos, classes e funções +- Funções devem ter propósito único +- Use `logging` em vez de `print` + +### Best Practices +- Use `pathlib` para operações com arquivos +- Use context managers (`with`) para gerenciamento de recursos +- Trate exceções apropriadamente com try/except +- Use `asyncio` para operações I/O pesadas (Telethon é assíncrono) + +## Testing Standards + +### Test Structure +- Organize testes espelhando a estrutura do código fonte +- Use nomes descritivos para testes +- Siga padrão AAA (Arrange, Act, Assert) +- Use fixtures para dados comuns de teste + +### Coverage Goals +- Objetivo: 70%+ de cobertura +- Testes unitários para lógica de negócio +- Testes assíncronos para funções Telethon + +### pytest Configuration +Configuração está em `pyproject.toml`: +```toml +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +``` -### Estilo de código +## Environment Setup -- Sem linter ou formatter configurado — manter consistência com o código existente. -- Type hints nas assinaturas de funções. -- Docstrings em português para todas as funções. -- Keyword-only arguments para flags booleanas (ex.: `*, dry_run: bool`). -- Logging via `logger` (módulo `logging`), não `print()` (exceto para interação direta com o usuário). +### Installation with UV +```bash +# Clonar repositório +git clone https://github.com/gabrielramos/CleanTelegram +cd CleanTelegram -### Segurança — regras invioláveis +# Criar venv com UV +uv venv +source .venv/bin/activate -1. **Nunca remover ou enfraquecer** o mecanismo de confirmação `"APAGAR TUDO"`. -2. **Nunca remover** a flag `--dry-run` — ela é a principal proteção do usuário. -3. **Nunca fazer commit de arquivos `.env`** ou `*.session` — contêm credenciais sensíveis. -4. **Manter rate-limit handling** — sem ele, a conta do usuário pode ser temporariamente bloqueada pela API do Telegram. -5. **Preservar delays entre operações** (`safe_sleep`) para reduzir risco de flood. +# Instalar dependências +uv pip install -e ".[dev]" + +# Configurar ambiente +cp .env.example .env +# Editar .env com API_ID e API_HASH +``` + +### Environment Variables +- `API_ID`: ID da API do Telegram (obrigatório) +- `API_HASH`: Hash da API do Telegram (obrigatório) +- `SESSION_NAME`: Nome da sessão Telethon (opcional, padrão: "session") -## Variáveis de ambiente +## Security Guidelines -| Variável | Obrigatória | Descrição | -| -------------- | ----------- | -------------------------------------------- | -| `API_ID` | Sim | ID da aplicação Telegram (inteiro) | -| `API_HASH` | Sim | Hash da aplicação Telegram (string hex) | -| `SESSION_NAME` | Não | Nome do arquivo de sessão (padrão: `session`)| +- Nunca commite `.env` com credenciais reais +- Nunca commite arquivos `*.session` - contêm credenciais de autenticação do Telegram +- Use `.env.example` como template +- Valide input do usuário +- Trate exceções de API apropriadamente (FloodWaitError, RPCError) +- Use logs em vez de print para debug -## Testes +## Development Workflow -Não há framework de testes configurado. O modo `--dry-run` é o mecanismo atual de verificação segura. Se testes forem adicionados no futuro: -- Usar `pytest` como framework. -- Mockar chamadas ao Telethon/Telegram API (nunca fazer chamadas reais em testes). -- Colocar testes em um diretório `tests/`. +### Before Starting +1. Ative o ambiente virtual +2. Instale dependências: `uv pip install -e ".[dev]"` +3. Configure `.env` com credenciais -## Dependências +### During Development +1. Use type hints para melhor documentação +2. Execute testes frequentemente +3. Use mensagens de commit significativas +4. Formate código com Black antes de commitar -Fixadas por versão exata em `requirements.txt`: -- **telethon==1.42.0** — Cliente Telegram (MTProto) -- **python-dotenv==1.2.1** — Carregamento de `.env` +### Before Committing +1. `pytest` - Execute testes +2. `black --check src/ tests/` - Verifique formatação +3. `isort --check-only src/ tests/` - Verifique imports +4. `flake8 src/ tests/` - Verifique lint +5. `mypy src/` - Verifique tipos + +## CLI Reference + +```bash +python -m clean_telegram --help +``` -Para adicionar dependências, atualizar `requirements.txt` com versão exata (ex.: `pacote==X.Y.Z`). +Opções disponíveis: +- `--dry-run`: Não faz alterações, só mostra o que faria +- `--yes`: Não pede confirmação interativa +- `--limit N`: Limita a N diálogos processados (0 = todos) diff --git a/README.md b/README.md index c4e9b5e..71bccbd 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Edite o arquivo `.env` com suas credenciais: ```ini API_ID=123456 API_HASH=abc123def456... -SESSION_NAME=session # opcional (padrão: session) +SESSION_NAME=session # opcional; padrão persiste em ~/.clean_telegram/ ``` ## Uso @@ -124,7 +124,7 @@ python clean_telegram.py --yes |----------|:-----------:|-----------| | `API_ID` | Sim | ID da aplicação Telegram (inteiro) | | `API_HASH` | Sim | Hash da aplicação Telegram (string hex) | -| `SESSION_NAME` | Não | Nome do arquivo de sessão (padrão: `session`) | +| `SESSION_NAME` | Não | Nome/caminho da sessão. Nome simples usa `~/.clean_telegram/`; caminho absoluto/relativo usa o caminho informado | ## Como funciona @@ -164,7 +164,7 @@ Na primeira vez que rodar o script, o Telethon solicitará: 2. **Código de verificação** — recebido no próprio Telegram 3. **Senha 2FA** — se autenticação em duas etapas estiver ativada -Após o login, um arquivo `session.session` é criado localmente. Nas execuções seguintes, o login é automático. +Após o login, um arquivo de sessão (ex.: `~/.clean_telegram/session.session`) é criado e reaproveitado nas próximas execuções, evitando pedir telefone/código novamente. > **Segurança:** O arquivo `*.session` contém credenciais de autenticação. > Ele está no `.gitignore` e **nunca** deve ser commitado ou compartilhado. @@ -219,5 +219,5 @@ Atualmente não. O script processa **todos** os diálogos da conta (ou os primei
Como deslogar / trocar de conta? -Delete o arquivo `session.session` (ou o nome configurado em `SESSION_NAME`). Na próxima execução, o Telethon pedirá login novamente. +Delete o arquivo de sessão usado (por padrão `~/.clean_telegram/session.session`, ou o caminho definido em `SESSION_NAME`). Na próxima execução, o Telethon pedirá login novamente.
diff --git a/clean_telegram.py b/clean_telegram.py.legacy similarity index 100% rename from clean_telegram.py rename to clean_telegram.py.legacy diff --git a/docs/UX_FLOW.md b/docs/UX_FLOW.md index eaf73a8..4947bdd 100644 --- a/docs/UX_FLOW.md +++ b/docs/UX_FLOW.md @@ -1,6 +1,6 @@ # Fluxo UX — CleanTelegram -Documento que mapeia a jornada completa do usuário ao interagir com o script `clean_telegram.py`. +Documento que mapeia a jornada completa do usuário ao interagir com o **CleanTelegram**, um projeto Python moderno para limpeza de contas Telegram via Telethon. --- @@ -26,20 +26,23 @@ Documento que mapeia a jornada completa do usuário ao interagir com o script `c ## 2. Fluxo de Setup Inicial (primeira vez) +### 2.1. Setup com UV (recomendado) + ```text USUÁRIO SISTEMA ─────── ────── │ - │ python -m venv .venv + │ uv venv │ source .venv/bin/activate - │ pip install -r requirements.txt + │ uv pip install -e ".[dev]" ├──────────────────────────────────▶ Instala telethon + python-dotenv + │ + ferramentas de dev │ │ cp .env.example .env │ (edita .env com API_ID e API_HASH) ├──────────────────────────────────▶ Configura credenciais │ - │ python clean_telegram.py --dry-run + │ python -m clean_telegram --dry-run ├──────────────────────────────────▶ Primeira execução │ │ │ ◀── Telethon pede telefone ───────┤ @@ -54,22 +57,66 @@ Documento que mapeia a jornada completa do usuário ao interagir com o script `c │ Digita senha 2FA │ ├──────────────────────────────────▶ │ │ │ - │ ├──▶ Salva session.session + │ ├──▶ Salva ~/.clean_telegram/session.session │ │ │ ◀── Dry-run: lista diálogos ──────┤ │ (nenhuma alteração feita) │ ▼ ▼ ``` -> **Nota:** Após o primeiro login, o arquivo `session.session` é reutilizado automaticamente. O fluxo de autenticação não se repete. +### 2.2. Setup com pip tradicional + +```bash +# Criar e ativar virtualenv +python -m venv .venv +source .venv/bin/activate + +# Instalar dependências +pip install -e ".[dev]" + +# Configurar ambiente +cp .env.example .env +# Editar .env com API_ID e API_HASH (https://my.telegram.org) + +# Executar +python -m clean_telegram --dry-run +``` + +> **Nota:** Após o primeiro login, o arquivo de sessão (por padrão `~/.clean_telegram/session.session`) é reutilizado automaticamente. O fluxo de autenticação não se repete. --- -## 3. Fluxo Principal de Execução +## 3. Estrutura do Projeto + +```text +CleanTelegram/ +├── src/ +│ └── clean_telegram/ +│ ├── __init__.py # Pacote principal +│ ├── __main__.py # Entry-point do CLI +│ ├── client.py # Funções de interação com Telegram +│ └── utils.py # Funções utilitárias +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # Fixtures pytest +│ ├── test_client.py # Testes do módulo client +│ └── test_utils.py # Testes do módulo utils +├── docs/ +│ └── UX_FLOW.md # Este documento +├── .env.example # Template de variáveis de ambiente +├── pyproject.toml # Configuração do projeto +├── requirements.txt # Dependências de produção +├── requirements-dev.txt # Dependências de desenvolvimento +└── README.md # Documentação do projeto +``` + +--- + +## 4. Fluxo Principal de Execução ```mermaid flowchart TD - A[python clean_telegram.py] --> B{Variáveis .env ok?} + A[python -m clean_telegram] --> B{Variáveis .env ok?} B -- "API_ID ou API_HASH faltando" --> C[/"SystemExit: Faltou ... no .env"/] B -- "OK" --> D{Qual modo?} @@ -89,7 +136,7 @@ flowchart TD K -- "Sim" --> L["Log: Concluído. Diálogos processados: N"] K -- "Não / sem limite" --> M[Próximo diálogo] - M --> N[_process_dialog] + M --> N[process_dialog] N --> O{Tipo da entidade} O -- "Channel" --> P["SAIR de canal/megagrupo\nLeaveChannelRequest"] @@ -114,11 +161,11 @@ flowchart TD --- -## 4. Fluxo de Tratamento de Erros (por diálogo) +## 5. Fluxo de Tratamento de Erros (por diálogo) ```mermaid flowchart TD - A[_process_dialog] --> B{Resultado} + A[process_dialog] --> B{Resultado} B -- "Sucesso" --> C["safe_sleep(0.35s)\n→ próximo diálogo"] @@ -144,7 +191,7 @@ flowchart TD --- -## 5. Fluxo do modo `--dry-run` +## 6. Fluxo do modo `--dry-run` ```text ┌─────────────────────────────────────────────────────────┐ @@ -168,7 +215,7 @@ flowchart TD --- -## 6. Mapa de decisões do roteador `_process_dialog` +## 7. Mapa de decisões do roteador `process_dialog` ```text entity recebida @@ -201,7 +248,7 @@ flowchart TD --- -## 7. Tabela de estados do terminal (o que o usuário vê) +## 8. Tabela de estados do terminal (o que o usuário vê) | Fase | Saída no terminal | Origem | |------|-------------------|--------| @@ -221,49 +268,63 @@ flowchart TD --- -## 8. Cenários de uso típicos +## 9. Cenários de uso típicos -### 8.1 Primeiro uso (cauteloso) +### 9.1 Primeiro uso (cauteloso) ```bash -# 1. Setup -python -m venv .venv && source .venv/bin/activate -pip install -r requirements.txt +# 1. Setup com UV (recomendado) +uv venv +source .venv/bin/activate +uv pip install -e ".[dev]" cp .env.example .env # editar com API_ID e API_HASH # 2. Testar com dry-run -python clean_telegram.py --dry-run +python -m clean_telegram --dry-run # 3. Testar com poucos diálogos -python clean_telegram.py --dry-run --limit 5 +python -m clean_telegram --dry-run --limit 5 # 4. Executar de verdade (poucos diálogos) -python clean_telegram.py --limit 5 +python -m clean_telegram --limit 5 # → Digita "APAGAR TUDO" # 5. Executar em tudo -python clean_telegram.py +python -m clean_telegram # → Digita "APAGAR TUDO" ``` -### 8.2 Uso automatizado (script/cron) +### 9.2 Uso automatizado (script/cron) ```bash -python clean_telegram.py --yes +python -m clean_telegram --yes # Pula confirmação interativa — usar com cuidado! ``` -### 8.3 Debugging de rate limit +### 9.3 Debugging de rate limit ```bash -python clean_telegram.py --limit 3 +python -m clean_telegram --limit 3 # Observar logs de FloodWaitError # Ajustar --limit conforme necessário ``` +### 9.4 Executar testes + +```bash +# Executar todos os testes +pytest + +# Executar com cobertura +pytest --cov=src/clean_telegram --cov-report=html + +# Executar teste específico +pytest tests/test_client.py -v +``` + --- -## 9. Diagrama de ciclo de vida da sessão +## 10. Diagrama de ciclo de vida da sessão ```text Primeira execução Execuções seguintes @@ -297,4 +358,28 @@ python clean_telegram.py --limit 3 └──────────────┘ ``` -> **Importante:** O arquivo `*.session` contém credenciais de autenticação e **nunca** deve ser commitado no repositório. +> **Importante:** O arquivo `*.session` contém credenciais de autenticação e **nunca** deve ser commitado no repositório. Ele já está configurado no `.gitignore`. + +--- + +## 11. Comandos de Desenvolvimento + +```bash +# Verificar ajuda +python -m clean_telegram --help + +# Formatar código +black src/ tests/ + +# Ordenar imports +isort src/ tests/ + +# Executar linting +flake8 src/ tests/ + +# Verificar tipos +mypy src/ + +# Executar todos os checks de uma vez +black --check src/ tests/ && isort --check-only src/ tests/ && flake8 src/ tests/ && mypy src/ +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..593ba81 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,144 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "clean-telegram" +version = "0.1.0" +description = "Script para limpar conta Telegram via Telethon" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "Gabriel Ramos"} +] +keywords = ["telegram", "telethon", "cleanup", "automation", "privacy"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Utilities", +] + +dependencies = [ + "telethon>=1.42.0,<2.0.0", + "python-dotenv>=1.0.0,<2.0.0", + "qrcode>=8.0,<9.0", +] + +[project.optional-dependencies] +dev = [ + "black>=24.0.0", + "isort>=5.13.0", + "flake8>=7.0.0", + "mypy>=1.8.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.1.0", +] + +[project.scripts] +clean-telegram = "clean_telegram.__main__:main" + +[project.urls] +Homepage = "https://github.com/gabrielramos/CleanTelegram" +Repository = "https://github.com/gabrielramos/CleanTelegram" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +clean_telegram = ["py.typed"] + +# Black configuration +[tool.black] +line-length = 100 +target-version = ["py310", "py311", "py312"] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.venv + | \.eggs + | \.tox + | build + | dist + | __pycache__ +)/ +''' + +# isort configuration +[tool.isort] +profile = "black" +line_length = 100 +src_paths = ["src", "tests"] +skip_gitignore = true + +# MyPy configuration +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = true +ignore_missing_imports = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +strict_equality = true +show_error_codes = true +plugins = [] + +[[tool.mypy.overrides]] +module = "telethon.*" +ignore_missing_imports = true + +# Pytest configuration +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--cov=src/clean_telegram", + "--cov-report=term-missing", + "--cov-report=html", + "-v", +] +asyncio_mode = "auto" +markers = [ + "asyncio: mark test as async", + "integration: mark test as integration test", +] + +# Coverage configuration +[tool.coverage.run] +source = ["src"] +branch = true +parallel = true + +[tool.coverage.paths] +source = [ + "src/", +] + +[tool.coverage.report] +fail_under = 55 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "if typing.TYPE_CHECKING:", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..297346f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,16 @@ +# Dependências de desenvolvimento +-r requirements.txt + +# Código quality +black>=24.0.0 +isort>=5.13.0 +flake8>=7.0.0 +mypy>=1.8.0 + +# Testes +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +pytest-cov>=4.1.0 + +# Types +types-python-dotenv>=1.0.0 diff --git a/requirements.txt b/requirements.txt index 4a2fccd..959ca72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -telethon==1.42.0 -python-dotenv==1.2.1 +# Dependências de produção +telethon>=1.42.0,<2.0.0 +python-dotenv>=1.0.0,<2.0.0 +qrcode>=8.0,<9.0 diff --git a/src/clean_telegram/__init__.py b/src/clean_telegram/__init__.py new file mode 100644 index 0000000..25b2f0e --- /dev/null +++ b/src/clean_telegram/__init__.py @@ -0,0 +1,14 @@ +"""CleanTelegram: script para limpar conta Telegram via Telethon. + +Este pacote fornece funcionalidades para: +- Apagar históricos de conversa (usuários/bots) +- Sair de grupos/canais +- Gerenciar diálogos do Telegram de forma programática +""" + +__version__ = "0.1.0" + +from .client import process_dialog +from .utils import env_int, safe_sleep + +__all__ = ["process_dialog", "env_int", "safe_sleep"] diff --git a/src/clean_telegram/__main__.py b/src/clean_telegram/__main__.py new file mode 100644 index 0000000..8fa1799 --- /dev/null +++ b/src/clean_telegram/__main__.py @@ -0,0 +1,210 @@ +"""Entry-point para execução do CleanTelegram.""" + +import argparse +import asyncio +import logging +import os +import sys + +import qrcode +from dotenv import load_dotenv +from telethon import TelegramClient +from telethon.errors import FloodWaitError, RPCError, SessionPasswordNeededError + +from clean_telegram.client import process_dialog +from clean_telegram.utils import env_int, resolve_session_name, safe_sleep + +logger = logging.getLogger(__name__) + + +def display_qr_code(url: str) -> None: + """Exibe o QR code no terminal usando ASCII. + + Args: + url: URL para codificar no QR code. + """ + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=1, + border=2, + ) + qr.add_data(url) + qr.print_ascii(invert=True) + print(f"\nURL: {url}\n") + + +async def login_with_qr(client: TelegramClient) -> bool: + """Realiza login usando QR code. + + Args: + client: Instância do TelegramClient. + + Returns: + True se login foi bem-sucedido, False caso contrário. + """ + logger.info("Iniciando login via QR code...") + + qr_login = await client.qr_login() + + print("\nEscaneie o QR code abaixo com o seu Telegram mobile:") + print("(Telegram > Configurações > Dispositivos > Escanear QR Code)\n") + + while not qr_login.is_logged: + display_qr_code(qr_login.url) + + try: + logger.info("Aguardando leitura do QR code...") + await asyncio.wait_for(qr_login.wait(), timeout=10) + + except asyncio.TimeoutError: + # Timeout é esperado - o QR code expira e precisamos gerar outro + logger.info("QR code expirado, gerando novo...") + continue + + except SessionPasswordNeededError: + print("\nVerificação em duas etapas (2FA) habilitada.") + print("Por favor, use o login por telefone/código.") + return False + + except Exception as e: + logger.error("Erro durante login via QR code: %s", e) + return False + + logger.info("Login via QR code realizado com sucesso!") + return True + + +async def main() -> None: + """Entry-point assíncrono.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + load_dotenv() + + parser = argparse.ArgumentParser( + description="Apaga diálogos e sai de grupos/canais (Telethon)." + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Não faz alterações; só imprime o que faria.", + ) + parser.add_argument( + "--yes", + action="store_true", + help="Não pedir confirmação interativa.", + ) + parser.add_argument( + "--limit", + type=int, + default=0, + help="Limita quantos diálogos processar (0 = todos).", + ) + parser.add_argument( + "--qr-login", + action="store_true", + help="Usa login via QR code em vez de telefone/código.", + ) + args = parser.parse_args() + + try: + api_id = env_int("API_ID") + except SystemExit: + raise SystemExit("Faltou API_ID no .env ou valor inválido") + + api_hash = os.getenv("API_HASH") + if not api_hash: + raise SystemExit("Faltou API_HASH no .env") + + session_name = resolve_session_name(os.getenv("SESSION_NAME", "session")) + + # Se for login via QR code, usa uma sessão temporária + if args.qr_login: + # Remove extensão .session se existir para criar sessão temporária + base_name = session_name.replace(".session", "") + session_name = f"{base_name}_qr.session" + + if not args.dry_run and not args.yes: + print( + "ATENÇÃO: isso vai apagar conversas e sair de grupos/canais.\n" + "Digite 'APAGAR TUDO' para confirmar: ", + end="", + flush=True, + ) + confirm = sys.stdin.readline().strip() + if confirm != "APAGAR TUDO": + print("Cancelado.") + return + + async with TelegramClient(session_name, api_id, api_hash) as client: + # Login via QR code se solicitado + if args.qr_login: + logger.info("Modo de login via QR code ativado.") + # Conecta o cliente antes de tentar QR login + await client.connect() + # Tenta fazer login via QR code + success = await login_with_qr(client) + if not success: + logger.error("Falha no login via QR code. Encerrando.") + return + + me = await client.get_me() + logger.info("Logado como: %s (id=%s)", me.username or me.first_name, me.id) + + processed = 0 + async for d in client.iter_dialogs(): + if args.limit and processed >= args.limit: + break + + title = d.name or "(sem nome)" + entity = d.entity + index = processed + 1 + + # FloodWait retry (não pular o diálogo) + max_retries = 5 + attempt = 0 + while True: + try: + await process_dialog( + client, + entity, + title, + index, + dry_run=args.dry_run, + ) + await safe_sleep(0.35) + # Só incrementa se processou com sucesso + processed += 1 + break + + except FloodWaitError as e: + attempt += 1 + wait_s = int(e.seconds) + logger.warning( + "Rate limit (FloodWait) em '%s'. Aguardando %ss (tentativa %s/%s)...", + title, + wait_s, + attempt, + max_retries, + ) + await asyncio.sleep(wait_s) + if attempt >= max_retries: + logger.error("Max retries atingido; pulando '%s'.", title) + break + + except RPCError: + logger.exception("RPCError em '%s'", title) + break + + except Exception: + logger.exception("Erro inesperado em '%s'", title) + break + + logger.info("Concluído. Diálogos processados: %s", processed) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/clean_telegram/client.py b/src/clean_telegram/client.py new file mode 100644 index 0000000..064a163 --- /dev/null +++ b/src/clean_telegram/client.py @@ -0,0 +1,124 @@ +"""Funções para interação com cliente Telegram.""" + +import logging +from typing import Union + +from telethon import TelegramClient +from telethon.errors import FloodWaitError, RPCError +from telethon.tl.functions.channels import LeaveChannelRequest +from telethon.tl.functions.messages import DeleteChatUserRequest, DeleteHistoryRequest +from telethon.tl.types import Channel, Chat, InputUserSelf, User + +logger = logging.getLogger(__name__) + + +async def delete_dialog( + client: TelegramClient, peer: Union[Channel, Chat, User, InputUserSelf], *, dry_run: bool +) -> None: + """Apaga o histórico do diálogo (tenta revogar quando aplicável). + + Args: + client: Instância do TelegramClient. + peer: Entidade do diálogo. + dry_run: Se True, não executa ações destrutivas. + """ + if dry_run: + return + await client(DeleteHistoryRequest(peer=peer, max_id=0, just_clear=False, revoke=True)) + + +async def leave_channel(client: TelegramClient, entity: Channel, *, dry_run: bool) -> None: + """Sai de um canal/megagrupo (Channel). + + Args: + client: Instância do TelegramClient. + entity: Entidade do canal. + dry_run: Se True, não executa ações destrutivas. + """ + if dry_run: + return + await client(LeaveChannelRequest(entity)) + + +async def leave_legacy_chat(client: TelegramClient, entity: Chat, *, dry_run: bool) -> None: + """Sai de um grupo antigo (Chat). + + Telethon/Telegram têm diferenças entre Chat (grupo antigo) e Channel (canal/megagrupo). + + Args: + client: Instância do TelegramClient. + entity: Entidade do chat. + dry_run: Se True, não executa ações destrutivas. + """ + if dry_run: + return + + # Remove o próprio usuário do chat legado. + await client(DeleteChatUserRequest(chat_id=entity.id, user_id=InputUserSelf())) + + +async def process_dialog( + client: TelegramClient, + entity: Union[Channel, Chat, User], + title: str, + index: int, + *, + dry_run: bool, +) -> bool: + """Processa um único diálogo, escolhendo a ação correta por tipo. + + Args: + client: Instância do TelegramClient. + entity: Entidade do diálogo. + title: Nome do diálogo. + index: Índice do diálogo para log. + dry_run: Se True, não executa ações destrutivas. + + Returns: + True se processado com sucesso, False se ocorreu erro. + """ + try: + if isinstance(entity, Channel): + logger.info("[%s] SAIR de canal/megagrupo: %s", index, title) + await leave_channel(client, entity, dry_run=dry_run) + return True + + if isinstance(entity, Chat): + logger.info("[%s] SAIR de grupo legado (Chat): %s", index, title) + try: + await leave_legacy_chat(client, entity, dry_run=dry_run) + except RPCError: + logger.warning( + "Falha ao sair via DeleteChatUserRequest; tentando fallback delete_dialog: %s", + title, + ) + if not dry_run: + await client.delete_dialog(entity) + return True + + if isinstance(entity, User): + logger.info("[%s] APAGAR conversa: %s", index, title) + await delete_dialog(client, entity, dry_run=dry_run) + return True + + logger.info("[%s] APAGAR diálogo (tipo desconhecido): %s", index, title) + if not dry_run: + await client.delete_dialog(entity) + return True + + except FloodWaitError as e: + logger.error( + "Erro ao processar diálogo '%s': FloodWaitError (aguardar %ss): %s", + title, + e.seconds, + e, + ) + return False + + except RPCError as e: + logger.error("Erro ao processar diálogo '%s': %s", title, e) + return False + + except Exception: + logger.exception("Erro inesperado ao processar diálogo '%s'", title) + return False diff --git a/src/clean_telegram/utils.py b/src/clean_telegram/utils.py new file mode 100644 index 0000000..eac818e --- /dev/null +++ b/src/clean_telegram/utils.py @@ -0,0 +1,101 @@ +"""Funções utilitárias para CleanTelegram.""" + +import asyncio +import logging +import os +import shutil +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def env_int(name: str) -> int: + """Lê uma variável de ambiente obrigatória e converte para int. + + Args: + name: Nome da variável de ambiente. + + Returns: + Valor da variável convertido para int. + + Raises: + SystemExit: Se a variável não estiver definida ou for inválida. + """ + v = os.getenv(name) + if not v: + logger.error("Variável de ambiente %s não definida", name) + raise SystemExit(f"Faltou {name} no .env") + if v.strip() != v: + logger.warning("Variável de ambiente %s contém espaços em branco", name) + v = v.strip() + try: + return int(v) + except ValueError: + logger.error("Variável de ambiente %s contém valor inválido: %s", name, v) + raise SystemExit(f"{name} deve ser um número inteiro válido") + + +async def safe_sleep(seconds: float) -> None: + """Sleep curto para reduzir risco de rate limit. + + Args: + seconds: Tempo de espera em segundos. Deve ser um número não negativo. + + Raises: + ValueError: Se seconds não for um número ou for negativo. + """ + if not isinstance(seconds, (int, float)): + raise ValueError("safe_sleep: seconds deve ser um número (int ou float)") + if seconds < 0: + raise ValueError("safe_sleep: seconds deve ser não negativo") + + if seconds > 0: + logger.debug("Aguardando %.2fs antes da próxima operação", seconds) + await asyncio.sleep(seconds) + + +def _session_db_path(base_dir: Path, session_name: str) -> Path: + """Retorna o caminho esperado do arquivo SQLite da sessão.""" + filename = session_name if session_name.endswith(".session") else f"{session_name}.session" + return base_dir / filename + + +def resolve_session_name( + session_name: str | None, *, cwd: Path | None = None, home: Path | None = None +) -> str: + """Resolve o nome/caminho de sessão para evitar novo login frequente. + + Regras: + - Se `SESSION_NAME` for caminho absoluto, usa esse caminho. + - Se `SESSION_NAME` tiver diretório relativo (ex.: `data/minha_sessao`), + resolve relativo ao diretório atual. + - Se for apenas nome simples (ex.: `session`), usa por padrão + `~/.clean_telegram/` para manter a sessão estável entre execuções + em diretórios diferentes. + - Se existir sessão legada no diretório atual e não existir no novo local, + migra automaticamente. + """ + current_dir = cwd or Path.cwd() + home_dir = home or Path.home() + + name = (session_name or "session").strip() or "session" + path_candidate = Path(name).expanduser() + has_directory_hint = "/" in name or "\\" in name + + if path_candidate.is_absolute() or has_directory_hint: + resolved = ( + path_candidate if path_candidate.is_absolute() else (current_dir / path_candidate) + ) + resolved.parent.mkdir(parents=True, exist_ok=True) + return str(resolved) + + session_dir = home_dir / ".clean_telegram" + session_dir.mkdir(parents=True, exist_ok=True) + modern_session = _session_db_path(session_dir, name) + legacy_session = _session_db_path(current_dir, name) + + if legacy_session.exists() and not modern_session.exists(): + shutil.copy2(legacy_session, modern_session) + logger.info("Sessão migrada para %s", modern_session) + + return str(session_dir / name) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..5083776 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Testes para CleanTelegram.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bd029ce --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,150 @@ +"""Configuração e fixtures para testes do CleanTelegram.""" + +import os +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest +from telethon.tl.types import Channel, Chat, Dialog, User + + +@pytest.fixture +def mock_telegram_client(): + """Fixture que retorna um mock do TelegramClient.""" + client = AsyncMock() + client.get_me = AsyncMock(return_value=Mock(username="testuser", id=12345, first_name="Test")) + client.iter_dialogs = AsyncMock() + client.delete_dialog = AsyncMock() + return client + + +@pytest.fixture +def mock_async_telegram_client(): + """Fixture que retorna um AsyncMock do TelegramClient como context manager. + + Este fixture simula o comportamento do TelegramClient quando usado com + 'async with', retornando um client que já implementa os métodos necessários. + """ + client = AsyncMock() + client.get_me = AsyncMock(return_value=Mock(username="testuser", id=12345, first_name="Test")) + client.iter_dialogs = AsyncMock() + client.delete_dialog = AsyncMock() + client.return_value = client + + @asynccontextmanager + async def _client_context(*args, **kwargs): + yield client + + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + return mock_client + + +@pytest.fixture +def monkeypatch_env(temp_env_vars): + """Fixture para monkeypatch variáveis de ambiente. + + Usa temp_env_vars como base e retorna uma função auxiliar para setar vars. + """ + + def set_env(**kwargs): + for key, value in kwargs.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = str(value) + + return set_env + + +@pytest.fixture +def mock_channel(): + """Fixture que retorna um mock de Channel (canal/megagrupo).""" + channel = MagicMock(spec=Channel) + channel.id = 123456 + channel.title = "Test Channel" + channel.username = "testchannel" + return channel + + +@pytest.fixture +def mock_chat(): + """Fixture que retorna um mock de Chat (grupo legado).""" + chat = MagicMock(spec=Chat) + chat.id = 789012 + chat.title = "Test Chat" + return chat + + +@pytest.fixture +def mock_user(): + """Fixture que retorna um mock de User (usuário/bot).""" + user = MagicMock(spec=User) + user.id = 345678 + user.first_name = "Test" + user.last_name = "User" + user.username = "testuser" + user.bot = False + return user + + +@pytest.fixture +def mock_bot(): + """Fixture que retorna um mock de bot.""" + bot = MagicMock(spec=User) + bot.id = 456789 + bot.first_name = "TestBot" + bot.last_name = "" # Bots geralmente não têm last_name + bot.username = "testbot" + bot.bot = True + return bot + + +@pytest.fixture +def mock_dialog(): + """Fixture que retorna um mock de Dialog.""" + dialog = MagicMock(spec=Dialog) + dialog.name = "Test Dialog" + dialog.entity = MagicMock() + return dialog + + +@pytest.fixture +def temp_env_vars(): + """Fixture que limpa e restaura variáveis de ambiente.""" + original_env = os.environ.copy() + yield + os.environ.clear() + os.environ.update(original_env) + + +@pytest.fixture +def mock_session_file(tmp_path): + """Fixture que cria um arquivo de sessão temporário.""" + session_file = tmp_path / "test.session" + session_file.touch() + return session_file + + +@pytest.fixture +def mock_dialog_factory(): + """Factory function para criar mocks de Dialog. + + Retorna uma função que pode criar diálogos com parâmetros customizáveis. + """ + + def _create_dialog( + name: str = "Test Dialog", + entity=None, + dialog_id: int = 12345, + ): + """Cria um mock de Dialog com parâmetros customizáveis.""" + dialog = MagicMock(spec=Dialog) + dialog.name = name + dialog.id = dialog_id + dialog.entity = entity or MagicMock() + return dialog + + return _create_dialog diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..f3543d4 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,243 @@ +"""Testes para o módulo client.py.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from telethon.errors import BadRequestError, FloodWaitError +from telethon.tl.functions.channels import LeaveChannelRequest +from telethon.tl.functions.messages import DeleteChatUserRequest, DeleteHistoryRequest + +from clean_telegram.client import ( + delete_dialog, + leave_channel, + leave_legacy_chat, + process_dialog, +) + + +class TestDeleteDialog: + """Testes para delete_dialog.""" + + @pytest.mark.asyncio + async def test_delete_dialog_dry_run(self, mock_telegram_client): + """Testa que delete_dialog não executa em dry_run.""" + peer = MagicMock() + + await delete_dialog(mock_telegram_client, peer, dry_run=True) + + mock_telegram_client.assert_not_awaited() + + @pytest.mark.asyncio + async def test_delete_dialog_executes(self, mock_telegram_client): + """Testa que delete_dialog executa DeleteHistoryRequest.""" + peer = MagicMock() + mock_telegram_client.return_value = None + + await delete_dialog(mock_telegram_client, peer, dry_run=False) + + mock_telegram_client.assert_called_once() + call_args = mock_telegram_client.call_args + assert isinstance(call_args[0][0], DeleteHistoryRequest) + + +class TestLeaveChannel: + """Testes para leave_channel.""" + + @pytest.mark.asyncio + async def test_leave_channel_dry_run(self, mock_telegram_client, mock_channel): + """Testa que leave_channel não executa em dry_run.""" + await leave_channel(mock_telegram_client, mock_channel, dry_run=True) + + mock_telegram_client.assert_not_awaited() + + @pytest.mark.asyncio + async def test_leave_channel_executes(self, mock_telegram_client, mock_channel): + """Testa que leave_channel executa LeaveChannelRequest.""" + mock_telegram_client.return_value = None + + await leave_channel(mock_telegram_client, mock_channel, dry_run=False) + + mock_telegram_client.assert_called_once() + call_args = mock_telegram_client.call_args + assert isinstance(call_args[0][0], LeaveChannelRequest) + + +class TestLeaveLegacyChat: + """Testes para leave_legacy_chat.""" + + @pytest.mark.asyncio + async def test_leave_legacy_chat_dry_run(self, mock_telegram_client, mock_chat): + """Testa que leave_legacy_chat não executa em dry_run.""" + await leave_legacy_chat(mock_telegram_client, mock_chat, dry_run=True) + + mock_telegram_client.assert_not_awaited() + + @pytest.mark.asyncio + async def test_leave_legacy_chat_executes(self, mock_telegram_client, mock_chat): + """Testa que leave_legacy_chat executa DeleteChatUserRequest.""" + mock_telegram_client.return_value = None + + await leave_legacy_chat(mock_telegram_client, mock_chat, dry_run=False) + + mock_telegram_client.assert_called_once() + call_args = mock_telegram_client.call_args + assert isinstance(call_args[0][0], DeleteChatUserRequest) + + +class TestProcessDialog: + """Testes para process_dialog.""" + + @pytest.mark.asyncio + async def test_process_channel(self, mock_telegram_client, mock_channel): + """Testa process_dialog com Channel.""" + mock_telegram_client.return_value = None + + result = await process_dialog( + mock_telegram_client, + mock_channel, + "Test Channel", + 1, + dry_run=False, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_process_channel_dry_run(self, mock_telegram_client, mock_channel): + """Testa process_dialog com Channel em dry_run.""" + result = await process_dialog( + mock_telegram_client, + mock_channel, + "Test Channel", + 1, + dry_run=True, + ) + + assert result is True + mock_telegram_client.assert_not_awaited() + + @pytest.mark.asyncio + async def test_process_chat(self, mock_telegram_client, mock_chat): + """Testa process_dialog com Chat.""" + mock_telegram_client.return_value = None + + result = await process_dialog( + mock_telegram_client, + mock_chat, + "Test Chat", + 1, + dry_run=False, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_process_chat_fallback_on_rpc_error(self, mock_telegram_client, mock_chat): + """Testa fallback em process_dialog quando RPCError ocorre em Chat.""" + + # Configura side_effect para a primeira chamada (DeleteChatUserRequest) levantar erro + # e mock delete_dialog para o fallback + mock_telegram_client.side_effect = BadRequestError(None, "Test error") + mock_telegram_client.delete_dialog = AsyncMock() + + result = await process_dialog( + mock_telegram_client, + mock_chat, + "Test Chat", + 1, + dry_run=False, + ) + + assert result is True + mock_telegram_client.delete_dialog.assert_called_once_with(mock_chat) + + @pytest.mark.asyncio + async def test_process_user(self, mock_telegram_client, mock_user): + """Testa process_dialog com User.""" + mock_telegram_client.return_value = None + + result = await process_dialog( + mock_telegram_client, + mock_user, + "Test User", + 1, + dry_run=False, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_process_bot(self, mock_telegram_client, mock_bot): + """Testa process_dialog com bot.""" + mock_telegram_client.return_value = None + + result = await process_dialog( + mock_telegram_client, + mock_bot, + "TestBot", + 1, + dry_run=False, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_process_unknown_type(self, mock_telegram_client): + """Testa process_dialog com tipo desconhecido.""" + unknown_entity = MagicMock(spec=object) + mock_telegram_client.return_value = None + + result = await process_dialog( + mock_telegram_client, + unknown_entity, + "Unknown", + 1, + dry_run=False, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_process_dialog_flood_wait_error(self, mock_telegram_client, mock_channel): + """Testa process_dialog com FloodWaitError.""" + mock_telegram_client.side_effect = FloodWaitError(None, 60) + + result = await process_dialog( + mock_telegram_client, + mock_channel, + "Test Channel", + 1, + dry_run=False, + ) + + assert result is False + + @pytest.mark.asyncio + async def test_process_dialog_rpc_error(self, mock_telegram_client, mock_channel): + """Testa process_dialog com RPCError.""" + mock_telegram_client.side_effect = BadRequestError(None, "Test error") + + result = await process_dialog( + mock_telegram_client, + mock_channel, + "Test Channel", + 1, + dry_run=False, + ) + + assert result is False + + @pytest.mark.asyncio + async def test_process_dialog_generic_exception(self, mock_telegram_client, mock_channel): + """Testa process_dialog com exceção genérica.""" + mock_telegram_client.side_effect = Exception("Unexpected error") + + result = await process_dialog( + mock_telegram_client, + mock_channel, + "Test Channel", + 1, + dry_run=False, + ) + + assert result is False diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..c660d8f --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,562 @@ +"""Testes para o módulo __main__.py.""" + +import asyncio +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from telethon.errors import FloodWaitError, RPCError +from telethon.tl.types import Channel, User + +# Importamos aqui para evitar problemas de importação +from clean_telegram import __main__ + + +@pytest.fixture +def mock_telegram_client_context(): + """Fixture que cria um mock do TelegramClient como context manager. + + Retorna uma função que pode ser usada para criar o mock com diferentes + configurações de iter_dialogs. + """ + + def _create_mock(iter_dialogs_return=None): + """Cria um mock do TelegramClient.""" + # Criar async generator function baseado nos items + if callable(iter_dialogs_return): + # Se passou uma função, usar ela diretamente + async def iter_dialogs_func(): + async for item in iter_dialogs_return(): + yield item + + elif iter_dialogs_return is None: + # Lista vazia + async def iter_dialogs_func(): + return + yield + + else: + # Lista de items + items = iter_dialogs_return + + async def iter_dialogs_func(): + for item in items: + yield item + + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + client.iter_dialogs = iter_dialogs_func + + @asynccontextmanager + async def _client_context(*args, **kwargs): + yield client + + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + return mock_client + + return _create_mock + + +class TestMainHelpFlag: + """Testes para flag --help.""" + + def test_main_help_flag(self, capsys): + """Testa exibição de ajuda com --help.""" + with patch("sys.argv", ["clean-telegram", "--help"]): + with pytest.raises(SystemExit) as exc_info: + asyncio.run(__main__.main()) + + # --help causa SystemExit com código 0 + assert exc_info.value.code == 0 + + +class TestMainEnvValidation: + """Testes para validação de variáveis de ambiente.""" + + @pytest.mark.asyncio + async def test_main_missing_api_id(self, monkeypatch_env, mock_telegram_client_context): + """Testa erro quando API_ID não está definido.""" + monkeypatch_env(API_ID=None, API_HASH="test_hash") + + # Mock TelegramClient no módulo __main__ para evitar tentativa de conexão real + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_telegram_client_context() + ): + # Mock load_dotenv para não carregar .env real + with patch("clean_telegram.__main__.load_dotenv"): + # Usar --dry-run para pular confirmação e chegar na validação de API_ID + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with pytest.raises(SystemExit) as exc_info: + await __main__.main() + + assert "API_ID" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_main_missing_api_hash(self, monkeypatch_env, mock_telegram_client_context): + """Testa erro quando API_HASH não está definido.""" + monkeypatch_env(API_ID="12345", API_HASH=None) + + # Mock TelegramClient no módulo __main__ para evitar tentativa de conexão real + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_telegram_client_context() + ): + # Mock load_dotenv para não carregar .env real + with patch("clean_telegram.__main__.load_dotenv"): + # Usar --dry-run para pular confirmação e chegar na validação de API_HASH + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with pytest.raises(SystemExit) as exc_info: + await __main__.main() + + assert "API_HASH" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_main_invalid_api_id(self, monkeypatch_env, mock_telegram_client_context): + """Testa erro quando API_ID tem valor inválido.""" + monkeypatch_env(API_ID="not_a_number", API_HASH="test_hash") + + # Mock TelegramClient no módulo __main__ para evitar tentativa de conexão real + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_telegram_client_context() + ): + # Mock load_dotenv para não carregar .env real + with patch("clean_telegram.__main__.load_dotenv"): + # Usar --dry-run para pular confirmação + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with pytest.raises(SystemExit) as exc_info: + await __main__.main() + + # Mensagem de erro pode ser sobre API_ID inválido ou faltando + assert "API_ID" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_main_resolves_session_name(self, monkeypatch_env, mock_async_telegram_client): + """Testa se resolve_session_name é usado antes de criar TelegramClient.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash", SESSION_NAME="session") + + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + + async def mock_iter_dialogs(): + return + yield + + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("clean_telegram.__main__.load_dotenv"): + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with patch( + "clean_telegram.__main__.resolve_session_name", + return_value="/tmp/clean-telegram/session", + ) as resolve_session_name_mock: + with patch( + "clean_telegram.__main__.TelegramClient", + return_value=mock_async_telegram_client, + ) as telegram_client_mock: + await __main__.main() + + resolve_session_name_mock.assert_called_once_with("session") + telegram_client_mock.assert_called_once_with( + "/tmp/clean-telegram/session", 12345, "test_hash" + ) + + +class TestMainConfirmation: + """Testes para fluxo de confirmação.""" + + @pytest.mark.asyncio + async def test_main_confirmation_cancel(self, monkeypatch_env, mock_async_telegram_client): + """Testa cancelamento quando usuário não digita 'APAGAR TUDO'.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Mock stdin para retornar resposta diferente de "APAGAR TUDO" + mock_stdin = MagicMock() + mock_stdin.readline.return_value = "nao apagar\n" + + with patch("sys.argv", ["clean-telegram"]): + with patch("sys.stdin", mock_stdin): + with patch( + "clean_telegram.__main__.TelegramClient", + return_value=mock_async_telegram_client, + ): + await __main__.main() + + # Verifica que o client não foi usado (cancelado antes de entrar) + mock_async_telegram_client.__aenter__.assert_not_called() + + @pytest.mark.asyncio + async def test_main_confirmation_accept(self, monkeypatch_env, mock_async_telegram_client): + """Testa confirmação quando usuário digita 'APAGAR TUDO'.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Mock stdin para retornar "APAGAR TUDO" + mock_stdin = MagicMock() + mock_stdin.readline.return_value = "APAGAR TUDO\n" + + # Mock iter_dialogs para não retornar nada (lista vazia) + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + + async def mock_iter_dialogs(): + return + yield # Make it an async generator + + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram"]): + with patch("sys.stdin", mock_stdin): + with patch( + "clean_telegram.__main__.TelegramClient", + return_value=mock_async_telegram_client, + ): + await __main__.main() + + # Verifica que o client foi inicializado (confirmação aceita) + mock_async_telegram_client.__aenter__.assert_called_once() + + +class TestMainDryRun: + """Testes para flag --dry-run.""" + + @pytest.mark.asyncio + async def test_main_dry_run_no_confirmation(self, monkeypatch_env, mock_async_telegram_client): + """Verifica que dry-run pula confirmação.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Mock iter_dialogs para não retornar nada + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + + async def mock_iter_dialogs(): + return + yield + + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + await __main__.main() + + # Verifica que o client foi usado sem pedir confirmação + mock_async_telegram_client.__aenter__.assert_called_once() + + @pytest.mark.asyncio + async def test_main_dry_run_flag(self, monkeypatch_env, mock_async_telegram_client): + """Testa execução em dry-run.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Criar um mock de diálogo + mock_dialog = MagicMock() + mock_dialog.name = "Test Channel" + mock_dialog.entity = MagicMock(spec=Channel) + + # Mock iter_dialogs como async generator + async def mock_iter_dialogs(): + yield mock_dialog + + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + await __main__.main() + + # Em dry-run, as operações destrutivas não devem ser executidas + # client.delete_dialog não deve ser chamado para canais + client.delete_dialog.assert_not_called() + + +class TestMainLimitFlag: + """Testes para flag --limit.""" + + @pytest.mark.asyncio + async def test_main_limit_flag(self, monkeypatch_env, mock_async_telegram_client): + """Testa flag --limit para processar subset de diálogos.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Criar 5 mocks de diálogos + mock_dialogs = [] + for i in range(5): + mock_dialog = MagicMock() + mock_dialog.name = f"Dialog {i}" + mock_dialog.entity = MagicMock(spec=Channel) + mock_dialogs.append(mock_dialog) + + # Mock iter_dialogs como async generator + async def mock_iter_dialogs(): + for dialog in mock_dialogs: + yield dialog + + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--dry-run", "--limit", "2"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + await __main__.main() + + # Com --limit 2, apenas 2 diálogos devem ser processados + # Não há como verificar diretamente quantos foram processados, + # mas podemos verificar que o iter_dialogs foi chamado + + +class TestMainYesFlag: + """Testes para flag --yes.""" + + @pytest.mark.asyncio + async def test_main_yes_flag(self, monkeypatch_env, mock_async_telegram_client): + """Testa flag --yes para pular confirmação.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Mock iter_dialogs para não retornar nada + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + + async def mock_iter_dialogs(): + return + yield + + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--yes"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + await __main__.main() + + # Verifica que o client foi usado sem pedir confirmação + mock_async_telegram_client.__aenter__.assert_called_once() + + +class TestMainClientIteration: + """Testes para iteração sobre diálogos.""" + + @pytest.mark.asyncio + async def test_main_client_iteration_empty(self, monkeypatch_env, mock_async_telegram_client): + """Testa iteração quando não há diálogos.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Mock iter_dialogs para não retornar nada + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + + async def mock_iter_dialogs(): + return + yield + + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--yes"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + await __main__.main() + + # Verifica que get_me foi chamado + client.get_me.assert_called_once() + + @pytest.mark.asyncio + async def test_main_client_iteration_with_dialogs( + self, monkeypatch_env, mock_async_telegram_client + ): + """Testa iteração sobre diálogos com mocks.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Criar mocks de diferentes tipos de diálogos + mock_channel = MagicMock() + mock_channel.name = "Test Channel" + mock_channel.entity = MagicMock(spec=Channel) + + mock_user = MagicMock() + mock_user.name = "Test User" + mock_user.entity = MagicMock(spec=User) + + # Mock iter_dialogs como async generator + async def mock_iter_dialogs(): + yield mock_channel + yield mock_user + + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + await __main__.main() + + # Verifica que get_me foi chamado + client.get_me.assert_called_once() + + +class TestMainFloodWaitRetry: + """Testes para retry em FloodWaitError.""" + + @pytest.mark.asyncio + async def test_main_flood_wait_retry(self, monkeypatch_env, mock_async_telegram_client): + """Testa retry em FloodWaitError.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Criar mock de diálogo + mock_dialog = MagicMock() + mock_dialog.name = "Test Channel" + mock_dialog.entity = MagicMock(spec=Channel) + + # Contador para controlar quando lançar erro + call_count = {"count": 0} + + # Mock process_dialog para lançar FloodWaitError na primeira chamada + async def mock_process_dialog(*args, **kwargs): + call_count["count"] += 1 + if call_count["count"] == 1: + raise FloodWaitError(None, 1) + return True + + # Mock iter_dialogs como async generator + async def mock_iter_dialogs(): + yield mock_dialog + + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + with patch( + "clean_telegram.__main__.process_dialog", side_effect=mock_process_dialog + ): + await __main__.main() + + # Verifica que process_dialog foi chamado mais de uma vez (retry) + assert call_count["count"] >= 1 + + @pytest.mark.asyncio + async def test_main_max_retries_exceeded(self, monkeypatch_env, mock_async_telegram_client): + """Testa quando max retries é atingido.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Criar mock de diálogo + mock_dialog = MagicMock() + mock_dialog.name = "Test Channel" + mock_dialog.entity = MagicMock(spec=Channel) + + # Mock process_dialog para sempre lançar FloodWaitError + async def mock_process_dialog_failing(*args, **kwargs): + raise FloodWaitError(None, 1) + + # Mock iter_dialogs como async generator + async def mock_iter_dialogs(): + yield mock_dialog + + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + with patch( + "clean_telegram.__main__.process_dialog", + side_effect=mock_process_dialog_failing, + ): + await __main__.main() + + # Verifica que o loop continuou mesmo após max retries + + +class TestMainRPCErrorHandling: + """Testes para tratamento de RPCError.""" + + @pytest.mark.asyncio + async def test_main_rpc_error_handling(self, monkeypatch_env, mock_async_telegram_client): + """Testa RPCError durante iteração.""" + monkeypatch_env(API_ID="12345", API_HASH="test_hash") + + # Criar mocks de diálogos + mock_dialog1 = MagicMock() + mock_dialog1.name = "Dialog 1" + mock_dialog1.entity = MagicMock(spec=Channel) + + mock_dialog2 = MagicMock() + mock_dialog2.name = "Dialog 2" + mock_dialog2.entity = MagicMock(spec=Channel) + + # Flag para controlar qual diálogo falha + call_count = {"count": 0} + + # Mock process_dialog para lançar RPCError na primeira chamada + async def mock_process_dialog(*args, **kwargs): + call_count["count"] += 1 + if call_count["count"] == 1: + raise RPCError(None, "Test error") + return True + + # Mock iter_dialogs como async generator + async def mock_iter_dialogs(): + yield mock_dialog1 + yield mock_dialog2 + + client = AsyncMock() + client.get_me = AsyncMock( + return_value=Mock(username="testuser", id=12345, first_name="Test") + ) + client.iter_dialogs = mock_iter_dialogs + mock_async_telegram_client.__aenter__.return_value = client + + with patch("sys.argv", ["clean-telegram", "--dry-run"]): + with patch( + "clean_telegram.__main__.TelegramClient", return_value=mock_async_telegram_client + ): + with patch( + "clean_telegram.__main__.process_dialog", side_effect=mock_process_dialog + ): + await __main__.main() + + # Verifica que ambos os diálogos foram considerados (mesmo com erro no primeiro) + # O segundo diálogo deve ter sido processado + assert call_count["count"] == 2 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..954d137 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,179 @@ +"""Testes para o módulo utils.py.""" + +import os + +import pytest + +from clean_telegram.utils import env_int, resolve_session_name, safe_sleep + + +class TestEnvInt: + """Testes para env_int.""" + + def test_env_int_valid(self, temp_env_vars): + """Testa env_int com valor válido.""" + os.environ["TEST_VAR"] = "12345" + result = env_int("TEST_VAR") + assert result == 12345 + + def test_env_int_missing(self, temp_env_vars): + """Testa env_int com variável não definida.""" + with pytest.raises(SystemExit): + env_int("NONEXISTENT_VAR") + + def test_env_int_invalid_value(self, temp_env_vars): + """Testa env_int com valor não numérico.""" + os.environ["TEST_VAR"] = "not_a_number" + with pytest.raises(SystemExit): + env_int("TEST_VAR") + + def test_env_int_zero(self, temp_env_vars): + """Testa env_int com valor zero.""" + os.environ["TEST_VAR"] = "0" + result = env_int("TEST_VAR") + assert result == 0 + + def test_env_int_negative(self, temp_env_vars): + """Testa env_int com valor negativo.""" + os.environ["TEST_VAR"] = "-100" + result = env_int("TEST_VAR") + assert result == -100 + + def test_env_int_empty_string(self, temp_env_vars): + """Testa env_int com string vazia.""" + os.environ["TEST_VAR"] = "" + with pytest.raises(SystemExit): + env_int("TEST_VAR") + + def test_env_int_spaces(self, temp_env_vars): + """Testa env_int com espaços que devem ser removidos.""" + os.environ["TEST_VAR"] = " 123 " + result = env_int("TEST_VAR") + assert result == 123 + + def test_env_int_float_string(self, temp_env_vars): + """Testa env_int com string de float (deve falhar).""" + os.environ["TEST_VAR"] = "12.5" + with pytest.raises(SystemExit): + env_int("TEST_VAR") + + +class TestSafeSleep: + """Testes para safe_sleep.""" + + @pytest.mark.asyncio + async def test_safe_sleep_zero(self): + """Testa safe_sleep com zero segundos.""" + await safe_sleep(0) + # Não deve levantar exceção + + @pytest.mark.asyncio + async def test_safe_sleep_positive(self): + """Testa safe_sleep com valor positivo.""" + import time + + start = time.monotonic() + await safe_sleep(0.1) + elapsed = time.monotonic() - start + assert elapsed >= 0.1 + + @pytest.mark.asyncio + async def test_safe_sleep_negative(self): + """Testa safe_sleep com valor negativo (deve falhar).""" + with pytest.raises(ValueError, match="não negativo"): + await safe_sleep(-1) + + @pytest.mark.asyncio + async def test_safe_sleep_invalid_type(self): + """Testa safe_sleep com tipo inválido (deve falhar).""" + with pytest.raises(ValueError, match="número"): + await safe_sleep("invalid") + + @pytest.mark.asyncio + async def test_safe_sleep_float(self): + """Testa safe_sleep com valor float.""" + import time + + start = time.monotonic() + await safe_sleep(0.05) + elapsed = time.monotonic() - start + assert elapsed >= 0.05 + + +class TestResolveSessionName: + """Testes para resolução do caminho de sessão.""" + + def test_resolve_session_name_defaults_to_user_dir(self, tmp_path): + """Nome simples deve usar ~/.clean_telegram.""" + cwd = tmp_path / "cwd" + home = tmp_path / "home" + cwd.mkdir() + home.mkdir() + + result = resolve_session_name("session", cwd=cwd, home=home) + + assert result == str(home / ".clean_telegram" / "session") + assert (home / ".clean_telegram").exists() + + def test_resolve_session_name_uses_absolute_path(self, tmp_path): + """Caminho absoluto deve ser preservado.""" + cwd = tmp_path / "cwd" + home = tmp_path / "home" + cwd.mkdir() + home.mkdir() + absolute = tmp_path / "sessions" / "my_session" + + result = resolve_session_name(str(absolute), cwd=cwd, home=home) + + assert result == str(absolute) + assert absolute.parent.exists() + + def test_resolve_session_name_uses_relative_directory(self, tmp_path): + """Caminho relativo com diretório deve ser resolvido pelo cwd.""" + cwd = tmp_path / "cwd" + home = tmp_path / "home" + cwd.mkdir() + home.mkdir() + + result = resolve_session_name("data/my_session", cwd=cwd, home=home) + + assert result == str(cwd / "data" / "my_session") + assert (cwd / "data").exists() + + def test_resolve_session_name_supports_dot_relative_path(self, tmp_path): + """Caminho relativo com ./ deve ser tratado como caminho explícito.""" + cwd = tmp_path / "cwd" + home = tmp_path / "home" + cwd.mkdir() + home.mkdir() + + result = resolve_session_name("./session", cwd=cwd, home=home) + + assert result == str(cwd / "session") + + def test_resolve_session_name_migrates_legacy_file(self, tmp_path): + """Sessão legada local deve ser migrada para ~/.clean_telegram.""" + cwd = tmp_path / "cwd" + home = tmp_path / "home" + cwd.mkdir() + home.mkdir() + legacy = cwd / "session.session" + legacy.write_text("legacy-data", encoding="utf-8") + + result = resolve_session_name("session", cwd=cwd, home=home) + + modern = home / ".clean_telegram" / "session.session" + assert result == str(home / ".clean_telegram" / "session") + assert modern.exists() + assert modern.read_text(encoding="utf-8") == "legacy-data" + + def test_resolve_session_name_defaults_when_empty(self, tmp_path): + """Valor vazio deve cair no nome padrão 'session'.""" + cwd = tmp_path / "cwd" + home = tmp_path / "home" + cwd.mkdir() + home.mkdir() + + result = resolve_session_name(" ", cwd=cwd, home=home) + + assert result == str(home / ".clean_telegram" / "session") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9959696 --- /dev/null +++ b/uv.lock @@ -0,0 +1,670 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "black" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/1b/523329e713f965ad0ea2b7a047eeb003007792a0353622ac7a8cb2ee6fef/black-26.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168", size = 1849661, upload-time = "2026-01-18T04:59:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/14/82/94c0640f7285fa71c2f32879f23e609dd2aa39ba2641f395487f24a578e7/black-26.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d", size = 1689065, upload-time = "2026-01-18T04:59:13.993Z" }, + { url = "https://files.pythonhosted.org/packages/f0/78/474373cbd798f9291ed8f7107056e343fd39fef42de4a51c7fd0d360840c/black-26.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0", size = 1751502, upload-time = "2026-01-18T04:59:15.971Z" }, + { url = "https://files.pythonhosted.org/packages/29/89/59d0e350123f97bc32c27c4d79563432d7f3530dca2bff64d855c178af8b/black-26.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24", size = 1400102, upload-time = "2026-01-18T04:59:17.8Z" }, + { url = "https://files.pythonhosted.org/packages/e1/bc/5d866c7ae1c9d67d308f83af5462ca7046760158bbf142502bad8f22b3a1/black-26.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89", size = 1207038, upload-time = "2026-01-18T04:59:19.543Z" }, + { url = "https://files.pythonhosted.org/packages/30/83/f05f22ff13756e1a8ce7891db517dbc06200796a16326258268f4658a745/black-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5", size = 1831956, upload-time = "2026-01-18T04:59:21.38Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f2/b2c570550e39bedc157715e43927360312d6dd677eed2cc149a802577491/black-26.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68", size = 1672499, upload-time = "2026-01-18T04:59:23.257Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d7/990d6a94dc9e169f61374b1c3d4f4dd3037e93c2cc12b6f3b12bc663aa7b/black-26.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14", size = 1735431, upload-time = "2026-01-18T04:59:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/36/1c/cbd7bae7dd3cb315dfe6eeca802bb56662cc92b89af272e014d98c1f2286/black-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c", size = 1400468, upload-time = "2026-01-18T04:59:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/59/b1/9fe6132bb2d0d1f7094613320b56297a108ae19ecf3041d9678aec381b37/black-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4", size = 1207332, upload-time = "2026-01-18T04:59:28.711Z" }, + { url = "https://files.pythonhosted.org/packages/f5/13/710298938a61f0f54cdb4d1c0baeb672c01ff0358712eddaf29f76d32a0b/black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f", size = 1878189, upload-time = "2026-01-18T04:59:30.682Z" }, + { url = "https://files.pythonhosted.org/packages/79/a6/5179beaa57e5dbd2ec9f1c64016214057b4265647c62125aa6aeffb05392/black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6", size = 1700178, upload-time = "2026-01-18T04:59:32.387Z" }, + { url = "https://files.pythonhosted.org/packages/8c/04/c96f79d7b93e8f09d9298b333ca0d31cd9b2ee6c46c274fd0f531de9dc61/black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a", size = 1777029, upload-time = "2026-01-18T04:59:33.767Z" }, + { url = "https://files.pythonhosted.org/packages/49/f9/71c161c4c7aa18bdda3776b66ac2dc07aed62053c7c0ff8bbda8c2624fe2/black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791", size = 1406466, upload-time = "2026-01-18T04:59:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8b/a7b0f974e473b159d0ac1b6bcefffeb6bec465898a516ee5cc989503cbc7/black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954", size = 1216393, upload-time = "2026-01-18T04:59:37.18Z" }, + { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, + { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, + { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, +] + +[[package]] +name = "clean-telegram" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "python-dotenv" }, + { name = "qrcode" }, + { name = "telethon" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'", specifier = ">=24.0.0" }, + { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.13.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "python-dotenv", specifier = ">=1.0.0,<2.0.0" }, + { name = "qrcode", specifier = ">=8.0,<9.0" }, + { name = "telethon", specifier = ">=1.42.0,<2.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/07/1c8099563a8a6c389a31c2d0aa1497cee86d6248bb4b9ba5e779215db9f9/coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0", size = 219143, upload-time = "2026-02-03T13:59:40.459Z" }, + { url = "https://files.pythonhosted.org/packages/69/39/a892d44af7aa092cab70e0cc5cdbba18eeccfe1d6930695dab1742eef9e9/coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b", size = 219663, upload-time = "2026-02-03T13:59:41.951Z" }, + { url = "https://files.pythonhosted.org/packages/9a/25/9669dcf4c2bb4c3861469e6db20e52e8c11908cf53c14ec9b12e9fd4d602/coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8", size = 246424, upload-time = "2026-02-03T13:59:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/68/d9766c4e298aca62ea5d9543e1dd1e4e1439d7284815244d8b7db1840bfb/coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0", size = 248228, upload-time = "2026-02-03T13:59:44.816Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e2/eea6cb4a4bd443741adf008d4cccec83a1f75401df59b6559aca2bdd9710/coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6", size = 250103, upload-time = "2026-02-03T13:59:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/db/77/664280ecd666c2191610842177e2fab9e5dbdeef97178e2078fed46a3d2c/coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f", size = 247107, upload-time = "2026-02-03T13:59:48.53Z" }, + { url = "https://files.pythonhosted.org/packages/2b/df/2a672eab99e0d0eba52d8a63e47dc92245eee26954d1b2d3c8f7d372151f/coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e", size = 248143, upload-time = "2026-02-03T13:59:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/a5/dc/a104e7a87c13e57a358b8b9199a8955676e1703bb372d79722b54978ae45/coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56", size = 246148, upload-time = "2026-02-03T13:59:52.025Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/e113d3a58dc20b03b7e59aed1e53ebc9ca6167f961876443e002b10e3ae9/coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f", size = 246414, upload-time = "2026-02-03T13:59:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/3f/60/a3fd0a6e8d89b488396019a2268b6a1f25ab56d6d18f3be50f35d77b47dc/coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a", size = 247023, upload-time = "2026-02-03T13:59:55.454Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/de4840bb939dbb22ba0648a6d8069fa91c9cf3b3fca8b0d1df461e885b3d/coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be", size = 221751, upload-time = "2026-02-03T13:59:57.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/87/233ff8b7ef62fb63f58c78623b50bef69681111e0c4d43504f422d88cda4/coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b", size = 222686, upload-time = "2026-02-03T13:59:58.825Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/1ac74e37cf45f17eb41e11a21854f7f92a4c2d6c6098ef4a1becb0c6d8d3/coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73", size = 219276, upload-time = "2026-02-03T14:00:00.296Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cb/71908b08b21beb2c437d0d5870c4ec129c570ca1b386a8427fcdb11cf89c/coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00", size = 219776, upload-time = "2026-02-03T14:00:02.414Z" }, + { url = "https://files.pythonhosted.org/packages/09/85/c4f3dd69232887666a2c0394d4be21c60ea934d404db068e6c96aa59cd87/coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2", size = 250196, upload-time = "2026-02-03T14:00:04.197Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cc/560ad6f12010344d0778e268df5ba9aa990aacccc310d478bf82bf3d302c/coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c", size = 252111, upload-time = "2026-02-03T14:00:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/f0/66/3193985fb2c58e91f94cfbe9e21a6fdf941e9301fe2be9e92c072e9c8f8c/coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b", size = 254217, upload-time = "2026-02-03T14:00:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c5/78/f0f91556bf1faa416792e537c523c5ef9db9b1d32a50572c102b3d7c45b3/coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0", size = 250318, upload-time = "2026-02-03T14:00:09.224Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/fc654e45e837d137b2c1f3a2cc09b4aea1e8b015acd2f774fa0f3d2ddeba/coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14", size = 251909, upload-time = "2026-02-03T14:00:10.712Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/ab53063992add8a9ca0463c9d92cce5994a29e17affd1c2daa091b922a93/coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4", size = 249971, upload-time = "2026-02-03T14:00:12.402Z" }, + { url = "https://files.pythonhosted.org/packages/29/25/83694b81e46fcff9899694a1b6f57573429cdd82b57932f09a698f03eea5/coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad", size = 249692, upload-time = "2026-02-03T14:00:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/d68fc304301f4cb4bf6aefa0045310520789ca38dabdfba9dbecd3f37919/coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222", size = 250597, upload-time = "2026-02-03T14:00:15.461Z" }, + { url = "https://files.pythonhosted.org/packages/8d/85/240ad396f914df361d0f71e912ddcedb48130c71b88dc4193fe3c0306f00/coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb", size = 221773, upload-time = "2026-02-03T14:00:17.462Z" }, + { url = "https://files.pythonhosted.org/packages/2f/71/165b3a6d3d052704a9ab52d11ea64ef3426745de517dda44d872716213a7/coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301", size = 222711, upload-time = "2026-02-03T14:00:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/51/d0/0ddc9c5934cdd52639c5df1f1eb0fdab51bb52348f3a8d1c7db9c600d93a/coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba", size = 221377, upload-time = "2026-02-03T14:00:20.968Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" }, + { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" }, + { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, + { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, + { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" }, + { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" }, + { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" }, + { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" }, + { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, + { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyaes" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" } + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "telethon" +version = "1.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyaes" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/10/8c8c9476bfce767a856d8aaf9eae8ea1869df4e970da16f1c5b638fd1b0c/telethon-1.42.0.tar.gz", hash = "sha256:032e95511261d5ead719f75494c6c85ece2ce71816b54f3c65d6ccc371d6994d", size = 672734, upload-time = "2025-11-05T19:15:19.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/e4/8ce0ff55251381966a7c3f88bd5b34abda79b225a8e7fb51ddef3b849c94/telethon-1.42.0-py3-none-any.whl", hash = "sha256:cf361c94586bcacd6d0fc8959a2bce509d1bb37007fe6476a80c4fb4a2decc29", size = 748466, upload-time = "2025-11-05T19:15:18.241Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]