capsize-commons 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.
- capsize_commons/__init__.py +37 -0
- capsize_commons/config/__init__.py +14 -0
- capsize_commons/config/base.py +46 -0
- capsize_commons/db/__init__.py +25 -0
- capsize_commons/db/base.py +95 -0
- capsize_commons/db/engine.py +60 -0
- capsize_commons/http/__init__.py +10 -0
- capsize_commons/http/retry.py +97 -0
- capsize_commons/logging/__init__.py +24 -0
- capsize_commons/logging/json_formatter.py +55 -0
- capsize_commons/logging/setup.py +122 -0
- capsize_commons/py.typed +1 -0
- capsize_commons/sentinel.py +35 -0
- capsize_commons/text/__init__.py +12 -0
- capsize_commons/text/case.py +62 -0
- capsize_commons/web/__init__.py +16 -0
- capsize_commons/web/auth.py +55 -0
- capsize_commons/web/health.py +55 -0
- capsize_commons-0.1.0.dist-info/METADATA +104 -0
- capsize_commons-0.1.0.dist-info/RECORD +22 -0
- capsize_commons-0.1.0.dist-info/WHEEL +5 -0
- capsize_commons-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The §14 JSON log shape, in one place.
|
|
2
|
+
|
|
3
|
+
Standards §14 requires structured JSON on stdout with at least ``timestamp``,
|
|
4
|
+
``level``, ``logger`` and ``message``. This module has no dependency beyond the
|
|
5
|
+
standard library and no import-time side effects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = ["JsonFormatter"]
|
|
17
|
+
|
|
18
|
+
#: Optional correlation fields copied onto the record when present.
|
|
19
|
+
_IDENTIFIER_FIELDS = ("service", "request_id", "trace_id", "span_id", "event")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JsonFormatter(logging.Formatter):
|
|
23
|
+
"""Render a log record as one compact JSON object on a single line."""
|
|
24
|
+
|
|
25
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
26
|
+
"""Return ``record`` serialized as a JSON line."""
|
|
27
|
+
return json.dumps(
|
|
28
|
+
self.build_payload(record), default=str, ensure_ascii=False
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def build_payload(self, record: logging.LogRecord) -> dict[str, Any]:
|
|
32
|
+
"""Return the standards-shaped mapping for ``record``.
|
|
33
|
+
|
|
34
|
+
The base fields are always present; correlation fields are added only
|
|
35
|
+
when the caller supplied them via ``extra=``. An optional ``fields``
|
|
36
|
+
mapping is nested under ``"fields"`` rather than spread into the
|
|
37
|
+
payload, so a field can never overwrite a base field.
|
|
38
|
+
"""
|
|
39
|
+
created = datetime.fromtimestamp(record.created, UTC)
|
|
40
|
+
payload: dict[str, Any] = {
|
|
41
|
+
"timestamp": created.isoformat(),
|
|
42
|
+
"level": record.levelname,
|
|
43
|
+
"logger": record.name,
|
|
44
|
+
"message": record.getMessage(),
|
|
45
|
+
}
|
|
46
|
+
for name in _IDENTIFIER_FIELDS:
|
|
47
|
+
value = getattr(record, name, None)
|
|
48
|
+
if value is not None:
|
|
49
|
+
payload[name] = value
|
|
50
|
+
fields = getattr(record, "fields", None)
|
|
51
|
+
if isinstance(fields, Mapping):
|
|
52
|
+
payload["fields"] = dict(fields)
|
|
53
|
+
if record.exc_info is not None:
|
|
54
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
55
|
+
return payload
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Opt-in, reversible structured logging.
|
|
2
|
+
|
|
3
|
+
Nothing here runs at import time and no entry point *must* call it, so a
|
|
4
|
+
library consumer's default logging behaviour is untouched. Call
|
|
5
|
+
:func:`configure_logging` to attach one handler to a named logger (never the
|
|
6
|
+
root logger) and :func:`reset_logging` to restore the exact prior state — which
|
|
7
|
+
is what makes this safe to use in tests.
|
|
8
|
+
|
|
9
|
+
``SPIKEFORGE_LOG_JSON`` / ``SPIKEFORGE_LOG_LEVEL`` and their copies across the
|
|
10
|
+
fleet become one implementation with a configurable prefix; the default prefix
|
|
11
|
+
here is ``CAPSIZE``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Final, TextIO
|
|
19
|
+
|
|
20
|
+
from capsize_commons.logging.json_formatter import JsonFormatter
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DEFAULT_LEVEL",
|
|
24
|
+
"DEFAULT_LOGGER_NAME",
|
|
25
|
+
"configure_logging",
|
|
26
|
+
"logging_enabled",
|
|
27
|
+
"reset_logging",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
#: Logger structured logging attaches to when no name is given.
|
|
31
|
+
DEFAULT_LOGGER_NAME: Final = "capsize"
|
|
32
|
+
#: Level used when neither the argument nor the environment supplies one.
|
|
33
|
+
DEFAULT_LEVEL: Final = "INFO"
|
|
34
|
+
#: Human-readable format used when JSON output is not requested.
|
|
35
|
+
HUMAN_FORMAT: Final = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
|
36
|
+
|
|
37
|
+
_FALSEY: Final = ("", "0", "false", "no", "off")
|
|
38
|
+
|
|
39
|
+
# logger name -> (installed handler, previous level, previous propagate)
|
|
40
|
+
_installed: dict[str, tuple[logging.Handler, int, bool]] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _truthy(value: str | None) -> bool:
|
|
44
|
+
"""Return ``True`` when an environment flag is set to a truthy value."""
|
|
45
|
+
return value is not None and value.strip().lower() not in _FALSEY
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _env_names(prefix: str) -> tuple[str, str]:
|
|
49
|
+
"""Return the ``(json_var, level_var)`` names for ``prefix``."""
|
|
50
|
+
return f"{prefix}_LOG_JSON", f"{prefix}_LOG_LEVEL"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def logging_enabled(prefix: str = "CAPSIZE") -> bool:
|
|
54
|
+
"""Return ``True`` when the environment asks for logging to be set up."""
|
|
55
|
+
json_var, level_var = _env_names(prefix)
|
|
56
|
+
return _truthy(os.environ.get(json_var)) or bool(os.environ.get(level_var))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_level(name: str | None, level_var: str) -> int:
|
|
60
|
+
"""Resolve a level from the argument, the environment, or the default."""
|
|
61
|
+
resolved = name or os.environ.get(level_var) or DEFAULT_LEVEL
|
|
62
|
+
candidate = getattr(logging, str(resolved).upper(), None)
|
|
63
|
+
return candidate if isinstance(candidate, int) else logging.INFO
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_json(json_mode: bool | None, json_var: str) -> bool:
|
|
67
|
+
"""Resolve the JSON decision from the argument or the environment."""
|
|
68
|
+
if json_mode is not None:
|
|
69
|
+
return json_mode
|
|
70
|
+
return _truthy(os.environ.get(json_var))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_handler(use_json: bool, stream: TextIO | None) -> logging.Handler:
|
|
74
|
+
"""Return a stream handler carrying the JSON or human formatter."""
|
|
75
|
+
handler = logging.StreamHandler(stream)
|
|
76
|
+
formatter: logging.Formatter = (
|
|
77
|
+
JsonFormatter() if use_json else logging.Formatter(HUMAN_FORMAT)
|
|
78
|
+
)
|
|
79
|
+
handler.setFormatter(formatter)
|
|
80
|
+
return handler
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def configure_logging(
|
|
84
|
+
level: str | None = None,
|
|
85
|
+
json_mode: bool | None = None,
|
|
86
|
+
*,
|
|
87
|
+
force: bool = False,
|
|
88
|
+
stream: TextIO | None = None,
|
|
89
|
+
logger_name: str = DEFAULT_LOGGER_NAME,
|
|
90
|
+
env_prefix: str = "CAPSIZE",
|
|
91
|
+
) -> logging.Handler | None:
|
|
92
|
+
"""Attach the opt-in handler, or return ``None`` when logging is disabled.
|
|
93
|
+
|
|
94
|
+
``level`` / ``json_mode`` override ``<prefix>_LOG_LEVEL`` /
|
|
95
|
+
``<prefix>_LOG_JSON``. ``force`` configures even when no environment
|
|
96
|
+
variable is set, and ``stream`` redirects output (used by tests). Repeating
|
|
97
|
+
the call is safe: any previously installed handler is removed first.
|
|
98
|
+
"""
|
|
99
|
+
json_var, level_var = _env_names(env_prefix)
|
|
100
|
+
if not force and not logging_enabled(env_prefix):
|
|
101
|
+
return None
|
|
102
|
+
reset_logging(logger_name)
|
|
103
|
+
logger = logging.getLogger(logger_name)
|
|
104
|
+
handler = _build_handler(_resolve_json(json_mode, json_var), stream)
|
|
105
|
+
_installed[logger_name] = (handler, logger.level, logger.propagate)
|
|
106
|
+
logger.addHandler(handler)
|
|
107
|
+
logger.setLevel(_resolve_level(level, level_var))
|
|
108
|
+
logger.propagate = False
|
|
109
|
+
return handler
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def reset_logging(logger_name: str = DEFAULT_LOGGER_NAME) -> None:
|
|
113
|
+
"""Remove the installed handler and restore the previous logger state."""
|
|
114
|
+
saved: Any = _installed.pop(logger_name, None)
|
|
115
|
+
if saved is None:
|
|
116
|
+
return
|
|
117
|
+
handler, level, propagate = saved
|
|
118
|
+
logger = logging.getLogger(logger_name)
|
|
119
|
+
logger.removeHandler(handler)
|
|
120
|
+
handler.close()
|
|
121
|
+
logger.setLevel(level)
|
|
122
|
+
logger.propagate = propagate
|
capsize_commons/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file: this package ships inline type information (PEP 561).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A single shared ``UNSET`` sentinel.
|
|
2
|
+
|
|
3
|
+
Projects keep re-declaring ``_UNSET = object()`` so that ``None`` can be a
|
|
4
|
+
legitimate value distinct from "not supplied". This gives that sentinel one
|
|
5
|
+
identity across the fleet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
__all__ = ["UNSET", "is_unset"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Unset:
|
|
16
|
+
"""The type of :data:`UNSET`. Instantiate only through the singleton."""
|
|
17
|
+
|
|
18
|
+
__slots__ = ()
|
|
19
|
+
|
|
20
|
+
def __bool__(self) -> bool:
|
|
21
|
+
"""Return ``False`` so ``if not value`` reads as "nothing given"."""
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
"""Return a stable, unambiguous spelling."""
|
|
26
|
+
return "UNSET"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
#: Sentinel for "no value was supplied", distinct from ``None``.
|
|
30
|
+
UNSET: Final[_Unset] = _Unset()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_unset(value: object) -> bool:
|
|
34
|
+
"""Return ``True`` when ``value`` is the shared :data:`UNSET` sentinel."""
|
|
35
|
+
return value is UNSET
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Text and naming helpers that implement the fleet's casing rules (§3.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from capsize_commons.text.case import (
|
|
6
|
+
slugify,
|
|
7
|
+
to_kebab_case,
|
|
8
|
+
to_pascal_case,
|
|
9
|
+
to_snake_case,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["slugify", "to_kebab_case", "to_pascal_case", "to_snake_case"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Case conversion for repository, file and symbol naming (§3.1).
|
|
2
|
+
|
|
3
|
+
Repos and files are ``kebab-case``, Python symbols ``snake_case`` and classes
|
|
4
|
+
``PascalCase``. Every project was writing a slightly different ``slugify``;
|
|
5
|
+
this is the one they share.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import unicodedata
|
|
12
|
+
|
|
13
|
+
__all__ = ["slugify", "to_kebab_case", "to_pascal_case", "to_snake_case"]
|
|
14
|
+
|
|
15
|
+
#: Split on any run of characters that are not alphanumerics.
|
|
16
|
+
_SEPARATORS = re.compile(r"[^0-9A-Za-z]+")
|
|
17
|
+
#: Split camelCase / PascalCase / HTTPServer at the correct boundaries.
|
|
18
|
+
_CAMEL_BOUNDARY = re.compile(
|
|
19
|
+
r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _to_ascii(text: str) -> str:
|
|
24
|
+
"""Fold accents to their closest ASCII spelling."""
|
|
25
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
26
|
+
return decomposed.encode("ascii", "ignore").decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _words(value: str) -> list[str]:
|
|
30
|
+
"""Split ``value`` into its alphanumeric words, accents folded."""
|
|
31
|
+
spaced = _CAMEL_BOUNDARY.sub(" ", _to_ascii(value).strip())
|
|
32
|
+
return [word for word in _SEPARATORS.split(spaced) if word]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def slugify(
|
|
36
|
+
value: str, *, separator: str = "-", lowercase: bool = True
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Join the words of ``value`` with ``separator``.
|
|
39
|
+
|
|
40
|
+
>>> slugify("Capsize Persona!")\
|
|
41
|
+
# doctest: +SKIP
|
|
42
|
+
'capsize-persona'
|
|
43
|
+
"""
|
|
44
|
+
words = _words(value)
|
|
45
|
+
if lowercase:
|
|
46
|
+
words = [word.lower() for word in words]
|
|
47
|
+
return separator.join(words)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def to_kebab_case(value: str) -> str:
|
|
51
|
+
"""Return ``value`` as ``kebab-case`` (the repo/file convention)."""
|
|
52
|
+
return slugify(value, separator="-")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def to_snake_case(value: str) -> str:
|
|
56
|
+
"""Return ``value`` as ``snake_case`` (the Python symbol convention)."""
|
|
57
|
+
return slugify(value, separator="_")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_pascal_case(value: str) -> str:
|
|
61
|
+
"""Return ``value`` as ``PascalCase`` (the class convention)."""
|
|
62
|
+
return "".join(word.capitalize() for word in _words(value))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""FastAPI integration helpers: API-key auth (§13) and health routes (§14).
|
|
2
|
+
|
|
3
|
+
Requires the ``web`` extra (``fastapi``).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from capsize_commons.web.auth import check_api_key, make_api_key_dependency
|
|
9
|
+
from capsize_commons.web.health import health_router, install_health_routes
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"check_api_key",
|
|
13
|
+
"health_router",
|
|
14
|
+
"install_health_routes",
|
|
15
|
+
"make_api_key_dependency",
|
|
16
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared-secret API-key authentication for service-to-service calls (§13).
|
|
2
|
+
|
|
3
|
+
``capsize-social`` and ``capsize-persona`` carried byte-identical copies of
|
|
4
|
+
``require_api_key``. This generalizes that function into a dependency factory
|
|
5
|
+
whose expected key is read lazily, so it can pull from settings or a secret
|
|
6
|
+
store without this module knowing which.
|
|
7
|
+
|
|
8
|
+
The security-relevant behaviours are preserved deliberately:
|
|
9
|
+
|
|
10
|
+
* an **empty** expected key rejects every request (fail closed — a service
|
|
11
|
+
started without configuration must not be wide open);
|
|
12
|
+
* comparison uses :func:`secrets.compare_digest`, so a wrong key cannot be
|
|
13
|
+
recovered by timing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import secrets
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
from fastapi import Header, HTTPException, status
|
|
22
|
+
|
|
23
|
+
__all__ = ["check_api_key", "make_api_key_dependency"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_api_key(provided: str, expected: str) -> bool:
|
|
27
|
+
"""Return ``True`` when ``provided`` matches a non-empty ``expected``."""
|
|
28
|
+
if not expected:
|
|
29
|
+
return False
|
|
30
|
+
return secrets.compare_digest(provided, expected)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_api_key_dependency(
|
|
34
|
+
expected_key: Callable[[], str],
|
|
35
|
+
*,
|
|
36
|
+
header_name: str = "X-API-Key",
|
|
37
|
+
) -> Callable[..., None]:
|
|
38
|
+
"""Build a FastAPI dependency enforcing the shared API key.
|
|
39
|
+
|
|
40
|
+
``expected_key`` is called per request so the value can come from mutable
|
|
41
|
+
or reloadable configuration. Raises ``401`` on any mismatch.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def require_api_key(
|
|
45
|
+
x_api_key: str = Header(default="", alias=header_name),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Reject the request unless the presented key matches."""
|
|
48
|
+
if not check_api_key(x_api_key, expected_key()):
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
51
|
+
detail="Invalid or missing API key",
|
|
52
|
+
headers={"WWW-Authenticate": "ApiKey"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return require_api_key
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Liveness and readiness routes (§14).
|
|
2
|
+
|
|
3
|
+
Every service in the fleet hand-rolled ``GET /health``. This adds the
|
|
4
|
+
``GET /ready`` counterpart §14 also requires, with an optional readiness
|
|
5
|
+
predicate so a service can report "up but not able to serve" (for example,
|
|
6
|
+
still loading a model or warming a pool) as ``503``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, FastAPI, HTTPException, status
|
|
15
|
+
|
|
16
|
+
__all__ = ["health_router", "install_health_routes"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def health_router(
|
|
20
|
+
*,
|
|
21
|
+
ready_check: Callable[[], bool] | None = None,
|
|
22
|
+
tags: Sequence[str | Enum] | None = None,
|
|
23
|
+
) -> APIRouter:
|
|
24
|
+
"""Return a router exposing ``/health`` and ``/ready``.
|
|
25
|
+
|
|
26
|
+
When ``ready_check`` is given and returns ``False``, ``/ready`` responds
|
|
27
|
+
``503`` so an orchestrator stops routing traffic to the instance.
|
|
28
|
+
"""
|
|
29
|
+
router = APIRouter(tags=None if tags is None else list(tags))
|
|
30
|
+
|
|
31
|
+
@router.get("/health")
|
|
32
|
+
def health() -> dict[str, str]:
|
|
33
|
+
"""Report liveness (the process is running)."""
|
|
34
|
+
return {"status": "ok"}
|
|
35
|
+
|
|
36
|
+
@router.get("/ready")
|
|
37
|
+
def ready() -> dict[str, str]:
|
|
38
|
+
"""Report readiness, consulting ``ready_check`` when supplied."""
|
|
39
|
+
if ready_check is not None and not ready_check():
|
|
40
|
+
raise HTTPException(
|
|
41
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
42
|
+
detail="Not ready",
|
|
43
|
+
)
|
|
44
|
+
return {"status": "ready"}
|
|
45
|
+
|
|
46
|
+
return router
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def install_health_routes(
|
|
50
|
+
app: FastAPI,
|
|
51
|
+
*,
|
|
52
|
+
ready_check: Callable[[], bool] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Include the health router on ``app`` at the root path."""
|
|
55
|
+
app.include_router(health_router(ready_check=ready_check))
|
|
@@ -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,22 @@
|
|
|
1
|
+
capsize_commons/__init__.py,sha256=J1k5q_S1QgNGtxfiZXZWxKuZG82FaPitjhF5SrNnqJY,1445
|
|
2
|
+
capsize_commons/py.typed,sha256=3CM6tLfKmfP9zl61YavKQobU_0-it54Wf_P7oI9ttmU,69
|
|
3
|
+
capsize_commons/sentinel.py,sha256=I5KGycT5eKbe4i6a6df4l-nMy3i9CsYMBc12ek40amI,912
|
|
4
|
+
capsize_commons/config/__init__.py,sha256=Zu3-qyoNQzkHZvK1Yqs2LjPe_-p_-xVDXv7lUgcNs18,342
|
|
5
|
+
capsize_commons/config/base.py,sha256=QgU979ZMEzQcJoXIO8CdRm6W4sPYLYo8D5ayphZaYJo,1403
|
|
6
|
+
capsize_commons/db/__init__.py,sha256=XfgYul2kZQkErfNZDVrgtft0Y4qpXcDiChGmgNkbloM,471
|
|
7
|
+
capsize_commons/db/base.py,sha256=BhqeNm1NuYnDSON49beZfWoIdBWQh7zu9xOJ5bAND1o,3048
|
|
8
|
+
capsize_commons/db/engine.py,sha256=arjNsgv66Zsoa0TjxnLuq4y7Lx_VdN-bRb-UQ1QMiaU,2025
|
|
9
|
+
capsize_commons/http/__init__.py,sha256=o844E7yAK8AB2DP1nCDUIwDaIbV9DDHGYgtPyZjGckc,254
|
|
10
|
+
capsize_commons/http/retry.py,sha256=ebFflmmJG0Eo2Mu8y0bVMhDgnlmk9u68Va7sBNXz_qw,3142
|
|
11
|
+
capsize_commons/logging/__init__.py,sha256=u-zdZdiywOdoxz3Js_pBW-0piOUFyyx65IyE-bvE0_A,527
|
|
12
|
+
capsize_commons/logging/json_formatter.py,sha256=wWZ_YpcvlFEpF5sBZj55kfiD7MRwqH0LeIzhlxhXKhk,2076
|
|
13
|
+
capsize_commons/logging/setup.py,sha256=yonyQbuixG1iiProW4PxYzJecleJCOGtZCT8KUHjU0Y,4508
|
|
14
|
+
capsize_commons/text/__init__.py,sha256=BLJXo0BlUXFWrWlsD96n-AN7czcJKSyTYKAFCl3yn6s,304
|
|
15
|
+
capsize_commons/text/case.py,sha256=BhB1H14oReg2RPUghsPgHAPT-_AcFtDqkNj7--HGLVc,1951
|
|
16
|
+
capsize_commons/web/__init__.py,sha256=FGhpwkbttKQbQWKVezOZCxnkNPvZL6fCN4crJavAREw,431
|
|
17
|
+
capsize_commons/web/auth.py,sha256=kqkLB_fswz1BWrJyHjqVkJR5ti9DOIxA39rtA5HH8nc,1900
|
|
18
|
+
capsize_commons/web/health.py,sha256=CidKxOIKGemmzJH9trz7WTp_m1AlUDnOm1gNo4DdKNQ,1756
|
|
19
|
+
capsize_commons-0.1.0.dist-info/METADATA,sha256=Sp0nX1iZPlUUEgwzmrGNItzet6w0ATj2cNHASEhBA5g,3734
|
|
20
|
+
capsize_commons-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
21
|
+
capsize_commons-0.1.0.dist-info/top_level.txt,sha256=h-Tcw-VCV-tS5L7Rni6ekVupGFyuCtpvHaZqDe2YQE8,16
|
|
22
|
+
capsize_commons-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
capsize_commons
|