jigkit 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.
- jigkit-0.1.0/.gitignore +30 -0
- jigkit-0.1.0/LICENSE +21 -0
- jigkit-0.1.0/PKG-INFO +68 -0
- jigkit-0.1.0/README.md +24 -0
- jigkit-0.1.0/pyproject.toml +89 -0
- jigkit-0.1.0/src/jigkit/__init__.py +9 -0
- jigkit-0.1.0/src/jigkit/db.py +84 -0
- jigkit-0.1.0/src/jigkit/healthcheck.py +92 -0
- jigkit-0.1.0/src/jigkit/logging.py +105 -0
- jigkit-0.1.0/src/jigkit/migrations.py +131 -0
- jigkit-0.1.0/src/jigkit/models.py +70 -0
- jigkit-0.1.0/src/jigkit/py.typed +0 -0
- jigkit-0.1.0/src/jigkit/secrets.py +66 -0
- jigkit-0.1.0/src/jigkit/settings.py +139 -0
- jigkit-0.1.0/src/jigkit/testing/__init__.py +90 -0
- jigkit-0.1.0/src/jigkit/testing/migrations.py +301 -0
- jigkit-0.1.0/tests/conftest.py +13 -0
- jigkit-0.1.0/tests/sample_app/alembic/env.py +35 -0
- jigkit-0.1.0/tests/sample_app/alembic/script.py.mako +28 -0
- jigkit-0.1.0/tests/sample_app/alembic/versions/0001_initial.py +73 -0
- jigkit-0.1.0/tests/sample_app/alembic.ini +25 -0
- jigkit-0.1.0/tests/sample_app/sample_schema.py +84 -0
- jigkit-0.1.0/tests/test_db.py +93 -0
- jigkit-0.1.0/tests/test_healthcheck.py +108 -0
- jigkit-0.1.0/tests/test_logging.py +94 -0
- jigkit-0.1.0/tests/test_migrations.py +207 -0
- jigkit-0.1.0/tests/test_models.py +100 -0
- jigkit-0.1.0/tests/test_secrets.py +59 -0
- jigkit-0.1.0/tests/test_settings.py +160 -0
- jigkit-0.1.0/tests/test_testing_plugin.py +98 -0
- jigkit-0.1.0/tests/test_version.py +9 -0
jigkit-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
|
|
9
|
+
# Test and coverage output
|
|
10
|
+
.coverage
|
|
11
|
+
.coverage.*
|
|
12
|
+
coverage.json
|
|
13
|
+
coverage.xml
|
|
14
|
+
htmlcov/
|
|
15
|
+
junit/
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
# Staging directory the coverage publish step assembles (CI only)
|
|
19
|
+
coverage-upload/
|
|
20
|
+
# Shared coverage tooling, fetched on demand by `make coverage`
|
|
21
|
+
.coverage-report.py
|
|
22
|
+
|
|
23
|
+
# Editor and OS
|
|
24
|
+
.idea/
|
|
25
|
+
*.iml
|
|
26
|
+
*.swp
|
|
27
|
+
.DS_Store
|
|
28
|
+
|
|
29
|
+
# Claude Code local settings
|
|
30
|
+
.claude/settings.local.json
|
jigkit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jure Merhar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jigkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jigkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The shared foundations of small self-hosted web apps: settings, database, migrations, logging, health checks and test fixtures
|
|
5
|
+
Project-URL: Homepage, https://github.com/jmerhar/jigkit
|
|
6
|
+
Project-URL: Changelog, https://github.com/jmerhar/jigkit/blob/main/CHANGELOG.md
|
|
7
|
+
Author: Jure Merhar
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Framework :: Pytest
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Requires-Dist: pydantic-settings>=2.15
|
|
19
|
+
Requires-Dist: pydantic>=2.13
|
|
20
|
+
Requires-Dist: tzdata>=2026.5
|
|
21
|
+
Provides-Extra: db
|
|
22
|
+
Requires-Dist: aiosqlite>=0.22; extra == 'db'
|
|
23
|
+
Requires-Dist: alembic>=1.19; extra == 'db'
|
|
24
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.52; extra == 'db'
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: aiosqlite>=0.22; extra == 'dev'
|
|
27
|
+
Requires-Dist: alembic>=1.19; extra == 'dev'
|
|
28
|
+
Requires-Dist: build>=1.3; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=7.1; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=9.1; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.16; extra == 'dev'
|
|
33
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.52; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine>=6.2; extra == 'dev'
|
|
35
|
+
Provides-Extra: postgres
|
|
36
|
+
Requires-Dist: aiosqlite>=0.22; extra == 'postgres'
|
|
37
|
+
Requires-Dist: alembic>=1.19; extra == 'postgres'
|
|
38
|
+
Requires-Dist: asyncpg>=0.30; extra == 'postgres'
|
|
39
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.52; extra == 'postgres'
|
|
40
|
+
Provides-Extra: testing
|
|
41
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == 'testing'
|
|
42
|
+
Requires-Dist: pytest>=9.1; extra == 'testing'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# jigkit
|
|
46
|
+
|
|
47
|
+
The shared foundations of small self-hosted web apps built on FastAPI, async SQLAlchemy and
|
|
48
|
+
Svelte: settings, database, migrations, logging, health checks and test fixtures, each hardened
|
|
49
|
+
once instead of copied between applications.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "jigkit[db]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| Module | Extra | What it gives an application |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `jigkit.settings` | — | `AppSettings`: blank environment values mean unset, `X` or `X_FILE` for secrets (never both), a validated `TZ`, a data directory |
|
|
58
|
+
| `jigkit.db` | `db` | an async engine with SQLite's pragmas (foreign keys, WAL, busy timeout), a session factory, a commit-or-roll-back `session_scope` |
|
|
59
|
+
| `jigkit.models` | `db` | a declarative `Base` with deterministic constraint names, `UtcDateTime`, `TimestampMixin` |
|
|
60
|
+
| `jigkit.migrations` | `db` | the whole of an Alembic `env.py`, with foreign keys off during SQLite table rebuilds and checked afterwards |
|
|
61
|
+
| `jigkit.logging` | — | stream and rotating file handlers, a level that changes at runtime, credential-carrying HTTP client loggers kept quiet |
|
|
62
|
+
| `jigkit.healthcheck` | — | `python -m jigkit.healthcheck URL --expect database=connected`, a container health probe |
|
|
63
|
+
| `jigkit.secrets` | — | `load_or_create_secret`, a generated secret kept owner-only in the data directory |
|
|
64
|
+
| `jigkit.testing` | `testing` | a pytest plugin (`data_dir`, `no_network`) and migration checks run through the application's own `env.py` |
|
|
65
|
+
|
|
66
|
+
PostgreSQL is supported through the `postgres` extra. Python 3.14 or newer.
|
|
67
|
+
|
|
68
|
+
See the [repository](https://github.com/jmerhar/jigkit) for the changelog and documentation.
|
jigkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# jigkit
|
|
2
|
+
|
|
3
|
+
The shared foundations of small self-hosted web apps built on FastAPI, async SQLAlchemy and
|
|
4
|
+
Svelte: settings, database, migrations, logging, health checks and test fixtures, each hardened
|
|
5
|
+
once instead of copied between applications.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install "jigkit[db]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| Module | Extra | What it gives an application |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `jigkit.settings` | — | `AppSettings`: blank environment values mean unset, `X` or `X_FILE` for secrets (never both), a validated `TZ`, a data directory |
|
|
14
|
+
| `jigkit.db` | `db` | an async engine with SQLite's pragmas (foreign keys, WAL, busy timeout), a session factory, a commit-or-roll-back `session_scope` |
|
|
15
|
+
| `jigkit.models` | `db` | a declarative `Base` with deterministic constraint names, `UtcDateTime`, `TimestampMixin` |
|
|
16
|
+
| `jigkit.migrations` | `db` | the whole of an Alembic `env.py`, with foreign keys off during SQLite table rebuilds and checked afterwards |
|
|
17
|
+
| `jigkit.logging` | — | stream and rotating file handlers, a level that changes at runtime, credential-carrying HTTP client loggers kept quiet |
|
|
18
|
+
| `jigkit.healthcheck` | — | `python -m jigkit.healthcheck URL --expect database=connected`, a container health probe |
|
|
19
|
+
| `jigkit.secrets` | — | `load_or_create_secret`, a generated secret kept owner-only in the data directory |
|
|
20
|
+
| `jigkit.testing` | `testing` | a pytest plugin (`data_dir`, `no_network`) and migration checks run through the application's own `env.py` |
|
|
21
|
+
|
|
22
|
+
PostgreSQL is supported through the `postgres` extra. Python 3.14 or newer.
|
|
23
|
+
|
|
24
|
+
See the [repository](https://github.com/jmerhar/jigkit) for the changelog and documentation.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jigkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The shared foundations of small self-hosted web apps: settings, database, migrations, logging, health checks and test fixtures"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Jure Merhar" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Framework :: Pytest",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pydantic>=2.13",
|
|
25
|
+
"pydantic-settings>=2.15",
|
|
26
|
+
# Time zone data for validating TZ, which a slim container image need not carry.
|
|
27
|
+
"tzdata>=2026.5",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
# An async SQLAlchemy database, SQLite by default, with Alembic migrations.
|
|
32
|
+
db = [
|
|
33
|
+
"sqlalchemy[asyncio]>=2.0.52",
|
|
34
|
+
"aiosqlite>=0.22",
|
|
35
|
+
"alembic>=1.19",
|
|
36
|
+
]
|
|
37
|
+
# PostgreSQL through asyncpg, on top of `db`.
|
|
38
|
+
postgres = [
|
|
39
|
+
"jigkit[db]",
|
|
40
|
+
"asyncpg>=0.30",
|
|
41
|
+
]
|
|
42
|
+
# The pytest plugin: fixtures and migration checks for applications built on jigkit.
|
|
43
|
+
testing = [
|
|
44
|
+
"pytest>=9.1",
|
|
45
|
+
"pytest-asyncio>=1.4",
|
|
46
|
+
]
|
|
47
|
+
# Everything this repository's own checks need.
|
|
48
|
+
dev = [
|
|
49
|
+
"jigkit[db,testing]",
|
|
50
|
+
"pytest-cov>=7.1",
|
|
51
|
+
"ruff>=0.16",
|
|
52
|
+
"build>=1.3",
|
|
53
|
+
"twine>=6.2",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[project.urls]
|
|
57
|
+
Homepage = "https://github.com/jmerhar/jigkit"
|
|
58
|
+
Changelog = "https://github.com/jmerhar/jigkit/blob/main/CHANGELOG.md"
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build.targets.wheel]
|
|
61
|
+
packages = ["src/jigkit"]
|
|
62
|
+
|
|
63
|
+
[tool.hatch.build.targets.sdist]
|
|
64
|
+
include = ["src/jigkit", "tests", "README.md", "LICENSE"]
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
asyncio_mode = "auto"
|
|
68
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
69
|
+
asyncio_default_test_loop_scope = "function"
|
|
70
|
+
testpaths = ["tests"]
|
|
71
|
+
filterwarnings = ["error"]
|
|
72
|
+
|
|
73
|
+
[tool.coverage.run]
|
|
74
|
+
source = ["jigkit"]
|
|
75
|
+
# Async throughout. Coverage's sys.monitoring core (COVERAGE_CORE=sysmon, set in bin/test.sh and
|
|
76
|
+
# CI) traces lines after `await`, which the legacy C core under-reports.
|
|
77
|
+
branch = true
|
|
78
|
+
# The migration tests run Alembic in a subprocess; this lets their coverage be measured too.
|
|
79
|
+
patch = ["subprocess"]
|
|
80
|
+
omit = ["*/tests/*"]
|
|
81
|
+
|
|
82
|
+
[tool.coverage.report]
|
|
83
|
+
show_missing = true
|
|
84
|
+
exclude_also = [
|
|
85
|
+
"if TYPE_CHECKING:",
|
|
86
|
+
"raise NotImplementedError",
|
|
87
|
+
"@(abc\\.)?abstractmethod",
|
|
88
|
+
"if __name__ == .__main__.:",
|
|
89
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The shared foundations of small self-hosted web apps.
|
|
2
|
+
|
|
3
|
+
Each module is imported on its own (``jigkit.settings``, ``jigkit.db``, ...); importing the
|
|
4
|
+
package pulls in nothing beyond the version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
|
|
9
|
+
__version__ = version("jigkit")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The async database engine, its sessions, and the transaction boundary.
|
|
2
|
+
|
|
3
|
+
SQLite is the default backend and gets the pragmas its schema assumptions depend on; PostgreSQL
|
|
4
|
+
(the ``postgres`` extra) gets a pre-ping, so a connection the server dropped is replaced instead
|
|
5
|
+
of failing the next request.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import event
|
|
15
|
+
from sqlalchemy.engine.interfaces import DBAPIConnection
|
|
16
|
+
from sqlalchemy.ext.asyncio import (
|
|
17
|
+
AsyncEngine,
|
|
18
|
+
AsyncSession,
|
|
19
|
+
async_sessionmaker,
|
|
20
|
+
create_async_engine,
|
|
21
|
+
)
|
|
22
|
+
from sqlalchemy.pool import ConnectionPoolEntry
|
|
23
|
+
|
|
24
|
+
#: Milliseconds a SQLite connection waits for a lock before giving up.
|
|
25
|
+
SQLITE_BUSY_TIMEOUT_MS = 5000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_sqlite(url: str) -> bool:
|
|
29
|
+
"""Whether ``url`` names a SQLite database, whose pragmas another backend would reject."""
|
|
30
|
+
return url.startswith("sqlite")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def apply_sqlite_pragmas(dbapi_connection: DBAPIConnection, _record: ConnectionPoolEntry) -> None:
|
|
34
|
+
"""Turn on the SQLite behaviour a schema with foreign keys assumes.
|
|
35
|
+
|
|
36
|
+
``foreign_keys`` is off by default in SQLite, which silently makes every ``ON DELETE`` rule a
|
|
37
|
+
no-op. WAL lets background work write while a request reads, instead of the two blocking each
|
|
38
|
+
other, and the busy timeout makes a writer wait for a lock rather than fail at once.
|
|
39
|
+
"""
|
|
40
|
+
cursor = dbapi_connection.cursor()
|
|
41
|
+
try:
|
|
42
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
43
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
44
|
+
cursor.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}")
|
|
45
|
+
finally:
|
|
46
|
+
cursor.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_engine(url: str, **kwargs: Any) -> AsyncEngine:
|
|
50
|
+
"""Build an async engine for ``url``, with the backend's connection setup wired in.
|
|
51
|
+
|
|
52
|
+
Keyword arguments go to SQLAlchemy's ``create_async_engine``.
|
|
53
|
+
"""
|
|
54
|
+
if is_sqlite(url):
|
|
55
|
+
engine = create_async_engine(url, **kwargs)
|
|
56
|
+
event.listen(engine.sync_engine, "connect", apply_sqlite_pragmas)
|
|
57
|
+
return engine
|
|
58
|
+
kwargs.setdefault("pool_pre_ping", True)
|
|
59
|
+
return create_async_engine(url, **kwargs)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_sessionmaker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
|
63
|
+
"""A session factory that leaves loaded objects usable after a commit.
|
|
64
|
+
|
|
65
|
+
``expire_on_commit=False`` so a handler can read the object it just saved without a second
|
|
66
|
+
round trip, which under async would be implicit IO hidden behind attribute access.
|
|
67
|
+
"""
|
|
68
|
+
return async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def session_scope(factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[AsyncSession]:
|
|
73
|
+
"""A transaction that commits on success and rolls back on any exception.
|
|
74
|
+
|
|
75
|
+
Read-only blocks commit too. On PostgreSQL that ends them as a no-op COMMIT instead of a
|
|
76
|
+
ROLLBACK, which keeps the server's rollback ratio meaningful.
|
|
77
|
+
"""
|
|
78
|
+
async with factory() as session:
|
|
79
|
+
try:
|
|
80
|
+
yield session
|
|
81
|
+
await session.commit()
|
|
82
|
+
except BaseException:
|
|
83
|
+
await session.rollback()
|
|
84
|
+
raise
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""A container health probe: ``python -m jigkit.healthcheck URL [--expect KEY=VALUE ...]``.
|
|
2
|
+
|
|
3
|
+
Exits 0 only when the URL answers 200 with a JSON object holding every expected value. A probe
|
|
4
|
+
that only checked the port would call a container healthy while every request it served failed,
|
|
5
|
+
so an application reports what it depends on (``{"database": "connected"}``) and the probe
|
|
6
|
+
checks it::
|
|
7
|
+
|
|
8
|
+
HEALTHCHECK CMD ["python", "-m", "jigkit.healthcheck",
|
|
9
|
+
"http://127.0.0.1:8000/api/health", "--expect", "database=connected"]
|
|
10
|
+
|
|
11
|
+
Only the standard library is used, so the probe starts quickly and needs no extra.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections.abc import Mapping, Sequence
|
|
22
|
+
|
|
23
|
+
TIMEOUT_SECONDS = 5.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def probe(
|
|
27
|
+
url: str, expect: Mapping[str, object] | None = None, timeout: float = TIMEOUT_SECONDS
|
|
28
|
+
) -> int:
|
|
29
|
+
"""Report whether the application at ``url`` is healthy, as an exit status.
|
|
30
|
+
|
|
31
|
+
Each reason for an unhealthy result is printed to stderr, where Docker keeps it in the
|
|
32
|
+
container's health log.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310
|
|
36
|
+
# urlopen raises for 4xx and 5xx, so this catches a 2xx that is not 200, which is not
|
|
37
|
+
# an answer to a health question either.
|
|
38
|
+
if response.status != 200:
|
|
39
|
+
return _unhealthy(f"HTTP {response.status}")
|
|
40
|
+
body = json.load(response)
|
|
41
|
+
except urllib.error.HTTPError as error:
|
|
42
|
+
# Closed explicitly: an HTTPError carries the response it was raised from.
|
|
43
|
+
with error:
|
|
44
|
+
return _unhealthy(f"HTTP {error.code}")
|
|
45
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
|
|
46
|
+
return _unhealthy(str(error))
|
|
47
|
+
|
|
48
|
+
if not isinstance(body, dict):
|
|
49
|
+
return _unhealthy(f"expected a JSON object, got {type(body).__name__}")
|
|
50
|
+
for key, wanted in (expect or {}).items():
|
|
51
|
+
if body.get(key) != wanted:
|
|
52
|
+
return _unhealthy(f"{key} is {body.get(key)!r}, expected {wanted!r}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unhealthy(reason: str) -> int:
|
|
57
|
+
"""Print why the probe failed, and return the failing exit status."""
|
|
58
|
+
print(f"unhealthy: {reason}", file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _expectation(text: str) -> tuple[str, object]:
|
|
63
|
+
"""Parse ``KEY=VALUE``, VALUE as JSON if it parses (``true``, ``3``) and as text if not."""
|
|
64
|
+
key, separator, raw = text.partition("=")
|
|
65
|
+
if not separator or not key:
|
|
66
|
+
raise argparse.ArgumentTypeError(f"expected KEY=VALUE, got {text!r}")
|
|
67
|
+
try:
|
|
68
|
+
value: object = json.loads(raw)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
value = raw
|
|
71
|
+
return key, value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
75
|
+
"""Run the probe from the command line."""
|
|
76
|
+
parser = argparse.ArgumentParser(prog="python -m jigkit.healthcheck", description=__doc__)
|
|
77
|
+
parser.add_argument("url", help="the health endpoint to query")
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--expect",
|
|
80
|
+
action="append",
|
|
81
|
+
type=_expectation,
|
|
82
|
+
default=[],
|
|
83
|
+
metavar="KEY=VALUE",
|
|
84
|
+
help="a value the JSON body must hold; repeatable",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument("--timeout", type=float, default=TIMEOUT_SECONDS)
|
|
87
|
+
arguments = parser.parse_args(argv)
|
|
88
|
+
return probe(arguments.url, dict(arguments.expect), arguments.timeout)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
sys.exit(main())
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Where an application's log goes, and how its level changes while it runs.
|
|
2
|
+
|
|
3
|
+
Two destinations. The stream is what ``docker logs`` shows. The file survives the container
|
|
4
|
+
being recreated; it belongs in the mounted data directory, so it can be read from the host, and
|
|
5
|
+
it rotates, because nothing else rotates a file inside a container.
|
|
6
|
+
|
|
7
|
+
The level can change at runtime, because the moment DEBUG is needed is the moment something is
|
|
8
|
+
failing, and restarting to get it loses whatever state was interesting.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from logging.handlers import RotatingFileHandler
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
FORMAT = "%(asctime)s %(levelname)-7s %(name)s %(message)s"
|
|
19
|
+
|
|
20
|
+
#: The levels an application offers to choose from.
|
|
21
|
+
LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
22
|
+
|
|
23
|
+
#: Five files of five megabytes.
|
|
24
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
25
|
+
BACKUP_COUNT = 5
|
|
26
|
+
|
|
27
|
+
#: Loggers that hold a level of their own and so are set alongside the root; uvicorn gives its
|
|
28
|
+
#: access and error loggers one, and raising the root alone would leave them where they were.
|
|
29
|
+
FOLLOW_ROOT = ("uvicorn", "uvicorn.access", "uvicorn.error")
|
|
30
|
+
|
|
31
|
+
#: Loggers held at WARNING however low the application goes. httpx logs every request's URL at
|
|
32
|
+
#: INFO and httpcore logs headers at DEBUG, and URLs and headers carry credentials: push-monitor
|
|
33
|
+
#: tokens, bot tokens, session cookies.
|
|
34
|
+
QUIET = ("httpx", "httpcore")
|
|
35
|
+
|
|
36
|
+
# Marks the handlers this module installed, so a repeated call replaces them rather than stacking
|
|
37
|
+
# a second copy that logs every line twice.
|
|
38
|
+
_MARKER = "_jigkit_handler"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configure_logging(
|
|
42
|
+
level: str = "INFO",
|
|
43
|
+
*,
|
|
44
|
+
log_file: Path | None = None,
|
|
45
|
+
quiet: Iterable[str] = QUIET,
|
|
46
|
+
max_bytes: int = MAX_BYTES,
|
|
47
|
+
backup_count: int = BACKUP_COUNT,
|
|
48
|
+
) -> Path | None:
|
|
49
|
+
"""Install the stream handler, and the rotating file handler when ``log_file`` is given.
|
|
50
|
+
|
|
51
|
+
Idempotent: calling it again replaces the handlers it installed rather than adding more.
|
|
52
|
+
Returns the file's path, or None when there is no file or it could not be opened. An
|
|
53
|
+
unwritable data directory does not stop the application, since the stream still carries
|
|
54
|
+
everything.
|
|
55
|
+
"""
|
|
56
|
+
quiet = tuple(quiet)
|
|
57
|
+
apply_level(level, quiet=quiet)
|
|
58
|
+
|
|
59
|
+
root = logging.getLogger()
|
|
60
|
+
for handler in [h for h in root.handlers if getattr(h, _MARKER, False)]:
|
|
61
|
+
root.removeHandler(handler)
|
|
62
|
+
handler.close()
|
|
63
|
+
|
|
64
|
+
formatter = logging.Formatter(FORMAT)
|
|
65
|
+
stream = logging.StreamHandler()
|
|
66
|
+
stream.setFormatter(formatter)
|
|
67
|
+
setattr(stream, _MARKER, True)
|
|
68
|
+
root.addHandler(stream)
|
|
69
|
+
|
|
70
|
+
if log_file is None:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
file_handler = RotatingFileHandler(
|
|
75
|
+
log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
|
|
76
|
+
)
|
|
77
|
+
except OSError:
|
|
78
|
+
logging.getLogger(__name__).warning(
|
|
79
|
+
"could not open %s for writing; logging to the stream only", log_file, exc_info=True
|
|
80
|
+
)
|
|
81
|
+
return None
|
|
82
|
+
file_handler.setFormatter(formatter)
|
|
83
|
+
setattr(file_handler, _MARKER, True)
|
|
84
|
+
root.addHandler(file_handler)
|
|
85
|
+
return log_file
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def apply_level(level: str, *, quiet: Iterable[str] = QUIET) -> int:
|
|
89
|
+
"""Set the level of the root logger and of the loggers that follow it, and return it.
|
|
90
|
+
|
|
91
|
+
The ``quiet`` loggers never go below WARNING.
|
|
92
|
+
"""
|
|
93
|
+
resolved = resolve_level(level)
|
|
94
|
+
logging.getLogger().setLevel(resolved)
|
|
95
|
+
for name in FOLLOW_ROOT:
|
|
96
|
+
logging.getLogger(name).setLevel(resolved)
|
|
97
|
+
for name in quiet:
|
|
98
|
+
logging.getLogger(name).setLevel(max(resolved, logging.WARNING))
|
|
99
|
+
return resolved
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_level(level: str) -> int:
|
|
103
|
+
"""The numeric level for a name, INFO for anything unrecognised."""
|
|
104
|
+
value = logging.getLevelNamesMapping().get(level.strip().upper())
|
|
105
|
+
return value if value is not None else logging.INFO
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Alembic's environment, shared: the whole body of an application's ``alembic/env.py``.
|
|
2
|
+
|
|
3
|
+
An application's env.py becomes::
|
|
4
|
+
|
|
5
|
+
from jigkit.migrations import run_migrations
|
|
6
|
+
|
|
7
|
+
from myapp.config import get_settings
|
|
8
|
+
from myapp.models import Base
|
|
9
|
+
|
|
10
|
+
run_migrations(get_settings().resolved_database_url, Base.metadata)
|
|
11
|
+
|
|
12
|
+
Migrations run through jigkit's own engine, so ``alembic upgrade head`` and the running
|
|
13
|
+
application can never disagree about which database is meant or how it is connected to.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from logging.config import fileConfig
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from alembic import context
|
|
24
|
+
from sqlalchemy import Connection, MetaData, event
|
|
25
|
+
from sqlalchemy.engine import make_url
|
|
26
|
+
|
|
27
|
+
from .db import create_engine, is_sqlite
|
|
28
|
+
from .models import UtcDateTime
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_item(obj_type: str, obj: object, autogen_context: Any) -> str | bool: # noqa: ARG001
|
|
32
|
+
"""Render :class:`~jigkit.models.UtcDateTime` as the plain ``DateTime`` it is built on.
|
|
33
|
+
|
|
34
|
+
A migration has to keep running years later, and one that imports an application class stops
|
|
35
|
+
working when that class moves. At the DDL level the type is exactly ``sa.DateTime()``.
|
|
36
|
+
|
|
37
|
+
Everything else returns False, Alembic's "use the default rendering"; None would render the
|
|
38
|
+
item as an empty string.
|
|
39
|
+
"""
|
|
40
|
+
if obj_type == "type" and isinstance(obj, UtcDateTime):
|
|
41
|
+
return "sa.DateTime()"
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_migrations(url: str, metadata: MetaData) -> None:
|
|
46
|
+
"""Run the migrations Alembic was asked for against the database at ``url``.
|
|
47
|
+
|
|
48
|
+
Online, this applies them; offline (``alembic upgrade --sql``), it prints the SQL instead.
|
|
49
|
+
The logging configuration in alembic.ini is applied first, as Alembic's own template does.
|
|
50
|
+
"""
|
|
51
|
+
if context.config.config_file_name is not None:
|
|
52
|
+
fileConfig(context.config.config_file_name)
|
|
53
|
+
if context.is_offline_mode():
|
|
54
|
+
_run_offline(url, metadata)
|
|
55
|
+
else:
|
|
56
|
+
asyncio.run(_run_online(url, metadata))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _configure_options(metadata: MetaData) -> dict[str, Any]:
|
|
60
|
+
"""The Alembic options both modes share.
|
|
61
|
+
|
|
62
|
+
``render_as_batch`` is required for SQLite, which cannot ALTER a column or a constraint:
|
|
63
|
+
batch mode copies the table instead. Set unconditionally, it keeps generated migrations
|
|
64
|
+
portable between backends.
|
|
65
|
+
"""
|
|
66
|
+
return {
|
|
67
|
+
"target_metadata": metadata,
|
|
68
|
+
"render_as_batch": True,
|
|
69
|
+
"compare_type": True,
|
|
70
|
+
"render_item": render_item,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run_offline(url: str, metadata: MetaData) -> None:
|
|
75
|
+
"""Emit the migrations' SQL to stdout instead of running it."""
|
|
76
|
+
context.configure(
|
|
77
|
+
url=url,
|
|
78
|
+
literal_binds=True,
|
|
79
|
+
dialect_opts={"paramstyle": "named"},
|
|
80
|
+
**_configure_options(metadata),
|
|
81
|
+
)
|
|
82
|
+
with context.begin_transaction():
|
|
83
|
+
context.run_migrations()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run(connection: Connection, metadata: MetaData) -> None:
|
|
87
|
+
"""Apply migrations on a live connection, then prove they left no dangling references."""
|
|
88
|
+
context.configure(connection=connection, **_configure_options(metadata))
|
|
89
|
+
with context.begin_transaction():
|
|
90
|
+
context.run_migrations()
|
|
91
|
+
if connection.dialect.name == "sqlite":
|
|
92
|
+
dangling = connection.exec_driver_sql("PRAGMA foreign_key_check").fetchall()
|
|
93
|
+
if dangling:
|
|
94
|
+
raise RuntimeError(f"the migrations left rows pointing at nothing: {dangling}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _without_foreign_keys(dbapi_connection: Any, _record: object) -> None:
|
|
98
|
+
"""Turn SQLite's foreign keys off for the migrations.
|
|
99
|
+
|
|
100
|
+
Batch mode rebuilds a table by copying it, dropping the original and renaming the copy. With
|
|
101
|
+
foreign keys on, dropping the original runs every ON DELETE rule that points at it, so
|
|
102
|
+
rebuilding a parent table would delete or orphan every child row. The check after the
|
|
103
|
+
migrations stands in for the enforcement left off during them.
|
|
104
|
+
"""
|
|
105
|
+
cursor = dbapi_connection.cursor()
|
|
106
|
+
try:
|
|
107
|
+
cursor.execute("PRAGMA foreign_keys=OFF")
|
|
108
|
+
finally:
|
|
109
|
+
cursor.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _ensure_sqlite_directory(url: str) -> None:
|
|
113
|
+
"""Create the directory a SQLite file lives in, which SQLite will not do itself."""
|
|
114
|
+
database = make_url(url).database
|
|
115
|
+
if database and database != ":memory:" and not database.startswith("file:"):
|
|
116
|
+
Path(database).parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _run_online(url: str, metadata: MetaData) -> None:
|
|
120
|
+
"""Apply migrations against the database at ``url``."""
|
|
121
|
+
engine = create_engine(url)
|
|
122
|
+
if is_sqlite(url):
|
|
123
|
+
_ensure_sqlite_directory(url)
|
|
124
|
+
# Registered after create_engine's own listener, which turns foreign keys on for every
|
|
125
|
+
# connection, so this one runs last and wins.
|
|
126
|
+
event.listen(engine.sync_engine, "connect", _without_foreign_keys)
|
|
127
|
+
try:
|
|
128
|
+
async with engine.connect() as connection:
|
|
129
|
+
await connection.run_sync(_run, metadata)
|
|
130
|
+
finally:
|
|
131
|
+
await engine.dispose()
|