From 6190db6a019370fb3a5ea664949f0d324bc98f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Kali=C5=84ski?= Date: Wed, 16 Oct 2024 17:41:10 +0200 Subject: [PATCH] Add FastAPI example and functionalities guidelines --- examples/fastAPI/Makefile | 70 +++++ examples/fastAPI/README.md | 1 + examples/fastAPI/constraints | 72 +++++ examples/fastAPI/pyproject.toml | 61 +++++ examples/fastAPI/src/features/__int__.py | 0 .../fastAPI/src/features/todos/__init__.py | 5 + examples/fastAPI/src/features/todos/calls.py | 15 ++ examples/fastAPI/src/features/todos/config.py | 1 + examples/fastAPI/src/features/todos/state.py | 11 + examples/fastAPI/src/features/todos/types.py | 15 ++ .../fastAPI/src/features/todos/user_tasks.py | 16 ++ examples/fastAPI/src/integrations/__init__.py | 0 .../src/integrations/postgres/__init__.py | 7 + .../src/integrations/postgres/client.py | 35 +++ .../src/integrations/postgres/config.py | 19 ++ .../src/integrations/postgres/connection.py | 160 +++++++++++ .../src/integrations/postgres/session.py | 43 +++ .../src/integrations/postgres/types.py | 7 + examples/fastAPI/src/server/__init__.py | 10 + examples/fastAPI/src/server/__main__.py | 6 + examples/fastAPI/src/server/application.py | 67 +++++ examples/fastAPI/src/server/config.py | 11 + .../src/server/middlewares/__init__.py | 5 + .../fastAPI/src/server/middlewares/context.py | 84 ++++++ .../fastAPI/src/server/routes/__init__.py | 7 + .../fastAPI/src/server/routes/technical.py | 21 ++ examples/fastAPI/src/server/routes/todos.py | 25 ++ examples/fastAPI/src/solutions/__init__.py | 0 .../src/solutions/user_tasks/__init__.py | 12 + .../fastAPI/src/solutions/user_tasks/calls.py | 54 ++++ .../src/solutions/user_tasks/config.py | 1 + .../src/solutions/user_tasks/postgres.py | 110 ++++++++ .../fastAPI/src/solutions/user_tasks/state.py | 25 ++ .../fastAPI/src/solutions/user_tasks/types.py | 70 +++++ guidelines/functionalities.md | 249 ++++++++++++++++++ guidelines/packages.md | 16 +- 36 files changed, 1303 insertions(+), 8 deletions(-) create mode 100644 examples/fastAPI/Makefile create mode 100644 examples/fastAPI/README.md create mode 100644 examples/fastAPI/constraints create mode 100644 examples/fastAPI/pyproject.toml create mode 100644 examples/fastAPI/src/features/__int__.py create mode 100644 examples/fastAPI/src/features/todos/__init__.py create mode 100644 examples/fastAPI/src/features/todos/calls.py create mode 100644 examples/fastAPI/src/features/todos/config.py create mode 100644 examples/fastAPI/src/features/todos/state.py create mode 100644 examples/fastAPI/src/features/todos/types.py create mode 100644 examples/fastAPI/src/features/todos/user_tasks.py create mode 100644 examples/fastAPI/src/integrations/__init__.py create mode 100644 examples/fastAPI/src/integrations/postgres/__init__.py create mode 100644 examples/fastAPI/src/integrations/postgres/client.py create mode 100644 examples/fastAPI/src/integrations/postgres/config.py create mode 100644 examples/fastAPI/src/integrations/postgres/connection.py create mode 100644 examples/fastAPI/src/integrations/postgres/session.py create mode 100644 examples/fastAPI/src/integrations/postgres/types.py create mode 100644 examples/fastAPI/src/server/__init__.py create mode 100644 examples/fastAPI/src/server/__main__.py create mode 100644 examples/fastAPI/src/server/application.py create mode 100644 examples/fastAPI/src/server/config.py create mode 100644 examples/fastAPI/src/server/middlewares/__init__.py create mode 100644 examples/fastAPI/src/server/middlewares/context.py create mode 100644 examples/fastAPI/src/server/routes/__init__.py create mode 100644 examples/fastAPI/src/server/routes/technical.py create mode 100644 examples/fastAPI/src/server/routes/todos.py create mode 100644 examples/fastAPI/src/solutions/__init__.py create mode 100644 examples/fastAPI/src/solutions/user_tasks/__init__.py create mode 100644 examples/fastAPI/src/solutions/user_tasks/calls.py create mode 100644 examples/fastAPI/src/solutions/user_tasks/config.py create mode 100644 examples/fastAPI/src/solutions/user_tasks/postgres.py create mode 100644 examples/fastAPI/src/solutions/user_tasks/state.py create mode 100644 examples/fastAPI/src/solutions/user_tasks/types.py create mode 100644 guidelines/functionalities.md diff --git a/examples/fastAPI/Makefile b/examples/fastAPI/Makefile new file mode 100644 index 0000000..7242743 --- /dev/null +++ b/examples/fastAPI/Makefile @@ -0,0 +1,70 @@ +SHELL := sh +.ONESHELL: +.SHELLFLAGS := -eu -c +.DELETE_ON_ERROR: + +SOURCES_PATH := src + +# load environment config from .env if able +-include .env + +ifndef PYTHON_ALIAS + PYTHON_ALIAS := python +endif + +ifndef INSTALL_OPTIONS + INSTALL_OPTIONS := .[dev] +endif + +ifndef UV_VERSION + UV_VERSION := 0.4.22 +endif + +.PHONY: venv sync lock update format lint test run + +# Setup virtual environment for local development. +venv: + @echo '# Preparing development environment...' + @echo '...installing uv...' + @curl -LsSf https://github.com/astral-sh/uv/releases/download/$(UV_VERSION)/uv-installer.sh | sh + @echo '...preparing venv...' + @$(PYTHON_ALIAS) -m venv .venv --prompt="VENV[DEV]" --clear --upgrade-deps + @. ./.venv/bin/activate && pip install --upgrade pip && uv pip install --editable $(INSTALL_OPTIONS) --constraint constraints + @echo '...development environment ready! Activate venv using `. ./.venv/bin/activate`.' + +# Sync environment with uv based on constraints +sync: + @echo '# Synchronizing dependencies...' + @$(if $(findstring $(UV_VERSION), $(shell uv --version | head -n1 | cut -d" " -f2)), , @echo '...updating uv...' && curl -LsSf https://github.com/astral-sh/uv/releases/download/$(UV_VERSION)/uv-installer.sh | sh) + @uv pip install --editable $(INSTALL_OPTIONS) --constraint constraints + @echo '...finished!' + +# Generate a set of locked dependencies from pyproject.toml +lock: + @echo '# Locking dependencies...' + @uv pip compile pyproject.toml -o constraints --all-extras + @echo '...finished!' + +# Update and lock dependencies from pyproject.toml +update: + @echo '# Updating dependencies...' + @$(if $(shell printf '%s\n%s\n' "$(UV_VERSION)" "$$(uv --version | head -n1 | cut -d' ' -f2)" | sort -V | head -n1 | grep -q "$(UV_VERSION)"), , @echo '...updating uv...' && curl -LsSf https://github.com/astral-sh/uv/releases/download/$(UV_VERSION)/uv-installer.sh | sh) + # @$(if $(findstring $(UV_VERSION), $(shell uv --version | head -n1 | cut -d" " -f2)), , @echo '...updating uv...' && curl -LsSf https://github.com/astral-sh/uv/releases/download/$(UV_VERSION)/uv-installer.sh | sh) + @uv --no-cache pip compile pyproject.toml -o constraints --all-extras --upgrade + @uv pip install --editable $(INSTALL_OPTIONS) --constraint constraints + @echo '...finished!' + +# Run formatter. +format: + @ruff check --quiet --fix $(SOURCES_PATH) + @ruff format --quiet $(SOURCES_PATH) + +# Run linters and code checks. +lint: + @bandit -r $(SOURCES_PATH) + @ruff check $(SOURCES_PATH) + @pyright --project ./ + +# Run the server +run: + @python -m server \ No newline at end of file diff --git a/examples/fastAPI/README.md b/examples/fastAPI/README.md new file mode 100644 index 0000000..10994a2 --- /dev/null +++ b/examples/fastAPI/README.md @@ -0,0 +1 @@ +## Haiway FastAPI Example diff --git a/examples/fastAPI/constraints b/examples/fastAPI/constraints new file mode 100644 index 0000000..4a1784d --- /dev/null +++ b/examples/fastAPI/constraints @@ -0,0 +1,72 @@ +# This file was autogenerated by uv via the following command: +# uv --no-cache pip compile pyproject.toml -o constraints --all-extras +annotated-types==0.7.0 + # via pydantic +anyio==4.6.2.post1 + # via + # httpx + # starlette +asyncpg==0.29.0 + # via haiway-fastapi (pyproject.toml) +bandit==1.7.10 + # via haiway-fastapi (pyproject.toml) +certifi==2024.8.30 + # via + # httpcore + # httpx +click==8.1.7 + # via uvicorn +fastapi-slim==0.115.2 + # via haiway-fastapi (pyproject.toml) +h11==0.14.0 + # via + # httpcore + # uvicorn +haiway==0.1.0 + # via haiway-fastapi (pyproject.toml) +httpcore==1.0.6 + # via httpx +httpx==0.25.2 + # via haiway-fastapi (pyproject.toml) +idna==3.10 + # via + # anyio + # httpx +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +nodeenv==1.9.1 + # via pyright +pbr==6.1.0 + # via stevedore +pydantic==2.9.2 + # via fastapi-slim +pydantic-core==2.23.4 + # via pydantic +pygments==2.18.0 + # via rich +pyright==1.1.385 + # via haiway-fastapi (pyproject.toml) +pyyaml==6.0.2 + # via bandit +rich==13.9.2 + # via bandit +ruff==0.5.7 + # via haiway-fastapi (pyproject.toml) +sniffio==1.3.1 + # via + # anyio + # httpx +starlette==0.40.0 + # via fastapi-slim +stevedore==5.3.0 + # via bandit +typing-extensions==4.12.2 + # via + # fastapi-slim + # pydantic + # pydantic-core + # pyright +uvicorn==0.32.0 + # via haiway-fastapi (pyproject.toml) diff --git a/examples/fastAPI/pyproject.toml b/examples/fastAPI/pyproject.toml new file mode 100644 index 0000000..d867ffc --- /dev/null +++ b/examples/fastAPI/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "haiway_fastapi" +description = "Example of haiway usage with fastapi." +version = "0.1.0" +readme = "README.md" +maintainers = [ + { name = "Kacper Kaliński", email = "kacper.kalinski@miquido.com" }, +] +requires-python = ">=3.12" +dependencies = [ + "haiway~=0.1.0", + "fastapi-slim~=0.115", + "asyncpg~=0.29", + "httpx~=0.25.0", +] + +[project.urls] +Homepage = "https://miquido.com" + +[project.optional-dependencies] +dev = [ + "uvicorn~=0.30", + "ruff~=0.5.0", + "pyright~=1.1", + "bandit~=1.7", +] + +[tool.ruff] +target-version = "py312" +line-length = 100 +extend-exclude = [".venv", ".git", ".cache"] +lint.select = ["E", "F", "A", "I", "B", "PL", "W", "C", "RUF", "UP"] +lint.ignore = [] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401", "E402"] +"./tests/*.py" = ["PLR2004"] + +[tool.pyright] +pythonVersion = "3.12" +venvPath = "." +venv = ".venv" +include = ["./src"] +exclude = ["**/node_modules", "**/__pycache__"] +ignore = [] +stubPath = "./stubs" +reportMissingImports = true +reportMissingTypeStubs = true +typeCheckingMode = "strict" +userFileIndexingLimit = -1 +useLibraryCodeForTypes = true + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/examples/fastAPI/src/features/__int__.py b/examples/fastAPI/src/features/__int__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/fastAPI/src/features/todos/__init__.py b/examples/fastAPI/src/features/todos/__init__.py new file mode 100644 index 0000000..0e3002a --- /dev/null +++ b/examples/fastAPI/src/features/todos/__init__.py @@ -0,0 +1,5 @@ +from features.todos.calls import complete_todo + +__all__ = [ + "complete_todo", +] diff --git a/examples/fastAPI/src/features/todos/calls.py b/examples/fastAPI/src/features/todos/calls.py new file mode 100644 index 0000000..9c5a59f --- /dev/null +++ b/examples/fastAPI/src/features/todos/calls.py @@ -0,0 +1,15 @@ +from uuid import UUID + +from features.todos.state import Todos +from haiway import ctx + +__all__ = [ + "complete_todo", +] + + +async def complete_todo( + *, + identifier: UUID, +) -> None: + await ctx.state(Todos).complete(identifier=identifier) diff --git a/examples/fastAPI/src/features/todos/config.py b/examples/fastAPI/src/features/todos/config.py new file mode 100644 index 0000000..1bb8bf6 --- /dev/null +++ b/examples/fastAPI/src/features/todos/config.py @@ -0,0 +1 @@ +# empty diff --git a/examples/fastAPI/src/features/todos/state.py b/examples/fastAPI/src/features/todos/state.py new file mode 100644 index 0000000..2a53470 --- /dev/null +++ b/examples/fastAPI/src/features/todos/state.py @@ -0,0 +1,11 @@ +from features.todos.types import TodoCompletion +from features.todos.user_tasks import complete_todo_task +from haiway import Structure + +__all__ = [ + "Todos", +] + + +class Todos(Structure): + complete: TodoCompletion = complete_todo_task diff --git a/examples/fastAPI/src/features/todos/types.py b/examples/fastAPI/src/features/todos/types.py new file mode 100644 index 0000000..ac6c04f --- /dev/null +++ b/examples/fastAPI/src/features/todos/types.py @@ -0,0 +1,15 @@ +from typing import Protocol, runtime_checkable +from uuid import UUID + +__all__ = [ + "TodoCompletion", +] + + +@runtime_checkable +class TodoCompletion(Protocol): + async def __call__( + self, + *, + identifier: UUID, + ) -> None: ... diff --git a/examples/fastAPI/src/features/todos/user_tasks.py b/examples/fastAPI/src/features/todos/user_tasks.py new file mode 100644 index 0000000..fea8fa1 --- /dev/null +++ b/examples/fastAPI/src/features/todos/user_tasks.py @@ -0,0 +1,16 @@ +from uuid import UUID + +from haiway import ctx +from solutions.user_tasks import UserTask, UserTasks + +__all__ = [ + "complete_todo_task", +] + + +async def complete_todo_task( + *, + identifier: UUID, +) -> None: + task: UserTask = await ctx.state(UserTasks).fetch(identifier=identifier) + await ctx.state(UserTasks).update(task=task.updated(completed=True)) diff --git a/examples/fastAPI/src/integrations/__init__.py b/examples/fastAPI/src/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/fastAPI/src/integrations/postgres/__init__.py b/examples/fastAPI/src/integrations/postgres/__init__.py new file mode 100644 index 0000000..f177b8b --- /dev/null +++ b/examples/fastAPI/src/integrations/postgres/__init__.py @@ -0,0 +1,7 @@ +from integrations.postgres.client import PostgresClient +from integrations.postgres.types import PostgresClientException + +__all__ = [ + "PostgresClient", + "PostgresClientException", +] diff --git a/examples/fastAPI/src/integrations/postgres/client.py b/examples/fastAPI/src/integrations/postgres/client.py new file mode 100644 index 0000000..1ed48fd --- /dev/null +++ b/examples/fastAPI/src/integrations/postgres/client.py @@ -0,0 +1,35 @@ +from typing import Self + +from haiway import Dependency + +from integrations.postgres.config import ( + POSTGRES_DATABASE, + POSTGRES_HOST, + POSTGRES_PASSWORD, + POSTGRES_PORT, + POSTGRES_SSLMODE, + POSTGRES_USER, +) +from integrations.postgres.session import PostgresClientSession + +__all__ = [ + "PostgresClient", +] + + +class PostgresClient( + PostgresClientSession, + Dependency, +): + @classmethod + async def prepare(cls) -> Self: + instance: Self = cls( + host=POSTGRES_HOST, + port=POSTGRES_PORT, + database=POSTGRES_DATABASE, + user=POSTGRES_USER, + password=POSTGRES_PASSWORD, + ssl=POSTGRES_SSLMODE, + ) + await instance.initialize() + return instance diff --git a/examples/fastAPI/src/integrations/postgres/config.py b/examples/fastAPI/src/integrations/postgres/config.py new file mode 100644 index 0000000..4d87ac0 --- /dev/null +++ b/examples/fastAPI/src/integrations/postgres/config.py @@ -0,0 +1,19 @@ +from typing import Final + +from haiway import getenv_str + +__all__ = [ + "POSTGRES_DATABASE", + "POSTGRES_HOST", + "POSTGRES_PASSWORD", + "POSTGRES_PORT", + "POSTGRES_SSLMODE", + "POSTGRES_USER", +] + +POSTGRES_DATABASE: Final[str] = getenv_str("POSTGRES_DATABASE", default="postgres") +POSTGRES_HOST: Final[str] = getenv_str("POSTGRES_HOST", default="localhost") +POSTGRES_PORT: Final[str] = getenv_str("POSTGRES_PORT", default="5432") +POSTGRES_USER: Final[str] = getenv_str("POSTGRES_USER", default="postgres") +POSTGRES_PASSWORD: Final[str] = getenv_str("POSTGRES_PASSWORD", default="postgres") +POSTGRES_SSLMODE: Final[str] = getenv_str("POSTGRES_SSLMODE", default="prefer") diff --git a/examples/fastAPI/src/integrations/postgres/connection.py b/examples/fastAPI/src/integrations/postgres/connection.py new file mode 100644 index 0000000..3aadde9 --- /dev/null +++ b/examples/fastAPI/src/integrations/postgres/connection.py @@ -0,0 +1,160 @@ +from collections.abc import Callable, Mapping +from types import TracebackType +from typing import Any, final + +from asyncpg import Connection, Pool # pyright: ignore[reportMissingTypeStubs] +from asyncpg.pool import PoolAcquireContext # pyright: ignore[reportMissingTypeStubs] +from asyncpg.transaction import Transaction # pyright: ignore[reportMissingTypeStubs] + +from integrations.postgres.types import PostgresClientException + +__all__ = [ + "PostgresConnection", + "PostgresTransaction", + "PostgresConnectionContext", +] + + +@final +class PostgresConnection: + def __init__( + self, + connection: Connection, + ) -> None: + self._connection: Connection = connection + + async def execute( + self, + statement: str, + /, + *args: Any, + ) -> str: + try: + return await self._connection.execute( # pyright: ignore[reportUnknownMemberType] + statement, + *args, + ) + + except Exception as exc: + raise PostgresClientException("Failed to execute SQL statement") from exc + + async def fetch( + self, + query: str, + /, + *args: Any, + ) -> list[Mapping[str, Any]]: + try: + return await self._connection.fetch( # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] + query, + *args, + ) + + except Exception as exc: + raise PostgresClientException("Failed to execute SQL statement") from exc + + async def fetch_one( + self, + query: str, + /, + *args: Any, + ) -> Mapping[str, Any] | None: + try: + return next( + ( + result + for result in await self.fetch( + query, + *args, + ) + ), + None, + ) + + except Exception as exc: + raise PostgresClientException("Failed to execute SQL statement") from exc + + def transaction(self) -> "PostgresTransaction": + return PostgresTransaction( + connection=self, + transaction=self._connection.transaction(), # pyright: ignore[reportUnknownMemberType] + ) + + async def set_type_codec( + self, + type_name: str, + /, + encoder: Callable[[str], Any], + decoder: Callable[[str], Any], + schema_name: str = "pg_catalog", + ) -> None: + await self._connection.set_type_codec( # pyright: ignore[reportUnknownMemberType] + type_name, + decoder=decoder, + encoder=encoder, + schema=schema_name, + format="text", + ) + + +@final +class PostgresTransaction: + def __init__( + self, + connection: PostgresConnection, + transaction: Transaction, + ) -> None: + self._connection: PostgresConnection = connection + self._transaction: Transaction = transaction + + async def __aenter__(self) -> PostgresConnection: + await self._transaction.__aenter__() + return self._connection + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self._transaction.__aexit__( # pyright: ignore[reportUnknownMemberType] + exc_type, + exc_val, + exc_tb, + ) + + +@final +class PostgresConnectionContext: + def __init__( + self, + pool: Pool, + ) -> None: + self._pool: Pool = pool + self._context: PoolAcquireContext + + async def __aenter__(self) -> PostgresConnection: + assert not hasattr(self, "_context") or self._context is None # nosec: B101 + self._context: PoolAcquireContext = (await self._pool or self._pool).acquire() # pyright: ignore[reportUnknownMemberType] + + return PostgresConnection( + connection=await self._context.__aenter__(), # pyright: ignore[reportUnknownArgumentType] + ) + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + assert hasattr(self, "_context") and self._context is not None # nosec: B101 + + try: + await self._context.__aexit__( # pyright: ignore[reportUnknownMemberType] + exc_type, + exc_val, + exc_tb, + ) + + finally: + del self._context diff --git a/examples/fastAPI/src/integrations/postgres/session.py b/examples/fastAPI/src/integrations/postgres/session.py new file mode 100644 index 0000000..78ef685 --- /dev/null +++ b/examples/fastAPI/src/integrations/postgres/session.py @@ -0,0 +1,43 @@ +from asyncpg import ( # pyright: ignore[reportMissingTypeStubs] + Pool, + create_pool, # pyright: ignore [reportUnknownVariableType] +) + +from integrations.postgres.connection import PostgresConnectionContext + +__all__ = [ + "PostgresClientSession", +] + + +class PostgresClientSession: + def __init__( # noqa: PLR0913 + self, + host: str, + port: str, + database: str, + user: str, + password: str, + ssl: str, + ) -> None: + # using python replicas - keep only a single connection per replica to avoid errors + self._pool: Pool = create_pool( + min_size=1, + max_size=1, + database=database, + user=user, + password=password, + host=host, + port=port, + ssl=ssl, + ) + + async def initialize(self) -> None: + await self._pool # initialize pool + + async def dispose(self) -> None: + if self._pool._initialized: # pyright: ignore[reportPrivateUsage] + await self._pool.close() + + def connection(self) -> PostgresConnectionContext: + return PostgresConnectionContext(pool=self._pool) diff --git a/examples/fastAPI/src/integrations/postgres/types.py b/examples/fastAPI/src/integrations/postgres/types.py new file mode 100644 index 0000000..45ce4b6 --- /dev/null +++ b/examples/fastAPI/src/integrations/postgres/types.py @@ -0,0 +1,7 @@ +__all__ = [ + "PostgresClientException", +] + + +class PostgresClientException(Exception): + pass diff --git a/examples/fastAPI/src/server/__init__.py b/examples/fastAPI/src/server/__init__.py new file mode 100644 index 0000000..cd5139a --- /dev/null +++ b/examples/fastAPI/src/server/__init__.py @@ -0,0 +1,10 @@ +from haiway import load_env, setup_logging + +load_env() # load env first if needed +setup_logging("server") # then setup logging before loading the app + +from server.application import app + +__all__ = [ + "app", +] diff --git a/examples/fastAPI/src/server/__main__.py b/examples/fastAPI/src/server/__main__.py new file mode 100644 index 0000000..645da2e --- /dev/null +++ b/examples/fastAPI/src/server/__main__.py @@ -0,0 +1,6 @@ +import uvicorn + +from server.application import app +from server.config import SERVER_HOST, SERVER_PORT + +uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT) diff --git a/examples/fastAPI/src/server/application.py b/examples/fastAPI/src/server/application.py new file mode 100644 index 0000000..89f25c8 --- /dev/null +++ b/examples/fastAPI/src/server/application.py @@ -0,0 +1,67 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from logging import Logger, getLogger +from typing import Final + +from fastapi import FastAPI +from haiway import Dependencies, Structure, frozenlist + +from server.middlewares import ContextMiddleware +from server.routes import technical_router, todos_router + +__all__ = [ + "app", +] + +# define common state available for all endpoints +STATE: Final[frozenlist[Structure]] = () + + +async def startup(app: FastAPI) -> None: + """ + Startup function is called when the server process starts. + """ + logger: Logger = getLogger("server") + if __debug__: + logger.warning("Starting DEBUG server...") + + else: + logger.info("Starting server...") + + app.extra["state"] = STATE # include base state for all endpoints + + logger.info("...server started!") + + +async def shutdown(app: FastAPI) -> None: + """ + Shutdown function is called when server process ends. + """ + await Dependencies.dispose() # dispose all dependencies on shutdown + + getLogger("server").info("...server shutdown!") + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + await startup(app) + yield + await shutdown(app) + + +app: FastAPI = FastAPI( + title="haiway-fastapi", + description="Example project using haiway with fastapi", + version="0.1.0", + lifespan=lifespan, + openapi_url="/openapi.json" if __debug__ else None, + docs_url="/swagger" if __debug__ else None, + redoc_url="/redoc" if __debug__ else None, +) + +# middlewares +app.add_middleware(ContextMiddleware) + +# routes +app.include_router(technical_router) +app.include_router(todos_router) diff --git a/examples/fastAPI/src/server/config.py b/examples/fastAPI/src/server/config.py new file mode 100644 index 0000000..be9b8a8 --- /dev/null +++ b/examples/fastAPI/src/server/config.py @@ -0,0 +1,11 @@ +from typing import Final + +from haiway import getenv_int, getenv_str + +__all__ = [ + "SERVER_HOST", + "SERVER_PORT", +] + +SERVER_HOST: Final[str] = getenv_str("SERVER_HOST", default="localhost") +SERVER_PORT: Final[int] = getenv_int("SERVER_PORT", default=8080) diff --git a/examples/fastAPI/src/server/middlewares/__init__.py b/examples/fastAPI/src/server/middlewares/__init__.py new file mode 100644 index 0000000..7625b15 --- /dev/null +++ b/examples/fastAPI/src/server/middlewares/__init__.py @@ -0,0 +1,5 @@ +from server.middlewares.context import ContextMiddleware + +__all__ = [ + "ContextMiddleware", +] diff --git a/examples/fastAPI/src/server/middlewares/context.py b/examples/fastAPI/src/server/middlewares/context.py new file mode 100644 index 0000000..9f44426 --- /dev/null +++ b/examples/fastAPI/src/server/middlewares/context.py @@ -0,0 +1,84 @@ +from uuid import uuid4 + +from haiway import ctx +from starlette.datastructures import MutableHeaders +from starlette.exceptions import HTTPException +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +__all__ = [ + "ContextMiddleware", +] + + +class ContextMiddleware: + def __init__( + self, + app: ASGIApp, + ) -> None: + self.app = app + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + match scope["type"]: + case "http": + trace_id: str = uuid4().hex + with ctx.scope( + scope.get("root_path", "") + scope["path"], + *scope["app"].extra.get("state", ()), + trace_id=trace_id, + ): + + async def traced_send(message: Message) -> None: + match message["type"]: + case "http.response.start": + headers = MutableHeaders(scope=message) + headers["trace_id"] = f"{trace_id}" + + case _: + pass + + await send(message) + + try: + return await self.app( + scope, + receive, + traced_send, + ) + + except HTTPException as exc: + if isinstance(exc.headers, dict): # type: ignore + exc.headers["trace_id"] = ( # pyright: ignore[reportUnknownMemberType] + f"{trace_id}" + ) + + else: + exc.headers = {"trace_id": f"{trace_id}"} + + raise exc # do not change behavior for HTTPException + + except BaseException as exc: + error_type: type[BaseException] = type(exc) + error_message: str = ( + f"{error_type.__name__} [{error_type.__module__}] - that is an error!" + ) + + if __debug__: + import traceback + + error_message = error_message + f"\n{traceback.format_exc()}" + + ctx.log_error(error_message) + + raise HTTPException( + status_code=500, + headers={"trace_id": f"{trace_id}"}, + detail=error_message, + ) from exc + + case _: + return await self.app(scope, receive, send) diff --git a/examples/fastAPI/src/server/routes/__init__.py b/examples/fastAPI/src/server/routes/__init__.py new file mode 100644 index 0000000..ef055c0 --- /dev/null +++ b/examples/fastAPI/src/server/routes/__init__.py @@ -0,0 +1,7 @@ +from server.routes.technical import router as technical_router +from server.routes.todos import router as todos_router + +__all__ = [ + "technical_router", + "todos_router", +] diff --git a/examples/fastAPI/src/server/routes/technical.py b/examples/fastAPI/src/server/routes/technical.py new file mode 100644 index 0000000..2d2c5c2 --- /dev/null +++ b/examples/fastAPI/src/server/routes/technical.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter +from starlette.responses import Response + +__all__ = [ + "router", +] + +router = APIRouter() + + +@router.get( + path="/health", + description="Server health check.", + status_code=204, + responses={ + 204: {"description": "Server up and running!"}, + 500: {"description": "Internal server error"}, + }, +) +async def health() -> Response: + return Response(status_code=204) diff --git a/examples/fastAPI/src/server/routes/todos.py b/examples/fastAPI/src/server/routes/todos.py new file mode 100644 index 0000000..d0df433 --- /dev/null +++ b/examples/fastAPI/src/server/routes/todos.py @@ -0,0 +1,25 @@ +from uuid import UUID + +from fastapi import APIRouter +from features.todos import complete_todo +from starlette.responses import Response + +__all__ = [ + "router", +] + +router = APIRouter() + + +@router.post( + path="/todo/{identifier}/complete", + description="Complete a TODO.", + status_code=204, + responses={ + 204: {"description": "TODO has been completed!"}, + 500: {"description": "Internal server error"}, + }, +) +async def complete_todo_endpoint(identifier: UUID) -> Response: + await complete_todo(identifier=identifier) + return Response(status_code=204) diff --git a/examples/fastAPI/src/solutions/__init__.py b/examples/fastAPI/src/solutions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/fastAPI/src/solutions/user_tasks/__init__.py b/examples/fastAPI/src/solutions/user_tasks/__init__.py new file mode 100644 index 0000000..a41d85c --- /dev/null +++ b/examples/fastAPI/src/solutions/user_tasks/__init__.py @@ -0,0 +1,12 @@ +from solutions.user_tasks.calls import create_task, delete_task, fetch_tasks, update_task +from solutions.user_tasks.state import UserTasks +from solutions.user_tasks.types import UserTask + +__all__ = [ + "UserTask", + "UserTasks", + "create_task", + "update_task", + "fetch_tasks", + "delete_task", +] diff --git a/examples/fastAPI/src/solutions/user_tasks/calls.py b/examples/fastAPI/src/solutions/user_tasks/calls.py new file mode 100644 index 0000000..ea36be8 --- /dev/null +++ b/examples/fastAPI/src/solutions/user_tasks/calls.py @@ -0,0 +1,54 @@ +from typing import overload +from uuid import UUID + +from haiway import ctx + +from solutions.user_tasks.state import UserTasks +from solutions.user_tasks.types import UserTask + +__all__ = [ + "create_task", + "update_task", + "fetch_tasks", + "delete_task", +] + + +async def create_task( + description: str, +) -> UserTask: + return await ctx.state(UserTasks).create(description=description) + + +async def update_task( + task: UserTask, + /, +) -> None: + await ctx.state(UserTasks).update(task=task) + + +@overload +async def fetch_tasks( + *, + identifier: None = None, +) -> list[UserTask]: ... + + +@overload +async def fetch_tasks( + *, + identifier: UUID, +) -> UserTask: ... + + +async def fetch_tasks( + identifier: UUID | None = None, +) -> list[UserTask] | UserTask: + return await ctx.state(UserTasks).fetch(identifier=identifier) + + +async def delete_task( + *, + identifier: UUID, +) -> None: + return await ctx.state(UserTasks).delete(identifier=identifier) diff --git a/examples/fastAPI/src/solutions/user_tasks/config.py b/examples/fastAPI/src/solutions/user_tasks/config.py new file mode 100644 index 0000000..1bb8bf6 --- /dev/null +++ b/examples/fastAPI/src/solutions/user_tasks/config.py @@ -0,0 +1 @@ +# empty diff --git a/examples/fastAPI/src/solutions/user_tasks/postgres.py b/examples/fastAPI/src/solutions/user_tasks/postgres.py new file mode 100644 index 0000000..dbd50e9 --- /dev/null +++ b/examples/fastAPI/src/solutions/user_tasks/postgres.py @@ -0,0 +1,110 @@ +from datetime import datetime +from typing import overload +from uuid import UUID, uuid4 + +from haiway import ctx +from integrations.postgres import PostgresClient, PostgresClientException + +from solutions.user_tasks.types import UserTask + +__all__ = [ + "postgres_task_create", + "postgres_task_update", + "postgres_tasks_fetch", + "postgres_task_delete", +] + + +async def postgres_task_create( + *, + description: str, +) -> UserTask: + postgres_client: PostgresClient = await ctx.dependency(PostgresClient) + async with postgres_client.connection() as connection: + try: # actual SQL goes here... + await connection.execute("EXAMPLE") + + except PostgresClientException as exc: + ctx.log_debug( + "Example postgres_task_create failed", + exception=exc, + ) + + return UserTask( + identifier=uuid4(), + description=description, + modified=datetime.now(), + completed=False, + ) + + +async def postgres_task_update( + *, + task: UserTask, +) -> None: + postgres_client: PostgresClient = await ctx.dependency(PostgresClient) + async with postgres_client.connection() as connection: + try: # actual SQL goes here... + await connection.execute("EXAMPLE") + + except PostgresClientException as exc: + ctx.log_debug( + "Example postgres_task_update failed", + exception=exc, + ) + + +@overload +async def postgres_tasks_fetch( + *, + identifier: None = None, +) -> list[UserTask]: ... + + +@overload +async def postgres_tasks_fetch( + *, + identifier: UUID, +) -> UserTask: ... + + +async def postgres_tasks_fetch( + identifier: UUID | None = None, +) -> list[UserTask] | UserTask: + postgres_client: PostgresClient = await ctx.dependency(PostgresClient) + async with postgres_client.connection() as connection: + try: # actual SQL goes here... + await connection.execute("EXAMPLE") + + except PostgresClientException as exc: + ctx.log_debug( + "Example postgres_tasks_fetch failed", + exception=exc, + ) + + if identifier: + return UserTask( + identifier=uuid4(), + description="Example", + modified=datetime.now(), + completed=False, + ) + + else: + return [] + + +async def postgres_task_delete( + *, + identifier: UUID, +) -> None: + postgres_client: PostgresClient = await ctx.dependency(PostgresClient) + async with postgres_client.connection() as connection: + try: # actual SQL goes here... + await connection.execute("EXAMPLE") + + except PostgresClientException as exc: + ctx.log_debug( + "Example postgres_task_delete failed", + exception=exc, + ) diff --git a/examples/fastAPI/src/solutions/user_tasks/state.py b/examples/fastAPI/src/solutions/user_tasks/state.py new file mode 100644 index 0000000..0ae54e6 --- /dev/null +++ b/examples/fastAPI/src/solutions/user_tasks/state.py @@ -0,0 +1,25 @@ +from haiway import Structure + +from solutions.user_tasks.postgres import ( + postgres_task_create, + postgres_task_delete, + postgres_task_update, + postgres_tasks_fetch, +) +from solutions.user_tasks.types import ( + UserTaskCreation, + UserTaskDeletion, + UserTaskFetching, + UserTaskUpdating, +) + +__all__ = [ + "UserTasks", +] + + +class UserTasks(Structure): + fetch: UserTaskFetching = postgres_tasks_fetch + create: UserTaskCreation = postgres_task_create + update: UserTaskUpdating = postgres_task_update + delete: UserTaskDeletion = postgres_task_delete diff --git a/examples/fastAPI/src/solutions/user_tasks/types.py b/examples/fastAPI/src/solutions/user_tasks/types.py new file mode 100644 index 0000000..967abc9 --- /dev/null +++ b/examples/fastAPI/src/solutions/user_tasks/types.py @@ -0,0 +1,70 @@ +from datetime import datetime +from typing import Protocol, overload, runtime_checkable +from uuid import UUID + +from haiway import Structure + +__all__ = [ + "UserTask", + "UserTaskCreation", + "UserTaskUpdating", + "UserTaskFetching", + "UserTaskDeletion", +] + + +class UserTask(Structure): + identifier: UUID + modified: datetime + description: str + completed: bool + + +@runtime_checkable +class UserTaskCreation(Protocol): + async def __call__( + self, + *, + description: str, + ) -> UserTask: ... + + +@runtime_checkable +class UserTaskUpdating(Protocol): + async def __call__( + self, + *, + task: UserTask, + ) -> None: ... + + +@runtime_checkable +class UserTaskFetching(Protocol): + @overload + async def __call__( + self, + *, + identifier: None = None, + ) -> list[UserTask]: ... + + @overload + async def __call__( + self, + *, + identifier: UUID, + ) -> UserTask: ... + + async def __call__( + self, + *, + identifier: UUID | None = None, + ) -> list[UserTask] | UserTask: ... + + +@runtime_checkable +class UserTaskDeletion(Protocol): + async def __call__( + self, + *, + identifier: UUID, + ) -> None: ... diff --git a/guidelines/functionalities.md b/guidelines/functionalities.md new file mode 100644 index 0000000..88a8221 --- /dev/null +++ b/guidelines/functionalities.md @@ -0,0 +1,249 @@ +## Functionalities + +The haiway framework is a framework designed to facilitate the development of applications using the functional programming paradigm combined with structured concurrency concepts. Unlike traditional object-oriented frameworks, haiway emphasizes immutability, pure functions, and context-based state management, enabling developers to build scalable and maintainable applications. By leveraging context managers combined with context vars, haiway ensures safe state propagation in concurrent environments and simplifies dependency injection through function implementation propagation. + +### Functional basics + +Functional programming centers around creating pure functions - functions that have no side effects and rely solely on their inputs to produce outputs. This approach promotes predictability, easier testing, and better modularity. While Python is inherently multi-paradigm and not strictly functional, haiway encourages adopting functional principles where feasible to enhance code clarity and reliability. + +Key functional concepts: +- Immutability: Data structures are immutable, preventing unintended side effects. +- Pure Functions: Functions depend only on their inputs and produce outputs without altering external state. +- Higher-Order Functions: Functions that can take other functions as arguments or return them as results. + +haiway balances functional purity with Python’s flexibility by allowing limited side effects when necessary, though minimizing them is recommended for maintainability. + +Instead of preparing objects with internal state and methods, haiway encourages creating structures containing sets of functions and providing state either through function arguments or contextually using execution scope state. Using explicit function arguments is the preferred method; however, some functionalities may benefit from contextual, broader accessible state. + +### Preparing functionalities + +In haiway, functionalities are modularized into two primary components: interfaces and implementations This separation ensures clear contracts for functionalities, promoting modularity and ease of testing. + +### Defining types + +Interfaces define the public API of a functionality, specifying the data types and functions it exposes without detailing the underlying implementation. Preparing functionality starts from defining associated types - data structures and function types. + +```python +# types.py +from typing import Protocol, Any +from haiway import Structure + +# Structure representing the argument passed to functions +class FunctionArgument(Structure): + value: Any + +# Protocol defining the expected function signature +class FunctionSignature(Protocol): + async def __call__(self, argument: FunctionArgument) -> None: ... +``` + +In the example above, typing.Protocol is used to fully define the function signature, along with a custom structure serving as its argument. Function type names should emphasize the nature of their operations by using continuous tense adjectives, such as 'ElementCreating' or 'ValueLoading.' + +### Defining state + +State represents the immutable data required by functionalities. It is propagated through contexts to maintain consistency and support dependency injection. haiway comes with a helpful base class `Structure` which utilizes dataclass-like transform combined with runtime type checking and immutability. + +```python +# state.py +from my_functionality.types import FunctionSignature +from haiway import Structure + +# Structure representing the state parameters needed by the functionality +class FunctionalityState(Structure): + parameter: Any + +# Structure encapsulating the functionality with its interface +class Functionality(Structure): + function: FunctionSignature +``` + +This example shows a state required by the functionality as well as a container for holding function implementations. Both are intended to be propagated contextually to be accessible throughout the application and possibly altered when needed. + +### Defining implementation + +Implementations provide concrete behavior for the defined interfaces, ensuring that they conform to the specified contracts. + +```python +# implementation.py +from my_functionality.types import FunctionArgument +from my_functionality.state import FunctionalityState, Functionality +from haiway import ctx + +# Concrete implementation of the FunctionInterface +async def function_implementation(argument: FunctionArgument) -> None: + # Retrieve 'parameter' from the current context's state + parameter = ctx.state(FunctionalityState).parameter + # Implement the desired functionality using 'parameter' and 'argument.value' + print(f"Parameter: {parameter}, Argument: {argument.value}") + # Additional logic here... + +# Factory function to instantiate the Functionality with its implementation +def functionality_implementation() -> Functionality: + return Functionality(function=function_implementation) + +``` + +In the example above, function_implementation is the concrete implementation of the previously declared function, and functionality_implementation is a factory method suitable for creating a full implementation of the Functionality. + +Alternatively, instead of providing a factory method, some implementations may allow to define default values within state. This approach is also valid to implement and allows to skip explicit definitions of state by leveraging automatically created defaults. + +### Defining calls + +Calls act as intermediaries that invoke the function implementations within the appropriate context. This abstraction simplifies access to functionalities by hiding non-essential details and access to various required components. + +```python +# calls.py +from my_functionality.types import FunctionArgument +from my_functionality.state import FunctionalityState, Functionality +from haiway import ctx + +# Call function that invokes the functionality within the current context +async def function_call(argument: FunctionArgument) -> None: + # Invoke the function implementation from the contextual state + await ctx.state(Functionality).function(argument=argument) +``` + +The example above shows a simple proxy call that accesses the required contextual details of the implementation. + +### Using implementation + +To utilize the defined functionalities within an application, contexts must be established to provide the necessary state and implementations. Below is an example of how to integrate haiway functionalities into an application. + +```python +# application.py +from my_functionality import functionality_implementation, function, FunctionalityState +from haiway import ctx + +# Example application function utilizing the functionality +async def application_function(argument: FunctionArgument) -> None: + # Enter a context with the required state and functionality implementation + async with ctx.scope( + "example_context", + functionality_implementation(), + FunctionalityState(parameter="ExampleParameter") + ): + # Execute the functionality using the predefined helper + await function_call(FunctionArgument(value="SampleValue")) +``` + +Going through all of these layers may seem unnecessary at first, but in the long term, it creates a robust, modular system that is easy to manage and work with. + + +### Flexible arguments + +When a function defined within the functionality is intended to utilize contextual state it might be beneficial to allow it to take any additional keyword arguments that would be used to update contextual state within the implementation. This approach allows to update contextual state locally, only for a single function call without propagating the change deeply into call tree. + +```python +... + +# function signature allowing extra arguments +class FunctionSignature(Protocol): + async def __call__(self, argument: FunctionArgument, **extra: Any) -> None: ... + +... + +# function implementation utilizing extra arguments to update local state +async def function_implementation(argument: FunctionArgument, **extra: Any) -> None: + # Retrieve 'parameter' from the current context's state updated with extra arguments + parameter = ctx.state(FunctionalityState).updated(**extra).parameter + ... + +``` + +haiway `Structure` types allow to create object copies with updated attributes by using `updated` method. The updated object is a swallow copy of the original object allowing to change the state only in local context without affecting other users of that state. When there are no changes to be applied no copy is created. Additionally the `updated` method skips all unnecessary arguments to handle described case without additional code required. However, there is always risk of name collisions, this approach should be carefully considered to avoid any potential issues. + +## Example + +To better understand the whole idea we can take a look at more concrete example of a notes management functionality: + +First we define some basic types required by our functionality - management functions signatures and the note itself. + +```python +# notes/types.py +from typing import Any, Protocol +from datetime import datetime +from uuid import UUID +from haiway import Structure + +# Structure representing the note +class Note(Structure): + identifier: UUID + last_update: datetime + content: str + +# Protocol defining the note creation function +class NoteCreating(Protocol): + async def __call__(self, content: str, **extra: Any) -> Note: ... + +# Protocol defining the note update function +class NoteUpdating(Protocol): + async def __call__(self, note: Note, **extra: Any) -> None: ... +``` + +Then we can define the state holding our functions and defining some context. + +```python +# notes/state.py +from notes.types import NoteCreating, NoteUpdating + +from haiway import Structure + +# Structure providing contextual state for the functionality +class NotesDirectory(Structure): + path: str = "./" + +# Structure encapsulating the functionality with its interface +class Notes(Structure): + create: NoteCreating + update: NoteUpdating +``` + +That allows us to provide a concrete implementation. Note that `extra` arguments would allow us to alter the `NotesDirectory` path for a single function call only. This might be very important feature in some cases i.e. when using recursive function calls. + +```python +# notes/files.py +from notes.types import FunctionArgument +from notes.state import Notes, NotesDirectory +from haiway import ctx + +# Implementation of note creation function +async def file_note_create(self, content: str, **extra: Any) -> Note: + # Retrieve path from the current context's state, updated if needed + path = ctx.state(NotesDirectory).updated(**extra).path + # Store note in file within the path... + +# Implementation of note update function +async def file_note_update(self, note: Note, **extra: Any) -> None: + # Retrieve path from the current context's state, updated if needed + path = ctx.state(NotesDirectory).updated(**extra).path + # Update the note... + + +# Factory function to instantiate the Notes utilizing files implementation +def files_notes() -> Notes: + return Notes( + create=file_note_create, + update=file_note_update, + ) + +``` + +Finally we can provide a helper call function for easier usage. + +```python +# notes/calls.py +from notes.types import Note +from notes.state import Notes +from haiway import ctx + +# Call of note creation function +async def create_note(self, content: str, **extra: Any) -> Note: + # Invoke the function implementation from the contextual state + await ctx.state(Notes).create(content=content, **extra) + +# Call of note update function +async def update_note(self, note: Note, **extra: Any) -> None: + # Invoke the function implementation from the contextual state + await ctx.state(Notes).update(note=note, **extra) +``` + diff --git a/guidelines/packages.md b/guidelines/packages.md index 78efe37..faaee84 100644 --- a/guidelines/packages.md +++ b/guidelines/packages.md @@ -13,7 +13,7 @@ haiway defines five distinct package types, each serving a specific purpose in t Here is a high level overview of the project packages structure which will be explained in detail below. ``` -src/project +src/ │ ├── ... │ @@ -63,7 +63,7 @@ src/project Entrypoint packages serve as the starting points for your application. They define how your application is invoked and interacted with from the outside world. Examples of entrypoints include command-line interfaces (CLIs), HTTP servers, or even graphical user interfaces (GUIs). ``` -src/project +src/ │ ├── entrypoint_a/ │ ├── __init__.py @@ -85,7 +85,7 @@ By keeping entrypoints separate, you maintain flexibility in how your applicatio Feature packages encapsulate the highest-level functions provided by your application. They represent the main capabilities or services that your application offers to its users. Examples of features could be user registration, chat handling or data processing pipelines. ``` -src/project +src/ │ ├── ... │ @@ -108,7 +108,7 @@ By organizing your core application capabilities into feature packages, you crea Solution packages provide smaller, more focused utilities and partial functionalities. They serve as the building blocks for your features, offering reusable components that can be combined to create more complex behaviors. While features implement a complete and complex functionalities, the solutions aim for simple, single purpose helpers that allow build numerous features on top. Examples of solutions include storage mechanism, user management or encryption helpers. ``` -src/project +src/ │ ├── ... │ @@ -131,7 +131,7 @@ By breaking down common functionalities into solution packages, you promote code Integration packages are responsible for implementing connections to third-party services, external APIs, or system resources. They serve as the bridge between your application and the outside world. Examples of integrations may be api clients or database connectors. ``` -src/project +src/ │ ├── ... │ @@ -154,7 +154,7 @@ By isolating integrations in their own packages, you make it easier to manage ex The commons package is a special package that provides shared utilities, extensions, and helper functions used throughout your application. It serves as a foundation for all other packages and may be used to resolve circular dependencies caused by type imports in some cases. ``` -src/project +src/ │ ├── ... │ @@ -273,7 +273,7 @@ When splitting your code into multiple small packages, you may encounter circula This approach involves creating an additional common package that contains the conflicting packages. This strategy allows you to resolve conflicts while keeping linked functionalities together. It is helpful to merge few (at most three) packages that are linked together and commonly providing functionalities within that link i.e. database storage of specific data and some linked service relaying on that data. ``` -src/project +src/ │ ├── package_group/ │ ├── __init__.py @@ -302,7 +302,7 @@ By placing linked packages within the common package, you create a new scope tha When the contained packages strategy can't be applied due to multiple dependencies spread across multiple packages, you can create an additional, shared package within the same package group. This shared package declares all required interfaces. ``` -src/project +src/ │ ├── package_group/ │ ├── __init__.py