cofy-api 0.1.0__tar.gz
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_api-0.1.0/PKG-INFO +163 -0
- cofy_api-0.1.0/README.md +147 -0
- cofy_api-0.1.0/pyproject.toml +76 -0
- cofy_api-0.1.0/setup.cfg +4 -0
- cofy_api-0.1.0/src/cofy/cofy_api.py +43 -0
- cofy_api-0.1.0/src/cofy/db/alembic/env.py +52 -0
- cofy_api-0.1.0/src/cofy/db/base.py +5 -0
- cofy_api-0.1.0/src/cofy/db/cofy_db.py +104 -0
- cofy_api-0.1.0/src/cofy/db/database_backed_source.py +13 -0
- cofy_api-0.1.0/src/cofy/db/timestamp_mixin.py +9 -0
- cofy_api-0.1.0/src/cofy/docs_router.py +37 -0
- cofy_api-0.1.0/src/cofy/token_auth.py +70 -0
- cofy_api-0.1.0/src/cofy/worker.py +187 -0
- cofy_api-0.1.0/src/cofy_api.egg-info/PKG-INFO +163 -0
- cofy_api-0.1.0/src/cofy_api.egg-info/SOURCES.txt +40 -0
- cofy_api-0.1.0/src/cofy_api.egg-info/dependency_links.txt +1 -0
- cofy_api-0.1.0/src/cofy_api.egg-info/requires.txt +7 -0
- cofy_api-0.1.0/src/cofy_api.egg-info/top_level.txt +4 -0
- cofy_api-0.1.0/src/demo/db/generate.py +38 -0
- cofy_api-0.1.0/src/demo/db/migrate.py +12 -0
- cofy_api-0.1.0/src/demo/db/reset.py +12 -0
- cofy_api-0.1.0/src/demo/db/seed.py +24 -0
- cofy_api-0.1.0/src/demo/main.py +49 -0
- cofy_api-0.1.0/src/demo/worker.py +39 -0
- cofy_api-0.1.0/src/modules/members/migrations/537edd2211fa_make_members_email_and_activation_code_.py +32 -0
- cofy_api-0.1.0/src/modules/members/migrations/members_core_0001_members_core_initial.py +41 -0
- cofy_api-0.1.0/src/modules/members/model.py +10 -0
- cofy_api-0.1.0/src/modules/members/models/db_member.py +13 -0
- cofy_api-0.1.0/src/modules/members/module.py +41 -0
- cofy_api-0.1.0/src/modules/members/source.py +22 -0
- cofy_api-0.1.0/src/modules/members/sources/db_source.py +43 -0
- cofy_api-0.1.0/src/modules/members/tasks/sync_from_csv.py +26 -0
- cofy_api-0.1.0/src/modules/tariff/formats/kiwatt.py +54 -0
- cofy_api-0.1.0/src/modules/tariff/module.py +69 -0
- cofy_api-0.1.0/src/modules/tariff/sources/entsoe_day_ahead.py +39 -0
- cofy_api-0.1.0/src/shared/module.py +57 -0
- cofy_api-0.1.0/src/shared/timeseries/format.py +37 -0
- cofy_api-0.1.0/src/shared/timeseries/formats/csv.py +51 -0
- cofy_api-0.1.0/src/shared/timeseries/formats/json.py +47 -0
- cofy_api-0.1.0/src/shared/timeseries/model.py +17 -0
- cofy_api-0.1.0/src/shared/timeseries/module.py +131 -0
- cofy_api-0.1.0/src/shared/timeseries/source.py +10 -0
cofy_api-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
cofy_api-0.1.0/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
**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.
|
|
2
|
+
|
|
3
|
+
Right now this is very much a work in progress.
|
|
4
|
+
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.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
7
|
+
### Install
|
|
8
|
+
*TODO*
|
|
9
|
+
### Configure
|
|
10
|
+
*TODO*
|
|
11
|
+
### Development
|
|
12
|
+
We use [astral](https://docs.astral.sh/) python tooling for our development environment.
|
|
13
|
+
We use [poethepoet](https://poethepoet.natn.io) to define some essential tasks.
|
|
14
|
+
The demo run task is also available as vscode execution task, making it easy to run and debug the demo application from within vscode.
|
|
15
|
+
|
|
16
|
+
#### Install/update dependencies:
|
|
17
|
+
First install [uv](https://docs.astral.sh/uv/) if you don't have it yet.
|
|
18
|
+
|
|
19
|
+
Then install/update dependencies:
|
|
20
|
+
```sh
|
|
21
|
+
uv sync
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Install [poethepoet](https://poethepoet.natn.io) and [pre-commit](https://pre-commit.com/)
|
|
25
|
+
```sh
|
|
26
|
+
uv tool install poethepoet
|
|
27
|
+
uv tool install pre-commit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Activate [pre-commit](https://pre-commit.com/) hooks that enforce code style on every commit:
|
|
31
|
+
```sh
|
|
32
|
+
pre-commit install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
#### Configure environment variables:
|
|
36
|
+
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`.
|
|
37
|
+
|
|
38
|
+
#### Set up the database:
|
|
39
|
+
The demo application uses a SQLite database. A different database can be configured via the environment variables.
|
|
40
|
+
To create the database and seed it with example data:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
poe db-seed
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This will run all pending migrations and load the example CSV data into the database.
|
|
47
|
+
|
|
48
|
+
#### Run development demo application:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
poe demo
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
#### Run background worker:
|
|
55
|
+
We use Cofy Worker to run background jobs that perform data ingestion and processing tasks asynchronously.
|
|
56
|
+
|
|
57
|
+
The background worker requires a Redis instance to run. You can configure the Redis URL via the environment variables.
|
|
58
|
+
The simplest way to run Redis locally is via Docker:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
docker run -p 6379:6379 redis
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then start the worker:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
poe worker
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### Code style checks:
|
|
71
|
+
```sh
|
|
72
|
+
poe lint # Check code style
|
|
73
|
+
poe format # Format code
|
|
74
|
+
poe check # Run type checks
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### Run tests:
|
|
78
|
+
```sh
|
|
79
|
+
poe test
|
|
80
|
+
```
|
|
81
|
+
#### Build & publish
|
|
82
|
+
TODO
|
|
83
|
+
|
|
84
|
+
## Database & Migrations
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
### Commands
|
|
89
|
+
|
|
90
|
+
| Command | Description |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `poe db-seed` | Run all migrations and seed the database with example data |
|
|
93
|
+
| `poe db-migrate` | Run all pending migrations |
|
|
94
|
+
| `poe db-reset` | Drop all tables and re-run migrations (⚠️ destroys all data) |
|
|
95
|
+
| `poe db-generate` | Generate a new migration file for a specific module branch |
|
|
96
|
+
|
|
97
|
+
### Generating a migration
|
|
98
|
+
|
|
99
|
+
When you change a module's SQLAlchemy model, generate a migration for that module's branch:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
poe db-generate "add phone number to member" --head members_core@head --rev-id members_core_0002
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
| Argument | Required | Description |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `message` (positional) | ✅ | Short description of the change |
|
|
108
|
+
| `--head` | ✅ | Branch to extend, e.g. `members_core@head` |
|
|
109
|
+
| `--rev-id` | ❌ | Custom revision ID (Alembic generates one if omitted) |
|
|
110
|
+
| `--no-autogenerate` | ❌ | Create an empty migration instead of diffing model changes |
|
|
111
|
+
|
|
112
|
+
The generated file will be placed in the module's own `migrations/versions/` directory automatically.
|
|
113
|
+
|
|
114
|
+
### How branches work
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
For example, the members module uses branch `members_core`:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
src/modules/members/migrations/versions/
|
|
122
|
+
├── members_core_0001_members_core_initial.py # branch_labels = ("members_core",)
|
|
123
|
+
└── members_core_0002_add_phone_number.py # extends members_core@head
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A separate module `foo` would have its own branch `foo_core` with revisions in its own directory:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
src/modules/foo/migrations/versions/
|
|
130
|
+
├── foo_core_0001_initial.py # branch_labels = ("foo_core",)
|
|
131
|
+
└── foo_core_0002_add_index.py # extends foo_core@head
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
When `poe db-migrate` runs, Alembic upgrades all branches to their latest head — both `members_core` and `foo_core` are applied independently.
|
|
135
|
+
|
|
136
|
+
### Extending a module's schema
|
|
137
|
+
|
|
138
|
+
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:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
revision = "members_custom_0001"
|
|
142
|
+
down_revision = None
|
|
143
|
+
branch_labels = ("members_custom",)
|
|
144
|
+
depends_on = "members_core_0001" # ensures the base schema exists first
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
This guarantees the base tables are created before your extension runs, while keeping both migration chains independent.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cofy-api"
|
|
3
|
+
authors = [
|
|
4
|
+
{ name="EnergyID", email = "info@energieid.be" },
|
|
5
|
+
{ name="Jorg" }
|
|
6
|
+
]
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An open-source modular framework for ingesting, standardising, storing, and computing energy-related data."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"alembic>=1.18.4",
|
|
13
|
+
"entsoe-py>=0.7.10",
|
|
14
|
+
"fastapi[standard]>=0.128.0",
|
|
15
|
+
"narwhals>=2.15.0",
|
|
16
|
+
"pandas>=3.0.0",
|
|
17
|
+
"polars>=1.37.1",
|
|
18
|
+
"saq[redis,web]>=0.26.1",
|
|
19
|
+
]
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest-asyncio>=1.3.0",
|
|
23
|
+
"pytest-cov>=7.0.0",
|
|
24
|
+
"pytest>=9.0.2",
|
|
25
|
+
"ruff>=0.14.13",
|
|
26
|
+
"ty>=0.0.12",
|
|
27
|
+
]
|
|
28
|
+
[tool.poe.tasks]
|
|
29
|
+
lint = "ruff check --fix"
|
|
30
|
+
format = "ruff format"
|
|
31
|
+
check = "ty check"
|
|
32
|
+
[tool.poe.tasks.demo]
|
|
33
|
+
cmd = "fastapi dev src/demo/main.py"
|
|
34
|
+
env = { PYTHONPATH = "." }
|
|
35
|
+
envfile = ".env.local"
|
|
36
|
+
[tool.poe.tasks.db-seed]
|
|
37
|
+
cmd = "python -m src.demo.db.seed"
|
|
38
|
+
envfile = ".env.local"
|
|
39
|
+
env = { PYTHONPATH = "." }
|
|
40
|
+
[tool.poe.tasks.db-reset]
|
|
41
|
+
cmd = "python -m src.demo.db.reset"
|
|
42
|
+
envfile = ".env.local"
|
|
43
|
+
[tool.poe.tasks.db-migrate]
|
|
44
|
+
cmd = "python -m src.demo.db.migrate"
|
|
45
|
+
envfile = ".env.local"
|
|
46
|
+
env = { PYTHONPATH = "." }
|
|
47
|
+
[tool.poe.tasks.db-generate]
|
|
48
|
+
cmd = "python -m src.demo.db.generate"
|
|
49
|
+
envfile = ".env.local"
|
|
50
|
+
env = { PYTHONPATH = "." }
|
|
51
|
+
[tool.poe.tasks.worker]
|
|
52
|
+
cmd = "saq src.demo.worker.settings"
|
|
53
|
+
env = { PYTHONPATH = "." }
|
|
54
|
+
envfile = ".env.local"
|
|
55
|
+
[tool.poe.tasks.test]
|
|
56
|
+
cmd = "pytest --cov=src --cov-report=term-missing --import-mode=importlib"
|
|
57
|
+
env = { PYTHONPATH = "." }
|
|
58
|
+
[tool.coverage.report]
|
|
59
|
+
skip_covered = true
|
|
60
|
+
[tool.coverage.run]
|
|
61
|
+
source = ["src"]
|
|
62
|
+
omit = ["src/demo/*", "src/modules/*/migrations/*", "src/cofy/db/alembic/*"]
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 120
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = [
|
|
67
|
+
"E", # pycodestyle
|
|
68
|
+
"F", # Pyflakes
|
|
69
|
+
"UP", # pyupgrade
|
|
70
|
+
"B", # flake8-bugbear
|
|
71
|
+
"SIM", # flake8-simplify
|
|
72
|
+
"I", # isort
|
|
73
|
+
]
|
|
74
|
+
ignore = [
|
|
75
|
+
"E501", #Line too long
|
|
76
|
+
]
|
cofy_api-0.1.0/setup.cfg
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)
|
|
@@ -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()
|
|
@@ -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())
|
|
@@ -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
|