wiltech-labs-core 1.0.1__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.
- wiltech_labs_core-1.0.1/.gitignore +220 -0
- wiltech_labs_core-1.0.1/PKG-INFO +156 -0
- wiltech_labs_core-1.0.1/README.md +136 -0
- wiltech_labs_core-1.0.1/pyproject.toml +48 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/__init__.py +59 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/config/__init__.py +3 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/config/config.py +60 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/database/__init__.py +19 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/database/database.py +244 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/errors/__init__.py +23 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/errors/errors.py +66 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/py.typed +0 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/web/__init__.py +4 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/web/cors.py +103 -0
- wiltech_labs_core-1.0.1/src/wiltech_labs_core/web/health.py +54 -0
- wiltech_labs_core-1.0.1/tests/test_config_and_database.py +139 -0
- wiltech_labs_core-1.0.1/tests/test_web.py +87 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
/NEXT_STEPS11111.md
|
|
220
|
+
.idea/*
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: wiltech-labs-core
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Runtime plumbing for Wiltech FastAPI apps: config from Worker env or .env, a portable Database (SQLite, Cloudflare D1 binding, D1 over HTTP), errors, CORS and health.
|
|
5
|
+
Project-URL: Homepage, https://github.com/wilferraciolli/py-libraries/tree/main/packages/core
|
|
6
|
+
Project-URL: Repository, https://github.com/wilferraciolli/py-libraries
|
|
7
|
+
Project-URL: Conventions, https://github.com/wilferraciolli/py-libraries/blob/main/docs/PYTHON_APP_CONVENTIONS.md
|
|
8
|
+
Author: Wiliam Ferraciolli
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: cloudflare-workers,config,d1,fastapi,sqlite
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Requires-Dist: fastapi>=0.110
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Provides-Extra: sqlite
|
|
18
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'sqlite'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# wiltech-labs-core
|
|
22
|
+
|
|
23
|
+
The runtime plumbing every Wiltech FastAPI app copies into `core/`: config that reads the
|
|
24
|
+
Cloudflare Worker env or `.env`, the portable `Database` protocol (SQLite locally, D1 in a
|
|
25
|
+
Worker, D1 over HTTP anywhere else), the `AppError` family, per-request CORS and a health
|
|
26
|
+
router. See
|
|
27
|
+
[`docs/PYTHON_APP_CONVENTIONS.md`](https://github.com/wilferraciolli/py-libraries/blob/main/docs/PYTHON_APP_CONVENTIONS.md).
|
|
28
|
+
|
|
29
|
+
Pure Python. `aiosqlite` is an extra, so a Cloudflare Worker (D1 only) installs nothing it
|
|
30
|
+
can't load.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv add "wiltech-labs-core[sqlite]" # apps that run locally / in Docker on SQLite (all of them)
|
|
36
|
+
uv add wiltech-labs-core # a Worker-only service that never touches SQLite
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# main.py
|
|
43
|
+
from fastapi import FastAPI
|
|
44
|
+
|
|
45
|
+
from wiltech_labs_core import EnvCORSMiddleware, database_check, health_router, register_error_handlers
|
|
46
|
+
|
|
47
|
+
app = FastAPI(title="My API")
|
|
48
|
+
register_error_handlers(app) # AppError subclasses -> {"detail": ...} + status
|
|
49
|
+
app.add_middleware(EnvCORSMiddleware) # CORS_ORIGINS read per request (works in Workers)
|
|
50
|
+
app.include_router(health_router({"db": database_check}), prefix="/api")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# todos/todo_router.py
|
|
55
|
+
from fastapi import Depends, Request
|
|
56
|
+
|
|
57
|
+
from wiltech_labs_core import Database, get_database
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_todo_service(request: Request) -> TodoService:
|
|
61
|
+
return TodoService(TodoRepository(get_database(request)))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# todos/todo_service.py
|
|
66
|
+
from wiltech_labs_core import NotFoundError
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def get(self, todo_id: str) -> TodoDTO:
|
|
70
|
+
todo = await self.repository.find(todo_id)
|
|
71
|
+
if todo is None:
|
|
72
|
+
raise NotFoundError("Todo not found") # -> 404 {"detail": "Todo not found"}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# a write that must change two tables together (D1 has no interactive transactions)
|
|
77
|
+
await db.batch([
|
|
78
|
+
("UPDATE users SET role_ids = ? WHERE id = ?", (roles, user_id)),
|
|
79
|
+
("INSERT INTO audit_events (id, action, resource_id) VALUES (?, ?, ?)", (event_id, "user.roles", user_id)),
|
|
80
|
+
])
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
| Variable | Used by | Default |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `DATABASE_MODE` | `get_database` | `d1_binding` if `env.DB` exists, else `sqlite` |
|
|
88
|
+
| `DATABASE_PATH` | sqlite | `./local.db` |
|
|
89
|
+
| `MIGRATIONS_DIR` | sqlite migrations | `./migrations` (relative to the working directory) |
|
|
90
|
+
| `CF_ACCOUNT_ID`, `CF_D1_DATABASE_ID`, `CF_D1_API_TOKEN` | d1_http | — |
|
|
91
|
+
| `CORS_ORIGINS` | `EnvCORSMiddleware` | none (comma-separated) |
|
|
92
|
+
|
|
93
|
+
Values come from the Worker env (`wrangler.jsonc` vars and secrets) first, then the process
|
|
94
|
+
environment. `.env` in the working directory is loaded into the process environment on
|
|
95
|
+
import, without overriding anything already set.
|
|
96
|
+
|
|
97
|
+
## Public API
|
|
98
|
+
|
|
99
|
+
| Name | What it is |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `get_config(request, key, default=None)` | Config from the Worker env, else `os.environ` |
|
|
102
|
+
| `get_binding(request, name)` | A Cloudflare binding (`env.DB`, `env.AI`, `env.BUCKET`…) or `None` outside a Worker |
|
|
103
|
+
| `load_dotenv(path=None)` | Load `.env` (runs once on import) |
|
|
104
|
+
| `Database` | Protocol: `fetch_all`, `fetch_one`, `execute`, `batch` |
|
|
105
|
+
| `SQLiteDatabase`, `D1BindingDatabase`, `D1HttpDatabase` | The three adapters |
|
|
106
|
+
| `get_database(request)` | Chooses an adapter from `DATABASE_MODE` / bindings |
|
|
107
|
+
| `Statement` | `tuple[str, tuple]`, one item of `batch` |
|
|
108
|
+
| `migrations_dir()` | The migrations folder, or a `RuntimeError` if it's missing |
|
|
109
|
+
| `AppError`, `NotFoundError` (404), `ForbiddenError` (403), `ConflictError` (409), `InvalidInputError` (422), `TooManyRequestsError` (429 + `Retry-After`), `UpstreamError` (502), `NotConfiguredError` (503) | Business errors services raise |
|
|
110
|
+
| `register_error_handlers(app)` | Maps `AppError` to its status code |
|
|
111
|
+
| `EnvCORSMiddleware(app, default_origins="")`, `allowed_origins(scope, default="")` | CORS with origins read per request (fallback `default_origins`); unhandled errors still get CORS headers |
|
|
112
|
+
| `health_router(checks=None, prefix="/health")`, `HealthCheck`, `database_check` | `GET /health`; 503 if any check fails |
|
|
113
|
+
|
|
114
|
+
### `batch` atomicity
|
|
115
|
+
|
|
116
|
+
| Adapter | Atomic? |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `SQLiteDatabase` | Yes, one transaction |
|
|
119
|
+
| `D1BindingDatabase` | Yes, D1 `batch()` |
|
|
120
|
+
| `D1HttpDatabase` | **No**: one request per statement, in order |
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv run --package wiltech-labs-core pytest packages/core
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Publishing
|
|
129
|
+
|
|
130
|
+
Publish **before** `wiltech-labs-auth` and `wiltech-labs-ai`, which depend on it.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
cd packages/core
|
|
134
|
+
|
|
135
|
+
# 1. Test, bump, build
|
|
136
|
+
uv run pytest
|
|
137
|
+
uv version --bump patch # or minor / major; skip for the first 0.1.0
|
|
138
|
+
rm -rf ../../dist && uv build # -> ../../dist/
|
|
139
|
+
|
|
140
|
+
# 2. TestPyPI (optional rehearsal), using a token from test.pypi.org
|
|
141
|
+
read -rsp "TestPyPI token: " UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && echo
|
|
142
|
+
uv publish --index testpypi ../../dist/*
|
|
143
|
+
unset UV_PUBLISH_TOKEN
|
|
144
|
+
|
|
145
|
+
# 3. PyPI (the real thing), using a token from pypi.org
|
|
146
|
+
read -rsp "PyPI token: " UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && echo
|
|
147
|
+
uv publish ../../dist/*
|
|
148
|
+
unset UV_PUBLISH_TOKEN
|
|
149
|
+
|
|
150
|
+
# 4. Commit and tag
|
|
151
|
+
git commit -am "wiltech-labs-core <version>"
|
|
152
|
+
git tag core-v<version> && git push && git push --tags
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A version number can only be uploaded once to each site. To retry after a fix, bump the
|
|
156
|
+
version and rebuild.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# wiltech-labs-core
|
|
2
|
+
|
|
3
|
+
The runtime plumbing every Wiltech FastAPI app copies into `core/`: config that reads the
|
|
4
|
+
Cloudflare Worker env or `.env`, the portable `Database` protocol (SQLite locally, D1 in a
|
|
5
|
+
Worker, D1 over HTTP anywhere else), the `AppError` family, per-request CORS and a health
|
|
6
|
+
router. See
|
|
7
|
+
[`docs/PYTHON_APP_CONVENTIONS.md`](https://github.com/wilferraciolli/py-libraries/blob/main/docs/PYTHON_APP_CONVENTIONS.md).
|
|
8
|
+
|
|
9
|
+
Pure Python. `aiosqlite` is an extra, so a Cloudflare Worker (D1 only) installs nothing it
|
|
10
|
+
can't load.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv add "wiltech-labs-core[sqlite]" # apps that run locally / in Docker on SQLite (all of them)
|
|
16
|
+
uv add wiltech-labs-core # a Worker-only service that never touches SQLite
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
# main.py
|
|
23
|
+
from fastapi import FastAPI
|
|
24
|
+
|
|
25
|
+
from wiltech_labs_core import EnvCORSMiddleware, database_check, health_router, register_error_handlers
|
|
26
|
+
|
|
27
|
+
app = FastAPI(title="My API")
|
|
28
|
+
register_error_handlers(app) # AppError subclasses -> {"detail": ...} + status
|
|
29
|
+
app.add_middleware(EnvCORSMiddleware) # CORS_ORIGINS read per request (works in Workers)
|
|
30
|
+
app.include_router(health_router({"db": database_check}), prefix="/api")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
# todos/todo_router.py
|
|
35
|
+
from fastapi import Depends, Request
|
|
36
|
+
|
|
37
|
+
from wiltech_labs_core import Database, get_database
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_todo_service(request: Request) -> TodoService:
|
|
41
|
+
return TodoService(TodoRepository(get_database(request)))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# todos/todo_service.py
|
|
46
|
+
from wiltech_labs_core import NotFoundError
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def get(self, todo_id: str) -> TodoDTO:
|
|
50
|
+
todo = await self.repository.find(todo_id)
|
|
51
|
+
if todo is None:
|
|
52
|
+
raise NotFoundError("Todo not found") # -> 404 {"detail": "Todo not found"}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# a write that must change two tables together (D1 has no interactive transactions)
|
|
57
|
+
await db.batch([
|
|
58
|
+
("UPDATE users SET role_ids = ? WHERE id = ?", (roles, user_id)),
|
|
59
|
+
("INSERT INTO audit_events (id, action, resource_id) VALUES (?, ?, ?)", (event_id, "user.roles", user_id)),
|
|
60
|
+
])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
| Variable | Used by | Default |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `DATABASE_MODE` | `get_database` | `d1_binding` if `env.DB` exists, else `sqlite` |
|
|
68
|
+
| `DATABASE_PATH` | sqlite | `./local.db` |
|
|
69
|
+
| `MIGRATIONS_DIR` | sqlite migrations | `./migrations` (relative to the working directory) |
|
|
70
|
+
| `CF_ACCOUNT_ID`, `CF_D1_DATABASE_ID`, `CF_D1_API_TOKEN` | d1_http | — |
|
|
71
|
+
| `CORS_ORIGINS` | `EnvCORSMiddleware` | none (comma-separated) |
|
|
72
|
+
|
|
73
|
+
Values come from the Worker env (`wrangler.jsonc` vars and secrets) first, then the process
|
|
74
|
+
environment. `.env` in the working directory is loaded into the process environment on
|
|
75
|
+
import, without overriding anything already set.
|
|
76
|
+
|
|
77
|
+
## Public API
|
|
78
|
+
|
|
79
|
+
| Name | What it is |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `get_config(request, key, default=None)` | Config from the Worker env, else `os.environ` |
|
|
82
|
+
| `get_binding(request, name)` | A Cloudflare binding (`env.DB`, `env.AI`, `env.BUCKET`…) or `None` outside a Worker |
|
|
83
|
+
| `load_dotenv(path=None)` | Load `.env` (runs once on import) |
|
|
84
|
+
| `Database` | Protocol: `fetch_all`, `fetch_one`, `execute`, `batch` |
|
|
85
|
+
| `SQLiteDatabase`, `D1BindingDatabase`, `D1HttpDatabase` | The three adapters |
|
|
86
|
+
| `get_database(request)` | Chooses an adapter from `DATABASE_MODE` / bindings |
|
|
87
|
+
| `Statement` | `tuple[str, tuple]`, one item of `batch` |
|
|
88
|
+
| `migrations_dir()` | The migrations folder, or a `RuntimeError` if it's missing |
|
|
89
|
+
| `AppError`, `NotFoundError` (404), `ForbiddenError` (403), `ConflictError` (409), `InvalidInputError` (422), `TooManyRequestsError` (429 + `Retry-After`), `UpstreamError` (502), `NotConfiguredError` (503) | Business errors services raise |
|
|
90
|
+
| `register_error_handlers(app)` | Maps `AppError` to its status code |
|
|
91
|
+
| `EnvCORSMiddleware(app, default_origins="")`, `allowed_origins(scope, default="")` | CORS with origins read per request (fallback `default_origins`); unhandled errors still get CORS headers |
|
|
92
|
+
| `health_router(checks=None, prefix="/health")`, `HealthCheck`, `database_check` | `GET /health`; 503 if any check fails |
|
|
93
|
+
|
|
94
|
+
### `batch` atomicity
|
|
95
|
+
|
|
96
|
+
| Adapter | Atomic? |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `SQLiteDatabase` | Yes, one transaction |
|
|
99
|
+
| `D1BindingDatabase` | Yes, D1 `batch()` |
|
|
100
|
+
| `D1HttpDatabase` | **No**: one request per statement, in order |
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv run --package wiltech-labs-core pytest packages/core
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Publishing
|
|
109
|
+
|
|
110
|
+
Publish **before** `wiltech-labs-auth` and `wiltech-labs-ai`, which depend on it.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cd packages/core
|
|
114
|
+
|
|
115
|
+
# 1. Test, bump, build
|
|
116
|
+
uv run pytest
|
|
117
|
+
uv version --bump patch # or minor / major; skip for the first 0.1.0
|
|
118
|
+
rm -rf ../../dist && uv build # -> ../../dist/
|
|
119
|
+
|
|
120
|
+
# 2. TestPyPI (optional rehearsal), using a token from test.pypi.org
|
|
121
|
+
read -rsp "TestPyPI token: " UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && echo
|
|
122
|
+
uv publish --index testpypi ../../dist/*
|
|
123
|
+
unset UV_PUBLISH_TOKEN
|
|
124
|
+
|
|
125
|
+
# 3. PyPI (the real thing), using a token from pypi.org
|
|
126
|
+
read -rsp "PyPI token: " UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && echo
|
|
127
|
+
uv publish ../../dist/*
|
|
128
|
+
unset UV_PUBLISH_TOKEN
|
|
129
|
+
|
|
130
|
+
# 4. Commit and tag
|
|
131
|
+
git commit -am "wiltech-labs-core <version>"
|
|
132
|
+
git tag core-v<version> && git push && git push --tags
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A version number can only be uploaded once to each site. To retry after a fix, bump the
|
|
136
|
+
version and rebuild.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wiltech-labs-core"
|
|
3
|
+
version = "1.0.1"
|
|
4
|
+
description = "Runtime plumbing for Wiltech FastAPI apps: config from Worker env or .env, a portable Database (SQLite, Cloudflare D1 binding, D1 over HTTP), errors, CORS and health."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
authors = [{ name = "Wiliam Ferraciolli" }]
|
|
9
|
+
keywords = ["fastapi", "cloudflare-workers", "d1", "sqlite", "config"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Framework :: FastAPI",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Typing :: Typed",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"fastapi>=0.110",
|
|
17
|
+
"httpx>=0.27",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
# Local uvicorn/Docker only. A Cloudflare Worker uses D1 and must not need it.
|
|
22
|
+
sqlite = ["aiosqlite>=0.20"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/wilferraciolli/py-libraries/tree/main/packages/core"
|
|
26
|
+
Repository = "https://github.com/wilferraciolli/py-libraries"
|
|
27
|
+
Conventions = "https://github.com/wilferraciolli/py-libraries/blob/main/docs/PYTHON_APP_CONVENTIONS.md"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"aiosqlite",
|
|
32
|
+
"pytest",
|
|
33
|
+
"pytest-asyncio",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
exclude = ["CLAUDE.md", ".pytest_cache"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/wiltech_labs_core"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
asyncio_mode = "auto"
|
|
48
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wiltech-labs-core: the runtime plumbing shared by Wiltech's FastAPI apps.
|
|
3
|
+
|
|
4
|
+
Config that reads the Worker env or .env, the portable Database protocol
|
|
5
|
+
(SQLite / D1 binding / D1 over HTTP), the AppError family, per-request CORS
|
|
6
|
+
and a health router.
|
|
7
|
+
|
|
8
|
+
This module is the entire public surface — import from `wiltech_labs_core`,
|
|
9
|
+
not from its sub-packages.
|
|
10
|
+
"""
|
|
11
|
+
from wiltech_labs_core.config import get_binding, get_config, load_dotenv
|
|
12
|
+
from wiltech_labs_core.database import (
|
|
13
|
+
D1BindingDatabase,
|
|
14
|
+
D1HttpDatabase,
|
|
15
|
+
Database,
|
|
16
|
+
SQLiteDatabase,
|
|
17
|
+
Statement,
|
|
18
|
+
get_database,
|
|
19
|
+
migrations_dir,
|
|
20
|
+
)
|
|
21
|
+
from wiltech_labs_core.errors import (
|
|
22
|
+
AppError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
ForbiddenError,
|
|
25
|
+
InvalidInputError,
|
|
26
|
+
NotConfiguredError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
TooManyRequestsError,
|
|
29
|
+
UpstreamError,
|
|
30
|
+
register_error_handlers,
|
|
31
|
+
)
|
|
32
|
+
from wiltech_labs_core.web import EnvCORSMiddleware, HealthCheck, allowed_origins, database_check, health_router
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AppError",
|
|
36
|
+
"ConflictError",
|
|
37
|
+
"D1BindingDatabase",
|
|
38
|
+
"D1HttpDatabase",
|
|
39
|
+
"Database",
|
|
40
|
+
"EnvCORSMiddleware",
|
|
41
|
+
"ForbiddenError",
|
|
42
|
+
"HealthCheck",
|
|
43
|
+
"InvalidInputError",
|
|
44
|
+
"NotConfiguredError",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"SQLiteDatabase",
|
|
47
|
+
"Statement",
|
|
48
|
+
"TooManyRequestsError",
|
|
49
|
+
"UpstreamError",
|
|
50
|
+
"allowed_origins",
|
|
51
|
+
"database_check",
|
|
52
|
+
"get_binding",
|
|
53
|
+
"get_config",
|
|
54
|
+
"get_database",
|
|
55
|
+
"health_router",
|
|
56
|
+
"load_dotenv",
|
|
57
|
+
"migrations_dir",
|
|
58
|
+
"register_error_handlers",
|
|
59
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from starlette.requests import HTTPConnection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_dotenv(path: Optional[Path] = None) -> None:
|
|
9
|
+
"""
|
|
10
|
+
Load the project's .env (in the working directory) for plain uvicorn/local runs, if one exists.
|
|
11
|
+
|
|
12
|
+
Values already in the environment win, so docker-compose `environment:`
|
|
13
|
+
entries override the file. Runs once on import; call it again only to load
|
|
14
|
+
a different file.
|
|
15
|
+
"""
|
|
16
|
+
env_path = path or Path.cwd() / ".env"
|
|
17
|
+
if not env_path.exists():
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
for line in env_path.read_text().splitlines():
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
key, value = line.split("=", 1)
|
|
26
|
+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
load_dotenv()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_config(request: HTTPConnection, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
33
|
+
"""
|
|
34
|
+
Read config from either the Worker env object or process environment.
|
|
35
|
+
|
|
36
|
+
Cloudflare injects bindings/config into request.scope["env"], while plain
|
|
37
|
+
uvicorn/Docker uses environment variables loaded from .env/docker-compose.
|
|
38
|
+
"""
|
|
39
|
+
env = request.scope.get("env")
|
|
40
|
+
if env is not None:
|
|
41
|
+
try:
|
|
42
|
+
value = getattr(env, key)
|
|
43
|
+
if value is not None:
|
|
44
|
+
return str(value)
|
|
45
|
+
except AttributeError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
return os.environ.get(key, default)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_binding(request: HTTPConnection, name: str) -> Optional[object]:
|
|
52
|
+
"""
|
|
53
|
+
A Cloudflare binding (env.DB, env.AI, env.BUCKET, ...) or None outside a
|
|
54
|
+
Worker. Adapter factories use this to prefer the native binding when it
|
|
55
|
+
exists and fall back to HTTP otherwise.
|
|
56
|
+
"""
|
|
57
|
+
env = request.scope.get("env")
|
|
58
|
+
if env is None:
|
|
59
|
+
return None
|
|
60
|
+
return getattr(env, name, None)
|