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.
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ capsize_commons