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 +43 -0
- cofy/db/alembic/env.py +52 -0
- cofy/db/base.py +5 -0
- cofy/db/cofy_db.py +104 -0
- cofy/db/database_backed_source.py +13 -0
- cofy/db/timestamp_mixin.py +9 -0
- cofy/docs_router.py +37 -0
- cofy/token_auth.py +70 -0
- cofy/worker.py +187 -0
- cofy_api-0.1.0.dist-info/METADATA +163 -0
- cofy_api-0.1.0.dist-info/RECORD +37 -0
- cofy_api-0.1.0.dist-info/WHEEL +5 -0
- cofy_api-0.1.0.dist-info/top_level.txt +4 -0
- demo/db/generate.py +38 -0
- demo/db/migrate.py +12 -0
- demo/db/reset.py +12 -0
- demo/db/seed.py +24 -0
- demo/main.py +49 -0
- demo/worker.py +39 -0
- modules/members/migrations/537edd2211fa_make_members_email_and_activation_code_.py +32 -0
- modules/members/migrations/members_core_0001_members_core_initial.py +41 -0
- modules/members/model.py +10 -0
- modules/members/models/db_member.py +13 -0
- modules/members/module.py +41 -0
- modules/members/source.py +22 -0
- modules/members/sources/db_source.py +43 -0
- modules/members/tasks/sync_from_csv.py +26 -0
- modules/tariff/formats/kiwatt.py +54 -0
- modules/tariff/module.py +69 -0
- modules/tariff/sources/entsoe_day_ahead.py +39 -0
- shared/module.py +57 -0
- shared/timeseries/format.py +37 -0
- shared/timeseries/formats/csv.py +51 -0
- shared/timeseries/formats/json.py +47 -0
- shared/timeseries/model.py +17 -0
- shared/timeseries/module.py +131 -0
- shared/timeseries/source.py +10 -0
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
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.
|