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.
- sapium_commons-0.1.0/.github/workflows/ci.yml +20 -0
- sapium_commons-0.1.0/.github/workflows/publish.yml +24 -0
- sapium_commons-0.1.0/.gitignore +9 -0
- sapium_commons-0.1.0/PKG-INFO +9 -0
- sapium_commons-0.1.0/README.md +74 -0
- sapium_commons-0.1.0/pyproject.toml +24 -0
- sapium_commons-0.1.0/src/sapium_commons/__init__.py +1 -0
- sapium_commons-0.1.0/src/sapium_commons/core/__init__.py +1 -0
- sapium_commons-0.1.0/src/sapium_commons/core/custom_exceptions.py +110 -0
- sapium_commons-0.1.0/src/sapium_commons/core/log.py +141 -0
- sapium_commons-0.1.0/src/sapium_commons/core/types.py +5 -0
- sapium_commons-0.1.0/src/sapium_commons/utils/__init__.py +1 -0
- sapium_commons-0.1.0/src/sapium_commons/utils/datetime_utils.py +8 -0
- sapium_commons-0.1.0/src/sapium_commons/utils/db_helper.py +8 -0
- sapium_commons-0.1.0/tests/unit/__init__.py +0 -0
- sapium_commons-0.1.0/tests/unit/test_smoke.py +31 -0
|
@@ -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
|
+
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 @@
|
|
|
1
|
+
"""Utility helpers for sapium_commons."""
|
|
@@ -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"
|