diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..309b99d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,136 @@ +FROM ubuntu:noble AS build + +# The following does not work in Podman unless you build in Docker +# compatibility mode: +# You can manually prepend every RUN script with `set -ex` too. +# SHELL ["sh", "-exc"] + +### Start build prep. +### This should be a separate build container for better reuse. + +RUN < src/fastapi_dynamic_response/__about__.py +touch README.md +uv sync \ + --locked \ + --no-dev \ + --no-install-project +EOT + +# Now install the APPLICATION from `/src` without any dependencies. +# `/src` will NOT be copied into the runtime container. +# LEAVE THIS OUT if your application is NOT a proper Python package. +# As of uv 0.4.11, you can also use +# `cd /src && uv sync --locked --no-dev --no-editable` instead. +COPY . /src +RUN --mount=type=cache,target=/root/.cache \ + uv pip install \ + --python=$UV_PROJECT_ENVIRONMENT \ + --no-deps \ + /src + + +########################################################################## + +FROM ubuntu:noble +# SHELL ["sh", "-exc"] + +# Optional: add the application virtualenv to search path. +ENV PATH=/app/bin:$PATH + +# Don't run your app as root. +RUN <. +STOPSIGNAL SIGINT + +# Note how the runtime dependencies differ from build-time ones. +# Notably, there is no uv either! +RUN <=0.115.0", "html2text>=2024.2.26", + "itsdangerous>=2.2.0", "jinja2>=3.1.4", "markdown>=3.7", "pillow>=10.4.0", + "pydantic-settings>=2.5.2", "pydyf==0.8.0", "python-levenshtein>=0.25.1", "rich>=13.9.2", "selenium>=4.25.0", + "structlog>=24.4.0", + "typer>=0.12.5", "uvicorn>=0.31.1", "weasyprint>=61.2", ] @@ -43,6 +47,9 @@ Documentation = "https://github.com/U.N. Owen/fastapi-dynamic-response#readme" Issues = "https://github.com/U.N. Owen/fastapi-dynamic-response/issues" Source = "https://github.com/U.N. Owen/fastapi-dynamic-response" +[project.scripts] +fdr_app = "fastapi_dynamic_response.cli.cli:app" + [tool.hatch.version] path = "src/fastapi_dynamic_response/__about__.py" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2b01b01 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,131 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml -o requirements.txt +annotated-types==0.7.0 + # via pydantic +anyio==4.6.2.post1 + # via starlette +attrs==24.2.0 + # via + # outcome + # trio +brotli==1.1.0 + # via fonttools +certifi==2024.8.30 + # via selenium +cffi==1.17.1 + # via weasyprint +click==8.1.7 + # via uvicorn +cssselect2==0.7.0 + # via weasyprint +fastapi==0.115.3 + # via fastapi-dynamic-response (pyproject.toml) +fonttools==4.54.1 + # via weasyprint +h11==0.14.0 + # via + # uvicorn + # wsproto +html2text==2024.2.26 + # via fastapi-dynamic-response (pyproject.toml) +html5lib==1.1 + # via weasyprint +idna==3.10 + # via + # anyio + # trio +itsdangerous==2.2.0 + # via fastapi-dynamic-response (pyproject.toml) +jinja2==3.1.4 + # via fastapi-dynamic-response (pyproject.toml) +levenshtein==0.26.0 + # via python-levenshtein +markdown==3.7 + # via fastapi-dynamic-response (pyproject.toml) +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.2 + # via jinja2 +mdurl==0.1.2 + # via markdown-it-py +outcome==1.3.0.post0 + # via trio +pillow==11.0.0 + # via + # fastapi-dynamic-response (pyproject.toml) + # weasyprint +pycparser==2.22 + # via cffi +pydantic==2.9.2 + # via + # fastapi + # pydantic-settings +pydantic-core==2.23.4 + # via pydantic +pydantic-settings==2.6.0 + # via fastapi-dynamic-response (pyproject.toml) +pydyf==0.8.0 + # via + # fastapi-dynamic-response (pyproject.toml) + # weasyprint +pygments==2.18.0 + # via rich +pyphen==0.16.0 + # via weasyprint +pysocks==1.7.1 + # via urllib3 +python-dotenv==1.0.1 + # via pydantic-settings +python-levenshtein==0.26.0 + # via fastapi-dynamic-response (pyproject.toml) +rapidfuzz==3.10.0 + # via levenshtein +rich==13.9.3 + # via fastapi-dynamic-response (pyproject.toml) +selenium==4.25.0 + # via fastapi-dynamic-response (pyproject.toml) +six==1.16.0 + # via html5lib +sniffio==1.3.1 + # via + # anyio + # trio +sortedcontainers==2.4.0 + # via trio +starlette==0.41.0 + # via fastapi +structlog==24.4.0 + # via fastapi-dynamic-response (pyproject.toml) +tinycss2==1.3.0 + # via + # cssselect2 + # weasyprint +trio==0.27.0 + # via + # selenium + # trio-websocket +trio-websocket==0.11.1 + # via selenium +typing-extensions==4.12.2 + # via + # fastapi + # pydantic + # pydantic-core + # selenium +urllib3==2.2.3 + # via selenium +uvicorn==0.32.0 + # via fastapi-dynamic-response (pyproject.toml) +weasyprint==61.2 + # via fastapi-dynamic-response (pyproject.toml) +webencodings==0.5.1 + # via + # cssselect2 + # html5lib + # tinycss2 +websocket-client==1.8.0 + # via selenium +wsproto==1.2.0 + # via trio-websocket +zopfli==0.2.3.post1 + # via fonttools diff --git a/sitemap.html b/sitemap.html new file mode 100644 index 0000000..189bbf6 --- /dev/null +++ b/sitemap.html @@ -0,0 +1,612 @@ + + + + + + Sitemap + + + + + +
+ +
+ +
+ +

Sitemap

+ + +
  • /livez
  • + +
  • /readyz
  • + +
  • /healthz
  • + +
  • /example
  • + +
  • /another-example
  • + +
  • /message
  • + +
  • /static
  • + +
  • /sitemap
  • + + +
    + +
    +

    © 2024 FastApi Dynamic Response

    +
    + + diff --git a/sitemap.pdf b/sitemap.pdf new file mode 100644 index 0000000..e69de29 diff --git a/sitemap.png b/sitemap.png new file mode 100644 index 0000000..cc770d5 Binary files /dev/null and b/sitemap.png differ diff --git a/src/fastapi_dynamic_response/auth.py b/src/fastapi_dynamic_response/auth.py new file mode 100644 index 0000000..1502dc9 --- /dev/null +++ b/src/fastapi_dynamic_response/auth.py @@ -0,0 +1,117 @@ +from fastapi import HTTPException, Request +from functools import wraps +from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser +from typing import Dict + +# In-memory user database for demonstration purposes +AUTH_DB: Dict[str, str] = { + "user1": "password123", + "user2": "securepassword", + "user3": "supersecurepassword", +} + +SCOPES = { + "authenticated": "Authenticated users", + "admin": "Admin users", + "superuser": "Superuser", +} + +USER_SCOPES = { + "user1": ["authenticated"], + "user2": ["authenticated", "admin"], + "user3": ["authenticated", "admin", "superuser"], +} + + +def authenticated(func: callable): + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + if not request.user.is_authenticated: + raise HTTPException(status_code=401, detail="Authentication required") + return await func(request, *args, **kwargs) + + return wrapper + + +def admin(func: callable): + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + if not request.user.is_authenticated: + raise HTTPException(status_code=401, detail="Authentication required") + if "admin" not in request.user.scopes: + raise HTTPException(status_code=403, detail="Admin access required") + return await func(request, *args, **kwargs) + + return wrapper + + +def has_scope(scope: str): + def decorator(func: callable): + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + if not request.user.is_authenticated: + raise HTTPException(status_code=401, detail="Authentication required") + if scope not in request.auth.scopes: + raise HTTPException(status_code=403, detail="Access denied") + return await func(request, *args, **kwargs) + + return wrapper + + return decorator + + +class BasicAuthBackend(AuthenticationBackend): + """Custom authentication backend that validates Basic auth credentials.""" + + async def authenticate(self, request: Request): + # Extract the 'Authorization' header from the request + auth_header = request.headers.get("Authorization") + + if not auth_header: + return None # No credentials provided + + try: + # Basic authentication: "Basic :" + auth_type, credentials = auth_header.split(" ", 1) + if auth_type != "Basic": + return None # Unsupported auth type + + username, password = credentials.split(":") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid Authorization format") + + # Validate credentials against the in-memory AUTH_DB + if AUTH_DB.get(username) != password: + raise HTTPException(status_code=401, detail="Invalid credentials") + + # If valid, return user object and auth credentials + return AuthCredentials(USER_SCOPES[username]), SimpleUser(username) + + +# # Initialize FastAPI app +# app = FastAPI() +# +# # Add AuthenticationMiddleware to FastAPI with the custom backend +# app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend()) +# +# # Add SessionMiddleware with a secret key +# app.add_middleware(SessionMiddleware, secret_key="your-secret-key") + +# @app.get("/") +# async def public(): +# """Public route.""" +# return {"message": "This route is accessible to everyone!"} +# +# @app.get("/private") +# async def private(request: Request): +# """Private route that requires authentication.""" +# if not request.user.is_authenticated: +# raise HTTPException(status_code=401, detail="Authentication required") +# +# return {"message": f"Welcome, {request.user.display_name}!"} +# +# @app.get("/session") +# async def session_info(request: Request): +# """Access session data.""" +# request.session["example"] = "This is session data" +# return {"session_data": request.session} diff --git a/src/fastapi_dynamic_response/base/router.py b/src/fastapi_dynamic_response/base/router.py index 5112381..eedb953 100644 --- a/src/fastapi_dynamic_response/base/router.py +++ b/src/fastapi_dynamic_response/base/router.py @@ -1,7 +1,6 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import Request +from fastapi import APIRouter, Depends, Request +from fastapi_dynamic_response.auth import admin, authenticated, has_scope from fastapi_dynamic_response.base.schema import Message from fastapi_dynamic_response.dependencies import get_content_type @@ -17,6 +16,46 @@ async def get_example( return {"message": "Hello, this is an example", "data": [1, 2, 3, 4]} +@router.get("/private") +@authenticated +async def get_private( + request: Request, + content_type: str = Depends(get_content_type), +): + request.state.template_name = "example.html" + return {"message": "This page is private", "data": [1, 2, 3, 4]} + + +@router.get("/admin") +@admin +async def get_admin( + request: Request, + content_type: str = Depends(get_content_type), +): + request.state.template_name = "example.html" + return {"message": "This is only for admin users", "data": [1, 2, 3, 4]} + + +@router.get("/superuser") +@has_scope("superuser") +async def get_superuser( + request: Request, + content_type: str = Depends(get_content_type), +): + request.state.template_name = "example.html" + return {"message": "This is only for superusers", "data": [1, 2, 3, 4]} + + +@router.get("/error") +async def get_error( + request: Request, + content_type: str = Depends(get_content_type), +): + request.state.template_name = "example.html" + 0 / 0 + return {"message": "Hello, this is an example", "data": [1, 2, 3, 4]} + + @router.get("/another-example") async def another_example( request: Request, @@ -30,6 +69,16 @@ async def another_example( } +@router.get("/message") +async def message( + request: Request, + message_id: int, + content_type: str = Depends(get_content_type), +): + request.state.template_name = "post_message.html" + return {"message": message.message} + + @router.post("/message") async def message( request: Request, diff --git a/src/fastapi_dynamic_response/cli/.null-ls_858127_app.py b/src/fastapi_dynamic_response/cli/.null-ls_858127_app.py new file mode 100644 index 0000000..71e9e28 --- /dev/null +++ b/src/fastapi_dynamic_response/cli/.null-ls_858127_app.py @@ -0,0 +1,26 @@ +import typer +import uvicorn + +from fastapi_dynamic_response import settings + + +app_app = typer.Typer() + + +@app_app.callback() +def app(): + "model cli" + + +@app_app.command() +def run( + env: str = typer.Option( + "local", + help="the environment to use", + ), +): + uvicorn.run(**settings.api_server.dict()) + + +if __name__ == "__main__": + app_app() diff --git a/src/fastapi_dynamic_response/cli/app.py b/src/fastapi_dynamic_response/cli/app.py new file mode 100644 index 0000000..417d622 --- /dev/null +++ b/src/fastapi_dynamic_response/cli/app.py @@ -0,0 +1,26 @@ +import typer +import uvicorn + +from fastapi_dynamic_response.settings import settings + + +app_app = typer.Typer() + + +@app_app.callback() +def app(): + "model cli" + + +@app_app.command() +def run( + env: str = typer.Option( + "local", + help="the environment to use", + ), +): + uvicorn.run(**settings.api_server.dict()) + + +if __name__ == "__main__": + app_app() diff --git a/src/fastapi_dynamic_response/cli/cli.py b/src/fastapi_dynamic_response/cli/cli.py new file mode 100644 index 0000000..27be712 --- /dev/null +++ b/src/fastapi_dynamic_response/cli/cli.py @@ -0,0 +1,7 @@ +import typer + +from fastapi_dynamic_response.cli.app import app_app + +app = typer.Typer() + +app.add_typer(app_app, name="app") diff --git a/src/fastapi_dynamic_response/constant.py b/src/fastapi_dynamic_response/constant.py index e25d785..4bb76f6 100644 --- a/src/fastapi_dynamic_response/constant.py +++ b/src/fastapi_dynamic_response/constant.py @@ -1,21 +1,32 @@ ACCEPT_TYPES = { - "application/json": "JSON", - "text/html": "html", "application/html": "html", - "text/html-partial": "html", - "text/html-fragment": "html", - "text/rich": "rtf", - "application/rtf": "rtf", - "text/rtf": "rtf", - "text/plain": "text", - "application/text": "text", + "application/html-partial": "html", + "application/json": "JSON", "application/markdown": "markdown", - "text/markdown": "markdown", - "text/x-markdown": "markdown", - "json": "JSON", + "application/md": "markdown", + "application/pdf": "pdf", + "application/plain": "text", + "application/rtf": "rtf", + "application/text": "text", "html": "html", - "rtf": "rtf", - "plain": "text", + "image/png": "png", + "json": "JSON", "markdown": "markdown", "md": "markdown", + "pdf": "pdf", + "plain": "text", + "png": "png", + "rich": "rtf", + "richtext": "rtf", + "richtextformat": "rtf", + "rtf": "rtf", + "text": "text", + "text/html": "html", + "text/html-partial": "html", + "text/markdown": "markdown", + "text/md": "markdown", + "text/plain": "text", + "text/rich": "rtf", + "text/rtf": "rtf", + "text/x-markdown": "markdown", } diff --git a/src/fastapi_dynamic_response/logging_config.py b/src/fastapi_dynamic_response/logging_config.py new file mode 100644 index 0000000..80f0a3f --- /dev/null +++ b/src/fastapi_dynamic_response/logging_config.py @@ -0,0 +1,73 @@ +# logging_config.py + + +import logging + +from fastapi_dynamic_response.settings import settings +import structlog + +logger = structlog.get_logger() + + +def configure_logging(): + # Clear existing loggers + logging.config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + } + ) + + if settings.ENV == "local": + # Local development logging configuration + processors = [ + # structlog.processors.TimeStamper(fmt="iso"), + structlog.dev.ConsoleRenderer(colors=False), + ] + logging_level = logging.DEBUG + + # Enable rich tracebacks + from rich.traceback import install + + install(show_locals=True) + + # Use RichHandler for pretty console logs + from rich.logging import RichHandler + + logging.basicConfig( + level=logging_level, + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler()], + ) + else: + # Production logging configuration + processors = [ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.JSONRenderer(), + ] + logging_level = logging.INFO + + # Standard logging configuration + logging.basicConfig( + format="%(message)s", + level=logging_level, + handlers=[logging.StreamHandler()], + ) + + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger(logging_level), + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + + # Redirect uvicorn loggers to structlog + for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"): + logger = logging.getLogger(logger_name) + logger.handlers = [] + logger.propagate = True + + logger.info("Logging configured") + logger.info(f"Environment: {settings.ENV}") diff --git a/src/fastapi_dynamic_response/main.py b/src/fastapi_dynamic_response/main.py index e395df1..b6bad64 100644 --- a/src/fastapi_dynamic_response/main.py +++ b/src/fastapi_dynamic_response/main.py @@ -1,18 +1,27 @@ -from fastapi import Depends, FastAPI, Request +from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi_dynamic_response import globals from fastapi_dynamic_response.__about__ import __version__ from fastapi_dynamic_response.base.router import router as base_router from fastapi_dynamic_response.dependencies import get_content_type -from fastapi_dynamic_response.middleware import ( - Sitemap, - catch_exceptions_middleware, - respond_based_on_content_type, - set_prefers, -) from fastapi_dynamic_response.zpages.router import router as zpages_router +from fastapi_dynamic_response.settings import settings + +from fastapi_dynamic_response.logging_config import configure_logging +from fastapi_dynamic_response.middleware import ( + Sitemap, + add_process_time_header, + catch_exceptions_middleware, + log_requests, + respond_based_on_content_type, + set_bound_logger, + set_prefers, + set_span_id, +) + +configure_logging() app = FastAPI( title="FastAPI Dynamic Response", version=__version__, @@ -21,20 +30,35 @@ app = FastAPI( openapi_url=None, # openapi_tags=tags_metadata, # exception_handlers=exception_handlers, - debug=True, + debug=settings.DEBUG, dependencies=[ - Depends(set_prefers), + # Depends(set_prefers), + # Depends(set_span_id), + # Depends(log_request_state), ], ) + +# configure_tracing(app) + app.include_router(zpages_router) app.include_router(base_router) -app.middleware("http")(Sitemap(app)) -app.middleware("http")(catch_exceptions_middleware) app.middleware("http")(respond_based_on_content_type) +app.middleware("http")(add_process_time_header) +app.middleware("http")(log_requests) +app.middleware("http")(Sitemap(app)) +app.middleware("http")(set_prefers) +app.middleware("http")(set_span_id) +app.middleware("http")(catch_exceptions_middleware) +app.middleware("http")(set_bound_logger) app.mount("/static", StaticFiles(directory="static"), name="static") +from fastapi import Depends, Request +from fastapi_dynamic_response.auth import BasicAuthBackend +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.middleware.sessions import SessionMiddleware -# Flag to indicate if the application is ready +app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend()) +app.add_middleware(SessionMiddleware, secret_key="your-secret-key") @app.on_event("startup") diff --git a/src/fastapi_dynamic_response/middleware.py b/src/fastapi_dynamic_response/middleware.py index d80e317..7cf923d 100644 --- a/src/fastapi_dynamic_response/middleware.py +++ b/src/fastapi_dynamic_response/middleware.py @@ -1,14 +1,13 @@ from difflib import get_close_matches +from fastapi_dynamic_response.settings import settings from io import BytesIO import json +import time import traceback from typing import Any, Dict +from uuid import uuid4 from fastapi import Request, Response -from fastapi.exceptions import ( - HTTPException as StarletteHTTPException, - RequestValidationError, -) from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse import html2text from pydantic import BaseModel, model_validator @@ -17,11 +16,17 @@ from rich.markdown import Markdown from rich.panel import Panel from selenium import webdriver from selenium.webdriver.chrome.options import Options -from weasyprint import HTML as WEAZYHTML +import base64 from fastapi_dynamic_response.constant import ACCEPT_TYPES from fastapi_dynamic_response.globals import templates +import structlog + +logger = structlog.get_logger() + +console = Console() + class Prefers(BaseModel): JSON: bool = False @@ -30,6 +35,15 @@ class Prefers(BaseModel): text: bool = False markdown: bool = False partial: bool = False + png: bool = False + pdf: bool = False + + def __repr__(self): + _repr = [] + for key, value in self.dict().items(): + if value: + _repr.append(key + "=True") + return f'Prefers({", ".join(_repr)})' @property def textlike(self) -> bool: @@ -37,48 +51,136 @@ class Prefers(BaseModel): @model_validator(mode="after") def check_one_true(self) -> Dict[str, Any]: - format_flags = [self.JSON, self.html, self.rtf, self.text, self.markdown] + format_flags = [ + self.JSON, + self.html, + self.rtf, + self.text, + self.markdown, + self.png, + self.pdf, + ] if format_flags.count(True) != 1: message = "Exactly one of JSON, html, rtf, text, or markdown must be True." raise ValueError(message) + return self + + +def log_request_state(request: Request): + console.log(request.state.span_id) + console.log(request.url.path) + console.log(request.state.prefers) + + +async def add_process_time_header(request: Request, call_next): + start_time = time.perf_counter() + response = await call_next(request) + process_time = time.perf_counter() - start_time + if str(response.status_code)[0] in "123": + response.headers["X-Process-Time"] = str(process_time) + return response + + +def set_bound_logger(request: Request, call_next): + request.state.bound_logger = logger.bind() + return call_next(request) + + +async def set_span_id(request: Request, call_next): + span_id = uuid4() + request.state.span_id = span_id + request.state.bound_logger = logger.bind(span_id=request.state.span_id) + + response = await call_next(request) + + if str(response.status_code)[0] in "123": + response.headers["x-request-id"] = str(span_id) + response.headers["x-span-id"] = str(span_id) + return response def set_prefers( request: Request, + call_next, ): content_type = ( - request.query_params.get("content_type") - or request.headers.get("content-type") - or request.headers.get("accept", None) + request.query_params.get( + "content-type", + request.query_params.get( + "content_type", + request.query_params.get("accept"), + ), + ) + or request.headers.get( + "content-type", + request.headers.get( + "content_type", + request.headers.get("accept"), + ), + ) ).lower() if content_type == "*/*": content_type = None hx_request_header = request.headers.get("hx-request") user_agent = request.headers.get("user-agent", "").lower() + referer = request.headers.get("referer", "") + + if content_type and "," in content_type: + content_type = content_type.split(",")[0] + + request.state.bound_logger.info( + "content_type set", + content_type=content_type, + hx_request_header=hx_request_header, + user_agent=user_agent, + referer=referer, + ) + + if content_type == "*/*": + content_type = None + if ("/docs" in referer or "/redoc" in referer) and content_type is None: + content_type = "application/json" + elif is_browser_request(user_agent) and content_type is None: + request.state.bound_logger.info("browser agent request") + content_type = "text/html" + elif is_rtf_request(user_agent) and content_type is None: + request.state.bound_logger.info("rtf agent request") + content_type = "application/rtf" + elif content_type is None: + request.state.bound_logger.info("no content type request") + content_type = content_type or "application/json" if hx_request_header == "true": - request.state.prefers = Prefers(html=True, partial=True) - return + content_type = "text/html-partial" + # request.state.prefers = Prefers(html=True, partial=True) + # content_type = "text/html" - if is_browser_request(user_agent) and content_type is None: + elif is_browser_request(user_agent) and content_type is None: content_type = "text/html" elif is_rtf_request(user_agent) and content_type is None: content_type = "text/rtf" - elif content_type is None: - content_type = "application/json" + # else: + # content_type = "application/json" + partial = "partial" in content_type # if content_type in ACCEPT_TYPES: - for accept_type, accept_value in ACCEPT_TYPES.items(): - if accept_type in content_type: - request.state.prefers = Prefers(**{ACCEPT_TYPES[accept_value]: True}) - print("content_type:", content_type) - print("prefers:", request.state.prefers) - return - request.state.prefers = Prefers(JSON=True, partial=False) - print("prefers:", request.state.prefers) - print("content_type:", content_type) + # for accept_type, accept_value in ACCEPT_TYPES.items(): + # if accept_type in content_type: + if content_type in ACCEPT_TYPES: + request.state.prefers = Prefers( + **{ACCEPT_TYPES[content_type]: True}, partial=partial + ) + else: + request.state.prefers = Prefers(JSON=True, partial=partial) + + request.state.content_type = content_type + request.state.bound_logger = request.state.bound_logger.bind( + # content_type=request.state.content_type, + prefers=request.state.prefers, + ) + return call_next(request) class Sitemap: @@ -133,6 +235,41 @@ def get_screenshot(html_content: str) -> BytesIO: return buffer +def get_pdf(html_content: str, scale: float = 1.0) -> BytesIO: + chrome_options = Options() + chrome_options.add_argument("--headless") + chrome_options.add_argument("--disable-gpu") + chrome_options.add_argument("--no-sandbox") + chrome_options.add_argument("--window-size=1280x1024") + chrome_options.add_argument("--disable-dev-shm-usage") # Helps avoid memory issues + + driver = webdriver.Chrome(options=chrome_options) + driver.get("data:text/html;charset=utf-8," + html_content) + + # Generate PDF + pdf = driver.execute_cdp_cmd( + "Page.printToPDF", + { + "printBackground": True, # Include CSS backgrounds in the PDF + "paperWidth": 8.27, # A4 paper size width in inches + "paperHeight": 11.69, # A4 paper size height in inches + "marginTop": 0, + "marginBottom": 0, + "marginLeft": 0, + "marginRight": 0, + "scale": scale, + }, + )["data"] + + driver.quit() + + # Convert base64 PDF to BytesIO + pdf_buffer = BytesIO() + pdf_buffer.write(base64.b64decode(pdf)) + pdf_buffer.seek(0) + return pdf_buffer.getvalue() + + def format_json_as_plain_text(data: dict) -> str: """Convert JSON to human-readable plain text format with indentation and bullet points.""" @@ -156,8 +293,8 @@ def format_json_as_plain_text(data: dict) -> str: def format_json_as_rich_text(data: dict, template_name: str) -> str: """Convert JSON to a human-readable rich text format using rich.""" - console = Console() # pretty_data = Pretty(data, indent_guides=True) + console = Console() template = templates.get_template(template_name) html_content = template.render(data=data) @@ -175,19 +312,6 @@ def format_json_as_rich_text(data: dict, template_name: str) -> str: return capture.get() -async def respond_based_on_content_type( - request: Request, - call_next, - content_type: str, - data: str, -): - requested_path = request.url.path - if requested_path in ["/docs", "/redoc", "/openapi.json"]: - return await call_next(request) - - return await call_next(request) - - def handle_not_found(request: Request, call_next, data: str): requested_path = request.url.path # available_routes = [route.path for route in app.router.routes if route.path] @@ -211,78 +335,75 @@ def handle_not_found(request: Request, call_next, data: str): async def respond_based_on_content_type(request: Request, call_next): requested_path = request.url.path - if requested_path in ["/docs", "/redoc", "/openapi.json"]: + if requested_path in ["/docs", "/redoc", "/openapi.json", "/static/app.css"]: + request.state.bound_logger.info( + "protected route returning non-dynamic response" + ) return await call_next(request) try: response = await call_next(request) - user_agent = request.headers.get("user-agent", "").lower() - referer = request.headers.get("referer", "") - content_type = request.query_params.get( - "content_type", - request.headers.get("content-type", request.headers.get("Accept")), - ) - if "raw" in content_type: - return await call_next(request) - if content_type == "*/*": - content_type = None - if ("/docs" in referer or "/redoc" in referer) and content_type is None: - content_type = "application/json" - elif is_browser_request(user_agent) and content_type is None: - content_type = "text/html" - elif is_rtf_request(user_agent) and content_type is None: - content_type = "application/rtf" - elif content_type is None: - content_type = content_type or "application/json" - - body = b"".join([chunk async for chunk in response.body_iterator]) - - data = body.decode("utf-8") - if response.status_code == 404: - return handle_not_found( + request.state.bound_logger.info("404 not found") + body = b"".join([chunk async for chunk in response.body_iterator]) + data = body.decode("utf-8") + response = handle_not_found( request=request, call_next=call_next, data=data, ) - if response.status_code == 422: - return response - if str(response.status_code)[0] not in "123": + elif str(response.status_code)[0] not in "123": + request.state.bound_logger.info(f"non-200 response {response.status_code}") + # return await handle_response(request, response, data) return response + else: + body = b"".join([chunk async for chunk in response.body_iterator]) + data = body.decode("utf-8") - return await handle_response(request, data) - # except TemplateNotFound: - # return HTMLResponse(content="Template Not Found ", status_code=404) - except StarletteHTTPException as exc: - return HTMLResponse( - content=f"Error {exc.status_code}: {exc.detail}", - status_code=exc.status_code, - ) - except RequestValidationError as exc: - return JSONResponse(status_code=422, content={"detail": exc.errors()}) + return await handle_response(request, response, data) except Exception as e: - print(traceback.format_exc()) - return HTMLResponse(content=f"Internal Server Error: {e!s}", status_code=500) + request.state.bound_logger.info("internal server error") + # print(traceback.format_exc()) + raise e + if settings.ENV == "local": + return HTMLResponse( + content=f"Internal Server Error: {e!s} {traceback.format_exc()}", + status_code=500, + ) + else: + return HTMLResponse( + content=f"Internal Server Error: {e!s}", status_code=500 + ) -async def handle_response(request: Request, data: str): +async def handle_response( + request: Request, + response: Response, + data: str, +): json_data = json.loads(data) template_name = getattr(request.state, "template_name", "default_template.html") if request.state.prefers.partial: + request.state.bound_logger = logger.bind(template_name=template_name) template_name = "partial_" + template_name - content_type = request.state.prefers if request.state.prefers.JSON: - return JSONResponse(content=json_data) - - elif request.state.prefers.html: - return templates.TemplateResponse( - template_name, {"request": request, "data": json_data} + request.state.bound_logger.info("returning JSON") + return JSONResponse( + content=json_data, ) - elif request.state.prefers.markdown: + if request.state.prefers.html: + request.state.bound_logger.info("returning html") + return templates.TemplateResponse( + template_name, + {"request": request, "data": json_data}, + ) + + if request.state.prefers.markdown: + request.state.bound_logger.info("returning markdown") import html2text template = templates.get_template(template_name) @@ -290,24 +411,75 @@ async def handle_response(request: Request, data: str): markdown_content = html2text.html2text(html_content) return PlainTextResponse(content=markdown_content) - elif request.state.prefers.text: + if request.state.prefers.text: + request.state.bound_logger.info("returning plain text") plain_text_content = format_json_as_plain_text(json_data) - return PlainTextResponse(content=plain_text_content) + return PlainTextResponse( + content=plain_text_content, + ) - elif request.state.prefers.rtf: + if request.state.prefers.rtf: + request.state.bound_logger.info("returning rich text") rich_text_content = format_json_as_rich_text(json_data, template_name) - return PlainTextResponse(content=rich_text_content) + return PlainTextResponse( + content=rich_text_content, + ) - elif content_type == "image/png": + if request.state.prefers.png: + request.state.bound_logger.info("returning PNG") template = templates.get_template(template_name) html_content = template.render(data=json_data) screenshot = get_screenshot(html_content) - return Response(content=screenshot.getvalue(), media_type="image/png") + return Response( + content=screenshot.getvalue(), + media_type="image/png", + ) - elif content_type == "application/pdf": + if request.state.prefers.pdf: + request.state.bound_logger.info("returning PDF") template = templates.get_template(template_name) html_content = template.render(data=json_data) - pdf = WEAZYHTML(string=html_content).write_pdf() - return Response(content=pdf, media_type="application/pdf") + scale = float( + request.headers.get("scale", request.query_params.get("scale", 1.0)) + ) + console.log(f"Scale: {scale}") + pdf = get_pdf(html_content, scale) - return JSONResponse(content=json_data) + return Response( + content=pdf, + media_type="application/pdf", + ) + + request.state.bound_logger.info("returning DEFAULT JSON") + return JSONResponse( + content=json_data, + ) + + +# Initialize the logger +async def log_requests(request: Request, call_next): + # Log request details + request.state.bound_logger = logger.bind( + method=request.method, path=request.url.path + ) + request.state.bound_logger.info( + "Request received", + ) + # logger.info( + # headers=dict(request.headers), + # prefers=request.state.prefers, + # ) + + # Process the request + response = await call_next(request) + + # Log response details + # logger.info( + # "Response sent", + # span_id=request.state.span_id, + # method=request.method, + # status_code=response.status_code, + # headers=dict(response.headers), + # ) + + return response diff --git a/src/fastapi_dynamic_response/settings.py b/src/fastapi_dynamic_response/settings.py new file mode 100644 index 0000000..ff41d04 --- /dev/null +++ b/src/fastapi_dynamic_response/settings.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, model_validator +from pydantic_settings import BaseSettings + + +class ApiServer(BaseModel): + app: str = "fastapi_dynamic_response.main:app" + port: int = 8000 + reload: bool = True + log_level: str = "info" + host: str = "0.0.0.0" + workers: int = 1 + forwarded_allow_ips: str = "*" + proxy_headers: bool = True + + +class Settings(BaseSettings): + ENV: str = "local" + DEBUG: bool = False + api_server: ApiServer = ApiServer() + + class Config: + env_file = "config.env" + + @model_validator(mode="after") + def validate_debug(self): + if self.ENV == "local" and self.DEBUG is False: + self.DEBUG = True + return self + + +settings = Settings() diff --git a/src/fastapi_dynamic_response/tracing.py b/src/fastapi_dynamic_response/tracing.py new file mode 100644 index 0000000..d73fa73 --- /dev/null +++ b/src/fastapi_dynamic_response/tracing.py @@ -0,0 +1,35 @@ +# tracing.py + +from fastapi_dynamic_response.settings import Settings +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + +# from opentelemetry.exporter.richconsole import RichConsoleSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + + +def configure_tracing(app): + settings = Settings() + trace.set_tracer_provider(TracerProvider()) + tracer_provider = trace.get_tracer_provider() + + if settings.ENV == "local": + # Use console exporter for local development + # span_exporter = RichConsoleSpanExporter() + # span_processor = SimpleSpanProcessor(span_exporter) + # span_exporter = OTLPSpanExporter() + span_exporter = OTLPSpanExporter( + endpoint="http://localhost:4317", insecure=True + ) + span_processor = BatchSpanProcessor(span_exporter) + else: + # Use OTLP exporter for production + span_exporter = OTLPSpanExporter() + span_processor = BatchSpanProcessor(span_exporter) + + tracer_provider.add_span_processor(span_processor) + + # Instrument FastAPI + FastAPIInstrumentor.instrument_app(app) diff --git a/static/app.css b/static/app.css index 99827ca..41a1a8c 100644 --- a/static/app.css +++ b/static/app.css @@ -593,6 +593,19 @@ video { margin-right: auto; } +.my-4 { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.ml-8 { + margin-left: 2rem; +} + +.mt-4 { + margin-top: 1rem; +} + .mt-auto { margin-top: auto; } @@ -609,6 +622,10 @@ video { min-height: 100vh; } +.list-disc { + list-style-type: disc; +} + .flex-col { flex-direction: column; } @@ -649,6 +666,11 @@ video { text-align: center; } +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + .text-xl { font-size: 1.25rem; line-height: 1.75rem; @@ -663,6 +685,16 @@ video { color: rgb(229 231 235 / var(--tw-text-opacity)); } +.text-gray-300 { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity)); +} + +.text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + .text-teal-400 { --tw-text-opacity: 1; color: rgb(45 212 191 / var(--tw-text-opacity)); diff --git a/templates/another_example.html b/templates/another_example.html index d313c56..34109dc 100644 --- a/templates/another_example.html +++ b/templates/another_example.html @@ -3,16 +3,16 @@ {% block title %}Another Example{% endblock %} {% block content %} -

    Example

    +

    Example

    {{ data.message }}

    -

    Items

    -

    +

    Items

    +

    there are {{ data.get('items', [])|length }} items in the list

    -
      +
        {% for item in data.get('items', []) %}
      • {{ item }}
      • {% endfor %} diff --git a/templates/base.html b/templates/base.html index 86c3e9d..ae038fe 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,8 +4,9 @@ {% block title %}FastAPI Dynamic Response{% endblock %} - - + + +
        diff --git a/uv.lock b/uv.lock index e9df9c5..11c4369 100644 --- a/uv.lock +++ b/uv.lock @@ -57,6 +57,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/c4/65456561d89d3c49f46b7fbeb8fe6e449f13bdc8ea7791832c5d476b2faf/Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d", size = 2809981 }, { url = "https://files.pythonhosted.org/packages/05/1b/cf49528437bae28abce5f6e059f0d0be6fecdcc1d3e33e7c54b3ca498425/Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0", size = 2935297 }, { url = "https://files.pythonhosted.org/packages/81/ff/190d4af610680bf0c5a09eb5d1eac6e99c7c8e216440f9c7cfd42b7adab5/Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e", size = 2930735 }, + { url = "https://files.pythonhosted.org/packages/80/7d/f1abbc0c98f6e09abd3cad63ec34af17abc4c44f308a7a539010f79aae7a/Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c", size = 2933107 }, + { url = "https://files.pythonhosted.org/packages/34/ce/5a5020ba48f2b5a4ad1c0522d095ad5847a0be508e7d7569c8630ce25062/Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1", size = 2845400 }, + { url = "https://files.pythonhosted.org/packages/44/89/fa2c4355ab1eecf3994e5a0a7f5492c6ff81dfcb5f9ba7859bd534bb5c1a/Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2", size = 3031985 }, + { url = "https://files.pythonhosted.org/packages/af/a4/79196b4a1674143d19dca400866b1a4d1a089040df7b93b88ebae81f3447/Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec", size = 2927099 }, { url = "https://files.pythonhosted.org/packages/e9/54/1c0278556a097f9651e657b873ab08f01b9a9ae4cac128ceb66427d7cd20/Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2", size = 333172 }, { url = "https://files.pythonhosted.org/packages/f7/65/b785722e941193fd8b571afd9edbec2a9b838ddec4375d8af33a50b8dab9/Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128", size = 357255 }, { url = "https://files.pythonhosted.org/packages/96/12/ad41e7fadd5db55459c4c401842b47f7fee51068f86dd2894dd0dcfc2d2a/Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", size = 873068 }, @@ -69,8 +73,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/0bd38d758d1afa62a5524172f0b18626bb2392d717ff94806f741fcd5ee9/Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", size = 2813051 }, { url = "https://files.pythonhosted.org/packages/14/56/48859dd5d129d7519e001f06dcfbb6e2cf6db92b2702c0c2ce7d97e086c1/Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", size = 2938172 }, { url = "https://files.pythonhosted.org/packages/3d/77/a236d5f8cd9e9f4348da5acc75ab032ab1ab2c03cc8f430d24eea2672888/Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", size = 2933023 }, + { url = "https://files.pythonhosted.org/packages/f1/87/3b283efc0f5cb35f7f84c0c240b1e1a1003a5e47141a4881bf87c86d0ce2/Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", size = 2935871 }, + { url = "https://files.pythonhosted.org/packages/f3/eb/2be4cc3e2141dc1a43ad4ca1875a72088229de38c68e842746b342667b2a/Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", size = 2847784 }, + { url = "https://files.pythonhosted.org/packages/66/13/b58ddebfd35edde572ccefe6890cf7c493f0c319aad2a5badee134b4d8ec/Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", size = 3034905 }, + { url = "https://files.pythonhosted.org/packages/84/9c/bc96b6c7db824998a49ed3b38e441a2cae9234da6fa11f6ed17e8cf4f147/Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", size = 2929467 }, { url = "https://files.pythonhosted.org/packages/e7/71/8f161dee223c7ff7fea9d44893fba953ce97cf2c3c33f78ba260a91bcff5/Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", size = 333169 }, { url = "https://files.pythonhosted.org/packages/02/8a/fece0ee1057643cb2a5bbf59682de13f1725f8482b2c057d4e799d7ade75/Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", size = 357253 }, + { url = "https://files.pythonhosted.org/packages/5c/d0/5373ae13b93fe00095a58efcbce837fd470ca39f703a235d2a999baadfbc/Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", size = 815693 }, + { url = "https://files.pythonhosted.org/packages/8e/48/f6e1cdf86751300c288c1459724bfa6917a80e30dbfc326f92cea5d3683a/Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", size = 422489 }, { url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081 }, { url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244 }, { url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505 }, @@ -81,8 +91,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452 }, { url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751 }, { url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757 }, + { url = "https://files.pythonhosted.org/packages/13/f0/358354786280a509482e0e77c1a5459e439766597d280f28cb097642fc26/Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", size = 2936146 }, + { url = "https://files.pythonhosted.org/packages/80/f7/daf538c1060d3a88266b80ecc1d1c98b79553b3f117a485653f17070ea2a/Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", size = 2848055 }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0eaa0585c4077d3c2d1edf322d8e97aabf317941d3a72d7b3ad8bce004b0/Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", size = 3035102 }, + { url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029 }, { url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276 }, { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255 }, + { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681 }, + { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475 }, + { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173 }, + { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803 }, + { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946 }, + { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707 }, + { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231 }, + { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157 }, + { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122 }, + { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206 }, + { url = "https://files.pythonhosted.org/packages/c2/f0/a61d9262cd01351df22e57ad7c34f66794709acab13f34be2675f45bf89d/Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", size = 333804 }, + { url = "https://files.pythonhosted.org/packages/7e/c1/ec214e9c94000d1c1974ec67ced1c970c148aa6b8d8373066123fc3dbf06/Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", size = 358517 }, { url = "https://files.pythonhosted.org/packages/34/1b/16114a20c0a43c20331f03431178ed8b12280b12c531a14186da0bc5b276/Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3", size = 873053 }, { url = "https://files.pythonhosted.org/packages/36/49/2afe4aa5a23a13dad4c7160ae574668eec58b3c80b56b74a826cebff7ab8/Brotli-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208", size = 446211 }, { url = "https://files.pythonhosted.org/packages/10/9d/6463edb80a9e0a944f70ed0c4d41330178526626d7824f729e81f78a3f24/Brotli-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7", size = 2904604 }, @@ -93,6 +119,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/53/110657f4017d34a2e9a96d9630a388ad7e56092023f1d46d11648c6c0bce/Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a", size = 2809968 }, { url = "https://files.pythonhosted.org/packages/3f/2a/fbc95429b45e4aa4a3a3a815e4af11772bfd8ef94e883dcff9ceaf556662/Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088", size = 2935402 }, { url = "https://files.pythonhosted.org/packages/4e/52/02acd2992e5a2c10adf65fa920fad0c29e11e110f95eeb11bcb20342ecd2/Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596", size = 2931208 }, + { url = "https://files.pythonhosted.org/packages/6b/35/5d258d1aeb407e1fc6fcbbff463af9c64d1ecc17042625f703a1e9d22ec5/Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7", size = 2933171 }, + { url = "https://files.pythonhosted.org/packages/cc/58/b25ca26492da9880e517753967685903c6002ddc2aade93d6e56df817b30/Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5", size = 2845347 }, + { url = "https://files.pythonhosted.org/packages/12/cf/91b84beaa051c9376a22cc38122dc6fbb63abcebd5a4b8503e9c388de7b1/Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943", size = 3031668 }, + { url = "https://files.pythonhosted.org/packages/38/05/04a57ba75aed972be0c6ad5f2f5ea34c83f5fecf57787cc6e54aac21a323/Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a", size = 2926949 }, { url = "https://files.pythonhosted.org/packages/c9/2f/fbe6938f33d2cd9b7d7fb591991eb3fb57ffa40416bb873bbbacab60a381/Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b", size = 333179 }, { url = "https://files.pythonhosted.org/packages/39/a5/9322c8436072e77b8646f6bde5e19ee66f62acf7aa01337ded10777077fa/Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0", size = 357254 }, { url = "https://files.pythonhosted.org/packages/1b/aa/aa6e0c9848ee4375514af0b27abf470904992939b7363ae78fc8aca8a9a8/Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a", size = 873048 }, @@ -105,6 +135,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/1f/be9443995821c933aad7159803f84ef4923c6f5b72c2affd001192b310fc/Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c", size = 2809728 }, { url = "https://files.pythonhosted.org/packages/76/2f/213bab6efa902658c80a1247142d42b138a27ccdd6bade49ca9cd74e714a/Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d", size = 2935043 }, { url = "https://files.pythonhosted.org/packages/27/89/bbb14fa98e895d1e601491fba54a5feec167d262f0d3d537a3b0d4cd0029/Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59", size = 2930639 }, + { url = "https://files.pythonhosted.org/packages/14/87/03a6d6e1866eddf9f58cc57e35befbeb5514da87a416befe820150cae63f/Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419", size = 2932834 }, + { url = "https://files.pythonhosted.org/packages/a4/d5/e5f85e04f75144d1a89421ba432def6bdffc8f28b04f5b7d540bbd03362c/Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2", size = 2845213 }, + { url = "https://files.pythonhosted.org/packages/99/bf/25ef07add7afbb1aacd4460726a1a40370dfd60c0810b6f242a6d3871d7e/Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f", size = 3031573 }, + { url = "https://files.pythonhosted.org/packages/55/22/948a97bda5c9dc9968d56b9ed722d9727778db43739cf12ef26ff69be94d/Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb", size = 2926885 }, { url = "https://files.pythonhosted.org/packages/31/ba/e53d107399b535ef89deb6977dd8eae468e2dde7b1b74c6cbe2c0e31fda2/Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64", size = 333171 }, { url = "https://files.pythonhosted.org/packages/99/b3/f7b3af539f74b82e1c64d28685a5200c631cc14ae751d37d6ed819655627/Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467", size = 357258 }, ] @@ -291,13 +325,17 @@ source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "html2text" }, + { name = "itsdangerous" }, { name = "jinja2" }, { name = "markdown" }, { name = "pillow" }, + { name = "pydantic-settings" }, { name = "pydyf" }, { name = "python-levenshtein" }, { name = "rich" }, { name = "selenium" }, + { name = "structlog" }, + { name = "typer" }, { name = "uvicorn" }, { name = "weasyprint" }, ] @@ -306,13 +344,17 @@ dependencies = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "html2text", specifier = ">=2024.2.26" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "jinja2", specifier = ">=3.1.4" }, { name = "markdown", specifier = ">=3.7" }, { name = "pillow", specifier = ">=10.4.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "pydyf", specifier = "==0.8.0" }, { name = "python-levenshtein", specifier = ">=0.25.1" }, { name = "rich", specifier = ">=13.9.2" }, { name = "selenium", specifier = ">=4.25.0" }, + { name = "structlog", specifier = ">=24.4.0" }, + { name = "typer", specifier = ">=0.12.5" }, { name = "uvicorn", specifier = ">=0.31.1" }, { name = "weasyprint", specifier = ">=61.2" }, ] @@ -418,14 +460,23 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.5.0" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, + { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269 }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, ] [[package]] @@ -852,6 +903,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/d9/1d7ecb98318da4cb96986daaf0e20d66f1651d0aeb9e2d4435b916ce031d/pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", size = 1920855 }, ] +[[package]] +name = "pydantic-settings" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/27/0bed9dd26b93328b60a1402febc780e7be72b42847fa8b5c94b7d0aeb6d1/pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0", size = 70938 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/8d/29e82e333f32d9e2051c10764b906c2a6cd140992910b5f49762790911ba/pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907", size = 26864 }, +] + [[package]] name = "pydyf" version = "0.8.0" @@ -888,6 +952,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, ] +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + [[package]] name = "python-levenshtein" version = "0.25.1" @@ -1046,6 +1119,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/85/fa44f23dd5d5066a72f7c4304cce4b5ff9a6e7fd92431a48b2c63fbf63ec/selenium-4.25.0-py3-none-any.whl", hash = "sha256:3798d2d12b4a570bc5790163ba57fef10b2afee958bf1d80f2a3cf07c4141f33", size = 9693127 }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + [[package]] name = "six" version = "1.16.0" @@ -1086,6 +1168,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/9c/93f7bc03ff03199074e81974cc148908ead60dcf189f68ba1761a0ee35cf/starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05", size = 71451 }, ] +[[package]] +name = "structlog" +version = "24.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/a3/e811a94ac3853826805253c906faa99219b79951c7d58605e89c79e65768/structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4", size = 1348634 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/65/813fc133609ebcb1299be6a42e5aea99d6344afb35ccb43f67e7daaa3b92/structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610", size = 67180 }, +] + [[package]] name = "tinycss2" version = "1.3.0" @@ -1130,6 +1221,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/be/a9ae5f50cad5b6f85bd2574c2c923730098530096e170c1ce7452394d7aa/trio_websocket-0.11.1-py3-none-any.whl", hash = "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638", size = 17408 }, ] +[[package]] +name = "typer" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/58/a79003b91ac2c6890fc5d90145c662fd5771c6f11447f116b63300436bc9/typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722", size = 98953 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/2b/886d13e742e514f704c33c4caa7df0f3b89e5a25ef8db02aa9ca3d9535d5/typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b", size = 47288 }, +] + [[package]] name = "typing-extensions" version = "4.12.2"