Skip to content

Commit 1b8a41e

Browse files
authored
feat(service): Add substations service, fix typing (#140)
* feat(service): Add substations service * chore(models): Consolidate Site and SiteProperties * feat(substations): Add substation forecast logic * chore(dp_client): Add logging * fix(dummy): Correct dummyclient * chore(repo): Typing * fix(substations): Optional timezone conversion on forecast * fix(server): Enable data platform connections
1 parent f15a954 commit 1b8a41e

File tree

31 files changed

+1135
-647
lines changed

31 files changed

+1135
-647
lines changed

.github/workflows/branch_ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json
2+
13
name: Branch Push CI (Python)
24

35
on:

.github/workflows/bump_tag.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json
2+
13
name: Merged CI
24
run-name: 'Bump tag with merge #${{ github.event.number }} "${{ github.event.pull_request.title }}"'
35

@@ -9,4 +11,4 @@ on:
911
jobs:
1012
bump-tag:
1113
uses: openclimatefix/.github/.github/workflows/bump_tag.yml@main
12-
secrets: inherit
14+
secrets: inherit

.github/workflows/tagged_ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow
2+
13
name: Tagged CI
24
run-name: 'Tagged CI for ${{ github.ref_name }} by ${{ github.actor }}'
35

@@ -11,4 +13,4 @@ jobs:
1113
secrets: inherit
1214
with:
1315
containerfile: Containerfile
14-
enable_pypi: false
16+
enable_pypi: false

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ dev = [
4848
]
4949

5050
[tool.uv.sources]
51-
dp-sdk = { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.14.0/dp_sdk-0.14.0-py3-none-any.whl" }
51+
dp-sdk = { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.16.0/dp_sdk-0.16.0-py3-none-any.whl" }
5252

5353
[project.urls]
5454
repository = "https://github.com/openclimatefix/quartz-api"

src/quartz_api/cmd/main.py

Lines changed: 83 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,14 @@
2727
```
2828
"""
2929

30+
import functools
31+
import importlib
32+
import importlib.metadata
3033
import logging
3134
import pathlib
3235
import sys
33-
from importlib.metadata import version
36+
from collections.abc import Generator
37+
from contextlib import asynccontextmanager
3438
from typing import Any
3539

3640
import uvicorn
@@ -40,14 +44,13 @@
4044
from fastapi.openapi.utils import get_openapi
4145
from grpclib.client import Channel
4246
from pydantic import BaseModel
43-
from pyhocon import ConfigFactory
47+
from pyhocon import ConfigFactory, ConfigTree
4448
from starlette.responses import FileResponse
4549
from starlette.staticfiles import StaticFiles
4650

51+
from quartz_api.internal import models, service
4752
from quartz_api.internal.backends import DataPlatformClient, DummyClient, QuartzClient
4853
from quartz_api.internal.middleware import audit, auth
49-
from quartz_api.internal.models import DatabaseInterface, get_db_client
50-
from quartz_api.internal.service import regions, sites
5154

5255
log = logging.getLogger(__name__)
5356
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout)
@@ -59,6 +62,7 @@ class GetHealthResponse(BaseModel):
5962

6063
status: int
6164

65+
6266
def _custom_openapi(server: FastAPI) -> dict[str, Any]:
6367
"""Customize the OpenAPI schema for ReDoc."""
6468
if server.openapi_schema:
@@ -87,20 +91,51 @@ def _custom_openapi(server: FastAPI) -> dict[str, Any]:
8791
return openapi_schema
8892

8993

90-
def run() -> None:
91-
"""Run the API using a uvicorn server."""
92-
# Get the application configuration from the environment
93-
conf = ConfigFactory.parse_file((pathlib.Path(__file__).parent / "server.conf").as_posix())
94+
@asynccontextmanager
95+
async def _lifespan(server: FastAPI, conf: ConfigTree) -> Generator[None]:
96+
"""Configure FastAPI app instance with startup and shutdown events."""
97+
db_instance: models.DatabaseInterface | None = None
98+
grpc_channel: Channel | None = None
9499

95-
# Create the FastAPI server
100+
match conf.get_string("backend.source"):
101+
case "quartzdb":
102+
db_instance = QuartzClient(
103+
database_url=conf.get_string("backend.quartzdb.database_url"),
104+
)
105+
case "dummydb":
106+
db_instance = DummyClient()
107+
log.warning("disabled backend. NOT recommended for production")
108+
case "dataplatform":
109+
grpc_channel = Channel(
110+
host=conf.get_string("backend.dataplatform.host"),
111+
port=conf.get_int("backend.dataplatform.port"),
112+
)
113+
client = dp.DataPlatformDataServiceStub(channel=grpc_channel)
114+
db_instance = DataPlatformClient.from_dp(dp_client=client)
115+
case _ as backend_type:
116+
raise ValueError(f"Unknown backend: {backend_type}")
117+
118+
server.dependency_overrides[models.get_db_client] = lambda: db_instance
119+
120+
yield
121+
122+
if grpc_channel:
123+
grpc_channel.close()
124+
125+
126+
def _create_server(conf: ConfigTree) -> FastAPI:
127+
"""Configure FastAPI app instance with routes, dependencies, and middleware."""
96128
server = FastAPI(
97-
version=version("quartz_api"),
129+
version=importlib.metadata.version("quartz_api"),
130+
lifespan=functools.partial(_lifespan, conf=conf),
98131
title="Quartz API",
99132
description=__doc__,
100-
openapi_tags=[{
101-
"name": "API Information",
102-
"description": "Routes providing information about the API.",
103-
}],
133+
openapi_tags=[
134+
{
135+
"name": "API Information",
136+
"description": "Routes providing information about the API.",
137+
},
138+
],
104139
docs_url="/swagger",
105140
redoc_url=None,
106141
)
@@ -126,14 +161,33 @@ def redoc_html() -> FileResponse:
126161
# Setup sentry, if configured
127162
if conf.get_string("sentry.dsn") != "":
128163
import sentry_sdk
164+
129165
sentry_sdk.init(
130166
dsn=conf.get_string("sentry.dsn"),
131167
environment=conf.get_string("sentry.environment"),
132168
traces_sample_rate=1,
133169
)
134170

135-
sentry_sdk.set_tag("app_name", "quartz_api")
136-
sentry_sdk.set_tag("version", version("quartz_api"))
171+
sentry_sdk.set_tag("server_name", "quartz_api")
172+
sentry_sdk.set_tag("version", importlib.metadata.version("quartz_api"))
173+
174+
# Add routers to the server according to configuration
175+
for r in conf.get_string("api.routers").split(","):
176+
try:
177+
mod = importlib.import_module(service.__name__ + f".{r}")
178+
server.include_router(mod.router)
179+
except ModuleNotFoundError as e:
180+
raise OSError(f"No such router router '{r}'") from e
181+
server.openapi_tags = [
182+
{
183+
"name": mod.__name__.split(".")[-1].capitalize(),
184+
"description": mod.__doc__,
185+
},
186+
*server.openapi_tags,
187+
]
188+
189+
# Customize the OpenAPI schema
190+
server.openapi = lambda: _custom_openapi(server)
137191

138192
# Override dependencies according to configuration
139193
match (conf.get_string("auth0.domain"), conf.get_string("auth0.audience")):
@@ -149,30 +203,8 @@ def redoc_html() -> FileResponse:
149203
case _:
150204
raise ValueError("Invalid Auth0 configuration")
151205

152-
db_instance: DatabaseInterface
153-
match conf.get_string("backend.source"):
154-
case "quartzdb":
155-
db_instance = QuartzClient(
156-
database_url=conf.get_string("backend.quartzdb.database_url"),
157-
)
158-
case "dummydb":
159-
db_instance = DummyClient()
160-
log.warning("disabled backend. NOT recommended for production")
161-
case "dataplatform":
162-
163-
channel = Channel(
164-
host=conf.get_string("backend.dataplatform.host"),
165-
port=conf.get_int("backend.dataplatform.port"),
166-
)
167-
client = dp.DataPlatformDataServiceStub(channel=channel)
168-
db_instance = DataPlatformClient.from_dp(dp_client=client)
169-
case _:
170-
raise ValueError(
171-
"Unknown backend. "
172-
f"Expected one of {list(conf.get('backend').keys())}",
173-
)
174-
175-
server.dependency_overrides[get_db_client] = lambda: db_instance
206+
timezone: str = conf.get_string("api.timezone")
207+
server.dependency_overrides[models.get_timezone] = lambda: timezone
176208

177209
# Add middlewares
178210
server.add_middleware(
@@ -182,32 +214,27 @@ def redoc_html() -> FileResponse:
182214
allow_methods=["*"],
183215
allow_headers=["*"],
184216
)
185-
server.add_middleware(
186-
audit.RequestLoggerMiddleware,
187-
db_client=db_instance,
188-
)
217+
server.add_middleware(audit.RequestLoggerMiddleware)
189218

190-
# Add routers to the server according to configuration
191-
for router_module in [sites, regions]:
192-
if conf.get_string("api.router") == router_module.__name__.split(".")[-1]:
193-
server.include_router(router_module.router)
194-
server.openapi_tags = [{
195-
"name": router_module.__name__.split(".")[-1].capitalize(),
196-
"description": router_module.__doc__,
197-
}, *server.openapi_tags]
198-
break
219+
return server
199220

200-
# Customize the OpenAPI schema
201-
server.openapi = lambda: _custom_openapi(server)
221+
222+
def run() -> None:
223+
"""Run the API using a uvicorn server."""
224+
# Get the application configuration from the environment
225+
conf = ConfigFactory.parse_file((pathlib.Path(__file__).parent / "server.conf").as_posix())
226+
227+
server = _create_server(conf=conf)
202228

203229
# Run the server with uvicorn
204230
uvicorn.run(
205231
server,
206-
host="0.0.0.0", # noqa: S104
232+
host="0.0.0.0", # noqa: S104
207233
port=conf.get_int("api.port"),
208234
log_level=conf.get_string("api.loglevel"),
209235
)
210236

211237

212238
if __name__ == "__main__":
213239
run()
240+

src/quartz_api/cmd/server.conf

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@ api {
77
port = ${?PORT}
88
loglevel = "debug"
99
loglevel = ${?LOGLEVEL}
10-
// Which router to serve requests through
11-
router = "none"
12-
router = ${?ROUTER}
10+
// Comma seperated list of routers to enable
11+
// Supported routers: "sites", "regions", "substations"
12+
routers = ""
13+
routers = ${?ROUTERS}
1314
origins = "*"
1415
origins = ${?ORIGINS}
16+
// The IANA timezone string to use for date/time operations
17+
timezone = "UTC"
18+
timezone = ${?TZ}
1519
}
1620

1721
// The backend to use for the service
22+
// Supported backends: "dummydb", "quartzdb", "dataplatform"
1823
backend {
1924
source = "dummydb"
2025
source = ${?SOURCE}
Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1 @@
1-
from .models import (
2-
ActualPower,
3-
PredictedPower,
4-
Site,
5-
SiteProperties,
6-
DBClientDependency,
7-
ForecastHorizon,
8-
DatabaseInterface,
9-
get_db_client
10-
)
1+

0 commit comments

Comments
 (0)