mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-03-01 17:00:48 +01:00
Compare commits
9 Commits
v0.9.0
...
73fae04333
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73fae04333 | ||
|
|
32ed36e102 | ||
|
|
48567310bc | ||
|
|
de51ed4675 | ||
|
|
794767edbb | ||
|
|
9c136f05bb | ||
|
3299a439fe
|
|||
|
|
d5b22a72fd | ||
|
|
c32f2e18be |
26
README.md
26
README.md
@@ -1,6 +1,6 @@
|
||||
# FastAPI Toolsets
|
||||
|
||||
FastAPI Toolsets provides production-ready utilities for FastAPI applications built with async SQLAlchemy and PostgreSQL. It includes generic CRUD operations, a fixture system with dependency resolution, a Django-like CLI, standardized API responses, and structured exception handling with automatic OpenAPI documentation.
|
||||
A modular collection of production-ready utilities for FastAPI. Install only what you need — from async CRUD and database helpers to CLI tooling, Prometheus metrics, and pytest fixtures. Each module is independently installable via optional extras, keeping your dependency footprint minimal.
|
||||
|
||||
[](https://github.com/d3vyce/fastapi-toolsets/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/d3vyce/fastapi-toolsets)
|
||||
@@ -20,20 +20,42 @@ FastAPI Toolsets provides production-ready utilities for FastAPI applications bu
|
||||
|
||||
## Installation
|
||||
|
||||
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging):
|
||||
|
||||
```bash
|
||||
uv add fastapi-toolsets
|
||||
```
|
||||
|
||||
Install only the extras you need:
|
||||
|
||||
```bash
|
||||
uv add "fastapi-toolsets[cli]" # CLI (typer)
|
||||
uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client)
|
||||
uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist)
|
||||
```
|
||||
|
||||
Or install everything:
|
||||
|
||||
```bash
|
||||
uv add "fastapi-toolsets[all]"
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Core
|
||||
|
||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal
|
||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||
|
||||
### Optional
|
||||
|
||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -31,12 +31,10 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"asyncpg>=0.29.0",
|
||||
"fastapi>=0.100.0",
|
||||
"pydantic>=2.0",
|
||||
"typer>=0.9.0",
|
||||
"httpx>=0.25.0",
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -46,15 +44,28 @@ Repository = "https://github.com/d3vyce/fastapi-toolsets"
|
||||
Issues = "https://github.com/d3vyce/fastapi-toolsets/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-anyio>=0.0.0",
|
||||
cli = [
|
||||
"typer>=0.9.0",
|
||||
]
|
||||
metrics = [
|
||||
"prometheus_client>=0.20.0",
|
||||
]
|
||||
pytest = [
|
||||
"httpx>=0.25.0",
|
||||
"pytest-xdist>=3.0.0",
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
all = [
|
||||
"fastapi-toolsets[cli,metrics,pytest]",
|
||||
]
|
||||
test = [
|
||||
"coverage>=7.0.0",
|
||||
"fastapi-toolsets[pytest]",
|
||||
"pytest-anyio>=0.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
]
|
||||
dev = [
|
||||
"fastapi-toolsets[test]",
|
||||
"fastapi-toolsets[all,test]",
|
||||
"ruff>=0.1.0",
|
||||
"ty>=0.0.1a0",
|
||||
]
|
||||
|
||||
@@ -21,4 +21,4 @@ Example usage:
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "0.9.0"
|
||||
__version__ = "0.10.0"
|
||||
|
||||
9
src/fastapi_toolsets/_imports.py
Normal file
9
src/fastapi_toolsets/_imports.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Optional dependency helpers."""
|
||||
|
||||
|
||||
def require_extra(package: str, extra: str) -> None:
|
||||
"""Raise *ImportError* with an actionable install instruction."""
|
||||
raise ImportError(
|
||||
f"'{package}' is required to use this module. "
|
||||
f"Install it with: pip install fastapi-toolsets[{extra}]"
|
||||
)
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Main CLI application."""
|
||||
|
||||
import typer
|
||||
try:
|
||||
import typer
|
||||
except ImportError:
|
||||
from .._imports import require_extra
|
||||
|
||||
require_extra(package="typer", extra="cli")
|
||||
|
||||
from ..logger import configure_logging
|
||||
from .config import get_custom_cli
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||
|
||||
from ..exceptions import NoSearchableFieldsError
|
||||
from .factory import CrudFactory
|
||||
from .factory import CrudFactory, JoinType, M2MFieldType
|
||||
from .search import (
|
||||
SearchConfig,
|
||||
get_searchable_fields,
|
||||
@@ -10,6 +10,8 @@ from .search import (
|
||||
__all__ = [
|
||||
"CrudFactory",
|
||||
"get_searchable_fields",
|
||||
"JoinType",
|
||||
"M2MFieldType",
|
||||
"NoSearchableFieldsError",
|
||||
"SearchConfig",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, cast, overload
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy import delete as sql_delete
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||
from sqlalchemy.sql.roles import WhereHavingRole
|
||||
|
||||
from ..db import get_transaction
|
||||
@@ -21,6 +21,7 @@ from .search import SearchConfig, SearchFieldType, build_search_filters
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||
|
||||
|
||||
class AsyncCrud(Generic[ModelType]):
|
||||
@@ -31,6 +32,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
|
||||
model: ClassVar[type[DeclarativeBase]]
|
||||
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
||||
m2m_fields: ClassVar[M2MFieldType | None] = None
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
@@ -52,6 +54,62 @@ class AsyncCrud(Generic[ModelType]):
|
||||
as_response: Literal[False] = ...,
|
||||
) -> ModelType: ...
|
||||
|
||||
@classmethod
|
||||
async def _resolve_m2m(
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
obj: BaseModel,
|
||||
*,
|
||||
only_set: bool = False,
|
||||
) -> dict[str, list[Any]]:
|
||||
"""Resolve M2M fields from a Pydantic schema into related model instances.
|
||||
|
||||
Args:
|
||||
session: DB async session
|
||||
obj: Pydantic model containing M2M ID fields
|
||||
only_set: If True, only process fields explicitly set on the schema
|
||||
|
||||
Returns:
|
||||
Dict mapping relationship attr names to lists of related instances
|
||||
"""
|
||||
result: dict[str, list[Any]] = {}
|
||||
if not cls.m2m_fields:
|
||||
return result
|
||||
|
||||
for schema_field, rel in cls.m2m_fields.items():
|
||||
rel_attr = rel.property.key
|
||||
related_model = rel.property.mapper.class_
|
||||
if only_set and schema_field not in obj.model_fields_set:
|
||||
continue
|
||||
ids = getattr(obj, schema_field, None)
|
||||
if ids is not None:
|
||||
related = (
|
||||
(
|
||||
await session.execute(
|
||||
select(related_model).where(related_model.id.in_(ids))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if len(related) != len(ids):
|
||||
found_ids = {r.id for r in related}
|
||||
missing = set(ids) - found_ids
|
||||
raise NotFoundError(
|
||||
f"Related {related_model.__name__} not found for IDs: {missing}"
|
||||
)
|
||||
result[rel_attr] = list(related)
|
||||
else:
|
||||
result[rel_attr] = []
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _m2m_schema_fields(cls: type[Self]) -> set[str]:
|
||||
"""Return the set of schema field names that are M2M fields."""
|
||||
if not cls.m2m_fields:
|
||||
return set()
|
||||
return set(cls.m2m_fields.keys())
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls: type[Self],
|
||||
@@ -71,7 +129,17 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Created model instance or Response wrapping it
|
||||
"""
|
||||
async with get_transaction(session):
|
||||
db_model = cls.model(**obj.model_dump())
|
||||
m2m_exclude = cls._m2m_schema_fields()
|
||||
data = (
|
||||
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
|
||||
)
|
||||
db_model = cls.model(**data)
|
||||
|
||||
if m2m_exclude:
|
||||
m2m_resolved = await cls._resolve_m2m(session, obj)
|
||||
for rel_attr, related_instances in m2m_resolved.items():
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
|
||||
session.add(db_model)
|
||||
await session.refresh(db_model)
|
||||
result = cast(ModelType, db_model)
|
||||
@@ -299,12 +367,33 @@ class AsyncCrud(Generic[ModelType]):
|
||||
NotFoundError: If no record found
|
||||
"""
|
||||
async with get_transaction(session):
|
||||
db_model = await cls.get(session=session, filters=filters)
|
||||
m2m_exclude = cls._m2m_schema_fields()
|
||||
|
||||
# Eagerly load M2M relationships that will be updated so that
|
||||
# setattr does not trigger a lazy load (which fails in async).
|
||||
m2m_load_options: list[Any] = []
|
||||
if m2m_exclude and cls.m2m_fields:
|
||||
for schema_field, rel in cls.m2m_fields.items():
|
||||
if schema_field in obj.model_fields_set:
|
||||
m2m_load_options.append(selectinload(rel))
|
||||
|
||||
db_model = await cls.get(
|
||||
session=session,
|
||||
filters=filters,
|
||||
load_options=m2m_load_options or None,
|
||||
)
|
||||
values = obj.model_dump(
|
||||
exclude_unset=exclude_unset, exclude_none=exclude_none
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_none=exclude_none,
|
||||
exclude=m2m_exclude,
|
||||
)
|
||||
for key, value in values.items():
|
||||
setattr(db_model, key, value)
|
||||
|
||||
if m2m_exclude:
|
||||
m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
|
||||
for rel_attr, related_instances in m2m_resolved.items():
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
await session.refresh(db_model)
|
||||
if as_response:
|
||||
return Response(data=db_model)
|
||||
@@ -578,12 +667,16 @@ def CrudFactory(
|
||||
model: type[ModelType],
|
||||
*,
|
||||
searchable_fields: Sequence[SearchFieldType] | None = None,
|
||||
m2m_fields: M2MFieldType | None = None,
|
||||
) -> type[AsyncCrud[ModelType]]:
|
||||
"""Create a CRUD class for a specific model.
|
||||
|
||||
Args:
|
||||
model: SQLAlchemy model class
|
||||
searchable_fields: Optional list of searchable fields
|
||||
m2m_fields: Optional mapping for many-to-many relationships.
|
||||
Maps schema field names (containing lists of IDs) to
|
||||
SQLAlchemy relationship attributes.
|
||||
|
||||
Returns:
|
||||
AsyncCrud subclass bound to the model
|
||||
@@ -601,10 +694,20 @@ def CrudFactory(
|
||||
searchable_fields=[User.username, User.email, (User.role, Role.name)]
|
||||
)
|
||||
|
||||
# With many-to-many fields:
|
||||
# Schema has `tag_ids: list[UUID]`, model has `tags` relationship to Tag
|
||||
PostCrud = CrudFactory(
|
||||
Post,
|
||||
m2m_fields={"tag_ids": Post.tags},
|
||||
)
|
||||
|
||||
# Usage
|
||||
user = await UserCrud.get(session, [User.id == 1])
|
||||
posts = await PostCrud.get_multi(session, filters=[Post.user_id == user.id])
|
||||
|
||||
# Create with M2M - tag_ids are automatically resolved
|
||||
post = await PostCrud.create(session, PostCreate(title="Hello", tag_ids=[id1, id2]))
|
||||
|
||||
# With search
|
||||
result = await UserCrud.paginate(session, search="john")
|
||||
|
||||
@@ -628,6 +731,7 @@ def CrudFactory(
|
||||
{
|
||||
"model": model,
|
||||
"searchable_fields": searchable_fields,
|
||||
"m2m_fields": m2m_fields,
|
||||
},
|
||||
)
|
||||
return cast(type[AsyncCrud[ModelType]], cls)
|
||||
|
||||
@@ -8,7 +8,9 @@ from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..crud import CrudFactory
|
||||
from .crud import CrudFactory
|
||||
|
||||
__all__ = ["BodyDependency", "PathDependency"]
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""FastAPI dependency factories for database objects."""
|
||||
|
||||
from .factory import BodyDependency, PathDependency
|
||||
|
||||
__all__ = ["BodyDependency", "PathDependency"]
|
||||
@@ -76,55 +76,6 @@ class ConflictError(ApiException):
|
||||
)
|
||||
|
||||
|
||||
class InsufficientRolesError(ForbiddenError):
|
||||
"""User does not have the required roles."""
|
||||
|
||||
api_error = ApiError(
|
||||
code=403,
|
||||
msg="Insufficient Roles",
|
||||
desc="You do not have the required roles to access this resource.",
|
||||
err_code="RBAC-403",
|
||||
)
|
||||
|
||||
def __init__(self, required_roles: list[str], user_roles: set[str] | None = None):
|
||||
"""Initialize the exception.
|
||||
|
||||
Args:
|
||||
required_roles: Roles needed to access the resource
|
||||
user_roles: Roles the current user has, if known
|
||||
"""
|
||||
self.required_roles = required_roles
|
||||
self.user_roles = user_roles
|
||||
|
||||
desc = f"Required roles: {', '.join(required_roles)}"
|
||||
if user_roles is not None:
|
||||
desc += f". User has: {', '.join(user_roles) if user_roles else 'no roles'}"
|
||||
|
||||
super().__init__(desc)
|
||||
|
||||
|
||||
class UserNotFoundError(NotFoundError):
|
||||
"""User was not found."""
|
||||
|
||||
api_error = ApiError(
|
||||
code=404,
|
||||
msg="User Not Found",
|
||||
desc="The requested user was not found.",
|
||||
err_code="USER-404",
|
||||
)
|
||||
|
||||
|
||||
class RoleNotFoundError(NotFoundError):
|
||||
"""Role was not found."""
|
||||
|
||||
api_error = ApiError(
|
||||
code=404,
|
||||
msg="Role Not Found",
|
||||
desc="The requested role was not found.",
|
||||
err_code="ROLE-404",
|
||||
)
|
||||
|
||||
|
||||
class NoSearchableFieldsError(ApiException):
|
||||
"""Raised when search is requested but no searchable fields are available."""
|
||||
|
||||
|
||||
21
src/fastapi_toolsets/metrics/__init__.py
Normal file
21
src/fastapi_toolsets/metrics/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Prometheus metrics integration for FastAPI applications."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .registry import Metric, MetricsRegistry
|
||||
|
||||
try:
|
||||
from .handler import init_metrics
|
||||
except ImportError:
|
||||
|
||||
def init_metrics(*_args: Any, **_kwargs: Any) -> None:
|
||||
from .._imports import require_extra
|
||||
|
||||
require_extra(package="prometheus_client", extra="metrics")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Metric",
|
||||
"MetricsRegistry",
|
||||
"init_metrics",
|
||||
]
|
||||
73
src/fastapi_toolsets/metrics/handler.py
Normal file
73
src/fastapi_toolsets/metrics/handler.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Prometheus metrics endpoint for FastAPI applications."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import Response
|
||||
from prometheus_client import (
|
||||
CONTENT_TYPE_LATEST,
|
||||
CollectorRegistry,
|
||||
generate_latest,
|
||||
multiprocess,
|
||||
)
|
||||
|
||||
from ..logger import get_logger
|
||||
from .registry import MetricsRegistry
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def _is_multiprocess() -> bool:
|
||||
"""Check if prometheus multi-process mode is enabled."""
|
||||
return "PROMETHEUS_MULTIPROC_DIR" in os.environ
|
||||
|
||||
|
||||
def init_metrics(
|
||||
app: FastAPI,
|
||||
registry: MetricsRegistry,
|
||||
*,
|
||||
path: str = "/metrics",
|
||||
) -> FastAPI:
|
||||
"""Register a Prometheus ``/metrics`` endpoint on a FastAPI app.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance.
|
||||
registry: A :class:`MetricsRegistry` containing providers and collectors.
|
||||
path: URL path for the metrics endpoint (default ``/metrics``).
|
||||
|
||||
Returns:
|
||||
The same FastAPI instance (for chaining).
|
||||
|
||||
Example:
|
||||
from fastapi import FastAPI
|
||||
from fastapi_toolsets.metrics import MetricsRegistry, init_metrics
|
||||
|
||||
metrics = MetricsRegistry()
|
||||
app = FastAPI()
|
||||
init_metrics(app, registry=metrics)
|
||||
"""
|
||||
for provider in registry.get_providers():
|
||||
logger.debug("Initialising metric provider '%s'", provider.name)
|
||||
provider.func()
|
||||
|
||||
collectors = registry.get_collectors()
|
||||
|
||||
@app.get(path, include_in_schema=False)
|
||||
async def metrics_endpoint() -> Response:
|
||||
for collector in collectors:
|
||||
if asyncio.iscoroutinefunction(collector.func):
|
||||
await collector.func()
|
||||
else:
|
||||
collector.func()
|
||||
|
||||
if _is_multiprocess():
|
||||
prom_registry = CollectorRegistry()
|
||||
multiprocess.MultiProcessCollector(prom_registry)
|
||||
output = generate_latest(prom_registry)
|
||||
else:
|
||||
output = generate_latest()
|
||||
|
||||
return Response(content=output, media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
return app
|
||||
122
src/fastapi_toolsets/metrics/registry.py
Normal file
122
src/fastapi_toolsets/metrics/registry.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Metrics registry with decorator-based registration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
from ..logger import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metric:
|
||||
"""A metric definition with metadata."""
|
||||
|
||||
name: str
|
||||
func: Callable[..., Any]
|
||||
collect: bool = field(default=False)
|
||||
|
||||
|
||||
class MetricsRegistry:
|
||||
"""Registry for managing Prometheus metric providers and collectors.
|
||||
|
||||
Example:
|
||||
from prometheus_client import Counter, Gauge
|
||||
from fastapi_toolsets.metrics import MetricsRegistry
|
||||
|
||||
metrics = MetricsRegistry()
|
||||
|
||||
@metrics.register
|
||||
def http_requests():
|
||||
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
|
||||
|
||||
@metrics.register(name="db_pool")
|
||||
def database_pool_size():
|
||||
return Gauge("db_pool_size", "Database connection pool size")
|
||||
|
||||
@metrics.register(collect=True)
|
||||
def collect_queue_depth(gauge=Gauge("queue_depth", "Current queue depth")):
|
||||
gauge.set(get_current_queue_depth())
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._metrics: dict[str, Metric] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
func: Callable[..., Any] | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
collect: bool = False,
|
||||
) -> Callable[..., Any]:
|
||||
"""Register a metric provider or collector function.
|
||||
|
||||
Can be used as a decorator with or without arguments.
|
||||
|
||||
Args:
|
||||
func: The metric function to register.
|
||||
name: Metric name (defaults to function name).
|
||||
collect: If ``True``, the function is called on every scrape.
|
||||
If ``False`` (default), called once at init time.
|
||||
|
||||
Example:
|
||||
@metrics.register
|
||||
def my_counter():
|
||||
return Counter("my_counter", "A counter")
|
||||
|
||||
@metrics.register(collect=True, name="queue")
|
||||
def collect_queue_depth():
|
||||
gauge.set(compute_depth())
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
metric_name = name or cast(Any, fn).__name__
|
||||
self._metrics[metric_name] = Metric(
|
||||
name=metric_name,
|
||||
func=fn,
|
||||
collect=collect,
|
||||
)
|
||||
return fn
|
||||
|
||||
if func is not None:
|
||||
return decorator(func)
|
||||
return decorator
|
||||
|
||||
def include_registry(self, registry: "MetricsRegistry") -> None:
|
||||
"""Include another :class:`MetricsRegistry` into this one.
|
||||
|
||||
Args:
|
||||
registry: The registry to merge in.
|
||||
|
||||
Raises:
|
||||
ValueError: If a metric name already exists in the current registry.
|
||||
|
||||
Example:
|
||||
main = MetricsRegistry()
|
||||
sub = MetricsRegistry()
|
||||
|
||||
@sub.register
|
||||
def sub_metric():
|
||||
return Counter("sub_total", "Sub counter")
|
||||
|
||||
main.include_registry(sub)
|
||||
"""
|
||||
for metric_name, definition in registry._metrics.items():
|
||||
if metric_name in self._metrics:
|
||||
raise ValueError(
|
||||
f"Metric '{metric_name}' already exists in the current registry"
|
||||
)
|
||||
self._metrics[metric_name] = definition
|
||||
|
||||
def get_all(self) -> list[Metric]:
|
||||
"""Get all registered metric definitions."""
|
||||
return list(self._metrics.values())
|
||||
|
||||
def get_providers(self) -> list[Metric]:
|
||||
"""Get metric providers (called once at init)."""
|
||||
return [m for m in self._metrics.values() if not m.collect]
|
||||
|
||||
def get_collectors(self) -> list[Metric]:
|
||||
"""Get collectors (called on each scrape)."""
|
||||
return [m for m in self._metrics.values() if m.collect]
|
||||
@@ -1,13 +1,24 @@
|
||||
"""Pytest helpers for FastAPI testing: sessions, clients, and fixtures."""
|
||||
|
||||
from .plugin import register_fixtures
|
||||
from .utils import (
|
||||
cleanup_tables,
|
||||
create_async_client,
|
||||
create_db_session,
|
||||
create_worker_database,
|
||||
worker_database_url,
|
||||
)
|
||||
try:
|
||||
from .plugin import register_fixtures
|
||||
except ImportError:
|
||||
from .._imports import require_extra
|
||||
|
||||
require_extra(package="pytest", extra="pytest")
|
||||
|
||||
try:
|
||||
from .utils import (
|
||||
cleanup_tables,
|
||||
create_async_client,
|
||||
create_db_session,
|
||||
create_worker_database,
|
||||
worker_database_url,
|
||||
)
|
||||
except ImportError:
|
||||
from .._imports import require_extra
|
||||
|
||||
require_extra(package="httpx", extra="pytest")
|
||||
|
||||
__all__ = [
|
||||
"cleanup_tables",
|
||||
|
||||
@@ -1,55 +1,4 @@
|
||||
"""Pytest plugin for using FixtureRegistry fixtures in tests.
|
||||
|
||||
This module provides utilities to automatically generate pytest fixtures
|
||||
from your FixtureRegistry, with proper dependency resolution.
|
||||
|
||||
Example:
|
||||
# conftest.py
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.fixtures import fixtures # Your FixtureRegistry
|
||||
from app.models import Base
|
||||
from fastapi_toolsets.pytest_plugin import register_fixtures
|
||||
|
||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db"
|
||||
|
||||
@pytest.fixture
|
||||
async def engine():
|
||||
engine = create_async_engine(DATABASE_URL)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(engine):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = session_factory()
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
# Automatically generate pytest fixtures from registry
|
||||
# Creates: fixture_roles, fixture_users, fixture_posts, etc.
|
||||
register_fixtures(fixtures, globals())
|
||||
|
||||
Usage in tests:
|
||||
# test_users.py
|
||||
async def test_user_count(db_session, fixture_users):
|
||||
# fixture_users automatically loads fixture_roles first (if dependency)
|
||||
# and returns the list of User models
|
||||
assert len(fixture_users) > 0
|
||||
|
||||
async def test_user_role(db_session, fixture_users):
|
||||
user = fixture_users[0]
|
||||
assert user.role_id is not None
|
||||
"""
|
||||
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
@@ -5,24 +5,18 @@ import uuid
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import ForeignKey, String, Uuid
|
||||
from sqlalchemy import Column, ForeignKey, String, Table, Uuid
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
from fastapi_toolsets.crud import CrudFactory
|
||||
|
||||
# PostgreSQL connection URL from environment or default for local development
|
||||
DATABASE_URL = os.getenv("DATABASE_URL") or os.getenv(
|
||||
"TEST_DATABASE_URL",
|
||||
"postgresql+asyncpg://postgres:postgres@localhost:5432/fastapi_toolsets_test",
|
||||
DATABASE_URL = os.getenv(
|
||||
key="DATABASE_URL",
|
||||
default="postgresql+asyncpg://postgres:postgres@localhost:5432/postgres",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for test models."""
|
||||
|
||||
@@ -56,6 +50,25 @@ class User(Base):
|
||||
role: Mapped[Role | None] = relationship(back_populates="users")
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
"""Test tag model."""
|
||||
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(50), unique=True)
|
||||
|
||||
|
||||
post_tags = Table(
|
||||
"post_tags",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"post_id", Uuid, ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
Column("tag_id", Uuid, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Post(Base):
|
||||
"""Test post model."""
|
||||
|
||||
@@ -67,10 +80,7 @@ class Post(Base):
|
||||
is_published: Mapped[bool] = mapped_column(default=False)
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Schemas
|
||||
# =============================================================================
|
||||
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
@@ -105,6 +115,13 @@ class UserUpdate(BaseModel):
|
||||
role_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
"""Schema for creating a tag."""
|
||||
|
||||
id: uuid.UUID | None = None
|
||||
name: str
|
||||
|
||||
|
||||
class PostCreate(BaseModel):
|
||||
"""Schema for creating a post."""
|
||||
|
||||
@@ -123,18 +140,31 @@ class PostUpdate(BaseModel):
|
||||
is_published: bool | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CRUD Classes
|
||||
# =============================================================================
|
||||
class PostM2MCreate(BaseModel):
|
||||
"""Schema for creating a post with M2M tag IDs."""
|
||||
|
||||
id: uuid.UUID | None = None
|
||||
title: str
|
||||
content: str = ""
|
||||
is_published: bool = False
|
||||
author_id: uuid.UUID
|
||||
tag_ids: list[uuid.UUID] = []
|
||||
|
||||
|
||||
class PostM2MUpdate(BaseModel):
|
||||
"""Schema for updating a post with M2M tag IDs."""
|
||||
|
||||
title: str | None = None
|
||||
content: str | None = None
|
||||
is_published: bool | None = None
|
||||
tag_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
RoleCrud = CrudFactory(Role)
|
||||
UserCrud = CrudFactory(User)
|
||||
PostCrud = CrudFactory(Post)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
TagCrud = CrudFactory(Tag)
|
||||
PostM2MCrud = CrudFactory(Post, m2m_fields={"tag_ids": Post.tags})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from fastapi_toolsets.crud import CrudFactory
|
||||
from fastapi_toolsets.crud.factory import AsyncCrud
|
||||
@@ -13,10 +14,15 @@ from .conftest import (
|
||||
Post,
|
||||
PostCreate,
|
||||
PostCrud,
|
||||
PostM2MCreate,
|
||||
PostM2MCrud,
|
||||
PostM2MUpdate,
|
||||
Role,
|
||||
RoleCreate,
|
||||
RoleCrud,
|
||||
RoleUpdate,
|
||||
TagCreate,
|
||||
TagCrud,
|
||||
User,
|
||||
UserCreate,
|
||||
UserCrud,
|
||||
@@ -812,3 +818,383 @@ class TestAsResponse:
|
||||
|
||||
assert isinstance(result, Response)
|
||||
assert result.data is None
|
||||
|
||||
|
||||
class TestCrudFactoryM2M:
|
||||
"""Tests for CrudFactory with m2m_fields parameter."""
|
||||
|
||||
def test_creates_crud_with_m2m_fields(self):
|
||||
"""CrudFactory configures m2m_fields on the class."""
|
||||
crud = CrudFactory(Post, m2m_fields={"tag_ids": Post.tags})
|
||||
assert crud.m2m_fields is not None
|
||||
assert "tag_ids" in crud.m2m_fields
|
||||
|
||||
def test_creates_crud_without_m2m_fields(self):
|
||||
"""CrudFactory without m2m_fields has None."""
|
||||
crud = CrudFactory(Post)
|
||||
assert crud.m2m_fields is None
|
||||
|
||||
def test_m2m_schema_fields(self):
|
||||
"""_m2m_schema_fields returns correct field names."""
|
||||
crud = CrudFactory(Post, m2m_fields={"tag_ids": Post.tags})
|
||||
assert crud._m2m_schema_fields() == {"tag_ids"}
|
||||
|
||||
def test_m2m_schema_fields_empty_when_none(self):
|
||||
"""_m2m_schema_fields returns empty set when no m2m_fields."""
|
||||
crud = CrudFactory(Post)
|
||||
assert crud._m2m_schema_fields() == set()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_m2m_returns_empty_without_m2m_fields(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""_resolve_m2m returns empty dict when m2m_fields is not configured."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class DummySchema(BaseModel):
|
||||
name: str
|
||||
|
||||
result = await PostCrud._resolve_m2m(db_session, DummySchema(name="test"))
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestM2MResolveNone:
|
||||
"""Tests for _resolve_m2m when IDs field is None."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_m2m_with_none_ids(self, db_session: AsyncSession):
|
||||
"""_resolve_m2m sets empty list when ids value is None."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SchemaWithNullableTags(BaseModel):
|
||||
tag_ids: list[uuid.UUID] | None = None
|
||||
|
||||
result = await PostM2MCrud._resolve_m2m(
|
||||
db_session, SchemaWithNullableTags(tag_ids=None)
|
||||
)
|
||||
assert result == {"tags": []}
|
||||
|
||||
|
||||
class TestM2MCreate:
|
||||
"""Tests for create with M2M relationships."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_with_m2m_tags(self, db_session: AsyncSession):
|
||||
"""Create a post with M2M tags resolves tag IDs."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
tag1 = await TagCrud.create(db_session, TagCreate(name="python"))
|
||||
tag2 = await TagCrud.create(db_session, TagCreate(name="fastapi"))
|
||||
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="M2M Post",
|
||||
author_id=user.id,
|
||||
tag_ids=[tag1.id, tag2.id],
|
||||
),
|
||||
)
|
||||
|
||||
assert post.id is not None
|
||||
assert post.title == "M2M Post"
|
||||
|
||||
# Reload with tags eagerly loaded
|
||||
loaded = await PostM2MCrud.get(
|
||||
db_session,
|
||||
[Post.id == post.id],
|
||||
load_options=[selectinload(Post.tags)],
|
||||
)
|
||||
tag_names = sorted(t.name for t in loaded.tags)
|
||||
assert tag_names == ["fastapi", "python"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_with_empty_m2m(self, db_session: AsyncSession):
|
||||
"""Create a post with empty tag_ids list works."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="No Tags Post",
|
||||
author_id=user.id,
|
||||
tag_ids=[],
|
||||
),
|
||||
)
|
||||
|
||||
assert post.id is not None
|
||||
|
||||
loaded = await PostM2MCrud.get(
|
||||
db_session,
|
||||
[Post.id == post.id],
|
||||
load_options=[selectinload(Post.tags)],
|
||||
)
|
||||
assert loaded.tags == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_with_default_m2m(self, db_session: AsyncSession):
|
||||
"""Create a post using default tag_ids (empty list) works."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(title="Default Tags", author_id=user.id),
|
||||
)
|
||||
|
||||
loaded = await PostM2MCrud.get(
|
||||
db_session,
|
||||
[Post.id == post.id],
|
||||
load_options=[selectinload(Post.tags)],
|
||||
)
|
||||
assert loaded.tags == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_with_nonexistent_tag_id_raises(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Create with a nonexistent tag ID raises NotFoundError."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
tag = await TagCrud.create(db_session, TagCreate(name="valid"))
|
||||
fake_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="Bad Tags",
|
||||
author_id=user.id,
|
||||
tag_ids=[tag.id, fake_id],
|
||||
),
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_with_single_tag(self, db_session: AsyncSession):
|
||||
"""Create with a single tag works correctly."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
tag = await TagCrud.create(db_session, TagCreate(name="solo"))
|
||||
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="Single Tag",
|
||||
author_id=user.id,
|
||||
tag_ids=[tag.id],
|
||||
),
|
||||
)
|
||||
|
||||
loaded = await PostM2MCrud.get(
|
||||
db_session,
|
||||
[Post.id == post.id],
|
||||
load_options=[selectinload(Post.tags)],
|
||||
)
|
||||
assert len(loaded.tags) == 1
|
||||
assert loaded.tags[0].name == "solo"
|
||||
|
||||
|
||||
class TestM2MUpdate:
|
||||
"""Tests for update with M2M relationships."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_m2m_tags(self, db_session: AsyncSession):
|
||||
"""Update replaces M2M tags when tag_ids is set."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
tag1 = await TagCrud.create(db_session, TagCreate(name="old_tag"))
|
||||
tag2 = await TagCrud.create(db_session, TagCreate(name="new_tag"))
|
||||
|
||||
# Create with tag1
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="Update Test",
|
||||
author_id=user.id,
|
||||
tag_ids=[tag1.id],
|
||||
),
|
||||
)
|
||||
|
||||
# Update to tag2
|
||||
updated = await PostM2MCrud.update(
|
||||
db_session,
|
||||
PostM2MUpdate(tag_ids=[tag2.id]),
|
||||
[Post.id == post.id],
|
||||
)
|
||||
|
||||
loaded = await PostM2MCrud.get(
|
||||
db_session,
|
||||
[Post.id == updated.id],
|
||||
load_options=[selectinload(Post.tags)],
|
||||
)
|
||||
assert len(loaded.tags) == 1
|
||||
assert loaded.tags[0].name == "new_tag"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_without_m2m_preserves_tags(self, db_session: AsyncSession):
|
||||
"""Update without setting tag_ids preserves existing tags."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
tag = await TagCrud.create(db_session, TagCreate(name="keep_me"))
|
||||
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="Keep Tags",
|
||||
author_id=user.id,
|
||||
tag_ids=[tag.id],
|
||||
),
|
||||
)
|
||||
|
||||
# Update only title, tag_ids not set
|
||||
await PostM2MCrud.update(
|
||||
db_session,
|
||||
PostM2MUpdate(title="Updated Title"),
|
||||
[Post.id == post.id],
|
||||
)
|
||||
|
||||
loaded = await PostM2MCrud.get(
|
||||
db_session,
|
||||
[Post.id == post.id],
|
||||
load_options=[selectinload(Post.tags)],
|
||||
)
|
||||
assert loaded.title == "Updated Title"
|
||||
assert len(loaded.tags) == 1
|
||||
assert loaded.tags[0].name == "keep_me"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_clear_m2m_tags(self, db_session: AsyncSession):
|
||||
"""Update with empty tag_ids clears all tags."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
tag = await TagCrud.create(db_session, TagCreate(name="remove_me"))
|
||||
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="Clear Tags",
|
||||
author_id=user.id,
|
||||
tag_ids=[tag.id],
|
||||
),
|
||||
)
|
||||
|
||||
# Explicitly set tag_ids to empty list
|
||||
await PostM2MCrud.update(
|
||||
db_session,
|
||||
PostM2MUpdate(tag_ids=[]),
|
||||
[Post.id == post.id],
|
||||
)
|
||||
|
||||
loaded = await PostM2MCrud.get(
|
||||
db_session,
|
||||
[Post.id == post.id],
|
||||
load_options=[selectinload(Post.tags)],
|
||||
)
|
||||
assert loaded.tags == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_m2m_with_nonexistent_id_raises(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Update with nonexistent tag ID raises NotFoundError."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
tag = await TagCrud.create(db_session, TagCreate(name="existing"))
|
||||
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="Bad Update",
|
||||
author_id=user.id,
|
||||
tag_ids=[tag.id],
|
||||
),
|
||||
)
|
||||
|
||||
fake_id = uuid.uuid4()
|
||||
with pytest.raises(NotFoundError):
|
||||
await PostM2MCrud.update(
|
||||
db_session,
|
||||
PostM2MUpdate(tag_ids=[fake_id]),
|
||||
[Post.id == post.id],
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_m2m_and_scalar_fields(self, db_session: AsyncSession):
|
||||
"""Update both scalar fields and M2M tags together."""
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
tag1 = await TagCrud.create(db_session, TagCreate(name="tag1"))
|
||||
tag2 = await TagCrud.create(db_session, TagCreate(name="tag2"))
|
||||
|
||||
post = await PostM2MCrud.create(
|
||||
db_session,
|
||||
PostM2MCreate(
|
||||
title="Original",
|
||||
author_id=user.id,
|
||||
tag_ids=[tag1.id],
|
||||
),
|
||||
)
|
||||
|
||||
# Update title and tags simultaneously
|
||||
await PostM2MCrud.update(
|
||||
db_session,
|
||||
PostM2MUpdate(title="Updated", tag_ids=[tag1.id, tag2.id]),
|
||||
[Post.id == post.id],
|
||||
)
|
||||
|
||||
loaded = await PostM2MCrud.get(
|
||||
db_session,
|
||||
[Post.id == post.id],
|
||||
load_options=[selectinload(Post.tags)],
|
||||
)
|
||||
assert loaded.title == "Updated"
|
||||
tag_names = sorted(t.name for t in loaded.tags)
|
||||
assert tag_names == ["tag1", "tag2"]
|
||||
|
||||
|
||||
class TestM2MWithNonM2MCrud:
|
||||
"""Tests that non-M2M CRUD classes are unaffected."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_without_m2m_unchanged(self, db_session: AsyncSession):
|
||||
"""Regular PostCrud.create still works without M2M logic."""
|
||||
from .conftest import PostCreate
|
||||
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
post = await PostCrud.create(
|
||||
db_session,
|
||||
PostCreate(title="Plain Post", author_id=user.id),
|
||||
)
|
||||
assert post.id is not None
|
||||
assert post.title == "Plain Post"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_without_m2m_unchanged(self, db_session: AsyncSession):
|
||||
"""Regular PostCrud.update still works without M2M logic."""
|
||||
from .conftest import PostCreate, PostUpdate
|
||||
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="author", email="author@test.com")
|
||||
)
|
||||
post = await PostCrud.create(
|
||||
db_session,
|
||||
PostCreate(title="Plain Post", author_id=user.id),
|
||||
)
|
||||
updated = await PostCrud.update(
|
||||
db_session,
|
||||
PostUpdate(title="Updated Plain"),
|
||||
[Post.id == post.id],
|
||||
)
|
||||
assert updated.title == "Updated Plain"
|
||||
|
||||
229
tests/test_imports.py
Normal file
229
tests/test_imports.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Tests for optional dependency import guards."""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi_toolsets._imports import require_extra
|
||||
|
||||
|
||||
class TestRequireExtra:
|
||||
"""Tests for the require_extra helper."""
|
||||
|
||||
def test_raises_import_error(self):
|
||||
"""require_extra raises ImportError."""
|
||||
with pytest.raises(ImportError):
|
||||
require_extra(package="some_pkg", extra="some_extra")
|
||||
|
||||
def test_error_message_contains_package_name(self):
|
||||
"""Error message mentions the missing package."""
|
||||
with pytest.raises(ImportError, match="'prometheus_client'"):
|
||||
require_extra(package="prometheus_client", extra="metrics")
|
||||
|
||||
def test_error_message_contains_install_instruction(self):
|
||||
"""Error message contains the pip install command."""
|
||||
with pytest.raises(
|
||||
ImportError, match=r"pip install fastapi-toolsets\[metrics\]"
|
||||
):
|
||||
require_extra(package="prometheus_client", extra="metrics")
|
||||
|
||||
|
||||
def _reload_without_package(module_path: str, blocked_packages: list[str]):
|
||||
"""Reload a module while blocking specific package imports.
|
||||
|
||||
Removes the target module and its parents from sys.modules so they
|
||||
get re-imported, and patches builtins.__import__ to raise ImportError
|
||||
for *blocked_packages*.
|
||||
"""
|
||||
# Remove cached modules so they get re-imported
|
||||
to_remove = [
|
||||
key
|
||||
for key in sys.modules
|
||||
if key == module_path or key.startswith(module_path + ".")
|
||||
]
|
||||
saved = {}
|
||||
for key in to_remove:
|
||||
saved[key] = sys.modules.pop(key)
|
||||
|
||||
# Also remove parent package to force re-execution of __init__.py
|
||||
parts = module_path.rsplit(".", 1)
|
||||
if len(parts) == 2:
|
||||
parent = parts[0]
|
||||
parent_keys = [
|
||||
key for key in sys.modules if key == parent or key.startswith(parent + ".")
|
||||
]
|
||||
for key in parent_keys:
|
||||
if key not in saved:
|
||||
saved[key] = sys.modules.pop(key)
|
||||
|
||||
original_import = (
|
||||
__builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
|
||||
)
|
||||
|
||||
def blocking_import(name, *args, **kwargs):
|
||||
for blocked in blocked_packages:
|
||||
if name == blocked or name.startswith(blocked + "."):
|
||||
raise ImportError(f"Mocked: No module named '{name}'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
return saved, blocking_import
|
||||
|
||||
|
||||
class TestMetricsImportGuard:
|
||||
"""Tests for metrics module import guard when prometheus_client is missing."""
|
||||
|
||||
def test_registry_imports_without_prometheus(self):
|
||||
"""Metric and MetricsRegistry are importable without prometheus_client."""
|
||||
saved, blocking_import = _reload_without_package(
|
||||
"fastapi_toolsets.metrics", ["prometheus_client"]
|
||||
)
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
mod = importlib.import_module("fastapi_toolsets.metrics")
|
||||
# Registry types should be available (they're stdlib-only)
|
||||
assert hasattr(mod, "Metric")
|
||||
assert hasattr(mod, "MetricsRegistry")
|
||||
finally:
|
||||
# Restore original modules
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.metrics"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.update(saved)
|
||||
|
||||
def test_init_metrics_stub_raises_without_prometheus(self):
|
||||
"""init_metrics raises ImportError when prometheus_client is missing."""
|
||||
saved, blocking_import = _reload_without_package(
|
||||
"fastapi_toolsets.metrics", ["prometheus_client"]
|
||||
)
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
mod = importlib.import_module("fastapi_toolsets.metrics")
|
||||
with pytest.raises(ImportError, match="prometheus_client"):
|
||||
mod.init_metrics(None, None) # type: ignore[arg-type]
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.metrics"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.update(saved)
|
||||
|
||||
def test_init_metrics_works_with_prometheus(self):
|
||||
"""init_metrics is the real function when prometheus_client is available."""
|
||||
from fastapi_toolsets.metrics import init_metrics
|
||||
|
||||
# Should be the real function, not a stub
|
||||
assert init_metrics.__module__ == "fastapi_toolsets.metrics.handler"
|
||||
|
||||
|
||||
class TestPytestImportGuard:
|
||||
"""Tests for pytest module import guard when dependencies are missing."""
|
||||
|
||||
def test_import_raises_without_pytest_package(self):
|
||||
"""Importing fastapi_toolsets.pytest raises when pytest is missing."""
|
||||
saved, blocking_import = _reload_without_package(
|
||||
"fastapi_toolsets.pytest", ["pytest"]
|
||||
)
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
with pytest.raises(ImportError, match="pytest"):
|
||||
importlib.import_module("fastapi_toolsets.pytest")
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.pytest"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.update(saved)
|
||||
|
||||
def test_import_raises_without_httpx(self):
|
||||
"""Importing fastapi_toolsets.pytest raises when httpx is missing."""
|
||||
saved, blocking_import = _reload_without_package(
|
||||
"fastapi_toolsets.pytest", ["httpx"]
|
||||
)
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
with pytest.raises(ImportError, match="httpx"):
|
||||
importlib.import_module("fastapi_toolsets.pytest")
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.pytest"):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.update(saved)
|
||||
|
||||
def test_all_exports_available_with_deps(self):
|
||||
"""All expected exports are available when deps are installed."""
|
||||
from fastapi_toolsets.pytest import (
|
||||
cleanup_tables,
|
||||
create_async_client,
|
||||
create_db_session,
|
||||
create_worker_database,
|
||||
register_fixtures,
|
||||
worker_database_url,
|
||||
)
|
||||
|
||||
assert callable(register_fixtures)
|
||||
assert callable(create_async_client)
|
||||
assert callable(create_db_session)
|
||||
assert callable(create_worker_database)
|
||||
assert callable(worker_database_url)
|
||||
assert callable(cleanup_tables)
|
||||
|
||||
|
||||
class TestCliImportGuard:
|
||||
"""Tests for CLI module import guard when typer is missing."""
|
||||
|
||||
def test_import_raises_without_typer(self):
|
||||
"""Importing cli.app raises when typer is missing."""
|
||||
saved, blocking_import = _reload_without_package(
|
||||
"fastapi_toolsets.cli.app", ["typer"]
|
||||
)
|
||||
# Also remove cli.config since it imports typer too
|
||||
config_keys = [
|
||||
k for k in sys.modules if k.startswith("fastapi_toolsets.cli.config")
|
||||
]
|
||||
for key in config_keys:
|
||||
if key not in saved:
|
||||
saved[key] = sys.modules.pop(key)
|
||||
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
with pytest.raises(ImportError, match="typer"):
|
||||
importlib.import_module("fastapi_toolsets.cli.app")
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
|
||||
"fastapi_toolsets.cli.config"
|
||||
):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.update(saved)
|
||||
|
||||
def test_error_message_suggests_cli_extra(self):
|
||||
"""Error message suggests installing the cli extra."""
|
||||
saved, blocking_import = _reload_without_package(
|
||||
"fastapi_toolsets.cli.app", ["typer"]
|
||||
)
|
||||
config_keys = [
|
||||
k for k in sys.modules if k.startswith("fastapi_toolsets.cli.config")
|
||||
]
|
||||
for key in config_keys:
|
||||
if key not in saved:
|
||||
saved[key] = sys.modules.pop(key)
|
||||
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
with pytest.raises(
|
||||
ImportError, match=r"pip install fastapi-toolsets\[cli\]"
|
||||
):
|
||||
importlib.import_module("fastapi_toolsets.cli.app")
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
|
||||
"fastapi_toolsets.cli.config"
|
||||
):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.update(saved)
|
||||
|
||||
def test_async_command_imports_without_typer(self):
|
||||
"""async_command is importable without typer (stdlib only)."""
|
||||
from fastapi_toolsets.cli import async_command
|
||||
|
||||
assert callable(async_command)
|
||||
519
tests/test_metrics.py
Normal file
519
tests/test_metrics.py
Normal file
@@ -0,0 +1,519 @@
|
||||
"""Tests for fastapi_toolsets.metrics module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from prometheus_client import REGISTRY, CollectorRegistry, Counter, Gauge
|
||||
|
||||
from fastapi_toolsets.metrics import Metric, MetricsRegistry, init_metrics
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_prometheus_registry():
|
||||
"""Unregister test collectors from the global registry after each test."""
|
||||
yield
|
||||
collectors = list(REGISTRY._names_to_collectors.values())
|
||||
for collector in collectors:
|
||||
try:
|
||||
REGISTRY.unregister(collector)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestMetric:
|
||||
"""Tests for Metric dataclass."""
|
||||
|
||||
def test_default_collect_is_false(self):
|
||||
"""Default collect is False (provider mode)."""
|
||||
definition = Metric(name="test", func=lambda: None)
|
||||
assert definition.collect is False
|
||||
|
||||
def test_collect_true(self):
|
||||
"""Collect can be set to True (collector mode)."""
|
||||
definition = Metric(name="test", func=lambda: None, collect=True)
|
||||
assert definition.collect is True
|
||||
|
||||
|
||||
class TestMetricsRegistry:
|
||||
"""Tests for MetricsRegistry class."""
|
||||
|
||||
def test_register_with_decorator(self):
|
||||
"""Register metric with bare decorator."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register
|
||||
def my_counter():
|
||||
return Counter("test_counter", "A test counter")
|
||||
|
||||
names = [m.name for m in registry.get_all()]
|
||||
assert "my_counter" in names
|
||||
|
||||
def test_register_with_custom_name(self):
|
||||
"""Register metric with custom name."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register(name="custom_name")
|
||||
def my_counter():
|
||||
return Counter("test_counter_2", "A test counter")
|
||||
|
||||
definition = registry.get_all()[0]
|
||||
assert definition.name == "custom_name"
|
||||
|
||||
def test_register_as_collector(self):
|
||||
"""Register metric with collect=True."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register(collect=True)
|
||||
def collect_something():
|
||||
pass
|
||||
|
||||
definition = registry.get_all()[0]
|
||||
assert definition.collect is True
|
||||
|
||||
def test_register_preserves_function(self):
|
||||
"""Decorator returns the original function unchanged."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
def my_func():
|
||||
return "original"
|
||||
|
||||
result = registry.register(my_func)
|
||||
assert result is my_func
|
||||
assert result() == "original"
|
||||
|
||||
def test_register_parameterized_preserves_function(self):
|
||||
"""Parameterized decorator returns the original function unchanged."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
def my_func():
|
||||
return "original"
|
||||
|
||||
result = registry.register(name="custom")(my_func)
|
||||
assert result is my_func
|
||||
assert result() == "original"
|
||||
|
||||
def test_get_all(self):
|
||||
"""Get all registered metrics."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register
|
||||
def metric_a():
|
||||
pass
|
||||
|
||||
@registry.register
|
||||
def metric_b():
|
||||
pass
|
||||
|
||||
names = {m.name for m in registry.get_all()}
|
||||
assert names == {"metric_a", "metric_b"}
|
||||
|
||||
def test_get_providers(self):
|
||||
"""Get only provider metrics (collect=False)."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register
|
||||
def provider():
|
||||
pass
|
||||
|
||||
@registry.register(collect=True)
|
||||
def collector():
|
||||
pass
|
||||
|
||||
providers = registry.get_providers()
|
||||
assert len(providers) == 1
|
||||
assert providers[0].name == "provider"
|
||||
|
||||
def test_get_collectors(self):
|
||||
"""Get only collector metrics (collect=True)."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register
|
||||
def provider():
|
||||
pass
|
||||
|
||||
@registry.register(collect=True)
|
||||
def collector():
|
||||
pass
|
||||
|
||||
collectors = registry.get_collectors()
|
||||
assert len(collectors) == 1
|
||||
assert collectors[0].name == "collector"
|
||||
|
||||
def test_register_overwrites_same_name(self):
|
||||
"""Registering with the same name overwrites the previous entry."""
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register(name="metric")
|
||||
def first():
|
||||
pass
|
||||
|
||||
@registry.register(name="metric")
|
||||
def second():
|
||||
pass
|
||||
|
||||
assert len(registry.get_all()) == 1
|
||||
assert registry.get_all()[0].func is second
|
||||
|
||||
|
||||
class TestIncludeRegistry:
|
||||
"""Tests for MetricsRegistry.include_registry method."""
|
||||
|
||||
def test_include_empty_registry(self):
|
||||
"""Include an empty registry does nothing."""
|
||||
main = MetricsRegistry()
|
||||
other = MetricsRegistry()
|
||||
|
||||
@main.register
|
||||
def metric_a():
|
||||
pass
|
||||
|
||||
main.include_registry(other)
|
||||
assert len(main.get_all()) == 1
|
||||
|
||||
def test_include_registry_adds_metrics(self):
|
||||
"""Include registry adds all metrics from the other registry."""
|
||||
main = MetricsRegistry()
|
||||
other = MetricsRegistry()
|
||||
|
||||
@main.register
|
||||
def metric_a():
|
||||
pass
|
||||
|
||||
@other.register
|
||||
def metric_b():
|
||||
pass
|
||||
|
||||
@other.register
|
||||
def metric_c():
|
||||
pass
|
||||
|
||||
main.include_registry(other)
|
||||
names = {m.name for m in main.get_all()}
|
||||
assert names == {"metric_a", "metric_b", "metric_c"}
|
||||
|
||||
def test_include_registry_preserves_collect_flag(self):
|
||||
"""Include registry preserves the collect flag."""
|
||||
main = MetricsRegistry()
|
||||
other = MetricsRegistry()
|
||||
|
||||
@other.register(collect=True)
|
||||
def collector():
|
||||
pass
|
||||
|
||||
main.include_registry(other)
|
||||
assert main.get_all()[0].collect is True
|
||||
|
||||
def test_include_registry_raises_on_duplicate(self):
|
||||
"""Include registry raises ValueError on duplicate metric names."""
|
||||
main = MetricsRegistry()
|
||||
other = MetricsRegistry()
|
||||
|
||||
@main.register(name="metric")
|
||||
def metric_main():
|
||||
pass
|
||||
|
||||
@other.register(name="metric")
|
||||
def metric_other():
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
main.include_registry(other)
|
||||
|
||||
def test_include_multiple_registries(self):
|
||||
"""Include multiple registries sequentially."""
|
||||
main = MetricsRegistry()
|
||||
sub1 = MetricsRegistry()
|
||||
sub2 = MetricsRegistry()
|
||||
|
||||
@main.register
|
||||
def base():
|
||||
pass
|
||||
|
||||
@sub1.register
|
||||
def sub1_metric():
|
||||
pass
|
||||
|
||||
@sub2.register
|
||||
def sub2_metric():
|
||||
pass
|
||||
|
||||
main.include_registry(sub1)
|
||||
main.include_registry(sub2)
|
||||
|
||||
names = {m.name for m in main.get_all()}
|
||||
assert names == {"base", "sub1_metric", "sub2_metric"}
|
||||
|
||||
|
||||
class TestInitMetrics:
|
||||
"""Tests for init_metrics function."""
|
||||
|
||||
def test_returns_app(self):
|
||||
"""Returns the FastAPI app."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
result = init_metrics(app, registry)
|
||||
assert result is app
|
||||
|
||||
def test_metrics_endpoint_responds(self):
|
||||
"""The /metrics endpoint returns 200."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/metrics")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_metrics_endpoint_content_type(self):
|
||||
"""The /metrics endpoint returns prometheus content type."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/metrics")
|
||||
|
||||
assert "text/plain" in response.headers["content-type"]
|
||||
|
||||
def test_custom_path(self):
|
||||
"""Custom path is used for the metrics endpoint."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
init_metrics(app, registry, path="/custom-metrics")
|
||||
|
||||
client = TestClient(app)
|
||||
assert client.get("/custom-metrics").status_code == 200
|
||||
assert client.get("/metrics").status_code == 404
|
||||
|
||||
def test_providers_called_at_init(self):
|
||||
"""Provider functions are called once at init time."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
mock = MagicMock()
|
||||
|
||||
@registry.register
|
||||
def my_provider():
|
||||
mock()
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_collectors_called_on_scrape(self):
|
||||
"""Collector functions are called on each scrape."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
mock = MagicMock()
|
||||
|
||||
@registry.register(collect=True)
|
||||
def my_collector():
|
||||
mock()
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/metrics")
|
||||
client.get("/metrics")
|
||||
|
||||
assert mock.call_count == 2
|
||||
|
||||
def test_collectors_not_called_at_init(self):
|
||||
"""Collector functions are not called at init time."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
mock = MagicMock()
|
||||
|
||||
@registry.register(collect=True)
|
||||
def my_collector():
|
||||
mock()
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_async_collectors_called_on_scrape(self):
|
||||
"""Async collector functions are awaited on each scrape."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
mock = AsyncMock()
|
||||
|
||||
@registry.register(collect=True)
|
||||
async def my_async_collector():
|
||||
await mock()
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/metrics")
|
||||
client.get("/metrics")
|
||||
|
||||
assert mock.call_count == 2
|
||||
|
||||
def test_mixed_sync_and_async_collectors(self):
|
||||
"""Both sync and async collectors are called on scrape."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
sync_mock = MagicMock()
|
||||
async_mock = AsyncMock()
|
||||
|
||||
@registry.register(collect=True)
|
||||
def sync_collector():
|
||||
sync_mock()
|
||||
|
||||
@registry.register(collect=True)
|
||||
async def async_collector():
|
||||
await async_mock()
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/metrics")
|
||||
|
||||
sync_mock.assert_called_once()
|
||||
async_mock.assert_called_once()
|
||||
|
||||
def test_registered_metrics_appear_in_output(self):
|
||||
"""Metrics created by providers appear in /metrics output."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register
|
||||
def my_gauge():
|
||||
g = Gauge("test_gauge_value", "A test gauge")
|
||||
g.set(42)
|
||||
return g
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/metrics")
|
||||
|
||||
assert b"test_gauge_value" in response.content
|
||||
assert b"42.0" in response.content
|
||||
|
||||
def test_endpoint_not_in_openapi_schema(self):
|
||||
"""The /metrics endpoint is not included in the OpenAPI schema."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
init_metrics(app, registry)
|
||||
|
||||
schema = app.openapi()
|
||||
assert "/metrics" not in schema.get("paths", {})
|
||||
|
||||
|
||||
class TestMultiProcessMode:
|
||||
"""Tests for multi-process Prometheus mode."""
|
||||
|
||||
def test_multiprocess_with_env_var(self):
|
||||
"""Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.environ["PROMETHEUS_MULTIPROC_DIR"] = tmpdir
|
||||
try:
|
||||
# Use a separate registry to avoid conflicts with default
|
||||
prom_registry = CollectorRegistry()
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register
|
||||
def mp_counter():
|
||||
return Counter(
|
||||
"mp_test_counter",
|
||||
"A multiprocess counter",
|
||||
registry=prom_registry,
|
||||
)
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/metrics")
|
||||
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
del os.environ["PROMETHEUS_MULTIPROC_DIR"]
|
||||
|
||||
def test_single_process_without_env_var(self):
|
||||
"""Single-process mode when PROMETHEUS_MULTIPROC_DIR is not set."""
|
||||
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
|
||||
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
|
||||
@registry.register
|
||||
def sp_gauge():
|
||||
g = Gauge("sp_test_gauge", "A single-process gauge")
|
||||
g.set(99)
|
||||
return g
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/metrics")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"sp_test_gauge" in response.content
|
||||
|
||||
|
||||
class TestMetricsIntegration:
|
||||
"""Integration tests for the metrics module."""
|
||||
|
||||
def test_full_workflow(self):
|
||||
"""Full workflow: registry, providers, collectors, endpoint."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
call_count = {"value": 0}
|
||||
|
||||
@registry.register
|
||||
def request_counter():
|
||||
return Counter(
|
||||
"integration_requests_total",
|
||||
"Total requests",
|
||||
["method"],
|
||||
)
|
||||
|
||||
@registry.register(collect=True)
|
||||
def collect_uptime():
|
||||
call_count["value"] += 1
|
||||
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert b"integration_requests_total" in response.content
|
||||
assert call_count["value"] == 1
|
||||
|
||||
response = client.get("/metrics")
|
||||
assert call_count["value"] == 2
|
||||
|
||||
def test_multiple_registries_merged(self):
|
||||
"""Multiple registries can be merged and used together."""
|
||||
app = FastAPI()
|
||||
main = MetricsRegistry()
|
||||
sub = MetricsRegistry()
|
||||
|
||||
@main.register
|
||||
def main_gauge():
|
||||
g = Gauge("main_gauge_val", "Main gauge")
|
||||
g.set(1)
|
||||
return g
|
||||
|
||||
@sub.register
|
||||
def sub_gauge():
|
||||
g = Gauge("sub_gauge_val", "Sub gauge")
|
||||
g.set(2)
|
||||
return g
|
||||
|
||||
main.include_registry(sub)
|
||||
init_metrics(app, main)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/metrics")
|
||||
|
||||
assert b"main_gauge_val" in response.content
|
||||
assert b"sub_gauge_val" in response.content
|
||||
136
uv.lock
generated
136
uv.lock
generated
@@ -226,7 +226,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.8"
|
||||
version = "0.129.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -235,36 +235,56 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-toolsets"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "httpx" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
cli = [
|
||||
{ name = "typer" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "coverage" },
|
||||
{ name = "httpx" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-anyio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
metrics = [
|
||||
{ name = "prometheus-client" },
|
||||
]
|
||||
pytest = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-xdist" },
|
||||
]
|
||||
test = [
|
||||
{ name = "coverage" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-anyio" },
|
||||
{ name = "pytest-cov" },
|
||||
@@ -276,19 +296,22 @@ requires-dist = [
|
||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||
{ name = "coverage", marker = "extra == 'test'", specifier = ">=7.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||
{ name = "fastapi-toolsets", extras = ["test"], marker = "extra == 'dev'" },
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "fastapi-toolsets", extras = ["all", "test"], marker = "extra == 'dev'" },
|
||||
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
||||
{ name = "fastapi-toolsets", extras = ["pytest"], marker = "extra == 'test'" },
|
||||
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
||||
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0" },
|
||||
{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-anyio", marker = "extra == 'test'", specifier = ">=0.0.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" },
|
||||
{ name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.0.0" },
|
||||
{ name = "pytest-xdist", marker = "extra == 'pytest'", specifier = ">=3.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a0" },
|
||||
{ name = "typer", specifier = ">=0.9.0" },
|
||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
||||
]
|
||||
provides-extras = ["test", "dev"]
|
||||
provides-extras = ["cli", "metrics", "pytest", "all", "test", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
@@ -436,6 +459,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.24.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
@@ -628,27 +660,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -783,31 +815,31 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.16"
|
||||
version = "0.0.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/18/77f84d89db54ea0d1d1b09fa2f630ac4c240c8e270761cb908c06b6e735c/ty-0.0.16.tar.gz", hash = "sha256:a999b0db6aed7d6294d036ebe43301105681e0c821a19989be7c145805d7351c", size = 5129637, upload-time = "2026-02-10T20:24:16.48Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b9/909ebcc7f59eaf8a2c18fb54bfcf1c106f99afb3e5460058d4b46dec7b20/ty-0.0.16-py3-none-linux_armv6l.whl", hash = "sha256:6d8833b86396ed742f2b34028f51c0e98dbf010b13ae4b79d1126749dc9dab15", size = 10113870, upload-time = "2026-02-10T20:24:11.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/2c/b963204f3df2fdbf46a4a1ea4a060af9bb676e065d59c70ad0f5ae0dbae8/ty-0.0.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934c0055d3b7f1cf3c8eab78c6c127ef7f347ff00443cef69614bda6f1502377", size = 9936286, upload-time = "2026-02-10T20:24:08.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/4d/3d78294f2ddfdded231e94453dea0e0adef212b2bd6536296039164c2a3e/ty-0.0.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b55e8e8733b416d914003cd22e831e139f034681b05afed7e951cc1a5ea1b8d4", size = 9442660, upload-time = "2026-02-10T20:24:02.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/40/ce48c0541e3b5749b0890725870769904e6b043e077d4710e5325d5cf807/ty-0.0.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feccae8f4abd6657de111353bd604f36e164844466346eb81ffee2c2b06ea0f0", size = 9934506, upload-time = "2026-02-10T20:24:35.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/16/3b29de57e1ec6e56f50a4bb625ee0923edb058c5f53e29014873573a00cd/ty-0.0.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cad5e29d8765b92db5fa284940ac57149561f3f89470b363b9aab8a6ce553b0", size = 9933099, upload-time = "2026-02-10T20:24:43.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a1/e546995c25563d318c502b2f42af0fdbed91e1fc343708241e2076373644/ty-0.0.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86f28797c7dc06f081238270b533bf4fc8e93852f34df49fb660e0b58a5cda9a", size = 10438370, upload-time = "2026-02-10T20:24:33.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/22d301a4b2cce0f75ae84d07a495f87da193bcb68e096d43695a815c4708/ty-0.0.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be971a3b42bcae44d0e5787f88156ed2102ad07558c05a5ae4bfd32a99118e66", size = 10992160, upload-time = "2026-02-10T20:24:25.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/40/f1892b8c890db3f39a1bab8ec459b572de2df49e76d3cad2a9a239adcde9/ty-0.0.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c9f982b7c4250eb91af66933f436b3a2363c24b6353e94992eab6551166c8b7", size = 10717892, upload-time = "2026-02-10T20:24:05.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/1b/caf9be8d0c738983845f503f2e92ea64b8d5fae1dd5ca98c3fca4aa7dadc/ty-0.0.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d122edf85ce7bdf6f85d19158c991d858fc835677bd31ca46319c4913043dc84", size = 10510916, upload-time = "2026-02-10T20:24:00.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ea/28980f5c7e1f4c9c44995811ea6a36f2fcb205232a6ae0f5b60b11504621/ty-0.0.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:497ebdddbb0e35c7758ded5aa4c6245e8696a69d531d5c9b0c1a28a075374241", size = 9908506, upload-time = "2026-02-10T20:24:28.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/80/8672306596349463c21644554f935ff8720679a14fd658fef658f66da944/ty-0.0.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e1e0ac0837bde634b030243aeba8499383c0487e08f22e80f5abdacb5b0bd8ce", size = 9949486, upload-time = "2026-02-10T20:24:18.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/8a/d8747d36f30bd82ea157835f5b70d084c9bb5d52dd9491dba8a149792d6a/ty-0.0.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1216c9bcca551d9f89f47a817ebc80e88ac37683d71504e5509a6445f24fd024", size = 10145269, upload-time = "2026-02-10T20:24:38.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/4c/753535acc7243570c259158b7df67e9c9dd7dab9a21ee110baa4cdcec45d/ty-0.0.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:221bbdd2c6ee558452c96916ab67fcc465b86967cf0482e19571d18f9c831828", size = 10608644, upload-time = "2026-02-10T20:24:40.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/05/8e8db64cf45a8b16757e907f7a3bfde8d6203e4769b11b64e28d5bdcd79a/ty-0.0.16-py3-none-win32.whl", hash = "sha256:d52c4eb786be878e7514cab637200af607216fcc5539a06d26573ea496b26512", size = 9582579, upload-time = "2026-02-10T20:24:30.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/bc/45759faea132cd1b2a9ff8374e42ba03d39d076594fbb94f3e0e2c226c62/ty-0.0.16-py3-none-win_amd64.whl", hash = "sha256:f572c216aa8ecf79e86589c6e6d4bebc01f1f3cb3be765c0febd942013e1e73a", size = 10436043, upload-time = "2026-02-10T20:23:57.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/02/70a491802e7593e444137ed4e41a04c34d186eb2856f452dd76b60f2e325/ty-0.0.16-py3-none-win_arm64.whl", hash = "sha256:430eadeb1c0de0c31ef7bef9d002bdbb5f25a31e3aad546f1714d76cd8da0a87", size = 9915122, upload-time = "2026-02-10T20:24:14.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -815,9 +847,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/e6/44e073787aa57cd71c151f44855232feb0f748428fd5242d7366e3c4ae8b/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc", size = 120181, upload-time = "2026-02-11T15:22:18.637Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b6/3e681d3b6bb22647509bdbfdd18055d5adc0dce5c5585359fa46ff805fdc/typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504", size = 118380, upload-time = "2026-02-16T22:08:48.496Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/ed/d6fca788b51d0d4640c4bc82d0e85bad4b49809bca36bf4af01b4dcb66a7/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913", size = 56668, upload-time = "2026-02-11T15:22:21.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d0/4da85c2a45054bb661993c93524138ace4956cb075a7ae0c9d1deadc331b/typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8", size = 56441, upload-time = "2026-02-16T22:08:47.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user