From c9cda89f7dae531a8feb79633592666d4bc6f68c Mon Sep 17 00:00:00 2001 From: Joberth Gambati Date: Sat, 17 Oct 2026 20:29:26 -0400 Subject: [PATCH] feat(v2.7.0): treemap interativo, CLI para IA (JSON) e benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Painel de treemap na janela "Espaço e treemap" (por tamanho ou quantidade), com hover, seleção sincronizada com a tabela e zoom por roda do mouse - Exportação do treemap para HTML autônomo (--treemap) - CLI: --maiores, --extensoes, --buscar, --status, --manifesto, --json, --so-indice, códigos de saída fixos; argumentos desconhecidos viram erro - Correções: --help abria a janela, saída sempre 0, falhas silenciosas, UnicodeEncodeError no console - Docs: docs/CLI.md, docs/IA.md, docs/BENCHMARK.md; tools/benchmark.py - 27 testes novos (95 no total) Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 13 + BACKLOG.md | 5 + CHANGELOG.md | 36 ++ CITATION.cff | 2 +- README.md | 28 +- docs/BENCHMARK.md | 67 +++ docs/CLI.md | 139 +++++ docs/IA.md | 96 +++ reperio.py | 1202 ++++++++++++++++++++++++++++++++++--- tests/test_cli_treemap.py | 359 +++++++++++ tools/benchmark.py | 170 ++++++ 11 files changed, 2013 insertions(+), 104 deletions(-) create mode 100644 docs/BENCHMARK.md create mode 100644 docs/CLI.md create mode 100644 docs/IA.md create mode 100644 tests/test_cli_treemap.py create mode 100644 tools/benchmark.py diff --git a/AGENTS.md b/AGENTS.md index 523b73f..ff4cce8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ para `reperio` (`_log_dir`). | `SearchThread` | Busca em largura com workers; troca de conexão, vítimas refeitas na hora, quarentena, rodadas no fim | | Cópia | `CopyManager`/`CopyWorker`, blocos de 1 MB, cancelável | | Exportação | `export_xlsx/csv/txt` (xlsx escrito com `zipfile`) | +| Treemap | `space_tree()` (árvore a partir de `aggregate_folder_space`), `squarify()`, `treemap_layout()` (retângulos), `render_treemap_html()`; a interface só desenha o que já está em memória (`SpaceDialog._sp`) | +| CLI | `_build_parser()` (argparse), `_parse_cli()`, `_run_cli()`: JSON em `_CliOut`; códigos de saída em `EXIT_*`; `--manifesto` é gerado a partir do parser | | `App` | Interface tkinter | ## 4. Regras da interface (não quebre) @@ -68,3 +70,14 @@ Tcl aborta a suíte. Atualize `__version__` (`reperio.py`), o `CHANGELOG.md` e o `CITATION.cff`; depois `git tag vX.Y.Z && git push origin vX.Y.Z`. A Release é montada pelo GitHub Actions (`.github/workflows/release.yml`). + +## 8. CLI e uso por IAs (v2.7) + +- A CLI é contrato público: **não renomeie** campos do JSON nem mude códigos de saída sem subir a versão + e atualizar `docs/CLI.md`, `docs/IA.md` e o `--manifesto`. Campos novos são sempre aditivos. +- Com `--json`, **nada** além do JSON vai para o stdout (mensagens vão para o stderr) e o JSON é ASCII. +- Argumento desconhecido é erro de uso (código 2); a janela só abre sem nenhuma ação. +- Toda ação nova da CLI precisa de teste em `tests/test_cli_treemap.py` (`run_cli` captura saída e código). +- O ReperIO **só lê**: nenhuma ação da CLI altera arquivos do usuário; só grava o índice e o que + `--exportar`/`--treemap` pedirem. +- Desempenho: `python tools/benchmark.py C:\ D:\` regenera `docs/BENCHMARK.md`. diff --git a/BACKLOG.md b/BACKLOG.md index 91e895c..60a5154 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -16,6 +16,11 @@ Ideias e próximos passos, em ordem de prioridade. O que já foi entregue está | 5 | P3 | M | Executável para **macOS** nas Releases | Hoje o macOS usa o código-fonte | | 6 | P3 | M | Troca de conexão no **Linux** (montagens CIFS alternativas) | Hoje o Linux usa o modo de rodadas no fim | | 7 | P3 | — | **Assinatura digital** do executável | Elimina o aviso do SmartScreen e reduz bloqueios de antivírus | +| 8 | P2 | M | **Tamanho em disco vs. lógico:** gravar o atributo "só na nuvem" (OneDrive) e o espaço alocado | Hoje arquivos só-nuvem contam o tamanho lógico (use `--ignorar OneDrive`); `FIND_DATA` já traz o atributo `RECALL_ON_DATA_ACCESS` | +| 12 | P2 | M | **Consultas em índices grandes:** `--espaco`/`--maiores`/`--extensoes` levam 7 a 10 s com 869 mil arquivos (benchmark); agregar em SQL (índice em `files(size)`, soma por pasta) em vez de em Python | Respostas de IA em < 1 s | +| 9 | P3 | P | **Duplicados** (`--duplicados`): agrupar por tamanho + nome e confirmar por hash sob demanda | Complementa `--maiores` na limpeza de disco | +| 10 | P3 | P | `--json` também para a janela de falhas e para `--status` por raiz | Mais autonomia para agentes | +| 11 | P3 | M | Treemap: **zoom por clique no HTML** (hoje o HTML é estático e aninhado até `--profundidade`) | Navegar sem voltar à janela | ## Limitações conhecidas diff --git a/CHANGELOG.md b/CHANGELOG.md index f443b56..65b0d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ Todas as mudanças relevantes do projeto. Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem [SemVer](https://semver.org/lang/pt-BR/). Próximos passos ficam no [roadmap](BACKLOG.md). +## [2.7.0] — 2026-10-17 + +### Adicionado +- **Treemap por tamanho ou por quantidade de arquivos** (opcional): + - Em **Espaço por pasta...**, painel interativo **acima da tabela, na mesma janela** (estilo + SpaceSniffer; **Ocultar treemap** e **Por: tamanho/quantidade**). Passar o mouse destaca o bloco e + mostra uma dica flutuante; clique seleciona e sincroniza com a tabela (nos dois sentidos); duplo + clique ou roda do mouse para cima entra na pasta; roda para baixo, botão direito ou Backspace sobe. + Desenha só dados já em memória, sem I/O na interface. + - **Exportação para HTML autônomo** (`Exportar... → Treemap`, ou `--treemap ARQ.html`): SVG com + dicas, alternância entre as duas métricas, tema claro/escuro e tabela dos maiores itens. + - Algoritmo *squarified* (`squarify`, `space_tree`, `treemap_layout`), testado quanto a proporção, + limites e sobreposição. +- **CLI para pessoas, scripts e IAs** (`docs/CLI.md`, `docs/IA.md`): + - Novas ações: `--maiores N`, `--extensoes [N]`, `--buscar PADRÃO` (com `--ext`, `--tamanho`, + `--ordenar`, `--limite`, `--maiusculas`), `--treemap`, `--status` e `--manifesto`. + - **`--json`**: um objeto JSON em ASCII no stdout (mensagens vão para o stderr), com estatísticas da + varredura (tempo, arquivos/s, pastas lidas, falhas com o motivo) e do processo (pico de RAM). + - **`--so-indice`** (nunca varre o disco), `--em PASTA`, `--profundidade`, `--ignorar` em todas as + ações, `--silencioso`, `--sem-indice`, `--ajuda`. + - **Códigos de saída fixos** (0 ok, 1 erro, 2 uso, 3 pasta/índice ausente, 130 Ctrl+C). + - `--manifesto`: descrição da CLI em JSON (argumentos, campos de saída, códigos, exemplos). +- `tools/benchmark.py` e `docs/BENCHMARK.md`: varredura a frio, reverificação a quente e consultas, + medidas pela própria CLI nos discos reais. +- Consultas novas no índice: `Index.largest_files`, `Index.extension_summary`, `Index.stats`. + +### Corrigido +- **`--ajuda`/`--help` abria a janela** e argumentos desconhecidos (ou com erro de digitação, como + `--espacoo`) também: agora são erro de uso (código 2) e a janela nunca abre por engano. +- A CLI **descartava em silêncio as pastas que não conseguiu ler** (sem permissão, servidor caiu); + agora aparecem em `varredura.falhas` e as pastas sem leitura em `pastas_pendentes`/`completo`. +- A CLI **caía com `UnicodeEncodeError`** ao imprimir nomes de arquivo fora da codificação do console + (cp1252/cp850); agora usa substituição segura. +- A CLI **sempre saía com código 0**, mesmo com pasta inexistente ou raiz inacessível; agora há códigos + de saída distintos (3 = pasta inexistente ou sem dados no índice). + ## [2.6.0] — 2026-10-17 ### Adicionado diff --git a/CITATION.cff b/CITATION.cff index 82bb994..911621e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,7 +11,7 @@ abstract: >- authors: - family-names: Gambati given-names: Joberth Firmino -version: 2.6.0 +version: 2.7.0 date-released: 2026-10-17 license: MIT repository-code: "https://github.com/Yiuky/ReperIO" diff --git a/README.md b/README.md index a42ee37..c090a9f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Python 3.8+ Windows | Linux Sem dependências - Versão v2.6.0 + Versão v2.7.0 License MIT Testes

@@ -149,11 +149,27 @@ padrão ou filtro de tamanho. Na busca seguinte: - **Apagar índice** limpa tudo; desmarque **Usar** para ignorá-lo numa busca. - Fica em `%LOCALAPPDATA%\reperio\` (Windows) ou `~/.local/state/reperio/` (Linux), ou no caminho informado com `--indice ` (útil para compartilhar um índice na rede). -- **Modo sem janela (CLI)** para tarefas agendadas e auditoria rápida de disco: +- **Modo sem janela (CLI)** para tarefas agendadas, auditoria rápida de disco e **uso por IAs**: ```bash python reperio.py --indexar "\\servidor\dados" --indice "\\servidor\dados\_reperio.sqlite" python reperio.py --espaco "C:\Users\voce" --top 25 --exportar espaco.xlsx + python reperio.py --maiores 20 --em D:\ --so-indice # 20 maiores arquivos, sem tocar o disco + python reperio.py --buscar "*.iso" --em D:\ --tamanho "> 1 GB" --json + python reperio.py --espaco D:\ --treemap mapa.html # mapa visual em HTML ``` + `--json` entrega **um objeto JSON** estável no stdout, `--so-indice` garante respostas instantâneas, + `--manifesto` descreve a CLI em JSON e os códigos de saída são fixos. Referência em + [docs/CLI.md](docs/CLI.md); guia para agentes em [docs/IA.md](docs/IA.md); números medidos em + [docs/BENCHMARK.md](docs/BENCHMARK.md). + +### 🗺️ Treemap interativo + +Em **Espaço por pasta...**, um painel de **treemap fica acima da tabela, na mesma janela** (estilo +SpaceSniffer): a **área dos blocos é proporcional ao tamanho ou à quantidade de arquivos** (botão +**Por: tamanho / quantidade**; **Ocultar treemap** esconde o painel). Passe o mouse para ver os detalhes, +clique para selecionar (a tabela acompanha), **roda do mouse** ou duplo clique para entrar numa pasta, +botão direito para subir. **Exportar... → Treemap +(página HTML)** ou `--treemap mapa.html` gera uma página autônoma (sem internet, tema claro/escuro). ## 🛡️ Confiável em redes lentas e instáveis @@ -278,7 +294,9 @@ listagem rápida via `ctypes` (Windows) e Excel escrito com `zipfile`. ``` reperio.py aplicativo completo, arquivo único (busca, índice, conexões, exportação, interface) iniciar.bat / .sh lançadores: Python da máquina, senão o executável -tests/ testes automatizados (unittest; cenários de rede simulados) +tests/ testes automatizados (unittest; cenários de rede simulados, CLI e treemap) +docs/ CLI.md, IA.md (uso por agentes) e BENCHMARK.md +tools/benchmark.py mede varredura e consultas pela CLI (discos C:/D:) tools/build.py gera o executável (PyInstaller) e o .zip de distribuição tools/screenshot.py gera a imagem do README com dados de demonstração tools/make_logo.ps1 gera o logo; tools/make_icon.py monta o ícone .ico @@ -296,9 +314,9 @@ python -m unittest discover -s tests -v # Windows xvfb-run -a python3 -m unittest discover -s tests -v # Linux sem tela ``` -68 testes, incluindo a simulação dos cenários vistos em servidor real: pasta que trava a conexão, +95 testes, incluindo a simulação dos cenários vistos em servidor real: pasta que trava a conexão, vítimas refeitas na hora, cascata entre conexões, pastas grandes que não são travamento, nomes -inválidos de servidores Samba antigos, filtros de tamanho/exclusão, análise de espaço por pasta e +inválidos de servidores Samba antigos, filtros de tamanho/exclusão, análise de espaço por pasta, treemap, CLI/JSON e exportação com datas corrompidas. O GitHub Actions roda a suíte em Windows e Linux, com Python 3.8 e 3.13, a cada push. diff --git a/docs/BENCHMARK.md b/docs/BENCHMARK.md new file mode 100644 index 0000000..e352db0 --- /dev/null +++ b/docs/BENCHMARK.md @@ -0,0 +1,67 @@ +# Benchmark do ReperIO + +Gerado por `tools/benchmark.py` em 2026-10-17 19:31. + +- Máquina: Windows-11-10.0.26200-SP0 +- Python 3.13.14 · ReperIO v2.7.0 · 12 CPUs lógicas + +## C:\ + +Disco: 299.1 GB no total, 168.4 GB usados, 130.8 GB livres. +Pastas ignoradas: `OneDrive`. +Indexado: **869.367 arquivos, 164.91 GB**; índice SQLite de 185.1 MB (há pastas sem leitura). + +| Etapa | Tempo (s) | Detalhe | Pico de RAM (MB) | +|---|---:|---|---:| +| Varredura a frio (índice vazio) | 85.4 | 869.363 arq. · 263636 pastas · 11182.2 arq./s; 531 pasta(s) não lidas | 410.8 | +| Reverificação a quente (índice pronto) | 49.73 | 264167 pastas conferidas · 563 relidas | 365.5 | +| `--espaco` (2 níveis) | 7.42 | — | 361.7 | +| `--maiores 20` | 8.8 | — | 361.7 | +| `--extensoes 20` | 9.81 | — | 361.7 | +| `--buscar *.zip` | 9.38 | 116 encontrados | 361.8 | +| `--treemap` (HTML) | 7.21 | HTML 229.5 KB | 361.7 | + +## D:\ + +Disco: 176.8 GB no total, 141.3 GB usados, 35.5 GB livres. +Indexado: **19.600 arquivos, 141.18 GB**; índice SQLite de 1.0 MB (há pastas sem leitura). + +| Etapa | Tempo (s) | Detalhe | Pico de RAM (MB) | +|---|---:|---|---:| +| Varredura a frio (índice vazio) | 0.76 | 19.600 arq. · 91 pastas · 35455.9 arq./s; 1 pasta(s) não lidas | 47.5 | +| Reverificação a quente (índice pronto) | 0.96 | 92 pastas conferidas · 1 relidas | 44.2 | +| `--espaco` (2 níveis) | 0.16 | — | 41.9 | +| `--maiores 20` | 0.18 | — | 42.0 | +| `--extensoes 20` | 0.2 | — | 42.4 | +| `--buscar *.zip` | 0.2 | 2 encontrados | 41.9 | +| `--treemap` (HTML) | 0.19 | HTML 18.9 KB | 42.0 | + +## %USERPROFILE%\OneDrive + +Disco: 299.1 GB no total, 168.4 GB usados, 130.8 GB livres. +Indexado: **182.073 arquivos, 653.26 GB**; índice SQLite de 15.6 MB. + +| Etapa | Tempo (s) | Detalhe | Pico de RAM (MB) | +|---|---:|---|---:| +| Varredura a frio (índice vazio) | 6.92 | 182.073 arq. · 14698 pastas · 29096.6 arq./s | 84.6 | +| Reverificação a quente (índice pronto) | 5.37 | 14698 pastas conferidas · 0 relidas | 98.9 | +| `--espaco` (2 níveis) | 0.65 | — | 60.5 | +| `--maiores 20` | 0.67 | — | 60.9 | +| `--extensoes 20` | 0.9 | — | 62.8 | +| `--buscar *.zip` | 1.07 | 148 encontrados | 60.8 | +| `--treemap` (HTML) | 0.68 | HTML 125.2 KB | 61.5 | + +Tempo = relógio de parede do processo inteiro (inclui iniciar o Python). O cache do sistema operacional pode estar aquecido, então a varredura "a frio" mede o ReperIO, não o disco puro. + +## Leitura dos resultados + +- **Pastas "não lidas"** são acesso negado a pastas do sistema (`System Volume Information`, `$Recycle.Bin` + de outros usuários, `PerfLogs`) quando a CLI roda sem administrador. Aparecem em `varredura.falhas` e não + afetam o restante; o total inclui apenas o que o usuário pode ler. +- **D:** poucos arquivos grandes (19,6 mil arquivos, 141 GB): o custo é por pasta/arquivo, não por byte. +- **Consultas em C: levam 7 a 10 s** com 869 mil arquivos (a de `D:` leva 0,2 s). A causa provável é a + agregação em Python linha a linha (não medida em separado); é o ponto a otimizar (ver BACKLOG). +- A reverificação "a quente" de C: (50 s) confere a data de cada uma das 264 mil pastas no disco; é bem mais + barata que a varredura a frio e só relê as pastas alteradas (563). +- A pasta OneDrive soma 653 GB lógicos em 182 mil arquivos, mas esse tamanho é lógico (arquivos só-nuvem não ocupam o disco local): por isso o + `C:` foi medido com `--ignorar OneDrive`. diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..41d4ff3 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,139 @@ +# Linha de comando do ReperIO + +Tudo que a janela faz com espaço, índice e busca também roda **sem janela**, para scripts, tarefas +agendadas e IAs. Só biblioteca padrão do Python (3.8+). A janela abre quando nenhuma ação é informada. + +```text +python reperio.py [ação] [filtros] [saída] [índice] +python reperio.py --ajuda +``` + +## Ações + +| Ação | O que faz | +|---|---| +| `--indexar PASTA` | Lê `PASTA` e grava no índice. Em pastas já indexadas só relê o que mudou (data da pasta). | +| `--espaco PASTA` | Espaço ocupado por subpasta (tamanho, `%`, arquivos). Usa o índice; **mapeia a pasta se o índice ainda não a cobre**. | +| `--maiores N` | Os `N` maiores arquivos de `--em PASTA`. | +| `--extensoes [N]` | Espaço por extensão (as `N` maiores, padrão 20). | +| `--buscar PADRÃO` | Arquivos por nome em `--em PASTA`: sem curingas é "contém"; com `*` e `?`, curingas. | +| `--treemap ARQ.html` | Grava o [treemap](#treemap) em uma página HTML autônoma. | +| `--status` | O que o índice conhece: pastas, arquivos, raízes, datas, tamanho do arquivo. | +| `--manifesto` | JSON com tudo que a CLI faz (argumentos, saídas, códigos). Pensado para IAs. | + +Ações combinam: `--espaco D:\ --maiores 10 --treemap mapa.html --json` faz as três coisas de uma vez, +com uma só leitura do índice. + +## Alvo e filtros + +| Opção | Efeito | +|---|---| +| `--em PASTA` | Pasta-alvo de `--buscar`, `--maiores`, `--extensoes` e `--treemap` (padrão: a de `--espaco` ou `--indexar`). | +| `--ignorar LISTA` | Pula pastas: nomes, curingas ou subcaminhos (`_backup, node_modules, temp_*, AppData/Local/Temp`). Vale na varredura **e** nas consultas. | +| `--ext LISTA` | `--buscar`: extensões (`shp tif`). | +| `--tamanho FILTRO` | `--buscar`: `> 100 MB`, `< 10 KB`, `10MB..500MB`. | +| `--maiusculas` | `--buscar` diferencia maiúsculas de minúsculas. | +| `--limite N` | `--buscar`: máximo de resultados exibidos (padrão 100); o total encontrado é sempre contado. | +| `--ordenar` | `--buscar`: `tamanho` (padrão), `data` ou `nome`. | + +## Saída + +| Opção | Efeito | +|---|---| +| `--json` | **Um único objeto JSON no stdout** (ASCII puro). Mensagens e progresso vão para o stderr. | +| `--top N` | `--espaco`: itens por nível (padrão 30). | +| `--profundidade N` | Níveis de subpastas: `--espaco` (padrão 1) e `--treemap` (padrão 3). | +| `--treemap-por` | Métrica mostrada primeiro no HTML: `tamanho` ou `quantidade`. | +| `--exportar ARQ` | `--espaco`: tabela em `.xlsx`, `.csv` ou `.txt`. | +| `-q`, `--silencioso` | Sem mensagens de progresso. | + +## Índice e execução + +| Opção | Efeito | +|---|---| +| `--indice ARQ` | Arquivo SQLite do índice (padrão `%LOCALAPPDATA%\reperio\index.sqlite`). Pode ficar em um compartilhamento. | +| `--no-index` | Sem índice, tudo em memória; só `--espaco` e `--treemap`. | +| `--atualizar` | Relê no disco as pastas alteradas mesmo que o índice já cubra a pasta. | +| `--so-indice` | **Nunca varre o disco.** Sem dados no índice, termina com código 3. Garante respostas rápidas. | + +## Códigos de saída + +| Código | Significado | +|---:|---| +| 0 | Ok | +| 1 | Erro inesperado (o detalhe vai no JSON `erro` ou no stderr) | +| 2 | Uso incorreto dos argumentos (inclui argumento desconhecido: **nunca abre a janela por engano**) | +| 3 | Pasta inexistente, ou sem dados no índice com `--so-indice` | +| 130 | Interrompido com Ctrl+C; o que já foi lido fica no índice | + +## Exemplos + +```bash +# Onde está o espaço do disco D:? (mapeia na 1ª vez; depois responde do índice) +python reperio.py --espaco D:\ --top 20 + +# O mesmo para uma IA ou script: JSON, dois níveis +python reperio.py --espaco D:\ --json --profundidade 2 --top 15 + +# Indexar o C: sem entrar no OneDrive (cujos arquivos só-nuvem contam tamanho lógico) +python reperio.py --indexar C:\ --ignorar OneDrive + +# Consultas instantâneas, sem tocar o disco +python reperio.py --maiores 30 --em C:\Users\eu --so-indice +python reperio.py --extensoes 15 --em D:\ --so-indice +python reperio.py --buscar "*.zip" --em D:\ --tamanho "> 1 GB" --json --so-indice + +# Treemap +python reperio.py --espaco D:\ --treemap D:\mapa.html + +# Indexação agendada (Agendador de Tarefas / cron), sem barulho +python reperio.py --indexar "\\servidor\dados" --indice "\\servidor\dados\_reperio.sqlite" -q +``` + +## Formato do JSON + +Sempre um objeto com `reperio` (versão) e `ok`. Em erro: `{"ok": false, "codigo": N, "erro": "..."}`. + +| Campo | Quando | Conteúdo | +|---|---|---| +| `pasta` | há pasta-alvo | Caminho normalizado consultado | +| `varredura` | sempre | `executada: false` se o índice bastou; senão `tempo_s`, `arquivos`, `pastas_lidas`, `pastas_conferidas`, `pastas_relidas`, `arquivos_por_s`, `falhas[]` (`pasta`, `erro`), `n_falhas`, `interrompido`, `indice` | +| `espaco` | `--espaco` | `total_bytes`, `total`, `total_arquivos`, `pastas_lidas`, `pastas_pendentes`, `completo`, `itens[]` | +| `espaco.itens[]` | | `nome`, `caminho`, `tipo` (`pasta` ou `arquivos_diretos`), `bytes`, `tamanho`, `arquivos`, `subpastas`, `bytes_diretos`, `arquivos_diretos`, `pct_pai`, `pct_raiz`, `filhos[]` (com `--profundidade` > 1) | +| `maiores[]` | `--maiores` | `caminho`, `bytes`, `tamanho`, `modificado` (ISO 8601) | +| `extensoes[]` | `--extensoes` | `extensao`, `arquivos`, `bytes`, `tamanho`, `pct` | +| `busca` | `--buscar` | `padrao`, `total_encontrado`, `bytes_total`, `exibidos`, `ordenado_por`, `resultados[]` (`caminho`, `nome`, `bytes`, `tamanho`, `modificado`) | +| `treemap` | `--treemap` | `arquivo`, `por`, `profundidade` | +| `indice` | `--status` | `arquivo`, `pastas`, `pastas_pendentes`, `arquivos`, `bytes`, `raizes[]`, `leitura_mais_antiga`, `leitura_mais_recente` | +| `desempenho` | sucesso | `tempo_total_s`, `pico_ram_mb` | + +`bytes` é exato; `tamanho` é só para leitura humana (`1.50 GB`, base 1024). + +## Treemap + +Mapa de blocos em que **a área é proporcional ao tamanho ou à quantidade de arquivos**. Pastas viram +retângulos aninhados (algoritmo *squarified*: blocos o mais quadrados possível), com uma cor por pasta +de primeiro nível. Serve para achar em segundos "o que está comendo o disco". + +- **Na janela (painel interativo, estilo SpaceSniffer):** em **Espaço por pasta...** o treemap fica em um + painel **acima da tabela**, na mesma janela (a divisória é ajustável; **Ocultar treemap** esconde o + painel e **Por: tamanho / quantidade** troca a métrica). Mostra 3 níveis de subpastas aninhadas. + - **Passar o mouse:** destaca o bloco e mostra uma dica com caminho, tamanho, arquivos e participação. + - **Clique:** seleciona o bloco e marca a linha correspondente da tabela (e vice-versa: escolher uma + linha da tabela contorna o bloco). + - **Duplo clique** ou **roda do mouse para cima:** entra na pasta sob o mouse. + - **Roda para baixo**, **botão direito** ou **Backspace:** sobe um nível. +- **Em arquivo:** `--treemap mapa.html` (CLI) ou **Exportar... → Treemap (página HTML)** na janela. + Um único `.html`, sem dependências nem internet: abre em qualquer navegador, tem tema claro/escuro e + alterna entre as duas métricas. Passe o mouse sobre um bloco para ver o caminho completo. +- **Blocos pequenos** (abaixo de 3 px) não são desenhados; cada pasta mostra no máximo 40 itens por + tamanho e por quantidade, e o resto vira um bloco cinza `[outros N itens]`. Arquivos soltos numa pasta + viram o bloco `[arquivos desta pasta]`. + +## Limitações conhecidas + +- **Arquivos do OneDrive só-nuvem** (e outros provedores de arquivos sob demanda) aparecem com o tamanho + **lógico**, não o ocupado no disco. Use `--ignorar OneDrive` para medir o disco local de verdade. +- O tamanho é o do arquivo, não o espaço alocado em clusters; links simbólicos e *junctions* não são + seguidos (evita ciclos) e hardlinks contam em duplicidade. +- `--espaco` e `--treemap` mostram só o que o índice já leu: veja `pastas_pendentes` e `completo`. diff --git a/docs/IA.md b/docs/IA.md new file mode 100644 index 0000000..92ff97e --- /dev/null +++ b/docs/IA.md @@ -0,0 +1,96 @@ +# Usando o ReperIO com uma IA (agentes, MCP, scripts) + +O ReperIO responde às perguntas mais comuns de "limpeza de disco" e "onde está o arquivo X" com +**uma linha de comando e uma saída JSON estável**. Um agente não precisa de interface gráfica, de +biblioteca extra nem de permissão de administrador. + +> Descoberta automática: `python reperio.py --manifesto` imprime em JSON todos os argumentos, os campos +> de saída, os códigos de saída e exemplos. Comece por ele. Referência completa em [CLI.md](CLI.md). + +## Regras de ouro para agentes + +1. **Sempre `--json`.** O stdout é *um* objeto JSON em ASCII; progresso e avisos vão para o stderr. + Leia `ok` antes de tudo. Em erro: `{"ok": false, "codigo": N, "erro": "..."}`. +2. **Consulte com `--so-indice`** depois da primeira leitura: responde em milissegundos e **nunca** + dispara uma varredura longa por engano (sem dados: código 3). +3. **Indexe uma vez, consulte muitas.** `--indexar PASTA` custa minutos em discos grandes; `--espaco`, + `--maiores`, `--extensoes`, `--buscar` e `--treemap` leem do índice SQLite. +4. **Meça o disco local com `--ignorar OneDrive`.** Arquivos só-nuvem contam o tamanho lógico. +5. **Confira `completo`/`pastas_pendentes`** em `espaco` e `varredura.falhas`: dizem se o número é + total ou parcial (pastas sem permissão aparecem lá, não derrubam a execução). +6. **O ReperIO só lê.** Nenhuma ação da CLI apaga, move ou altera arquivos. Quem decide remover é + a pessoa, depois de ver o relatório; mostre os caminhos exatos (`caminho`) antes de qualquer remoção. +7. Argumentos desconhecidos dão código 2 (a janela nunca abre por engano); `Ctrl+C` dá 130 e preserva o + que já foi lido. + +## Receitas + +| Pergunta | Comando | +|---|---| +| Quanto sobrou e o que ocupa o disco D:? | `python reperio.py --espaco D:\ --json --profundidade 2 --top 15` | +| Quais são os 20 maiores arquivos do perfil? | `python reperio.py --maiores 20 --em C:\Users\eu --json --so-indice --ignorar OneDrive` | +| Que tipo de arquivo ocupa mais espaço? | `python reperio.py --extensoes 15 --em D:\ --json --so-indice` | +| Onde estão os `.iso` com mais de 1 GB? | `python reperio.py --buscar "*.iso" --em D:\ --tamanho "> 1 GB" --json --so-indice` | +| O índice está atual? | `python reperio.py --status --json` (veja `leitura_mais_recente`) e `--atualizar` para revarrer só o que mudou | +| Quero um mapa visual para a pessoa | `python reperio.py --espaco D:\ --treemap D:\mapa.html --json` e abra o `.html` | +| Atualizar depois de limpar | `python reperio.py --indexar D:\ --json -q` (relê só pastas alteradas) | + +## Fluxo recomendado para "liberar espaço" + +1. `--status --json`: há índice? De quando? +2. `--indexar --ignorar OneDrive --json -q` (ou `--espaco` na pasta, que mapeia se faltar). +3. `--espaco --json --profundidade 2` para achar as pastas gordas; `--extensoes` e `--maiores` + para entender o *quê*; `--treemap` para a pessoa ver. +4. Propor a lista de candidatos **com caminho, tamanho e motivo**; esperar a decisão. +5. Depois de remover (feito pela pessoa ou com autorização explícita), reindexar com `--indexar` e + reportar o ganho (`espaco.total_bytes` antes e depois). + +## Esquema da resposta (resumo) + +```jsonc +{ + "reperio": "2.7.0", "ok": true, "pasta": "D:\\", + "varredura": { "executada": true, "tempo_s": 41.2, "arquivos": 300000, "pastas_lidas": 52000, + "arquivos_por_s": 7280.4, "n_falhas": 0, "falhas": [], "interrompido": false }, + "espaco": { + "total_bytes": 152000000000, "total": "141.57 GB", "total_arquivos": 300000, + "pastas_pendentes": 0, "completo": true, + "itens": [ { "nome": "Projetos", "caminho": "D:\\Projetos", "tipo": "pasta", + "bytes": 90000000000, "tamanho": "83.82 GB", "arquivos": 12000, "subpastas": 40, + "pct_pai": 59.2, "pct_raiz": 59.2, "filhos": [ /* com --profundidade 2 */ ] } ] + }, + "desempenho": { "tempo_total_s": 41.9, "pico_ram_mb": 188.1 } +} +``` + +Campos de cada ação: veja a tabela em [CLI.md](CLI.md#formato-do-json). Todos os nomes de campo são +ASCII em português; datas em ISO 8601 (`2026-10-17T16:52:50`, hora local). + +## Integração como ferramenta (exemplo) + +Qualquer agente que execute comandos pode usar o ReperIO. Em Python: + +```python +import json, subprocess, sys + +def reperio(*args): + p = subprocess.run([sys.executable, "reperio.py", *args, "--json", "-q"], + capture_output=True, text=True, encoding="utf-8") + data = json.loads(p.stdout) + if not data["ok"]: + raise RuntimeError("%s (código %s)" % (data["erro"], data["codigo"])) + return data + +top = reperio("--espaco", "D:\\", "--profundidade", "2", "--top", "10")["espaco"]["itens"] +for it in top: + print(it["tamanho"], it["caminho"]) +``` + +Para registrar em um servidor MCP ou numa *skill*, basta expor esse invólucro com os parâmetros +`pasta`, `acao` (`espaco`, `maiores`, `extensoes`, `buscar`, `treemap`) e `so_indice`. A autodescrição +de `--manifesto` já traz os nomes e as descrições dos argumentos para gerar o esquema da ferramenta. + +## Desempenho esperado + +Veja [BENCHMARK.md](BENCHMARK.md): números medidos pela própria CLI em discos reais (varredura a frio, +reverificação a quente e cada consulta ao índice). diff --git a/reperio.py b/reperio.py index fc5480a..6468fd3 100644 --- a/reperio.py +++ b/reperio.py @@ -18,8 +18,12 @@ 3. Toda comunicação thread -> GUI via queue.Queue + root.after (polling). 4. Treeview limpo com um único comando Tcl; contadores mantidos em Python. """ +import argparse +import colorsys import errno import fnmatch +import heapq +import json import os import platform import queue @@ -32,7 +36,7 @@ import time import traceback -__version__ = "2.6.0" +__version__ = "2.7.0" APP_NAME = "ReperIO" APP_TAGLINE = "busca, índice, exportação e cópia de arquivos em servidores" APP_URL = "https://github.com/Yiuky/ReperIO" @@ -979,6 +983,77 @@ def folder_space(self, root, exclude_dirs=(), stop=None): finally: c.close() + def _excl_function(self, c, root, exclude_dirs): + """Registra `gff_excl(caminho)` na conexão: 1 = pasta ignorada pelos filtros.""" + excluder = make_dir_excluder(exclude_dirs, root) if exclude_dirs else None + cache = {} + + def excl(dpath): + if excluder is None: + return 0 + r = cache.get(dpath) + if r is None: + r = cache[dpath] = 1 if excluder(db_dec(dpath)) else 0 + return r + c.create_function("gff_excl", 1, excl) + + def largest_files(self, root, n=20, exclude_dirs=()): + """Os `n` maiores arquivos indexados sob `root`: [(caminho, tamanho, mtime)].""" + self.flush() + c = self.connect() + try: + self._excl_function(c, root, exclude_dirs) + args = tuple(db_enc(a) for a in _under_args(root)) + cur = c.execute( + "SELECT d.path, f.name, f.size, f.mtime FROM files f JOIN dirs d ON d.id = f.dir_id " + "WHERE %s AND d.state = 1 AND f.size > 0 AND gff_excl(d.path) = 0 " + "ORDER BY f.size DESC LIMIT ?" % _UNDER, args + (max(1, int(n)),)) + return [(os.path.join(db_dec(dp), db_dec(nm)), sz, mt) for dp, nm, sz, mt in cur] + finally: + c.close() + + def extension_summary(self, root, top=20, exclude_dirs=()): + """Espaço por extensão sob `root`: [(extensão, arquivos, bytes)], do maior para o menor.""" + self.flush() + c = self.connect() + try: + self._excl_function(c, root, exclude_dirs) + c.create_function("gff_ext", 1, + lambda n: os.path.splitext(db_dec(n))[1].lower() or "(sem extensão)") + args = tuple(db_enc(a) for a in _under_args(root)) + cur = c.execute( + "SELECT gff_ext(f.name) AS e, count(*), " + "COALESCE(sum(CASE WHEN f.size > 0 THEN f.size ELSE 0 END), 0) AS s " + "FROM files f JOIN dirs d ON d.id = f.dir_id " + "WHERE %s AND d.state = 1 AND gff_excl(d.path) = 0 " + "GROUP BY e ORDER BY s DESC LIMIT ?" % _UNDER, args + (max(1, int(top)),)) + return [(e, n, s) for e, n, s in cur] + finally: + c.close() + + def stats(self): + """Resumo do próprio índice (para `--status`).""" + self.flush() + c = self.connect() + try: + n_done, n_pend, oldest, newest = c.execute( + "SELECT sum(state = 1), sum(state = 0), min(scanned), max(scanned) FROM dirs").fetchone() + n_files, n_bytes = c.execute( + "SELECT count(*), COALESCE(sum(CASE WHEN size > 0 THEN size ELSE 0 END), 0) " + "FROM files").fetchone() + n_prob = c.execute("SELECT count(*) FROM problems").fetchone()[0] + roots = [db_dec(r[0]) for r in c.execute( + "SELECT path FROM dirs WHERE parent IS NULL AND state = 1 ORDER BY path")] + try: + disk = os.path.getsize(self.path) + except OSError: + disk = 0 + return {"path": self.path, "dirs": n_done or 0, "pending_dirs": n_pend or 0, + "files": n_files, "bytes": n_bytes, "problems": n_prob, "roots": roots, + "oldest": oldest, "newest": newest, "disk_bytes": disk} + finally: + c.close() + def aggregate_folder_space(dir_stats, root, pending_dirs=0): """ Agrega o consumo de espaço de toda a árvore sob `root` em uma única passada. @@ -1127,6 +1202,306 @@ def summarize_rows_space(rows, root, exclude_dirs=()): stats = [(dp, cnt, tot) for dp, (cnt, tot) in by_dir.items()] return aggregate_folder_space(stats, root, pending_dirs=0) +# =========================================================================== +# Treemap: mapa de blocos proporcional ao tamanho ou à quantidade de arquivos +# =========================================================================== +# Reaproveita a agregação de `aggregate_folder_space` (já em memória): não faz +# nenhum I/O. O desenho (Tk ou HTML) só consome a lista de retângulos de +# `treemap_layout`. Algoritmo "squarified" (Bruls, Huizing e van Wijk, 1999): +# blocos o mais próximo possível de quadrados, fáceis de comparar a olho. +TREEMAP_METRICS = ("tamanho", "quantidade") + +def treemap_key(metric): + """'tamanho'/'size' -> 'size'; 'quantidade'/'files' -> 'files'.""" + return "files" if str(metric).lower() in ("quantidade", "files", "arquivos") else "size" + +def space_tree(space_info, folder_path=None, depth=2, max_children=40): + """ + Árvore aninhada para o treemap a partir de `aggregate_folder_space`: + {"name", "path", "size", "files", "dirs", "kind", "children"}, com `kind` em + "dir" | "files" (arquivos soltos na pasta) | "others" (itens pequenos agrupados). + `depth` = níveis de subpastas abertos; cada pasta mantém no máximo + `max_children` filhos por tamanho e por quantidade, e o resto vira "[outros]". + """ + nodes = space_info.get("nodes") or {} + key = path_key(folder_path) if folder_path else space_info.get("root_key") + if key not in nodes: + return None + + def build(k, d): + n = nodes[k] + t = {"name": n["name"], "path": n["path"], "size": n["total_size"], + "files": n["total_files"], "dirs": len(n["children"]), + "kind": "dir", "children": []} + if d <= 0: + return t + kids = [build(c, d - 1) for c in n["children"]] + if n["direct_files"] > 0: + kids.append({"name": "[arquivos desta pasta]", "path": n["path"], + "size": n["direct_size"], "files": n["direct_files"], + "dirs": 0, "kind": "files", "children": []}) + if len(kids) > max_children: + keep = {id(x) for x in sorted(kids, key=lambda x: x["size"], reverse=True)[:max_children]} + keep |= {id(x) for x in sorted(kids, key=lambda x: x["files"], reverse=True)[:max_children]} + rest = [x for x in kids if id(x) not in keep] + kids = [x for x in kids if id(x) in keep] + if rest: + kids.append({"name": "[outros %s itens]" % fmt_n(len(rest)), "path": n["path"], + "size": sum(x["size"] for x in rest), + "files": sum(x["files"] for x in rest), + "dirs": 0, "kind": "others", "children": []}) + t["children"] = kids + return t + return build(key, max(0, int(depth))) + +def _worst_ratio(row, side): + s = sum(row) + return max(side * side * max(row) / (s * s), (s * s) / (side * side * min(row))) + +def squarify(values, x, y, w, h): + """ + Divide o retângulo (x, y, w, h) em blocos de área proporcional a `values` + (ordem decrescente, todos > 0). Devolve [(x, y, w, h)] na mesma ordem. + """ + n = len(values) + total = float(sum(values)) + if n == 0 or total <= 0 or w <= 0 or h <= 0: + return [(x, y, 0.0, 0.0)] * n + scale = w * h / total + areas = [v * scale for v in values] + rects = [None] * n + i = 0 + while i < n: + side = min(w, h) + row = [areas[i]] + j = i + 1 + while j < n and _worst_ratio(row + [areas[j]], side) <= _worst_ratio(row, side): + row.append(areas[j]) + j += 1 + s = sum(row) + if w >= h: # coluna à esquerda + cw = s / h + cy = y + for k, a in enumerate(row): + ch = a / cw if cw > 0 else 0.0 + rects[i + k] = (x, cy, cw, ch) + cy += ch + x += cw + w -= cw + else: # linha em cima + rh = s / w + cx = x + for k, a in enumerate(row): + cw2 = a / rh if rh > 0 else 0.0 + rects[i + k] = (cx, y, cw2, rh) + cx += cw2 + y += rh + h -= rh + i = j + return rects + +def treemap_layout(tree, metric, width, height, header=16, pad=2, min_px=3): + """ + Posiciona `tree` (de `space_tree`) em `width` x `height` pixels. `metric`: + "tamanho" ou "quantidade". Devolve retângulos em ordem de desenho (a pasta + antes de seus filhos): {"x", "y", "w", "h", "depth", "hue", "node", "leaf"}. + `header` reserva uma faixa para o nome da pasta; blocos menores que + `min_px` em qualquer lado não são desenhados. + """ + key = treemap_key(metric) + out = [] + + def place(t, x, y, w, h, depth, hue): + r = {"x": x, "y": y, "w": w, "h": h, "depth": depth, "hue": hue, + "node": t, "leaf": True} + out.append(r) + kids = [c for c in t["children"] if c[key] > 0] + if not kids or w < 24 or h < 18: + return + top = header if (h >= header * 2.2 and w >= 50) else 0 + ix, iy = x + pad, y + pad + top + iw, ih = w - 2 * pad, h - 2 * pad - top + if iw <= min_px or ih <= min_px: + return + kids.sort(key=lambda c: c[key], reverse=True) + for i, (c, (cx, cy, cw, ch)) in enumerate( + zip(kids, squarify([c[key] for c in kids], ix, iy, iw, ih))): + if cw < min_px or ch < min_px: + continue + r["leaf"] = False + place(c, cx, cy, cw, ch, depth + 1, + (i * 0.61803398875) % 1.0 if depth == 0 else hue) + place(tree, 0.0, 0.0, float(width), float(height), 0, 0.0) + return out + +def treemap_rgb(hue, depth, kind="dir"): + """Cor (r, g, b) de um bloco: matiz por pasta de 1º nível, mais claro a cada nível.""" + if kind == "others": + return (92, 96, 104) + if kind == "files": + s, v = 0.12, 0.55 + 0.04 * min(depth, 4) + else: + s = max(0.25, 0.62 - 0.08 * depth) + v = min(0.92, 0.50 + 0.09 * depth) + r, g, b = colorsys.hsv_to_rgb(hue, s, v) + return int(r * 255), int(g * 255), int(b * 255) + +def _text_on(rgb): + return "#101418" if (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) > 150 else "#ffffff" + +def treemap_tip(node, root_total, root_files, metric): + """Texto de dica de um bloco (tooltip do HTML e barra de status do Tk).""" + base = root_files if treemap_key(metric) == "files" else root_total + val = node["files"] if treemap_key(metric) == "files" else node["size"] + pct = (val * 100.0 / base) if base > 0 else 0.0 + return "%s — %s · %s arquivo(s) · %.1f%% do total (%s)" % ( + node["name"], fsize(node["size"]), fmt_n(node["files"]), pct, + "por quantidade" if treemap_key(metric) == "files" else "por tamanho") + +def _svg_label(text, w, size=11): + cap = int((w - 8) / (size * 0.58)) + if cap < 3: + return "" + return text if len(text) <= cap else text[:cap - 1] + "…" + +def render_treemap_svg(tree, metric, width=1200, height=720, header=16): + """SVG do treemap (dicas nativas via , sem JavaScript).""" + key = treemap_key(metric) + rects = treemap_layout(tree, metric, width, height, header=header) + root_val = tree[key] or 1 + parts = ['<svg viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg" ' + 'font-family="Segoe UI, system-ui, sans-serif" font-size="11" role="img">' + % (width, height)] + for r in rects: + if r["depth"] == 0: + continue + t = r["node"] + rgb = treemap_rgb(r["hue"], r["depth"], t["kind"]) + ink = _text_on(rgb) + x, y, w, h = r["x"], r["y"], r["w"], r["h"] + tip = "%s\n%s" % (t["path"], treemap_tip(t, tree["size"], tree["files"], metric)) + parts.append('<g><title>%s' + % (_xml(tip), x, y, w, h, rgb[0], rgb[1], rgb[2])) + if r["leaf"] and w >= 56 and h >= 30: + parts.append('%s' + '%s' + % (x + 5, y + 15, ink, _xml(_svg_label(t["name"], w)), + x + 5, y + 29, ink, _xml(_svg_label( + fsize(t["size"]) if key == "size" else "%s arq." % fmt_n(t["files"]), w)))) + elif not r["leaf"] and w >= 56 and h >= header * 2.2: + parts.append('%s' + % (x + 5, y + 12, ink, _xml(_svg_label( + "%s · %s" % (t["name"], fsize(t["size"]) if key == "size" + else "%s arq." % fmt_n(t["files"])), w)))) + parts.append("") + parts.append("") + return "".join(parts) + +_TREEMAP_HTML = """ + + + + +@@TITLE@@ + + + +
+

@@TITLE@@

+

@@ROOT@@

+
+
@@TOTAL@@espaço ocupado
+
@@FILES@@arquivos
+
@@DIRS@@pastas lidas@@PENDING@@
+
+
+Dimensionar blocos por: + + +Passe o mouse sobre um bloco para ver caminho, tamanho e participação. +
+
@@SVG_SIZE@@
+
@@SVG_FILES@@
+ + +@@ROWS@@ +
Maiores itensTamanho% do totalArquivos
+
Gerado pelo ReperIO @@VERSION@@ em @@WHEN@@. Arquivo único, sem dependências externas.
+
+ + + +""" + +def render_treemap_html(space_info, folder_path=None, depth=3, title=None, top=25, primary="tamanho"): + """Página HTML autônoma com o treemap (por tamanho e por quantidade) e a tabela dos maiores.""" + tree = space_tree(space_info, folder_path, depth=depth) + if tree is None: + raise ValueError("pasta não encontrada nos dados de espaço: %s" % folder_path) + items = folder_space_children(space_info, tree["path"])[:top] + rows = [] + for it in items: + pct = it["pct_root"] if tree["path"] == space_info["root"] else it["pct"] + rows.append('%s%s%.1f%% ' + '%s' % (_xml(it["name"]), + _xml(fsize(it["total_size"])), pct, + max(1, int(pct * 0.8)), fmt_n(it["total_files"]))) + pend = space_info.get("pending_dirs") or 0 + by_files = treemap_key(primary) == "files" + repl = { + "TITLE": _xml(title or "Treemap de espaço — %s" % tree["name"]), + "ROOT": _xml(tree["path"]), + "TOTAL": _xml(fsize(tree["size"])), + "FILES": _xml(fmt_n(tree["files"])), + "DIRS": _xml(fmt_n(space_info.get("scanned_dirs", 0))), + "PENDING": _xml(" (+%s ainda sem leitura)" % fmt_n(pend)) if pend else "", + "SVG_SIZE": render_treemap_svg(tree, "tamanho"), + "SVG_FILES": render_treemap_svg(tree, "quantidade"), + "ROWS": "".join(rows), + "CHK_SIZE": "" if by_files else " checked", "CHK_FILES": " checked" if by_files else "", + "HID_SIZE": " hidden" if by_files else "", "HID_FILES": "" if by_files else " hidden", + "VERSION": "v" + __version__, + "WHEN": _xml(time.strftime("%d/%m/%Y %H:%M")), + } + html = _TREEMAP_HTML + for k, v in repl.items(): + html = html.replace("@@%s@@" % k, v) + return html + +def export_treemap_html(space_info, dest, folder_path=None, depth=3, title=None, primary="tamanho"): + """Grava o treemap em `dest` (.html, UTF-8). Devolve o número de blocos de 1º nível.""" + html = render_treemap_html(space_info, folder_path, depth=depth, title=title, primary=primary) + with open(dest, "w", encoding="utf-8", newline="\n") as f: + f.write(html) + return len(folder_space_children(space_info, folder_path)) + # =========================================================================== # Canais: várias conexões SMB independentes com o mesmo servidor # =========================================================================== @@ -2665,8 +3040,8 @@ def _win(self): self.root.iconphoto(True, self._icon) except tk.TclError: pass # Tk sem suporte a PNG (muito antigo): fica o ícone padrão - self.root.geometry("1100x700") - self.root.minsize(900, 560) + self.root.geometry("1100x820") + self.root.minsize(900, 620) self.root.configure(bg=self.BG) def _fonts(self): @@ -2813,6 +3188,7 @@ def row(r, lbl, var, hint=""): self.prog_s = ttk.Progressbar(f, mode="indeterminate", length=120, style="Accent.Horizontal.TProgressbar") self.prog_s.pack(side="left", padx=(12, 0)) + self.prog_s.pack_forget() self.root.bind("", lambda _e: self._start()) e_root.focus_set() @@ -2826,7 +3202,7 @@ def _results(self, p): self.lbl_cnt.pack(side="left", padx=(8, 0), pady=5) self.b_export = ttk.Button(tb, text="Exportar...", style="Primary.TButton", command=self._export) self.b_export.pack(side="right", padx=(3, 8), pady=5) - self.b_space = ttk.Button(tb, text="Espaço por pasta...", style="Secondary.TButton", + self.b_space = ttk.Button(tb, text="Espaço e treemap...", style="Secondary.TButton", command=self._open_space) self.b_space.pack(side="right", padx=3, pady=5) for t, c in [("Inverter", self._invert), @@ -2977,6 +3353,7 @@ def _start(self): self._stop_s.set() self._running = True self.b_stop.config(state="normal") + self.prog_s.pack(side="left", padx=(12, 0)) self.prog_s.start(12) self._st("Iniciando busca...", self.WARN) params = { @@ -3155,6 +3532,7 @@ def _done_s(self): self.b_start.config(state="normal") self.b_stop.config(state="disabled") self.prog_s.stop() + self.prog_s.pack_forget() extra = "" if self._sthread is not None and self._sthread.summary: extra = " — origem: " + self._sthread.summary @@ -3450,8 +3828,8 @@ def __init__(self, app, initial_dir=""): self.app = app self.win = tk.Toplevel(app.root) self.win.title("Espaço por pasta — %s" % APP_NAME) - self.win.geometry("980x580") - self.win.minsize(780, 420) + self.win.geometry("1260x760") + self.win.minsize(900, 480) self.win.configure(bg=app.BG) self._closed = False self._q = queue.Queue() @@ -3464,6 +3842,14 @@ def __init__(self, app, initial_dir=""): self._root_total = {"total_size": 0, "total_files": 0} self._scol = "size" self._srev = True + self._sp = None # agregação completa (nodes) da última consulta, para o treemap + self._tm_on = True # painel do treemap visível junto da tabela + self._tm_metric = "tamanho" + self._tm_rects = [] + self._tm_sel_key = None # (caminho, tipo) do bloco selecionado + self._tm_hover_key = None + self._tm_after = None + self._tm_last_zoom = 0.0 self._build(initial_dir) self.win.protocol("WM_DELETE_WINDOW", self._close) self._poll() @@ -3502,10 +3888,21 @@ def _build(self, initial_dir): self.prog = ttk.Progressbar(bf, mode="indeterminate", length=110, style="Accent.Horizontal.TProgressbar") self.prog.pack(side="left", padx=(8, 8)) + self.prog.pack_forget() self.b_export = ttk.Button(bf, text="Exportar...", style="Primary.TButton", command=self._export) self.b_export.pack(side="right", padx=(4, 0)) + self.f_metric = ttk.Frame(bf, style="Panel.TFrame") + self.f_metric.pack(side="right", padx=(4, 8)) + ttk.Label(self.f_metric, text="Blocos por:", style="Panel.TLabel").pack(side="left", padx=(0, 4)) + self.v_metric = tk.StringVar(value="tamanho") + for txt, val in (("Tamanho", "tamanho"), ("Quantidade", "quantidade")): + ttk.Radiobutton(self.f_metric, text=txt, value=val, variable=self.v_metric, + style="Dark.TRadiobutton", command=self._set_metric).pack(side="left", padx=2) + self.b_tm = ttk.Button(bf, text="Ocultar treemap", style="Secondary.TButton", + command=self._toggle_treemap) + self.b_tm.pack(side="right", padx=4) ttk.Button(bf, text="Abrir no sistema", style="Secondary.TButton", command=self._open_selected).pack(side="right", padx=4) ttk.Button(bf, text="Usar como Pasta Raiz", style="Secondary.TButton", @@ -3519,11 +3916,28 @@ def _build(self, initial_dir): sf, text=" Informe uma pasta para ver o espaço ocupado por cada subpasta.", style="Counter.TLabel") self.lbl_sum.pack(side="left", padx=8, pady=5) - ttk.Label(sf, text="Duplo clique em uma subpasta para entrar nela ", + ttk.Label(sf, text="Cor = pasta de 1º nível · cinza = arquivos soltos · roda do mouse entra/sobe · botão direito sobe ", style="Sub.TLabel").pack(side="right", padx=8, pady=5) - tf = ttk.Frame(self.win, style="Dark.TFrame") - tf.pack(fill="both", expand=True, padx=4, pady=4) + self.pw = ttk.PanedWindow(self.win, orient="vertical") + self.pw.pack(fill="both", expand=True, padx=4, pady=4) + self._tm_frame = ttk.Frame(self.pw, style="Dark.TFrame") + self.canvas = tk.Canvas(self._tm_frame, bg=a.BG, highlightthickness=0, cursor="hand2", + takefocus=True) + self.canvas.pack(fill="both", expand=True) + self.canvas.bind("", lambda _e: self._tm_schedule()) + self.canvas.bind("", self._tm_motion) + self.canvas.bind("", self._tm_leave) + self.canvas.bind("", self._tm_click) + self.canvas.bind("", self._tm_double) + self.canvas.bind("", lambda _e: self.go_up()) + self.canvas.bind("", lambda _e: self.go_up()) + self.canvas.bind("", lambda e: self._tm_zoom(e, 1 if e.delta > 0 else -1)) + self.canvas.bind("", lambda e: self._tm_zoom(e, 1)) + self.canvas.bind("", lambda e: self._tm_zoom(e, -1)) + self.pw.add(self._tm_frame, weight=3) + tf = ttk.Frame(self.pw, style="Dark.TFrame") + self.pw.add(tf, weight=2) tf.rowconfigure(0, weight=1) tf.columnconfigure(0, weight=1) self.tree = ttk.Treeview(tf, columns=self.COLS, show="headings", @@ -3544,6 +3958,8 @@ def _build(self, initial_dir): vsb.grid(row=0, column=1, sticky="ns") hsb.grid(row=1, column=0, sticky="ew") self.tree.bind("", self._on_double_click) + self.tree.bind("<>", self._on_tree_select) + self._tf = tf self.lbl_st = ttk.Label(self.win, text=" Pronto.", style="Status.TLabel") self.lbl_st.pack(fill="x", side="bottom", padx=4, pady=3) @@ -3614,6 +4030,7 @@ def scan_dir(self): self._scan_stop = threading.Event() self.b_scan.config(state="disabled") self.b_stop.config(state="normal") + self.prog.pack(side="left", padx=(8, 8), after=self.b_stop) self.prog.start(12) self.lbl_st.config(text=" Mapeando %s..." % norm, foreground=self.app.WARN) idx = self.app._index @@ -3672,7 +4089,201 @@ def stop_scan(self): self.lbl_st.config(text=" Parando mapeamento (o que já foi lido fica salvo no índice)...", foreground=self.app.WARN) + # -- Treemap (painel interativo, estilo SpaceSniffer) -------------------- + # Só desenha dados que já estão em memória (`self._sp`): nenhum I/O na GUI. + # passar o mouse .. destaca o bloco e mostra uma dica flutuante + # clique ........... seleciona (e marca a linha da tabela, e vice-versa) + # duplo clique ..... entra na pasta; roda do mouse para cima = entra, para baixo = sobe + # botão direito .... sobe um nível + def _toggle_treemap(self): + self._tm_on = not self._tm_on + if self._tm_on: + self.pw.insert(0, self._tm_frame, weight=3) + self.b_tm.config(text="Ocultar treemap") + self.f_metric.pack(side="right", padx=(4, 8)) + self._tm_schedule() + else: + self.pw.forget(self._tm_frame) + self.b_tm.config(text="Mostrar treemap") + self.f_metric.pack_forget() + + def _set_metric(self): + self._tm_metric = self.v_metric.get() + self._tm_redraw() + + def _cycle_metric(self): + self.v_metric.set("quantidade" if self._tm_metric == "tamanho" else "tamanho") + self._set_metric() + + def _tm_schedule(self): + if self._tm_after is not None: + try: + self.win.after_cancel(self._tm_after) + except tk.TclError: + pass + self._tm_after = self.win.after(80, self._tm_redraw) + + @staticmethod + def _tm_hex(rgb): + return "#%02x%02x%02x" % rgb + + @staticmethod + def _tm_key(r): + return (r["node"]["path"], r["node"]["kind"]) + + def _tm_find(self, key): + if key is None: + return None + for r in self._tm_rects: + if r["depth"] and self._tm_key(r) == key: + return r + return None + + def _tm_redraw(self): + self._tm_after = None + if self._closed or not self._tm_on: + return + c = self.canvas + c.delete("all") + self._tm_rects = [] + sp = self._sp + w, h = c.winfo_width(), c.winfo_height() + tree = space_tree(sp, self._cur_dir, depth=3) if (sp and sp.get("nodes") and self._cur_dir) else None + if tree is None or w < 40 or h < 40 or (tree["size"] <= 0 and tree["files"] <= 0): + c.create_text(w // 2, h // 2, fill=self.app.WARN, font=("Segoe UI", 11), + text="Sem dados para o treemap: consulte o índice ou mapeie a pasta.") + return + rects = treemap_layout(tree, self._tm_metric, w - 2, h - 2) + self._tm_rects = rects + by_files = treemap_key(self._tm_metric) == "files" + for r in rects: + if r["depth"] == 0: + continue + t = r["node"] + rgb = treemap_rgb(r["hue"], r["depth"], t["kind"]) + x, y, rw, rh = r["x"] + 1, r["y"] + 1, r["w"], r["h"] + c.create_rectangle(x, y, x + rw, y + rh, fill=self._tm_hex(rgb), + outline="#0d1117", width=1) + ink = _text_on(rgb) + val = ("%s arq." % fmt_n(t["files"])) if by_files else fsize(t["size"]) + if r["leaf"] and rw >= 56 and rh >= 32: + c.create_text(x + 5, y + 4, anchor="nw", fill=ink, font=("Segoe UI", 9), + text="%s\n%s" % (_svg_label(t["name"], rw, 9), _svg_label(val, rw, 9))) + elif not r["leaf"] and rw >= 56 and rh >= 35: + c.create_text(x + 5, y + 2, anchor="nw", fill=ink, font=("Segoe UI", 9, "bold"), + text=_svg_label("%s · %s" % (t["name"], val), rw, 9)) + self._tm_overlay() + self.lbl_st.config(text=" Treemap por %s de %s — passe o mouse, clique, role a roda para entrar/subir." + % (self._tm_metric, self._cur_dir), foreground=self.app.OK) + + def _tm_overlay(self): + """Contornos de seleção e de passagem do mouse, sempre por cima dos blocos.""" + c = self.canvas + c.delete("ov") + for key, color, width in ((self._tm_hover_key, "#ffd54a", 2), (self._tm_sel_key, "#ffffff", 3)): + r = self._tm_find(key) + if r is not None: + c.create_rectangle(r["x"] + 1, r["y"] + 1, r["x"] + 1 + r["w"], r["y"] + 1 + r["h"], + outline=color, width=width, tags="ov") + + def _tm_hit(self, x, y): + for r in reversed(self._tm_rects): # o último desenhado é o mais profundo + if r["depth"] and r["x"] + 1 <= x <= r["x"] + 1 + r["w"] and r["y"] + 1 <= y <= r["y"] + 1 + r["h"]: + return r + return None + + def _tm_tip(self, r): + sp = self._sp + return treemap_tip(r["node"], sp["total_size"], sp["total_files"], self._tm_metric) + + def _tm_show_tip(self, event, r): + c = self.canvas + c.delete("tip") + node = r["node"] + txt = "%s\n%s" % (node["path"] if node["kind"] == "dir" else "%s (%s)" % (node["name"], node["path"]), + self._tm_tip(r).split(" — ", 1)[1]) + t = c.create_text(0, 0, text=txt, anchor="nw", fill="#e6edf3", font=("Segoe UI", 9), tags="tip") + x0, y0, x1, y1 = c.bbox(t) + wd, ht = x1 - x0 + 12, y1 - y0 + 8 + x = min(event.x + 16, max(0, c.winfo_width() - wd - 2)) + y = min(event.y + 18, max(0, c.winfo_height() - ht - 2)) + c.coords(t, x + 6, y + 4) + bg = c.create_rectangle(x, y, x + wd, y + ht, fill="#1f2630", outline="#8b949e", tags="tip") + c.tag_lower(bg, t) + c.tag_raise("tip") + + def _tm_motion(self, event): + r = self._tm_hit(event.x, event.y) + key = self._tm_key(r) if r is not None else None + if key != self._tm_hover_key: + self._tm_hover_key = key + self._tm_overlay() + if r is None: + self.canvas.delete("tip") + return + self._tm_show_tip(event, r) + self.lbl_st.config(text=" " + self._tm_tip(r), foreground=self.app.OK) + + def _tm_leave(self, _event): + self._tm_hover_key = None + self.canvas.delete("tip") + self._tm_overlay() + + def _tm_select_key(self, key, sync_table=True): + self._tm_sel_key = key + self._tm_overlay() + if not sync_table or key is None: + return + target = None # acompanha a seleção na tabela: + for iid, it in self._rdata.items(): # a linha exata, senão a pasta que a contém + if it["path"] == key[0] and ("files" if it.get("is_self") else "dir") == key[1]: + target = iid + break + if target is None and not it.get("is_self") and is_under(path_key(key[0]), path_key(it["path"])): + target = iid + if target is not None: + if self.tree.selection() != (target,): + self.tree.selection_set(target) + self.tree.see(target) + + def _on_tree_select(self, _event=None): + sel = self.tree.selection() + if not sel or sel[0] not in self._rdata: + return + it = self._rdata[sel[0]] + key = (it["path"], "files" if it.get("is_self") else "dir") + if key != self._tm_sel_key: + self._tm_select_key(key, sync_table=False) + + def _tm_click(self, event): + self.canvas.focus_set() + r = self._tm_hit(event.x, event.y) + if r is None: + return + self._tm_select_key(self._tm_key(r)) + self.lbl_st.config(text=" " + self._tm_tip(r) + " · " + r["node"]["path"], foreground=self.app.OK) + + def _tm_double(self, event): + r = self._tm_hit(event.x, event.y) + # blocos "[arquivos desta pasta]" e "[outros]" pertencem à pasta do caminho: entra nela + if r is not None and path_key(r["node"]["path"]) != path_key(self._cur_dir): + self.load_dir(r["node"]["path"]) + + def _tm_zoom(self, event, direction): + """Roda para cima entra na pasta sob o mouse; para baixo sobe um nível.""" + now = time.time() + if now - self._tm_last_zoom < 0.3: + return + self._tm_last_zoom = now + if direction < 0: + self.go_up() + return + r = self._tm_hit(event.x, event.y) + if r is not None and path_key(r["node"]["path"]) != path_key(self._cur_dir): + self.load_dir(r["node"]["path"]) + def _populate(self, norm, items, rt, origin): + self._sp = rt try: tw = self.tree._w self.tree.tk.eval("catch {%s delete [%s children {}]}" % (tw, tw)) @@ -3699,6 +4310,8 @@ def _populate(self, norm, items, rt, origin): text=" Total em %s: %s em %d arquivo(s) | %d item(ns)" % (os.path.basename(norm) or norm, fsize(rt["total_size"]), rt["total_files"], len(self._rows))) + if self._tm_on: + self._tm_redraw() if not self._scanning: self.lbl_st.config( text=" Espaço calculado (%s): %s em %d arquivo(s)." @@ -3737,6 +4350,7 @@ def _poll(self): self.b_scan.config(state="normal") self.b_stop.config(state="disabled") self.prog.stop() + self.prog.pack_forget() self._populate(norm, items, rt, "mapeamento pausado" if stopped else "índice atualizado") self.lbl_st.config( text=" Mapeamento %s: %s em %d arquivo(s)." @@ -3814,20 +4428,25 @@ def _export(self): defaultextension=".xlsx", filetypes=[("Planilha Excel", "*.xlsx"), ("CSV (Excel, LibreOffice, QGIS)", "*.csv"), - ("Texto formatado", "*.txt")]) + ("Texto formatado", "*.txt"), + ("Treemap (página HTML)", "*.html")]) if not dest: return dest = os.path.normpath(dest) - if os.path.splitext(dest)[1].lower() not in EXPORT_FORMATS: + if os.path.splitext(dest)[1].lower() not in tuple(EXPORT_FORMATS) + (".html",): dest += ".xlsx" self.b_export.config(state="disabled") self.lbl_st.config(text=" Exportando %d linha(s) para %s..." % (len(rows), dest), foreground=self.app.WARN) done_q = queue.Queue() + sp_now, cur_now = self._sp, self._cur_dir def work(): try: - n = export_space_results(rows, dest) + if dest.lower().endswith(".html"): + n = export_treemap_html(sp_now, dest, cur_now, primary=self._tm_metric) + else: + n = export_space_results(rows, dest) done_q.put((n, None)) except Exception as e: done_q.put((0, str(e))) @@ -3877,117 +4496,504 @@ def _hide_own_console(): except Exception: pass +# =========================================================================== +# Linha de comando (modo sem janela), pensada para pessoas, scripts e IAs +# =========================================================================== +CLI_ACTIONS = ("indexar", "espaco", "buscar", "maiores", "extensoes", "status", + "treemap", "manifesto") +EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_INTERRUPTED = 0, 1, 2, 3, 130 + +_CLI_EPILOG = """\ +exemplos: + reperio --espaco D:\\ --top 20 espaço por pasta (mapeia se o índice não cobrir) + reperio --espaco D:\\ --json --profundidade 2 o mesmo em JSON, 2 níveis de subpastas + reperio --indexar C:\\ --ignorar OneDrive (re)indexa C:\\ sem entrar no OneDrive + reperio --maiores 30 --em C:\\Users\\eu --so-indice 30 maiores arquivos, só consultando o índice + reperio --extensoes 15 --em D:\\ espaço por extensão + reperio --buscar "*.zip" --em D:\\ --tamanho "> 1 GB" --json + reperio --espaco D:\\ --treemap mapa.html gera o treemap (HTML autônomo) + reperio --status o que o índice já conhece + reperio --manifesto descrição legível por máquina (para IAs) + +códigos de saída: 0 ok · 1 erro · 2 uso incorreto · 3 pasta inexistente/sem dados no índice · 130 interrompido +Sem nenhuma ação, abre a janela. Documentação: docs/CLI.md e docs/IA.md.""" + +def _build_parser(): + p = argparse.ArgumentParser( + prog="reperio", add_help=False, allow_abbrev=False, + formatter_class=argparse.RawDescriptionHelpFormatter, + description="%s %s — busca, índice, espaço por pasta e treemap de arquivos." % (APP_NAME, __version__), + epilog=_CLI_EPILOG) + a = p.add_argument_group("ações (sem nenhuma, abre a janela)") + a.add_argument("--indexar", metavar="PASTA", help="lê PASTA no disco/servidor e grava no índice") + a.add_argument("--espaco", metavar="PASTA", help="espaço ocupado por subpasta (usa o índice; mapeia se faltar)") + a.add_argument("--buscar", metavar="PADRÃO", help="arquivos por nome ('contém' ou curingas * ?) no índice") + a.add_argument("--maiores", metavar="N", type=int, help="os N maiores arquivos") + a.add_argument("--extensoes", metavar="N", type=int, nargs="?", const=20, + help="espaço por extensão (as N maiores, padrão 20)") + a.add_argument("--treemap", metavar="ARQ.html", help="grava o treemap em HTML autônomo") + a.add_argument("--status", action="store_true", help="resumo do índice (pastas, arquivos, raízes, tamanho)") + a.add_argument("--manifesto", action="store_true", + help="imprime em JSON tudo que a CLI faz (argumentos, saídas, códigos) — para IAs") + f = p.add_argument_group("alvo e filtros") + f.add_argument("--em", metavar="PASTA", help="pasta-alvo de --buscar/--maiores/--extensoes/--treemap " + "(padrão: a de --espaco ou --indexar)") + f.add_argument("--ignorar", metavar="LISTA", default="", + help="pastas a ignorar: nomes, curingas ou subcaminhos ('_backup, node_modules, temp_*')") + f.add_argument("--ext", metavar="LISTA", default="", help="--buscar: extensões ('shp tif')") + f.add_argument("--tamanho", metavar="FILTRO", default="", + help="--buscar: filtro de tamanho ('> 100 MB', '10MB..500MB')") + f.add_argument("--maiusculas", action="store_true", help="--buscar: diferencia maiúsculas de minúsculas") + f.add_argument("--limite", metavar="N", type=int, default=100, help="--buscar: máximo de resultados (padrão 100)") + f.add_argument("--ordenar", choices=("tamanho", "data", "nome"), default="tamanho", + help="--buscar: ordem dos resultados (padrão tamanho)") + o = p.add_argument_group("saída") + o.add_argument("--json", action="store_true", + help="resultado em UM objeto JSON no stdout (ASCII); mensagens vão para o stderr") + o.add_argument("--top", metavar="N", type=int, default=30, help="--espaco: itens por nível (padrão 30)") + o.add_argument("--profundidade", metavar="N", type=int, default=None, + help="níveis de subpastas: --espaco (padrão 1) e --treemap (padrão 3)") + o.add_argument("--treemap-por", dest="treemap_por", choices=TREEMAP_METRICS, default="tamanho", + help="métrica do treemap exibida primeiro no HTML (ambas ficam no arquivo)") + o.add_argument("--exportar", metavar="ARQ", help="--espaco: grava a tabela em .xlsx, .csv ou .txt") + o.add_argument("-q", "--silencioso", action="store_true", help="sem mensagens de progresso") + c = p.add_argument_group("índice e execução") + c.add_argument("--indice", "--index", dest="indice", metavar="ARQ", default=INDEX_PATH, + help="arquivo SQLite do índice (padrão: %s)" % INDEX_PATH.replace("%", "%%")) + c.add_argument("--no-index", "--sem-indice", dest="no_index", action="store_true", + help="não usa índice (tudo em memória; só --espaco e --treemap)") + c.add_argument("--atualizar", action="store_true", + help="relê no disco as pastas alteradas mesmo que o índice já cubra a pasta-alvo") + c.add_argument("--so-indice", dest="so_indice", action="store_true", + help="nunca varre o disco: sem dados no índice, termina com código 3") + h = p.add_argument_group("ajuda") + h.add_argument("-h", "--help", "--ajuda", action="help", help="mostra esta ajuda") + h.add_argument("--version", action="version", version="%s %s" % (APP_NAME, __version__), + help="mostra a versão") + return p + +def _safe_stdio(): + """Console em codificação antiga (cp1252/cp850) não pode derrubar a CLI ao imprimir + um nome de arquivo com caracteres fora dela: troca por '?' em vez de lançar exceção.""" + for s in (sys.stdout, sys.stderr): + try: + s.reconfigure(errors="replace") + except (AttributeError, ValueError): + pass + def _parse_cli(argv): - """Interpreta argumentos de linha de comando sem impedir os flags existentes.""" - opts = { - "index_path": INDEX_PATH, - "no_index": "--no-index" in argv, - "index_dir": None, - "space_dir": None, - "export": None, - "excludes": (), - "refresh": "--atualizar" in argv, - "top": 30, - } - i = 0 - while i < len(argv): - a = argv[i] - if a in ("--indice", "--index") and i + 1 < len(argv): - opts["index_path"] = os.path.normpath(argv[i + 1]) - i += 2 - elif a == "--indexar" and i + 1 < len(argv): - opts["index_dir"] = os.path.normpath(argv[i + 1]) - i += 2 - elif a == "--espaco" and i + 1 < len(argv): - opts["space_dir"] = os.path.normpath(argv[i + 1]) - i += 2 - elif a == "--exportar" and i + 1 < len(argv): - opts["export"] = os.path.normpath(argv[i + 1]) - i += 2 - elif a == "--ignorar" and i + 1 < len(argv): - opts["excludes"] = parse_excludes(argv[i + 1]) - i += 2 - elif a == "--top" and i + 1 < len(argv): - try: - opts["top"] = max(1, int(argv[i + 1])) - except ValueError: - pass - i += 2 - else: - i += 1 + """Interpreta a linha de comando. Argumento desconhecido = erro de uso (código 2), + nunca abre a janela por engano.""" + parser = _build_parser() + ns = parser.parse_args(argv) + opts = dict(vars(ns)) + opts["index_path"] = os.path.normpath(ns.indice) + opts["excludes"] = tuple(parse_excludes(ns.ignorar)) + opts["refresh"] = ns.atualizar + opts["actions"] = [k for k in CLI_ACTIONS if getattr(ns, k) not in (None, False)] + root = ns.em or ns.espaco or ns.indexar + opts["root"] = os.path.normpath(root) if root else None + opts["index_dir"] = os.path.normpath(ns.indexar) if ns.indexar else None + opts["space_dir"] = os.path.normpath(ns.espaco) if ns.espaco else None + opts["export"] = os.path.normpath(ns.exportar) if ns.exportar else None + opts["_parser"] = parser + acts = opts["actions"] + if not acts: + extra = [k for k in ("em", "ext", "tamanho", "json", "profundidade", "exportar", + "so_indice", "atualizar", "maiusculas") if getattr(ns, k) not in (None, False, "")] + if extra: + parser.error("nenhuma ação informada para: --%s (veja --ajuda)" % ", --".join(extra)) + return opts + needs_root = {"buscar", "maiores", "extensoes", "treemap"} & set(acts) + if needs_root and not opts["root"]: + parser.error("--%s precisa de uma pasta: use --em PASTA (ou --espaco/--indexar)" % sorted(needs_root)[0]) + if opts["no_index"] and ({"buscar", "maiores", "extensoes", "status"} & set(acts)): + parser.error("--no-index só vale para --espaco e --treemap") + if opts["no_index"] and ns.so_indice: + parser.error("--no-index e --so-indice são contraditórios") + if ns.exportar and "espaco" not in acts: + parser.error("--exportar vale para --espaco") + for name in ("top", "limite"): + if getattr(ns, name) < 1: + parser.error("--%s precisa ser maior que zero" % name) + if ns.maiores is not None and ns.maiores < 1: + parser.error("--maiores precisa ser maior que zero") + if ns.profundidade is not None and ns.profundidade < 1: + parser.error("--profundidade precisa ser maior que zero") return opts -def _run_cli(opts): - """Modo de linha de comando (sem abrir janela): `--indexar` e/ou `--espaco`.""" - rotate_log() - index = None - if not opts["no_index"]: - index = Index(opts["index_path"]) +def _peak_ram_mb(): + """Pico de memória do processo em MB (None se o sistema não informar).""" try: - scan_target = opts["index_dir"] - if not scan_target and opts["space_dir"]: - if index is None: - scan_target = opts["space_dir"] - elif opts["refresh"]: - scan_target = opts["space_dir"] - else: - sp = index.folder_space(opts["space_dir"], exclude_dirs=opts["excludes"]) - if not sp or (sp["scanned_dirs"] == 0 and sp["total_files"] == 0): - scan_target = opts["space_dir"] - mem_rows = [] - if scan_target: - t0 = time.time() - q = queue.Queue(maxsize=4000) - stop = threading.Event() - st = SearchThread(scan_target, "*", set(), False, q, stop, - fast_mode=True, index=index, refresh=True, - exclude_dirs=opts["excludes"], want_meta=True) - st.start() - count = 0 - while True: - item = q.get() - if item is SearchThread.DONE: + if IS_WIN: + import ctypes + from ctypes import wintypes + + class _PMC(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), + ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)] + k32 = ctypes.WinDLL("kernel32") + k32.GetCurrentProcess.restype = wintypes.HANDLE + psapi = ctypes.WinDLL("psapi") + psapi.GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(_PMC), wintypes.DWORD] + pmc = _PMC() + pmc.cb = ctypes.sizeof(pmc) + if psapi.GetProcessMemoryInfo(k32.GetCurrentProcess(), ctypes.byref(pmc), pmc.cb): + return round(pmc.PeakWorkingSetSize / 1048576.0, 1) + return None + import resource + peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return round(peak / (1048576.0 if sys.platform == "darwin" else 1024.0), 1) + except Exception: + return None + +def _iso(t): + if t is None or t < 0: + return None + try: + return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(t)) + except (ValueError, OSError, OverflowError): + return None + +def space_item_to_dict(it): + """Item de `folder_space_children` no formato do JSON da CLI.""" + return { + "nome": it["name"], "caminho": it["path"], + "tipo": "arquivos_diretos" if it.get("is_self") else "pasta", + "bytes": it["total_size"], "tamanho": fsize(it["total_size"]), + "arquivos": it["total_files"], "subpastas": it["subdirs"], + "bytes_diretos": it["direct_size"], "arquivos_diretos": it["direct_files"], + "pct_pai": round(it["pct_parent"], 2), "pct_raiz": round(it["pct_root"], 2), + } + +def space_to_dict(space_info, folder, depth=1, top=30): + """Espaço por pasta como lista de dicts, com "filhos" até `depth` níveis.""" + out = [] + for it in folder_space_children(space_info, folder)[:top]: + d = space_item_to_dict(it) + if depth > 1 and not it.get("is_self") and it["subdirs"]: + d["filhos"] = space_to_dict(space_info, it["path"], depth - 1, top) + out.append(d) + return out + +class _CliOut(object): + """Separa o que é resultado (stdout) do que é mensagem (stdout no modo texto, + stderr no modo --json, para o stdout ser sempre um JSON válido).""" + def __init__(self, as_json, quiet): + self.as_json, self.quiet = as_json, quiet + self.data = {} + + def info(self, text=""): + if self.quiet: + return + print(text, file=sys.stderr if self.as_json else sys.stdout) + + def text(self, text=""): + if not self.as_json: + print(text) + + def fail(self, code, msg): + if self.as_json: + print(json.dumps({"reperio": __version__, "ok": False, "codigo": code, "erro": msg}, + ensure_ascii=True, indent=2)) + else: + sys.stderr.write("Erro: %s\n" % msg) + return code + + def emit(self): + if self.as_json: + print(json.dumps(self.data, ensure_ascii=True, indent=2)) + +def _cli_scan(target, index, excludes, out): + """Lê `target` alimentando o índice (ou a memória, sem índice). Devolve estatísticas.""" + t0 = time.time() + q = queue.Queue(maxsize=4000) + stop = threading.Event() + st = SearchThread(target, "*", set(), False, q, stop, fast_mode=True, index=index, + refresh=True, exclude_dirs=excludes, want_meta=True) + st.start() + count, mem_rows, errors = 0, ([] if index is None else None), [] + interrupted, last = False, time.time() + try: + while True: + try: + item = q.get(timeout=1.0) + except queue.Empty: + item = None + if not st.is_alive(): break + if item is SearchThread.DONE: + break + if item is not None: tag = item[0] if tag == "__ERR__": + errors.append(item[1]) sys.stderr.write("Erro: %s\n" % item[1]) elif tag not in ("__STATUS__", "__REMOVE__", "__REMOVE_DIR__"): count += 1 - if index is None: + if mem_rows is not None: path, _name, _ext, sz, dp, mt = item mem_rows.append({"path": path, "dir": dp, "size": sz, "mtime": mt}) - if index is not None: - index.flush() - el = time.time() - t0 - print("Indexado: %s (%d arquivos em %.1fs) -> %s" % ( - scan_target, count, el, opts["index_path"] if index else "memória")) - if opts["space_dir"]: + if time.time() - last >= 10: + last = time.time() + out.info(" ... %s pastas lidas, %s arquivos, %s conferidas, %ds" % ( + fmt_n(st.dirs), fmt_n(count), fmt_n(st.checked), time.time() - t0)) + except KeyboardInterrupt: + interrupted = True + stop.set() + end = time.time() + 10 + while time.time() < end: # deixa a busca encerrar e gravar o índice + try: + if q.get(timeout=0.5) is SearchThread.DONE: + break + except queue.Empty: + if not st.is_alive(): + break + if index is not None: + index.flush() + el = time.time() - t0 + return {"alvo": target, "tempo_s": round(el, 3), "arquivos": count, + "pastas_lidas": st.dirs, "pastas_conferidas": st.checked, "pastas_relidas": st.changed, + "arquivos_por_s": round(count / el, 1) if el > 0 else None, + "falhas": [{"pasta": p, "erro": str(e)} for p, e in st.failed[:200]], + "n_falhas": len(st.failed), "problematicas_puladas": len(st.skipped_problems), + "erros": errors, "interrompido": interrupted, + "indice": index.path if index is not None else None, + "resumo": st.summary}, mem_rows + +def _cli_search(index, opts, out): + """`--buscar`: filtra o índice e devolve (resumo, resultados).""" + root = opts["root"] + lo, hi = parse_size_filter(opts["tamanho"]) + matcher = make_matcher(opts["buscar"], parse_exts(opts["ext"]), opts["maiusculas"]) + limit, order = opts["limite"], opts["ordenar"] + seq, total, total_bytes, heap, every = [0], [0], [0], [], [] + + def emit(item): + path, name, _ext, sz, _dp, mt = item + total[0] += 1 + total_bytes[0] += max(0, sz) + row = {"caminho": path, "nome": name, "bytes": sz, "tamanho": fsize(sz), "modificado": _iso(mt)} + if order == "nome": + every.append(row) + return + seq[0] += 1 + key = sz if order == "tamanho" else mt + if len(heap) < limit: + heapq.heappush(heap, (key, seq[0], row)) + elif key > heap[0][0]: + heapq.heapreplace(heap, (key, seq[0], row)) + info = index.query(root, matcher, emit, threading.Event(), + size_matcher=make_size_matcher(lo, hi), + dir_excluder=make_dir_excluder(opts["excludes"], root)) + if order == "nome": + rows = sorted(every, key=lambda r: (r["nome"].lower(), r["caminho"].lower()))[:limit] + else: + rows = [r for _k, _s, r in sorted(heap, key=lambda x: (x[0], x[1]), reverse=True)] + return {"padrao": opts["buscar"], "pasta": root, "total_encontrado": total[0], + "bytes_total": total_bytes[0], "exibidos": len(rows), "ordenado_por": order, + "pastas_no_indice": info["dirs"], "pastas_pendentes": len(info["pending"]), + "resultados": rows} + +def _cli_manifest(parser): + """Descrição legível por máquina da CLI (para IAs descobrirem o que ela faz).""" + args = [] + for a in parser._actions: + if not a.option_strings or isinstance(a, (argparse._HelpAction, argparse._VersionAction)): + continue + d = a.default + args.append({"flags": a.option_strings, "ajuda": a.help, + "tipo": ("flag" if a.nargs == 0 else getattr(a.type, "__name__", "texto")), + "padrao": d if isinstance(d, (str, int, float, bool, type(None))) else None, + "opcoes": list(a.choices) if a.choices else None}) + return { + "programa": APP_NAME, "versao": __version__, "url": APP_URL, + "descricao": "Indexa pastas locais e de rede em SQLite e responde: onde está o espaço, " + "quais são os maiores arquivos, quais arquivos casam com um padrão, e gera treemap.", + "convencoes": [ + "Use --json: o stdout é sempre UM objeto JSON (ASCII); mensagens de progresso vão para o stderr.", + "O índice acumula leituras: a 2ª consulta à mesma pasta sai em segundos sem tocar o disco.", + "Use --so-indice para garantir que nenhuma varredura longa seja disparada.", + "Os caminhos no JSON são os reais do sistema; 'bytes' é exato, 'tamanho' é só legível.", + "Tamanho de arquivos do OneDrive/nuvem só-online é o lógico, não o ocupado no disco: " + "use --ignorar OneDrive para medir o disco local.", + ], + "argumentos": args, + "saida_json": { + "comum": ["reperio", "ok", "pasta", "varredura", "desempenho"], + "espaco": "lista 'itens' (nome, caminho, tipo, bytes, tamanho, arquivos, subpastas, " + "pct_pai, pct_raiz, filhos) + total_bytes, total_arquivos, pastas_lidas, " + "pastas_pendentes, completo", + "maiores": "lista de {caminho, bytes, tamanho, modificado}", + "extensoes": "lista de {extensao, arquivos, bytes, tamanho, pct}", + "busca": "{padrao, total_encontrado, bytes_total, exibidos, resultados[]}", + "treemap": "{arquivo, por, profundidade}", + "status": "{indice, pastas, pastas_pendentes, arquivos, bytes, raizes[], ...}", + "erro": "{ok:false, codigo, erro}", + }, + "codigos_de_saida": {"0": "ok", "1": "erro", "2": "uso incorreto dos argumentos", + "3": "pasta inexistente ou sem dados no índice (com --so-indice)", + "130": "interrompido (Ctrl+C); o que foi lido fica no índice"}, + "exemplos": [ + "reperio --espaco D:\\ --json --profundidade 2 --top 15", + "reperio --maiores 25 --em C:\\Users\\eu --json --so-indice", + "reperio --buscar \"*.zip\" --em D:\\ --tamanho \"> 500 MB\" --json", + "reperio --espaco D:\\ --treemap D:\\mapa.html --json", + ], + } + +def _run_cli(opts): + """Executa as ações da CLI. Devolve o código de saída.""" + rotate_log() + t_start = time.time() + acts = opts["actions"] + out = _CliOut(opts["json"], opts["silencioso"]) + if "manifesto" in acts: + print(json.dumps(_cli_manifest(opts["_parser"]), ensure_ascii=True, indent=2)) + return EXIT_OK + data = out.data + data.update({"reperio": __version__, "ok": True}) + root, excl = opts["root"], opts["excludes"] + index = None + try: + if not opts["no_index"]: + index = Index(opts["index_path"]) + except Exception as e: + return out.fail(EXIT_ERROR, "não foi possível abrir o índice %s: %s" % (opts["index_path"], e)) + try: + if "status" in acts: + s = index.stats() + data["indice"] = { + "arquivo": s["path"], "tamanho_arquivo_bytes": s["disk_bytes"], "pastas": s["dirs"], + "pastas_pendentes": s["pending_dirs"], "arquivos": s["files"], "bytes": s["bytes"], + "tamanho": fsize(s["bytes"]), "problematicas": s["problems"], "raizes": s["roots"], + "leitura_mais_antiga": _iso(s["oldest"]), "leitura_mais_recente": _iso(s["newest"])} + out.text("Índice: %s (%s no disco)" % (s["path"], fsize(s["disk_bytes"]))) + out.text("Pastas lidas: %s · pendentes: %s · problemáticas: %s" % ( + fmt_n(s["dirs"]), fmt_n(s["pending_dirs"]), fmt_n(s["problems"]))) + out.text("Arquivos: %s · %s" % (fmt_n(s["files"]), fsize(s["bytes"]))) + out.text("Leitura: %s a %s" % (fdate(s["oldest"]), fdate(s["newest"]))) + for r in s["roots"]: + out.text(" raiz: %s" % r) + if len(acts) == 1: + out.emit() + return EXIT_OK + sp, mem_rows, scan = None, None, None + scan_target = opts["index_dir"] + if not scan_target and root: + if index is None or opts["refresh"]: + scan_target = root + else: + sp = index.folder_space(root, exclude_dirs=excl) + if not sp or (sp["scanned_dirs"] == 0 and sp["total_files"] == 0): + scan_target = root + if scan_target: + if opts["so_indice"]: + return out.fail(EXIT_NOT_FOUND, "sem dados no índice para %s (--so-indice impede " + "a varredura; rode antes --indexar)" % scan_target) + if not os.path.isdir(fs_path(scan_target)): + return out.fail(EXIT_NOT_FOUND, "pasta não encontrada: %s" % scan_target) + scan, mem_rows = _cli_scan(scan_target, index, excl, out) + for e in scan["erros"]: + data.setdefault("avisos", []).append(e) + out.text("Indexado: %s (%d arquivos em %.1fs) -> %s" % ( + scan_target, scan["arquivos"], scan["tempo_s"], + opts["index_path"] if index else "memória")) + if scan["n_falhas"]: + out.info("Atenção: %d pasta(s) não puderam ser lidas (veja 'varredura.falhas' no --json)" + % scan["n_falhas"]) + data["varredura"] = scan + if scan["interrompido"]: + data["ok"] = False + data["erro"] = "interrompido pelo usuário; o que foi lido ficou no índice" + out.emit() + return EXIT_INTERRUPTED + else: + data["varredura"] = {"executada": False, "motivo": "índice já cobre a pasta"} + if root: + data["pasta"] = root + if sp is None or scan is not None: + sp = (index.folder_space(root, exclude_dirs=excl) if index is not None + else summarize_rows_space(mem_rows or [], root, exclude_dirs=excl)) + + if "espaco" in acts: target = opts["space_dir"] - if index is not None: - sp = index.folder_space(target, exclude_dirs=opts["excludes"]) + if path_key(target) != path_key(root) and index is not None: # --em aponta para outra pasta + sp_e = index.folder_space(target, exclude_dirs=excl) else: - sp = summarize_rows_space(mem_rows, target, exclude_dirs=opts["excludes"]) - items = folder_space_children(sp, target) - print("\nEspaço por pasta em: %s" % target) - print("Total: %s em %d arquivo(s)\n" % (fsize(sp["total_size"]), sp["total_files"])) + sp_e = sp + items = folder_space_children(sp_e, target) + depth = opts["profundidade"] or 1 + data["espaco"] = { + "pasta": target, "total_bytes": sp_e["total_size"], "total": fsize(sp_e["total_size"]), + "total_arquivos": sp_e["total_files"], "pastas_lidas": sp_e["scanned_dirs"], + "pastas_pendentes": sp_e["pending_dirs"], "completo": sp_e["pending_dirs"] == 0, + "itens": space_to_dict(sp_e, target, depth, opts["top"])} + out.text("\nEspaço por pasta em: %s" % target) + out.text("Total: %s em %d arquivo(s)\n" % (fsize(sp_e["total_size"]), sp_e["total_files"])) for it in items[:opts["top"]]: - print("%12s %5.1f%% %8d arq. %s" % ( + out.text("%12s %5.1f%% %8d arq. %s" % ( fsize(it["total_size"]), it["pct"], it["total_files"], it["path"])) if opts["export"]: export_space_results(items, opts["export"]) - print("\nExportado para: %s" % opts["export"]) + data["exportado"] = opts["export"] + out.text("\nExportado para: %s" % opts["export"]) + if "maiores" in acts: + rows = index.largest_files(root, opts["maiores"], exclude_dirs=excl) + data["maiores"] = [{"caminho": p, "bytes": s, "tamanho": fsize(s), "modificado": _iso(m)} + for p, s, m in rows] + out.text("\nMaiores arquivos em: %s\n" % root) + for p, s, _m in rows: + out.text("%12s %s" % (fsize(s), p)) + if "extensoes" in acts: + n_ext = opts["extensoes"] or 20 + rows = index.extension_summary(root, n_ext, exclude_dirs=excl) + tot = sp["total_size"] or 1 + data["extensoes"] = [{"extensao": e, "arquivos": n, "bytes": s, "tamanho": fsize(s), + "pct": round(s * 100.0 / tot, 2)} for e, n, s in rows] + out.text("\nEspaço por extensão em: %s\n" % root) + for e, n, s in rows: + out.text("%12s %5.1f%% %9d arq. %s" % (fsize(s), s * 100.0 / tot, n, e)) + if "buscar" in acts: + try: + res = _cli_search(index, opts, out) + except ValueError as e: + return out.fail(EXIT_USAGE, str(e)) + data["busca"] = res + out.text("\n%s encontrado(s) para %r em %s (%s); mostrando %d por %s\n" % ( + fmt_n(res["total_encontrado"]), res["padrao"], root, + fsize(res["bytes_total"]), res["exibidos"], res["ordenado_por"])) + for r in res["resultados"]: + out.text("%12s %s" % (r["tamanho"], r["caminho"])) + if "treemap" in acts: + dest = os.path.normpath(opts["treemap"]) + depth = opts["profundidade"] or 3 + export_treemap_html(sp, dest, root, depth=depth, primary=opts["treemap_por"]) + data["treemap"] = {"arquivo": dest, "por": opts["treemap_por"], "profundidade": depth} + out.text("\nTreemap gravado em: %s" % dest) + data["desempenho"] = {"tempo_total_s": round(time.time() - t_start, 3), + "pico_ram_mb": _peak_ram_mb()} + out.emit() + return EXIT_OK + except KeyboardInterrupt: + return out.fail(EXIT_INTERRUPTED, "interrompido") + except Exception as e: + dlog("[ERRO] CLI\n%s" % traceback.format_exc()) + return out.fail(EXIT_ERROR, "%s: %s" % (type(e).__name__, e)) finally: if index is not None: index.close() - return 0 def main(argv=None): argv = sys.argv[1:] if argv is None else argv - if "--version" in argv: - print("%s %s" % (APP_NAME, __version__)) - return 0 + _safe_stdio() opts = _parse_cli(argv) - if opts["index_dir"] or opts["space_dir"]: + if opts["actions"]: return _run_cli(opts) if tk is None: sys.stderr.write("Erro: tkinter não está disponível neste Python.\n" diff --git a/tests/test_cli_treemap.py b/tests/test_cli_treemap.py new file mode 100644 index 0000000..658b8bc --- /dev/null +++ b/tests/test_cli_treemap.py @@ -0,0 +1,359 @@ +# -*- coding: utf-8 -*- +"""Testes do treemap e da linha de comando (v2.7): argumentos, JSON, códigos de saída. +Rodar: python -m unittest discover -s tests -v +""" +import contextlib +import gc +import io +import json +import os +import shutil +import sys +import tempfile +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import reperio as g # noqa: E402 + +g.LOG_PATH = os.path.join(tempfile.gettempdir(), "reperio_tests.log") + + +def make_files(base, spec): + """spec: {"a/b.bin": tamanho_em_bytes}""" + for rel, size in spec.items(): + p = os.path.join(base, *rel.split("/")) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "wb") as f: + f.write(b"x" * size) + + +def run_cli(*argv): + """Executa g.main(argv) e devolve (código, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + code = 0 + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + code = g.main(list(argv)) + except SystemExit as e: # argparse sai com 2 em erro de uso e 0 na ajuda + code = e.code if isinstance(e.code, int) else 1 + return code, out.getvalue(), err.getvalue() + + +class TestSquarify(unittest.TestCase): + def test_areas_proportional_and_inside(self): + values = [600, 300, 100, 50, 25, 25] + rects = g.squarify(values, 10, 20, 400, 300) + self.assertEqual(len(rects), len(values)) + total_area = 400 * 300 + for v, (x, y, w, h) in zip(values, rects): + self.assertGreaterEqual(x, 10 - 1e-6) + self.assertGreaterEqual(y, 20 - 1e-6) + self.assertLessEqual(x + w, 410 + 1e-6) + self.assertLessEqual(y + h, 320 + 1e-6) + self.assertAlmostEqual(w * h, total_area * v / sum(values), delta=1e-3 * total_area) + self.assertAlmostEqual(sum(w * h for _x, _y, w, h in rects), total_area, delta=1e-6 * total_area) + + def test_blocks_do_not_overlap(self): + values = [50, 40, 30, 20, 10, 9, 8, 7, 3, 1] + rects = g.squarify(values, 0, 0, 500, 200) + for i, (x1, y1, w1, h1) in enumerate(rects): + for x2, y2, w2, h2 in rects[i + 1:]: + ox = min(x1 + w1, x2 + w2) - max(x1, x2) + oy = min(y1 + h1, y2 + h2) - max(y1, y2) + self.assertFalse(ox > 1e-6 and oy > 1e-6) + + def test_degenerate_inputs(self): + self.assertEqual(g.squarify([], 0, 0, 10, 10), []) + self.assertEqual(g.squarify([5], 0, 0, 0, 10), [(0, 0, 0.0, 0.0)]) + self.assertEqual(g.squarify([5], 1, 2, 30, 40)[0], (1, 2, 30.0, 40.0)) + + +class TestTreemapData(unittest.TestCase): + def setUp(self): + self.d = tempfile.mkdtemp(prefix="rp_tm_") + self.data = os.path.join(self.d, "dados") + make_files(self.data, {"video/a.mp4": 9000, "video/b.mp4": 6000, "docs/x.pdf": 1000, + "docs/y.pdf": 500, "docs/z.txt": 10, "solto.bin": 100, + "muitos/f1": 1, "muitos/f2": 1, "muitos/f3": 1, "muitos/f4": 1}) + self.sp = g.summarize_rows_space(self._rows(), self.data) + + def tearDown(self): + shutil.rmtree(self.d, ignore_errors=True) + + def _rows(self): + rows = [] + for dp, _dn, fns in os.walk(self.data): + for fn in fns: + p = os.path.join(dp, fn) + rows.append({"path": p, "dir": dp, "size": os.path.getsize(p)}) + return rows + + def test_space_tree_totals_and_kinds(self): + t = g.space_tree(self.sp, self.data, depth=2) + self.assertEqual(t["size"], 16614) + self.assertEqual(t["files"], 10) + kinds = {c["name"]: c["kind"] for c in t["children"]} + self.assertEqual(kinds["video"], "dir") + self.assertEqual(kinds["[arquivos desta pasta]"], "files") + self.assertEqual(sum(c["size"] for c in t["children"]), t["size"]) + + def test_space_tree_groups_small_items(self): + t = g.space_tree(self.sp, self.data, depth=2, max_children=1) + names = [c["name"] for c in t["children"]] + self.assertTrue(any(n.startswith("[outros") for n in names)) + self.assertEqual(sum(c["size"] for c in t["children"]), t["size"]) + self.assertIsNone(g.space_tree(self.sp, os.path.join(self.d, "nao_existe"))) + + def test_layout_metrics_differ(self): + t = g.space_tree(self.sp, self.data, depth=2) + by_size = {r["node"]["name"]: r for r in g.treemap_layout(t, "tamanho", 800, 500) if r["depth"] == 1} + by_files = {r["node"]["name"]: r for r in g.treemap_layout(t, "quantidade", 800, 500) if r["depth"] == 1} + # video domina por tamanho; docs (3 arquivos) e muitos (4) pesam mais por quantidade + self.assertGreater(by_size["video"]["w"] * by_size["video"]["h"], + by_size["docs"]["w"] * by_size["docs"]["h"]) + self.assertGreater(by_files["muitos"]["w"] * by_files["muitos"]["h"], + by_files["video"]["w"] * by_files["video"]["h"]) + + def test_html_export(self): + dest = os.path.join(self.d, "mapa.html") + n = g.export_treemap_html(self.sp, dest, self.data, primary="quantidade") + self.assertEqual(n, 4) + with open(dest, encoding="utf-8") as f: + html = f.read() + self.assertIn(" 4000") + self.assertEqual(j["busca"]["total_encontrado"], 3) + code, j, _e = self.q("--buscar", "*", "--em", self.data, "--tamanho", "banana") + self.assertEqual(code, 2) + self.assertFalse(j["ok"]) + + def test_buscar_case_sensitive(self): + code, j, _e = self.q("--buscar", "*.JPG", "--maiusculas", "--em", self.data) + self.assertEqual(j["busca"]["total_encontrado"], 1) + + def test_status(self): + code, j, _e = self.q("--status") + self.assertEqual(code, 0) + self.assertEqual(j["indice"]["arquivos"], 6) + self.assertIn(os.path.normpath(self.data), j["indice"]["raizes"]) + + def test_so_indice_without_data_exit_3(self): + other = os.path.join(self.d, "outra") + os.makedirs(other, exist_ok=True) + code, j, _e = self.q("--espaco", other) + self.assertEqual(code, 3) + self.assertFalse(j["ok"]) + self.assertEqual(j["codigo"], 3) + + def test_missing_folder_exit_3(self): + code, o, e = run_cli("--indice", self.idx, "--espaco", os.path.join(self.d, "nao_existe")) + self.assertEqual(code, 3) + self.assertIn("não encontrada", e) + + def test_treemap_file(self): + dest = os.path.join(self.d, "t.html") + code, j, _e = self.q("--espaco", self.data, "--treemap", dest, "--treemap-por", "quantidade") + self.assertEqual(code, 0) + self.assertEqual(j["treemap"]["por"], "quantidade") + self.assertTrue(os.path.getsize(dest) > 1000) + + def test_text_mode_unchanged(self): + code, o, _e = run_cli("--indice", self.idx, "--so-indice", "--espaco", self.data, "--top", "2") + self.assertEqual(code, 0) + self.assertIn("Espaço por pasta em:", o) + self.assertIn("Total:", o) + + def test_json_stdout_is_pure(self): + code, o, e = run_cli("--indice", self.idx, "--so-indice", "--json", "--espaco", self.data) + self.assertEqual(code, 0) + json.loads(o) # não pode haver texto solto antes/depois do JSON + self.assertTrue(o.isascii()) + + +class TestGuiTreemap(unittest.TestCase): + def test_toggle_and_redraw(self): + if g.tk is None: + self.skipTest("tkinter indisponível") + try: + root = g.tk.Tk() + except g.tk.TclError as e: + self.skipTest("sem display: %s" % e) + base = tempfile.mkdtemp(prefix="rp_gui_") + idx = g.Index(os.path.join(base, "idx.sqlite")) + try: + data = os.path.join(base, "dados") + make_files(data, {"alfa/x.bin": 6000, "alfa/y.bin": 2000, "beta/z.bin": 1000}) + root.withdraw() + app = g.App(root, idx) + app.v_root.set(data) + app._start() + deadline = time.time() + 15 + while (app._running or app._rcount == 0) and time.time() < deadline: + root.update() + time.sleep(0.02) + app._open_space() + dlg = app._space_win + deadline = time.time() + 10 + while not dlg._rows and time.time() < deadline: + root.update() + time.sleep(0.02) + dlg.win.geometry("900x700") + for _ in range(40): + root.update() + time.sleep(0.02) + # o painel do treemap já vem visível, junto da tabela + self.assertTrue(dlg._tm_on) + self.assertTrue(dlg.canvas.winfo_ismapped() and dlg.tree.winfo_ismapped()) + names = {r["node"]["name"] for r in dlg._tm_rects} + self.assertTrue({"alfa", "beta"} <= names) + # seleção: bloco -> tabela e tabela -> bloco + blk = [r for r in dlg._tm_rects if r["node"]["name"] == "beta"][0] + + class Ev: + x = int(blk["x"] + blk["w"] / 2) + y = int(blk["y"] + blk["h"] / 2) + dlg._tm_click(Ev) + sel = dlg.tree.selection() + self.assertEqual(dlg._rdata[sel[0]]["name"], "beta") + alfa_iid = [i for i, it in dlg._rdata.items() if it["name"] == "alfa"][0] + dlg.tree.selection_set(alfa_iid) + root.update() + self.assertEqual(dlg._tm_sel_key[0], os.path.join(data, "alfa")) + # passar o mouse mostra a dica flutuante + dlg._tm_motion(Ev) + self.assertTrue(dlg.canvas.find_withtag("tip")) + # roda para cima entra na pasta sob o mouse + dlg._tm_zoom(Ev, 1) + deadline = time.time() + 5 + while dlg._cur_dir != os.path.join(data, "beta") and time.time() < deadline: + root.update() + time.sleep(0.02) + self.assertEqual(dlg._cur_dir, os.path.join(data, "beta")) + dlg._cycle_metric() + for _ in range(10): + root.update() + self.assertEqual(dlg._tm_metric, "quantidade") + dlg._toggle_treemap() # ocultar + self.assertFalse(dlg._tm_on) + self.assertEqual(dlg.b_tm.cget("text"), "Mostrar treemap") + dlg._toggle_treemap() # mostrar de novo + self.assertTrue(dlg._tm_on) + dlg._close() + finally: + idx.close() + root.destroy() + dlg = app = root = None + gc.collect() + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/benchmark.py b/tools/benchmark.py new file mode 100644 index 0000000..2ebc90c --- /dev/null +++ b/tools/benchmark.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Benchmark do ReperIO pela própria CLI (o que uma IA ou um script veria). + +Para cada pasta/disco informado mede, com um índice temporário novo: + 1. varredura "a frio" (--indexar, índice vazio) + 2. reverificação "a quente" (--indexar de novo: só relê pastas alteradas) + 3. consultas ao índice (--espaco, --maiores, --extensoes, --buscar, --treemap) +e grava um relatório em Markdown e um JSON com os números brutos. + +Uso: + python tools/benchmark.py C:\\ D:\\ [--ignorar OneDrive] [--md docs/BENCHMARK.md] [--json saida.json] + +Atenção: a varredura "a frio" lê o disco inteiro. O cache do sistema operacional já pode estar +aquecido por leituras anteriores, então o tempo "a frio" é o do ReperIO, não do disco puro. +""" +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CLI = os.path.join(ROOT, "reperio.py") + + +def run(index, *args): + """Executa a CLI com --json; devolve (resultado_json, segundos_de_parede, código).""" + cmd = [sys.executable, CLI, "--indice", index] + list(args) + ["--json", "-q"] + t0 = time.time() + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + wall = time.time() - t0 + try: + data = json.loads(p.stdout.decode("utf-8", "replace")) + except ValueError: + data = {"ok": False, "erro": p.stderr.decode("utf-8", "replace")[-400:]} + return data, wall, p.returncode + + +def bench_target(target, ignorar, keep_dir): + res = {"alvo": target, "ignorar": ignorar} + try: + du = shutil.disk_usage(target) + res["disco"] = {"total_gb": round(du.total / 2**30, 1), "usado_gb": round(du.used / 2**30, 1), + "livre_gb": round(du.free / 2**30, 1)} + except OSError: + pass + tmp = tempfile.mkdtemp(prefix="reperio_bench_", dir=keep_dir) + index = os.path.join(tmp, "bench.sqlite") + extra = ["--ignorar", ignorar] if ignorar else [] + try: + steps = {} + d, wall, rc = run(index, "--indexar", target, *extra) + v = d.get("varredura", {}) + steps["varredura_a_frio"] = { + "ok": d.get("ok"), "parede_s": round(wall, 2), "arquivos": v.get("arquivos"), + "pastas": v.get("pastas_lidas"), "arquivos_por_s": v.get("arquivos_por_s"), + "falhas": v.get("n_falhas"), "pico_ram_mb": d.get("desempenho", {}).get("pico_ram_mb")} + res["indice_mb"] = round(os.path.getsize(index) / 2**20, 1) if os.path.exists(index) else None + d, wall, rc = run(index, "--indexar", target, *extra) + v = d.get("varredura", {}) + steps["reverificacao_a_quente"] = { + "ok": d.get("ok"), "parede_s": round(wall, 2), "pastas_conferidas": v.get("pastas_conferidas"), + "pastas_relidas": v.get("pastas_relidas"), "arquivos_por_s": v.get("arquivos_por_s"), + "pico_ram_mb": d.get("desempenho", {}).get("pico_ram_mb")} + html = os.path.join(tmp, "treemap.html") + for nome, args in ( + ("consulta_espaco", ["--espaco", target, "--top", "30", "--profundidade", "2"]), + ("consulta_maiores", ["--maiores", "20", "--em", target]), + ("consulta_extensoes", ["--extensoes", "20", "--em", target]), + ("consulta_buscar", ["--buscar", "*.zip", "--em", target, "--limite", "50"]), + ("treemap_html", ["--espaco", target, "--treemap", html])): + d, wall, rc = run(index, *(args + ["--so-indice"] + extra)) + steps[nome] = {"ok": d.get("ok"), "parede_s": round(wall, 2), + "pico_ram_mb": d.get("desempenho", {}).get("pico_ram_mb")} + if nome == "consulta_espaco" and d.get("ok"): + res["total_gb"] = round(d["espaco"]["total_bytes"] / 2**30, 2) + res["total_arquivos"] = d["espaco"]["total_arquivos"] + res["completo"] = d["espaco"]["completo"] + if nome == "consulta_buscar" and d.get("ok"): + steps[nome]["encontrados"] = d["busca"]["total_encontrado"] + if nome == "treemap_html" and os.path.exists(html): + steps[nome]["html_kb"] = round(os.path.getsize(html) / 1024, 1) + res["etapas"] = steps + finally: + shutil.rmtree(tmp, ignore_errors=True) + return res + + +def markdown(results, meta): + L = ["# Benchmark do ReperIO", "", + "Gerado por `tools/benchmark.py` em %s." % meta["quando"], "", + "- Máquina: %s" % meta["maquina"], + "- Python %s · ReperIO v%s · %d CPUs lógicas" % (meta["python"], meta["versao"], meta["cpus"]), ""] + for r in results: + L += ["## %s" % r["alvo"], ""] + if r.get("disco"): + L.append("Disco: %(total_gb)s GB no total, %(usado_gb)s GB usados, %(livre_gb)s GB livres." % r["disco"]) + if r.get("ignorar"): + L.append("Pastas ignoradas: `%s`." % r["ignorar"]) + if "total_gb" in r: + L.append("Indexado: **%s arquivos, %s GB**; índice SQLite de %s MB%s." % ( + "{:,}".format(r["total_arquivos"]).replace(",", "."), r["total_gb"], r.get("indice_mb"), + "" if r.get("completo") else " (há pastas sem leitura)")) + L += ["", "| Etapa | Tempo (s) | Detalhe | Pico de RAM (MB) |", "|---|---:|---|---:|"] + nomes = {"varredura_a_frio": "Varredura a frio (índice vazio)", + "reverificacao_a_quente": "Reverificação a quente (índice pronto)", + "consulta_espaco": "`--espaco` (2 níveis)", "consulta_maiores": "`--maiores 20`", + "consulta_extensoes": "`--extensoes 20`", "consulta_buscar": "`--buscar *.zip`", + "treemap_html": "`--treemap` (HTML)"} + for k, s in r["etapas"].items(): + det = [] + if s.get("arquivos") is not None: + det.append("%s arq. · %s pastas · %s arq./s" % ( + "{:,}".format(s["arquivos"]).replace(",", "."), s.get("pastas"), s.get("arquivos_por_s"))) + if s.get("pastas_conferidas") is not None: + det.append("%s pastas conferidas · %s relidas" % (s["pastas_conferidas"], s["pastas_relidas"])) + if s.get("falhas"): + det.append("%s pasta(s) não lidas" % s["falhas"]) + if s.get("encontrados") is not None: + det.append("%s encontrados" % s["encontrados"]) + if s.get("html_kb") is not None: + det.append("HTML %s KB" % s["html_kb"]) + L.append("| %s | %s | %s | %s |" % (nomes.get(k, k), s["parede_s"], "; ".join(det) or "—", + s.get("pico_ram_mb") or "—")) + L.append("") + L += ["Tempo = relógio de parede do processo inteiro (inclui iniciar o Python). " + "O cache do sistema operacional pode estar aquecido, então a varredura \"a frio\" mede o ReperIO, " + "não o disco puro.", ""] + return "\n".join(L) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("alvos", nargs="+", help="pastas ou discos (ex.: C:\\ D:\\)") + ap.add_argument("--ignorar", default="", help="repassado à CLI (ex.: OneDrive)") + ap.add_argument("--md", default=os.path.join(ROOT, "docs", "BENCHMARK.md")) + ap.add_argument("--json", default=None, help="também grava os números brutos neste arquivo") + ap.add_argument("--tmp", default=None, help="onde criar o índice temporário (padrão: temp do sistema)") + a = ap.parse_args() + sys.path.insert(0, ROOT) + import reperio + meta = {"quando": time.strftime("%Y-%m-%d %H:%M"), "maquina": platform.platform(), + "python": platform.python_version(), "versao": reperio.__version__, + "cpus": os.cpu_count() or 0} + results = [] + for t in a.alvos: + t, _sep, ign = t.partition("|") # "C:\|OneDrive" ignora só nesse alvo + print(">>> %s" % t, flush=True) + r = bench_target(os.path.normpath(t), ign or a.ignorar, a.tmp) + results.append(r) + for k, s in r["etapas"].items(): + print(" %-24s %8.2f s" % (k, s["parede_s"]), flush=True) + md = markdown(results, meta) + with open(a.md, "w", encoding="utf-8", newline="\n") as f: + f.write(md) + print("Relatório: %s" % a.md) + if a.json: + with open(a.json, "w", encoding="utf-8") as f: + json.dump({"meta": meta, "resultados": results}, f, ensure_ascii=False, indent=2) + print("JSON: %s" % a.json) + + +if __name__ == "__main__": + main()