add structlog
This commit is contained in:
parent
7f0934ac14
commit
f64e488ab1
7 changed files with 323 additions and 36 deletions
|
|
@ -30,10 +30,12 @@ dependencies = [
|
|||
"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",
|
||||
"uvicorn>=0.31.1",
|
||||
"weasyprint>=61.2",
|
||||
]
|
||||
|
|
|
|||
114
src/fastapi_dynamic_response/logging_config.py
Normal file
114
src/fastapi_dynamic_response/logging_config.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# logging_config.py
|
||||
import logging
|
||||
import structlog
|
||||
import sys
|
||||
|
||||
from structlog.dev import ConsoleRenderer
|
||||
from structlog.processors import JSONRenderer
|
||||
|
||||
from rich.traceback import install
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
install(show_locals=True)
|
||||
|
||||
|
||||
def configure_logging_one(dev_mode: bool = True):
|
||||
"""Configure structlog based on the mode (dev or prod)."""
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=logging.DEBUG if dev_mode else logging.INFO,
|
||||
)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"), # Add timestamps
|
||||
structlog.stdlib.add_log_level, # Add log levels
|
||||
structlog.processors.StackInfoRenderer(), # Render stack info
|
||||
structlog.processors.format_exc_info, # Format exceptions
|
||||
# structlog.dev.RichTracebackFormatter(),
|
||||
ConsoleRenderer(
|
||||
# exception_formatter=structlog.dev.rich_traceback
|
||||
)
|
||||
if dev_mode
|
||||
else JSONRenderer(), # Render logs nicely for dev
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi_dynamic_response.settings import Settings
|
||||
import structlog
|
||||
|
||||
|
||||
def configure_logging_two():
|
||||
settings = Settings()
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
configure_logging = configure_logging_two
|
||||
|
|
@ -7,14 +7,19 @@ 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,
|
||||
add_process_time_header,
|
||||
catch_exceptions_middleware,
|
||||
log_request_state,
|
||||
log_requests,
|
||||
respond_based_on_content_type,
|
||||
set_prefers,
|
||||
set_span_id,
|
||||
)
|
||||
from fastapi_dynamic_response.zpages.router import router as zpages_router
|
||||
|
||||
from fastapi_dynamic_response.logging_config import configure_logging
|
||||
|
||||
|
||||
configure_logging()
|
||||
app = FastAPI(
|
||||
title="FastAPI Dynamic Response",
|
||||
version=__version__,
|
||||
|
|
@ -25,16 +30,23 @@ app = FastAPI(
|
|||
# exception_handlers=exception_handlers,
|
||||
debug=True,
|
||||
dependencies=[
|
||||
Depends(set_prefers),
|
||||
Depends(set_span_id),
|
||||
Depends(log_request_state),
|
||||
# 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.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from difflib import get_close_matches
|
||||
from io import BytesIO
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Dict
|
||||
from uuid import uuid4
|
||||
|
|
@ -23,6 +24,10 @@ 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()
|
||||
|
||||
|
||||
|
|
@ -36,6 +41,13 @@ class Prefers(BaseModel):
|
|||
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:
|
||||
return self.rtf or self.text or self.markdown
|
||||
|
|
@ -63,13 +75,29 @@ def log_request_state(request: Request):
|
|||
console.log(request.state.prefers)
|
||||
|
||||
|
||||
def set_span_id(request: Request):
|
||||
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
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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(
|
||||
|
|
@ -93,24 +121,31 @@ def set_prefers(
|
|||
user_agent = request.headers.get("user-agent", "").lower()
|
||||
|
||||
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:
|
||||
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_value: True})
|
||||
return
|
||||
request.state.prefers = Prefers(JSON=True, partial=False)
|
||||
request.state.prefers = Prefers(**{accept_value: 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:
|
||||
|
|
@ -318,7 +353,7 @@ async def respond_based_on_content_type(request: Request, call_next):
|
|||
if str(response.status_code)[0] not in "123":
|
||||
return response
|
||||
|
||||
return await handle_response(request, data)
|
||||
return await handle_response(request, response, data)
|
||||
# except TemplateNotFound:
|
||||
# return HTMLResponse(content="Template Not Found ", status_code=404)
|
||||
except StarletteHTTPException as exc:
|
||||
|
|
@ -333,45 +368,55 @@ async def respond_based_on_content_type(request: Request, call_next):
|
|||
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:
|
||||
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}
|
||||
return JSONResponse(
|
||||
content=json_data,
|
||||
)
|
||||
|
||||
elif request.state.prefers.markdown:
|
||||
if request.state.prefers.html:
|
||||
return templates.TemplateResponse(
|
||||
template_name,
|
||||
{"request": request, "data": json_data},
|
||||
headers=response.headers,
|
||||
)
|
||||
|
||||
if request.state.prefers.markdown:
|
||||
import html2text
|
||||
|
||||
template = templates.get_template(template_name)
|
||||
html_content = template.render(data=json_data)
|
||||
markdown_content = html2text.html2text(html_content)
|
||||
return PlainTextResponse(content=markdown_content)
|
||||
return PlainTextResponse(content=markdown_content, headers=response.headers)
|
||||
|
||||
elif request.state.prefers.text:
|
||||
if request.state.prefers.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:
|
||||
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 request.state.prefers.png:
|
||||
if request.state.prefers.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 request.state.prefers.pdf:
|
||||
if request.state.prefers.pdf:
|
||||
template = templates.get_template(template_name)
|
||||
html_content = template.render(data=json_data)
|
||||
scale = float(
|
||||
|
|
@ -380,6 +425,42 @@ async def handle_response(request: Request, data: str):
|
|||
console.log(f"Scale: {scale}")
|
||||
pdf = get_pdf(html_content, scale)
|
||||
|
||||
return Response(content=pdf, media_type="application/pdf")
|
||||
return Response(
|
||||
content=pdf,
|
||||
media_type="application/pdf",
|
||||
)
|
||||
|
||||
return JSONResponse(content=json_data)
|
||||
return JSONResponse(
|
||||
content=json_data,
|
||||
)
|
||||
|
||||
|
||||
# Initialize the logger
|
||||
async def log_requests(request: Request, call_next):
|
||||
# Log request details
|
||||
request.state.bound_logger.info(
|
||||
"Request received",
|
||||
# span_id=request.state.span_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
# headers=dict(request.headers),
|
||||
# prefers=request.state.prefers,
|
||||
)
|
||||
# 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
|
||||
|
|
|
|||
8
src/fastapi_dynamic_response/settings.py
Normal file
8
src/fastapi_dynamic_response/settings.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
ENV: str = "local"
|
||||
|
||||
class Config:
|
||||
env_file = "config.env"
|
||||
35
src/fastapi_dynamic_response/tracing.py
Normal file
35
src/fastapi_dynamic_response/tracing.py
Normal file
|
|
@ -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)
|
||||
41
uv.lock
generated
41
uv.lock
generated
|
|
@ -294,10 +294,12 @@ dependencies = [
|
|||
{ name = "jinja2" },
|
||||
{ name = "markdown" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pydyf" },
|
||||
{ name = "python-levenshtein" },
|
||||
{ name = "rich" },
|
||||
{ name = "selenium" },
|
||||
{ name = "structlog" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "weasyprint" },
|
||||
]
|
||||
|
|
@ -309,10 +311,12 @@ requires-dist = [
|
|||
{ 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 = "uvicorn", specifier = ">=0.31.1" },
|
||||
{ name = "weasyprint", specifier = ">=61.2" },
|
||||
]
|
||||
|
|
@ -418,14 +422,14 @@ 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]]
|
||||
|
|
@ -852,6 +856,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 +905,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"
|
||||
|
|
@ -1086,6 +1112,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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue