Compare commits
No commits in common. "9c83f862e0095c20cc9b83a28ad9530d486da893" and "16e207000f3d228dfffabf70c722ecd5c606964b" have entirely different histories.
9c83f862e0
...
16e207000f
28 changed files with 130 additions and 1959 deletions
136
Dockerfile
136
Dockerfile
|
|
@ -1,136 +0,0 @@
|
|||
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", ]
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/bash
|
||||
fdr_app app run
|
||||
60
justfile
60
justfile
|
|
@ -1,77 +1,29 @@
|
|||
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:
|
||||
@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'
|
||||
uv run -- uvicorn --reload --log-level debug src.fastapi_dynamic_response.main:app
|
||||
|
||||
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/example Content-Type:application/json
|
||||
http GET :8000 Content-Type=application/json
|
||||
|
||||
get-html:
|
||||
http GET :8000/example Content-Type:text/html
|
||||
http GET :8000 Content-Type=text/html
|
||||
|
||||
get-md:
|
||||
http GET :8000/example Content-Type:application/markdown
|
||||
http GET :8000 Content-Type=application/markdown
|
||||
|
||||
|
||||
livez:
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# 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
BIN
kube-linter
Binary file not shown.
BIN
kube-score
BIN
kube-score
Binary file not shown.
|
|
@ -1,18 +0,0 @@
|
|||
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]
|
||||
|
|
@ -27,17 +27,13 @@ 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",
|
||||
]
|
||||
|
|
@ -47,9 +43,6 @@ 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
131
requirements.txt
|
|
@ -1,131 +0,0 @@
|
|||
# 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
612
sitemap.html
|
|
@ -1,612 +0,0 @@
|
|||
<!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>© 2024 FastApi Dynamic Response</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
BIN
sitemap.png
BIN
sitemap.png
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
|
|
@ -1,117 +0,0 @@
|
|||
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}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter
|
||||
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.dependencies import get_content_type
|
||||
|
||||
|
|
@ -16,46 +17,6 @@ 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,
|
||||
|
|
@ -69,16 +30,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import typer
|
||||
|
||||
from fastapi_dynamic_response.cli.app import app_app
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
app.add_typer(app_app, name="app")
|
||||
|
|
@ -1,32 +1,21 @@
|
|||
ACCEPT_TYPES = {
|
||||
"application/html": "html",
|
||||
"application/html-partial": "html",
|
||||
"application/json": "JSON",
|
||||
"application/markdown": "markdown",
|
||||
"application/md": "markdown",
|
||||
"application/pdf": "pdf",
|
||||
"application/plain": "text",
|
||||
"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",
|
||||
"html": "html",
|
||||
"image/png": "png",
|
||||
"application/markdown": "markdown",
|
||||
"text/markdown": "markdown",
|
||||
"text/x-markdown": "markdown",
|
||||
"json": "JSON",
|
||||
"html": "html",
|
||||
"rtf": "rtf",
|
||||
"plain": "text",
|
||||
"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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
# 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}")
|
||||
|
|
@ -1,27 +1,18 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
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.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,
|
||||
)
|
||||
from fastapi_dynamic_response.zpages.router import router as zpages_router
|
||||
|
||||
configure_logging()
|
||||
app = FastAPI(
|
||||
title="FastAPI Dynamic Response",
|
||||
version=__version__,
|
||||
|
|
@ -30,35 +21,20 @@ app = FastAPI(
|
|||
openapi_url=None,
|
||||
# openapi_tags=tags_metadata,
|
||||
# exception_handlers=exception_handlers,
|
||||
debug=settings.DEBUG,
|
||||
debug=True,
|
||||
dependencies=[
|
||||
# Depends(set_prefers),
|
||||
# Depends(set_span_id),
|
||||
# Depends(log_request_state),
|
||||
Depends(set_prefers),
|
||||
],
|
||||
)
|
||||
|
||||
# configure_tracing(app)
|
||||
|
||||
app.include_router(zpages_router)
|
||||
app.include_router(base_router)
|
||||
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.middleware("http")(respond_based_on_content_type)
|
||||
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
|
||||
|
||||
app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
|
||||
app.add_middleware(SessionMiddleware, secret_key="your-secret-key")
|
||||
# Flag to indicate if the application is ready
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
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
|
||||
|
|
@ -16,17 +17,11 @@ 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
|
||||
|
|
@ -35,15 +30,6 @@ 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:
|
||||
|
|
@ -51,136 +37,48 @@ 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,
|
||||
self.png,
|
||||
self.pdf,
|
||||
]
|
||||
format_flags = [self.JSON, self.html, self.rtf, self.text, self.markdown]
|
||||
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",
|
||||
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"),
|
||||
),
|
||||
)
|
||||
request.query_params.get("content_type")
|
||||
or request.headers.get("content-type")
|
||||
or request.headers.get("accept", None)
|
||||
).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":
|
||||
content_type = "text/html-partial"
|
||||
# request.state.prefers = Prefers(html=True, partial=True)
|
||||
# content_type = "text/html"
|
||||
request.state.prefers = Prefers(html=True, partial=True)
|
||||
return
|
||||
|
||||
elif is_browser_request(user_agent) and content_type is None:
|
||||
if 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"
|
||||
|
||||
# else:
|
||||
# content_type = "application/json"
|
||||
elif content_type is None:
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
class Sitemap:
|
||||
|
|
@ -235,41 +133,6 @@ 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."""
|
||||
|
||||
|
|
@ -293,8 +156,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."""
|
||||
|
||||
# pretty_data = Pretty(data, indent_guides=True)
|
||||
console = Console()
|
||||
# pretty_data = Pretty(data, indent_guides=True)
|
||||
|
||||
template = templates.get_template(template_name)
|
||||
html_content = template.render(data=data)
|
||||
|
|
@ -312,6 +175,19 @@ 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]
|
||||
|
|
@ -335,75 +211,78 @@ 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", "/static/app.css"]:
|
||||
request.state.bound_logger.info(
|
||||
"protected route returning non-dynamic response"
|
||||
)
|
||||
if requested_path in ["/docs", "/redoc", "/openapi.json"]:
|
||||
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:
|
||||
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(
|
||||
return handle_not_found(
|
||||
request=request,
|
||||
call_next=call_next,
|
||||
data=data,
|
||||
)
|
||||
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)
|
||||
if response.status_code == 422:
|
||||
return response
|
||||
if str(response.status_code)[0] not in "123":
|
||||
return response
|
||||
else:
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
data = body.decode("utf-8")
|
||||
|
||||
return await handle_response(request, response, data)
|
||||
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()})
|
||||
except Exception as e:
|
||||
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
|
||||
)
|
||||
print(traceback.format_exc())
|
||||
return HTMLResponse(content=f"Internal Server Error: {e!s}", status_code=500)
|
||||
|
||||
|
||||
async def handle_response(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: str,
|
||||
):
|
||||
async def handle_response(request: Request, 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:
|
||||
request.state.bound_logger.info("returning JSON")
|
||||
return JSONResponse(
|
||||
content=json_data,
|
||||
)
|
||||
return JSONResponse(content=json_data)
|
||||
|
||||
if request.state.prefers.html:
|
||||
request.state.bound_logger.info("returning html")
|
||||
elif request.state.prefers.html:
|
||||
return templates.TemplateResponse(
|
||||
template_name,
|
||||
{"request": request, "data": json_data},
|
||||
template_name, {"request": request, "data": json_data}
|
||||
)
|
||||
|
||||
if request.state.prefers.markdown:
|
||||
request.state.bound_logger.info("returning markdown")
|
||||
elif request.state.prefers.markdown:
|
||||
import html2text
|
||||
|
||||
template = templates.get_template(template_name)
|
||||
|
|
@ -411,75 +290,24 @@ async def handle_response(
|
|||
markdown_content = html2text.html2text(html_content)
|
||||
return PlainTextResponse(content=markdown_content)
|
||||
|
||||
if request.state.prefers.text:
|
||||
request.state.bound_logger.info("returning plain text")
|
||||
elif request.state.prefers.text:
|
||||
plain_text_content = format_json_as_plain_text(json_data)
|
||||
return PlainTextResponse(
|
||||
content=plain_text_content,
|
||||
)
|
||||
return PlainTextResponse(content=plain_text_content)
|
||||
|
||||
if request.state.prefers.rtf:
|
||||
request.state.bound_logger.info("returning rich text")
|
||||
elif request.state.prefers.rtf:
|
||||
rich_text_content = format_json_as_rich_text(json_data, template_name)
|
||||
return PlainTextResponse(
|
||||
content=rich_text_content,
|
||||
)
|
||||
return PlainTextResponse(content=rich_text_content)
|
||||
|
||||
if request.state.prefers.png:
|
||||
request.state.bound_logger.info("returning PNG")
|
||||
elif content_type == "image/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")
|
||||
|
||||
if request.state.prefers.pdf:
|
||||
request.state.bound_logger.info("returning PDF")
|
||||
elif content_type == "application/pdf":
|
||||
template = templates.get_template(template_name)
|
||||
html_content = template.render(data=json_data)
|
||||
scale = float(
|
||||
request.headers.get("scale", request.query_params.get("scale", 1.0))
|
||||
)
|
||||
console.log(f"Scale: {scale}")
|
||||
pdf = get_pdf(html_content, scale)
|
||||
pdf = WEAZYHTML(string=html_content).write_pdf()
|
||||
return Response(content=pdf, media_type="application/pdf")
|
||||
|
||||
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
|
||||
return JSONResponse(content=json_data)
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# 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)
|
||||
|
|
@ -593,19 +593,6 @@ 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;
|
||||
}
|
||||
|
|
@ -622,10 +609,6 @@ video {
|
|||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -666,11 +649,6 @@ video {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
|
|
@ -685,16 +663,6 @@ 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));
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@
|
|||
{% block title %}Another Example{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class='text-gray-400 font-bold text-2xl'>Example</h2>
|
||||
<h2>Example</h2>
|
||||
<p>
|
||||
{{ data.message }}
|
||||
</p>
|
||||
|
||||
<h3 class='mt-4 text-gray-400 font-bold text-xl'>Items</h3>
|
||||
<p class='text-gray-300 my-4'>
|
||||
<h3>Items</h3>
|
||||
<p>
|
||||
there are {{ data.get('items', [])|length }} items in the list
|
||||
</p>
|
||||
<ul class='list-disc ml-8'>
|
||||
<ul>
|
||||
{% for item in data.get('items', []) %}
|
||||
<li>{{ item }}</li>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}FastAPI Dynamic Response{% endblock %}</title>
|
||||
<!-- <link href="/static/app.css" rel="stylesheet"> -->
|
||||
<link href="http://localhost:8000/static/app.css" rel="stylesheet">
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="/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
112
uv.lock
generated
|
|
@ -57,10 +57,6 @@ 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 },
|
||||
|
|
@ -73,14 +69,8 @@ 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 },
|
||||
|
|
@ -91,24 +81,8 @@ 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 },
|
||||
|
|
@ -119,10 +93,6 @@ 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 },
|
||||
|
|
@ -135,10 +105,6 @@ 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 },
|
||||
]
|
||||
|
|
@ -325,17 +291,13 @@ 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" },
|
||||
]
|
||||
|
|
@ -344,17 +306,13 @@ 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" },
|
||||
]
|
||||
|
|
@ -460,23 +418,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.4.0"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 }
|
||||
wheels = [
|
||||
{ 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 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -903,19 +852,6 @@ 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"
|
||||
|
|
@ -952,15 +888,6 @@ 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"
|
||||
|
|
@ -1119,15 +1046,6 @@ 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"
|
||||
|
|
@ -1168,15 +1086,6 @@ 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"
|
||||
|
|
@ -1221,21 +1130,6 @@ 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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue