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 +9 -0
- jigkit/db.py +84 -0
- jigkit/healthcheck.py +92 -0
- jigkit/logging.py +105 -0
- jigkit/migrations.py +131 -0
- jigkit/models.py +70 -0
- jigkit/py.typed +0 -0
- jigkit/secrets.py +66 -0
- jigkit/settings.py +139 -0
- jigkit/testing/__init__.py +90 -0
- jigkit/testing/migrations.py +301 -0
- jigkit-0.1.0.dist-info/METADATA +68 -0
- jigkit-0.1.0.dist-info/RECORD +15 -0
- jigkit-0.1.0.dist-info/WHEEL +4 -0
- jigkit-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|