diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 2c80acf..0000000 --- a/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM python:3.12-slim - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ - -WORKDIR /app -COPY pyproject.toml uv.lock README.md ./ -COPY src ./src - -RUN uv sync --frozen --no-dev - -ENV PATH="/app/.venv/bin:$PATH" - -EXPOSE 8000 -CMD ["uvicorn", "olapi.main:app", "--host", "0.0.0.0"] diff --git a/Makefile b/Makefile index bd94ae9..e24214d 100644 --- a/Makefile +++ b/Makefile @@ -16,9 +16,6 @@ help: # Setup -sync: - uv sync - rebuild: docker compose down --rmi local -v docker compose build @@ -29,12 +26,15 @@ run: # Contribute +sync: + uv sync + format: uv run ruff check --fix . uv run ruff format . lint: - uv run ruff check src + uv run ruff check uv run ruff format --check typecheck: @@ -43,8 +43,8 @@ typecheck: check: lint typecheck - test: uv run pytest + uv run --directory apps/olapi pytest -ci: sync lint check test +ci: sync lint typecheck test diff --git a/README.md b/README.md index 2cb0838..351cf36 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ make rebuild Then: -* **Requests on API**: `uv run scripts/request_api.py` +* **Requests on API**: `uv run --directory apps/olapi python -m scripts.request_api` * **See swagger**: [http://localhost:8000/docs](http://localhost:8000/docs) * **Check keycloak users**: [http://localhost:8080/admin/master/console/#/olapi/users](http://localhost:8080/admin/master/console/#/olapi/users) * username=admin @@ -55,32 +55,37 @@ Then: ### Structure +Using [uv workspaces](https://docs.astral.sh/uv/concepts/projects/workspaces/): + ``` . ├── Makefile ├── README.md - ├── README_DEV.md + ├── CONTRIBUTING.md ├── docker-compose.yml ├── pyproject.toml ├── ... - ├── keycloak/ # Settings for keycloak server - │ └── ... - ├── scripts/ - │ └── ... - └── src/ - ├── authentication/ # Keycloak client - │ └── ... - └── olapi/ # FastAPI application - ├── __init__.py - ├── main.py - ├── settings.py - ├── ... - ├── models/ # tables - │ ├── ... - ├── routers/ # endpoints + ├── docker/ # docker stack config + │ ├── Dockerfile # api image build + │ └── keycloak/ # keycloak realm import + │ └── ... + ├── apps/ + │ └── olapi/ # FastAPI application (flat uv-app) + │ ├── main.py + │ ├── settings.py + │ ├── ... + │ ├── models/ # tables + │ ├── routers/ # endpoints + │ ├── dtos/ # dtos + │ ├── scripts/ # API usage scripts + │ ├── tests/ + │ └── pyproject.toml + └── libs/ + └── authentication/ # Keycloak client (packaged library) + ├── src/authentication/ │ └── ... - └── dtos/ # dtos - └── ... + ├── tests/ + └── pyproject.toml ``` ### Shortcuts diff --git a/apps/olapi/README.md b/apps/olapi/README.md new file mode 100644 index 0000000..089be47 --- /dev/null +++ b/apps/olapi/README.md @@ -0,0 +1,27 @@ +# olapi + +The Social Media API application (FastAPI). + +This is a flat [uv-app](https://docs.astral.sh/uv/concepts/projects/init/#applications): +modules live directly under this directory and are imported relative to it (e.g. +`from main import app`). The app is not packaged or installed. + +## Run + +From the repository root: + +```bash +make run # docker compose up (postgres + keycloak + api) +``` + +Or directly with uvicorn from this directory: + +```bash +uv run uvicorn main:app --reload +``` + +## Test + +```bash +uv run --directory apps/olapi pytest +``` diff --git a/src/olapi/auth.py b/apps/olapi/auth.py similarity index 93% rename from src/olapi/auth.py rename to apps/olapi/auth.py index 565c5dd..70b4d0b 100644 --- a/src/olapi/auth.py +++ b/apps/olapi/auth.py @@ -6,9 +6,9 @@ from authentication import exceptions as auth_exceptions from authentication.keycloak import KeycloakClient -from olapi.database import get_session -from olapi.models.user import UserModel -from olapi.settings import settings +from database import get_session +from models.user import UserModel +from settings import settings oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login", auto_error=False) auth_client = KeycloakClient( diff --git a/src/olapi/database.py b/apps/olapi/database.py similarity index 92% rename from src/olapi/database.py rename to apps/olapi/database.py index 62bf9ae..871cdf1 100644 --- a/src/olapi/database.py +++ b/apps/olapi/database.py @@ -3,7 +3,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker -from olapi.settings import settings +from settings import settings # TODO: use context managers ? diff --git a/src/authentication/__init__.py b/apps/olapi/dtos/__init__.py similarity index 100% rename from src/authentication/__init__.py rename to apps/olapi/dtos/__init__.py diff --git a/src/olapi/dtos/auth.py b/apps/olapi/dtos/auth.py similarity index 100% rename from src/olapi/dtos/auth.py rename to apps/olapi/dtos/auth.py diff --git a/src/olapi/dtos/user.py b/apps/olapi/dtos/user.py similarity index 94% rename from src/olapi/dtos/user.py rename to apps/olapi/dtos/user.py index 233f3c9..976b9dc 100644 --- a/src/olapi/dtos/user.py +++ b/apps/olapi/dtos/user.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, EmailStr, Field -from olapi.models.user import UserModel +from models.user import UserModel class UserCreatePayload(BaseModel): diff --git a/src/olapi/main.py b/apps/olapi/main.py similarity index 72% rename from src/olapi/main.py rename to apps/olapi/main.py index d400e1f..abcdf27 100644 --- a/src/olapi/main.py +++ b/apps/olapi/main.py @@ -4,11 +4,11 @@ from fastapi import APIRouter, Depends, FastAPI -from olapi.auth import check_authentication -from olapi.database import engine -from olapi.models.base import BaseModel -from olapi.routers import auth, temporary -from olapi.settings import LOGGING_CONFIG +from auth import check_authentication +from database import engine +from models.base import BaseModel +from routers import auth, temporary +from settings import LOGGING_CONFIG dictConfig(LOGGING_CONFIG) diff --git a/src/olapi/__init__.py b/apps/olapi/models/__init__.py similarity index 100% rename from src/olapi/__init__.py rename to apps/olapi/models/__init__.py diff --git a/src/olapi/models/base.py b/apps/olapi/models/base.py similarity index 100% rename from src/olapi/models/base.py rename to apps/olapi/models/base.py diff --git a/src/olapi/models/user.py b/apps/olapi/models/user.py similarity index 93% rename from src/olapi/models/user.py rename to apps/olapi/models/user.py index a0f6f06..4772a17 100644 --- a/src/olapi/models/user.py +++ b/apps/olapi/models/user.py @@ -3,7 +3,7 @@ from sqlalchemy import DateTime, String, func from sqlalchemy.orm import Mapped, mapped_column -from olapi.models.base import BaseModel +from models.base import BaseModel class UserModel(BaseModel): diff --git a/apps/olapi/pyproject.toml b/apps/olapi/pyproject.toml new file mode 100644 index 0000000..ff8f301 --- /dev/null +++ b/apps/olapi/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "olapi" +version = "0.0.1" +description = "Social Media API" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "sqlalchemy>=2.0", + "psycopg[binary]>=3.1", + "pydantic[email]>=2.6", + "python-jose[cryptography]>=3.3", + "python-ulid>=2.2", + "httpx2>=2.3", + "authentication", +] + +# Flat uv-app: no [build-system], so the app is not built or installed as a +# package. Its modules are imported relative to this directory (see pythonpath). + +[tool.uv.sources] +authentication = { workspace = true } + +[tool.pytest.ini_options] +pythonpath = ["."] + +[tool.ruff] +extend = "../../pyproject.toml" + +# The app is flat, so isort can't auto-detect its own modules; the authentication +# workspace library is also a first-party concern. Group them together. +[tool.ruff.lint.isort] +known-first-party = [ + "authentication", + "auth", + "conftest", + "database", + "dtos", + "main", + "models", + "routers", + "settings", +] diff --git a/src/olapi/dtos/__init__.py b/apps/olapi/routers/__init__.py similarity index 100% rename from src/olapi/dtos/__init__.py rename to apps/olapi/routers/__init__.py diff --git a/src/olapi/routers/auth.py b/apps/olapi/routers/auth.py similarity index 92% rename from src/olapi/routers/auth.py rename to apps/olapi/routers/auth.py index 5c50004..ab3e904 100644 --- a/src/olapi/routers/auth.py +++ b/apps/olapi/routers/auth.py @@ -6,12 +6,12 @@ from sqlalchemy.orm import Session from ulid import ULID +from auth import auth_client from authentication import exceptions as auth_exceptions -from olapi.auth import auth_client -from olapi.database import get_session -from olapi.dtos.auth import Credentials, TokenResponse -from olapi.dtos.user import User, UserCreatePayload -from olapi.models.user import UserModel +from database import get_session +from dtos.auth import Credentials, TokenResponse +from dtos.user import User, UserCreatePayload +from models.user import UserModel logger = logging.getLogger(__name__) diff --git a/src/olapi/routers/temporary.py b/apps/olapi/routers/temporary.py similarity index 73% rename from src/olapi/routers/temporary.py rename to apps/olapi/routers/temporary.py index 18f666d..4ca5c95 100644 --- a/src/olapi/routers/temporary.py +++ b/apps/olapi/routers/temporary.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends -from olapi.auth import get_user -from olapi.models.user import UserModel +from auth import get_user +from models.user import UserModel router = APIRouter() diff --git a/scripts/request_api.py b/apps/olapi/scripts/request_api.py similarity index 92% rename from scripts/request_api.py rename to apps/olapi/scripts/request_api.py index 39fffa2..14ff0c1 100644 --- a/scripts/request_api.py +++ b/apps/olapi/scripts/request_api.py @@ -3,8 +3,8 @@ import httpx2 from authentication.keycloak import check_response -from olapi.dtos.auth import Credentials, TokenResponse -from olapi.dtos.user import User, UserCreatePayload +from dtos.auth import Credentials, TokenResponse +from dtos.user import User, UserCreatePayload logger = logging.getLogger(__name__) diff --git a/src/olapi/settings.py b/apps/olapi/settings.py similarity index 100% rename from src/olapi/settings.py rename to apps/olapi/settings.py diff --git a/tests/conftest.py b/apps/olapi/tests/conftest.py similarity index 87% rename from tests/conftest.py rename to apps/olapi/tests/conftest.py index 6d6e1b6..236f1c5 100644 --- a/tests/conftest.py +++ b/apps/olapi/tests/conftest.py @@ -6,10 +6,10 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool -from olapi.auth import check_authentication -from olapi.database import get_session -from olapi.main import app -from olapi.models.base import BaseModel +from auth import check_authentication +from database import get_session +from main import app +from models.base import BaseModel TEST_AUTH_ID = "test-auth-id" diff --git a/tests/test_temporary.py b/apps/olapi/tests/test_temporary.py similarity index 92% rename from tests/test_temporary.py rename to apps/olapi/tests/test_temporary.py index 70c18f7..339aa49 100644 --- a/tests/test_temporary.py +++ b/apps/olapi/tests/test_temporary.py @@ -1,8 +1,8 @@ -from conftest import TEST_AUTH_ID from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from olapi.models.user import UserModel +from conftest import TEST_AUTH_ID +from models.user import UserModel def test_get_hello(app_client: TestClient, db_session: Session): diff --git a/docker-compose.yml b/docker-compose.yml index 4bfb52b..bcf4d67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,13 +20,15 @@ services: KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin volumes: - - ./keycloak:/opt/keycloak/data/import:ro + - ./docker/keycloak:/opt/keycloak/data/import:ro ports: - "8080:8080" api: - build: . - command: uvicorn olapi.main:app --host 0.0.0.0 --reload --reload-dir /app/src + build: + context: . + dockerfile: docker/Dockerfile + command: uvicorn main:app --host 0.0.0.0 --reload --reload-dir /app/apps/olapi depends_on: postgres: condition: service_healthy @@ -35,4 +37,4 @@ services: ports: - "8000:8000" volumes: - - ./src:/app/src + - ./apps/olapi:/app/apps/olapi diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..3e29c75 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app +COPY pyproject.toml uv.lock ./ +COPY libs ./libs +COPY apps ./apps + +RUN uv sync --frozen --no-dev + +ENV PATH="/app/.venv/bin:$PATH" + +# The olapi app is a flat uv-app; run it from its own directory so `main:app` resolves. +WORKDIR /app/apps/olapi + +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0"] diff --git a/keycloak/ABOUT.md b/docker/keycloak/ABOUT.md similarity index 100% rename from keycloak/ABOUT.md rename to docker/keycloak/ABOUT.md diff --git a/keycloak/realm.json b/docker/keycloak/realm.json similarity index 100% rename from keycloak/realm.json rename to docker/keycloak/realm.json diff --git a/libs/authentication/README.md b/libs/authentication/README.md new file mode 100644 index 0000000..89c8b07 --- /dev/null +++ b/libs/authentication/README.md @@ -0,0 +1,24 @@ +# authentication + +Keycloak authentication client library (src-layout, packaged with `uv_build`). + +Exposes a `KeycloakClient` and related exceptions used by workspace apps to +register users, issue tokens, and validate them. + +## Use + +Declare it as a workspace dependency in a member's `pyproject.toml`: + +```toml +dependencies = ["authentication"] + +[tool.uv.sources] +authentication = { workspace = true } +``` + +Then import: + +```python +from authentication.keycloak import KeycloakClient +from authentication import exceptions +``` diff --git a/libs/authentication/pyproject.toml b/libs/authentication/pyproject.toml new file mode 100644 index 0000000..13bbbc9 --- /dev/null +++ b/libs/authentication/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "authentication" +version = "0.0.1" +description = "Keycloak authentication client" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "httpx2>=2.3", + "python-jose[cryptography]>=3.3", + "starlette>=0.37", +] + +[build-system] +requires = ["uv_build>=0.8.3,<0.9.0"] +build-backend = "uv_build" + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/scripts/run_keycloak_client.py b/libs/authentication/scripts/run_keycloak_client.py similarity index 100% rename from scripts/run_keycloak_client.py rename to libs/authentication/scripts/run_keycloak_client.py diff --git a/src/olapi/models/__init__.py b/libs/authentication/src/authentication/__init__.py similarity index 100% rename from src/olapi/models/__init__.py rename to libs/authentication/src/authentication/__init__.py diff --git a/src/authentication/exceptions.py b/libs/authentication/src/authentication/exceptions.py similarity index 100% rename from src/authentication/exceptions.py rename to libs/authentication/src/authentication/exceptions.py diff --git a/src/authentication/keycloak.py b/libs/authentication/src/authentication/keycloak.py similarity index 100% rename from src/authentication/keycloak.py rename to libs/authentication/src/authentication/keycloak.py diff --git a/src/olapi/routers/__init__.py b/libs/authentication/src/authentication/py.typed similarity index 100% rename from src/olapi/routers/__init__.py rename to libs/authentication/src/authentication/py.typed diff --git a/libs/authentication/tests/test_keycloak.py b/libs/authentication/tests/test_keycloak.py new file mode 100644 index 0000000..8fb97e6 --- /dev/null +++ b/libs/authentication/tests/test_keycloak.py @@ -0,0 +1,17 @@ +import httpx2 +import pytest + +from authentication.keycloak import check_response + + +def _response(status_code: int) -> httpx2.Response: + return httpx2.Response(status_code, request=httpx2.Request("GET", "http://keycloak/test")) + + +def test_check_response_passes_on_success(): + check_response(_response(200)) + + +def test_check_response_raises_on_error(): + with pytest.raises(httpx2.HTTPStatusError): + check_response(_response(404)) diff --git a/pyproject.toml b/pyproject.toml index 2f58b00..65de0db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,5 @@ -[project] -name = "olapi" -version = "0.0.1" -description = "Social Media API" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "fastapi>=0.110", - "uvicorn[standard]>=0.27", - "sqlalchemy>=2.0", - "psycopg[binary]>=3.1", - "pydantic[email]>=2.6", - "python-jose[cryptography]>=3.3", - "python-ulid>=2.2", - "httpx2>=2.3", -] +[tool.uv.workspace] +members = ["apps/*", "libs/*"] [dependency-groups] dev = [ @@ -23,13 +9,16 @@ dev = [ "pytest>=9.0.3", ] -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/olapi", "src/authentication"] +[tool.pytest.ini_options] +# Libraries are collected here; apps run in their own pytest process (see the +# Makefile) so the flat app modules don't collide with library test discovery. +testpaths = ["libs"] +# importlib import mode imports each test file under a unique name, so +# identically-named test files across members can coexist without __init__.py. +addopts = ["--import-mode=importlib"] +# Shared lint config for the whole workspace. Members extend this via +# `[tool.ruff] extend = "../../pyproject.toml"`. [tool.ruff] target-version = "py312" line-length = 100 @@ -54,8 +43,10 @@ pythonVersion = "3.12" typeCheckingMode = "strict" venvPath = "." venv = ".venv" -include = ["src", "tests"] +include = ["apps", "libs"] -[tool.pytest.ini_options] -pythonpath = ["src", "tests"] -testpaths = ["tests"] +# The olapi app is a flat, non-packaged uv-app: its modules live directly under +# apps/olapi and are imported as `from main import ...`. This execution +# environment puts that directory on the import path for the type checker. +[[tool.pyright.executionEnvironments]] +root = "apps/olapi" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uv.lock b/uv.lock index 2a33c22..954dec9 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,20 @@ resolution-markers = [ "python_full_version < '3.15'", ] +[manifest] +members = [ + "authentication", + "olapi", +] + +[manifest.dependency-groups] +dev = [ + { name = "pyright", specifier = ">=1.1.350" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.6" }, + { name = "types-python-jose", specifier = ">=3.3" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -37,6 +51,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "authentication" +version = "0.0.1" +source = { editable = "libs/authentication" } +dependencies = [ + { name = "httpx2" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "starlette" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx2", specifier = ">=2.3" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" }, + { name = "starlette", specifier = ">=0.37" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -388,8 +419,9 @@ wheels = [ [[package]] name = "olapi" version = "0.0.1" -source = { editable = "." } +source = { virtual = "apps/olapi" } dependencies = [ + { name = "authentication" }, { name = "fastapi" }, { name = "httpx2" }, { name = "psycopg", extra = ["binary"] }, @@ -400,16 +432,9 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, - { name = "types-python-jose" }, -] - [package.metadata] requires-dist = [ + { name = "authentication", editable = "libs/authentication" }, { name = "fastapi", specifier = ">=0.110" }, { name = "httpx2", specifier = ">=2.3" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, @@ -420,14 +445,6 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.27" }, ] -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.350" }, - { name = "pytest", specifier = ">=9.0.3" }, - { name = "ruff", specifier = ">=0.6" }, - { name = "types-python-jose", specifier = ">=3.3" }, -] - [[package]] name = "packaging" version = "26.2"