Merge branch 'feature/api-rescaffold' into develop

This commit is contained in:
Jörn-Michael Miehe 2026-02-20 17:11:14 +00:00
commit 8c231b5bf4
25 changed files with 1459 additions and 2213 deletions

View file

@ -11,9 +11,12 @@
**/.dockerignore
# found in python and JS dirs
**/__pycache__
**/node_modules
**/.pytest_cache
**/__pycache__/
**/node_modules/
**/.pytest_cache/
**/.ruff_cache/
**/.uv_cache/
**/.venv/
# env files
**/.env
@ -25,3 +28,6 @@
**/yarn-debug.log*
**/yarn-error.log*
**/pnpm-debug.log*
# custom files
api/api.conf

View file

@ -1,51 +1,6 @@
ARG NODE_VERSION=24
ARG PYTHON_VERSION=3.14-slim
ARG PYTHON_VERSION=3.14
#############
# build api #
#############
ARG PYTHON_VERSION
FROM python:${PYTHON_VERSION} AS build-api
# env setup
WORKDIR /usr/local/src/advent22_api
ENV \
PATH="/root/.local/bin:${PATH}"
# install poetry with export plugin
RUN set -ex; \
\
python -m pip --no-cache-dir install --upgrade pip wheel; \
\
apt-get update; apt-get install --yes --no-install-recommends \
curl \
; rm -rf /var/lib/apt/lists/*; \
\
curl -sSL https://install.python-poetry.org | python3 -; \
poetry self add poetry-plugin-export;
# build dependency wheels
COPY api/pyproject.toml api/poetry.lock ./
RUN set -ex; \
\
# # buildtime dependencies
# apt-get update; apt-get install --yes --no-install-recommends \
# build-essential \
# ; rm -rf /var/lib/apt/lists/*; \
\
# generate requirements.txt
poetry export \
--format requirements.txt \
--output requirements.txt; \
\
python3 -m pip --no-cache-dir wheel \
--wheel-dir ./dist \
--requirement requirements.txt;
# build advent22_api wheel
COPY api ./
RUN poetry build --format wheel --output ./dist
############
# build ui #
@ -54,76 +9,91 @@ RUN poetry build --format wheel --output ./dist
ARG NODE_VERSION
FROM node:${NODE_VERSION} AS build-ui
# env setup
# install ui dependencies
WORKDIR /usr/local/src/advent22_ui
RUN --mount=type=bind,source=ui/package.json,target=package.json \
--mount=type=bind,source=ui/yarn.lock,target=yarn.lock \
--mount=type=bind,source=ui/.yarn/releases,target=.yarn/releases \
--mount=type=bind,source=ui/.yarnrc.yml,target=.yarnrc.yml \
--mount=type=cache,id=ui,target=/root/.yarn \
\
yarn install --immutable --check-cache;
# install advent22_ui dependencies
COPY ui/package*.json ui/yarn*.lock ./
RUN set -ex; \
corepack enable; \
yarn install;
# copy and build advent22_ui
# copy and build ui
COPY ui ./
RUN set -ex; \
RUN --mount=type=cache,id=ui,target=/root/.yarn \
set -ex; \
\
yarn dlx update-browserslist-db@latest; \
yarn build --dest /tmp/advent22_ui/html; \
yarn build --dest /opt/advent22/ui; \
# exclude webpack-bundle-analyzer output
rm -f /tmp/advent22_ui/html/report.html;
rm -f /opt/advent22/ui/report.html;
######################
# python preparation #
######################
###############
# install app #
###############
ARG PYTHON_VERSION
FROM python:${PYTHON_VERSION} AS uvicorn-gunicorn
# where credit is due ...
LABEL maintainer="Sebastián Ramirez <tiangolo@gmail.com>"
WORKDIR /usr/local/share/uvicorn-gunicorn
# install uvicorn-gunicorn
COPY ./scripts/mini-tiangolo ./
RUN set -ex; \
chmod +x start.sh; \
python3 -m pip --no-cache-dir install gunicorn;
CMD ["/usr/local/share/uvicorn-gunicorn/start.sh"]
###########
# web app #
###########
FROM uvicorn-gunicorn AS production
FROM dhi.io/python:${PYTHON_VERSION}-dev AS install-app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# env setup
ENV \
PRODUCTION_MODE="true" \
PORT="8000" \
MODULE_NAME="advent22_api.app"
EXPOSE 8000
WORKDIR /opt/advent22
VOLUME [ "/opt/advent22" ]
ENV UV_COMPILE_BYTECODE=1 \
UV_NO_DEV=1 \
UV_LINK_MODE="copy"
COPY --from=build-api /usr/local/src/advent22_api/dist /usr/local/share/advent22_api.dist
RUN set -ex; \
# remove example app
rm -rf /app; \
\
# # runtime dependencies
# apt-get update; apt-get install --yes --no-install-recommends \
# ; rm -rf /var/lib/apt/lists/*; \
\
# install advent22_api wheels
python3 -m pip --no-cache-dir install --no-deps /usr/local/share/advent22_api.dist/*.whl; \
RUN --mount=type=bind,source=api/uv.lock,target=api/uv.lock \
--mount=type=bind,source=api/pyproject.toml,target=api/pyproject.toml \
--mount=type=bind,source=api/.python-version,target=api/.python-version \
--mount=type=cache,id=api,target=/root/.cache/uv \
set -ex; \
\
# prepare data directory
chown nobody:nogroup ./
mkdir data; \
chown nobody:nobody data; \
chmod u=rwx,g=rx,o=rx data; \
\
# install api deps
uv sync \
--project api/ \
--locked \
--no-install-project \
--no-editable \
;
# add prepared advent22_ui
COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui
# install api
COPY api api/
RUN --mount=type=cache,id=api,target=/root/.cache/uv \
\
uv sync \
--project api/ \
--locked \
--no-editable \
;
# add prepared ui
COPY --from=build-ui /opt/advent22/ui ui/
####################
# production image #
####################
ARG PYTHON_VERSION
FROM dhi.io/python:${PYTHON_VERSION} AS production
ENV PATH="/opt/advent22/api/.venv/bin:$PATH"
EXPOSE 8000
CMD [ "advent22" ]
ARG PYTHON_VERSION
COPY --from=install-app /opt/python/lib/python${PYTHON_VERSION} /opt/python/lib/python${PYTHON_VERSION}/
COPY --from=install-app /opt/advent22 /opt/advent22/
WORKDIR /opt/advent22/data
VOLUME [ "/opt/advent22/data" ]
# run as unprivileged user
USER nobody

View file

@ -9,7 +9,10 @@
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers-extra/features/poetry:2": {},
"ghcr.io/devcontainers-extra/features/uv:1": {},
"ghcr.io/devcontainers-extra/features/zsh-plugins:0": {
"plugins": "git-flow uv"
},
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
"packages": "git-flow"
},
@ -17,7 +20,8 @@
},
"containerEnv": {
"TZ": "Europe/Berlin"
"TZ": "Europe/Berlin",
"UV_CACHE_DIR": "/workspaces/advent22/.uv_cache"
},
// Configure tool-specific properties.
@ -31,22 +35,21 @@
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"astral-sh.ty",
"charliermarsh.ruff",
"be5invis.toml",
"mhutchie.git-graph",
"ms-python.python",
"ms-python.black-formatter",
"ms-python.flake8",
"ms-python.isort",
"ms-python.vscode-pylance"
]
}
},
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sudo /usr/local/py-utils/bin/poetry self add poetry-plugin-up",
"postCreateCommand": "uv tool install uv-upx",
// Use 'postStartCommand' to run commands after the container is started.
"postStartCommand": "poetry install"
"postStartCommand": "uv tool upgrade uv-upx && uv sync"
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],

View file

@ -1,4 +0,0 @@
[flake8]
max-line-length = 80
extend-select = B950
extend-ignore = E203,E501

View file

@ -1,3 +0,0 @@
[settings]
profile = black
line_length = 80

1
api/.python-version Normal file
View file

@ -0,0 +1 @@
3.14

View file

@ -5,18 +5,22 @@
"version": "0.2.0",
"configurations": [
{
"name": "Main Module",
"type": "python",
"name": "FastAPI CLI (dev)",
"type": "debugpy",
"request": "launch",
"module": "advent22_api.main",
"pythonArgs": [
"-Xfrozen_modules=off",
"module": "fastapi",
"args": [
"dev",
"--host", "0.0.0.0",
"--port", "8000",
"--entrypoint", "advent22_api.app:app",
"--reload-dir", "${workspaceFolder}/advent22_api",
],
"env": {
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
"WEBDAV__CACHE_TTL": "30",
},
"justMyCode": true,
}
},
]
}

View file

@ -3,31 +3,16 @@
"[python]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll": "explicit",
},
},
"python.languageServer": "Pylance",
"python.analysis.autoImportCompletions": true,
"python.analysis.importFormat": "relative",
"python.analysis.fixAll": [
"source.convertImportFormat",
"source.unusedImports",
],
"python.analysis.typeCheckingMode": "basic",
"python.analysis.diagnosticMode": "workspace",
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"--import-mode=importlib",
"test",
],
"black-formatter.importStrategy": "fromEnvironment",
"flake8.importStrategy": "fromEnvironment",
"isort.importStrategy": "fromEnvironment",
"ty.diagnosticMode": "workspace",
"ruff.nativeServer": "on",
}

21
api/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Lenaisten e.V.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

0
api/README.md Normal file
View file

View file

View file

@ -37,7 +37,8 @@ if SETTINGS.production_mode:
else:
# Allow CORS in debug mode
app.add_middleware(
CORSMiddleware,
# HACK: suppress while unresolved https://github.com/astral-sh/ty/issues/1635
CORSMiddleware, # ty: ignore[invalid-argument-type]
allow_credentials=True,
allow_origins=["*"],
allow_methods=["*"],

View file

@ -1,7 +1,7 @@
import colorsys
import logging
from dataclasses import dataclass
from typing import AnyStr, Self, TypeAlias
from typing import Self, cast
import numpy as np
from PIL import Image as PILImage
@ -11,9 +11,9 @@ from PIL.ImageFont import FreeTypeFont
from .config import Config
_RGB: TypeAlias = tuple[int, int, int]
_XY: TypeAlias = tuple[float, float]
_Box: TypeAlias = tuple[int, int, int, int]
type _RGB = tuple[int, int, int]
type _XY = tuple[float, float]
type _Box = tuple[int, int, int, int]
_logger = logging.getLogger(__name__)
@ -56,7 +56,7 @@ class AdventImage:
async def get_text_box(
self,
xy: _XY,
text: AnyStr,
text: str,
font: FreeTypeFont,
anchor: str | None = "mm",
**text_kwargs,
@ -95,12 +95,12 @@ class AdventImage:
pixel_data = np.asarray(self.img.crop(box))
mean_color: np.ndarray = np.mean(pixel_data, axis=(0, 1))
return _RGB(mean_color.astype(int))
return cast(_RGB, mean_color.astype(int))
async def hide_text(
self,
xy: _XY,
text: AnyStr,
text: str,
font: FreeTypeFont,
anchor: str | None = "mm",
**text_kwargs,
@ -134,8 +134,10 @@ class AdventImage:
else:
tc_v -= 3
text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v)
text_color = _RGB(int(val) for val in text_color)
text_color: tuple[int | float, int | float, int | float] = colorsys.hsv_to_rgb(
tc_h, tc_s, tc_v
)
text_color = cast(_RGB, tuple(int(val) for val in text_color))
# Buchstaben verstecken
ImageDraw.Draw(self.img).text(

View file

@ -1,5 +1,4 @@
import tomllib
from typing import TypeAlias
import tomli_w
from fastapi import Depends
@ -20,7 +19,7 @@ class DoorSaved(BaseModel):
y2: int
DoorsSaved: TypeAlias = list[DoorSaved]
type DoorsSaved = list[DoorSaved]
class CalendarConfig(BaseModel):

View file

@ -55,6 +55,6 @@ class RedisCache(__RedisCache):
try:
return super()._deserialize(s)
except (UnicodeDecodeError, JSONDecodeError):
except UnicodeDecodeError, JSONDecodeError:
assert isinstance(s, bytes)
return s

View file

@ -219,7 +219,7 @@ async def get_day_image(
image = await AdventImage.from_img(img, cfg)
return image.img
except (KeyError, RuntimeError):
except KeyError, RuntimeError:
# Erstelle automatisch generiertes Bild
return await gen_day_auto_image(
day=day,

View file

@ -1,10 +1,6 @@
from typing import TypeVar
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T")
class Credentials(BaseModel):
username: str = ""
@ -55,6 +51,7 @@ class Settings(BaseSettings):
"""
model_config = SettingsConfigDict(
env_prefix="ADVENT22__",
env_file="api.conf",
env_file_encoding="utf-8",
env_nested_delimiter="__",
@ -65,13 +62,13 @@ class Settings(BaseSettings):
#####
production_mode: bool = False
ui_directory: str = "/usr/local/share/advent22_ui/html"
ui_directory: str = "/opt/advent22/ui"
#####
# openapi settings
#####
def __dev_value(self, value: T) -> T | None:
def __dev_value[T](self, value: T) -> T | None:
if self.production_mode:
return None

View file

@ -1,22 +0,0 @@
#!/usr/bin/python3
import uvicorn
from .core.settings import SETTINGS
def main() -> None:
"""
If the `main` script is run, `uvicorn` is used to run the app.
"""
uvicorn.run(
app="advent22_api.app:app",
host="0.0.0.0",
port=8000,
reload=not SETTINGS.production_mode,
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,58 @@
import os
from granian.cli import cli as granian_cli
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkersSettings(BaseModel):
per_core: int = Field(1, ge=1)
max: int | None = Field(None, ge=1)
exact: int | None = Field(None, ge=1)
@property
def count(self) -> int:
# usage of "or" operator: values here are not allowed to be 0
base = self.exact or (self.per_core * (os.cpu_count() or 1))
return min(base, self.max or base)
class BindSettings(BaseModel):
host: str = "0.0.0.0"
port: int = 8000
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="ADVENT22__",
env_nested_delimiter="__",
)
workers: WorkersSettings = WorkersSettings()
bind: BindSettings = BindSettings()
def start():
os.environ["ADVENT22__PRODUCTION_MODE"] = "true"
settings = Settings()
granian_cli(
[
"--host",
settings.bind.host,
"--port",
settings.bind.port,
"--workers",
settings.workers.count,
"--interface",
"asgi",
"--loop",
"uvloop",
"--process-name",
"advent22",
# app
"advent22_api.app:app",
],
auto_envvar_prefix="GRANIAN",
standalone_mode=False,
)

1876
api/poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,33 +1,44 @@
[tool.poetry]
authors = [
"Jörn-Michael Miehe <jmm@yavook.de>",
"Penner42 <unbekannt42@web.de>",
]
description = ""
license = "MIT"
name = "advent22_api"
[project]
name = "advent22-api"
version = "0.1.0"
description = ""
license = {file = "LICENSE"}
readme = "README.md"
requires-python = ">=3.14"
authors = [
{name = "Jörn-Michael Miehe", email = "jmm@yavook.de"},
{name = "Penner42", email = "unbekannt42@web.de"},
]
dependencies = [
"asyncify>=0.12.1",
"cachetools>=7.0.1",
"cachetoolsutils>=11.0",
"fastapi>=0.129.0",
"granian[pname,uvloop]>=2.7.1",
"markdown>=3.10.2",
"numpy>=2.4.2",
"pillow>=12.1.1",
"pydantic-settings>=2.13.1",
"redis[hiredis]>=7.2.0",
"requests>=2.32.5",
"tomli-w>=1.2.0",
"webdavclient3>=3.14.7",
]
[tool.poetry.dependencies]
Pillow = "^12.1.1"
asyncify = "^0.12.1"
cachetools = "^7.0.1"
cachetoolsutils = "^11.0"
fastapi = "^0.129.0"
markdown = "^3.10.2"
numpy = "^2.4.2"
pydantic-settings = "^2.13.0"
python = ">=3.11,<3.15"
redis = {extras = ["hiredis"], version = "^7.1.1"}
tomli-w = "^1.2.0"
uvicorn = {extras = ["standard"], version = "^0.40.0"}
webdavclient3 = "^3.14.7"
[project.scripts]
advent22 = "advent22_api.production:start"
[tool.poetry.group.dev.dependencies]
black = "^26.1.0"
flake8 = "^7.3.0"
pytest = "^9.0.2"
[dependency-groups]
dev = [
"fastapi[standard]>=0.129.0",
"pytest>=9.0.2",
"ruff>=0.15.2",
]
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0"]
requires = ["uv_build>=0.10.4,<0.11.0", "packaging"]
build-backend = "uv_build"
[tool.uv.build-backend]
# module-name = "advent22_api"
module-root = ""

1177
api/uv.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,67 +0,0 @@
import json
import multiprocessing
import os
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
max_workers_str = os.getenv("MAX_WORKERS")
use_max_workers = None
if max_workers_str:
use_max_workers = int(max_workers_str)
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
host = os.getenv("HOST", "0.0.0.0")
port = os.getenv("PORT", "80")
bind_env = os.getenv("BIND", None)
use_loglevel = os.getenv("LOG_LEVEL", "info")
if bind_env:
use_bind = bind_env
else:
use_bind = f"{host}:{port}"
cores = multiprocessing.cpu_count()
workers_per_core = float(workers_per_core_str)
default_web_concurrency = workers_per_core * cores
if web_concurrency_str:
web_concurrency = int(web_concurrency_str)
assert web_concurrency > 0
else:
web_concurrency = max(int(default_web_concurrency), 2)
if use_max_workers:
web_concurrency = min(web_concurrency, use_max_workers)
accesslog_var = os.getenv("ACCESS_LOG", "-")
use_accesslog = accesslog_var or None
errorlog_var = os.getenv("ERROR_LOG", "-")
use_errorlog = errorlog_var or None
graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120")
timeout_str = os.getenv("TIMEOUT", "120")
keepalive_str = os.getenv("KEEP_ALIVE", "5")
# Gunicorn config variables
loglevel = use_loglevel
workers = web_concurrency
bind = use_bind
errorlog = use_errorlog
worker_tmp_dir = "/dev/shm"
accesslog = use_accesslog
graceful_timeout = int(graceful_timeout_str)
timeout = int(timeout_str)
keepalive = int(keepalive_str)
# For debugging and testing
log_data = {
"loglevel": loglevel,
"workers": workers,
"bind": bind,
"graceful_timeout": graceful_timeout,
"timeout": timeout,
"keepalive": keepalive,
"errorlog": errorlog,
"accesslog": accesslog,
# Additional, non-gunicorn variables
"workers_per_core": workers_per_core,
"use_max_workers": use_max_workers,
"host": host,
"port": port,
}
print(json.dumps(log_data))

View file

@ -1,20 +0,0 @@
#!/bin/sh
set -e
MODULE_NAME=${MODULE_NAME:-"app.main"}
VARIABLE_NAME=${VARIABLE_NAME:-"app"}
export APP_MODULE="${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"}"
export GUNICORN_CONF="${GUNICORN_CONF:-"/usr/local/share/uvicorn-gunicorn/gunicorn_conf.py"}"
export WORKER_CLASS="${WORKER_CLASS:-"uvicorn.workers.UvicornWorker"}"
if [ -f "${PRE_START_PATH}" ] ; then
echo "Running script ${PRE_START_PATH}"
# shellcheck disable=SC1090
. "${PRE_START_PATH}"
fi
# Start Gunicorn
exec gunicorn \
-k "${WORKER_CLASS}" \
-c "${GUNICORN_CONF}" \
"${APP_MODULE}"

View file

@ -9,6 +9,9 @@
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers-extra/features/zsh-plugins:0": {
"plugins": "git-flow npm nvm yarn"
},
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
"packages": "git-flow"
},