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.
Files changed (36) hide show
  1. capsize_commons-0.1.0/PKG-INFO +104 -0
  2. capsize_commons-0.1.0/README.md +63 -0
  3. capsize_commons-0.1.0/pyproject.toml +71 -0
  4. capsize_commons-0.1.0/setup.cfg +4 -0
  5. capsize_commons-0.1.0/src/capsize_commons/__init__.py +37 -0
  6. capsize_commons-0.1.0/src/capsize_commons/config/__init__.py +14 -0
  7. capsize_commons-0.1.0/src/capsize_commons/config/base.py +46 -0
  8. capsize_commons-0.1.0/src/capsize_commons/db/__init__.py +25 -0
  9. capsize_commons-0.1.0/src/capsize_commons/db/base.py +95 -0
  10. capsize_commons-0.1.0/src/capsize_commons/db/engine.py +60 -0
  11. capsize_commons-0.1.0/src/capsize_commons/http/__init__.py +10 -0
  12. capsize_commons-0.1.0/src/capsize_commons/http/retry.py +97 -0
  13. capsize_commons-0.1.0/src/capsize_commons/logging/__init__.py +24 -0
  14. capsize_commons-0.1.0/src/capsize_commons/logging/json_formatter.py +55 -0
  15. capsize_commons-0.1.0/src/capsize_commons/logging/setup.py +122 -0
  16. capsize_commons-0.1.0/src/capsize_commons/py.typed +1 -0
  17. capsize_commons-0.1.0/src/capsize_commons/sentinel.py +35 -0
  18. capsize_commons-0.1.0/src/capsize_commons/text/__init__.py +12 -0
  19. capsize_commons-0.1.0/src/capsize_commons/text/case.py +62 -0
  20. capsize_commons-0.1.0/src/capsize_commons/web/__init__.py +16 -0
  21. capsize_commons-0.1.0/src/capsize_commons/web/auth.py +55 -0
  22. capsize_commons-0.1.0/src/capsize_commons/web/health.py +55 -0
  23. capsize_commons-0.1.0/src/capsize_commons.egg-info/PKG-INFO +104 -0
  24. capsize_commons-0.1.0/src/capsize_commons.egg-info/SOURCES.txt +34 -0
  25. capsize_commons-0.1.0/src/capsize_commons.egg-info/dependency_links.txt +1 -0
  26. capsize_commons-0.1.0/src/capsize_commons.egg-info/requires.txt +31 -0
  27. capsize_commons-0.1.0/src/capsize_commons.egg-info/top_level.txt +1 -0
  28. capsize_commons-0.1.0/tests/test_case.py +40 -0
  29. capsize_commons-0.1.0/tests/test_config.py +45 -0
  30. capsize_commons-0.1.0/tests/test_db.py +72 -0
  31. capsize_commons-0.1.0/tests/test_json_formatter.py +49 -0
  32. capsize_commons-0.1.0/tests/test_logging_setup.py +72 -0
  33. capsize_commons-0.1.0/tests/test_retry.py +93 -0
  34. capsize_commons-0.1.0/tests/test_sentinel.py +16 -0
  35. capsize_commons-0.1.0/tests/test_web_auth.py +56 -0
  36. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]