sapium-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.
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ commons:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.13"
15
+ - name: Install dependencies
16
+ run: pip install -e ".[dev]"
17
+ - name: Lint
18
+ run: ruff check src/ && ruff format --check src/
19
+ - name: Test
20
+ run: pytest tests/
@@ -0,0 +1,24 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: read
14
+ id-token: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.13"
20
+ - name: Build package
21
+ run: |
22
+ pip install build
23
+ python -m build
24
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ *.pyc
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: sapium-commons
3
+ Version: 0.1.0
4
+ Summary: Sapium Commons — shared utilities for sapium services
5
+ Requires-Dist: structlog
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest; extra == 'dev'
8
+ Requires-Dist: pytest-cov; extra == 'dev'
9
+ Requires-Dist: ruff; extra == 'dev'
@@ -0,0 +1,74 @@
1
+ # sapium-commons
2
+
3
+ Shared utilities for Sapium services. Used by [conduit](https://github.com/sapiumai/conduit) and [augur](https://github.com/sapiumai/augur).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install sapium-commons
9
+ ```
10
+
11
+ ## Contents
12
+
13
+ ### `sapium_commons.core.log`
14
+
15
+ Structured logging via [structlog](https://www.structlog.org/). Provides namespace filtering, transaction ID binding, and a consistent log format across services.
16
+
17
+ ```python
18
+ from sapium_commons.core.log import setup_logging, bind_transaction_id, get_logger
19
+
20
+ setup_logging(
21
+ name=__name__,
22
+ log_level="INFO",
23
+ log_output="stdout",
24
+ log_output_type="json",
25
+ namespace="sapium_myservice",
26
+ )
27
+
28
+ log = get_logger(__name__)
29
+ bind_transaction_id() # generates and binds a UUID4
30
+ log.info("service started")
31
+ ```
32
+
33
+ ### `sapium_commons.core.custom_exceptions`
34
+
35
+ Typed exception hierarchy for consistent error handling.
36
+
37
+ ```python
38
+ from sapium_commons.core.custom_exceptions import (
39
+ NotFoundError,
40
+ ValidationError,
41
+ ServiceError,
42
+ DatabaseError,
43
+ )
44
+ ```
45
+
46
+ ### `sapium_commons.core.types`
47
+
48
+ Common type aliases.
49
+
50
+ ```python
51
+ from sapium_commons.core.types import JsonDict # dict[str, Any]
52
+ ```
53
+
54
+ ### `sapium_commons.utils.datetime_utils`
55
+
56
+ ```python
57
+ from sapium_commons.utils.datetime_utils import utcnow # timezone-aware datetime
58
+ ```
59
+
60
+ ### `sapium_commons.utils.db_helper`
61
+
62
+ ```python
63
+ from sapium_commons.utils.db_helper import make_postgres_url
64
+ ```
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ pip install -e ".[dev]"
70
+ ruff check src/ && ruff format src/
71
+ pytest tests/
72
+ ```
73
+
74
+ Releases are published to PyPI automatically on `v*` tags via GitHub Actions.
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "sapium-commons"
3
+ version = "0.1.0"
4
+ description = "Sapium Commons — shared utilities for sapium services"
5
+
6
+ dependencies = [
7
+ "structlog",
8
+ ]
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest", "pytest-cov", "ruff"]
12
+
13
+ [tool.ruff]
14
+ line-length = 88
15
+
16
+ [tool.pytest.ini_options]
17
+ testpaths = ["tests"]
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/sapium_commons"]
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
@@ -0,0 +1 @@
1
+ """Sapium Commons — shared utilities for sapium services."""
@@ -0,0 +1 @@
1
+ """Core utilities for sapium_commons."""
@@ -0,0 +1,110 @@
1
+ """Custom exceptions for sapium services, providing specific error handling for
2
+ various failure scenarios.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class BaseCustomException(Exception):
9
+ """Base exception class for all custom exceptions."""
10
+
11
+ def __init__(self, message: str, detail: Optional[str] = None):
12
+ self.message = message
13
+ self.detail = detail
14
+ super().__init__(self.message)
15
+
16
+
17
+ class DatabaseError(BaseCustomException):
18
+ """Raised when database operations fail."""
19
+
20
+ pass
21
+
22
+
23
+ class ValidationError(BaseCustomException):
24
+ """Raised when data validation fails."""
25
+
26
+ pass
27
+
28
+
29
+ class NotFoundError(BaseCustomException):
30
+ """Raised when a requested resource is not found."""
31
+
32
+ pass
33
+
34
+
35
+ class DuplicateError(BaseCustomException):
36
+ """Raised when trying to create a duplicate resource."""
37
+
38
+ pass
39
+
40
+
41
+ class FileError(BaseCustomException):
42
+ """Raised when file operations fail."""
43
+
44
+ pass
45
+
46
+
47
+ class ServiceError(BaseCustomException):
48
+ """Raised when service layer operations fail."""
49
+
50
+ pass
51
+
52
+
53
+ class ExternalServiceError(BaseCustomException):
54
+ """Raised when external service calls fail."""
55
+
56
+ pass
57
+
58
+
59
+ class ConfigurationError(BaseCustomException):
60
+ """Raised when configuration values are missing or invalid."""
61
+
62
+ pass
63
+
64
+
65
+ class SchedulerError(BaseCustomException):
66
+ """Raised when scheduler operations fail."""
67
+
68
+ pass
69
+
70
+
71
+ class StorageError(BaseCustomException):
72
+ """Raised when storage operations fail."""
73
+
74
+ pass
75
+
76
+
77
+ class UnauthorizedError(BaseCustomException):
78
+ """Raised when a request lacks valid credentials."""
79
+
80
+ pass
81
+
82
+
83
+ class ForbiddenError(BaseCustomException):
84
+ """Raised when a request is understood but not permitted."""
85
+
86
+ pass
87
+
88
+
89
+ class TooManyRequestsError(BaseCustomException):
90
+ """Raised when a rate limit is exceeded."""
91
+
92
+ pass
93
+
94
+
95
+ class ConflictError(BaseCustomException):
96
+ """Raised when a resource already exists with conflicting state."""
97
+
98
+ pass
99
+
100
+
101
+ class ArtifactError(BaseCustomException):
102
+ """Raised when model artifact loading or execution fails."""
103
+
104
+ pass
105
+
106
+
107
+ class ConnectorError(BaseCustomException):
108
+ """Raised when FeatureSourceConnector operations fail."""
109
+
110
+ pass
@@ -0,0 +1,141 @@
1
+ """Structured logging helpers for sapium services.
2
+
3
+ Provides `setup_logging`, namespace filtering, transaction ID binding, and
4
+ structlog logger access. Has no dependency on sapium_augur or
5
+ sapium_augur_console — either service imports from here.
6
+ """
7
+
8
+ import contextvars
9
+ import logging
10
+ import sys
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ import structlog
15
+ from structlog import contextvars as structlog_contextvars
16
+
17
+
18
+ class _NamespaceFilter(logging.Filter):
19
+ """Allow only log records whose name matches a namespace prefix."""
20
+
21
+ def __init__(self, prefix: str) -> None:
22
+ """Initialise with the namespace prefix to allow."""
23
+ super().__init__()
24
+ self.prefix = prefix
25
+
26
+ def filter(self, record: logging.LogRecord) -> bool:
27
+ """Return True when the record originates from the allowed namespace."""
28
+ return record.name == self.prefix or record.name.startswith(f"{self.prefix}.")
29
+
30
+
31
+ def setup_logging(
32
+ name: str,
33
+ log_level: str,
34
+ log_output: str,
35
+ log_output_type: str,
36
+ namespace: str,
37
+ ) -> None:
38
+ """Configure structlog for the given service namespace.
39
+
40
+ Args:
41
+ name: Logger name passed by the caller (e.g. __name__).
42
+ log_level: Logging level string, e.g. "INFO".
43
+ log_output: Destination — "stdout", "file", or "db".
44
+ log_output_type: Renderer — "json" or "text".
45
+ namespace: Namespace prefix used by _NamespaceFilter.
46
+ """
47
+ root_logger = logging.getLogger()
48
+ root_logger.setLevel(level=log_level)
49
+
50
+ for h in list(root_logger.handlers):
51
+ root_logger.removeHandler(h)
52
+
53
+ match log_output:
54
+ case "file":
55
+ log_dir = Path("logs")
56
+ log_dir.mkdir(parents=True, exist_ok=True)
57
+ handler: logging.Handler = logging.FileHandler(log_dir / f"{namespace}.log")
58
+ case "stdout" | _:
59
+ if log_output not in ("stdout",):
60
+ logging.warning(
61
+ "Unsupported log_output %r — falling back to stdout", log_output
62
+ )
63
+ handler = logging.StreamHandler(sys.stdout)
64
+
65
+ if log_output_type == "json":
66
+ formatter = logging.Formatter("%(message)s")
67
+ else:
68
+ formatter = logging.Formatter(
69
+ "[%(asctime)s] - [%(levelname)s]: %(message)s",
70
+ datefmt="%Y-%m-%d %H:%M:%S",
71
+ )
72
+
73
+ handler.setLevel(level=log_level)
74
+ handler.setFormatter(formatter)
75
+ handler.addFilter(_NamespaceFilter(prefix=namespace))
76
+ root_logger.addHandler(handler)
77
+
78
+ logging.getLogger(name).propagate = True
79
+
80
+ base_processors = [
81
+ structlog_contextvars.merge_contextvars,
82
+ structlog.stdlib.add_logger_name,
83
+ structlog.stdlib.add_log_level,
84
+ structlog.processors.TimeStamper(fmt="iso"),
85
+ structlog.processors.StackInfoRenderer(),
86
+ structlog.processors.format_exc_info,
87
+ ]
88
+
89
+ if log_output_type == "text":
90
+
91
+ def _message_only_renderer(logger, method_name, event_dict):
92
+ """Return only the event message string, discarding structured fields."""
93
+ message = event_dict.get("event")
94
+ if message is None:
95
+ message = event_dict.get("msg", "")
96
+ return str(message)
97
+
98
+ processors = base_processors + [_message_only_renderer]
99
+ else:
100
+ processors = base_processors + [structlog.processors.JSONRenderer()]
101
+
102
+ structlog.configure(
103
+ processors=processors,
104
+ logger_factory=structlog.stdlib.LoggerFactory(),
105
+ wrapper_class=structlog.stdlib.BoundLogger,
106
+ cache_logger_on_first_use=False,
107
+ )
108
+
109
+
110
+ def bind_transaction_id(transaction_id: str | None = None) -> str:
111
+ """Bind a transaction_id into structlog contextvars for the current context.
112
+
113
+ Generates a UUID4 if no id is provided. Returns the bound transaction id.
114
+ """
115
+ if transaction_id is None:
116
+ transaction_id = str(uuid.uuid4())
117
+ structlog_contextvars.bind_contextvars(transaction_id=transaction_id)
118
+ _tx_ctx.set(transaction_id)
119
+ return transaction_id
120
+
121
+
122
+ def clear_transaction_context() -> None:
123
+ """Clear all structlog contextvars for the current context."""
124
+ structlog_contextvars.clear_contextvars()
125
+ try:
126
+ _tx_ctx.set(None)
127
+ except Exception:
128
+ pass
129
+
130
+
131
+ def get_current_transaction_id() -> str | None:
132
+ """Return the currently bound transaction id, or None."""
133
+ return _tx_ctx.get(None)
134
+
135
+
136
+ def get_logger(name: str) -> structlog.BoundLogger:
137
+ """Return a structlog logger instance for the given name."""
138
+ return structlog.get_logger(name)
139
+
140
+
141
+ _tx_ctx: contextvars.ContextVar = contextvars.ContextVar("transaction_id", default=None)
@@ -0,0 +1,5 @@
1
+ """Common type aliases shared across sapium_augur services."""
2
+
3
+ from typing import Any
4
+
5
+ JsonDict = dict[str, Any]
@@ -0,0 +1 @@
1
+ """Utility helpers for sapium_commons."""
@@ -0,0 +1,8 @@
1
+ """UTC datetime utilities."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ def utcnow() -> datetime:
7
+ """Return the current UTC datetime (timezone-aware)."""
8
+ return datetime.now(timezone.utc)
@@ -0,0 +1,8 @@
1
+ """Connection string builders for PostgreSQL."""
2
+
3
+ from urllib.parse import quote_plus
4
+
5
+
6
+ def make_postgres_url(host: str, port: int, user: str, password: str, db: str) -> str:
7
+ """Return a psycopg2-compatible PostgreSQL connection string."""
8
+ return f"postgresql+psycopg2://{quote_plus(user)}:{quote_plus(password)}@{host}:{port}/{db}"
File without changes
@@ -0,0 +1,31 @@
1
+ """Smoke tests for sapium_commons."""
2
+
3
+ from sapium_commons.core.custom_exceptions import BaseCustomException
4
+ from sapium_commons.core.types import JsonDict
5
+ from sapium_commons.utils.datetime_utils import utcnow
6
+ from sapium_commons.utils.db_helper import make_postgres_url
7
+
8
+
9
+ def test_utcnow_is_aware() -> None:
10
+ """utcnow() returns a timezone-aware datetime."""
11
+ dt = utcnow()
12
+ assert dt.tzinfo is not None
13
+
14
+
15
+ def test_make_postgres_url() -> None:
16
+ """make_postgres_url returns a valid psycopg2 connection string."""
17
+ url = make_postgres_url("localhost", 5432, "user", "pass", "db")
18
+ assert url == "postgresql+psycopg2://user:pass@localhost:5432/db"
19
+
20
+
21
+ def test_json_dict_alias() -> None:
22
+ """JsonDict is a valid type alias for dict[str, Any]."""
23
+ d: JsonDict = {"key": "value"}
24
+ assert d["key"] == "value"
25
+
26
+
27
+ def test_base_custom_exception() -> None:
28
+ """BaseCustomException stores message and detail."""
29
+ exc = BaseCustomException(message="test", detail="extra")
30
+ assert exc.message == "test"
31
+ assert exc.detail == "extra"