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.
@@ -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()