turnero 0.1.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.
- turnero-0.1.0/.github/workflows/pruebas.yml +50 -0
- turnero-0.1.0/.github/workflows/publicar.yml +80 -0
- turnero-0.1.0/.gitignore +13 -0
- turnero-0.1.0/CHANGELOG.md +15 -0
- turnero-0.1.0/LICENSE +21 -0
- turnero-0.1.0/PKG-INFO +157 -0
- turnero-0.1.0/README.md +110 -0
- turnero-0.1.0/pyproject.toml +39 -0
- turnero-0.1.0/src/turnero/__init__.py +26 -0
- turnero-0.1.0/src/turnero/_turnero.py +116 -0
- turnero-0.1.0/src/turnero/_un_viaje.py +66 -0
- turnero-0.1.0/src/turnero/errores.py +29 -0
- turnero-0.1.0/src/turnero/py.typed +0 -0
- turnero-0.1.0/tests/test_juntos.py +29 -0
- turnero-0.1.0/tests/test_turnero.py +133 -0
- turnero-0.1.0/tests/test_un_viaje.py +98 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Las pruebas en cada push y en cada pull request.
|
|
2
|
+
#
|
|
3
|
+
# En un repositorio PÚBLICO los minutos de GitHub Actions con los
|
|
4
|
+
# runners estándar son gratis e ilimitados. Por eso este fichero no
|
|
5
|
+
# cuesta dinero y el repositorio es público a propósito.
|
|
6
|
+
#
|
|
7
|
+
# POR QUÉ 3.9 CORRE EN OTRA MÁQUINA QUE LAS DEMÁS
|
|
8
|
+
# -----------------------------------------------
|
|
9
|
+
# La primera ejecución, el 2 de noviembre de 2026, salió roja. No por
|
|
10
|
+
# el código: 3.11 y 3.13 pasaron las 20 pruebas, y en 3.9 ni siquiera
|
|
11
|
+
# llegaron a lanzarse. Lo que falló fue el paso de ANTES,
|
|
12
|
+
# actions/setup-python, porque `ubuntu-latest` ya no trae Python 3.9.
|
|
13
|
+
#
|
|
14
|
+
# Había dos salidas. Quitar 3.9 de la matriz era la cómoda, pero
|
|
15
|
+
# entonces pyproject diría `requires-python = ">=3.9"` sin que nadie
|
|
16
|
+
# lo hubiera comprobado nunca: justo la clase de afirmación que este
|
|
17
|
+
# paquete no quiere hacer. La otra es correr esa versión en la máquina
|
|
18
|
+
# donde sí existe. Es la que está aquí.
|
|
19
|
+
#
|
|
20
|
+
# Si algún día ubuntu-22.04 desaparece, hay que elegir otra vez: o se
|
|
21
|
+
# encuentra dónde correr 3.9, o se sube el mínimo a 3.10 en
|
|
22
|
+
# pyproject.toml y se quitan sus clasificadores. Lo que NO vale es
|
|
23
|
+
# dejar de probarla y seguir diciendo que funciona.
|
|
24
|
+
name: pruebas
|
|
25
|
+
|
|
26
|
+
on:
|
|
27
|
+
push:
|
|
28
|
+
branches: [main]
|
|
29
|
+
pull_request:
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
pytest:
|
|
33
|
+
runs-on: ${{ matrix.so }}
|
|
34
|
+
strategy:
|
|
35
|
+
fail-fast: false
|
|
36
|
+
matrix:
|
|
37
|
+
include:
|
|
38
|
+
- python: "3.9"
|
|
39
|
+
so: ubuntu-22.04
|
|
40
|
+
- python: "3.11"
|
|
41
|
+
so: ubuntu-latest
|
|
42
|
+
- python: "3.13"
|
|
43
|
+
so: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v4
|
|
46
|
+
- uses: actions/setup-python@v5
|
|
47
|
+
with:
|
|
48
|
+
python-version: ${{ matrix.python }}
|
|
49
|
+
- run: pip install -e ".[dev]"
|
|
50
|
+
- run: pytest -q
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Publicar en PyPI sin que exista ningún token en ninguna parte.
|
|
2
|
+
#
|
|
3
|
+
# POR QUÉ ASÍ
|
|
4
|
+
# -----------
|
|
5
|
+
# Lo normal es crear un token en PyPI, guardarlo en ~/.pypirc o en los
|
|
6
|
+
# secretos del repositorio, y subir el paquete desde ahí. Funciona, y
|
|
7
|
+
# tiene un problema: existe un secreto. Un secreto que se puede copiar,
|
|
8
|
+
# pegar donde no debe y acabar en un repositorio público. En este
|
|
9
|
+
# proyecto eso no es una hipótesis — la clave de football-data de
|
|
10
|
+
# Sportalyze acabó en el historial público de git y lo único que cerró
|
|
11
|
+
# aquello fue anularla en el proveedor.
|
|
12
|
+
#
|
|
13
|
+
# Esto es «Trusted Publishing»: PyPI confía en ESTE flujo de trabajo,
|
|
14
|
+
# de ESTE repositorio, y GitHub le entrega un carné de identidad de un
|
|
15
|
+
# solo uso (OIDC) cada vez que corre. No hay nada que guardar, nada que
|
|
16
|
+
# rotar y nada que filtrar. Si alguien se lleva una copia del
|
|
17
|
+
# repositorio, no se lleva con ella la capacidad de publicar.
|
|
18
|
+
#
|
|
19
|
+
# CÓMO SE PUBLICA UNA VERSIÓN
|
|
20
|
+
# ---------------------------
|
|
21
|
+
# git tag v0.1.0
|
|
22
|
+
# git push origin v0.1.0
|
|
23
|
+
#
|
|
24
|
+
# Y ya. Nada más es necesario, y nada menos: la etiqueta es el gesto
|
|
25
|
+
# que dice «esto es una versión».
|
|
26
|
+
name: publicar
|
|
27
|
+
|
|
28
|
+
on:
|
|
29
|
+
push:
|
|
30
|
+
tags: ["v*"]
|
|
31
|
+
|
|
32
|
+
jobs:
|
|
33
|
+
construir:
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@v4
|
|
37
|
+
- uses: actions/setup-python@v5
|
|
38
|
+
with:
|
|
39
|
+
python-version: "3.13"
|
|
40
|
+
|
|
41
|
+
# La etiqueta y la versión del paquete tienen que decir lo mismo.
|
|
42
|
+
# Si no, se publica una v0.1.1 que por dentro dice 0.1.0 y en PyPI
|
|
43
|
+
# eso no se arregla nunca: una versión publicada no se puede
|
|
44
|
+
# borrar ni reemplazar. Mejor parar aquí.
|
|
45
|
+
- name: La etiqueta tiene que coincidir con la versión
|
|
46
|
+
run: |
|
|
47
|
+
ETIQUETA="${GITHUB_REF_NAME#v}"
|
|
48
|
+
VERSION=$(sed -n 's/^version *= *"\(.*\)"/\1/p' pyproject.toml | head -1)
|
|
49
|
+
echo "etiqueta: $ETIQUETA pyproject: $VERSION"
|
|
50
|
+
if [ "$ETIQUETA" != "$VERSION" ]; then
|
|
51
|
+
echo "::error::La etiqueta dice $ETIQUETA y pyproject.toml dice $VERSION."
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
- run: pip install -e ".[dev]"
|
|
56
|
+
|
|
57
|
+
# No se publica nada que no pase sus propias pruebas. Las de las
|
|
58
|
+
# tres versiones de Python ya corrieron en el push; esto es el
|
|
59
|
+
# último cerrojo antes de que salga de aquí.
|
|
60
|
+
- run: pytest -q
|
|
61
|
+
|
|
62
|
+
- run: pip install build
|
|
63
|
+
- run: python -m build
|
|
64
|
+
- uses: actions/upload-artifact@v4
|
|
65
|
+
with:
|
|
66
|
+
name: paquete
|
|
67
|
+
path: dist/
|
|
68
|
+
|
|
69
|
+
publicar:
|
|
70
|
+
needs: construir
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
environment: pypi
|
|
73
|
+
permissions:
|
|
74
|
+
id-token: write # esto es lo que sustituye al token
|
|
75
|
+
steps:
|
|
76
|
+
- uses: actions/download-artifact@v4
|
|
77
|
+
with:
|
|
78
|
+
name: paquete
|
|
79
|
+
path: dist/
|
|
80
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
turnero-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.pytest_cache/
|
|
4
|
+
dist/
|
|
5
|
+
build/
|
|
6
|
+
*.egg-info/
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
.DS_Store
|
|
10
|
+
|
|
11
|
+
# Las instrucciones de publicación son para Félix, no para quien se
|
|
12
|
+
# instale el paquete. Fuera del repositorio público a propósito.
|
|
13
|
+
COMO_PUBLICAR.txt
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Registro de cambios
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — primera versión
|
|
4
|
+
|
|
5
|
+
Primera publicación. Sale de un servicio en producción, no de un ejemplo:
|
|
6
|
+
el limitador que hacía falta para abrir [Sportalyze](https://sportalyze.onrender.com)
|
|
7
|
+
a la gente con una clave de diez peticiones por minuto.
|
|
8
|
+
|
|
9
|
+
- `Turnero`: ventana deslizante, `espera_maxima` configurable, `HayCola`
|
|
10
|
+
con los segundos dentro, gestor de contexto y `espera_estimada()` que
|
|
11
|
+
no gasta turno.
|
|
12
|
+
- `UnViaje`: agrupación de llamadas idénticas simultáneas, con `shield`
|
|
13
|
+
para que cancelar a uno no deje tirados a los demás.
|
|
14
|
+
- Veinte pruebas, sin red. Una prueba retirada por no poder fallar; el
|
|
15
|
+
motivo está en el código y en el README.
|
turnero-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Félix Ramírez Ramírez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
turnero-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: turnero
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Límite de peticiones por ventana y agrupación de llamadas idénticas, para asyncio.
|
|
5
|
+
Author: Félix Ramírez Ramírez
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Félix Ramírez Ramírez
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Keywords: api,asyncio,rate-limit,single-flight,sliding-window
|
|
29
|
+
Classifier: Development Status :: 4 - Beta
|
|
30
|
+
Classifier: Framework :: AsyncIO
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Natural Language :: Spanish
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
41
|
+
Classifier: Typing :: Typed
|
|
42
|
+
Requires-Python: >=3.9
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
45
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# turnero
|
|
49
|
+
|
|
50
|
+
**Límite de peticiones por ventana y agrupación de llamadas idénticas, para `asyncio`.**
|
|
51
|
+
Sin dependencias. Veinte pruebas. En castellano.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install turnero
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## El problema
|
|
60
|
+
|
|
61
|
+
Tu proveedor te deja **diez peticiones por minuto**. No diez por visitante: diez en total.
|
|
62
|
+
|
|
63
|
+
En tu portátil, probando tú solo, nunca lo tocas. El día que compartes el enlace y entran
|
|
64
|
+
cinco personas a la vez, se las comen y todas reciben un error.
|
|
65
|
+
|
|
66
|
+
Un semáforo no sirve: limita cuántas cosas pasan **a la vez**, no cuántas pasan **por minuto**.
|
|
67
|
+
Puedes tener una sola petición en vuelo y aun así pasarte, si haces sesenta seguidas y rápidas.
|
|
68
|
+
|
|
69
|
+
## Las dos piezas
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
from turnero import Turnero, UnViaje, HayCola
|
|
74
|
+
|
|
75
|
+
turnero = Turnero(cuantas=9, ventana=60) # nueve de diez: ver abajo
|
|
76
|
+
viajes = UnViaje()
|
|
77
|
+
|
|
78
|
+
async def traer_equipo(id_equipo):
|
|
79
|
+
return await viajes.haz(f"equipo:{id_equipo}", lambda: _traer(id_equipo))
|
|
80
|
+
|
|
81
|
+
async def _traer(id_equipo):
|
|
82
|
+
await turnero.turno() # espera lo justo, o lanza HayCola
|
|
83
|
+
...tu petición de verdad...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**`Turnero`** — ventana deslizante. Recuerda cuándo salió cada petición y no deja salir la
|
|
87
|
+
siguiente hasta que la más vieja caduca. Si la espera supera `espera_maxima` (20 s por defecto),
|
|
88
|
+
lanza `HayCola` con el número de segundos, para que puedas escribir *«vuelve en 12 segundos»*
|
|
89
|
+
en vez de un error genérico.
|
|
90
|
+
|
|
91
|
+
**`UnViaje`** — si cinco llamadas con la misma clave coinciden en el tiempo, sale **una** y las
|
|
92
|
+
otras cuatro se cuelgan de ella. En producción esto ahorró más peticiones que el propio
|
|
93
|
+
limitador, de largo: en una web abierta todo el mundo mira las mismas cosas.
|
|
94
|
+
|
|
95
|
+
También vale como gestor de contexto:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
async with turnero:
|
|
99
|
+
...tu petición...
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Por qué nueve y no diez
|
|
103
|
+
|
|
104
|
+
El contador de tu proveedor va por su ventana y su reloj; el tuyo va por el tuyo. Dos peticiones
|
|
105
|
+
separadas por un cuarto de segundo pueden caer en el mismo minuto suyo aunque no caigan en el
|
|
106
|
+
tuyo.
|
|
107
|
+
|
|
108
|
+
Dejar uno de margen cuesta un turno y evita el error. Con nueve de diez no he visto un 429
|
|
109
|
+
nunca; rozando el tope, sí.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Lo que NO resuelve
|
|
114
|
+
|
|
115
|
+
Esto es tan importante como lo anterior, así que va en el README y no en una nota al pie.
|
|
116
|
+
|
|
117
|
+
**Vive en la memoria de UN proceso.** Con dos procesos, dos *workers* o dos máquinas, cada uno
|
|
118
|
+
lleva su cuenta y entre los dos se pasan del límite. Para eso hace falta un contador compartido
|
|
119
|
+
(Redis o similar) y esto no lo es. Si despliegas con más de un *worker*, `turnero` no te está
|
|
120
|
+
protegiendo.
|
|
121
|
+
|
|
122
|
+
**`UnViaje` no es una caché.** Agrupa sólo lo **simultáneo**. En cuanto la petición termina, la
|
|
123
|
+
siguiente vuelve a salir. Si quieres recordar la respuesta, pon una caché delante; son cosas
|
|
124
|
+
distintas y hacen falta las dos.
|
|
125
|
+
|
|
126
|
+
**No reintenta ni respeta `Retry-After`.** Si el proveedor te dice que no, eso lo decides tú
|
|
127
|
+
arriba.
|
|
128
|
+
|
|
129
|
+
## Lo que no está probado
|
|
130
|
+
|
|
131
|
+
En `tests/test_turnero.py` había una prueba que decía comprobar que el `sleep` está fuera del
|
|
132
|
+
cerrojo. Al romper el código a propósito, **siguió en verde**: no comprobaba nada. La he
|
|
133
|
+
borrado y he dejado escrito el motivo en su sitio.
|
|
134
|
+
|
|
135
|
+
La razón de tener el `sleep` fuera del cerrojo sigue siendo buena —si durmiera dentro, el
|
|
136
|
+
turnero dejaría de ser concurrente y se convertiría en una cola de uno en uno— pero es un
|
|
137
|
+
argumento de diseño que **no he sabido fijar con una prueba**. Prefiero decirlo a dejar una
|
|
138
|
+
prueba verde que miente.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Pruebas
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
pip install -e ".[dev]"
|
|
146
|
+
pytest -q
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Veinte pruebas, sin red. Cada una se escribió rompiendo el código a propósito y comprobando
|
|
150
|
+
que se ponía roja; lo que rompió cada una está anotado en su propio docstring.
|
|
151
|
+
|
|
152
|
+
De los tres sabotajes probados al publicar, dos pusieron la suite en rojo y uno no — ese está
|
|
153
|
+
contado arriba.
|
|
154
|
+
|
|
155
|
+
## Licencia
|
|
156
|
+
|
|
157
|
+
MIT. Félix Ramírez Ramírez, 2026.
|
turnero-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# turnero
|
|
2
|
+
|
|
3
|
+
**Límite de peticiones por ventana y agrupación de llamadas idénticas, para `asyncio`.**
|
|
4
|
+
Sin dependencias. Veinte pruebas. En castellano.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install turnero
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## El problema
|
|
13
|
+
|
|
14
|
+
Tu proveedor te deja **diez peticiones por minuto**. No diez por visitante: diez en total.
|
|
15
|
+
|
|
16
|
+
En tu portátil, probando tú solo, nunca lo tocas. El día que compartes el enlace y entran
|
|
17
|
+
cinco personas a la vez, se las comen y todas reciben un error.
|
|
18
|
+
|
|
19
|
+
Un semáforo no sirve: limita cuántas cosas pasan **a la vez**, no cuántas pasan **por minuto**.
|
|
20
|
+
Puedes tener una sola petición en vuelo y aun así pasarte, si haces sesenta seguidas y rápidas.
|
|
21
|
+
|
|
22
|
+
## Las dos piezas
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import asyncio
|
|
26
|
+
from turnero import Turnero, UnViaje, HayCola
|
|
27
|
+
|
|
28
|
+
turnero = Turnero(cuantas=9, ventana=60) # nueve de diez: ver abajo
|
|
29
|
+
viajes = UnViaje()
|
|
30
|
+
|
|
31
|
+
async def traer_equipo(id_equipo):
|
|
32
|
+
return await viajes.haz(f"equipo:{id_equipo}", lambda: _traer(id_equipo))
|
|
33
|
+
|
|
34
|
+
async def _traer(id_equipo):
|
|
35
|
+
await turnero.turno() # espera lo justo, o lanza HayCola
|
|
36
|
+
...tu petición de verdad...
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**`Turnero`** — ventana deslizante. Recuerda cuándo salió cada petición y no deja salir la
|
|
40
|
+
siguiente hasta que la más vieja caduca. Si la espera supera `espera_maxima` (20 s por defecto),
|
|
41
|
+
lanza `HayCola` con el número de segundos, para que puedas escribir *«vuelve en 12 segundos»*
|
|
42
|
+
en vez de un error genérico.
|
|
43
|
+
|
|
44
|
+
**`UnViaje`** — si cinco llamadas con la misma clave coinciden en el tiempo, sale **una** y las
|
|
45
|
+
otras cuatro se cuelgan de ella. En producción esto ahorró más peticiones que el propio
|
|
46
|
+
limitador, de largo: en una web abierta todo el mundo mira las mismas cosas.
|
|
47
|
+
|
|
48
|
+
También vale como gestor de contexto:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
async with turnero:
|
|
52
|
+
...tu petición...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Por qué nueve y no diez
|
|
56
|
+
|
|
57
|
+
El contador de tu proveedor va por su ventana y su reloj; el tuyo va por el tuyo. Dos peticiones
|
|
58
|
+
separadas por un cuarto de segundo pueden caer en el mismo minuto suyo aunque no caigan en el
|
|
59
|
+
tuyo.
|
|
60
|
+
|
|
61
|
+
Dejar uno de margen cuesta un turno y evita el error. Con nueve de diez no he visto un 429
|
|
62
|
+
nunca; rozando el tope, sí.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Lo que NO resuelve
|
|
67
|
+
|
|
68
|
+
Esto es tan importante como lo anterior, así que va en el README y no en una nota al pie.
|
|
69
|
+
|
|
70
|
+
**Vive en la memoria de UN proceso.** Con dos procesos, dos *workers* o dos máquinas, cada uno
|
|
71
|
+
lleva su cuenta y entre los dos se pasan del límite. Para eso hace falta un contador compartido
|
|
72
|
+
(Redis o similar) y esto no lo es. Si despliegas con más de un *worker*, `turnero` no te está
|
|
73
|
+
protegiendo.
|
|
74
|
+
|
|
75
|
+
**`UnViaje` no es una caché.** Agrupa sólo lo **simultáneo**. En cuanto la petición termina, la
|
|
76
|
+
siguiente vuelve a salir. Si quieres recordar la respuesta, pon una caché delante; son cosas
|
|
77
|
+
distintas y hacen falta las dos.
|
|
78
|
+
|
|
79
|
+
**No reintenta ni respeta `Retry-After`.** Si el proveedor te dice que no, eso lo decides tú
|
|
80
|
+
arriba.
|
|
81
|
+
|
|
82
|
+
## Lo que no está probado
|
|
83
|
+
|
|
84
|
+
En `tests/test_turnero.py` había una prueba que decía comprobar que el `sleep` está fuera del
|
|
85
|
+
cerrojo. Al romper el código a propósito, **siguió en verde**: no comprobaba nada. La he
|
|
86
|
+
borrado y he dejado escrito el motivo en su sitio.
|
|
87
|
+
|
|
88
|
+
La razón de tener el `sleep` fuera del cerrojo sigue siendo buena —si durmiera dentro, el
|
|
89
|
+
turnero dejaría de ser concurrente y se convertiría en una cola de uno en uno— pero es un
|
|
90
|
+
argumento de diseño que **no he sabido fijar con una prueba**. Prefiero decirlo a dejar una
|
|
91
|
+
prueba verde que miente.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Pruebas
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install -e ".[dev]"
|
|
99
|
+
pytest -q
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Veinte pruebas, sin red. Cada una se escribió rompiendo el código a propósito y comprobando
|
|
103
|
+
que se ponía roja; lo que rompió cada una está anotado en su propio docstring.
|
|
104
|
+
|
|
105
|
+
De los tres sabotajes probados al publicar, dos pusieron la suite en rojo y uno no — ese está
|
|
106
|
+
contado arriba.
|
|
107
|
+
|
|
108
|
+
## Licencia
|
|
109
|
+
|
|
110
|
+
MIT. Félix Ramírez Ramírez, 2026.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "turnero"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Límite de peticiones por ventana y agrupación de llamadas idénticas, para asyncio."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Félix Ramírez Ramírez" }]
|
|
13
|
+
keywords = ["rate-limit", "asyncio", "sliding-window", "single-flight", "api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Natural Language :: Spanish",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=7", "pytest-asyncio>=0.21"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/turnero"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""turnero — límite por ventana y un viaje por petición, para asyncio.
|
|
3
|
+
|
|
4
|
+
from turnero import Turnero, UnViaje, HayCola
|
|
5
|
+
|
|
6
|
+
turnero = Turnero(cuantas=9, ventana=60) # nueve de diez: el margen
|
|
7
|
+
viajes = UnViaje()
|
|
8
|
+
|
|
9
|
+
async def traer(id):
|
|
10
|
+
return await viajes.haz(id, lambda: _traer_de_verdad(id))
|
|
11
|
+
|
|
12
|
+
async def _traer_de_verdad(id):
|
|
13
|
+
await turnero.turno()
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
Lo que NO resuelve, y está escrito también en el README: el turnero
|
|
17
|
+
vive en la memoria de UN proceso. Con dos procesos o dos máquinas, cada
|
|
18
|
+
uno lleva su cuenta y entre los dos se pasan del límite. Para eso hace
|
|
19
|
+
falta algo compartido (Redis o similar) y esto no lo es.
|
|
20
|
+
"""
|
|
21
|
+
from .errores import HayCola
|
|
22
|
+
from ._turnero import Turnero, ESPERA_MAXIMA_POR_DEFECTO
|
|
23
|
+
from ._un_viaje import UnViaje
|
|
24
|
+
|
|
25
|
+
__all__ = ["Turnero", "UnViaje", "HayCola", "ESPERA_MAXIMA_POR_DEFECTO"]
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""El turnero: una ventana deslizante que reparte turnos.
|
|
3
|
+
|
|
4
|
+
POR QUÉ EXISTE ESTO Y NO USO UN SEMÁFORO
|
|
5
|
+
----------------------------------------
|
|
6
|
+
Un semáforo limita cuántas cosas pasan A LA VEZ. Lo que limita una API
|
|
7
|
+
es cuántas pasan POR MINUTO, que es otra cosa: puedes tener una sola
|
|
8
|
+
petición en vuelo y aun así pasarte del límite si haces sesenta
|
|
9
|
+
seguidas y rápidas.
|
|
10
|
+
|
|
11
|
+
Lo que hace falta es una ventana deslizante: recordar cuándo salió
|
|
12
|
+
cada petición y no dejar salir la número N+1 hasta que la primera
|
|
13
|
+
tenga más de `ventana` segundos. Eso es todo lo que hay aquí.
|
|
14
|
+
|
|
15
|
+
LA DECISIÓN QUE MÁS SE DISCUTE
|
|
16
|
+
------------------------------
|
|
17
|
+
Si tu proveedor te deja diez por minuto, configura NUEVE.
|
|
18
|
+
|
|
19
|
+
Su contador va por su ventana y su reloj; el tuyo va por el tuyo. Dos
|
|
20
|
+
peticiones separadas por un cuarto de segundo pueden caer en el mismo
|
|
21
|
+
minuto suyo aunque no caigan en el tuyo. Dejar uno de margen cuesta un
|
|
22
|
+
turno y evita el error. En producción, con nueve de diez, no he visto
|
|
23
|
+
un 429; rozando el tope, sí.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import time
|
|
29
|
+
from collections import deque
|
|
30
|
+
|
|
31
|
+
from .errores import HayCola
|
|
32
|
+
|
|
33
|
+
ESPERA_MAXIMA_POR_DEFECTO = 20.0
|
|
34
|
+
"""Más de esto esperando y se prefiere avisar. Veinte segundos es
|
|
35
|
+
mucho para una web y poco para perder la petición."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Turnero:
|
|
39
|
+
"""Reparte turnos para no pasarse de un límite por ventana.
|
|
40
|
+
|
|
41
|
+
turnero = Turnero(cuantas=9, ventana=60)
|
|
42
|
+
await turnero.turno() # espera lo justo, o lanza HayCola
|
|
43
|
+
...tu petición...
|
|
44
|
+
|
|
45
|
+
También vale como gestor de contexto:
|
|
46
|
+
|
|
47
|
+
async with turnero:
|
|
48
|
+
...tu petición...
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, cuantas: int = 9, ventana: float = 60.0,
|
|
52
|
+
espera_maxima: float = ESPERA_MAXIMA_POR_DEFECTO):
|
|
53
|
+
if cuantas < 1:
|
|
54
|
+
raise ValueError("cuantas tiene que ser 1 o más")
|
|
55
|
+
if ventana <= 0:
|
|
56
|
+
raise ValueError("ventana tiene que ser mayor que cero")
|
|
57
|
+
self.cuantas = cuantas
|
|
58
|
+
self.ventana = float(ventana)
|
|
59
|
+
self.espera_maxima = float(espera_maxima)
|
|
60
|
+
self._salidas: deque[float] = deque()
|
|
61
|
+
self._cerrojo = asyncio.Lock()
|
|
62
|
+
|
|
63
|
+
# ── por dentro ──────────────────────────────────────────────
|
|
64
|
+
def _limpiar(self, ahora: float) -> None:
|
|
65
|
+
"""Olvida las salidas que ya no cuentan para la ventana."""
|
|
66
|
+
while self._salidas and ahora - self._salidas[0] >= self.ventana:
|
|
67
|
+
self._salidas.popleft()
|
|
68
|
+
|
|
69
|
+
# ── por fuera ───────────────────────────────────────────────
|
|
70
|
+
def espera_estimada(self) -> float:
|
|
71
|
+
"""Cuánto habría que esperar AHORA. No pide turno ni lo gasta.
|
|
72
|
+
|
|
73
|
+
Sirve para decidir antes de llamar: si esto devuelve 12, puedes
|
|
74
|
+
enseñar «hay cola» sin bloquear a nadie.
|
|
75
|
+
"""
|
|
76
|
+
ahora = time.monotonic()
|
|
77
|
+
self._limpiar(ahora)
|
|
78
|
+
if len(self._salidas) < self.cuantas:
|
|
79
|
+
return 0.0
|
|
80
|
+
return max(0.0, self.ventana - (ahora - self._salidas[0]))
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def en_la_ventana(self) -> int:
|
|
84
|
+
"""Cuántas peticiones cuentan ahora mismo dentro de la ventana."""
|
|
85
|
+
self._limpiar(time.monotonic())
|
|
86
|
+
return len(self._salidas)
|
|
87
|
+
|
|
88
|
+
async def turno(self) -> None:
|
|
89
|
+
"""Espera a que toque. Lanza HayCola si la espera es absurda.
|
|
90
|
+
|
|
91
|
+
El cerrojo sólo cubre mirar y apuntar, nunca el sleep: si
|
|
92
|
+
durmiera con el cerrojo cogido, el turnero dejaría de ser
|
|
93
|
+
concurrente y se convertiría en una cola de uno en uno.
|
|
94
|
+
"""
|
|
95
|
+
while True:
|
|
96
|
+
async with self._cerrojo:
|
|
97
|
+
ahora = time.monotonic()
|
|
98
|
+
self._limpiar(ahora)
|
|
99
|
+
if len(self._salidas) < self.cuantas:
|
|
100
|
+
self._salidas.append(ahora)
|
|
101
|
+
return
|
|
102
|
+
falta = self.ventana - (ahora - self._salidas[0]) + 0.05
|
|
103
|
+
if falta > self.espera_maxima:
|
|
104
|
+
raise HayCola(falta, self.cuantas, self.ventana)
|
|
105
|
+
await asyncio.sleep(min(falta, 1.0))
|
|
106
|
+
|
|
107
|
+
async def __aenter__(self) -> "Turnero":
|
|
108
|
+
await self.turno()
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
async def __aexit__(self, *_) -> None:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
return (f"Turnero(cuantas={self.cuantas}, ventana={self.ventana:g}, "
|
|
116
|
+
f"en_la_ventana={len(self._salidas)})")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Un viaje por petición: si varios piden lo mismo a la vez, sale una.
|
|
3
|
+
|
|
4
|
+
ESTO AHORRA MÁS TURNOS QUE EL PROPIO LÍMITE
|
|
5
|
+
-------------------------------------------
|
|
6
|
+
Lo normal, en una web abierta, es que todo el mundo mire las mismas
|
|
7
|
+
cosas. Si cinco visitantes abren la misma página en el mismo segundo,
|
|
8
|
+
el turnero reparte cinco turnos para traer cinco veces lo mismo.
|
|
9
|
+
|
|
10
|
+
Con esto sale UNA petición y las otras cuatro se cuelgan de ella. En
|
|
11
|
+
producción ahorró más peticiones que el limitador, de largo.
|
|
12
|
+
|
|
13
|
+
POR QUÉ `shield`
|
|
14
|
+
----------------
|
|
15
|
+
Si el primero que pidió se cancela —cierra la pestaña, se le va la
|
|
16
|
+
conexión—, los otros cuatro NO pueden quedarse colgados de una tarea
|
|
17
|
+
muerta. `asyncio.shield` hace que cancelar la espera no cancele el
|
|
18
|
+
viaje: el que se va se va, y los que quedan reciben su respuesta.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
from typing import Any, Awaitable, Callable, Dict, Hashable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnViaje:
|
|
27
|
+
"""Agrupa llamadas idénticas simultáneas en una sola.
|
|
28
|
+
|
|
29
|
+
viajes = UnViaje()
|
|
30
|
+
datos = await viajes.haz("equipo:81", lambda: traer(81))
|
|
31
|
+
|
|
32
|
+
Lo que agrupa es la CLAVE: dos llamadas con la misma clave a la vez
|
|
33
|
+
comparten resultado; con claves distintas, no se tocan.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._en_vuelo: Dict[Hashable, asyncio.Future] = {}
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def en_vuelo(self) -> int:
|
|
41
|
+
"""Cuántas peticiones distintas están saliendo ahora mismo."""
|
|
42
|
+
return len(self._en_vuelo)
|
|
43
|
+
|
|
44
|
+
async def haz(self, clave: Hashable,
|
|
45
|
+
hacer: Callable[[], Awaitable[Any]]) -> Any:
|
|
46
|
+
"""Ejecuta `hacer()` una sola vez por clave simultánea.
|
|
47
|
+
|
|
48
|
+
`hacer` es una función SIN argumentos que devuelve una
|
|
49
|
+
corutina. Se pide así, y no la corutina ya creada, porque si
|
|
50
|
+
hay que descartarla quedaría una corutina sin esperar y Python
|
|
51
|
+
avisa por consola.
|
|
52
|
+
"""
|
|
53
|
+
pendiente = self._en_vuelo.get(clave)
|
|
54
|
+
if pendiente is not None:
|
|
55
|
+
return await asyncio.shield(pendiente)
|
|
56
|
+
|
|
57
|
+
tarea = asyncio.ensure_future(hacer())
|
|
58
|
+
self._en_vuelo[clave] = tarea
|
|
59
|
+
try:
|
|
60
|
+
return await asyncio.shield(tarea)
|
|
61
|
+
finally:
|
|
62
|
+
# Sólo lo quita quien lo puso: si el primero se canceló y
|
|
63
|
+
# otro ya empezó un viaje nuevo con la misma clave, borrar
|
|
64
|
+
# a ciegas tiraría el viaje bueno.
|
|
65
|
+
if self._en_vuelo.get(clave) is tarea:
|
|
66
|
+
self._en_vuelo.pop(clave, None)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Los errores que este paquete puede lanzar.
|
|
3
|
+
|
|
4
|
+
Tienen clase propia a propósito. Quien usa la librería necesita poder
|
|
5
|
+
distinguir «hay cola, espera» de «esto se ha roto», porque son dos
|
|
6
|
+
mensajes distintos para el usuario final y dos decisiones distintas
|
|
7
|
+
para el programa.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HayCola(Exception):
|
|
13
|
+
"""La espera sería más larga de lo que se ha dicho que se tolera.
|
|
14
|
+
|
|
15
|
+
No es un fallo: es el turnero diciendo que prefiere contestar a
|
|
16
|
+
tener a alguien mirando una ruedecita. Trae el número para que
|
|
17
|
+
arriba se pueda escribir «vuelve en N segundos» en vez de un
|
|
18
|
+
error genérico.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, espera: float, cuantas: int, ventana: float):
|
|
22
|
+
self.espera = espera
|
|
23
|
+
self.cuantas = cuantas
|
|
24
|
+
self.ventana = ventana
|
|
25
|
+
super().__init__(
|
|
26
|
+
f"hay cola: habría que esperar {espera:.0f} segundos. "
|
|
27
|
+
f"El límite configurado es de {cuantas} peticiones "
|
|
28
|
+
f"cada {ventana:.0f} segundos, compartidas por todo el mundo."
|
|
29
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Los dos a la vez, que es como se usan de verdad."""
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from turnero import Turnero, UnViaje
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def test_veinte_visitantes_pidiendo_cinco_cosas_gastan_cinco_turnos():
|
|
9
|
+
"""El caso real: mucha gente mirando las mismas pocas páginas.
|
|
10
|
+
|
|
11
|
+
Sin agrupar serían veinte turnos y se comerían el límite. Con los
|
|
12
|
+
dos juntos son cinco, uno por cosa distinta.
|
|
13
|
+
"""
|
|
14
|
+
turnero = Turnero(cuantas=9, ventana=60.0)
|
|
15
|
+
viajes = UnViaje()
|
|
16
|
+
|
|
17
|
+
async def traer(clave):
|
|
18
|
+
await turnero.turno()
|
|
19
|
+
await asyncio.sleep(0.02)
|
|
20
|
+
return clave
|
|
21
|
+
|
|
22
|
+
async def pedir(clave):
|
|
23
|
+
return await viajes.haz(clave, lambda: traer(clave))
|
|
24
|
+
|
|
25
|
+
peticiones = [pedir(f"cosa{i % 5}") for i in range(20)]
|
|
26
|
+
res = await asyncio.gather(*peticiones)
|
|
27
|
+
|
|
28
|
+
assert len(res) == 20
|
|
29
|
+
assert turnero.en_la_ventana == 5
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Pruebas del turnero.
|
|
3
|
+
|
|
4
|
+
Sin red y sin esperas largas: las ventanas son de décimas de segundo.
|
|
5
|
+
Cada prueba se escribió rompiendo el código a propósito primero, para
|
|
6
|
+
comprobar que se ponía roja. Lo que rompió cada una está anotado.
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from turnero import Turnero, HayCola
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def test_por_debajo_del_limite_no_se_espera():
|
|
17
|
+
t = Turnero(cuantas=5, ventana=60.0)
|
|
18
|
+
ini = time.monotonic()
|
|
19
|
+
for _ in range(5):
|
|
20
|
+
await t.turno()
|
|
21
|
+
assert time.monotonic() - ini < 0.2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def test_pasado_el_limite_se_espera_lo_justo():
|
|
25
|
+
"""SABOTAJE: quitar el `+ 0.05` del cálculo de `falta` dejaba que
|
|
26
|
+
la cuarta saliera un pelo antes de que caducara la primera."""
|
|
27
|
+
t = Turnero(cuantas=3, ventana=0.5)
|
|
28
|
+
for _ in range(3):
|
|
29
|
+
await t.turno()
|
|
30
|
+
ini = time.monotonic()
|
|
31
|
+
await t.turno()
|
|
32
|
+
esperado = time.monotonic() - ini
|
|
33
|
+
assert 0.3 < esperado < 1.2, f"esperó {esperado:.2f}s"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_la_ventana_se_vacia_sola():
|
|
37
|
+
t = Turnero(cuantas=2, ventana=0.3)
|
|
38
|
+
await t.turno(); await t.turno()
|
|
39
|
+
assert t.en_la_ventana == 2
|
|
40
|
+
await asyncio.sleep(0.35)
|
|
41
|
+
assert t.en_la_ventana == 0
|
|
42
|
+
assert t.espera_estimada() == 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def test_si_la_cola_es_absurda_se_avisa_en_vez_de_colgar():
|
|
46
|
+
"""SABOTAJE: devolver None en vez de lanzar hacía que la petición
|
|
47
|
+
saliera igual, saltándose el límite en silencio."""
|
|
48
|
+
t = Turnero(cuantas=1, ventana=60.0, espera_maxima=1.0)
|
|
49
|
+
await t.turno()
|
|
50
|
+
with pytest.raises(HayCola) as e:
|
|
51
|
+
await t.turno()
|
|
52
|
+
assert e.value.espera > 1.0
|
|
53
|
+
assert e.value.cuantas == 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_el_aviso_trae_los_numeros_para_el_usuario():
|
|
57
|
+
t = Turnero(cuantas=2, ventana=30.0, espera_maxima=0.5)
|
|
58
|
+
await t.turno(); await t.turno()
|
|
59
|
+
with pytest.raises(HayCola) as e:
|
|
60
|
+
await t.turno()
|
|
61
|
+
texto = str(e.value)
|
|
62
|
+
assert "2" in texto and "30" in texto
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_la_espera_estimada_no_pide_turno():
|
|
66
|
+
"""SABOTAJE: si espera_estimada apuntara la salida, consultar el
|
|
67
|
+
estado gastaría turnos y la web se limitaría a sí misma."""
|
|
68
|
+
t = Turnero(cuantas=3, ventana=60.0)
|
|
69
|
+
for _ in range(10):
|
|
70
|
+
t.espera_estimada()
|
|
71
|
+
assert t.en_la_ventana == 0
|
|
72
|
+
for _ in range(3):
|
|
73
|
+
await t.turno()
|
|
74
|
+
assert t.espera_estimada() > 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_el_limite_se_respeta_con_muchos_a_la_vez():
|
|
78
|
+
"""Veinte tareas a la vez contra un límite de cinco: salen cinco.
|
|
79
|
+
SABOTAJE: quitar el cerrojo dejaba pasar a varias entre el mirar
|
|
80
|
+
y el apuntar, y salían siete u ocho."""
|
|
81
|
+
t = Turnero(cuantas=5, ventana=60.0, espera_maxima=0.2)
|
|
82
|
+
async def intentar():
|
|
83
|
+
try:
|
|
84
|
+
await t.turno()
|
|
85
|
+
return True
|
|
86
|
+
except HayCola:
|
|
87
|
+
return False
|
|
88
|
+
salidas = await asyncio.gather(*[intentar() for _ in range(20)])
|
|
89
|
+
assert sum(salidas) == 5
|
|
90
|
+
assert t.en_la_ventana == 5
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_sirve_como_gestor_de_contexto():
|
|
94
|
+
t = Turnero(cuantas=2, ventana=60.0)
|
|
95
|
+
async with t:
|
|
96
|
+
pass
|
|
97
|
+
assert t.en_la_ventana == 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# AQUÍ HABÍA UNA PRUEBA QUE NO PROTEGÍA NADA
|
|
101
|
+
#
|
|
102
|
+
# Se llamaba test_no_duerme_con_el_cerrojo_cogido y decía comprobar que
|
|
103
|
+
# el `await asyncio.sleep(...)` de Turnero.turno() está FUERA del
|
|
104
|
+
# `async with self._cerrojo`.
|
|
105
|
+
#
|
|
106
|
+
# Al sabotear el código —metiendo el sleep dentro del cerrojo— la
|
|
107
|
+
# prueba siguió en VERDE. O sea que no comprobaba lo que decía.
|
|
108
|
+
#
|
|
109
|
+
# El motivo: con una ventana corta, cuando el que duerme se despierta
|
|
110
|
+
# la ventana ya ha caducado entera, así que el siguiente entra rápido
|
|
111
|
+
# de las dos maneras. Los tiempos salen casi iguales con el código
|
|
112
|
+
# bueno y con el roto.
|
|
113
|
+
#
|
|
114
|
+
# La razón de tener el sleep fuera del cerrojo sigue siendo buena —si
|
|
115
|
+
# durmiera dentro, el turnero dejaría de ser concurrente y se
|
|
116
|
+
# convertiría en una cola de uno en uno— pero es un argumento de
|
|
117
|
+
# diseño que NO he sabido fijar con una prueba. Antes que dejar una
|
|
118
|
+
# prueba verde que miente, se borra y se dice.
|
|
119
|
+
#
|
|
120
|
+
# Está en el README, en «lo que no está probado».
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.parametrize("cuantas,ventana", [(0, 60), (-1, 60), (5, 0), (5, -1)])
|
|
124
|
+
async def test_configuracion_imposible_se_rechaza_al_construir(cuantas, ventana):
|
|
125
|
+
"""Mejor reventar al arrancar que a las tres de la mañana."""
|
|
126
|
+
with pytest.raises(ValueError):
|
|
127
|
+
Turnero(cuantas=cuantas, ventana=ventana)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def test_el_repr_dice_el_estado():
|
|
131
|
+
t = Turnero(cuantas=4, ventana=10.0)
|
|
132
|
+
await t.turno()
|
|
133
|
+
assert "cuantas=4" in repr(t) and "en_la_ventana=1" in repr(t)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Pruebas de «un viaje por petición»."""
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from turnero import UnViaje
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def test_cinco_a_la_vez_son_un_solo_viaje():
|
|
11
|
+
"""SABOTAJE: quitar el registro en `_en_vuelo` → salieron cinco."""
|
|
12
|
+
viajes = UnViaje()
|
|
13
|
+
salidas = 0
|
|
14
|
+
|
|
15
|
+
async def traer():
|
|
16
|
+
nonlocal salidas
|
|
17
|
+
salidas += 1
|
|
18
|
+
await asyncio.sleep(0.05)
|
|
19
|
+
return "dato"
|
|
20
|
+
|
|
21
|
+
res = await asyncio.gather(*[viajes.haz("a", traer) for _ in range(5)])
|
|
22
|
+
assert salidas == 1
|
|
23
|
+
assert res == ["dato"] * 5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_claves_distintas_son_viajes_distintos():
|
|
27
|
+
viajes = UnViaje()
|
|
28
|
+
salidas = 0
|
|
29
|
+
|
|
30
|
+
async def traer():
|
|
31
|
+
nonlocal salidas
|
|
32
|
+
salidas += 1
|
|
33
|
+
await asyncio.sleep(0.02)
|
|
34
|
+
return salidas
|
|
35
|
+
|
|
36
|
+
await asyncio.gather(viajes.haz("a", traer), viajes.haz("b", traer))
|
|
37
|
+
assert salidas == 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_despues_de_acabar_el_siguiente_vuelve_a_salir():
|
|
41
|
+
"""Agrupar sólo vale para lo SIMULTÁNEO. Esto no es una caché.
|
|
42
|
+
SABOTAJE: no limpiar `_en_vuelo` al acabar → la segunda tanda
|
|
43
|
+
devolvía el dato viejo para siempre."""
|
|
44
|
+
viajes = UnViaje()
|
|
45
|
+
salidas = 0
|
|
46
|
+
|
|
47
|
+
async def traer():
|
|
48
|
+
nonlocal salidas
|
|
49
|
+
salidas += 1
|
|
50
|
+
return salidas
|
|
51
|
+
|
|
52
|
+
assert await viajes.haz("a", traer) == 1
|
|
53
|
+
assert await viajes.haz("a", traer) == 2
|
|
54
|
+
assert viajes.en_vuelo == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def test_si_el_primero_se_cancela_los_demas_cobran():
|
|
58
|
+
"""La razón de `asyncio.shield`.
|
|
59
|
+
SABOTAJE: quitar el shield → al cancelar el primero, los otros dos
|
|
60
|
+
se quedaron con CancelledError sin haber pedido nada."""
|
|
61
|
+
viajes = UnViaje()
|
|
62
|
+
|
|
63
|
+
async def traer():
|
|
64
|
+
await asyncio.sleep(0.1)
|
|
65
|
+
return "llegó"
|
|
66
|
+
|
|
67
|
+
primero = asyncio.create_task(viajes.haz("a", traer))
|
|
68
|
+
await asyncio.sleep(0.01)
|
|
69
|
+
otros = [asyncio.create_task(viajes.haz("a", traer)) for _ in range(2)]
|
|
70
|
+
await asyncio.sleep(0.01)
|
|
71
|
+
primero.cancel()
|
|
72
|
+
assert await asyncio.gather(*otros) == ["llegó", "llegó"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_un_fallo_llega_a_todos_los_que_esperan():
|
|
76
|
+
viajes = UnViaje()
|
|
77
|
+
|
|
78
|
+
async def traer():
|
|
79
|
+
await asyncio.sleep(0.02)
|
|
80
|
+
raise RuntimeError("la fuente ha dicho que no")
|
|
81
|
+
|
|
82
|
+
with pytest.raises(RuntimeError):
|
|
83
|
+
await asyncio.gather(*[viajes.haz("a", traer) for _ in range(3)])
|
|
84
|
+
assert viajes.en_vuelo == 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_el_contador_de_en_vuelo_es_de_verdad():
|
|
88
|
+
viajes = UnViaje()
|
|
89
|
+
|
|
90
|
+
async def traer():
|
|
91
|
+
await asyncio.sleep(0.05)
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
t = [asyncio.create_task(viajes.haz(k, traer)) for k in ("a", "b", "c")]
|
|
95
|
+
await asyncio.sleep(0.01)
|
|
96
|
+
assert viajes.en_vuelo == 3
|
|
97
|
+
await asyncio.gather(*t)
|
|
98
|
+
assert viajes.en_vuelo == 0
|