From 451bc7a597439b392ed3c32a28eff3d9e3562e43 Mon Sep 17 00:00:00 2001 From: Fan4ik20 Date: Thu, 7 Sep 2023 01:40:28 +0300 Subject: [PATCH] Created initial structure. Added linters, dockerfile, actions --- .github/workflows/lint.yaml | 28 +++++ .gitignore | 11 ++ .pre-commit-config.yaml | 27 ++++ app/.dockerignore | 17 +++ app/.env.sample | 4 + app/Dockerfile | 21 ++++ app/alembic.ini | 116 ++++++++++++++++++ app/core/__init__.py | 0 app/core/domain/__init__.py | 0 app/core/services/__init__.py | 0 app/infrastructure/__init__.py | 0 app/infrastructure/database/__init__.py | 13 ++ app/infrastructure/database/alembic/README | 1 + app/infrastructure/database/alembic/env.py | 106 ++++++++++++++++ .../database/alembic/script.py.mako | 26 ++++ app/infrastructure/database/db_utils.py | 34 +++++ app/infrastructure/database/models.py | 5 + app/lint.sh | 4 + app/presentation/__init__.py | 0 app/presentation/main.py | 5 + app/pyproject.toml | 23 ++++ app/requirements.txt | 5 + app/requirements_dev.txt | 3 + app/start.sh | 5 + docker-compose.yml | 30 +++++ 25 files changed, 484 insertions(+) create mode 100644 .github/workflows/lint.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 app/.dockerignore create mode 100644 app/.env.sample create mode 100644 app/Dockerfile create mode 100644 app/alembic.ini create mode 100644 app/core/__init__.py create mode 100644 app/core/domain/__init__.py create mode 100644 app/core/services/__init__.py create mode 100644 app/infrastructure/__init__.py create mode 100644 app/infrastructure/database/__init__.py create mode 100644 app/infrastructure/database/alembic/README create mode 100644 app/infrastructure/database/alembic/env.py create mode 100644 app/infrastructure/database/alembic/script.py.mako create mode 100644 app/infrastructure/database/db_utils.py create mode 100644 app/infrastructure/database/models.py create mode 100644 app/lint.sh create mode 100644 app/presentation/__init__.py create mode 100644 app/presentation/main.py create mode 100644 app/pyproject.toml create mode 100644 app/requirements.txt create mode 100644 app/requirements_dev.txt create mode 100755 app/start.sh create mode 100644 docker-compose.yml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..ffc930a --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,28 @@ +name: Lint +on: + pull_request: + push: { branches: master } + +jobs: + test: + defaults: + run: + working-directory: ./app + + name: Run lint suite + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Grant privileges + run: chmod +x ./lint.sh + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_dev.txt + + - name: Run testing phase + run: ./lint.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2e8962 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# JetBrains +.idea + +# Python +venv +__pycache__ +*.sql + +# Secrets +.env +.env.local diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9db51e3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/psf/black + rev: 22.12.0 + hooks: + - id: black + args: ["--config=app/pyproject.toml"] + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--settings-path=app/pyproject.toml"] + + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + entry: pflake8 + additional_dependencies: [pyproject-flake8] + args: ["--config=app/pyproject.toml"] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.5.0 + hooks: + - id: mypy + args: ['--config-file', 'app/pyproject.toml', '--show-traceback'] + name: mypy \ No newline at end of file diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..2787e99 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,17 @@ +# Ignore everything +* + +# Allow files and directories + +#Global +!/app +!/alembic.ini +!/requirements.txt +!/start.sh + +#Lint +!/.git +!/.pre-commit-config.yaml +!/pyproject.toml +!/requirements_dev.txt +!/lint.sh diff --git a/app/.env.sample b/app/.env.sample new file mode 100644 index 0000000..be8d857 --- /dev/null +++ b/app/.env.sample @@ -0,0 +1,4 @@ +DB_USER= +DB_PASSWORD= +DB_ENDPOINT= +DB_NAME= \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..8c7ccc7 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,21 @@ +# Pull base image +FROM python:3.10 + +# Set environment varibles +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /code/ + +# Install dependencies +RUN pip install --upgrade pip +COPY ./requirements.txt . +RUN pip install -r requirements.txt + +# Copy app +COPY . . + +EXPOSE 7777 + +RUN chmod +x ./start.sh +CMD ["./start.sh"] \ No newline at end of file diff --git a/app/alembic.ini b/app/alembic.ini new file mode 100644 index 0000000..dad54e1 --- /dev/null +++ b/app/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = infrastructure/database/alembic + +# 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. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# 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 infrastructure/database/alembic/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 "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:infrastructure/database/alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# 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 + +sqlalchemy.url = + + +[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 exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +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 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/domain/__init__.py b/app/core/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/services/__init__.py b/app/core/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/infrastructure/__init__.py b/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/infrastructure/database/__init__.py b/app/infrastructure/database/__init__.py new file mode 100644 index 0000000..8d3d023 --- /dev/null +++ b/app/infrastructure/database/__init__.py @@ -0,0 +1,13 @@ +from .db_utils import ( + DBConfig, + build_async_db_url, + create_engine, + create_sessionmaker, +) + +__all__ = ( + "DBConfig", + "create_engine", + "create_sessionmaker", + "build_async_db_url", +) diff --git a/app/infrastructure/database/alembic/README b/app/infrastructure/database/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/app/infrastructure/database/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/app/infrastructure/database/alembic/env.py b/app/infrastructure/database/alembic/env.py new file mode 100644 index 0000000..67d6aab --- /dev/null +++ b/app/infrastructure/database/alembic/env.py @@ -0,0 +1,106 @@ +# type: ignore + +from logging.config import fileConfig + +from alembic import context +from alembic.script import ScriptDirectory +from sqlalchemy.ext.asyncio import create_async_engine + +from infrastructure.database import DBConfig, build_async_db_url, models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# this will overwrite the ini-file sqlalchemy.url path +# with the path given in the config of the main code + +# TODO. NOT WORKING +config.set_main_option("sqlalchemy.url", build_async_db_url(DBConfig())) + +# 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) + +# here target_metadata was equal to None +target_metadata = models.Base.metadata + +# 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 process_revision_directives(context, revision, directives): + # extract Migration + migration_script = directives[0] + # extract current head revision + head_revision = ScriptDirectory.from_config( + context.config + ).get_current_head() + + if head_revision is None: + # edge case with first migration + new_rev_id = 1 + else: + # default branch with incrementation + last_rev_id = int(head_revision.lstrip("0")) + new_rev_id = last_rev_id + 1 + # fill zeros up to 5 digits: 1 -> 00001 + migration_script.rev_id = "{0:05}".format(new_rev_id) + + +def run_migrations_offline(): + """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() + + +async def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + engine = create_async_engine(config.get_main_option("sqlalchemy.url")) + + def do_migrations(connection_): + context.configure( + connection=connection_, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + ) + + with context.begin_transaction(): + context.run_migrations() + + async with engine.connect() as connection: + await connection.run_sync(do_migrations) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + import asyncio + + asyncio.run(run_migrations_online()) diff --git a/app/infrastructure/database/alembic/script.py.mako b/app/infrastructure/database/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/app/infrastructure/database/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${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, 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: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/app/infrastructure/database/db_utils.py b/app/infrastructure/database/db_utils.py new file mode 100644 index 0000000..d6ebf1a --- /dev/null +++ b/app/infrastructure/database/db_utils.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + async_sessionmaker, + create_async_engine, +) + +__all__ = ( + "DBConfig", + "create_engine", + "create_sessionmaker", + "build_async_db_url", +) + + +@dataclass(frozen=True) +class DBConfig: + username: str + password: str + endpoint: str + db_name: str + + +def create_engine(db_config: DBConfig) -> AsyncEngine: + return create_async_engine(build_async_db_url(db_config)) + + +def create_sessionmaker(engine: AsyncEngine) -> async_sessionmaker: + return async_sessionmaker(bind=engine) + + +def build_async_db_url(config: DBConfig) -> str: + return f"postgresql+asyncpg://{config.username}:{config.password}@{config.endpoint}/{config.db_name}" # noqa diff --git a/app/infrastructure/database/models.py b/app/infrastructure/database/models.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/app/infrastructure/database/models.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/app/lint.sh b/app/lint.sh new file mode 100644 index 0000000..018831e --- /dev/null +++ b/app/lint.sh @@ -0,0 +1,4 @@ +set -euxo pipefail + +pre-commit install +pre-commit run --all-files --show-diff-on-failure diff --git a/app/presentation/__init__.py b/app/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/presentation/main.py b/app/presentation/main.py new file mode 100644 index 0000000..2068e11 --- /dev/null +++ b/app/presentation/main.py @@ -0,0 +1,5 @@ +from fastapi import FastAPI + + +def create_app() -> FastAPI: + return FastAPI() diff --git a/app/pyproject.toml b/app/pyproject.toml new file mode 100644 index 0000000..9757c1f --- /dev/null +++ b/app/pyproject.toml @@ -0,0 +1,23 @@ +[tool.black] +line-length = 79 + +[tool.isort] +profile = "black" +line_length = 79 +[tool.flake8] + +max-line-length = 79 +exclude = ["venv"] +extend-ignore = "E203" +per-file-ignores = "__init__.py:F401" + +[tool.mypy] +exclude = ["venv", "alembic"] + +strict_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +disallow_untyped_calls = true +disallow_untyped_defs = true +warn_incomplete_stub = true +ignore_missing_imports = true \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..0911f4f --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.103.1 +gunicorn==21.2.0 +uvicorn==0.23.2 +SQLAlchemy==2.0.20 +alembic==1.12.0 \ No newline at end of file diff --git a/app/requirements_dev.txt b/app/requirements_dev.txt new file mode 100644 index 0000000..658cab3 --- /dev/null +++ b/app/requirements_dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt + +pre-commit==3.4.0 diff --git a/app/start.sh b/app/start.sh new file mode 100755 index 0000000..f8a874f --- /dev/null +++ b/app/start.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +workers_cnt=$(( $(nproc) * 2 + 1 )) + +gunicorn --bind 0.0.0.0:7777 -w "$workers_cnt" -k uvicorn.workers.UvicornWorker presentation.main:create_app diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..53dc2f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3.9" + +services: + recipedb: + image: postgres:11 + ports: + - "5432:5432" + environment: + - POSTGRES_USER=recipe_user + - POSTGRES_PASSWORD=recipe_psw + - POSTGRES_DB=recipe_db + web: + build: + context: . + dockerfile: ./app/Dockerfile + volumes: + - .:/code + ports: + - "7777:7777" + depends_on: + - recipedb + env_file: + - .env + environment: + POSTGRES_HOST_AUTH_METHOD: trust + +networks: + default: + name: recipe_network + external: true \ No newline at end of file