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.
@@ -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
@@ -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.
@@ -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