Compare commits

...

10 commits

Author SHA1 Message Date
9c83f862e0 wip 2025-11-22 22:00:19 -06:00
Waylon S. Walker
2e03f31a09 docker 2024-10-29 20:14:57 -05:00
Waylon S. Walker
986b8ba632 add Docker 2024-10-24 09:36:10 -05:00
Waylon S. Walker
feb21a4292 add auth 2024-10-21 08:27:28 -05:00
Waylon S. Walker
e334e711cc sort accepts 2024-10-21 07:44:46 -05:00
Waylon S. Walker
19db26b0cb better errors 2024-10-17 08:20:37 -05:00
Waylon S. Walker
c8afba360b clean up logging 2024-10-17 07:39:28 -05:00
Waylon S. Walker
7c1b153020 fix return types 2024-10-16 21:13:53 -05:00
Waylon S. Walker
f64e488ab1 add structlog 2024-10-16 20:46:11 -05:00
Waylon S. Walker
7f0934ac14 better pdf using headless chrome 2024-10-15 15:53:32 -05:00
28 changed files with 1968 additions and 139 deletions

136
Dockerfile Normal file
View file

@ -0,0 +1,136 @@
FROM ubuntu:noble AS build
# The following does not work in Podman unless you build in Docker
# compatibility mode: <https://github.com/containers/podman/issues/8477>
# 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 <<EOT
apt-get update -qy
apt-get install -qyy \
-o APT::Install-Recommends=false \
-o APT::Install-Suggests=false \
build-essential \
ca-certificates \
python3-setuptools \
python3.12-dev
EOT
# Security-conscious organizations should package/review uv themselves.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# - Silence uv complaining about not being able to use hard links,
# - tell uv to byte-compile packages for faster application startups,
# - prevent uv from accidentally downloading isolated Python builds,
# - pick a Python,
# - and finally declare `/app` as the target for `uv sync`.
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PYTHON_DOWNLOADS=never \
UV_PYTHON=python3.12 \
UV_PROJECT_ENVIRONMENT=/app
### End build prep -- this is where your app Dockerfile should start.
# Since there's no point in shipping lock files, we move them
# into a directory that is NOT copied into the runtime image.
# The trailing slash makes COPY create `/_lock/` automagically.
COPY pyproject.toml /_lock/
COPY uv.lock /_lock/
# Synchronize DEPENDENCIES without the application itself.
# This layer is cached until uv.lock or pyproject.toml change.
# You can create `/app` using `uv venv` in a separate `RUN`
# step to have it cached, but with uv it's so fast, it's not worth
# it, so we let `uv sync` create it for us automagically.
RUN --mount=type=cache,target=/root/.cache <<EOT
cd /_lock
mkdir -p src/fastapi_dynamic_response
echo '__version__ = "0.0.0"' > 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 <<EOT
groupadd -r app
useradd -r -d /app -g app -N app
EOT
ENTRYPOINT ["/docker-entrypoint.sh"]
# See <https://hynek.me/articles/docker-signals/>.
STOPSIGNAL SIGINT
# Note how the runtime dependencies differ from build-time ones.
# Notably, there is no uv either!
RUN <<EOT
apt-get update -qy
apt-get install -qyy \
-o APT::Install-Recommends=false \
-o APT::Install-Suggests=false \
python3.12 \
libpython3.12 \
libpcre3 \
libxml2
apt-get clean
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
EOT
COPY docker-entrypoint.sh /
# Copy the pre-built `/app` directory to the runtime container
# and change the ownership to user app and group app in one step.
COPY --from=build --chown=app:app /app /app
# If your application is NOT a proper Python package that got
# pip-installed above, you need to copy your application into
# the container HERE:
# COPY . /app/whereever-your-entrypoint-finds-it
USER app
WORKDIR /app
# Strictly optional, but I like it for introspection of what I've built
# and run a smoke test that the application can, in fact, be imported.
RUN <<EOT
set -e
python -V
python -Im site
python -Ic 'import fastapi_dynamic_response'
EOT
COPY static static
COPY templates templates
EXPOSE 8000
ENTRYPOINT ["fdr_app"]
CMD ["app", "run", ]

2
docker-entrypoint.sh Normal file
View file

@ -0,0 +1,2 @@
#!/bin/bash
fdr_app app run

View file

@ -1,29 +1,77 @@
set dotenv-load
default: default:
@just --choose @just --choose
setup: kind-create
teardown: kind-delete
version:
echo ${VERSION}
kind-create:
kind create cluster --name fastapi-dynamic-response --config kind-config.yaml
kind load docker-image --name fastapi-dynamic-response docker.io/waylonwalker/fastapi-dynamic-response:${VERSION}
kind-delete:
kind delete cluster --name fastapi-dynamic-response
argo-install:
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl get pods -n argocd
kubectl apply -f argo
compile:
uv pip compile pyproject.toml -o requirements.txt
venv: venv:
uv venv uv venv
build-podman:
podman build -t docker.io/waylonwalker/fastapi-dynamic-response:${VERSION} .
run: run:
uv run -- uvicorn --reload --log-level debug src.fastapi_dynamic_response.main:app @just -l | grep '^\s*run-' | gum filter --header 'Choose a command' | xargs -I {} just {}
run-local:
uv run -- fdr_app app run
run-workers:
uv run -- uvicorn --workers 6 --log-level debug src.fastapi_dynamic_response.main:app
run-podman:
podman run -it --rm -p 8000:8000 --name fastapi-dynamic-response docker.io/waylonwalker/fastapi-dynamic-response:${VERSION} app run
run-podman-bash:
podman run -it --rm -p 8000:8000 --name fastapi-dynamic-response --entrypoint bash docker.io/waylonwalker/fastapi-dynamic-response:${VERSION}
local-run:
uv run -- uvicorn --workers 6 --log-level debug src.fastapi_dynamic_response.main:app
push-podman:
podman push docker.io/waylonwalker/fastapi-dynamic-response:${VERSION}
podman tag docker.io/waylonwalker/fastapi-dynamic-response:${VERSION} docker.io/waylonwalker/fastapi-dynamic-response:latest
podman push docker.io/waylonwalker/fastapi-dynamic-response:latest
get-authorized:
http GET :8000/example 'Authorization:Basic user1:password123'
get-admin:
http GET :8000/example 'Authorization:Basic user2:securepassword'
get: get:
http GET :8000/example http GET :8000/example
get-plain: get-plain:
http GET :8000/exa Content-Type=text/plain http GET :8000/exa Content-Type:text/plain
get-rtf: get-rtf:
http GET :8000/example Content-Type=application/rtf http GET :8000/example Content-Type:application/rtf
get-json: get-json:
http GET :8000 Content-Type=application/json http GET :8000/example Content-Type:application/json
get-html: get-html:
http GET :8000 Content-Type=text/html http GET :8000/example Content-Type:text/html
get-md: get-md:
http GET :8000 Content-Type=application/markdown http GET :8000/example Content-Type:application/markdown
livez: livez:

153
k8s/base/deployment.yaml Normal file
View file

@ -0,0 +1,153 @@
apiVersion: v1
kind: Namespace
metadata:
name: fastapi-dynamic-response
namespace: fastapi-dynamic-response
---
apiVersion: v1
kind: Service
metadata:
name: fastapi-dynamic-response
namespace: fastapi-dynamic-response
spec:
selector:
app: fastapi-dynamic-response
ports:
- name: "8000"
port: 8000
targetPort: 8000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fastapi-dynamic-response
namespace: fastapi-dynamic-response
labels:
app: fastapi-dynamic-response
version: "0.0.3"
owner: "waylonwalker"
annotations:
email: "fastapi-dynamic-response@fastapi-dynamic-response.com"
spec:
replicas: 3
selector:
matchLabels:
app: fastapi-dynamic-response
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: fastapi-dynamic-response
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: fastapi-dynamic-response
topologyKey: "kubernetes.io/hostname"
containers:
- image: docker.io/waylonwalker/fastapi-dynamic-response:0.0.2
name: fastapi-dynamic-response
ports:
- containerPort: 8000
protocol: TCP
imagePullPolicy: Always
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsUser: 10001
runAsGroup: 10001
readinessProbe:
httpGet:
path: /livez
port: 8000
initialDelaySeconds: 3
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 3
periodSeconds: 15
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 100Mi
ephemeral-storage: 1Gi
limits:
cpu: 500m
memory: 500Mi
ephemeral-storage: 2Gi
restartPolicy: Always
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fastapi-dynamic-response
namespace: fastapi-dynamic-response
spec:
ingressClassName: nginx
rules:
- host: fastapi-dynamic-response.waylonwalker.com
http:
paths:
- backend:
service:
name: fastapi-dynamic-response
port:
number: 8000
path: /
pathType: Prefix
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-fastapi-dynamic-response
namespace: fastapi-dynamic-response
spec:
podSelector:
matchLabels:
app: fastapi-dynamic-response
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector: {}
# - namespaceSelector:
# matchLabels:
# name: fastapi-dynamic-response
ports:
- protocol: TCP
port: 8000
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- protocol: TCP
port: 443
- protocol: TCP
port: 80
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: fastapi-dynamic-response-pdb
namespace: fastapi-dynamic-response
spec:
minAvailable: 1
selector:
matchLabels:
app: fastapi-dynamic-response

12
kind-config.yaml Normal file
View file

@ -0,0 +1,12 @@
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 30080
protocol: TCP
extraMounts:
- hostPath: ./sqlite-data
containerPath: /sqlite-data

BIN
kube-linter Normal file

Binary file not shown.

BIN
kube-score Normal file

Binary file not shown.

View file

@ -0,0 +1,18 @@
receivers:
otlp:
protocols:
grpc:
http:
exporters:
otlp:
endpoint: "0.0.0.0:14250"
tls:
insecure: true
processors:
batch:
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp]

View file

@ -27,13 +27,17 @@ classifiers = [
dependencies = [ dependencies = [
"fastapi>=0.115.0", "fastapi>=0.115.0",
"html2text>=2024.2.26", "html2text>=2024.2.26",
"itsdangerous>=2.2.0",
"jinja2>=3.1.4", "jinja2>=3.1.4",
"markdown>=3.7", "markdown>=3.7",
"pillow>=10.4.0", "pillow>=10.4.0",
"pydantic-settings>=2.5.2",
"pydyf==0.8.0", "pydyf==0.8.0",
"python-levenshtein>=0.25.1", "python-levenshtein>=0.25.1",
"rich>=13.9.2", "rich>=13.9.2",
"selenium>=4.25.0", "selenium>=4.25.0",
"structlog>=24.4.0",
"typer>=0.12.5",
"uvicorn>=0.31.1", "uvicorn>=0.31.1",
"weasyprint>=61.2", "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" Issues = "https://github.com/U.N. Owen/fastapi-dynamic-response/issues"
Source = "https://github.com/U.N. Owen/fastapi-dynamic-response" Source = "https://github.com/U.N. Owen/fastapi-dynamic-response"
[project.scripts]
fdr_app = "fastapi_dynamic_response.cli.cli:app"
[tool.hatch.version] [tool.hatch.version]
path = "src/fastapi_dynamic_response/__about__.py" path = "src/fastapi_dynamic_response/__about__.py"

131
requirements.txt Normal file
View file

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

612
sitemap.html Normal file
View file

@ -0,0 +1,612 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sitemap</title>
<style>
*, ::before, ::after {
}
::backdrop {
}
/*
! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com
*/
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: #e5e7eb;
/* 2 */
}
::before,
::after {
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/
html,
:host {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
-o-tab-size: 4;
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
font-variation-settings: normal;
/* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-feature-settings: normal;
/* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em;
/* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%;
/* 1 */
font-weight: inherit;
/* 1 */
line-height: inherit;
/* 1 */
letter-spacing: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
input:where([type='button']),
input:where([type='reset']),
input:where([type='submit']) {
-webkit-appearance: button;
/* 1 */
background-color: transparent;
/* 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::-moz-placeholder, textarea::-moz-placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
.container {
width: 100%;
}
@media (min-width: 640px) {
.container {
max-width: 640px;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
}
@media (min-width: 1280px) {
.container {
max-width: 1280px;
}
}
@media (min-width: 1536px) {
.container {
max-width: 1536px;
}
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.mt-auto {
margin-top: auto;
}
.block {
display: block;
}
.flex {
display: flex;
}
.min-h-screen {
min-height: 100vh;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
}
.bg-gray-800 {
}
.bg-gray-900 {
}
.p-4 {
padding: 1rem;
}
.text-center {
text-align: center;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.font-bold {
font-weight: 700;
}
.text-gray-200 {
}
.text-teal-400 {
}
.hover\:text-teal-300:hover {
}
</style>
</head>
<body class="bg-gray-900 text-gray-200 min-h-screen flex flex-col">
<header class="bg-gray-800 p-4">
<div class="container mx-auto flex justify-between items-center">
<a href="/" class="text-xl font-bold text-teal-400">FastAPI Dynamic Response</a>
<nav class="space-x-4">
<a href="/example" class="hover:text-teal-300">Example</a>
<a href="/another-example" class="hover:text-teal-300">Another Example</a>
<a href="/message" class="hover:text-teal-300">Message</a>
<a href="/sitemap" class="hover:text-teal-300">Sitemap</a>
</nav>
</div>
</header>
<main class="container mx-auto p-4">
<h1>Sitemap</h1>
<li><a href="/livez">/livez</a> </li>
<li><a href="/readyz">/readyz</a> </li>
<li><a href="/healthz">/healthz</a> </li>
<li><a href="/example">/example</a> </li>
<li><a href="/another-example">/another-example</a> </li>
<li><a href="/message">/message</a> </li>
<li><a href="/static">/static</a> </li>
<li><a href="/sitemap">/sitemap</a> </li>
</main>
<footer class="bg-gray-800 text-center p-4 mt-auto justify-end">
<p>&copy; 2024 FastApi Dynamic Response</p>
</footer>
</body>
</html>

0
sitemap.pdf Normal file
View file

BIN
sitemap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -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 <username>:<password>"
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}

View file

@ -1,7 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter, Depends, Request
from fastapi import Depends
from fastapi import Request
from fastapi_dynamic_response.auth import admin, authenticated, has_scope
from fastapi_dynamic_response.base.schema import Message from fastapi_dynamic_response.base.schema import Message
from fastapi_dynamic_response.dependencies import get_content_type 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]} 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") @router.get("/another-example")
async def another_example( async def another_example(
request: Request, 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") @router.post("/message")
async def message( async def message(
request: Request, request: Request,

View file

@ -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()

View file

@ -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()

View file

@ -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")

View file

@ -1,21 +1,32 @@
ACCEPT_TYPES = { ACCEPT_TYPES = {
"application/json": "JSON",
"text/html": "html",
"application/html": "html", "application/html": "html",
"text/html-partial": "html", "application/html-partial": "html",
"text/html-fragment": "html", "application/json": "JSON",
"text/rich": "rtf",
"application/rtf": "rtf",
"text/rtf": "rtf",
"text/plain": "text",
"application/text": "text",
"application/markdown": "markdown", "application/markdown": "markdown",
"text/markdown": "markdown", "application/md": "markdown",
"text/x-markdown": "markdown", "application/pdf": "pdf",
"json": "JSON", "application/plain": "text",
"application/rtf": "rtf",
"application/text": "text",
"html": "html", "html": "html",
"rtf": "rtf", "image/png": "png",
"plain": "text", "json": "JSON",
"markdown": "markdown", "markdown": "markdown",
"md": "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",
} }

View file

@ -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}")

View file

@ -1,18 +1,27 @@
from fastapi import Depends, FastAPI, Request from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi_dynamic_response import globals from fastapi_dynamic_response import globals
from fastapi_dynamic_response.__about__ import __version__ from fastapi_dynamic_response.__about__ import __version__
from fastapi_dynamic_response.base.router import router as base_router from fastapi_dynamic_response.base.router import router as base_router
from fastapi_dynamic_response.dependencies import get_content_type 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.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( app = FastAPI(
title="FastAPI Dynamic Response", title="FastAPI Dynamic Response",
version=__version__, version=__version__,
@ -21,20 +30,35 @@ app = FastAPI(
openapi_url=None, openapi_url=None,
# openapi_tags=tags_metadata, # openapi_tags=tags_metadata,
# exception_handlers=exception_handlers, # exception_handlers=exception_handlers,
debug=True, debug=settings.DEBUG,
dependencies=[ 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(zpages_router)
app.include_router(base_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")(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") 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") @app.on_event("startup")

View file

@ -1,14 +1,13 @@
from difflib import get_close_matches from difflib import get_close_matches
from fastapi_dynamic_response.settings import settings
from io import BytesIO from io import BytesIO
import json import json
import time
import traceback import traceback
from typing import Any, Dict from typing import Any, Dict
from uuid import uuid4
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.exceptions import (
HTTPException as StarletteHTTPException,
RequestValidationError,
)
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
import html2text import html2text
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
@ -17,11 +16,17 @@ from rich.markdown import Markdown
from rich.panel import Panel from rich.panel import Panel
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.chrome.options import Options 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.constant import ACCEPT_TYPES
from fastapi_dynamic_response.globals import templates from fastapi_dynamic_response.globals import templates
import structlog
logger = structlog.get_logger()
console = Console()
class Prefers(BaseModel): class Prefers(BaseModel):
JSON: bool = False JSON: bool = False
@ -30,6 +35,15 @@ class Prefers(BaseModel):
text: bool = False text: bool = False
markdown: bool = False markdown: bool = False
partial: 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 @property
def textlike(self) -> bool: def textlike(self) -> bool:
@ -37,48 +51,136 @@ class Prefers(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def check_one_true(self) -> Dict[str, Any]: 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: if format_flags.count(True) != 1:
message = "Exactly one of JSON, html, rtf, text, or markdown must be True." message = "Exactly one of JSON, html, rtf, text, or markdown must be True."
raise ValueError(message) 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( def set_prefers(
request: Request, request: Request,
call_next,
): ):
content_type = ( content_type = (
request.query_params.get("content_type") request.query_params.get(
or request.headers.get("content-type") "content-type",
or request.headers.get("accept", None) 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() ).lower()
if content_type == "*/*": if content_type == "*/*":
content_type = None content_type = None
hx_request_header = request.headers.get("hx-request") hx_request_header = request.headers.get("hx-request")
user_agent = request.headers.get("user-agent", "").lower() 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": if hx_request_header == "true":
request.state.prefers = Prefers(html=True, partial=True) content_type = "text/html-partial"
return # 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" content_type = "text/html"
elif is_rtf_request(user_agent) and content_type is None: elif is_rtf_request(user_agent) and content_type is None:
content_type = "text/rtf" content_type = "text/rtf"
elif content_type is None: # else:
content_type = "application/json" # content_type = "application/json"
partial = "partial" in content_type
# if content_type in ACCEPT_TYPES: # if content_type in ACCEPT_TYPES:
for accept_type, accept_value in ACCEPT_TYPES.items(): # for accept_type, accept_value in ACCEPT_TYPES.items():
if accept_type in content_type: # if accept_type in content_type:
request.state.prefers = Prefers(**{ACCEPT_TYPES[accept_value]: True}) if content_type in ACCEPT_TYPES:
print("content_type:", content_type) request.state.prefers = Prefers(
print("prefers:", request.state.prefers) **{ACCEPT_TYPES[content_type]: True}, partial=partial
return )
request.state.prefers = Prefers(JSON=True, partial=False) else:
print("prefers:", request.state.prefers) request.state.prefers = Prefers(JSON=True, partial=partial)
print("content_type:", content_type)
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: class Sitemap:
@ -133,6 +235,41 @@ def get_screenshot(html_content: str) -> BytesIO:
return buffer 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: def format_json_as_plain_text(data: dict) -> str:
"""Convert JSON to human-readable plain text format with indentation and bullet points.""" """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: def format_json_as_rich_text(data: dict, template_name: str) -> str:
"""Convert JSON to a human-readable rich text format using rich.""" """Convert JSON to a human-readable rich text format using rich."""
console = Console()
# pretty_data = Pretty(data, indent_guides=True) # pretty_data = Pretty(data, indent_guides=True)
console = Console()
template = templates.get_template(template_name) template = templates.get_template(template_name)
html_content = template.render(data=data) 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() 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): def handle_not_found(request: Request, call_next, data: str):
requested_path = request.url.path requested_path = request.url.path
# available_routes = [route.path for route in app.router.routes if route.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): async def respond_based_on_content_type(request: Request, call_next):
requested_path = request.url.path 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) return await call_next(request)
try: try:
response = await call_next(request) 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: 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, request=request,
call_next=call_next, call_next=call_next,
data=data, data=data,
) )
if response.status_code == 422: elif str(response.status_code)[0] not in "123":
return response request.state.bound_logger.info(f"non-200 response {response.status_code}")
if str(response.status_code)[0] not in "123": # return await handle_response(request, response, data)
return response 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) return await handle_response(request, response, 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()})
except Exception as e: except Exception as e:
print(traceback.format_exc()) request.state.bound_logger.info("internal server error")
return HTMLResponse(content=f"Internal Server Error: {e!s}", status_code=500) # 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) json_data = json.loads(data)
template_name = getattr(request.state, "template_name", "default_template.html") template_name = getattr(request.state, "template_name", "default_template.html")
if request.state.prefers.partial: if request.state.prefers.partial:
request.state.bound_logger = logger.bind(template_name=template_name)
template_name = "partial_" + template_name template_name = "partial_" + template_name
content_type = request.state.prefers
if request.state.prefers.JSON: if request.state.prefers.JSON:
return JSONResponse(content=json_data) request.state.bound_logger.info("returning JSON")
return JSONResponse(
elif request.state.prefers.html: content=json_data,
return templates.TemplateResponse(
template_name, {"request": request, "data": 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 import html2text
template = templates.get_template(template_name) template = templates.get_template(template_name)
@ -290,24 +411,75 @@ async def handle_response(request: Request, data: str):
markdown_content = html2text.html2text(html_content) markdown_content = html2text.html2text(html_content)
return PlainTextResponse(content=markdown_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) 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) 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) template = templates.get_template(template_name)
html_content = template.render(data=json_data) html_content = template.render(data=json_data)
screenshot = get_screenshot(html_content) 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) template = templates.get_template(template_name)
html_content = template.render(data=json_data) html_content = template.render(data=json_data)
pdf = WEAZYHTML(string=html_content).write_pdf() scale = float(
return Response(content=pdf, media_type="application/pdf") 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

View file

@ -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()

View 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)

View file

@ -593,6 +593,19 @@ video {
margin-right: auto; margin-right: auto;
} }
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.ml-8 {
margin-left: 2rem;
}
.mt-4 {
margin-top: 1rem;
}
.mt-auto { .mt-auto {
margin-top: auto; margin-top: auto;
} }
@ -609,6 +622,10 @@ video {
min-height: 100vh; min-height: 100vh;
} }
.list-disc {
list-style-type: disc;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@ -649,6 +666,11 @@ video {
text-align: center; text-align: center;
} }
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-xl { .text-xl {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.75rem; line-height: 1.75rem;
@ -663,6 +685,16 @@ video {
color: rgb(229 231 235 / var(--tw-text-opacity)); 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 { .text-teal-400 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(45 212 191 / var(--tw-text-opacity)); color: rgb(45 212 191 / var(--tw-text-opacity));

View file

@ -3,16 +3,16 @@
{% block title %}Another Example{% endblock %} {% block title %}Another Example{% endblock %}
{% block content %} {% block content %}
<h2>Example</h2> <h2 class='text-gray-400 font-bold text-2xl'>Example</h2>
<p> <p>
{{ data.message }} {{ data.message }}
</p> </p>
<h3>Items</h3> <h3 class='mt-4 text-gray-400 font-bold text-xl'>Items</h3>
<p> <p class='text-gray-300 my-4'>
there are {{ data.get('items', [])|length }} items in the list there are {{ data.get('items', [])|length }} items in the list
</p> </p>
<ul> <ul class='list-disc ml-8'>
{% for item in data.get('items', []) %} {% for item in data.get('items', []) %}
<li>{{ item }}</li> <li>{{ item }}</li>
{% endfor %} {% endfor %}

View file

@ -4,8 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}FastAPI Dynamic Response{% endblock %}</title> <title>{% block title %}FastAPI Dynamic Response{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script> <!-- <link href="/static/app.css" rel="stylesheet"> -->
<link href="/static/app.css" rel="stylesheet"> <link href="http://localhost:8000/static/app.css" rel="stylesheet">
</head> </head>
<body class="bg-gray-900 text-gray-200 min-h-screen flex flex-col"> <body class="bg-gray-900 text-gray-200 min-h-screen flex flex-col">
<header class="bg-gray-800 p-4"> <header class="bg-gray-800 p-4">

112
uv.lock generated
View file

@ -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/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/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/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/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/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 }, { 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/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/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/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/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/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/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/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 }, { 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/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/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/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/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/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/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/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 }, { 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/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/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/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/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/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 }, { 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/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/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/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/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 }, { 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 = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "html2text" }, { name = "html2text" },
{ name = "itsdangerous" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "markdown" }, { name = "markdown" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pydantic-settings" },
{ name = "pydyf" }, { name = "pydyf" },
{ name = "python-levenshtein" }, { name = "python-levenshtein" },
{ name = "rich" }, { name = "rich" },
{ name = "selenium" }, { name = "selenium" },
{ name = "structlog" },
{ name = "typer" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "weasyprint" }, { name = "weasyprint" },
] ]
@ -306,13 +344,17 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "jinja2", specifier = ">=3.1.4" }, { name = "jinja2", specifier = ">=3.1.4" },
{ name = "markdown", specifier = ">=3.7" }, { name = "markdown", specifier = ">=3.7" },
{ name = "pillow", specifier = ">=10.4.0" }, { name = "pillow", specifier = ">=10.4.0" },
{ name = "pydantic-settings", specifier = ">=2.5.2" },
{ name = "pydyf", specifier = "==0.8.0" }, { name = "pydyf", specifier = "==0.8.0" },
{ name = "python-levenshtein", specifier = ">=0.25.1" }, { name = "python-levenshtein", specifier = ">=0.25.1" },
{ name = "rich", specifier = ">=13.9.2" }, { name = "rich", specifier = ">=13.9.2" },
{ name = "selenium", specifier = ">=4.25.0" }, { 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 = "uvicorn", specifier = ">=0.31.1" },
{ name = "weasyprint", specifier = ">=61.2" }, { name = "weasyprint", specifier = ">=61.2" },
] ]
@ -418,14 +460,23 @@ wheels = [
[[package]] [[package]]
name = "importlib-metadata" name = "importlib-metadata"
version = "8.5.0" version = "8.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "zipp", marker = "python_full_version < '3.13'" }, { 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 = [ 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]] [[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 }, { 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]] [[package]]
name = "pydyf" name = "pydyf"
version = "0.8.0" 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 }, { 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]] [[package]]
name = "python-levenshtein" name = "python-levenshtein"
version = "0.25.1" 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 }, { 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]] [[package]]
name = "six" name = "six"
version = "1.16.0" 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 }, { 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]] [[package]]
name = "tinycss2" name = "tinycss2"
version = "1.3.0" 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 }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"