capsize-commons 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capsize_commons-0.1.0/PKG-INFO +104 -0
- capsize_commons-0.1.0/README.md +63 -0
- capsize_commons-0.1.0/pyproject.toml +71 -0
- capsize_commons-0.1.0/setup.cfg +4 -0
- capsize_commons-0.1.0/src/capsize_commons/__init__.py +37 -0
- capsize_commons-0.1.0/src/capsize_commons/config/__init__.py +14 -0
- capsize_commons-0.1.0/src/capsize_commons/config/base.py +46 -0
- capsize_commons-0.1.0/src/capsize_commons/db/__init__.py +25 -0
- capsize_commons-0.1.0/src/capsize_commons/db/base.py +95 -0
- capsize_commons-0.1.0/src/capsize_commons/db/engine.py +60 -0
- capsize_commons-0.1.0/src/capsize_commons/http/__init__.py +10 -0
- capsize_commons-0.1.0/src/capsize_commons/http/retry.py +97 -0
- capsize_commons-0.1.0/src/capsize_commons/logging/__init__.py +24 -0
- capsize_commons-0.1.0/src/capsize_commons/logging/json_formatter.py +55 -0
- capsize_commons-0.1.0/src/capsize_commons/logging/setup.py +122 -0
- capsize_commons-0.1.0/src/capsize_commons/py.typed +1 -0
- capsize_commons-0.1.0/src/capsize_commons/sentinel.py +35 -0
- capsize_commons-0.1.0/src/capsize_commons/text/__init__.py +12 -0
- capsize_commons-0.1.0/src/capsize_commons/text/case.py +62 -0
- capsize_commons-0.1.0/src/capsize_commons/web/__init__.py +16 -0
- capsize_commons-0.1.0/src/capsize_commons/web/auth.py +55 -0
- capsize_commons-0.1.0/src/capsize_commons/web/health.py +55 -0
- capsize_commons-0.1.0/src/capsize_commons.egg-info/PKG-INFO +104 -0
- capsize_commons-0.1.0/src/capsize_commons.egg-info/SOURCES.txt +34 -0
- capsize_commons-0.1.0/src/capsize_commons.egg-info/dependency_links.txt +1 -0
- capsize_commons-0.1.0/src/capsize_commons.egg-info/requires.txt +31 -0
- capsize_commons-0.1.0/src/capsize_commons.egg-info/top_level.txt +1 -0
- capsize_commons-0.1.0/tests/test_case.py +40 -0
- capsize_commons-0.1.0/tests/test_config.py +45 -0
- capsize_commons-0.1.0/tests/test_db.py +72 -0
- capsize_commons-0.1.0/tests/test_json_formatter.py +49 -0
- capsize_commons-0.1.0/tests/test_logging_setup.py +72 -0
- capsize_commons-0.1.0/tests/test_retry.py +93 -0
- capsize_commons-0.1.0/tests/test_sentinel.py +16 -0
- capsize_commons-0.1.0/tests/test_web_auth.py +56 -0
- capsize_commons-0.1.0/tests/test_web_health.py +32 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capsize-commons
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Selectively installable common building blocks shared across Capsize projects: structured logging, FastAPI auth/health, SQLAlchemy conventions, HTTP retry, and case conversion.
|
|
5
|
+
Author-email: Capsize LLC <contact@capsizegames.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Capsize-Games/capsize-commons
|
|
8
|
+
Project-URL: Issues, https://github.com/Capsize-Games/capsize-commons/issues
|
|
9
|
+
Keywords: capsize,commons,logging,fastapi,sqlalchemy,utilities
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: config
|
|
17
|
+
Requires-Dist: pydantic>=2.7; extra == "config"
|
|
18
|
+
Requires-Dist: pydantic-settings>=2.3; extra == "config"
|
|
19
|
+
Provides-Extra: db
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0.30; extra == "db"
|
|
21
|
+
Provides-Extra: web
|
|
22
|
+
Requires-Dist: fastapi>=0.115; extra == "web"
|
|
23
|
+
Provides-Extra: http
|
|
24
|
+
Requires-Dist: httpx>=0.27; extra == "http"
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Requires-Dist: pydantic>=2.7; extra == "all"
|
|
27
|
+
Requires-Dist: pydantic-settings>=2.3; extra == "all"
|
|
28
|
+
Requires-Dist: sqlalchemy>=2.0.30; extra == "all"
|
|
29
|
+
Requires-Dist: fastapi>=0.115; extra == "all"
|
|
30
|
+
Requires-Dist: httpx>=0.27; extra == "all"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8.2; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
34
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
37
|
+
Requires-Dist: pydantic>=2.7; extra == "dev"
|
|
38
|
+
Requires-Dist: pydantic-settings>=2.3; extra == "dev"
|
|
39
|
+
Requires-Dist: sqlalchemy>=2.0.30; extra == "dev"
|
|
40
|
+
Requires-Dist: fastapi>=0.115; extra == "dev"
|
|
41
|
+
|
|
42
|
+
# capsize-commons (Python)
|
|
43
|
+
|
|
44
|
+
The Python distribution of [`capsize-commons`](../README.md). It has **no
|
|
45
|
+
required dependencies**; each sub-package is pulled in through an extra and
|
|
46
|
+
imports its third-party dependency lazily, so importing one module never drags
|
|
47
|
+
in another module's stack.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv add "capsize-commons[logging]" # anything is optional
|
|
53
|
+
uv add "capsize-commons[web,db,config]" # or pick several
|
|
54
|
+
uv add "capsize-commons[all]" # or everything
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Extra | Enables | Third-party |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `config` | `capsize_commons.config` | `pydantic`, `pydantic-settings` |
|
|
60
|
+
| `db` | `capsize_commons.db` | `sqlalchemy` |
|
|
61
|
+
| `web` | `capsize_commons.web` | `fastapi` |
|
|
62
|
+
| `http` | `capsize_commons.http` | none (stdlib only) |
|
|
63
|
+
| `all` | every sub-package | above |
|
|
64
|
+
|
|
65
|
+
`capsize_commons.text` and `capsize_commons.logging` are stdlib-only and ship
|
|
66
|
+
with the base install.
|
|
67
|
+
|
|
68
|
+
## Modules
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# Structured JSON logs matching §14
|
|
72
|
+
from capsize_commons.logging import configure_logging
|
|
73
|
+
configure_logging(json_mode=True, logger_name="myapp")
|
|
74
|
+
|
|
75
|
+
# Env-backed settings, cached per class
|
|
76
|
+
from capsize_commons.config import CapsizeSettings, get_settings
|
|
77
|
+
class Settings(CapsizeSettings):
|
|
78
|
+
model_config = CapsizeSettings.model_config | {"env_prefix": "MYAPP_"}
|
|
79
|
+
database_url: str = "sqlite:///./app.db"
|
|
80
|
+
|
|
81
|
+
# FastAPI auth + health
|
|
82
|
+
from capsize_commons.web import make_api_key_dependency, install_health_routes
|
|
83
|
+
|
|
84
|
+
# SQLAlchemy engine, sessions and the standard model mixin
|
|
85
|
+
from capsize_commons.db import make_engine, make_session_factory, TimestampedBase
|
|
86
|
+
|
|
87
|
+
# HTTP retry with backoff
|
|
88
|
+
from capsize_commons.http import Backoff, retry_async
|
|
89
|
+
|
|
90
|
+
# Naming (§3.1)
|
|
91
|
+
from capsize_commons.text import slugify, to_snake_case
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cd python
|
|
98
|
+
uv sync --all-extras
|
|
99
|
+
uv run pytest
|
|
100
|
+
uv run ruff check . && uv run ruff format --check .
|
|
101
|
+
uv run mypy src
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or from the repository root: `just test`, `just lint`, `just typecheck`.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# capsize-commons (Python)
|
|
2
|
+
|
|
3
|
+
The Python distribution of [`capsize-commons`](../README.md). It has **no
|
|
4
|
+
required dependencies**; each sub-package is pulled in through an extra and
|
|
5
|
+
imports its third-party dependency lazily, so importing one module never drags
|
|
6
|
+
in another module's stack.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uv add "capsize-commons[logging]" # anything is optional
|
|
12
|
+
uv add "capsize-commons[web,db,config]" # or pick several
|
|
13
|
+
uv add "capsize-commons[all]" # or everything
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Extra | Enables | Third-party |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `config` | `capsize_commons.config` | `pydantic`, `pydantic-settings` |
|
|
19
|
+
| `db` | `capsize_commons.db` | `sqlalchemy` |
|
|
20
|
+
| `web` | `capsize_commons.web` | `fastapi` |
|
|
21
|
+
| `http` | `capsize_commons.http` | none (stdlib only) |
|
|
22
|
+
| `all` | every sub-package | above |
|
|
23
|
+
|
|
24
|
+
`capsize_commons.text` and `capsize_commons.logging` are stdlib-only and ship
|
|
25
|
+
with the base install.
|
|
26
|
+
|
|
27
|
+
## Modules
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# Structured JSON logs matching §14
|
|
31
|
+
from capsize_commons.logging import configure_logging
|
|
32
|
+
configure_logging(json_mode=True, logger_name="myapp")
|
|
33
|
+
|
|
34
|
+
# Env-backed settings, cached per class
|
|
35
|
+
from capsize_commons.config import CapsizeSettings, get_settings
|
|
36
|
+
class Settings(CapsizeSettings):
|
|
37
|
+
model_config = CapsizeSettings.model_config | {"env_prefix": "MYAPP_"}
|
|
38
|
+
database_url: str = "sqlite:///./app.db"
|
|
39
|
+
|
|
40
|
+
# FastAPI auth + health
|
|
41
|
+
from capsize_commons.web import make_api_key_dependency, install_health_routes
|
|
42
|
+
|
|
43
|
+
# SQLAlchemy engine, sessions and the standard model mixin
|
|
44
|
+
from capsize_commons.db import make_engine, make_session_factory, TimestampedBase
|
|
45
|
+
|
|
46
|
+
# HTTP retry with backoff
|
|
47
|
+
from capsize_commons.http import Backoff, retry_async
|
|
48
|
+
|
|
49
|
+
# Naming (§3.1)
|
|
50
|
+
from capsize_commons.text import slugify, to_snake_case
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd python
|
|
57
|
+
uv sync --all-extras
|
|
58
|
+
uv run pytest
|
|
59
|
+
uv run ruff check . && uv run ruff format --check .
|
|
60
|
+
uv run mypy src
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or from the repository root: `just test`, `just lint`, `just typecheck`.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "capsize-commons"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Selectively installable common building blocks shared across Capsize projects: structured logging, FastAPI auth/health, SQLAlchemy conventions, HTTP retry, and case conversion."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Capsize LLC", email = "contact@capsizegames.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"capsize",
|
|
17
|
+
"commons",
|
|
18
|
+
"logging",
|
|
19
|
+
"fastapi",
|
|
20
|
+
"sqlalchemy",
|
|
21
|
+
"utilities",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# The base distribution is deliberately dependency-free: every module that
|
|
31
|
+
# needs a third-party package imports it lazily and is pulled in by an extra.
|
|
32
|
+
dependencies = []
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
config = ["pydantic>=2.7", "pydantic-settings>=2.3"]
|
|
36
|
+
db = ["sqlalchemy>=2.0.30"]
|
|
37
|
+
web = ["fastapi>=0.115"]
|
|
38
|
+
http = ["httpx>=0.27"]
|
|
39
|
+
all = [
|
|
40
|
+
"pydantic>=2.7",
|
|
41
|
+
"pydantic-settings>=2.3",
|
|
42
|
+
"sqlalchemy>=2.0.30",
|
|
43
|
+
"fastapi>=0.115",
|
|
44
|
+
"httpx>=0.27",
|
|
45
|
+
]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.2",
|
|
48
|
+
"pytest-asyncio>=0.23",
|
|
49
|
+
"httpx>=0.27",
|
|
50
|
+
"ruff>=0.5",
|
|
51
|
+
"mypy>=1.10",
|
|
52
|
+
"pydantic>=2.7",
|
|
53
|
+
"pydantic-settings>=2.3",
|
|
54
|
+
"sqlalchemy>=2.0.30",
|
|
55
|
+
"fastapi>=0.115",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[project.urls]
|
|
59
|
+
Homepage = "https://github.com/Capsize-Games/capsize-commons"
|
|
60
|
+
Issues = "https://github.com/Capsize-Games/capsize-commons/issues"
|
|
61
|
+
|
|
62
|
+
[tool.setuptools.packages.find]
|
|
63
|
+
where = ["src"]
|
|
64
|
+
|
|
65
|
+
# The package is mypy --strict clean, so the marker is a claim it can keep.
|
|
66
|
+
[tool.setuptools.package-data]
|
|
67
|
+
capsize_commons = ["py.typed"]
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
testpaths = ["tests"]
|
|
71
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Generic, selectively-installable building blocks shared across Capsize.
|
|
2
|
+
|
|
3
|
+
What this package deliberately does **not** do, because each one is what makes
|
|
4
|
+
in-repo copies hard to consolidate:
|
|
5
|
+
|
|
6
|
+
* **No application logic.** If a helper knows a domain noun, it belongs in the
|
|
7
|
+
project, not here.
|
|
8
|
+
* **No required dependencies.** The base install is stdlib-only. Anything that
|
|
9
|
+
needs a third-party package lives in a sub-package behind an extra and
|
|
10
|
+
imports it lazily.
|
|
11
|
+
* **No import-time side effects.** Importing a module never reads the
|
|
12
|
+
environment, opens a connection, or configures logging. You call
|
|
13
|
+
:func:`capsize_commons.logging.configure_logging` when you want it.
|
|
14
|
+
* **No global mutable state** beyond explicitly cached, resettable settings.
|
|
15
|
+
|
|
16
|
+
The sub-packages:
|
|
17
|
+
|
|
18
|
+
``capsize_commons.logging``
|
|
19
|
+
Structured JSON logging with the §14 field set, reversible by design.
|
|
20
|
+
``capsize_commons.config``
|
|
21
|
+
A ``pydantic-settings`` base plus a per-class cached accessor.
|
|
22
|
+
``capsize_commons.db``
|
|
23
|
+
SQLAlchemy engine/session factories and the §6 model conventions
|
|
24
|
+
(time-ordered UUID keys, UTC timestamps).
|
|
25
|
+
``capsize_commons.web``
|
|
26
|
+
FastAPI API-key auth and the ``/health`` + ``/ready`` routes.
|
|
27
|
+
``capsize_commons.http``
|
|
28
|
+
Retry with exponential backoff, sync and async.
|
|
29
|
+
``capsize_commons.text``
|
|
30
|
+
Case conversion for the fleet's naming rules (§3.1).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
__all__ = ["__version__"]
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Environment-backed settings conventions (§7).
|
|
2
|
+
|
|
3
|
+
Requires the ``config`` extra (``pydantic`` + ``pydantic-settings``).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from capsize_commons.config.base import (
|
|
9
|
+
CapsizeSettings,
|
|
10
|
+
clear_settings_cache,
|
|
11
|
+
get_settings,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = ["CapsizeSettings", "clear_settings_cache", "get_settings"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""A shared ``pydantic-settings`` base and a per-class cached accessor.
|
|
2
|
+
|
|
3
|
+
Every FastAPI service in the fleet re-declared ``class Settings(BaseSettings)``
|
|
4
|
+
plus a ``@lru_cache get_settings()``. The base here fixes the common
|
|
5
|
+
conventions (``.env`` loading, unknown keys ignored) and the factory caches one
|
|
6
|
+
instance per settings class, so tests can clear it in one call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from functools import cache
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
15
|
+
|
|
16
|
+
__all__ = ["CapsizeSettings", "clear_settings_cache", "get_settings"]
|
|
17
|
+
|
|
18
|
+
_T = TypeVar("_T", bound=BaseSettings)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CapsizeSettings(BaseSettings):
|
|
22
|
+
"""Base class for Capsize runtime settings.
|
|
23
|
+
|
|
24
|
+
Subclasses override ``model_config`` to set their own ``env_prefix``
|
|
25
|
+
(``CAPSIZE_`` by default). Real ``.env`` files stay gitignored; ship an
|
|
26
|
+
``.env.example`` alongside them (§7).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = SettingsConfigDict(
|
|
30
|
+
env_prefix="CAPSIZE_",
|
|
31
|
+
env_file=".env",
|
|
32
|
+
env_file_encoding="utf-8",
|
|
33
|
+
extra="ignore",
|
|
34
|
+
case_sensitive=False,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@cache
|
|
39
|
+
def get_settings(settings_cls: type[_T]) -> _T:
|
|
40
|
+
"""Return the process-wide, cached instance of ``settings_cls``."""
|
|
41
|
+
return settings_cls()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clear_settings_cache() -> None:
|
|
45
|
+
"""Drop every cached settings instance (used by tests)."""
|
|
46
|
+
get_settings.cache_clear()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""SQLAlchemy engine, session and model conventions (§6).
|
|
2
|
+
|
|
3
|
+
Requires the ``db`` extra (``sqlalchemy``).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from capsize_commons.db.base import (
|
|
9
|
+
Base,
|
|
10
|
+
TimestampedBase,
|
|
11
|
+
UtcDateTime,
|
|
12
|
+
utcnow,
|
|
13
|
+
uuid7,
|
|
14
|
+
)
|
|
15
|
+
from capsize_commons.db.engine import make_engine, make_session_factory
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Base",
|
|
19
|
+
"TimestampedBase",
|
|
20
|
+
"UtcDateTime",
|
|
21
|
+
"make_engine",
|
|
22
|
+
"make_session_factory",
|
|
23
|
+
"utcnow",
|
|
24
|
+
"uuid7",
|
|
25
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""SQLAlchemy model conventions from CAPSIZE_PROJECT_STANDARDS.md §6.
|
|
2
|
+
|
|
3
|
+
Two things every model re-implemented, now shared:
|
|
4
|
+
|
|
5
|
+
* :class:`UtcDateTime` — SQLite has no native timezone-aware storage, so the
|
|
6
|
+
stock ``DateTime`` reads back naive even when written aware. This reattaches
|
|
7
|
+
UTC on the way out and rejects naive values on the way in.
|
|
8
|
+
* :class:`TimestampedBase` — the mandated ``id`` + ``created_at`` +
|
|
9
|
+
``updated_at`` columns, with a time-ordered UUIDv7 primary key.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import DateTime, Uuid
|
|
20
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
21
|
+
from sqlalchemy.types import TypeDecorator
|
|
22
|
+
|
|
23
|
+
__all__ = ["Base", "TimestampedBase", "UtcDateTime", "uuid7"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def uuid7() -> uuid.UUID:
|
|
27
|
+
"""Return a time-ordered UUIDv7 (§6 primary-key preference).
|
|
28
|
+
|
|
29
|
+
Layout: 48-bit Unix milliseconds, 4-bit version, 12-bit ``rand_a``,
|
|
30
|
+
2-bit variant, 62-bit ``rand_b``.
|
|
31
|
+
"""
|
|
32
|
+
milliseconds = int(time.time() * 1000) & ((1 << 48) - 1)
|
|
33
|
+
random_bits = int.from_bytes(os.urandom(10), "big") >> 6 # 74 random bits
|
|
34
|
+
rand_a = random_bits >> 62
|
|
35
|
+
rand_b = random_bits & ((1 << 62) - 1)
|
|
36
|
+
value = (
|
|
37
|
+
(milliseconds << 80)
|
|
38
|
+
| (0x7 << 76)
|
|
39
|
+
| (rand_a << 64)
|
|
40
|
+
| (0x2 << 62)
|
|
41
|
+
| rand_b
|
|
42
|
+
)
|
|
43
|
+
return uuid.UUID(int=value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def utcnow() -> datetime.datetime:
|
|
47
|
+
"""Return the current time as a timezone-aware UTC ``datetime``."""
|
|
48
|
+
return datetime.datetime.now(datetime.UTC)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UtcDateTime(TypeDecorator[datetime.datetime]):
|
|
52
|
+
"""A ``DateTime`` that round-trips as timezone-aware UTC through SQLite."""
|
|
53
|
+
|
|
54
|
+
impl = DateTime(timezone=True)
|
|
55
|
+
cache_ok = True
|
|
56
|
+
|
|
57
|
+
def process_bind_param(
|
|
58
|
+
self, value: datetime.datetime | None, dialect: object
|
|
59
|
+
) -> datetime.datetime | None:
|
|
60
|
+
"""Require and normalize an aware value on the way into storage."""
|
|
61
|
+
if value is None:
|
|
62
|
+
return None
|
|
63
|
+
if value.tzinfo is None:
|
|
64
|
+
raise ValueError("UtcDateTime requires a timezone-aware value")
|
|
65
|
+
return value.astimezone(datetime.UTC)
|
|
66
|
+
|
|
67
|
+
def process_result_value(
|
|
68
|
+
self, value: datetime.datetime | None, dialect: object
|
|
69
|
+
) -> datetime.datetime | None:
|
|
70
|
+
"""Reattach UTC to a value SQLite returned as naive."""
|
|
71
|
+
if value is None:
|
|
72
|
+
return None
|
|
73
|
+
if value.tzinfo is None:
|
|
74
|
+
return value.replace(tzinfo=datetime.UTC)
|
|
75
|
+
return value.astimezone(datetime.UTC)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Base(DeclarativeBase):
|
|
79
|
+
"""Declarative base for every Capsize SQLAlchemy model."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TimestampedBase(Base):
|
|
83
|
+
"""Abstract base adding the standard ``id`` and UTC timestamps (§6)."""
|
|
84
|
+
|
|
85
|
+
__abstract__ = True
|
|
86
|
+
|
|
87
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
88
|
+
Uuid, primary_key=True, default=uuid7
|
|
89
|
+
)
|
|
90
|
+
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
91
|
+
UtcDateTime, default=utcnow
|
|
92
|
+
)
|
|
93
|
+
updated_at: Mapped[datetime.datetime] = mapped_column(
|
|
94
|
+
UtcDateTime, default=utcnow, onupdate=utcnow
|
|
95
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""SQLAlchemy engine and session factories.
|
|
2
|
+
|
|
3
|
+
Consolidates the per-project ``make_engine`` / ``make_session_factory`` pair.
|
|
4
|
+
The SQLite branch is the part every copy got subtly wrong or left out: it
|
|
5
|
+
disables the thread check (so a single file-backed database can be shared) and
|
|
6
|
+
turns on ``PRAGMA foreign_keys`` (which SQLite leaves off by default, so every
|
|
7
|
+
``ON DELETE CASCADE`` in a project silently did nothing).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import Engine, create_engine, event
|
|
15
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
16
|
+
|
|
17
|
+
__all__ = ["make_engine", "make_session_factory"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_engine(
|
|
21
|
+
database_url: str,
|
|
22
|
+
*,
|
|
23
|
+
echo: bool = False,
|
|
24
|
+
pool_pre_ping: bool = True,
|
|
25
|
+
**engine_kwargs: Any,
|
|
26
|
+
) -> Engine:
|
|
27
|
+
"""Create an :class:`Engine`, applying SQLite-safe defaults.
|
|
28
|
+
|
|
29
|
+
``pool_pre_ping`` defaults to ``True`` so a pooled connection that a
|
|
30
|
+
database or proxy has since dropped is detected and replaced (standards
|
|
31
|
+
§6: pooling must be explicit, never unbounded).
|
|
32
|
+
"""
|
|
33
|
+
connect_args: dict[str, Any] = {}
|
|
34
|
+
if database_url.startswith("sqlite"):
|
|
35
|
+
connect_args["check_same_thread"] = False
|
|
36
|
+
engine = create_engine(
|
|
37
|
+
database_url,
|
|
38
|
+
echo=echo,
|
|
39
|
+
pool_pre_ping=pool_pre_ping,
|
|
40
|
+
connect_args=connect_args,
|
|
41
|
+
**engine_kwargs,
|
|
42
|
+
)
|
|
43
|
+
if database_url.startswith("sqlite"):
|
|
44
|
+
_enable_sqlite_foreign_keys(engine)
|
|
45
|
+
return engine
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _enable_sqlite_foreign_keys(engine: Engine) -> None:
|
|
49
|
+
"""Turn on foreign-key enforcement for every SQLite connection."""
|
|
50
|
+
|
|
51
|
+
@event.listens_for(engine, "connect")
|
|
52
|
+
def _on_connect(dbapi_connection: Any, _record: Any) -> None:
|
|
53
|
+
cursor = dbapi_connection.cursor()
|
|
54
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
55
|
+
cursor.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def make_session_factory(engine: Engine) -> sessionmaker[Session]:
|
|
59
|
+
"""Return a session factory with the fleet's standard flags."""
|
|
60
|
+
return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""HTTP helpers: retry with exponential backoff.
|
|
2
|
+
|
|
3
|
+
Stdlib-only: part of the base install.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from capsize_commons.http.retry import Backoff, retry_async, retry_sync
|
|
9
|
+
|
|
10
|
+
__all__ = ["Backoff", "retry_async", "retry_sync"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Retry helpers with exponential backoff and jitter.
|
|
2
|
+
|
|
3
|
+
Stdlib-only. Callers supply the operation as a zero-argument callable and the
|
|
4
|
+
sleep function, which is what makes these testable without real delays and
|
|
5
|
+
usable from both sync and async code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TypeVar
|
|
16
|
+
|
|
17
|
+
__all__ = ["Backoff", "retry_async", "retry_sync"]
|
|
18
|
+
|
|
19
|
+
_T = TypeVar("_T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Backoff:
|
|
24
|
+
"""Retry tuning: attempt count and the delay curve between attempts."""
|
|
25
|
+
|
|
26
|
+
attempts: int = 3
|
|
27
|
+
base_delay: float = 0.1
|
|
28
|
+
max_delay: float = 5.0
|
|
29
|
+
factor: float = 2.0
|
|
30
|
+
jitter: bool = True
|
|
31
|
+
retry_on: tuple[type[BaseException], ...] = (Exception,)
|
|
32
|
+
|
|
33
|
+
def delay_for(self, attempt: int, rng: random.Random) -> float:
|
|
34
|
+
"""Return the delay to wait before retrying zero-based ``attempt``.
|
|
35
|
+
|
|
36
|
+
With ``jitter`` enabled the delay is drawn uniformly from
|
|
37
|
+
``[0, raw]``, which spreads out retries from many clients instead of
|
|
38
|
+
letting them all return at the same instant.
|
|
39
|
+
"""
|
|
40
|
+
raw = min(self.base_delay * (self.factor**attempt), self.max_delay)
|
|
41
|
+
if self.jitter:
|
|
42
|
+
return rng.uniform(0.0, raw)
|
|
43
|
+
return raw
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def retry_sync(
|
|
47
|
+
func: Callable[[], _T],
|
|
48
|
+
*,
|
|
49
|
+
backoff: Backoff | None = None,
|
|
50
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
51
|
+
rng: random.Random | None = None,
|
|
52
|
+
) -> _T:
|
|
53
|
+
"""Call ``func`` until it returns, retrying the configured exceptions.
|
|
54
|
+
|
|
55
|
+
Re-raises the final exception once attempts are exhausted.
|
|
56
|
+
"""
|
|
57
|
+
config = backoff or Backoff()
|
|
58
|
+
generator = rng or random.Random()
|
|
59
|
+
last_error: BaseException | None = None
|
|
60
|
+
for attempt in range(config.attempts):
|
|
61
|
+
try:
|
|
62
|
+
return func()
|
|
63
|
+
except config.retry_on as error:
|
|
64
|
+
last_error = error
|
|
65
|
+
if attempt == config.attempts - 1:
|
|
66
|
+
break
|
|
67
|
+
sleep(config.delay_for(attempt, generator))
|
|
68
|
+
if last_error is None: # pragma: no cover - only when attempts < 1
|
|
69
|
+
raise ValueError("Backoff.attempts must be at least 1")
|
|
70
|
+
raise last_error
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def retry_async(
|
|
74
|
+
func: Callable[[], Awaitable[_T]],
|
|
75
|
+
*,
|
|
76
|
+
backoff: Backoff | None = None,
|
|
77
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
78
|
+
rng: random.Random | None = None,
|
|
79
|
+
) -> _T:
|
|
80
|
+
"""Await ``func`` until it returns, retrying the configured exceptions.
|
|
81
|
+
|
|
82
|
+
Re-raises the final exception once attempts are exhausted.
|
|
83
|
+
"""
|
|
84
|
+
config = backoff or Backoff()
|
|
85
|
+
generator = rng or random.Random()
|
|
86
|
+
last_error: BaseException | None = None
|
|
87
|
+
for attempt in range(config.attempts):
|
|
88
|
+
try:
|
|
89
|
+
return await func()
|
|
90
|
+
except config.retry_on as error:
|
|
91
|
+
last_error = error
|
|
92
|
+
if attempt == config.attempts - 1:
|
|
93
|
+
break
|
|
94
|
+
await sleep(config.delay_for(attempt, generator))
|
|
95
|
+
if last_error is None: # pragma: no cover - only when attempts < 1
|
|
96
|
+
raise ValueError("Backoff.attempts must be at least 1")
|
|
97
|
+
raise last_error
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Structured JSON logging that matches CAPSIZE_PROJECT_STANDARDS.md §14.
|
|
2
|
+
|
|
3
|
+
Stdlib-only: part of the base install.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from capsize_commons.logging.json_formatter import JsonFormatter
|
|
9
|
+
from capsize_commons.logging.setup import (
|
|
10
|
+
DEFAULT_LEVEL,
|
|
11
|
+
DEFAULT_LOGGER_NAME,
|
|
12
|
+
configure_logging,
|
|
13
|
+
logging_enabled,
|
|
14
|
+
reset_logging,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_LEVEL",
|
|
19
|
+
"DEFAULT_LOGGER_NAME",
|
|
20
|
+
"JsonFormatter",
|
|
21
|
+
"configure_logging",
|
|
22
|
+
"logging_enabled",
|
|
23
|
+
"reset_logging",
|
|
24
|
+
]
|