cashing2fast-fastapi 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.
Files changed (23) hide show
  1. cashing2fast_fastapi-0.1.0/LICENSE +21 -0
  2. cashing2fast_fastapi-0.1.0/MANIFEST.in +4 -0
  3. cashing2fast_fastapi-0.1.0/PKG-INFO +65 -0
  4. cashing2fast_fastapi-0.1.0/README.md +19 -0
  5. cashing2fast_fastapi-0.1.0/docs/usage.md +76 -0
  6. cashing2fast_fastapi-0.1.0/examples/.env.examples +18 -0
  7. cashing2fast_fastapi-0.1.0/pyproject.toml +71 -0
  8. cashing2fast_fastapi-0.1.0/setup.cfg +4 -0
  9. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/__init__.py +12 -0
  10. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/__version__.py +1 -0
  11. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/dependencies.py +91 -0
  12. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/exceptions.py +6 -0
  13. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/services/__init__.py +7 -0
  14. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/services/billing_service.py +62 -0
  15. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/settings.py +68 -0
  16. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/utils/__init__.py +6 -0
  17. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/utils/redis_client.py +25 -0
  18. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/PKG-INFO +65 -0
  19. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/SOURCES.txt +21 -0
  20. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/dependency_links.txt +1 -0
  21. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/requires.txt +4 -0
  22. cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/top_level.txt +1 -0
  23. cashing2fast_fastapi-0.1.0/tests/test_billing.py +104 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Angel Daniel Sanchez Castillo
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.
@@ -0,0 +1,4 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include examples *.examples
4
+ recursive-include docs *.md
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: cashing2fast-fastapi
3
+ Version: 0.1.0
4
+ Summary: Billing and request limit control for FastAPI
5
+ Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Angel Daniel Sanchez Castillo
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
+
28
+ Keywords: fastapi,billing,request-limit,redis,oauth2
29
+ Classifier: Development Status :: 3 - Alpha
30
+ Classifier: Intended Audience :: Developers
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.10
34
+ Classifier: Programming Language :: Python :: 3.11
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Framework :: FastAPI
37
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
38
+ Requires-Python: >=3.10
39
+ Description-Content-Type: text/markdown
40
+ License-File: LICENSE
41
+ Requires-Dist: oauth2fast-fastapi>=0.3.0
42
+ Requires-Dist: tools2fast-fastapi>=0.1.5
43
+ Requires-Dist: redis>=5.0.0
44
+ Requires-Dist: pydantic-settings>=2.0.0
45
+ Dynamic: license-file
46
+
47
+ # cashing2fast-fastapi
48
+ 🚀 Simple and fast cashing tools for FastAPI with minimal configuration
49
+
50
+ ## Documentación
51
+
52
+ - [Guía de Uso (Usage Guide)](docs/usage.md)
53
+ - [Ejemplos de configuración](examples/.env.examples)
54
+
55
+ ## Instalación
56
+
57
+ ```bash
58
+ uv add cashing2fast-fastapi
59
+ ```
60
+
61
+ ## Características
62
+
63
+ - 🎯 **Lógica de 3 Fases:** Tiempo gratuito, periodo de cobro por peticiones y bloqueo final.
64
+ - ⚡ **Redis Native:** Contadores atómicos y caché de usuario para evitar cargas innecesarias a DB.
65
+ - 🛡️ **Tools2Fast Integration:** Respuestas con formato estándar y código HTTP 402.
@@ -0,0 +1,19 @@
1
+ # cashing2fast-fastapi
2
+ 🚀 Simple and fast cashing tools for FastAPI with minimal configuration
3
+
4
+ ## Documentación
5
+
6
+ - [Guía de Uso (Usage Guide)](docs/usage.md)
7
+ - [Ejemplos de configuración](examples/.env.examples)
8
+
9
+ ## Instalación
10
+
11
+ ```bash
12
+ uv add cashing2fast-fastapi
13
+ ```
14
+
15
+ ## Características
16
+
17
+ - 🎯 **Lógica de 3 Fases:** Tiempo gratuito, periodo de cobro por peticiones y bloqueo final.
18
+ - ⚡ **Redis Native:** Contadores atómicos y caché de usuario para evitar cargas innecesarias a DB.
19
+ - 🛡️ **Tools2Fast Integration:** Respuestas con formato estándar y código HTTP 402.
@@ -0,0 +1,76 @@
1
+ # Guía de Uso: Cashing2Fast FastAPI
2
+
3
+ `cashing2fast-fastapi` es una extensión para gestionar límites de peticiones y control de facturación en aplicaciones FastAPI, utilizando Redis para alta velocidad y minimizando consultas a la base de datos.
4
+
5
+ ## 1. Configuración de Entorno (.env)
6
+
7
+ Asegúrate de tener las siguientes variables en tu archivo `.env`. Puedes ver un ejemplo completo en [examples/.env.examples](../examples/.env.examples).
8
+
9
+ ```env
10
+ # Límites de Facturación (en minutos)
11
+ CASHING_FREE_MINUTES=5 # Minutos gratis después de crear la cuenta
12
+ CASHING_REDIRECT_MINUTES=60 # Minutos en fase de cobro/conteo
13
+ CASHING_MAX_REQUESTS=10 # Máximo de peticiones en fase de cobro
14
+
15
+ # Redis para el contador y caché
16
+ CASHING_REDIS__HOST=localhost
17
+ CASHING_REDIS__PORT=6379
18
+ ```
19
+
20
+ ## 2. Registro del Manejador de Excepciones
21
+
22
+ Para que tu API responda con el formato estándar (402 Payment Required) y el esquema de `tools2fast`, debes registrar el manejador de excepciones en tu archivo `main.py`:
23
+
24
+ ```python
25
+ from fastapi import FastAPI
26
+ from cashing2fast_fastapi.dependencies import register_billing_exception_handler
27
+
28
+ app = FastAPI()
29
+
30
+ # Esto permite que la excepción PaymentRequiredException
31
+ # se convierta automáticamente en una respuesta Tools2Fast
32
+ register_billing_exception_handler(app)
33
+ ```
34
+
35
+ ## 3. Uso de la Dependencia en Routers
36
+
37
+ Para proteger un endpoint o un router completo, simplemente utiliza la dependencia `require_billing_checks`.
38
+
39
+ ### Proteger un Endpoint Individual:
40
+
41
+ ```python
42
+ from fastapi import APIRouter, Depends
43
+ from cashing2fast_fastapi import require_billing_checks
44
+
45
+ router = APIRouter()
46
+
47
+ @router.get("/data-limitada", dependencies=[Depends(require_billing_checks)])
48
+ async def get_protected_data():
49
+ return {"message": "Has pasado el filtro de facturación correctamente"}
50
+ ```
51
+
52
+ ### Proteger un Router Completo:
53
+
54
+ ```python
55
+ router = APIRouter(
56
+ prefix="/api/premium",
57
+ dependencies=[Depends(require_billing_checks)]
58
+ )
59
+ ```
60
+
61
+ ## 4. ¿Cómo funciona internamente?
62
+
63
+ La dependencia sigue una lógica de **3 fases** basada en la fecha de creación del usuario (`user.created_at`):
64
+
65
+ 1. **Fase Gratuita:** Si el tiempo desde la creación es menor o igual a `CASHING_FREE_MINUTES`, el usuario puede navegar libremente.
66
+ 2. **Fase de Conteo:** Si el tiempo es mayor a los minutos gratis pero menor al tiempo de redirección (`FREE + REDIRECT`):
67
+ * Se cuenta cada petición en Redis.
68
+ * Si se supera el `CASHING_MAX_REQUESTS`, se lanza un error **402**.
69
+ * **Importante:** Al lanzar el error, el contador se resetea a 0 para que el usuario pueda volver a navegar tras ser redireccionado por el frontend.
70
+ 3. **Fase Bloqueada:** Si el tiempo total expira, el acceso se bloquea permanentemente con un error **402** hasta que se realice un pago.
71
+
72
+ ## 5. Optimización (Redis)
73
+
74
+ La librería minimiza el uso de la base de datos:
75
+ * La primera vez que un usuario hace una petición, se busca su `id` y `created_at` en la DB y se guarda en Redis.
76
+ * En todas las peticiones siguientes, la información se extrae directamente de Redis usando el token Bearer, ahorrando una consulta SQL por cada request.
@@ -0,0 +1,18 @@
1
+ # Cashing2Fast FastAPI configuration example
2
+ SECRET_KEY=yoursecretkeyhere
3
+
4
+ # Database connections (Standard for your apps)
5
+ DB_CONNECTIONS__AUTH__HOST=localhost
6
+ DB_CONNECTIONS__AUTH__PORT=5432
7
+ DB_CONNECTIONS__AUTH__USERNAME=postgres
8
+ DB_CONNECTIONS__AUTH__PASSWORD=postgres
9
+ DB_CONNECTIONS__AUTH__DATABASE=postgres
10
+
11
+ # Billing Logic
12
+ CASHING_FREE_MINUTES=0
13
+ CASHING_REDIRECT_MINUTES=60
14
+ CASHING_MAX_REQUESTS=100
15
+
16
+ # Redis Connection
17
+ CASHING_REDIS__HOST=localhost
18
+ CASHING_REDIS__PORT=6379
@@ -0,0 +1,71 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cashing2fast-fastapi"
7
+ version = "0.1.0"
8
+ description = "Billing and request limit control for FastAPI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {file = "LICENSE"}
12
+ authors = [
13
+ {name = "Angel Daniel Sanchez Castillo", email = "angeldaniel.sanchezcastillo@gmail.com"}
14
+ ]
15
+ keywords = ["fastapi", "billing", "request-limit", "redis", "oauth2"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Framework :: FastAPI",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ ]
27
+
28
+ dependencies = [
29
+ "oauth2fast-fastapi>=0.3.0",
30
+ "tools2fast-fastapi>=0.1.5",
31
+ "redis>=5.0.0",
32
+ "pydantic-settings>=2.0.0",
33
+ ]
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "black",
38
+ "ruff",
39
+ "mypy>=1.0.0",
40
+ "pre-commit",
41
+ "build",
42
+ "twine",
43
+ ]
44
+ test = [
45
+ "pytest>=7.0.0",
46
+ "pytest-asyncio>=0.21.0",
47
+ "pytest-cov>=4.0.0",
48
+ "httpx>=0.24.0",
49
+ "python-dotenv>=1.0.0",
50
+ ]
51
+
52
+ [tool.setuptools]
53
+ package-dir = {"" = "src"}
54
+
55
+ [tool.setuptools.packages.find]
56
+ where = ["src"]
57
+ include = ["cashing2fast_fastapi*"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ python_files = ["test_*.py"]
62
+ python_classes = ["Test*"]
63
+ python_functions = ["test_*"]
64
+ asyncio_mode = "auto"
65
+ asyncio_default_fixture_loop_scope = "function"
66
+
67
+ [tool.mypy]
68
+ python_version = "3.10"
69
+ warn_return_any = true
70
+ warn_unused_configs = true
71
+ disallow_untyped_defs = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ from .__version__ import __version__
2
+ from .settings import settings
3
+ from .dependencies import require_billing_checks
4
+ from .utils.redis_client import get_redis_client, close_redis
5
+
6
+ __all__ = [
7
+ "__version__",
8
+ "settings",
9
+ "require_billing_checks",
10
+ "get_redis_client",
11
+ "close_redis",
12
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,91 @@
1
+ from typing import Any
2
+ from datetime import datetime, timezone
3
+ from fastapi import Depends, HTTPException, status, Request
4
+ from sqlmodel.ext.asyncio.session import AsyncSession
5
+ from oauth2fast_fastapi.dependencies import get_auth_session, oauth2_dependency
6
+ from oauth2fast_fastapi.utils.token_utils import verify_token
7
+ from tools2fast_fastapi import APIResponse
8
+
9
+ from .settings import settings
10
+ from .services import billing_service
11
+ from .exceptions import PaymentRequiredException
12
+
13
+ async def require_billing_checks(
14
+ request: Request,
15
+ token: str = Depends(oauth2_dependency),
16
+ session: AsyncSession = Depends(get_auth_session)
17
+ ):
18
+ """
19
+ Dependency to check if the user has billing limits.
20
+ """
21
+ # 1. Decode token to get email without hitting User DB yet
22
+ payload = verify_token(token)
23
+ if not payload:
24
+ raise HTTPException(
25
+ status_code=status.HTTP_401_UNAUTHORIZED,
26
+ detail="Token inválido",
27
+ headers={"WWW-Authenticate": "Bearer"},
28
+ )
29
+
30
+ email = payload.get("sub")
31
+ if not email:
32
+ raise HTTPException(
33
+ status_code=status.HTTP_401_UNAUTHORIZED,
34
+ detail="Token sin identificador",
35
+ )
36
+
37
+ # 2. Get User ID and created_at (cached in Redis)
38
+ try:
39
+ user_info = await billing_service.get_user_billing_info(email, session)
40
+ except Exception:
41
+ raise HTTPException(
42
+ status_code=status.HTTP_401_UNAUTHORIZED,
43
+ detail="Usuario no encontrado",
44
+ )
45
+
46
+ user_id = user_info["id"]
47
+ created_at = datetime.fromisoformat(user_info["created_at"])
48
+ if created_at.tzinfo is None:
49
+ created_at = created_at.replace(tzinfo=timezone.utc)
50
+
51
+ # 3. Calculate elapsed minutes
52
+ now = datetime.now(timezone.utc)
53
+ elapsed_minutes = (now - created_at).total_seconds() / 60
54
+
55
+ # 4. Evaluate phases
56
+
57
+ # Phase 1: Unlimited (Free period)
58
+ if elapsed_minutes <= settings.free_minutes:
59
+ return True
60
+
61
+ # Phase 2: Tracked (Redirect period)
62
+ if elapsed_minutes <= (settings.free_minutes + settings.redirect_minutes):
63
+ count = await billing_service.increment_request_count(user_id)
64
+ if count > settings.max_requests:
65
+ # Reseteo el valor en el redis para que la pueda seguir ocupando
66
+ # hasta llegar a ese número nuevamente después de la redirección
67
+ await billing_service.reset_request_count(user_id)
68
+ # Raise exception to stop execution and return 402
69
+ raise PaymentRequiredException(
70
+ message="Límite de peticiones alcanzado. Por favor, realiza un pago."
71
+ )
72
+ return True
73
+
74
+ # Phase 3: Blocked (Expired)
75
+ raise PaymentRequiredException(
76
+ message="Su periodo de uso ha expirado. Por favor, realice un pago para continuar."
77
+ )
78
+
79
+ def register_billing_exception_handler(app: Any):
80
+ """
81
+ Register the global exception handler for PaymentRequiredException.
82
+ """
83
+ from fastapi import Request
84
+ from fastapi.responses import JSONResponse
85
+
86
+ @app.exception_handler(PaymentRequiredException)
87
+ async def billing_exception_handler(request: Request, exc: PaymentRequiredException):
88
+ return APIResponse.payment_required(
89
+ message=exc.message,
90
+ error=exc.error
91
+ )
@@ -0,0 +1,6 @@
1
+ from typing import Any
2
+
3
+ class PaymentRequiredException(Exception):
4
+ def __init__(self, message: str = "Pago Requerido", error: Any = None):
5
+ self.message = message
6
+ self.error = error
@@ -0,0 +1,7 @@
1
+ from .billing_service import get_user_billing_info, increment_request_count, reset_request_count
2
+
3
+ __all__ = [
4
+ "get_user_billing_info",
5
+ "increment_request_count",
6
+ "reset_request_count",
7
+ ]
@@ -0,0 +1,62 @@
1
+ import json
2
+ from datetime import datetime
3
+ from typing import TypedDict, Any
4
+ from sqlmodel import select
5
+ from sqlmodel.ext.asyncio.session import AsyncSession
6
+ from oauth2fast_fastapi import User
7
+ from ..utils.redis_client import get_redis_client
8
+ from ..settings import settings
9
+
10
+ class UserAuthCache(TypedDict):
11
+ id: int
12
+ created_at: str
13
+
14
+ async def get_user_billing_info(email: str, session: AsyncSession) -> UserAuthCache:
15
+ """
16
+ Obtiene id y created_at del usuario, priorizando Redis.
17
+ Si no está en Redis, consulta DB y lo guarda.
18
+ """
19
+ redis = get_redis_client()
20
+ key = f"cashing:user_auth:{email}"
21
+
22
+ cached_data = await redis.get(key)
23
+ if cached_data:
24
+ try:
25
+ return json.loads(cached_data)
26
+ except json.JSONDecodeError:
27
+ pass
28
+
29
+ # Si no hay caché, buscar en DB
30
+ result = await session.exec(select(User).where(User.email == email))
31
+ user = result.one_or_none()
32
+
33
+ if not user:
34
+ raise ValueError(f"User with email {email} not found")
35
+
36
+ info: UserAuthCache = {
37
+ "id": user.id,
38
+ "created_at": user.created_at.isoformat()
39
+ }
40
+
41
+ # Guardar en caché
42
+ await redis.setex(
43
+ key,
44
+ settings.user_cache_ttl,
45
+ json.dumps(info)
46
+ )
47
+
48
+ return info
49
+
50
+ async def increment_request_count(user_id: int) -> int:
51
+ """Incrementa el contador de peticiones del usuario en Redis."""
52
+ redis = get_redis_client()
53
+ key = f"cashing:{user_id}:requests"
54
+
55
+ # Incrementa y devuelve el nuevo valor
56
+ return await redis.incr(key)
57
+
58
+ async def reset_request_count(user_id: int):
59
+ """Resetea el contador de peticiones del usuario en Redis."""
60
+ redis = get_redis_client()
61
+ key = f"cashing:{user_id}:requests"
62
+ await redis.set(key, 0)
@@ -0,0 +1,68 @@
1
+ """
2
+ Cashing2Fast FastAPI Settings
3
+
4
+ Configuration for cashing2fast-fastapi module using pydantic-settings.
5
+ Reads from environment variables with CASHING_ prefix.
6
+ """
7
+
8
+ import os
9
+ from pydantic import BaseModel, SecretStr
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+ # Look for .env in the current working directory (where the app is running)
13
+ DOTENV_PATH = os.path.join(os.getcwd(), ".env")
14
+
15
+ class RedisSettings(BaseModel):
16
+ """Redis configuration for caching billing info."""
17
+ host: str = "localhost"
18
+ port: int = 6379
19
+ db: int = 0
20
+ password: SecretStr | None = None
21
+ decode_responses: bool = True
22
+
23
+ class Settings(BaseSettings):
24
+ """Billing and request limit configuration settings."""
25
+
26
+ model_config = SettingsConfigDict(
27
+ env_file=DOTENV_PATH,
28
+ env_file_encoding="utf-8",
29
+ env_prefix="CASHING_",
30
+ env_nested_delimiter="__",
31
+ extra="ignore",
32
+ )
33
+
34
+ # Redis settings
35
+ redis: RedisSettings = RedisSettings()
36
+
37
+ # Feature flags
38
+ redis_enabled: bool = True
39
+
40
+ # Billing Logic Limits
41
+ # Period where users are never penalized/redirected (in minutes)
42
+ free_minutes: int = 0
43
+ # Period following the free minutes where users are tracked (in minutes)
44
+ redirect_minutes: int = 60
45
+ # Number of allowed requests during the redirect_minutes phase
46
+ max_requests: int = 100
47
+
48
+ # Cache settings
49
+ # Default TTL for cached user info (created_at)
50
+ user_cache_ttl: int = 86400 # 24 hours
51
+
52
+ try:
53
+ settings = Settings()
54
+ except Exception as e:
55
+ # Use log2fast_fastapi for proper error logging if available
56
+ try:
57
+ from log2fast_fastapi import get_logger
58
+ logger = get_logger(__name__)
59
+ logger.exception(
60
+ "🚨 Error loading Cashing2Fast configuration",
61
+ extra_data={
62
+ "error": str(e),
63
+ "dotenv_path": DOTENV_PATH,
64
+ },
65
+ )
66
+ except ImportError:
67
+ print(f"🚨 Error loading Cashing2Fast configuration: {e}")
68
+ raise
@@ -0,0 +1,6 @@
1
+ from .redis_client import get_redis_client, close_redis
2
+
3
+ __all__ = [
4
+ "get_redis_client",
5
+ "close_redis",
6
+ ]
@@ -0,0 +1,25 @@
1
+ from redis.asyncio import Redis
2
+ from ..settings import settings
3
+
4
+ # Global redis pool
5
+ _redis_client: Redis | None = None
6
+
7
+ def get_redis_client() -> Redis:
8
+ """Get or initialize the Redis client from settings."""
9
+ global _redis_client
10
+ if _redis_client is None:
11
+ _redis_client = Redis(
12
+ host=settings.redis.host,
13
+ port=settings.redis.port,
14
+ db=settings.redis.db,
15
+ password=settings.redis.password.get_secret_value() if settings.redis.password else None,
16
+ decode_responses=settings.redis.decode_responses,
17
+ )
18
+ return _redis_client
19
+
20
+ async def close_redis() -> None:
21
+ """Close the Redis connection pool."""
22
+ global _redis_client
23
+ if _redis_client is not None:
24
+ await _redis_client.aclose()
25
+ _redis_client = None
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: cashing2fast-fastapi
3
+ Version: 0.1.0
4
+ Summary: Billing and request limit control for FastAPI
5
+ Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Angel Daniel Sanchez Castillo
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
+
28
+ Keywords: fastapi,billing,request-limit,redis,oauth2
29
+ Classifier: Development Status :: 3 - Alpha
30
+ Classifier: Intended Audience :: Developers
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.10
34
+ Classifier: Programming Language :: Python :: 3.11
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Framework :: FastAPI
37
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
38
+ Requires-Python: >=3.10
39
+ Description-Content-Type: text/markdown
40
+ License-File: LICENSE
41
+ Requires-Dist: oauth2fast-fastapi>=0.3.0
42
+ Requires-Dist: tools2fast-fastapi>=0.1.5
43
+ Requires-Dist: redis>=5.0.0
44
+ Requires-Dist: pydantic-settings>=2.0.0
45
+ Dynamic: license-file
46
+
47
+ # cashing2fast-fastapi
48
+ 🚀 Simple and fast cashing tools for FastAPI with minimal configuration
49
+
50
+ ## Documentación
51
+
52
+ - [Guía de Uso (Usage Guide)](docs/usage.md)
53
+ - [Ejemplos de configuración](examples/.env.examples)
54
+
55
+ ## Instalación
56
+
57
+ ```bash
58
+ uv add cashing2fast-fastapi
59
+ ```
60
+
61
+ ## Características
62
+
63
+ - 🎯 **Lógica de 3 Fases:** Tiempo gratuito, periodo de cobro por peticiones y bloqueo final.
64
+ - ⚡ **Redis Native:** Contadores atómicos y caché de usuario para evitar cargas innecesarias a DB.
65
+ - 🛡️ **Tools2Fast Integration:** Respuestas con formato estándar y código HTTP 402.
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ docs/usage.md
6
+ examples/.env.examples
7
+ src/cashing2fast_fastapi/__init__.py
8
+ src/cashing2fast_fastapi/__version__.py
9
+ src/cashing2fast_fastapi/dependencies.py
10
+ src/cashing2fast_fastapi/exceptions.py
11
+ src/cashing2fast_fastapi/settings.py
12
+ src/cashing2fast_fastapi.egg-info/PKG-INFO
13
+ src/cashing2fast_fastapi.egg-info/SOURCES.txt
14
+ src/cashing2fast_fastapi.egg-info/dependency_links.txt
15
+ src/cashing2fast_fastapi.egg-info/requires.txt
16
+ src/cashing2fast_fastapi.egg-info/top_level.txt
17
+ src/cashing2fast_fastapi/services/__init__.py
18
+ src/cashing2fast_fastapi/services/billing_service.py
19
+ src/cashing2fast_fastapi/utils/__init__.py
20
+ src/cashing2fast_fastapi/utils/redis_client.py
21
+ tests/test_billing.py
@@ -0,0 +1,4 @@
1
+ oauth2fast-fastapi>=0.3.0
2
+ tools2fast-fastapi>=0.1.5
3
+ redis>=5.0.0
4
+ pydantic-settings>=2.0.0
@@ -0,0 +1,104 @@
1
+ import pytest
2
+ from datetime import datetime, timezone, timedelta
3
+ from unittest.mock import AsyncMock, patch
4
+ from fastapi import FastAPI, Depends
5
+ from httpx import AsyncClient, ASGITransport
6
+ from cashing2fast_fastapi import require_billing_checks, settings
7
+ from cashing2fast_fastapi.dependencies import register_billing_exception_handler
8
+
9
+ # Mock app
10
+ app = FastAPI()
11
+ register_billing_exception_handler(app)
12
+
13
+ @app.get("/test")
14
+ async def test_route(dep = Depends(require_billing_checks)):
15
+ return {"message": "success"}
16
+
17
+ @pytest.fixture
18
+ def mock_redis():
19
+ with patch("cashing2fast_fastapi.services.billing_service.get_redis_client") as mock:
20
+ client = AsyncMock()
21
+ mock.return_value = client
22
+ yield client
23
+
24
+ @pytest.fixture
25
+ def mock_verify_token():
26
+ with patch("cashing2fast_fastapi.dependencies.verify_token") as mock:
27
+ yield mock
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_phase_1_free(mock_redis, mock_verify_token):
31
+ """Phase 1: User created recently, should allow without counting."""
32
+ mock_verify_token.return_value = {"sub": "test@example.com"}
33
+
34
+ # Mock user info in Redis (Phase 1: now - 5 mins)
35
+ created_at = datetime.now(timezone.utc) - timedelta(minutes=5)
36
+ mock_redis.get.return_value = '{"id": 1, "created_at": "' + created_at.isoformat() + '"}'
37
+
38
+ # Settings: free=10, redirect=60
39
+ with patch.object(settings, "free_minutes", 10):
40
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
41
+ response = await ac.get("/test", headers={"Authorization": "Bearer token"})
42
+
43
+ assert response.status_code == 200
44
+ assert response.json() == {"message": "success"}
45
+ # Should NOT increment count in phase 1
46
+ mock_redis.incr.assert_not_called()
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_phase_2_tracked_ok(mock_redis, mock_verify_token):
50
+ """Phase 2: User in tracking period, should count and allow if below max."""
51
+ mock_verify_token.return_value = {"sub": "test@example.com"}
52
+
53
+ # Mock user info in Redis (Phase 2: now - 20 mins, where free=10)
54
+ created_at = datetime.now(timezone.utc) - timedelta(minutes=20)
55
+ mock_redis.get.return_value = '{"id": 1, "created_at": "' + created_at.isoformat() + '"}'
56
+ # Current count = 1
57
+ mock_redis.incr.return_value = 1
58
+
59
+ with patch.object(settings, "free_minutes", 10):
60
+ with patch.object(settings, "redirect_minutes", 60):
61
+ with patch.object(settings, "max_requests", 5):
62
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
63
+ response = await ac.get("/test", headers={"Authorization": "Bearer token"})
64
+
65
+ assert response.status_code == 200
66
+ mock_redis.incr.assert_called_once_with("cashing:1:requests")
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_phase_2_tracked_limit_reached(mock_redis, mock_verify_token):
70
+ """Phase 2: User reached limit, should return 402 and reset."""
71
+ mock_verify_token.return_value = {"sub": "test@example.com"}
72
+
73
+ created_at = datetime.now(timezone.utc) - timedelta(minutes=20)
74
+ mock_redis.get.return_value = '{"id": 1, "created_at": "' + created_at.isoformat() + '"}'
75
+ # Current count = 6 (limit is 5)
76
+ mock_redis.incr.return_value = 6
77
+
78
+ with patch.object(settings, "free_minutes", 10):
79
+ with patch.object(settings, "redirect_minutes", 60):
80
+ with patch.object(settings, "max_requests", 5):
81
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
82
+ response = await ac.get("/test", headers={"Authorization": "Bearer token"})
83
+
84
+ assert response.status_code == 402
85
+ assert response.json()["message"] == "Límite de peticiones alcanzado. Por favor, realiza un pago."
86
+ # Should reset count
87
+ mock_redis.set.assert_called_with("cashing:1:requests", 0)
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_phase_3_expired(mock_redis, mock_verify_token):
91
+ """Phase 3: Period expired, should block always."""
92
+ mock_verify_token.return_value = {"sub": "test@example.com"}
93
+
94
+ # Phase 3: now - 100 mins (free=10 + redirect=60 = 70 mins total period)
95
+ created_at = datetime.now(timezone.utc) - timedelta(minutes=100)
96
+ mock_redis.get.return_value = '{"id": 1, "created_at": "' + created_at.isoformat() + '"}'
97
+
98
+ with patch.object(settings, "free_minutes", 10):
99
+ with patch.object(settings, "redirect_minutes", 60):
100
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
101
+ response = await ac.get("/test", headers={"Authorization": "Bearer token"})
102
+
103
+ assert response.status_code == 402
104
+ assert "expirado" in response.json()["message"]