coasti-planner 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.
Files changed (28) hide show
  1. coasti_planner-0.1.0/.gitignore +31 -0
  2. coasti_planner-0.1.0/.python-version +1 -0
  3. coasti_planner-0.1.0/PKG-INFO +129 -0
  4. coasti_planner-0.1.0/README.md +115 -0
  5. coasti_planner-0.1.0/pyproject.toml +128 -0
  6. coasti_planner-0.1.0/src/coasti_planner/__init__.py +0 -0
  7. coasti_planner-0.1.0/src/coasti_planner/__main__.py +54 -0
  8. coasti_planner-0.1.0/src/coasti_planner/config.py +80 -0
  9. coasti_planner-0.1.0/src/coasti_planner/database.py +23 -0
  10. coasti_planner-0.1.0/src/coasti_planner/dependencies.py +27 -0
  11. coasti_planner-0.1.0/src/coasti_planner/exceptions.py +25 -0
  12. coasti_planner-0.1.0/src/coasti_planner/models.py +102 -0
  13. coasti_planner-0.1.0/src/coasti_planner/routes/__init__.py +1 -0
  14. coasti_planner-0.1.0/src/coasti_planner/routes/api_v1/__init__.py +1 -0
  15. coasti_planner-0.1.0/src/coasti_planner/routes/api_v1/dummy.py +33 -0
  16. coasti_planner-0.1.0/src/coasti_planner/routes/api_v1/extension.py +29 -0
  17. coasti_planner-0.1.0/src/coasti_planner/routes/api_v1/filter.py +21 -0
  18. coasti_planner-0.1.0/src/coasti_planner/routes/api_v1/log.py +36 -0
  19. coasti_planner-0.1.0/src/coasti_planner/routes/api_v1/router.py +18 -0
  20. coasti_planner-0.1.0/src/coasti_planner/routes/api_v1/table.py +40 -0
  21. coasti_planner-0.1.0/src/coasti_planner/routes/framework.py +66 -0
  22. coasti_planner-0.1.0/src/coasti_planner/server.py +35 -0
  23. coasti_planner-0.1.0/src/coasti_planner/sql_io.py +201 -0
  24. coasti_planner-0.1.0/tests/conftest.py +43 -0
  25. coasti_planner-0.1.0/tests/test_main.py +52 -0
  26. coasti_planner-0.1.0/tests/test_server.py +214 -0
  27. coasti_planner-0.1.0/tests/test_sql_io.py +103 -0
  28. coasti_planner-0.1.0/uv.lock +1182 -0
@@ -0,0 +1,31 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ *.egg-info/
6
+ .mypy_cache/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ htmlcov/
11
+ coverage.xml
12
+ dist/
13
+ build/
14
+
15
+ # Environment / secrets
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+
20
+ # Node (frontend)
21
+ node_modules/
22
+
23
+ # Nix / direnv
24
+ .direnv/
25
+
26
+ # OS / editor
27
+ .DS_Store
28
+
29
+ # Module Federation
30
+ @mf-types
31
+ .mf
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.5
2
+ Name: coasti-planner
3
+ Version: 0.1.0
4
+ Summary: Backend service for the Coasti Planning Framework.
5
+ Author-email: "Sebastian B. Mohr" <sebastian@mohrenclan.de>, "F. Paul Spitzner" <paul.spitzner@linkfish.eu>
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: eyconf[pydantic]>=0.8.0
8
+ Requires-Dist: fastapi>=0.115.0
9
+ Requires-Dist: ruamel-yaml>=0.19.1
10
+ Requires-Dist: sqlalchemy>=2.0.0
11
+ Requires-Dist: typer>=0.27.3
12
+ Requires-Dist: uvicorn[standard]>=0.34.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Coasti Planning Framework — Backend
16
+
17
+ The backend is a Python 3.13 FastAPI application managed with
18
+ [uv](https://docs.astral.sh/uv/). It serves the framework and content-package
19
+ assets from local Vite build directories.
20
+
21
+ ## Setup
22
+
23
+ Run this command from the repository root:
24
+
25
+ ```bash
26
+ uv sync --directory backend --locked
27
+ ```
28
+
29
+ The repository root also provides a Nix/direnv development shell that installs
30
+ the required tools automatically. See the root [README](../README.md).
31
+
32
+ ## Build the frontend distributions
33
+
34
+ The backend does not build frontend assets itself. Build the framework and the
35
+ example extension before running the backend in local distribution mode.
36
+
37
+ ### Framework distribution
38
+
39
+ Install the frontend workspace dependencies and build the planner framework:
40
+
41
+ ```bash
42
+ pnpm --dir frontend install --frozen-lockfile
43
+ pnpm --dir frontend build
44
+ ```
45
+
46
+ This creates the framework distribution in `frontend/planner/dist`.
47
+
48
+ ### Example extension distribution
49
+
50
+ Install the extension dependencies and build the example content package:
51
+
52
+ ```bash
53
+ pnpm --dir cp_example_extension install --frozen-lockfile
54
+ pnpm --dir cp_example_extension build
55
+ ```
56
+
57
+ This creates the extension distribution in `cp_example_extension/dist`. The
58
+ build includes the generated `manifest.json` and Module Federation entry point.
59
+
60
+ ## Configuration
61
+
62
+ The server accepts a `server` command and an optional YAML configuration path.
63
+ The legacy form with only the configuration path remains supported.
64
+
65
+ The example configuration is available at
66
+ [config.example.yml](../cp_example_extension/config.example.yml):
67
+
68
+ ```yaml
69
+ backend:
70
+ host: 127.0.0.1
71
+ port: 8000
72
+ framework:
73
+ dist_folder: ../frontend/planner/dist
74
+ extension:
75
+ dist_folder: ./dist
76
+ ```
77
+
78
+ Configuration is loaded by [EYConf](https://eyconf.readthedocs.io/) using its
79
+ Pydantic validation backend. The configuration is represented by three pairs of
80
+ schema/runtime types in `coasti_planner.config`:
81
+
82
+ - `BackendConfigSchema` / `BackendConfig` define the listening host and port.
83
+ - `AssetSourceSchema` / `AssetSource` define a local distribution or remote
84
+ development server.
85
+ - `ServerConfigSchema` / `ServerConfig` combine the backend, framework, and
86
+ extension settings.
87
+
88
+ Each asset source defines a local `dist_folder`. Relative paths are resolved
89
+ relative to the configuration file, not relative to the current working
90
+ directory.
91
+
92
+ ## Run the backend
93
+
94
+ From the repository root, after building both distributions:
95
+
96
+ ```bash
97
+ uv run --directory backend coasti-planner ../cp_example_extension/config.example.yml
98
+ ```
99
+
100
+ ### Run the full stack in development mode
101
+
102
+ Development mode does not require either frontend distribution directory. Run
103
+ the backend from the backend project directory:
104
+
105
+ ```bash
106
+ cd backend
107
+ uv run backend server --dev
108
+ ```
109
+
110
+ The backend uses Uvicorn autoreload and the example configuration by default.
111
+ Run the Vite development servers in separate terminals:
112
+
113
+ ```bash
114
+ pnpm --dir frontend dev
115
+ pnpm --dir cp_example_extension dev
116
+ ```
117
+
118
+ The frontend development server proxies API requests to the autoreloading
119
+ backend, while the frontend and extension servers reload their own source files.
120
+
121
+ ## HTTP endpoints
122
+
123
+ - `/` serves the framework `index.html` and its root-relative assets.
124
+ - `/api_v1/framework/{path}` serves framework distribution files.
125
+ - `/api_v1/extension/{path}` serves extension distribution files.
126
+ - `/api_v1/extension/manifest.json` serves the extension's generated manifest.
127
+
128
+ The manifest is read from the extension distribution. It is not duplicated in
129
+ the backend.
@@ -0,0 +1,115 @@
1
+ # Coasti Planning Framework — Backend
2
+
3
+ The backend is a Python 3.13 FastAPI application managed with
4
+ [uv](https://docs.astral.sh/uv/). It serves the framework and content-package
5
+ assets from local Vite build directories.
6
+
7
+ ## Setup
8
+
9
+ Run this command from the repository root:
10
+
11
+ ```bash
12
+ uv sync --directory backend --locked
13
+ ```
14
+
15
+ The repository root also provides a Nix/direnv development shell that installs
16
+ the required tools automatically. See the root [README](../README.md).
17
+
18
+ ## Build the frontend distributions
19
+
20
+ The backend does not build frontend assets itself. Build the framework and the
21
+ example extension before running the backend in local distribution mode.
22
+
23
+ ### Framework distribution
24
+
25
+ Install the frontend workspace dependencies and build the planner framework:
26
+
27
+ ```bash
28
+ pnpm --dir frontend install --frozen-lockfile
29
+ pnpm --dir frontend build
30
+ ```
31
+
32
+ This creates the framework distribution in `frontend/planner/dist`.
33
+
34
+ ### Example extension distribution
35
+
36
+ Install the extension dependencies and build the example content package:
37
+
38
+ ```bash
39
+ pnpm --dir cp_example_extension install --frozen-lockfile
40
+ pnpm --dir cp_example_extension build
41
+ ```
42
+
43
+ This creates the extension distribution in `cp_example_extension/dist`. The
44
+ build includes the generated `manifest.json` and Module Federation entry point.
45
+
46
+ ## Configuration
47
+
48
+ The server accepts a `server` command and an optional YAML configuration path.
49
+ The legacy form with only the configuration path remains supported.
50
+
51
+ The example configuration is available at
52
+ [config.example.yml](../cp_example_extension/config.example.yml):
53
+
54
+ ```yaml
55
+ backend:
56
+ host: 127.0.0.1
57
+ port: 8000
58
+ framework:
59
+ dist_folder: ../frontend/planner/dist
60
+ extension:
61
+ dist_folder: ./dist
62
+ ```
63
+
64
+ Configuration is loaded by [EYConf](https://eyconf.readthedocs.io/) using its
65
+ Pydantic validation backend. The configuration is represented by three pairs of
66
+ schema/runtime types in `coasti_planner.config`:
67
+
68
+ - `BackendConfigSchema` / `BackendConfig` define the listening host and port.
69
+ - `AssetSourceSchema` / `AssetSource` define a local distribution or remote
70
+ development server.
71
+ - `ServerConfigSchema` / `ServerConfig` combine the backend, framework, and
72
+ extension settings.
73
+
74
+ Each asset source defines a local `dist_folder`. Relative paths are resolved
75
+ relative to the configuration file, not relative to the current working
76
+ directory.
77
+
78
+ ## Run the backend
79
+
80
+ From the repository root, after building both distributions:
81
+
82
+ ```bash
83
+ uv run --directory backend coasti-planner ../cp_example_extension/config.example.yml
84
+ ```
85
+
86
+ ### Run the full stack in development mode
87
+
88
+ Development mode does not require either frontend distribution directory. Run
89
+ the backend from the backend project directory:
90
+
91
+ ```bash
92
+ cd backend
93
+ uv run backend server --dev
94
+ ```
95
+
96
+ The backend uses Uvicorn autoreload and the example configuration by default.
97
+ Run the Vite development servers in separate terminals:
98
+
99
+ ```bash
100
+ pnpm --dir frontend dev
101
+ pnpm --dir cp_example_extension dev
102
+ ```
103
+
104
+ The frontend development server proxies API requests to the autoreloading
105
+ backend, while the frontend and extension servers reload their own source files.
106
+
107
+ ## HTTP endpoints
108
+
109
+ - `/` serves the framework `index.html` and its root-relative assets.
110
+ - `/api_v1/framework/{path}` serves framework distribution files.
111
+ - `/api_v1/extension/{path}` serves extension distribution files.
112
+ - `/api_v1/extension/manifest.json` serves the extension's generated manifest.
113
+
114
+ The manifest is read from the extension distribution. It is not duplicated in
115
+ the backend.
@@ -0,0 +1,128 @@
1
+ [project]
2
+ name = "coasti-planner"
3
+ version = "0.1.0"
4
+ description = "Backend service for the Coasti Planning Framework."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Sebastian B. Mohr", email = "sebastian@mohrenclan.de" },
8
+ { name = "F. Paul Spitzner", email = "paul.spitzner@linkfish.eu" },
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "eyconf[pydantic]>=0.8.0",
13
+ "fastapi>=0.115.0",
14
+ "ruamel-yaml>=0.19.1",
15
+ "sqlalchemy>=2.0.0",
16
+ "typer>=0.27.3",
17
+ "uvicorn[standard]>=0.34.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ coasti-planner = "coasti_planner.__main__:app"
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/coasti_planner"]
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ { include-group = "lint" },
33
+ { include-group = "test" },
34
+ { include-group = "typed" },
35
+ "pre-commit>=4.5.1",
36
+ ]
37
+ lint = [
38
+ "ruff"
39
+ ]
40
+ typed = [
41
+ "mypy>=1.19.1",
42
+ ]
43
+ test = [
44
+ "pytest>=9.0.2",
45
+ "pytest-cov>=7.1.0",
46
+ ]
47
+
48
+ [tool.ruff]
49
+ target-version = "py313"
50
+ src = ["src", "tests"]
51
+
52
+ [tool.ruff.lint]
53
+ future-annotations = true
54
+ select = [
55
+ "A", # flake8-builtins
56
+ "ANN", # flake8-annotations
57
+ "B", # flake8-bugbear
58
+ "C4", # flake8-comprehensions
59
+ "E", # pycodestyle
60
+ "F", # pyflakes
61
+ "I", # isort
62
+ "ISC", # flake8-implicit-str-concat
63
+ "N", # pep8-naming
64
+ "PIE", # flake8-pie
65
+ "PT", # flake8-pytest-style
66
+ "RUF", # ruff
67
+ "RET", # flake8-return
68
+ "SIM", # flake8-simplify
69
+ "UP", # pyupgrade
70
+ "TC", # flake8-type-checking
71
+ "PTH", # Use pathlib instead of os.path
72
+ "D", # pydocstyle
73
+ "W", # pycodestyle
74
+ ]
75
+ ignore = [
76
+ # Docstrings are not mandatory for public functions.
77
+ "D10",
78
+ "D202",
79
+ "TC006", # no need to quote 'cast's since we use 'from __future__ import annotations'
80
+ ]
81
+ fixable = ["ALL"]
82
+
83
+ [tool.ruff.lint.flake8-type-checking]
84
+ # FastAPI route decorators evaluate annotations at import time, so dependency
85
+ # imports used in endpoints must remain runtime imports.
86
+ runtime-evaluated-decorators = [
87
+ "fastapi.APIRouter.delete",
88
+ "fastapi.APIRouter.get",
89
+ "fastapi.APIRouter.head",
90
+ "fastapi.APIRouter.options",
91
+ "fastapi.APIRouter.patch",
92
+ "fastapi.APIRouter.post",
93
+ "fastapi.APIRouter.put",
94
+ "fastapi.APIRouter.trace",
95
+ ]
96
+
97
+ [tool.ruff.lint.per-file-ignores]
98
+ "tests/**" = [
99
+ "ANN", # full annotations on tests add noise; mypy still checks them
100
+ "D",
101
+ ]
102
+
103
+ [tool.ruff.lint.isort]
104
+ known-first-party = ["coasti_planner"]
105
+
106
+ [tool.mypy]
107
+ files = ["src", "tests"]
108
+ python_version = "3.13"
109
+ mypy_path = ["src"]
110
+ warn_unreachable = true
111
+ strict = true
112
+ check_untyped_defs = true
113
+ disallow_untyped_decorators = true
114
+ allow_redefinition = true
115
+ warn_unused_configs = true
116
+
117
+ [tool.pytest.ini_options]
118
+ testpaths = ["tests"]
119
+ addopts = "-ra --strict-markers --strict-config"
120
+
121
+ [tool.coverage.run]
122
+ source = ["coasti_planner"]
123
+ omit = ["*/__main__.py"]
124
+ branch = true
125
+
126
+ [tool.coverage.report]
127
+ fail_under = 0
128
+ show_missing = true
File without changes
@@ -0,0 +1,54 @@
1
+ """Coasti Planner backend package and CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ import uvicorn
11
+
12
+ from coasti_planner.config import Config
13
+
14
+ app = typer.Typer(help="Run the Coasti Planner backend.")
15
+
16
+
17
+ @app.command()
18
+ def server(
19
+ config_file: Annotated[
20
+ Path | None,
21
+ typer.Argument(help="Path to the YAML config file"),
22
+ ] = None,
23
+ dev: Annotated[
24
+ bool,
25
+ typer.Option(
26
+ help="Run with Uvicorn autoreload and without distribution assets",
27
+ ),
28
+ ] = False,
29
+ ) -> None:
30
+ """Start the backend server."""
31
+ config_file = config_file or Path.cwd() / "config.yml"
32
+ if not config_file.is_file():
33
+ raise FileNotFoundError(f"Configuration file does not exist: {config_file}")
34
+
35
+ # We just validate the config here to raise early
36
+ resolved_config_file = config_file.resolve()
37
+
38
+ # setting the env var propagates the config file to the server init in server.py
39
+ os.environ["EYCONF_CONFIG_FILE"] = str(resolved_config_file)
40
+ config = Config()
41
+
42
+ if dev:
43
+ print("Running uvicorn in dev mode with live reloading")
44
+
45
+ uvicorn.run(
46
+ "coasti_planner.server:app",
47
+ host=config.backend.host,
48
+ port=config.backend.port,
49
+ reload=dev,
50
+ )
51
+
52
+
53
+ if __name__ == "__main__":
54
+ app()
@@ -0,0 +1,80 @@
1
+ """Runtime configuration schema and loading for the backend."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from eyconf import EYConf
7
+ from eyconf.validation.backends.pydantic import PydanticValidator
8
+ from sqlalchemy.engine import make_url
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class BackendConfig:
13
+ host: str = "127.0.0.1"
14
+ port: int = 8000
15
+
16
+
17
+ @dataclass
18
+ class DatabaseConfig:
19
+ connection_string: str = "sqlite:///:memory:"
20
+
21
+
22
+ @dataclass
23
+ class AssetSource:
24
+ """Configuration for a local build or a running development server."""
25
+
26
+ dist_folder: Path = field(
27
+ default_factory=lambda: Path(__file__).parent.parent / "dist"
28
+ )
29
+ sql_folder: Path = field(default_factory=lambda: Path("sql"))
30
+
31
+
32
+ @dataclass
33
+ class ServerConfigSchema:
34
+ """Configuration for the framework and content-package assets."""
35
+
36
+ backend: BackendConfig = field(default_factory=BackendConfig)
37
+ database: DatabaseConfig = field(default_factory=DatabaseConfig)
38
+ framework: AssetSource = field(default_factory=AssetSource)
39
+ extension: AssetSource = field(default_factory=AssetSource)
40
+
41
+
42
+ class Config(EYConf[ServerConfigSchema]):
43
+ """Configuration for the Coasti Planner backend."""
44
+
45
+ def __init__(self) -> None:
46
+ super().__init__(ServerConfigSchema, validator=PydanticValidator())
47
+
48
+ # absolute paths for dist bundles
49
+ for source in (self.data.framework, self.data.extension):
50
+ if not source.dist_folder.is_absolute():
51
+ source.dist_folder = (
52
+ self.get_file().parent / source.dist_folder
53
+ ).resolve()
54
+ if not source.sql_folder.is_absolute():
55
+ source.sql_folder = (
56
+ self.get_file().parent / source.sql_folder
57
+ ).resolve()
58
+
59
+ # abs path for sqlite database
60
+ database_url = make_url(self.data.database.connection_string)
61
+ if database_url.drivername == "sqlite" and database_url.database:
62
+ database_path = Path(database_url.database)
63
+ if not database_path.is_absolute() and database_path.name != ":memory:":
64
+ self.data.database.connection_string = str(
65
+ database_url.set(
66
+ database=str(self.get_file().parent / database_path)
67
+ )
68
+ )
69
+
70
+ @property
71
+ def framework(self) -> AssetSource:
72
+ return self.data.framework
73
+
74
+ @property
75
+ def extension(self) -> AssetSource:
76
+ return self.data.extension
77
+
78
+ @property
79
+ def backend(self) -> BackendConfig:
80
+ return self.data.backend
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy import create_engine, text
6
+
7
+
8
+ class Database:
9
+ """Execute SQL against the configured database."""
10
+
11
+ def __init__(self, connection_string: str) -> None:
12
+ self._engine = create_engine(connection_string)
13
+
14
+ def execute_query(
15
+ self,
16
+ query: str,
17
+ parameters: dict[str, Any] | None = None,
18
+ ) -> tuple[list[str], list[dict[str, Any]]]:
19
+ with self._engine.connect() as connection:
20
+ result = connection.execute(text(query), parameters or {})
21
+ columns = list(result.keys())
22
+ rows = [dict(row) for row in result.mappings()]
23
+ return columns, rows
@@ -0,0 +1,27 @@
1
+ """Dependencies shared by the FastAPI route handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, cast
6
+
7
+ from fastapi import Depends, Request
8
+
9
+ from coasti_planner.config import Config
10
+ from coasti_planner.database import Database
11
+ from coasti_planner.sql_io import SQLIO
12
+
13
+
14
+ def get_config(request: Request) -> Config:
15
+ """Return the configuration stored on the application state."""
16
+ return cast("Config", request.app.state.config)
17
+
18
+
19
+ def get_sqlio(request: Request, config: ConfigDep) -> SQLIO:
20
+ """Load and return the SQL interaction layer."""
21
+
22
+ database = Database(config.data.database.connection_string)
23
+ return SQLIO(config.data.extension.sql_folder, database)
24
+
25
+
26
+ ConfigDep = Annotated[Config, Depends(get_config)]
27
+ SqlioDep = Annotated[SQLIO, Depends(get_sqlio)]
@@ -0,0 +1,25 @@
1
+ """Serialization of backend exceptions for the frontend error format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import traceback
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class SerializedException(BaseModel):
11
+ type: str
12
+ message: str
13
+ description: str | None
14
+ trace: str | None
15
+
16
+
17
+ def to_serialized_exception(exception: Exception) -> SerializedException:
18
+ """Convert a backend exception into the frontend error format."""
19
+ trace = "".join(traceback.format_exception(exception))
20
+ return SerializedException(
21
+ type=type(exception).__name__,
22
+ message=str(exception),
23
+ description=exception.__doc__,
24
+ trace=trace,
25
+ )