rust-py-cache 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. rust_py_cache-0.3.0/.github/workflows/release.yml +389 -0
  2. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/.gitignore +3 -0
  3. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/CHANGELOG.md +72 -1
  4. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/Cargo.lock +1 -1
  5. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/Cargo.toml +1 -1
  6. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/PKG-INFO +8 -3
  7. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/README.md +7 -2
  8. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/ROADMAP.md +8 -3
  9. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/pyproject.toml +1 -1
  10. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/python/rust_py_cache/__init__.py +1 -1
  11. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/cache.rs +114 -12
  12. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/entry.rs +1 -1
  13. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_eviction.py +15 -0
  14. rust_py_cache-0.2.3/.github/workflows/ci.yml +0 -71
  15. rust_py_cache-0.2.3/.github/workflows/release.yml +0 -256
  16. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/LICENSE +0 -0
  17. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/Makefile +0 -0
  18. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/PUBLISHING.md +0 -0
  19. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/clippy.toml +0 -0
  20. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/examples/django_example.py +0 -0
  21. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/examples/fastapi_example.py +0 -0
  22. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/python/rust_py_cache/decorators.py +0 -0
  23. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/lib.rs +0 -0
  24. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/stats.rs +0 -0
  25. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/sweeper.rs +0 -0
  26. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/ttl.rs +0 -0
  27. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_cache_basic.py +0 -0
  28. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_delete.py +0 -0
  29. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_features.py +0 -0
  30. {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_set_get.py +0 -0
@@ -0,0 +1,389 @@
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # release.yml canônico dos `rust_py_*` — P3-1
3
+ #
4
+ # Um arquivo só por projeto: lint, teste, build da matriz, ensaio no TestPyPI e
5
+ # publicação. Substitui o par `ci.yml` + `release.yml` que `cache` e
6
+ # `rate_limit` mantinham, e que tinha um problema silencioso — ver "Por que um
7
+ # workflow só", abaixo.
8
+ #
9
+ # Gerado a partir de `rust_py_template/.github/workflows/release.yml` em
10
+ # 12/08/2026 — o `cache` é o primeiro projeto a adotar o formato unificado.
11
+ # Correção feita aqui deve voltar para o template, senão a divergência
12
+ # recomeça pelo mesmo caminho de sempre.
13
+ #
14
+ # ⚠️ NÃO RENOMEAR ESTE ARQUIVO. O trusted publisher cadastrado no PyPI amarra
15
+ # `release.yml`; renomear quebra a publicação até o cadastro ser refeito.
16
+ #
17
+ # Substituiu o par `ci.yml` + `release.yml` que o `cache` mantinha.
18
+ #
19
+ # ── POR QUE UM WORKFLOW SÓ ───────────────────────────────────────────────────
20
+ # `cache` e `rate_limit` tinham dois arquivos, e a divisão escondia uma
21
+ # inversão: o `ci.yml` deles rodava a suíte RICA (multi-OS, Python 3.10–3.13)
22
+ # em push/PR sem barrar nada, enquanto o `release.yml` barrava a publicação com
23
+ # uma suíte MAGRA (ubuntu, 3.12). Ou seja, o portão do PyPI era o teste mais
24
+ # fraco dos dois. `needs` não atravessa workflow — é a lição do P1-1, aqui numa
25
+ # segunda forma.
26
+ #
27
+ # Este arquivo resolve juntando: a suíte rica é a que barra o release.
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ name: CI / Release
31
+
32
+ on:
33
+ push:
34
+ branches: [main]
35
+ tags: ["v*"] # `v*`, nunca `*` — ver nota no job `release`
36
+ pull_request:
37
+ workflow_dispatch: {}
38
+
39
+ permissions:
40
+ contents: read
41
+
42
+ env:
43
+ PACKAGE_NAME: rust-py-cache
44
+ # 2 manylinux + 2 musllinux + 1 windows + 2 macos. O `monitor` soma 4 do
45
+ # PyPy (11); o `scheduler`, com a matriz longa, chega a 15.
46
+ MIN_WHEELS: "7"
47
+
48
+ jobs:
49
+ # ── PORTÃO 1: lint ─────────────────────────────────────────────────────────
50
+ # Barra o release junto com o `test`. Nos repositórios de workflow único, um
51
+ # `lint` vermelho ao lado de uma publicação bem-sucedida seria ilegível — a
52
+ # leitura tem que ser inequívoca: vermelho significa que não publicou.
53
+ lint:
54
+ name: fmt + clippy
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+ # Versão fixa, não "stable". Com `-D warnings`, um clippy mais novo no
59
+ # runner reprova código que ninguém tocou, e a CI fica vermelha por conta
60
+ # do calendário. Subir este número vira uma mudança deliberada.
61
+ - uses: dtolnay/rust-toolchain@1.88.0
62
+ with:
63
+ components: rustfmt, clippy
64
+ - uses: Swatinem/rust-cache@v2
65
+ - name: cargo fmt
66
+ run: cargo fmt --all -- --check
67
+ # clippy só checa tipos, não linka, então --all-features é seguro aqui —
68
+ # diferente do `cargo test`, que precisa de --no-default-features.
69
+ - name: cargo clippy
70
+ run: cargo clippy --all-targets --all-features -- -D warnings
71
+
72
+ # ── PORTÃO 2: teste ────────────────────────────────────────────────────────
73
+ # Nada é construído e nada chega ao PyPI antes disto passar. Publicação no
74
+ # PyPI é irreversível: um wheel ruim não pode ser substituído, só yankado.
75
+ test:
76
+ name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }})
77
+ runs-on: ${{ matrix.os }}
78
+ strategy:
79
+ fail-fast: false
80
+ # A matriz veio do `ci.yml` do `cache` e do `CI.yml` do `rate_limit`, que
81
+ # tinham a cobertura mais larga dos cinco — e que, por viverem em
82
+ # workflow separado, não barravam nada. Agora barram.
83
+ #
84
+ # O eixo de SO é o do `cache`, reduzido: ele rodava macOS em três versões
85
+ # de Python, o que dobrava a matriz para provar a mesma coisa. Uma versão
86
+ # em macOS basta para pegar o que ubuntu não pega (link da extensão,
87
+ # caminho de arquivo, comportamento de thread). O eixo de Python inteiro
88
+ # fica no ubuntu, que é onde ele importa.
89
+ matrix:
90
+ os: [ubuntu-latest]
91
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
92
+ include:
93
+ - os: macos-latest
94
+ python-version: "3.12"
95
+ steps:
96
+ - uses: actions/checkout@v4
97
+ - uses: dtolnay/rust-toolchain@stable
98
+ - uses: actions/setup-python@v5
99
+ with:
100
+ python-version: ${{ matrix.python-version }}
101
+ - uses: Swatinem/rust-cache@v2
102
+
103
+ # --no-default-features desliga `extension-module`, que manda o linker
104
+ # deixar os símbolos da libpython indefinidos. Correto para a wheel, mas
105
+ # produz um binário de teste que não linka no Linux.
106
+ #
107
+ # ⚠️ O `rate_limit` é o único sem seção `[features]` no Cargo.toml — lá
108
+ # o `extension-module` vem só de `[tool.maturin].features`, e a flag é
109
+ # inócua. Alinhar o Cargo.toml dele é item pendente.
110
+ - name: Rust unit tests
111
+ # Uma vez por SO basta: o core não muda com a versão do Python.
112
+ if: matrix.python-version == '3.12'
113
+ env:
114
+ PYO3_PYTHON: python
115
+ run: cargo test --no-default-features --lib
116
+
117
+ # `pip install` aciona o backend maturin, compila a extensão e a instala
118
+ # no Python do runner. Ao contrário de `maturin develop`, não exige
119
+ # virtualenv — armadilha que custou um release do `monitor` (P1-9).
120
+ # Instalar não-editável faz os testes exercitarem o artefato que embarca.
121
+ - name: Build, install the extension and run the Python tests
122
+ run: |
123
+ python -m pip install --upgrade pip
124
+ python -m pip install ".[dev]"
125
+ python -m pytest -q
126
+
127
+ # ── BUILD ──────────────────────────────────────────────────────────────────
128
+ # Guardados por tag OU disparo manual: um push comum não precisa pagar a
129
+ # matriz inteira, mas o ensaio no TestPyPI precisa dos artefatos.
130
+ #
131
+ # `fail-fast: false` em todas as matrizes: sem isso, um alvo que cai CANCELA
132
+ # os irmãos. Em 12/08/2026 o build x86 do `scheduler` quebrou num 403 do
133
+ # vault.centos.org e levou junto o diagnóstico de armv7/s390x/ppc64le.
134
+ #
135
+ # Nenhum job de build passa `--find-interpreter`: com `abi3-py310` ele não
136
+ # produz nada a mais. Verificado — o `audit` publicava 7 wheels, todos abi3,
137
+ # com a flag ligada. É flag morto e confunde quem lê.
138
+ linux:
139
+ runs-on: ubuntu-latest
140
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
141
+ needs: [lint, test]
142
+ strategy:
143
+ fail-fast: false
144
+ matrix:
145
+ target: [x86_64, aarch64]
146
+ steps:
147
+ - uses: actions/checkout@v4
148
+ - uses: PyO3/maturin-action@v1
149
+ with:
150
+ target: ${{ matrix.target }}
151
+ args: --release --out dist
152
+ # `auto` constrói dentro do container manylinux, para o wheel
153
+ # declarar um piso de glibc portável em vez do glibc do runner.
154
+ manylinux: auto
155
+ - uses: actions/upload-artifact@v4
156
+ with:
157
+ name: wheels-linux-${{ matrix.target }}
158
+ path: dist
159
+
160
+ musllinux:
161
+ runs-on: ubuntu-latest
162
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
163
+ needs: [lint, test]
164
+ strategy:
165
+ fail-fast: false
166
+ matrix:
167
+ target: [x86_64, aarch64]
168
+ steps:
169
+ - uses: actions/checkout@v4
170
+ - uses: PyO3/maturin-action@v1
171
+ with:
172
+ target: ${{ matrix.target }}
173
+ args: --release --out dist
174
+ manylinux: musllinux_1_2
175
+ - uses: actions/upload-artifact@v4
176
+ with:
177
+ name: wheels-musllinux-${{ matrix.target }}
178
+ path: dist
179
+
180
+ windows:
181
+ runs-on: windows-latest
182
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
183
+ needs: [lint, test]
184
+ steps:
185
+ - uses: actions/checkout@v4
186
+ - uses: actions/setup-python@v5
187
+ with:
188
+ architecture: x64
189
+ - uses: PyO3/maturin-action@v1
190
+ with:
191
+ target: x64
192
+ args: --release --out dist
193
+ - uses: actions/upload-artifact@v4
194
+ with:
195
+ name: wheels-windows-x64
196
+ path: dist
197
+
198
+ macos:
199
+ runs-on: macos-latest
200
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
201
+ needs: [lint, test]
202
+ strategy:
203
+ fail-fast: false
204
+ matrix:
205
+ target: [x86_64, aarch64]
206
+ steps:
207
+ - uses: actions/checkout@v4
208
+ - uses: PyO3/maturin-action@v1
209
+ with:
210
+ target: ${{ matrix.target }}
211
+ args: --release --out dist
212
+ - uses: actions/upload-artifact@v4
213
+ with:
214
+ name: wheels-macos-${{ matrix.target }}
215
+ path: dist
216
+
217
+ sdist:
218
+ runs-on: ubuntu-latest
219
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
220
+ needs: [lint, test]
221
+ steps:
222
+ - uses: actions/checkout@v4
223
+ - uses: PyO3/maturin-action@v1
224
+ with:
225
+ command: sdist
226
+ args: --out dist
227
+ - uses: actions/upload-artifact@v4
228
+ with:
229
+ name: wheels-sdist
230
+ path: dist
231
+
232
+ # ── ENSAIO ─────────────────────────────────────────────────────────────────
233
+ # Valida a troca OIDC inteira sem tocar no índice de produção. É onde um
234
+ # publisher mal cadastrado deve ser descoberto — no PyPI, um nome de arquivo
235
+ # publicado nunca pode ser reusado.
236
+ #
237
+ # ⚠️ Precisa do seu próprio publisher, cadastrado no TestPyPI (conta separada
238
+ # do PyPI), com environment `testpypi`.
239
+ #
240
+ # Sem verificação pós-upload de propósito: o que se valida aqui é a troca de
241
+ # token, e o TestPyPI expira distribuições — reprovar por um arquivo que o
242
+ # índice de teste descartou seria alarme falso.
243
+ publish-testpypi:
244
+ name: Publish to TestPyPI (ensaio)
245
+ runs-on: ubuntu-latest
246
+ if: github.event_name == 'workflow_dispatch'
247
+ needs: [lint, test, linux, musllinux, windows, macos, sdist]
248
+ environment: testpypi
249
+ permissions:
250
+ id-token: write
251
+ steps:
252
+ - uses: actions/download-artifact@v4
253
+ with:
254
+ pattern: wheels-*
255
+ path: dist
256
+ merge-multiple: true
257
+ - uses: pypa/gh-action-pypi-publish@release/v1
258
+ with:
259
+ repository-url: https://test.pypi.org/legacy/
260
+ packages-dir: dist
261
+ skip-existing: true
262
+
263
+ # ── PUBLICAÇÃO ─────────────────────────────────────────────────────────────
264
+ release:
265
+ name: Publish to PyPI
266
+ runs-on: ubuntu-latest
267
+ # SÓ TAG. Sem a cláusula de `workflow_dispatch` que os jobs de build têm —
268
+ # a assimetria é a proteção: disparo manual vai ao TestPyPI, só a tag chega
269
+ # ao índice de verdade.
270
+ #
271
+ # ⚠️ Isto depende do filtro `tags: ["v*"]` lá em cima. O `scheduler` usava
272
+ # `'*'`, e como esta guarda só checa `refs/tags/`, QUALQUER tag empurrada
273
+ # teria publicado. Corrigido em 12/08/2026.
274
+ if: startsWith(github.ref, 'refs/tags/')
275
+ needs: [lint, test, linux, musllinux, windows, macos, sdist]
276
+
277
+ # Trusted publishing (OIDC): sem `secrets.PYPI_API_TOKEN`. O GitHub emite
278
+ # um JWT curto afirmando repositório, workflow e ref, e o PyPI o troca por
279
+ # um token de upload de poucos minutos, restrito a este projeto.
280
+ #
281
+ # ⚠️ Os três valores precisam bater EXATAMENTE com o publisher cadastrado
282
+ # em PyPI → Manage → Publishing:
283
+ # owner/repo .... <owner>/<repo>
284
+ # workflow ...... release.yml (o NOME DO ARQUIVO, não o `name:` acima)
285
+ # environment ... pypi
286
+ # Errar qualquer um dá `invalid-publisher`, que é falha segura: acontece na
287
+ # troca do token, depois de construir tudo e antes de qualquer arquivo sair
288
+ # do runner.
289
+ environment: pypi
290
+ permissions:
291
+ id-token: write
292
+
293
+ steps:
294
+ - uses: actions/download-artifact@v4
295
+ with:
296
+ pattern: wheels-*
297
+ path: dist
298
+ merge-multiple: true
299
+ - uses: actions/setup-python@v5
300
+ with:
301
+ python-version: "3.12"
302
+ - name: Install the verification dependency
303
+ run: python -m pip install --upgrade requests
304
+
305
+ # Conferência antes de qualquer coisa sair do runner: publicar um
306
+ # conjunto incompleto é pior que não publicar, porque o PyPI nunca deixa
307
+ # reusar um nome de arquivo — uma versão meio-publicada só pode ser
308
+ # completada, nunca substituída. Aconteceu de verdade: `monitor` 0.4.0
309
+ # ficou com 37 de 45 arquivos e sem sdist (P1-9).
310
+ - name: Verify the artifact set is complete
311
+ run: |
312
+ set -euo pipefail
313
+ ls -la dist/
314
+ test -n "$(find dist -name '*.tar.gz' -print -quit)" \
315
+ || { echo "::error::sdist ausente — abortando antes de publicar"; exit 1; }
316
+ wheels=$(find dist -name '*.whl' | wc -l)
317
+ echo "wheels: $wheels (mínimo ${MIN_WHEELS})"
318
+ test "$wheels" -ge "${MIN_WHEELS}" \
319
+ || { echo "::error::só $wheels wheels; esperado >= ${MIN_WHEELS}"; exit 1; }
320
+
321
+ # O PyPI devolve 500 transitório com frequência suficiente para importar,
322
+ # e o upload é arquivo por arquivo: uma falha no meio deixa a versão
323
+ # publicada pela metade. `skip-existing` torna a segunda tentativa segura
324
+ # — sem ele, ela morre nos arquivos que já subiram.
325
+ #
326
+ # Dois passos explícitos em vez de um wrapper de retry porque o trusted
327
+ # publishing exige a action oficial, que não dá para acionar por linha de
328
+ # comando de dentro do `nick-fields/retry`.
329
+ #
330
+ # ⚠️ Se a primeira tentativa falhar por `invalid-publisher`, a segunda vai
331
+ # falhar igual — é erro de configuração, não transitório. Custa 45s de
332
+ # espera inútil; melhoria conhecida e ainda não feita.
333
+ - name: Publish to PyPI (attempt 1)
334
+ id: publish1
335
+ continue-on-error: true
336
+ uses: pypa/gh-action-pypi-publish@release/v1
337
+ with:
338
+ packages-dir: dist
339
+ skip-existing: true
340
+
341
+ - name: Wait before retrying
342
+ if: steps.publish1.outcome == 'failure'
343
+ run: sleep 45
344
+
345
+ - name: Publish to PyPI (attempt 2)
346
+ if: steps.publish1.outcome == 'failure'
347
+ uses: pypa/gh-action-pypi-publish@release/v1
348
+ with:
349
+ packages-dir: dist
350
+ skip-existing: true
351
+
352
+ # Prova que a release está completa, em vez de confiar num código de
353
+ # saída que uma publicação parcial também produz.
354
+ - name: Verify every file reached PyPI
355
+ run: |
356
+ set -euo pipefail
357
+ python - <<'PY'
358
+ import os, sys, glob, time, requests
359
+ package = os.environ["PACKAGE_NAME"]
360
+ version = os.environ["GITHUB_REF_NAME"].lstrip("v")
361
+ # Só .whl e .tar.gz: os únicos tipos que o PyPI hospeda. O
362
+ # `pypa/gh-action-pypi-publish` deixa um .publish.attestation por
363
+ # distribuição em dist/ DEPOIS de publicar (atestados PEP 740, que
364
+ # sobem junto do arquivo e não como arquivo próprio). Um glob amplo
365
+ # os conta como "faltando" e reprova uma release que deu certo — foi
366
+ # o alarme falso da rate_limit 0.2.1 (P1-9).
367
+ local = {
368
+ os.path.basename(p)
369
+ for pattern in ("dist/*.whl", "dist/*.tar.gz")
370
+ for p in glob.glob(pattern)
371
+ }
372
+ url = f"https://pypi.org/pypi/{package}/{version}/json"
373
+ for attempt in range(6): # a API JSON do PyPI é cacheada
374
+ response = requests.get(url, timeout=30)
375
+ if response.status_code == 200:
376
+ remote = {f["filename"] for f in response.json()["urls"]}
377
+ missing = local - remote
378
+ if not missing:
379
+ print(f"OK: {len(remote)} arquivos publicados na {version}")
380
+ sys.exit(0)
381
+ else:
382
+ missing = local
383
+ print(f"tentativa {attempt + 1}: faltam {len(missing)}")
384
+ time.sleep(20)
385
+ print("::error::faltaram arquivos no PyPI:")
386
+ for name in sorted(missing):
387
+ print(f" - {name}")
388
+ sys.exit(1)
389
+ PY
@@ -5,3 +5,6 @@ __pycache__/
5
5
  dist/
6
6
  *.egg-info/
7
7
  .pytest_cache/
8
+
9
+ # macOS
10
+ .DS_Store
@@ -5,7 +5,78 @@ Todas as mudanças relevantes deste projeto são registradas aqui.
5
5
  O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
6
6
  e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
7
7
 
8
- ## [0.2.3] — não publicada
8
+ ## [0.3.0] — não publicada
9
+
10
+ Evicção LRU com custo amortizado O(1) (**P2-2**). Inclui também tudo o que
11
+ estava na 0.2.4, que nunca chegou a ser publicada.
12
+
13
+ ### Alterado
14
+
15
+ - **A evicção LRU passou a remover um lote, não uma entrada.** Até a 0.2.x,
16
+ achar a entrada menos recentemente usada varria o `DashMap` inteiro — e isso
17
+ acontecia a **cada** `set` de chave nova com o cache cheio, que é o regime
18
+ normal de um cache cheio sob escrita. Agora cada varredura remove
19
+ `max_size / 32` entradas (no mínimo 1), e a próxima só vem depois de outras
20
+ ~n/32 inserções. Medido com `max_size=100_000`: **~620 µs → ~1,6 µs** por
21
+ `set` com o cache cheio (com 10 000: ~70 µs → ~1,5 µs). O custo deixou de
22
+ crescer com o tamanho do cache.
23
+ - A seleção acha o corte com `select_nth_unstable` sobre os carimbos de acesso
24
+ (sem clonar chaves nem ordenar) e remove com `remove_if`, que recheca o
25
+ carimbo: uma chave lida por outra thread no meio da varredura fica.
26
+ - Um `Mutex` cobre **só** o caminho de evicção, para várias threads que acham o
27
+ cache cheio ao mesmo tempo não varrerem em paralelo. `get`/`set` comuns não o
28
+ tocam.
29
+
30
+ ### Compatibilidade
31
+
32
+ - Nenhuma API mudou. O que muda de observável, e por isso o minor sobe: logo
33
+ depois de uma evicção o cache fica ~3% abaixo de `max_size` (antes ficava em
34
+ `max_size - 1`), e `stats()["evicted"]` cresce em lotes. Com `max_size < 64`
35
+ o lote é de 1 entrada e o comportamento é idêntico ao anterior.
36
+ - Amostragem estilo Redis, que o backlog sugeria, foi descartada: exigiria
37
+ acesso aleatório às entradas, que o `DashMap` só expõe pela `raw-api` (tabela
38
+ interna do hashbrown, iteração `unsafe`).
39
+
40
+ ## [0.2.4] — não publicada
41
+
42
+ Adoção do `release.yml` unificado do template (**P3-1**). Nada muda no
43
+ comportamento da biblioteca — mas muda, e bastante, o que precisa passar antes
44
+ de uma versão chegar ao PyPI.
45
+
46
+ ### Corrigido
47
+
48
+ - **O release era barrado pela suíte mais fraca das duas.** O projeto tinha
49
+ dois workflows, e a divisão escondia uma inversão:
50
+
51
+ | | rodava | barrava o release? |
52
+ | --- | --- | --- |
53
+ | `ci.yml` | ubuntu + macOS × Python 3.10/3.12/3.13 | **não** |
54
+ | `release.yml` | ubuntu × Python 3.12 | sim |
55
+
56
+ Ou seja, a suíte boa não protegia nada e o portão do PyPI era a magra.
57
+ `needs` não atravessa workflow — a mesma lição do P1-1, numa segunda forma.
58
+ Os dois arquivos ainda mantinham o job `lint` duplicado.
59
+
60
+ Agora é um workflow só, e quem barra a publicação é
61
+ **ubuntu × Python 3.10/3.11/3.12/3.13 + macOS × 3.12**.
62
+
63
+ ⚠️ O eixo de macOS foi reduzido de três versões de Python para uma. Rodar as
64
+ três dobrava a matriz para provar a mesma coisa; uma basta para pegar o que
65
+ o ubuntu não pega (link da extensão, caminhos, threads).
66
+
67
+ ### Adicionado
68
+
69
+ - **Ensaio no TestPyPI** (`publish-testpypi`, por `workflow_dispatch`), que o
70
+ projeto não tinha. Valida a troca de token OIDC sem tocar no índice de
71
+ produção. ⚠️ Exige um publisher próprio cadastrado no TestPyPI.
72
+ - **`fail-fast: false`** nas matrizes: um alvo que cai deixava de cancelar os
73
+ irmãos e apagar o diagnóstico deles.
74
+
75
+ ### Removido
76
+
77
+ - **`.github/workflows/ci.yml`**, fundido no `release.yml`.
78
+
79
+ ## [0.2.3] - 2026-08-12
9
80
 
10
81
  Versão de empacotamento e qualidade: nada muda no comportamento da biblioteca.
11
82
 
@@ -202,7 +202,7 @@ dependencies = [
202
202
 
203
203
  [[package]]
204
204
  name = "rust_py_cache"
205
- version = "0.2.3"
205
+ version = "0.3.0"
206
206
  dependencies = [
207
207
  "dashmap",
208
208
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rust_py_cache"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  license = "MIT"
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-py-cache
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: Implementation :: CPython
@@ -73,7 +73,7 @@ cache.stats() # {'hits','misses','sets','deletes','expired','evi
73
73
 
74
74
  ```python
75
75
  # Cap the number of keys. When full and a new key arrives, evict the
76
- # least-recently-used entry instead of rejecting the write.
76
+ # least-recently-used entries (a ~3% batch) instead of rejecting the write.
77
77
  cache = Cache(max_size=1000, eviction_policy="lru")
78
78
  cache.eviction_policy # "lru"
79
79
 
@@ -138,7 +138,12 @@ Constructor: `Cache(max_size=None, eviction_policy="reject", cleanup_interval=No
138
138
  accessed (`get`/`exists`) or via `cleanup_expired()`. Pass `cleanup_interval` to
139
139
  also run a **background** sweeper thread that reclaims expired keys on its own.
140
140
  - **Eviction:** with `max_size` + `eviction_policy="lru"`, a full cache evicts the
141
- least-recently-used entry (recency updated on every `get` hit) to admit a new key.
141
+ least-recently-used entries (recency updated on every `get` hit) to admit a new key.
142
+ It removes them in a **batch** of `max_size / 32` (at least 1) per pass, so the
143
+ O(n) scan that finds them runs once every ~n/32 inserts instead of on every one —
144
+ amortized O(1) per write, with no global lock on the `get`/`set` path. A `set` on
145
+ a full 100k-entry cache went from ~620 µs (one full scan per insert, up to 0.2.x)
146
+ to ~1.6 µs. Right after a pass the cache sits ~3% below `max_size`.
142
147
 
143
148
  ## Limitations
144
149
 
@@ -52,7 +52,7 @@ cache.stats() # {'hits','misses','sets','deletes','expired','evi
52
52
 
53
53
  ```python
54
54
  # Cap the number of keys. When full and a new key arrives, evict the
55
- # least-recently-used entry instead of rejecting the write.
55
+ # least-recently-used entries (a ~3% batch) instead of rejecting the write.
56
56
  cache = Cache(max_size=1000, eviction_policy="lru")
57
57
  cache.eviction_policy # "lru"
58
58
 
@@ -117,7 +117,12 @@ Constructor: `Cache(max_size=None, eviction_policy="reject", cleanup_interval=No
117
117
  accessed (`get`/`exists`) or via `cleanup_expired()`. Pass `cleanup_interval` to
118
118
  also run a **background** sweeper thread that reclaims expired keys on its own.
119
119
  - **Eviction:** with `max_size` + `eviction_policy="lru"`, a full cache evicts the
120
- least-recently-used entry (recency updated on every `get` hit) to admit a new key.
120
+ least-recently-used entries (recency updated on every `get` hit) to admit a new key.
121
+ It removes them in a **batch** of `max_size / 32` (at least 1) per pass, so the
122
+ O(n) scan that finds them runs once every ~n/32 inserts instead of on every one —
123
+ amortized O(1) per write, with no global lock on the `get`/`set` path. A `set` on
124
+ a full 100k-entry cache went from ~620 µs (one full scan per insert, up to 0.2.x)
125
+ to ~1.6 µs. Right after a pass the cache sits ~3% below `max_size`.
121
126
 
122
127
  ## Limitations
123
128
 
@@ -36,16 +36,21 @@ the stages already shipped and what comes next.
36
36
  ### LRU eviction
37
37
 
38
38
  - ✅ `Cache(max_size=..., eviction_policy="lru")` evicts the **least-recently-used**
39
- entry once the cap is hit (recency updated on every `get` hit). Default policy
39
+ entries once the cap is hit (recency updated on every `get` hit). Default policy
40
40
  is `"reject"` (backward compatible). Evictions are surfaced in `stats()` as an
41
41
  `evicted` counter.
42
+ - ✅ **v0.3 — amortized O(1) eviction.** Evicts a batch of `max_size / 32` per
43
+ scan instead of one entry per scan, so a full cache under sustained writes no
44
+ longer pays an O(n) scan on every new key (~620 µs → ~1.6 µs per `set` at 100k
45
+ entries).
42
46
 
43
47
  ## Planned
44
48
 
45
- ### v0.3 — LFU eviction
49
+ ### v0.4 — LFU eviction
46
50
 
47
51
  - 🔜 **Least-frequently-used** eviction policy as an alternative to LRU, selectable
48
- at construction time (e.g. `Cache(max_size=..., policy="lfu")`).
52
+ at construction time (e.g. `Cache(max_size=..., policy="lfu")`). Reuses the
53
+ batch selection from v0.3, keyed on `hits` instead of `last_accessed_at`.
49
54
 
50
55
  ### v0.5 — Configurable serializer
51
56
 
@@ -8,7 +8,7 @@ build-backend = "maturin"
8
8
 
9
9
  [project]
10
10
  name = "rust-py-cache"
11
- version = "0.2.3"
11
+ version = "0.3.0"
12
12
  description = "An ultra-fast local cache for Python, powered by Rust."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.10"
@@ -19,4 +19,4 @@ class Cache(_RustCache):
19
19
 
20
20
 
21
21
  __all__ = ["Cache", "hello"]
22
- __version__ = "0.2.3"
22
+ __version__ = "0.3.0"
@@ -4,6 +4,8 @@
4
4
  //! de testar em Rust puro e para separar responsabilidades: `lib.rs` cuida da
5
5
  //! ponte com o Python; `cache.rs` cuida do armazenamento.
6
6
 
7
+ use std::sync::Mutex;
8
+
7
9
  use dashmap::DashMap;
8
10
 
9
11
  use crate::entry::CacheEntry;
@@ -15,10 +17,14 @@ pub enum EvictionPolicy {
15
17
  /// Rejeita a escrita (`set` devolve `false`). Comportamento padrão.
16
18
  #[default]
17
19
  Reject,
18
- /// Remove a entrada menos recentemente usada (LRU) para abrir espaço.
20
+ /// Remove as entradas menos recentemente usadas (LRU) para abrir espaço.
19
21
  Lru,
20
22
  }
21
23
 
24
+ /// Fração de `max_size` removida de uma vez quando a LRU precisa abrir espaço
25
+ /// (1/32 ≈ 3%, no mínimo 1 entrada). Ver [`RustCache::evict_lru_batch`].
26
+ const EVICTION_BATCH_DIVISOR: usize = 32;
27
+
22
28
  /// Núcleo concorrente do cache.
23
29
  ///
24
30
  /// `DashMap` é um `HashMap` thread-safe com *sharding*: ele divide o mapa em
@@ -37,6 +43,11 @@ pub struct RustCache {
37
43
  max_size: Option<usize>,
38
44
  /// Política aplicada quando o cache enche e chega uma chave nova.
39
45
  eviction_policy: EvictionPolicy,
46
+ /// Serializa só o caminho de evicção: quando várias threads encontram o
47
+ /// cache cheio ao mesmo tempo, uma varre e as outras, ao entrar, veem que
48
+ /// já há espaço e seguem — sem N varreduras simultâneas. Não é tocado no
49
+ /// caminho comum de `get`/`set`.
50
+ eviction_lock: Mutex<()>,
40
51
  }
41
52
 
42
53
  impl RustCache {
@@ -47,6 +58,7 @@ impl RustCache {
47
58
  stats: CacheStats::new(),
48
59
  max_size,
49
60
  eviction_policy,
61
+ eviction_lock: Mutex::new(()),
50
62
  }
51
63
  }
52
64
 
@@ -55,17 +67,67 @@ impl RustCache {
55
67
  self.eviction_policy
56
68
  }
57
69
 
58
- /// Remove a entrada com o menor `last_accessed_at` (a "menos recentemente
59
- /// usada"). Varre o mapa uma vez — O(n) só na evicção (caminho raro: cache
60
- /// cheio + chave nova). Conta em `evicted`.
61
- fn evict_lru(&self) {
62
- let oldest = self
70
+ /// Abre espaço removendo, de uma vez, as `max / 32` (no mínimo 1) entradas
71
+ /// menos recentemente usadas. Conta cada uma em `evicted`.
72
+ ///
73
+ /// ## Por que em lote
74
+ ///
75
+ /// Achar a entrada mais antiga num `DashMap` exige varrer o mapa inteiro —
76
+ /// não há ordem nem acesso aleatório. Até a 0.2 isso acontecia a **cada**
77
+ /// `set` de chave nova com o cache cheio, que é o regime normal de um cache
78
+ /// cheio sob escrita: O(n) por inserção. Removendo ~3% por varredura, a
79
+ /// próxima varredura só vem depois de outras ~n/32 inserções — custo
80
+ /// amortizado O(1) (≈ 64 visitas por inserção), sem lock global no caminho
81
+ /// comum e sem `unsafe` para amostrar shards.
82
+ ///
83
+ /// Uma lista LRU exata exigiria um lock global em todo `get`, que é o que o
84
+ /// `DashMap` existe para evitar. A amostragem estilo Redis exigiria acesso
85
+ /// aleatório às entradas, que o `DashMap` só oferece pela `raw-api`
86
+ /// (tabela interna do hashbrown, iteração `unsafe`).
87
+ ///
88
+ /// ## Como
89
+ ///
90
+ /// 1. Coleta só os `last_accessed_at` (um `Vec<u64>`, sem clonar chaves) e
91
+ /// acha o corte do lote com `select_nth_unstable` — O(n), sem ordenar.
92
+ /// 2. Coleta as chaves com acesso `<= corte` (até o tamanho do lote).
93
+ /// 3. Remove com `remove_if`, que **recheca** o carimbo: uma chave lida por
94
+ /// outra thread entre as fases deixou de ser LRU e fica.
95
+ fn evict_lru_batch(&self, max: usize) {
96
+ let _guard = self
97
+ .eviction_lock
98
+ .lock()
99
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
100
+ // Outra thread pode ter aberto espaço enquanto esperávamos o lock.
101
+ if self.store.len() < max {
102
+ return;
103
+ }
104
+
105
+ let mut stamps: Vec<u64> = self
63
106
  .store
64
107
  .iter()
65
- .min_by_key(|e| e.value().last_accessed_at)
66
- .map(|e| e.key().clone());
67
- if let Some(key) = oldest {
68
- if self.store.remove(&key).is_some() {
108
+ .map(|e| e.value().last_accessed_at)
109
+ .collect();
110
+ if stamps.is_empty() {
111
+ return;
112
+ }
113
+ let batch = (max / EVICTION_BATCH_DIVISOR).clamp(1, stamps.len());
114
+ let (_, &mut cutoff, _) = stamps.select_nth_unstable(batch - 1);
115
+ drop(stamps);
116
+
117
+ let victims: Vec<String> = self
118
+ .store
119
+ .iter()
120
+ .filter(|e| e.value().last_accessed_at <= cutoff)
121
+ .take(batch)
122
+ .map(|e| e.key().clone())
123
+ .collect();
124
+
125
+ for key in victims {
126
+ if self
127
+ .store
128
+ .remove_if(&key, |_, v| v.last_accessed_at <= cutoff)
129
+ .is_some()
130
+ {
69
131
  self.stats.record_evicted();
70
132
  }
71
133
  }
@@ -79,14 +141,15 @@ impl RustCache {
79
141
  ///
80
142
  /// Sobrescrever uma chave existente sempre é permitido. Para uma chave
81
143
  /// **nova** com o cache cheio (`max_size`): com `Reject` devolve `false` sem
82
- /// inserir; com `Lru` remove a entrada menos recentemente usada e prossegue.
144
+ /// inserir; com `Lru` remove um lote das menos recentemente usadas (ver
145
+ /// [`Self::evict_lru_batch`]) e prossegue.
83
146
  /// Devolve `true` quando a entrada foi (ou seria) gravada.
84
147
  pub fn set(&self, key: String, value: Vec<u8>, expires_at: Option<u64>, now: u64) -> bool {
85
148
  if let Some(max) = self.max_size {
86
149
  if self.store.len() >= max && !self.store.contains_key(&key) {
87
150
  match self.eviction_policy {
88
151
  EvictionPolicy::Reject => return false,
89
- EvictionPolicy::Lru => self.evict_lru(),
152
+ EvictionPolicy::Lru => self.evict_lru_batch(max),
90
153
  }
91
154
  }
92
155
  }
@@ -314,6 +377,45 @@ mod tests {
314
377
  assert!(c.get("c", NOW + 11).is_some());
315
378
  }
316
379
 
380
+ #[test]
381
+ fn lru_evicts_a_batch_of_the_oldest() {
382
+ // max 64 => lote de 64/32 = 2.
383
+ let c = RustCache::new(Some(64), EvictionPolicy::Lru);
384
+ for i in 0..64u64 {
385
+ c.set(format!("k{i}"), b"v".to_vec(), None, NOW + i);
386
+ }
387
+ // k0 e k1 são as mais antigas; tocar em k0 a torna a mais recente.
388
+ assert!(c.get("k0", NOW + 100).is_some());
389
+ assert!(c.set("new".into(), b"v".to_vec(), None, NOW + 101));
390
+ assert_eq!(c.stats().evicted, 2);
391
+ assert_eq!(c.len(), 63);
392
+ assert!(c.exists("k0", NOW + 102));
393
+ assert!(!c.exists("k1", NOW + 102));
394
+ assert!(!c.exists("k2", NOW + 102));
395
+ assert!(c.exists("k3", NOW + 102));
396
+ assert!(c.exists("new", NOW + 102));
397
+ }
398
+
399
+ #[test]
400
+ fn lru_amortizes_the_scan() {
401
+ // Escrita sustentada com o cache cheio: o número de varreduras é
402
+ // ~inserções / lote, não uma por inserção.
403
+ let max = 1_024;
404
+ let c = RustCache::new(Some(max), EvictionPolicy::Lru);
405
+ for i in 0..(max as u64 * 4) {
406
+ c.set(format!("k{i}"), b"v".to_vec(), None, NOW + i);
407
+ assert!(c.len() <= max);
408
+ }
409
+ let evicted = c.stats().evicted;
410
+ let batch = (max / EVICTION_BATCH_DIVISOR) as u64;
411
+ assert_eq!(evicted % batch, 0, "sempre um lote cheio por varredura");
412
+ // 3 * max inserções além da capacidade => ~3 * 32 varreduras.
413
+ assert!(evicted / batch <= 3 * EVICTION_BATCH_DIVISOR as u64 + 1);
414
+ // As sobreviventes são as mais recentes.
415
+ assert!(c.exists(&format!("k{}", max as u64 * 4 - 1), NOW));
416
+ assert!(!c.exists("k0", NOW));
417
+ }
418
+
317
419
  #[test]
318
420
  fn no_max_size_never_evicts() {
319
421
  let c = RustCache::new(None, EvictionPolicy::Lru);
@@ -18,7 +18,7 @@ pub struct CacheEntry {
18
18
  pub expires_at: Option<u64>,
19
19
  /// Quando a entrada foi criada (epoch-ms).
20
20
  pub created_at: u64,
21
- /// Último acesso por `get` bem-sucedido (epoch-ms) — base para LRU futuro.
21
+ /// Último acesso por `get` bem-sucedido (epoch-ms) — base da evicção LRU.
22
22
  pub last_accessed_at: u64,
23
23
  /// Quantas vezes esta entrada foi lida — base para LFU futuro.
24
24
  pub hits: u64,
@@ -48,6 +48,21 @@ def test_lru_evicts_to_make_room():
48
48
  assert cache.stats()["evicted"] == 1
49
49
 
50
50
 
51
+ def test_lru_evicts_in_batches_and_stays_bounded():
52
+ # max_size 320 => lote de 10 por varredura.
53
+ cache = Cache(max_size=320, eviction_policy="lru")
54
+ for i in range(320):
55
+ cache.set(f"k{i}", i)
56
+ cache.set("new", 1)
57
+ assert cache.stats()["evicted"] == 10
58
+ assert len(cache) == 311
59
+ assert cache.get("new") == 1
60
+ for i in range(5_000):
61
+ cache.set(f"x{i}", i)
62
+ assert len(cache) <= 320
63
+ assert cache.stats()["evicted"] % 10 == 0
64
+
65
+
51
66
  def test_lru_keeps_recently_used():
52
67
  cache = Cache(max_size=2, eviction_policy="lru")
53
68
  cache.set("a", 1)
@@ -1,71 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- workflow_dispatch:
8
-
9
- jobs:
10
- # O lint faz parte do portão de release, não de um workflow paralelo.
11
- # Nos repositórios de workflow único, um `lint` vermelho ao lado de uma
12
- # publicação bem-sucedida seria ruído difícil de ler — a leitura passa a ser
13
- # inequívoca: vermelho significa que não publicou.
14
- #
15
- # A toolchain é fixa, não "stable": com `-D warnings`, um clippy mais novo
16
- # no runner reprova código que ninguém tocou, e a CI ficaria vermelha por
17
- # conta do calendário. Subir este número vira uma mudança deliberada.
18
- lint:
19
- name: fmt + clippy
20
- runs-on: ubuntu-latest
21
- steps:
22
- - uses: actions/checkout@v4
23
- - uses: dtolnay/rust-toolchain@1.88.0
24
- with:
25
- components: rustfmt, clippy
26
- - uses: Swatinem/rust-cache@v2
27
- - name: cargo fmt
28
- run: cargo fmt --all -- --check
29
- # clippy só checa tipos, não linka, então --all-features é seguro aqui
30
- # (diferente do `cargo test`, que precisa de --no-default-features).
31
- - name: cargo clippy
32
- run: cargo clippy --all-targets --all-features -- -D warnings
33
-
34
- test:
35
- name: cargo test + pytest
36
- runs-on: ${{ matrix.os }}
37
- strategy:
38
- fail-fast: false
39
- matrix:
40
- os: [ubuntu-latest, macos-latest]
41
- python-version: ["3.10", "3.12", "3.13"]
42
- steps:
43
- - uses: actions/checkout@v4
44
-
45
- - name: Set up Python ${{ matrix.python-version }}
46
- uses: actions/setup-python@v5
47
- with:
48
- python-version: ${{ matrix.python-version }}
49
-
50
- - name: Set up Rust
51
- uses: dtolnay/rust-toolchain@stable
52
-
53
- - name: Cache cargo
54
- uses: Swatinem/rust-cache@v2
55
-
56
- # --no-default-features desliga extension-module, que manda o linker
57
- # deixar os símbolos da libpython indefinidos. Isso é correto para a
58
- # wheel, mas produz um binário de teste que não linka no Linux.
59
- - name: cargo test
60
- env:
61
- PYO3_PYTHON: python
62
- run: cargo test --no-default-features --lib
63
-
64
- # `pip install` aciona o backend maturin, que compila a extensão e a
65
- # instala no Python do runner — sem exigir virtualenv, ao contrário do
66
- # `maturin develop`, que aborta quando não encontra um.
67
- - name: Build, install the extension and run the Python tests
68
- run: |
69
- python -m pip install --upgrade pip
70
- python -m pip install ".[dev]"
71
- python -m pytest -q
@@ -1,256 +0,0 @@
1
- name: Release
2
-
3
- # Dispara ao publicar uma tag de versão (ex.: v0.1.0). Constrói as wheels para
4
- # todas as plataformas + sdist e publica no PyPI via Trusted Publishing (OIDC),
5
- # sem precisar guardar um token no repositório.
6
- #
7
- # O `ci.yml` roda em push/PR, mas é um workflow *separado*: um push de tag ia
8
- # direto para build e publicação sem depender dele. O job `test` abaixo é o que
9
- # torna a publicação condicionada a uma suíte verde — publicar no PyPI é
10
- # irreversível, então não pode acontecer sobre código não verificado.
11
- on:
12
- push:
13
- tags: ["v*"]
14
- workflow_dispatch: {}
15
-
16
- permissions:
17
- contents: read
18
-
19
- jobs:
20
- # O lint faz parte do portão de release, não de um workflow paralelo.
21
- # Nos repositórios de workflow único, um `lint` vermelho ao lado de uma
22
- # publicação bem-sucedida seria ruído difícil de ler — a leitura passa a ser
23
- # inequívoca: vermelho significa que não publicou.
24
- #
25
- # A toolchain é fixa, não "stable": com `-D warnings`, um clippy mais novo
26
- # no runner reprova código que ninguém tocou, e a CI ficaria vermelha por
27
- # conta do calendário. Subir este número vira uma mudança deliberada.
28
- lint:
29
- name: fmt + clippy
30
- runs-on: ubuntu-latest
31
- steps:
32
- - uses: actions/checkout@v4
33
- - uses: dtolnay/rust-toolchain@1.88.0
34
- with:
35
- components: rustfmt, clippy
36
- - uses: Swatinem/rust-cache@v2
37
- - name: cargo fmt
38
- run: cargo fmt --all -- --check
39
- # clippy só checa tipos, não linka, então --all-features é seguro aqui
40
- # (diferente do `cargo test`, que precisa de --no-default-features).
41
- - name: cargo clippy
42
- run: cargo clippy --all-targets --all-features -- -D warnings
43
-
44
- test:
45
- name: Test (Rust + Python)
46
- runs-on: ubuntu-latest
47
- steps:
48
- - uses: actions/checkout@v4
49
- - uses: dtolnay/rust-toolchain@stable
50
- - uses: actions/setup-python@v5
51
- with:
52
- python-version: "3.12"
53
- - name: cargo test
54
- env:
55
- PYO3_PYTHON: python
56
- run: cargo test --no-default-features --lib
57
- - name: Build, install the extension and run the Python tests
58
- run: |
59
- python -m pip install --upgrade pip
60
- python -m pip install ".[dev]"
61
- python -m pytest -q
62
-
63
- # A matriz abaixo substitui um job `build` que rodava em ubuntu/macos/windows
64
- # com `maturin build` puro. Aquilo publicava 3 wheels e o de Linux herdava a
65
- # glibc do runner, em vez de passar pelo container manylinux. Quem estava em
66
- # aarch64, em musl ou em Mac Intel caía no sdist — que exige toolchain Rust.
67
- linux:
68
- runs-on: ubuntu-latest
69
- if: startsWith(github.ref, 'refs/tags/')
70
- needs: [test, lint]
71
- strategy:
72
- matrix:
73
- target: [x86_64, aarch64]
74
- steps:
75
- - uses: actions/checkout@v4
76
- - uses: PyO3/maturin-action@v1
77
- with:
78
- target: ${{ matrix.target }}
79
- args: --release --out dist
80
- # `auto` constrói dentro do container manylinux, então a wheel
81
- # declara um piso de glibc portável em vez do da máquina de build.
82
- manylinux: auto
83
- - uses: actions/upload-artifact@v4
84
- with:
85
- name: wheels-linux-${{ matrix.target }}
86
- path: dist
87
-
88
- musllinux:
89
- runs-on: ubuntu-latest
90
- if: startsWith(github.ref, 'refs/tags/')
91
- needs: [test, lint]
92
- strategy:
93
- matrix:
94
- target: [x86_64, aarch64]
95
- steps:
96
- - uses: actions/checkout@v4
97
- - uses: PyO3/maturin-action@v1
98
- with:
99
- target: ${{ matrix.target }}
100
- args: --release --out dist
101
- manylinux: musllinux_1_2
102
- - uses: actions/upload-artifact@v4
103
- with:
104
- name: wheels-musllinux-${{ matrix.target }}
105
- path: dist
106
-
107
- windows:
108
- runs-on: windows-latest
109
- if: startsWith(github.ref, 'refs/tags/')
110
- needs: [test, lint]
111
- steps:
112
- - uses: actions/checkout@v4
113
- - uses: actions/setup-python@v5
114
- with:
115
- architecture: x64
116
- - uses: PyO3/maturin-action@v1
117
- with:
118
- target: x64
119
- args: --release --out dist
120
- - uses: actions/upload-artifact@v4
121
- with:
122
- name: wheels-windows-x64
123
- path: dist
124
-
125
- macos:
126
- runs-on: macos-latest
127
- if: startsWith(github.ref, 'refs/tags/')
128
- needs: [test, lint]
129
- strategy:
130
- matrix:
131
- target: [x86_64, aarch64]
132
- steps:
133
- - uses: actions/checkout@v4
134
- - uses: PyO3/maturin-action@v1
135
- with:
136
- target: ${{ matrix.target }}
137
- args: --release --out dist
138
- - uses: actions/upload-artifact@v4
139
- with:
140
- name: wheels-macos-${{ matrix.target }}
141
- path: dist
142
-
143
- sdist:
144
- runs-on: ubuntu-latest
145
- if: startsWith(github.ref, 'refs/tags/')
146
- needs: [test, lint]
147
- steps:
148
- - uses: actions/checkout@v4
149
- - uses: PyO3/maturin-action@v1
150
- with:
151
- command: sdist
152
- args: --out dist
153
- - uses: actions/upload-artifact@v4
154
- with:
155
- name: wheels-sdist
156
- path: dist
157
-
158
- publish:
159
- name: publish to PyPI
160
- # Guarda além do gatilho: sem ela, um `workflow_dispatch` a partir de um
161
- # branch publicaria no PyPI.
162
- if: startsWith(github.ref, 'refs/tags/')
163
- needs: [lint, test, linux, musllinux, windows, macos, sdist]
164
- runs-on: ubuntu-latest
165
- environment: pypi
166
- permissions:
167
- id-token: write # necessário para o Trusted Publishing (OIDC)
168
- steps:
169
- - uses: actions/download-artifact@v4
170
- with:
171
- pattern: wheels-*
172
- merge-multiple: true
173
- path: dist
174
- - uses: actions/setup-python@v5
175
- with:
176
- python-version: "3.12"
177
-
178
- # Conferência antes de qualquer coisa sair do runner: publicar um
179
- # conjunto incompleto é pior que não publicar, porque o PyPI nunca deixa
180
- # reusar um nome de arquivo — uma versão meio-publicada só pode ser
181
- # completada, nunca substituída.
182
- - name: Verify the artifact set is complete
183
- run: |
184
- set -euo pipefail
185
- ls -la dist/
186
- test -n "$(find dist -name '*.tar.gz' -print -quit)" \
187
- || { echo "::error::sdist ausente — abortando antes de publicar"; exit 1; }
188
- wheels=$(find dist -name '*.whl' | wc -l)
189
- echo "wheels: $wheels"
190
- test "$wheels" -ge 7 \
191
- || { echo "::error::só $wheels wheels; esperado >= 7 (2 linux, 2 musl, 1 win, 2 macos)"; exit 1; }
192
-
193
- # O PyPI devolve 500 transitório com frequência suficiente para importar,
194
- # e o upload é arquivo por arquivo: uma falha no meio deixa a versão
195
- # publicada pela metade. `skip-existing` é o que torna a segunda
196
- # tentativa segura — sem ele, ela morre nos arquivos que já subiram.
197
- #
198
- # São dois passos explícitos em vez de um wrapper de retry porque o
199
- # trusted publishing (OIDC) exige a action oficial, que não dá para
200
- # acionar por linha de comando.
201
- - name: Publish to PyPI (attempt 1)
202
- id: publish1
203
- continue-on-error: true
204
- uses: pypa/gh-action-pypi-publish@release/v1
205
- with:
206
- packages-dir: dist
207
- skip-existing: true
208
-
209
- - name: Wait before retrying
210
- if: steps.publish1.outcome == 'failure'
211
- run: sleep 45
212
-
213
- - name: Publish to PyPI (attempt 2)
214
- if: steps.publish1.outcome == 'failure'
215
- uses: pypa/gh-action-pypi-publish@release/v1
216
- with:
217
- packages-dir: dist
218
- skip-existing: true
219
-
220
- # Prova que a release está completa, em vez de confiar num código de
221
- # saída que uma publicação parcial também produz.
222
- - name: Verify every file reached PyPI
223
- run: |
224
- set -euo pipefail
225
- python -m pip install --quiet requests
226
- python - <<'PY'
227
- import os, sys, glob, time, requests
228
- version = os.environ["GITHUB_REF_NAME"].lstrip("v")
229
- # Só .whl e .tar.gz: são os únicos tipos de arquivo que o PyPI hospeda.
230
- # `pypa/gh-action-pypi-publish` deixa um .publish.attestation por
231
- # distribuição em dist/ depois de publicar (atestados PEP 740, que
232
- # sobem junto do arquivo, não como arquivo próprio). Um glob amplo os
233
- # conta como "faltando no PyPI" e reprova uma release que deu certo.
234
- local = {
235
- os.path.basename(p)
236
- for pattern in ("dist/*.whl", "dist/*.tar.gz")
237
- for p in glob.glob(pattern)
238
- }
239
- url = f"https://pypi.org/pypi/rust-py-cache/{version}/json"
240
- for attempt in range(6): # a API JSON do PyPI é cacheada
241
- response = requests.get(url, timeout=30)
242
- if response.status_code == 200:
243
- remote = {f["filename"] for f in response.json()["urls"]}
244
- missing = local - remote
245
- if not missing:
246
- print(f"OK: {len(remote)} arquivos publicados na {version}")
247
- sys.exit(0)
248
- else:
249
- missing = local
250
- print(f"tentativa {attempt + 1}: faltam {len(missing)}")
251
- time.sleep(20)
252
- print("::error::faltaram arquivos no PyPI:")
253
- for name in sorted(missing):
254
- print(f" - {name}")
255
- sys.exit(1)
256
- PY
File without changes
File without changes
File without changes
File without changes
File without changes