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:
@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:
uv venv
build-podman:
podman build -t docker.io/waylonwalker/fastapi-dynamic-response:${VERSION} .
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:
http GET :8000/example
get-plain:
http GET :8000/exa Content-Type=text/plain
http GET :8000/exa Content-Type:text/plain
get-rtf:
http GET :8000/example Content-Type=application/rtf
http GET :8000/example Content-Type:application/rtf
get-json:
http GET :8000 Content-Type=application/json
http GET :8000/example Content-Type:application/json
get-html:
http GET :8000 Content-Type=text/html
http GET :8000/example Content-Type:text/html
get-md:
http GET :8000 Content-Type=application/markdown
http GET :8000/example Content-Type:application/markdown
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 = [
"fastapi>=0.115.0",
"html2text>=2024.2.26",
"itsdangerous>=2.2.0",
"jinja2>=3.1.4",
"markdown>=3.7",
"pillow>=10.4.0",
"pydantic-settings>=2.5.2",
"pydyf==0.8.0",
"python-levenshtein>=0.25.1",
"rich>=13.9.2",
"selenium>=4.25.0",
"structlog>=24.4.0",
"typer>=0.12.5",
"uvicorn>=0.31.1",
"weasyprint>=61.2",
]
@ -43,6 +47,9 @@ Documentation = "https://github.com/U.N. Owen/fastapi-dynamic-response#readme"
Issues = "https://github.com/U.N. Owen/fastapi-dynamic-response/issues"
Source = "https://github.com/U.N. Owen/fastapi-dynamic-response"
[project.scripts]
fdr_app = "fastapi_dynamic_response.cli.cli:app"
[tool.hatch.version]
path = "src/fastapi_dynamic_response/__about__.py"

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 Depends
from fastapi import Request
from fastapi import APIRouter, Depends, Request
from fastapi_dynamic_response.auth import admin, authenticated, has_scope
from fastapi_dynamic_response.base.schema import Message
from fastapi_dynamic_response.dependencies import get_content_type
@ -17,6 +16,46 @@ async def get_example(
return {"message": "Hello, this is an example", "data": [1, 2, 3, 4]}
@router.get("/private")
@authenticated
async def get_private(
request: Request,
content_type: str = Depends(get_content_type),
):
request.state.template_name = "example.html"
return {"message": "This page is private", "data": [1, 2, 3, 4]}
@router.get("/admin")
@admin
async def get_admin(
request: Request,
content_type: str = Depends(get_content_type),
):
request.state.template_name = "example.html"
return {"message": "This is only for admin users", "data": [1, 2, 3, 4]}
@router.get("/superuser")
@has_scope("superuser")
async def get_superuser(
request: Request,
content_type: str = Depends(get_content_type),
):
request.state.template_name = "example.html"
return {"message": "This is only for superusers", "data": [1, 2, 3, 4]}
@router.get("/error")
async def get_error(
request: Request,
content_type: str = Depends(get_content_type),
):
request.state.template_name = "example.html"
0 / 0
return {"message": "Hello, this is an example", "data": [1, 2, 3, 4]}
@router.get("/another-example")
async def another_example(
request: Request,
@ -30,6 +69,16 @@ async def another_example(
}
@router.get("/message")
async def message(
request: Request,
message_id: int,
content_type: str = Depends(get_content_type),
):
request.state.template_name = "post_message.html"
return {"message": message.message}
@router.post("/message")
async def message(
request: Request,

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 = {
"application/json": "JSON",
"text/html": "html",
"application/html": "html",
"text/html-partial": "html",
"text/html-fragment": "html",
"text/rich": "rtf",
"application/rtf": "rtf",
"text/rtf": "rtf",
"text/plain": "text",
"application/text": "text",
"application/html-partial": "html",
"application/json": "JSON",
"application/markdown": "markdown",
"text/markdown": "markdown",
"text/x-markdown": "markdown",
"json": "JSON",
"application/md": "markdown",
"application/pdf": "pdf",
"application/plain": "text",
"application/rtf": "rtf",
"application/text": "text",
"html": "html",
"rtf": "rtf",
"plain": "text",
"image/png": "png",
"json": "JSON",
"markdown": "markdown",
"md": "markdown",
"pdf": "pdf",
"plain": "text",
"png": "png",
"rich": "rtf",
"richtext": "rtf",
"richtextformat": "rtf",
"rtf": "rtf",
"text": "text",
"text/html": "html",
"text/html-partial": "html",
"text/markdown": "markdown",
"text/md": "markdown",
"text/plain": "text",
"text/rich": "rtf",
"text/rtf": "rtf",
"text/x-markdown": "markdown",
}

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_dynamic_response import globals
from fastapi_dynamic_response.__about__ import __version__
from fastapi_dynamic_response.base.router import router as base_router
from fastapi_dynamic_response.dependencies import get_content_type
from fastapi_dynamic_response.middleware import (
Sitemap,
catch_exceptions_middleware,
respond_based_on_content_type,
set_prefers,
)
from fastapi_dynamic_response.zpages.router import router as zpages_router
from fastapi_dynamic_response.settings import settings
from fastapi_dynamic_response.logging_config import configure_logging
from fastapi_dynamic_response.middleware import (
Sitemap,
add_process_time_header,
catch_exceptions_middleware,
log_requests,
respond_based_on_content_type,
set_bound_logger,
set_prefers,
set_span_id,
)
configure_logging()
app = FastAPI(
title="FastAPI Dynamic Response",
version=__version__,
@ -21,20 +30,35 @@ app = FastAPI(
openapi_url=None,
# openapi_tags=tags_metadata,
# exception_handlers=exception_handlers,
debug=True,
debug=settings.DEBUG,
dependencies=[
Depends(set_prefers),
# Depends(set_prefers),
# Depends(set_span_id),
# Depends(log_request_state),
],
)
# configure_tracing(app)
app.include_router(zpages_router)
app.include_router(base_router)
app.middleware("http")(Sitemap(app))
app.middleware("http")(catch_exceptions_middleware)
app.middleware("http")(respond_based_on_content_type)
app.middleware("http")(add_process_time_header)
app.middleware("http")(log_requests)
app.middleware("http")(Sitemap(app))
app.middleware("http")(set_prefers)
app.middleware("http")(set_span_id)
app.middleware("http")(catch_exceptions_middleware)
app.middleware("http")(set_bound_logger)
app.mount("/static", StaticFiles(directory="static"), name="static")
from fastapi import Depends, Request
from fastapi_dynamic_response.auth import BasicAuthBackend
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.sessions import SessionMiddleware
# Flag to indicate if the application is ready
app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
app.add_middleware(SessionMiddleware, secret_key="your-secret-key")
@app.on_event("startup")

View file

@ -1,14 +1,13 @@
from difflib import get_close_matches
from fastapi_dynamic_response.settings import settings
from io import BytesIO
import json
import time
import traceback
from typing import Any, Dict
from uuid import uuid4
from fastapi import Request, Response
from fastapi.exceptions import (
HTTPException as StarletteHTTPException,
RequestValidationError,
)
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
import html2text
from pydantic import BaseModel, model_validator
@ -17,11 +16,17 @@ from rich.markdown import Markdown
from rich.panel import Panel
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from weasyprint import HTML as WEAZYHTML
import base64
from fastapi_dynamic_response.constant import ACCEPT_TYPES
from fastapi_dynamic_response.globals import templates
import structlog
logger = structlog.get_logger()
console = Console()
class Prefers(BaseModel):
JSON: bool = False
@ -30,6 +35,15 @@ class Prefers(BaseModel):
text: bool = False
markdown: bool = False
partial: bool = False
png: bool = False
pdf: bool = False
def __repr__(self):
_repr = []
for key, value in self.dict().items():
if value:
_repr.append(key + "=True")
return f'Prefers({", ".join(_repr)})'
@property
def textlike(self) -> bool:
@ -37,48 +51,136 @@ class Prefers(BaseModel):
@model_validator(mode="after")
def check_one_true(self) -> Dict[str, Any]:
format_flags = [self.JSON, self.html, self.rtf, self.text, self.markdown]
format_flags = [
self.JSON,
self.html,
self.rtf,
self.text,
self.markdown,
self.png,
self.pdf,
]
if format_flags.count(True) != 1:
message = "Exactly one of JSON, html, rtf, text, or markdown must be True."
raise ValueError(message)
return self
def log_request_state(request: Request):
console.log(request.state.span_id)
console.log(request.url.path)
console.log(request.state.prefers)
async def add_process_time_header(request: Request, call_next):
start_time = time.perf_counter()
response = await call_next(request)
process_time = time.perf_counter() - start_time
if str(response.status_code)[0] in "123":
response.headers["X-Process-Time"] = str(process_time)
return response
def set_bound_logger(request: Request, call_next):
request.state.bound_logger = logger.bind()
return call_next(request)
async def set_span_id(request: Request, call_next):
span_id = uuid4()
request.state.span_id = span_id
request.state.bound_logger = logger.bind(span_id=request.state.span_id)
response = await call_next(request)
if str(response.status_code)[0] in "123":
response.headers["x-request-id"] = str(span_id)
response.headers["x-span-id"] = str(span_id)
return response
def set_prefers(
request: Request,
call_next,
):
content_type = (
request.query_params.get("content_type")
or request.headers.get("content-type")
or request.headers.get("accept", None)
request.query_params.get(
"content-type",
request.query_params.get(
"content_type",
request.query_params.get("accept"),
),
)
or request.headers.get(
"content-type",
request.headers.get(
"content_type",
request.headers.get("accept"),
),
)
).lower()
if content_type == "*/*":
content_type = None
hx_request_header = request.headers.get("hx-request")
user_agent = request.headers.get("user-agent", "").lower()
referer = request.headers.get("referer", "")
if content_type and "," in content_type:
content_type = content_type.split(",")[0]
request.state.bound_logger.info(
"content_type set",
content_type=content_type,
hx_request_header=hx_request_header,
user_agent=user_agent,
referer=referer,
)
if content_type == "*/*":
content_type = None
if ("/docs" in referer or "/redoc" in referer) and content_type is None:
content_type = "application/json"
elif is_browser_request(user_agent) and content_type is None:
request.state.bound_logger.info("browser agent request")
content_type = "text/html"
elif is_rtf_request(user_agent) and content_type is None:
request.state.bound_logger.info("rtf agent request")
content_type = "application/rtf"
elif content_type is None:
request.state.bound_logger.info("no content type request")
content_type = content_type or "application/json"
if hx_request_header == "true":
request.state.prefers = Prefers(html=True, partial=True)
return
content_type = "text/html-partial"
# request.state.prefers = Prefers(html=True, partial=True)
# content_type = "text/html"
if is_browser_request(user_agent) and content_type is None:
elif is_browser_request(user_agent) and content_type is None:
content_type = "text/html"
elif is_rtf_request(user_agent) and content_type is None:
content_type = "text/rtf"
elif content_type is None:
content_type = "application/json"
# else:
# content_type = "application/json"
partial = "partial" in content_type
# if content_type in ACCEPT_TYPES:
for accept_type, accept_value in ACCEPT_TYPES.items():
if accept_type in content_type:
request.state.prefers = Prefers(**{ACCEPT_TYPES[accept_value]: True})
print("content_type:", content_type)
print("prefers:", request.state.prefers)
return
request.state.prefers = Prefers(JSON=True, partial=False)
print("prefers:", request.state.prefers)
print("content_type:", content_type)
# for accept_type, accept_value in ACCEPT_TYPES.items():
# if accept_type in content_type:
if content_type in ACCEPT_TYPES:
request.state.prefers = Prefers(
**{ACCEPT_TYPES[content_type]: True}, partial=partial
)
else:
request.state.prefers = Prefers(JSON=True, partial=partial)
request.state.content_type = content_type
request.state.bound_logger = request.state.bound_logger.bind(
# content_type=request.state.content_type,
prefers=request.state.prefers,
)
return call_next(request)
class Sitemap:
@ -133,6 +235,41 @@ def get_screenshot(html_content: str) -> BytesIO:
return buffer
def get_pdf(html_content: str, scale: float = 1.0) -> BytesIO:
chrome_options = Options()
chrome_options.add_argument("--headless")
chrome_options.add_argument("--disable-gpu")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--window-size=1280x1024")
chrome_options.add_argument("--disable-dev-shm-usage") # Helps avoid memory issues
driver = webdriver.Chrome(options=chrome_options)
driver.get("data:text/html;charset=utf-8," + html_content)
# Generate PDF
pdf = driver.execute_cdp_cmd(
"Page.printToPDF",
{
"printBackground": True, # Include CSS backgrounds in the PDF
"paperWidth": 8.27, # A4 paper size width in inches
"paperHeight": 11.69, # A4 paper size height in inches
"marginTop": 0,
"marginBottom": 0,
"marginLeft": 0,
"marginRight": 0,
"scale": scale,
},
)["data"]
driver.quit()
# Convert base64 PDF to BytesIO
pdf_buffer = BytesIO()
pdf_buffer.write(base64.b64decode(pdf))
pdf_buffer.seek(0)
return pdf_buffer.getvalue()
def format_json_as_plain_text(data: dict) -> str:
"""Convert JSON to human-readable plain text format with indentation and bullet points."""
@ -156,8 +293,8 @@ def format_json_as_plain_text(data: dict) -> str:
def format_json_as_rich_text(data: dict, template_name: str) -> str:
"""Convert JSON to a human-readable rich text format using rich."""
console = Console()
# pretty_data = Pretty(data, indent_guides=True)
console = Console()
template = templates.get_template(template_name)
html_content = template.render(data=data)
@ -175,19 +312,6 @@ def format_json_as_rich_text(data: dict, template_name: str) -> str:
return capture.get()
async def respond_based_on_content_type(
request: Request,
call_next,
content_type: str,
data: str,
):
requested_path = request.url.path
if requested_path in ["/docs", "/redoc", "/openapi.json"]:
return await call_next(request)
return await call_next(request)
def handle_not_found(request: Request, call_next, data: str):
requested_path = request.url.path
# available_routes = [route.path for route in app.router.routes if route.path]
@ -211,78 +335,75 @@ def handle_not_found(request: Request, call_next, data: str):
async def respond_based_on_content_type(request: Request, call_next):
requested_path = request.url.path
if requested_path in ["/docs", "/redoc", "/openapi.json"]:
if requested_path in ["/docs", "/redoc", "/openapi.json", "/static/app.css"]:
request.state.bound_logger.info(
"protected route returning non-dynamic response"
)
return await call_next(request)
try:
response = await call_next(request)
user_agent = request.headers.get("user-agent", "").lower()
referer = request.headers.get("referer", "")
content_type = request.query_params.get(
"content_type",
request.headers.get("content-type", request.headers.get("Accept")),
)
if "raw" in content_type:
return await call_next(request)
if content_type == "*/*":
content_type = None
if ("/docs" in referer or "/redoc" in referer) and content_type is None:
content_type = "application/json"
elif is_browser_request(user_agent) and content_type is None:
content_type = "text/html"
elif is_rtf_request(user_agent) and content_type is None:
content_type = "application/rtf"
elif content_type is None:
content_type = content_type or "application/json"
body = b"".join([chunk async for chunk in response.body_iterator])
data = body.decode("utf-8")
if response.status_code == 404:
return handle_not_found(
request.state.bound_logger.info("404 not found")
body = b"".join([chunk async for chunk in response.body_iterator])
data = body.decode("utf-8")
response = handle_not_found(
request=request,
call_next=call_next,
data=data,
)
if response.status_code == 422:
return response
if str(response.status_code)[0] not in "123":
elif str(response.status_code)[0] not in "123":
request.state.bound_logger.info(f"non-200 response {response.status_code}")
# return await handle_response(request, response, data)
return response
else:
body = b"".join([chunk async for chunk in response.body_iterator])
data = body.decode("utf-8")
return await handle_response(request, data)
# except TemplateNotFound:
# return HTMLResponse(content="Template Not Found ", status_code=404)
except StarletteHTTPException as exc:
return HTMLResponse(
content=f"Error {exc.status_code}: {exc.detail}",
status_code=exc.status_code,
)
except RequestValidationError as exc:
return JSONResponse(status_code=422, content={"detail": exc.errors()})
return await handle_response(request, response, data)
except Exception as e:
print(traceback.format_exc())
return HTMLResponse(content=f"Internal Server Error: {e!s}", status_code=500)
request.state.bound_logger.info("internal server error")
# print(traceback.format_exc())
raise e
if settings.ENV == "local":
return HTMLResponse(
content=f"Internal Server Error: {e!s} {traceback.format_exc()}",
status_code=500,
)
else:
return HTMLResponse(
content=f"Internal Server Error: {e!s}", status_code=500
)
async def handle_response(request: Request, data: str):
async def handle_response(
request: Request,
response: Response,
data: str,
):
json_data = json.loads(data)
template_name = getattr(request.state, "template_name", "default_template.html")
if request.state.prefers.partial:
request.state.bound_logger = logger.bind(template_name=template_name)
template_name = "partial_" + template_name
content_type = request.state.prefers
if request.state.prefers.JSON:
return JSONResponse(content=json_data)
elif request.state.prefers.html:
return templates.TemplateResponse(
template_name, {"request": request, "data": json_data}
request.state.bound_logger.info("returning JSON")
return JSONResponse(
content=json_data,
)
elif request.state.prefers.markdown:
if request.state.prefers.html:
request.state.bound_logger.info("returning html")
return templates.TemplateResponse(
template_name,
{"request": request, "data": json_data},
)
if request.state.prefers.markdown:
request.state.bound_logger.info("returning markdown")
import html2text
template = templates.get_template(template_name)
@ -290,24 +411,75 @@ async def handle_response(request: Request, data: str):
markdown_content = html2text.html2text(html_content)
return PlainTextResponse(content=markdown_content)
elif request.state.prefers.text:
if request.state.prefers.text:
request.state.bound_logger.info("returning plain text")
plain_text_content = format_json_as_plain_text(json_data)
return PlainTextResponse(content=plain_text_content)
return PlainTextResponse(
content=plain_text_content,
)
elif request.state.prefers.rtf:
if request.state.prefers.rtf:
request.state.bound_logger.info("returning rich text")
rich_text_content = format_json_as_rich_text(json_data, template_name)
return PlainTextResponse(content=rich_text_content)
return PlainTextResponse(
content=rich_text_content,
)
elif content_type == "image/png":
if request.state.prefers.png:
request.state.bound_logger.info("returning PNG")
template = templates.get_template(template_name)
html_content = template.render(data=json_data)
screenshot = get_screenshot(html_content)
return Response(content=screenshot.getvalue(), media_type="image/png")
return Response(
content=screenshot.getvalue(),
media_type="image/png",
)
elif content_type == "application/pdf":
if request.state.prefers.pdf:
request.state.bound_logger.info("returning PDF")
template = templates.get_template(template_name)
html_content = template.render(data=json_data)
pdf = WEAZYHTML(string=html_content).write_pdf()
return Response(content=pdf, media_type="application/pdf")
scale = float(
request.headers.get("scale", request.query_params.get("scale", 1.0))
)
console.log(f"Scale: {scale}")
pdf = get_pdf(html_content, scale)
return JSONResponse(content=json_data)
return Response(
content=pdf,
media_type="application/pdf",
)
request.state.bound_logger.info("returning DEFAULT JSON")
return JSONResponse(
content=json_data,
)
# Initialize the logger
async def log_requests(request: Request, call_next):
# Log request details
request.state.bound_logger = logger.bind(
method=request.method, path=request.url.path
)
request.state.bound_logger.info(
"Request received",
)
# logger.info(
# headers=dict(request.headers),
# prefers=request.state.prefers,
# )
# Process the request
response = await call_next(request)
# Log response details
# logger.info(
# "Response sent",
# span_id=request.state.span_id,
# method=request.method,
# status_code=response.status_code,
# headers=dict(response.headers),
# )
return response

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

View file

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

View file

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