You've already forked RekomenciBackend
add tests and docker infra
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
config.toml
|
||||||
|
.venv
|
||||||
|
.idea
|
||||||
|
__pycache__
|
||||||
|
docker-compose.yml
|
||||||
|
*.egg-info
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
[doc("Все команды")]
|
||||||
|
default:
|
||||||
|
just --list --unsorted --list-heading $'Commands…\n'
|
||||||
|
|
||||||
|
# =========
|
||||||
|
# > Docker
|
||||||
|
# =========
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Docker")]
|
||||||
|
[doc("Билд основного контейнера")]
|
||||||
|
docker-build-main:
|
||||||
|
docker build -t template_project_deploy .
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Docker")]
|
||||||
|
[doc("Билд тестового контейнера")]
|
||||||
|
docker-build-tests:
|
||||||
|
docker build -t template_project_tests .
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Docker")]
|
||||||
|
[doc("Билд миграционного контейнера")]
|
||||||
|
docker-build-migrations:
|
||||||
|
docker build -t template_project_migrations .
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Docker")]
|
||||||
|
[doc("Билд всех контейнеров")]
|
||||||
|
docker-build-all:
|
||||||
|
just docker-build-main
|
||||||
|
just docker-build-tests
|
||||||
|
just docker-build-migrations
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Docker")]
|
||||||
|
[doc("Запуск композа")]
|
||||||
|
docker-up:
|
||||||
|
just docker-build-all
|
||||||
|
|
||||||
|
docker compose up web_api -d
|
||||||
|
|
||||||
|
# =========
|
||||||
|
# > Tests
|
||||||
|
# =========
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Tests")]
|
||||||
|
[doc("Запуск тестов")]
|
||||||
|
tests-run:
|
||||||
|
just docker-up
|
||||||
|
|
||||||
|
docker compose up tests --abort-on-container-exit --remove-orphans
|
||||||
|
coverage report
|
||||||
|
|
||||||
|
# =========
|
||||||
|
# > Lints
|
||||||
|
# =========
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Lints")]
|
||||||
|
[doc("Запуск всех линтов")]
|
||||||
|
lints-run:
|
||||||
|
ruff check
|
||||||
|
mypy
|
||||||
|
codespell src tests
|
||||||
|
bandit src tests
|
||||||
|
|
||||||
|
# =========
|
||||||
|
# > Migrations
|
||||||
|
# =========
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Migrations")]
|
||||||
|
[doc("Запуск миграции")]
|
||||||
|
migrations-run tag="head":
|
||||||
|
docker compose run --remove-orphans migrations alembic upgrade {{tag}}
|
||||||
|
docker compose down postgresql
|
||||||
|
|
||||||
|
[no-cd]
|
||||||
|
[group("Migrations")]
|
||||||
|
[doc('Создание миграции')]
|
||||||
|
migrations-make message:
|
||||||
|
docker compose run migrations alembic revision --autogenerate -m "{{message}}"
|
||||||
|
docker compose down postgresql
|
||||||
+63
-6
@@ -1,13 +1,70 @@
|
|||||||
FROM python:3.13-alpine
|
# =====
|
||||||
|
# > Python-Base
|
||||||
|
# =====
|
||||||
|
|
||||||
WORKDIR /app
|
FROM python:3.13-slim-bookworm AS python-base
|
||||||
|
|
||||||
RUN pip install uv
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONOPTIMIZE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_DEFAULT_TIMEOUT=100 \
|
||||||
|
APP_PATH="/app" \
|
||||||
|
UV_VERSION="0.8.19"
|
||||||
|
|
||||||
|
ENV \
|
||||||
|
VIRTUAL_ENV="$APP_PATH/.venv" \
|
||||||
|
PATH="$VIRTUAL_ENV/bin:$PATH" \
|
||||||
|
PROJECT_PATH="$APP_PATH/src/template_project"
|
||||||
|
|
||||||
|
WORKDIR $APP_PATH
|
||||||
|
|
||||||
|
# =====
|
||||||
|
# > Builder
|
||||||
|
# =====
|
||||||
|
|
||||||
|
FROM python-base AS template_project_builder
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gcc git \
|
||||||
|
&& rm -rf /var/lib/apt/lists
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir "uv==$UV_VERSION"
|
||||||
RUN mkdir -p ./src/
|
RUN mkdir -p ./src/
|
||||||
|
|
||||||
COPY pyproject.toml /app/pyproject.toml
|
COPY pyproject.toml $APP_PATH/pyproject.toml
|
||||||
|
RUN uv venv -p 3.13 && uv pip install -e .
|
||||||
|
COPY ./src/template_project $PROJECT_PATH
|
||||||
|
|
||||||
RUN uv pip install -e . --system --no-cache
|
# =====
|
||||||
|
# > Deploy
|
||||||
|
# =====
|
||||||
|
|
||||||
COPY ./src/template_project /app/src/template_project
|
FROM template_project_builder AS template_project_deploy
|
||||||
|
|
||||||
|
COPY --from=template_project_builder $VIRTUAL_ENV $VIRTUAL_ENV
|
||||||
|
|
||||||
|
# =====
|
||||||
|
# > Tests
|
||||||
|
# =====
|
||||||
|
|
||||||
|
FROM template_project_builder AS template_project_tests
|
||||||
|
|
||||||
|
COPY --from=template_project_builder $VIRTUAL_ENV $VIRTUAL_ENV
|
||||||
|
|
||||||
|
RUN uv pip install --group tests
|
||||||
|
|
||||||
|
COPY ./tests $APP_PATH/tests
|
||||||
|
|
||||||
|
# =====
|
||||||
|
# > Migrations
|
||||||
|
# =====
|
||||||
|
|
||||||
|
FROM template_project_builder AS template_project_migrations
|
||||||
|
|
||||||
|
COPY --from=template_project_builder $VIRTUAL_ENV $VIRTUAL_ENV
|
||||||
|
|
||||||
|
VOLUME $PROJECT_PATH/migrations
|
||||||
|
|
||||||
|
RUN uv pip install --group migrations
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/src/template_project/migrations
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
services:
|
||||||
|
web_api:
|
||||||
|
image: template_project_deploy
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/app/config.toml:ro
|
||||||
|
command: web_api_cli /app/config.toml
|
||||||
|
depends_on:
|
||||||
|
- postgresql
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "curl", "-f", "http://localhost:8080/healthcheck/" ]
|
||||||
|
interval: 2s
|
||||||
|
|
||||||
|
tests:
|
||||||
|
image: template_project_tests
|
||||||
|
volumes:
|
||||||
|
- ./cov/:/app/cov
|
||||||
|
- ./tests:/app/tests:ro
|
||||||
|
- ./config.toml:/app/config.toml:ro
|
||||||
|
environment:
|
||||||
|
CONFIGURATION_PATH: /app/config.toml
|
||||||
|
command: coverage run --source src -m pytest && coverage report && coverage xml -o ./cov/coverage.xml
|
||||||
|
depends_on:
|
||||||
|
web_api:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
migrations:
|
||||||
|
image: template_project_migrations
|
||||||
|
environment:
|
||||||
|
CONFIGURATION_PATH: /app/config.toml
|
||||||
|
volumes:
|
||||||
|
- ./alembic.ini:/app/alembic.ini:ro
|
||||||
|
- ./config.toml:/app/config.toml:ro
|
||||||
|
- ./src/template_project/migrations:/app/src/template_project/migrations
|
||||||
|
depends_on:
|
||||||
|
- postgresql
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: template_project_password
|
||||||
|
POSTGRES_USER: template_project_user
|
||||||
|
POSTGRES_DB: template_project_db
|
||||||
|
PGDATA: /var/lib/postgresql/data/
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgresql_volume:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_volume:
|
||||||
|
postgresql_volume:
|
||||||
+147
-2
@@ -4,11 +4,156 @@ requires-python = ">=3.13"
|
|||||||
description = "template project"
|
description = "template project"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"uuid-utils==0.11.1",
|
"uuid_utils==0.11.1",
|
||||||
"adaptix==3.0.0b11",
|
"adaptix==3.0.0b11",
|
||||||
"fastapi==0.119.0",
|
"fastapi==0.119.0",
|
||||||
"uvicorn==0.37.0",
|
"uvicorn==0.37.0",
|
||||||
|
"sqlalchemy==2.0.44",
|
||||||
"dishka==1.7.2",
|
"dishka==1.7.2",
|
||||||
"argon2-cffi==23.1.0",
|
"argon2_cffi==23.1.0",
|
||||||
"cryptography==46.0.3",
|
"cryptography==46.0.3",
|
||||||
|
"httpx==0.28.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
types = [
|
||||||
|
"types-cachetools==6.2.0.20250827",
|
||||||
|
]
|
||||||
|
migrations = [
|
||||||
|
"alembic==1.17.0",
|
||||||
|
]
|
||||||
|
linters = [
|
||||||
|
"mypy==1.17.1",
|
||||||
|
"ruff==0.12.11",
|
||||||
|
"codespell==2.4.1",
|
||||||
|
"bandit==1.8.6",
|
||||||
|
{ include-group = "types" },
|
||||||
|
]
|
||||||
|
tests = [
|
||||||
|
"pytest==8.4.0",
|
||||||
|
"coverage==7.11.0",
|
||||||
|
"pytest_asyncio==1.2.0",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
{ include-group = "tests" },
|
||||||
|
{ include-group = "linters" },
|
||||||
|
{ include-group = "migrations"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
web_api_cli = "template_project.web_api.entry_point:main"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::UserWarning",
|
||||||
|
'ignore:function ham\(\) is deprecated:DeprecationWarning',
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
strict_bytes = true
|
||||||
|
local_partial_types = true
|
||||||
|
warn_unreachable = true
|
||||||
|
files = ["src", "tests"]
|
||||||
|
exclude = [
|
||||||
|
"src/template_project/migrations",
|
||||||
|
]
|
||||||
|
enable_error_code = [
|
||||||
|
"truthy-bool",
|
||||||
|
"truthy-iterable",
|
||||||
|
"redundant-expr",
|
||||||
|
"unused-awaitable",
|
||||||
|
"ignore-without-code",
|
||||||
|
"possibly-undefined",
|
||||||
|
"redundant-self",
|
||||||
|
"explicit-override",
|
||||||
|
"mutable-override",
|
||||||
|
"unimported-reveal",
|
||||||
|
"deprecated",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
fix = true
|
||||||
|
preview = true
|
||||||
|
target-version = "py313"
|
||||||
|
line-length = 120
|
||||||
|
indent-width = 4
|
||||||
|
exclude = [
|
||||||
|
"src/template_project/migrations/**.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"A", # flake8-builtins
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"C90", # maccabe
|
||||||
|
"COM", # flake8-commas
|
||||||
|
"D", # pydocstyle
|
||||||
|
"DTZ", # flake8-datetimez
|
||||||
|
"E", # pycodestyle
|
||||||
|
"ERA", # flake8-eradicate
|
||||||
|
"EXE", # flake8-executable
|
||||||
|
"F", # pyflakes
|
||||||
|
"FA", # flake8-future-annotations
|
||||||
|
"FBT", # flake8-boolean-trap
|
||||||
|
"FLY", # pyflint
|
||||||
|
"FURB", # refurb
|
||||||
|
"G", # flake8-logging-format
|
||||||
|
"I", # isort
|
||||||
|
"ICN", # flake8-import-conventions
|
||||||
|
"ISC", # flake8-implicit-str-concat
|
||||||
|
"LOG", # flake8-logging
|
||||||
|
"N", # pep8-naming
|
||||||
|
"PERF", # perflint
|
||||||
|
"PIE", # flake8-pie
|
||||||
|
"PL", # pylint
|
||||||
|
"PT", # flake8-pytest-style
|
||||||
|
"PTH", # flake8-use-pathlib
|
||||||
|
"Q", # flake8-quotes
|
||||||
|
"RET", # flake8-return
|
||||||
|
"RSE", # flake8-raise
|
||||||
|
"RUF", # ruff
|
||||||
|
"S", # flake8-bandit
|
||||||
|
"SIM", # flake8-simpify
|
||||||
|
"SLF", # flake8-self
|
||||||
|
"SLOT", # flake8-slots
|
||||||
|
"T100", # flake8-debugger
|
||||||
|
"TRY", # tryceratops
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"W", # pycodestyle
|
||||||
|
"YTT", # flake8-2020
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"A005", # allow to shadow stdlib and builtin module names
|
||||||
|
"COM812", # trailing comma, conflicts with `ruff format`
|
||||||
|
# Different doc rules that we don't really care about:
|
||||||
|
"D",
|
||||||
|
"ISC001", # implicit string concat conflicts with `ruff format`
|
||||||
|
"ISC003", # prefer explicit string concat over implicit concat
|
||||||
|
"PLR09", # we have our own complexity rules
|
||||||
|
"PLR2004", # do not report magic numbers
|
||||||
|
"PLR6301", # do not require classmethod / staticmethod when self not used
|
||||||
|
"TRY003", # long exception messages from `tryceratops`
|
||||||
|
]
|
||||||
|
external = [ "WPS" ]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*.py" = [
|
||||||
|
"S101", # asserts
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
case-sensitive = true
|
||||||
|
combine-as-imports = true
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "lf"
|
||||||
|
|
||||||
|
# This is only required because we have invalid on-purpose code in docstrings:
|
||||||
|
docstring-code-format = false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
|
"access_token_table",
|
||||||
"meta_data",
|
"meta_data",
|
||||||
"user_table",
|
"user_table",
|
||||||
"access_token_table",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, MetaData, String, Table
|
from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, MetaData, String, Table
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from typing import override
|
from typing import TYPE_CHECKING, override
|
||||||
|
|
||||||
from template_project.application.access_token.entity import AccessTokenId
|
|
||||||
from template_project.application.common.errors import ApplicationError, to_error
|
from template_project.application.common.errors import ApplicationError, to_error
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from template_project.application.access_token.entity import AccessTokenId
|
||||||
|
|
||||||
|
|
||||||
@to_error
|
@to_error
|
||||||
class AccessTokenExpiredError(ApplicationError):
|
class AccessTokenExpiredError(ApplicationError):
|
||||||
id_: AccessTokenId
|
id_: "AccessTokenId"
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from template_project.web_api.configuration import load_configuration
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
from template_project.adapters.data_gateways.tables import meta_data
|
||||||
|
target_metadata = meta_data
|
||||||
|
|
||||||
|
configuration = load_configuration(Path(os.environ["CONFIGURATION_PATH"]))
|
||||||
|
config.set_main_option("sqlalchemy.url", configuration.database.url.get_value())
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -31,6 +31,10 @@ class ServerConfiguration:
|
|||||||
port: int
|
port: int
|
||||||
access_log: bool
|
access_log: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
return f"http://{self.host}:{self.port}"
|
||||||
|
|
||||||
|
|
||||||
@to_configuration
|
@to_configuration
|
||||||
class Configuration:
|
class Configuration:
|
||||||
|
|||||||
+14
-8
@@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -14,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from template_project.web_api.configuration import load_configuration
|
from template_project.web_api.configuration import load_configuration
|
||||||
from template_project.web_api.ioc.make import make_ioc
|
from template_project.web_api.ioc.make import make_ioc
|
||||||
|
from template_project.web_api.routes import healthcheck, user
|
||||||
|
|
||||||
LOG_CONFIG: Final = {
|
LOG_CONFIG: Final = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -53,15 +55,15 @@ def make_asgi_application(
|
|||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
openapi_url="/openapi.json",
|
openapi_url="/openapi.json",
|
||||||
)
|
)
|
||||||
origins = ["*"]
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=origins,
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.include_router(user.router)
|
||||||
|
app.include_router(healthcheck.router)
|
||||||
|
|
||||||
setup_dishka(container=ioc, app=app)
|
setup_dishka(container=ioc, app=app)
|
||||||
|
|
||||||
@@ -91,13 +93,17 @@ def main() -> None:
|
|||||||
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
|
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
|
||||||
|
|
||||||
arg_parser = argparse.ArgumentParser()
|
arg_parser = argparse.ArgumentParser()
|
||||||
subparsers = arg_parser.add_subparsers()
|
arg_parser.add_argument("configuration", default=None)
|
||||||
|
|
||||||
web_api_parser = subparsers.add_parser("web_api")
|
|
||||||
web_api_parser.add_argument("configuration", dest="configuration", type=Path)
|
|
||||||
|
|
||||||
args = arg_parser.parse_args()
|
args = arg_parser.parse_args()
|
||||||
_main(args.configuration)
|
configuration_path = args.configuration or os.getenv("CONFIGURATION_PATH")
|
||||||
|
|
||||||
|
if configuration_path is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"pass the path to the config or specify it in the environment variables `CONFIGURATION_PATH`",
|
||||||
|
)
|
||||||
|
|
||||||
|
_main(Path(configuration_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -8,13 +8,13 @@ from template_project.web_api.configuration import DatabaseConfiguration
|
|||||||
|
|
||||||
class ConnectionProvider(Provider):
|
class ConnectionProvider(Provider):
|
||||||
@provide(scope=Scope.APP)
|
@provide(scope=Scope.APP)
|
||||||
async def make_engine(self, configuration: DatabaseConfiguration) -> AsyncIterable[AsyncEngine]:
|
async def engine(self, configuration: DatabaseConfiguration) -> AsyncIterable[AsyncEngine]:
|
||||||
engine = create_async_engine(configuration.url.get_value())
|
engine = create_async_engine(configuration.url.get_value())
|
||||||
yield engine
|
yield engine
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
@provide()
|
@provide(scope=Scope.REQUEST)
|
||||||
async def make_connection(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]:
|
async def async_session(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]:
|
||||||
session = AsyncSession(
|
session = AsyncSession(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
expire_on_commit=True,
|
expire_on_commit=True,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from dishka import BaseScope, Provider, Scope, provide
|
||||||
|
|
||||||
|
from template_project.web_api.identity_provider import WebApiIdentityProvider
|
||||||
|
|
||||||
|
|
||||||
|
class IdPProvider(Provider):
|
||||||
|
scope: BaseScope | None = Scope.REQUEST
|
||||||
|
|
||||||
|
web_api = provide(WebApiIdentityProvider)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from dishka import AsyncContainer, make_async_container
|
from dishka import STRICT_VALIDATION, AsyncContainer, make_async_container
|
||||||
from dishka.integrations.fastapi import FastapiProvider
|
from dishka.integrations.fastapi import FastapiProvider
|
||||||
|
|
||||||
from template_project.web_api.configuration import (
|
from template_project.web_api.configuration import (
|
||||||
@@ -7,19 +7,24 @@ from template_project.web_api.configuration import (
|
|||||||
DatabaseConfiguration,
|
DatabaseConfiguration,
|
||||||
ServerConfiguration,
|
ServerConfiguration,
|
||||||
)
|
)
|
||||||
|
from template_project.web_api.ioc.connection import ConnectionProvider
|
||||||
from template_project.web_api.ioc.cryptographer import CryptographerProvider
|
from template_project.web_api.ioc.cryptographer import CryptographerProvider
|
||||||
from template_project.web_api.ioc.data_gateway import DataGatewayProvider
|
from template_project.web_api.ioc.data_gateway import DataGatewayProvider
|
||||||
from template_project.web_api.ioc.factory import FactoryProvider
|
from template_project.web_api.ioc.factory import FactoryProvider
|
||||||
|
from template_project.web_api.ioc.idp import IdPProvider
|
||||||
from template_project.web_api.ioc.interactor import InteractorProvider
|
from template_project.web_api.ioc.interactor import InteractorProvider
|
||||||
|
|
||||||
|
|
||||||
def make_ioc(configuration: Configuration) -> AsyncContainer:
|
def make_ioc(configuration: Configuration) -> AsyncContainer:
|
||||||
return make_async_container(
|
return make_async_container(
|
||||||
|
IdPProvider(),
|
||||||
FactoryProvider(),
|
FactoryProvider(),
|
||||||
FastapiProvider(),
|
FastapiProvider(),
|
||||||
|
ConnectionProvider(),
|
||||||
InteractorProvider(),
|
InteractorProvider(),
|
||||||
DataGatewayProvider(),
|
DataGatewayProvider(),
|
||||||
CryptographerProvider(),
|
CryptographerProvider(),
|
||||||
|
validation_settings=STRICT_VALIDATION,
|
||||||
context={
|
context={
|
||||||
ServerConfiguration: configuration.server,
|
ServerConfiguration: configuration.server,
|
||||||
DatabaseConfiguration: configuration.database,
|
DatabaseConfiguration: configuration.database,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from dishka.integrations.fastapi import DishkaRoute
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter(route_class=DishkaRoute)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthcheckResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/healthcheck")
|
||||||
|
async def healthcheck() -> HealthcheckResponse:
|
||||||
|
return HealthcheckResponse(ok=True)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from collections.abc import AsyncIterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dishka import AsyncContainer
|
||||||
|
|
||||||
|
from template_project.web_api.configuration import load_configuration
|
||||||
|
from tests.web_api.ioc import make_ioc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def dishka_container() -> AsyncIterable[AsyncContainer]:
|
||||||
|
path = Path("config.toml")
|
||||||
|
configuration = load_configuration(path)
|
||||||
|
ioc = make_ioc(configuration)
|
||||||
|
yield ioc
|
||||||
|
await ioc.close()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from dishka import FromDishka
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from tests.web_api.ioc import inject
|
||||||
|
|
||||||
|
|
||||||
|
@inject
|
||||||
|
async def test_healthcheck(
|
||||||
|
http_client: FromDishka[AsyncClient],
|
||||||
|
) -> None:
|
||||||
|
response = await http_client.get("/healthcheck")
|
||||||
|
response_json = response.json()
|
||||||
|
|
||||||
|
assert response_json["ok"]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from inspect import Parameter
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from dishka import STRICT_VALIDATION, AsyncContainer, BaseScope, Provider, Scope, make_async_container, provide
|
||||||
|
from dishka.integrations.base import wrap_injection
|
||||||
|
from dishka.integrations.fastapi import FastapiProvider
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from template_project.web_api.configuration import (
|
||||||
|
AccessTokenConfiguration,
|
||||||
|
Configuration,
|
||||||
|
DatabaseConfiguration,
|
||||||
|
ServerConfiguration,
|
||||||
|
)
|
||||||
|
from template_project.web_api.ioc.connection import ConnectionProvider
|
||||||
|
from template_project.web_api.ioc.cryptographer import CryptographerProvider
|
||||||
|
from template_project.web_api.ioc.data_gateway import DataGatewayProvider
|
||||||
|
from template_project.web_api.ioc.factory import FactoryProvider
|
||||||
|
from template_project.web_api.ioc.idp import IdPProvider
|
||||||
|
from template_project.web_api.ioc.interactor import InteractorProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseClearer:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def clear(self) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("""
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tb text;
|
||||||
|
BEGIN
|
||||||
|
FOR tb IN (
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_catalog.pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename != 'alembic_version'
|
||||||
|
)
|
||||||
|
LOOP
|
||||||
|
EXECUTE 'TRUNCATE TABLE ' || tb || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProvider(Provider):
|
||||||
|
scope: BaseScope | None = Scope.REQUEST
|
||||||
|
|
||||||
|
database_clearer = provide(DatabaseClearer)
|
||||||
|
|
||||||
|
@provide
|
||||||
|
def http_client(self) -> AsyncClient:
|
||||||
|
return AsyncClient(base_url="http://web_api:8080")
|
||||||
|
|
||||||
|
|
||||||
|
def make_ioc(configuration: Configuration) -> AsyncContainer:
|
||||||
|
return make_async_container(
|
||||||
|
IdPProvider(),
|
||||||
|
FactoryProvider(),
|
||||||
|
FastapiProvider(),
|
||||||
|
ConnectionProvider(),
|
||||||
|
InteractorProvider(),
|
||||||
|
DataGatewayProvider(),
|
||||||
|
CryptographerProvider(),
|
||||||
|
TestProvider(),
|
||||||
|
validation_settings=STRICT_VALIDATION,
|
||||||
|
context={
|
||||||
|
ServerConfiguration: configuration.server,
|
||||||
|
DatabaseConfiguration: configuration.database,
|
||||||
|
AccessTokenConfiguration: configuration.access_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CONTAINER_PARAM: Final = "dishka_container"
|
||||||
|
|
||||||
|
|
||||||
|
def inject[ReturnT, **FuncParams](func: Callable[FuncParams, ReturnT]) -> Callable[FuncParams, ReturnT]:
|
||||||
|
return wrap_injection(
|
||||||
|
func=func,
|
||||||
|
is_async=True,
|
||||||
|
manage_scope=True,
|
||||||
|
container_getter=lambda args, kwargs: kwargs[CONTAINER_PARAM],
|
||||||
|
additional_params=[
|
||||||
|
Parameter(
|
||||||
|
name=CONTAINER_PARAM,
|
||||||
|
annotation=AsyncContainer,
|
||||||
|
kind=Parameter.KEYWORD_ONLY,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user