From 01dea502ac1a09048460027c8f5d2eb23a7f1dcc Mon Sep 17 00:00:00 2001 From: let Date: Thu, 25 Jun 2026 23:58:39 -0300 Subject: [PATCH 001/122] feat: remove similar changed path method + rename some pytest-ranking occurences --- CHANGELOG.md | 5 + analise-pytest-ranking.md | 554 +++++++++++++++++++++++++++ run_test_local.sh | 2 +- setup.py | 17 +- src/pytest_ranking/change_tracker.py | 78 ---- src/pytest_ranking/const.py | 4 +- src/pytest_ranking/plugin.py | 36 +- 7 files changed, 588 insertions(+), 108 deletions(-) create mode 100644 analise-pytest-ranking.md delete mode 100644 src/pytest_ranking/change_tracker.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 656ef62..b7bb956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog +0.4.0 (2026-06-25) +---- + +* Remove Similar Changed Path method + 0.3.3 (2024-04-08) ---- diff --git a/analise-pytest-ranking.md b/analise-pytest-ranking.md new file mode 100644 index 0000000..64c635d --- /dev/null +++ b/analise-pytest-ranking.md @@ -0,0 +1,554 @@ +# pytest-ranking — Documentação Detalhada + +## Visão Geral + +**pytest-ranking** é um plugin pytest que implementa **Regression Test Prioritization (RTP)** para acelerar a detecção de falhas em regressão. Ele reordena a execução dos testes para que os mais propensos a falhar executem primeiro, baseando-se em três heurísticas combináveis: + +1. **Testes mais rápidos primeiro** (peso `1-0-0`, padrão) +2. **Testes que falharam recentemente primeiro** (peso `0-1-0`) +3. **Testes relacionados a mudanças no código** (peso `0-0-1`) + +O plugin foi criado por Runxiang Cheng, Kaiyao Ke e Darko Marinov (University of Illinois), apresentado no FSE Companion 2025, e avaliado em **4.308 builds de 14 projetos open-source** no GitHub Actions CI. + +--- + +## Arquitetura do Projeto + +``` +src/pytest_ranking/ +├── __init__.py # Pacote vazio +├── const.py # Constantes, defaults, enum LEVEL +├── change_tracker.py # changeTracker: hasheia .py, detecta delta, similaridade textual +├── rank.py # get_test_group() + get_ranking(): agrega scores por nível +└── plugin.py # RTPRunner (classe principal), hooks pytest, compute_test_features +``` + +### Quatro Componentes Principais (artigo §2.1) + +O artigo define **quatro componentes** que interagem com o core do pytest, conforme ilustrado na **Figura 1** (página 1090): + +``` + Pytest pytest-ranking + ┌──────────────┐ ┌──────────────────────────┐ + │ Collect Tests│──(1)──▶ │ Ranker │ + │ │◀──(2)── │ │ + │ Execute Tests│──(3)──▶ │ Monitor │ + │ │ │ │ + │ Pytest Cache │◀──(5)── │ Extractor │ + │ │ │ │ + │ Print Summary│◀──(7)── │ Reporter │ + └──────────────┘ └──────────────────────────┘ +``` + +**Fluxo numerado conforme a Figura 1:** + +1. **(1)** Pytest fornece a lista de testes coletados ao **Ranker** +2. **(2)** Ranker devolve a lista priorizada para o Pytest executar +3. **(3)** Conforme cada teste termina, Pytest chama o **Monitor** com um objeto `TestReport` (duração, resultado) +4. **(4)** **Monitor** apenas acumula os `TestReport` em memória (lista no `RTPRunner`) — sem processamento para minimizar overhead +5. **(5)** Quando toda a suite termina, o **Extractor** processa os relatórios e salva dados no cache do pytest +6. **(6)** No próximo TSR, o **Ranker** carrega os dados cacheados para priorizar +7. **(7)** Ao final, o **Reporter** adiciona um sumário ao terminal do pytest com configuração e overhead + +### Fluxo de Execução Detalhado + +1. `pytest_configure` → instancia `RTPRunner` (contém os 4 componentes) e registra no `pluginmanager` +2. `pytest_report_header` → **Reporter** exibe configurações se `--rank` ativo +3. `changeTracker.__init__` → hasheia todos `*.py` e computa delta (arquivos alterados) — usado pelo **Ranker** +4. `pytest_collection_modifyitems` → **Ranker** valida replay+random, chama `run_rtp(items)` para reordenar +5. `pytest_runtest_logreport` → **Monitor** captura `TestReport` (não-skipped, `when=="call"`) +6. `pytest_sessionfinish` → **Extractor** persiste `last_durations` e `num_runs_since_fail` no cache +7. `pytest_terminal_summary` → **Reporter** exibe métricas de overhead + +### Armazenamento de Dados + +Toda a persistência usa o **cache interno do pytest** (`config.cache`), armazenado em `.pytest_cache/v/pytest_ranking_data/`: + +| Chave | Conteúdo | +|---|---| +| `file_hashes` | SHA1 de cada `*.py` no projeto | +| `change_similarity` | Interseção de tokens entre nodeid do teste e paths alterados | +| `last_durations` | Último tempo de execução (`round(duration, 3)`) por nodeid | +| `num_runs_since_fail` | Nº de execuções desde a última falha (limitado por `--rank-hist-len`) | + +--- + +## Parâmetros de Entrada + +### Linha de comando / addopts + +| Flag | Tipo | Default | Descrição | +|---|---|---|---| +| `--rank` | flag | `False` | Ativa o plugin | +| `--rank-weight` | string `"A-B-C"` | `"1-0-0"` | Pesos para tempo, falha, similaridade (normalizados p/ soma=1) | +| `--rank-level` | `put` / `function` / `module` / `dir` | `"put"` | Nível de agrupamento para reordenação | +| `--rank-hist-len` | int | `50` | Máximo de execuções registradas desde última falha | +| `--rank-seed` | int | `0` | Semente para ordem aleatória | +| `--rank-replay` | path | `None` | Arquivo com ordem explícita de testes (1 ID por linha) | + +### Config file (pytest.ini) + +```ini +[pytest] +rank_weight = 0-1-0 +rank_level = module +rank_hist_len = 30 +rank_seed = 42 +``` + +CLI não-default sobrescreve `pytest.ini` (`RTPRunner.parse_*()`). + +--- + +## Parâmetros de Saída + +### Cabeçalho (antes da execução) + +``` +Using --rank-weight=1-0-0 +Using --rank-level=put +Using --rank-hist-len=50 +Using --rank-seed=0 +Using --rank-replay=None +``` + +### Sumário (após execução) + +``` +=== pytest-ranking summary info === +Number of changed Python files: 3 +Time to compute test-change similarity (s): 0.00087 +Time to reorder tests (s): 0.00036 +Time to collect test features (s): 0.00046 +``` + +--- + +## Níveis de Reordenação (LEVEL) + +| Nível | Agrupamento | Exemplo (nodeid) | +|---|---|---| +| `put` | Cada parametrização individual | `test_file.py::TestClass::test_method[param1]` | +| `function` | Função de teste sem parâmetros | `test_file.py::TestClass::test_method` | +| `module` | Arquivo de teste | `test_file.py` | +| `dir` | Diretório | `tests/` | + +O score de um grupo é a **média aritmética** dos scores individuais dos testes no grupo. Testes dentro do mesmo grupo seguem a ordem padrão do pytest. + +--- + +## HOOKs do pytest Utilizados + +Os hooks são a API do pytest que o plugin reimplementa para alterar o comportamento padrão ([55]). A tabela abaixo mapeia cada hook ao componente do artigo (§2.1) que o implementa: + +| Hook | Componente | Localização | Finalidade | +|---|---|---|---| +| `pytest_addoption(parser)` | — | `plugin.py` | Registra `--rank`, `--rank-weight`, `--rank-level`, `--rank-hist-len`, `--rank-seed`, `--rank-replay` + 5 opções `addini()` | +| `pytest_configure(config)` | **Runner** | `plugin.py` (trylast) | Instancia `RTPRunner` e registra no `pluginmanager` ([49]) | +| `pytest_report_header(config)` | **Reporter** | `plugin.py` | Exibe configuração no cabeçalho se `--rank` ativo | +| `pytest_collection_modifyitems(items)` | **Ranker** ([50]) | `plugin.py` (trylast) | Valida replay+random, chama `run_rtp(items)` que reordena a lista | +| `pytest_runtest_logreport(report)` | **Monitor** ([51]) | `plugin.py` | Acumula `TestReport` dos testes (não-skipped, `when=="call"`) | +| `pytest_sessionfinish(session, exitstatus)` | **Extractor** | `plugin.py` | Persiste `last_durations` e `num_runs_since_fail` no cache | +| `pytest_terminal_summary(terminalreporter, exitstatus, config)` | **Reporter** ([52]) | `plugin.py` | Exibe overhead: tempo de similaridade, reordenação, coleta | + +--- + +## Etapas de Coleta (Feature Collection) + +### 1. changeTracker.get_delta() — ao iniciar + +- Varre todos `**/*.py` abaixo de `rootpath` +- Calcula SHA1 de cada arquivo +- Compara com hashes salvos no cache (`file_hashes`) +- Se houve mudança, tokeniza o path do arquivo e adiciona ao conjunto `delta` +- Salva hashes atualizados + +### 2. compute_test_suite_similarity(items) — durante run_rtp() + +- Para cada `item`, tokeniza `item.nodeid` +- Calcula `|delta ∩ tokens(item.nodeid)|` (interseção de conjuntos) +- Salva em cache (`change_similarity`) + +### 3. load_feature(feature_name, items, reverse) — durante run_rtp() + +- Carrega do cache: `last_durations` (reverse=True), `num_runs_since_fail` (reverse=True), `change_similarity` (reverse=False) +- Aplica `min_max_normalization` → valores em [0,1] +- Se `reverse=True`, transforma para `1 - valor` (menor é melhor vira maior é melhor) + +### 4. compute_test_features() — ao final da sessão + +- Atualiza `last_durations[nodeid]` com `report.duration` de cada teste +- Atualiza `num_runs_since_fail[nodeid]`: 0 se falhou, `min(hist_len, valor_anterior + 1)` se passou + +--- + +## Algoritmo de Ranqueamento + +### Cálculo do Score Individual + +``` +score(item) = -(w_t * norm_time + w_f * norm_fail + w_r * norm_rel) + +onde: + w_t + w_f + w_r = 1 (após normalização) + norm_time = 1 - min_max_normalize(last_durations[nodeid]) + norm_fail = 1 - min_max_normalize(num_runs_since_fail[nodeid]) + norm_rel = min_max_normalize(change_similarity[nodeid]) + +Menor score → executado antes +``` + +### Agregação por Grupo + +``` +group_score(grupo) = mean(score(item) para todo item no grupo) +rank(item) = posição ordenada por (group_score, init_order) +``` + +### Ordenação Final + +1. Testes com `@pytest.mark.order` ou `@pytest.mark.dependency` (OD) são mantidos no início, na ordem original +2. Testes sem OD são ordenados por `rank.get(nodeid, 0)` e depois `init_order` (desempate) +3. `items[:] = od_items + nod_items` (substitui a lista in-place) + +### Modos Especiais + +- **Replay** (`--rank-replay`): scores = posição no arquivo; ignora pesos +- **Aleatório** (`--rank-weight=0-0-0`): scores = `random.random()`; pre-sort por `nodeid` para compatibilidade com xdist +- Erro se `--rank-replay` + `--rank-weight=0-0-0` + +--- + +## Detecção de Mudanças (changeTracker) + +```python +changeTracker.__init__(pytest_config) +├── get_all_file_paths() -> glob("**/*.py", recursive=True) +├── get_hash(file_path) -> hashlib.sha1(file.read()).hexdigest() +├── get_delta() -> compara hashes, tokeniza paths alterados +└── compute_test_suite_similarity(items) -> interseção tokens +``` + +`tokenize(string)` usa `re.findall(r'[a-zA-Z0-9]+', string.lower())` — extrai tokens alfanuméricos em lowercase. + +Exemplo: path `src/utils/helper.py` → tokens `{"src", "utils", "helper", "py"}`. + +--- + +## Comunicação com o Ecossistema pytest + +### Descoberta Automática + +```python +entry_points={ + 'pytest11': ['pytest_ranking = pytest_ranking.plugin'], +} +``` + +### Compatibilidade com pytest-xdist + +- Pre-sort dos items por `nodeid` antes de aplicar `random.seed()` para garantir mesma ordem em todos workers +- Reordenação ocorre antes da distribuição (hook `pytest_collection_modifyitems`) + +### Respeito a Dependências de Ordem + +```python +if item.get_closest_marker('order') or item.get_closest_marker('dependency'): + od_items.append(item) +else: + nod_items.append(item) +``` + +Testes com `@pytest.mark.order(n)` ou `@pytest.mark.dependency(...)` executam primeiro, na ordem original. + +### Plugins Potencialmente Conflitantes + +`--ff` (pytest), `pytest-randomly`, `pytest-random-order`, `pytest-reverse` — usam o mesmo hook `pytest_collection_modifyitems`. + +--- + +## Experimento e Avaliação (artigo FSE Companion 2025) + +### Pipeline de Seleção de Projetos (§4.1) + +Processo em cascata partindo dos 2.500 projetos mais baixados do PyPI em 2023: + +| Etapa | Filtro | Projetos Restantes | +|---|---|---| +| 1 | Top 2500 downloads PyPI 2023 | 2.500 | +| 2 | Exclui sem URL GitHub, Python version ou pytest version no metadata | 315 | +| 3 | Exclui sem CI nos commits recentes ou clone >1 min | 122 | +| 4 | Mantém >1.000 estrelas + ≥1 TSR com falha na branch default | 58 | +| 5 | Inspeção manual dos top 40; verifica se build passa em ubuntu-latest/Python3 com TSR >45s | **12** | +| 6 | Extra: busca nos top 50.000 projetos que usam plugins de ordem aleatória, inspeciona 19 | +**2** | +| **Total** | | **14 projetos** | + +### Dataset + +- **14 projetos open-source** que usam pytest e GitHub Actions +- **27.224 builds** coletados via API do GitHub (TSR builds completos, Jan–Dez 2024) +- **718 commits selecionados** para reexecução → **4.308 TSRs** (6 ordens cada) +- Builds não-TSR (release, docs) e incompletos (cancelled, timed_out, skipped) removidos + +### Métrica + +- **APFDc** (Average Percentage of Faults Detected per Cost) — normalizada em [0, 1] + - `APFDc = ∑custos_TF / (num_bugs * ∑todas_durações)` onde `custo_TF(pos) = ∑duração[pos-1:] - duração[pos-1]/2` +- Dois mapeamentos falha→bug: `sameBug` (many-to-one) e `uniqueBug` (one-to-one); produzem resultados similares +- Comparação contra: ordem default e ordem aleatória (seed mediana) + +### Análise de Testes Flaky (§5.1) + +Das 4.308 TSRs, 1.121 tiveram ao menos uma falha. Destas: + +- **1.035 falhas de regressão** (bugs reais introduzidos por mudanças no código) +- **146 falhas flaky** (passam/falham sem mudança de código), distribuídas em 3 categorias: + +| Categoria | Qtd | Descrição | +|---|---|---| +| **OD (Order-Dependent)** | 112 (77%) | 107 Victims (falham se executados após um polluter), 5 Brittles (passam só após state-setter) | +| **Concurrency** | 6 | Falham apenas sob `pytest-xdist` (múltiplos processos competindo por recurso) | +| **ND (Non-Deterministic)** | 28 | Comportamento não-determinístico de APIs (ex: geradores aleatórios) | + +Testes flaky são **7× menos frequentes** que regressão mas falham **2.6× mais vezes por teste** (ciclo de vida mais longo). #10 execuções extras do Random expuseram +34 flaky tests. + +### Resultados de Efetividade (§5.2) + +`pytest-ranking` detecta falhas **mais rápido** que default e aleatório: + +| Técnica | APFDc médio | vs. Default | vs. Random | +|---|---|---|---| +| **Default** (linha base) | 0.47 | — | — | +| **Random** (linha base) | 0.50 | — | — | +| QTF | 0.78 | +66% | +56% | +| RecentFail | 0.65 | +38% | +30% | +| SimChgPath | 0.66 | +40% | +32% | +| **Hybrid** (0-0.5-0.5) | **0.83** | **+77%** | **+66%** | + +Overhead computacional **muito baixo**: +- Runtime médio: **0.1s** (0.03% da duração total da TSR) +- Cache: **6–538 KB** (média 5% do tamanho do log de CI) +- Duração da TSR varia apenas 1–10% entre ordens diferentes (média 4%) + +### Citação + +```bibtex +@inproceedings{cheng2025pytest, + title={{pytest-ranking: A Regression Test Prioritization Tool for Python}}, + author={Cheng, Runxiang and Ke, Kaiyao and Marinov, Darko}, + booktitle={Companion Proceedings of the 33rd ACM International Conference + on the Foundations of Software Engineering}, + year={2025}, +} +``` + +--- + +## Deploy em CI/CD + +### Setup Básico + +Segundo o artigo (§3), o setup em GitHub Actions adiciona **apenas 14 linhas de YAML** ao arquivo de workflow. O plugin persiste dados no cache do pytest (`.pytest_cache/v/pytest_ranking_data/`). Em CI efêmero (ex: GitHub Actions), é necessário: + +1. **Restore** antes do `pytest`: `actions/cache/restore@v4` apontando para `.pytest_cache/v/pytest_ranking_data` +2. **Save** após o `pytest`: `actions/cache/save@v4` apontando para o mesmo path + +Para projetos com Tox, o `cachedir` fica dentro de `.tox/TOX_ENV_NAME/.pytest_cache/`. + +### Exemplo Completo de Workflow (GitHub Actions) + +```yaml +- name: Restore pytest-ranking cache + id: restore-pytest-ranking-cache + if: always() + uses: actions/cache/restore@v4 + with: + path: ${{ github.workspace }}/.pytest_cache/v/pytest_ranking_data + key: pytest-ranking-cache-${{ runner.os }}-${{ matrix.python }} + +- name: Install dependencies + run: | + pip install pytest-ranking + pip install -r requirements.txt + +- name: Run tests with pytest-ranking + run: pytest --rank --rank-weight=0-0.5-0.5 + +- name: Save pytest-ranking cache + id: save-pytest-ranking-cache + if: always() + uses: actions/cache/save@v4 + with: + path: ${{ github.workspace }}/.pytest_cache/v/pytest_ranking_data + key: pytest-ranking-cache-${{ runner.os }}-${{ matrix.python }}-${{ github.run_id }} +``` + +### Experimento de Reexecução no Mundo Real (§4.2) + +Diferente de estudos anteriores que apenas **simulavam** ordens RTP em dados históricos, o artigo executou de fato as suites reordenadas no CI real. O procedimento foi: + +1. **Fork** de cada projeto e setup do `pytest-ranking` no workflow do GitHub Actions +2. Para cada build histórico, obtém-se o **archive zip** do repositório no commit exato ([14]) +3. Sobrescreve o fork com o conteúdo do archive, exceto pelos **arquivos de CI modificados** +4. Adiciona `uv.toml` com `exclude-newer` para instalar dependências compatíveis com a época do build +5. Faz **push** do código para o fork, disparando o GitHub Actions automaticamente +6. **Download** dos logs e artefatos (relatório JSON dos testes via `pytest-json-report`) +7. Para simular **overlap de builds**: se o build _i+1_ começou após o _i_ terminar, executa sequencialmente (usa cache atualizado); caso contrário, executa em paralelo (usa cache anterior) + +### Ordens Executadas no Experimento + +Para cada build, 6 workflows separados (para não haver interferência de cache): + +| Workflow | Flag | Objetivo | +|---|---|---| +| **Default** | `pytest` (sem `--rank`) | Linha base — ordem padrão do pytest | +| **Random** | `--rank-weight=0-0-0` | Linha base — ordem aleatória | +| **QTF** | `--rank-weight=1-0-0` | Testes mais rápidos primeiro | +| **RecentFail** | `--rank-weight=0-1-0` | Falhas recentes primeiro | +| **SimChgPath** | `--rank-weight=0-0-1` | Similaridade com mudanças | +| **Hybrid** | `--rank-weight=0-0.5-0.5` | Combinação linear com pesos iguais | + +Para o Random, foram feitas **10 execuções adicionais** por build com falha, usando o `run_id` como semente. + +### Seleção de Builds para Reexecução + +Dos 27.224 builds coletados, nem todos foram reexecutados. Para cada projeto: +- Selecionam-se **todos os builds com falha** +- Para cada build com falha, seleciona-se o **primeiro build bem-sucedido não-overlap anterior** (para criar o cache de dados correto) +- Continua até atingir o último build ou o **50º build**; depois continua até pelo menos **30 TSRs com falha** por projeto +- Total: **718 commits** reexecutados com sucesso → **4.308 TSRs** (6 ordens cada) + +### Compatibilidade com Versões de Dependências + +Como builds históricos podem exigir versões antigas de dependências, o experimento integrou o **UV** com a opção `--exclude-newer` configurada para a data de início de cada build, garantindo que apenas versões lançadas antes daquela data fossem instaladas. + +--- + +## Referências + +- Código: https://github.com/softwareTestingResearch/pytest-ranking +- PyPI: https://pypi.org/project/pytest-ranking +- Artigo: https://doi.org/10.1145/3696630.3728587 +- Demo: https://youtu.be/SrnkgTs3uok +- Tese: https://hdl.handle.net/2142/129204 + +--- + +## Repositório de Artefatos (pytest-ranking-artifact) + +O repositório **pytest-ranking-artifact** (https://github.com/softwareTestingResearch/pytest-ranking-artifact) contém scripts e dados utilizados nos experimentos de avaliação do plugin. Ele está dividido em duas pastas principais. + +### project_selection_scripts — Curadoria de Projetos Candidatos + +Scripts para selecionar projetos a partir do PyPI e filtrar candidatos viáveis para avaliação. + +| Script | Finalidade | +|---|---| +| `get_project_list.py` | Obtém lista dos projetos mais baixados em 2023 via BigQuery público do PyPI (`bigquery-public-data.pypi.file_downloads`), filtra top 2500, coleta metadados (classificadores, versão, `requires_python`, `requires_dist`) via API JSON do PyPI, e gera `project_candidate.csv` | +| `get_project_stats_via_github.py` | Obtém estatísticas do GitHub para os projetos candidatos (estrelas, forks, etc.) | +| `get_project_commit_stats.py` | Coleta commits de 2024 para cada projeto candidato, incluindo HEAD commit e status | +| `get_commit_status_df.py` | Gera DataFrame com estatísticas de commits por projeto | +| `get_commit_status_via_github.py` | Consulta status de commits via API GitHub | +| `const.py` | Diretórios de metadados, commits e buffer | +| `token_pool.py` | Pool de tokens de autenticação GitHub para rate limiting | + +**Processo de seleção:** +1. Consulta ao BigQuery público do PyPI para downloads em 2023 +2. Filtra top 2500 projetos mais baixados +3. Para cada projeto, coleta metadados via API PyPI JSON +4. Filtra projetos que **declaram dependência do pytest** (`requires_dist` contém `"pytest "`) +5. Filtra projetos com **URL GitHub válida** e **versão Python definida** +6. Remove duplicatas de URL GitHub (mantém apenas um slug por repositório) + +### rerun_test_build_scripts — Reexecução de Builds e Avaliação + +Scripts para coletar builds históricos, reexecutá-los em CI com diferentes ordens de teste, baixar resultados e computar métricas. + +| Script | Finalidade | +|---|---| +| `download_global_runs_dataset.py` | Cura lista de builds históricos do GitHub Actions CI a partir de `project_meta.json`; coleta metadados do build, metadados do commit e archive zip do repositório | +| `rerun_global_runs.py` | Reexecuta builds históricos em 6 ordens diferentes via GitHub Actions. Suporta CLI: `setup` (clona fork, backup CI files), `rerun` (checkout commit, modifica CI, push e dispara workflow), `download` (baixa logs e artefatos) | +| `rerun_random.py` | Reexecuta builds com ordem aleatória (10x por build, cada execução usa run_id como seed). Requer `regression_failed_runs.json` como entrada | +| `metrics.py` | Implementa as métricas de avaliação: classe `FaultDetectionMetric` com métodos `APFD()` e `APFDc()`, função `compute_metrics()`. Suporta dois mapeamentos falha-para-bug: `sameBug` (many-to-one — todas falhas contam como mesmo bug) e `uniqueBug` (one-to-one — cada falha conta como bug distinto) | +| `main.py` | Wrapper de linha de comando para `rerun_global_runs` | +| `local_const.py` | Constantes: URLs da API GitHub, nomes dos workflows, diretórios de dados, regex de status | +| `local_utils.py` | Utilitários: parsing de timestamps, extração de URL de artefato, etc. | +| `token_pool.py` | Pool de tokens GitHub para requisições autenticadas | +| `project_meta.json` | Metadados dos 14 projetos avaliados: `name`, `origin_slug`, `fork_slug`, `fork_branch`, `edited_ci_file_paths` | + +### Ordens de Teste Avaliadas + +Definidas em `local_const.py` com 6 workflows de CI diferentes: + +| Workflow | Nome no Experimento | Descrição | +|---|---|---| +| `pytest_default_order` | **Default** | Ordem padrão do pytest (sem `--rank`) | +| `random_order` | **Random** | Ordem aleatória (`--rank-weight=0-0-0`) | +| `qtf_order` | **QTF** | Quickest-test-first: `--rank-weight=1-0-0` (padrão do plugin) | +| `recent_fail_order` | **RecentFail** | Falhas recentes primeiro: `--rank-weight=0-1-0` | +| `change_first_order` | **SimChgPath** | Similaridade com mudanças: `--rank-weight=0-0-1` | +| `mix_order` | **Hybrid** | Combinação linear: `--rank-weight=0-0.5-0.5` | + +### eval_results — Análise dos Resultados + +| Script/Arquivo | Finalidade | +|---|---| +| `parse_rerun_results.py` | Parseia artefatos baixados para CSVs de resultado por (projeto, commit, ordem) | +| `analyze_rerun_results.py` | Computa resultados da avaliação. Gera: `dataset_statistics.csv`, `APFDc_one2one.csv`, `APFDc_many2one.csv`, `failure_statistics.csv`, `overhead_statistics.csv` | +| `flaky_test_failures.csv` | Falhas identificadas como flaky (inspenção manual) | +| `real_test_failures.csv` | Falhas reais de regressão (inspeção manual) | +| `regression_failed_runs.json` | Build IDs que contêm falhas de regressão | + +### Métricas de Avaliação (metrics.py) + +**APFD (Average Percentage of Faults Detected):** +``` +APFD = 1 - (sum(TFis) / (num_bugs * num_tests)) + (1 / (2 * num_tests)) + +onde TFis = posições (1-indexed) dos testes que falham +``` + +**APFDc (APFD com custo — duração dos testes):** +``` +APFDc = sum(custos_TF) / (num_bugs * sum(todas_durações)) + +onde custo_TF(pos) = sum(duração[pos-1:]) - (duração[pos-1] / 2) +``` + +### Dados Disponíveis para Download + +- **Dados brutos (~4 GB)**: Logs de CI e relatórios JSON de todas as ordens — https://drive.google.com/file/d/1osFBDosPCqmlkbkNGZ6dWA53lvhwkDM3 +- **Dados brutos (~1 GB)**: Logs de CI das 10 execuções aleatórias — https://drive.google.com/file/d/1c-9SWRBmK3EILkXPQDViEPMk8VrJQInG +- **Dados processados (~500 MB)**: CSVs de resultados parseados — https://drive.google.com/file/d/1zLfE9WkHMtqpQoWPaWgHsUmW4wN0GngF +- **Arquivos de CI modificados**: `modified_ci_files_for_rerun.zip` e `modified_ci_files_for_rerun_random_order.zip` + +### Processo de Reexecução (rerun_global_runs.py) + +O script `ForkProject` implementa o pipeline completo: + +1. **Setup**: Clona o fork do projeto, faz backup dos arquivos de CI originais +2. **Rerun**: Para cada build histórico: + - Baixa o archive zip do repositório no commit exato + - Substitui os arquivos de CI pelos modificados (com suporte a pytest-ranking + cache restore/save) + - Adiciona `uv.toml` com `exclude-newer` para instalar dependências compatíveis com a época + - Adiciona `pytest_ranking_seed.txt` com o run_id como seed para ordem aleatória + - Faz push force para o fork, disparando o workflow do GitHub Actions + - Executa builds em paralelo simulando overlap (respeitando a timeline real) +3. **Download**: Após conclusão, baixa logs e artefatos (relatório JSON dos testes via `pytest-json-report`) + +### Arquivos de CI Modificados + +Os arquivos `.zip` contêm workflows do GitHub Actions adaptados para cada projeto para: +- Instalar `pytest-ranking` como dependência +- Adicionar cache restore/save de `.pytest_cache/v/pytest_ranking_data` +- Executar pytest com a flag `--rank` e pesos específicos de cada ordem +- Fazer upload do relatório JSON como artefato do workflow + +### Dependências (requirements.txt) + +``` +numpy +pandas +Requests +pre-commit +``` diff --git a/run_test_local.sh b/run_test_local.sh index 6429ceb..8df6770 100755 --- a/run_test_local.sh +++ b/run_test_local.sh @@ -3,4 +3,4 @@ pip install . pytest -xv -pip uninstall pytest-ranking +pip uninstall pytest-ranked-selection diff --git a/setup.py b/setup.py index 37178f5..21eed3a 100644 --- a/setup.py +++ b/setup.py @@ -14,16 +14,16 @@ def read(fname): setup( - name='pytest-ranking', + name='pytest-ranked-selection', version='0.3.4', - author='softwareTestingResearch', - author_email='testingresearch4all@gmail.com', - maintainer='softwareTestingResearch', - maintainer_email='testingresearch4all@gmail.com', + author='leticosta4', + author_email='leticiacostaoa@gmail.com', + maintainer='leticosta4', + maintainer_email='leticiacostaoa@gmail.com', license='MIT', - url='https://github.com/softwareTestingResearch/pytest-ranking', + url='https://github.com/leticosta4/pytest-ranked-selection', description='A Pytest plugin for faster fault detection' - + ' via regression test prioritization', + + ' via regression test prioritization and selection.', long_description=read('README.md'), long_description_content_type='text/markdown', packages=find_packages('src'), @@ -59,11 +59,12 @@ def read(fname): 'software testing', 'regression testing', 'test prioritization', + 'test selection', 'pytest', ], entry_points={ 'pytest11': [ - 'pytest_ranking = pytest_ranking.plugin', + 'pytest_ranked_selection = pytest_ranked_selection.plugin', ], }, ) diff --git a/src/pytest_ranking/change_tracker.py b/src/pytest_ranking/change_tracker.py deleted file mode 100644 index d40bfd0..0000000 --- a/src/pytest_ranking/change_tracker.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import glob -import hashlib -import os -import re -import time - -from _pytest.config import Config -from _pytest.nodes import Item - -from .const import DATA_DIR - - -def tokenize(string: str) -> list[str]: - return re.findall(r'[a-zA-Z0-9]+', string.lower()) - - -class changeTracker: - def __init__(self, pytest_config: Config) -> None: - self.pytest_config = pytest_config - self.delta = set() - self.num_delta_files = 0 - self.runtime = 0 - # Get data of the changed file set. - self.get_delta() - - def get_all_file_paths(self): - """Get all file paths in the codebase.""" - pattern = os.path.join(self.pytest_config.rootpath, "**/*.py") - file_paths = glob.glob(pattern, recursive=True) - return file_paths - - def get_hash(self, file_path): - """Compute hash for the file.""" - with open(file_path, "rb") as f: - return hashlib.sha1(f.read()).hexdigest() - - def get_delta(self) -> None: - """Compute hashes for all files, - get token set for files whose hashes differ or have not been seen, - those are the changed or new files since last run (delta). - Save the newest hashes for all files. - Update the number of files that were re-computed hashes. - """ - start_time = time.time() - file_paths = self.get_all_file_paths() - hashes = {path: self.get_hash(path) for path in file_paths} - - key = os.path.join(DATA_DIR, "file_hashes") - # Load file hashes since last run. - old_hashes = self.pytest_config.cache.get(key, {}) - # Save newest hashes anyway. - self.pytest_config.cache.set(key, hashes) - - # If hashes are computed for the first time, - # No need to get delta. - if old_hashes == {}: - self.runtime += time.time() - start_time - return - - # Get files that have new hashes since last run. - for path, hash in hashes.items(): - if path not in old_hashes or old_hashes[path] != hash: - self.delta = self.delta.union(tokenize(path)) - self.num_delta_files += 1 - self.runtime += time.time() - start_time - - def compute_test_suite_similarity(self, items: list[Item]) -> None: - """Compute and save similarity to changed files per test.""" - start_time = time.time() - ret = {} - for item in items: - test_tokens = set(tokenize(item.nodeid)) - ret[item.nodeid] = len(self.delta.intersection(set(test_tokens))) - key = os.path.join(DATA_DIR, "change_similarity") - self.pytest_config.cache.set(key, ret) - self.runtime += time.time() - start_time diff --git a/src/pytest_ranking/const.py b/src/pytest_ranking/const.py index 1a0db1e..c2f90f9 100644 --- a/src/pytest_ranking/const.py +++ b/src/pytest_ranking/const.py @@ -2,12 +2,12 @@ # ------ Constants ------ -DATA_DIR = "pytest_ranking_data" +DATA_DIR = "pytest_ranked_selection_data" # Default amount of historical test run results to store per test. DEFAULT_HIST_LEN = 50 -DEFAULT_WEIGHT = "1-0-0" +DEFAULT_WEIGHT = "1-0" DEFAULT_SEED = 0 diff --git a/src/pytest_ranking/plugin.py b/src/pytest_ranking/plugin.py index a5f7afe..a1ce2b0 100644 --- a/src/pytest_ranking/plugin.py +++ b/src/pytest_ranking/plugin.py @@ -16,7 +16,6 @@ from _pytest.reports import TestReport from _pytest.terminal import TerminalReporter -from .change_tracker import changeTracker from .const import (DATA_DIR, DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, LEVEL) from .rank import get_ranking @@ -32,7 +31,7 @@ separated by hyphens `-`. The sum of weights will be normalized to 1. Higher weight means that heuristic will be favored. -Default value is 1-0-0. +Default value is 1-0. """) HIST_LEN_HELP = textwrap.dedent("""\ @@ -56,9 +55,9 @@ REPLAY_HELP = textwrap.dedent(""" Provide a text file where each line is a test ID. -pytest-ranking will run tests with the order defined in the file. +pytest-ranking will run tests with the order defined in the file. Default value is None. -""") +""") ## ver ainda o que fazer sobre isso def pytest_addoption(parser: Parser) -> None: @@ -121,13 +120,13 @@ def weight_type(string: str) -> str: return string try: weights = string.split("-") - assert len(weights) == 3 + assert len(weights) == 2 weights = [float(w) for w in weights] return string except (AssertionError, ValueError): raise argparse.ArgumentTypeError( "Cannot parse input for `--rank-weight`." - + "Valid examples: 1-0-0, 0.4-0.2-0.2, and 2-7-1." + + "Valid examples: 1-0, 0.4-0.2, and 2-7." ) @@ -144,6 +143,7 @@ def level_type(string: str) -> str: "Invalid input for `--rank-level`." + " Please run `pytest --help` for instruction." ) + #talvez add aqui a condição de "nivel de granularidade nao valido por causa do conjunto de testes selecionado" def replay_type(string: str) -> str: @@ -161,7 +161,7 @@ def replay_type(string: str) -> str: ) -def min_max_normalization(x: list[float]) -> np.ndarray: +def min_max_normalization(x: list[float]) -> np.ndarray: # talvez isso deva sair daqui x = np.array(x) x_range = (np.max(x) - np.min(x)) x = (x - np.min(x)) / x_range if x_range else np.zeros(len(x)) @@ -179,7 +179,6 @@ def __init__(self, config: Config) -> None: self.replay_file = self.parse_replay() self.hist_len = self.parse_hist_len() self.seed = self.parse_seed() - self.chgtracker = changeTracker(config) def parse_rtp_weights(self) -> list[float]: """Get weights, non-default CLI overrides ini file input.""" @@ -192,7 +191,7 @@ def parse_rtp_weights(self) -> list[float]: weights = [float(w) for w in weights] weight_sum = sum(weights) if weight_sum == 0: - return [0, 0, 0] + return [0, 0] weights = [w_i / weight_sum for w_i in weights] return weights @@ -270,7 +269,7 @@ def run_rtp(self, items: list[Item]) -> None: with open(self.replay_file) as f: test_list = [x.strip() for x in f.readlines()] scores = {x: i for i, x in enumerate(test_list)} - elif self.weights == [0, 0, 0]: + elif self.weights == [0, 0]: # Run tests in random order. # Pre-sort so that all workers gets the same order in pytest-xdist. # https://pytest-xdist.readthedocs.io/en/stable/known-limitations.html @@ -279,18 +278,17 @@ def run_rtp(self, items: list[Item]) -> None: scores = {item.nodeid: random.random() for item in items} else: # Prioritize by test features. - w_time, w_fail, w_rel = self.weights + w_time, w_fail = self.weights h_time = self.load_feature("last_durations", items, True) h_fail = self.load_feature("num_runs_since_fail", items, True) - h_rel = self.load_feature("change_similarity", items, False) def hybrid(i): # Linearly combine different heurisic values. # The higher, the earlier the test will be run. - s = h_time[i] * w_time + h_fail[i] * w_fail + h_rel[i] * w_rel + s = h_time[i] * w_time + h_fail[i] * w_fail return -s - scores = {item.nodeid: hybrid(i) for i, item in enumerate(items)} + scores = {item.nodeid: hybrid(i) for i, item in enumerate(items)} # revisar rank = get_ranking(scores, self.level, init_order) @@ -348,13 +346,13 @@ def pytest_report_header(self, config: Config) -> str: @pytest.hookimpl(trylast=True) def pytest_collection_modifyitems(self, items: list[Item]) -> None: if self.config.getoption("--rank"): - if self.replay_file and self.weights == [0, 0, 0]: + if self.replay_file and self.weights == [0, 0]: raise argparse.ArgumentTypeError( "--rank-replay cannot be used together with random order." ) self.run_rtp(items) - def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None: + def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None: #ver se insiro realmente exit status e session ou se removo start_time = time.time() compute_test_features(self.config, self.test_reports, self.hist_len) # Record feature collection runtime. @@ -366,11 +364,11 @@ def pytest_terminal_summary( self, terminalreporter: TerminalReporter, exitstatus: int, - config: Config) -> None: + config: Config) -> None: #ver se insiro realmente exit status e config ou se removo """Report plugin runtime when it is enabled.""" if self.config.getoption("--rank"): tr = terminalreporter - tr._tw.sep("=", "pytest-ranking summary info") + tr._tw.sep("=", "pytest-ranked-selection summary info") for k, v in self.log.items(): tr._tw.line(f"{k}: {v}") pass @@ -389,7 +387,7 @@ def compute_test_features( last_durations[nodeid] = round(duration, 3) config.cache.set(key, last_durations) - # Get the number of runs since its last failure per test. + # Get the number of runs since its last failure per test. => isso poderia ir pra outra função não? key = os.path.join(DATA_DIR, "num_runs_since_fail") num_runs_since_fail = config.cache.get(key, {}) for report in test_reports: From 06c38cf399659fa48edccb5fd26bc9c654e05761 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 26 Jun 2026 00:17:26 -0300 Subject: [PATCH 002/122] remove: remove remaining SCP parts from code --- ...anking.md => analise-old-pytest-ranking.md | 0 setup.py | 2 +- src/pytest_ranking/plugin.py | 6 - tests/test_pytest_ranking.py | 184 ++---------------- 4 files changed, 13 insertions(+), 179 deletions(-) rename analise-pytest-ranking.md => analise-old-pytest-ranking.md (100%) diff --git a/analise-pytest-ranking.md b/analise-old-pytest-ranking.md similarity index 100% rename from analise-pytest-ranking.md rename to analise-old-pytest-ranking.md diff --git a/setup.py b/setup.py index 21eed3a..6dfd047 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ def read(fname): ], entry_points={ 'pytest11': [ - 'pytest_ranked_selection = pytest_ranked_selection.plugin', + 'pytest_ranking = pytest_ranking.plugin', ], }, ) diff --git a/src/pytest_ranking/plugin.py b/src/pytest_ranking/plugin.py index a1ce2b0..33154f1 100644 --- a/src/pytest_ranking/plugin.py +++ b/src/pytest_ranking/plugin.py @@ -252,12 +252,6 @@ def run_rtp(self, items: list[Item]) -> None: """Run test prioritization algorithm.""" # Get pytest default order. init_order = {item.nodeid: i for i, item in enumerate(items)} - # Load code change features. - self.chgtracker.compute_test_suite_similarity(items) - num_delta_file = self.chgtracker.num_delta_files - compute_time = self.chgtracker.runtime - self.log["Number of changed Python files"] = num_delta_file - self.log["Time to compute test-change similarity (s)"] = compute_time # Start reordering. start_time = time.time() diff --git a/tests/test_pytest_ranking.py b/tests/test_pytest_ranking.py index 8952207..4f984e9 100644 --- a/tests/test_pytest_ranking.py +++ b/tests/test_pytest_ranking.py @@ -49,18 +49,6 @@ def test_medium(self): """ -source_method_one = \ - """ - print("source_method_one") - """ - - -source_class_one = \ - """ - print("source_method_one") - """ - - test_put_one = \ """ import pytest @@ -133,10 +121,9 @@ def test_faster_test_first(mytester): out.assert_outcomes(passed=4, failed=2) # Run with RTP. - args = ["-v", "--rank", "--rank-weight=1-0-0"] + args = ["-v", "--rank", "--rank-weight=1-0"] out = mytester.runpytest(*args) - # assert outcome to be the same as if no rtp out.assert_outcomes(passed=4, failed=2) # assert faster tests are run first out.stdout.fnmatch_lines( @@ -165,7 +152,7 @@ def test_recent_fail_first(mytester): out.assert_outcomes(passed=4, failed=2) # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-1-0"] + args = ["-v", "--rank", "--rank-weight=0-1"] out = mytester.runpytest(*args) # assert outcome to be the same as if no rtp @@ -185,22 +172,19 @@ def test_recent_fail_first(mytester): def test_550_weight(mytester): - """--rank-weight=.5-.5-0, run recently failed and faster tests first""" + """--rank-weight=5-5, run recently failed and faster tests first""" mytester.makepyfile( test_method_one=test_method_one, test_class_one=test_class_one, ) - # Run without RTP. args = ["-v"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=5-5-0"] + args = ["-v", "--rank", "--rank-weight=5-5"] out = mytester.runpytest(*args) - # assert outcome to be the same as if no rtp out.assert_outcomes(passed=4, failed=2) out.stdout.fnmatch_lines( [ @@ -216,146 +200,6 @@ def test_550_weight(mytester): pass -def test_001_028_weight(mytester): - """run failed tests more related to code change first""" - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - - mytester.makepyfile(source_method_one=source_method_one) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-0-1"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - # assert tests more related to the change are run first - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_slow PASSED", - "test_method_one.py::test_fast_fail FAILED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_class_one.py::TestClassSample::test_medium PASSED", - ], - consecutive=True - ) - - mytester.makepyfile(source_class_one=source_class_one) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-2-8"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_fast_fail FAILED", - "test_method_one.py::test_slow PASSED", - "test_method_one.py::test_medium PASSED", - ], - consecutive=True - ) - - -def test_208_093_weight(mytester): - """run faster tests more related to code change first""" - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - - mytester.makepyfile(source_method_one=source_method_one) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=.2-0-.8"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - # assert tests more related to the change are run first - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_method_one.py::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - - mytester.makepyfile(source_class_one=source_class_one) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-9-3"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_method_one.py::test_medium PASSED", - ], - consecutive=True - ) - - def test_logging(mytester): mytester.makepyfile( test_method_one=test_method_one @@ -371,13 +215,11 @@ def test_logging(mytester): "Using --rank-level", "Using --rank-hist-len", "Using --rank-seed", - "Number of changed Python files", - "Time to compute test-change similarity (s)", "Time to reorder tests (s)", "Time to collect test features (s)", ) - header = "= pytest-ranking summary info =" + header = "= pytest-ranked-selection summary info =" assert len([x for x in out.outlines if header in x]) == 0 assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 @@ -386,7 +228,7 @@ def test_logging(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=2, failed=1) # Should log everything. - assert len([x for x in out.outlines if x.startswith(log_text)]) == 8 + assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 def test_invalid_weight(mytester): @@ -422,8 +264,6 @@ def test_random_order(mytester): "Using --rank-level", "Using --rank-hist-len", "Using --rank-seed", - "Number of changed Python files", - "Time to compute test-change similarity (s)", "Time to reorder tests (s)", "Time to collect test features (s)", ) @@ -434,10 +274,10 @@ def test_random_order(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) # Should log everything. - assert len([x for x in out.outlines if x.startswith(log_text)]) == 8 + assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 # Run with default seed. - args = ["-v", "--rank", "--rank-weight=0-0-0"] + args = ["-v", "--rank", "--rank-weight=0-0"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) log_text = ( @@ -447,7 +287,7 @@ def test_random_order(mytester): test_lines_default1 = [x for x in out.outlines if "::" in x] # Run with specific seed. - args = ["-v", "--rank", "--rank-weight=0.0-0.0-0.0", "--rank-seed=8"] + args = ["-v", "--rank", "--rank-weight=0.0-0.0", "--rank-seed=8"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) log_text = ( @@ -457,7 +297,7 @@ def test_random_order(mytester): test_lines_1 = [x for x in out.outlines if "::" in x] # Run with specific seed. - args = ["-v", "--rank", "--rank-weight=0-0-0", "--rank-seed=16"] + args = ["-v", "--rank", "--rank-weight=0-0", "--rank-seed=16"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) log_text = ( @@ -476,7 +316,7 @@ def test_xdist(mytester): test_put_one=test_put_one, ) - args = ["-v", "--rank", "-n", "auto", "--rank-weight=0-0-0"] + args = ["-v", "--rank", "-n", "auto", "--rank-weight=0-0"] out = mytester.runpytest(*args) assert len([x for x in out.outlines if x.startswith("ERROR")]) == 0 pass @@ -942,7 +782,7 @@ def test_replay_with_random(mytester): "-v", "--rank", "--rank-replay=replay_order.txt", - "--rank-weight=0-0-0" + "--rank-weight=0-0" ] out = mytester.runpytest(*args) error_msg = "--rank-replay cannot be used together with random order." From 7295369e2276f56887b5ce4858b36a94aa943839 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 26 Jun 2026 00:18:49 -0300 Subject: [PATCH 003/122] refactor: separate help strings --- src/pytest_ranking/help_strings.py | 41 +++++++++++++++++++++++++++++ src/pytest_ranking/plugin.py | 42 ++---------------------------- 2 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 src/pytest_ranking/help_strings.py diff --git a/src/pytest_ranking/help_strings.py b/src/pytest_ranking/help_strings.py new file mode 100644 index 0000000..ec7c13d --- /dev/null +++ b/src/pytest_ranking/help_strings.py @@ -0,0 +1,41 @@ +import textwrap + + +PLUGIN_HELP = textwrap.dedent("""\ +Run regression test prioritization for pytest test suite. +It re-orders execution of tests to expose test failure sooner. +""") + + +WEIGHT_HELP = textwrap.dedent("""\ +Set weights on different prioritization heuristics, +separated by hyphens `-`. +The sum of weights will be normalized to 1. +Higher weight means that heuristic will be favored. +Default value is 1-0. +""") + +HIST_LEN_HELP = textwrap.dedent("""\ +The maximum number of previous test runs +that can be recorded for a test since the test has failed. +Default value is 50 (must be integer). +""") + +SEED_HELP = textwrap.dedent("""\ +Seed when running tests in random order. +You can run random order via setting `--rank-weight=0-0-0` +Default value is 0. +""") + +LEVEL_HELP = textwrap.dedent(""" +The test group level at which the prioritization takes place. +Test items below the configured level follow pytest default order. +Score of a test group is the mean score over all tests in that group. +Default value is PUT. +""") + +REPLAY_HELP = textwrap.dedent(""" +Provide a text file where each line is a test ID. +pytest-ranking will run tests with the order defined in the file. +Default value is None. +""") ## ver ainda o que fazer sobre isso diff --git a/src/pytest_ranking/plugin.py b/src/pytest_ranking/plugin.py index 33154f1..a244b57 100644 --- a/src/pytest_ranking/plugin.py +++ b/src/pytest_ranking/plugin.py @@ -3,7 +3,6 @@ import argparse import os import random -import textwrap import time from enum import Enum @@ -18,47 +17,10 @@ from .const import (DATA_DIR, DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, LEVEL) +from .help_strings import (HIST_LEN_HELP, LEVEL_HELP, PLUGIN_HELP, + REPLAY_HELP, SEED_HELP, WEIGHT_HELP) from .rank import get_ranking -PLUGIN_HELP = textwrap.dedent("""\ -Run regression test prioritization for pytest test suite. -It re-orders execution of tests to expose test failure sooner. -""") - - -WEIGHT_HELP = textwrap.dedent("""\ -Set weights on different prioritization heuristics, -separated by hyphens `-`. -The sum of weights will be normalized to 1. -Higher weight means that heuristic will be favored. -Default value is 1-0. -""") - -HIST_LEN_HELP = textwrap.dedent("""\ -The maximum number of previous test runs -that can be recorded for a test since the test has failed. -Default value is 50 (must be integer). -""") - -SEED_HELP = textwrap.dedent("""\ -Seed when running tests in random order. -You can run random order via setting `--rank-weight=0-0-0` -Default value is 0. -""") - -LEVEL_HELP = textwrap.dedent(""" -The test group level at which the prioritization takes place. -Test items below the configured level follow pytest default order. -Score of a test group is the mean score over all tests in that group. -Default value is PUT. -""") - -REPLAY_HELP = textwrap.dedent(""" -Provide a text file where each line is a test ID. -pytest-ranking will run tests with the order defined in the file. -Default value is None. -""") ## ver ainda o que fazer sobre isso - def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("rank", "pytest-ranking") From 71a9228be0d30b5729ec557b679f27311f902c2a Mon Sep 17 00:00:00 2001 From: let Date: Sat, 27 Jun 2026 10:35:18 -0300 Subject: [PATCH 004/122] refactor: update project name from pytest-ranked-selection to pytest-regsmart --- run_test_local.sh | 2 +- setup.py | 4 ++-- src/pytest_ranking/plugin.py | 2 +- tests/test_pytest_ranking.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/run_test_local.sh b/run_test_local.sh index 8df6770..2be3937 100755 --- a/run_test_local.sh +++ b/run_test_local.sh @@ -3,4 +3,4 @@ pip install . pytest -xv -pip uninstall pytest-ranked-selection +pip uninstall pytest-regsmart diff --git a/setup.py b/setup.py index 6dfd047..97da46a 100644 --- a/setup.py +++ b/setup.py @@ -14,14 +14,14 @@ def read(fname): setup( - name='pytest-ranked-selection', + name='pytest-regsmart', version='0.3.4', author='leticosta4', author_email='leticiacostaoa@gmail.com', maintainer='leticosta4', maintainer_email='leticiacostaoa@gmail.com', license='MIT', - url='https://github.com/leticosta4/pytest-ranked-selection', + url='https://github.com/leticosta4/pytest-regsmart', description='A Pytest plugin for faster fault detection' + ' via regression test prioritization and selection.', long_description=read('README.md'), diff --git a/src/pytest_ranking/plugin.py b/src/pytest_ranking/plugin.py index a244b57..084fb6e 100644 --- a/src/pytest_ranking/plugin.py +++ b/src/pytest_ranking/plugin.py @@ -324,7 +324,7 @@ def pytest_terminal_summary( """Report plugin runtime when it is enabled.""" if self.config.getoption("--rank"): tr = terminalreporter - tr._tw.sep("=", "pytest-ranked-selection summary info") + tr._tw.sep("=", "pytest-regsmart summary info") for k, v in self.log.items(): tr._tw.line(f"{k}: {v}") pass diff --git a/tests/test_pytest_ranking.py b/tests/test_pytest_ranking.py index 4f984e9..9a561aa 100644 --- a/tests/test_pytest_ranking.py +++ b/tests/test_pytest_ranking.py @@ -219,7 +219,7 @@ def test_logging(mytester): "Time to collect test features (s)", ) - header = "= pytest-ranked-selection summary info =" + header = "= pytest-regsmart summary info =" assert len([x for x in out.outlines if header in x]) == 0 assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 From 82356f09f3743d79fed312b5311b26e6e2809e9a Mon Sep 17 00:00:00 2001 From: let Date: Sun, 19 Jul 2026 15:19:01 -0300 Subject: [PATCH 005/122] refactor: update gitignore (remove studying and experiments docs) --- .gitignore | 2 + docs/DEPLOYMENT.md => DEPLOYMENT.md | 0 analise-old-pytest-ranking.md | 554 ---------------------------- 3 files changed, 2 insertions(+), 554 deletions(-) rename docs/DEPLOYMENT.md => DEPLOYMENT.md (100%) delete mode 100644 analise-old-pytest-ranking.md diff --git a/.gitignore b/.gitignore index ec2de4a..afc5c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ dist/ *.egg-info/ +estudo/ +validacao-exeprimentos/ \ No newline at end of file diff --git a/docs/DEPLOYMENT.md b/DEPLOYMENT.md similarity index 100% rename from docs/DEPLOYMENT.md rename to DEPLOYMENT.md diff --git a/analise-old-pytest-ranking.md b/analise-old-pytest-ranking.md deleted file mode 100644 index 64c635d..0000000 --- a/analise-old-pytest-ranking.md +++ /dev/null @@ -1,554 +0,0 @@ -# pytest-ranking — Documentação Detalhada - -## Visão Geral - -**pytest-ranking** é um plugin pytest que implementa **Regression Test Prioritization (RTP)** para acelerar a detecção de falhas em regressão. Ele reordena a execução dos testes para que os mais propensos a falhar executem primeiro, baseando-se em três heurísticas combináveis: - -1. **Testes mais rápidos primeiro** (peso `1-0-0`, padrão) -2. **Testes que falharam recentemente primeiro** (peso `0-1-0`) -3. **Testes relacionados a mudanças no código** (peso `0-0-1`) - -O plugin foi criado por Runxiang Cheng, Kaiyao Ke e Darko Marinov (University of Illinois), apresentado no FSE Companion 2025, e avaliado em **4.308 builds de 14 projetos open-source** no GitHub Actions CI. - ---- - -## Arquitetura do Projeto - -``` -src/pytest_ranking/ -├── __init__.py # Pacote vazio -├── const.py # Constantes, defaults, enum LEVEL -├── change_tracker.py # changeTracker: hasheia .py, detecta delta, similaridade textual -├── rank.py # get_test_group() + get_ranking(): agrega scores por nível -└── plugin.py # RTPRunner (classe principal), hooks pytest, compute_test_features -``` - -### Quatro Componentes Principais (artigo §2.1) - -O artigo define **quatro componentes** que interagem com o core do pytest, conforme ilustrado na **Figura 1** (página 1090): - -``` - Pytest pytest-ranking - ┌──────────────┐ ┌──────────────────────────┐ - │ Collect Tests│──(1)──▶ │ Ranker │ - │ │◀──(2)── │ │ - │ Execute Tests│──(3)──▶ │ Monitor │ - │ │ │ │ - │ Pytest Cache │◀──(5)── │ Extractor │ - │ │ │ │ - │ Print Summary│◀──(7)── │ Reporter │ - └──────────────┘ └──────────────────────────┘ -``` - -**Fluxo numerado conforme a Figura 1:** - -1. **(1)** Pytest fornece a lista de testes coletados ao **Ranker** -2. **(2)** Ranker devolve a lista priorizada para o Pytest executar -3. **(3)** Conforme cada teste termina, Pytest chama o **Monitor** com um objeto `TestReport` (duração, resultado) -4. **(4)** **Monitor** apenas acumula os `TestReport` em memória (lista no `RTPRunner`) — sem processamento para minimizar overhead -5. **(5)** Quando toda a suite termina, o **Extractor** processa os relatórios e salva dados no cache do pytest -6. **(6)** No próximo TSR, o **Ranker** carrega os dados cacheados para priorizar -7. **(7)** Ao final, o **Reporter** adiciona um sumário ao terminal do pytest com configuração e overhead - -### Fluxo de Execução Detalhado - -1. `pytest_configure` → instancia `RTPRunner` (contém os 4 componentes) e registra no `pluginmanager` -2. `pytest_report_header` → **Reporter** exibe configurações se `--rank` ativo -3. `changeTracker.__init__` → hasheia todos `*.py` e computa delta (arquivos alterados) — usado pelo **Ranker** -4. `pytest_collection_modifyitems` → **Ranker** valida replay+random, chama `run_rtp(items)` para reordenar -5. `pytest_runtest_logreport` → **Monitor** captura `TestReport` (não-skipped, `when=="call"`) -6. `pytest_sessionfinish` → **Extractor** persiste `last_durations` e `num_runs_since_fail` no cache -7. `pytest_terminal_summary` → **Reporter** exibe métricas de overhead - -### Armazenamento de Dados - -Toda a persistência usa o **cache interno do pytest** (`config.cache`), armazenado em `.pytest_cache/v/pytest_ranking_data/`: - -| Chave | Conteúdo | -|---|---| -| `file_hashes` | SHA1 de cada `*.py` no projeto | -| `change_similarity` | Interseção de tokens entre nodeid do teste e paths alterados | -| `last_durations` | Último tempo de execução (`round(duration, 3)`) por nodeid | -| `num_runs_since_fail` | Nº de execuções desde a última falha (limitado por `--rank-hist-len`) | - ---- - -## Parâmetros de Entrada - -### Linha de comando / addopts - -| Flag | Tipo | Default | Descrição | -|---|---|---|---| -| `--rank` | flag | `False` | Ativa o plugin | -| `--rank-weight` | string `"A-B-C"` | `"1-0-0"` | Pesos para tempo, falha, similaridade (normalizados p/ soma=1) | -| `--rank-level` | `put` / `function` / `module` / `dir` | `"put"` | Nível de agrupamento para reordenação | -| `--rank-hist-len` | int | `50` | Máximo de execuções registradas desde última falha | -| `--rank-seed` | int | `0` | Semente para ordem aleatória | -| `--rank-replay` | path | `None` | Arquivo com ordem explícita de testes (1 ID por linha) | - -### Config file (pytest.ini) - -```ini -[pytest] -rank_weight = 0-1-0 -rank_level = module -rank_hist_len = 30 -rank_seed = 42 -``` - -CLI não-default sobrescreve `pytest.ini` (`RTPRunner.parse_*()`). - ---- - -## Parâmetros de Saída - -### Cabeçalho (antes da execução) - -``` -Using --rank-weight=1-0-0 -Using --rank-level=put -Using --rank-hist-len=50 -Using --rank-seed=0 -Using --rank-replay=None -``` - -### Sumário (após execução) - -``` -=== pytest-ranking summary info === -Number of changed Python files: 3 -Time to compute test-change similarity (s): 0.00087 -Time to reorder tests (s): 0.00036 -Time to collect test features (s): 0.00046 -``` - ---- - -## Níveis de Reordenação (LEVEL) - -| Nível | Agrupamento | Exemplo (nodeid) | -|---|---|---| -| `put` | Cada parametrização individual | `test_file.py::TestClass::test_method[param1]` | -| `function` | Função de teste sem parâmetros | `test_file.py::TestClass::test_method` | -| `module` | Arquivo de teste | `test_file.py` | -| `dir` | Diretório | `tests/` | - -O score de um grupo é a **média aritmética** dos scores individuais dos testes no grupo. Testes dentro do mesmo grupo seguem a ordem padrão do pytest. - ---- - -## HOOKs do pytest Utilizados - -Os hooks são a API do pytest que o plugin reimplementa para alterar o comportamento padrão ([55]). A tabela abaixo mapeia cada hook ao componente do artigo (§2.1) que o implementa: - -| Hook | Componente | Localização | Finalidade | -|---|---|---|---| -| `pytest_addoption(parser)` | — | `plugin.py` | Registra `--rank`, `--rank-weight`, `--rank-level`, `--rank-hist-len`, `--rank-seed`, `--rank-replay` + 5 opções `addini()` | -| `pytest_configure(config)` | **Runner** | `plugin.py` (trylast) | Instancia `RTPRunner` e registra no `pluginmanager` ([49]) | -| `pytest_report_header(config)` | **Reporter** | `plugin.py` | Exibe configuração no cabeçalho se `--rank` ativo | -| `pytest_collection_modifyitems(items)` | **Ranker** ([50]) | `plugin.py` (trylast) | Valida replay+random, chama `run_rtp(items)` que reordena a lista | -| `pytest_runtest_logreport(report)` | **Monitor** ([51]) | `plugin.py` | Acumula `TestReport` dos testes (não-skipped, `when=="call"`) | -| `pytest_sessionfinish(session, exitstatus)` | **Extractor** | `plugin.py` | Persiste `last_durations` e `num_runs_since_fail` no cache | -| `pytest_terminal_summary(terminalreporter, exitstatus, config)` | **Reporter** ([52]) | `plugin.py` | Exibe overhead: tempo de similaridade, reordenação, coleta | - ---- - -## Etapas de Coleta (Feature Collection) - -### 1. changeTracker.get_delta() — ao iniciar - -- Varre todos `**/*.py` abaixo de `rootpath` -- Calcula SHA1 de cada arquivo -- Compara com hashes salvos no cache (`file_hashes`) -- Se houve mudança, tokeniza o path do arquivo e adiciona ao conjunto `delta` -- Salva hashes atualizados - -### 2. compute_test_suite_similarity(items) — durante run_rtp() - -- Para cada `item`, tokeniza `item.nodeid` -- Calcula `|delta ∩ tokens(item.nodeid)|` (interseção de conjuntos) -- Salva em cache (`change_similarity`) - -### 3. load_feature(feature_name, items, reverse) — durante run_rtp() - -- Carrega do cache: `last_durations` (reverse=True), `num_runs_since_fail` (reverse=True), `change_similarity` (reverse=False) -- Aplica `min_max_normalization` → valores em [0,1] -- Se `reverse=True`, transforma para `1 - valor` (menor é melhor vira maior é melhor) - -### 4. compute_test_features() — ao final da sessão - -- Atualiza `last_durations[nodeid]` com `report.duration` de cada teste -- Atualiza `num_runs_since_fail[nodeid]`: 0 se falhou, `min(hist_len, valor_anterior + 1)` se passou - ---- - -## Algoritmo de Ranqueamento - -### Cálculo do Score Individual - -``` -score(item) = -(w_t * norm_time + w_f * norm_fail + w_r * norm_rel) - -onde: - w_t + w_f + w_r = 1 (após normalização) - norm_time = 1 - min_max_normalize(last_durations[nodeid]) - norm_fail = 1 - min_max_normalize(num_runs_since_fail[nodeid]) - norm_rel = min_max_normalize(change_similarity[nodeid]) - -Menor score → executado antes -``` - -### Agregação por Grupo - -``` -group_score(grupo) = mean(score(item) para todo item no grupo) -rank(item) = posição ordenada por (group_score, init_order) -``` - -### Ordenação Final - -1. Testes com `@pytest.mark.order` ou `@pytest.mark.dependency` (OD) são mantidos no início, na ordem original -2. Testes sem OD são ordenados por `rank.get(nodeid, 0)` e depois `init_order` (desempate) -3. `items[:] = od_items + nod_items` (substitui a lista in-place) - -### Modos Especiais - -- **Replay** (`--rank-replay`): scores = posição no arquivo; ignora pesos -- **Aleatório** (`--rank-weight=0-0-0`): scores = `random.random()`; pre-sort por `nodeid` para compatibilidade com xdist -- Erro se `--rank-replay` + `--rank-weight=0-0-0` - ---- - -## Detecção de Mudanças (changeTracker) - -```python -changeTracker.__init__(pytest_config) -├── get_all_file_paths() -> glob("**/*.py", recursive=True) -├── get_hash(file_path) -> hashlib.sha1(file.read()).hexdigest() -├── get_delta() -> compara hashes, tokeniza paths alterados -└── compute_test_suite_similarity(items) -> interseção tokens -``` - -`tokenize(string)` usa `re.findall(r'[a-zA-Z0-9]+', string.lower())` — extrai tokens alfanuméricos em lowercase. - -Exemplo: path `src/utils/helper.py` → tokens `{"src", "utils", "helper", "py"}`. - ---- - -## Comunicação com o Ecossistema pytest - -### Descoberta Automática - -```python -entry_points={ - 'pytest11': ['pytest_ranking = pytest_ranking.plugin'], -} -``` - -### Compatibilidade com pytest-xdist - -- Pre-sort dos items por `nodeid` antes de aplicar `random.seed()` para garantir mesma ordem em todos workers -- Reordenação ocorre antes da distribuição (hook `pytest_collection_modifyitems`) - -### Respeito a Dependências de Ordem - -```python -if item.get_closest_marker('order') or item.get_closest_marker('dependency'): - od_items.append(item) -else: - nod_items.append(item) -``` - -Testes com `@pytest.mark.order(n)` ou `@pytest.mark.dependency(...)` executam primeiro, na ordem original. - -### Plugins Potencialmente Conflitantes - -`--ff` (pytest), `pytest-randomly`, `pytest-random-order`, `pytest-reverse` — usam o mesmo hook `pytest_collection_modifyitems`. - ---- - -## Experimento e Avaliação (artigo FSE Companion 2025) - -### Pipeline de Seleção de Projetos (§4.1) - -Processo em cascata partindo dos 2.500 projetos mais baixados do PyPI em 2023: - -| Etapa | Filtro | Projetos Restantes | -|---|---|---| -| 1 | Top 2500 downloads PyPI 2023 | 2.500 | -| 2 | Exclui sem URL GitHub, Python version ou pytest version no metadata | 315 | -| 3 | Exclui sem CI nos commits recentes ou clone >1 min | 122 | -| 4 | Mantém >1.000 estrelas + ≥1 TSR com falha na branch default | 58 | -| 5 | Inspeção manual dos top 40; verifica se build passa em ubuntu-latest/Python3 com TSR >45s | **12** | -| 6 | Extra: busca nos top 50.000 projetos que usam plugins de ordem aleatória, inspeciona 19 | +**2** | -| **Total** | | **14 projetos** | - -### Dataset - -- **14 projetos open-source** que usam pytest e GitHub Actions -- **27.224 builds** coletados via API do GitHub (TSR builds completos, Jan–Dez 2024) -- **718 commits selecionados** para reexecução → **4.308 TSRs** (6 ordens cada) -- Builds não-TSR (release, docs) e incompletos (cancelled, timed_out, skipped) removidos - -### Métrica - -- **APFDc** (Average Percentage of Faults Detected per Cost) — normalizada em [0, 1] - - `APFDc = ∑custos_TF / (num_bugs * ∑todas_durações)` onde `custo_TF(pos) = ∑duração[pos-1:] - duração[pos-1]/2` -- Dois mapeamentos falha→bug: `sameBug` (many-to-one) e `uniqueBug` (one-to-one); produzem resultados similares -- Comparação contra: ordem default e ordem aleatória (seed mediana) - -### Análise de Testes Flaky (§5.1) - -Das 4.308 TSRs, 1.121 tiveram ao menos uma falha. Destas: - -- **1.035 falhas de regressão** (bugs reais introduzidos por mudanças no código) -- **146 falhas flaky** (passam/falham sem mudança de código), distribuídas em 3 categorias: - -| Categoria | Qtd | Descrição | -|---|---|---| -| **OD (Order-Dependent)** | 112 (77%) | 107 Victims (falham se executados após um polluter), 5 Brittles (passam só após state-setter) | -| **Concurrency** | 6 | Falham apenas sob `pytest-xdist` (múltiplos processos competindo por recurso) | -| **ND (Non-Deterministic)** | 28 | Comportamento não-determinístico de APIs (ex: geradores aleatórios) | - -Testes flaky são **7× menos frequentes** que regressão mas falham **2.6× mais vezes por teste** (ciclo de vida mais longo). #10 execuções extras do Random expuseram +34 flaky tests. - -### Resultados de Efetividade (§5.2) - -`pytest-ranking` detecta falhas **mais rápido** que default e aleatório: - -| Técnica | APFDc médio | vs. Default | vs. Random | -|---|---|---|---| -| **Default** (linha base) | 0.47 | — | — | -| **Random** (linha base) | 0.50 | — | — | -| QTF | 0.78 | +66% | +56% | -| RecentFail | 0.65 | +38% | +30% | -| SimChgPath | 0.66 | +40% | +32% | -| **Hybrid** (0-0.5-0.5) | **0.83** | **+77%** | **+66%** | - -Overhead computacional **muito baixo**: -- Runtime médio: **0.1s** (0.03% da duração total da TSR) -- Cache: **6–538 KB** (média 5% do tamanho do log de CI) -- Duração da TSR varia apenas 1–10% entre ordens diferentes (média 4%) - -### Citação - -```bibtex -@inproceedings{cheng2025pytest, - title={{pytest-ranking: A Regression Test Prioritization Tool for Python}}, - author={Cheng, Runxiang and Ke, Kaiyao and Marinov, Darko}, - booktitle={Companion Proceedings of the 33rd ACM International Conference - on the Foundations of Software Engineering}, - year={2025}, -} -``` - ---- - -## Deploy em CI/CD - -### Setup Básico - -Segundo o artigo (§3), o setup em GitHub Actions adiciona **apenas 14 linhas de YAML** ao arquivo de workflow. O plugin persiste dados no cache do pytest (`.pytest_cache/v/pytest_ranking_data/`). Em CI efêmero (ex: GitHub Actions), é necessário: - -1. **Restore** antes do `pytest`: `actions/cache/restore@v4` apontando para `.pytest_cache/v/pytest_ranking_data` -2. **Save** após o `pytest`: `actions/cache/save@v4` apontando para o mesmo path - -Para projetos com Tox, o `cachedir` fica dentro de `.tox/TOX_ENV_NAME/.pytest_cache/`. - -### Exemplo Completo de Workflow (GitHub Actions) - -```yaml -- name: Restore pytest-ranking cache - id: restore-pytest-ranking-cache - if: always() - uses: actions/cache/restore@v4 - with: - path: ${{ github.workspace }}/.pytest_cache/v/pytest_ranking_data - key: pytest-ranking-cache-${{ runner.os }}-${{ matrix.python }} - -- name: Install dependencies - run: | - pip install pytest-ranking - pip install -r requirements.txt - -- name: Run tests with pytest-ranking - run: pytest --rank --rank-weight=0-0.5-0.5 - -- name: Save pytest-ranking cache - id: save-pytest-ranking-cache - if: always() - uses: actions/cache/save@v4 - with: - path: ${{ github.workspace }}/.pytest_cache/v/pytest_ranking_data - key: pytest-ranking-cache-${{ runner.os }}-${{ matrix.python }}-${{ github.run_id }} -``` - -### Experimento de Reexecução no Mundo Real (§4.2) - -Diferente de estudos anteriores que apenas **simulavam** ordens RTP em dados históricos, o artigo executou de fato as suites reordenadas no CI real. O procedimento foi: - -1. **Fork** de cada projeto e setup do `pytest-ranking` no workflow do GitHub Actions -2. Para cada build histórico, obtém-se o **archive zip** do repositório no commit exato ([14]) -3. Sobrescreve o fork com o conteúdo do archive, exceto pelos **arquivos de CI modificados** -4. Adiciona `uv.toml` com `exclude-newer` para instalar dependências compatíveis com a época do build -5. Faz **push** do código para o fork, disparando o GitHub Actions automaticamente -6. **Download** dos logs e artefatos (relatório JSON dos testes via `pytest-json-report`) -7. Para simular **overlap de builds**: se o build _i+1_ começou após o _i_ terminar, executa sequencialmente (usa cache atualizado); caso contrário, executa em paralelo (usa cache anterior) - -### Ordens Executadas no Experimento - -Para cada build, 6 workflows separados (para não haver interferência de cache): - -| Workflow | Flag | Objetivo | -|---|---|---| -| **Default** | `pytest` (sem `--rank`) | Linha base — ordem padrão do pytest | -| **Random** | `--rank-weight=0-0-0` | Linha base — ordem aleatória | -| **QTF** | `--rank-weight=1-0-0` | Testes mais rápidos primeiro | -| **RecentFail** | `--rank-weight=0-1-0` | Falhas recentes primeiro | -| **SimChgPath** | `--rank-weight=0-0-1` | Similaridade com mudanças | -| **Hybrid** | `--rank-weight=0-0.5-0.5` | Combinação linear com pesos iguais | - -Para o Random, foram feitas **10 execuções adicionais** por build com falha, usando o `run_id` como semente. - -### Seleção de Builds para Reexecução - -Dos 27.224 builds coletados, nem todos foram reexecutados. Para cada projeto: -- Selecionam-se **todos os builds com falha** -- Para cada build com falha, seleciona-se o **primeiro build bem-sucedido não-overlap anterior** (para criar o cache de dados correto) -- Continua até atingir o último build ou o **50º build**; depois continua até pelo menos **30 TSRs com falha** por projeto -- Total: **718 commits** reexecutados com sucesso → **4.308 TSRs** (6 ordens cada) - -### Compatibilidade com Versões de Dependências - -Como builds históricos podem exigir versões antigas de dependências, o experimento integrou o **UV** com a opção `--exclude-newer` configurada para a data de início de cada build, garantindo que apenas versões lançadas antes daquela data fossem instaladas. - ---- - -## Referências - -- Código: https://github.com/softwareTestingResearch/pytest-ranking -- PyPI: https://pypi.org/project/pytest-ranking -- Artigo: https://doi.org/10.1145/3696630.3728587 -- Demo: https://youtu.be/SrnkgTs3uok -- Tese: https://hdl.handle.net/2142/129204 - ---- - -## Repositório de Artefatos (pytest-ranking-artifact) - -O repositório **pytest-ranking-artifact** (https://github.com/softwareTestingResearch/pytest-ranking-artifact) contém scripts e dados utilizados nos experimentos de avaliação do plugin. Ele está dividido em duas pastas principais. - -### project_selection_scripts — Curadoria de Projetos Candidatos - -Scripts para selecionar projetos a partir do PyPI e filtrar candidatos viáveis para avaliação. - -| Script | Finalidade | -|---|---| -| `get_project_list.py` | Obtém lista dos projetos mais baixados em 2023 via BigQuery público do PyPI (`bigquery-public-data.pypi.file_downloads`), filtra top 2500, coleta metadados (classificadores, versão, `requires_python`, `requires_dist`) via API JSON do PyPI, e gera `project_candidate.csv` | -| `get_project_stats_via_github.py` | Obtém estatísticas do GitHub para os projetos candidatos (estrelas, forks, etc.) | -| `get_project_commit_stats.py` | Coleta commits de 2024 para cada projeto candidato, incluindo HEAD commit e status | -| `get_commit_status_df.py` | Gera DataFrame com estatísticas de commits por projeto | -| `get_commit_status_via_github.py` | Consulta status de commits via API GitHub | -| `const.py` | Diretórios de metadados, commits e buffer | -| `token_pool.py` | Pool de tokens de autenticação GitHub para rate limiting | - -**Processo de seleção:** -1. Consulta ao BigQuery público do PyPI para downloads em 2023 -2. Filtra top 2500 projetos mais baixados -3. Para cada projeto, coleta metadados via API PyPI JSON -4. Filtra projetos que **declaram dependência do pytest** (`requires_dist` contém `"pytest "`) -5. Filtra projetos com **URL GitHub válida** e **versão Python definida** -6. Remove duplicatas de URL GitHub (mantém apenas um slug por repositório) - -### rerun_test_build_scripts — Reexecução de Builds e Avaliação - -Scripts para coletar builds históricos, reexecutá-los em CI com diferentes ordens de teste, baixar resultados e computar métricas. - -| Script | Finalidade | -|---|---| -| `download_global_runs_dataset.py` | Cura lista de builds históricos do GitHub Actions CI a partir de `project_meta.json`; coleta metadados do build, metadados do commit e archive zip do repositório | -| `rerun_global_runs.py` | Reexecuta builds históricos em 6 ordens diferentes via GitHub Actions. Suporta CLI: `setup` (clona fork, backup CI files), `rerun` (checkout commit, modifica CI, push e dispara workflow), `download` (baixa logs e artefatos) | -| `rerun_random.py` | Reexecuta builds com ordem aleatória (10x por build, cada execução usa run_id como seed). Requer `regression_failed_runs.json` como entrada | -| `metrics.py` | Implementa as métricas de avaliação: classe `FaultDetectionMetric` com métodos `APFD()` e `APFDc()`, função `compute_metrics()`. Suporta dois mapeamentos falha-para-bug: `sameBug` (many-to-one — todas falhas contam como mesmo bug) e `uniqueBug` (one-to-one — cada falha conta como bug distinto) | -| `main.py` | Wrapper de linha de comando para `rerun_global_runs` | -| `local_const.py` | Constantes: URLs da API GitHub, nomes dos workflows, diretórios de dados, regex de status | -| `local_utils.py` | Utilitários: parsing de timestamps, extração de URL de artefato, etc. | -| `token_pool.py` | Pool de tokens GitHub para requisições autenticadas | -| `project_meta.json` | Metadados dos 14 projetos avaliados: `name`, `origin_slug`, `fork_slug`, `fork_branch`, `edited_ci_file_paths` | - -### Ordens de Teste Avaliadas - -Definidas em `local_const.py` com 6 workflows de CI diferentes: - -| Workflow | Nome no Experimento | Descrição | -|---|---|---| -| `pytest_default_order` | **Default** | Ordem padrão do pytest (sem `--rank`) | -| `random_order` | **Random** | Ordem aleatória (`--rank-weight=0-0-0`) | -| `qtf_order` | **QTF** | Quickest-test-first: `--rank-weight=1-0-0` (padrão do plugin) | -| `recent_fail_order` | **RecentFail** | Falhas recentes primeiro: `--rank-weight=0-1-0` | -| `change_first_order` | **SimChgPath** | Similaridade com mudanças: `--rank-weight=0-0-1` | -| `mix_order` | **Hybrid** | Combinação linear: `--rank-weight=0-0.5-0.5` | - -### eval_results — Análise dos Resultados - -| Script/Arquivo | Finalidade | -|---|---| -| `parse_rerun_results.py` | Parseia artefatos baixados para CSVs de resultado por (projeto, commit, ordem) | -| `analyze_rerun_results.py` | Computa resultados da avaliação. Gera: `dataset_statistics.csv`, `APFDc_one2one.csv`, `APFDc_many2one.csv`, `failure_statistics.csv`, `overhead_statistics.csv` | -| `flaky_test_failures.csv` | Falhas identificadas como flaky (inspenção manual) | -| `real_test_failures.csv` | Falhas reais de regressão (inspeção manual) | -| `regression_failed_runs.json` | Build IDs que contêm falhas de regressão | - -### Métricas de Avaliação (metrics.py) - -**APFD (Average Percentage of Faults Detected):** -``` -APFD = 1 - (sum(TFis) / (num_bugs * num_tests)) + (1 / (2 * num_tests)) - -onde TFis = posições (1-indexed) dos testes que falham -``` - -**APFDc (APFD com custo — duração dos testes):** -``` -APFDc = sum(custos_TF) / (num_bugs * sum(todas_durações)) - -onde custo_TF(pos) = sum(duração[pos-1:]) - (duração[pos-1] / 2) -``` - -### Dados Disponíveis para Download - -- **Dados brutos (~4 GB)**: Logs de CI e relatórios JSON de todas as ordens — https://drive.google.com/file/d/1osFBDosPCqmlkbkNGZ6dWA53lvhwkDM3 -- **Dados brutos (~1 GB)**: Logs de CI das 10 execuções aleatórias — https://drive.google.com/file/d/1c-9SWRBmK3EILkXPQDViEPMk8VrJQInG -- **Dados processados (~500 MB)**: CSVs de resultados parseados — https://drive.google.com/file/d/1zLfE9WkHMtqpQoWPaWgHsUmW4wN0GngF -- **Arquivos de CI modificados**: `modified_ci_files_for_rerun.zip` e `modified_ci_files_for_rerun_random_order.zip` - -### Processo de Reexecução (rerun_global_runs.py) - -O script `ForkProject` implementa o pipeline completo: - -1. **Setup**: Clona o fork do projeto, faz backup dos arquivos de CI originais -2. **Rerun**: Para cada build histórico: - - Baixa o archive zip do repositório no commit exato - - Substitui os arquivos de CI pelos modificados (com suporte a pytest-ranking + cache restore/save) - - Adiciona `uv.toml` com `exclude-newer` para instalar dependências compatíveis com a época - - Adiciona `pytest_ranking_seed.txt` com o run_id como seed para ordem aleatória - - Faz push force para o fork, disparando o workflow do GitHub Actions - - Executa builds em paralelo simulando overlap (respeitando a timeline real) -3. **Download**: Após conclusão, baixa logs e artefatos (relatório JSON dos testes via `pytest-json-report`) - -### Arquivos de CI Modificados - -Os arquivos `.zip` contêm workflows do GitHub Actions adaptados para cada projeto para: -- Instalar `pytest-ranking` como dependência -- Adicionar cache restore/save de `.pytest_cache/v/pytest_ranking_data` -- Executar pytest com a flag `--rank` e pesos específicos de cada ordem -- Fazer upload do relatório JSON como artefato do workflow - -### Dependências (requirements.txt) - -``` -numpy -pandas -Requests -pre-commit -``` From 2323205227638d7c99934507f528f8fdbf8987ab Mon Sep 17 00:00:00 2001 From: let Date: Sun, 19 Jul 2026 15:25:26 -0300 Subject: [PATCH 006/122] fix typo in gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index afc5c0e..3357e57 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ __pycache__/ dist/ *.egg-info/ estudo/ -validacao-exeprimentos/ \ No newline at end of file +validacao-experimentos/ \ No newline at end of file From 57e5e36b7a84ffc921669589dd84edc4a3151f7c Mon Sep 17 00:00:00 2001 From: let Date: Sun, 19 Jul 2026 15:42:28 -0300 Subject: [PATCH 007/122] docs: update readme with local development and testing instructions --- README.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a073ba6..9f0f6f3 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,41 @@ A 5-minute demo video with walkthrough of `pytest-ranking`: [YouTube link](https ## Contributing -Contributions are very welcome. Tests can be run with [tox](https://tox.readthedocs.io/en/latest/). +Contributions are very welcome. + +### Local development + +Install the package in editable mode: + +```bash +pip install -e . +``` + +### Running tests + +During development, to quickly validate your changes: + +```bash +pytest -xv tests/ +``` + +To test across all supported Python versions and ensure compatibility before publishing, use [tox](https://tox.readthedocs.io/en/latest/): + +```bash +tox +``` + +To run a specific environment only (e.g. Python 3.12): + +```bash +tox -e py312 +``` + +To pass extra arguments to pytest via tox: + +```bash +tox -- -k "test_name" +``` ## License From afc068d5ab9d5d613a1ec57f3df83c7a7223cc50 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 19 Jul 2026 15:44:01 -0300 Subject: [PATCH 008/122] fix: correct test_invalid_weight according to new weight config --- tests/test_pytest_ranking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pytest_ranking.py b/tests/test_pytest_ranking.py index 9a561aa..faf13ac 100644 --- a/tests/test_pytest_ranking.py +++ b/tests/test_pytest_ranking.py @@ -235,7 +235,7 @@ def test_invalid_weight(mytester): mytester.makepyfile( test_method_one=test_method_one ) - args = ["-v", "--rank", "--rank-weight=1-3"] + args = ["-v", "--rank", "--rank-weight=1-3-2"] out = mytester.runpytest(*args) error_msg = "pytest: error: argument --rank-weight:" \ + " Cannot parse input for `--rank-weight`." From 44778d2ddc2a22dbe18eafc51e0cbf6054ff6718 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 19 Jul 2026 16:00:14 -0300 Subject: [PATCH 009/122] refactor: reorganize tests dir --- tests/conftest.py | 16 + tests/fake_test_data.py | 218 +++++++++ tests/test_interop.py | 35 ++ tests/test_options.py | 133 +++++ tests/test_pytest_ranking.py | 802 ------------------------------- tests/test_ranking_heuristics.py | 111 +++++ tests/test_ranking_levels.py | 187 +++++++ tests/test_replay.py | 73 +++ 8 files changed, 773 insertions(+), 802 deletions(-) create mode 100644 tests/fake_test_data.py create mode 100644 tests/test_interop.py create mode 100644 tests/test_options.py delete mode 100644 tests/test_pytest_ranking.py create mode 100644 tests/test_ranking_heuristics.py create mode 100644 tests/test_ranking_levels.py create mode 100644 tests/test_replay.py diff --git a/tests/conftest.py b/tests/conftest.py index c6481d5..67136df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,17 @@ +from __future__ import annotations + +import pytest + pytest_plugins = ["pytester"] + + +@pytest.fixture +def mytester(pytester): + pytester.makefile( + ".ini", + pytest=""" + [pytest] + console_output_style = classic + """, + ) + yield pytester diff --git a/tests/fake_test_data.py b/tests/fake_test_data.py new file mode 100644 index 0000000..01f9eb9 --- /dev/null +++ b/tests/fake_test_data.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +test_method_one = \ + """ + import time + + def func(x): + return x + 1 + + def test_slow(): + time.sleep(1.5) + assert func(4) == 5 + + # FAIL + def test_fast_fail(): + time.sleep(0.5) + assert func(3) == 5 + + def test_medium(): + time.sleep(1) + assert func(4) == 5 + """ + + +test_class_one = \ + """ + import time + + def func(x): + return x + 1 + + class TestClassSample: + def test_fast(self): + time.sleep(0.7) + assert func(4) == 5 + + # FAIL + def test_slow_fail(self): + time.sleep(1.7) + assert func(3) == 5 + + def test_medium(self): + time.sleep(1.2) + assert func(4) == 5 + """ + + +test_put_one = \ + """ + import pytest + + @pytest.mark.parametrize("param", {"a", "b", "c", "d"}) + def test_put_unordered(param): + pass + + @pytest.mark.parametrize("param", ["d", "e", 1, 3, "a", "b", " "]) + def test_put_ordered(param): + pass + + """ + + +test_a_method = \ + """ + import time + + def func(x): + return x + 1 + + def test_a_slow(): + time.sleep(1.5) + assert func(4) == 5 + + # FAIL + def test_b_fast_fail(): + time.sleep(0.5) + assert func(3) == 5 + + def test_c_medium(): + time.sleep(1) + assert func(4) == 5 + """ + + +test_b_class = \ + """ + import time + + def func(x): + return x + 1 + + class TestClassA: + def test_a_fast(self): + time.sleep(0.7) + assert func(4) == 5 + + # FAIL + def test_b_slow_fail(self): + time.sleep(1.7) + assert func(3) == 5 + + def test_c_medium(self): + time.sleep(1.2) + assert func(4) == 5 + """ + + +test_c_put = \ + """ + import pytest + import time + + @pytest.mark.parametrize("param", {0.1, 0.2, 0.3, 0.4}) + def test_a_put_unordered(param): + time.sleep(param) + pass + + @pytest.mark.parametrize("param", [0.45, 0.25, 0.55, 0.35, 0.15]) + def test_b_put_ordered(param): + time.sleep(param) + pass + + """ + + +test_c_put_ordered = \ + """ + import pytest + import time + + @pytest.mark.parametrize("param", [0.45, 0.25, 0.55, 0.35, 0.15]) + def test_b_put_ordered(param): + time.sleep(param) + pass + + """ + + +test_a_method_two = \ + """ + import time + + def func(x): + return x + 1 + + def test_a_slow(): + time.sleep(0.05) + assert func(4) == 5 + + # FAIL + def test_b_fast_fail(): + time.sleep(0.01) + assert func(3) == 5 + + def test_c_medium(): + time.sleep(0.02) + assert func(4) == 5 + """ + + +test_order = \ + """ + import pytest + import time + @pytest.mark.order(2) + def test_foo(): + time.sleep(4.5) + assert True + + @pytest.mark.order(1) + def test_bar(): + time.sleep(5) + assert True + """ + + +test_dependency = \ + """ + import pytest + import time + + @pytest.mark.dependency() + @pytest.mark.xfail(reason="deliberate fail") + def test_a(): + time.sleep(4) + assert False + + @pytest.mark.dependency() + def test_b(): + time.sleep(3.5) + pass + + @pytest.mark.dependency(depends=["test_a"]) + def test_c(): + time.sleep(3) + pass + + @pytest.mark.dependency(depends=["test_b"]) + def test_d(): + time.sleep(2.5) + pass + + @pytest.mark.dependency(depends=["test_b", "test_c"]) + def test_e(): + time.sleep(2) + pass + """ + + +replay_order_one = \ + """ + test_class_one.py::TestClassSample::test_fast + test_class_one.py::TestClassSample::test_slow_fail + test_method_one.py::test_medium + test_method_one.py::test_fast_fail + test_class_one.py::TestClassSample::test_medium + test_method_one.py::test_slow + """ diff --git a/tests/test_interop.py b/tests/test_interop.py new file mode 100644 index 0000000..444f90a --- /dev/null +++ b/tests/test_interop.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from tests.fake_test_data import test_a_method, test_dependency, test_order + + +def test_order_dependency_marker(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_order=test_order, + test_dependency=test_dependency, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=8, failed=1, xfailed=1) + + args = ["-v", "--rank"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=8, failed=1, xfailed=1) + out.stdout.fnmatch_lines( + [ + "test_dependency.py::test_a XFAIL (deliberate fail)", + "test_dependency.py::test_b PASSED", + "test_dependency.py::test_c PASSED", + "test_dependency.py::test_d PASSED", + "test_dependency.py::test_e PASSED", + "test_order.py::test_foo PASSED", + "test_order.py::test_bar PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_a_method.py::test_c_medium PASSED", + "test_a_method.py::test_a_slow PASSED", + ], + consecutive=True + ) diff --git a/tests/test_options.py b/tests/test_options.py new file mode 100644 index 0000000..8528ef2 --- /dev/null +++ b/tests/test_options.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from tests.fake_test_data import ( + test_a_method, + test_b_class, + test_c_put, + test_class_one, + test_method_one, + test_put_one, +) + + +def test_logging(mytester): + mytester.makepyfile( + test_method_one=test_method_one + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + log_text = ( + "Using --rank-weight", + "Using --rank-level", + "Using --rank-hist-len", + "Using --rank-seed", + "Time to reorder tests (s)", + "Time to collect test features (s)", + ) + + header = "= pytest-regsmart summary info =" + assert len([x for x in out.outlines if header in x]) == 0 + assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 + + args = ["-v", "--rank"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 + + +def test_invalid_weight(mytester): + mytester.makepyfile( + test_method_one=test_method_one + ) + args = ["-v", "--rank", "--rank-weight=1-3-2"] + out = mytester.runpytest(*args) + error_msg = "pytest: error: argument --rank-weight:" \ + + " Cannot parse input for `--rank-weight`." + assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 + + args = ["-v", "--rank", "--rank-weight=1-3-x"] + out = mytester.runpytest(*args) + error_msg = "pytest: error: argument --rank-weight:" \ + + " Cannot parse input for `--rank-weight`." + assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 + + +def test_random_order(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + log_text = ( + "Using --rank-weight", + "Using --rank-level", + "Using --rank-hist-len", + "Using --rank-seed", + "Time to reorder tests (s)", + "Time to collect test features (s)", + ) + assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 + + args = ["-v", "--rank"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 + + args = ["-v", "--rank", "--rank-weight=0-0"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + log_text = ( + "Using --rank-seed=", + ) + assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 + test_lines_default1 = [x for x in out.outlines if "::" in x] + + args = ["-v", "--rank", "--rank-weight=0.0-0.0", "--rank-seed=8"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + log_text = ( + "Using --rank-seed=8", + ) + assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 + test_lines_1 = [x for x in out.outlines if "::" in x] + + args = ["-v", "--rank", "--rank-weight=0-0", "--rank-seed=16"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + log_text = ( + "Using --rank-seed=16", + ) + assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 + test_lines_2 = [x for x in out.outlines if "::" in x] + + assert test_lines_default1 != test_lines_1 != test_lines_2 + + +def test_xdist(mytester): + mytester.makepyfile( + test_put_one=test_put_one, + ) + + args = ["-v", "--rank", "-n", "auto", "--rank-weight=0-0"] + out = mytester.runpytest(*args) + assert len([x for x in out.outlines if x.startswith("ERROR")]) == 0 + + +def test_invalid_level(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_b_class=test_b_class, + test_c_put=test_c_put, + ) + + args = ["-v", "--rank", "--rank-level=class"] + out = mytester.runpytest(*args) + error_msg = "pytest: error: argument --rank-level:" \ + + " Invalid input for `--rank-level`." \ + + " Please run `pytest --help` for instruction." + assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 diff --git a/tests/test_pytest_ranking.py b/tests/test_pytest_ranking.py deleted file mode 100644 index faf13ac..0000000 --- a/tests/test_pytest_ranking.py +++ /dev/null @@ -1,802 +0,0 @@ -from __future__ import annotations - -import textwrap - -import pytest - -test_method_one = \ - """ - import time - - def func(x): - return x + 1 - - def test_slow(): - time.sleep(1.5) - assert func(4) == 5 - - # FAIL - def test_fast_fail(): - time.sleep(0.5) - assert func(3) == 5 - - def test_medium(): - time.sleep(1) - assert func(4) == 5 - """ - - -test_class_one = \ - """ - import time - - def func(x): - return x + 1 - - class TestClassSample: - def test_fast(self): - time.sleep(0.7) - assert func(4) == 5 - - # FAIL - def test_slow_fail(self): - time.sleep(1.7) - assert func(3) == 5 - - def test_medium(self): - time.sleep(1.2) - assert func(4) == 5 - """ - - -test_put_one = \ - """ - import pytest - - @pytest.mark.parametrize("param", {"a", "b", "c", "d"}) - def test_put_unordered(param): - pass - - @pytest.mark.parametrize("param", ["d", "e", 1, 3, "a", "b", " "]) - def test_put_ordered(param): - pass - - """ - - -@pytest.fixture -def mytester(pytester): - pytester.makefile( - ".ini", - pytest=""" - [pytest] - console_output_style = classic - """, - ) - yield pytester - - -def test_default(mytester): - """Default currently is faster test first""" - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - # assert faster tests are run first - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - pass - - -def test_faster_test_first(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=1-0"] - out = mytester.runpytest(*args) - - out.assert_outcomes(passed=4, failed=2) - # assert faster tests are run first - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - pass - - -def test_recent_fail_first(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-1"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_method_one.py::test_medium PASSED", - ], - consecutive=True - ) - pass - - -def test_550_weight(mytester): - """--rank-weight=5-5, run recently failed and faster tests first""" - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - args = ["-v", "--rank", "--rank-weight=5-5"] - out = mytester.runpytest(*args) - - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - ], - consecutive=True - ) - pass - - -def test_logging(mytester): - mytester.makepyfile( - test_method_one=test_method_one - ) - - # Without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=2, failed=1) - # Should print nothing. - log_text = ( - "Using --rank-weight", - "Using --rank-level", - "Using --rank-hist-len", - "Using --rank-seed", - "Time to reorder tests (s)", - "Time to collect test features (s)", - ) - - header = "= pytest-regsmart summary info =" - assert len([x for x in out.outlines if header in x]) == 0 - assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=2, failed=1) - # Should log everything. - assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 - - -def test_invalid_weight(mytester): - mytester.makepyfile( - test_method_one=test_method_one - ) - args = ["-v", "--rank", "--rank-weight=1-3-2"] - out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-weight:" \ - + " Cannot parse input for `--rank-weight`." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 - - args = ["-v", "--rank", "--rank-weight=1-3-x"] - out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-weight:" \ - + " Cannot parse input for `--rank-weight`." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 - - -def test_random_order(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - # Should print nothing. - log_text = ( - "Using --rank-weight", - "Using --rank-level", - "Using --rank-hist-len", - "Using --rank-seed", - "Time to reorder tests (s)", - "Time to collect test features (s)", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 - - # Run default. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - # Should log everything. - assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 - - # Run with default seed. - args = ["-v", "--rank", "--rank-weight=0-0"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 - test_lines_default1 = [x for x in out.outlines if "::" in x] - - # Run with specific seed. - args = ["-v", "--rank", "--rank-weight=0.0-0.0", "--rank-seed=8"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=8", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 - test_lines_1 = [x for x in out.outlines if "::" in x] - - # Run with specific seed. - args = ["-v", "--rank", "--rank-weight=0-0", "--rank-seed=16"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=16", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 - test_lines_2 = [x for x in out.outlines if "::" in x] - - # Assert test order differ by three different seeds. - assert test_lines_default1 != test_lines_1 != test_lines_2 - - -def test_xdist(mytester): - """Test the plugin under pytest-xdist (test parallel).""" - mytester.makepyfile( - test_put_one=test_put_one, - ) - - args = ["-v", "--rank", "-n", "auto", "--rank-weight=0-0"] - out = mytester.runpytest(*args) - assert len([x for x in out.outlines if x.startswith("ERROR")]) == 0 - pass - - -test_a_method = \ - """ - import time - - def func(x): - return x + 1 - - def test_a_slow(): - time.sleep(1.5) - assert func(4) == 5 - - # FAIL - def test_b_fast_fail(): - time.sleep(0.5) - assert func(3) == 5 - - def test_c_medium(): - time.sleep(1) - assert func(4) == 5 - """ - - -test_b_class = \ - """ - import time - - def func(x): - return x + 1 - - class TestClassA: - def test_a_fast(self): - time.sleep(0.7) - assert func(4) == 5 - - # FAIL - def test_b_slow_fail(self): - time.sleep(1.7) - assert func(3) == 5 - - def test_c_medium(self): - time.sleep(1.2) - assert func(4) == 5 - """ - -test_c_put = \ - """ - import pytest - import time - - @pytest.mark.parametrize("param", {0.1, 0.2, 0.3, 0.4}) - def test_a_put_unordered(param): - time.sleep(param) - pass - - @pytest.mark.parametrize("param", [0.45, 0.25, 0.55, 0.35, 0.15]) - def test_b_put_ordered(param): - time.sleep(param) - pass - - """ - - -test_c_put_ordered = \ - """ - import pytest - import time - - @pytest.mark.parametrize("param", [0.45, 0.25, 0.55, 0.35, 0.15]) - def test_b_put_ordered(param): - time.sleep(param) - pass - - """ - - -def test_put_level_ranking(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_b_class=test_b_class, - test_c_put=test_c_put, - ) - - # Without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=13, failed=2) - - # With RTP. - args = ["-v", "--rank", "--rank-level=put"] - out = mytester.runpytest(*args) - - # Assert outcome to be the same as if no RTP. - out.assert_outcomes(passed=13, failed=2) - # Assert faster tests are run first at PUT level. - out.stdout.fnmatch_lines( - [ - "test_c_put.py::test_a_put_unordered[0.1] PASSED", - "test_c_put.py::test_b_put_ordered[0.15] PASSED", - "test_c_put.py::test_a_put_unordered[0.2] PASSED", - "test_c_put.py::test_b_put_ordered[0.25] PASSED", - "test_c_put.py::test_a_put_unordered[0.3] PASSED", - "test_c_put.py::test_b_put_ordered[0.35] PASSED", - "test_c_put.py::test_a_put_unordered[0.4] PASSED", - "test_c_put.py::test_b_put_ordered[0.45] PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_c_put.py::test_b_put_ordered[0.55] PASSED", - "test_b_class.py::TestClassA::test_a_fast PASSED", - "test_a_method.py::test_c_medium PASSED", - "test_b_class.py::TestClassA::test_c_medium PASSED", - "test_a_method.py::test_a_slow PASSED", - "test_b_class.py::TestClassA::test_b_slow_fail FAILED" - ], - consecutive=True - ) - pass - - -def test_function_level_ranking(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_b_class=test_b_class, - test_c_put=test_c_put_ordered, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=9, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-level=function"] - out = mytester.runpytest(*args) - - # Assert outcome to be the same as if no RTP. - out.assert_outcomes(passed=9, failed=2) - out.stdout.fnmatch_lines( - [ - "test_c_put.py::test_b_put_ordered[0.45] PASSED", - "test_c_put.py::test_b_put_ordered[0.25] PASSED", - "test_c_put.py::test_b_put_ordered[0.55] PASSED", - "test_c_put.py::test_b_put_ordered[0.35] PASSED", - "test_c_put.py::test_b_put_ordered[0.15] PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_b_class.py::TestClassA::test_a_fast PASSED", - "test_a_method.py::test_c_medium PASSED", - "test_b_class.py::TestClassA::test_c_medium PASSED", - "test_a_method.py::test_a_slow PASSED", - "test_b_class.py::TestClassA::test_b_slow_fail FAILED", - ], - consecutive=True - ) - pass - - -def test_module_level_ranking(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_b_class=test_b_class, - test_c_put=test_c_put_ordered, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=9, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-level=module"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=9, failed=2) - out.stdout.fnmatch_lines( - [ - "test_c_put.py::test_b_put_ordered[0.45] PASSED", - "test_c_put.py::test_b_put_ordered[0.25] PASSED", - "test_c_put.py::test_b_put_ordered[0.55] PASSED", - "test_c_put.py::test_b_put_ordered[0.35] PASSED", - "test_c_put.py::test_b_put_ordered[0.15] PASSED", - "test_a_method.py::test_a_slow PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_a_method.py::test_c_medium PASSED", - "test_b_class.py::TestClassA::test_a_fast PASSED", - "test_b_class.py::TestClassA::test_b_slow_fail FAILED", - "test_b_class.py::TestClassA::test_c_medium PASSED", - ], - consecutive=True - ) - pass - - -def test_dir_level_ranking(mytester): - - a = mytester.mkdir("a") - a.joinpath("test_a_method.py").write_text( - textwrap.dedent(test_a_method)) - a.joinpath("test_b_class.py").write_text( - textwrap.dedent(test_b_class)) - b = mytester.mkdir("b") - b.joinpath("test_c_put.py").write_text( - textwrap.dedent(test_c_put_ordered)) - - # Without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=9, failed=2) - - # With RTP. - args = ["-v", "--rank", "--rank-level=dir"] - out = mytester.runpytest(*args) - - # Assert outcome. - out.assert_outcomes(passed=9, failed=2) - out.stdout.fnmatch_lines( - [ - "b/test_c_put.py::test_b_put_ordered[0.45] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.25] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.55] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.35] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.15] PASSED", - "a/test_a_method.py::test_a_slow PASSED", - "a/test_a_method.py::test_b_fast_fail FAILED", - "a/test_a_method.py::test_c_medium PASSED", - "a/test_b_class.py::TestClassA::test_a_fast PASSED", - "a/test_b_class.py::TestClassA::test_b_slow_fail FAILED", - "a/test_b_class.py::TestClassA::test_c_medium PASSED", - ], - consecutive=True - ) - pass - - -def test_invalid_level(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_b_class=test_b_class, - test_c_put=test_c_put, - ) - - args = ["-v", "--rank", "--rank-level=class"] - out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-level:" \ - + " Invalid input for `--rank-level`." \ - + " Please run `pytest --help` for instruction." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 - - -test_a_method_two = \ - """ - import time - - def func(x): - return x + 1 - - def test_a_slow(): - time.sleep(0.05) - assert func(4) == 5 - - # FAIL - def test_b_fast_fail(): - time.sleep(0.01) - assert func(3) == 5 - - def test_c_medium(): - time.sleep(0.02) - assert func(4) == 5 - """ - - -def test_function_level_ranking_with_duplicate_methods(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_a_method_two=test_a_method_two, - test_b_class=test_b_class, - test_c_put=test_c_put_ordered, - ) - - # Without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=11, failed=3) - - # With RTP. - args = ["-v", "--rank", "--rank-level=function"] - out = mytester.runpytest(*args) - - # Assert outcome to be the same with/without RTP. - out.assert_outcomes(passed=11, failed=3) - # Assert that tests with the same method name, - # i.e., from test_a_method and test_a_method_two, - # are in two different group. - out.stdout.fnmatch_lines( - [ - "test_a_method_two.py::test_b_fast_fail FAILED", - "test_a_method_two.py::test_c_medium PASSED", - "test_a_method_two.py::test_a_slow PASSED", - "test_c_put.py::test_b_put_ordered[0.45] PASSED", - "test_c_put.py::test_b_put_ordered[0.25] PASSED", - "test_c_put.py::test_b_put_ordered[0.55] PASSED", - "test_c_put.py::test_b_put_ordered[0.35] PASSED", - "test_c_put.py::test_b_put_ordered[0.15] PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_b_class.py::TestClassA::test_a_fast PASSED", - "test_a_method.py::test_c_medium PASSED", - "test_b_class.py::TestClassA::test_c_medium PASSED", - "test_a_method.py::test_a_slow PASSED", - "test_b_class.py::TestClassA::test_b_slow_fail FAILED", - ], - consecutive=True - ) - pass - - -test_order = \ - """ - import pytest - import time - @pytest.mark.order(2) - def test_foo(): - time.sleep(4.5) - assert True - - @pytest.mark.order(1) - def test_bar(): - time.sleep(5) - assert True - """ - -test_dependency = \ - """ - import pytest - import time - - @pytest.mark.dependency() - @pytest.mark.xfail(reason="deliberate fail") - def test_a(): - time.sleep(4) - assert False - - @pytest.mark.dependency() - def test_b(): - time.sleep(3.5) - pass - - @pytest.mark.dependency(depends=["test_a"]) - def test_c(): - time.sleep(3) - pass - - @pytest.mark.dependency(depends=["test_b"]) - def test_d(): - time.sleep(2.5) - pass - - @pytest.mark.dependency(depends=["test_b", "test_c"]) - def test_e(): - time.sleep(2) - pass - """ - - -def test_order_dependency_marker(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_order=test_order, - test_dependency=test_dependency, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=8, failed=1, xfailed=1) - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=8, failed=1, xfailed=1) - # assert that tests with the same method name, - # i.e., from test_a_method and test_a_method_two - # are in two different group - out.stdout.fnmatch_lines( - [ - "test_dependency.py::test_a XFAIL (deliberate fail)", - "test_dependency.py::test_b PASSED", - "test_dependency.py::test_c PASSED", - "test_dependency.py::test_d PASSED", - "test_dependency.py::test_e PASSED", - "test_order.py::test_foo PASSED", - "test_order.py::test_bar PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_a_method.py::test_c_medium PASSED", - "test_a_method.py::test_a_slow PASSED", - ], - consecutive=True - ) - pass - - -replay_order_one = \ - """ - test_class_one.py::TestClassSample::test_fast - test_class_one.py::TestClassSample::test_slow_fail - test_method_one.py::test_medium - test_method_one.py::test_fast_fail - test_class_one.py::TestClassSample::test_medium - test_method_one.py::test_slow - """ - - -def test_replay(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - mytester.maketxtfile( - replay_order=replay_order_one - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-replay=replay_order.txt"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_method_one.py::test_medium PASSED", - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - ], - consecutive=True - ) - - -def test_replay_with_random(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - mytester.maketxtfile( - replay_order=replay_order_one - ) - - # Run with RTP. - args = [ - "-v", - "--rank", - "--rank-replay=replay_order.txt", - "--rank-weight=0-0" - ] - out = mytester.runpytest(*args) - error_msg = "--rank-replay cannot be used together with random order." - assert len([x for x in out.outlines if error_msg in x]) == 1 - - -def test_invalid_replay(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - ) - - args = ["-v", "--rank", "--rank-replay=order.txt"] - out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-replay:" \ - + " File provided to `--rank-replay` cannot be read." \ - + " Please run `pytest --help` for instruction." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 diff --git a/tests/test_ranking_heuristics.py b/tests/test_ranking_heuristics.py new file mode 100644 index 0000000..1c22a9c --- /dev/null +++ b/tests/test_ranking_heuristics.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from tests.fake_test_data import test_class_one, test_method_one + + +def test_default(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--rank"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_method_one.py::test_medium PASSED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + ], + consecutive=True + ) + + +def test_faster_test_first(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--rank", "--rank-weight=1-0"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_method_one.py::test_medium PASSED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + ], + consecutive=True + ) + + +def test_recent_fail_first(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--rank", "--rank-weight=0-1"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + "test_method_one.py::test_medium PASSED", + ], + consecutive=True + ) + + +def test_550_weight(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--rank", "--rank-weight=5-5"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_method_one.py::test_medium PASSED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + ], + consecutive=True + ) diff --git a/tests/test_ranking_levels.py b/tests/test_ranking_levels.py new file mode 100644 index 0000000..b95ab95 --- /dev/null +++ b/tests/test_ranking_levels.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import textwrap + +from tests.fake_test_data import ( + test_a_method, + test_a_method_two, + test_b_class, + test_c_put, + test_c_put_ordered, +) + + +def test_put_level_ranking(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_b_class=test_b_class, + test_c_put=test_c_put, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=13, failed=2) + + args = ["-v", "--rank", "--rank-level=put"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=13, failed=2) + out.stdout.fnmatch_lines( + [ + "test_c_put.py::test_a_put_unordered[0.1] PASSED", + "test_c_put.py::test_b_put_ordered[0.15] PASSED", + "test_c_put.py::test_a_put_unordered[0.2] PASSED", + "test_c_put.py::test_b_put_ordered[0.25] PASSED", + "test_c_put.py::test_a_put_unordered[0.3] PASSED", + "test_c_put.py::test_b_put_ordered[0.35] PASSED", + "test_c_put.py::test_a_put_unordered[0.4] PASSED", + "test_c_put.py::test_b_put_ordered[0.45] PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_c_put.py::test_b_put_ordered[0.55] PASSED", + "test_b_class.py::TestClassA::test_a_fast PASSED", + "test_a_method.py::test_c_medium PASSED", + "test_b_class.py::TestClassA::test_c_medium PASSED", + "test_a_method.py::test_a_slow PASSED", + "test_b_class.py::TestClassA::test_b_slow_fail FAILED" + ], + consecutive=True + ) + + +def test_function_level_ranking(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_b_class=test_b_class, + test_c_put=test_c_put_ordered, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=9, failed=2) + + args = ["-v", "--rank", "--rank-level=function"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=9, failed=2) + out.stdout.fnmatch_lines( + [ + "test_c_put.py::test_b_put_ordered[0.45] PASSED", + "test_c_put.py::test_b_put_ordered[0.25] PASSED", + "test_c_put.py::test_b_put_ordered[0.55] PASSED", + "test_c_put.py::test_b_put_ordered[0.35] PASSED", + "test_c_put.py::test_b_put_ordered[0.15] PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_b_class.py::TestClassA::test_a_fast PASSED", + "test_a_method.py::test_c_medium PASSED", + "test_b_class.py::TestClassA::test_c_medium PASSED", + "test_a_method.py::test_a_slow PASSED", + "test_b_class.py::TestClassA::test_b_slow_fail FAILED", + ], + consecutive=True + ) + + +def test_module_level_ranking(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_b_class=test_b_class, + test_c_put=test_c_put_ordered, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=9, failed=2) + + args = ["-v", "--rank", "--rank-level=module"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=9, failed=2) + out.stdout.fnmatch_lines( + [ + "test_c_put.py::test_b_put_ordered[0.45] PASSED", + "test_c_put.py::test_b_put_ordered[0.25] PASSED", + "test_c_put.py::test_b_put_ordered[0.55] PASSED", + "test_c_put.py::test_b_put_ordered[0.35] PASSED", + "test_c_put.py::test_b_put_ordered[0.15] PASSED", + "test_a_method.py::test_a_slow PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_a_method.py::test_c_medium PASSED", + "test_b_class.py::TestClassA::test_a_fast PASSED", + "test_b_class.py::TestClassA::test_b_slow_fail FAILED", + "test_b_class.py::TestClassA::test_c_medium PASSED", + ], + consecutive=True + ) + + +def test_dir_level_ranking(mytester): + a = mytester.mkdir("a") + a.joinpath("test_a_method.py").write_text( + textwrap.dedent(test_a_method)) + a.joinpath("test_b_class.py").write_text( + textwrap.dedent(test_b_class)) + b = mytester.mkdir("b") + b.joinpath("test_c_put.py").write_text( + textwrap.dedent(test_c_put_ordered)) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=9, failed=2) + + args = ["-v", "--rank", "--rank-level=dir"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=9, failed=2) + out.stdout.fnmatch_lines( + [ + "b/test_c_put.py::test_b_put_ordered[0.45] PASSED", + "b/test_c_put.py::test_b_put_ordered[0.25] PASSED", + "b/test_c_put.py::test_b_put_ordered[0.55] PASSED", + "b/test_c_put.py::test_b_put_ordered[0.35] PASSED", + "b/test_c_put.py::test_b_put_ordered[0.15] PASSED", + "a/test_a_method.py::test_a_slow PASSED", + "a/test_a_method.py::test_b_fast_fail FAILED", + "a/test_a_method.py::test_c_medium PASSED", + "a/test_b_class.py::TestClassA::test_a_fast PASSED", + "a/test_b_class.py::TestClassA::test_b_slow_fail FAILED", + "a/test_b_class.py::TestClassA::test_c_medium PASSED", + ], + consecutive=True + ) + + +def test_function_level_ranking_with_duplicate_methods(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_a_method_two=test_a_method_two, + test_b_class=test_b_class, + test_c_put=test_c_put_ordered, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=11, failed=3) + + args = ["-v", "--rank", "--rank-level=function"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=11, failed=3) + out.stdout.fnmatch_lines( + [ + "test_a_method_two.py::test_b_fast_fail FAILED", + "test_a_method_two.py::test_c_medium PASSED", + "test_a_method_two.py::test_a_slow PASSED", + "test_c_put.py::test_b_put_ordered[0.45] PASSED", + "test_c_put.py::test_b_put_ordered[0.25] PASSED", + "test_c_put.py::test_b_put_ordered[0.55] PASSED", + "test_c_put.py::test_b_put_ordered[0.35] PASSED", + "test_c_put.py::test_b_put_ordered[0.15] PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_b_class.py::TestClassA::test_a_fast PASSED", + "test_a_method.py::test_c_medium PASSED", + "test_b_class.py::TestClassA::test_c_medium PASSED", + "test_a_method.py::test_a_slow PASSED", + "test_b_class.py::TestClassA::test_b_slow_fail FAILED", + ], + consecutive=True + ) diff --git a/tests/test_replay.py b/tests/test_replay.py new file mode 100644 index 0000000..979a5b3 --- /dev/null +++ b/tests/test_replay.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from tests.fake_test_data import ( + replay_order_one, + test_a_method, + test_class_one, + test_method_one, +) + + +def test_replay(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + mytester.maketxtfile( + replay_order=replay_order_one + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--rank", "--rank-replay=replay_order.txt"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + "test_method_one.py::test_medium PASSED", + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + ], + consecutive=True + ) + + +def test_replay_with_random(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + mytester.maketxtfile( + replay_order=replay_order_one + ) + + args = [ + "-v", + "--rank", + "--rank-replay=replay_order.txt", + "--rank-weight=0-0" + ] + out = mytester.runpytest(*args) + error_msg = "--rank-replay cannot be used together with random order." + assert len([x for x in out.outlines if error_msg in x]) == 1 + + +def test_invalid_replay(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + ) + + args = ["-v", "--rank", "--rank-replay=order.txt"] + out = mytester.runpytest(*args) + error_msg = "pytest: error: argument --rank-replay:" \ + + " File provided to `--rank-replay` cannot be read." \ + + " Please run `pytest --help` for instruction." + assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 From cb2687dba17f313d78629f42e851ea6b2e3be63e Mon Sep 17 00:00:00 2001 From: let Date: Sun, 19 Jul 2026 19:49:56 -0300 Subject: [PATCH 010/122] remove: del dir as an option for rank level --- README.md | 5 ++--- src/pytest_ranking/const.py | 2 -- src/pytest_ranking/plugin.py | 1 - src/pytest_ranking/rank.py | 4 ---- tests/__init__.py | 0 tests/test_ranking_levels.py | 36 ------------------------------------ 6 files changed, 2 insertions(+), 46 deletions(-) create mode 100644 tests/__init__.py diff --git a/README.md b/README.md index 9f0f6f3..03639bb 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The default value is ``1-0-0``, which only prioritizes faster tests. ### Optimizing test prioritization levels -You can set at which level of your test suite will be reordered, by passing the optional `--rank-level` flag in one of these values: `put`, `function`, `module`, `dir`. For example: +You can set at which level of your test suite will be reordered, by passing the optional `--rank-level` flag in one of these values: `put`, `function`, `module`. For example: ```bash pytest --rank --rank-level=function @@ -84,8 +84,7 @@ pytest --rank --rank-level=function - `put` reorders the each PUT and re-arranges their order based on their assigned priority scores - `function` reorders each test function, parametrized values of a test function follow their default order - `module` reorders each test file, all tests in the test file follow their default order - - `dir` reorders each test directory, all tests within each directory follow their default order - +7 The default value is `put`. diff --git a/src/pytest_ranking/const.py b/src/pytest_ranking/const.py index c2f90f9..e8e7f00 100644 --- a/src/pytest_ranking/const.py +++ b/src/pytest_ranking/const.py @@ -22,7 +22,5 @@ class LEVEL(str, Enum): PUT = "put" FUNCTION = "function" MODULE = "module" - DIR = "dir" - DEFAULT_LEVEL = LEVEL.PUT diff --git a/src/pytest_ranking/plugin.py b/src/pytest_ranking/plugin.py index 084fb6e..6a7114b 100644 --- a/src/pytest_ranking/plugin.py +++ b/src/pytest_ranking/plugin.py @@ -105,7 +105,6 @@ def level_type(string: str) -> str: "Invalid input for `--rank-level`." + " Please run `pytest --help` for instruction." ) - #talvez add aqui a condição de "nivel de granularidade nao valido por causa do conjunto de testes selecionado" def replay_type(string: str) -> str: diff --git a/src/pytest_ranking/rank.py b/src/pytest_ranking/rank.py index df27efa..415caa1 100644 --- a/src/pytest_ranking/rank.py +++ b/src/pytest_ranking/rank.py @@ -13,19 +13,15 @@ def get_test_group(nodeid: str, level: Enum) -> str: Given a put: folder/testfile.py::TestClass::testmethod[param1] - function: folder/testfile.py::TestClass:testmethod - module: folder/testfile.py - - dir: folder Otherwise, treat each PUT as a unique test group. """ test_without_param = nodeid.split("[")[0] test_file_path = test_without_param.split("::")[0] - test_folder = os.path.dirname(test_file_path) if level == LEVEL.FUNCTION: return test_without_param elif level == LEVEL.MODULE: return test_file_path - elif level == LEVEL.DIR: - return test_folder else: return nodeid diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ranking_levels.py b/tests/test_ranking_levels.py index b95ab95..69ef57f 100644 --- a/tests/test_ranking_levels.py +++ b/tests/test_ranking_levels.py @@ -114,42 +114,6 @@ def test_module_level_ranking(mytester): ) -def test_dir_level_ranking(mytester): - a = mytester.mkdir("a") - a.joinpath("test_a_method.py").write_text( - textwrap.dedent(test_a_method)) - a.joinpath("test_b_class.py").write_text( - textwrap.dedent(test_b_class)) - b = mytester.mkdir("b") - b.joinpath("test_c_put.py").write_text( - textwrap.dedent(test_c_put_ordered)) - - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=9, failed=2) - - args = ["-v", "--rank", "--rank-level=dir"] - out = mytester.runpytest(*args) - - out.assert_outcomes(passed=9, failed=2) - out.stdout.fnmatch_lines( - [ - "b/test_c_put.py::test_b_put_ordered[0.45] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.25] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.55] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.35] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.15] PASSED", - "a/test_a_method.py::test_a_slow PASSED", - "a/test_a_method.py::test_b_fast_fail FAILED", - "a/test_a_method.py::test_c_medium PASSED", - "a/test_b_class.py::TestClassA::test_a_fast PASSED", - "a/test_b_class.py::TestClassA::test_b_slow_fail FAILED", - "a/test_b_class.py::TestClassA::test_c_medium PASSED", - ], - consecutive=True - ) - - def test_function_level_ranking_with_duplicate_methods(mytester): mytester.makepyfile( test_a_method=test_a_method, From 12e1767176ddd271d7281b981aca28b86da3325c Mon Sep 17 00:00:00 2001 From: let Date: Sun, 19 Jul 2026 20:36:53 -0300 Subject: [PATCH 011/122] docs: update changelog according to latest change --- CHANGELOG.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bb956..54cd567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,28 @@ # Changelog -0.4.0 (2026-06-25) +## pytest-regsmart + +0.2.0 (2026-07-19) +---- + +* Remove directory-level granularity (`--rank-level=dir`) + +0.1.0 (2026-06-25) +---- + +* Fork of pytest-ranking (based on PyPI release 0.3.4) +* Rename plugin to `pytest-regsmart` +* Remove Similar Changed Path method (`--rank-weight=0-0-1`) + +--- + +## Inherited history (pytest-ranking) + +0.3.4 (2025-04-09) ---- -* Remove Similar Changed Path method +* Published version at PyPI (https://pypi.org/project/pytest-ranking/0.3.4/#history) 0.3.3 (2024-04-08) ---- From 6615f67806ef411b3c57f645684f299b5996cb10 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 19 Jul 2026 22:24:13 -0300 Subject: [PATCH 012/122] refactor: modularize ranking plugin by splitting into multiple files --- CHANGELOG.md | 1 + src/pytest_ranking/args.py | 110 +++++++++++ src/pytest_ranking/extractor.py | 50 +++++ src/pytest_ranking/monitor.py | 14 ++ src/pytest_ranking/plugin.py | 314 +++++--------------------------- src/pytest_ranking/rank.py | 54 ------ src/pytest_ranking/ranker.py | 108 +++++++++++ src/pytest_ranking/reporter.py | 33 ++++ 8 files changed, 358 insertions(+), 326 deletions(-) create mode 100644 src/pytest_ranking/args.py create mode 100644 src/pytest_ranking/extractor.py create mode 100644 src/pytest_ranking/monitor.py delete mode 100644 src/pytest_ranking/rank.py create mode 100644 src/pytest_ranking/ranker.py create mode 100644 src/pytest_ranking/reporter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bb956..5193841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 0.4.0 (2026-06-25) ---- +* Refactor ranking module internals (modularization) * Remove Similar Changed Path method 0.3.3 (2024-04-08) diff --git a/src/pytest_ranking/args.py b/src/pytest_ranking/args.py new file mode 100644 index 0000000..d7ecf5e --- /dev/null +++ b/src/pytest_ranking/args.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import argparse + +import numpy as np + +from .const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, LEVEL + + +def weight_type(string: str) -> str: + """Check weight format.""" + if string == DEFAULT_WEIGHT: + return string + try: + weights = string.split("-") + assert len(weights) == 2 + weights = [float(w) for w in weights] + return string + except (AssertionError, ValueError): + raise argparse.ArgumentTypeError( + "Cannot parse input for `--rank-weight`." + + "Valid examples: 1-0, 0.4-0.2, and 2-7." + ) + + +def level_type(string: str) -> str: + "Check level format." + if string == DEFAULT_LEVEL: + return string + try: + valid_levels = [i.value for i in LEVEL] + assert string in valid_levels + return string + except AssertionError: + raise argparse.ArgumentTypeError( + "Invalid input for `--rank-level`." + + " Please run `pytest --help` for instruction." + ) + + +def replay_type(string: str) -> str: + "Check replay file format." + if string == DEFAULT_REPLAY: + return string + try: + with open(string) as f: + _ = f.readlines() + return string + except Exception: + raise argparse.ArgumentTypeError( + "File provided to `--rank-replay` cannot be read." + + " Please run `pytest --help` for instruction." + ) + + +def min_max_normalization(x: list[float]) -> np.ndarray: + x = np.array(x) + x_range = (np.max(x) - np.min(x)) + x = (x - np.min(x)) / x_range if x_range else np.zeros(len(x)) + return x + + +def parse_rtp_weights(config) -> list[float]: + """Get weights, non-default CLI overrides ini file input.""" + weights = config.getoption("--rank-weight") + if weights == DEFAULT_WEIGHT: + ini_val = config.getini("rank_weight") + weights = ini_val if ini_val else weights + weights = weights.split("-") + weights = [float(w) for w in weights] + weight_sum = sum(weights) + if weight_sum == 0: + return [0, 0] + return [w_i / weight_sum for w_i in weights] + + +def parse_rtp_level(config) -> str: + """Get test group level, non-default CLI overrides ini file input.""" + level = config.getoption("--rank-level") + if level == DEFAULT_LEVEL: + ini_val = config.getini("rank_level") + level = ini_val if ini_val else level + return level + + +def parse_replay(config) -> str | None: + """Get replay file, non-default CLI overrides ini file input.""" + replay_file = config.getoption("--rank-replay") + if replay_file == DEFAULT_REPLAY: + ini_val = config.getini("rank_replay") + replay_file = ini_val if ini_val else replay_file + return replay_file + + +def parse_hist_len(config) -> int: + """Get history length, non-default CLI overrides ini file input.""" + hist_len = config.getoption("--rank-hist-len") + if hist_len == DEFAULT_HIST_LEN: + ini_val = config.getini("rank_hist_len") + hist_len = ini_val if ini_val else hist_len + return int(hist_len) + + +def parse_seed(config) -> int: + """Get random seed, non-default CLI overrides ini file input.""" + rand_seed = config.getoption("--rank-seed") + if rand_seed == DEFAULT_SEED: + ini_val = config.getini("rank_seed") + rand_seed = ini_val if ini_val else rand_seed + return int(rand_seed) diff --git a/src/pytest_ranking/extractor.py b/src/pytest_ranking/extractor.py new file mode 100644 index 0000000..effe3b9 --- /dev/null +++ b/src/pytest_ranking/extractor.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import os + +from _pytest.config import Config +from _pytest.reports import TestReport + +from .args import min_max_normalization +from .const import DATA_DIR + + +def load_feature( + config: Config, + feature_name: str, + items: list, + reverse: bool, +) -> list[float]: + """Load and normalize test-wise feature data from cache.""" + key = os.path.join(DATA_DIR, feature_name) + values = config.cache.get(key, {}) + values = [values.get(item.nodeid, 0) for item in items] + values = min_max_normalization(values) + if reverse: + values = 1 - values + return values.tolist() + + +def compute_test_features( + config: Config, + test_reports: list[TestReport], + hist_len: int, +) -> None: + """Persist last_durations and num_runs_since_fail to cache.""" + key = os.path.join(DATA_DIR, "last_durations") + last_durations = config.cache.get(key, {}) + for report in test_reports: + last_durations[report.nodeid] = round(report.duration, 3) + config.cache.set(key, last_durations) + + key = os.path.join(DATA_DIR, "num_runs_since_fail") + num_runs_since_fail = config.cache.get(key, {}) + for report in test_reports: + if report.outcome == "failed": + num_runs_since_fail[report.nodeid] = 0 + else: + num_runs_since_fail[report.nodeid] = min( + hist_len, + num_runs_since_fail.get(report.nodeid, 0) + 1, + ) + config.cache.set(key, num_runs_since_fail) diff --git a/src/pytest_ranking/monitor.py b/src/pytest_ranking/monitor.py new file mode 100644 index 0000000..1c2aa47 --- /dev/null +++ b/src/pytest_ranking/monitor.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from _pytest.reports import TestReport + + +class Monitor: + """Coleta resultados de execução dos testes.""" + + def __init__(self) -> None: + self.test_reports: list[TestReport] = [] + + def pytest_runtest_logreport(self, report: TestReport) -> None: + if not report.skipped and report.when == "call": + self.test_reports.append(report) diff --git a/src/pytest_ranking/plugin.py b/src/pytest_ranking/plugin.py index 6a7114b..d1fe19a 100644 --- a/src/pytest_ranking/plugin.py +++ b/src/pytest_ranking/plugin.py @@ -1,12 +1,8 @@ from __future__ import annotations import argparse -import os -import random import time -from enum import Enum -import numpy as np import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -15,11 +11,16 @@ from _pytest.reports import TestReport from _pytest.terminal import TerminalReporter -from .const import (DATA_DIR, DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, - DEFAULT_SEED, DEFAULT_WEIGHT, LEVEL) -from .help_strings import (HIST_LEN_HELP, LEVEL_HELP, PLUGIN_HELP, - REPLAY_HELP, SEED_HELP, WEIGHT_HELP) -from .rank import get_ranking +from . import args +from . import extractor +from . import ranker +from . import reporter as reporter_mod +from .const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT +from .help_strings import ( + HIST_LEN_HELP, LEVEL_HELP, PLUGIN_HELP, + REPLAY_HELP, SEED_HELP, WEIGHT_HELP, +) +from .monitor import Monitor def pytest_addoption(parser: Parser) -> None: @@ -32,7 +33,7 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--rank-level", action="store", - type=level_type, + type=args.level_type, default=DEFAULT_LEVEL, dest="rank_level", help=LEVEL_HELP) @@ -40,7 +41,7 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--rank-weight", action="store", - type=weight_type, + type=args.weight_type, default=DEFAULT_WEIGHT, dest="rank_weight", help=WEIGHT_HELP) @@ -48,7 +49,7 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--rank-replay", action="store", - type=replay_type, + type=args.replay_type, default=DEFAULT_REPLAY, dest="rank_replay", help=REPLAY_HELP) @@ -76,295 +77,64 @@ def pytest_addoption(parser: Parser) -> None: parser.addini("rank_seed", SEED_HELP, default=DEFAULT_SEED) -def weight_type(string: str) -> str: - """Check weight format.""" - if string == DEFAULT_WEIGHT: - return string - try: - weights = string.split("-") - assert len(weights) == 2 - weights = [float(w) for w in weights] - return string - except (AssertionError, ValueError): - raise argparse.ArgumentTypeError( - "Cannot parse input for `--rank-weight`." - + "Valid examples: 1-0, 0.4-0.2, and 2-7." - ) - - -def level_type(string: str) -> str: - "Check level format." - if string == DEFAULT_LEVEL: - return string - try: - valid_levels = [i.value for i in LEVEL] - assert string in valid_levels - return string - except AssertionError: - raise argparse.ArgumentTypeError( - "Invalid input for `--rank-level`." - + " Please run `pytest --help` for instruction." - ) - - -def replay_type(string: str) -> str: - "Check replay file format." - if string == DEFAULT_REPLAY: - return string - try: - with open(string) as f: - _ = f.readlines() - return string - except Exception: - raise argparse.ArgumentTypeError( - "File provided to `--rank-replay` cannot be read." - + " Please run `pytest --help` for instruction." - ) - - -def min_max_normalization(x: list[float]) -> np.ndarray: # talvez isso deva sair daqui - x = np.array(x) - x_range = (np.max(x) - np.min(x)) - x = (x - np.min(x)) / x_range if x_range else np.zeros(len(x)) - return x - - class RTPRunner: """Plugin class.""" def __init__(self, config: Config) -> None: self.config = config - self.test_reports = [] self.log = {} - self.weights = self.parse_rtp_weights() - self.level = self.parse_rtp_level() - self.replay_file = self.parse_replay() - self.hist_len = self.parse_hist_len() - self.seed = self.parse_seed() - - def parse_rtp_weights(self) -> list[float]: - """Get weights, non-default CLI overrides ini file input.""" - weights = self.config.getoption("--rank-weight") - if weights == DEFAULT_WEIGHT: - ini_val = self.config.getini("rank_weight") - weights = ini_val if ini_val else weights - - weights = weights.split("-") - weights = [float(w) for w in weights] - weight_sum = sum(weights) - if weight_sum == 0: - return [0, 0] - weights = [w_i / weight_sum for w_i in weights] - return weights - - def parse_rtp_level(self) -> Enum: - """Get test group level, non-default CLI overrides ini file input.""" - level = self.config.getoption("--rank-level") - if level == DEFAULT_LEVEL: - ini_val = self.config.getini("rank_level") - level = ini_val if ini_val else level - return level - - def parse_replay(self) -> str: - """Get replay file, non-default CLI overrides ini file input.""" - replay_file = self.config.getoption("--rank-replay") - if replay_file == DEFAULT_REPLAY: - ini_val = self.config.getini("rank_replay") - replay_file = ini_val if ini_val else replay_file - return replay_file - - def parse_hist_len(self) -> int: - """Get history length, non-default CLI overrides ini file input.""" - hist_len = self.config.getoption("--rank-hist-len") - if hist_len == DEFAULT_HIST_LEN: - ini_val = self.config.getini("rank_hist_len") - hist_len = ini_val if ini_val else hist_len - return int(hist_len) - - def parse_seed(self) -> int: - """Get random seed, non-default CLI overrides ini file input.""" - rand_seed = self.config.getoption("--rank-seed") - if rand_seed == DEFAULT_SEED: - ini_val = self.config.getini("rank_seed") - rand_seed = ini_val if ini_val else rand_seed - return int(rand_seed) - - def load_feature( - self, - feature_name: str, - items: list[Item], - reverse: bool) -> list[float]: - """ - Load and normalize test-wise feature data for the current test suite, - - reverse: True if originally smaller value means higher priority. - """ - # Load original data. - key = os.path.join(DATA_DIR, feature_name) - values = self.config.cache.get(key, {}) - # 0 if not exist: prioritizes newly selected/created tests. - values = [values.get(item.nodeid, 0) for item in items] - # Normalize to [0, 1] range. - values = min_max_normalization(values) - # If smaller values is better, transform to larger is better. - if reverse: - values = 1 - values - return values.tolist() + self.monitor = Monitor() - def run_rtp(self, items: list[Item]) -> None: - """Run test prioritization algorithm.""" - # Get pytest default order. - init_order = {item.nodeid: i for i, item in enumerate(items)} + self.weights = args.parse_rtp_weights(config) + self.level = args.parse_rtp_level(config) + self.replay_file = args.parse_replay(config) + self.hist_len = args.parse_hist_len(config) + self.seed = args.parse_seed(config) - # Start reordering. - start_time = time.time() - - # Get priority score per test, prioritized tests have LOWER scores. - scores = {} - if self.replay_file and os.path.exists(self.replay_file): - # Run tests in the order specified in the replay file. - with open(self.replay_file) as f: - test_list = [x.strip() for x in f.readlines()] - scores = {x: i for i, x in enumerate(test_list)} - elif self.weights == [0, 0]: - # Run tests in random order. - # Pre-sort so that all workers gets the same order in pytest-xdist. - # https://pytest-xdist.readthedocs.io/en/stable/known-limitations.html - items.sort(key=lambda item: item.nodeid) - random.seed(self.seed) - scores = {item.nodeid: random.random() for item in items} - else: - # Prioritize by test features. - w_time, w_fail = self.weights - h_time = self.load_feature("last_durations", items, True) - h_fail = self.load_feature("num_runs_since_fail", items, True) - - def hybrid(i): - # Linearly combine different heurisic values. - # The higher, the earlier the test will be run. - s = h_time[i] * w_time + h_fail[i] * w_fail - return -s - - scores = {item.nodeid: hybrid(i) for i, item in enumerate(items)} # revisar - - rank = get_ranking(scores, self.level, init_order) - # Respect tests with declared order dependency (OD). - od_items: list[Item] = [] - nod_items: list[Item] = [] - for item in items: - # For https://github.com/pytest-dev/pytest-order - # For https://github.com/RKrahl/pytest-dependency - if ( - item.get_closest_marker('order') - or item.get_closest_marker('dependency') - ): - od_items.append(item) - else: - nod_items.append(item) - # Only reorder tests with no declared OD. - nod_items.sort( - key=lambda item: ( - rank.get(item.nodeid, 0), init_order[item.nodeid] + @pytest.hookimpl(trylast=True) + def pytest_collection_modifyitems(self, items: list[Item]) -> None: + if not self.config.getoption("--rank"): + return + if self.replay_file and self.weights == [0, 0]: + raise argparse.ArgumentTypeError( + "--rank-replay cannot be used together with random order." ) + ranker.run_rtp( + items, self.level, self.weights, + self.replay_file, self.seed, self.log, + lambda feature_name, items, reverse: + extractor.load_feature(self.config, feature_name, items, reverse), ) - # Run OD tests first. - items[:] = od_items + nod_items - # Record reordering runtime. - self.log["Time to reorder tests (s)"] = time.time() - start_time def pytest_runtest_logreport(self, report: TestReport) -> None: - """Record test result of each executed test.""" - if not report.skipped and report.when == "call": - # No skipped: only look at the executed test. - # `call`: only look at called duration (ignore setup/teardown). - self.test_reports.append(report) + self.monitor.pytest_runtest_logreport(report) - def pytest_report_header(self, config: Config) -> str: - """Report plugin configurations before test session starts.""" - # Report nothing if the plugin is not enabled. - if not self.config.getoption("--rank"): - return None - weight = self.config.getoption("--rank-weight") - replay = self.config.getoption("--rank-replay") - level = self.config.getoption("--rank-level") - hist_len = self.config.getoption("--rank-hist-len") - random_seed = self.config.getoption("--rank-seed") - report = [ - f"Using --rank-weight={weight}", - f"Using --rank-level={level}", - f"Using --rank-hist-len={hist_len}", - f"Using --rank-seed={random_seed}", - f"Using --rank-replay={replay}", - ] - return "\n".join(report) - @pytest.hookimpl(trylast=True) - def pytest_collection_modifyitems(self, items: list[Item]) -> None: - if self.config.getoption("--rank"): - if self.replay_file and self.weights == [0, 0]: - raise argparse.ArgumentTypeError( - "--rank-replay cannot be used together with random order." - ) - self.run_rtp(items) + def pytest_report_header(self, config: Config) -> str | None: + return reporter_mod.pytest_report_header(config) + - def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None: #ver se insiro realmente exit status e session ou se removo + def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None: start_time = time.time() - compute_test_features(self.config, self.test_reports, self.hist_len) - # Record feature collection runtime. + extractor.compute_test_features( + self.config, self.monitor.test_reports, self.hist_len, + ) self.log["Time to collect test features (s)"] = ( time.time() - start_time ) + def pytest_terminal_summary( self, terminalreporter: TerminalReporter, exitstatus: int, - config: Config) -> None: #ver se insiro realmente exit status e config ou se removo - """Report plugin runtime when it is enabled.""" + config: Config) -> None: if self.config.getoption("--rank"): - tr = terminalreporter - tr._tw.sep("=", "pytest-regsmart summary info") - for k, v in self.log.items(): - tr._tw.line(f"{k}: {v}") - pass - - -def compute_test_features( - config: Config, - test_reports: list[TestReport], - hist_len: int) -> None: - # Get the most recent execution time per test. - key = os.path.join(DATA_DIR, "last_durations") - last_durations = config.cache.get(key, {}) - for report in test_reports: - nodeid = report.nodeid - duration = report.duration - last_durations[nodeid] = round(duration, 3) - config.cache.set(key, last_durations) - - # Get the number of runs since its last failure per test. => isso poderia ir pra outra função não? - key = os.path.join(DATA_DIR, "num_runs_since_fail") - num_runs_since_fail = config.cache.get(key, {}) - for report in test_reports: - nodeid = report.nodeid - outcome = report.outcome - if outcome == "failed": - num_runs_since_fail[nodeid] = 0 - else: - # Cap within history limit. - num_runs_since_fail[nodeid] = min( - hist_len, - num_runs_since_fail.get(nodeid, 0) + 1 - ) - config.cache.set(key, num_runs_since_fail) + reporter_mod.pytest_terminal_summary(terminalreporter, self.log) @pytest.hookimpl(trylast=True) def pytest_configure(config: Config) -> None: - """ - Called when pytest is about to start: - - Create a cache folder (if not exist) to store test features for RTP - - Register this plugin - """ runner = RTPRunner(config) config.pluginmanager.register(runner) diff --git a/src/pytest_ranking/rank.py b/src/pytest_ranking/rank.py deleted file mode 100644 index 415caa1..0000000 --- a/src/pytest_ranking/rank.py +++ /dev/null @@ -1,54 +0,0 @@ -import collections -import os -from enum import Enum - -import numpy as np - -from .const import LEVEL - - -def get_test_group(nodeid: str, level: Enum) -> str: - """Get test group of a PUT at different level. - - Given a put: folder/testfile.py::TestClass::testmethod[param1] - - function: folder/testfile.py::TestClass:testmethod - - module: folder/testfile.py - - Otherwise, treat each PUT as a unique test group. - """ - test_without_param = nodeid.split("[")[0] - test_file_path = test_without_param.split("::")[0] - if level == LEVEL.FUNCTION: - return test_without_param - elif level == LEVEL.MODULE: - return test_file_path - else: - return nodeid - - -def get_ranking(scores: dict, level: Enum, init_order: dict) -> dict: - """Return ranking of tests by test nodeid. - - scores: mapping between test nodeid to its score - """ - # Get test groups. - tests = [] - for nodeid, score in scores.items(): - group = get_test_group(nodeid, level) - tests.append([nodeid, score, group]) - # Get aggregated score (mean) per group. - group_scores = collections.defaultdict(list) - for _, score, group in tests: - group_scores[group].append(score) - agg_group_scores = { - group: np.mean(score_list) - for group, score_list in group_scores.items() - } - # Sort tests by its aggregated group score. - # Break tie by default order. - tests.sort( - key=lambda x: (agg_group_scores[x[2]], init_order[x[0]]), - ) - return { - nodeid: rank - for rank, (nodeid, score, group) in enumerate(tests) - } diff --git a/src/pytest_ranking/ranker.py b/src/pytest_ranking/ranker.py new file mode 100644 index 0000000..ed7df20 --- /dev/null +++ b/src/pytest_ranking/ranker.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import collections +import os +import random +import time + +import numpy as np + +from .const import LEVEL + + +def get_test_group(nodeid: str, level: LEVEL) -> str: + """Get test group at different granularity levels. + + Given: folder/testfile.py::TestClass::testmethod[param1] + - function: folder/testfile.py::TestClass::testmethod + - module: folder/testfile.py + + Otherwise, treat each PUT as a unique test group. + """ + test_without_param = nodeid.split("[")[0] + test_file_path = test_without_param.split("::")[0] + if level == LEVEL.FUNCTION: + return test_without_param + elif level == LEVEL.MODULE: + return test_file_path + else: + return nodeid + + +def get_ranking(scores: dict, level: LEVEL, init_order: dict) -> dict: + """Aggregate scores per test group, sort, return {nodeid: rank}.""" + tests = [] + for nodeid, score in scores.items(): + group = get_test_group(nodeid, level) + tests.append([nodeid, score, group]) + + group_scores = collections.defaultdict(list) + for _, score, group in tests: + group_scores[group].append(score) + agg_group_scores = { + group: np.mean(score_list) + for group, score_list in group_scores.items() + } + + tests.sort( + key=lambda x: (agg_group_scores[x[2]], init_order[x[0]]), + ) + return { + nodeid: rank + for rank, (nodeid, _, _) in enumerate(tests) + } + + +def run_rtp( + items: list, + level: LEVEL, + weights: list[float], + replay_file: str | None, + seed: int, + log_dict: dict, + load_feature_fn, +) -> None: + """Orquestra o ranqueamento: replay, random, ou híbrido.""" + init_order = {item.nodeid: i for i, item in enumerate(items)} + start_time = time.time() + + scores: dict = {} + if replay_file and os.path.exists(replay_file): + with open(replay_file) as f: + test_list = [x.strip() for x in f.readlines()] + scores = {x: i for i, x in enumerate(test_list)} + elif weights == [0, 0]: + items.sort(key=lambda item: item.nodeid) + random.seed(seed) + scores = {item.nodeid: random.random() for item in items} + else: + w_time, w_fail = weights + h_time = load_feature_fn("last_durations", items, True) + h_fail = load_feature_fn("num_runs_since_fail", items, True) + + scores = { + item.nodeid: -( + h_time[i] * w_time + h_fail[i] * w_fail + ) + for i, item in enumerate(items) + } + + rank = get_ranking(scores, level, init_order) + + od_items = [ + item for item in items + if item.get_closest_marker("order") + or item.get_closest_marker("dependency") + ] + nod_items = [ + item for item in items + if item not in od_items + ] + nod_items.sort( + key=lambda item: ( + rank.get(item.nodeid, 0), init_order[item.nodeid] + ) + ) + items[:] = od_items + nod_items + + log_dict["Time to reorder tests (s)"] = time.time() - start_time diff --git a/src/pytest_ranking/reporter.py b/src/pytest_ranking/reporter.py new file mode 100644 index 0000000..12c73d2 --- /dev/null +++ b/src/pytest_ranking/reporter.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from _pytest.config import Config +from _pytest.terminal import TerminalReporter + + +def pytest_report_header(config: Config) -> str | None: + """Report plugin configurations before test session starts.""" + if not config.getoption("--rank"): + return None + weight = config.getoption("--rank-weight") + replay = config.getoption("--rank-replay") + level = config.getoption("--rank-level") + hist_len = config.getoption("--rank-hist-len") + random_seed = config.getoption("--rank-seed") + return "\n".join([ + f"Using --rank-weight={weight}", + f"Using --rank-level={level}", + f"Using --rank-hist-len={hist_len}", + f"Using --rank-seed={random_seed}", + f"Using --rank-replay={replay}", + ]) + + +def pytest_terminal_summary( + terminalreporter: TerminalReporter, + log_dict: dict, +) -> None: + """Report plugin runtime when it is enabled.""" + tr = terminalreporter + tr._tw.sep("=", "pytest-regsmart summary info") + for k, v in log_dict.items(): + tr._tw.line(f"{k}: {v}") From 4056eacc43c1c87b30b7bcf9fe236258fe35bee9 Mon Sep 17 00:00:00 2001 From: let Date: Mon, 20 Jul 2026 00:13:11 -0300 Subject: [PATCH 013/122] refactor: rename package name from pytest-ranking to pytest-regsmart --- {src/pytest_ranking => pytest_regsmart}/__init__.py | 0 {src/pytest_ranking => pytest_regsmart}/args.py | 0 {src/pytest_ranking => pytest_regsmart}/const.py | 0 {src/pytest_ranking => pytest_regsmart}/extractor.py | 0 {src/pytest_ranking => pytest_regsmart}/help_strings.py | 2 +- {src/pytest_ranking => pytest_regsmart}/monitor.py | 0 {src/pytest_ranking => pytest_regsmart}/plugin.py | 2 +- {src/pytest_ranking => pytest_regsmart}/ranker.py | 0 {src/pytest_ranking => pytest_regsmart}/reporter.py | 0 setup.py | 9 ++------- 10 files changed, 4 insertions(+), 9 deletions(-) rename {src/pytest_ranking => pytest_regsmart}/__init__.py (100%) rename {src/pytest_ranking => pytest_regsmart}/args.py (100%) rename {src/pytest_ranking => pytest_regsmart}/const.py (100%) rename {src/pytest_ranking => pytest_regsmart}/extractor.py (100%) rename {src/pytest_ranking => pytest_regsmart}/help_strings.py (94%) rename {src/pytest_ranking => pytest_regsmart}/monitor.py (100%) rename {src/pytest_ranking => pytest_regsmart}/plugin.py (98%) rename {src/pytest_ranking => pytest_regsmart}/ranker.py (100%) rename {src/pytest_ranking => pytest_regsmart}/reporter.py (100%) diff --git a/src/pytest_ranking/__init__.py b/pytest_regsmart/__init__.py similarity index 100% rename from src/pytest_ranking/__init__.py rename to pytest_regsmart/__init__.py diff --git a/src/pytest_ranking/args.py b/pytest_regsmart/args.py similarity index 100% rename from src/pytest_ranking/args.py rename to pytest_regsmart/args.py diff --git a/src/pytest_ranking/const.py b/pytest_regsmart/const.py similarity index 100% rename from src/pytest_ranking/const.py rename to pytest_regsmart/const.py diff --git a/src/pytest_ranking/extractor.py b/pytest_regsmart/extractor.py similarity index 100% rename from src/pytest_ranking/extractor.py rename to pytest_regsmart/extractor.py diff --git a/src/pytest_ranking/help_strings.py b/pytest_regsmart/help_strings.py similarity index 94% rename from src/pytest_ranking/help_strings.py rename to pytest_regsmart/help_strings.py index ec7c13d..1212afe 100644 --- a/src/pytest_ranking/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -36,6 +36,6 @@ REPLAY_HELP = textwrap.dedent(""" Provide a text file where each line is a test ID. -pytest-ranking will run tests with the order defined in the file. +pytest-regsmart will run tests with the order defined in the file. Default value is None. """) ## ver ainda o que fazer sobre isso diff --git a/src/pytest_ranking/monitor.py b/pytest_regsmart/monitor.py similarity index 100% rename from src/pytest_ranking/monitor.py rename to pytest_regsmart/monitor.py diff --git a/src/pytest_ranking/plugin.py b/pytest_regsmart/plugin.py similarity index 98% rename from src/pytest_ranking/plugin.py rename to pytest_regsmart/plugin.py index d1fe19a..cb4518d 100644 --- a/src/pytest_ranking/plugin.py +++ b/pytest_regsmart/plugin.py @@ -24,7 +24,7 @@ def pytest_addoption(parser: Parser) -> None: - group = parser.getgroup("rank", "pytest-ranking") + group = parser.getgroup("rank", "pytest-regsmart") group._addoption( "--rank", action="store_true", diff --git a/src/pytest_ranking/ranker.py b/pytest_regsmart/ranker.py similarity index 100% rename from src/pytest_ranking/ranker.py rename to pytest_regsmart/ranker.py diff --git a/src/pytest_ranking/reporter.py b/pytest_regsmart/reporter.py similarity index 100% rename from src/pytest_ranking/reporter.py rename to pytest_regsmart/reporter.py diff --git a/setup.py b/setup.py index 97da46a..78ede15 100644 --- a/setup.py +++ b/setup.py @@ -2,9 +2,6 @@ import codecs import os -from glob import glob -from os.path import basename, splitext - from setuptools import find_packages, setup @@ -26,9 +23,7 @@ def read(fname): + ' via regression test prioritization and selection.', long_description=read('README.md'), long_description_content_type='text/markdown', - packages=find_packages('src'), - package_dir={'': 'src'}, - py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], + packages=find_packages(), python_requires='>=3.5', install_requires=[ 'pytest>=7.4.3', @@ -64,7 +59,7 @@ def read(fname): ], entry_points={ 'pytest11': [ - 'pytest_ranking = pytest_ranking.plugin', + 'pytest_regsmart = pytest_regsmart.plugin', ], }, ) From b5c8522f6ecceff63785f783c6d980da14a8b171 Mon Sep 17 00:00:00 2001 From: let Date: Mon, 20 Jul 2026 14:35:20 -0300 Subject: [PATCH 014/122] code: migrate to uv and hatchling --- .github/workflows/ci.yml | 19 +- .gitignore | 1 + .pre-commit-config.yaml | 6 +- CHANGELOG.md | 5 + MANIFEST.in | 5 - README.md | 153 +++++----- pyproject.toml | 52 ++++ pytest_regsmart/__init__.py | 1 + run_test_local.sh | 6 +- setup.py | 65 ----- tests/test_options.py | 16 +- tests/test_replay.py | 6 +- tox.ini | 10 +- uv.lock | 562 ++++++++++++++++++++++++++++++++++++ 14 files changed, 713 insertions(+), 194 deletions(-) delete mode 100644 MANIFEST.in create mode 100644 pyproject.toml delete mode 100644 setup.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48655f3..33d7dab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,3 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - name: CI on: @@ -11,7 +8,6 @@ on: jobs: tests: - runs-on: ubuntu-latest strategy: fail-fast: false @@ -24,14 +20,9 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v3 - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Install tox and any other packages - run: | - pip install tox - - name: Run tox - # Run tox using the version of Python in `PATH` - run: | - tox run -e py$(echo ${{ matrix.python-version }}) + run: uv sync --frozen + - name: Run tests + run: uv run pytest -xv tests/ diff --git a/.gitignore b/.gitignore index 3357e57..4d795c7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ __pycache__/ dist/ *.egg-info/ +.venv/ estudo/ validacao-experimentos/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0aac9fa..d644c6c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,10 @@ repos: - id: end-of-file-fixer exclude: '.*\.pth$' - id: debug-statements + - repo: https://github.com/astral-sh/uv-pre-commit + rev: v0.5.0 + hooks: + - id: uv-lock - repo: https://github.com/codespell-project/codespell rev: v2.2.6 hooks: @@ -18,7 +22,7 @@ repos: rev: v3.3.1 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py38-plus] - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc0751..2d44827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## pytest-regsmart +0.3.0 (2026-07-20) +---- + +* Migrate package manager to uv + 0.2.0 (2026-07-19) ---- diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index f3155af..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include LICENSE -include README.md - -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] diff --git a/README.md b/README.md index 03639bb..abfc16e 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,65 @@ - -# pytest-ranking - - - -[![CI](https://github.com/softwareTestingResearch/pytest-ranking/workflows/CI/badge.svg)](https://github.com/softwareTestingResearch/pytest-ranking/actions?workflow=CI) -[![PyPI](https://img.shields.io/pypi/v/pytest-ranking)](https://pypi.org/project/pytest-ranking) -[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/softwareTestingResearch/pytest-ranking/main.svg)](https://results.pre-commit.ci/latest/github/softwareTestingResearch/pytest-ranking/main) - +# pytest-regsmart A Pytest plugin that implements Regression Test Prioritization (RTP) for faster regression fault detection. This [pytest](https://github.com/pytest-dev/pytest) plugin allows you to find regression test failures faster and receive testing feedback sooner from CI build. -It speeds up the failure detection of your test suite by executing earlier the tests that are faster, recently failed, and more code-change-related. - +It speeds up the failure detection of your test suite by executing earlier the tests that are faster and recently failed. ## Installation -To install `pytest-ranking` via [pip](https://pypi.org/project/pip/) from [PyPI](https://pypi.org/project): - ```bash -pip install pytest-ranking +uv pip install pytest-regsmart ``` +Or using pip: + +```bash +pip install pytest-regsmart +``` ## Usage Pytest will automatically find the plugin and use it when you run ``pytest``. -You can run `pytest-ranking` with its default configuration, which runs faster tests first by passing the ``--rank`` option: +You can run `pytest-regsmart` with its default configuration, which runs faster tests first by passing the ``--rank`` option: ```bash pytest --rank ``` -Before the test run starts, if `--rank` is passed, the terminal header will report `pytest-ranking`'s configuration of this run, for example: -```text -Using --rank-weight=0-0-0 +Before the test run starts, if `--rank` is passed, the terminal header will report `pytest-regsmart`'s configuration of this run, for example: + +``` +Using --rank-weight=1-0 Using --rank-level=put Using --rank-hist-len=50 -Using --rank-seed=1744140050 +Using --rank-seed=123 Using --rank-replay=None ``` +After the test run finishes, the terminal summary will show the overhead of `pytest-regsmart` in this run, for example: -After the test run finishes, the terminal summary will show the overhead of `pytest-ranking` in this run, for example: - - ```text -============================================= pytest-ranking summary info ============================================= -Number of changed Python files: 0 -Time to compute test-change similarity (s): 0.000865936279296875 +``` +=================================== pytest-regsmart summary info ==================================== Time to reorder tests (s): 0.0003600120544433594 Time to collect test features (s): 0.0004608631134033203 ``` - ### Optimizing test prioritization heuristics You can set the weights of different test prioritization heuristics by passing the optional `--rank-weight` flag with formatted values: ```bash -pytest --rank --rank-weight=0-1-0 +pytest --rank --rank-weight=0-1 ``` - Weights are separated by ``-`` - - The first weight is for running faster tests - - The second weight is for running recently failed tests - - The third weight is for running tests more similar to the changed `*.py` files since the last run + - The first weight is for running faster tests + - The second weight is for running recently failed tests + - The third weight is for running tests more similar to the changed `*.py` files since the last run - All weights must be integers or floats, and their sum will be normalized to 1 - A higher weight means that a corresponding heuristic is favored. -The default value is ``1-0-0``, which only prioritizes faster tests. - +The default value is ``1-0``, which only prioritizes faster tests. ### Optimizing test prioritization levels @@ -81,12 +71,11 @@ pytest --rank --rank-level=function - The smallest test item that can be reordered in pytest test suite is [parametrized unit test](https://docs.pytest.org/en/7.1.x/example/parametrize.html) (PUT) - This option allows you to set at which level the reordering takes place: - - `put` reorders the each PUT and re-arranges their order based on their assigned priority scores - - `function` reorders each test function, parametrized values of a test function follow their default order - - `module` reorders each test file, all tests in the test file follow their default order -7 -The default value is `put`. + - `put` reorders the each PUT and re-arranges their order based on their assigned priority scores + - `function` reorders each test function, parametrized values of a test function follow their default order + - `module` reorders each test file, all tests in the test file follow their default order +The default value is `put`. ### Replaying specified test order @@ -96,7 +85,6 @@ You can run/replay tests in a specific order by listing the to-be-run test IDs i pytest --rank --rank-replay=replay_order.txt ``` - ### Tracking data from historical runs You can also set the maximum value of *the number test runs since a test's last failure* that could be recorded for each test, by passing the optional `--rank-hist-len` flag: @@ -106,91 +94,62 @@ pytest --rank --rank-hist-len=30 ``` The default value is 50. -Note that `pytest-ranking` does not store any historical test run logs, it merely updated its cached data from the previous run with data from the latest run. +Note that `pytest-regsmart` does not store any historical test run logs, it merely updated its cached data from the previous run with data from the latest run. ### Running tests in random order -You can prompt `pytest-ranking` to run tests in random order, by setting the sum of `--rank-weight` option to 0, e.g., `--rank-weight=0-0-0`. +You can prompt `pytest-regsmart` to run tests in random order, by setting the sum of `--rank-weight` option to 0, e.g., `--rank-weight=0-0`. You can also set the seed used when running tests in random order, via setting an integer to the option `--rank-seed`. For example, the command below runs tests randomly with seed `1234`: ```bash -pytest --rank --rank-weight=0-0-0 --rank-seed=1234 +pytest --rank --rank-weight=0-0 --rank-seed=1234 ``` -By default, `pytest-ranking` uses `0` as the seed. +By default, `pytest-regsmart` uses `0` as the seed. ### Setting configurable options via config file You can always apply available options by adding them to the ``addopts`` setting in your [pytest.ini](https://docs.pytest.org/en/latest/reference/customize.html#configuration). For example, create `pytest.ini` in your codebase root folder as such: + ```ini [pytest] -addopts = --rank --rank-weight=0-1-0 --rank-hist-len=30 +addopts = --rank --rank-weight=0-1 --rank-hist-len=30 ``` + and run `pytest` on the command line. Alternatively, you can also create `pytest.ini` in your codebase root folder as such: + ```ini [pytest] -rank_weight=0-1-0 +rank_weight=0-1 rank_hist_len=30 ``` and run `pytest --rank` on the command line. - -## Deployment +## Deployment (old) `pytest-ranking` is easy to deploy into CI workflow, please see [deployment](./docs/DEPLOYMENT.md). +## Local development -## Compatibility - -`pytest-ranking` works with [test selection](https://docs.pytest.org/en/6.2.x/usage.html#specifying-tests-selecting-tests) and [parallelization](https://pypi.org/project/pytest-xdist). -It also works with plugins for ordering tests, e.g., [pytest-order](https://pypi.org/project/pytest-order), [pytest-dependency](https://pypi.org/project/pytest-dependency) by -running ordered tests first in their declared order. -Pytest options that order tests generally (e.g., [`--ff`](https://docs.pytest.org/en/stable/how-to/cache.html#usage)), or plugins that randomly order tests (e.g., [pytest-randomly](https://github.com/pytest-dev/pytest-randomly), [pytest-random-order](https://github.com/pytest-dev/pytest-random-order), [pytest-reverse](https://github.com/adamchainz/pytest-reverse)), can interfere with `pytest-ranking` as they use the same reordering hook. - - -## Reference - -#### Demo video -A 5-minute demo video with walkthrough of `pytest-ranking`: [YouTube link](https://youtu.be/SrnkgTs3uok?feature=shared) - -#### Bibtex citation -``` -@inproceedings{cheng2025pytest, - title={{pytest-ranking: A Regression Test Prioritization Tool for Python}}, - author={Cheng, Runxiang and Ke, Kaiyao and Marinov, Darko}, - booktitle={Companion Proceedings of the 33rd ACM International Conference on the Foundations of Software Engineering}, - year={2025}, -} -``` - - -## Contributing - -Contributions are very welcome. - -### Local development - -Install the package in editable mode: +Install the package and dependencies with uv: ```bash -pip install -e . +uv sync ``` ### Running tests -During development, to quickly validate your changes: - ```bash -pytest -xv tests/ +uv run pytest -xv tests/ ``` -To test across all supported Python versions and ensure compatibility before publishing, use [tox](https://tox.readthedocs.io/en/latest/): +To test across all supported Python versions, use [tox](https://tox.readthedocs.io/en/latest/): ```bash tox @@ -208,11 +167,37 @@ To pass extra arguments to pytest via tox: tox -- -k "test_name" ``` +## Compatibility + +`pytest-regsmart` works with [test selection](https://docs.pytest.org/en/6.2.x/usage.html#specifying-tests-selecting-tests) and [parallelization](https://pypi.org/project/pytest-xdist). +It also works with plugins for ordering tests, e.g., [pytest-order](https://pypi.org/project/pytest-order), [pytest-dependency](https://pypi.org/project/pytest-dependency) by +running ordered tests first in their declared order. +Pytest options that order tests generally (e.g., [`--ff`](https://docs.pytest.org/en/stable/how-to/cache.html#usage)), or plugins that randomly order tests (e.g., [pytest-randomly](https://github.com/pytest-dev/pytest-randomly), [pytest-random-order](https://github.com/pytest-dev/pytest-random-order), [pytest-reverse](https://github.com/adamchainz/pytest-reverse)), can interfere with `pytest-regsmart` as they use the same reordering hook. + +## Reference + +#### Demo video +A 5-minute demo video with walkthrough of `pytest-ranking`: [YouTube link](https://youtu.be/SrnkgTs3uok?feature=shared) + +#### Bibtex citation + +``` +@inproceedings{cheng2025pytest, + title={{pytest-ranking: A Regression Test Prioritization Tool for Python}}, + author={Cheng, Runxiang and Ke, Kaiyao and Marinov, Darko}, + booktitle={Companion Proceedings of the 33rd ACM International Conference on the Foundations of Software Engineering}, + year={2025}, +} +``` + +## Contributing + +Contributions are very welcome. ## License -Distributed under the terms of the [MIT](http://opensource.org/licenses/MIT) license, `pytest-ranking` is free and open-source software. +Distributed under the terms of the [MIT](http://opensource.org/licenses/MIT) license, `pytest-regsmart` is free and open-source software. ## Issues -If you encounter any problems, please [file an issue](https://github.com/softwareTestingResearch/pytest-ranking/issues) or [pull request](https://github.com/softwareTestingResearch/pytest-ranking/pulls) along with a detailed description. +If you encounter any problems, please [file an issue](https://github.com/leticosta4/pytest-regsmart/issues) or [pull request](https://github.com/leticosta4/pytest-regsmart/pulls) along with a detailed description. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fd4a6fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pytest-regsmart" +version = "0.3.4" +description = "A Pytest plugin for faster fault detection via regression test prioritization and selection." +readme = "README.md" +license = { text = "MIT" } +authors = [ + { name = "leticosta4", email = "leticiacostaoa@gmail.com" }, +] +maintainers = [ + { name = "leticosta4", email = "leticiacostaoa@gmail.com" }, +] +requires-python = ">=3.8" +dependencies = [ + "pytest>=7.4.3", + "numpy", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Pytest", + "Intended Audience :: Developers", + "Topic :: Software Development :: Testing", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Operating System :: OS Independent", + "License :: OSI Approved :: MIT License", +] +keywords = [ + "software testing", + "regression testing", + "test prioritization", + "test selection", + "pytest", +] + +[project.urls] +Homepage = "https://github.com/leticosta4/pytest-regsmart" + +[project.entry-points."pytest11"] +pytest_regsmart = "pytest_regsmart.plugin" diff --git a/pytest_regsmart/__init__.py b/pytest_regsmart/__init__.py index e69de29..334b899 100644 --- a/pytest_regsmart/__init__.py +++ b/pytest_regsmart/__init__.py @@ -0,0 +1 @@ +__version__ = "0.3.4" diff --git a/run_test_local.sh b/run_test_local.sh index 2be3937..87070e2 100755 --- a/run_test_local.sh +++ b/run_test_local.sh @@ -1,6 +1,4 @@ #!/bin/bash -pip install . -pytest -xv - -pip uninstall pytest-regsmart +uv sync +uv run pytest -xv "$@" diff --git a/setup.py b/setup.py deleted file mode 100644 index 78ede15..0000000 --- a/setup.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python - -import codecs -import os -from setuptools import find_packages, setup - - -def read(fname): - file_path = os.path.join(os.path.dirname(__file__), fname) - return codecs.open(file_path, encoding='utf-8').read() - - -setup( - name='pytest-regsmart', - version='0.3.4', - author='leticosta4', - author_email='leticiacostaoa@gmail.com', - maintainer='leticosta4', - maintainer_email='leticiacostaoa@gmail.com', - license='MIT', - url='https://github.com/leticosta4/pytest-regsmart', - description='A Pytest plugin for faster fault detection' - + ' via regression test prioritization and selection.', - long_description=read('README.md'), - long_description_content_type='text/markdown', - packages=find_packages(), - python_requires='>=3.5', - install_requires=[ - 'pytest>=7.4.3', - 'numpy', - ], - classifiers=[ - 'Development Status :: 4 - Beta', - 'Framework :: Pytest', - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Testing', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Operating System :: OS Independent', - 'License :: OSI Approved :: MIT License', - ], - keywords=[ - 'software testing', - 'regression testing', - 'test prioritization', - 'test selection', - 'pytest', - ], - entry_points={ - 'pytest11': [ - 'pytest_regsmart = pytest_regsmart.plugin', - ], - }, -) diff --git a/tests/test_options.py b/tests/test_options.py index 8528ef2..3d505bd 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -43,15 +43,13 @@ def test_invalid_weight(mytester): ) args = ["-v", "--rank", "--rank-weight=1-3-2"] out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-weight:" \ - + " Cannot parse input for `--rank-weight`." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 + error_msg = "Cannot parse input for `--rank-weight`." + assert any(error_msg in x for x in out.errlines) args = ["-v", "--rank", "--rank-weight=1-3-x"] out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-weight:" \ - + " Cannot parse input for `--rank-weight`." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 + error_msg = "Cannot parse input for `--rank-weight`." + assert any(error_msg in x for x in out.errlines) def test_random_order(mytester): @@ -127,7 +125,5 @@ def test_invalid_level(mytester): args = ["-v", "--rank", "--rank-level=class"] out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-level:" \ - + " Invalid input for `--rank-level`." \ - + " Please run `pytest --help` for instruction." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 + error_msg = "Invalid input for `--rank-level`." + assert any(error_msg in x for x in out.errlines) diff --git a/tests/test_replay.py b/tests/test_replay.py index 979a5b3..0439871 100644 --- a/tests/test_replay.py +++ b/tests/test_replay.py @@ -67,7 +67,5 @@ def test_invalid_replay(mytester): args = ["-v", "--rank", "--rank-replay=order.txt"] out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-replay:" \ - + " File provided to `--rank-replay` cannot be read." \ - + " Please run `pytest --help` for instruction." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 + error_msg = "File provided to `--rank-replay` cannot be read." + assert any(error_msg in x for x in out.errlines) diff --git a/tox.ini b/tox.ini index 3db4ad9..2a946e3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,10 @@ -# For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] -envlist = py35,py36,py37,py38,py310,py311,py312,pypy3,flake8 +requires = tox-uv +envlist = py38,py39,py310,py311,py312 [testenv] +installer = uv deps = pytest pytest-xdist commands = pytest -xv {posargs:tests} - -[testenv:flake8] -skip_install = true -deps = flake8 -commands = flake8 src tests setup.py diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..14c3ac4 --- /dev/null +++ b/uv.lock @@ -0,0 +1,562 @@ +version = 1 +revision = 3 +requires-python = ">=3.8" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "1.24.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/9b/027bec52c633f6556dba6b722d9a0befb40498b9ceddd29cbe67a45a127c/numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463", size = 10911229, upload-time = "2023-06-26T13:39:33.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/80/6cdfb3e275d95155a34659163b83c09e3a3ff9f1456880bec6cc63d71083/numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64", size = 19789140, upload-time = "2023-06-26T13:22:33.184Z" }, + { url = "https://files.pythonhosted.org/packages/64/5f/3f01d753e2175cfade1013eea08db99ba1ee4bdb147ebcf3623b75d12aa7/numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1", size = 13854297, upload-time = "2023-06-26T13:22:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b3/2f9c21d799fa07053ffa151faccdceeb69beec5a010576b8991f614021f7/numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4", size = 13995611, upload-time = "2023-06-26T13:23:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/10/be/ae5bf4737cb79ba437879915791f6f26d92583c738d7d960ad94e5c36adf/numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6", size = 17282357, upload-time = "2023-06-26T13:23:51.446Z" }, + { url = "https://files.pythonhosted.org/packages/c0/64/908c1087be6285f40e4b3e79454552a701664a079321cff519d8c7051d06/numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc", size = 12429222, upload-time = "2023-06-26T13:24:13.849Z" }, + { url = "https://files.pythonhosted.org/packages/22/55/3d5a7c1142e0d9329ad27cece17933b0e2ab4e54ddc5c1861fbfeb3f7693/numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e", size = 14841514, upload-time = "2023-06-26T13:24:38.129Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cc/5ed2280a27e5dab12994c884f1f4d8c3bd4d885d02ae9e52a9d213a6a5e2/numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810", size = 19775508, upload-time = "2023-06-26T13:25:08.882Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bc/77635c657a3668cf652806210b8662e1aff84b818a55ba88257abf6637a8/numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254", size = 13840033, upload-time = "2023-06-26T13:25:33.417Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4c/96cdaa34f54c05e97c1c50f39f98d608f96f0677a6589e64e53104e22904/numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7", size = 13991951, upload-time = "2023-06-26T13:25:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/22/97/dfb1a31bb46686f09e68ea6ac5c63fdee0d22d7b23b8f3f7ea07712869ef/numpy-1.24.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7215847ce88a85ce39baf9e89070cb860c98fdddacbaa6c0da3ffb31b3350bd5", size = 17278923, upload-time = "2023-06-26T13:26:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/35/e2/76a11e54139654a324d107da1d98f99e7aa2a7ef97cfd7c631fba7dbde71/numpy-1.24.4-cp311-cp311-win32.whl", hash = "sha256:4979217d7de511a8d57f4b4b5b2b965f707768440c17cb70fbf254c4b225238d", size = 12422446, upload-time = "2023-06-26T13:26:49.302Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ec/ebef2f7d7c28503f958f0f8b992e7ce606fb74f9e891199329d5f5f87404/numpy-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b7b1fc9864d7d39e28f41d089bfd6353cb5f27ecd9905348c24187a768c79694", size = 14834466, upload-time = "2023-06-26T13:27:16.029Z" }, + { url = "https://files.pythonhosted.org/packages/11/10/943cfb579f1a02909ff96464c69893b1d25be3731b5d3652c2e0cf1281ea/numpy-1.24.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1452241c290f3e2a312c137a9999cdbf63f78864d63c79039bda65ee86943f61", size = 19780722, upload-time = "2023-06-26T13:27:49.573Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ae/f53b7b265fdc701e663fbb322a8e9d4b14d9cb7b2385f45ddfabfc4327e4/numpy-1.24.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f", size = 13843102, upload-time = "2023-06-26T13:28:12.288Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/2586a50ad72e8dbb1d8381f837008a0321a3516dfd7cb57fc8cf7e4bb06b/numpy-1.24.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5425b114831d1e77e4b5d812b69d11d962e104095a5b9c3b641a218abcc050e", size = 14039616, upload-time = "2023-06-26T13:28:35.659Z" }, + { url = "https://files.pythonhosted.org/packages/98/5d/5738903efe0ecb73e51eb44feafba32bdba2081263d40c5043568ff60faf/numpy-1.24.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd80e219fd4c71fc3699fc1dadac5dcf4fd882bfc6f7ec53d30fa197b8ee22dc", size = 17316263, upload-time = "2023-06-26T13:29:09.272Z" }, + { url = "https://files.pythonhosted.org/packages/d1/57/8d328f0b91c733aa9aa7ee540dbc49b58796c862b4fbcb1146c701e888da/numpy-1.24.4-cp38-cp38-win32.whl", hash = "sha256:4602244f345453db537be5314d3983dbf5834a9701b7723ec28923e2889e0bb2", size = 12455660, upload-time = "2023-06-26T13:29:33.434Z" }, + { url = "https://files.pythonhosted.org/packages/69/65/0d47953afa0ad569d12de5f65d964321c208492064c38fe3b0b9744f8d44/numpy-1.24.4-cp38-cp38-win_amd64.whl", hash = "sha256:692f2e0f55794943c5bfff12b3f56f99af76f902fc47487bdfe97856de51a706", size = 14868112, upload-time = "2023-06-26T13:29:58.385Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/d5b0402b801c8a8b56b04c1e85c6165efab298d2f0ab741c2406516ede3a/numpy-1.24.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2541312fbf09977f3b3ad449c4e5f4bb55d0dbf79226d7724211acc905049400", size = 19816549, upload-time = "2023-06-26T13:30:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/638aaa446f39113a3ed38b37a66243e21b38110d021bfcb940c383e120f2/numpy-1.24.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9667575fb6d13c95f1b36aca12c5ee3356bf001b714fc354eb5465ce1609e62f", size = 13879950, upload-time = "2023-06-26T13:31:01.787Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/91894916e50627476cff1a4e4363ab6179d01077d71b9afed41d9e1f18bf/numpy-1.24.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a86ed21e4f87050382c7bc96571755193c4c1392490744ac73d660e8f564a9", size = 14030228, upload-time = "2023-06-26T13:31:26.696Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/d7b2a0417af6428440c0ad7cb9799073e507b1a465f827d058b826236964/numpy-1.24.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11efb4dbecbdf22508d55e48d9c8384db795e1b7b51ea735289ff96613ff74d", size = 17311170, upload-time = "2023-06-26T13:31:56.615Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/e02ace5d7dfccee796c37b995c63322674daf88ae2f4a4724c5dd0afcc91/numpy-1.24.4-cp39-cp39-win32.whl", hash = "sha256:6620c0acd41dbcb368610bb2f4d83145674040025e5536954782467100aa8835", size = 12454918, upload-time = "2023-06-26T13:32:16.8Z" }, + { url = "https://files.pythonhosted.org/packages/63/38/6cc19d6b8bfa1d1a459daf2b3fe325453153ca7019976274b6f33d8b5663/numpy-1.24.4-cp39-cp39-win_amd64.whl", hash = "sha256:befe2bf740fd8373cf56149a5c23a0f601e82869598d41f8e188a0e9869926f8", size = 14867441, upload-time = "2023-06-26T13:32:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fd/8dff40e25e937c94257455c237b9b6bf5a30d42dd1cc11555533be099492/numpy-1.24.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31f13e25b4e304632a4619d0e0777662c2ffea99fcae2029556b17d8ff958aef", size = 19156590, upload-time = "2023-06-26T13:33:10.36Z" }, + { url = "https://files.pythonhosted.org/packages/42/e7/4bf953c6e05df90c6d351af69966384fed8e988d0e8c54dad7103b59f3ba/numpy-1.24.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7ac6540e95bc440ad77f56e520da5bf877f87dca58bd095288dce8940532a", size = 16705744, upload-time = "2023-06-26T13:33:36.703Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/9106005eb477d022b60b3817ed5937a43dad8fd1f20b0610ea8a32fcb407/numpy-1.24.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e98f220aa76ca2a977fe435f5b04d7b3470c0a2e6312907b37ba6068f26787f2", size = 14734290, upload-time = "2023-06-26T13:34:05.409Z" }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "tomli", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "packaging", marker = "python_full_version == '3.9.*'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pygments", marker = "python_full_version == '3.9.*'" }, + { name = "tomli", marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-regsmart" +version = "0.3.4" +source = { editable = "." } +dependencies = [ + { name = "numpy", version = "1.24.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy" }, + { name = "pytest", specifier = ">=7.4.3" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From b0b1be35c2f0611b96b3864341e439cb34536ad5 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 21 Jul 2026 23:27:50 -0300 Subject: [PATCH 015/122] fix: format runtime output in pytest_terminal_summary for better readability --- pytest_regsmart/reporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_regsmart/reporter.py b/pytest_regsmart/reporter.py index 12c73d2..46b1ad6 100644 --- a/pytest_regsmart/reporter.py +++ b/pytest_regsmart/reporter.py @@ -30,4 +30,4 @@ def pytest_terminal_summary( tr = terminalreporter tr._tw.sep("=", "pytest-regsmart summary info") for k, v in log_dict.items(): - tr._tw.line(f"{k}: {v}") + tr._tw.line(f"{k}: {v*1000:.2f} ms ({v} s)") From 0afc9c9f40ea50fdabf6d4824ed3a1c4b17d7ea6 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 23 Jul 2026 23:15:42 -0300 Subject: [PATCH 016/122] refactor: rename RTPRunner to PluginRunner --- pytest_regsmart/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index cb4518d..ad05b42 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -77,7 +77,7 @@ def pytest_addoption(parser: Parser) -> None: parser.addini("rank_seed", SEED_HELP, default=DEFAULT_SEED) -class RTPRunner: +class PluginRunner: """Plugin class.""" def __init__(self, config: Config) -> None: self.config = config @@ -136,5 +136,5 @@ def pytest_terminal_summary( @pytest.hookimpl(trylast=True) def pytest_configure(config: Config) -> None: - runner = RTPRunner(config) + runner = PluginRunner(config) config.pluginmanager.register(runner) From 232e85e091b72933fb17050bffebff4a8f5fa6b5 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 24 Jul 2026 21:43:05 -0300 Subject: [PATCH 017/122] refactor: update main --rank flag to --regsmart --- pytest_regsmart/help_strings.py | 4 ++++ pytest_regsmart/plugin.py | 17 ++++++++++++----- pytest_regsmart/reporter.py | 2 +- tests/test_interop.py | 2 +- tests/test_options.py | 18 +++++++++--------- tests/test_ranking_heuristics.py | 8 ++++---- tests/test_ranking_levels.py | 8 ++++---- tests/test_replay.py | 6 +++--- 8 files changed, 38 insertions(+), 27 deletions(-) diff --git a/pytest_regsmart/help_strings.py b/pytest_regsmart/help_strings.py index 1212afe..18b7237 100644 --- a/pytest_regsmart/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -39,3 +39,7 @@ pytest-regsmart will run tests with the order defined in the file. Default value is None. """) ## ver ainda o que fazer sobre isso + +NO_RANK_HELP = textwrap.dedent(""" +Disable ranking option (RTP). +""") diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index ad05b42..3c2b630 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -18,17 +18,23 @@ from .const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT from .help_strings import ( HIST_LEN_HELP, LEVEL_HELP, PLUGIN_HELP, - REPLAY_HELP, SEED_HELP, WEIGHT_HELP, + REPLAY_HELP, SEED_HELP, WEIGHT_HELP, NO_RANK_HELP ) from .monitor import Monitor def pytest_addoption(parser: Parser) -> None: - group = parser.getgroup("rank", "pytest-regsmart") + group = parser.getgroup("regsmart", "pytest-regsmart") group._addoption( - "--rank", + "--regsmart", #era o antigo --rank que ativava o default basico do pytest-ranking action="store_true", help=PLUGIN_HELP) + + group._addoption( + "--no-rank", + action="store_false", + help=NO_RANK_HELP, + dest="no_rank") #talvez nao precise group._addoption( "--rank-level", @@ -75,6 +81,7 @@ def pytest_addoption(parser: Parser) -> None: parser.addini("rank_level", LEVEL_HELP, default=DEFAULT_LEVEL) parser.addini("rank_hist_len", HIST_LEN_HELP, default=DEFAULT_HIST_LEN) parser.addini("rank_seed", SEED_HELP, default=DEFAULT_SEED) + parser.addini("no_rank", NO_RANK_HELP, default=False) class PluginRunner: @@ -93,7 +100,7 @@ def __init__(self, config: Config) -> None: @pytest.hookimpl(trylast=True) def pytest_collection_modifyitems(self, items: list[Item]) -> None: - if not self.config.getoption("--rank"): + if not self.config.getoption("--regsmart"): return if self.replay_file and self.weights == [0, 0]: raise argparse.ArgumentTypeError( @@ -130,7 +137,7 @@ def pytest_terminal_summary( terminalreporter: TerminalReporter, exitstatus: int, config: Config) -> None: - if self.config.getoption("--rank"): + if self.config.getoption("--regsmart"): reporter_mod.pytest_terminal_summary(terminalreporter, self.log) diff --git a/pytest_regsmart/reporter.py b/pytest_regsmart/reporter.py index 46b1ad6..77115c1 100644 --- a/pytest_regsmart/reporter.py +++ b/pytest_regsmart/reporter.py @@ -6,7 +6,7 @@ def pytest_report_header(config: Config) -> str | None: """Report plugin configurations before test session starts.""" - if not config.getoption("--rank"): + if not config.getoption("--regsmart"): return None weight = config.getoption("--rank-weight") replay = config.getoption("--rank-replay") diff --git a/tests/test_interop.py b/tests/test_interop.py index 444f90a..6307a1d 100644 --- a/tests/test_interop.py +++ b/tests/test_interop.py @@ -14,7 +14,7 @@ def test_order_dependency_marker(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=8, failed=1, xfailed=1) - args = ["-v", "--rank"] + args = ["-v", "--regsmart"] out = mytester.runpytest(*args) out.assert_outcomes(passed=8, failed=1, xfailed=1) diff --git a/tests/test_options.py b/tests/test_options.py index 3d505bd..e91e6d0 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -31,7 +31,7 @@ def test_logging(mytester): assert len([x for x in out.outlines if header in x]) == 0 assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 - args = ["-v", "--rank"] + args = ["-v", "--regsmart"] out = mytester.runpytest(*args) out.assert_outcomes(passed=2, failed=1) assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 @@ -41,12 +41,12 @@ def test_invalid_weight(mytester): mytester.makepyfile( test_method_one=test_method_one ) - args = ["-v", "--rank", "--rank-weight=1-3-2"] + args = ["-v", "--regsmart", "--rank-weight=1-3-2"] out = mytester.runpytest(*args) error_msg = "Cannot parse input for `--rank-weight`." assert any(error_msg in x for x in out.errlines) - args = ["-v", "--rank", "--rank-weight=1-3-x"] + args = ["-v", "--regsmart", "--rank-weight=1-3-x"] out = mytester.runpytest(*args) error_msg = "Cannot parse input for `--rank-weight`." assert any(error_msg in x for x in out.errlines) @@ -71,12 +71,12 @@ def test_random_order(mytester): ) assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 - args = ["-v", "--rank"] + args = ["-v", "--regsmart"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 - args = ["-v", "--rank", "--rank-weight=0-0"] + args = ["-v", "--regsmart", "--rank-weight=0-0"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) log_text = ( @@ -85,7 +85,7 @@ def test_random_order(mytester): assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 test_lines_default1 = [x for x in out.outlines if "::" in x] - args = ["-v", "--rank", "--rank-weight=0.0-0.0", "--rank-seed=8"] + args = ["-v", "--regsmart", "--rank-weight=0.0-0.0", "--rank-seed=8"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) log_text = ( @@ -94,7 +94,7 @@ def test_random_order(mytester): assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 test_lines_1 = [x for x in out.outlines if "::" in x] - args = ["-v", "--rank", "--rank-weight=0-0", "--rank-seed=16"] + args = ["-v", "--regsmart", "--rank-weight=0-0", "--rank-seed=16"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) log_text = ( @@ -111,7 +111,7 @@ def test_xdist(mytester): test_put_one=test_put_one, ) - args = ["-v", "--rank", "-n", "auto", "--rank-weight=0-0"] + args = ["-v", "--regsmart", "-n", "auto", "--rank-weight=0-0"] out = mytester.runpytest(*args) assert len([x for x in out.outlines if x.startswith("ERROR")]) == 0 @@ -123,7 +123,7 @@ def test_invalid_level(mytester): test_c_put=test_c_put, ) - args = ["-v", "--rank", "--rank-level=class"] + args = ["-v", "--regsmart", "--rank-level=class"] out = mytester.runpytest(*args) error_msg = "Invalid input for `--rank-level`." assert any(error_msg in x for x in out.errlines) diff --git a/tests/test_ranking_heuristics.py b/tests/test_ranking_heuristics.py index 1c22a9c..0ef58bd 100644 --- a/tests/test_ranking_heuristics.py +++ b/tests/test_ranking_heuristics.py @@ -13,7 +13,7 @@ def test_default(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - args = ["-v", "--rank"] + args = ["-v", "--regsmart"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) @@ -40,7 +40,7 @@ def test_faster_test_first(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - args = ["-v", "--rank", "--rank-weight=1-0"] + args = ["-v", "--regsmart", "--rank-weight=1-0"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) @@ -67,7 +67,7 @@ def test_recent_fail_first(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - args = ["-v", "--rank", "--rank-weight=0-1"] + args = ["-v", "--regsmart", "--rank-weight=0-1"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) @@ -94,7 +94,7 @@ def test_550_weight(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - args = ["-v", "--rank", "--rank-weight=5-5"] + args = ["-v", "--regsmart", "--rank-weight=5-5"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) diff --git a/tests/test_ranking_levels.py b/tests/test_ranking_levels.py index 69ef57f..1a6b73b 100644 --- a/tests/test_ranking_levels.py +++ b/tests/test_ranking_levels.py @@ -22,7 +22,7 @@ def test_put_level_ranking(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=13, failed=2) - args = ["-v", "--rank", "--rank-level=put"] + args = ["-v", "--regsmart", "--rank-level=put"] out = mytester.runpytest(*args) out.assert_outcomes(passed=13, failed=2) @@ -59,7 +59,7 @@ def test_function_level_ranking(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=9, failed=2) - args = ["-v", "--rank", "--rank-level=function"] + args = ["-v", "--regsmart", "--rank-level=function"] out = mytester.runpytest(*args) out.assert_outcomes(passed=9, failed=2) @@ -92,7 +92,7 @@ def test_module_level_ranking(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=9, failed=2) - args = ["-v", "--rank", "--rank-level=module"] + args = ["-v", "--regsmart", "--rank-level=module"] out = mytester.runpytest(*args) out.assert_outcomes(passed=9, failed=2) @@ -126,7 +126,7 @@ def test_function_level_ranking_with_duplicate_methods(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=11, failed=3) - args = ["-v", "--rank", "--rank-level=function"] + args = ["-v", "--regsmart", "--rank-level=function"] out = mytester.runpytest(*args) out.assert_outcomes(passed=11, failed=3) diff --git a/tests/test_replay.py b/tests/test_replay.py index 0439871..c15ecbf 100644 --- a/tests/test_replay.py +++ b/tests/test_replay.py @@ -22,7 +22,7 @@ def test_replay(mytester): out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - args = ["-v", "--rank", "--rank-replay=replay_order.txt"] + args = ["-v", "--regsmart", "--rank-replay=replay_order.txt"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) @@ -51,7 +51,7 @@ def test_replay_with_random(mytester): args = [ "-v", - "--rank", + "--regsmart", "--rank-replay=replay_order.txt", "--rank-weight=0-0" ] @@ -65,7 +65,7 @@ def test_invalid_replay(mytester): test_a_method=test_a_method, ) - args = ["-v", "--rank", "--rank-replay=order.txt"] + args = ["-v", "--regsmart", "--rank-replay=order.txt"] out = mytester.runpytest(*args) error_msg = "File provided to `--rank-replay` cannot be read." assert any(error_msg in x for x in out.errlines) From ed308616863bab4566845fd64db334697070b687 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 24 Jul 2026 22:04:49 -0300 Subject: [PATCH 018/122] feat: add logic for new --no-rank execution option --- pytest_regsmart/args.py | 8 ++++++++ pytest_regsmart/plugin.py | 20 ++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/pytest_regsmart/args.py b/pytest_regsmart/args.py index d7ecf5e..4ad275c 100644 --- a/pytest_regsmart/args.py +++ b/pytest_regsmart/args.py @@ -108,3 +108,11 @@ def parse_seed(config) -> int: ini_val = config.getini("rank_seed") rand_seed = ini_val if ini_val else rand_seed return int(rand_seed) + +def parse_no_rank(config) -> bool: + """Get no-rank option, non-default CLI overrides ini file input.""" + no_rtp = config.getoption("--no-rank") + if not no_rtp: + ini_val = config.getini("no_rank") + no_rtp = ini_val if ini_val else no_rtp + return bool(no_rtp) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 3c2b630..651ef97 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -32,7 +32,8 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--no-rank", - action="store_false", + action="store_true", + default=False, help=NO_RANK_HELP, dest="no_rank") #talvez nao precise @@ -76,12 +77,12 @@ def pytest_addoption(parser: Parser) -> None: default=DEFAULT_SEED, help=SEED_HELP) + parser.addini("no_rank", NO_RANK_HELP, default=False) parser.addini("rank_weight", WEIGHT_HELP, default=DEFAULT_WEIGHT) parser.addini("rank_replay", REPLAY_HELP, default=DEFAULT_REPLAY) parser.addini("rank_level", LEVEL_HELP, default=DEFAULT_LEVEL) parser.addini("rank_hist_len", HIST_LEN_HELP, default=DEFAULT_HIST_LEN) parser.addini("rank_seed", SEED_HELP, default=DEFAULT_SEED) - parser.addini("no_rank", NO_RANK_HELP, default=False) class PluginRunner: @@ -91,6 +92,7 @@ def __init__(self, config: Config) -> None: self.log = {} self.monitor = Monitor() + self.no_rank = args.parse_no_rank(config) self.weights = args.parse_rtp_weights(config) self.level = args.parse_rtp_level(config) self.replay_file = args.parse_replay(config) @@ -106,12 +108,14 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: raise argparse.ArgumentTypeError( "--rank-replay cannot be used together with random order." ) - ranker.run_rtp( - items, self.level, self.weights, - self.replay_file, self.seed, self.log, - lambda feature_name, items, reverse: - extractor.load_feature(self.config, feature_name, items, reverse), - ) + #acho que o selector vai vir aqui + if not self.no_rank: + ranker.run_rtp( + items, self.level, self.weights, + self.replay_file, self.seed, self.log, + lambda feature_name, items, reverse: + extractor.load_feature(self.config, feature_name, items, reverse), + ) def pytest_runtest_logreport(self, report: TestReport) -> None: From 8ccdafb8e3b51c0ca70b57d7c0c480197218d6ab Mon Sep 17 00:00:00 2001 From: let Date: Fri, 24 Jul 2026 22:10:05 -0300 Subject: [PATCH 019/122] docs: update documentation with new name conventions --- DEPLOYMENT.md | 34 +++++++++++++++++----------------- README.md | 36 +++++++++++++++++++++++------------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2b747c2..412614d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,51 +1,51 @@ # Deployment -## Using pytest-ranking in fixed location +## Using pytest-regsmart in fixed location -If your CI runs different test builds in a fixed location, e.g., a project folder in specific machine, you can directly use `pytest-ranking` after installation without additional setup. +If your CI runs different test builds in a fixed location, e.g., a project folder in specific machine, you can directly use `pytest-regsmart` after installation without additional setup. -## Using pytest-ranking in Github Actions +## Using pytest-regsmart in Github Actions If your CI workflow always starts a new virtual machine to run a test build, you need to set up the CI to be able to pass `pytest` cache data across test builds. Here, we use GitHub Actions as an example. -### Add pytest-ranking to project dependency +### Add pytest-regsmart to project dependency -You can add `pytest-ranking` as a dependency by adding a installation job before the job that runs `pytest ...` (a job is often specified by `-name: `) in the workflow file: +You can add `pytest-regsmart` as a dependency by adding a installation job before the job that runs `pytest ...` (a job is often specified by `-name: `) in the workflow file: ```yml - - name: Install pytest-ranking related - run: pip install pytest-ranking + - name: Install pytest-regsmart related + run: pip install pytest-regsmart ``` -Alternatively, depending on where the forked project puts its dependency, e.g., can be in `setup.py`, `pyproejct.toml`, you can also add the `pytest-ranking` to the build/test dependency, but best not to specify version. +Alternatively, depending on where the forked project puts its dependency, e.g., can be in `setup.py`, `pyproejct.toml`, you can also add the `pytest-regsmart` to the build/test dependency, but best not to specify version. #### If the project uses `Tox` -Add `pytest-ranking` to the `deps` of `[testenv]` in `./tox.ini`: +Add `pytest-regsmart` to the `deps` of `[testenv]` in `./tox.ini`: ```ini [testenv] deps = ; ... - pytest-ranking + pytest-regsmart pytest-json-report ``` ### Setup pytest_cache -Before the job in the workflow file that runs the `pytest ...` but after the `pytest-ranking` installation job, add the job that restores cache from the latest run if such run exists: +Before the job in the workflow file that runs the `pytest ...` but after the `pytest-regsmart` installation job, add the job that restores cache from the latest run if such run exists: ```yml - - name: Restore pytest-ranking cache - id: restore-pytest-ranking-cache + - name: Restore pytest-regsmart cache + id: restore-pytest-regsmart-cache if: always() uses: actions/cache/restore@v4 with: path: ${{ github.workspace }}/.pytest_cache/v/pytest_ranking_data - key: pytest-ranking-cache-${{ github.workflow }}-${{ runner.os }}-${{ matrix.python }} + key: pytest-regsmart-cache-${{ github.workflow }}-${{ runner.os }}-${{ matrix.python }} # --------below is the job for running pytest -name: pytest ... @@ -57,13 +57,13 @@ And after the job that runs `pytest ...` command, add the job that caches result -name: pytest ... # --------above is the job for running pytest - - name: Save pytest-ranking cache - id: save-pytest-ranking-cache + - name: Save pytest-regsmart cache + id: save-pytest-regsmart-cache if: always() uses: actions/cache/save@v4 with: path: ${{ github.workspace }}/.pytest_cache/v/pytest_ranking_data - key: pytest-ranking-cache-${{ github.workflow }}-${{ runner.os }}-${{ matrix.python }}-${{ github.run_id }} + key: pytest-regsmart-cache-${{ github.workflow }}-${{ runner.os }}-${{ matrix.python }}-${{ github.run_id }} ``` #### If the project uses `Tox` diff --git a/README.md b/README.md index abfc16e..c5610f4 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ pip install pytest-regsmart ## Usage Pytest will automatically find the plugin and use it when you run ``pytest``. -You can run `pytest-regsmart` with its default configuration, which runs faster tests first by passing the ``--rank`` option: +You can run `pytest-regsmart` with its default configuration, which runs faster tests first by passing the ``--regsmart`` option: ```bash -pytest --rank +pytest --regsmart ``` -Before the test run starts, if `--rank` is passed, the terminal header will report `pytest-regsmart`'s configuration of this run, for example: +Before the test run starts, if `--regsmart` is passed, the terminal header will report `pytest-regsmart`'s configuration of this run, for example: ``` Using --rank-weight=1-0 @@ -44,12 +44,22 @@ Time to reorder tests (s): 0.0003600120544433594 Time to collect test features (s): 0.0004608631134033203 ``` +### Disabling ranking (RTP) + +You can disable the regression test prioritization while keeping the plugin active with the `--no-rank` flag: + +```bash +pytest --regsmart --no-rank +``` + +This is useful when you want to collect test data without reordering tests. + ### Optimizing test prioritization heuristics You can set the weights of different test prioritization heuristics by passing the optional `--rank-weight` flag with formatted values: ```bash -pytest --rank --rank-weight=0-1 +pytest --regsmart --rank-weight=0-1 ``` - Weights are separated by ``-`` @@ -66,7 +76,7 @@ The default value is ``1-0``, which only prioritizes faster tests. You can set at which level of your test suite will be reordered, by passing the optional `--rank-level` flag in one of these values: `put`, `function`, `module`. For example: ```bash -pytest --rank --rank-level=function +pytest --regsmart --rank-level=function ``` - The smallest test item that can be reordered in pytest test suite is [parametrized unit test](https://docs.pytest.org/en/7.1.x/example/parametrize.html) (PUT) @@ -82,7 +92,7 @@ The default value is `put`. You can run/replay tests in a specific order by listing the to-be-run test IDs in a text file, where each line is a test ID, and pass the file path to the optional `--rank-replay` flag: ```bash -pytest --rank --rank-replay=replay_order.txt +pytest --regsmart --rank-replay=replay_order.txt ``` ### Tracking data from historical runs @@ -90,7 +100,7 @@ pytest --rank --rank-replay=replay_order.txt You can also set the maximum value of *the number test runs since a test's last failure* that could be recorded for each test, by passing the optional `--rank-hist-len` flag: ```bash -pytest --rank --rank-hist-len=30 +pytest --regsmart --rank-hist-len=30 ``` The default value is 50. @@ -103,7 +113,7 @@ You can also set the seed used when running tests in random order, via setting a For example, the command below runs tests randomly with seed `1234`: ```bash -pytest --rank --rank-weight=0-0 --rank-seed=1234 +pytest --regsmart --rank-weight=0-0 --rank-seed=1234 ``` By default, `pytest-regsmart` uses `0` as the seed. @@ -116,7 +126,7 @@ For example, create `pytest.ini` in your codebase root folder as such: ```ini [pytest] -addopts = --rank --rank-weight=0-1 --rank-hist-len=30 +addopts = --regsmart --rank-weight=0-1 --rank-hist-len=30 ``` and run `pytest` on the command line. @@ -129,11 +139,11 @@ rank_weight=0-1 rank_hist_len=30 ``` -and run `pytest --rank` on the command line. +and run `pytest --regsmart` on the command line. ## Deployment (old) -`pytest-ranking` is easy to deploy into CI workflow, please see [deployment](./docs/DEPLOYMENT.md). +`pytest-regsmart` is easy to deploy into CI workflow, please see [deployment](./docs/DEPLOYMENT.md). ## Local development @@ -177,13 +187,13 @@ Pytest options that order tests generally (e.g., [`--ff`](https://docs.pytest.or ## Reference #### Demo video -A 5-minute demo video with walkthrough of `pytest-ranking`: [YouTube link](https://youtu.be/SrnkgTs3uok?feature=shared) +A 5-minute demo video with walkthrough of `pytest-regsmart`: [YouTube link](https://youtu.be/SrnkgTs3uok?feature=shared) #### Bibtex citation ``` @inproceedings{cheng2025pytest, - title={{pytest-ranking: A Regression Test Prioritization Tool for Python}}, + title={{pytest-regsmart: A Regression Test Prioritization Tool for Python}}, author={Cheng, Runxiang and Ke, Kaiyao and Marinov, Darko}, booktitle={Companion Proceedings of the 33rd ACM International Conference on the Foundations of Software Engineering}, year={2025}, From 6818aea70a7ebb0ba7bcc389f5abebc0101386ae Mon Sep 17 00:00:00 2001 From: let Date: Fri, 24 Jul 2026 22:12:27 -0300 Subject: [PATCH 020/122] fix typo in help_strings.py --- pytest_regsmart/help_strings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_regsmart/help_strings.py b/pytest_regsmart/help_strings.py index 1212afe..d814fb2 100644 --- a/pytest_regsmart/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -23,7 +23,7 @@ SEED_HELP = textwrap.dedent("""\ Seed when running tests in random order. -You can run random order via setting `--rank-weight=0-0-0` +You can run random order via setting `--rank-weight=0-0` Default value is 0. """) From 727b798b91c5a6de9a860b0d370fd6fde6fcec39 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 24 Jul 2026 22:20:20 -0300 Subject: [PATCH 021/122] refactor: improve no_rank_help string --- pytest_regsmart/help_strings.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pytest_regsmart/help_strings.py b/pytest_regsmart/help_strings.py index 18b7237..f8fa5d8 100644 --- a/pytest_regsmart/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -41,5 +41,6 @@ """) ## ver ainda o que fazer sobre isso NO_RANK_HELP = textwrap.dedent(""" -Disable ranking option (RTP). -""") +Boolean flag to disable ranking option (RTP); cannot be used with other ranking options (--rank-weights & --rank-replay). +When selected, tests will run in pytest default order. +""") #depois preciso adaptar esse final pra usar a RTS From 7050e571cba2e3ecc050f910af6428420aae3159 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 24 Jul 2026 22:20:48 -0300 Subject: [PATCH 022/122] feat: add validation for --no-rank combined with ranking options --- pytest_regsmart/plugin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 651ef97..b0793ce 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -104,6 +104,12 @@ def __init__(self, config: Config) -> None: def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): return + + if self.no_rank and (self.weights != [0, 0] or self.replay_file): + raise argparse.ArgumentTypeError( + "--no-rank cannot be used together with other ranking options." + ) + if self.replay_file and self.weights == [0, 0]: raise argparse.ArgumentTypeError( "--rank-replay cannot be used together with random order." From 708f91177fc1cfb9f3dbb20577eb75df7b4a5885 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 24 Jul 2026 22:21:53 -0300 Subject: [PATCH 023/122] docs: update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d44827..8e2e071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## pytest-regsmart +0.4.0 (2026-07-24) +---- + +* Add the `--no-rank` option to remove RTP, considering only the future RTS module +* Rename activation flag from `--rank` to `--regsmart` + 0.3.0 (2026-07-20) ---- From 5878b987eb90c0ad8818ef50b024952d75845c61 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 24 Jul 2026 23:28:02 -0300 Subject: [PATCH 024/122] fix: enhance no-rank option validation and logging options --- pytest_regsmart/help_strings.py | 2 +- pytest_regsmart/plugin.py | 10 ++++++++-- pytest_regsmart/reporter.py | 4 ++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pytest_regsmart/help_strings.py b/pytest_regsmart/help_strings.py index 3930f48..b3891de 100644 --- a/pytest_regsmart/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -41,6 +41,6 @@ """) ## ver ainda o que fazer sobre isso NO_RANK_HELP = textwrap.dedent(""" -Boolean flag to disable ranking option (RTP); cannot be used with other ranking options (--rank-weights & --rank-replay). +Boolean flag to disable ranking option (RTP); cannot be used with other ranking flags. When selected, tests will run in pytest default order. """) #depois preciso adaptar esse final pra usar a RTS diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index b0793ce..f1a032d 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -105,9 +105,15 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): return - if self.no_rank and (self.weights != [0, 0] or self.replay_file): + if self.no_rank and ( + self.config.getoption("--rank-weight") != DEFAULT_WEIGHT + or self.config.getoption("--rank-level") != DEFAULT_LEVEL.value + or self.config.getoption("--rank-replay") is not None + or self.config.getoption("--rank-hist-len") != DEFAULT_HIST_LEN + or self.config.getoption("--rank-seed") != DEFAULT_SEED + ): #refatorar isso depois urgwnremente raise argparse.ArgumentTypeError( - "--no-rank cannot be used together with other ranking options." + "--no-rank cannot be used together with other ranking flags. It excludes RTP." ) if self.replay_file and self.weights == [0, 0]: diff --git a/pytest_regsmart/reporter.py b/pytest_regsmart/reporter.py index 77115c1..b939172 100644 --- a/pytest_regsmart/reporter.py +++ b/pytest_regsmart/reporter.py @@ -8,6 +8,10 @@ def pytest_report_header(config: Config) -> str | None: """Report plugin configurations before test session starts.""" if not config.getoption("--regsmart"): return None + + if config.getoption("--no-rank"): + return "Using --no-rank (RTP disabled)." + weight = config.getoption("--rank-weight") replay = config.getoption("--rank-replay") level = config.getoption("--rank-level") From 078921911f5a6d4ec16f15c367406451fa1d76ea Mon Sep 17 00:00:00 2001 From: let Date: Sat, 25 Jul 2026 00:05:44 -0300 Subject: [PATCH 025/122] fix: properly validate --no-rank usage with other rank flags --- pytest_regsmart/plugin.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index f1a032d..81ccba0 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -104,23 +104,21 @@ def __init__(self, config: Config) -> None: def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): return - - if self.no_rank and ( - self.config.getoption("--rank-weight") != DEFAULT_WEIGHT - or self.config.getoption("--rank-level") != DEFAULT_LEVEL.value - or self.config.getoption("--rank-replay") is not None - or self.config.getoption("--rank-hist-len") != DEFAULT_HIST_LEN - or self.config.getoption("--rank-seed") != DEFAULT_SEED - ): #refatorar isso depois urgwnremente - raise argparse.ArgumentTypeError( - "--no-rank cannot be used together with other ranking flags. It excludes RTP." - ) + + # if self.no_rank: + # for arg in self.config.invocation_params.args: + # if arg.startswith("--rank-"): + # raise pytest.UsageError( + # "--no-rank cannot be used together with other ranking flags. It excludes RTP." + # ) if self.replay_file and self.weights == [0, 0]: - raise argparse.ArgumentTypeError( + raise argparse.ArgumentTypeError( #ou usage error "--rank-replay cannot be used together with random order." ) + #acho que o selector vai vir aqui + if not self.no_rank: ranker.run_rtp( items, self.level, self.weights, @@ -159,5 +157,12 @@ def pytest_terminal_summary( @pytest.hookimpl(trylast=True) def pytest_configure(config: Config) -> None: + if config.getoption("--regsmart") and config.getoption("--no-rank"): + for arg in config.invocation_params.args: + if arg.startswith("--rank-"): + raise pytest.UsageError( + "--no-rank cannot be used together with other ranking flags. It excludes RTP." + ) + runner = PluginRunner(config) config.pluginmanager.register(runner) From 075937043d161dea0f276d0b4aac5cde4122d309 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 25 Jul 2026 00:25:09 -0300 Subject: [PATCH 026/122] add tests , remove old comment and complement changelog --- CHANGELOG.md | 1 + pytest_regsmart/plugin.py | 7 ----- tests/test_no_rank.py | 59 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 tests/test_no_rank.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2e071..3ae84d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Add the `--no-rank` option to remove RTP, considering only the future RTS module * Rename activation flag from `--rank` to `--regsmart` +* Improve documentation 0.3.0 (2026-07-20) ---- diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 81ccba0..d92e9a9 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -105,13 +105,6 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): return - # if self.no_rank: - # for arg in self.config.invocation_params.args: - # if arg.startswith("--rank-"): - # raise pytest.UsageError( - # "--no-rank cannot be used together with other ranking flags. It excludes RTP." - # ) - if self.replay_file and self.weights == [0, 0]: raise argparse.ArgumentTypeError( #ou usage error "--rank-replay cannot be used together with random order." diff --git a/tests/test_no_rank.py b/tests/test_no_rank.py new file mode 100644 index 0000000..19e35b1 --- /dev/null +++ b/tests/test_no_rank.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest + +from tests.fake_test_data import test_method_one + + +def test_no_rank_without_regsmart(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + ) + args = ["-v", "--no-rank"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + + +def test_no_rank_disables_rtp(mytester): + mytester.makepyfile(test_method_one=test_method_one) + args = ["-v", "--regsmart", "--no-rank"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + out.stdout.fnmatch_lines([ + "test_method_one.py::test_slow PASSED", + "test_method_one.py::test_fast_fail FAILED", + "test_method_one.py::test_medium PASSED", + ], consecutive=True) + + +@pytest.mark.parametrize("ranking_flag", [ + "--rank-weight=0-1", + "--rank-weight=1-0", + "--rank-level=function", + "--rank-hist-len=30", + "--rank-seed=42", +]) +def test_no_rank_with_ranking_flag_error(mytester, ranking_flag): + mytester.makepyfile(test_method_one=test_method_one) + args = ["-v", "--regsmart", "--no-rank", ranking_flag] + out = mytester.runpytest(*args) + assert "--no-rank cannot be used together" in str(out.errlines + out.outlines) + + +def test_no_rank_header_and_summary(mytester): + mytester.makepyfile(test_method_one=test_method_one) + args = ["-v", "--regsmart", "--no-rank"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + + assert any("Using --no-rank (RTP disabled)." in x for x in out.outlines) + assert any("pytest-regsmart summary info" in x for x in out.outlines) + assert any("Time to collect test features (s)" in x for x in out.outlines) + + log_lines = ( + "Using --rank-weight", + "Using --rank-level", + "Using --rank-hist-len", + "Using --rank-seed", + ) + assert len([x for x in out.outlines if x.startswith(log_lines)]) == 0 From b900812bebe9182adb650d49e9859945531abe11 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 29 Jul 2026 22:36:56 -0300 Subject: [PATCH 027/122] chore: add pyan3 and GitPython for new selector module --- pyproject.toml | 2 + uv.lock | 249 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index fd4a6fb..e99cb9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ requires-python = ">=3.8" dependencies = [ "pytest>=7.4.3", "numpy", + "pyan3>=1.2.0", + "gitpython>=3.1.57", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/uv.lock b/uv.lock index 14c3ac4..a3f3127 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -58,6 +84,182 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "2.1.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384, upload-time = "2024-02-02T16:31:22.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/54/ad5eb37bf9d51800010a74e4665425831a9db4e7c4e0fde4352e391e808e/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", size = 18206, upload-time = "2024-02-02T16:30:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a4d49415e600bacae038c67f9fecc1d5433b9d3c71a4de6f33537b89654c/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", size = 14079, upload-time = "2024-02-02T16:30:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7b/85681ae3c33c385b10ac0f8dd025c30af83c78cec1c37a6aa3b55e67f5ec/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", size = 26620, upload-time = "2024-02-02T16:30:08.31Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/2b1b570f6b8b803cef5ac28fdf78c0da318916c7d2fe9402a84d591b394c/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", size = 25818, upload-time = "2024-02-02T16:30:09.577Z" }, + { url = "https://files.pythonhosted.org/packages/29/fe/a36ba8c7ca55621620b2d7c585313efd10729e63ef81e4e61f52330da781/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", size = 25493, upload-time = "2024-02-02T16:30:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/60/ae/9c60231cdfda003434e8bd27282b1f4e197ad5a710c14bee8bea8a9ca4f0/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", size = 30630, upload-time = "2024-02-02T16:30:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/65/dc/1510be4d179869f5dafe071aecb3f1f41b45d37c02329dfba01ff59e5ac5/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", size = 29745, upload-time = "2024-02-02T16:30:14.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/39/8d845dd7d0b0613d86e0ef89549bfb5f61ed781f59af45fc96496e897f3a/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", size = 30021, upload-time = "2024-02-02T16:30:16.032Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5c/356a6f62e4f3c5fbf2602b4771376af22a3b16efa74eb8716fb4e328e01e/MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", size = 16659, upload-time = "2024-02-02T16:30:17.079Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/acbf292615c65f0604a0c6fc402ce6d8c991276e16c80c46a8f758fbd30c/MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", size = 17213, upload-time = "2024-02-02T16:30:18.251Z" }, + { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219, upload-time = "2024-02-02T16:30:19.988Z" }, + { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098, upload-time = "2024-02-02T16:30:21.063Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014, upload-time = "2024-02-02T16:30:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220, upload-time = "2024-02-02T16:30:24.76Z" }, + { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756, upload-time = "2024-02-02T16:30:25.877Z" }, + { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988, upload-time = "2024-02-02T16:30:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718, upload-time = "2024-02-02T16:30:28.111Z" }, + { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317, upload-time = "2024-02-02T16:30:29.214Z" }, + { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670, upload-time = "2024-02-02T16:30:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224, upload-time = "2024-02-02T16:30:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215, upload-time = "2024-02-02T16:30:33.081Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069, upload-time = "2024-02-02T16:30:34.148Z" }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452, upload-time = "2024-02-02T16:30:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462, upload-time = "2024-02-02T16:30:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869, upload-time = "2024-02-02T16:30:37.834Z" }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906, upload-time = "2024-02-02T16:30:39.366Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296, upload-time = "2024-02-02T16:30:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038, upload-time = "2024-02-02T16:30:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572, upload-time = "2024-02-02T16:30:43.326Z" }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127, upload-time = "2024-02-02T16:30:44.418Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ff/2c942a82c35a49df5de3a630ce0a8456ac2969691b230e530ac12314364c/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", size = 18192, upload-time = "2024-02-02T16:30:57.715Z" }, + { url = "https://files.pythonhosted.org/packages/4f/14/6f294b9c4f969d0c801a4615e221c1e084722ea6114ab2114189c5b8cbe0/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", size = 14072, upload-time = "2024-02-02T16:30:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/81/d4/fd74714ed30a1dedd0b82427c02fa4deec64f173831ec716da11c51a50aa/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", size = 26928, upload-time = "2024-02-02T16:30:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/50319665ce81bb10e90d1cf76f9e1aa269ea6f7fa30ab4521f14d122a3df/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", size = 26106, upload-time = "2024-02-02T16:31:01.582Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/f2b0f675635b05f6afd5ea03c094557bdb8622fa8e673387444fe8d8e787/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68", size = 25781, upload-time = "2024-02-02T16:31:02.71Z" }, + { url = "https://files.pythonhosted.org/packages/51/e0/393467cf899b34a9d3678e78961c2c8cdf49fb902a959ba54ece01273fb1/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", size = 30518, upload-time = "2024-02-02T16:31:04.392Z" }, + { url = "https://files.pythonhosted.org/packages/f6/02/5437e2ad33047290dafced9df741d9efc3e716b75583bbd73a9984f1b6f7/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", size = 29669, upload-time = "2024-02-02T16:31:05.53Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7d/968284145ffd9d726183ed6237c77938c021abacde4e073020f920e060b2/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", size = 29933, upload-time = "2024-02-02T16:31:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f3/ecb00fc8ab02b7beae8699f34db9357ae49d9f21d4d3de6f305f34fa949e/MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", size = 16656, upload-time = "2024-02-02T16:31:07.767Z" }, + { url = "https://files.pythonhosted.org/packages/92/21/357205f03514a49b293e214ac39de01fadd0970a6e05e4bf1ddd0ffd0881/MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", size = 17206, upload-time = "2024-02-02T16:31:08.843Z" }, + { url = "https://files.pythonhosted.org/packages/0f/31/780bb297db036ba7b7bbede5e1d7f1e14d704ad4beb3ce53fb495d22bc62/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", size = 18193, upload-time = "2024-02-02T16:31:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d77701bbef72892affe060cdacb7a2ed7fd68dae3b477a8642f15ad3b132/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", size = 14073, upload-time = "2024-02-02T16:31:11.442Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a7/1e558b4f78454c8a3a0199292d96159eb4d091f983bc35ef258314fe7269/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", size = 26486, upload-time = "2024-02-02T16:31:12.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5a/360da85076688755ea0cceb92472923086993e86b5613bbae9fbc14136b0/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", size = 25685, upload-time = "2024-02-02T16:31:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/6a/18/ae5a258e3401f9b8312f92b028c54d7026a97ec3ab20bfaddbdfa7d8cce8/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", size = 25338, upload-time = "2024-02-02T16:31:14.812Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cc/48206bd61c5b9d0129f4d75243b156929b04c94c09041321456fd06a876d/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", size = 30439, upload-time = "2024-02-02T16:31:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/d1/06/a41c112ab9ffdeeb5f77bc3e331fdadf97fa65e52e44ba31880f4e7f983c/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", size = 29531, upload-time = "2024-02-02T16:31:17.13Z" }, + { url = "https://files.pythonhosted.org/packages/02/8c/ab9a463301a50dab04d5472e998acbd4080597abc048166ded5c7aa768c8/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", size = 29823, upload-time = "2024-02-02T16:31:18.247Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/9bc18da763496b055d8e98ce476c8e718dcfd78157e17f555ce6dd7d0895/MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", size = 16658, upload-time = "2024-02-02T16:31:19.583Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f8/4da07de16f10551ca1f640c92b5f316f9394088b183c6a57183df6de5ae4/MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", size = 17211, upload-time = "2024-02-02T16:31:20.96Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, +] + [[package]] name = "numpy" version = "1.24.4" @@ -388,6 +590,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pyan3" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +dependencies = [ + { name = "jinja2", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/8e/340533f6c084a53705527735e7db521756a10b297841671ee4042c5b281a/pyan3-1.2.0.tar.gz", hash = "sha256:36b49b678820e882de7271fb8a18789d57cd1e284f0314bdecd587f02c049a19", size = 49721, upload-time = "2021-02-11T13:22:25.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/f8/73460b7ac0650feec0601c3c24fd1ced4d986eecc447236464ae487fab10/pyan3-1.2.0-py3-none-any.whl", hash = "sha256:89b4d7b3003b794fb9b041b08da91bcbd1dde4c82ec4af039b1e6b8f418efab1", size = 50561, upload-time = "2021-02-11T13:22:23.898Z" }, +] + +[[package]] +name = "pyan3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "jinja2", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/f4/0fc2023b7971af8ab00e261ed59916be93ff7ae745f9d4e91de40cbf85ff/pyan3-2.6.2.tar.gz", hash = "sha256:ecb63d32ea28ec10caf22aad11310022a341c9bf5e8b9301e9a64ed3fe2edc91", size = 101955, upload-time = "2026-07-14T08:20:53.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/b6/6c4aadea4274657b5539c4d0365aba87d44ce12f4cf32556aee616187b80/pyan3-2.6.2-py3-none-any.whl", hash = "sha256:51b3f124286398f983e67bb1569074ca98047e51c420e9055376c56c9232c41e", size = 99491, upload-time = "2026-07-14T08:20:52.487Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -466,11 +701,14 @@ name = "pytest-regsmart" version = "0.3.4" source = { editable = "." } dependencies = [ + { name = "gitpython" }, { name = "numpy", version = "1.24.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyan3", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyan3", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -478,10 +716,21 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "gitpython", specifier = ">=3.1.57" }, { name = "numpy" }, + { name = "pyan3", specifier = ">=1.2.0" }, { name = "pytest", specifier = ">=7.4.3" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "tomli" version = "2.4.1" From c2147e139ba2d620a21d14e7f655f4b24ebe8c35 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 29 Jul 2026 22:37:54 -0300 Subject: [PATCH 028/122] [wip] feat: new selector module base --- pytest_regsmart/plugin.py | 5 ++-- pytest_regsmart/selector.py | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 pytest_regsmart/selector.py diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index d92e9a9..b008ea2 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -14,6 +14,7 @@ from . import args from . import extractor from . import ranker +from . import selector from . import reporter as reporter_mod from .const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT from .help_strings import ( @@ -110,10 +111,10 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: "--rank-replay cannot be used together with random order." ) - #acho que o selector vai vir aqui + selector.run_rts() #ainda nao implementado, mas vai ser chamado aqui if not self.no_rank: - ranker.run_rtp( + ranker.run_rtp( #tenho que ver como mudar essa assinatura para que o ranker receba somente os testes afetados, e nao todos os testes items, self.level, self.weights, self.replay_file, self.seed, self.log, lambda feature_name, items, reverse: diff --git a/pytest_regsmart/selector.py b/pytest_regsmart/selector.py new file mode 100644 index 0000000..2781606 --- /dev/null +++ b/pytest_regsmart/selector.py @@ -0,0 +1,46 @@ +from git import Repo + + +def get_git_diff(): #vai considerar sempre a branch atual e a branch base (main/master) - pelo menos por enquanto + # vou ter que ver se tem unstaged tb + # tenho que pegar da branch atual e fazer o git diff com a branch base - main/master + # como vou conseguir a branch base? talvez seja melhor pegar o commit mais recente da branch base e fazer o diff com ele + repo = Repo(".") + current_branch = repo.active_branch.name + base_branch = "main" # ou "master", dependendo do seu repositório + + complete_diff = repo.git.diff(f"{base_branch}..{current_branch}", name_only=True) + + + #olhar melhor depois: + # Initialize commit references + #hcommit = repo.head.commit + + # 1. Compare Working Directory vs Staging Area (Index) + #diffs = repo.index.diff(None) + + # 2. Compare Staging Area (Index) vs HEAD + #diffs = repo.index.diff("HEAD") + + # 3. Compare HEAD Commit vs Working Directory + #diffs = hcommit.diff(None) + + pass + + +def get_dependency_graph(): + pass + + +def get_affected_tests(): + pass + + +def run_rts(): + pass + +#rodar o git diff pra ver a diferença +#guardar os trechos +#talvez em paralelo: rodar o pyan3 pra pegar o grafo de dependencias +#funcao pra correlacionar os trechos com o grafo de dependencias e achar testes afetados +#pega a nova lista de testes e manda para o ranker \ No newline at end of file From eeac122221518ce19aa96cb5922249275c5289b9 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 29 Jul 2026 23:46:48 -0300 Subject: [PATCH 029/122] feat: get_git_diff func --- pytest_regsmart/selector.py | 38 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/pytest_regsmart/selector.py b/pytest_regsmart/selector.py index 2781606..b524497 100644 --- a/pytest_regsmart/selector.py +++ b/pytest_regsmart/selector.py @@ -1,31 +1,26 @@ from git import Repo +from dataclasses import dataclass -def get_git_diff(): #vai considerar sempre a branch atual e a branch base (main/master) - pelo menos por enquanto - # vou ter que ver se tem unstaged tb - # tenho que pegar da branch atual e fazer o git diff com a branch base - main/master - # como vou conseguir a branch base? talvez seja melhor pegar o commit mais recente da branch base e fazer o diff com ele - repo = Repo(".") - current_branch = repo.active_branch.name - base_branch = "main" # ou "master", dependendo do seu repositório +@dataclass +class DiffResult: + modified_files: list[str] #staged+unstaged + untracked_files: list[str] #completamente novo - talvez colocar uma flag futura para comparar tentar buscar só testes do unstages+untracked + #depois ainda quero pegar a diff mais granular, talvez por linha ou conteudo de forma semantica?? n sei - complete_diff = repo.git.diff(f"{base_branch}..{current_branch}", name_only=True) +def get_git_diff(repo_path: str = ".") -> DiffResult: #esse ponto indica o diretorio atual + repo = Repo(repo_path) + default_branch = "main" #talvez eu deixe isso manualmente configuravel via flag depois - #olhar melhor depois: - # Initialize commit references - #hcommit = repo.head.commit + merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description + working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() + untracked_diff = repo.untracked_files - # 1. Compare Working Directory vs Staging Area (Index) - #diffs = repo.index.diff(None) - - # 2. Compare Staging Area (Index) vs HEAD - #diffs = repo.index.diff("HEAD") - - # 3. Compare HEAD Commit vs Working Directory - #diffs = hcommit.diff(None) - - pass + return DiffResult( + modified_files=working_dir_diff, + untracked_files=untracked_diff + ) def get_dependency_graph(): @@ -37,6 +32,7 @@ def get_affected_tests(): def run_rts(): + """[wip] Orquestra a seleção...""" pass #rodar o git diff pra ver a diferença From cffd76fbc8c4417ea89b719eaf714103f7fd8c04 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 29 Jul 2026 23:57:24 -0300 Subject: [PATCH 030/122] feat: update get_affected_tests signature and integrate get_git_diff in run_rts --- pytest_regsmart/selector.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pytest_regsmart/selector.py b/pytest_regsmart/selector.py index b524497..9e3cc3e 100644 --- a/pytest_regsmart/selector.py +++ b/pytest_regsmart/selector.py @@ -27,12 +27,15 @@ def get_dependency_graph(): pass -def get_affected_tests(): +def get_affected_tests(diff_result: DiffResult, short_graph) -> list[str]: pass def run_rts(): """[wip] Orquestra a seleção...""" + + diff_result = get_git_diff() + pass #rodar o git diff pra ver a diferença From 212a97bf480d7288e8ca55eba16d89999ecc7587 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 30 Jul 2026 00:14:56 -0300 Subject: [PATCH 031/122] tests + fix: comment run_rts call --- pytest_regsmart/plugin.py | 2 +- tests/test_selector.py | 105 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/test_selector.py diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index b008ea2..af45b78 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -111,7 +111,7 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: "--rank-replay cannot be used together with random order." ) - selector.run_rts() #ainda nao implementado, mas vai ser chamado aqui + #selector.run_rts() #ainda nao implementado, mas vai ser chamado aqui if not self.no_rank: ranker.run_rtp( #tenho que ver como mudar essa assinatura para que o ranker receba somente os testes afetados, e nao todos os testes diff --git a/tests/test_selector.py b/tests/test_selector.py new file mode 100644 index 0000000..9d88d1b --- /dev/null +++ b/tests/test_selector.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from git import Repo +from git.exc import InvalidGitRepositoryError, GitCommandError + +from pytest_regsmart.selector import get_git_diff, DiffResult + + +def _init_repo(path: Path) -> Repo: + repo = Repo.init(path) + writer = repo.config_writer() + writer.set_value("user", "name", "Test") + writer.set_value("user", "email", "test@test.com") + writer.release() + return repo + + +def _commit_file(repo: Repo, filename: str, content: str = "") -> Path: + filepath = Path(repo.working_tree_dir) / filename + filepath.write_text(content) + repo.index.add(filename) + repo.index.commit(f"Add {filename}") + return filepath + + +def test_commits_ahead_of_main(tmp_path): + repo = _init_repo(tmp_path) + _commit_file(repo, "file_a.py") + repo.git.branch("-m", "main") + repo.git.checkout("-b", "feature") + _commit_file(repo, "file_b.py") + + result = get_git_diff(str(tmp_path)) + + assert "file_b.py" in result.modified_files + assert "file_a.py" not in result.modified_files + + +def test_with_unstaged_changes(tmp_path): + repo = _init_repo(tmp_path) + _commit_file(repo, "file_a.py", "original") + repo.git.branch("-m", "main") + filepath = Path(repo.working_tree_dir) / "file_a.py" + filepath.write_text("modified") + + result = get_git_diff(str(tmp_path)) + + assert "file_a.py" in result.modified_files + + +def test_with_untracked_files(tmp_path): + repo = _init_repo(tmp_path) + _commit_file(repo, "file_a.py") + repo.git.branch("-m", "main") + untracked = Path(repo.working_tree_dir) / "new_file.py" + untracked.write_text("new") + + result = get_git_diff(str(tmp_path)) + + assert "new_file.py" in result.untracked_files + assert "file_a.py" not in result.modified_files + + +def test_combined(tmp_path): + repo = _init_repo(tmp_path) + _commit_file(repo, "file_a.py", "original") + repo.git.branch("-m", "main") + repo.git.checkout("-b", "feature") + _commit_file(repo, "file_b.py") + (Path(repo.working_tree_dir) / "file_a.py").write_text("unstaged") + (Path(repo.working_tree_dir) / "new_file.py").write_text("untracked") + + result = get_git_diff(str(tmp_path)) + + assert "file_b.py" in result.modified_files + assert "file_a.py" in result.modified_files + assert "new_file.py" in result.untracked_files + + +def test_no_changes(tmp_path): + repo = _init_repo(tmp_path) + _commit_file(repo, "file_a.py") + repo.git.branch("-m", "main") + + result = get_git_diff(str(tmp_path)) + + assert result.modified_files == [] + assert result.untracked_files == [] + + +def test_not_a_repo(tmp_path): + with pytest.raises(InvalidGitRepositoryError): + get_git_diff(str(tmp_path)) + + +def test_no_main_branch(tmp_path): + repo = _init_repo(tmp_path) + _commit_file(repo, "file_a.py") + repo.git.branch("-m", "develop") + + with pytest.raises(GitCommandError): + get_git_diff(str(tmp_path)) From 9f8ced1dd93ffa4d73e20240c71b4bc1eaa68d07 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 31 Jul 2026 00:12:14 -0300 Subject: [PATCH 032/122] refactor: make the repository dir a constant --- pytest_regsmart/const.py | 4 ++++ pytest_regsmart/selector.py | 10 ++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pytest_regsmart/const.py b/pytest_regsmart/const.py index e8e7f00..3246da8 100644 --- a/pytest_regsmart/const.py +++ b/pytest_regsmart/const.py @@ -1,4 +1,5 @@ from enum import Enum +from git import Repo # ------ Constants ------ @@ -24,3 +25,6 @@ class LEVEL(str, Enum): MODULE = "module" DEFAULT_LEVEL = LEVEL.PUT + +#diretorio base: repo raiz do repositorio git para pegar o diff e para calcular o grafo de deps +REPOSITORY_DIR = Repo(".") #talvez vou deixar isso configuravel via flag depois, mas por enquanto vou deixar fixo no repo raiz diff --git a/pytest_regsmart/selector.py b/pytest_regsmart/selector.py index 9e3cc3e..97ffb7e 100644 --- a/pytest_regsmart/selector.py +++ b/pytest_regsmart/selector.py @@ -1,6 +1,8 @@ -from git import Repo from dataclasses import dataclass +from .const import REPOSITORY_DIR +from .ast import get_dependency_graph + @dataclass class DiffResult: @@ -10,7 +12,7 @@ class DiffResult: def get_git_diff(repo_path: str = ".") -> DiffResult: #esse ponto indica o diretorio atual - repo = Repo(repo_path) + repo = REPOSITORY_DIR default_branch = "main" #talvez eu deixe isso manualmente configuravel via flag depois merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description @@ -23,10 +25,6 @@ def get_git_diff(repo_path: str = ".") -> DiffResult: #esse ponto indica o diret ) -def get_dependency_graph(): - pass - - def get_affected_tests(diff_result: DiffResult, short_graph) -> list[str]: pass From c4b970287152f1dced56c104448c4a9d81009cec Mon Sep 17 00:00:00 2001 From: let Date: Fri, 31 Jul 2026 10:27:08 -0300 Subject: [PATCH 033/122] feat: dependency graph creation 1st draft via pyan3.ImportVisitor (modular granularity only) --- pytest_regsmart/ast.py | 72 +++++++++++++++++++++++++++++++++++++ pytest_regsmart/selector.py | 1 + 2 files changed, 73 insertions(+) create mode 100644 pytest_regsmart/ast.py diff --git a/pytest_regsmart/ast.py b/pytest_regsmart/ast.py new file mode 100644 index 0000000..2e75132 --- /dev/null +++ b/pytest_regsmart/ast.py @@ -0,0 +1,72 @@ +from collections import defaultdict +import logging +import os +from dataclasses import dataclass +from pyan.modvis import ImportVisitor + +from .const import REPOSITORY_DIR +#inicialmente tenho que tentar gerar a forma mais simples só com os arquivos, nao precisa das funções +#vou usar a API do pyan3, não o CLI + +@dataclass +class DependencyGraph: + dependents: dict[str, set[str]] # chave: módulo, valor: conjunto de módulos que dependem/sao afetados por ele + + +def _find_py_files(working_dir: str) -> list[str]: + excludes = [".venv", ".git", "__pycache__", "dist", "build"] + + py_files = [] + for dirpath, dirnames, filenames in os.walk(working_dir): + dirnames[:] = [d for d in dirnames if d not in excludes and not d.endswith(".egg-info")] #nova lista de diretorios só dos arquivos core para checar + for filename in filenames: + if filename.endswith(".py"): + py_files.append(os.path.join(dirpath, filename)) #monta o caminho completo + return py_files + + +def _build_module_relative_path(fullpaths, working_dir: str) -> dict[str, str]: + """Converte nomes de módulo do pyan3 pra caminhos relativos ao repo (match com o diff). + + Ex: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" + """ + return { + module: os.path.relpath(fullpath, working_dir) + for module, fullpath in fullpaths.items() + } + +def _invert_dependency_graph( + module_imports: dict[str, set[str]], + module_to_path: dict[str, str] + ) -> DependencyGraph: + """Conversao de 'modulo importa X' para 'arquivo X é usado por módulo'. + + Ex: se plugin.py importa selector.py, o retorno tem + selector.py -> {plugin.py} (selector.py afeta plugin.py se mudar) + """ + + dependents = defaultdict(set) #versão de dict que não dá KeyError quando acessa uma chave que ainda não existe; subclasssed e dict: https://www.geeksforgeeks.org/python/defaultdict-in-python/ + + for importer, imported_modules in module_imports.items(): + importer_path = module_to_path.get(importer) + for imported in imported_modules: + imported_path = module_to_path.get(imported) + if imported_path is None: + continue # nao importa arquivo no repo + dependents[imported_path].add(importer_path) + + + return DependencyGraph(dependents=dict(dependents)) + +def get_dependency_graph() -> DependencyGraph: + working_dir = REPOSITORY_DIR.working_tree_dir + python_files = _find_py_files(working_dir) + + graph = ImportVisitor(filenames=python_files, logger=logging.getLogger(__name__), root=working_dir) + #graph.modules: módulo -> set de módulos importados por ele + #graph.fullpaths: módulo -> caminho absoluto do arquivo + + module_to_path = _build_module_relative_path(graph.fullpaths, working_dir) + dependents_by_file = _invert_dependency_graph(graph.modules, module_to_path) #quero inverter pra ficar modulo -> quem IMPORTA esse modulo / quem é afetado por ele + + return dependents_by_file diff --git a/pytest_regsmart/selector.py b/pytest_regsmart/selector.py index 97ffb7e..bb893a0 100644 --- a/pytest_regsmart/selector.py +++ b/pytest_regsmart/selector.py @@ -33,6 +33,7 @@ def run_rts(): """[wip] Orquestra a seleção...""" diff_result = get_git_diff() + imports_deps_graph = get_dependency_graph() #talvez vou paralelizar isso com o git diff mais pra frente pass From 88c33949f55231f967363ac4b2ee9f2d59110ebf Mon Sep 17 00:00:00 2001 From: let Date: Fri, 31 Jul 2026 11:00:25 -0300 Subject: [PATCH 034/122] tests: convert git_repo and commit_file into pytest fixtures --- tests/conftest.py | 25 ++++++++++++ tests/test_selector.py | 92 ++++++++++++++++-------------------------- 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 67136df..38bd0dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,35 @@ from __future__ import annotations +from pathlib import Path + import pytest +from git import Repo pytest_plugins = ["pytester"] +@pytest.fixture +def git_repo(tmp_path) -> Repo: + repo = Repo.init(tmp_path) + writer = repo.config_writer() + writer.set_value("user", "name", "Test") + writer.set_value("user", "email", "test@test.com") + writer.release() + return repo + + +@pytest.fixture +def commit_file(git_repo): + def _commit_file(filename: str, content: str = "") -> Path: + filepath = Path(git_repo.working_tree_dir) / filename + filepath.write_text(content) + git_repo.index.add(filename) + git_repo.index.commit(f"Add {filename}") + return filepath + + return _commit_file + + @pytest.fixture def mytester(pytester): pytester.makefile( diff --git a/tests/test_selector.py b/tests/test_selector.py index 9d88d1b..9b93706 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -3,89 +3,66 @@ from pathlib import Path import pytest -from git import Repo -from git.exc import InvalidGitRepositoryError, GitCommandError +from git.exc import GitCommandError, InvalidGitRepositoryError -from pytest_regsmart.selector import get_git_diff, DiffResult +from pytest_regsmart.selector import DiffResult, get_git_diff -def _init_repo(path: Path) -> Repo: - repo = Repo.init(path) - writer = repo.config_writer() - writer.set_value("user", "name", "Test") - writer.set_value("user", "email", "test@test.com") - writer.release() - return repo +def test_commits_ahead_of_main(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + commit_file("file_b.py") - -def _commit_file(repo: Repo, filename: str, content: str = "") -> Path: - filepath = Path(repo.working_tree_dir) / filename - filepath.write_text(content) - repo.index.add(filename) - repo.index.commit(f"Add {filename}") - return filepath - - -def test_commits_ahead_of_main(tmp_path): - repo = _init_repo(tmp_path) - _commit_file(repo, "file_a.py") - repo.git.branch("-m", "main") - repo.git.checkout("-b", "feature") - _commit_file(repo, "file_b.py") - - result = get_git_diff(str(tmp_path)) + result = get_git_diff(str(git_repo.working_tree_dir)) assert "file_b.py" in result.modified_files assert "file_a.py" not in result.modified_files -def test_with_unstaged_changes(tmp_path): - repo = _init_repo(tmp_path) - _commit_file(repo, "file_a.py", "original") - repo.git.branch("-m", "main") - filepath = Path(repo.working_tree_dir) / "file_a.py" +def test_with_unstaged_changes(git_repo, commit_file): + commit_file("file_a.py", "original") + git_repo.git.branch("-m", "main") + filepath = Path(git_repo.working_tree_dir) / "file_a.py" filepath.write_text("modified") - result = get_git_diff(str(tmp_path)) + result = get_git_diff(str(git_repo.working_tree_dir)) assert "file_a.py" in result.modified_files -def test_with_untracked_files(tmp_path): - repo = _init_repo(tmp_path) - _commit_file(repo, "file_a.py") - repo.git.branch("-m", "main") - untracked = Path(repo.working_tree_dir) / "new_file.py" +def test_with_untracked_files(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + untracked = Path(git_repo.working_tree_dir) / "new_file.py" untracked.write_text("new") - result = get_git_diff(str(tmp_path)) + result = get_git_diff(str(git_repo.working_tree_dir)) assert "new_file.py" in result.untracked_files assert "file_a.py" not in result.modified_files -def test_combined(tmp_path): - repo = _init_repo(tmp_path) - _commit_file(repo, "file_a.py", "original") - repo.git.branch("-m", "main") - repo.git.checkout("-b", "feature") - _commit_file(repo, "file_b.py") - (Path(repo.working_tree_dir) / "file_a.py").write_text("unstaged") - (Path(repo.working_tree_dir) / "new_file.py").write_text("untracked") +def test_combined(git_repo, commit_file): + commit_file("file_a.py", "original") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + commit_file("file_b.py") + (Path(git_repo.working_tree_dir) / "file_a.py").write_text("unstaged") + (Path(git_repo.working_tree_dir) / "new_file.py").write_text("untracked") - result = get_git_diff(str(tmp_path)) + result = get_git_diff(str(git_repo.working_tree_dir)) assert "file_b.py" in result.modified_files assert "file_a.py" in result.modified_files assert "new_file.py" in result.untracked_files -def test_no_changes(tmp_path): - repo = _init_repo(tmp_path) - _commit_file(repo, "file_a.py") - repo.git.branch("-m", "main") +def test_no_changes(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") - result = get_git_diff(str(tmp_path)) + result = get_git_diff(str(git_repo.working_tree_dir)) assert result.modified_files == [] assert result.untracked_files == [] @@ -96,10 +73,9 @@ def test_not_a_repo(tmp_path): get_git_diff(str(tmp_path)) -def test_no_main_branch(tmp_path): - repo = _init_repo(tmp_path) - _commit_file(repo, "file_a.py") - repo.git.branch("-m", "develop") +def test_no_main_branch(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "develop") with pytest.raises(GitCommandError): - get_git_diff(str(tmp_path)) + get_git_diff(str(git_repo.working_tree_dir)) From 41fc0b4e904416d0995643633fb8e9739358d06e Mon Sep 17 00:00:00 2001 From: let Date: Fri, 31 Jul 2026 11:00:44 -0300 Subject: [PATCH 035/122] tests: add integration and unit tests for new ast module --- pytest_regsmart/ast.py | 12 ++- tests/test_ast.py | 197 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 tests/test_ast.py diff --git a/pytest_regsmart/ast.py b/pytest_regsmart/ast.py index 2e75132..c8568bc 100644 --- a/pytest_regsmart/ast.py +++ b/pytest_regsmart/ast.py @@ -1,10 +1,13 @@ -from collections import defaultdict import logging import os +from collections import defaultdict from dataclasses import dataclass +from typing import Optional + from pyan.modvis import ImportVisitor from .const import REPOSITORY_DIR + #inicialmente tenho que tentar gerar a forma mais simples só com os arquivos, nao precisa das funções #vou usar a API do pyan3, não o CLI @@ -35,6 +38,7 @@ def _build_module_relative_path(fullpaths, working_dir: str) -> dict[str, str]: for module, fullpath in fullpaths.items() } + def _invert_dependency_graph( module_imports: dict[str, set[str]], module_to_path: dict[str, str] @@ -58,8 +62,10 @@ def _invert_dependency_graph( return DependencyGraph(dependents=dict(dependents)) -def get_dependency_graph() -> DependencyGraph: - working_dir = REPOSITORY_DIR.working_tree_dir + +def get_dependency_graph(working_dir: Optional[str] = None) -> DependencyGraph: + if working_dir is None: + working_dir = REPOSITORY_DIR.working_tree_dir python_files = _find_py_files(working_dir) graph = ImportVisitor(filenames=python_files, logger=logging.getLogger(__name__), root=working_dir) diff --git a/tests/test_ast.py b/tests/test_ast.py new file mode 100644 index 0000000..12b5f1a --- /dev/null +++ b/tests/test_ast.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import os +from pathlib import Path +from pytest import mark + +from pytest_regsmart.ast import (DependencyGraph, _build_module_relative_path, + _find_py_files, _invert_dependency_graph, + get_dependency_graph) + + +def _write(tmp_path: Path, relpath: str, content: str = "") -> Path: + filepath = tmp_path / relpath + filepath.parent.mkdir(parents=True, exist_ok=True) + filepath.write_text(content) + return filepath + + +# --------------------------------------------------------------------------- +# _find_py_files +# --------------------------------------------------------------------------- + + +def test_find_py_files_only_discovers_project_files(tmp_path): + _write(tmp_path, "app/mod.py") + _write(tmp_path, "app/sub/helper.py") + _write(tmp_path, "not_python.txt") + + files = _find_py_files(str(tmp_path)) + + assert {os.path.relpath(f, tmp_path) for f in files} == { + os.path.join("app", "mod.py"), + os.path.join("app", "sub", "helper.py"), + } + + +def test_find_py_files_excludes_env_and_build_dirs(tmp_path): + _write(tmp_path, "app/mod.py") + _write(tmp_path, ".venv/lib.py") + _write(tmp_path, ".git/hooks.py") + _write(tmp_path, "__pycache__/cache.py") + _write(tmp_path, "dist/bundle.py") + _write(tmp_path, "build/gen.py") + _write(tmp_path, "pkg.egg-info/PKG-INFO.py") + + files = _find_py_files(str(tmp_path)) + + assert {os.path.relpath(f, tmp_path) for f in files} == { + os.path.join("app", "mod.py") + } + + +# --------------------------------------------------------------------------- +# _build_module_relative_path +# --------------------------------------------------------------------------- + + +def test_build_module_relative_path(tmp_path): + fullpaths = { + "pkg.mod": str(tmp_path / "pkg" / "mod.py"), + "pkg": str(tmp_path / "pkg" / "__init__.py"), + } + + result = _build_module_relative_path(fullpaths, str(tmp_path)) + + assert result == { + "pkg.mod": os.path.join("pkg", "mod.py"), + "pkg": os.path.join("pkg", "__init__.py"), + } + + +# --------------------------------------------------------------------------- +# _invert_dependency_graph +# --------------------------------------------------------------------------- + +@mark.parametrize( + "module_imports,module_to_path,expected_dependents", + [ + ( + { + "app.main": {"app.service", "os"}, + "app.service": {"app.helpers"}, + }, + { + "app.main": "app/main.py", + "app.service": "app/service.py", + "app.helpers": "app/helpers.py", + }, + { + "app/service.py": {"app/main.py"}, + "app/helpers.py": {"app/service.py"}, + }, + ), + ( + { + "app.main": {"app.service", "os", "requests"}, + "app.service": {"numpy"}, + }, + { + "app.main": "app/main.py", + "app.service": "app/service.py", + }, + { + "app/service.py": {"app/main.py"}, + }, + ), + ( + { + "app.main": {"app.service"}, + "app.service": set(), + }, + { + "app.main": "app/main.py", + "app.service": "app/service.py", + }, + { + "app/service.py": {"app/main.py"}, + }, + ), + ]) +def test_invert_dependency_graph_cases(module_imports,module_to_path,expected_dependents): + graph = _invert_dependency_graph(module_imports, module_to_path) + + assert graph == DependencyGraph(dependents=expected_dependents) + + +# --------------------------------------------------------------------------- +# get_dependency_graph (end-to-end) +# --------------------------------------------------------------------------- + + +def _build_sample_project(repo): + root = Path(repo.working_tree_dir) + _write(root, "mypkg/__init__.py") + _write( + root, + "mypkg/service.py", + "import os\n\ndef run():\n return os.getcwd()\n", + ) + _write(root, "mypkg/main.py", "from .service import run\n") + _write(root, "tests/test_app.py", "from mypkg.service import run\n") + _write(root, ".venv/skip.py", "import os\n") + _write(root, "build/gen.py", "print('generated')\n") + + +def test_get_dependency_graph_end_to_end(git_repo): + _build_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir) + + assert graph.dependents["mypkg/service.py"] == { + "mypkg/main.py", + "tests/test_app.py", + } + assert graph.dependents["mypkg/__init__.py"] == { + "mypkg/main.py", + "tests/test_app.py", + } + + +def test_get_dependency_graph_drops_stdlib_and_non_repo_files(git_repo): + _build_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir) + + for filepath in graph.dependents: + assert filepath.endswith(".py") + assert not any( + part in filepath + for part in (".venv", "build", "__pycache__", ".git") + ) + assert not any("os" in filepath for filepath in graph.dependents) + + +def test_get_dependency_graph_leaf_module_has_no_entry(git_repo): + _build_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir) + + assert "mypkg/main.py" not in graph.dependents + assert "tests/test_app.py" not in graph.dependents + + +def test_get_dependency_graph_uses_repository_dir_by_default( + monkeypatch, git_repo +): + _build_sample_project(git_repo) + monkeypatch.setattr( + "pytest_regsmart.ast.REPOSITORY_DIR", git_repo, raising=False + ) + + graph = get_dependency_graph() + + assert graph.dependents["mypkg/service.py"] == { + "mypkg/main.py", + "tests/test_app.py", + } From e235d87b00e1df04edef5796def9053e111c61d7 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 31 Jul 2026 11:23:56 -0300 Subject: [PATCH 036/122] refactor/fix: unify repo resolution for git diff and deps graph + adapt tests + lint --- pytest_regsmart/ast.py | 9 ++++----- pytest_regsmart/const.py | 6 ++++-- pytest_regsmart/selector.py | 6 +++--- tests/test_ast.py | 7 +++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pytest_regsmart/ast.py b/pytest_regsmart/ast.py index c8568bc..eaf2ec3 100644 --- a/pytest_regsmart/ast.py +++ b/pytest_regsmart/ast.py @@ -2,11 +2,10 @@ import os from collections import defaultdict from dataclasses import dataclass -from typing import Optional from pyan.modvis import ImportVisitor -from .const import REPOSITORY_DIR +from .const import resolve_repo #inicialmente tenho que tentar gerar a forma mais simples só com os arquivos, nao precisa das funções #vou usar a API do pyan3, não o CLI @@ -63,9 +62,9 @@ def _invert_dependency_graph( return DependencyGraph(dependents=dict(dependents)) -def get_dependency_graph(working_dir: Optional[str] = None) -> DependencyGraph: - if working_dir is None: - working_dir = REPOSITORY_DIR.working_tree_dir +def get_dependency_graph(repo_path: str = ".") -> DependencyGraph: + repo = resolve_repo(repo_path) + working_dir = repo.working_tree_dir python_files = _find_py_files(working_dir) graph = ImportVisitor(filenames=python_files, logger=logging.getLogger(__name__), root=working_dir) diff --git a/pytest_regsmart/const.py b/pytest_regsmart/const.py index 3246da8..2e1de39 100644 --- a/pytest_regsmart/const.py +++ b/pytest_regsmart/const.py @@ -1,4 +1,5 @@ from enum import Enum + from git import Repo # ------ Constants ------ @@ -26,5 +27,6 @@ class LEVEL(str, Enum): DEFAULT_LEVEL = LEVEL.PUT -#diretorio base: repo raiz do repositorio git para pegar o diff e para calcular o grafo de deps -REPOSITORY_DIR = Repo(".") #talvez vou deixar isso configuravel via flag depois, mas por enquanto vou deixar fixo no repo raiz + +def resolve_repo(repo_path: str = ".") -> Repo: + return Repo(repo_path) diff --git a/pytest_regsmart/selector.py b/pytest_regsmart/selector.py index bb893a0..b4649f6 100644 --- a/pytest_regsmart/selector.py +++ b/pytest_regsmart/selector.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -from .const import REPOSITORY_DIR from .ast import get_dependency_graph +from .const import resolve_repo @dataclass @@ -11,8 +11,8 @@ class DiffResult: #depois ainda quero pegar a diff mais granular, talvez por linha ou conteudo de forma semantica?? n sei -def get_git_diff(repo_path: str = ".") -> DiffResult: #esse ponto indica o diretorio atual - repo = REPOSITORY_DIR +def get_git_diff(repo_path: str = ".") -> DiffResult: + repo = resolve_repo(repo_path=repo_path) default_branch = "main" #talvez eu deixe isso manualmente configuravel via flag depois merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description diff --git a/tests/test_ast.py b/tests/test_ast.py index 12b5f1a..d630776 100644 --- a/tests/test_ast.py +++ b/tests/test_ast.py @@ -2,6 +2,7 @@ import os from pathlib import Path + from pytest import mark from pytest_regsmart.ast import (DependencyGraph, _build_module_relative_path, @@ -181,12 +182,10 @@ def test_get_dependency_graph_leaf_module_has_no_entry(git_repo): assert "tests/test_app.py" not in graph.dependents -def test_get_dependency_graph_uses_repository_dir_by_default( - monkeypatch, git_repo -): +def test_get_dependency_graph_defaults_to_dot_repo(monkeypatch, git_repo): _build_sample_project(git_repo) monkeypatch.setattr( - "pytest_regsmart.ast.REPOSITORY_DIR", git_repo, raising=False + "pytest_regsmart.ast.resolve_repo", lambda repo_path=".": git_repo ) graph = get_dependency_graph() From da7e40be527da971320cf81fe63fa2add24b7916 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 31 Jul 2026 21:54:28 -0300 Subject: [PATCH 037/122] refactor: rename args to rank_args --- pytest_regsmart/extractor.py | 2 +- pytest_regsmart/plugin.py | 20 ++++++++++---------- pytest_regsmart/{args.py => rank_args.py} | 0 3 files changed, 11 insertions(+), 11 deletions(-) rename pytest_regsmart/{args.py => rank_args.py} (100%) diff --git a/pytest_regsmart/extractor.py b/pytest_regsmart/extractor.py index effe3b9..26ed8c8 100644 --- a/pytest_regsmart/extractor.py +++ b/pytest_regsmart/extractor.py @@ -5,7 +5,7 @@ from _pytest.config import Config from _pytest.reports import TestReport -from .args import min_max_normalization +from .rank_args import min_max_normalization from .const import DATA_DIR diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index af45b78..c72e6e9 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -11,7 +11,7 @@ from _pytest.reports import TestReport from _pytest.terminal import TerminalReporter -from . import args +from . import rank_args from . import extractor from . import ranker from . import selector @@ -41,7 +41,7 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--rank-level", action="store", - type=args.level_type, + type=rank_args.level_type, default=DEFAULT_LEVEL, dest="rank_level", help=LEVEL_HELP) @@ -49,7 +49,7 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--rank-weight", action="store", - type=args.weight_type, + type=rank_args.weight_type, default=DEFAULT_WEIGHT, dest="rank_weight", help=WEIGHT_HELP) @@ -57,7 +57,7 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--rank-replay", action="store", - type=args.replay_type, + type=rank_args.replay_type, default=DEFAULT_REPLAY, dest="rank_replay", help=REPLAY_HELP) @@ -93,12 +93,12 @@ def __init__(self, config: Config) -> None: self.log = {} self.monitor = Monitor() - self.no_rank = args.parse_no_rank(config) - self.weights = args.parse_rtp_weights(config) - self.level = args.parse_rtp_level(config) - self.replay_file = args.parse_replay(config) - self.hist_len = args.parse_hist_len(config) - self.seed = args.parse_seed(config) + self.no_rank = rank_args.parse_no_rank(config) + self.weights = rank_args.parse_rtp_weights(config) + self.level = rank_args.parse_rtp_level(config) + self.replay_file = rank_args.parse_replay(config) + self.hist_len = rank_args.parse_hist_len(config) + self.seed = rank_args.parse_seed(config) @pytest.hookimpl(trylast=True) diff --git a/pytest_regsmart/args.py b/pytest_regsmart/rank_args.py similarity index 100% rename from pytest_regsmart/args.py rename to pytest_regsmart/rank_args.py From b6f58a592045f1c4798474bc44729e4168b28245 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 1 Aug 2026 01:33:29 -0300 Subject: [PATCH 038/122] restructure modules for better organization --- pytest_regsmart/extractor.py | 2 +- pytest_regsmart/plugin.py | 6 +++--- pytest_regsmart/ranking/__init__.py | 0 pytest_regsmart/{ => ranking}/rank_args.py | 2 +- pytest_regsmart/{ => ranking}/ranker.py | 2 +- pytest_regsmart/selection/__init__.py | 0 pytest_regsmart/{ => selection}/ast.py | 2 +- pytest_regsmart/{ => selection}/selector.py | 2 +- tests/{ => ranking}/test_ranking_heuristics.py | 0 tests/{ => ranking}/test_ranking_levels.py | 0 tests/{ => ranking}/test_replay.py | 0 tests/{ => selection}/test_ast.py | 4 ++-- tests/{ => selection}/test_selector.py | 2 +- 13 files changed, 11 insertions(+), 11 deletions(-) create mode 100644 pytest_regsmart/ranking/__init__.py rename pytest_regsmart/{ => ranking}/rank_args.py (97%) rename pytest_regsmart/{ => ranking}/ranker.py (99%) create mode 100644 pytest_regsmart/selection/__init__.py rename pytest_regsmart/{ => selection}/ast.py (98%) rename pytest_regsmart/{ => selection}/selector.py (97%) rename tests/{ => ranking}/test_ranking_heuristics.py (100%) rename tests/{ => ranking}/test_ranking_levels.py (100%) rename tests/{ => ranking}/test_replay.py (100%) rename tests/{ => selection}/test_ast.py (97%) rename tests/{ => selection}/test_selector.py (97%) diff --git a/pytest_regsmart/extractor.py b/pytest_regsmart/extractor.py index 26ed8c8..7a11666 100644 --- a/pytest_regsmart/extractor.py +++ b/pytest_regsmart/extractor.py @@ -5,7 +5,7 @@ from _pytest.config import Config from _pytest.reports import TestReport -from .rank_args import min_max_normalization +from .ranking.rank_args import min_max_normalization from .const import DATA_DIR diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index c72e6e9..9f3c827 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -11,10 +11,10 @@ from _pytest.reports import TestReport from _pytest.terminal import TerminalReporter -from . import rank_args +from .ranking import rank_args from . import extractor -from . import ranker -from . import selector +from .ranking import ranker +from .selection import selector from . import reporter as reporter_mod from .const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT from .help_strings import ( diff --git a/pytest_regsmart/ranking/__init__.py b/pytest_regsmart/ranking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytest_regsmart/rank_args.py b/pytest_regsmart/ranking/rank_args.py similarity index 97% rename from pytest_regsmart/rank_args.py rename to pytest_regsmart/ranking/rank_args.py index 4ad275c..68ce185 100644 --- a/pytest_regsmart/rank_args.py +++ b/pytest_regsmart/ranking/rank_args.py @@ -4,7 +4,7 @@ import numpy as np -from .const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, LEVEL +from ..const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, LEVEL def weight_type(string: str) -> str: diff --git a/pytest_regsmart/ranker.py b/pytest_regsmart/ranking/ranker.py similarity index 99% rename from pytest_regsmart/ranker.py rename to pytest_regsmart/ranking/ranker.py index ed7df20..cd4b8a9 100644 --- a/pytest_regsmart/ranker.py +++ b/pytest_regsmart/ranking/ranker.py @@ -7,7 +7,7 @@ import numpy as np -from .const import LEVEL +from ..const import LEVEL def get_test_group(nodeid: str, level: LEVEL) -> str: diff --git a/pytest_regsmart/selection/__init__.py b/pytest_regsmart/selection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytest_regsmart/ast.py b/pytest_regsmart/selection/ast.py similarity index 98% rename from pytest_regsmart/ast.py rename to pytest_regsmart/selection/ast.py index eaf2ec3..76cbff3 100644 --- a/pytest_regsmart/ast.py +++ b/pytest_regsmart/selection/ast.py @@ -5,7 +5,7 @@ from pyan.modvis import ImportVisitor -from .const import resolve_repo +from ..const import resolve_repo #inicialmente tenho que tentar gerar a forma mais simples só com os arquivos, nao precisa das funções #vou usar a API do pyan3, não o CLI diff --git a/pytest_regsmart/selector.py b/pytest_regsmart/selection/selector.py similarity index 97% rename from pytest_regsmart/selector.py rename to pytest_regsmart/selection/selector.py index b4649f6..6da293f 100644 --- a/pytest_regsmart/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from .ast import get_dependency_graph -from .const import resolve_repo +from ..const import resolve_repo @dataclass diff --git a/tests/test_ranking_heuristics.py b/tests/ranking/test_ranking_heuristics.py similarity index 100% rename from tests/test_ranking_heuristics.py rename to tests/ranking/test_ranking_heuristics.py diff --git a/tests/test_ranking_levels.py b/tests/ranking/test_ranking_levels.py similarity index 100% rename from tests/test_ranking_levels.py rename to tests/ranking/test_ranking_levels.py diff --git a/tests/test_replay.py b/tests/ranking/test_replay.py similarity index 100% rename from tests/test_replay.py rename to tests/ranking/test_replay.py diff --git a/tests/test_ast.py b/tests/selection/test_ast.py similarity index 97% rename from tests/test_ast.py rename to tests/selection/test_ast.py index d630776..d096632 100644 --- a/tests/test_ast.py +++ b/tests/selection/test_ast.py @@ -5,7 +5,7 @@ from pytest import mark -from pytest_regsmart.ast import (DependencyGraph, _build_module_relative_path, +from pytest_regsmart.selection.ast import (DependencyGraph, _build_module_relative_path, _find_py_files, _invert_dependency_graph, get_dependency_graph) @@ -185,7 +185,7 @@ def test_get_dependency_graph_leaf_module_has_no_entry(git_repo): def test_get_dependency_graph_defaults_to_dot_repo(monkeypatch, git_repo): _build_sample_project(git_repo) monkeypatch.setattr( - "pytest_regsmart.ast.resolve_repo", lambda repo_path=".": git_repo + "pytest_regsmart.selection.ast.resolve_repo", lambda repo_path=".": git_repo ) graph = get_dependency_graph() diff --git a/tests/test_selector.py b/tests/selection/test_selector.py similarity index 97% rename from tests/test_selector.py rename to tests/selection/test_selector.py index 9b93706..06fca83 100644 --- a/tests/test_selector.py +++ b/tests/selection/test_selector.py @@ -5,7 +5,7 @@ import pytest from git.exc import GitCommandError, InvalidGitRepositoryError -from pytest_regsmart.selector import DiffResult, get_git_diff +from pytest_regsmart.selection.selector import DiffResult, get_git_diff def test_commits_ahead_of_main(git_repo, commit_file): From 05677d707b3a555d9fb40c180e49dd31cef01b74 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 1 Aug 2026 21:19:35 -0300 Subject: [PATCH 039/122] chore: replace isort and flake8 for ruff as a linter --- .pre-commit-config.yaml | 16 ++++------------ pyproject.toml | 7 +++++++ pytest_regsmart/extractor.py | 2 +- pytest_regsmart/help_strings.py | 1 - pytest_regsmart/plugin.py | 21 +++++++++++++++------ pytest_regsmart/ranking/rank_args.py | 17 ++++++++++++----- pytest_regsmart/ranking/ranker.py | 2 +- pytest_regsmart/selection/ast.py | 2 ++ pytest_regsmart/selection/selector.py | 9 +++++---- tests/ranking/test_ranking_levels.py | 2 -- tests/selection/test_ast.py | 10 +++++++--- tests/selection/test_selector.py | 2 +- 12 files changed, 55 insertions(+), 36 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d644c6c..308769f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,16 +14,8 @@ repos: rev: v2.2.6 hooks: - id: codespell - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.1 hooks: - - id: isort - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 - hooks: - - id: pyupgrade - args: [--py38-plus] - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 + - id: ruff + args: [--fix, --exit-non-zero-on-fix] diff --git a/pyproject.toml b/pyproject.toml index e99cb9b..deda5c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,3 +52,10 @@ Homepage = "https://github.com/leticosta4/pytest-regsmart" [project.entry-points."pytest11"] pytest_regsmart = "pytest_regsmart.plugin" + +[tool.ruff] +line-length = 88 +target-version = "py38" + +[tool.ruff.lint] +extend-select = ["UP", "B"] diff --git a/pytest_regsmart/extractor.py b/pytest_regsmart/extractor.py index 7a11666..e26a049 100644 --- a/pytest_regsmart/extractor.py +++ b/pytest_regsmart/extractor.py @@ -5,8 +5,8 @@ from _pytest.config import Config from _pytest.reports import TestReport -from .ranking.rank_args import min_max_normalization from .const import DATA_DIR +from .ranking.rank_args import min_max_normalization def load_feature( diff --git a/pytest_regsmart/help_strings.py b/pytest_regsmart/help_strings.py index b3891de..bc16732 100644 --- a/pytest_regsmart/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -1,6 +1,5 @@ import textwrap - PLUGIN_HELP = textwrap.dedent("""\ Run regression test prioritization for pytest test suite. It re-orders execution of tests to expose test failure sooner. diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 9f3c827..617fa0d 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -11,17 +11,26 @@ from _pytest.reports import TestReport from _pytest.terminal import TerminalReporter -from .ranking import rank_args from . import extractor -from .ranking import ranker -from .selection import selector from . import reporter as reporter_mod -from .const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT +from .const import ( + DEFAULT_HIST_LEN, + DEFAULT_LEVEL, + DEFAULT_REPLAY, + DEFAULT_SEED, + DEFAULT_WEIGHT, +) from .help_strings import ( - HIST_LEN_HELP, LEVEL_HELP, PLUGIN_HELP, - REPLAY_HELP, SEED_HELP, WEIGHT_HELP, NO_RANK_HELP + HIST_LEN_HELP, + LEVEL_HELP, + NO_RANK_HELP, + PLUGIN_HELP, + REPLAY_HELP, + SEED_HELP, + WEIGHT_HELP, ) from .monitor import Monitor +from .ranking import rank_args, ranker def pytest_addoption(parser: Parser) -> None: diff --git a/pytest_regsmart/ranking/rank_args.py b/pytest_regsmart/ranking/rank_args.py index 68ce185..184dad5 100644 --- a/pytest_regsmart/ranking/rank_args.py +++ b/pytest_regsmart/ranking/rank_args.py @@ -4,7 +4,14 @@ import numpy as np -from ..const import DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, LEVEL +from ..const import ( + DEFAULT_HIST_LEN, + DEFAULT_LEVEL, + DEFAULT_REPLAY, + DEFAULT_SEED, + DEFAULT_WEIGHT, + LEVEL, +) def weight_type(string: str) -> str: @@ -20,7 +27,7 @@ def weight_type(string: str) -> str: raise argparse.ArgumentTypeError( "Cannot parse input for `--rank-weight`." + "Valid examples: 1-0, 0.4-0.2, and 2-7." - ) + ) from None def level_type(string: str) -> str: @@ -35,7 +42,7 @@ def level_type(string: str) -> str: raise argparse.ArgumentTypeError( "Invalid input for `--rank-level`." + " Please run `pytest --help` for instruction." - ) + ) from None def replay_type(string: str) -> str: @@ -46,11 +53,11 @@ def replay_type(string: str) -> str: with open(string) as f: _ = f.readlines() return string - except Exception: + except Exception: # noqa: BLE001 raise argparse.ArgumentTypeError( "File provided to `--rank-replay` cannot be read." + " Please run `pytest --help` for instruction." - ) + ) from None def min_max_normalization(x: list[float]) -> np.ndarray: diff --git a/pytest_regsmart/ranking/ranker.py b/pytest_regsmart/ranking/ranker.py index cd4b8a9..143ff34 100644 --- a/pytest_regsmart/ranking/ranker.py +++ b/pytest_regsmart/ranking/ranker.py @@ -69,7 +69,7 @@ def run_rtp( scores: dict = {} if replay_file and os.path.exists(replay_file): with open(replay_file) as f: - test_list = [x.strip() for x in f.readlines()] + test_list = [x.strip() for x in f] scores = {x: i for i, x in enumerate(test_list)} elif weights == [0, 0]: items.sort(key=lambda item: item.nodeid) diff --git a/pytest_regsmart/selection/ast.py b/pytest_regsmart/selection/ast.py index 76cbff3..cc5e953 100644 --- a/pytest_regsmart/selection/ast.py +++ b/pytest_regsmart/selection/ast.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import os from collections import defaultdict diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index 6da293f..111d5e2 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from dataclasses import dataclass -from .ast import get_dependency_graph from ..const import resolve_repo +from .ast import get_dependency_graph @dataclass @@ -32,10 +34,9 @@ def get_affected_tests(diff_result: DiffResult, short_graph) -> list[str]: def run_rts(): """[wip] Orquestra a seleção...""" - diff_result = get_git_diff() - imports_deps_graph = get_dependency_graph() #talvez vou paralelizar isso com o git diff mais pra frente + diff_result = get_git_diff() # noqa: F841 + imports_deps_graph = get_dependency_graph() # noqa: F841 #talvez vou paralelizar isso com o git diff mais pra frente - pass #rodar o git diff pra ver a diferença #guardar os trechos diff --git a/tests/ranking/test_ranking_levels.py b/tests/ranking/test_ranking_levels.py index 1a6b73b..af34af3 100644 --- a/tests/ranking/test_ranking_levels.py +++ b/tests/ranking/test_ranking_levels.py @@ -1,7 +1,5 @@ from __future__ import annotations -import textwrap - from tests.fake_test_data import ( test_a_method, test_a_method_two, diff --git a/tests/selection/test_ast.py b/tests/selection/test_ast.py index d096632..2f6c51e 100644 --- a/tests/selection/test_ast.py +++ b/tests/selection/test_ast.py @@ -5,9 +5,13 @@ from pytest import mark -from pytest_regsmart.selection.ast import (DependencyGraph, _build_module_relative_path, - _find_py_files, _invert_dependency_graph, - get_dependency_graph) +from pytest_regsmart.selection.ast import ( + DependencyGraph, + _build_module_relative_path, + _find_py_files, + _invert_dependency_graph, + get_dependency_graph, +) def _write(tmp_path: Path, relpath: str, content: str = "") -> Path: diff --git a/tests/selection/test_selector.py b/tests/selection/test_selector.py index 06fca83..7d16f5f 100644 --- a/tests/selection/test_selector.py +++ b/tests/selection/test_selector.py @@ -5,7 +5,7 @@ import pytest from git.exc import GitCommandError, InvalidGitRepositoryError -from pytest_regsmart.selection.selector import DiffResult, get_git_diff +from pytest_regsmart.selection.selector import get_git_diff def test_commits_ahead_of_main(git_repo, commit_file): From ce52120cfa808423e4db28812cf48accbca6d129 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 2 Aug 2026 22:05:49 -0300 Subject: [PATCH 040/122] refactor: reorganize selection dir internal structure --- pytest_regsmart/const.py | 6 ---- .../selection/{ast.py => deps_graph.py} | 2 +- pytest_regsmart/selection/git_diff.py | 29 ++++++++++++++++ pytest_regsmart/selection/selector.py | 34 +++++-------------- .../{test_ast.py => test_deps_graph.py} | 4 +-- .../{test_selector.py => test_git_diff.py} | 2 +- 6 files changed, 41 insertions(+), 36 deletions(-) rename pytest_regsmart/selection/{ast.py => deps_graph.py} (98%) create mode 100644 pytest_regsmart/selection/git_diff.py rename tests/selection/{test_ast.py => test_deps_graph.py} (97%) rename tests/selection/{test_selector.py => test_git_diff.py} (97%) diff --git a/pytest_regsmart/const.py b/pytest_regsmart/const.py index 2e1de39..e8e7f00 100644 --- a/pytest_regsmart/const.py +++ b/pytest_regsmart/const.py @@ -1,7 +1,5 @@ from enum import Enum -from git import Repo - # ------ Constants ------ DATA_DIR = "pytest_ranked_selection_data" @@ -26,7 +24,3 @@ class LEVEL(str, Enum): MODULE = "module" DEFAULT_LEVEL = LEVEL.PUT - - -def resolve_repo(repo_path: str = ".") -> Repo: - return Repo(repo_path) diff --git a/pytest_regsmart/selection/ast.py b/pytest_regsmart/selection/deps_graph.py similarity index 98% rename from pytest_regsmart/selection/ast.py rename to pytest_regsmart/selection/deps_graph.py index cc5e953..df8987a 100644 --- a/pytest_regsmart/selection/ast.py +++ b/pytest_regsmart/selection/deps_graph.py @@ -7,7 +7,7 @@ from pyan.modvis import ImportVisitor -from ..const import resolve_repo +from .git_diff import resolve_repo #inicialmente tenho que tentar gerar a forma mais simples só com os arquivos, nao precisa das funções #vou usar a API do pyan3, não o CLI diff --git a/pytest_regsmart/selection/git_diff.py b/pytest_regsmart/selection/git_diff.py new file mode 100644 index 0000000..fbc9819 --- /dev/null +++ b/pytest_regsmart/selection/git_diff.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from dataclasses import dataclass +from git import Repo + + +@dataclass +class DiffResult: + modified_files: list[str] #staged+unstaged + untracked_files: list[str] #completamente novo - talvez colocar uma flag futura para comparar tentar buscar só testes do unstages+untracked + #depois ainda quero pegar a diff mais granular, talvez por linha ou conteudo de forma semantica?? n sei + + +def resolve_repo(repo_path: str = ".") -> Repo: + return Repo(repo_path) + + +def get_git_diff(repo_path: str = ".") -> DiffResult: + repo = resolve_repo(repo_path=repo_path) + default_branch = "main" #talvez eu deixe isso manualmente configuravel via flag depois + + merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description + working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() + untracked_diff = repo.untracked_files + + return DiffResult( + modified_files=working_dir_diff, + untracked_files=untracked_diff + ) diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index 111d5e2..7ee53f0 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -1,33 +1,13 @@ from __future__ import annotations -from dataclasses import dataclass +from .deps_graph import DependencyGraph, get_dependency_graph +from .git_diff import DiffResult, get_git_diff -from ..const import resolve_repo -from .ast import get_dependency_graph - -@dataclass -class DiffResult: - modified_files: list[str] #staged+unstaged - untracked_files: list[str] #completamente novo - talvez colocar uma flag futura para comparar tentar buscar só testes do unstages+untracked - #depois ainda quero pegar a diff mais granular, talvez por linha ou conteudo de forma semantica?? n sei - - -def get_git_diff(repo_path: str = ".") -> DiffResult: - repo = resolve_repo(repo_path=repo_path) - default_branch = "main" #talvez eu deixe isso manualmente configuravel via flag depois - - merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description - working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() - untracked_diff = repo.untracked_files - - return DiffResult( - modified_files=working_dir_diff, - untracked_files=untracked_diff - ) - - -def get_affected_tests(diff_result: DiffResult, short_graph) -> list[str]: +def get_affected_tests(diff_result: DiffResult, imports_deps_graph: DependencyGraph) -> list[str]: + #para cada arquivo modificado no diff vai procurar a ref dele no grafo de dependencia e checar o valor relacioando (dependentes) + #dentro dos dependentes vai filtrar só os que são testes (talvez por regex ou por heuristica de nome de arquivo) + #eu quero pegar testes imapctados indiretamente tb, nao é só o dependente direto pass @@ -37,6 +17,8 @@ def run_rts(): diff_result = get_git_diff() # noqa: F841 imports_deps_graph = get_dependency_graph() # noqa: F841 #talvez vou paralelizar isso com o git diff mais pra frente + selected_tests = get_affected_tests(diff_result, imports_deps_graph) # noqa: F841 + #rodar o git diff pra ver a diferença #guardar os trechos diff --git a/tests/selection/test_ast.py b/tests/selection/test_deps_graph.py similarity index 97% rename from tests/selection/test_ast.py rename to tests/selection/test_deps_graph.py index 2f6c51e..701fb83 100644 --- a/tests/selection/test_ast.py +++ b/tests/selection/test_deps_graph.py @@ -5,7 +5,7 @@ from pytest import mark -from pytest_regsmart.selection.ast import ( +from pytest_regsmart.selection.deps_graph import ( DependencyGraph, _build_module_relative_path, _find_py_files, @@ -189,7 +189,7 @@ def test_get_dependency_graph_leaf_module_has_no_entry(git_repo): def test_get_dependency_graph_defaults_to_dot_repo(monkeypatch, git_repo): _build_sample_project(git_repo) monkeypatch.setattr( - "pytest_regsmart.selection.ast.resolve_repo", lambda repo_path=".": git_repo + "pytest_regsmart.selection.deps_graph.resolve_repo", lambda repo_path=".": git_repo ) graph = get_dependency_graph() diff --git a/tests/selection/test_selector.py b/tests/selection/test_git_diff.py similarity index 97% rename from tests/selection/test_selector.py rename to tests/selection/test_git_diff.py index 7d16f5f..d158b63 100644 --- a/tests/selection/test_selector.py +++ b/tests/selection/test_git_diff.py @@ -5,7 +5,7 @@ import pytest from git.exc import GitCommandError, InvalidGitRepositoryError -from pytest_regsmart.selection.selector import get_git_diff +from pytest_regsmart.selection.git_diff import get_git_diff def test_commits_ahead_of_main(git_repo, commit_file): From 79b0ae3868a19cea427b21977958b43395389340 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 5 Aug 2026 00:37:10 -0300 Subject: [PATCH 041/122] feat: add the main test selection function (diff and graph correlaton) --- pytest_regsmart/selection/selector.py | 41 +++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index 7ee53f0..be2c27d 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -1,23 +1,52 @@ from __future__ import annotations +import os + from .deps_graph import DependencyGraph, get_dependency_graph from .git_diff import DiffResult, get_git_diff -def get_affected_tests(diff_result: DiffResult, imports_deps_graph: DependencyGraph) -> list[str]: +def _is_test_file(filepath: str) -> bool: + filename = os.path.basename(filepath) + return filename.startswith("test_") or filename.endswith("_test.py") + + +def get_affected_tests(diff_result: DiffResult, deps_graph: DependencyGraph) -> list[str]: #para cada arquivo modificado no diff vai procurar a ref dele no grafo de dependencia e checar o valor relacioando (dependentes) #dentro dos dependentes vai filtrar só os que são testes (talvez por regex ou por heuristica de nome de arquivo) #eu quero pegar testes imapctados indiretamente tb, nao é só o dependente direto - pass + + changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) + seen = set(changed_files) + to_check = list(changed_files) + affected_tests = [] + + while to_check: + current_file = to_check.pop() + dependents = deps_graph.dependents.get(current_file, set()) + + for dependent in dependents: + if dependent in seen: + continue + seen.add(dependent) #seen = o dependente foi descoberto, mas ainda sera visitadoe e + to_check.append(dependent) #caso nao seja um teste ainda quero checar os dependentes pra ver o que pode estar sendo afetado indiretamente -def run_rts(): + if _is_test_file(dependent): + affected_tests.append(dependent) + + return sorted(affected_tests) + + +def run_rts() -> list[str]: """[wip] Orquestra a seleção...""" - diff_result = get_git_diff() # noqa: F841 - imports_deps_graph = get_dependency_graph() # noqa: F841 #talvez vou paralelizar isso com o git diff mais pra frente + diff_result = get_git_diff() + deps_graph = get_dependency_graph() #talvez vou paralelizar isso com o git diff mais pra frente + + selected_tests = get_affected_tests(diff_result, deps_graph) - selected_tests = get_affected_tests(diff_result, imports_deps_graph) # noqa: F841 + return selected_tests #rodar o git diff pra ver a diferença From b9064c0afd206b07c8bbfe2c9fbc9196f92883df Mon Sep 17 00:00:00 2001 From: let Date: Wed, 5 Aug 2026 01:05:49 -0300 Subject: [PATCH 042/122] feat: add run_rts_ call to the pluigin --- pytest_regsmart/plugin.py | 9 +++++++-- pytest_regsmart/selection/selector.py | 11 +---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 617fa0d..c541d5d 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -11,6 +11,8 @@ from _pytest.reports import TestReport from _pytest.terminal import TerminalReporter +from pytest_regsmart.selection import selector + from . import extractor from . import reporter as reporter_mod from .const import ( @@ -120,10 +122,13 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: "--rank-replay cannot be used together with random order." ) - #selector.run_rts() #ainda nao implementado, mas vai ser chamado aqui + selected_tests = selector.run_rts() + if selected_tests: + items[:] = [item for item in items if item.nodeid.split("::")[0] in set(selected_tests)] + if not self.no_rank: - ranker.run_rtp( #tenho que ver como mudar essa assinatura para que o ranker receba somente os testes afetados, e nao todos os testes + ranker.run_rtp( items, self.level, self.weights, self.replay_file, self.seed, self.log, lambda feature_name, items, reverse: diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index be2c27d..ad270f7 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -44,13 +44,4 @@ def run_rts() -> list[str]: diff_result = get_git_diff() deps_graph = get_dependency_graph() #talvez vou paralelizar isso com o git diff mais pra frente - selected_tests = get_affected_tests(diff_result, deps_graph) - - return selected_tests - - -#rodar o git diff pra ver a diferença -#guardar os trechos -#talvez em paralelo: rodar o pyan3 pra pegar o grafo de dependencias -#funcao pra correlacionar os trechos com o grafo de dependencias e achar testes afetados -#pega a nova lista de testes e manda para o ranker \ No newline at end of file + return get_affected_tests(diff_result, deps_graph) From 39855d53488ff833a40e4528eecaa845ab775301 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 5 Aug 2026 01:08:14 -0300 Subject: [PATCH 043/122] test: add a very simple unit testing --- tests/selection/test_selection.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/selection/test_selection.py diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py new file mode 100644 index 0000000..6948115 --- /dev/null +++ b/tests/selection/test_selection.py @@ -0,0 +1,22 @@ +from pytest_regsmart.selection.git_diff import DiffResult +from pytest_regsmart.selection.deps_graph import DependencyGraph +from pytest_regsmart.selection.selector import get_affected_tests + + +def test_get_affected_tests_reaches_indirect_test_dependents(): + deps_graph = DependencyGraph( + dependents={ + "service.py": {"ranker.py"}, + "ranker.py": {"main.py"}, + "main.py": {"test_app.py"}, + } + ) + diff_result = DiffResult( + modified_files=["service.py"], + untracked_files=[], + ) + + result = get_affected_tests(diff_result, deps_graph) + + assert result == ["test_app.py"] + \ No newline at end of file From e72403d6ec31c8bd3f67f68fb0566494dfab1b67 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 5 Aug 2026 22:37:36 -0300 Subject: [PATCH 044/122] feat: add is_git_repo function for plugin validation --- pytest_regsmart/selection/git_diff.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pytest_regsmart/selection/git_diff.py b/pytest_regsmart/selection/git_diff.py index fbc9819..28c135a 100644 --- a/pytest_regsmart/selection/git_diff.py +++ b/pytest_regsmart/selection/git_diff.py @@ -1,7 +1,9 @@ from __future__ import annotations from dataclasses import dataclass + from git import Repo +from git.exc import InvalidGitRepositoryError, NoSuchPathError @dataclass @@ -15,6 +17,14 @@ def resolve_repo(repo_path: str = ".") -> Repo: return Repo(repo_path) +def verify_git_repo(repo_path: str = ".") -> bool: + try: + resolve_repo(repo_path) + return True + except (InvalidGitRepositoryError, NoSuchPathError): + return False + + def get_git_diff(repo_path: str = ".") -> DiffResult: repo = resolve_repo(repo_path=repo_path) default_branch = "main" #talvez eu deixe isso manualmente configuravel via flag depois From 5bd9df0cd5e8ddef3b44c65f72f682d0d11b5fea Mon Sep 17 00:00:00 2001 From: let Date: Wed, 5 Aug 2026 22:39:02 -0300 Subject: [PATCH 045/122] rename git module files for better organization --- pytest_regsmart/selection/deps_graph.py | 2 +- pytest_regsmart/selection/{git_diff.py => git_manager.py} | 0 pytest_regsmart/selection/selector.py | 2 +- tests/selection/{test_git_diff.py => test_git.py} | 2 +- tests/selection/test_selection.py | 3 +-- 5 files changed, 4 insertions(+), 5 deletions(-) rename pytest_regsmart/selection/{git_diff.py => git_manager.py} (100%) rename tests/selection/{test_git_diff.py => test_git.py} (97%) diff --git a/pytest_regsmart/selection/deps_graph.py b/pytest_regsmart/selection/deps_graph.py index df8987a..16049ed 100644 --- a/pytest_regsmart/selection/deps_graph.py +++ b/pytest_regsmart/selection/deps_graph.py @@ -7,7 +7,7 @@ from pyan.modvis import ImportVisitor -from .git_diff import resolve_repo +from .git_manager import resolve_repo #inicialmente tenho que tentar gerar a forma mais simples só com os arquivos, nao precisa das funções #vou usar a API do pyan3, não o CLI diff --git a/pytest_regsmart/selection/git_diff.py b/pytest_regsmart/selection/git_manager.py similarity index 100% rename from pytest_regsmart/selection/git_diff.py rename to pytest_regsmart/selection/git_manager.py diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index ad270f7..d7cd780 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -3,7 +3,7 @@ import os from .deps_graph import DependencyGraph, get_dependency_graph -from .git_diff import DiffResult, get_git_diff +from .git_manager import DiffResult, get_git_diff def _is_test_file(filepath: str) -> bool: diff --git a/tests/selection/test_git_diff.py b/tests/selection/test_git.py similarity index 97% rename from tests/selection/test_git_diff.py rename to tests/selection/test_git.py index d158b63..ecc4403 100644 --- a/tests/selection/test_git_diff.py +++ b/tests/selection/test_git.py @@ -5,7 +5,7 @@ import pytest from git.exc import GitCommandError, InvalidGitRepositoryError -from pytest_regsmart.selection.git_diff import get_git_diff +from pytest_regsmart.selection.git_manager import get_git_diff def test_commits_ahead_of_main(git_repo, commit_file): diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py index 6948115..06ff24a 100644 --- a/tests/selection/test_selection.py +++ b/tests/selection/test_selection.py @@ -1,4 +1,4 @@ -from pytest_regsmart.selection.git_diff import DiffResult +from pytest_regsmart.selection.git_manager import DiffResult from pytest_regsmart.selection.deps_graph import DependencyGraph from pytest_regsmart.selection.selector import get_affected_tests @@ -19,4 +19,3 @@ def test_get_affected_tests_reaches_indirect_test_dependents(): result = get_affected_tests(diff_result, deps_graph) assert result == ["test_app.py"] - \ No newline at end of file From bb9640fcdb3ee48ad9cb2ad9731466094e869f6f Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 00:10:47 -0300 Subject: [PATCH 046/122] feat: add Git repository validation and handle GitCommandError in test selection --- pytest_regsmart/plugin.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index c541d5d..2dfc631 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -10,6 +10,7 @@ from _pytest.nodes import Item from _pytest.reports import TestReport from _pytest.terminal import TerminalReporter +from git.exc import GitCommandError from pytest_regsmart.selection import selector @@ -122,9 +123,15 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: "--rank-replay cannot be used together with random order." ) - selected_tests = selector.run_rts() + selected_tests: list[str] = [] + try: + selected_tests = selector.run_rts() + except GitCommandError: + #repo git sem a branch default 'main' (tratamento dinamico fica pra depois) -> roda tudo + selected_tests = [] if selected_tests: - items[:] = [item for item in items if item.nodeid.split("::")[0] in set(selected_tests)] + selected_nodes = set(selected_tests) + items[:] = [item for item in items if item.nodeid.split("::")[0] in selected_nodes] if not self.no_rank: @@ -172,5 +179,10 @@ def pytest_configure(config: Config) -> None: "--no-rank cannot be used together with other ranking flags. It excludes RTP." ) + if config.getoption("--regsmart") and not selector.is_git_repo(): + raise pytest.UsageError( + "--regsmart requires a git repository." + ) + runner = PluginRunner(config) config.pluginmanager.register(runner) From 024bd5cdde9072efa71f0b4f866f917ad1d762ef Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 00:19:38 -0300 Subject: [PATCH 047/122] feat: integrate git repository validation in test selection and enhance test setup --- pytest_regsmart/plugin.py | 4 ++-- tests/conftest.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 2dfc631..898cf7d 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -12,7 +12,7 @@ from _pytest.terminal import TerminalReporter from git.exc import GitCommandError -from pytest_regsmart.selection import selector +from pytest_regsmart.selection import selector, git_manager from . import extractor from . import reporter as reporter_mod @@ -179,7 +179,7 @@ def pytest_configure(config: Config) -> None: "--no-rank cannot be used together with other ranking flags. It excludes RTP." ) - if config.getoption("--regsmart") and not selector.is_git_repo(): + if config.getoption("--regsmart") and not git_manager.verify_git_repo(): raise pytest.UsageError( "--regsmart requires a git repository." ) diff --git a/tests/conftest.py b/tests/conftest.py index 38bd0dc..423eafa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,4 +39,14 @@ def mytester(pytester): console_output_style = classic """, ) + repo = Repo.init(pytester.path) + writer = repo.config_writer() + writer.set_value("user", "name", "Test") + writer.set_value("user", "email", "test@test.com") + writer.release() + if repo.active_branch.name != "main": + repo.git.branch("-m", "main") + (pytester.path / ".gitignore").write_text("__pycache__/\n") + repo.index.add([".gitignore"]) + repo.index.commit("chore: baseline") yield pytester From a4a6b9194b1cbb2612d5955cfac7df20271c4473 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 00:23:06 -0300 Subject: [PATCH 048/122] feat: add tests for verifying git repository status and requirements --- tests/selection/test_git.py | 16 +++++++++++++++- tests/selection/test_selector_opts.py | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/selection/test_selector_opts.py diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py index ecc4403..4ceb087 100644 --- a/tests/selection/test_git.py +++ b/tests/selection/test_git.py @@ -5,7 +5,21 @@ import pytest from git.exc import GitCommandError, InvalidGitRepositoryError -from pytest_regsmart.selection.git_manager import get_git_diff +from pytest_regsmart.selection.git_manager import get_git_diff, verify_git_repo + + +def test_verify_git_repo_true(git_repo, commit_file): + commit_file("file_a.py") + + assert verify_git_repo(str(git_repo.working_tree_dir)) is True + + +def test_verify_git_repo_false(tmp_path): + assert verify_git_repo(str(tmp_path)) is False + + +def test_verify_git_repo_nonexistent_path(tmp_path): + assert verify_git_repo(str(tmp_path / "does-not-exist")) is False def test_commits_ahead_of_main(git_repo, commit_file): diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py new file mode 100644 index 0000000..38f67ea --- /dev/null +++ b/tests/selection/test_selector_opts.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from tests.fake_test_data import test_method_one + + +def test_regsmart_requires_git_repo(pytester): + pytester.makepyfile(test_method_one=test_method_one) + + out = pytester.runpytest("--regsmart") + + assert any("--regsmart requires a git repository." in x for x in out.errlines) + assert not any("::" in x and "PASSED" in x for x in out.outlines) From e3d53d077cf1379636d898eeb39623f2dcda3ecf Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 00:23:15 -0300 Subject: [PATCH 049/122] fix lint (ruff) --- pytest_regsmart/plugin.py | 2 +- tests/selection/test_selection.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 898cf7d..241c9de 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -12,7 +12,7 @@ from _pytest.terminal import TerminalReporter from git.exc import GitCommandError -from pytest_regsmart.selection import selector, git_manager +from pytest_regsmart.selection import git_manager, selector from . import extractor from . import reporter as reporter_mod diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py index 06ff24a..60825e2 100644 --- a/tests/selection/test_selection.py +++ b/tests/selection/test_selection.py @@ -1,5 +1,5 @@ -from pytest_regsmart.selection.git_manager import DiffResult from pytest_regsmart.selection.deps_graph import DependencyGraph +from pytest_regsmart.selection.git_manager import DiffResult from pytest_regsmart.selection.selector import get_affected_tests From 18d81e0489cc85180a70a8ea791a65d64b3e52b0 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 01:06:58 -0300 Subject: [PATCH 050/122] fix: resolve default git branch dynamicallly and handle repo without commits --- pytest_regsmart/plugin.py | 12 ++--- pytest_regsmart/selection/git_manager.py | 32 ++++++++++++- tests/conftest.py | 2 - tests/selection/test_git.py | 57 +++++++++++++++++++++--- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 241c9de..44808f6 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -10,9 +10,6 @@ from _pytest.nodes import Item from _pytest.reports import TestReport from _pytest.terminal import TerminalReporter -from git.exc import GitCommandError - -from pytest_regsmart.selection import git_manager, selector from . import extractor from . import reporter as reporter_mod @@ -34,6 +31,7 @@ ) from .monitor import Monitor from .ranking import rank_args, ranker +from .selection import git_manager, selector def pytest_addoption(parser: Parser) -> None: @@ -123,12 +121,8 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: "--rank-replay cannot be used together with random order." ) - selected_tests: list[str] = [] - try: - selected_tests = selector.run_rts() - except GitCommandError: - #repo git sem a branch default 'main' (tratamento dinamico fica pra depois) -> roda tudo - selected_tests = [] + selected_tests = selector.run_rts() + if selected_tests: selected_nodes = set(selected_tests) items[:] = [item for item in items if item.nodeid.split("::")[0] in selected_nodes] diff --git a/pytest_regsmart/selection/git_manager.py b/pytest_regsmart/selection/git_manager.py index 28c135a..dc9d7aa 100644 --- a/pytest_regsmart/selection/git_manager.py +++ b/pytest_regsmart/selection/git_manager.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from git import Repo -from git.exc import InvalidGitRepositoryError, NoSuchPathError +from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError @dataclass @@ -25,9 +25,37 @@ def verify_git_repo(repo_path: str = ".") -> bool: return False +def get_default_repo_branch(repo: Repo) -> str: + try: #pra quando o repo tem remote (origin/HEAD) + symbolic_ref = repo.git.symbolic_ref('refs/remotes/origin/HEAD') + return symbolic_ref.split('/')[-1] #a ultima parte da ref é o nome da branch + except GitCommandError: + pass + + + branch_names = [branch.name for branch in repo.branches] #se nao tiver vai tentar buscar main/master + for candidate in ("main", "master"): + if candidate in branch_names: + return candidate + + try: + return repo.active_branch.name + except TypeError: # detached head + raise ValueError( + "Unable to determine the default branch." #sem origin/HEAD, sem main/master e sem branch ativa + ) from None + + def get_git_diff(repo_path: str = ".") -> DiffResult: repo = resolve_repo(repo_path=repo_path) - default_branch = "main" #talvez eu deixe isso manualmente configuravel via flag depois + + if not repo.head.is_valid(): #se o repo nao tem commits (mas tem git init), vou só checar o diff untracked + return DiffResult( + modified_files=[], + untracked_files=repo.untracked_files + ) + + default_branch = get_default_repo_branch(repo) #talvez eu deixe isso manualmente configuravel via flag depois merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() diff --git a/tests/conftest.py b/tests/conftest.py index 423eafa..740cdab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,8 +44,6 @@ def mytester(pytester): writer.set_value("user", "name", "Test") writer.set_value("user", "email", "test@test.com") writer.release() - if repo.active_branch.name != "main": - repo.git.branch("-m", "main") (pytester.path / ".gitignore").write_text("__pycache__/\n") repo.index.add([".gitignore"]) repo.index.commit("chore: baseline") diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py index 4ceb087..abec96a 100644 --- a/tests/selection/test_git.py +++ b/tests/selection/test_git.py @@ -3,9 +3,14 @@ from pathlib import Path import pytest -from git.exc import GitCommandError, InvalidGitRepositoryError +from git import Repo +from git.exc import InvalidGitRepositoryError -from pytest_regsmart.selection.git_manager import get_git_diff, verify_git_repo +from pytest_regsmart.selection.git_manager import ( + get_default_repo_branch, + get_git_diff, + verify_git_repo, +) def test_verify_git_repo_true(git_repo, commit_file): @@ -87,9 +92,51 @@ def test_not_a_repo(tmp_path): get_git_diff(str(tmp_path)) -def test_no_main_branch(git_repo, commit_file): +def test_default_branch_is_main(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_default_branch_is_master(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "master") + + assert get_default_repo_branch(git_repo) == "master" + + +def test_default_branch_feature_prefers_local_main(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_default_branch_detached_with_local_main(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("--detach") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_get_git_diff_custom_default_branch(git_repo, commit_file): commit_file("file_a.py") git_repo.git.branch("-m", "develop") - with pytest.raises(GitCommandError): - get_git_diff(str(git_repo.working_tree_dir)) + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert result.modified_files == [] + assert result.untracked_files == [] + + +def test_get_git_diff_no_commits(tmp_path): + Repo.init(tmp_path) + (tmp_path / "new_file.py").write_text("new") + + result = get_git_diff(str(tmp_path)) + + assert result.modified_files == [] + assert "new_file.py" in result.untracked_files From 64eb59af90ee7694198988cf98a22cc85e672898 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 01:29:23 -0300 Subject: [PATCH 051/122] refactor: add warnings and time duration logs to rts module --- pytest_regsmart/plugin.py | 20 ++++++++++++++++---- pytest_regsmart/reporter.py | 3 +++ pytest_regsmart/selection/selector.py | 14 ++++++++++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 44808f6..0e71ca8 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -101,6 +101,7 @@ class PluginRunner: def __init__(self, config: Config) -> None: self.config = config self.log = {} + self.warnings: list[str] = [] self.monitor = Monitor() self.no_rank = rank_args.parse_no_rank(config) @@ -121,12 +122,23 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: "--rank-replay cannot be used together with random order." ) - selected_tests = selector.run_rts() + selection_start_time = time.time() + selection = selector.run_rts() + self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time - if selected_tests: - selected_nodes = set(selected_tests) + if selection.affected_tests: + selected_nodes = set(selection.affected_tests) items[:] = [item for item in items if item.nodeid.split("::")[0] in selected_nodes] + if not selection.has_diff: + self.warnings.append( + "No diff detected: regression test selection was skipped." + ) + if self.no_rank: + self.warnings.append( + "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." + ) + if not self.no_rank: ranker.run_rtp( @@ -161,7 +173,7 @@ def pytest_terminal_summary( exitstatus: int, config: Config) -> None: if self.config.getoption("--regsmart"): - reporter_mod.pytest_terminal_summary(terminalreporter, self.log) + reporter_mod.pytest_terminal_summary(terminalreporter, self.log, self.warnings) @pytest.hookimpl(trylast=True) diff --git a/pytest_regsmart/reporter.py b/pytest_regsmart/reporter.py index b939172..d26b258 100644 --- a/pytest_regsmart/reporter.py +++ b/pytest_regsmart/reporter.py @@ -29,9 +29,12 @@ def pytest_report_header(config: Config) -> str | None: def pytest_terminal_summary( terminalreporter: TerminalReporter, log_dict: dict, + warnings: tuple[str, ...] = (), ) -> None: """Report plugin runtime when it is enabled.""" tr = terminalreporter tr._tw.sep("=", "pytest-regsmart summary info") + for w in warnings: + tr._tw.line(f"WARNING: {w}") for k, v in log_dict.items(): tr._tw.line(f"{k}: {v*1000:.2f} ms ({v} s)") diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index d7cd780..f4afcf8 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -1,11 +1,18 @@ from __future__ import annotations import os +from dataclasses import dataclass from .deps_graph import DependencyGraph, get_dependency_graph from .git_manager import DiffResult, get_git_diff +@dataclass +class SelectionResult: + affected_tests: list[str] + has_diff: bool + + def _is_test_file(filepath: str) -> bool: filename = os.path.basename(filepath) return filename.startswith("test_") or filename.endswith("_test.py") @@ -38,10 +45,13 @@ def get_affected_tests(diff_result: DiffResult, deps_graph: DependencyGraph) -> return sorted(affected_tests) -def run_rts() -> list[str]: +def run_rts() -> SelectionResult: """[wip] Orquestra a seleção...""" diff_result = get_git_diff() deps_graph = get_dependency_graph() #talvez vou paralelizar isso com o git diff mais pra frente - return get_affected_tests(diff_result, deps_graph) + return SelectionResult( + affected_tests=get_affected_tests(diff_result, deps_graph), + has_diff=bool(diff_result.modified_files or diff_result.untracked_files), + ) From f585456ecd642084dc3db3afb495c459ea46eb33 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 01:55:31 -0300 Subject: [PATCH 052/122] refactor: add rts info in report header formatting --- pytest_regsmart/reporter.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pytest_regsmart/reporter.py b/pytest_regsmart/reporter.py index d26b258..e75d330 100644 --- a/pytest_regsmart/reporter.py +++ b/pytest_regsmart/reporter.py @@ -10,20 +10,25 @@ def pytest_report_header(config: Config) -> str | None: return None if config.getoption("--no-rank"): - return "Using --no-rank (RTP disabled)." + return [ + "\nStarting RTS (Regression Test Selection)", + "Using --no-rank (RTP disabled).", + ] weight = config.getoption("--rank-weight") replay = config.getoption("--rank-replay") level = config.getoption("--rank-level") hist_len = config.getoption("--rank-hist-len") random_seed = config.getoption("--rank-seed") - return "\n".join([ + return [ + "\nStarting Smart Regression Test Management (RTS + RTP)", + ] + [ f"Using --rank-weight={weight}", f"Using --rank-level={level}", f"Using --rank-hist-len={hist_len}", f"Using --rank-seed={random_seed}", f"Using --rank-replay={replay}", - ]) + ] def pytest_terminal_summary( From 5663204c05a1fb095b98e884506ea7982355bb52 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 22:51:17 -0300 Subject: [PATCH 053/122] fix: include changed test files to affected_tests list --- pytest_regsmart/selection/selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index f4afcf8..33f16e9 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -24,9 +24,9 @@ def get_affected_tests(diff_result: DiffResult, deps_graph: DependencyGraph) -> #eu quero pegar testes imapctados indiretamente tb, nao é só o dependente direto changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) + affected_tests = sorted(f for f in changed_files if _is_test_file(f)) seen = set(changed_files) to_check = list(changed_files) - affected_tests = [] while to_check: current_file = to_check.pop() From 30b6afd4e4ad80e87723b88ed0816e5d61ec8886 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 6 Aug 2026 23:22:21 -0300 Subject: [PATCH 054/122] add integration tests for selection module --- tests/conftest.py | 66 ++++++++++++++++ tests/selection/test_selector_opts.py | 106 ++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 740cdab..42c3365 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,3 +48,69 @@ def mytester(pytester): repo.index.add([".gitignore"]) repo.index.commit("chore: baseline") yield pytester + + +@pytest.fixture +def selection_project(pytester): + """Repo git com módulo de produção + testes que o importam, tudo commitado. + + Permite exercitar a seleção de verdade: o teste altera `service.py` + (produção) para gerar um diff e selecionar só os testes dependentes. + Retorna (Path do projeto, Repo). + """ + pytester.makefile( + ".ini", + pytest=""" + [pytest] + console_output_style = classic + """, + ) + repo = Repo.init(pytester.path) + writer = repo.config_writer() + writer.set_value("user", "name", "Test") + writer.set_value("user", "email", "test@test.com") + writer.release() + + (pytester.path / ".gitignore").write_text("__pycache__/\n.pytest_cache/\n") + pytester.makepyfile( + service=""" + def run(): + return 42 + """, + test_service=""" + import time + from service import run + + def test_service_fast(): + time.sleep(0.05) + assert run() == 42 + + def test_service_slow(): + time.sleep(0.5) + assert run() == 42 + """, + test_other=""" + import time + from service import run + + def test_other_run(): + time.sleep(0.2) + assert run() == 42 + """, + test_unrelated=""" + def test_unrelated(): + assert True + """, + ) + repo.index.add( + [ + ".gitignore", + "pytest.ini", + "service.py", + "test_service.py", + "test_other.py", + "test_unrelated.py", + ] + ) + repo.index.commit("chore: baseline") + yield pytester, repo diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py index 38f67ea..a624436 100644 --- a/tests/selection/test_selector_opts.py +++ b/tests/selection/test_selector_opts.py @@ -1,8 +1,25 @@ from __future__ import annotations +from pathlib import Path + from tests.fake_test_data import test_method_one +def _ran_files(out) -> list[str]: + """Arquivos de teste que apareceram como resultado no run (PASSED/FAILED).""" + return sorted( + { + line.split("::")[0] + for line in out.outlines + if "::" in line and ("PASSED" in line or "FAILED" in line) + } + ) + + +def _change(repo, filename: str, content: str) -> None: + (Path(repo.working_tree_dir) / filename).write_text(content) + + def test_regsmart_requires_git_repo(pytester): pytester.makepyfile(test_method_one=test_method_one) @@ -10,3 +27,92 @@ def test_regsmart_requires_git_repo(pytester): assert any("--regsmart requires a git repository." in x for x in out.errlines) assert not any("::" in x and "PASSED" in x for x in out.outlines) + + +def test_selection_no_rank_only_affected_in_collection_order(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=3) + assert _ran_files(out) == ["test_other.py", "test_service.py"] + assert any("Using --no-rank (RTP disabled)." in x for x in out.outlines) + + +def test_selection_with_rank_levels(selection_project): + pytester, repo = selection_project + pytester.runpytest("-v") + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + for level in ("put", "function", "module"): + out = pytester.runpytest("-v", "--regsmart", f"--rank-level={level}") + + out.assert_outcomes(passed=3) + assert _ran_files(out) == ["test_other.py", "test_service.py"] + + +def test_selection_with_replay_order(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + pytester.maketxtfile( + replay_order=""" + test_other.py::test_other_run + test_service.py::test_service_fast + test_service.py::test_service_slow + """ + ) + + out = pytester.runpytest("-v", "--regsmart", "--rank-replay=replay_order.txt") + + out.assert_outcomes(passed=3) + test_lines = [x for x in out.outlines if "::" in x and "PASSED" in x] + assert test_lines[0] == "test_other.py::test_other_run PASSED" + + +def test_selection_with_random_model(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest( + "-v", "--regsmart", "--rank-weight=0-0", "--rank-seed=42" + ) + + out.assert_outcomes(passed=3) + assert _ran_files(out) == ["test_other.py", "test_service.py"] + + +def test_seed_fix_change_test_file_selects_only_it(selection_project): + pytester, repo = selection_project + _change(repo, "test_service.py", "from service import run\n\n" + "def test_service_fast():\n assert run() == 42\n") + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=1) + assert _ran_files(out) == ["test_service.py"] + + +def test_no_diff_with_rank_runs_everything(selection_project): + pytester, _repo = selection_project + + out = pytester.runpytest("-v", "--regsmart") + + out.assert_outcomes(passed=4) + assert any( + "No diff detected: regression test selection was skipped." in x + for x in out.outlines + ) + assert any("Time to run the regression test selection (s)" in x for x in out.outlines) + + +def test_no_diff_with_no_rank_plugin_does_nothing(selection_project): + pytester, _repo = selection_project + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=4) + assert any( + "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." + in x for x in out.outlines + ) From 2d835844c13452da5da68a0612321d06f2e003a0 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 7 Aug 2026 00:07:37 -0300 Subject: [PATCH 055/122] fix: _find_py_files function more restrictive --- pytest_regsmart/selection/deps_graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_regsmart/selection/deps_graph.py b/pytest_regsmart/selection/deps_graph.py index 16049ed..3ee7718 100644 --- a/pytest_regsmart/selection/deps_graph.py +++ b/pytest_regsmart/selection/deps_graph.py @@ -18,7 +18,7 @@ class DependencyGraph: def _find_py_files(working_dir: str) -> list[str]: - excludes = [".venv", ".git", "__pycache__", "dist", "build"] + excludes = [".venv", ".git", "__pycache__", "dist", "build", "venv", "site-packages"] py_files = [] for dirpath, dirnames, filenames in os.walk(working_dir): From 76692d27e77e1278b328fbecfb43925ff7642de2 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 7 Aug 2026 00:25:33 -0300 Subject: [PATCH 056/122] docs: update changelog and README to include the latest RTS module details --- CHANGELOG.md | 11 +++++++++++ README.md | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae84d2..c590230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ ## pytest-regsmart +0.5.0 (2026-08-07) +---- + +* Add Regression Test Selection (RTS): a new `selection` module that computes the test files affected by the current changes and runs only them under `--regsmart` + * Detect changed files with git (modified + untracked) against the resolved default branch (origin/HEAD → main/master → active branch); repositories without commits fall back to untracked files only + * Build a module-level dependency graph with pyan3 and propagate changes transitively (BFS) to affected test files + * Raise a `UsageError` when `--regsmart` is used outside a git repository +* Refine `--no-rank`: it now only disables RTP while keeping RTS active, and cannot be combined with other `--rank-*` flags (raises a `UsageError`) +* Report the RTS configuration and timing in the terminal header/summary +* Add integration tests for the selection module and improve the test suite + 0.4.0 (2026-07-24) ---- diff --git a/README.md b/README.md index c5610f4..6cae493 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # pytest-regsmart -A Pytest plugin that implements Regression Test Prioritization (RTP) for faster regression fault detection. +A Pytest plugin that implements Regression Test Selection (RTS) and Regression Test Prioritization (RTP) for faster regression fault detection. This [pytest](https://github.com/pytest-dev/pytest) plugin allows you to find regression test failures faster and receive testing feedback sooner from CI build. -It speeds up the failure detection of your test suite by executing earlier the tests that are faster and recently failed. +When enabled with `--regsmart`, it first **selects** only the test files affected by the changes since the last baseline (RTS), then **prioritizes** them so that the tests that are faster or recently failed run earlier (RTP). ## Installation @@ -20,15 +20,18 @@ pip install pytest-regsmart ## Usage Pytest will automatically find the plugin and use it when you run ``pytest``. -You can run `pytest-regsmart` with its default configuration, which runs faster tests first by passing the ``--regsmart`` option: +You can run `pytest-regsmart` with its default configuration, which selects only the affected tests and runs faster tests first, by passing the ``--regsmart`` option: ```bash pytest --regsmart ``` +Note that `--regsmart` requires the project to be a `git` repository (the regression test selection is computed from the changes since the last baseline). Running it outside a git repo raises a `UsageError`. + Before the test run starts, if `--regsmart` is passed, the terminal header will report `pytest-regsmart`'s configuration of this run, for example: ``` +Starting Smart Regression Test Management (RTS + RTP) Using --rank-weight=1-0 Using --rank-level=put Using --rank-hist-len=50 @@ -40,10 +43,26 @@ After the test run finishes, the terminal summary will show the overhead of `pyt ``` =================================== pytest-regsmart summary info ==================================== -Time to reorder tests (s): 0.0003600120544433594 +Time to run the regression test selection (s): 0.0003604120544433594 +Time to reorder tests (s): 0.0004608631134033203 Time to collect test features (s): 0.0004608631134033203 ``` +## How Regression Test Selection (RTS) works + +When `--regsmart` is used, `pytest-regsmart` runs a Regression Test Selection step before the tests are executed: + +1. **Compute the changed files.** It uses git to inspect the working tree against the default branch: + - the default branch is resolved from `refs/remotes/origin/HEAD`, falling back to `main`/`master`, and finally to the currently active branch; + - it collects both modified (`staged` + `unstaged`) and untracked files. If the repository has no commits yet, only untracked files are considered. +2. **Build a dependency graph.** Using [`pyan3`](https://pypi.org/project/pyan3/), it parses every `*.py` file in the repository (excluding `.venv`, `venv`, `.git`, `__pycache__`, `dist`, `build`) and builds a module-level import graph. This graph is then inverted so that, for each module, it knows *which* other modules depend on it. +3. **Propagate changes transitively.** A BFS traversal starts from the changed and untracked files and walks through their dependents, collecting any test file (files named `test_*.py` or `*_test.py`) that is affected directly or indirectly. +4. **Filter the test suite.** Test items whose file is not in the selected set are removed from the run; only affected tests are actually executed. + +If there is no diff since the baseline, the selection is skipped (a warning is reported) and the full test suite runs. + +Because selection works at the **module/file level**, it is intentionally conservative: a change in one module selects every test file that transitively depends on it, which may include more tests than strictly necessary. Finer (e.g. function-level) granularity is planned as future work. + ### Disabling ranking (RTP) You can disable the regression test prioritization while keeping the plugin active with the `--no-rank` flag: @@ -52,7 +71,15 @@ You can disable the regression test prioritization while keeping the plugin acti pytest --regsmart --no-rank ``` -This is useful when you want to collect test data without reordering tests. +This is useful when you want to run only the selected tests (RTS) without reordering them (RTP). +With `--no-rank`, the header instead reports: + +``` +Starting RTS (Regression Test Selection) +Using --no-rank (RTP disabled). +``` + +Note that `--no-rank` **cannot be combined with other `--rank-*` flags**: passing any of them together raises a `UsageError`. It only disables the prioritization step; the regression test selection still applies. ### Optimizing test prioritization heuristics @@ -143,7 +170,7 @@ and run `pytest --regsmart` on the command line. ## Deployment (old) -`pytest-regsmart` is easy to deploy into CI workflow, please see [deployment](./docs/DEPLOYMENT.md). +`pytest-regsmart` is easy to deploy into CI workflow, please see [deployment](./DEPLOYMENT.md). ## Local development From 4f0427c55a3181b9528c28d3c45b76dfb4331b2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:30:30 +0000 Subject: [PATCH 057/122] chore(deps): bump gitpython from 3.1.57 to 3.1.58 Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.57 to 3.1.58. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.57...3.1.58) --- updated-dependencies: - dependency-name: gitpython dependency-version: 3.1.58 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 54 +++++++++++++++++++++++++------------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index deda5c9..624a294 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "pytest>=7.4.3", "numpy", "pyan3>=1.2.0", - "gitpython>=3.1.57", + "gitpython>=3.1.58", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/uv.lock b/uv.lock index a3f3127..b94547e 100644 --- a/uv.lock +++ b/uv.lock @@ -23,7 +23,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' or python_full_version >= '3.11'" }, { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } @@ -45,16 +45,16 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.57" +version = "3.1.58" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/d6/5f358ff283325580c2003a6d953aea18cfe10ae87b46f5ebc80fa3a386dc/gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22", size = 228498, upload-time = "2026-08-04T15:05:49.47Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f", size = 220183, upload-time = "2026-08-04T15:05:48.025Z" }, ] [[package]] @@ -599,7 +599,7 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/8e/340533f6c084a53705527735e7db521756a10b297841671ee4042c5b281a/pyan3-1.2.0.tar.gz", hash = "sha256:36b49b678820e882de7271fb8a18789d57cd1e284f0314bdecd587f02c049a19", size = 49721, upload-time = "2021-02-11T13:22:25.838Z" } wheels = [ @@ -616,7 +616,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "jinja2", marker = "python_full_version >= '3.10'" }, + { name = "jinja2" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/f4/0fc2023b7971af8ab00e261ed59916be93ff7ae745f9d4e91de40cbf85ff/pyan3-2.6.2.tar.gz", hash = "sha256:ecb63d32ea28ec10caf22aad11310022a341c9bf5e8b9301e9a64ed3fe2edc91", size = 101955, upload-time = "2026-07-14T08:20:53.698Z" } wheels = [ @@ -640,12 +640,12 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } wheels = [ @@ -660,13 +660,13 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "packaging", marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pygments", marker = "python_full_version == '3.9.*'" }, - { name = "tomli", marker = "python_full_version == '3.9.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pygments" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -683,13 +683,13 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -716,7 +716,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "gitpython", specifier = ">=3.1.57" }, + { name = "gitpython", specifier = ">=3.1.58" }, { name = "numpy" }, { name = "pyan3", specifier = ">=1.2.0" }, { name = "pytest", specifier = ">=7.4.3" }, From 483590b9c9b18e7aecadec63ef93b247a88d3ca9 Mon Sep 17 00:00:00 2001 From: let Date: Mon, 10 Aug 2026 22:41:33 -0300 Subject: [PATCH 058/122] chore: drop support for python 3.8 and 3.9 --- .github/workflows/ci.yml | 2 +- pyproject.toml | 6 +- tox.ini | 2 +- uv.lock | 333 ++------------------------------------- 4 files changed, 20 insertions(+), 323 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33d7dab..981079e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index deda5c9..de55ed8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [ maintainers = [ { name = "leticosta4", email = "leticiacostaoa@gmail.com" }, ] -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = [ "pytest>=7.4.3", "numpy", @@ -29,8 +29,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -55,7 +53,7 @@ pytest_regsmart = "pytest_regsmart.plugin" [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py310" [tool.ruff.lint] extend-select = ["UP", "B"] diff --git a/tox.ini b/tox.ini index 2a946e3..66c5e8c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] requires = tox-uv -envlist = py38,py39,py310,py311,py312 +envlist = py310,py311,py312 [testenv] installer = uv diff --git a/uv.lock b/uv.lock index a3f3127..5e6e427 100644 --- a/uv.lock +++ b/uv.lock @@ -1,12 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.8" +requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", + "python_full_version < '3.11'", ] [[package]] @@ -23,8 +21,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -49,36 +46,16 @@ version = "3.1.57" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, ] -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -89,85 +66,17 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "markupsafe" -version = "2.1.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384, upload-time = "2024-02-02T16:31:22.863Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/54/ad5eb37bf9d51800010a74e4665425831a9db4e7c4e0fde4352e391e808e/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", size = 18206, upload-time = "2024-02-02T16:30:04.105Z" }, - { url = "https://files.pythonhosted.org/packages/6a/4a/a4d49415e600bacae038c67f9fecc1d5433b9d3c71a4de6f33537b89654c/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", size = 14079, upload-time = "2024-02-02T16:30:06.5Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7b/85681ae3c33c385b10ac0f8dd025c30af83c78cec1c37a6aa3b55e67f5ec/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", size = 26620, upload-time = "2024-02-02T16:30:08.31Z" }, - { url = "https://files.pythonhosted.org/packages/7c/52/2b1b570f6b8b803cef5ac28fdf78c0da318916c7d2fe9402a84d591b394c/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", size = 25818, upload-time = "2024-02-02T16:30:09.577Z" }, - { url = "https://files.pythonhosted.org/packages/29/fe/a36ba8c7ca55621620b2d7c585313efd10729e63ef81e4e61f52330da781/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", size = 25493, upload-time = "2024-02-02T16:30:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/60/ae/9c60231cdfda003434e8bd27282b1f4e197ad5a710c14bee8bea8a9ca4f0/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", size = 30630, upload-time = "2024-02-02T16:30:13.144Z" }, - { url = "https://files.pythonhosted.org/packages/65/dc/1510be4d179869f5dafe071aecb3f1f41b45d37c02329dfba01ff59e5ac5/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", size = 29745, upload-time = "2024-02-02T16:30:14.222Z" }, - { url = "https://files.pythonhosted.org/packages/30/39/8d845dd7d0b0613d86e0ef89549bfb5f61ed781f59af45fc96496e897f3a/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", size = 30021, upload-time = "2024-02-02T16:30:16.032Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5c/356a6f62e4f3c5fbf2602b4771376af22a3b16efa74eb8716fb4e328e01e/MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", size = 16659, upload-time = "2024-02-02T16:30:17.079Z" }, - { url = "https://files.pythonhosted.org/packages/69/48/acbf292615c65f0604a0c6fc402ce6d8c991276e16c80c46a8f758fbd30c/MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", size = 17213, upload-time = "2024-02-02T16:30:18.251Z" }, - { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219, upload-time = "2024-02-02T16:30:19.988Z" }, - { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098, upload-time = "2024-02-02T16:30:21.063Z" }, - { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014, upload-time = "2024-02-02T16:30:22.926Z" }, - { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220, upload-time = "2024-02-02T16:30:24.76Z" }, - { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756, upload-time = "2024-02-02T16:30:25.877Z" }, - { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988, upload-time = "2024-02-02T16:30:26.935Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718, upload-time = "2024-02-02T16:30:28.111Z" }, - { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317, upload-time = "2024-02-02T16:30:29.214Z" }, - { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670, upload-time = "2024-02-02T16:30:30.915Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224, upload-time = "2024-02-02T16:30:32.09Z" }, - { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215, upload-time = "2024-02-02T16:30:33.081Z" }, - { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069, upload-time = "2024-02-02T16:30:34.148Z" }, - { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452, upload-time = "2024-02-02T16:30:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462, upload-time = "2024-02-02T16:30:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869, upload-time = "2024-02-02T16:30:37.834Z" }, - { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906, upload-time = "2024-02-02T16:30:39.366Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296, upload-time = "2024-02-02T16:30:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038, upload-time = "2024-02-02T16:30:42.243Z" }, - { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572, upload-time = "2024-02-02T16:30:43.326Z" }, - { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127, upload-time = "2024-02-02T16:30:44.418Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ff/2c942a82c35a49df5de3a630ce0a8456ac2969691b230e530ac12314364c/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", size = 18192, upload-time = "2024-02-02T16:30:57.715Z" }, - { url = "https://files.pythonhosted.org/packages/4f/14/6f294b9c4f969d0c801a4615e221c1e084722ea6114ab2114189c5b8cbe0/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", size = 14072, upload-time = "2024-02-02T16:30:58.844Z" }, - { url = "https://files.pythonhosted.org/packages/81/d4/fd74714ed30a1dedd0b82427c02fa4deec64f173831ec716da11c51a50aa/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", size = 26928, upload-time = "2024-02-02T16:30:59.922Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bd/50319665ce81bb10e90d1cf76f9e1aa269ea6f7fa30ab4521f14d122a3df/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", size = 26106, upload-time = "2024-02-02T16:31:01.582Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6f/f2b0f675635b05f6afd5ea03c094557bdb8622fa8e673387444fe8d8e787/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68", size = 25781, upload-time = "2024-02-02T16:31:02.71Z" }, - { url = "https://files.pythonhosted.org/packages/51/e0/393467cf899b34a9d3678e78961c2c8cdf49fb902a959ba54ece01273fb1/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", size = 30518, upload-time = "2024-02-02T16:31:04.392Z" }, - { url = "https://files.pythonhosted.org/packages/f6/02/5437e2ad33047290dafced9df741d9efc3e716b75583bbd73a9984f1b6f7/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", size = 29669, upload-time = "2024-02-02T16:31:05.53Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7d/968284145ffd9d726183ed6237c77938c021abacde4e073020f920e060b2/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", size = 29933, upload-time = "2024-02-02T16:31:06.636Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f3/ecb00fc8ab02b7beae8699f34db9357ae49d9f21d4d3de6f305f34fa949e/MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", size = 16656, upload-time = "2024-02-02T16:31:07.767Z" }, - { url = "https://files.pythonhosted.org/packages/92/21/357205f03514a49b293e214ac39de01fadd0970a6e05e4bf1ddd0ffd0881/MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", size = 17206, upload-time = "2024-02-02T16:31:08.843Z" }, - { url = "https://files.pythonhosted.org/packages/0f/31/780bb297db036ba7b7bbede5e1d7f1e14d704ad4beb3ce53fb495d22bc62/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", size = 18193, upload-time = "2024-02-02T16:31:10.155Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d77701bbef72892affe060cdacb7a2ed7fd68dae3b477a8642f15ad3b132/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", size = 14073, upload-time = "2024-02-02T16:31:11.442Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a7/1e558b4f78454c8a3a0199292d96159eb4d091f983bc35ef258314fe7269/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", size = 26486, upload-time = "2024-02-02T16:31:12.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5a/360da85076688755ea0cceb92472923086993e86b5613bbae9fbc14136b0/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", size = 25685, upload-time = "2024-02-02T16:31:13.726Z" }, - { url = "https://files.pythonhosted.org/packages/6a/18/ae5a258e3401f9b8312f92b028c54d7026a97ec3ab20bfaddbdfa7d8cce8/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", size = 25338, upload-time = "2024-02-02T16:31:14.812Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cc/48206bd61c5b9d0129f4d75243b156929b04c94c09041321456fd06a876d/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", size = 30439, upload-time = "2024-02-02T16:31:15.946Z" }, - { url = "https://files.pythonhosted.org/packages/d1/06/a41c112ab9ffdeeb5f77bc3e331fdadf97fa65e52e44ba31880f4e7f983c/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", size = 29531, upload-time = "2024-02-02T16:31:17.13Z" }, - { url = "https://files.pythonhosted.org/packages/02/8c/ab9a463301a50dab04d5472e998acbd4080597abc048166ded5c7aa768c8/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", size = 29823, upload-time = "2024-02-02T16:31:18.247Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/9bc18da763496b055d8e98ce476c8e718dcfd78157e17f555ce6dd7d0895/MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", size = 16658, upload-time = "2024-02-02T16:31:19.583Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f8/4da07de16f10551ca1f640c92b5f316f9394088b183c6a57183df6de5ae4/MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", size = 17211, upload-time = "2024-02-02T16:31:20.96Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version == '3.9.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, @@ -247,110 +156,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, - { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, - { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, - { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, - { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, - { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, -] - -[[package]] -name = "numpy" -version = "1.24.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/a4/9b/027bec52c633f6556dba6b722d9a0befb40498b9ceddd29cbe67a45a127c/numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463", size = 10911229, upload-time = "2023-06-26T13:39:33.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/80/6cdfb3e275d95155a34659163b83c09e3a3ff9f1456880bec6cc63d71083/numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64", size = 19789140, upload-time = "2023-06-26T13:22:33.184Z" }, - { url = "https://files.pythonhosted.org/packages/64/5f/3f01d753e2175cfade1013eea08db99ba1ee4bdb147ebcf3623b75d12aa7/numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1", size = 13854297, upload-time = "2023-06-26T13:22:59.541Z" }, - { url = "https://files.pythonhosted.org/packages/5a/b3/2f9c21d799fa07053ffa151faccdceeb69beec5a010576b8991f614021f7/numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4", size = 13995611, upload-time = "2023-06-26T13:23:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/10/be/ae5bf4737cb79ba437879915791f6f26d92583c738d7d960ad94e5c36adf/numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6", size = 17282357, upload-time = "2023-06-26T13:23:51.446Z" }, - { url = "https://files.pythonhosted.org/packages/c0/64/908c1087be6285f40e4b3e79454552a701664a079321cff519d8c7051d06/numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc", size = 12429222, upload-time = "2023-06-26T13:24:13.849Z" }, - { url = "https://files.pythonhosted.org/packages/22/55/3d5a7c1142e0d9329ad27cece17933b0e2ab4e54ddc5c1861fbfeb3f7693/numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e", size = 14841514, upload-time = "2023-06-26T13:24:38.129Z" }, - { url = "https://files.pythonhosted.org/packages/a9/cc/5ed2280a27e5dab12994c884f1f4d8c3bd4d885d02ae9e52a9d213a6a5e2/numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810", size = 19775508, upload-time = "2023-06-26T13:25:08.882Z" }, - { url = "https://files.pythonhosted.org/packages/c0/bc/77635c657a3668cf652806210b8662e1aff84b818a55ba88257abf6637a8/numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254", size = 13840033, upload-time = "2023-06-26T13:25:33.417Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4c/96cdaa34f54c05e97c1c50f39f98d608f96f0677a6589e64e53104e22904/numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7", size = 13991951, upload-time = "2023-06-26T13:25:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/22/97/dfb1a31bb46686f09e68ea6ac5c63fdee0d22d7b23b8f3f7ea07712869ef/numpy-1.24.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7215847ce88a85ce39baf9e89070cb860c98fdddacbaa6c0da3ffb31b3350bd5", size = 17278923, upload-time = "2023-06-26T13:26:25.658Z" }, - { url = "https://files.pythonhosted.org/packages/35/e2/76a11e54139654a324d107da1d98f99e7aa2a7ef97cfd7c631fba7dbde71/numpy-1.24.4-cp311-cp311-win32.whl", hash = "sha256:4979217d7de511a8d57f4b4b5b2b965f707768440c17cb70fbf254c4b225238d", size = 12422446, upload-time = "2023-06-26T13:26:49.302Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ec/ebef2f7d7c28503f958f0f8b992e7ce606fb74f9e891199329d5f5f87404/numpy-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b7b1fc9864d7d39e28f41d089bfd6353cb5f27ecd9905348c24187a768c79694", size = 14834466, upload-time = "2023-06-26T13:27:16.029Z" }, - { url = "https://files.pythonhosted.org/packages/11/10/943cfb579f1a02909ff96464c69893b1d25be3731b5d3652c2e0cf1281ea/numpy-1.24.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1452241c290f3e2a312c137a9999cdbf63f78864d63c79039bda65ee86943f61", size = 19780722, upload-time = "2023-06-26T13:27:49.573Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ae/f53b7b265fdc701e663fbb322a8e9d4b14d9cb7b2385f45ddfabfc4327e4/numpy-1.24.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f", size = 13843102, upload-time = "2023-06-26T13:28:12.288Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/2586a50ad72e8dbb1d8381f837008a0321a3516dfd7cb57fc8cf7e4bb06b/numpy-1.24.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5425b114831d1e77e4b5d812b69d11d962e104095a5b9c3b641a218abcc050e", size = 14039616, upload-time = "2023-06-26T13:28:35.659Z" }, - { url = "https://files.pythonhosted.org/packages/98/5d/5738903efe0ecb73e51eb44feafba32bdba2081263d40c5043568ff60faf/numpy-1.24.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd80e219fd4c71fc3699fc1dadac5dcf4fd882bfc6f7ec53d30fa197b8ee22dc", size = 17316263, upload-time = "2023-06-26T13:29:09.272Z" }, - { url = "https://files.pythonhosted.org/packages/d1/57/8d328f0b91c733aa9aa7ee540dbc49b58796c862b4fbcb1146c701e888da/numpy-1.24.4-cp38-cp38-win32.whl", hash = "sha256:4602244f345453db537be5314d3983dbf5834a9701b7723ec28923e2889e0bb2", size = 12455660, upload-time = "2023-06-26T13:29:33.434Z" }, - { url = "https://files.pythonhosted.org/packages/69/65/0d47953afa0ad569d12de5f65d964321c208492064c38fe3b0b9744f8d44/numpy-1.24.4-cp38-cp38-win_amd64.whl", hash = "sha256:692f2e0f55794943c5bfff12b3f56f99af76f902fc47487bdfe97856de51a706", size = 14868112, upload-time = "2023-06-26T13:29:58.385Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/d5b0402b801c8a8b56b04c1e85c6165efab298d2f0ab741c2406516ede3a/numpy-1.24.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2541312fbf09977f3b3ad449c4e5f4bb55d0dbf79226d7724211acc905049400", size = 19816549, upload-time = "2023-06-26T13:30:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/638aaa446f39113a3ed38b37a66243e21b38110d021bfcb940c383e120f2/numpy-1.24.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9667575fb6d13c95f1b36aca12c5ee3356bf001b714fc354eb5465ce1609e62f", size = 13879950, upload-time = "2023-06-26T13:31:01.787Z" }, - { url = "https://files.pythonhosted.org/packages/8f/27/91894916e50627476cff1a4e4363ab6179d01077d71b9afed41d9e1f18bf/numpy-1.24.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a86ed21e4f87050382c7bc96571755193c4c1392490744ac73d660e8f564a9", size = 14030228, upload-time = "2023-06-26T13:31:26.696Z" }, - { url = "https://files.pythonhosted.org/packages/7a/7c/d7b2a0417af6428440c0ad7cb9799073e507b1a465f827d058b826236964/numpy-1.24.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11efb4dbecbdf22508d55e48d9c8384db795e1b7b51ea735289ff96613ff74d", size = 17311170, upload-time = "2023-06-26T13:31:56.615Z" }, - { url = "https://files.pythonhosted.org/packages/18/9d/e02ace5d7dfccee796c37b995c63322674daf88ae2f4a4724c5dd0afcc91/numpy-1.24.4-cp39-cp39-win32.whl", hash = "sha256:6620c0acd41dbcb368610bb2f4d83145674040025e5536954782467100aa8835", size = 12454918, upload-time = "2023-06-26T13:32:16.8Z" }, - { url = "https://files.pythonhosted.org/packages/63/38/6cc19d6b8bfa1d1a459daf2b3fe325453153ca7019976274b6f33d8b5663/numpy-1.24.4-cp39-cp39-win_amd64.whl", hash = "sha256:befe2bf740fd8373cf56149a5c23a0f601e82869598d41f8e188a0e9869926f8", size = 14867441, upload-time = "2023-06-26T13:32:40.521Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fd/8dff40e25e937c94257455c237b9b6bf5a30d42dd1cc11555533be099492/numpy-1.24.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31f13e25b4e304632a4619d0e0777662c2ffea99fcae2029556b17d8ff958aef", size = 19156590, upload-time = "2023-06-26T13:33:10.36Z" }, - { url = "https://files.pythonhosted.org/packages/42/e7/4bf953c6e05df90c6d351af69966384fed8e988d0e8c54dad7103b59f3ba/numpy-1.24.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7ac6540e95bc440ad77f56e520da5bf877f87dca58bd095288dce8940532a", size = 16705744, upload-time = "2023-06-26T13:33:36.703Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dd/9106005eb477d022b60b3817ed5937a43dad8fd1f20b0610ea8a32fcb407/numpy-1.24.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e98f220aa76ca2a977fe435f5b04d7b3470c0a2e6312907b37ba6068f26787f2", size = 14734290, upload-time = "2023-06-26T13:34:05.409Z" }, -] - -[[package]] -name = "numpy" -version = "2.0.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, - { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, - { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, - { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, - { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, - { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, - { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, - { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, - { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, - { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, - { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, - { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, - { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, - { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, - { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, - { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, - { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, - { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, - { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, - { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, - { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, ] [[package]] @@ -358,7 +163,7 @@ name = "numpy" version = "2.2.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ @@ -563,60 +368,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "pluggy" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version == '3.9.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "pyan3" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] -dependencies = [ - { name = "jinja2", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/8e/340533f6c084a53705527735e7db521756a10b297841671ee4042c5b281a/pyan3-1.2.0.tar.gz", hash = "sha256:36b49b678820e882de7271fb8a18789d57cd1e284f0314bdecd587f02c049a19", size = 49721, upload-time = "2021-02-11T13:22:25.838Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/f8/73460b7ac0650feec0601c3c24fd1ced4d986eecc447236464ae487fab10/pyan3-1.2.0-py3-none-any.whl", hash = "sha256:89b4d7b3003b794fb9b041b08da91bcbd1dde4c82ec4af039b1e6b8f418efab1", size = 50561, upload-time = "2021-02-11T13:22:23.898Z" }, -] - [[package]] name = "pyan3" version = "2.6.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "jinja2", marker = "python_full_version >= '3.10'" }, + { name = "jinja2" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/f4/0fc2023b7971af8ab00e261ed59916be93ff7ae745f9d4e91de40cbf85ff/pyan3-2.6.2.tar.gz", hash = "sha256:ecb63d32ea28ec10caf22aad11310022a341c9bf5e8b9301e9a64ed3fe2edc91", size = 101955, upload-time = "2026-07-14T08:20:53.698Z" } wheels = [ @@ -632,64 +398,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pytest" -version = "8.3.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "packaging", marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pygments", marker = "python_full_version == '3.9.*'" }, - { name = "tomli", marker = "python_full_version == '3.9.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - [[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -702,16 +422,11 @@ version = "0.3.4" source = { editable = "." } dependencies = [ { name = "gitpython" }, - { name = "numpy", version = "1.24.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pyan3", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pyan3", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyan3" }, + { name = "pytest" }, ] [package.metadata] @@ -785,26 +500,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] -[[package]] -name = "typing-extensions" -version = "4.13.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.10.*'", - "python_full_version == '3.9.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, From c5410f23b2625e69d6e06098526d2a31dbb01b7a Mon Sep 17 00:00:00 2001 From: let Date: Mon, 10 Aug 2026 22:48:12 -0300 Subject: [PATCH 059/122] chore: bump minimum pytest version to 9.0.3 (CVE-2025-71176) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de55ed8..d3445ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ maintainers = [ ] requires-python = ">=3.10" dependencies = [ - "pytest>=7.4.3", + "pytest>=9.0.3", "numpy", "pyan3>=1.2.0", "gitpython>=3.1.57", diff --git a/uv.lock b/uv.lock index 5e6e427..0cf3753 100644 --- a/uv.lock +++ b/uv.lock @@ -434,7 +434,7 @@ requires-dist = [ { name = "gitpython", specifier = ">=3.1.57" }, { name = "numpy" }, { name = "pyan3", specifier = ">=1.2.0" }, - { name = "pytest", specifier = ">=7.4.3" }, + { name = "pytest", specifier = ">=9.0.3" }, ] [[package]] From 5d999ce35c766b320f498283cac4b3f71ed939eb Mon Sep 17 00:00:00 2001 From: let Date: Mon, 10 Aug 2026 23:27:48 -0300 Subject: [PATCH 060/122] docs: update changelog with latest chore bumps --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c590230..fbf83d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## pytest-regsmart +0.6.0 (2026-08-10) +---- + +* Drop support for python 3.8 and 3.9 +* Bump pytest from version 7.4.3 to 9.0.3 +* Bump gitpython from 3.1.57 to 3.1.58 + + 0.5.0 (2026-08-07) ---- From cb54b8786d68022a5677bbfc89d62bfd41ae5819 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 11 Aug 2026 00:24:31 -0300 Subject: [PATCH 061/122] style: translate some comments and docstrgins from pt-br to english --- pytest_regsmart/help_strings.py | 4 ++-- pytest_regsmart/monitor.py | 2 +- pytest_regsmart/plugin.py | 6 ++--- pytest_regsmart/ranking/ranker.py | 2 +- pytest_regsmart/selection/deps_graph.py | 30 ++++++++++++------------ pytest_regsmart/selection/git_manager.py | 16 ++++++------- pytest_regsmart/selection/selector.py | 14 +++++------ tests/conftest.py | 8 +++---- 8 files changed, 41 insertions(+), 41 deletions(-) diff --git a/pytest_regsmart/help_strings.py b/pytest_regsmart/help_strings.py index bc16732..678bbfe 100644 --- a/pytest_regsmart/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -37,9 +37,9 @@ Provide a text file where each line is a test ID. pytest-regsmart will run tests with the order defined in the file. Default value is None. -""") ## ver ainda o que fazer sobre isso +""") ## gotta see what to do about this NO_RANK_HELP = textwrap.dedent(""" Boolean flag to disable ranking option (RTP); cannot be used with other ranking flags. When selected, tests will run in pytest default order. -""") #depois preciso adaptar esse final pra usar a RTS +""") #need to adapt this ending to use the RTS later diff --git a/pytest_regsmart/monitor.py b/pytest_regsmart/monitor.py index 1c2aa47..a355cce 100644 --- a/pytest_regsmart/monitor.py +++ b/pytest_regsmart/monitor.py @@ -4,7 +4,7 @@ class Monitor: - """Coleta resultados de execução dos testes.""" + """Collects test run results.""" def __init__(self) -> None: self.test_reports: list[TestReport] = [] diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 0e71ca8..d5dff08 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -37,7 +37,7 @@ def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("regsmart", "pytest-regsmart") group._addoption( - "--regsmart", #era o antigo --rank que ativava o default basico do pytest-ranking + "--regsmart", #was the old --rank flag that activated the pytest-ranking default action="store_true", help=PLUGIN_HELP) @@ -46,7 +46,7 @@ def pytest_addoption(parser: Parser) -> None: action="store_true", default=False, help=NO_RANK_HELP, - dest="no_rank") #talvez nao precise + dest="no_rank") #maybe unecessary group._addoption( "--rank-level", @@ -118,7 +118,7 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: return if self.replay_file and self.weights == [0, 0]: - raise argparse.ArgumentTypeError( #ou usage error + raise argparse.ArgumentTypeError( #or usage error "--rank-replay cannot be used together with random order." ) diff --git a/pytest_regsmart/ranking/ranker.py b/pytest_regsmart/ranking/ranker.py index 143ff34..2f68cae 100644 --- a/pytest_regsmart/ranking/ranker.py +++ b/pytest_regsmart/ranking/ranker.py @@ -62,7 +62,7 @@ def run_rtp( log_dict: dict, load_feature_fn, ) -> None: - """Orquestra o ranqueamento: replay, random, ou híbrido.""" + """Orchestrates the ranking: replay, random, or hybrid.""" init_order = {item.nodeid: i for i, item in enumerate(items)} start_time = time.time() diff --git a/pytest_regsmart/selection/deps_graph.py b/pytest_regsmart/selection/deps_graph.py index 3ee7718..c731856 100644 --- a/pytest_regsmart/selection/deps_graph.py +++ b/pytest_regsmart/selection/deps_graph.py @@ -9,12 +9,12 @@ from .git_manager import resolve_repo -#inicialmente tenho que tentar gerar a forma mais simples só com os arquivos, nao precisa das funções -#vou usar a API do pyan3, não o CLI +#initially I'll try the simplest form using only files; functions are not needed yet +#use the pyan3 API, not the CLI @dataclass class DependencyGraph: - dependents: dict[str, set[str]] # chave: módulo, valor: conjunto de módulos que dependem/sao afetados por ele + dependents: dict[str, set[str]] #key: module, value: set of modules that depend on / are affected by it def _find_py_files(working_dir: str) -> list[str]: @@ -22,17 +22,17 @@ def _find_py_files(working_dir: str) -> list[str]: py_files = [] for dirpath, dirnames, filenames in os.walk(working_dir): - dirnames[:] = [d for d in dirnames if d not in excludes and not d.endswith(".egg-info")] #nova lista de diretorios só dos arquivos core para checar + dirnames[:] = [d for d in dirnames if d not in excludes and not d.endswith(".egg-info")] # keep only core directories to check for filename in filenames: if filename.endswith(".py"): - py_files.append(os.path.join(dirpath, filename)) #monta o caminho completo + py_files.append(os.path.join(dirpath, filename)) #build the full path return py_files def _build_module_relative_path(fullpaths, working_dir: str) -> dict[str, str]: - """Converte nomes de módulo do pyan3 pra caminhos relativos ao repo (match com o diff). + """Convert pyan3 module names to paths relative to the repo (to match the diff). - Ex: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" + E.g.: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" """ return { module: os.path.relpath(fullpath, working_dir) @@ -44,20 +44,20 @@ def _invert_dependency_graph( module_imports: dict[str, set[str]], module_to_path: dict[str, str] ) -> DependencyGraph: - """Conversao de 'modulo importa X' para 'arquivo X é usado por módulo'. + """Invert 'module imports X' into 'file X is used by module'. - Ex: se plugin.py importa selector.py, o retorno tem - selector.py -> {plugin.py} (selector.py afeta plugin.py se mudar) + E.g.: if plugin.py imports selector.py, the result contains + selector.py -> {plugin.py} (changing selector.py affects plugin.py) """ - dependents = defaultdict(set) #versão de dict que não dá KeyError quando acessa uma chave que ainda não existe; subclasssed e dict: https://www.geeksforgeeks.org/python/defaultdict-in-python/ + dependents = defaultdict(set) #dict that does not raise KeyError on missing keys; a dict subclass: https://www.geeksforgeeks.org/python/defaultdict-in-python/ for importer, imported_modules in module_imports.items(): importer_path = module_to_path.get(importer) for imported in imported_modules: imported_path = module_to_path.get(imported) if imported_path is None: - continue # nao importa arquivo no repo + continue #doesnt import a file in the repo dependents[imported_path].add(importer_path) @@ -70,10 +70,10 @@ def get_dependency_graph(repo_path: str = ".") -> DependencyGraph: python_files = _find_py_files(working_dir) graph = ImportVisitor(filenames=python_files, logger=logging.getLogger(__name__), root=working_dir) - #graph.modules: módulo -> set de módulos importados por ele - #graph.fullpaths: módulo -> caminho absoluto do arquivo + #graph.modules: module -> set of modules imported by it + #graph.fullpaths: module -> absolute file path module_to_path = _build_module_relative_path(graph.fullpaths, working_dir) - dependents_by_file = _invert_dependency_graph(graph.modules, module_to_path) #quero inverter pra ficar modulo -> quem IMPORTA esse modulo / quem é afetado por ele + dependents_by_file = _invert_dependency_graph(graph.modules, module_to_path) # invert so it becomes module -> who IMPORTS it / who is affected by it return dependents_by_file diff --git a/pytest_regsmart/selection/git_manager.py b/pytest_regsmart/selection/git_manager.py index dc9d7aa..266cdcc 100644 --- a/pytest_regsmart/selection/git_manager.py +++ b/pytest_regsmart/selection/git_manager.py @@ -9,8 +9,8 @@ @dataclass class DiffResult: modified_files: list[str] #staged+unstaged - untracked_files: list[str] #completamente novo - talvez colocar uma flag futura para comparar tentar buscar só testes do unstages+untracked - #depois ainda quero pegar a diff mais granular, talvez por linha ou conteudo de forma semantica?? n sei + untracked_files: list[str] #brand new files - maybe add a future flag to look only at unstaged+untracked tests + #later I also want a more granular diff, maybe per line or by semantic content?? idk def resolve_repo(repo_path: str = ".") -> Repo: @@ -26,14 +26,14 @@ def verify_git_repo(repo_path: str = ".") -> bool: def get_default_repo_branch(repo: Repo) -> str: - try: #pra quando o repo tem remote (origin/HEAD) + try: #for when the repo has a remote (origin/HEAD) symbolic_ref = repo.git.symbolic_ref('refs/remotes/origin/HEAD') - return symbolic_ref.split('/')[-1] #a ultima parte da ref é o nome da branch + return symbolic_ref.split('/')[-1] #the last part of the ref is the branch name except GitCommandError: pass - branch_names = [branch.name for branch in repo.branches] #se nao tiver vai tentar buscar main/master + branch_names = [branch.name for branch in repo.branches] #fall back to main/master if not found for candidate in ("main", "master"): if candidate in branch_names: return candidate @@ -42,20 +42,20 @@ def get_default_repo_branch(repo: Repo) -> str: return repo.active_branch.name except TypeError: # detached head raise ValueError( - "Unable to determine the default branch." #sem origin/HEAD, sem main/master e sem branch ativa + "Unable to determine the default branch." #no origin/HEAD, no main/master, no active branch ) from None def get_git_diff(repo_path: str = ".") -> DiffResult: repo = resolve_repo(repo_path=repo_path) - if not repo.head.is_valid(): #se o repo nao tem commits (mas tem git init), vou só checar o diff untracked + if not repo.head.is_valid(): #repo has no commits yet (just git init); only check the untracked diff return DiffResult( modified_files=[], untracked_files=repo.untracked_files ) - default_branch = get_default_repo_branch(repo) #talvez eu deixe isso manualmente configuravel via flag depois + default_branch = get_default_repo_branch(repo) #maybe make this configurable via a flag later merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index 33f16e9..3a5bb42 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -19,9 +19,9 @@ def _is_test_file(filepath: str) -> bool: def get_affected_tests(diff_result: DiffResult, deps_graph: DependencyGraph) -> list[str]: - #para cada arquivo modificado no diff vai procurar a ref dele no grafo de dependencia e checar o valor relacioando (dependentes) - #dentro dos dependentes vai filtrar só os que são testes (talvez por regex ou por heuristica de nome de arquivo) - #eu quero pegar testes imapctados indiretamente tb, nao é só o dependente direto + #for each file modified in the diff, look up its node in the dependency graph and get the related value (dependents) + #loooking the dependents, keep only those that are tests (maybe via regex or file-name heuristic) + #i also want indirectly affected tests, not just direct dependents changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) affected_tests = sorted(f for f in changed_files if _is_test_file(f)) @@ -36,8 +36,8 @@ def get_affected_tests(diff_result: DiffResult, deps_graph: DependencyGraph) -> if dependent in seen: continue - seen.add(dependent) #seen = o dependente foi descoberto, mas ainda sera visitadoe e - to_check.append(dependent) #caso nao seja um teste ainda quero checar os dependentes pra ver o que pode estar sendo afetado indiretamente + seen.add(dependent) #seen = dependent was discovered and will be visited + to_check.append(dependent) #even if not a test, check its dependents for indirect affected tests if _is_test_file(dependent): affected_tests.append(dependent) @@ -46,10 +46,10 @@ def get_affected_tests(diff_result: DiffResult, deps_graph: DependencyGraph) -> def run_rts() -> SelectionResult: - """[wip] Orquestra a seleção...""" + """[wip] Orchestrates the selection...""" diff_result = get_git_diff() - deps_graph = get_dependency_graph() #talvez vou paralelizar isso com o git diff mais pra frente + deps_graph = get_dependency_graph() # maybe parallelize this with the git diff later return SelectionResult( affected_tests=get_affected_tests(diff_result, deps_graph), diff --git a/tests/conftest.py b/tests/conftest.py index 42c3365..7db703c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,11 +52,11 @@ def mytester(pytester): @pytest.fixture def selection_project(pytester): - """Repo git com módulo de produção + testes que o importam, tudo commitado. + """Git repo with production module + tests that import it, all committed. - Permite exercitar a seleção de verdade: o teste altera `service.py` - (produção) para gerar um diff e selecionar só os testes dependentes. - Retorna (Path do projeto, Repo). + Tests real selection: the test changes `service.py` + (production) to produce a diff and select only the dependent tests cases. + Returns (project Path, Repo). """ pytester.makefile( ".ini", From 61185cd86b417d7d1b3c9ac8637c4b053e4a9ea7 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 11 Aug 2026 00:53:37 -0300 Subject: [PATCH 062/122] feat: add branch tracking to diff detection and reporting --- pytest_regsmart/plugin.py | 6 +++++- pytest_regsmart/reporter.py | 4 ++++ pytest_regsmart/selection/git_manager.py | 7 +++++-- pytest_regsmart/selection/selector.py | 2 ++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index d5dff08..0b06e97 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -104,6 +104,7 @@ def __init__(self, config: Config) -> None: self.warnings: list[str] = [] self.monitor = Monitor() + self.branch = "" # will be set after selection self.no_rank = rank_args.parse_no_rank(config) self.weights = rank_args.parse_rtp_weights(config) self.level = rank_args.parse_rtp_level(config) @@ -124,6 +125,7 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: selection_start_time = time.time() selection = selector.run_rts() + self.branch = selection.branch self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time if selection.affected_tests: @@ -173,7 +175,9 @@ def pytest_terminal_summary( exitstatus: int, config: Config) -> None: if self.config.getoption("--regsmart"): - reporter_mod.pytest_terminal_summary(terminalreporter, self.log, self.warnings) + reporter_mod.pytest_terminal_summary( + terminalreporter, self.log, self.warnings, branch=self.branch, + ) @pytest.hookimpl(trylast=True) diff --git a/pytest_regsmart/reporter.py b/pytest_regsmart/reporter.py index e75d330..c2607c4 100644 --- a/pytest_regsmart/reporter.py +++ b/pytest_regsmart/reporter.py @@ -6,6 +6,7 @@ def pytest_report_header(config: Config) -> str | None: """Report plugin configurations before test session starts.""" + if not config.getoption("--regsmart"): return None @@ -35,10 +36,13 @@ def pytest_terminal_summary( terminalreporter: TerminalReporter, log_dict: dict, warnings: tuple[str, ...] = (), + branch: str = "", ) -> None: """Report plugin runtime when it is enabled.""" tr = terminalreporter tr._tw.sep("=", "pytest-regsmart summary info") + if branch: + tr._tw.line(f"Branched used for diff detection: {branch}") for w in warnings: tr._tw.line(f"WARNING: {w}") for k, v in log_dict.items(): diff --git a/pytest_regsmart/selection/git_manager.py b/pytest_regsmart/selection/git_manager.py index 266cdcc..4c34454 100644 --- a/pytest_regsmart/selection/git_manager.py +++ b/pytest_regsmart/selection/git_manager.py @@ -10,6 +10,7 @@ class DiffResult: modified_files: list[str] #staged+unstaged untracked_files: list[str] #brand new files - maybe add a future flag to look only at unstaged+untracked tests + used_branch: str #later I also want a more granular diff, maybe per line or by semantic content?? idk @@ -52,7 +53,8 @@ def get_git_diff(repo_path: str = ".") -> DiffResult: if not repo.head.is_valid(): #repo has no commits yet (just git init); only check the untracked diff return DiffResult( modified_files=[], - untracked_files=repo.untracked_files + untracked_files=repo.untracked_files, + used_branch="" ) default_branch = get_default_repo_branch(repo) #maybe make this configurable via a flag later @@ -63,5 +65,6 @@ def get_git_diff(repo_path: str = ".") -> DiffResult: return DiffResult( modified_files=working_dir_diff, - untracked_files=untracked_diff + untracked_files=untracked_diff, + used_branch=default_branch ) diff --git a/pytest_regsmart/selection/selector.py b/pytest_regsmart/selection/selector.py index 3a5bb42..ad46b57 100644 --- a/pytest_regsmart/selection/selector.py +++ b/pytest_regsmart/selection/selector.py @@ -11,6 +11,7 @@ class SelectionResult: affected_tests: list[str] has_diff: bool + branch: str def _is_test_file(filepath: str) -> bool: @@ -54,4 +55,5 @@ def run_rts() -> SelectionResult: return SelectionResult( affected_tests=get_affected_tests(diff_result, deps_graph), has_diff=bool(diff_result.modified_files or diff_result.untracked_files), + branch=diff_result.used_branch, ) From e32270414cc28d934eb9de3999ee3a80f5f2d294 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 11 Aug 2026 00:57:58 -0300 Subject: [PATCH 063/122] add tests + improve info text --- pytest_regsmart/reporter.py | 2 +- tests/selection/test_git.py | 2 ++ tests/selection/test_selection.py | 1 + tests/test_options.py | 14 ++++++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pytest_regsmart/reporter.py b/pytest_regsmart/reporter.py index c2607c4..6a45144 100644 --- a/pytest_regsmart/reporter.py +++ b/pytest_regsmart/reporter.py @@ -42,7 +42,7 @@ def pytest_terminal_summary( tr = terminalreporter tr._tw.sep("=", "pytest-regsmart summary info") if branch: - tr._tw.line(f"Branched used for diff detection: {branch}") + tr._tw.line(f"Default branch used for comparison: {branch}") for w in warnings: tr._tw.line(f"WARNING: {w}") for k, v in log_dict.items(): diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py index abec96a..69f4d4e 100644 --- a/tests/selection/test_git.py +++ b/tests/selection/test_git.py @@ -130,6 +130,7 @@ def test_get_git_diff_custom_default_branch(git_repo, commit_file): assert result.modified_files == [] assert result.untracked_files == [] + assert result.used_branch == "develop" def test_get_git_diff_no_commits(tmp_path): @@ -140,3 +141,4 @@ def test_get_git_diff_no_commits(tmp_path): assert result.modified_files == [] assert "new_file.py" in result.untracked_files + assert result.used_branch == "" diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py index 60825e2..b6346b2 100644 --- a/tests/selection/test_selection.py +++ b/tests/selection/test_selection.py @@ -14,6 +14,7 @@ def test_get_affected_tests_reaches_indirect_test_dependents(): diff_result = DiffResult( modified_files=["service.py"], untracked_files=[], + used_branch="main", ) result = get_affected_tests(diff_result, deps_graph) diff --git a/tests/test_options.py b/tests/test_options.py index e91e6d0..3f683e8 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -127,3 +127,17 @@ def test_invalid_level(mytester): out = mytester.runpytest(*args) error_msg = "Invalid input for `--rank-level`." assert any(error_msg in x for x in out.errlines) + + +def test_summary_reports_used_branch(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + ) + + args = ["-v", "--regsmart"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + assert any( + x.startswith("Default branch used for comparison:") + for x in out.outlines + ) From 00e2a79e27bb9d3ffcf3c7dfca80e0492987a94c Mon Sep 17 00:00:00 2001 From: let Date: Tue, 11 Aug 2026 22:58:04 -0300 Subject: [PATCH 064/122] docs: change license from MIT to GPLv2+ --- LICENSE | 376 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 359 insertions(+), 17 deletions(-) diff --git a/LICENSE b/LICENSE index 90cb912..070bdd2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,22 +1,364 @@ +pytest-regsmart +Copyright (C) 2025-2026 Letícia Costa -The MIT License (MIT) +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. -Copyright (c) 2023 softwareTestingResearch +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software Foundation, +Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +This program incorporates code derived from pytest-ranking +(https://github.com/softwareTestingResearch/pytest-ranking), originally +licensed under the MIT License, and depends on pyan3 +(https://pypi.org/project/pyan3/), licensed under the GNU General Public +License, version 2 or later. See NOTICE.md for full attribution details. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +=============================================================================== + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. From cddca7c20bf58e1c76803e786525510a647c4973 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 11 Aug 2026 22:58:52 -0300 Subject: [PATCH 065/122] docs: add a notice about pytest-ranking fork and pyan3 usage --- NOTICE.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 NOTICE.md diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..d6c425d --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,46 @@ +# Third-Party Notices + +This project, pytest-regsmart, is distributed under the GNU General Public +License v2.0 or later (see LICENSE). It builds on and incorporates +third-party code under different licenses, as detailed below. + +## pytest-ranking (upstream project — MIT License) + +pytest-regsmart is a fork of pytest-ranking +(https://github.com/softwareTestingResearch/pytest-ranking) originally +distributed under the MIT License: + +--- + +The MIT License (MIT) + +Copyright (c) 2023 softwareTestingResearch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +--- + +## pyan3 (runtime dependency — GPL-2.0-or-later) + +pytest-regsmart imports and depends directly on pyan3 +(https://pypi.org/project/pyan3/) at runtime to build the dependency +graph used for regression test selection. pyan3 is licensed under the +GNU General Public License, version 2 or later. As a result, the +combined work (pytest-regsmart) is distributed under the same terms — +see LICENSE for the full GPL-2.0-or-later text. From c91906750413419c6e24895e240b96afef4a96f1 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 11 Aug 2026 22:59:10 -0300 Subject: [PATCH 066/122] docs: update changelog with latest changes --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf83d2..d4c1364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## pytest-regsmart +0.6.1 (2026-08-11) +---- + +* Changes license from MIT for GPLv2+ +* Adds default branch information in summary log report + + 0.6.0 (2026-08-10) ---- From a3770b56e196914f01aa6a6d67a3cf88c2fabf33 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 11 Aug 2026 23:10:47 -0300 Subject: [PATCH 067/122] chore: update license metadata and docs to GPL-2.0-or-later --- README.md | 3 ++- pyproject.toml | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6cae493..9ba1fec 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,8 @@ Contributions are very welcome. ## License -Distributed under the terms of the [MIT](http://opensource.org/licenses/MIT) license, `pytest-regsmart` is free and open-source software. +Distributed under the terms of the [GNU General Public License v2.0 or later](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html), `pytest-regsmart` is free and open-source software. +See [NOTICE.md](./NOTICE.md) for attribution of the incorporated third-party code (pytest-ranking under the MIT License and pyan3 under the GPLv2+). ## Issues diff --git a/pyproject.toml b/pyproject.toml index f65c81d..888327e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,8 @@ name = "pytest-regsmart" version = "0.3.4" description = "A Pytest plugin for faster fault detection via regression test prioritization and selection." readme = "README.md" -license = { text = "MIT" } +license = "GPL-2.0-or-later" +license-files = ["LICENSE", "NOTICE.md"] authors = [ { name = "leticosta4", email = "leticiacostaoa@gmail.com" }, ] @@ -35,7 +36,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Operating System :: OS Independent", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", ] keywords = [ "software testing", From 04641d4c6bd74ddd886b67297f58b02cd08e0f85 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 12 Aug 2026 15:20:48 -0300 Subject: [PATCH 068/122] refactor: make rank level constant more specific --- pytest_regsmart/const.py | 4 ++-- pytest_regsmart/help_strings.py | 2 +- pytest_regsmart/plugin.py | 16 +++++++++------- pytest_regsmart/ranking/rank_args.py | 10 +++++----- pytest_regsmart/ranking/ranker.py | 12 ++++++------ 5 files changed, 23 insertions(+), 21 deletions(-) diff --git a/pytest_regsmart/const.py b/pytest_regsmart/const.py index e8e7f00..acd684e 100644 --- a/pytest_regsmart/const.py +++ b/pytest_regsmart/const.py @@ -14,7 +14,7 @@ DEFAULT_REPLAY = None -class LEVEL(str, Enum): +class RANK_LEVEL(str, Enum): """The test group level at which the test suites are reordered. Tests within each group follows the pytest default order. https://docs.pytest.org/en/stable/reference/fixtures.html#fixtures @@ -23,4 +23,4 @@ class LEVEL(str, Enum): FUNCTION = "function" MODULE = "module" -DEFAULT_LEVEL = LEVEL.PUT +DEFAULT_RANK_LEVEL = RANK_LEVEL.PUT diff --git a/pytest_regsmart/help_strings.py b/pytest_regsmart/help_strings.py index 678bbfe..5eaf25c 100644 --- a/pytest_regsmart/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -26,7 +26,7 @@ Default value is 0. """) -LEVEL_HELP = textwrap.dedent(""" +RANK_LEVEL_HELP = textwrap.dedent(""" The test group level at which the prioritization takes place. Test items below the configured level follow pytest default order. Score of a test group is the mean score over all tests in that group. diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 0b06e97..2df59fa 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -15,14 +15,14 @@ from . import reporter as reporter_mod from .const import ( DEFAULT_HIST_LEN, - DEFAULT_LEVEL, + DEFAULT_RANK_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, ) from .help_strings import ( HIST_LEN_HELP, - LEVEL_HELP, + RANK_LEVEL_HELP, NO_RANK_HELP, PLUGIN_HELP, REPLAY_HELP, @@ -40,7 +40,9 @@ def pytest_addoption(parser: Parser) -> None: "--regsmart", #was the old --rank flag that activated the pytest-ranking default action="store_true", help=PLUGIN_HELP) - + + #new flag here + group._addoption( "--no-rank", action="store_true", @@ -52,9 +54,9 @@ def pytest_addoption(parser: Parser) -> None: "--rank-level", action="store", type=rank_args.level_type, - default=DEFAULT_LEVEL, + default=DEFAULT_RANK_LEVEL, dest="rank_level", - help=LEVEL_HELP) + help=RANK_LEVEL_HELP) group._addoption( "--rank-weight", @@ -91,7 +93,7 @@ def pytest_addoption(parser: Parser) -> None: parser.addini("no_rank", NO_RANK_HELP, default=False) parser.addini("rank_weight", WEIGHT_HELP, default=DEFAULT_WEIGHT) parser.addini("rank_replay", REPLAY_HELP, default=DEFAULT_REPLAY) - parser.addini("rank_level", LEVEL_HELP, default=DEFAULT_LEVEL) + parser.addini("rank_level", RANK_LEVEL_HELP, default=DEFAULT_RANK_LEVEL) parser.addini("rank_hist_len", HIST_LEN_HELP, default=DEFAULT_HIST_LEN) parser.addini("rank_seed", SEED_HELP, default=DEFAULT_SEED) @@ -130,7 +132,7 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if selection.affected_tests: selected_nodes = set(selection.affected_tests) - items[:] = [item for item in items if item.nodeid.split("::")[0] in selected_nodes] + items[:] = [item for item in items if item.nodeid.split("::")[0] in selected_nodes] #filters only per file now, probably gotaa setup a flag if not selection.has_diff: self.warnings.append( diff --git a/pytest_regsmart/ranking/rank_args.py b/pytest_regsmart/ranking/rank_args.py index 184dad5..5818c7f 100644 --- a/pytest_regsmart/ranking/rank_args.py +++ b/pytest_regsmart/ranking/rank_args.py @@ -6,11 +6,11 @@ from ..const import ( DEFAULT_HIST_LEN, - DEFAULT_LEVEL, + DEFAULT_RANK_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, - LEVEL, + RANK_LEVEL, ) @@ -32,10 +32,10 @@ def weight_type(string: str) -> str: def level_type(string: str) -> str: "Check level format." - if string == DEFAULT_LEVEL: + if string == DEFAULT_RANK_LEVEL: return string try: - valid_levels = [i.value for i in LEVEL] + valid_levels = [i.value for i in RANK_LEVEL] assert string in valid_levels return string except AssertionError: @@ -84,7 +84,7 @@ def parse_rtp_weights(config) -> list[float]: def parse_rtp_level(config) -> str: """Get test group level, non-default CLI overrides ini file input.""" level = config.getoption("--rank-level") - if level == DEFAULT_LEVEL: + if level == DEFAULT_RANK_LEVEL: ini_val = config.getini("rank_level") level = ini_val if ini_val else level return level diff --git a/pytest_regsmart/ranking/ranker.py b/pytest_regsmart/ranking/ranker.py index 2f68cae..ce06dc9 100644 --- a/pytest_regsmart/ranking/ranker.py +++ b/pytest_regsmart/ranking/ranker.py @@ -7,10 +7,10 @@ import numpy as np -from ..const import LEVEL +from ..const import RANK_LEVEL -def get_test_group(nodeid: str, level: LEVEL) -> str: +def get_test_group(nodeid: str, level: RANK_LEVEL) -> str: """Get test group at different granularity levels. Given: folder/testfile.py::TestClass::testmethod[param1] @@ -21,15 +21,15 @@ def get_test_group(nodeid: str, level: LEVEL) -> str: """ test_without_param = nodeid.split("[")[0] test_file_path = test_without_param.split("::")[0] - if level == LEVEL.FUNCTION: + if level == RANK_LEVEL.FUNCTION: return test_without_param - elif level == LEVEL.MODULE: + elif level == RANK_LEVEL.MODULE: return test_file_path else: return nodeid -def get_ranking(scores: dict, level: LEVEL, init_order: dict) -> dict: +def get_ranking(scores: dict, level: RANK_LEVEL, init_order: dict) -> dict: """Aggregate scores per test group, sort, return {nodeid: rank}.""" tests = [] for nodeid, score in scores.items(): @@ -55,7 +55,7 @@ def get_ranking(scores: dict, level: LEVEL, init_order: dict) -> dict: def run_rtp( items: list, - level: LEVEL, + level: RANK_LEVEL, weights: list[float], replay_file: str | None, seed: int, From c2b1ef6308d325dd9316de1b121588cf64903e4d Mon Sep 17 00:00:00 2001 From: let Date: Wed, 12 Aug 2026 15:34:42 -0300 Subject: [PATCH 069/122] feat: add new --diff-level flag basic configurations --- pytest_regsmart/const.py | 8 +++++++ pytest_regsmart/help_strings.py | 4 ++++ pytest_regsmart/plugin.py | 19 +++++++++++---- pytest_regsmart/selection/selection_args.py | 26 +++++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 pytest_regsmart/selection/selection_args.py diff --git a/pytest_regsmart/const.py b/pytest_regsmart/const.py index acd684e..f1711b4 100644 --- a/pytest_regsmart/const.py +++ b/pytest_regsmart/const.py @@ -24,3 +24,11 @@ class RANK_LEVEL(str, Enum): MODULE = "module" DEFAULT_RANK_LEVEL = RANK_LEVEL.PUT + + +class DIFF_LEVEL(str, Enum): + """The level at which the diff is identified.""" + LINE = "line" #still not sure about this + FILE = "file" + +DEFAULT_DIFF_LEVEL = DIFF_LEVEL.FILE #for now... i'll change it later diff --git a/pytest_regsmart/help_strings.py b/pytest_regsmart/help_strings.py index 5eaf25c..af123bf 100644 --- a/pytest_regsmart/help_strings.py +++ b/pytest_regsmart/help_strings.py @@ -43,3 +43,7 @@ Boolean flag to disable ranking option (RTP); cannot be used with other ranking flags. When selected, tests will run in pytest default order. """) #need to adapt this ending to use the RTS later + +DIFF_LEVEL_HELP = textwrap.dedent(""" +The levels at which the diff will be identified using Git. +""") #make this better asap diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 2df59fa..64e7e37 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -19,6 +19,7 @@ DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, + DEFAULT_DIFF_LEVEL, ) from .help_strings import ( HIST_LEN_HELP, @@ -28,10 +29,11 @@ REPLAY_HELP, SEED_HELP, WEIGHT_HELP, + DIFF_LEVEL_HELP, ) from .monitor import Monitor from .ranking import rank_args, ranker -from .selection import git_manager, selector +from .selection import git_manager, selector, selection_args def pytest_addoption(parser: Parser) -> None: @@ -41,7 +43,14 @@ def pytest_addoption(parser: Parser) -> None: action="store_true", help=PLUGIN_HELP) - #new flag here + #new flag to keep both file/line options + group.addoption( + "--diff-level", + action="store", + type=selection_args.level_type, + default=DEFAULT_DIFF_LEVEL, + dest="diff_level", + help=DIFF_LEVEL_HELP) group._addoption( "--no-rank", @@ -90,6 +99,7 @@ def pytest_addoption(parser: Parser) -> None: default=DEFAULT_SEED, help=SEED_HELP) + parser.addini("diff_level", DIFF_LEVEL_HELP, default=DEFAULT_DIFF_LEVEL) parser.addini("no_rank", NO_RANK_HELP, default=False) parser.addini("rank_weight", WEIGHT_HELP, default=DEFAULT_WEIGHT) parser.addini("rank_replay", REPLAY_HELP, default=DEFAULT_REPLAY) @@ -107,6 +117,7 @@ def __init__(self, config: Config) -> None: self.monitor = Monitor() self.branch = "" # will be set after selection + self.diff_level = selection_args self.no_rank = rank_args.parse_no_rank(config) self.weights = rank_args.parse_rtp_weights(config) self.level = rank_args.parse_rtp_level(config) @@ -126,9 +137,9 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: ) selection_start_time = time.time() - selection = selector.run_rts() + selection = selector.run_rts() #vai passar a flag aqui e retorna dependendo do diff self.branch = selection.branch - self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time + self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time #vai entrar em condicional pra ver o tipo do diff if selection.affected_tests: selected_nodes = set(selection.affected_tests) diff --git a/pytest_regsmart/selection/selection_args.py b/pytest_regsmart/selection/selection_args.py new file mode 100644 index 0000000..06f8c64 --- /dev/null +++ b/pytest_regsmart/selection/selection_args.py @@ -0,0 +1,26 @@ +import argparse + +from ..const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL + +def level_type(string: str) -> str: + "Check level format." + if string == DEFAULT_DIFF_LEVEL: + return string + try: + valid_levels = [i.value for i in DIFF_LEVEL] + assert string in valid_levels + return string + except AssertionError: + raise argparse.ArgumentTypeError( + "Invalid input for `--diff-level`." + + " Please run `pytest --help` for instruction." + ) from None + + +def parse_diff_level(config) -> str: + """Get selected level for git diff, non-default CLI overrides ini file input.""" #i need to decide if it's gonna be settable via ini/config file + level = config.getoption("--diff-level") + if level == DEFAULT_DIFF_LEVEL: + ini_val = config.getini("diff_level") + level = ini_val if ini_val else level + return level From 2861138ae4d1d0fb8c83cb674da1947c9cd9885f Mon Sep 17 00:00:00 2001 From: let Date: Wed, 12 Aug 2026 15:38:19 -0300 Subject: [PATCH 070/122] refactor: improve some output logs with new diff-level flag info --- pytest_regsmart/plugin.py | 2 +- pytest_regsmart/reporter.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index 64e7e37..e17e3f4 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -147,7 +147,7 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not selection.has_diff: self.warnings.append( - "No diff detected: regression test selection was skipped." + "No diff detected: regression test selection was skipped. The value set for '--diff-level' will be ignored." ) if self.no_rank: self.warnings.append( diff --git a/pytest_regsmart/reporter.py b/pytest_regsmart/reporter.py index 6a45144..5fea0a7 100644 --- a/pytest_regsmart/reporter.py +++ b/pytest_regsmart/reporter.py @@ -15,17 +15,19 @@ def pytest_report_header(config: Config) -> str | None: "\nStarting RTS (Regression Test Selection)", "Using --no-rank (RTP disabled).", ] - + + diff_level = config.getoption("--diff-level") weight = config.getoption("--rank-weight") replay = config.getoption("--rank-replay") - level = config.getoption("--rank-level") + rank_level = config.getoption("--rank-level") hist_len = config.getoption("--rank-hist-len") random_seed = config.getoption("--rank-seed") return [ "\nStarting Smart Regression Test Management (RTS + RTP)", ] + [ + f"Using --diff-level={diff_level}", f"Using --rank-weight={weight}", - f"Using --rank-level={level}", + f"Using --rank-level={rank_level}", f"Using --rank-hist-len={hist_len}", f"Using --rank-seed={random_seed}", f"Using --rank-replay={replay}", From 80b4428ec8b4a6d28986b05376b00b382dc58ee7 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 14 Aug 2026 07:17:43 -0300 Subject: [PATCH 071/122] fix: change diff level from line to function --- pytest_regsmart/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_regsmart/const.py b/pytest_regsmart/const.py index f1711b4..d7bff07 100644 --- a/pytest_regsmart/const.py +++ b/pytest_regsmart/const.py @@ -28,7 +28,7 @@ class RANK_LEVEL(str, Enum): class DIFF_LEVEL(str, Enum): """The level at which the diff is identified.""" - LINE = "line" #still not sure about this + FUNCTION = "function" FILE = "file" DEFAULT_DIFF_LEVEL = DIFF_LEVEL.FILE #for now... i'll change it later From c82225398943a1ad1f2783bfd85873e1b818e85a Mon Sep 17 00:00:00 2001 From: let Date: Fri, 14 Aug 2026 08:48:20 -0300 Subject: [PATCH 072/122] fix: add parse_git_level at plugin.py --- pytest_regsmart/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_regsmart/plugin.py b/pytest_regsmart/plugin.py index e17e3f4..1af4d0d 100644 --- a/pytest_regsmart/plugin.py +++ b/pytest_regsmart/plugin.py @@ -117,7 +117,7 @@ def __init__(self, config: Config) -> None: self.monitor = Monitor() self.branch = "" # will be set after selection - self.diff_level = selection_args + self.diff_level = selection_args.parse_diff_level(config) self.no_rank = rank_args.parse_no_rank(config) self.weights = rank_args.parse_rtp_weights(config) self.level = rank_args.parse_rtp_level(config) From 0b87290104f079f5e05c6ee4a7938430aed8eda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Let=C3=ADcia=20Almeida?= <136915855+leticosta4@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:51:49 -0300 Subject: [PATCH 073/122] docs: update LICENSE to remove attribution details Removed attribution details for pytest-ranking and pyan3 from the LICENSE file. --- LICENSE | 8 -------- 1 file changed, 8 deletions(-) diff --git a/LICENSE b/LICENSE index 070bdd2..b5d6739 100644 --- a/LICENSE +++ b/LICENSE @@ -15,14 +15,6 @@ You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -This program incorporates code derived from pytest-ranking -(https://github.com/softwareTestingResearch/pytest-ranking), originally -licensed under the MIT License, and depends on pyan3 -(https://pypi.org/project/pyan3/), licensed under the GNU General Public -License, version 2 or later. See NOTICE.md for full attribution details. - -=============================================================================== - GNU GENERAL PUBLIC LICENSE Version 2, June 1991 From c07a398903df06f7cc1965c8e29e2dc09ea6d161 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 16 Aug 2026 09:54:38 -0300 Subject: [PATCH 074/122] feat: enhance git diff with deleted files tracking and line range parsing for more granularity --- pytest_regsmart/selection/git_manager.py | 60 +++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/pytest_regsmart/selection/git_manager.py b/pytest_regsmart/selection/git_manager.py index 4c34454..823b62a 100644 --- a/pytest_regsmart/selection/git_manager.py +++ b/pytest_regsmart/selection/git_manager.py @@ -1,17 +1,20 @@ from __future__ import annotations +import os +import re from dataclasses import dataclass from git import Repo from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError +HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") @dataclass class DiffResult: modified_files: list[str] #staged+unstaged untracked_files: list[str] #brand new files - maybe add a future flag to look only at unstaged+untracked tests used_branch: str - #later I also want a more granular diff, maybe per line or by semantic content?? idk + deleted_files: list[str] | None = None def resolve_repo(repo_path: str = ".") -> Repo: @@ -47,6 +50,17 @@ def get_default_repo_branch(repo: Repo) -> str: ) from None +def _is_deleted(repo: Repo, merge_base_hash: str, path: str) -> bool: + """True se o arquivo foi apagado do disco, mas ainda existe no commit-base.""" + if os.path.exists(os.path.join(repo.working_tree_dir, path)): + return False + try: + repo.git.show(f"{merge_base_hash}:{path}") + return True + except GitCommandError: + return False + + def get_git_diff(repo_path: str = ".") -> DiffResult: repo = resolve_repo(repo_path=repo_path) @@ -61,10 +75,54 @@ def get_git_diff(repo_path: str = ".") -> DiffResult: merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() + deleted_files = [path for path in working_dir_diff if _is_deleted(repo, merge_base_commit.hexsha, path)] untracked_diff = repo.untracked_files return DiffResult( modified_files=working_dir_diff, untracked_files=untracked_diff, + deleted_files=deleted_files, used_branch=default_branch ) + + +def parse_diff_output(raw_diff: str) -> dict[str, list[tuple[int, int]]]: + """Parses `git diff -U0` and returns the changed line ranges for each file from the entire repo""" + changed_line_ranges: dict[str, list[tuple[int, int]]] = {} + current_file: str | None = None + + for line in raw_diff.splitlines(): + if line.startswith("+++ b/"): + current_file = line.removeprefix("+++ b/") + continue + if line == "+++ /dev/null": + current_file = None + continue + if current_file is None: + continue + + hunk_match = HUNK_HEADER.match(line) + if not hunk_match: + continue + + # filters the hunk header: @@ -1,10 +1,12 @@ to get the start and end lines + new_start_line = int(hunk_match.group(1)) + new_count = int(hunk_match.group(2)) if hunk_match.group(2) is not None else 1 # it's 1 if the hunk is a single line change (e.g., +1) + new_end_line = new_start_line if new_count == 0 else new_start_line + new_count - 1 # has the same value as start_line if the change is just deletion + + # get the changed line ranges for the current modified file + changed_line_ranges.setdefault(current_file, []).append((new_start_line, new_end_line)) + + return changed_line_ranges + + +def get_changed_line_ranges(repo_path: str = ".") -> dict[str, list[tuple[int, int]]]: + repo = resolve_repo(repo_path=repo_path) + if not repo.head.is_valid(): + return {} + + default_branch = get_default_repo_branch(repo) + merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] + raw_diff = repo.git.diff(merge_base_commit, unified=0) + + return parse_diff_output(raw_diff) From 7dde2d54a21cce7426f1095de5e0935b2b98d04e Mon Sep 17 00:00:00 2001 From: let Date: Sun, 16 Aug 2026 11:26:16 -0300 Subject: [PATCH 075/122] test: add tests for parse_diff_output and get_changed_line_ranges functions --- tests/selection/test_git.py | 160 ++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py index 69f4d4e..7eb9a6c 100644 --- a/tests/selection/test_git.py +++ b/tests/selection/test_git.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path import pytest @@ -7,8 +8,10 @@ from git.exc import InvalidGitRepositoryError from pytest_regsmart.selection.git_manager import ( + get_changed_line_ranges, get_default_repo_branch, get_git_diff, + parse_diff_output, verify_git_repo, ) @@ -142,3 +145,160 @@ def test_get_git_diff_no_commits(tmp_path): assert result.modified_files == [] assert "new_file.py" in result.untracked_files assert result.used_branch == "" + + +def _diff(*lines: str) -> str: + return "\n".join(lines) + + +@pytest.mark.parametrize( + ("raw_diff", "expected"), + [ + ( + _diff("--- a/a.py", "+++ b/a.py", "@@ -9,9 +9,9 @@", "-old", "+new"), + {"a.py": [(9, 17)]}, + ), + ( + _diff( + "+++ b/a.py", + "@@ -9,9 +9,9 @@", + "-old", + "+new", + "@@ -20,2 +21,2 @@", + "-x", + "+y", + ), + {"a.py": [(9, 17), (21, 22)]}, + ), + ( + _diff( + "+++ b/a.py", + "@@ -9,9 +9,9 @@", + "-old", + "+new", + "+++ b/b.py", + "@@ -1,26 +1,26 @@", + "-a", + "+b", + ), + {"a.py": [(9, 17)], "b.py": [(1, 26)]}, + ), + ( + _diff("+++ b/novo.py", "@@ -0,0 +1,26 @@", "+def a():", "+ pass"), + {"novo.py": [(1, 26)]}, + ), + ( + _diff("+++ b/a.py", "@@ -10,3 +9,0 @@", "- a", "- b", "- c"), + {"a.py": [(9, 9)]}, + ), + ( + _diff( + "+++ b/a.py", + "@@ -1,3 +1,3 @@", + "-a", + "+b", + "+++ /dev/null", + "@@ -1,30 +0,0 @@", + "- gone", + ), + {"a.py": [(1, 3)]}, + ), + (_diff("+++ b/a.py", "@@ -18 +18 @@", "-x", "+y"), {"a.py": [(18, 18)]}), + ( + _diff( + "--- a/x.py", + "+++ b/x.py", + "@@ -1,3 +1,4 @@", + " def a():", + "- return 1", + "+ return 2", + "+++ comentario estranho", + "+ mais codigo", + "@@ -20,2 +21,2 @@", + "-def b():", + "+def b():", + ), + {"x.py": [(1, 4), (21, 22)]}, + ), + ], + ids=[ + "single hunk edit", + "multiple hunks same file", + "multiple files", + "new file addition", + "pure deletion anchors on new_start", + "deleted file ignored and does not contaminate", + "hunk without count", + "added line starting with '++ ' does not corrupt", + ], +) +def test_parse_diff_output(raw_diff, expected): + assert parse_diff_output(raw_diff) == expected + + +@pytest.mark.parametrize( + ("original", "edited", "expected"), + [ + ("a\nb\nc\n", "a\nB\nc\n", {"file.py": [(2, 2)]}), + ("a\nb\nc\n", "a\nb\nX\nY\nc\n", {"file.py": [(3, 4)]}), + ("a\nb\nc\n", "a\nc\n", {"file.py": [(1, 1)]}), + ], + ids=[ + "single line edit", + "insert two lines", + "delete one line anchors on new_start", + ], +) +def test_get_changed_line_ranges_edits(git_repo, commit_file, original, edited, expected): + commit_file("file.py", original) + git_repo.git.branch("-m", "main") + (Path(git_repo.working_tree_dir) / "file.py").write_text(edited) + + assert get_changed_line_ranges(str(git_repo.working_tree_dir)) == expected + + +def test_get_changed_line_ranges_committed_on_feature_branch(git_repo, commit_file): + commit_file("file.py", "a\nb\nc\n") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + filepath = Path(git_repo.working_tree_dir) / "file.py" + filepath.write_text("a\nB\nc\n") + git_repo.index.add("file.py") + git_repo.index.commit("edit line 2") + + assert get_changed_line_ranges(str(git_repo.working_tree_dir)) == {"file.py": [(2, 2)]} + + +def test_get_changed_line_ranges_deleted_file_absent(git_repo, commit_file): + commit_file("file.py", "a\nb\nc\n") + git_repo.git.branch("-m", "main") + os.remove(Path(git_repo.working_tree_dir) / "file.py") + + assert get_changed_line_ranges(str(git_repo.working_tree_dir)) == {} + + +def test_get_changed_line_ranges_no_commits(tmp_path): + Repo.init(tmp_path) + + assert get_changed_line_ranges(str(tmp_path)) == {} + + +def test_get_git_diff_deleted_file(git_repo, commit_file): + commit_file("file_a.py") + commit_file("file_b.py") + git_repo.git.branch("-m", "main") + os.remove(Path(git_repo.working_tree_dir) / "file_a.py") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert result.deleted_files == ["file_a.py"] + assert "file_a.py" in result.modified_files + + +def test_get_git_diff_no_deleted_files(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert result.deleted_files == [] From 80304359f3fdf7bff29e8c8a2965799db23755a5 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 16 Aug 2026 21:47:30 -0300 Subject: [PATCH 076/122] chore: reestructure directories into a src style divison --- {pytest_regsmart => src/pytest_regsmart}/__init__.py | 0 {pytest_regsmart => src/pytest_regsmart}/const.py | 0 {pytest_regsmart => src/pytest_regsmart}/extractor.py | 0 {pytest_regsmart => src/pytest_regsmart}/help_strings.py | 0 {pytest_regsmart => src/pytest_regsmart}/monitor.py | 0 {pytest_regsmart => src/pytest_regsmart}/plugin.py | 6 ++++-- .../pytest_regsmart}/ranking/__init__.py | 0 .../pytest_regsmart}/ranking/rank_args.py | 0 {pytest_regsmart => src/pytest_regsmart}/ranking/ranker.py | 0 {pytest_regsmart => src/pytest_regsmart}/reporter.py | 0 .../pytest_regsmart}/selection/__init__.py | 0 .../pytest_regsmart}/selection/deps_graph.py | 0 .../pytest_regsmart}/selection/git_manager.py | 0 .../pytest_regsmart}/selection/selector.py | 0 tests/selection/test_deps_graph.py | 4 ++-- tests/selection/test_git.py | 2 +- tests/selection/test_selection.py | 6 +++--- 17 files changed, 10 insertions(+), 8 deletions(-) rename {pytest_regsmart => src/pytest_regsmart}/__init__.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/const.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/extractor.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/help_strings.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/monitor.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/plugin.py (97%) rename {pytest_regsmart => src/pytest_regsmart}/ranking/__init__.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/ranking/rank_args.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/ranking/ranker.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/reporter.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/selection/__init__.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/selection/deps_graph.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/selection/git_manager.py (100%) rename {pytest_regsmart => src/pytest_regsmart}/selection/selector.py (100%) diff --git a/pytest_regsmart/__init__.py b/src/pytest_regsmart/__init__.py similarity index 100% rename from pytest_regsmart/__init__.py rename to src/pytest_regsmart/__init__.py diff --git a/pytest_regsmart/const.py b/src/pytest_regsmart/const.py similarity index 100% rename from pytest_regsmart/const.py rename to src/pytest_regsmart/const.py diff --git a/pytest_regsmart/extractor.py b/src/pytest_regsmart/extractor.py similarity index 100% rename from pytest_regsmart/extractor.py rename to src/pytest_regsmart/extractor.py diff --git a/pytest_regsmart/help_strings.py b/src/pytest_regsmart/help_strings.py similarity index 100% rename from pytest_regsmart/help_strings.py rename to src/pytest_regsmart/help_strings.py diff --git a/pytest_regsmart/monitor.py b/src/pytest_regsmart/monitor.py similarity index 100% rename from pytest_regsmart/monitor.py rename to src/pytest_regsmart/monitor.py diff --git a/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py similarity index 97% rename from pytest_regsmart/plugin.py rename to src/pytest_regsmart/plugin.py index 0b06e97..1fd0601 100644 --- a/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -30,8 +30,10 @@ WEIGHT_HELP, ) from .monitor import Monitor -from .ranking import rank_args, ranker -from .selection import git_manager, selector +from .ranking import ranker +from .ranking import rank_args +from .selection import selector +from .selection import git_manager def pytest_addoption(parser: Parser) -> None: diff --git a/pytest_regsmart/ranking/__init__.py b/src/pytest_regsmart/ranking/__init__.py similarity index 100% rename from pytest_regsmart/ranking/__init__.py rename to src/pytest_regsmart/ranking/__init__.py diff --git a/pytest_regsmart/ranking/rank_args.py b/src/pytest_regsmart/ranking/rank_args.py similarity index 100% rename from pytest_regsmart/ranking/rank_args.py rename to src/pytest_regsmart/ranking/rank_args.py diff --git a/pytest_regsmart/ranking/ranker.py b/src/pytest_regsmart/ranking/ranker.py similarity index 100% rename from pytest_regsmart/ranking/ranker.py rename to src/pytest_regsmart/ranking/ranker.py diff --git a/pytest_regsmart/reporter.py b/src/pytest_regsmart/reporter.py similarity index 100% rename from pytest_regsmart/reporter.py rename to src/pytest_regsmart/reporter.py diff --git a/pytest_regsmart/selection/__init__.py b/src/pytest_regsmart/selection/__init__.py similarity index 100% rename from pytest_regsmart/selection/__init__.py rename to src/pytest_regsmart/selection/__init__.py diff --git a/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py similarity index 100% rename from pytest_regsmart/selection/deps_graph.py rename to src/pytest_regsmart/selection/deps_graph.py diff --git a/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py similarity index 100% rename from pytest_regsmart/selection/git_manager.py rename to src/pytest_regsmart/selection/git_manager.py diff --git a/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py similarity index 100% rename from pytest_regsmart/selection/selector.py rename to src/pytest_regsmart/selection/selector.py diff --git a/tests/selection/test_deps_graph.py b/tests/selection/test_deps_graph.py index 701fb83..25925a7 100644 --- a/tests/selection/test_deps_graph.py +++ b/tests/selection/test_deps_graph.py @@ -5,7 +5,7 @@ from pytest import mark -from pytest_regsmart.selection.deps_graph import ( +from src.pytest_regsmart.selection.deps_graph import ( DependencyGraph, _build_module_relative_path, _find_py_files, @@ -189,7 +189,7 @@ def test_get_dependency_graph_leaf_module_has_no_entry(git_repo): def test_get_dependency_graph_defaults_to_dot_repo(monkeypatch, git_repo): _build_sample_project(git_repo) monkeypatch.setattr( - "pytest_regsmart.selection.deps_graph.resolve_repo", lambda repo_path=".": git_repo + "src.pytest_regsmart.selection.deps_graph.resolve_repo", lambda repo_path=".": git_repo ) graph = get_dependency_graph() diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py index 69f4d4e..89efa7a 100644 --- a/tests/selection/test_git.py +++ b/tests/selection/test_git.py @@ -6,7 +6,7 @@ from git import Repo from git.exc import InvalidGitRepositoryError -from pytest_regsmart.selection.git_manager import ( +from src.pytest_regsmart.selection.git_manager import ( get_default_repo_branch, get_git_diff, verify_git_repo, diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py index b6346b2..ac8d230 100644 --- a/tests/selection/test_selection.py +++ b/tests/selection/test_selection.py @@ -1,6 +1,6 @@ -from pytest_regsmart.selection.deps_graph import DependencyGraph -from pytest_regsmart.selection.git_manager import DiffResult -from pytest_regsmart.selection.selector import get_affected_tests +from src.pytest_regsmart.selection.deps_graph import DependencyGraph +from src.pytest_regsmart.selection.git_manager import DiffResult +from src.pytest_regsmart.selection.selector import get_affected_tests def test_get_affected_tests_reaches_indirect_test_dependents(): From 716aa2061e0190b04fca13a1b47ca92372391f0f Mon Sep 17 00:00:00 2001 From: let Date: Sun, 16 Aug 2026 22:17:09 -0300 Subject: [PATCH 077/122] chore(pyproject.toml): add dev dependencies --- pyproject.toml | 8 ++ uv.lock | 224 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 888327e..e072d31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,14 @@ Homepage = "https://github.com/leticosta4/pytest-regsmart" [project.entry-points."pytest11"] pytest_regsmart = "pytest_regsmart.plugin" +[dependency-groups] +dev = [ + "tox", + "tox-uv", + "pytest-xdist", + "ruff", +] + [tool.ruff] line-length = 88 target-version = "py310" diff --git a/uv.lock b/uv.lock index cc8a907..c299ddb 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "cachetools" +version = "7.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -16,6 +25,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -28,6 +46,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -368,6 +404,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -398,6 +443,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyproject-api" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/bd/2f985c12bf33fdd8637e3a8f9418d6806f177601dee7c4924d0b2bb28650/pyproject_api-1.11.0.tar.gz", hash = "sha256:b8807d85a293e6c9f133e6575946fed45f1d42b22d58c780b33aa2421a799549", size = 23787, upload-time = "2026-07-21T13:09:33.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/6a/2822ee12bafe87af8f6cc620f7d1f126e77f82ce56ba888b94a9af5a979c/pyproject_api-1.11.0-py3-none-any.whl", hash = "sha256:860060c8832dce983b5eec6f41c4c43eb3ec06ff7332387a63acdf5ca27b68d8", size = 13275, upload-time = "2026-07-21T13:09:32.559Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -429,6 +487,14 @@ dependencies = [ { name = "pytest" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, +] + [package.metadata] requires-dist = [ { name = "gitpython", specifier = ">=3.1.58" }, @@ -437,6 +503,64 @@ requires-dist = [ { name = "pytest", specifier = ">=9.0.3" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/b7/ac44da2cf0e53ada0e419033c2d058219c95dc1403126f163304c9e814b1/python_discovery-1.5.2.tar.gz", hash = "sha256:45fd4f20a4e3f9b7bf2e0817870bc8e3b320a19658da177af800768c82dbf354", size = 82350, upload-time = "2026-08-12T14:05:26.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3", size = 38350, upload-time = "2026-08-12T14:05:25.113Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + [[package]] name = "smmap" version = "5.0.3" @@ -500,6 +624,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tox" +version = "4.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "python-discovery" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomli-w" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/1f/3acba68301b0081cec87b1769ba4c3a4a0a30f20879a05420673c7e4671d/tox-4.60.0.tar.gz", hash = "sha256:6f93bb580d35e00fc69cfec1116eecbe21155f747753ca5019374f7c69b2eac9", size = 301598, upload-time = "2026-08-13T23:14:26.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/e6/3a1042add765c0e1e67f76a3b0f2036bfe6b0229ace83c6ff9638a5e391b/tox-4.60.0-py3-none-any.whl", hash = "sha256:175abbc4cdef615d66874c0843be4f44c353c14aab6d89939bb22246f84122bd", size = 226332, upload-time = "2026-08-13T23:14:25.324Z" }, +] + +[[package]] +name = "tox-uv" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tox-uv-bare" }, + { name = "uv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/d7/3fce976b9e295218a2d7e541e0f1e9b1259cb893de7a8d277952a66a798d/tox_uv-1.36.0-py3-none-any.whl", hash = "sha256:5f81b39be3fe4e14c6b9bb7ba637ed97d34efa6214efd5f525d95ee9559a99ac", size = 6564, upload-time = "2026-07-21T13:09:54.316Z" }, +] + +[[package]] +name = "tox-uv-bare" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tox" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/df/9f90a59de8c87cece6e691eee53dc1cb0a824d73aea8f380ae2f6ecb71de/tox_uv_bare-1.36.0.tar.gz", hash = "sha256:d9b0a2fd0f74fa65d9597108f8a0ef7abb08651c9196a998a6073b781b45cfd0", size = 32548, upload-time = "2026-07-21T13:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/0a/6dc462e4fb543305283a6157c80f43e3d12ca4702da6ae6521d541c6b55c/tox_uv_bare-1.36.0-py3-none-any.whl", hash = "sha256:ba397dd0396df95a75744d4e42a50ee27207c0ffcf277b62ffba9c3de455a939", size = 22489, upload-time = "2026-07-21T13:09:55.389Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -508,3 +690,45 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3 wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] + +[[package]] +name = "uv" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, + { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, + { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, + { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, + { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, + { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/a6eb1ddfa7f1e390fa599b078453c97edb3f6f846b34fb4eac3e8ea16401/virtualenv-21.7.4.tar.gz", hash = "sha256:c9d960c95fa458171e58222a5ccab7465298e4b6559977865e627c4719f1e825", size = 5345511, upload-time = "2026-08-10T22:54:33.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl", hash = "sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843", size = 5324444, upload-time = "2026-08-10T22:54:31.515Z" }, +] From 88b740b5f575ccba7539cd68e0bf5d8f1c84a166 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 16 Aug 2026 23:25:08 -0300 Subject: [PATCH 078/122] docs: fix inconsistencies in readme and changelog --- CHANGELOG.md | 4 ---- README.md | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c1364..05d1155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,10 +63,6 @@ ---- * Published version at PyPI (https://pypi.org/project/pytest-ranking/0.3.4/#history) - -0.3.3 (2024-04-08) ----- - * Add replay option * Refine random order and test group level * Code refactoring diff --git a/README.md b/README.md index 9ba1fec..7b9b03d 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ pytest --regsmart --rank-hist-len=30 ``` The default value is 50. -Note that `pytest-regsmart` does not store any historical test run logs, it merely updated its cached data from the previous run with data from the latest run. +Note that `pytest-regsmart` does not store any historical test run logs, it merely updates its cached data from the previous run with data from the latest run. ### Running tests in random order From 66e2abd80ab8b4c1b5eedcb787cf2b76cae5f4ec Mon Sep 17 00:00:00 2001 From: let Date: Sun, 16 Aug 2026 23:37:50 -0300 Subject: [PATCH 079/122] docs: update changelog style into keep-a-changelog template --- CHANGELOG.md | 217 +++++++++++++++++++++++++-------------------------- 1 file changed, 105 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d1155..13631ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,120 +1,113 @@ # Changelog -## pytest-regsmart - -0.6.1 (2026-08-11) ----- - -* Changes license from MIT for GPLv2+ -* Adds default branch information in summary log report - - -0.6.0 (2026-08-10) ----- - -* Drop support for python 3.8 and 3.9 -* Bump pytest from version 7.4.3 to 9.0.3 -* Bump gitpython from 3.1.57 to 3.1.58 - - -0.5.0 (2026-08-07) ----- - -* Add Regression Test Selection (RTS): a new `selection` module that computes the test files affected by the current changes and runs only them under `--regsmart` - * Detect changed files with git (modified + untracked) against the resolved default branch (origin/HEAD → main/master → active branch); repositories without commits fall back to untracked files only - * Build a module-level dependency graph with pyan3 and propagate changes transitively (BFS) to affected test files - * Raise a `UsageError` when `--regsmart` is used outside a git repository -* Refine `--no-rank`: it now only disables RTP while keeping RTS active, and cannot be combined with other `--rank-*` flags (raises a `UsageError`) -* Report the RTS configuration and timing in the terminal header/summary -* Add integration tests for the selection module and improve the test suite - -0.4.0 (2026-07-24) ----- - -* Add the `--no-rank` option to remove RTP, considering only the future RTS module -* Rename activation flag from `--rank` to `--regsmart` -* Improve documentation - -0.3.0 (2026-07-20) ----- - -* Migrate package manager to uv - -0.2.0 (2026-07-19) ----- - -* Rename whole package name to `pytest-regsmart` -* Refactor ranking module internals (modularization) -* Remove directory-level granularity (`--rank-level=dir`) - -0.1.0 (2026-06-25) ----- - -* Fork of pytest-ranking (based on PyPI release 0.3.4) -* Rename plugin to `pytest-regsmart` -* Remove Similar Changed Path method (`--rank-weight=0-0-1`) +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] pytest-regsmart + +## [0.6.1] - 2026-08-11 + +### Changed +- License changed from MIT to GPLv2+ +### Added +- Default branch information in summary log report +## [0.6.0] - 2026-08-10 + +### Security +- Bump pytest from version 7.4.3 to 9.0.3 ([CVE-2025-71176](https://github.com/leticosta4/pytest-regsmart/security/dependabot/1)) +- Bump gitpython from 3.1.57 to 3.1.58 ([CVEs](https://github.com/leticosta4/pytest-regsmart/security/dependabot?q=GitPython+is%3Aclosed)) +### Removed +- Support for Python 3.8 and 3.9 +## [0.5.0] - 2026-08-07 + +### Added +- Regression Test Selection (RTS): a new `selection` module that computes the test files affected by the current changes and runs only them under `--regsmart` + - Detect changed files with git (modified + untracked) against the resolved default branch (origin/HEAD → main/master → active branch); repositories without commits fall back to untracked files only + - Build a module-level dependency graph with pyan3 and propagate changes transitively (BFS) to affected test files + - Raise a `UsageError` when `--regsmart` is used outside a git repository +- Report the RTS configuration and timing in the terminal header/summary +- Integration tests for the selection module and improved test suite +### Changed +- Refine `--no-rank`: it now only disables RTP while keeping RTS active, and cannot be combined with other `--rank-*` flags (raises a `UsageError`) +## [0.4.0] - 2026-07-24 + +### Added +- `--no-rank` option to remove RTP, considering only the future RTS module +### Changed +- Rename activation flag from `--rank` to `--regsmart` +- Improve documentation +## [0.3.0] - 2026-07-20 + +### Changed +- Migrate package manager to uv +## [0.2.0] - 2026-07-19 + +### Changed +- Rename whole package name to `pytest-regsmart` +- Refactor ranking module internals (modularization) +### Removed +- Directory-level granularity (`--rank-level=dir`) +## [0.1.0] - 2026-06-25 + +### Added +- Fork of pytest-ranking (based on PyPI release 0.3.4) +### Changed +- Rename plugin to `pytest-regsmart` +### Removed +- Similar Changed Path method (`--rank-weight=0-0-1`) --- ## Inherited history (pytest-ranking) -0.3.4 (2025-04-09) ----- - -* Published version at PyPI (https://pypi.org/project/pytest-ranking/0.3.4/#history) -* Add replay option -* Refine random order and test group level -* Code refactoring - -0.3.3 (2024-12-24) ----- - -* Support order dependency - -0.3.2 (2024-12-23) ----- - -* Refine test group level definition and extraction -* Use pytest test discovery order as default - -0.3.1 (2024-06-06) ----- - -* Support ranking tests at different granularity levels (PUT, method, file, folder) -* Add plugin summary via pytest_terminal_summary - -0.3.0 (2024-05-12) ----- - -* Fix attribute initialization in change tracker for Python 3.11 and lower versions - - -0.2.8 (2024-05-08) ----- - -* Make pytest-xdist compatible for random and change-related heuristics - - -0.2.7 (2024-03-18) ----- - -* Add option to run tests in random order -* Improve documentation - - - - -0.2.0 (2024-02-16) ----- - -* Add textual similarity between tests and changed files since last run as the third heuristic -* Weight normalization -* History length as an optional argument -* Rename plugin to `pytest-ranking` - - -0.1.0 (2023-12-04) ----- - -* First release on PyPI +## [0.3.4] - 2025-04-09 + +### Added +- Replay option +- Published version at [PyPI](https://pypi.org/project/pytest-ranking/0.3.4/#history) +### Changed +- Refine random order and test group level +- Code refactoring +## [0.3.3] - 2024-12-24 + +### Added +- Support order dependency +## [0.3.2] - 2024-12-23 + +### Changed +- Refine test group level definition and extraction +- Use pytest test discovery order as default +## [0.3.1] - 2024-06-06 + +### Added +- Support ranking tests at different granularity levels (PUT, method, file, folder) +- Plugin summary via `pytest_terminal_summary` +## [0.3.0] - 2024-05-12 + +### Fixed +- Attribute initialization in change tracker for Python 3.11 and lower versions +## [0.2.8] - 2024-05-08 + +### Fixed +- pytest-xdist compatibility for random and change-related heuristics +## [0.2.7] - 2024-03-18 + +### Added +- Option to run tests in random order +### Changed +- Improve documentation +## [0.2.0] - 2024-02-16 + +### Added +- Textual similarity between tests and changed files since last run as the third heuristic +- History length as an optional argument +### Changed +- Weight normalization +- Rename plugin to `pytest-ranking` +## [0.1.0] - 2023-12-04 + +### Added +- First release on PyPI From 4a366c416bca8a1e400a1fc8b6482fb862809f45 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 16 Aug 2026 23:50:26 -0300 Subject: [PATCH 080/122] chore: update version to 0.6.1 in pyproject.toml, __init__.py, and uv.lock --- pyproject.toml | 2 +- src/pytest_regsmart/__init__.py | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e072d31..a17c902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytest-regsmart" -version = "0.3.4" +version = "0.6.1" description = "A Pytest plugin for faster fault detection via regression test prioritization and selection." readme = "README.md" license = "GPL-2.0-or-later" diff --git a/src/pytest_regsmart/__init__.py b/src/pytest_regsmart/__init__.py index 334b899..43c4ab0 100644 --- a/src/pytest_regsmart/__init__.py +++ b/src/pytest_regsmart/__init__.py @@ -1 +1 @@ -__version__ = "0.3.4" +__version__ = "0.6.1" diff --git a/uv.lock b/uv.lock index c299ddb..a863500 100644 --- a/uv.lock +++ b/uv.lock @@ -476,7 +476,7 @@ wheels = [ [[package]] name = "pytest-regsmart" -version = "0.3.4" +version = "0.6.1" source = { editable = "." } dependencies = [ { name = "gitpython" }, From 8d85e6a4290f2faad097619d18aed5907e7ef790 Mon Sep 17 00:00:00 2001 From: let Date: Mon, 17 Aug 2026 23:42:55 -0300 Subject: [PATCH 081/122] refactor: simplify untracked diff attr --- src/pytest_regsmart/selection/git_manager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py index 823b62a..06a0d0d 100644 --- a/src/pytest_regsmart/selection/git_manager.py +++ b/src/pytest_regsmart/selection/git_manager.py @@ -63,11 +63,12 @@ def _is_deleted(repo: Repo, merge_base_hash: str, path: str) -> bool: def get_git_diff(repo_path: str = ".") -> DiffResult: repo = resolve_repo(repo_path=repo_path) + untracked_diff = repo.untracked_files if not repo.head.is_valid(): #repo has no commits yet (just git init); only check the untracked diff return DiffResult( modified_files=[], - untracked_files=repo.untracked_files, + untracked_files=untracked_diff, used_branch="" ) @@ -76,7 +77,7 @@ def get_git_diff(repo_path: str = ".") -> DiffResult: merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() deleted_files = [path for path in working_dir_diff if _is_deleted(repo, merge_base_commit.hexsha, path)] - untracked_diff = repo.untracked_files + return DiffResult( modified_files=working_dir_diff, From 4673c7346a179d57999e6f819472a9edcb70092d Mon Sep 17 00:00:00 2001 From: let Date: Tue, 18 Aug 2026 06:31:16 -0300 Subject: [PATCH 082/122] refactor: make diff_level_help string more specific --- src/pytest_regsmart/help_strings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pytest_regsmart/help_strings.py b/src/pytest_regsmart/help_strings.py index af123bf..20fafb1 100644 --- a/src/pytest_regsmart/help_strings.py +++ b/src/pytest_regsmart/help_strings.py @@ -46,4 +46,6 @@ DIFF_LEVEL_HELP = textwrap.dedent(""" The levels at which the diff will be identified using Git. +Default value is file, which means that the diff will be identified at the file level. +If you want to identify the diff at the function level, you can set this option to function. """) #make this better asap From a8aba04487411a8dab58fa9017be4c2d0bfa41c0 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 18 Aug 2026 08:13:39 -0300 Subject: [PATCH 083/122] refactor: restructure git_manager to make it less redundant --- src/pytest_regsmart/const.py | 3 + src/pytest_regsmart/selection/git_manager.py | 80 ++++++++++---------- tests/selection/test_git.py | 18 ++--- 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/src/pytest_regsmart/const.py b/src/pytest_regsmart/const.py index d7bff07..cdf16fd 100644 --- a/src/pytest_regsmart/const.py +++ b/src/pytest_regsmart/const.py @@ -1,3 +1,4 @@ +import re from enum import Enum # ------ Constants ------ @@ -32,3 +33,5 @@ class DIFF_LEVEL(str, Enum): FILE = "file" DEFAULT_DIFF_LEVEL = DIFF_LEVEL.FILE #for now... i'll change it later + +DIFF_HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") diff --git a/src/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py index 06a0d0d..7d8cf3d 100644 --- a/src/pytest_regsmart/selection/git_manager.py +++ b/src/pytest_regsmart/selection/git_manager.py @@ -1,20 +1,21 @@ from __future__ import annotations import os -import re -from dataclasses import dataclass +from dataclasses import dataclass, field from git import Repo from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError -HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") +from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_HUNK_HEADER, DIFF_LEVEL + @dataclass class DiffResult: - modified_files: list[str] #staged+unstaged - untracked_files: list[str] #brand new files - maybe add a future flag to look only at unstaged+untracked tests used_branch: str - deleted_files: list[str] | None = None + modified_files: list[str] = field(default_factory=list) #staged+unstaged + untracked_files: list[str] = field(default_factory=list) #brand new files - maybe add a future flag to look only at unstaged+untracked tests + deleted_files: list[str] = field(default_factory=list) + changed_line_ranges: dict[str, list[tuple[int, int]]] = field(default_factory=dict) #only for DIFF_LEVEL.FUNCTION def resolve_repo(repo_path: str = ".") -> Repo: @@ -61,32 +62,6 @@ def _is_deleted(repo: Repo, merge_base_hash: str, path: str) -> bool: return False -def get_git_diff(repo_path: str = ".") -> DiffResult: - repo = resolve_repo(repo_path=repo_path) - untracked_diff = repo.untracked_files - - if not repo.head.is_valid(): #repo has no commits yet (just git init); only check the untracked diff - return DiffResult( - modified_files=[], - untracked_files=untracked_diff, - used_branch="" - ) - - default_branch = get_default_repo_branch(repo) #maybe make this configurable via a flag later - - merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description - working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() - deleted_files = [path for path in working_dir_diff if _is_deleted(repo, merge_base_commit.hexsha, path)] - - - return DiffResult( - modified_files=working_dir_diff, - untracked_files=untracked_diff, - deleted_files=deleted_files, - used_branch=default_branch - ) - - def parse_diff_output(raw_diff: str) -> dict[str, list[tuple[int, int]]]: """Parses `git diff -U0` and returns the changed line ranges for each file from the entire repo""" changed_line_ranges: dict[str, list[tuple[int, int]]] = {} @@ -102,7 +77,7 @@ def parse_diff_output(raw_diff: str) -> dict[str, list[tuple[int, int]]]: if current_file is None: continue - hunk_match = HUNK_HEADER.match(line) + hunk_match = DIFF_HUNK_HEADER.match(line) if not hunk_match: continue @@ -117,13 +92,36 @@ def parse_diff_output(raw_diff: str) -> dict[str, list[tuple[int, int]]]: return changed_line_ranges -def get_changed_line_ranges(repo_path: str = ".") -> dict[str, list[tuple[int, int]]]: +def get_git_diff(repo_path: str = ".", graph_level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> DiffResult: repo = resolve_repo(repo_path=repo_path) - if not repo.head.is_valid(): - return {} - - default_branch = get_default_repo_branch(repo) - merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] - raw_diff = repo.git.diff(merge_base_commit, unified=0) + untracked_diff = repo.untracked_files + + if not repo.head.is_valid(): #repo has no commits yet (just git init); only check the untracked diff + return DiffResult( + modified_files=[], + untracked_files=untracked_diff, + used_branch="" + ) + + default_branch = get_default_repo_branch(repo) #maybe make this configurable via a flag later + merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description + working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() + deleted_files = [path for path in working_dir_diff if _is_deleted(repo, merge_base_commit.hexsha, path)] - return parse_diff_output(raw_diff) + if graph_level == DIFF_LEVEL.FUNCTION: + raw_diff = repo.git.diff(merge_base_commit, unified=0) + + return DiffResult( + modified_files=working_dir_diff, + untracked_files=untracked_diff, + deleted_files=deleted_files, + used_branch=default_branch, + changed_line_ranges=parse_diff_output(raw_diff) + ) + + return DiffResult( + modified_files=working_dir_diff, + untracked_files=untracked_diff, + deleted_files=deleted_files, + used_branch=default_branch + ) diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py index f7de936..9e3f767 100644 --- a/tests/selection/test_git.py +++ b/tests/selection/test_git.py @@ -7,8 +7,8 @@ from git import Repo from git.exc import InvalidGitRepositoryError +from src.pytest_regsmart.const import DIFF_LEVEL from src.pytest_regsmart.selection.git_manager import ( - get_changed_line_ranges, get_default_repo_branch, get_git_diff, parse_diff_output, @@ -249,15 +249,15 @@ def test_parse_diff_output(raw_diff, expected): "delete one line anchors on new_start", ], ) -def test_get_changed_line_ranges_edits(git_repo, commit_file, original, edited, expected): +def test_get_git_diff_changed_line_ranges_edits(git_repo, commit_file, original, edited, expected): commit_file("file.py", original) git_repo.git.branch("-m", "main") (Path(git_repo.working_tree_dir) / "file.py").write_text(edited) - assert get_changed_line_ranges(str(git_repo.working_tree_dir)) == expected + assert get_git_diff(str(git_repo.working_tree_dir), graph_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == expected -def test_get_changed_line_ranges_committed_on_feature_branch(git_repo, commit_file): +def test_get_git_diff_changed_line_ranges_committed_on_feature_branch(git_repo, commit_file): commit_file("file.py", "a\nb\nc\n") git_repo.git.branch("-m", "main") git_repo.git.checkout("-b", "feature") @@ -266,21 +266,21 @@ def test_get_changed_line_ranges_committed_on_feature_branch(git_repo, commit_fi git_repo.index.add("file.py") git_repo.index.commit("edit line 2") - assert get_changed_line_ranges(str(git_repo.working_tree_dir)) == {"file.py": [(2, 2)]} + assert get_git_diff(str(git_repo.working_tree_dir), graph_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {"file.py": [(2, 2)]} -def test_get_changed_line_ranges_deleted_file_absent(git_repo, commit_file): +def test_get_git_diff_changed_line_ranges_deleted_file_absent(git_repo, commit_file): commit_file("file.py", "a\nb\nc\n") git_repo.git.branch("-m", "main") os.remove(Path(git_repo.working_tree_dir) / "file.py") - assert get_changed_line_ranges(str(git_repo.working_tree_dir)) == {} + assert get_git_diff(str(git_repo.working_tree_dir), graph_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {} -def test_get_changed_line_ranges_no_commits(tmp_path): +def test_get_git_diff_changed_line_ranges_no_commits(tmp_path): Repo.init(tmp_path) - assert get_changed_line_ranges(str(tmp_path)) == {} + assert get_git_diff(str(tmp_path), graph_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {} def test_get_git_diff_deleted_file(git_repo, commit_file): From 6c1d49ea69a38bfe1ea9e58338072c5848737141 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 18 Aug 2026 08:13:50 -0300 Subject: [PATCH 084/122] fix lint --- src/pytest_regsmart/plugin.py | 8 ++++---- src/pytest_regsmart/selection/selection_args.py | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 1af4d0d..97584d5 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -14,26 +14,26 @@ from . import extractor from . import reporter as reporter_mod from .const import ( + DEFAULT_DIFF_LEVEL, DEFAULT_HIST_LEN, DEFAULT_RANK_LEVEL, DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, - DEFAULT_DIFF_LEVEL, ) from .help_strings import ( + DIFF_LEVEL_HELP, HIST_LEN_HELP, - RANK_LEVEL_HELP, NO_RANK_HELP, PLUGIN_HELP, + RANK_LEVEL_HELP, REPLAY_HELP, SEED_HELP, WEIGHT_HELP, - DIFF_LEVEL_HELP, ) from .monitor import Monitor from .ranking import rank_args, ranker -from .selection import git_manager, selector, selection_args +from .selection import git_manager, selection_args, selector def pytest_addoption(parser: Parser) -> None: diff --git a/src/pytest_regsmart/selection/selection_args.py b/src/pytest_regsmart/selection/selection_args.py index 06f8c64..1fcf13a 100644 --- a/src/pytest_regsmart/selection/selection_args.py +++ b/src/pytest_regsmart/selection/selection_args.py @@ -2,6 +2,7 @@ from ..const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL + def level_type(string: str) -> str: "Check level format." if string == DEFAULT_DIFF_LEVEL: From f00cda45bf6af5c3269c5f02e4b2ef013acb4b8f Mon Sep 17 00:00:00 2001 From: let Date: Tue, 18 Aug 2026 08:32:07 -0300 Subject: [PATCH 085/122] refactor: add validation for invalid diff_level values --- src/pytest_regsmart/plugin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 97584d5..4b036ee 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -20,6 +20,7 @@ DEFAULT_REPLAY, DEFAULT_SEED, DEFAULT_WEIGHT, + DIFF_LEVEL, ) from .help_strings import ( DIFF_LEVEL_HELP, @@ -131,6 +132,11 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): return + if self.diff_level not in (level.value for level in DIFF_LEVEL): + raise argparse.ArgumentTypeError( + f"Invalid diff level: {self.diff_level}. {DIFF_LEVEL_HELP}" + ) + if self.replay_file and self.weights == [0, 0]: raise argparse.ArgumentTypeError( #or usage error "--rank-replay cannot be used together with random order." From b77dbaa713594200d698af9c02f213a5b53badc9 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 19 Aug 2026 09:44:08 -0300 Subject: [PATCH 086/122] refactor: update DIFF_LEVEL and level_type to use enum type --- src/pytest_regsmart/const.py | 6 ++++++ src/pytest_regsmart/selection/selection_args.py | 14 +++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/pytest_regsmart/const.py b/src/pytest_regsmart/const.py index cdf16fd..868ad78 100644 --- a/src/pytest_regsmart/const.py +++ b/src/pytest_regsmart/const.py @@ -24,6 +24,9 @@ class RANK_LEVEL(str, Enum): FUNCTION = "function" MODULE = "module" + def __str__(self) -> str: + return self.value + DEFAULT_RANK_LEVEL = RANK_LEVEL.PUT @@ -32,6 +35,9 @@ class DIFF_LEVEL(str, Enum): FUNCTION = "function" FILE = "file" + def __str__(self) -> str: + return self.value + DEFAULT_DIFF_LEVEL = DIFF_LEVEL.FILE #for now... i'll change it later DIFF_HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") diff --git a/src/pytest_regsmart/selection/selection_args.py b/src/pytest_regsmart/selection/selection_args.py index 1fcf13a..f7f6a45 100644 --- a/src/pytest_regsmart/selection/selection_args.py +++ b/src/pytest_regsmart/selection/selection_args.py @@ -3,25 +3,21 @@ from ..const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL -def level_type(string: str) -> str: +def level_type(string: str) -> DIFF_LEVEL: "Check level format." - if string == DEFAULT_DIFF_LEVEL: - return string try: - valid_levels = [i.value for i in DIFF_LEVEL] - assert string in valid_levels - return string - except AssertionError: + return DIFF_LEVEL(string) + except ValueError: raise argparse.ArgumentTypeError( "Invalid input for `--diff-level`." + " Please run `pytest --help` for instruction." ) from None -def parse_diff_level(config) -> str: +def parse_diff_level(config) -> DIFF_LEVEL: """Get selected level for git diff, non-default CLI overrides ini file input.""" #i need to decide if it's gonna be settable via ini/config file level = config.getoption("--diff-level") if level == DEFAULT_DIFF_LEVEL: ini_val = config.getini("diff_level") level = ini_val if ini_val else level - return level + return DIFF_LEVEL(level) From 3a8118f5d2f70adb5083e2ae994907506e78fa61 Mon Sep 17 00:00:00 2001 From: let Date: Wed, 19 Aug 2026 10:37:50 -0300 Subject: [PATCH 087/122] refactor: inital refactor to make deps_graph more generic --- src/pytest_regsmart/selection/deps_graph.py | 37 +++++++++++++++------ tests/selection/test_deps_graph.py | 11 +++--- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py index c731856..c58b078 100644 --- a/src/pytest_regsmart/selection/deps_graph.py +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -7,6 +7,8 @@ from pyan.modvis import ImportVisitor +# from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL + from .git_manager import resolve_repo #initially I'll try the simplest form using only files; functions are not needed yet @@ -29,7 +31,7 @@ def _find_py_files(working_dir: str) -> list[str]: return py_files -def _build_module_relative_path(fullpaths, working_dir: str) -> dict[str, str]: +def _convert_module_to_relative_path(fullpaths, working_dir: str) -> dict[str, str]: """Convert pyan3 module names to paths relative to the repo (to match the diff). E.g.: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" @@ -43,7 +45,7 @@ def _build_module_relative_path(fullpaths, working_dir: str) -> dict[str, str]: def _invert_dependency_graph( module_imports: dict[str, set[str]], module_to_path: dict[str, str] - ) -> DependencyGraph: + ) -> dict[str, set[str]]: """Invert 'module imports X' into 'file X is used by module'. E.g.: if plugin.py imports selector.py, the result contains @@ -61,19 +63,32 @@ def _invert_dependency_graph( dependents[imported_path].add(importer_path) - return DependencyGraph(dependents=dict(dependents)) - + return dict(dependents) -def get_dependency_graph(repo_path: str = ".") -> DependencyGraph: - repo = resolve_repo(repo_path) - working_dir = repo.working_tree_dir - python_files = _find_py_files(working_dir) +def _build_file_dependency_graph( + python_files: list[str], + working_dir: str, +) -> DependencyGraph: graph = ImportVisitor(filenames=python_files, logger=logging.getLogger(__name__), root=working_dir) #graph.modules: module -> set of modules imported by it #graph.fullpaths: module -> absolute file path - module_to_path = _build_module_relative_path(graph.fullpaths, working_dir) - dependents_by_file = _invert_dependency_graph(graph.modules, module_to_path) # invert so it becomes module -> who IMPORTS it / who is affected by it + module_to_path = _convert_module_to_relative_path(graph.fullpaths, working_dir) + dependents = _invert_dependency_graph(graph.modules, module_to_path) # invert so it becomes module -> who IMPORTS it / who is affected by it + + return DependencyGraph(dependents=dependents) + + +def get_dependency_graph( + repo_path: str = ".", + #graph_level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, +) -> DependencyGraph: + repo = resolve_repo(repo_path) + working_dir = repo.working_tree_dir + python_files = _find_py_files(working_dir) + + # if graph_level == DIFF_LEVEL.FUNCTION: + # return _build_function_dependency_graph(python_files, working_dir) - return dependents_by_file + return _build_file_dependency_graph(python_files, working_dir) diff --git a/tests/selection/test_deps_graph.py b/tests/selection/test_deps_graph.py index 25925a7..f70006e 100644 --- a/tests/selection/test_deps_graph.py +++ b/tests/selection/test_deps_graph.py @@ -6,8 +6,7 @@ from pytest import mark from src.pytest_regsmart.selection.deps_graph import ( - DependencyGraph, - _build_module_relative_path, + _convert_module_to_relative_path, _find_py_files, _invert_dependency_graph, get_dependency_graph, @@ -56,17 +55,17 @@ def test_find_py_files_excludes_env_and_build_dirs(tmp_path): # --------------------------------------------------------------------------- -# _build_module_relative_path +# _convert_module_to_relative_path # --------------------------------------------------------------------------- -def test_build_module_relative_path(tmp_path): +def test_convert_module_to_relative_path(tmp_path): fullpaths = { "pkg.mod": str(tmp_path / "pkg" / "mod.py"), "pkg": str(tmp_path / "pkg" / "__init__.py"), } - result = _build_module_relative_path(fullpaths, str(tmp_path)) + result = _convert_module_to_relative_path(fullpaths, str(tmp_path)) assert result == { "pkg.mod": os.path.join("pkg", "mod.py"), @@ -126,7 +125,7 @@ def test_build_module_relative_path(tmp_path): def test_invert_dependency_graph_cases(module_imports,module_to_path,expected_dependents): graph = _invert_dependency_graph(module_imports, module_to_path) - assert graph == DependencyGraph(dependents=expected_dependents) + assert graph == expected_dependents # --------------------------------------------------------------------------- From ed6066ef9e09ae8d4950283e5fcce8fcf85c60a5 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 20 Aug 2026 00:18:46 -0300 Subject: [PATCH 088/122] feat: add a new function level CallGraph --- CHANGELOG.md | 5 + src/pytest_regsmart/selection/deps_graph.py | 119 +++++++++++++++++--- 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13631ed..ad5f220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] pytest-regsmart +## [0.6.2] - 2026-08-11 + +### Added +- New `--diff-level` flag to choose the diff granularity: `file` (default) or `function` +- Track deleted files in the diff result ## [0.6.1] - 2026-08-11 ### Changed diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py index c58b078..282288d 100644 --- a/src/pytest_regsmart/selection/deps_graph.py +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -3,20 +3,66 @@ import logging import os from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field +from pyan.analyzer import CallGraphVisitor from pyan.modvis import ImportVisitor +from pyan.node import Flavor -# from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL +from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL from .git_manager import resolve_repo #initially I'll try the simplest form using only files; functions are not needed yet #use the pyan3 API, not the CLI +_KEPT_FLAVORS = { + Flavor.FUNCTION, + Flavor.METHOD, + Flavor.CLASSMETHOD, + Flavor.STATICMETHOD, + Flavor.CLASS, +} #only functions, methods and classes on the fucntion callgraph (classes because of internal methods that can be desconsidered in git diff) + + +@dataclass(frozen=True, slots=True) +class FunctionMetadata: + filepath: str + start_line: int + end_line: int + + @dataclass class DependencyGraph: - dependents: dict[str, set[str]] #key: module, value: set of modules that depend on / are affected by it + dependents: dict[str, set[str]] #key: module (file level) or function_id (function level), value: set of modules that depend on / are affected by it + function_nodes: dict[str, FunctionMetadata] = field(default_factory=dict) #the key represents the funcion name/node name + + +def _extract_function_nodes( + graph: CallGraphVisitor, + working_dir: str, +) -> dict[str, FunctionMetadata]: + """Filters and converts pyan3 nodes into simpler function nodes indexed by name""" + + function_nodes: dict[str, FunctionMetadata] = {} + + for node_group in graph.nodes.values(): + for node in node_group: + if node.flavor not in _KEPT_FLAVORS or node.ast_node is None: + continue + + filepath = os.path.relpath(node.filename, working_dir) + if filepath.startswith(".."): + continue + + function_name = node.get_name() #combines the module (node.namespace) and the function name (node.name) + function_nodes[function_name] = FunctionMetadata( + filepath=filepath, + start_line=node.ast_node.lineno, + end_line=node.ast_node.end_lineno, + ) + + return function_nodes def _find_py_files(working_dir: str) -> list[str]: @@ -42,27 +88,34 @@ def _convert_module_to_relative_path(fullpaths, working_dir: str) -> dict[str, s } -def _invert_dependency_graph( - module_imports: dict[str, set[str]], - module_to_path: dict[str, str] +def _invert_dependency_map( + connections: dict[str, set[str]], + module_to_path: dict[str, str] | None = None # only necessary for file-level graphs ) -> dict[str, set[str]]: - """Invert 'module imports X' into 'file X is used by module'. + """Invert 'Y imports X' into 'X is used by Y'. E.g.: if plugin.py imports selector.py, the result contains - selector.py -> {plugin.py} (changing selector.py affects plugin.py) + selector.py -> {plugin.py} (changing selector.py affects plugin.py); OR changing run affects test_run() + + With module_to_path: translates module names to relative paths (file-level graph) + e.g.: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" + Without module_to_path: keeps ids (functions references) as-is (function-level graph) + e.g.: "pytest_regsmart.selector:selector" -> "pytest_regsmart.selector:plugin" (changing selector affects plugin) """ dependents = defaultdict(set) #dict that does not raise KeyError on missing keys; a dict subclass: https://www.geeksforgeeks.org/python/defaultdict-in-python/ - for importer, imported_modules in module_imports.items(): - importer_path = module_to_path.get(importer) - for imported in imported_modules: - imported_path = module_to_path.get(imported) + for importer, dependencies in connections.items(): + importer_path = importer if module_to_path is None else module_to_path.get(importer) + if importer_path is None: + continue #importer is not in the repo - just a precaution, should not happen + + for imported in dependencies: + imported_path = imported if module_to_path is None else module_to_path.get(imported) if imported_path is None: continue #doesnt import a file in the repo dependents[imported_path].add(importer_path) - return dict(dependents) @@ -75,20 +128,52 @@ def _build_file_dependency_graph( #graph.fullpaths: module -> absolute file path module_to_path = _convert_module_to_relative_path(graph.fullpaths, working_dir) - dependents = _invert_dependency_graph(graph.modules, module_to_path) # invert so it becomes module -> who IMPORTS it / who is affected by it + dependents = _invert_dependency_map(graph.modules, module_to_path) # invert so it becomes module -> who IMPORTS it / who is affected by it return DependencyGraph(dependents=dependents) +def _build_function_dependency_graph( + python_files: list[str], + working_dir: str, +) -> DependencyGraph: + graph = CallGraphVisitor( + filenames=python_files, + root=working_dir, + logger=logging.getLogger(__name__), + ) + + function_nodes = _extract_function_nodes(graph, working_dir) + + function_dependencies: defaultdict[str, set[str]] = defaultdict(set) + + for function_node, dependency_nodes in graph.uses_edges.items(): + function_name = function_node.get_name() + if function_name not in function_nodes: + continue #skip nodes that are not functions/methods/classes + + for dependency in dependency_nodes: + dependency_name = dependency.get_name() + if dependency_name in function_nodes: + function_dependencies[function_name].add(dependency_name) + + dependents = _invert_dependency_map(function_dependencies) #function_dependencies already have the function names, so no need to convert to paths + + return DependencyGraph( + dependents=dependents, + function_nodes=function_nodes, + ) + + def get_dependency_graph( repo_path: str = ".", - #graph_level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, + graph_level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, ) -> DependencyGraph: repo = resolve_repo(repo_path) working_dir = repo.working_tree_dir python_files = _find_py_files(working_dir) - # if graph_level == DIFF_LEVEL.FUNCTION: - # return _build_function_dependency_graph(python_files, working_dir) + if graph_level == DIFF_LEVEL.FUNCTION: + return _build_function_dependency_graph(python_files, working_dir) return _build_file_dependency_graph(python_files, working_dir) From dc8eadb7e42d1a08fb7206debb92c94d0bb76844 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 20 Aug 2026 10:29:18 -0300 Subject: [PATCH 089/122] test: add cases for function callgraph + docs: update changelog --- CHANGELOG.md | 2 +- tests/selection/test_deps_graph.py | 143 ++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5f220..0472476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] pytest-regsmart -## [0.6.2] - 2026-08-11 +## [0.6.2] - 2026-08-21 ### Added - New `--diff-level` flag to choose the diff granularity: `file` (default) or `function` diff --git a/tests/selection/test_deps_graph.py b/tests/selection/test_deps_graph.py index f70006e..2cc12d1 100644 --- a/tests/selection/test_deps_graph.py +++ b/tests/selection/test_deps_graph.py @@ -1,14 +1,19 @@ from __future__ import annotations +import logging import os from pathlib import Path +from pyan.analyzer import CallGraphVisitor from pytest import mark +from pytest_regsmart.const import DIFF_LEVEL from src.pytest_regsmart.selection.deps_graph import ( + FunctionMetadata, _convert_module_to_relative_path, + _extract_function_nodes, _find_py_files, - _invert_dependency_graph, + _invert_dependency_map, get_dependency_graph, ) @@ -74,7 +79,7 @@ def test_convert_module_to_relative_path(tmp_path): # --------------------------------------------------------------------------- -# _invert_dependency_graph +# _invert_dependency_map # --------------------------------------------------------------------------- @mark.parametrize( @@ -120,14 +125,67 @@ def test_convert_module_to_relative_path(tmp_path): { "app/service.py": {"app/main.py"}, }, + ), + ( + { + "a.f": {"b.g", "c.h"}, + "b.g": {"c.h"}, + }, + None, + { + "b.g": {"a.f"}, + "c.h": {"a.f", "b.g"}, + }, ), ]) -def test_invert_dependency_graph_cases(module_imports,module_to_path,expected_dependents): - graph = _invert_dependency_graph(module_imports, module_to_path) +def test_invert_dependency_map_cases(module_imports,module_to_path,expected_dependents): + graph = _invert_dependency_map(module_imports, module_to_path) assert graph == expected_dependents +# --------------------------------------------------------------------------- +# _extract_function_nodes +# --------------------------------------------------------------------------- + + +def test_extract_function_nodes_keeps_classes_and_methods(tmp_path): + _write(tmp_path, "pkg/__init__.py") + _write( + tmp_path, + "pkg/mod.py", + "class Calculator:\n" + " def add(self, a, b):\n" + " return a + b\n" + "\n" + "def make_calc():\n" + " return Calculator()\n", + ) + + graph = CallGraphVisitor( + filenames=[str(tmp_path / "pkg" / "mod.py")], + root=str(tmp_path), + logger=logging.getLogger(__name__), + ) + nodes = _extract_function_nodes(graph, str(tmp_path)) + + assert set(nodes) == { + "pkg.mod.Calculator", + "pkg.mod.Calculator.add", + "pkg.mod.make_calc", + } + assert nodes["pkg.mod.Calculator.add"] == FunctionMetadata( + filepath=os.path.join("pkg", "mod.py"), + start_line=2, + end_line=3, + ) + assert nodes["pkg.mod.make_calc"] == FunctionMetadata( + filepath=os.path.join("pkg", "mod.py"), + start_line=5, + end_line=6, + ) + + # --------------------------------------------------------------------------- # get_dependency_graph (end-to-end) # --------------------------------------------------------------------------- @@ -197,3 +255,80 @@ def test_get_dependency_graph_defaults_to_dot_repo(monkeypatch, git_repo): "mypkg/main.py", "tests/test_app.py", } + + +# --------------------------------------------------------------------------- +# get_dependency_graph (function-level) +# --------------------------------------------------------------------------- + + +def _build_function_sample_project(repo): + root = Path(repo.working_tree_dir) + _write(root, "mypkg/__init__.py") + _write( + root, + "mypkg/service.py", + "import os\n" + "\n" + "def run():\n" + " return os.getcwd()\n" + "\n" + "def helper():\n" + " return 42\n", + ) + _write( + root, + "mypkg/main.py", + "from .service import run\n" + "\n" + "def main():\n" + " return run()\n", + ) + _write( + root, + "tests/test_app.py", + "from mypkg.service import run, helper\n" + "\n" + "def test_app():\n" + " assert run()\n" + "\n" + "def test_helper():\n" + " assert helper()\n", + ) + + +def test_get_function_dependency_graph_end_to_end(git_repo): + _build_function_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FUNCTION) + + assert graph.dependents == { + "mypkg.service.run": {"mypkg.main.main", "tests.test_app.test_app"}, + "mypkg.service.helper": {"tests.test_app.test_helper"}, + } + + +def test_get_function_dependency_graph_keys_are_unit_ids(git_repo): + _build_function_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FUNCTION) + + assert "mypkg/service.py" not in graph.dependents + assert all("/" not in key and not key.endswith(".py") for key in graph.dependents) + assert set(graph.dependents) <= set(graph.function_nodes) + + +def test_get_function_dependency_graph_locations(git_repo): + _build_function_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FUNCTION) + + assert graph.function_nodes["mypkg.service.run"] == FunctionMetadata( + filepath="mypkg/service.py", start_line=3, end_line=4 + ) + assert graph.function_nodes["mypkg.service.helper"] == FunctionMetadata( + filepath="mypkg/service.py", start_line=6, end_line=7 + ) + assert graph.function_nodes["tests.test_app.test_helper"] == FunctionMetadata( + filepath="tests/test_app.py", start_line=6, end_line=7 + ) From 7e6fa100ccd80ebe0475b1b2e096fc40e6124b8f Mon Sep 17 00:00:00 2001 From: let Date: Fri, 21 Aug 2026 10:31:28 -0300 Subject: [PATCH 090/122] feat: base for line diff and function graph correlation --- src/pytest_regsmart/selection/deps_graph.py | 12 +- src/pytest_regsmart/selection/git_manager.py | 4 +- src/pytest_regsmart/selection/selector.py | 113 +++++++++++++++---- 3 files changed, 101 insertions(+), 28 deletions(-) diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py index 282288d..4543adf 100644 --- a/src/pytest_regsmart/selection/deps_graph.py +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -36,15 +36,17 @@ class FunctionMetadata: class DependencyGraph: dependents: dict[str, set[str]] #key: module (file level) or function_id (function level), value: set of modules that depend on / are affected by it function_nodes: dict[str, FunctionMetadata] = field(default_factory=dict) #the key represents the funcion name/node name + functions_by_file: dict[str, set[str]] = field(default_factory=dict) def _extract_function_nodes( graph: CallGraphVisitor, working_dir: str, -) -> dict[str, FunctionMetadata]: +) -> tuple[dict[str, FunctionMetadata], dict[str, set[str]]]: """Filters and converts pyan3 nodes into simpler function nodes indexed by name""" function_nodes: dict[str, FunctionMetadata] = {} + functions_by_file: dict[str, set[str]] = defaultdict(set) for node_group in graph.nodes.values(): for node in node_group: @@ -61,11 +63,12 @@ def _extract_function_nodes( start_line=node.ast_node.lineno, end_line=node.ast_node.end_lineno, ) + functions_by_file[filepath].add(function_name) - return function_nodes + return function_nodes, dict(functions_by_file) -def _find_py_files(working_dir: str) -> list[str]: +def _find_py_files(working_dir: str) -> list[str]: #mudar para utils talvez excludes = [".venv", ".git", "__pycache__", "dist", "build", "venv", "site-packages"] py_files = [] @@ -143,7 +146,7 @@ def _build_function_dependency_graph( logger=logging.getLogger(__name__), ) - function_nodes = _extract_function_nodes(graph, working_dir) + function_nodes, functions_by_files = _extract_function_nodes(graph, working_dir) function_dependencies: defaultdict[str, set[str]] = defaultdict(set) @@ -162,6 +165,7 @@ def _build_function_dependency_graph( return DependencyGraph( dependents=dependents, function_nodes=function_nodes, + functions_by_file=functions_by_files ) diff --git a/src/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py index 7d8cf3d..caf1c53 100644 --- a/src/pytest_regsmart/selection/git_manager.py +++ b/src/pytest_regsmart/selection/git_manager.py @@ -92,7 +92,7 @@ def parse_diff_output(raw_diff: str) -> dict[str, list[tuple[int, int]]]: return changed_line_ranges -def get_git_diff(repo_path: str = ".", graph_level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> DiffResult: +def get_git_diff(repo_path: str = ".", diff_level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> DiffResult: repo = resolve_repo(repo_path=repo_path) untracked_diff = repo.untracked_files @@ -108,7 +108,7 @@ def get_git_diff(repo_path: str = ".", graph_level: DIFF_LEVEL = DEFAULT_DIFF_LE working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() deleted_files = [path for path in working_dir_diff if _is_deleted(repo, merge_base_commit.hexsha, path)] - if graph_level == DIFF_LEVEL.FUNCTION: + if diff_level == DIFF_LEVEL.FUNCTION: raw_diff = repo.git.diff(merge_base_commit, unified=0) return DiffResult( diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index ad46b57..356cb3e 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -3,6 +3,8 @@ import os from dataclasses import dataclass +from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL + from .deps_graph import DependencyGraph, get_dependency_graph from .git_manager import DiffResult, get_git_diff @@ -14,46 +16,113 @@ class SelectionResult: branch: str -def _is_test_file(filepath: str) -> bool: +def _is_test_file(filepath: str) -> bool: #mudar para utils filename = os.path.basename(filepath) return filename.startswith("test_") or filename.endswith("_test.py") -def get_affected_tests(diff_result: DiffResult, deps_graph: DependencyGraph) -> list[str]: - #for each file modified in the diff, look up its node in the dependency graph and get the related value (dependents) - #loooking the dependents, keep only those that are tests (maybe via regex or file-name heuristic) - #i also want indirectly affected tests, not just direct dependents - - changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) - affected_tests = sorted(f for f in changed_files if _is_test_file(f)) - seen = set(changed_files) - to_check = list(changed_files) +def line_diff_match_function_ids( + diff_result: DiffResult, + deps_graph: DependencyGraph, +) -> set[str]: + """Project changed diff lines onto function-level call graph nodes. + Hunks outside every function (imports, constants) or files without + specific range (untracked, sem entrada em changed_line_ranges) caem + de volta para "todas as funções do arquivo" como aproximação conservadora. + """ + changed: set[str] = set() + + no_range_files = ( + set(diff_result.untracked_files) + | (set(diff_result.modified_files) - set(diff_result.changed_line_ranges)) + ) #checar se isso realmente funciona + + for filepath in no_range_files: + changed.update(deps_graph.functions_by_file.get(filepath, set())) + + for filepath, ranges in diff_result.changed_line_ranges.items(): + candidates = deps_graph.functions_by_file.get(filepath, set()) + if not candidates: + continue + + for start, end in ranges: + matched = { + fid for fid in candidates + if start <= deps_graph.function_nodes[fid].end_line + and deps_graph.function_nodes[fid].start_line <= end + } + changed.update(matched or candidates) # sem match preciso -> conservador + + return changed + + +def _get_affected_nodes( + changed_nodes: set[str], + deps_graph: DependencyGraph, +) -> set[str]: + """ For each file/function modified in the diff, look up its node in the dependency graph and get the related value (dependents) + #loooking the dependents, keep only those that are tests + #i also want indirectly affected tests, not just direct dependents""" + + seen = set(changed_nodes) + to_check = list(changed_nodes) while to_check: - current_file = to_check.pop() - dependents = deps_graph.dependents.get(current_file, set()) - + current_node = to_check.pop() + dependents = deps_graph.dependents.get(current_node, set()) + for dependent in dependents: if dependent in seen: continue - seen.add(dependent) #seen = dependent was discovered and will be visited - to_check.append(dependent) #even if not a test, check its dependents for indirect affected tests + seen.add(dependent) #seen = dependent was discovered and will be visited + to_check.append(dependent) #even if not a test, check its dependents for indirect affected tests - if _is_test_file(dependent): - affected_tests.append(dependent) + return seen - return sorted(affected_tests) + +def _get_affected_tests_at_file_level( + diff_result: DiffResult, + deps_graph: DependencyGraph, +) -> list[str]: + changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) + affected_nodes = _get_affected_nodes(changed_files, deps_graph) + + return sorted(node for node in affected_nodes if _is_test_file(node)) + + +def _get_affected_tests_at_function_level( + diff_result: DiffResult, + deps_graph: DependencyGraph, +) -> list[str]: + changed_function_ids = line_diff_match_function_ids(diff_result, deps_graph) + affected_function_ids = _get_affected_nodes(changed_function_ids, deps_graph) + + # The pytest integration still filters collected items by test *file*. + # Keep that public contract here even though propagation occurs per function. + return sorted( + { + deps_graph.function_nodes[function_id].filepath + for function_id in affected_function_ids + if _is_test_file(deps_graph.function_nodes[function_id].filepath) + } + ) -def run_rts() -> SelectionResult: +def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> SelectionResult: """[wip] Orchestrates the selection...""" - diff_result = get_git_diff() - deps_graph = get_dependency_graph() # maybe parallelize this with the git diff later + diff_result = get_git_diff(diff_level= level) + deps_graph = get_dependency_graph(graph_level= level) # maybe parallelize this with the git diff later + selected = ( + _get_affected_tests_at_function_level(diff_result, deps_graph) + if level == DIFF_LEVEL.FUNCTION + else _get_affected_tests_at_file_level(diff_result, deps_graph) + ) + return SelectionResult( - affected_tests=get_affected_tests(diff_result, deps_graph), + affected_tests=selected, has_diff=bool(diff_result.modified_files or diff_result.untracked_files), branch=diff_result.used_branch, ) From e7a4a51635892bd16396534d6ee3c30cc92f1f24 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 21 Aug 2026 11:01:03 -0300 Subject: [PATCH 091/122] refactor: reorganize some functions into utils.py --- src/pytest_regsmart/selection/deps_graph.py | 13 +------------ src/pytest_regsmart/selection/selector.py | 6 +----- src/pytest_regsmart/utils.py | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 17 deletions(-) create mode 100644 src/pytest_regsmart/utils.py diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py index 4543adf..559756d 100644 --- a/src/pytest_regsmart/selection/deps_graph.py +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -12,6 +12,7 @@ from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL from .git_manager import resolve_repo +from ..utils import _find_py_files #initially I'll try the simplest form using only files; functions are not needed yet #use the pyan3 API, not the CLI @@ -68,18 +69,6 @@ def _extract_function_nodes( return function_nodes, dict(functions_by_file) -def _find_py_files(working_dir: str) -> list[str]: #mudar para utils talvez - excludes = [".venv", ".git", "__pycache__", "dist", "build", "venv", "site-packages"] - - py_files = [] - for dirpath, dirnames, filenames in os.walk(working_dir): - dirnames[:] = [d for d in dirnames if d not in excludes and not d.endswith(".egg-info")] # keep only core directories to check - for filename in filenames: - if filename.endswith(".py"): - py_files.append(os.path.join(dirpath, filename)) #build the full path - return py_files - - def _convert_module_to_relative_path(fullpaths, working_dir: str) -> dict[str, str]: """Convert pyan3 module names to paths relative to the repo (to match the diff). diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index 356cb3e..44c49fc 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -7,6 +7,7 @@ from .deps_graph import DependencyGraph, get_dependency_graph from .git_manager import DiffResult, get_git_diff +from ..utils import _is_test_file @dataclass @@ -16,11 +17,6 @@ class SelectionResult: branch: str -def _is_test_file(filepath: str) -> bool: #mudar para utils - filename = os.path.basename(filepath) - return filename.startswith("test_") or filename.endswith("_test.py") - - def line_diff_match_function_ids( diff_result: DiffResult, deps_graph: DependencyGraph, diff --git a/src/pytest_regsmart/utils.py b/src/pytest_regsmart/utils.py new file mode 100644 index 0000000..a1c2299 --- /dev/null +++ b/src/pytest_regsmart/utils.py @@ -0,0 +1,18 @@ +import os + + +def _find_py_files(working_dir: str) -> list[str]: #mudar para utils talvez + excludes = [".venv", ".git", "__pycache__", "dist", "build", "venv", "site-packages"] + + py_files = [] + for dirpath, dirnames, filenames in os.walk(working_dir): + dirnames[:] = [d for d in dirnames if d not in excludes and not d.endswith(".egg-info")] # keep only core directories to check + for filename in filenames: + if filename.endswith(".py"): + py_files.append(os.path.join(dirpath, filename)) #build the full path + return py_files + + +def _is_test_file(filepath: str) -> bool: #mudar para utils + filename = os.path.basename(filepath) + return filename.startswith("test_") or filename.endswith("_test.py") From 394bc30b05ab6003b2c7a135bbb586b8b8d99f3b Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 00:58:03 -0300 Subject: [PATCH 092/122] feat(plugin.py): pytest nodeid selection based on diff level --- src/pytest_regsmart/plugin.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 4b036ee..ecc0f08 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -143,15 +143,28 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: ) selection_start_time = time.time() - selection = selector.run_rts() #vai passar a flag aqui e retorna dependendo do diff + selection = selector.run_rts(level=self.diff_level) #vai passar a flag aqui e retorna dependendo do diff self.branch = selection.branch self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time #vai entrar em condicional pra ver o tipo do diff if selection.affected_tests: selected_nodes = set(selection.affected_tests) - items[:] = [item for item in items if item.nodeid.split("::")[0] in selected_nodes] #filters only per file now, probably gotaa setup a flag - if not selection.has_diff: + items[:] = ( + [item for item in items if item.nodeid.split("::")[0] in selected_nodes] if self.diff_level == DIFF_LEVEL.FILE + else [ + item + for item in items + if any( + item.nodeid == selected_node + or item.nodeid.startswith(f"{selected_node}[") + for selected_node in selected_nodes + ) + ] + ) + + + if not selection.has_diff: #this should be moved to the beggining though self.warnings.append( "No diff detected: regression test selection was skipped. The value set for '--diff-level' will be ignored." ) From ca24baeb6fe700154fcb6865e2871ea949c23819 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 17:05:19 -0300 Subject: [PATCH 093/122] fix: properly convert pyan3 functionid to pytest nodeids --- src/pytest_regsmart/selection/selector.py | 24 +++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index 44c49fc..f35b815 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -5,9 +5,9 @@ from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL +from ..utils import _is_test_file from .deps_graph import DependencyGraph, get_dependency_graph from .git_manager import DiffResult, get_git_diff -from ..utils import _is_test_file @dataclass @@ -94,17 +94,33 @@ def _get_affected_tests_at_function_level( changed_function_ids = line_diff_match_function_ids(diff_result, deps_graph) affected_function_ids = _get_affected_nodes(changed_function_ids, deps_graph) - # The pytest integration still filters collected items by test *file*. - # Keep that public contract here even though propagation occurs per function. + # pytest filters collected items by their nodeid. A function graph id uses + # dotted Python names, while pytest uses ``path.py::Class::test``. return sorted( { - deps_graph.function_nodes[function_id].filepath + _function_id_to_pytest_nodeid( + function_id, deps_graph.function_nodes[function_id].filepath + ) for function_id in affected_function_ids if _is_test_file(deps_graph.function_nodes[function_id].filepath) } ) +def _function_id_to_pytest_nodeid(function_id: str, filepath: str) -> str: + """Translate a pyan3 function id into the equivalent pytest nodeid. + + tests.selection.test_selector.py.test_regsmart_requires_git_repo =>> + tests/selection/test_selector.py::test_regsmart_requires_git_repo + """ + + + module_name = os.path.splitext(filepath)[0].replace(os.sep, ".") + qualified_name = function_id.removeprefix(f"{module_name}.") + + return f"{filepath}::{qualified_name.replace('.', '::')}" + + def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> SelectionResult: """[wip] Orchestrates the selection...""" From f286d6599068107129757c6fb135b71501b67d2a Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 17:08:09 -0300 Subject: [PATCH 094/122] fix(ranking): add guard for no diff detected --- src/pytest_regsmart/ranking/rank_args.py | 2 ++ tests/ranking/test_ranking_heuristics.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/pytest_regsmart/ranking/rank_args.py b/src/pytest_regsmart/ranking/rank_args.py index 5818c7f..acf0539 100644 --- a/src/pytest_regsmart/ranking/rank_args.py +++ b/src/pytest_regsmart/ranking/rank_args.py @@ -62,6 +62,8 @@ def replay_type(string: str) -> str: def min_max_normalization(x: list[float]) -> np.ndarray: x = np.array(x) + if x.size == 0: + return x x_range = (np.max(x) - np.min(x)) x = (x - np.min(x)) / x_range if x_range else np.zeros(len(x)) return x diff --git a/tests/ranking/test_ranking_heuristics.py b/tests/ranking/test_ranking_heuristics.py index 0ef58bd..9485688 100644 --- a/tests/ranking/test_ranking_heuristics.py +++ b/tests/ranking/test_ranking_heuristics.py @@ -1,8 +1,13 @@ from __future__ import annotations +from pytest_regsmart.ranking.rank_args import min_max_normalization from tests.fake_test_data import test_class_one, test_method_one +def test_min_max_normalization_accepts_empty_input(): + assert min_max_normalization([]).tolist() == [] + + def test_default(mytester): mytester.makepyfile( test_method_one=test_method_one, From 0e2117d355921ac9e4b44ee0da062d0dc2a76717 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 17:53:54 -0300 Subject: [PATCH 095/122] fic: change default diff level from file to function --- src/pytest_regsmart/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytest_regsmart/const.py b/src/pytest_regsmart/const.py index 868ad78..c81df76 100644 --- a/src/pytest_regsmart/const.py +++ b/src/pytest_regsmart/const.py @@ -38,6 +38,6 @@ class DIFF_LEVEL(str, Enum): def __str__(self) -> str: return self.value -DEFAULT_DIFF_LEVEL = DIFF_LEVEL.FILE #for now... i'll change it later +DEFAULT_DIFF_LEVEL = DIFF_LEVEL.FUNCTION DIFF_HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") From e722b0f881300ae1da2dabd9eb2fd3a6c662bce7 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 20:54:03 -0300 Subject: [PATCH 096/122] style: org studies comments --- src/pytest_regsmart/plugin.py | 4 ++-- src/pytest_regsmart/selection/selector.py | 6 ++---- src/pytest_regsmart/utils.py | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index ecc0f08..79faa56 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -143,9 +143,9 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: ) selection_start_time = time.time() - selection = selector.run_rts(level=self.diff_level) #vai passar a flag aqui e retorna dependendo do diff + selection = selector.run_rts(level=self.diff_level) self.branch = selection.branch - self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time #vai entrar em condicional pra ver o tipo do diff + self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time if selection.affected_tests: selected_nodes = set(selection.affected_tests) diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index f35b815..acc25e1 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -23,9 +23,7 @@ def line_diff_match_function_ids( ) -> set[str]: """Project changed diff lines onto function-level call graph nodes. Hunks outside every function (imports, constants) or files without - specific range (untracked, sem entrada em changed_line_ranges) caem - de volta para "todas as funções do arquivo" como aproximação conservadora. - """ + specific range fallback to the entire file""" changed: set[str] = set() no_range_files = ( @@ -47,7 +45,7 @@ def line_diff_match_function_ids( if start <= deps_graph.function_nodes[fid].end_line and deps_graph.function_nodes[fid].start_line <= end } - changed.update(matched or candidates) # sem match preciso -> conservador + changed.update(matched or candidates) #not a specific match return changed diff --git a/src/pytest_regsmart/utils.py b/src/pytest_regsmart/utils.py index a1c2299..d1e1c41 100644 --- a/src/pytest_regsmart/utils.py +++ b/src/pytest_regsmart/utils.py @@ -1,7 +1,7 @@ import os -def _find_py_files(working_dir: str) -> list[str]: #mudar para utils talvez +def _find_py_files(working_dir: str) -> list[str]: excludes = [".venv", ".git", "__pycache__", "dist", "build", "venv", "site-packages"] py_files = [] @@ -13,6 +13,6 @@ def _find_py_files(working_dir: str) -> list[str]: #mudar para utils talvez return py_files -def _is_test_file(filepath: str) -> bool: #mudar para utils +def _is_test_file(filepath: str) -> bool: filename = os.path.basename(filepath) return filename.startswith("test_") or filename.endswith("_test.py") From d9727555ee3ebfe4b6038d9fa42657183a93d2b6 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 21:37:21 -0300 Subject: [PATCH 097/122] fix: adjust old tests based on new logic --- CHANGELOG.md | 4 ++-- tests/selection/test_deps_graph.py | 13 +++++++--- tests/selection/test_git.py | 8 +++---- tests/selection/test_selection.py | 34 +++++++++++++++++++++++---- tests/selection/test_selector_opts.py | 23 ++++++++++++++++++ 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0472476..42879e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] pytest-regsmart -## [0.6.2] - 2026-08-21 +## [0.6.2] - 2026-08-22 ### Added -- New `--diff-level` flag to choose the diff granularity: `file` (default) or `function` +- New `--diff-level` flag to choose the diff granularity: `function`pr (default) or `file` - Track deleted files in the diff result ## [0.6.1] - 2026-08-11 diff --git a/tests/selection/test_deps_graph.py b/tests/selection/test_deps_graph.py index 2cc12d1..bbfbf7e 100644 --- a/tests/selection/test_deps_graph.py +++ b/tests/selection/test_deps_graph.py @@ -167,7 +167,7 @@ def test_extract_function_nodes_keeps_classes_and_methods(tmp_path): root=str(tmp_path), logger=logging.getLogger(__name__), ) - nodes = _extract_function_nodes(graph, str(tmp_path)) + nodes, functions_by_file = _extract_function_nodes(graph, str(tmp_path)) assert set(nodes) == { "pkg.mod.Calculator", @@ -184,6 +184,13 @@ def test_extract_function_nodes_keeps_classes_and_methods(tmp_path): start_line=5, end_line=6, ) + assert functions_by_file == { + os.path.join("pkg", "mod.py"): { + "pkg.mod.Calculator", + "pkg.mod.Calculator.add", + "pkg.mod.make_calc", + } + } # --------------------------------------------------------------------------- @@ -208,7 +215,7 @@ def _build_sample_project(repo): def test_get_dependency_graph_end_to_end(git_repo): _build_sample_project(git_repo) - graph = get_dependency_graph(git_repo.working_tree_dir) + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) assert graph.dependents["mypkg/service.py"] == { "mypkg/main.py", @@ -249,7 +256,7 @@ def test_get_dependency_graph_defaults_to_dot_repo(monkeypatch, git_repo): "src.pytest_regsmart.selection.deps_graph.resolve_repo", lambda repo_path=".": git_repo ) - graph = get_dependency_graph() + graph = get_dependency_graph(graph_level=DIFF_LEVEL.FILE) assert graph.dependents["mypkg/service.py"] == { "mypkg/main.py", diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py index 9e3f767..cfef672 100644 --- a/tests/selection/test_git.py +++ b/tests/selection/test_git.py @@ -254,7 +254,7 @@ def test_get_git_diff_changed_line_ranges_edits(git_repo, commit_file, original, git_repo.git.branch("-m", "main") (Path(git_repo.working_tree_dir) / "file.py").write_text(edited) - assert get_git_diff(str(git_repo.working_tree_dir), graph_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == expected + assert get_git_diff(str(git_repo.working_tree_dir), diff_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == expected def test_get_git_diff_changed_line_ranges_committed_on_feature_branch(git_repo, commit_file): @@ -266,7 +266,7 @@ def test_get_git_diff_changed_line_ranges_committed_on_feature_branch(git_repo, git_repo.index.add("file.py") git_repo.index.commit("edit line 2") - assert get_git_diff(str(git_repo.working_tree_dir), graph_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {"file.py": [(2, 2)]} + assert get_git_diff(str(git_repo.working_tree_dir), diff_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {"file.py": [(2, 2)]} def test_get_git_diff_changed_line_ranges_deleted_file_absent(git_repo, commit_file): @@ -274,13 +274,13 @@ def test_get_git_diff_changed_line_ranges_deleted_file_absent(git_repo, commit_f git_repo.git.branch("-m", "main") os.remove(Path(git_repo.working_tree_dir) / "file.py") - assert get_git_diff(str(git_repo.working_tree_dir), graph_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {} + assert get_git_diff(str(git_repo.working_tree_dir), diff_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {} def test_get_git_diff_changed_line_ranges_no_commits(tmp_path): Repo.init(tmp_path) - assert get_git_diff(str(tmp_path), graph_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {} + assert get_git_diff(str(tmp_path), diff_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {} def test_get_git_diff_deleted_file(git_repo, commit_file): diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py index ac8d230..f831716 100644 --- a/tests/selection/test_selection.py +++ b/tests/selection/test_selection.py @@ -1,9 +1,15 @@ -from src.pytest_regsmart.selection.deps_graph import DependencyGraph +from src.pytest_regsmart.selection.deps_graph import ( + DependencyGraph, + FunctionMetadata, +) from src.pytest_regsmart.selection.git_manager import DiffResult -from src.pytest_regsmart.selection.selector import get_affected_tests +from src.pytest_regsmart.selection.selector import ( + _get_affected_tests_at_function_level, + _get_affected_tests_at_file_level, +) -def test_get_affected_tests_reaches_indirect_test_dependents(): +def test_get_affected_tests_reaches_indirect_test_files_dependents(): deps_graph = DependencyGraph( dependents={ "service.py": {"ranker.py"}, @@ -17,6 +23,26 @@ def test_get_affected_tests_reaches_indirect_test_dependents(): used_branch="main", ) - result = get_affected_tests(diff_result, deps_graph) + result = _get_affected_tests_at_file_level(diff_result, deps_graph) assert result == ["test_app.py"] + + +def test_function_level_selection_returns_pytest_nodeids(): + deps_graph = DependencyGraph( + dependents={"app.service.run": {"tests.test_app.test_run"}}, + function_nodes={ + "app.service.run": FunctionMetadata("app/service.py", 1, 2), + "tests.test_app.test_run": FunctionMetadata("tests/test_app.py", 3, 4), + }, + functions_by_file={"app/service.py": {"app.service.run"}}, + ) + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + changed_line_ranges={"app/service.py": [(1, 1)]}, + ) + + result = _get_affected_tests_at_function_level(diff_result, deps_graph) + + assert result == ["tests/test_app.py::test_run"] diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py index a624436..558c8a9 100644 --- a/tests/selection/test_selector_opts.py +++ b/tests/selection/test_selector_opts.py @@ -40,6 +40,29 @@ def test_selection_no_rank_only_affected_in_collection_order(selection_project): assert any("Using --no-rank (RTP disabled)." in x for x in out.outlines) +def test_function_level_selection_keeps_only_affected_test_functions( + selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest( + "-v", "--regsmart", "--diff-level=function", "--no-rank" + ) + + out.assert_outcomes(passed=3) + assert _ran_files(out) == ["test_other.py", "test_service.py"] + + +def test_function_level_selection_can_be_ranked(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest("-v", "--regsmart", "--diff-level=function") + + out.assert_outcomes(passed=3) + assert _ran_files(out) == ["test_other.py", "test_service.py"] + + def test_selection_with_rank_levels(selection_project): pytester, repo = selection_project pytester.runpytest("-v") From 14bbec7b5416d7496e6508be7b05dcfa4e773919 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 22:01:25 -0300 Subject: [PATCH 098/122] remove duplicate validation for diff level flag --- src/pytest_regsmart/plugin.py | 5 ----- src/pytest_regsmart/selection/selector.py | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 79faa56..121cc00 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -132,11 +132,6 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): return - if self.diff_level not in (level.value for level in DIFF_LEVEL): - raise argparse.ArgumentTypeError( - f"Invalid diff level: {self.diff_level}. {DIFF_LEVEL_HELP}" - ) - if self.replay_file and self.weights == [0, 0]: raise argparse.ArgumentTypeError( #or usage error "--rank-replay cannot be used together with random order." diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index acc25e1..2c93440 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -26,10 +26,10 @@ def line_diff_match_function_ids( specific range fallback to the entire file""" changed: set[str] = set() - no_range_files = ( + no_range_files = ( set(diff_result.untracked_files) | (set(diff_result.modified_files) - set(diff_result.changed_line_ranges)) - ) #checar se isso realmente funciona + ) #maybe broken for filepath in no_range_files: changed.update(deps_graph.functions_by_file.get(filepath, set())) From f43ecf298285b911907b1f6151d73aa182bb0d4f Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 22:41:14 -0300 Subject: [PATCH 099/122] new tests --- tests/selection/test_deps_graph.py | 10 ++ tests/selection/test_selection.py | 179 ++++++++++++++++++++++++- tests/selection/test_selection_args.py | 43 ++++++ tests/selection/test_selector_opts.py | 105 ++++++++++++++- 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 tests/selection/test_selection_args.py diff --git a/tests/selection/test_deps_graph.py b/tests/selection/test_deps_graph.py index bbfbf7e..0ae3d0f 100644 --- a/tests/selection/test_deps_graph.py +++ b/tests/selection/test_deps_graph.py @@ -339,3 +339,13 @@ def test_get_function_dependency_graph_locations(git_repo): assert graph.function_nodes["tests.test_app.test_helper"] == FunctionMetadata( filepath="tests/test_app.py", start_line=6, end_line=7 ) + + +def test_get_dependency_graph_defaults_to_function_level(git_repo): + _build_function_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir) + + assert "mypkg.service.run" in graph.dependents + assert graph.function_nodes + assert "mypkg/service.py" not in graph.dependents diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py index f831716..bd0efb7 100644 --- a/tests/selection/test_selection.py +++ b/tests/selection/test_selection.py @@ -1,11 +1,17 @@ +from pathlib import Path + +from src.pytest_regsmart.const import DIFF_LEVEL from src.pytest_regsmart.selection.deps_graph import ( DependencyGraph, FunctionMetadata, ) from src.pytest_regsmart.selection.git_manager import DiffResult from src.pytest_regsmart.selection.selector import ( - _get_affected_tests_at_function_level, + _function_id_to_pytest_nodeid, _get_affected_tests_at_file_level, + _get_affected_tests_at_function_level, + line_diff_match_function_ids, + run_rts, ) @@ -46,3 +52,174 @@ def test_function_level_selection_returns_pytest_nodeids(): result = _get_affected_tests_at_function_level(diff_result, deps_graph) assert result == ["tests/test_app.py::test_run"] + + +# --------------------------------------------------------------------------- +# line_diff_match_function_ids +# --------------------------------------------------------------------------- + + +def _function_graph() -> DependencyGraph: + return DependencyGraph( + dependents={ + "app.service.run": {"tests.test_app.test_run"}, + "app.service.helper": {"tests.test_app.test_helper"}, + "app.new.fresh": set(), + }, + function_nodes={ + "app.service.run": FunctionMetadata("app/service.py", 3, 4), + "app.service.helper": FunctionMetadata("app/service.py", 6, 7), + "app.new.fresh": FunctionMetadata("app/new.py", 1, 2), + "tests.test_app.test_run": FunctionMetadata("tests/test_app.py", 3, 4), + "tests.test_app.test_helper": FunctionMetadata("tests/test_app.py", 6, 7), + }, + functions_by_file={ + "app/service.py": {"app.service.run", "app.service.helper"}, + "app/new.py": {"app.new.fresh"}, + "tests/test_app.py": { + "tests.test_app.test_run", + "tests.test_app.test_helper", + }, + }, + ) + + +def test_line_diff_matches_only_touched_function(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + changed_line_ranges={"app/service.py": [(6, 7)]}, + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.service.helper"} + + +def test_line_diff_spanning_functions_matches_all_of_them(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + changed_line_ranges={"app/service.py": [(3, 7)]}, + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.service.run", "app.service.helper"} + + +def test_line_diff_outside_any_function_falls_back_to_whole_file(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + changed_line_ranges={"app/service.py": [(1, 1)]}, + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.service.run", "app.service.helper"} + + +def test_line_diff_modified_file_without_ranges_uses_whole_file(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.service.run", "app.service.helper"} + + +def test_line_diff_untracked_file_marks_all_its_functions(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=[], + untracked_files=["app/new.py"], + used_branch="main", + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.new.fresh"} + + +def test_line_diff_file_absent_from_graph_contributes_nothing(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["deleted/service.py"], + used_branch="main", + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == set() + + +# --------------------------------------------------------------------------- +# _function_id_to_pytest_nodeid +# --------------------------------------------------------------------------- + + +def test_nodeid_conversion_for_plain_function(): + nodeid = _function_id_to_pytest_nodeid( + "tests.test_app.test_run", "tests/test_app.py" + ) + + assert nodeid == "tests/test_app.py::test_run" + + +def test_nodeid_conversion_for_class_method(): + nodeid = _function_id_to_pytest_nodeid( + "pkg.mod.Calculator.add", "pkg/mod.py" + ) + + assert nodeid == "pkg/mod.py::Calculator::add" + + +# --------------------------------------------------------------------------- +# run_rts (end-to-end on a real repo) +# --------------------------------------------------------------------------- + + +def _commit_sample_project(git_repo, commit_file): + commit_file("service.py", "def run():\n return 42\n") + commit_file( + "test_app.py", + "from service import run\n" + "\n" + "def test_run():\n" + " assert run()\n", + ) + git_repo.git.branch("-m", "main") + + +def test_run_rts_function_level_returns_full_nodeids( + git_repo, commit_file, monkeypatch +): + _commit_sample_project(git_repo, commit_file) + Path(git_repo.working_tree_dir, "service.py").write_text( + "def run():\n return 0\n" + ) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(DIFF_LEVEL.FUNCTION) + + assert result.affected_tests == ["test_app.py::test_run"] + assert result.has_diff is True + assert result.branch == "main" + + +def test_run_rts_without_diff_selects_nothing(git_repo, commit_file, monkeypatch): + _commit_sample_project(git_repo, commit_file) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(DIFF_LEVEL.FUNCTION) + + assert result.affected_tests == [] + assert result.has_diff is False + assert result.branch == "main" diff --git a/tests/selection/test_selection_args.py b/tests/selection/test_selection_args.py new file mode 100644 index 0000000..5944c5a --- /dev/null +++ b/tests/selection/test_selection_args.py @@ -0,0 +1,43 @@ +import argparse + +import pytest + +from src.pytest_regsmart.const import DIFF_LEVEL +from src.pytest_regsmart.selection.selection_args import ( + level_type, + parse_diff_level, +) + + +class _FakeConfig: + def __init__(self, option: DIFF_LEVEL, ini: str = ""): + self._option = option + self._ini = ini + + def getoption(self, _name: str) -> DIFF_LEVEL: + return self._option + + def getini(self, _name: str) -> str: + return self._ini + + +def test_level_type_accepts_valid_levels(): + assert level_type("file") is DIFF_LEVEL.FILE + assert level_type("function") is DIFF_LEVEL.FUNCTION + + +def test_level_type_rejects_invalid_level(): + with pytest.raises(argparse.ArgumentTypeError, match="--diff-level"): + level_type("line") + + +def test_parse_diff_level_cli_value_wins_over_ini(): + config = _FakeConfig(option=DIFF_LEVEL.FILE, ini="function") + + assert parse_diff_level(config) is DIFF_LEVEL.FILE + + +def test_parse_diff_level_ini_used_when_cli_has_default(): + config = _FakeConfig(option=DIFF_LEVEL.FUNCTION, ini="file") + + assert parse_diff_level(config) is DIFF_LEVEL.FILE diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py index 558c8a9..f3ed81b 100644 --- a/tests/selection/test_selector_opts.py +++ b/tests/selection/test_selector_opts.py @@ -6,7 +6,6 @@ def _ran_files(out) -> list[str]: - """Arquivos de teste que apareceram como resultado no run (PASSED/FAILED).""" return sorted( { line.split("::")[0] @@ -16,10 +15,24 @@ def _ran_files(out) -> list[str]: ) +def _ran_nodeids(out) -> list[str]: + return sorted( + line.split(" ")[0] + for line in out.outlines + if "::" in line and "PASSED" in line + ) + + def _change(repo, filename: str, content: str) -> None: (Path(repo.working_tree_dir) / filename).write_text(content) +def _commit_new_file(repo, filename: str, content: str) -> None: + (Path(repo.working_tree_dir) / filename).write_text(content) + repo.index.add([filename]) + repo.index.commit(f"Add {filename}") + + def test_regsmart_requires_git_repo(pytester): pytester.makepyfile(test_method_one=test_method_one) @@ -63,6 +76,96 @@ def test_function_level_selection_can_be_ranked(selection_project): assert _ran_files(out) == ["test_other.py", "test_service.py"] +def test_function_level_selects_only_tests_of_changed_function(selection_project): + pytester, repo = selection_project + _commit_new_file( + repo, + "extra.py", + 'def alpha():\n return "a"\n\n\ndef beta():\n return "b"\n', + ) + _commit_new_file( + repo, + "test_extra.py", + "from extra import alpha, beta\n" + "\n" + 'def test_alpha():\n assert alpha() == "a"\n' + "\n" + 'def test_beta():\n assert beta() == "b"\n', + ) + _change( + repo, + "extra.py", + 'def alpha():\n return "a"\n\n\ndef beta():\n return "b" # touched\n', + ) + + out = pytester.runpytest("-v", "--regsmart", "--diff-level=function", "--no-rank") + + out.assert_outcomes(passed=1) + assert _ran_nodeids(out) == ["test_extra.py::test_beta"] + + +def test_function_level_runs_parametrized_variants_of_selected_node(selection_project): + pytester, repo = selection_project + _commit_new_file( + repo, + "test_params.py", + "import pytest\n" + "from service import run\n" + "\n" + '@pytest.mark.parametrize("value", [10, 20])\n' + "def test_run_value(value):\n" + " assert run() == 42\n", + ) + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest("-v", "--regsmart", "--diff-level=function", "--no-rank") + + out.assert_outcomes(passed=5) + nodeids = _ran_nodeids(out) + assert "test_params.py::test_run_value[10]" in nodeids + assert "test_params.py::test_run_value[20]" in nodeids + + +def test_diff_level_ini_option_used_when_cli_absent(selection_project): + pytester, repo = selection_project + _commit_new_file( + repo, + "extra.py", + 'def alpha():\n return "a"\n\n\ndef beta():\n return "b"\n', + ) + _commit_new_file( + repo, + "test_extra.py", + "from extra import alpha, beta\n" + "\n" + 'def test_alpha():\n assert alpha() == "a"\n' + "\n" + 'def test_beta():\n assert beta() == "b"\n', + ) + _change( + repo, + "extra.py", + 'def alpha():\n return "a"\n\n\ndef beta():\n return "b" # touched\n', + ) + (Path(repo.working_tree_dir) / "pytest.ini").write_text( + "[pytest]\nconsole_output_style = classic\ndiff_level = file\n" + ) + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=2) + assert _ran_files(out) == ["test_extra.py"] + + +def test_report_header_shows_diff_level(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest("--regsmart") + + assert any("Using --diff-level=function" in x for x in out.outlines) + + def test_selection_with_rank_levels(selection_project): pytester, repo = selection_project pytester.runpytest("-v") From 48426ead39ce8f4e12733e7df9202c87840ff305 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 22 Aug 2026 22:43:38 -0300 Subject: [PATCH 100/122] fix lint --- src/pytest_regsmart/selection/deps_graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py index 559756d..7f08573 100644 --- a/src/pytest_regsmart/selection/deps_graph.py +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -11,8 +11,8 @@ from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL -from .git_manager import resolve_repo from ..utils import _find_py_files +from .git_manager import resolve_repo #initially I'll try the simplest form using only files; functions are not needed yet #use the pyan3 API, not the CLI From 0789d70f0e9b5f0311c2cddf92bbddf1c6d6365f Mon Sep 17 00:00:00 2001 From: let Date: Sun, 23 Aug 2026 17:28:00 -0300 Subject: [PATCH 101/122] reduce unecessary tests --- tests/selection/test_deps_graph.py | 4 ++-- tests/selection/test_selector_opts.py | 23 ------------------- tests/test_options.py | 33 +++------------------------ 3 files changed, 5 insertions(+), 55 deletions(-) diff --git a/tests/selection/test_deps_graph.py b/tests/selection/test_deps_graph.py index 0ae3d0f..de46450 100644 --- a/tests/selection/test_deps_graph.py +++ b/tests/selection/test_deps_graph.py @@ -230,7 +230,7 @@ def test_get_dependency_graph_end_to_end(git_repo): def test_get_dependency_graph_drops_stdlib_and_non_repo_files(git_repo): _build_sample_project(git_repo) - graph = get_dependency_graph(git_repo.working_tree_dir) + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) for filepath in graph.dependents: assert filepath.endswith(".py") @@ -244,7 +244,7 @@ def test_get_dependency_graph_drops_stdlib_and_non_repo_files(git_repo): def test_get_dependency_graph_leaf_module_has_no_entry(git_repo): _build_sample_project(git_repo) - graph = get_dependency_graph(git_repo.working_tree_dir) + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) assert "mypkg/main.py" not in graph.dependents assert "tests/test_app.py" not in graph.dependents diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py index f3ed81b..3ce4ca4 100644 --- a/tests/selection/test_selector_opts.py +++ b/tests/selection/test_selector_opts.py @@ -53,29 +53,6 @@ def test_selection_no_rank_only_affected_in_collection_order(selection_project): assert any("Using --no-rank (RTP disabled)." in x for x in out.outlines) -def test_function_level_selection_keeps_only_affected_test_functions( - selection_project): - pytester, repo = selection_project - _change(repo, "service.py", "def run():\n return 42 # changed\n") - - out = pytester.runpytest( - "-v", "--regsmart", "--diff-level=function", "--no-rank" - ) - - out.assert_outcomes(passed=3) - assert _ran_files(out) == ["test_other.py", "test_service.py"] - - -def test_function_level_selection_can_be_ranked(selection_project): - pytester, repo = selection_project - _change(repo, "service.py", "def run():\n return 42 # changed\n") - - out = pytester.runpytest("-v", "--regsmart", "--diff-level=function") - - out.assert_outcomes(passed=3) - assert _ran_files(out) == ["test_other.py", "test_service.py"] - - def test_function_level_selects_only_tests_of_changed_function(selection_project): pytester, repo = selection_project _commit_new_file( diff --git a/tests/test_options.py b/tests/test_options.py index 3f683e8..6dd4370 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -58,49 +58,22 @@ def test_random_order(mytester): test_class_one=test_class_one, ) - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-weight", - "Using --rank-level", - "Using --rank-hist-len", - "Using --rank-seed", - "Time to reorder tests (s)", - "Time to collect test features (s)", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 - - args = ["-v", "--regsmart"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 - args = ["-v", "--regsmart", "--rank-weight=0-0"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 + assert len([x for x in out.outlines if x.startswith("Using --rank-seed=")]) == 1 test_lines_default1 = [x for x in out.outlines if "::" in x] args = ["-v", "--regsmart", "--rank-weight=0.0-0.0", "--rank-seed=8"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=8", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 + assert len([x for x in out.outlines if x.startswith("Using --rank-seed=8")]) == 1 test_lines_1 = [x for x in out.outlines if "::" in x] args = ["-v", "--regsmart", "--rank-weight=0-0", "--rank-seed=16"] out = mytester.runpytest(*args) out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=16", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 + assert len([x for x in out.outlines if x.startswith("Using --rank-seed=16")]) == 1 test_lines_2 = [x for x in out.outlines if "::" in x] assert test_lines_default1 != test_lines_1 != test_lines_2 From 6dc7e7ba9ba1099ab4f6c87011a8f303de616978 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 23 Aug 2026 21:14:34 -0300 Subject: [PATCH 102/122] perf(selection): skip dependency graph generation when no diff is detected --- src/pytest_regsmart/plugin.py | 21 +++++++++------------ src/pytest_regsmart/selection/selector.py | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 121cc00..e99083e 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -44,7 +44,6 @@ def pytest_addoption(parser: Parser) -> None: action="store_true", help=PLUGIN_HELP) - #new flag to keep both file/line options group.addoption( "--diff-level", action="store", @@ -142,6 +141,15 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: self.branch = selection.branch self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time + if not selection.has_diff: #maybe i should separate return items though + self.warnings.append( + "No diff detected: regression test selection was skipped. The value set for '--diff-level' will be ignored." + ) + if self.no_rank: + self.warnings.append( + "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." + ) + if selection.affected_tests: selected_nodes = set(selection.affected_tests) @@ -158,17 +166,6 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: ] ) - - if not selection.has_diff: #this should be moved to the beggining though - self.warnings.append( - "No diff detected: regression test selection was skipped. The value set for '--diff-level' will be ignored." - ) - if self.no_rank: - self.warnings.append( - "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." - ) - - if not self.no_rank: ranker.run_rtp( items, self.level, self.weights, diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index 2c93440..d2398ff 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -123,7 +123,17 @@ def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> SelectionResult: """[wip] Orchestrates the selection...""" diff_result = get_git_diff(diff_level= level) - deps_graph = get_dependency_graph(graph_level= level) # maybe parallelize this with the git diff later + has_diff=bool(diff_result.modified_files or diff_result.untracked_files) + used_branch = diff_result.used_branch + + if not has_diff: + return SelectionResult( + affected_tests=[], + has_diff=has_diff, + branch=used_branch + ) + + deps_graph = get_dependency_graph(graph_level= level) selected = ( _get_affected_tests_at_function_level(diff_result, deps_graph) @@ -133,6 +143,6 @@ def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> SelectionResult: return SelectionResult( affected_tests=selected, - has_diff=bool(diff_result.modified_files or diff_result.untracked_files), - branch=diff_result.used_branch, + has_diff=has_diff, + branch=used_branch ) From 28a4aae21d0d840da65a275fdd3d4bf487983834 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 23 Aug 2026 21:22:40 -0300 Subject: [PATCH 103/122] docs: update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b9b03d..8a35bea 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ When `--regsmart` is used, `pytest-regsmart` runs a Regression Test Selection st 1. **Compute the changed files.** It uses git to inspect the working tree against the default branch: - the default branch is resolved from `refs/remotes/origin/HEAD`, falling back to `main`/`master`, and finally to the currently active branch; - it collects both modified (`staged` + `unstaged`) and untracked files. If the repository has no commits yet, only untracked files are considered. -2. **Build a dependency graph.** Using [`pyan3`](https://pypi.org/project/pyan3/), it parses every `*.py` file in the repository (excluding `.venv`, `venv`, `.git`, `__pycache__`, `dist`, `build`) and builds a module-level import graph. This graph is then inverted so that, for each module, it knows *which* other modules depend on it. +2. **Build a dependency graph when changes exist.** If a diff is detected, [`pyan3`](https://pypi.org/project/pyan3/) parses every `*.py` file in the repository (excluding `.venv`, `venv`, `.git`, `__pycache__`, `dist`, `build`) and builds a module-level import graph. This graph is then inverted so that, for each module, it knows *which* other modules depend on it. 3. **Propagate changes transitively.** A BFS traversal starts from the changed and untracked files and walks through their dependents, collecting any test file (files named `test_*.py` or `*_test.py`) that is affected directly or indirectly. 4. **Filter the test suite.** Test items whose file is not in the selected set are removed from the run; only affected tests are actually executed. -If there is no diff since the baseline, the selection is skipped (a warning is reported) and the full test suite runs. +If there is no diff since the baseline, `pytest-regsmart` skips both dependency-graph generation and test selection, reports a warning, and runs the full test suite. RTP still runs unless `--no-rank` is set; with both no diff and `--no-rank`, the plugin reports that it has no work to do. Because selection works at the **module/file level**, it is intentionally conservative: a change in one module selects every test file that transitively depends on it, which may include more tests than strictly necessary. Finer (e.g. function-level) granularity is planned as future work. From b3a2c30a6309065c24af5bc1f94b58d4431ad3cc Mon Sep 17 00:00:00 2001 From: let Date: Sun, 23 Aug 2026 22:11:14 -0300 Subject: [PATCH 104/122] refactor: add typealias for better readability --- src/pytest_regsmart/selection/deps_graph.py | 29 +++++++++++++------- src/pytest_regsmart/selection/git_manager.py | 11 ++++++-- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py index 7f08573..007002d 100644 --- a/src/pytest_regsmart/selection/deps_graph.py +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -4,6 +4,7 @@ import os from collections import defaultdict from dataclasses import dataclass, field +from typing import TypeAlias from pyan.analyzer import CallGraphVisitor from pyan.modvis import ImportVisitor @@ -33,21 +34,29 @@ class FunctionMetadata: end_line: int +NodeId: TypeAlias = str #file or function level according to diff-level +DependenciesMap: TypeAlias = dict[NodeId, set[NodeId]] # node -> who it depends on +DependentsMap: TypeAlias = dict[NodeId, set[NodeId]] # node -> who depends on it +FunctionNodes: TypeAlias = dict[str, FunctionMetadata] +FunctionsByFile: TypeAlias = dict[str, set[str]] +ModuleToPath: TypeAlias = dict[str, str] + + @dataclass class DependencyGraph: - dependents: dict[str, set[str]] #key: module (file level) or function_id (function level), value: set of modules that depend on / are affected by it - function_nodes: dict[str, FunctionMetadata] = field(default_factory=dict) #the key represents the funcion name/node name - functions_by_file: dict[str, set[str]] = field(default_factory=dict) + dependents: DependentsMap #key: module (file level) or function_id (function level), value: set of modules that depend on / are affected by it + function_nodes: FunctionNodes = field(default_factory=dict) #the key represents the funcion name/node name + functions_by_file: FunctionsByFile = field(default_factory=dict) def _extract_function_nodes( graph: CallGraphVisitor, working_dir: str, -) -> tuple[dict[str, FunctionMetadata], dict[str, set[str]]]: +) -> tuple[FunctionNodes, FunctionsByFile]: """Filters and converts pyan3 nodes into simpler function nodes indexed by name""" - function_nodes: dict[str, FunctionMetadata] = {} - functions_by_file: dict[str, set[str]] = defaultdict(set) + function_nodes: FunctionNodes = {} + functions_by_file: FunctionsByFile = defaultdict(set) for node_group in graph.nodes.values(): for node in node_group: @@ -69,7 +78,7 @@ def _extract_function_nodes( return function_nodes, dict(functions_by_file) -def _convert_module_to_relative_path(fullpaths, working_dir: str) -> dict[str, str]: +def _convert_module_to_relative_path(fullpaths: ModuleToPath, working_dir: str) -> ModuleToPath: """Convert pyan3 module names to paths relative to the repo (to match the diff). E.g.: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" @@ -81,9 +90,9 @@ def _convert_module_to_relative_path(fullpaths, working_dir: str) -> dict[str, s def _invert_dependency_map( - connections: dict[str, set[str]], - module_to_path: dict[str, str] | None = None # only necessary for file-level graphs - ) -> dict[str, set[str]]: + connections: DependenciesMap, + module_to_path: ModuleToPath | None = None # only necessary for file-level graphs + ) -> DependentsMap: """Invert 'Y imports X' into 'X is used by Y'. E.g.: if plugin.py imports selector.py, the result contains diff --git a/src/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py index caf1c53..9e9661c 100644 --- a/src/pytest_regsmart/selection/git_manager.py +++ b/src/pytest_regsmart/selection/git_manager.py @@ -2,6 +2,7 @@ import os from dataclasses import dataclass, field +from typing import TypeAlias from git import Repo from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError @@ -9,13 +10,17 @@ from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_HUNK_HEADER, DIFF_LEVEL +LineRange: TypeAlias = tuple[int, int] +ChangedLineRanges: TypeAlias = dict[str, list[LineRange]] + + @dataclass class DiffResult: used_branch: str modified_files: list[str] = field(default_factory=list) #staged+unstaged untracked_files: list[str] = field(default_factory=list) #brand new files - maybe add a future flag to look only at unstaged+untracked tests deleted_files: list[str] = field(default_factory=list) - changed_line_ranges: dict[str, list[tuple[int, int]]] = field(default_factory=dict) #only for DIFF_LEVEL.FUNCTION + changed_line_ranges: ChangedLineRanges = field(default_factory=dict) #only for DIFF_LEVEL.FUNCTION def resolve_repo(repo_path: str = ".") -> Repo: @@ -62,9 +67,9 @@ def _is_deleted(repo: Repo, merge_base_hash: str, path: str) -> bool: return False -def parse_diff_output(raw_diff: str) -> dict[str, list[tuple[int, int]]]: +def parse_diff_output(raw_diff: str) -> ChangedLineRanges: """Parses `git diff -U0` and returns the changed line ranges for each file from the entire repo""" - changed_line_ranges: dict[str, list[tuple[int, int]]] = {} + changed_line_ranges: ChangedLineRanges = {} current_file: str | None = None for line in raw_diff.splitlines(): From e0a9a780652e2131ef2adaee63c1aa43c5273aa3 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 23 Aug 2026 22:50:00 -0300 Subject: [PATCH 105/122] fix: replace argparse.ArgumentTypeError with pytest.UsageError for better error handling --- src/pytest_regsmart/plugin.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index e99083e..1fc9d77 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -130,11 +130,6 @@ def __init__(self, config: Config) -> None: def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): return - - if self.replay_file and self.weights == [0, 0]: - raise argparse.ArgumentTypeError( #or usage error - "--rank-replay cannot be used together with random order." - ) selection_start_time = time.time() selection = selector.run_rts(level=self.diff_level) @@ -218,5 +213,11 @@ def pytest_configure(config: Config) -> None: "--regsmart requires a git repository." ) + if config.getoption("--rank-replay") and config.getoption("--rank-weight") == "0-0": + raise pytest.UsageError( + "--rank-replay cannot be used together with random order." + ) + + runner = PluginRunner(config) config.pluginmanager.register(runner) From 8880e015fd850d03aea3fc0bc950a61659ee1190 Mon Sep 17 00:00:00 2001 From: let Date: Sun, 23 Aug 2026 22:52:40 -0300 Subject: [PATCH 106/122] fix lint and more basic stuff --- src/pytest_regsmart/plugin.py | 22 ++++++++------------ src/pytest_regsmart/selection/git_manager.py | 1 - 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 1fc9d77..0ab4e9e 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -1,6 +1,5 @@ from __future__ import annotations -import argparse import time import pytest @@ -56,8 +55,7 @@ def pytest_addoption(parser: Parser) -> None: "--no-rank", action="store_true", default=False, - help=NO_RANK_HELP, - dest="no_rank") #maybe unecessary + help=NO_RANK_HELP) group._addoption( "--rank-level", @@ -125,7 +123,6 @@ def __init__(self, config: Config) -> None: self.hist_len = rank_args.parse_hist_len(config) self.seed = rank_args.parse_seed(config) - @pytest.hookimpl(trylast=True) def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): @@ -151,14 +148,14 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: items[:] = ( [item for item in items if item.nodeid.split("::")[0] in selected_nodes] if self.diff_level == DIFF_LEVEL.FILE else [ - item - for item in items - if any( - item.nodeid == selected_node - or item.nodeid.startswith(f"{selected_node}[") - for selected_node in selected_nodes - ) - ] + item + for item in items + if any( + item.nodeid == selected_node + or item.nodeid.startswith(f"{selected_node}[") + for selected_node in selected_nodes + ) + ] ) if not self.no_rank: @@ -177,7 +174,6 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: def pytest_report_header(self, config: Config) -> str | None: return reporter_mod.pytest_report_header(config) - def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None: start_time = time.time() extractor.compute_test_features( diff --git a/src/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py index 9e9661c..dfa292e 100644 --- a/src/pytest_regsmart/selection/git_manager.py +++ b/src/pytest_regsmart/selection/git_manager.py @@ -9,7 +9,6 @@ from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_HUNK_HEADER, DIFF_LEVEL - LineRange: TypeAlias = tuple[int, int] ChangedLineRanges: TypeAlias = dict[str, list[LineRange]] From 9b2db9ab6ed37b88ffafddc790b1bbe8f3ed9c1c Mon Sep 17 00:00:00 2001 From: let Date: Sun, 23 Aug 2026 23:28:38 -0300 Subject: [PATCH 107/122] refactor(plugin): function separation + self.branch type --- src/pytest_regsmart/plugin.py | 67 +++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 0ab4e9e..944a3e1 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -36,6 +36,40 @@ from .selection import git_manager, selection_args, selector +def _validate_options(config: Config) -> None: + if config.getoption("--regsmart") and config.getoption("--no-rank"): + for arg in config.invocation_params.args: + if arg.startswith("--rank-"): + raise pytest.UsageError( + "--no-rank cannot be used together with other ranking flags. It excludes RTP." + ) + + if config.getoption("--regsmart") and not git_manager.verify_git_repo(): + raise pytest.UsageError( + "--regsmart requires a git repository." + ) + + if config.getoption("--rank-replay") and config.getoption("--rank-weight") == "0-0": + raise pytest.UsageError( + "--rank-replay cannot be used together with random order." + ) + + +def _select_pytest_items_for_rtp(self, items, selected_nodes) -> None: + items[:] = ( + [item for item in items if item.nodeid.split("::")[0] in selected_nodes] if self.diff_level == DIFF_LEVEL.FILE + else [ + item + for item in items + if any( + item.nodeid == selected_node + or item.nodeid.startswith(f"{selected_node}[") + for selected_node in selected_nodes + ) + ] + ) + + def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("regsmart", "pytest-regsmart") group._addoption( @@ -114,7 +148,7 @@ def __init__(self, config: Config) -> None: self.warnings: list[str] = [] self.monitor = Monitor() - self.branch = "" # will be set after selection + self.branch = str | None self.diff_level = selection_args.parse_diff_level(config) self.no_rank = rank_args.parse_no_rank(config) self.weights = rank_args.parse_rtp_weights(config) @@ -145,18 +179,7 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if selection.affected_tests: selected_nodes = set(selection.affected_tests) - items[:] = ( - [item for item in items if item.nodeid.split("::")[0] in selected_nodes] if self.diff_level == DIFF_LEVEL.FILE - else [ - item - for item in items - if any( - item.nodeid == selected_node - or item.nodeid.startswith(f"{selected_node}[") - for selected_node in selected_nodes - ) - ] - ) + _select_pytest_items_for_rtp(self, items, selected_nodes) if not self.no_rank: ranker.run_rtp( @@ -197,23 +220,7 @@ def pytest_terminal_summary( @pytest.hookimpl(trylast=True) def pytest_configure(config: Config) -> None: - if config.getoption("--regsmart") and config.getoption("--no-rank"): - for arg in config.invocation_params.args: - if arg.startswith("--rank-"): - raise pytest.UsageError( - "--no-rank cannot be used together with other ranking flags. It excludes RTP." - ) - - if config.getoption("--regsmart") and not git_manager.verify_git_repo(): - raise pytest.UsageError( - "--regsmart requires a git repository." - ) - - if config.getoption("--rank-replay") and config.getoption("--rank-weight") == "0-0": - raise pytest.UsageError( - "--rank-replay cannot be used together with random order." - ) - + _validate_options(config) runner = PluginRunner(config) config.pluginmanager.register(runner) From eaafc45a3c976a6e0f5099560eb6cbcc5b1ea660 Mon Sep 17 00:00:00 2001 From: let Date: Mon, 24 Aug 2026 23:08:37 -0300 Subject: [PATCH 108/122] refactor reporter - ranking log --- README.md | 2 +- src/pytest_regsmart/ranking/ranker.py | 2 +- tests/test_options.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8a35bea..e6f6bff 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ After the test run finishes, the terminal summary will show the overhead of `pyt ``` =================================== pytest-regsmart summary info ==================================== Time to run the regression test selection (s): 0.0003604120544433594 -Time to reorder tests (s): 0.0004608631134033203 +Time to run the regression test prioritization (s): 0.0004608631134033203 Time to collect test features (s): 0.0004608631134033203 ``` diff --git a/src/pytest_regsmart/ranking/ranker.py b/src/pytest_regsmart/ranking/ranker.py index ce06dc9..bbf820d 100644 --- a/src/pytest_regsmart/ranking/ranker.py +++ b/src/pytest_regsmart/ranking/ranker.py @@ -105,4 +105,4 @@ def run_rtp( ) items[:] = od_items + nod_items - log_dict["Time to reorder tests (s)"] = time.time() - start_time + log_dict["Time to run the regression test prioritization (s)"] = time.time() - start_time diff --git a/tests/test_options.py b/tests/test_options.py index 6dd4370..a6db606 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -23,7 +23,7 @@ def test_logging(mytester): "Using --rank-level", "Using --rank-hist-len", "Using --rank-seed", - "Time to reorder tests (s)", + "Time to run the regression test prioritization (s)", "Time to collect test features (s)", ) From 08a644b37be73f94927bf65c4b791fa32ce15e3c Mon Sep 17 00:00:00 2001 From: let Date: Mon, 24 Aug 2026 23:47:40 -0300 Subject: [PATCH 109/122] more accurate readme --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e6f6bff..d652b43 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Before the test run starts, if `--regsmart` is passed, the terminal header will ``` Starting Smart Regression Test Management (RTS + RTP) +Using --diff-level=function Using --rank-weight=1-0 Using --rank-level=put Using --rank-hist-len=50 @@ -48,20 +49,6 @@ Time to run the regression test prioritization (s): 0.0004608631134033203 Time to collect test features (s): 0.0004608631134033203 ``` -## How Regression Test Selection (RTS) works - -When `--regsmart` is used, `pytest-regsmart` runs a Regression Test Selection step before the tests are executed: - -1. **Compute the changed files.** It uses git to inspect the working tree against the default branch: - - the default branch is resolved from `refs/remotes/origin/HEAD`, falling back to `main`/`master`, and finally to the currently active branch; - - it collects both modified (`staged` + `unstaged`) and untracked files. If the repository has no commits yet, only untracked files are considered. -2. **Build a dependency graph when changes exist.** If a diff is detected, [`pyan3`](https://pypi.org/project/pyan3/) parses every `*.py` file in the repository (excluding `.venv`, `venv`, `.git`, `__pycache__`, `dist`, `build`) and builds a module-level import graph. This graph is then inverted so that, for each module, it knows *which* other modules depend on it. -3. **Propagate changes transitively.** A BFS traversal starts from the changed and untracked files and walks through their dependents, collecting any test file (files named `test_*.py` or `*_test.py`) that is affected directly or indirectly. -4. **Filter the test suite.** Test items whose file is not in the selected set are removed from the run; only affected tests are actually executed. - -If there is no diff since the baseline, `pytest-regsmart` skips both dependency-graph generation and test selection, reports a warning, and runs the full test suite. RTP still runs unless `--no-rank` is set; with both no diff and `--no-rank`, the plugin reports that it has no work to do. - -Because selection works at the **module/file level**, it is intentionally conservative: a change in one module selects every test file that transitively depends on it, which may include more tests than strictly necessary. Finer (e.g. function-level) granularity is planned as future work. ### Disabling ranking (RTP) @@ -81,6 +68,19 @@ Using --no-rank (RTP disabled). Note that `--no-rank` **cannot be combined with other `--rank-*` flags**: passing any of them together raises a `UsageError`. It only disables the prioritization step; the regression test selection still applies. +### Choosing the diff granularity + +You can set at which level the changes are identified and tests are selected, by passing the optional `--diff-level` flag in one of these values: `function`, `file`. For example: + +```bash +pytest --regsmart --diff-level=file +``` + +- `function` (default): maps each changed line range from the git diff to the functions or methods that contain it, then selects only the test functions affected directly or transitively through the call graph. Diff hunks outside any function (e.g., imports or module constants) fall back to selecting the whole file. +- `file`: a change in one module selects every test file that transitively depends on it, which may include more tests than strictly necessary. + +This option can also be configured via the `diff_level` ini option (see [Setting configurable options via config file](#setting-configurable-options-via-config-file)). + ### Optimizing test prioritization heuristics You can set the weights of different test prioritization heuristics by passing the optional `--rank-weight` flag with formatted values: @@ -168,6 +168,32 @@ rank_hist_len=30 and run `pytest --regsmart` on the command line. +## How Regression Test Selection (RTS) works + +When `--regsmart` is used, `pytest-regsmart` runs a Regression Test Selection step before the tests are executed: + +1. **Compute the changed files.** It uses git to inspect the working tree against the default branch: + - the default branch is resolved from `refs/remotes/origin/HEAD`, falling back to `main`/`master`, and finally to the currently active branch; + - it collects both modified (`staged` + `unstaged`) and untracked files. If the repository has no commits yet, only untracked files are considered. +2. **Build a dependency graph when changes exist.** If a diff is detected, [`pyan3`](https://pypi.org/project/pyan3/) parses every `*.py` file in the repository (excluding `.venv`, `venv`, `.git`, `__pycache__`, `dist`, `build`) and builds a dependency graph whose granularity follows `--diff-level`: a function-level call graph when `function` (default), or a module-level import graph when `file`. The graph is then inverted so that, for each node, it knows *which* other nodes depend on it. +3. **Propagate changes transitively.** A BFS traversal starts from the changed units — changed files at the `file` level, or the functions containing the changed lines at the `function` level — and walks through their dependents, collecting every test unit affected directly or indirectly: test files (files named `test_*.py` or `*_test.py`) or individual test functions, respectively. +4. **Filter the test suite.** At the `file` level, test items whose file is not in the selected set are removed; at the `function` level, only collected items matching a selected pytest nodeid are kept. Only affected tests are actually executed. + +If there is no diff since the baseline, `pytest-regsmart` skips both dependency-graph generation and test selection, reports a warning, and runs the full test suite. RTP still runs unless `--no-rank` is set; with both no diff and `--no-rank`, the plugin reports that it has no work to do. + +Because selection granularity follows `--diff-level`, the default `function` level narrows the selection down to the affected test functions, while `file` selects whole files and is therefore intentionally conservative: a change in one module selects every test file that transitively depends on it, which may include more tests than strictly necessary. See [Choosing the diff granularity](#choosing-the-diff-granularity). + +## How Regression Test Prioritization (RTP) works + +After selection, `pytest-regsmart` reorders the remaining tests so that failures are exposed sooner. Each test receives a priority score computed as the weighted sum of two heuristics: + +- **Faster tests first**, based on their recorded execution durations from previous runs; +- **Recently failed tests first**, based on how many runs have passed since each test's last failure. + +Weights are set with `--rank-weight`, normalized to sum 1 (default `1-0`, speed only), and scores are aggregated per group according to `--rank-level` (`put`, `function`, or `module`). Two special modes replace these heuristics: replaying a fixed order listed in a text file (`--rank-replay`) and random order (`--rank-weight=0-0`, seeded by `--rank-seed`). Tests carrying an `order` or `dependency` marker always run first, in their declared order. + +See [Usage](#usage) for all available options. + ## Deployment (old) `pytest-regsmart` is easy to deploy into CI workflow, please see [deployment](./DEPLOYMENT.md). @@ -206,27 +232,32 @@ tox -- -k "test_name" ## Compatibility -`pytest-regsmart` works with [test selection](https://docs.pytest.org/en/6.2.x/usage.html#specifying-tests-selecting-tests) and [parallelization](https://pypi.org/project/pytest-xdist). +`pytest-regsmart` works with [_pytest_ test filtering](https://docs.pytest.org/en/6.2.x/usage.html#specifying-tests-selecting-tests) and [parallelization](https://pypi.org/project/pytest-xdist). It also works with plugins for ordering tests, e.g., [pytest-order](https://pypi.org/project/pytest-order), [pytest-dependency](https://pypi.org/project/pytest-dependency) by running ordered tests first in their declared order. Pytest options that order tests generally (e.g., [`--ff`](https://docs.pytest.org/en/stable/how-to/cache.html#usage)), or plugins that randomly order tests (e.g., [pytest-randomly](https://github.com/pytest-dev/pytest-randomly), [pytest-random-order](https://github.com/pytest-dev/pytest-random-order), [pytest-reverse](https://github.com/adamchainz/pytest-reverse)), can interfere with `pytest-regsmart` as they use the same reordering hook. +`pytest-regsmart` supports Python 3.8+. + ## Reference #### Demo video -A 5-minute demo video with walkthrough of `pytest-regsmart`: [YouTube link](https://youtu.be/SrnkgTs3uok?feature=shared) +A 5-minute demo video with walkthrough of `pytest-ranking`: [YouTube link](https://youtu.be/SrnkgTs3uok?feature=shared) +(pytest-regsmart: TBA) #### Bibtex citation ``` @inproceedings{cheng2025pytest, - title={{pytest-regsmart: A Regression Test Prioritization Tool for Python}}, + title={{pytest-ranking: A Regression Test Prioritization Tool for Python}}, author={Cheng, Runxiang and Ke, Kaiyao and Marinov, Darko}, booktitle={Companion Proceedings of the 33rd ACM International Conference on the Foundations of Software Engineering}, year={2025}, } ``` +(pytest-regsmart: TBA) + ## Contributing Contributions are very welcome. From 157a7899947d21c216e5ba4dd52ce1115bc7bd43 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 25 Aug 2026 08:04:19 -0300 Subject: [PATCH 110/122] refactor(plugin): move timing logs for feature collection and rts to respective functions --- src/pytest_regsmart/extractor.py | 6 ++++++ src/pytest_regsmart/plugin.py | 12 ++---------- src/pytest_regsmart/selection/selector.py | 11 +++++++++-- tests/ranking/test_replay.py | 2 +- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/pytest_regsmart/extractor.py b/src/pytest_regsmart/extractor.py index e26a049..a57e8f4 100644 --- a/src/pytest_regsmart/extractor.py +++ b/src/pytest_regsmart/extractor.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import time from _pytest.config import Config from _pytest.reports import TestReport @@ -29,8 +30,10 @@ def compute_test_features( config: Config, test_reports: list[TestReport], hist_len: int, + log_dict: dict | None = None, ) -> None: """Persist last_durations and num_runs_since_fail to cache.""" + start_time = time.time() key = os.path.join(DATA_DIR, "last_durations") last_durations = config.cache.get(key, {}) for report in test_reports: @@ -48,3 +51,6 @@ def compute_test_features( num_runs_since_fail.get(report.nodeid, 0) + 1, ) config.cache.set(key, num_runs_since_fail) + + if log_dict is not None: + log_dict["Time to collect test features (s)"] = time.time() - start_time diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 944a3e1..21cf8a3 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -1,7 +1,5 @@ from __future__ import annotations -import time - import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -162,10 +160,8 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not self.config.getoption("--regsmart"): return - selection_start_time = time.time() - selection = selector.run_rts(level=self.diff_level) + selection = selector.run_rts(level=self.diff_level, log_dict=self.log) self.branch = selection.branch - self.log["Time to run the regression test selection (s)"] = time.time() - selection_start_time if not selection.has_diff: #maybe i should separate return items though self.warnings.append( @@ -198,12 +194,8 @@ def pytest_report_header(self, config: Config) -> str | None: return reporter_mod.pytest_report_header(config) def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None: - start_time = time.time() extractor.compute_test_features( - self.config, self.monitor.test_reports, self.hist_len, - ) - self.log["Time to collect test features (s)"] = ( - time.time() - start_time + self.config, self.monitor.test_reports, self.hist_len, self.log, ) diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index d2398ff..9dffad5 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import time from dataclasses import dataclass from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL @@ -119,14 +120,17 @@ def _function_id_to_pytest_nodeid(function_id: str, filepath: str) -> str: return f"{filepath}::{qualified_name.replace('.', '::')}" -def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> SelectionResult: +def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, log_dict: dict | None = None) -> SelectionResult: """[wip] Orchestrates the selection...""" + start_time = time.time() diff_result = get_git_diff(diff_level= level) has_diff=bool(diff_result.modified_files or diff_result.untracked_files) used_branch = diff_result.used_branch if not has_diff: + if log_dict is not None: + log_dict["Time to run the regression test selection (s)"] = time.time() - start_time return SelectionResult( affected_tests=[], has_diff=has_diff, @@ -140,7 +144,10 @@ def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> SelectionResult: if level == DIFF_LEVEL.FUNCTION else _get_affected_tests_at_file_level(diff_result, deps_graph) ) - + + if log_dict is not None: + log_dict["Time to run the regression test selection (s)"] = time.time() - start_time + return SelectionResult( affected_tests=selected, has_diff=has_diff, diff --git a/tests/ranking/test_replay.py b/tests/ranking/test_replay.py index c15ecbf..64814dc 100644 --- a/tests/ranking/test_replay.py +++ b/tests/ranking/test_replay.py @@ -57,7 +57,7 @@ def test_replay_with_random(mytester): ] out = mytester.runpytest(*args) error_msg = "--rank-replay cannot be used together with random order." - assert len([x for x in out.outlines if error_msg in x]) == 1 + assert len([x for x in out.errlines if error_msg in x]) == 1 def test_invalid_replay(mytester): From 4a42191d3a4aac1ff30a31ef10bde2545c89eb98 Mon Sep 17 00:00:00 2001 From: let Date: Tue, 25 Aug 2026 08:24:44 -0300 Subject: [PATCH 111/122] refactor: update diff level help string --- src/pytest_regsmart/help_strings.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pytest_regsmart/help_strings.py b/src/pytest_regsmart/help_strings.py index 20fafb1..cc48b6d 100644 --- a/src/pytest_regsmart/help_strings.py +++ b/src/pytest_regsmart/help_strings.py @@ -5,7 +5,6 @@ It re-orders execution of tests to expose test failure sooner. """) - WEIGHT_HELP = textwrap.dedent("""\ Set weights on different prioritization heuristics, separated by hyphens `-`. @@ -45,7 +44,6 @@ """) #need to adapt this ending to use the RTS later DIFF_LEVEL_HELP = textwrap.dedent(""" -The levels at which the diff will be identified using Git. -Default value is file, which means that the diff will be identified at the file level. -If you want to identify the diff at the function level, you can set this option to function. -""") #make this better asap +The levels at which the diff will be identified using Git and the level for the dependency graph. +Possible values are: file and function (default). +""") From 317fff8636740b53ea07abe3781c727b070b182d Mon Sep 17 00:00:00 2001 From: let Date: Tue, 25 Aug 2026 09:34:51 -0300 Subject: [PATCH 112/122] fix: change no_rank ini type to bool and update related logic --- src/pytest_regsmart/plugin.py | 2 +- src/pytest_regsmart/ranking/rank_args.py | 5 ++--- src/pytest_regsmart/reporter.py | 4 +++- tests/test_no_rank.py | 16 ++++++++++++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 21cf8a3..66d0c10 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -130,7 +130,7 @@ def pytest_addoption(parser: Parser) -> None: help=SEED_HELP) parser.addini("diff_level", DIFF_LEVEL_HELP, default=DEFAULT_DIFF_LEVEL) - parser.addini("no_rank", NO_RANK_HELP, default=False) + parser.addini("no_rank", NO_RANK_HELP, type="bool") parser.addini("rank_weight", WEIGHT_HELP, default=DEFAULT_WEIGHT) parser.addini("rank_replay", REPLAY_HELP, default=DEFAULT_REPLAY) parser.addini("rank_level", RANK_LEVEL_HELP, default=DEFAULT_RANK_LEVEL) diff --git a/src/pytest_regsmart/ranking/rank_args.py b/src/pytest_regsmart/ranking/rank_args.py index acf0539..995a50b 100644 --- a/src/pytest_regsmart/ranking/rank_args.py +++ b/src/pytest_regsmart/ranking/rank_args.py @@ -122,6 +122,5 @@ def parse_no_rank(config) -> bool: """Get no-rank option, non-default CLI overrides ini file input.""" no_rtp = config.getoption("--no-rank") if not no_rtp: - ini_val = config.getini("no_rank") - no_rtp = ini_val if ini_val else no_rtp - return bool(no_rtp) + return config.getini("no_rank") + return True diff --git a/src/pytest_regsmart/reporter.py b/src/pytest_regsmart/reporter.py index 5fea0a7..04354fe 100644 --- a/src/pytest_regsmart/reporter.py +++ b/src/pytest_regsmart/reporter.py @@ -3,6 +3,8 @@ from _pytest.config import Config from _pytest.terminal import TerminalReporter +from .ranking.rank_args import parse_no_rank + def pytest_report_header(config: Config) -> str | None: """Report plugin configurations before test session starts.""" @@ -10,7 +12,7 @@ def pytest_report_header(config: Config) -> str | None: if not config.getoption("--regsmart"): return None - if config.getoption("--no-rank"): + if parse_no_rank(config): return [ "\nStarting RTS (Regression Test Selection)", "Using --no-rank (RTP disabled).", diff --git a/tests/test_no_rank.py b/tests/test_no_rank.py index 19e35b1..5363849 100644 --- a/tests/test_no_rank.py +++ b/tests/test_no_rank.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + import pytest from tests.fake_test_data import test_method_one @@ -40,6 +42,20 @@ def test_no_rank_with_ranking_flag_error(mytester, ranking_flag): assert "--no-rank cannot be used together" in str(out.errlines + out.outlines) +@pytest.mark.parametrize("ini_value, expect_no_rank_header", [ + ("false", False), + ("true", True), +]) +def test_no_rank_ini_value(mytester, ini_value, expect_no_rank_header): + mytester.makepyfile(test_method_one=test_method_one) + (Path(mytester.path) / "pytest.ini").write_text( + f"[pytest]\nconsole_output_style = classic\nno_rank = {ini_value}\n" + ) + out = mytester.runpytest("-v", "--regsmart") + has_header = any("Using --no-rank (RTP disabled)." in x for x in out.outlines) + assert has_header is expect_no_rank_header + + def test_no_rank_header_and_summary(mytester): mytester.makepyfile(test_method_one=test_method_one) args = ["-v", "--regsmart", "--no-rank"] From 71d08420afa871d7a02ca4c073c648b4d8d6ca04 Mon Sep 17 00:00:00 2001 From: let Date: Thu, 27 Aug 2026 00:56:55 -0300 Subject: [PATCH 113/122] fix: properly select tests at file level considering more import names --- src/pytest_regsmart/selection/deps_graph.py | 57 ++++++- tests/selection/test_deps_graph.py | 155 +++++++++++++++++++- 2 files changed, 208 insertions(+), 4 deletions(-) diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py index 007002d..6d08380 100644 --- a/src/pytest_regsmart/selection/deps_graph.py +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -89,15 +89,63 @@ def _convert_module_to_relative_path(fullpaths: ModuleToPath, working_dir: str) } +def _build_import_name_to_path(fullpaths: ModuleToPath, working_dir: str) -> dict[str, str]: + """Map every possible import-style suffix of a module's dotted name to its file path. + + E.g. for module "src.text_toolkit.text_stats": + "text_stats" -> path + "text_toolkit.text_stats" -> path + "src.text_toolkit.text_stats" -> path (redundant with module_to_path, harmless) + + For __init__ modules, the __init__ segment is dropped so that the package + name itself maps to the __init__.py file: + "src.text_toolkit.__init__" -> segments become ["src", "text_toolkit"] + "text_toolkit" -> path + "src.text_toolkit" -> path + + __init__ modules take priority over regular modules for the same import name, + matching Python's import resolution semantics (from pkg import X resolves to + pkg/__init__.py, not pkg/X.py). + + Ambiguous suffixes (same suffix pointing to different non-init files) are + dropped entirely. + """ + import_to_path: dict[str, str] = {} + ambiguous: set[str] = set() + + for module, fullpath in fullpaths.items(): + rel_path = os.path.relpath(fullpath, working_dir) + segments = module.split(".") + is_init = segments[-1] == "__init__" + if is_init: + segments = segments[:-1] + + for i in range(len(segments)): + candidate = ".".join(segments[i:]) + existing = import_to_path.get(candidate) + if existing is None: + import_to_path[candidate] = rel_path + elif is_init: + import_to_path[candidate] = rel_path + elif existing != rel_path: + ambiguous.add(candidate) + + for name in ambiguous: + import_to_path.pop(name, None) + + return import_to_path + + def _invert_dependency_map( connections: DependenciesMap, - module_to_path: ModuleToPath | None = None # only necessary for file-level graphs + module_to_path: ModuleToPath | None = None, # only necessary for file-level graphs + import_name_to_path: dict[str, str] | None = None, # fallback for import-style names that don't match path-derived module names (ImportVisitor problem) ) -> DependentsMap: """Invert 'Y imports X' into 'X is used by Y'. E.g.: if plugin.py imports selector.py, the result contains selector.py -> {plugin.py} (changing selector.py affects plugin.py); OR changing run affects test_run() - + With module_to_path: translates module names to relative paths (file-level graph) e.g.: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" Without module_to_path: keeps ids (functions references) as-is (function-level graph) @@ -113,6 +161,8 @@ def _invert_dependency_map( for imported in dependencies: imported_path = imported if module_to_path is None else module_to_path.get(imported) + if imported_path is None and import_name_to_path is not None: + imported_path = import_name_to_path.get(imported) if imported_path is None: continue #doesnt import a file in the repo dependents[imported_path].add(importer_path) @@ -129,7 +179,8 @@ def _build_file_dependency_graph( #graph.fullpaths: module -> absolute file path module_to_path = _convert_module_to_relative_path(graph.fullpaths, working_dir) - dependents = _invert_dependency_map(graph.modules, module_to_path) # invert so it becomes module -> who IMPORTS it / who is affected by it + import_name_to_path = _build_import_name_to_path(graph.fullpaths, working_dir) + dependents = _invert_dependency_map(graph.modules, module_to_path, import_name_to_path) # invert so it becomes module -> who IMPORTS it / who is affected by it return DependencyGraph(dependents=dependents) diff --git a/tests/selection/test_deps_graph.py b/tests/selection/test_deps_graph.py index de46450..9e86327 100644 --- a/tests/selection/test_deps_graph.py +++ b/tests/selection/test_deps_graph.py @@ -5,11 +5,12 @@ from pathlib import Path from pyan.analyzer import CallGraphVisitor -from pytest import mark +from pytest import mark, param from pytest_regsmart.const import DIFF_LEVEL from src.pytest_regsmart.selection.deps_graph import ( FunctionMetadata, + _build_import_name_to_path, _convert_module_to_relative_path, _extract_function_nodes, _find_py_files, @@ -78,6 +79,84 @@ def test_convert_module_to_relative_path(tmp_path): } +# --------------------------------------------------------------------------- +# _build_import_name_to_path +# --------------------------------------------------------------------------- + + +@mark.parametrize( + "fullpaths,expected", + [ + param( + { + "pkg.__init__": "pkg/__init__.py", + "pkg.service": "pkg/service.py", + "tests.test_app": "tests/test_app.py", + }, + { + "pkg": os.path.join("pkg", "__init__.py"), + "service": os.path.join("pkg", "service.py"), + "test_app": os.path.join("tests", "test_app.py"), + "pkg.service": os.path.join("pkg", "service.py"), + "tests.test_app": os.path.join("tests", "test_app.py"), + }, + id="flat-layout", + ), + param( + { + "src.hi": "src/hi.py", + "src.text_toolkit.__init__": "src/text_toolkit/__init__.py", + "src.text_toolkit.text_stats": "src/text_toolkit/text_stats.py", + "tests.test_hi": "tests/test_hi.py", + "tests.test_text_stats": "tests/test_text_stats.py", + }, + { + "hi": os.path.join("src", "hi.py"), + "text_toolkit": os.path.join("src", "text_toolkit", "__init__.py"), + "text_stats": os.path.join("src", "text_toolkit", "text_stats.py"), + "test_hi": os.path.join("tests", "test_hi.py"), + "test_text_stats": os.path.join("tests", "test_text_stats.py"), + "src.hi": os.path.join("src", "hi.py"), + "src.text_toolkit": os.path.join("src", "text_toolkit", "__init__.py"), + "src.text_toolkit.text_stats": os.path.join("src", "text_toolkit", "text_stats.py"), + "tests.test_hi": os.path.join("tests", "test_hi.py"), + "tests.test_text_stats": os.path.join("tests", "test_text_stats.py"), + "text_toolkit.text_stats": os.path.join("src", "text_toolkit", "text_stats.py"), + }, + id="src-layout", + ), + ], +) +def test_build_import_name_to_path(tmp_path, fullpaths, expected): + abs_fullpaths = {k: str(tmp_path / v) for k, v in fullpaths.items()} + + result = _build_import_name_to_path(abs_fullpaths, str(tmp_path)) + + assert result == expected + + +def test_build_import_name_to_path_drops_ambiguous(tmp_path): + fullpaths = { + "a.foo": str(tmp_path / "a" / "foo.py"), + "b.foo": str(tmp_path / "b" / "foo.py"), + } + + result = _build_import_name_to_path(fullpaths, str(tmp_path)) + + assert "foo" not in result + + +def test_build_import_name_to_path_init_package_name(tmp_path): + fullpaths = { + "mypkg.__init__": str(tmp_path / "mypkg" / "__init__.py"), + } + + result = _build_import_name_to_path(fullpaths, str(tmp_path)) + + assert result["mypkg"] == os.path.join("mypkg", "__init__.py") + assert "__init__" not in result + + # --------------------------------------------------------------------------- # _invert_dependency_map # --------------------------------------------------------------------------- @@ -144,6 +223,43 @@ def test_invert_dependency_map_cases(module_imports,module_to_path,expected_depe assert graph == expected_dependents +@mark.parametrize( + "connections,module_to_path,import_name_to_path,expected", + [ + param( + {"tests.test_app": {"text_toolkit"}}, + { + "tests.test_app": "tests/test_app.py", + "src.text_toolkit.__init__": "src/text_toolkit/__init__.py", + }, + {"text_toolkit": "src/text_toolkit/__init__.py"}, + {"src/text_toolkit/__init__.py": {"tests/test_app.py"}}, + id="fallback-resolves", + ), + param( + {"tests.test_app": {"mypkg.service"}}, + { + "tests.test_app": "tests/test_app.py", + "mypkg.service": "mypkg/service.py", + }, + {"service": "mypkg/service.py"}, + {"mypkg/service.py": {"tests/test_app.py"}}, + id="exact-match-wins", + ), + param( + {"tests.test_app": {"stdlib_module"}}, + {"tests.test_app": "tests/test_app.py"}, + {}, + {}, + id="fallback-also-fails", + ), + ], +) +def test_invert_dependency_map_fallback(connections, module_to_path, import_name_to_path, expected): + result = _invert_dependency_map(connections, module_to_path, import_name_to_path) + assert result == expected + + # --------------------------------------------------------------------------- # _extract_function_nodes # --------------------------------------------------------------------------- @@ -264,6 +380,43 @@ def test_get_dependency_graph_defaults_to_dot_repo(monkeypatch, git_repo): } +# --------------------------------------------------------------------------- +# get_dependency_graph (src-layout end-to-end) +# --------------------------------------------------------------------------- + + +def _build_src_layout_project(repo): + root = Path(repo.working_tree_dir) + _write(root, "src/mypkg/__init__.py", "from mypkg.service import run\n") + _write( + root, + "src/mypkg/service.py", + "def run():\n return 42\n", + ) + _write(root, "tests/test_app.py", "from mypkg.service import run\n") + _write(root, "tests/test_other.py", "from mypkg import service\n") + + +def test_get_dependency_graph_src_layout(git_repo): + _build_src_layout_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) + + assert "tests/test_app.py" in graph.dependents["src/mypkg/service.py"] + assert "tests/test_other.py" in graph.dependents["src/mypkg/__init__.py"] + + +def test_get_dependency_graph_src_layout_import_submodule(git_repo): + root = Path(git_repo.working_tree_dir) + _write(root, "src/pkg/__init__.py") + _write(root, "src/pkg/core.py", "def compute():\n return 1\n") + _write(root, "tests/test_core.py", "from pkg.core import compute\n") + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) + + assert "tests/test_core.py" in graph.dependents["src/pkg/core.py"] + + # --------------------------------------------------------------------------- # get_dependency_graph (function-level) # --------------------------------------------------------------------------- From b6063c22b072a64bf151e3c007c8e535af51d978 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 28 Aug 2026 09:21:42 -0300 Subject: [PATCH 114/122] fix: run the entire test suite when conftest is in git diff --- src/pytest_regsmart/plugin.py | 9 +++++++++ src/pytest_regsmart/selection/selector.py | 20 +++++++++++++++++--- src/pytest_regsmart/utils.py | 4 ++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 66d0c10..25ad6ad 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -172,6 +172,15 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." ) + if selection.full_run: + self.warnings.append( + "conftest.py was modified: regression test selection was skipped. The value set for '--diff-level' will be ignored." + ) + if self.no_rank: + self.warnings.append( + "conftest diff and --no-rank enabled: pytest-regsmart is not doing anything." + ) + if selection.affected_tests: selected_nodes = set(selection.affected_tests) diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index 9dffad5..cbdf0e1 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -6,7 +6,7 @@ from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL -from ..utils import _is_test_file +from ..utils import _is_test_file, _is_conftest from .deps_graph import DependencyGraph, get_dependency_graph from .git_manager import DiffResult, get_git_diff @@ -16,6 +16,7 @@ class SelectionResult: affected_tests: list[str] has_diff: bool branch: str + full_run: bool = False def line_diff_match_function_ids( @@ -124,7 +125,7 @@ def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, log_dict: dict | None = None """[wip] Orchestrates the selection...""" start_time = time.time() - diff_result = get_git_diff(diff_level= level) + diff_result = get_git_diff(diff_level=level) has_diff=bool(diff_result.modified_files or diff_result.untracked_files) used_branch = diff_result.used_branch @@ -134,7 +135,20 @@ def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, log_dict: dict | None = None return SelectionResult( affected_tests=[], has_diff=has_diff, - branch=used_branch + branch=used_branch, + full_run=True + ) + + changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) + if any(_is_conftest(f) for f in changed_files): + if log_dict is not None: + log_dict["Time to run the regression test selection (s)"] = time.time() - start_time + + return SelectionResult( + affected_tests=[], + has_diff=has_diff, + branch=used_branch, + full_run=True ) deps_graph = get_dependency_graph(graph_level= level) diff --git a/src/pytest_regsmart/utils.py b/src/pytest_regsmart/utils.py index d1e1c41..77c00e9 100644 --- a/src/pytest_regsmart/utils.py +++ b/src/pytest_regsmart/utils.py @@ -16,3 +16,7 @@ def _find_py_files(working_dir: str) -> list[str]: def _is_test_file(filepath: str) -> bool: filename = os.path.basename(filepath) return filename.startswith("test_") or filename.endswith("_test.py") + + +def _is_conftest(filepath: str) -> bool: + return os.path.basename(filepath) == "conftest.py" From f0e06b71e389d9b92f54bcae5e680d11970bcb3f Mon Sep 17 00:00:00 2001 From: let Date: Fri, 28 Aug 2026 09:56:37 -0300 Subject: [PATCH 115/122] fix: change warning logs emission sequence --- src/pytest_regsmart/plugin.py | 11 +- src/pytest_regsmart/selection/selector.py | 10 +- tests/selection/test_selection.py | 150 ++++++++++++++++++++++ tests/selection/test_selector_opts.py | 63 ++++++++- tests/selection/test_utils.py | 22 ++++ 5 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 tests/selection/test_utils.py diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 25ad6ad..b5473b4 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -165,20 +165,19 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: if not selection.has_diff: #maybe i should separate return items though self.warnings.append( - "No diff detected: regression test selection was skipped. The value set for '--diff-level' will be ignored." + "No diff detected: regression test selection was skipped. The value set for '--diff-level' was ignored." ) if self.no_rank: - self.warnings.append( + self.warnings.append( #maybe should raise an usageError "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." ) - - if selection.full_run: + elif selection.full_run: self.warnings.append( - "conftest.py was modified: regression test selection was skipped. The value set for '--diff-level' will be ignored." + "conftest.py changed: regression test selection was skipped. The full suite will run. The value set for '--diff-level' was ignored." ) if self.no_rank: self.warnings.append( - "conftest diff and --no-rank enabled: pytest-regsmart is not doing anything." + "conftest.py changed and --no-rank enabled: pytest-regsmart is not doing anything." ) if selection.affected_tests: diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index cbdf0e1..f8c14b8 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -6,7 +6,7 @@ from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL -from ..utils import _is_test_file, _is_conftest +from ..utils import _is_conftest, _is_test_file from .deps_graph import DependencyGraph, get_dependency_graph from .git_manager import DiffResult, get_git_diff @@ -130,20 +130,14 @@ def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, log_dict: dict | None = None used_branch = diff_result.used_branch if not has_diff: - if log_dict is not None: - log_dict["Time to run the regression test selection (s)"] = time.time() - start_time return SelectionResult( affected_tests=[], has_diff=has_diff, - branch=used_branch, - full_run=True + branch=used_branch ) changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) if any(_is_conftest(f) for f in changed_files): - if log_dict is not None: - log_dict["Time to run the regression test selection (s)"] = time.time() - start_time - return SelectionResult( affected_tests=[], has_diff=has_diff, diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py index bd0efb7..90100a0 100644 --- a/tests/selection/test_selection.py +++ b/tests/selection/test_selection.py @@ -1,5 +1,8 @@ +import os from pathlib import Path +import pytest + from src.pytest_regsmart.const import DIFF_LEVEL from src.pytest_regsmart.selection.deps_graph import ( DependencyGraph, @@ -223,3 +226,150 @@ def test_run_rts_without_diff_selects_nothing(git_repo, commit_file, monkeypatch assert result.affected_tests == [] assert result.has_diff is False assert result.branch == "main" + assert result.full_run is False + + +# --------------------------------------------------------------------------- +# run_rts (conftest -> full suite) +# --------------------------------------------------------------------------- + + +def _write_untracked(repo, relpath: str, content: str = "") -> Path: + filepath = Path(repo.working_tree_dir) / relpath + filepath.parent.mkdir(parents=True, exist_ok=True) + filepath.write_text(content) + return filepath + + +def _commit_sample_project_with_conftest(git_repo, commit_file): + _commit_sample_project(git_repo, commit_file) + commit_file("conftest.py", "# shared fixtures\n") + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_untracked_triggers_full_run(git_repo, commit_file, monkeypatch, level): + _commit_sample_project(git_repo, commit_file) + _write_untracked(git_repo, "conftest.py", "# shared fixtures\n") + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + assert result.has_diff is True + assert result.branch == "main" + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_untracked_with_modified_file_triggers_full_run( + git_repo, commit_file, monkeypatch, level +): + _commit_sample_project(git_repo, commit_file) + Path(git_repo.working_tree_dir, "service.py").write_text( + "def run():\n return 0\n" + ) + _write_untracked(git_repo, "conftest.py", "# shared fixtures\n") + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_tracked_modified_triggers_full_run( + git_repo, commit_file, monkeypatch, level +): + _commit_sample_project_with_conftest(git_repo, commit_file) + Path(git_repo.working_tree_dir, "conftest.py").write_text( + "# shared fixtures (edited)\n" + ) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_tracked_deleted_triggers_full_run( + git_repo, commit_file, monkeypatch, level +): + _commit_sample_project_with_conftest(git_repo, commit_file) + os.remove(Path(git_repo.working_tree_dir, "conftest.py")) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_in_subdirectory_untracked_triggers_full_run( + git_repo, commit_file, monkeypatch, level +): + _commit_sample_project(git_repo, commit_file) + _write_untracked(git_repo, "tests/conftest.py", "# shared fixtures\n") + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + + +@pytest.mark.parametrize( + ("level", "expected"), + [ + (DIFF_LEVEL.FILE, ["test_app.py"]), + (DIFF_LEVEL.FUNCTION, ["test_app.py::test_run"]), + ], +) +def test_run_rts_without_conftest_selects_normally(git_repo, commit_file, monkeypatch, level, expected): + _commit_sample_project(git_repo, commit_file) + Path(git_repo.working_tree_dir, "service.py").write_text( + "def run():\n return 0\n" + ) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is False + assert result.affected_tests == expected + + +def test_run_rts_conftest_does_not_log_selection_time(git_repo, commit_file, monkeypatch): + _commit_sample_project(git_repo, commit_file) + _write_untracked(git_repo, "conftest.py", "# shared fixtures\n") + monkeypatch.chdir(git_repo.working_tree_dir) + log_dict: dict = {} + + run_rts(DIFF_LEVEL.FUNCTION, log_dict=log_dict) + + assert "Time to run the regression test selection (s)" not in log_dict + + +def test_run_rts_without_diff_does_not_log_selection_time(git_repo, commit_file, monkeypatch): + _commit_sample_project(git_repo, commit_file) + monkeypatch.chdir(git_repo.working_tree_dir) + log_dict: dict = {} + + run_rts(DIFF_LEVEL.FUNCTION, log_dict=log_dict) + + assert "Time to run the regression test selection (s)" not in log_dict + + +def test_run_rts_logs_selection_time_when_selecting(git_repo, commit_file, monkeypatch): + _commit_sample_project(git_repo, commit_file) + Path(git_repo.working_tree_dir, "service.py").write_text( + "def run():\n return 0\n" + ) + monkeypatch.chdir(git_repo.working_tree_dir) + log_dict: dict = {} + + run_rts(DIFF_LEVEL.FUNCTION, log_dict=log_dict) + + assert "Time to run the regression test selection (s)" in log_dict diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py index 3ce4ca4..03369d2 100644 --- a/tests/selection/test_selector_opts.py +++ b/tests/selection/test_selector_opts.py @@ -206,7 +206,9 @@ def test_no_diff_with_rank_runs_everything(selection_project): "No diff detected: regression test selection was skipped." in x for x in out.outlines ) - assert any("Time to run the regression test selection (s)" in x for x in out.outlines) + assert not any( + "Time to run the regression test selection (s)" in x for x in out.outlines + ) def test_no_diff_with_no_rank_plugin_does_nothing(selection_project): @@ -219,3 +221,62 @@ def test_no_diff_with_no_rank_plugin_does_nothing(selection_project): "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." in x for x in out.outlines ) + + +def test_conftest_untracked_runs_full_suite(selection_project): + pytester, repo = selection_project + (Path(repo.working_tree_dir) / "conftest.py").write_text( + "# shared fixtures\n" + ) + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=4) + assert _ran_files(out) == [ + "test_other.py", + "test_service.py", + "test_unrelated.py", + ] + assert any( + "conftest.py changed: regression test selection was skipped." in x + for x in out.outlines + ) + assert not any( + "Time to run the regression test selection (s)" in x for x in out.outlines + ) + + +def test_conftest_untracked_with_modified_file_runs_full_suite(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + (Path(repo.working_tree_dir) / "conftest.py").write_text( + "# shared fixtures\n" + ) + + out = pytester.runpytest( + "-v", "--regsmart", "--diff-level=function", "--no-rank" + ) + + out.assert_outcomes(passed=4) + assert len(_ran_files(out)) == 3 + assert any( + "conftest.py changed: regression test selection was skipped." in x + for x in out.outlines + ) + assert not any( + "Time to run the regression test selection (s)" in x for x in out.outlines + ) + + +def test_conftest_only_warning_mentions_full_suite(selection_project): + pytester, repo = selection_project + (Path(repo.working_tree_dir) / "conftest.py").write_text( + "# shared fixtures\n" + ) + + out = pytester.runpytest("-v", "--regsmart") + + out.assert_outcomes(passed=4) + assert any( + "The full suite will run." in x for x in out.outlines + ) diff --git a/tests/selection/test_utils.py b/tests/selection/test_utils.py new file mode 100644 index 0000000..b4d3306 --- /dev/null +++ b/tests/selection/test_utils.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os + +from src.pytest_regsmart.utils import _is_conftest + + +def test_is_conftest_root_level(): + assert _is_conftest("conftest.py") is True + + +def test_is_conftest_in_subdirectory(): + assert _is_conftest("tests/conftest.py") is True + assert _is_conftest(os.path.join("tests", "conftest.py")) is True + assert _is_conftest("tests/sub/conftest.py") is True + + +def test_is_conftest_rejects_other_files(): + assert _is_conftest("service.py") is False + assert _is_conftest("test_conftest.py") is False + assert _is_conftest("conftest.py.bak") is False + assert _is_conftest("CONFTEST.py") is False \ No newline at end of file From 755b7887c8e6b50e0f96a409f4a06b29bfa27051 Mon Sep 17 00:00:00 2001 From: let Date: Fri, 28 Aug 2026 23:52:45 -0300 Subject: [PATCH 116/122] fix lint --- src/pytest_regsmart/selection/deps_graph.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py index 6d08380..d259a4c 100644 --- a/src/pytest_regsmart/selection/deps_graph.py +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -123,9 +123,7 @@ def _build_import_name_to_path(fullpaths: ModuleToPath, working_dir: str) -> dic for i in range(len(segments)): candidate = ".".join(segments[i:]) existing = import_to_path.get(candidate) - if existing is None: - import_to_path[candidate] = rel_path - elif is_init: + if existing is None or is_init: import_to_path[candidate] = rel_path elif existing != rel_path: ambiguous.add(candidate) From 573542fd5d2e67fc500744e40224029d8d116fde Mon Sep 17 00:00:00 2001 From: let Date: Sat, 29 Aug 2026 00:13:25 -0300 Subject: [PATCH 117/122] fix: add default value for some flag at addini option --- src/pytest_regsmart/plugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index b5473b4..55206bd 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -129,11 +129,11 @@ def pytest_addoption(parser: Parser) -> None: default=DEFAULT_SEED, help=SEED_HELP) - parser.addini("diff_level", DIFF_LEVEL_HELP, default=DEFAULT_DIFF_LEVEL) - parser.addini("no_rank", NO_RANK_HELP, type="bool") + parser.addini("diff_level", DIFF_LEVEL_HELP, default=DEFAULT_DIFF_LEVEL.value) + parser.addini("no_rank", NO_RANK_HELP, type="bool", default=False) parser.addini("rank_weight", WEIGHT_HELP, default=DEFAULT_WEIGHT) parser.addini("rank_replay", REPLAY_HELP, default=DEFAULT_REPLAY) - parser.addini("rank_level", RANK_LEVEL_HELP, default=DEFAULT_RANK_LEVEL) + parser.addini("rank_level", RANK_LEVEL_HELP, default=DEFAULT_RANK_LEVEL.value) parser.addini("rank_hist_len", HIST_LEN_HELP, default=DEFAULT_HIST_LEN) parser.addini("rank_seed", SEED_HELP, default=DEFAULT_SEED) From 3a0e474c1c6eb4c241450c2f677d10e7259ef246 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 29 Aug 2026 18:20:03 -0300 Subject: [PATCH 118/122] fix: add validation for args config in pytest.ini + uv sync --- CHANGELOG.md | 5 + pyproject.toml | 2 +- src/pytest_regsmart/ranking/rank_args.py | 89 ++++++---- .../selection/selection_args.py | 15 +- src/pytest_regsmart/utils.py | 14 ++ tests/ranking/test_rank_args.py | 157 ++++++++++++++++++ tests/selection/test_selection_args.py | 13 ++ tests/selection/test_selector_opts.py | 24 +++ tests/test_options.py | 2 +- uv.lock | 4 +- 10 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 tests/ranking/test_rank_args.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 42879e8..f624842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] pytest-regsmart +## [0.6.3] - 2026-08-29 + +### Added +- Add validation for args options in pytest.ini +- Add default values for diff-level, rank-level and no-rank flags in pytest.ini ## [0.6.2] - 2026-08-22 ### Added diff --git a/pyproject.toml b/pyproject.toml index a17c902..63218f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytest-regsmart" -version = "0.6.1" +version = "0.6.3" description = "A Pytest plugin for faster fault detection via regression test prioritization and selection." readme = "README.md" license = "GPL-2.0-or-later" diff --git a/src/pytest_regsmart/ranking/rank_args.py b/src/pytest_regsmart/ranking/rank_args.py index 995a50b..4445429 100644 --- a/src/pytest_regsmart/ranking/rank_args.py +++ b/src/pytest_regsmart/ranking/rank_args.py @@ -12,6 +12,7 @@ DEFAULT_WEIGHT, RANK_LEVEL, ) +from ..utils import _resolve_ini_value def weight_type(string: str) -> str: @@ -39,9 +40,9 @@ def level_type(string: str) -> str: assert string in valid_levels return string except AssertionError: + valid = ", ".join(item.value for item in RANK_LEVEL) raise argparse.ArgumentTypeError( - "Invalid input for `--rank-level`." - + " Please run `pytest --help` for instruction." + f"Invalid input for `--rank-level`: '{string}'. Valid values: {valid}." ) from None @@ -60,6 +61,26 @@ def replay_type(string: str) -> str: ) from None +def hist_len_type(string) -> int: + "Check history length format." + try: + return int(string) + except (TypeError, ValueError): + raise argparse.ArgumentTypeError( + f"Invalid input for `--rank-hist-len`: '{string}'. It must be an integer." + ) from None + + +def seed_type(string) -> int: + "Check seed format." + try: + return int(string) + except (TypeError, ValueError): + raise argparse.ArgumentTypeError( + f"Invalid input for `--rank-seed`: '{string}'. It must be an integer." + ) from None + + def min_max_normalization(x: list[float]) -> np.ndarray: x = np.array(x) if x.size == 0: @@ -71,12 +92,15 @@ def min_max_normalization(x: list[float]) -> np.ndarray: def parse_rtp_weights(config) -> list[float]: """Get weights, non-default CLI overrides ini file input.""" - weights = config.getoption("--rank-weight") - if weights == DEFAULT_WEIGHT: - ini_val = config.getini("rank_weight") - weights = ini_val if ini_val else weights - weights = weights.split("-") - weights = [float(w) for w in weights] + weights = _resolve_ini_value( + config, + cli_opt="--rank-weight", + default=DEFAULT_WEIGHT, + ini_key="rank_weight", + type_fn=weight_type, + ) + + weights = [float(w) for w in weights.split("-")] weight_sum = sum(weights) if weight_sum == 0: return [0, 0] @@ -85,38 +109,47 @@ def parse_rtp_weights(config) -> list[float]: def parse_rtp_level(config) -> str: """Get test group level, non-default CLI overrides ini file input.""" - level = config.getoption("--rank-level") - if level == DEFAULT_RANK_LEVEL: - ini_val = config.getini("rank_level") - level = ini_val if ini_val else level - return level + return _resolve_ini_value( + config, + cli_opt="--rank-level", + default=DEFAULT_RANK_LEVEL, + ini_key="rank_level", + type_fn=level_type, + ) def parse_replay(config) -> str | None: """Get replay file, non-default CLI overrides ini file input.""" - replay_file = config.getoption("--rank-replay") - if replay_file == DEFAULT_REPLAY: - ini_val = config.getini("rank_replay") - replay_file = ini_val if ini_val else replay_file - return replay_file + return _resolve_ini_value( + config, + cli_opt="--rank-replay", + default=DEFAULT_REPLAY, + ini_key="rank_replay", + type_fn=replay_type, + ) def parse_hist_len(config) -> int: """Get history length, non-default CLI overrides ini file input.""" - hist_len = config.getoption("--rank-hist-len") - if hist_len == DEFAULT_HIST_LEN: - ini_val = config.getini("rank_hist_len") - hist_len = ini_val if ini_val else hist_len - return int(hist_len) + return _resolve_ini_value( + config, + cli_opt="--rank-hist-len", + default=DEFAULT_HIST_LEN, + ini_key="rank_hist_len", + type_fn=hist_len_type, + ) def parse_seed(config) -> int: """Get random seed, non-default CLI overrides ini file input.""" - rand_seed = config.getoption("--rank-seed") - if rand_seed == DEFAULT_SEED: - ini_val = config.getini("rank_seed") - rand_seed = ini_val if ini_val else rand_seed - return int(rand_seed) + return _resolve_ini_value( + config, + cli_opt="--rank-seed", + default=DEFAULT_SEED, + ini_key="rank_seed", + type_fn=seed_type, + ) + def parse_no_rank(config) -> bool: """Get no-rank option, non-default CLI overrides ini file input.""" diff --git a/src/pytest_regsmart/selection/selection_args.py b/src/pytest_regsmart/selection/selection_args.py index f7f6a45..56c5f10 100644 --- a/src/pytest_regsmart/selection/selection_args.py +++ b/src/pytest_regsmart/selection/selection_args.py @@ -1,6 +1,7 @@ import argparse from ..const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL +from ..utils import _resolve_ini_value def level_type(string: str) -> DIFF_LEVEL: @@ -15,9 +16,11 @@ def level_type(string: str) -> DIFF_LEVEL: def parse_diff_level(config) -> DIFF_LEVEL: - """Get selected level for git diff, non-default CLI overrides ini file input.""" #i need to decide if it's gonna be settable via ini/config file - level = config.getoption("--diff-level") - if level == DEFAULT_DIFF_LEVEL: - ini_val = config.getini("diff_level") - level = ini_val if ini_val else level - return DIFF_LEVEL(level) + """Get selected level for git diff, non-default CLI overrides ini file input.""" + return _resolve_ini_value( + config, + cli_opt="--diff-level", + default=DEFAULT_DIFF_LEVEL, + ini_key="diff_level", + type_fn=level_type, + ) diff --git a/src/pytest_regsmart/utils.py b/src/pytest_regsmart/utils.py index 77c00e9..b6d23c6 100644 --- a/src/pytest_regsmart/utils.py +++ b/src/pytest_regsmart/utils.py @@ -1,5 +1,8 @@ +import argparse import os +import pytest + def _find_py_files(working_dir: str) -> list[str]: excludes = [".venv", ".git", "__pycache__", "dist", "build", "venv", "site-packages"] @@ -20,3 +23,14 @@ def _is_test_file(filepath: str) -> bool: def _is_conftest(filepath: str) -> bool: return os.path.basename(filepath) == "conftest.py" + + +def _resolve_ini_value(config, cli_opt, default, ini_key, type_fn): + value = config.getoption(cli_opt) + if value == default: + ini_val = config.getini(ini_key) + value = ini_val if ini_val else value + try: + return type_fn(value) + except argparse.ArgumentTypeError as e: + raise pytest.UsageError(f"Invalid value for '{ini_key}' in pytest.ini: {e}") from None diff --git a/tests/ranking/test_rank_args.py b/tests/ranking/test_rank_args.py new file mode 100644 index 0000000..31c65f3 --- /dev/null +++ b/tests/ranking/test_rank_args.py @@ -0,0 +1,157 @@ +import pytest + +from src.pytest_regsmart.const import RANK_LEVEL +from src.pytest_regsmart.ranking.rank_args import ( + parse_hist_len, + parse_no_rank, + parse_replay, + parse_rtp_level, + parse_rtp_weights, + parse_seed, +) + + +class _FakeConfig: + def __init__(self, option, ini=None): + self._option = option + self._ini = ini + + def getoption(self, _name): + return self._option + + def getini(self, _name): + return self._ini + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param("0-1", "1-0", [0, 1], id="cli value wins over ini"), + pytest.param( + "1-0", + "0.4-0.2", + pytest.approx([0.4 / 0.6, 0.2 / 0.6]), + id="ini used when cli has default", + ), + pytest.param("1-0", None, [1, 0], id="default when ini unset"), + pytest.param("0-0", None, [0, 0], id="zero sum returns zeros"), + ], +) +def test_parse_rtp_weights(option, ini, expected): + config = _FakeConfig(option=option, ini=ini) + + assert parse_rtp_weights(config) == expected + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param("module", "put", "module", id="cli value wins over ini"), + pytest.param( + RANK_LEVEL.PUT, + "function", + "function", + id="ini used when cli has default", + ), + pytest.param(RANK_LEVEL.PUT, "put", "put", id="default is plain str"), + ], +) +def test_parse_rtp_level(option, ini, expected): + result = parse_rtp_level(_FakeConfig(option=option, ini=ini)) + + assert result == expected + assert type(result) is str + + +@pytest.mark.parametrize( + "from_cli", + [ + pytest.param(True, id="cli value wins over ini"), + pytest.param(False, id="ini uses valid file"), + ], +) +def test_parse_replay_uses_provided_file(tmp_path, from_cli): + ini_file = tmp_path / "ini_order.txt" + ini_file.write_text("test_a\n") + cli_file = tmp_path / "cli_order.txt" + cli_file.write_text("test_a\n") + + config = _FakeConfig( + option=str(cli_file) if from_cli else None, + ini=str(ini_file), + ) + + expected = str(cli_file) if from_cli else str(ini_file) + assert parse_replay(config) == expected + + +def test_parse_replay_default_none_when_ini_unset(): + config = _FakeConfig(option=None, ini=None) + + assert parse_replay(config) is None + + +def test_parse_replay_ini_missing_file_raises_usage_error(tmp_path): + config = _FakeConfig(option=None, ini=str(tmp_path / "missing.txt")) + + with pytest.raises(pytest.UsageError, match="rank_replay"): + parse_replay(config) + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param(40, 10, 40, id="cli value wins over ini"), + pytest.param(50, "30", 30, id="ini used when cli has default"), + pytest.param(50, 50, 50, id="default when ini unset"), + ], +) +def test_parse_hist_len(option, ini, expected): + config = _FakeConfig(option=option, ini=ini) + + assert parse_hist_len(config) == expected + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param(1234, 0, 1234, id="cli value wins over ini"), + pytest.param(0, "42", 42, id="ini used when cli has default"), + pytest.param(0, 0, 0, id="default when ini unset"), + ], +) +def test_parse_seed(option, ini, expected): + config = _FakeConfig(option=option, ini=ini) + + assert parse_seed(config) == expected + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param(False, False, False, id="default false when unset"), + pytest.param(False, True, True, id="ini true"), + pytest.param(True, False, True, id="cli true wins"), + ], +) +def test_parse_no_rank(option, ini, expected): + config = _FakeConfig(option=option, ini=ini) + + assert parse_no_rank(config) is expected + + +@pytest.mark.parametrize( + ("parse_fn", "option", "ini", "match"), + [ + pytest.param(parse_rtp_weights, "1-0", "1-3-2", "rank_weight", id="weight too many parts"), + pytest.param(parse_rtp_weights, "1-0", "x-y", "rank_weight", id="weight non numeric parts"), + pytest.param(parse_rtp_level, RANK_LEVEL.PUT, "class", "rank_level", id="level unknown value"), + pytest.param(parse_hist_len, 50, "abc", "rank_hist_len", id="hist len not an integer"), + pytest.param(parse_seed, 0, "not-a-number", "rank_seed", id="seed not an integer"), + ], +) +def test_invalid_ini_value_raises_usage_error(parse_fn, option, ini, match): + config = _FakeConfig(option=option, ini=ini) + + with pytest.raises(pytest.UsageError, match=match): + parse_fn(config) diff --git a/tests/selection/test_selection_args.py b/tests/selection/test_selection_args.py index 5944c5a..2f6fc67 100644 --- a/tests/selection/test_selection_args.py +++ b/tests/selection/test_selection_args.py @@ -41,3 +41,16 @@ def test_parse_diff_level_ini_used_when_cli_has_default(): config = _FakeConfig(option=DIFF_LEVEL.FUNCTION, ini="file") assert parse_diff_level(config) is DIFF_LEVEL.FILE + + +def test_parse_diff_level_default_when_ini_unset(): + config = _FakeConfig(option=DIFF_LEVEL.FUNCTION, ini="") + + assert parse_diff_level(config) is DIFF_LEVEL.FUNCTION + + +def test_parse_diff_level_invalid_ini_value_raises_usage_error(): + config = _FakeConfig(option=DIFF_LEVEL.FUNCTION, ini="class") + + with pytest.raises(pytest.UsageError, match="diff_level"): + parse_diff_level(config) diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py index 03369d2..80a1f34 100644 --- a/tests/selection/test_selector_opts.py +++ b/tests/selection/test_selector_opts.py @@ -134,6 +134,30 @@ def test_diff_level_ini_option_used_when_cli_absent(selection_project): assert _ran_files(out) == ["test_extra.py"] +def test_invalid_rank_weight_ini_raises_usage_error(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + (Path(repo.working_tree_dir) / "pytest.ini").write_text( + "[pytest]\nconsole_output_style = classic\nrank_weight = 1-3-2\n" + ) + + out = pytester.runpytest("-v", "--regsmart") + + assert any("rank_weight" in x for x in out.errlines) + assert not any("::" in x and "PASSED" in x for x in out.outlines) + + +def test_invalid_diff_level_ini_raises_usage_error(selection_project): + pytester, repo = selection_project + (Path(repo.working_tree_dir) / "pytest.ini").write_text( + "[pytest]\nconsole_output_style = classic\ndiff_level = class\n" + ) + + out = pytester.runpytest("-v", "--regsmart") + + assert any("diff_level" in x for x in out.errlines) + + def test_report_header_shows_diff_level(selection_project): pytester, repo = selection_project _change(repo, "service.py", "def run():\n return 42 # changed\n") diff --git a/tests/test_options.py b/tests/test_options.py index a6db606..ef817ea 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -98,7 +98,7 @@ def test_invalid_level(mytester): args = ["-v", "--regsmart", "--rank-level=class"] out = mytester.runpytest(*args) - error_msg = "Invalid input for `--rank-level`." + error_msg = "Invalid input for `--rank-level`:" assert any(error_msg in x for x in out.errlines) diff --git a/uv.lock b/uv.lock index a863500..b135251 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -476,7 +476,7 @@ wheels = [ [[package]] name = "pytest-regsmart" -version = "0.6.1" +version = "0.6.3" source = { editable = "." } dependencies = [ { name = "gitpython" }, From 167df01313a758cf86f56a0032d8e30c99031955 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 29 Aug 2026 18:31:58 -0300 Subject: [PATCH 119/122] docs: improve license files writing --- LICENSE | 17 ----------------- NOTICE.md | 21 ++++++++++++--------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/LICENSE b/LICENSE index b5d6739..d159169 100644 --- a/LICENSE +++ b/LICENSE @@ -1,20 +1,3 @@ -pytest-regsmart -Copyright (C) 2025-2026 Letícia Costa - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software Foundation, -Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - GNU GENERAL PUBLIC LICENSE Version 2, June 1991 diff --git a/NOTICE.md b/NOTICE.md index d6c425d..41cb6da 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,14 +1,16 @@ # Third-Party Notices -This project, pytest-regsmart, is distributed under the GNU General Public -License v2.0 or later (see LICENSE). It builds on and incorporates -third-party code under different licenses, as detailed below. +This project, pytest-regsmart, is distributed under the GNU General Public License v2.0 or later (see `LICENSE`). + +It builds on and incorporates third-party code under different licenses, as detailed below. ## pytest-ranking (upstream project — MIT License) pytest-regsmart is a fork of pytest-ranking -(https://github.com/softwareTestingResearch/pytest-ranking) originally -distributed under the MIT License: +(https://github.com/softwareTestingResearch/pytest-ranking), originally +distributed under the MIT License. + +The relevant MIT License notice from the upstream project is reproduced below: --- @@ -40,7 +42,8 @@ DEALINGS IN THE SOFTWARE. pytest-regsmart imports and depends directly on pyan3 (https://pypi.org/project/pyan3/) at runtime to build the dependency -graph used for regression test selection. pyan3 is licensed under the -GNU General Public License, version 2 or later. As a result, the -combined work (pytest-regsmart) is distributed under the same terms — -see LICENSE for the full GPL-2.0-or-later text. +graph used for regression test selection. + +pyan3 is licensed under the GNU General Public License, version 2 or later. + +See `LICENSE` for the full GPL-2.0-or-later license text. From 7ad60c906aec652a04bdf3fe129b239450f8233a Mon Sep 17 00:00:00 2001 From: let Date: Sat, 29 Aug 2026 21:02:50 -0300 Subject: [PATCH 120/122] fix(rts): resolve base branch to the merge destination instead of current branch --- CHANGELOG.md | 1 + DEPLOYMENT.md | 12 ++ README.md | 8 +- src/pytest_regsmart/plugin.py | 6 +- src/pytest_regsmart/selection/git_manager.py | 57 +++++++-- src/pytest_regsmart/selection/selector.py | 9 ++ tests/selection/test_git.py | 124 ++++++++++++++++++- tests/selection/test_selector_opts.py | 29 +++++ 8 files changed, 231 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f624842..e665bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.6.3] - 2026-08-29 ### Added +- Hotfix: Resolve base branch to the merge destination instead of current branch - Add validation for args options in pytest.ini - Add default values for diff-level, rank-level and no-rank flags in pytest.ini ## [0.6.2] - 2026-08-22 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 412614d..da48cbc 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -9,6 +9,18 @@ If your CI runs different test builds in a fixed location, e.g., a project folde If your CI workflow always starts a new virtual machine to run a test build, you need to set up the CI to be able to pass `pytest` cache data across test builds. Here, we use GitHub Actions as an example. +### Check out the full history + +`pytest-regsmart` computes the diff against the **base** (destination) branch — e.g. `main` for a PR or push. GitHub Actions' default checkout does a shallow, single-branch clone (`fetch-depth: 1`), which does not fetch that base branch, so the base cannot be resolved and `pytest-regsmart` raises a `UsageError`. Fetch the full history: + +```yml + - uses: actions/checkout@v4 + with: + fetch-depth: 0 +``` + +This only affects the clone metadata, not the working-tree files, so the added time is negligible even for large repositories. + ### Add pytest-regsmart to project dependency diff --git a/README.md b/README.md index d652b43..955d47f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ pytest --regsmart Note that `--regsmart` requires the project to be a `git` repository (the regression test selection is computed from the changes since the last baseline). Running it outside a git repo raises a `UsageError`. +The **base branch must exist locally** for the diff to be computed. On a shallow clone (e.g. `actions/checkout` with the default `fetch-depth: 1`), the destination branch (such as `main`) is not fetched, so the base cannot be resolved — `pytest-regsmart` raises a `UsageError` telling you what to fix instead of silently running the whole suite. In CI, fetch the full history (see [Deployment](./DEPLOYMENT.md)). + Before the test run starts, if `--regsmart` is passed, the terminal header will report `pytest-regsmart`'s configuration of this run, for example: ``` @@ -172,9 +174,11 @@ and run `pytest --regsmart` on the command line. When `--regsmart` is used, `pytest-regsmart` runs a Regression Test Selection step before the tests are executed: -1. **Compute the changed files.** It uses git to inspect the working tree against the default branch: - - the default branch is resolved from `refs/remotes/origin/HEAD`, falling back to `main`/`master`, and finally to the currently active branch; +1. **Compute the changed files.** It uses git to inspect the working tree against the **base** branch — the branch the current commit is headed to (its destination), never the currently checked-out branch: + - the base is resolved, in order, from the `GITHUB_BASE_REF` environment variable (set by GitHub Actions on pull requests), `refs/remotes/origin/HEAD`, a local `main`/`master`, and finally the upstream tracking branch of the current branch; + - the diff is `HEAD` vs the merge-base with that base, so every commit on a PR/branch is compared against its destination (e.g. `main`), not against itself; - it collects both modified (`staged` + `unstaged`) and untracked files. If the repository has no commits yet, only untracked files are considered. + 2. **Build a dependency graph when changes exist.** If a diff is detected, [`pyan3`](https://pypi.org/project/pyan3/) parses every `*.py` file in the repository (excluding `.venv`, `venv`, `.git`, `__pycache__`, `dist`, `build`) and builds a dependency graph whose granularity follows `--diff-level`: a function-level call graph when `function` (default), or a module-level import graph when `file`. The graph is then inverted so that, for each node, it knows *which* other nodes depend on it. 3. **Propagate changes transitively.** A BFS traversal starts from the changed units — changed files at the `file` level, or the functions containing the changed lines at the `function` level — and walks through their dependents, collecting every test unit affected directly or indirectly: test files (files named `test_*.py` or `*_test.py`) or individual test functions, respectively. 4. **Filter the test suite.** At the `file` level, test items whose file is not in the selected set are removed; at the `function` level, only collected items matching a selected pytest nodeid are kept. Only affected tests are actually executed. diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py index 55206bd..90b99d7 100644 --- a/src/pytest_regsmart/plugin.py +++ b/src/pytest_regsmart/plugin.py @@ -163,7 +163,11 @@ def pytest_collection_modifyitems(self, items: list[Item]) -> None: selection = selector.run_rts(level=self.diff_level, log_dict=self.log) self.branch = selection.branch - if not selection.has_diff: #maybe i should separate return items though + if selection.no_merge_base: + self.warnings.append( + f"No shared history with base branch '{selection.branch}': regression test selection was skipped. The full suite will run. The value set for '--diff-level' was ignored." + ) + elif not selection.has_diff: #maybe i should separate return items though self.warnings.append( "No diff detected: regression test selection was skipped. The value set for '--diff-level' was ignored." ) diff --git a/src/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py index dfa292e..bed9983 100644 --- a/src/pytest_regsmart/selection/git_manager.py +++ b/src/pytest_regsmart/selection/git_manager.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from typing import TypeAlias +import pytest from git import Repo from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError @@ -20,6 +21,7 @@ class DiffResult: untracked_files: list[str] = field(default_factory=list) #brand new files - maybe add a future flag to look only at unstaged+untracked tests deleted_files: list[str] = field(default_factory=list) changed_line_ranges: ChangedLineRanges = field(default_factory=dict) #only for DIFF_LEVEL.FUNCTION + no_merge_base: bool = False #base resolved, but no shared history with HEAD def resolve_repo(repo_path: str = ".") -> Repo: @@ -34,25 +36,43 @@ def verify_git_repo(repo_path: str = ".") -> bool: return False -def get_default_repo_branch(repo: Repo) -> str: +def get_default_repo_branch(repo: Repo) -> str | None: + """Resolve the base branch to compare against the *destination* a commit + is headed to (never the current branch). Returns None when it cannot be determined, + which happens on shallow clones in CI where the destination branch isn't fetched.""" + # 1. GitHub Actions PR: explicit destination via env var + if base_ref := os.environ.get("GITHUB_BASE_REF"): + return base_ref + + # 2. Remote default (origin/HEAD) try: #for when the repo has a remote (origin/HEAD) symbolic_ref = repo.git.symbolic_ref('refs/remotes/origin/HEAD') return symbolic_ref.split('/')[-1] #the last part of the ref is the branch name except GitCommandError: pass - - branch_names = [branch.name for branch in repo.branches] #fall back to main/master if not found + # 3. Local main/master + branch_names = [branch.name for branch in repo.branches] for candidate in ("main", "master"): if candidate in branch_names: return candidate + # 4. Upstream tracking of the current branch (if it targets a different branch) try: - return repo.active_branch.name - except TypeError: # detached head - raise ValueError( - "Unable to determine the default branch." #no origin/HEAD, no main/master, no active branch - ) from None + active = repo.active_branch + except TypeError: # detached HEAD + active = None + if active is not None: + try: + upstream = active.tracking_branch() + if upstream is not None: + upstream_name = upstream.name.lstrip("./") + if upstream_name.split('/')[-1] != active.name: + return upstream_name + except (AttributeError, GitCommandError): + pass + + return None def _is_deleted(repo: Repo, merge_base_hash: str, path: str) -> bool: @@ -108,7 +128,26 @@ def get_git_diff(repo_path: str = ".", diff_level: DIFF_LEVEL = DEFAULT_DIFF_LEV ) default_branch = get_default_repo_branch(repo) #maybe make this configurable via a flag later - merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description + if default_branch is None: + raise pytest.UsageError( + "Unable to determine the base branch to compare against. " + "This usually happens on a shallow clone (CI often uses fetch-depth: 1), " + "where the destination branch (e.g. `main`) is not available locally. " + "Fix by using `fetch-depth: 0` in your checkout step (e.g. actions/checkout), " + "by fetching the base branch, or by setting the GITHUB_BASE_REF environment variable." + ) + + try: + merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description + except (IndexError, GitCommandError): + # Base resolved but shares no history with HEAD (e.g. unrelated first commit). + return DiffResult( + modified_files=[], + untracked_files=untracked_diff, + used_branch=default_branch, + no_merge_base=True, + ) + working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() deleted_files = [path for path in working_dir_diff if _is_deleted(repo, merge_base_commit.hexsha, path)] diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py index f8c14b8..8680b95 100644 --- a/src/pytest_regsmart/selection/selector.py +++ b/src/pytest_regsmart/selection/selector.py @@ -17,6 +17,7 @@ class SelectionResult: has_diff: bool branch: str full_run: bool = False + no_merge_base: bool = False def line_diff_match_function_ids( @@ -129,6 +130,14 @@ def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, log_dict: dict | None = None has_diff=bool(diff_result.modified_files or diff_result.untracked_files) used_branch = diff_result.used_branch + if diff_result.no_merge_base: + return SelectionResult( + affected_tests=[], + has_diff=has_diff, + branch=used_branch, + no_merge_base=True, + ) + if not has_diff: return SelectionResult( affected_tests=[], diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py index cfef672..d8f40a6 100644 --- a/tests/selection/test_git.py +++ b/tests/selection/test_git.py @@ -125,15 +125,96 @@ def test_default_branch_detached_with_local_main(git_repo, commit_file): assert get_default_repo_branch(git_repo) == "main" -def test_get_git_diff_custom_default_branch(git_repo, commit_file): +def test_get_git_diff_usage_error_when_no_base_destination(git_repo, commit_file): commit_file("file_a.py") git_repo.git.branch("-m", "develop") + with pytest.raises(pytest.UsageError): + get_git_diff(str(git_repo.working_tree_dir)) + + +def test_default_branch_uses_upstream_tracking(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "dev") + git_repo.git.branch("-u", "main", "dev") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_default_branch_uses_remote_upstream_when_no_local_main(tmp_path): + origin = tmp_path / "origin" + origin.mkdir() + Repo.init(origin, bare=True) + + local = tmp_path / "local" + local_repo = Repo.init(local) + (local / "file_a.py").write_text("base\n") + local_repo.index.add(["file_a.py"]) + local_repo.index.commit("base") + local_repo.git.branch("-m", "main") + local_repo.create_remote("origin", str(origin)) + local_repo.git.push("--set-upstream", "origin", "main") + + local_repo.git.checkout("-b", "dev") + local_repo.git.push("--set-upstream", "origin", "dev") + local_repo.delete_head("main", force=True) # only 'dev' stays local + local_repo.git.branch("-u", "origin/main", "dev") # dev heads to origin/main + + # no local 'main'/'master' and no origin/HEAD -> must fall back to the upstream (origin/main) + assert get_default_repo_branch(local_repo) == "origin/main" + + +def test_default_branch_skips_self_mirror_upstream(tmp_path): + origin = tmp_path / "origin" + origin.mkdir() + Repo.init(origin, bare=True) + + local = tmp_path / "local" + local_repo = Repo.init(local) + (local / "file_a.py").write_text("base\n") + local_repo.index.add(["file_a.py"]) + local_repo.index.commit("base") + local_repo.git.branch("-m", "main") + local_repo.create_remote("origin", str(origin)) + local_repo.git.push("--set-upstream", "origin", "main") + + local_repo.git.checkout("-b", "feature") + local_repo.git.push("--set-upstream", "origin", "feature") + local_repo.delete_head("main", force=True) # only 'feature' stays local + + # upstream only mirrors the current branch (origin/feature) -> not a valid destination + assert get_default_repo_branch(local_repo) is None + + +def test_default_branch_from_github_base_ref(git_repo, commit_file, monkeypatch): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + monkeypatch.setenv("GITHUB_BASE_REF", "main") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_default_branch_detached_without_local_main_returns_none(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "develop") + git_repo.git.checkout("--detach") + + assert get_default_repo_branch(git_repo) is None + + +def test_get_git_diff_no_merge_base_runs_full(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("--orphan", "orphan-branch") + git_repo.git.rm("-r", "--cached", ".") + commit_file("orphan.py") + result = get_git_diff(str(git_repo.working_tree_dir)) + assert result.no_merge_base is True assert result.modified_files == [] - assert result.untracked_files == [] - assert result.used_branch == "develop" def test_get_git_diff_no_commits(tmp_path): @@ -302,3 +383,40 @@ def test_get_git_diff_no_deleted_files(git_repo, commit_file): result = get_git_diff(str(git_repo.working_tree_dir)) assert result.deleted_files == [] + + +def test_shallow_single_branch_clone_raises_usage_error(tmp_path): + """Reproduces GitHub Actions default checkout (fetch-depth: 1, single branch): + only the pushed branch exists locally, so no base destination is available.""" + origin = tmp_path / "origin" + origin.mkdir() + Repo.init(origin, bare=True) + + local = tmp_path / "local" + local_repo = Repo.init(local) + (local / "file_a.py").write_text("base\n") + local_repo.index.add(["file_a.py"]) + local_repo.index.commit("base") + local_repo.git.branch("-m", "main") + local_repo.create_remote("origin", str(origin)) + local_repo.git.push("--set-upstream", "origin", "main") + + local_repo.git.checkout("-b", "feature") + (local / "file_b.py").write_text("new\n") + local_repo.index.add(["file_b.py"]) + local_repo.index.commit("feature commit") + local_repo.git.push("--set-upstream", "origin", "feature") + + # shallow clone of only the feature branch (depth 1), like actions/checkout on push + shallow_path = tmp_path / "shallow" + Repo.clone_from( + str(origin), + str(shallow_path), + depth=1, + single_branch=True, + branch="feature", + ) + + assert get_default_repo_branch(Repo(shallow_path)) is None + with pytest.raises(pytest.UsageError): + get_git_diff(str(shallow_path)) diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py index 80a1f34..0eb77f0 100644 --- a/tests/selection/test_selector_opts.py +++ b/tests/selection/test_selector_opts.py @@ -304,3 +304,32 @@ def test_conftest_only_warning_mentions_full_suite(selection_project): assert any( "The full suite will run." in x for x in out.outlines ) + + +def test_no_merge_base_runs_full_suite_with_warning(selection_project): + pytester, repo = selection_project + repo.git.checkout("--orphan", "orphan-branch") + repo.git.rm("-r", "--cached", ".") + for f in ".gitignore", "pytest.ini", "service.py", "test_service.py", "test_other.py", "test_unrelated.py": + repo.index.add([f]) + repo.index.commit("orphan baseline") + + # orphan branch shares no history with the base -> no merge base -> full suite + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=4) + assert any( + "No shared history" in x for x in out.outlines + ) + + +def test_detached_head_without_base_raises_clean_usage_error(selection_project): + pytester, repo = selection_project + repo.git.branch("-m", "dev") + repo.git.checkout("--detach") + + # detached HEAD with no main/master, no origin/HEAD -> clean UsageError, no crash + out = pytester.runpytest("--regsmart") + + assert any("Unable to determine the base branch" in x for x in out.errlines) + assert not any("::" in x and "PASSED" in x for x in out.outlines) From 5227497b276798821d2da950c25e84fd800d00ea Mon Sep 17 00:00:00 2001 From: let Date: Sat, 29 Aug 2026 22:17:53 -0300 Subject: [PATCH 121/122] fix(git): improve base branch resolution for CI environments --- DEPLOYMENT.md | 2 + src/pytest_regsmart/selection/git_manager.py | 69 +++++++------------- 2 files changed, 25 insertions(+), 46 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index da48cbc..7c83a9c 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -17,6 +17,8 @@ Here, we use GitHub Actions as an example. - uses: actions/checkout@v4 with: fetch-depth: 0 + + - run: git fetch --depth=1 origin main:refs/remotes/origin/main ``` This only affects the clone metadata, not the working-tree files, so the added time is negligible even for large repositories. diff --git a/src/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py index bed9983..3961212 100644 --- a/src/pytest_regsmart/selection/git_manager.py +++ b/src/pytest_regsmart/selection/git_manager.py @@ -6,7 +6,7 @@ import pytest from git import Repo -from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError +from git.exc import BadName, GitCommandError, InvalidGitRepositoryError, NoSuchPathError from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_HUNK_HEADER, DIFF_LEVEL @@ -36,42 +36,20 @@ def verify_git_repo(repo_path: str = ".") -> bool: return False -def get_default_repo_branch(repo: Repo) -> str | None: - """Resolve the base branch to compare against the *destination* a commit - is headed to (never the current branch). Returns None when it cannot be determined, - which happens on shallow clones in CI where the destination branch isn't fetched.""" - # 1. GitHub Actions PR: explicit destination via env var - if base_ref := os.environ.get("GITHUB_BASE_REF"): - return base_ref +_DEFAULT_BRANCH_CANDIDATES = ("main", "master") - # 2. Remote default (origin/HEAD) - try: #for when the repo has a remote (origin/HEAD) - symbolic_ref = repo.git.symbolic_ref('refs/remotes/origin/HEAD') - return symbolic_ref.split('/')[-1] #the last part of the ref is the branch name - except GitCommandError: - pass - - # 3. Local main/master - branch_names = [branch.name for branch in repo.branches] - for candidate in ("main", "master"): - if candidate in branch_names: - return candidate - - # 4. Upstream tracking of the current branch (if it targets a different branch) - try: - active = repo.active_branch - except TypeError: # detached HEAD - active = None - if active is not None: - try: - upstream = active.tracking_branch() - if upstream is not None: - upstream_name = upstream.name.lstrip("./") - if upstream_name.split('/')[-1] != active.name: - return upstream_name - except (AttributeError, GitCommandError): - pass +def resolve_base_ref(repo: Repo) -> str | None: + """Ref que o git consegue resolver para a branch default (main/master): + primeiro a branch local, depois a remote-tracking (origin/main) -- que e o + que sobra depois do actions/checkout com fetch-depth: 0 em qualquer evento.""" + for branch in _DEFAULT_BRANCH_CANDIDATES: + for ref in (branch, f"origin/{branch}"): + try: + repo.commit(ref) + except (BadName, GitCommandError): + continue + return ref # "main" local ou "origin/main" return None @@ -127,24 +105,23 @@ def get_git_diff(repo_path: str = ".", diff_level: DIFF_LEVEL = DEFAULT_DIFF_LEV used_branch="" ) - default_branch = get_default_repo_branch(repo) #maybe make this configurable via a flag later - if default_branch is None: + base_ref = resolve_base_ref(repo) #maybe make this configurable via a flag later + if base_ref is None: raise pytest.UsageError( - "Unable to determine the base branch to compare against. " - "This usually happens on a shallow clone (CI often uses fetch-depth: 1), " - "where the destination branch (e.g. `main`) is not available locally. " - "Fix by using `fetch-depth: 0` in your checkout step (e.g. actions/checkout), " - "by fetching the base branch, or by setting the GITHUB_BASE_REF environment variable." + "main/master nao foi encontrada (nem local nem origin/main). " + "Em CI raso (fetch-depth: 1) a base nao e buscada; use fetch-depth: 0 no " + "actions/checkout, e garanta que a main tambem seja buscada " + "(ex: `git fetch origin main:refs/remotes/origin/main`)." ) try: - merge_base_commit = repo.merge_base(default_branch, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description + merge_base_commit = repo.merge_base(base_ref, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description except (IndexError, GitCommandError): # Base resolved but shares no history with HEAD (e.g. unrelated first commit). return DiffResult( modified_files=[], untracked_files=untracked_diff, - used_branch=default_branch, + used_branch=base_ref, no_merge_base=True, ) @@ -158,7 +135,7 @@ def get_git_diff(repo_path: str = ".", diff_level: DIFF_LEVEL = DEFAULT_DIFF_LEV modified_files=working_dir_diff, untracked_files=untracked_diff, deleted_files=deleted_files, - used_branch=default_branch, + used_branch=base_ref, changed_line_ranges=parse_diff_output(raw_diff) ) @@ -166,5 +143,5 @@ def get_git_diff(repo_path: str = ".", diff_level: DIFF_LEVEL = DEFAULT_DIFF_LEV modified_files=working_dir_diff, untracked_files=untracked_diff, deleted_files=deleted_files, - used_branch=default_branch + used_branch=base_ref ) From b04a6793280c180057256d1cc2eada07eca8d9c1 Mon Sep 17 00:00:00 2001 From: let Date: Sat, 29 Aug 2026 22:29:50 -0300 Subject: [PATCH 122/122] docs: remove uneccessary command in deployment guide --- DEPLOYMENT.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 7c83a9c..da48cbc 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -17,8 +17,6 @@ Here, we use GitHub Actions as an example. - uses: actions/checkout@v4 with: fetch-depth: 0 - - - run: git fetch --depth=1 origin main:refs/remotes/origin/main ``` This only affects the clone metadata, not the working-tree files, so the added time is negligible even for large repositories.