rust-py-scheduler 0.2.3__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 (40) hide show
  1. rust_py_scheduler-0.2.3/.github/workflows/release.yml +387 -0
  2. rust_py_scheduler-0.2.3/.gitignore +7 -0
  3. rust_py_scheduler-0.2.3/CHANGELOG.md +218 -0
  4. rust_py_scheduler-0.2.3/Cargo.lock +445 -0
  5. rust_py_scheduler-0.2.3/Cargo.toml +27 -0
  6. rust_py_scheduler-0.2.3/LICENSE +21 -0
  7. rust_py_scheduler-0.2.3/Makefile +68 -0
  8. rust_py_scheduler-0.2.3/PKG-INFO +422 -0
  9. rust_py_scheduler-0.2.3/README.md +387 -0
  10. rust_py_scheduler-0.2.3/clippy.toml +11 -0
  11. rust_py_scheduler-0.2.3/examples/basic_usage.py +56 -0
  12. rust_py_scheduler-0.2.3/examples/celery_example.py +66 -0
  13. rust_py_scheduler-0.2.3/examples/django_apps.py +39 -0
  14. rust_py_scheduler-0.2.3/examples/fastapi_example.py +32 -0
  15. rust_py_scheduler-0.2.3/pyproject.toml +45 -0
  16. rust_py_scheduler-0.2.3/python/rust_py_scheduler/__init__.py +6 -0
  17. rust_py_scheduler-0.2.3/python/rust_py_scheduler/django.py +82 -0
  18. rust_py_scheduler-0.2.3/python/rust_py_scheduler/fastapi.py +67 -0
  19. rust_py_scheduler-0.2.3/src/backoff.rs +143 -0
  20. rust_py_scheduler-0.2.3/src/cron.rs +294 -0
  21. rust_py_scheduler-0.2.3/src/errors.rs +38 -0
  22. rust_py_scheduler-0.2.3/src/executor.rs +246 -0
  23. rust_py_scheduler-0.2.3/src/interval.rs +82 -0
  24. rust_py_scheduler-0.2.3/src/job.rs +200 -0
  25. rust_py_scheduler-0.2.3/src/lib.rs +19 -0
  26. rust_py_scheduler-0.2.3/src/registry.rs +817 -0
  27. rust_py_scheduler-0.2.3/src/scheduler.rs +305 -0
  28. rust_py_scheduler-0.2.3/src/time_utils.rs +45 -0
  29. rust_py_scheduler-0.2.3/tests/test_background.py +76 -0
  30. rust_py_scheduler-0.2.3/tests/test_concurrency.py +212 -0
  31. rust_py_scheduler-0.2.3/tests/test_cron.py +127 -0
  32. rust_py_scheduler-0.2.3/tests/test_django.py +89 -0
  33. rust_py_scheduler-0.2.3/tests/test_errors.py +85 -0
  34. rust_py_scheduler-0.2.3/tests/test_fastapi.py +82 -0
  35. rust_py_scheduler-0.2.3/tests/test_interval.py +93 -0
  36. rust_py_scheduler-0.2.3/tests/test_jobs.py +62 -0
  37. rust_py_scheduler-0.2.3/tests/test_list_jobs.py +70 -0
  38. rust_py_scheduler-0.2.3/tests/test_remove_job.py +62 -0
  39. rust_py_scheduler-0.2.3/tests/test_retry.py +198 -0
  40. rust_py_scheduler-0.2.3/tests/test_scheduler.py +62 -0
@@ -0,0 +1,387 @@
1
+ # Base structure autogenerated by maturin v1.14.0
2
+ # (maturin generate-ci github), then extended with a `test` job that runs the
3
+ # Rust and Python suites on every push/PR. Every wheel build — and the
4
+ # release — depends on `test`, so a change that breaks the tests never gets
5
+ # packaged or published.
6
+ name: CI / Release
7
+
8
+ # ⚠️ O nome deste arquivo é parte da configuração de publicação. O trusted
9
+ # publisher cadastrado no PyPI amarra `release.yml`; renomear aqui quebra a
10
+ # release até o cadastro ser refeito. Era `CI.yml` até 12/08/2026.
11
+
12
+ on:
13
+ push:
14
+ branches:
15
+ - main
16
+ - master
17
+ tags:
18
+ # `v*`, não `*`. Com o filtro aberto, QUALQUER tag empurrada disparava o
19
+ # job de release — que só checa `startsWith(github.ref, 'refs/tags/')` —
20
+ # e uma tag de anotação qualquer teria ido ao PyPI. Os outros quatro
21
+ # projetos sempre usaram `v*`.
22
+ - 'v*'
23
+ pull_request:
24
+ workflow_dispatch:
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ # O lint faz parte do portão de release, não de um workflow paralelo.
31
+ # Nos repositórios de workflow único, um `lint` vermelho ao lado de uma
32
+ # publicação bem-sucedida seria ruído difícil de ler — a leitura passa a ser
33
+ # inequívoca: vermelho significa que não publicou.
34
+ #
35
+ # A toolchain é fixa, não "stable": com `-D warnings`, um clippy mais novo
36
+ # no runner reprova código que ninguém tocou, e a CI ficaria vermelha por
37
+ # conta do calendário. Subir este número vira uma mudança deliberada.
38
+ lint:
39
+ name: fmt + clippy
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+ - uses: dtolnay/rust-toolchain@1.88.0
44
+ with:
45
+ components: rustfmt, clippy
46
+ - uses: Swatinem/rust-cache@v2
47
+ - name: cargo fmt
48
+ run: cargo fmt --all -- --check
49
+ # clippy só checa tipos, não linka, então --all-features é seguro aqui
50
+ # (diferente do `cargo test`, que precisa de --no-default-features).
51
+ - name: cargo clippy
52
+ run: cargo clippy --all-targets --all-features -- -D warnings
53
+
54
+ test:
55
+ name: Test (Rust + Python)
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ - uses: actions/checkout@v6
59
+ - uses: actions/setup-python@v6
60
+ with:
61
+ python-version: "3.x"
62
+ - name: Install Rust toolchain
63
+ uses: dtolnay/rust-toolchain@stable
64
+ - name: Rust unit tests
65
+ # --no-default-features drops the extension-module feature so the test
66
+ # binary can link against libpython on the runner.
67
+ env:
68
+ PYO3_PYTHON: python
69
+ run: cargo test --no-default-features --lib
70
+ - name: Build, install the extension, and run Python tests
71
+ # `pip install` drives the maturin build backend, so it compiles the
72
+ # Rust extension and installs it (plus the dev extras) into the
73
+ # runner's Python — no virtualenv needed, unlike `maturin develop`.
74
+ run: |
75
+ python -m pip install --upgrade pip
76
+ python -m pip install ".[dev]"
77
+ python -m pytest
78
+
79
+ linux:
80
+ runs-on: ${{ matrix.platform.runner }}
81
+ needs: [test, lint]
82
+ strategy:
83
+ # Sem isto, um alvo que cai CANCELA os irmãos. Foi o que escondeu o
84
+ # estado de armv7/s390x/ppc64le quando o build x86 quebrou no 403 do
85
+ # vault.centos.org, em 12/08/2026: uma falha apagou o diagnóstico
86
+ # das outras cinco.
87
+ fail-fast: false
88
+ matrix:
89
+ platform:
90
+ - runner: ubuntu-22.04
91
+ target: x86_64
92
+ - runner: ubuntu-22.04
93
+ target: x86
94
+ - runner: ubuntu-22.04
95
+ target: aarch64
96
+ - runner: ubuntu-22.04
97
+ target: armv7
98
+ - runner: ubuntu-22.04
99
+ target: s390x
100
+ - runner: ubuntu-22.04
101
+ target: ppc64le
102
+ steps:
103
+ - uses: actions/checkout@v6
104
+ - uses: actions/setup-python@v6
105
+ with:
106
+ python-version: "3.x"
107
+ - name: Build wheels
108
+ uses: PyO3/maturin-action@v1
109
+ with:
110
+ target: ${{ matrix.platform.target }}
111
+ args: --release --out dist --find-interpreter
112
+ sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
113
+ manylinux: auto
114
+ - name: Upload wheels
115
+ uses: actions/upload-artifact@v6
116
+ with:
117
+ name: wheels-linux-${{ matrix.platform.target }}
118
+ path: dist
119
+
120
+ musllinux:
121
+ runs-on: ${{ matrix.platform.runner }}
122
+ needs: [test, lint]
123
+ strategy:
124
+ # Uma falha não pode apagar o diagnóstico das outras.
125
+ fail-fast: false
126
+ matrix:
127
+ platform:
128
+ - runner: ubuntu-22.04
129
+ target: x86_64
130
+ - runner: ubuntu-22.04
131
+ target: x86
132
+ - runner: ubuntu-22.04
133
+ target: aarch64
134
+ - runner: ubuntu-22.04
135
+ target: armv7
136
+ steps:
137
+ - uses: actions/checkout@v6
138
+ - uses: actions/setup-python@v6
139
+ with:
140
+ python-version: "3.x"
141
+ - name: Build wheels
142
+ uses: PyO3/maturin-action@v1
143
+ with:
144
+ target: ${{ matrix.platform.target }}
145
+ args: --release --out dist --find-interpreter
146
+ sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
147
+ manylinux: musllinux_1_2
148
+ - name: Upload wheels
149
+ uses: actions/upload-artifact@v6
150
+ with:
151
+ name: wheels-musllinux-${{ matrix.platform.target }}
152
+ path: dist
153
+
154
+ windows:
155
+ runs-on: ${{ matrix.platform.runner }}
156
+ needs: [test, lint]
157
+ strategy:
158
+ # Uma falha não pode apagar o diagnóstico das outras.
159
+ fail-fast: false
160
+ matrix:
161
+ platform:
162
+ - runner: windows-latest
163
+ target: x64
164
+ python_arch: x64
165
+ - runner: windows-latest
166
+ target: x86
167
+ python_arch: x86
168
+ - runner: windows-11-arm
169
+ target: aarch64
170
+ python_arch: arm64
171
+ steps:
172
+ - uses: actions/checkout@v6
173
+ - uses: actions/setup-python@v6
174
+ with:
175
+ python-version: "3.13"
176
+ architecture: ${{ matrix.platform.python_arch }}
177
+ - name: Build wheels
178
+ uses: PyO3/maturin-action@v1
179
+ with:
180
+ target: ${{ matrix.platform.target }}
181
+ args: --release --out dist --find-interpreter
182
+ sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
183
+ - name: Upload wheels
184
+ uses: actions/upload-artifact@v6
185
+ with:
186
+ name: wheels-windows-${{ matrix.platform.target }}
187
+ path: dist
188
+
189
+ macos:
190
+ runs-on: ${{ matrix.platform.runner }}
191
+ needs: [test, lint]
192
+ strategy:
193
+ # Uma falha não pode apagar o diagnóstico das outras.
194
+ fail-fast: false
195
+ matrix:
196
+ platform:
197
+ - runner: macos-15-intel
198
+ target: x86_64
199
+ - runner: macos-latest
200
+ target: aarch64
201
+ steps:
202
+ - uses: actions/checkout@v6
203
+ - uses: actions/setup-python@v6
204
+ with:
205
+ python-version: "3.x"
206
+ - name: Build wheels
207
+ uses: PyO3/maturin-action@v1
208
+ with:
209
+ target: ${{ matrix.platform.target }}
210
+ args: --release --out dist --find-interpreter
211
+ sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
212
+ - name: Upload wheels
213
+ uses: actions/upload-artifact@v6
214
+ with:
215
+ name: wheels-macos-${{ matrix.platform.target }}
216
+ path: dist
217
+
218
+ sdist:
219
+ runs-on: ubuntu-latest
220
+ needs: [test, lint]
221
+ steps:
222
+ - uses: actions/checkout@v6
223
+ - name: Build sdist
224
+ uses: PyO3/maturin-action@v1
225
+ with:
226
+ command: sdist
227
+ args: --out dist
228
+ - name: Upload sdist
229
+ uses: actions/upload-artifact@v6
230
+ with:
231
+ name: wheels-sdist
232
+ path: dist
233
+
234
+ # Ensaio da troca OIDC, sem tocar no índice de produção. Descobre um
235
+ # publisher mal cadastrado num lugar onde nada é irreversível — no PyPI, um
236
+ # nome de arquivo publicado nunca pode ser reusado.
237
+ #
238
+ # ⚠️ Precisa do seu próprio publisher, cadastrado no TestPyPI (conta separada
239
+ # do PyPI), com owner/repo, workflow `release.yml` e environment `testpypi`.
240
+ publish-testpypi:
241
+ name: Publish to TestPyPI (ensaio)
242
+ runs-on: ubuntu-latest
243
+ if: github.event_name == 'workflow_dispatch'
244
+ needs: [lint, test, linux, musllinux, windows, macos, sdist]
245
+ environment: testpypi
246
+ permissions:
247
+ id-token: write
248
+ steps:
249
+ - uses: actions/download-artifact@v7
250
+ with:
251
+ pattern: wheels-*
252
+ path: dist
253
+ merge-multiple: true
254
+ - uses: pypa/gh-action-pypi-publish@release/v1
255
+ with:
256
+ repository-url: https://test.pypi.org/legacy/
257
+ packages-dir: dist
258
+ skip-existing: true
259
+
260
+ release:
261
+ name: Publish to PyPI
262
+ runs-on: ubuntu-latest
263
+ # Só tag. Antes este job também rodava em `workflow_dispatch` porque o
264
+ # atestado de artefato vivia aqui; o ensaio manual virou o job acima, então
265
+ # a guarda pôde ficar estrita — e os `if:` que cada passo repetia sumiram.
266
+ if: startsWith(github.ref, 'refs/tags/')
267
+ needs: [lint, test, linux, musllinux, windows, macos, sdist]
268
+
269
+ # Trusted publishing (OIDC): o `UV_PUBLISH_TOKEN` deixou de existir. O
270
+ # GitHub emite um JWT curto afirmando repositório, workflow e ref, e o PyPI
271
+ # o troca por um token de upload de poucos minutos, restrito a este projeto.
272
+ # Era o último dos cinco publicando com segredo de longa duração.
273
+ #
274
+ # ⚠️ Os três valores precisam bater EXATAMENTE com o publisher cadastrado
275
+ # em PyPI → Manage → Publishing:
276
+ # owner/repo .... robertolima-dev/rust-py-scheduler
277
+ # workflow ...... release.yml (renomeado de CI.yml em 12/08/2026)
278
+ # environment ... pypi
279
+ environment: pypi
280
+ permissions:
281
+ # Troca OIDC do trusted publishing e assinatura do atestado.
282
+ id-token: write
283
+ contents: write
284
+ attestations: write
285
+
286
+ steps:
287
+ # `dist/` com merge-multiple, no lugar dos diretórios `wheels-*/` que a
288
+ # forma antiga produzia — é o layout que os outros quatro projetos usam,
289
+ # e o que `packages-dir` da action oficial espera.
290
+ - uses: actions/download-artifact@v7
291
+ with:
292
+ pattern: wheels-*
293
+ path: dist
294
+ merge-multiple: true
295
+
296
+ # Atestado do Actions (sigstore), independente dos atestados PEP 740 que
297
+ # o publicador gera depois — sistemas diferentes, ambos úteis.
298
+ - name: Generate artifact attestation
299
+ uses: actions/attest@v4
300
+ with:
301
+ subject-path: 'dist/*'
302
+
303
+ - uses: actions/setup-python@v6
304
+ with:
305
+ python-version: "3.12"
306
+ - name: Install the verification dependency
307
+ run: python -m pip install --upgrade requests
308
+
309
+ # A sanity check before anything leaves the runner: publishing an
310
+ # incomplete set is worse than not publishing, because PyPI never lets a
311
+ # filename be reused — a half-uploaded version can only be completed,
312
+ # never replaced.
313
+ - name: Verify the artifact set is complete
314
+ run: |
315
+ set -euo pipefail
316
+ ls -la dist/
317
+ test -n "$(find dist -name '*.tar.gz' -print -quit)" \
318
+ || { echo "::error::sdist ausente — abortando antes de publicar"; exit 1; }
319
+ wheels=$(find dist -name '*.whl' | wc -l)
320
+ echo "wheels: $wheels"
321
+ test "$wheels" -ge 15 \
322
+ || { echo "::error::só $wheels wheels; esperado >= 15 (6 linux, 4 musl, 3 win, 2 macos)"; exit 1; }
323
+
324
+ # O PyPI devolve 500 transitório com frequência suficiente para importar,
325
+ # e o upload é arquivo por arquivo: uma falha no meio deixa a versão
326
+ # publicada pela metade. `skip-existing` é o que torna a segunda
327
+ # tentativa segura — faz o papel que o `--check-url` do `uv publish`
328
+ # fazia aqui antes.
329
+ #
330
+ # São dois passos explícitos em vez de um wrapper de retry porque o
331
+ # trusted publishing exige a action oficial, e ela não dá para acionar
332
+ # por linha de comando de dentro do `nick-fields/retry`.
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
+ # Proves the release is actually complete, rather than trusting an exit
353
+ # code that a partial upload can still produce.
354
+ - name: Verify every file reached PyPI
355
+ run: |
356
+ set -euo pipefail
357
+ python - <<'PY'
358
+ import os, sys, glob, time, requests
359
+ version = os.environ["GITHUB_REF_NAME"].lstrip("v")
360
+ # Só .whl e .tar.gz: são os únicos tipos de arquivo que o PyPI hospeda.
361
+ # `pypa/gh-action-pypi-publish` deixa um .publish.attestation por
362
+ # distribuição em dist/ depois de publicar (atestados PEP 740, que
363
+ # sobem junto do arquivo, não como arquivo próprio). Um glob amplo os
364
+ # conta como "faltando no PyPI" e reprova uma release que deu certo.
365
+ local = {
366
+ os.path.basename(p)
367
+ for pattern in ("dist/*.whl", "dist/*.tar.gz")
368
+ for p in glob.glob(pattern)
369
+ }
370
+ url = f"https://pypi.org/pypi/rust-py-scheduler/{version}/json"
371
+ for attempt in range(6): # PyPI's JSON API is cached
372
+ response = requests.get(url, timeout=30)
373
+ if response.status_code == 200:
374
+ remote = {f["filename"] for f in response.json()["urls"]}
375
+ missing = local - remote
376
+ if not missing:
377
+ print(f"OK: {len(remote)} arquivos publicados na {version}")
378
+ sys.exit(0)
379
+ else:
380
+ missing = local
381
+ print(f"tentativa {attempt + 1}: faltam {len(missing)}")
382
+ time.sleep(20)
383
+ print("::error::faltaram arquivos no PyPI:")
384
+ for name in sorted(missing):
385
+ print(f" - {name}")
386
+ sys.exit(1)
387
+ PY
@@ -0,0 +1,7 @@
1
+ /target
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ *.so
6
+ *.pyd
7
+ .pytest_cache/
@@ -0,0 +1,218 @@
1
+ # Changelog
2
+
3
+ Todas as mudanças relevantes deste projeto são registradas aqui.
4
+
5
+ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
6
+ e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
7
+
8
+ ## [0.2.3] — 2026-08-12
9
+
10
+ Migração para trusted publishing. Nada muda no comportamento da biblioteca.
11
+ Era o último dos cinco `rust_py_*` publicando com segredo de longa duração.
12
+
13
+ ### Alterado
14
+
15
+ - **Publicação por trusted publishing (OIDC)**, no lugar de
16
+ `secrets.PYPI_API_TOKEN` + `uv publish`. O GitHub emite um JWT curto
17
+ afirmando repositório, workflow e ref, e o PyPI o troca por um token de
18
+ upload válido por poucos minutos e restrito a este projeto. O upload passou
19
+ a ser dois `pypa/gh-action-pypi-publish` explícitos com
20
+ `skip-existing: true`, que faz o papel que o `--check-url` do `uv publish`
21
+ fazia: tornar a segunda tentativa segura.
22
+
23
+ Diferente do `rust_py_monitor`, aqui **não havia bug a consertar** — o
24
+ `uv publish` já transmitia o metadado de licença corretamente, e a 0.2.2 saiu
25
+ com `license_expression: MIT`. Esta migração é higiene de segredo e
26
+ convergência de forma.
27
+
28
+ ⚠️ **Depende de cadastro no PyPI**, com os valores batendo exatamente:
29
+ `robertolima-dev` / `rust-py-scheduler` / `release.yml` / `pypi`. Sem o
30
+ publisher, o job falha na troca do token — depois de construir tudo e antes
31
+ de qualquer arquivo sair do runner.
32
+
33
+ - **`CI.yml` renomeado para `release.yml`.** O trusted publisher cadastrado no
34
+ PyPI amarra o nome do arquivo de workflow, então o rename precisou vir
35
+ **antes** do cadastro — renomear depois obrigaria a cadastrar de novo. Alinha
36
+ com os outros quatro projetos e é pré-requisito do `release.yml` único do
37
+ template (**P3-1**).
38
+
39
+ - **Artefatos passam por `dist/`** (`merge-multiple: true`) no lugar dos
40
+ diretórios `wheels-*/`. É o layout que `packages-dir` espera e o que os
41
+ outros quatro já usam. Os dois passos de verificação e o atestado foram
42
+ ajustados para o novo caminho.
43
+
44
+ - **O job de release ficou restrito a tag.** Antes ele também rodava em
45
+ `workflow_dispatch`, porque o atestado de artefato vivia nele; o ensaio
46
+ manual virou um job próprio (`publish-testpypi`, apontando para o TestPyPI),
47
+ então a guarda pôde ficar estrita e os `if:` repetidos em cada passo sumiram.
48
+
49
+ ### Corrigido
50
+
51
+ - **Filtro de tag fechado para `v*`.** Era `'*'`, ou seja, **qualquer** tag
52
+ empurrada disparava o workflow — e o job de release só checava
53
+ `startsWith(github.ref, 'refs/tags/')`, que é verdadeiro para qualquer tag.
54
+ Uma tag de anotação, de rascunho ou de qualquer outra finalidade teria ido
55
+ ao PyPI. Os outros quatro projetos sempre usaram `v*`.
56
+
57
+ - **`fail-fast: false` nas quatro matrizes de build.** O padrão do GitHub
58
+ cancela os alvos irmãos quando um cai. Em 12/08/2026 o build `x86` quebrou
59
+ num 403 do `vault.centos.org` (CentOS 7 EOL, instabilidade de mirror) e
60
+ levou junto o diagnóstico de `armv7`, `s390x` e `ppc64le` — não dava para
61
+ saber se tinham falhado ou apenas sido interrompidos. Uma falha não pode
62
+ apagar a informação das outras.
63
+
64
+ ## [0.2.2] - 2026-08-12
65
+
66
+ Correção do passo de verificação pós-upload, mais o trabalho de licença e lint
67
+ de 12/08/2026 (**P1-4** e **P1-6**).
68
+
69
+ ### Adicionado
70
+
71
+ - **`LICENSE`** (MIT). O projeto declarava MIT no `Cargo.toml` e no
72
+ `pyproject.toml` desde sempre, mas o texto não existia no repositório
73
+ (**P1-4**). Era o único dos cinco cujo metadado no PyPI já estava certo
74
+ (`license_expression: MIT`, graças à maturin 1.14 que ele já exigia) e ainda
75
+ assim sem o arquivo — metadado e texto são coisas separadas.
76
+ - **Job `lint`** (`cargo fmt --check` + `cargo clippy -D warnings`) no
77
+ `CI.yml`, com `needs: [test, lint]` nos jobs de build e no de publicação —
78
+ o lint barra a release, igual ao `test`. Toolchain fixa em `1.88.0`.
79
+ - **`clippy.toml` e `Makefile`** compartilhados com os outros quatro
80
+ `rust_py_*`. O `clippy.toml` sobe `too-many-arguments-threshold` para 10, e
81
+ é o que silencia os dois `too_many_arguments` deste projeto: `add_job()` leva
82
+ 8 parâmetros porque a API Python leva 8 (`retry_backoff`, `timeout`,
83
+ `max_instances` e afins). Reagrupar em struct pioraria a API que o usuário vê.
84
+
85
+ ### Corrigido
86
+
87
+ - **Formatação.** `cargo fmt --all` aplicado (12 blocos divergentes). Os 55
88
+ testes Rust seguem passando.
89
+
90
+ - **Declaração de licença no formato do PEP 639.** Entrou
91
+ `license-files = ["LICENSE"]` e o classifier `License :: OSI Approved` saiu,
92
+ por ser mutuamente exclusivo com `License-Expression` no Metadata 2.4.
93
+
94
+ ### Corrigido — pipeline
95
+
96
+ - **Falso negativo na verificação pós-publicação.** O passo comparava
97
+ `glob("wheels-*/*")` com o que estava no PyPI, o que conta qualquer arquivo
98
+ que apareça nos diretórios de artefato — não só distribuições. O passo agora
99
+ só considera `*.whl` e `*.tar.gz`, os únicos tipos de arquivo que o PyPI
100
+ hospeda. Foi o `rust_py_rate_limit` que expôs o problema: lá o publicador
101
+ deixa um `.publish.attestation` por distribuição, e a verificação reprovou
102
+ uma release que tinha subido inteira.
103
+
104
+ **Não vai ao PyPI sozinha:** a 0.2.0 está lá completa e a 0.2.1/0.2.2 só mexem
105
+ no pipeline. Os números ficam reservados e saem com a próxima mudança que valha
106
+ uma tag.
107
+
108
+ ## [0.2.1] — não publicada
109
+
110
+ Versão de infraestrutura: nada muda no comportamento da biblioteca.
111
+
112
+ **Não foi ao PyPI de propósito.** A 0.2.0 está completa lá (ver a nota abaixo)
113
+ e esta versão só mexe no pipeline de publicação, então uma release dela seria
114
+ ruído para quem usa a biblioteca — as melhorias já valem no próximo release,
115
+ que herda o workflow. O número fica reservado aqui e sai junto com a próxima
116
+ mudança que valha uma tag.
117
+
118
+ ### Corrigido
119
+
120
+ - **O upload aguenta o 500 do PyPI.** A publicação da 0.2.0 morreu num
121
+ `500 Internal Server Error` do `upload.pypi.org` no meio da fila, deixando
122
+ 11 dos 16 arquivos publicados — faltaram a wheel musllinux x86_64, as três
123
+ de Windows e o sdist. O passo de publicação agora tem retry (4 tentativas).
124
+ - **O retry ficou seguro.** O `uv publish` só pula arquivos que consegue
125
+ confirmar que já estão no índice; sem `--check-url` uma segunda tentativa
126
+ podia falhar de vez nos arquivos que já tinham subido. O parâmetro foi
127
+ acrescentado.
128
+ - **Conferência dos dois lados.** O job de release valida o conjunto de
129
+ artefatos **antes** de publicar (aborta se faltar o sdist ou se houver menos
130
+ wheels que o esperado) e confere contra a API do PyPI **depois**, listando
131
+ nome por nome o que não chegou.
132
+
133
+ ### Nota sobre a 0.2.0
134
+
135
+ A 0.2.0 chegou ao PyPI com 11 dos 16 arquivos por causa do erro descrito
136
+ acima, e **foi completada por um re-run em 11/08/2026** — hoje tem os 16
137
+ arquivos, com sdist. Ou seja, a 0.2.0 é válida e é a versão a usar; esta 0.2.1
138
+ não muda uma linha da biblioteca, só o pipeline que a publica.
139
+
140
+ ## [0.2.0] - 2026-08-11
141
+
142
+ Quatro correções de P0 do BACKLOG2, todas no mesmo release porque as três
143
+ últimas mexem no mesmo caminho de execução.
144
+
145
+ ### Corrigido
146
+
147
+ - **Deriva acumulada no intervalo (P0-3).** O reagendamento usava
148
+ `Instant::now()` depois de o callback terminar, então o período real era
149
+ `intervalo + duração do callback`: um `every("60s")` com callback de 5s
150
+ rodava a cada 65s e perdia ~1h de execuções por dia. Agora o próximo
151
+ deadline é ancorado no deadline que acabou de disparar, mantendo a cadência
152
+ na grade `t0 + n * intervalo`.
153
+ - **Retry em rajada (P0-4a).** As tentativas disparavam coladas, sem espera —
154
+ `max_retries=5` contra uma API fora do ar virava 6 chamadas em sequência,
155
+ martelando o serviço que já estava caído. Agora há backoff exponencial com
156
+ jitter entre as tentativas.
157
+ - **Um job pendurado congelava o scheduler (P0-4b).** Os callbacks rodavam em
158
+ sequência dentro do loop de agendamento, que é single-thread: um callback
159
+ travado parava *todos* os outros jobs. Agora existe um pool de workers e o
160
+ loop só despacha.
161
+ - **Mutex envenenado derrubava tudo em cascata (P0-5).** Havia 14
162
+ `.expect("... mutex poisoned")` em caminho de produção. Bastava uma thread
163
+ entrar em pânico segurando o lock para toda chamada seguinte (`add_job`,
164
+ `remove_job`, `list_jobs`, o próprio loop) entrar em pânico, permanentemente.
165
+ Agora todos recuperam com `unwrap_or_else(|p| p.into_inner())`.
166
+
167
+ ### Adicionado
168
+
169
+ - `Scheduler(max_workers=4)` — tamanho do pool de workers.
170
+ - `every(...)` e `cron(...)` aceitam:
171
+ - `retry_backoff=0.5` — base do backoff exponencial entre tentativas. A
172
+ espera dobra a cada tentativa, é limitada a 30s e recebe jitter. `0`
173
+ restaura o comportamento anterior (imediato).
174
+ - `timeout=None` — segundos após os quais o scheduler para de esperar por
175
+ uma execução.
176
+ - `max_instances=1` — quantas execuções do mesmo job podem se sobrepor.
177
+ - **Política de misfire.** Quando um job fica para trás em um ou mais ciclos
178
+ inteiros, ele pula para o próximo deadline futuro e roda uma vez — em vez de
179
+ disparar de uma vez todas as execuções perdidas.
180
+ - **Coalescing.** Com `max_instances` saturado, o tick que vence é descartado e
181
+ contado em `coalesced_count`, em vez de enfileirar execuções que o job nunca
182
+ vai alcançar.
183
+ - `list_jobs()` ganhou as chaves `retry_backoff`, `timeout`, `max_instances`,
184
+ `running`, `coalesced_count` e `timeout_count`.
185
+
186
+ ### Alterado — atenção ao atualizar
187
+
188
+ - **O padrão de retry mudou.** Antes as tentativas eram imediatas; agora
189
+ esperam `retry_backoff` (0,5s de base) entre elas. Código que dependia de
190
+ `max_retries` resolver dentro do mesmo tick precisa ou de mais tempo, ou de
191
+ `retry_backoff=0`.
192
+ - **Callbacks agora rodam em threads de worker**, não na thread que chamou
193
+ `run()`. Callback que dependia de estado preso a uma thread específica
194
+ (`threading.local`, por exemplo) precisa ser revisto. Dois callbacks do mesmo
195
+ job não se sobrepõem por padrão (`max_instances=1`), mas callbacks de jobs
196
+ diferentes agora podem rodar ao mesmo tempo.
197
+ - `Scheduler()` continua funcionando sem argumento; `max_workers` tem padrão.
198
+
199
+ ### Limitação conhecida
200
+
201
+ - **`timeout` não mata o callback.** Não existe forma segura de interromper
202
+ código Python arbitrário a partir de outra thread. O que o timeout faz é
203
+ limitar a exposição do *scheduler*: marca a execução como estourada, devolve
204
+ a vaga de `max_instances` e para de contabilizá-la. A thread do worker segue
205
+ presa dentro do Python até o callback retornar por conta própria — ou seja,
206
+ um job que pendura para sempre custa um worker para sempre. Por isso
207
+ `shutdown()` pode bloquear esperando um callback assim.
208
+
209
+ ## [0.1.5]
210
+
211
+ ### Adicionado
212
+
213
+ - `Scheduler` com `every()` / `cron()`, nas formas de chamada direta e de
214
+ decorator.
215
+ - Expressões cron de 5 campos, avaliadas no fuso local.
216
+ - `list_jobs()`, `remove_job()`, `run()`, `start_background()`, `shutdown()`.
217
+ - `max_retries` por job.
218
+ - Integrações para FastAPI e Django.