jigkit 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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")
jigkit/db.py ADDED
@@ -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
jigkit/healthcheck.py ADDED
@@ -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())
jigkit/logging.py ADDED
@@ -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
jigkit/migrations.py ADDED
@@ -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()
jigkit/models.py ADDED
@@ -0,0 +1,70 @@
1
+ """The declarative base every model shares, and the column types that keep data consistent.
2
+
3
+ One :class:`Base` per process: an application's models and the tables jigkit itself provides
4
+ register on the same metadata, so one set of migrations covers them all.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+
12
+ from sqlalchemy import DateTime, MetaData, TypeDecorator
13
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
14
+
15
+ #: Deterministic constraint names. SQLite cannot ALTER a constraint, so Alembic rebuilds the
16
+ #: whole table to change one, which it can only do when every constraint has a name it can
17
+ #: reproduce.
18
+ NAMING_CONVENTION = {
19
+ "ix": "ix_%(column_0_label)s",
20
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
21
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
22
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
23
+ "pk": "pk_%(table_name)s",
24
+ }
25
+
26
+
27
+ class UtcDateTime(TypeDecorator[datetime]):
28
+ """A timestamp that is always a timezone-aware UTC ``datetime`` in Python.
29
+
30
+ SQLite has no timestamp type: it stores a string and hands back a naive ``datetime``, and
31
+ comparing that with ``datetime.now(UTC)`` raises ``TypeError``. Normalising to UTC on the way
32
+ in and re-attaching the zone on the way out keeps every datetime aware, whatever the backend.
33
+ Naive input is rejected rather than assumed to be UTC, because the assumption is wrong exactly
34
+ when a caller has a local time in hand.
35
+ """
36
+
37
+ impl = DateTime
38
+ cache_ok = True
39
+
40
+ def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | None: # noqa: ARG002
41
+ """Store an aware datetime as naive UTC."""
42
+ if value is None:
43
+ return None
44
+ if value.tzinfo is None:
45
+ raise ValueError(f"expected a timezone-aware datetime, got {value!r}")
46
+ return value.astimezone(UTC).replace(tzinfo=None)
47
+
48
+ def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None: # noqa: ARG002
49
+ """Load a stored naive UTC value as an aware datetime."""
50
+ if value is None:
51
+ return None
52
+ return value.replace(tzinfo=UTC)
53
+
54
+
55
+ def utcnow() -> datetime:
56
+ """The current time, timezone-aware, for column defaults."""
57
+ return datetime.now(UTC)
58
+
59
+
60
+ class Base(DeclarativeBase):
61
+ """Declarative base for every model."""
62
+
63
+ metadata = MetaData(naming_convention=NAMING_CONVENTION)
64
+
65
+
66
+ class TimestampMixin:
67
+ """Creation and modification times, maintained by the ORM."""
68
+
69
+ created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
70
+ updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow, onupdate=utcnow)
jigkit/py.typed ADDED
File without changes
jigkit/secrets.py ADDED
@@ -0,0 +1,66 @@
1
+ """Secrets an application generates for itself on first start and keeps in its data directory.
2
+
3
+ Generating on first start is what lets an application run with no configuration at all. The file
4
+ is never rewritten afterwards: whatever the secret protects (encrypted rows, signed tokens) is
5
+ lost with it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import secrets
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ #: Random bytes in a generated secret, before URL-safe base64 encoding.
19
+ SECRET_BYTES = 48
20
+
21
+
22
+ class SecretFileError(RuntimeError):
23
+ """A secret file exists but holds nothing usable."""
24
+
25
+
26
+ def load_or_create_secret(path: Path, nbytes: int = SECRET_BYTES) -> str:
27
+ """Return the secret in ``path``, generating it there first if the file does not exist.
28
+
29
+ The secret is written to a temporary owner-only file beside ``path`` and hard-linked into
30
+ place. The link either succeeds with the whole secret already in the file, or fails because
31
+ the file exists, so when two processes start at once one creates the secret and the other
32
+ reads it, and neither ever sees a half-written file.
33
+
34
+ An existing empty file is an error rather than something to overwrite: the secret it should
35
+ hold may already protect data, and replacing it would silently make that data unreadable.
36
+ """
37
+ try:
38
+ return _read(path)
39
+ except FileNotFoundError:
40
+ pass
41
+
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ secret = secrets.token_urlsafe(nbytes)
44
+ # mkstemp creates the file 0600, so the secret is never readable by anyone else.
45
+ fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
46
+ try:
47
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
48
+ handle.write(secret)
49
+ handle.flush()
50
+ os.fsync(handle.fileno())
51
+ try:
52
+ os.link(temporary, path)
53
+ except FileExistsError:
54
+ return _read(path)
55
+ finally:
56
+ os.unlink(temporary)
57
+ logger.info("generated a new secret at %s", path)
58
+ return secret
59
+
60
+
61
+ def _read(path: Path) -> str:
62
+ """The secret in ``path``, which must not be empty."""
63
+ value = path.read_text(encoding="utf-8").strip()
64
+ if not value:
65
+ raise SecretFileError(f"{path} is empty; restore it, or delete it to generate a new one")
66
+ return value
jigkit/settings.py ADDED
@@ -0,0 +1,139 @@
1
+ """Settings read from the environment, with the behaviour every application needs.
2
+
3
+ An application subclasses :class:`AppSettings`, adds its own fields, and gets:
4
+
5
+ - **Blank means unset.** Compose writes ``VAR: ${VAR:-}`` for an optional setting, so a variable
6
+ nobody set arrives as an empty string. A blank value is dropped before validation, so the
7
+ field takes its default: an empty ``STATIC_DIR`` stays ``None`` instead of becoming
8
+ ``Path(".")``, and an empty ``COOKIE_SECURE`` stays "follow the scheme" instead of failing to
9
+ parse.
10
+ - **Secrets from files.** A field ``x`` with a sibling ``x_file`` can be given either directly
11
+ or as a path to a file holding it, such as a mounted Docker secret. Setting both is an error,
12
+ as is a file that is empty or unreadable: either way the application would otherwise run with
13
+ a value nobody intended.
14
+ - **A known time zone.** ``TZ`` is validated at startup, so a typo stops the application instead
15
+ of surfacing later as a scheduler that cannot compute the next run.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import Any, ClassVar, Self
22
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
23
+
24
+ from pydantic import AliasChoices, Field, TypeAdapter, field_validator, model_validator
25
+ from pydantic_settings import BaseSettings, SettingsConfigDict
26
+
27
+ #: The suffix that marks a field as the file form of another.
28
+ FILE_SUFFIX = "_file"
29
+
30
+
31
+ class AppSettings(BaseSettings):
32
+ """The settings every application shares.
33
+
34
+ Subclass it and add fields. Set ``database_filename`` on the subclass to name the SQLite file
35
+ the default database URL points at.
36
+ """
37
+
38
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
39
+
40
+ #: The SQLite file inside ``data_dir`` that :attr:`resolved_database_url` defaults to.
41
+ database_filename: ClassVar[str] = "app.db"
42
+
43
+ #: Where all mutable state lives: the database, generated keys, logs, uploads. One directory
44
+ #: means a deployment has exactly one thing to persist and back up.
45
+ data_dir: Path = Path("data")
46
+ #: Where the built single-page app is served from. Unset, the API runs on its own, which is
47
+ #: how a development stack works (the frontend's dev server serves the app and proxies the API).
48
+ static_dir: Path | None = None
49
+ #: Overrides the default SQLite database in ``data_dir``.
50
+ database_url: str | None = None
51
+
52
+ log_level: str = "INFO"
53
+
54
+ #: The address people open the application at, behind its reverse proxy.
55
+ public_url: str | None = None
56
+ #: Forces the session cookie's Secure flag on or off. Unset, it follows the request's scheme.
57
+ cookie_secure: bool | None = None
58
+ #: The proxies whose X-Forwarded-For and X-Forwarded-Proto are believed, as uvicorn's
59
+ #: ``forwarded_allow_ips`` takes them. Never ``*``: uvicorn then believes the leftmost
60
+ #: address, which the client wrote itself.
61
+ forwarded_allow_ips: str = "127.0.0.1"
62
+
63
+ #: The application's time zone, read from ``TZ``, which the container's clock follows too.
64
+ timezone: str = Field("UTC", validation_alias=AliasChoices("TZ", "timezone"))
65
+
66
+ #: The commit the image was built from; unset outside an image.
67
+ revision: str | None = None
68
+
69
+ #: Whether in-process scheduled work runs. Tests turn it off and drive the work themselves.
70
+ scheduler_enabled: bool = True
71
+ scheduler_tick_seconds: int = Field(60, ge=1)
72
+
73
+ @model_validator(mode="before")
74
+ @classmethod
75
+ def _blank_means_unset(cls, data: Any) -> Any:
76
+ """Drop blank string values, so their fields take their defaults."""
77
+ if not isinstance(data, dict):
78
+ return data
79
+ return {
80
+ key: value
81
+ for key, value in data.items()
82
+ if not (isinstance(value, str) and not value.strip())
83
+ }
84
+
85
+ @field_validator("timezone")
86
+ @classmethod
87
+ def _known_zone(cls, value: str) -> str:
88
+ """Reject a time zone the standard library does not know."""
89
+ try:
90
+ ZoneInfo(value)
91
+ except (ZoneInfoNotFoundError, ValueError) as error:
92
+ raise ValueError(f"unknown time zone {value!r}") from error
93
+ return value
94
+
95
+ @model_validator(mode="after")
96
+ def _read_secret_files(self) -> Self:
97
+ """Fill each field whose ``*_file`` companion names a file, from that file.
98
+
99
+ The file's stripped text is validated as the target field's type, so a number or a flag
100
+ can come from a file as well as a string. The value and its file are exclusive, and the
101
+ file must hold something: an unreadable or empty file is an error rather than a silent
102
+ fall back to the default.
103
+ """
104
+ fields = type(self).model_fields
105
+ for name in fields:
106
+ if not name.endswith(FILE_SUFFIX):
107
+ continue
108
+ target = name.removesuffix(FILE_SUFFIX)
109
+ path = getattr(self, name)
110
+ if target not in fields or path is None:
111
+ continue
112
+ env_target = target.upper()
113
+ if target in self.model_fields_set:
114
+ raise ValueError(
115
+ f"{env_target} and {env_target}{FILE_SUFFIX.upper()} are exclusive; "
116
+ "set one of them"
117
+ )
118
+ try:
119
+ value = Path(path).read_text(encoding="utf-8").strip()
120
+ except OSError as error:
121
+ raise ValueError(
122
+ f"{env_target}{FILE_SUFFIX.upper()}: cannot read {path}: {error.strerror}"
123
+ ) from error
124
+ if not value:
125
+ raise ValueError(f"{env_target}{FILE_SUFFIX.upper()}: {path} is empty")
126
+ setattr(self, target, TypeAdapter(fields[target].annotation).validate_python(value))
127
+ return self
128
+
129
+ @property
130
+ def resolved_database_url(self) -> str:
131
+ """The database URL, defaulting to a SQLite file inside the data directory."""
132
+ if self.database_url:
133
+ return self.database_url
134
+ return f"sqlite+aiosqlite:///{self.data_dir / self.database_filename}"
135
+
136
+ @property
137
+ def zone(self) -> ZoneInfo:
138
+ """The application's time zone."""
139
+ return ZoneInfo(self.timezone)
@@ -0,0 +1,90 @@
1
+ """The pytest plugin for applications built on jigkit (extra ``testing``).
2
+
3
+ Load it from the application's ``conftest.py``::
4
+
5
+ pytest_plugins = ["jigkit.testing"]
6
+
7
+ It provides fixtures; the migration checks are in :mod:`jigkit.testing.migrations`. Fixtures an
8
+ application defines under the same name in its own conftest take precedence.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ipaddress
14
+ import socket
15
+ from collections.abc import Iterator
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import pytest
20
+
21
+ #: Host names a test may still resolve and connect to under :func:`no_network`.
22
+ LOCAL_NAMES = frozenset({"localhost", "localhost.localdomain", ""})
23
+
24
+
25
+ class NetworkAccessError(AssertionError):
26
+ """A test tried to reach a host other than this machine."""
27
+
28
+
29
+ @pytest.fixture
30
+ def data_dir(tmp_path: Path) -> Path:
31
+ """A throwaway data directory, created and empty."""
32
+ directory = tmp_path / "data"
33
+ directory.mkdir()
34
+ return directory
35
+
36
+
37
+ def _is_local(host: object) -> bool:
38
+ """Whether ``host`` (a name or an address) is this machine."""
39
+ if not isinstance(host, str):
40
+ return False
41
+ if host.lower() in LOCAL_NAMES:
42
+ return True
43
+ try:
44
+ return ipaddress.ip_address(host.split("%", 1)[0]).is_loopback
45
+ except ValueError:
46
+ return False
47
+
48
+
49
+ def _is_allowed(family: int, address: Any) -> bool:
50
+ """Whether a socket address of ``family`` stays on this machine."""
51
+ if family == getattr(socket, "AF_UNIX", object()):
52
+ return True
53
+ if isinstance(address, tuple) and address:
54
+ return _is_local(address[0])
55
+ return False
56
+
57
+
58
+ @pytest.fixture
59
+ def no_network(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
60
+ """Fail the test if it resolves or connects to anything but this machine.
61
+
62
+ A suite that quietly reaches the network depends on DNS and on hosts it does not control,
63
+ and sends traffic to whatever host a fixture names. Loopback stays open, so tests can still
64
+ talk to a server they started themselves. Request it per test, or make it autouse in the
65
+ application's conftest.
66
+ """
67
+ real_connect = socket.socket.connect
68
+ real_connect_ex = socket.socket.connect_ex
69
+ real_getaddrinfo = socket.getaddrinfo
70
+
71
+ def guarded_connect(self: socket.socket, address: Any) -> None:
72
+ if not _is_allowed(self.family, address):
73
+ raise NetworkAccessError(f"test tried to connect to {address!r}")
74
+ real_connect(self, address)
75
+
76
+ def guarded_connect_ex(self: socket.socket, address: Any) -> int:
77
+ if not _is_allowed(self.family, address):
78
+ raise NetworkAccessError(f"test tried to connect to {address!r}")
79
+ return real_connect_ex(self, address)
80
+
81
+ def guarded_getaddrinfo(host: Any, *args: Any, **kwargs: Any) -> Any:
82
+ name = host.decode() if isinstance(host, bytes) else host
83
+ if name is not None and not _is_local(name):
84
+ raise NetworkAccessError(f"test tried to resolve {name!r}")
85
+ return real_getaddrinfo(host, *args, **kwargs)
86
+
87
+ monkeypatch.setattr(socket.socket, "connect", guarded_connect)
88
+ monkeypatch.setattr(socket.socket, "connect_ex", guarded_connect_ex)
89
+ monkeypatch.setattr(socket, "getaddrinfo", guarded_getaddrinfo)
90
+ yield
@@ -0,0 +1,301 @@
1
+ """Checks an application runs against its own Alembic migrations.
2
+
3
+ Every check runs ``alembic`` in a subprocess from the application's directory, exactly as a
4
+ container's entrypoint does, so the application's real env.py and settings are what is tested::
5
+
6
+ from jigkit.testing.migrations import MigrationProject
7
+
8
+ PROJECT = MigrationProject(Path(__file__).parents[1], database_filename="myapp.db")
9
+
10
+ def test_models_match_migrations(data_dir):
11
+ PROJECT.assert_models_match(Base.metadata, data_dir)
12
+
13
+ def test_one_head():
14
+ PROJECT.assert_single_head()
15
+
16
+ def test_rebuilding_parents_keeps_children(data_dir, tmp_path):
17
+ PROJECT.assert_parent_rebuilds_keep_children(Base.metadata, data_dir, tmp_path)
18
+
19
+ The application's tests build their schema with ``create_all``, so without these nothing would
20
+ notice a model change that never got a migration, or a migration that empties a table.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import uuid
30
+ from collections.abc import Mapping
31
+ from dataclasses import dataclass, field
32
+ from datetime import UTC, date, datetime, time, timedelta
33
+ from decimal import Decimal
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from alembic.autogenerate import compare_metadata
38
+ from alembic.config import Config
39
+ from alembic.migration import MigrationContext
40
+ from alembic.script import ScriptDirectory
41
+ from sqlalchemy import (
42
+ JSON,
43
+ Boolean,
44
+ Column,
45
+ Date,
46
+ DateTime,
47
+ Enum,
48
+ Float,
49
+ Integer,
50
+ Interval,
51
+ LargeBinary,
52
+ MetaData,
53
+ Numeric,
54
+ String,
55
+ Table,
56
+ Time,
57
+ Uuid,
58
+ create_engine,
59
+ func,
60
+ select,
61
+ )
62
+
63
+ from jigkit.models import UtcDateTime
64
+
65
+ #: Environment variables passed through to the Alembic subprocess. Everything else is left out,
66
+ #: so a developer's own DATABASE_URL or similar cannot point the check at a real database.
67
+ PASSED_THROUGH = ("PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SYSTEMROOT")
68
+
69
+ #: The revision of the throwaway migration that rebuilds every parent table.
70
+ REBUILD_REVISION = "jigkit_rebuild_parents"
71
+
72
+
73
+ class UnsupportedColumnError(TypeError):
74
+ """A column needs a value the synthetic row generator cannot invent."""
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class MigrationProject:
79
+ """An application directory holding ``alembic.ini``, and how to point it at a database.
80
+
81
+ ``env`` is added to the subprocess environment, for settings the application insists on
82
+ (a secret key, say). The data directory reaches the application as ``data_dir_variable``.
83
+ """
84
+
85
+ directory: Path
86
+ database_filename: str = "app.db"
87
+ env: Mapping[str, str] = field(default_factory=dict)
88
+ data_dir_variable: str = "DATA_DIR"
89
+ ini_name: str = "alembic.ini"
90
+
91
+ def config(self) -> Config:
92
+ """The application's Alembic configuration, with paths made absolute."""
93
+ config = Config(str(self.directory / self.ini_name))
94
+ location = config.get_main_option("script_location") or "alembic"
95
+ config.set_main_option("script_location", str(self.directory / location))
96
+ return config
97
+
98
+ def sync_url(self, data_dir: Path) -> str:
99
+ """A synchronous URL for the SQLite database the application keeps in ``data_dir``."""
100
+ return f"sqlite:///{data_dir / self.database_filename}"
101
+
102
+ def alembic(self, data_dir: Path, *args: str, directory: Path | None = None) -> None:
103
+ """Run ``alembic *args`` against the database in ``data_dir``; raise if it fails.
104
+
105
+ ``directory`` runs it from a copy of the project instead of the project itself.
106
+ """
107
+ cwd = directory or self.directory
108
+ environment = {k: v for k, v in os.environ.items() if k in PASSED_THROUGH}
109
+ # Coverage measures subprocesses through variables of its own; keep them so the code
110
+ # the migrations run is measured too.
111
+ environment |= {k: v for k, v in os.environ.items() if k.startswith("COVERAGE")}
112
+ environment |= {"PYTHONPATH": os.pathsep.join(self._python_path())}
113
+ environment |= dict(self.env)
114
+ environment[self.data_dir_variable] = str(data_dir)
115
+ result = subprocess.run(
116
+ [sys.executable, "-m", "alembic", "-c", self.ini_name, *args],
117
+ cwd=cwd,
118
+ env=environment,
119
+ capture_output=True,
120
+ text=True,
121
+ check=False,
122
+ )
123
+ if result.returncode != 0:
124
+ raise AssertionError(
125
+ f"alembic {' '.join(args)} failed with status {result.returncode}:\n"
126
+ f"{result.stdout}{result.stderr}"
127
+ )
128
+
129
+ def _python_path(self) -> list[str]:
130
+ """The project's ``prepend_sys_path`` entries made absolute, then the current path.
131
+
132
+ Absolute, so the project's own modules still import when Alembic runs from a copy.
133
+ """
134
+ config = Config(str(self.directory / self.ini_name))
135
+ entries = (config.get_main_option("prepend_sys_path") or "").split(os.pathsep)
136
+ prepended = [str((self.directory / entry).resolve()) for entry in entries if entry]
137
+ return [*prepended, *sys.path]
138
+
139
+ def assert_single_head(self) -> None:
140
+ """Fail unless the migrations have exactly one head.
141
+
142
+ Two heads mean two migrations claim the same parent, and ``alembic upgrade head`` fails
143
+ on a deployment rather than in CI.
144
+ """
145
+ heads = ScriptDirectory.from_config(self.config()).get_heads()
146
+ assert len(heads) == 1, f"expected one migration head, found {len(heads)}: {heads}"
147
+
148
+ def assert_models_match(self, metadata: MetaData, data_dir: Path) -> None:
149
+ """Fail unless upgrading an empty database to head yields exactly ``metadata``'s schema."""
150
+ self.alembic(data_dir, "upgrade", "head")
151
+ engine = create_engine(self.sync_url(data_dir))
152
+ try:
153
+ with engine.connect() as connection:
154
+ context = MigrationContext.configure(
155
+ connection, opts={"compare_type": True, "render_as_batch": True}
156
+ )
157
+ differences = compare_metadata(context, metadata)
158
+ finally:
159
+ engine.dispose()
160
+ assert differences == [], (
161
+ f"the models and the migrations disagree; generate a migration for: {differences}"
162
+ )
163
+
164
+ def assert_parent_rebuilds_keep_children(
165
+ self,
166
+ metadata: MetaData,
167
+ data_dir: Path,
168
+ scratch: Path,
169
+ overrides: Mapping[str, Mapping[str, Any]] | None = None,
170
+ ) -> None:
171
+ """Fail if rebuilding a table that others point at loses or orphans their rows.
172
+
173
+ SQLite's batch mode rebuilds a table by copying it, dropping the original and renaming
174
+ the copy; with foreign keys on, the drop runs every ON DELETE rule pointing at it. This
175
+ upgrades a database to head, puts one synthetic row in every table, then runs a copy of
176
+ the migrations with one more that rebuilds every referenced table, and checks every row
177
+ is still there.
178
+
179
+ Rows are invented from column types. ``overrides`` supplies values for columns whose
180
+ type or constraints the generator cannot satisfy: ``{"table": {"column": value}}``.
181
+ ``scratch`` is an empty directory for the copy of the migrations.
182
+ """
183
+ parents = sorted(
184
+ {fk.column.table.name for table in metadata.sorted_tables for fk in table.foreign_keys}
185
+ )
186
+ assert parents, "no table is referenced by a foreign key; there is nothing to check"
187
+
188
+ self.alembic(data_dir, "upgrade", "head")
189
+ engine = create_engine(self.sync_url(data_dir))
190
+ try:
191
+ with engine.begin() as connection:
192
+ for table in metadata.sorted_tables:
193
+ connection.execute(table.insert().values(**synthetic_row(table, overrides)))
194
+ before = _row_counts(engine, metadata)
195
+
196
+ copy = self._copy_with_rebuild(scratch, parents)
197
+ self.alembic(data_dir, "upgrade", "head", directory=copy)
198
+
199
+ after = _row_counts(engine, metadata)
200
+ finally:
201
+ engine.dispose()
202
+ lost = {name: (before[name], after[name]) for name in before if after[name] != before[name]}
203
+ assert not lost, (
204
+ f"rebuilding {', '.join(parents)} changed row counts (before, after): {lost}"
205
+ )
206
+
207
+ def _copy_with_rebuild(self, scratch: Path, parents: list[str]) -> Path:
208
+ """Copy the project's Alembic files into ``scratch`` and add the rebuilding migration."""
209
+ config = self.config()
210
+ source = Path(config.get_main_option("script_location") or "")
211
+ head = ScriptDirectory.from_config(config).get_current_head()
212
+ shutil.copy(self.directory / self.ini_name, scratch / self.ini_name)
213
+ relative = source.relative_to(self.directory)
214
+ shutil.copytree(source, scratch / relative)
215
+ versions = scratch / relative / "versions"
216
+ (versions / f"{REBUILD_REVISION}.py").write_text(
217
+ "from alembic import op\n\n"
218
+ f"revision = {REBUILD_REVISION!r}\n"
219
+ f"down_revision = {head!r}\n"
220
+ "branch_labels = None\n"
221
+ "depends_on = None\n\n\n"
222
+ "def upgrade():\n"
223
+ f" for name in {parents!r}:\n"
224
+ " with op.batch_alter_table(name, recreate='always'):\n"
225
+ " pass\n\n\n"
226
+ "def downgrade():\n"
227
+ " pass\n",
228
+ encoding="utf-8",
229
+ )
230
+ return scratch
231
+
232
+
233
+ def _row_counts(engine: Any, metadata: MetaData) -> dict[str, int]:
234
+ """The number of rows in each of ``metadata``'s tables."""
235
+ with engine.connect() as connection:
236
+ return {
237
+ table.name: connection.execute(select(func.count()).select_from(table)).scalar_one()
238
+ for table in metadata.sorted_tables
239
+ }
240
+
241
+
242
+ def synthetic_row(
243
+ table: Table, overrides: Mapping[str, Mapping[str, Any]] | None = None
244
+ ) -> dict[str, Any]:
245
+ """Values for one row of ``table`` that point every foreign key at the referenced table's row.
246
+
247
+ Every table gets a row with integer primary keys of 1, so a foreign key column takes the
248
+ value of the column it references in the row generated for that table. Nullable columns with
249
+ no foreign key and columns with a server default are left out.
250
+ """
251
+ explicit = dict((overrides or {}).get(table.name, {}))
252
+ row: dict[str, Any] = {}
253
+ for column in table.columns:
254
+ if column.name in explicit:
255
+ row[column.name] = explicit[column.name]
256
+ elif column.foreign_keys:
257
+ # A column with several foreign keys points at the same value in each referenced row,
258
+ # since every generated row has the same keys; the first one decides it.
259
+ fk = sorted(column.foreign_keys, key=lambda key: key.target_fullname)[0]
260
+ row[column.name] = _value_for(fk.column, table.name, overrides)
261
+ elif column.primary_key or not (column.nullable or column.server_default is not None):
262
+ row[column.name] = _value_for(column, table.name, overrides)
263
+ return row
264
+
265
+
266
+ def _value_for(
267
+ column: Column[Any], table_name: str, overrides: Mapping[str, Mapping[str, Any]] | None
268
+ ) -> Any:
269
+ """The value the generated row of ``column``'s table holds in it."""
270
+ owner = column.table.name
271
+ explicit = (overrides or {}).get(owner, {})
272
+ if column.name in explicit:
273
+ return explicit[column.name]
274
+ column_type = column.type
275
+ # Checked before the generic table below: Enum is a String, and UtcDateTime wraps DateTime.
276
+ if isinstance(column_type, Enum):
277
+ return column_type.enums[0]
278
+ if isinstance(column_type, UtcDateTime):
279
+ return datetime(2026, 1, 1, tzinfo=UTC)
280
+ simple: list[tuple[type, Any]] = [
281
+ (Boolean, True),
282
+ (Integer, 1),
283
+ (String, "x"),
284
+ (DateTime, datetime(2026, 1, 1)),
285
+ (Date, date(2026, 1, 1)),
286
+ (Time, time(0, 0)),
287
+ (Interval, timedelta(0)),
288
+ # Float subclasses Numeric, so it comes first.
289
+ (Float, 1.0),
290
+ (Numeric, Decimal(1)),
291
+ (LargeBinary, b"\x00"),
292
+ (JSON, {}),
293
+ (Uuid, uuid.UUID(int=1)),
294
+ ]
295
+ for kind, value in simple:
296
+ if isinstance(column_type, kind):
297
+ return value
298
+ raise UnsupportedColumnError(
299
+ f"no synthetic value for {owner}.{column.name} ({column_type!r}) while filling "
300
+ f"{table_name}; pass one in overrides={{{owner!r}: {{{column.name!r}: ...}}}}"
301
+ )
@@ -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.
@@ -0,0 +1,15 @@
1
+ jigkit/__init__.py,sha256=ODsJ8SvjKmh4Sz8-C_NDgdwyGvdfCtblRCxwoMJgHZ8,272
2
+ jigkit/db.py,sha256=gRnapV6OEewx8ldGkfziK7YRCFVn6Hgxy_jBnLRTtxo,3173
3
+ jigkit/healthcheck.py,sha256=hAZ6UH0hQBnYdCWMJdFPYTH3Ii7GfruIGBLpl0e8pV8,3452
4
+ jigkit/logging.py,sha256=hQ-sepb1wKG2e-7ETpdOfpnVxG-rFUSlpGDqZo2wNTc,3921
5
+ jigkit/migrations.py,sha256=reXhJnbVxtapzu8zwVQbnq4hujwob6WiUycf9i_YsWs,4967
6
+ jigkit/models.py,sha256=oTcgOJDs_-KFj6TG7qickBR0oCQ3M3Ededx86ERsnzI,2726
7
+ jigkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ jigkit/secrets.py,sha256=3k1tJ4GwtF80iXDhOODRk9_lNijnUvaMjcfFpXXrxZs,2368
9
+ jigkit/settings.py,sha256=60OnhST0dSvw1cGqygRt8Ve-BqmP5Ha5OJl2aD-yAZg,6199
10
+ jigkit/testing/__init__.py,sha256=aBOeSnKo3xxERX9lTp7n4l47LDXarj_kAYbmwXsfYbs,3233
11
+ jigkit/testing/migrations.py,sha256=WDRkMBJy2LRIKnIL5P24IUXuB3QCenAqfw749q6wgCY,12464
12
+ jigkit-0.1.0.dist-info/METADATA,sha256=rnicey7cgvBpSPJrxE4MZea1XW362A8dcPT3ueo2Hqs,3420
13
+ jigkit-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ jigkit-0.1.0.dist-info/licenses/LICENSE,sha256=73LaOF6AdxjwgsK9X5n4lEFJj1jAEh4d_QJZtdOCRKY,1068
15
+ jigkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.