cofy-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cofy/cofy_api.py ADDED
@@ -0,0 +1,43 @@
1
+ from typing import Any
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.openapi.utils import get_openapi
5
+
6
+ from src.cofy.docs_router import DocsRouter
7
+ from src.shared.module import Module
8
+
9
+ DEFAULT_ARGS: dict[str, Any] = {
10
+ "title": "Cofy API",
11
+ "version": "0.1.0",
12
+ "description": "Modular cloud API for energy data",
13
+ "docs_url": None,
14
+ "redoc_url": None,
15
+ "openapi_url": None,
16
+ }
17
+
18
+
19
+ class CofyApi(FastAPI):
20
+ def __init__(self, **kwargs):
21
+ super().__init__(**(DEFAULT_ARGS | kwargs))
22
+ self._modules: list[Module] = []
23
+ self.include_router(DocsRouter(self.openapi))
24
+
25
+ def openapi(self):
26
+ return get_openapi(
27
+ title=self.title,
28
+ version=self.version,
29
+ routes=self.routes,
30
+ tags=self.tags_metadata,
31
+ )
32
+
33
+ def register_module(self, module: Module):
34
+ self._modules.append(module)
35
+ self.include_router(module)
36
+
37
+ @property
38
+ def tags_metadata(self) -> list[dict[str, Any]]:
39
+ return [module.tag for module in self._modules]
40
+
41
+ @property
42
+ def modules(self) -> tuple[Module, ...]:
43
+ return tuple(self._modules)
cofy/db/alembic/env.py ADDED
@@ -0,0 +1,52 @@
1
+ from logging.config import fileConfig
2
+
3
+ from alembic import context
4
+ from sqlalchemy import engine_from_config, pool
5
+
6
+ from src.cofy.db.base import Base
7
+
8
+ config = context.config
9
+
10
+ if config.config_file_name is not None:
11
+ fileConfig(config.config_file_name)
12
+
13
+ configured_metadata = config.attributes.get("target_metadata")
14
+ if isinstance(configured_metadata, list):
15
+ target_metadata = configured_metadata[0] if configured_metadata else Base.metadata
16
+ elif configured_metadata is not None:
17
+ target_metadata = configured_metadata
18
+ else:
19
+ target_metadata = Base.metadata
20
+
21
+
22
+ def run_migrations_offline() -> None:
23
+ url = config.get_main_option("sqlalchemy.url")
24
+ context.configure(
25
+ url=url,
26
+ target_metadata=target_metadata,
27
+ literal_binds=True,
28
+ dialect_opts={"paramstyle": "named"},
29
+ )
30
+
31
+ with context.begin_transaction():
32
+ context.run_migrations()
33
+
34
+
35
+ def run_migrations_online() -> None:
36
+ connectable = engine_from_config(
37
+ config.get_section(config.config_ini_section, {}),
38
+ prefix="sqlalchemy.",
39
+ poolclass=pool.NullPool,
40
+ )
41
+
42
+ with connectable.connect() as connection:
43
+ context.configure(connection=connection, target_metadata=target_metadata)
44
+
45
+ with context.begin_transaction():
46
+ context.run_migrations()
47
+
48
+
49
+ if context.is_offline_mode():
50
+ run_migrations_offline()
51
+ else:
52
+ run_migrations_online()
cofy/db/base.py ADDED
@@ -0,0 +1,5 @@
1
+ from sqlalchemy.orm import DeclarativeBase
2
+
3
+
4
+ class Base(DeclarativeBase):
5
+ pass
cofy/db/cofy_db.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from importlib import resources
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from alembic import command
9
+ from alembic.config import Config
10
+ from sqlalchemy import MetaData, create_engine
11
+
12
+ from src.cofy.db.database_backed_source import DatabaseBackedSource
13
+
14
+ if TYPE_CHECKING:
15
+ from sqlalchemy.engine import Engine
16
+
17
+ from src.cofy.cofy_api import CofyApi
18
+ from src.shared.module import Module
19
+
20
+
21
+ class CofyDB:
22
+ def __init__(self, url: str | None = None, **engine_kwargs):
23
+ if url is None:
24
+ raise ValueError("CofyDB requires a database URL to be configured.")
25
+ self._sources: list[DatabaseBackedSource] = []
26
+ self._url = url
27
+ self.engine: Engine = create_engine(self._url, **engine_kwargs)
28
+
29
+ def register_module(self, module: Module):
30
+ source = getattr(module, "source", None)
31
+ if isinstance(source, DatabaseBackedSource):
32
+ self._sources.append(source)
33
+
34
+ def register_modules(self, modules: Iterable[Module]):
35
+ for module in modules:
36
+ self.register_module(module)
37
+
38
+ def bind_api(self, api: CofyApi):
39
+ self.register_modules(api.modules)
40
+
41
+ @property
42
+ def migration_locations(self) -> list[str]:
43
+ locations: list[str] = []
44
+ for source in self._sources:
45
+ for location in source.migration_locations:
46
+ resolved_location = str(Path(location).resolve())
47
+ if resolved_location not in locations:
48
+ locations.append(resolved_location)
49
+ return locations
50
+
51
+ @property
52
+ def target_metadata(self) -> list[Any]:
53
+ metadata: list[Any] = []
54
+ for source in self._sources:
55
+ if source.target_metadata is not None and source.target_metadata not in metadata:
56
+ metadata.append(source.target_metadata)
57
+ return metadata
58
+
59
+ def _build_config(self) -> Config:
60
+ config = Config()
61
+ config.set_main_option("script_location", str(resources.files("src.cofy.db").joinpath("alembic")))
62
+ config.set_main_option("sqlalchemy.url", self._url)
63
+ config.set_main_option("path_separator", "os")
64
+
65
+ if self.migration_locations:
66
+ config.set_main_option("version_locations", " ".join(self.migration_locations))
67
+
68
+ config.attributes["target_metadata"] = self.target_metadata
69
+ return config
70
+
71
+ def run_migrations(self, revision: str = "heads") -> None:
72
+ command.upgrade(self._build_config(), revision)
73
+
74
+ def generate_migration(
75
+ self,
76
+ message: str,
77
+ head: str,
78
+ rev_id: str | None = None,
79
+ autogenerate: bool = True,
80
+ ) -> None:
81
+ """Generate a new Alembic migration revision for a specific module branch.
82
+
83
+ Args:
84
+ message: Description of the migration.
85
+ head: The branch head to extend, e.g. "members_core@head".
86
+ rev_id: Optional custom revision ID. If None, Alembic generates one.
87
+ autogenerate: Whether to autogenerate the migration from model changes.
88
+ """
89
+ command.revision(
90
+ self._build_config(),
91
+ message=message,
92
+ autogenerate=autogenerate,
93
+ head=head,
94
+ rev_id=rev_id,
95
+ )
96
+
97
+ def reset(self) -> None:
98
+ """usefull in development to reset the database to a clean state
99
+ Warning: this will delete all data in the database, use with caution!
100
+ """
101
+ meta = MetaData()
102
+ meta.reflect(bind=self.engine)
103
+ meta.drop_all(bind=self.engine)
104
+ self.run_migrations()
@@ -0,0 +1,13 @@
1
+ from collections.abc import Sequence
2
+ from typing import Any, Protocol, runtime_checkable
3
+
4
+
5
+ @runtime_checkable
6
+ class DatabaseBackedSource(Protocol):
7
+ @property
8
+ def migration_locations(self) -> Sequence[str]:
9
+ """Filesystem paths containing alembic version files."""
10
+
11
+ @property
12
+ def target_metadata(self) -> Any | None:
13
+ """SQLAlchemy metadata used for migration autogeneration."""
@@ -0,0 +1,9 @@
1
+ import datetime as dt
2
+
3
+ from sqlalchemy import DateTime, func
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+
6
+
7
+ class TimestampMixin:
8
+ created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=func.now())
9
+ updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=func.now(), onupdate=func.now())
cofy/docs_router.py ADDED
@@ -0,0 +1,37 @@
1
+ from collections.abc import Callable
2
+
3
+ from fastapi import APIRouter, Request
4
+ from fastapi.openapi.docs import get_swagger_ui_html
5
+ from fastapi.responses import HTMLResponse
6
+
7
+
8
+ class DocsRouter(APIRouter):
9
+ def __init__(self, get_openapi: Callable[[], dict]):
10
+ super().__init__()
11
+ self.get_openapi = get_openapi
12
+ self.add_api_route(
13
+ "/docs",
14
+ self.get_swagger_ui_html,
15
+ include_in_schema=False,
16
+ )
17
+ self.add_api_route("/openapi.json", self.get_openapi, include_in_schema=False)
18
+
19
+ async def get_swagger_ui_html(self, request: Request):
20
+ response = get_swagger_ui_html(
21
+ openapi_url="/openapi.json",
22
+ title="Docs",
23
+ swagger_ui_parameters={
24
+ "spec": self.get_openapi(),
25
+ "onComplete": "AUTHORIZE_API",
26
+ },
27
+ )
28
+
29
+ if hasattr(request.state, "auth_info"):
30
+ assert isinstance(response.body, bytes)
31
+ content = response.body.decode()
32
+ content = content.replace(
33
+ '"AUTHORIZE_API"',
34
+ f'() => ui.preauthorizeApiKey("{request.state.auth_info["scheme"]}", "{request.state.auth_info["content"]}")',
35
+ )
36
+ response = HTMLResponse(content=content, status_code=response.status_code)
37
+ return response
cofy/token_auth.py ADDED
@@ -0,0 +1,70 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from fastapi import Depends, HTTPException, Request
4
+ from fastapi.security import APIKeyHeader, APIKeyQuery
5
+ from pydantic import BaseModel
6
+ from starlette.status import HTTP_401_UNAUTHORIZED
7
+
8
+
9
+ class TokenInfo(BaseModel):
10
+ name: str
11
+ expires: str | None = None
12
+
13
+ def __init__(self, info: dict):
14
+ if "name" not in info or not info["name"]:
15
+ raise ValueError("Token info must include a name")
16
+ if "expires" in info and info["expires"] is not None:
17
+ try:
18
+ datetime.fromisoformat(info["expires"])
19
+ except Exception:
20
+ raise ValueError("Token expires must be in ISO8601 format") from None
21
+ super().__init__(**info)
22
+
23
+ def is_expired(self) -> bool:
24
+ if self.expires:
25
+ expires_dt = datetime.fromisoformat(self.expires)
26
+ if expires_dt.tzinfo is None:
27
+ expires_dt = expires_dt.replace(tzinfo=UTC)
28
+
29
+ return datetime.now(UTC) > expires_dt
30
+ return False
31
+
32
+
33
+ def token_verifier(tokens: dict):
34
+ tokens = {token: TokenInfo(info) for token, info in tokens.items()}
35
+
36
+ def verify(
37
+ request: Request,
38
+ header_token: str = Depends(APIKeyHeader(name="Authorization", auto_error=False, scheme_name="header")),
39
+ query_token: str = Depends(APIKeyQuery(name="token", auto_error=False, scheme_name="query")),
40
+ ):
41
+ token = None
42
+ auth_info = None
43
+ if header_token and header_token.lower().startswith("bearer "):
44
+ token = header_token[7:]
45
+ auth_info = {
46
+ "scheme": "header",
47
+ "content": header_token,
48
+ }
49
+ elif query_token:
50
+ token = query_token
51
+ auth_info = {
52
+ "scheme": "query",
53
+ "content": query_token,
54
+ }
55
+ if not token:
56
+ if header_token:
57
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid token format")
58
+
59
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Missing token")
60
+
61
+ token_info = tokens.get(token)
62
+ if not token_info:
63
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid token")
64
+
65
+ if token_info.is_expired():
66
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Token expired")
67
+ request.state.token = token
68
+ request.state.auth_info = auth_info
69
+
70
+ return verify
cofy/worker.py ADDED
@@ -0,0 +1,187 @@
1
+ """
2
+ Provides CofyWorker — a thin scaffolding layer over SAQ for background job processing.
3
+
4
+ Each community (deployment) creates its own worker with the jobs and cron
5
+ schedules it needs. Modules provide reusable job functions; the community
6
+ decides which ones to activate and how to schedule them.
7
+
8
+ Architecture:
9
+ ┌─────────────┐ ┌─────────┐ ┌──────────────┐
10
+ │ FastAPI API │────▶│ Redis │◀────│ SAQ Worker │
11
+ │ (enqueue) │ │ Queue │ │ (process) │
12
+ └─────────────┘ └─────────┘ └──────────────┘
13
+
14
+ Both the API and the worker connect to the same Redis queue.
15
+ The API enqueues jobs; the worker processes them.
16
+
17
+ Example — community worker (e.g. src/demo/worker.py):
18
+
19
+ from src.cofy.jobs.worker import CofyWorker
20
+
21
+ worker = CofyWorker(url="redis://localhost:6379")
22
+ worker.register(my_job_function)
23
+ worker.schedule(my_job_function, cron="0 2 * * *")
24
+ settings = worker.settings # SAQ entry point: saq src.demo.worker.settings
25
+
26
+ Example — enqueue from a FastAPI endpoint:
27
+
28
+ from src.cofy.worker import create_queue
29
+
30
+ queue = create_queue("redis://localhost:6379")
31
+
32
+ @app.post("/upload")
33
+ async def upload(file: UploadFile):
34
+ await queue.enqueue("process_upload", file_id="...")
35
+ """
36
+
37
+ import asyncio
38
+ import functools
39
+ import inspect
40
+ from collections.abc import Callable, Coroutine
41
+ from typing import Any, Protocol
42
+
43
+ from saq import CronJob, Queue
44
+
45
+ type JobFunction = Callable[..., Coroutine[Any, Any, Any]]
46
+
47
+
48
+ class NamedCallable(Protocol):
49
+ """A callable that exposes a ``__name__`` attribute (i.e. a function)."""
50
+
51
+ __name__: str
52
+
53
+ def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
54
+
55
+
56
+ def create_queue(url: str = "redis://localhost:6379", **kwargs: Any) -> Queue:
57
+ """Create a SAQ queue for enqueuing jobs (e.g. from the API process).
58
+
59
+ Use this in the API process when you only need to enqueue jobs,
60
+ not process them. Points to the same Redis as the worker.
61
+ """
62
+ return Queue.from_url(url, **kwargs)
63
+
64
+
65
+ def to_task(function: Callable, **fixed_kwargs: Any) -> JobFunction:
66
+ """Wrap a plain function into a SAQ-compatible task.
67
+
68
+ The wrapped function does NOT need to know about SAQ's `ctx` dict.
69
+ It simply declares the arguments it needs::
70
+
71
+ async def sync_members(db_engine, file_path: str) -> dict: ...
72
+
73
+ Arguments are resolved in this priority order (last wins):
74
+ 1. fixed_kwargs — bound at registration time via ``to_task(fn, file_path="/data/x.csv")``
75
+ 2. ctx values — set by the worker's on_startup hooks (e.g. ``db_engine``)
76
+ 3. enqueue kwargs — passed at ``queue.enqueue("sync_members", file_path="/other.csv")``
77
+
78
+ Only arguments that match the function's signature are passed through,
79
+ so SAQ internals in ctx (``queue``, ``job``) don't leak in.
80
+
81
+ Works with both sync and async functions. Sync functions are
82
+ automatically run in a thread via ``asyncio.to_thread``.
83
+ """
84
+ sig = inspect.signature(function)
85
+ params = sig.parameters
86
+ accepts_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
87
+ is_async = inspect.iscoroutinefunction(function)
88
+
89
+ @functools.wraps(function)
90
+ async def task(ctx: dict, **kwargs: Any) -> Any:
91
+ merged = {**fixed_kwargs, **ctx, **kwargs}
92
+
93
+ call_kwargs = merged if accepts_var_keyword else {k: v for k, v in merged.items() if k in params}
94
+
95
+ if is_async:
96
+ return await function(**call_kwargs)
97
+ return await asyncio.to_thread(function, **call_kwargs)
98
+
99
+ return task
100
+
101
+
102
+ class CofyWorker:
103
+ """Builds SAQ worker settings from registered job functions and cron schedules.
104
+
105
+ The worker collects job functions and cron schedules, then exposes them as a `settings` dict for the SAQ CLI.
106
+
107
+ It also provides lifecycle hooks (on_startup / on_shutdown) for setting up shared resources like database engines
108
+ """
109
+
110
+ def __init__(self, url: str = "redis://localhost:6379", **queue_kwargs: Any):
111
+ self.queue = Queue.from_url(url, **queue_kwargs)
112
+ self._functions: dict[str, JobFunction] = {}
113
+ self._cron_jobs: list[CronJob] = []
114
+ self._startup_hooks: list[JobFunction] = []
115
+ self._shutdown_hooks: list[JobFunction] = []
116
+
117
+ def register(self, func: NamedCallable, **fixed_kwargs: Any) -> JobFunction:
118
+ """Register a job function so it can be enqueued and processed by this worker.
119
+
120
+ Registered functions become available for ``queue.enqueue("function_name", ...)``.
121
+ """
122
+ if func.__name__ not in self._functions:
123
+ self._functions[func.__name__] = to_task(func, **fixed_kwargs)
124
+ return self._functions[func.__name__]
125
+
126
+ def schedule(
127
+ self,
128
+ func: NamedCallable,
129
+ cron: str,
130
+ function_kwargs: dict | None = None,
131
+ **kwargs: Any,
132
+ ) -> JobFunction:
133
+ """Schedule a function to run on a cron expression."""
134
+ task = self.register(func)
135
+ self._cron_jobs.append(
136
+ CronJob(task, cron=cron, kwargs=function_kwargs or {}, **kwargs),
137
+ )
138
+ return task
139
+
140
+ def on_startup(self, func: JobFunction) -> JobFunction:
141
+ """Register a hook that runs when the worker starts.
142
+
143
+ Use this to set up shared resources (DB engines, HTTP clients, etc.)
144
+ that jobs can access through `ctx`.
145
+ """
146
+ self._startup_hooks.append(func)
147
+ return func
148
+
149
+ def on_shutdown(self, func: JobFunction) -> JobFunction:
150
+ """Register a hook that runs when the worker stops. Use for cleanup."""
151
+ self._shutdown_hooks.append(func)
152
+ return func
153
+
154
+ @property
155
+ def settings(self) -> dict[str, Any]:
156
+ """SAQ worker settings dict.
157
+
158
+ Export this as a module-level `settings` variable for the SAQ CLI:
159
+
160
+ settings = worker.settings
161
+ # Run with: saq src.demo.worker.settings
162
+ """
163
+ result: dict[str, Any] = {
164
+ "queue": self.queue,
165
+ "functions": list(self._functions.values()),
166
+ "cron_jobs": self._cron_jobs,
167
+ }
168
+
169
+ if self._startup_hooks:
170
+ startup_hooks = list(self._startup_hooks)
171
+
172
+ async def startup(ctx: dict) -> None:
173
+ for hook in startup_hooks:
174
+ await hook(ctx)
175
+
176
+ result["startup"] = startup
177
+
178
+ if self._shutdown_hooks:
179
+ shutdown_hooks = list(self._shutdown_hooks)
180
+
181
+ async def shutdown(ctx: dict) -> None:
182
+ for hook in shutdown_hooks:
183
+ await hook(ctx)
184
+
185
+ result["shutdown"] = shutdown
186
+
187
+ return result
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: cofy-api
3
+ Version: 0.1.0
4
+ Summary: An open-source modular framework for ingesting, standardising, storing, and computing energy-related data.
5
+ Author: Jorg
6
+ Author-email: EnergyID <info@energieid.be>
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: alembic>=1.18.4
10
+ Requires-Dist: entsoe-py>=0.7.10
11
+ Requires-Dist: fastapi[standard]>=0.128.0
12
+ Requires-Dist: narwhals>=2.15.0
13
+ Requires-Dist: pandas>=3.0.0
14
+ Requires-Dist: polars>=1.37.1
15
+ Requires-Dist: saq[redis,web]>=0.26.1
16
+
17
+ **Cofy Cloud** is an open-source modular framework for ingesting, standardising, storing, and computing energy-related data, designed to run anywhere from local deployments to cloud environments.
18
+
19
+ Right now this is very much a work in progress.
20
+ With the development of a first proof of concept. This is not ready for production use and the api is likely to change significantly in the near future.
21
+
22
+ ## Setup
23
+ ### Install
24
+ *TODO*
25
+ ### Configure
26
+ *TODO*
27
+ ### Development
28
+ We use [astral](https://docs.astral.sh/) python tooling for our development environment.
29
+ We use [poethepoet](https://poethepoet.natn.io) to define some essential tasks.
30
+ The demo run task is also available as vscode execution task, making it easy to run and debug the demo application from within vscode.
31
+
32
+ #### Install/update dependencies:
33
+ First install [uv](https://docs.astral.sh/uv/) if you don't have it yet.
34
+
35
+ Then install/update dependencies:
36
+ ```sh
37
+ uv sync
38
+ ```
39
+
40
+ Install [poethepoet](https://poethepoet.natn.io) and [pre-commit](https://pre-commit.com/)
41
+ ```sh
42
+ uv tool install poethepoet
43
+ uv tool install pre-commit
44
+ ```
45
+
46
+ Activate [pre-commit](https://pre-commit.com/) hooks that enforce code style on every commit:
47
+ ```sh
48
+ pre-commit install
49
+ ```
50
+
51
+ #### Configure environment variables:
52
+ Our demo application uses some API keys for external services. You can provide these `.env.local` file in the root of the repository, following the structure of `.env.example`.
53
+
54
+ #### Set up the database:
55
+ The demo application uses a SQLite database. A different database can be configured via the environment variables.
56
+ To create the database and seed it with example data:
57
+
58
+ ```sh
59
+ poe db-seed
60
+ ```
61
+
62
+ This will run all pending migrations and load the example CSV data into the database.
63
+
64
+ #### Run development demo application:
65
+
66
+ ```sh
67
+ poe demo
68
+ ```
69
+
70
+ #### Run background worker:
71
+ We use Cofy Worker to run background jobs that perform data ingestion and processing tasks asynchronously.
72
+
73
+ The background worker requires a Redis instance to run. You can configure the Redis URL via the environment variables.
74
+ The simplest way to run Redis locally is via Docker:
75
+
76
+ ```sh
77
+ docker run -p 6379:6379 redis
78
+ ```
79
+
80
+ Then start the worker:
81
+
82
+ ```sh
83
+ poe worker
84
+ ```
85
+
86
+ #### Code style checks:
87
+ ```sh
88
+ poe lint # Check code style
89
+ poe format # Format code
90
+ poe check # Run type checks
91
+ ```
92
+
93
+ #### Run tests:
94
+ ```sh
95
+ poe test
96
+ ```
97
+ #### Build & publish
98
+ TODO
99
+
100
+ ## Database & Migrations
101
+
102
+ Cofy uses [Alembic](https://alembic.sqlalchemy.org/) for database migrations. Each module that needs database storage owns its own migration branch, keeping schemas independent and composable.
103
+
104
+ ### Commands
105
+
106
+ | Command | Description |
107
+ |---|---|
108
+ | `poe db-seed` | Run all migrations and seed the database with example data |
109
+ | `poe db-migrate` | Run all pending migrations |
110
+ | `poe db-reset` | Drop all tables and re-run migrations (⚠️ destroys all data) |
111
+ | `poe db-generate` | Generate a new migration file for a specific module branch |
112
+
113
+ ### Generating a migration
114
+
115
+ When you change a module's SQLAlchemy model, generate a migration for that module's branch:
116
+
117
+ ```sh
118
+ poe db-generate "add phone number to member" --head members_core@head --rev-id members_core_0002
119
+ ```
120
+
121
+ | Argument | Required | Description |
122
+ |---|---|---|
123
+ | `message` (positional) | ✅ | Short description of the change |
124
+ | `--head` | ✅ | Branch to extend, e.g. `members_core@head` |
125
+ | `--rev-id` | ❌ | Custom revision ID (Alembic generates one if omitted) |
126
+ | `--no-autogenerate` | ❌ | Create an empty migration instead of diffing model changes |
127
+
128
+ The generated file will be placed in the module's own `migrations/versions/` directory automatically.
129
+
130
+ ### How branches work
131
+
132
+ Each module declares its own Alembic branch label in its initial migration. This allows multiple modules to coexist in the same database without interfering with each other.
133
+
134
+ For example, the members module uses branch `members_core`:
135
+
136
+ ```
137
+ src/modules/members/migrations/versions/
138
+ ├── members_core_0001_members_core_initial.py # branch_labels = ("members_core",)
139
+ └── members_core_0002_add_phone_number.py # extends members_core@head
140
+ ```
141
+
142
+ A separate module `foo` would have its own branch `foo_core` with revisions in its own directory:
143
+
144
+ ```
145
+ src/modules/foo/migrations/versions/
146
+ ├── foo_core_0001_initial.py # branch_labels = ("foo_core",)
147
+ └── foo_core_0002_add_index.py # extends foo_core@head
148
+ ```
149
+
150
+ When `poe db-migrate` runs, Alembic upgrades all branches to their latest head — both `members_core` and `foo_core` are applied independently.
151
+
152
+ ### Extending a module's schema
153
+
154
+ If you build a custom implementation that extends an existing module's schema, you can create a new branch that depends on a specific revision:
155
+
156
+ ```python
157
+ revision = "members_custom_0001"
158
+ down_revision = None
159
+ branch_labels = ("members_custom",)
160
+ depends_on = "members_core_0001" # ensures the base schema exists first
161
+ ```
162
+
163
+ This guarantees the base tables are created before your extension runs, while keeping both migration chains independent.