wshtlib 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.
- wshtlib-0.1.0/.github/workflows/ci.yml +32 -0
- wshtlib-0.1.0/.gitignore +14 -0
- wshtlib-0.1.0/CHANGELOG.md +14 -0
- wshtlib-0.1.0/LICENSE +21 -0
- wshtlib-0.1.0/PKG-INFO +12 -0
- wshtlib-0.1.0/README.md +116 -0
- wshtlib-0.1.0/pyproject.toml +54 -0
- wshtlib-0.1.0/tests/__init__.py +1 -0
- wshtlib-0.1.0/tests/conftest.py +68 -0
- wshtlib-0.1.0/tests/test_context.py +170 -0
- wshtlib-0.1.0/tests/test_decorators.py +165 -0
- wshtlib-0.1.0/tests/test_env.py +22 -0
- wshtlib-0.1.0/tests/test_http.py +30 -0
- wshtlib-0.1.0/tests/test_logger.py +342 -0
- wshtlib-0.1.0/tests/test_metrics.py +191 -0
- wshtlib-0.1.0/tests/test_middleware.py +142 -0
- wshtlib-0.1.0/tests/test_readme.py +174 -0
- wshtlib-0.1.0/tests/test_scaffold.py +182 -0
- wshtlib-0.1.0/tests/test_secrets.py +130 -0
- wshtlib-0.1.0/uv.lock +542 -0
- wshtlib-0.1.0/wshtlib/__init__.py +34 -0
- wshtlib-0.1.0/wshtlib/context.py +62 -0
- wshtlib-0.1.0/wshtlib/decorators.py +39 -0
- wshtlib-0.1.0/wshtlib/env.py +16 -0
- wshtlib-0.1.0/wshtlib/http.py +16 -0
- wshtlib-0.1.0/wshtlib/logger.py +142 -0
- wshtlib-0.1.0/wshtlib/metrics.py +118 -0
- wshtlib-0.1.0/wshtlib/middleware.py +50 -0
- wshtlib-0.1.0/wshtlib/secrets.py +27 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v5
|
|
16
|
+
- run: uv sync --group dev
|
|
17
|
+
- run: uv run pytest
|
|
18
|
+
- run: uv run mypy wshtlib
|
|
19
|
+
- run: uv run ruff check wshtlib
|
|
20
|
+
|
|
21
|
+
publish:
|
|
22
|
+
needs: test
|
|
23
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
environment: pypi
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
- uses: astral-sh/setup-uv@v5
|
|
31
|
+
- run: uv build
|
|
32
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
wshtlib-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-04-12
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
- Structured JSON logging (`get_logger`) with Lambda context and runtime enrichment
|
|
9
|
+
- CloudWatch EMF metrics (`MetricsContext`, `metrics`) with namespace and dimension support
|
|
10
|
+
- Request context propagation via `ContextVar` (`init_context`, `get_context`, `set_user_id`)
|
|
11
|
+
- Lambda handler decorator (`@bootstrap`) with warming event detection and error boundary
|
|
12
|
+
- FastAPI/Starlette middleware for automatic context init and request logging (`WshtlibMiddleware`)
|
|
13
|
+
- AWS Secrets Manager helper (`require_secret`) with cold-start caching
|
|
14
|
+
- Utilities: `require_env`, `require_https_url`
|
wshtlib-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wholeshoot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
wshtlib-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wshtlib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared runtime library for Wholeshoot services
|
|
5
|
+
Project-URL: Homepage, https://github.com/polsen/wshtlib
|
|
6
|
+
Project-URL: Repository, https://github.com/polsen/wshtlib
|
|
7
|
+
Author: Wholeshoot
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Provides-Extra: fastapi
|
|
12
|
+
Requires-Dist: starlette>=0.27; extra == 'fastapi'
|
wshtlib-0.1.0/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# wshtlib
|
|
2
|
+
|
|
3
|
+
Lightweight observability library for AWS Lambda and FastAPI. Zero external dependencies.
|
|
4
|
+
|
|
5
|
+
A focused alternative to [aws-powertools](https://github.com/aws-powertools/powertools-lambda-python) — covers structured logging, CloudWatch metrics (EMF), request context propagation, and Lambda handler boilerplate. Nothing more.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install wshtlib
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
FastAPI/Starlette middleware is optional:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install wshtlib[fastapi]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Lambda handler
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from wshtlib import bootstrap, get_logger
|
|
25
|
+
|
|
26
|
+
logger = get_logger("my-service")
|
|
27
|
+
|
|
28
|
+
@bootstrap
|
|
29
|
+
def handler(event, context):
|
|
30
|
+
logger.info("invoked", path=event.get("path"))
|
|
31
|
+
return {"statusCode": 200}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The decorator handles:
|
|
35
|
+
- Warming events (`"source": "lambda-warming"`) — returns 200 early
|
|
36
|
+
- Context init and structured log enrichment
|
|
37
|
+
- Unhandled exceptions — logs error, returns 500
|
|
38
|
+
|
|
39
|
+
### Structured logging
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from wshtlib import get_logger
|
|
43
|
+
|
|
44
|
+
logger = get_logger("my-service")
|
|
45
|
+
logger.info("user signed in", user_id="u_123", plan="pro")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Output is JSON to stdout, enriched with `level`, `timestamp`, `service`, `location`, runtime fields, and Lambda context on invocation.
|
|
49
|
+
|
|
50
|
+
### CloudWatch metrics (EMF)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from wshtlib.metrics import metrics
|
|
54
|
+
|
|
55
|
+
metrics.count("OrderPlaced")
|
|
56
|
+
metrics.put("Duration", 142.5, unit="Milliseconds")
|
|
57
|
+
metrics.flush()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`metrics` is a module-level `MetricsContext` instance. For isolated contexts (e.g. per-request), instantiate `MetricsContext()` directly.
|
|
61
|
+
|
|
62
|
+
Namespace defaults to the `METRICS_NAMESPACE` env var, falling back to `"Wholeshoot"`.
|
|
63
|
+
|
|
64
|
+
### Request context
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from wshtlib import get_context, set_user_id
|
|
68
|
+
|
|
69
|
+
set_user_id(claims["sub"])
|
|
70
|
+
ctx = get_context() # {"trace_id": ..., "correlation_id": ..., "user_id": ...}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Context is stored in a `ContextVar` — safe for concurrent async handlers.
|
|
74
|
+
|
|
75
|
+
### FastAPI middleware
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi import FastAPI
|
|
79
|
+
from wshtlib.middleware import WshtlibMiddleware
|
|
80
|
+
|
|
81
|
+
app = FastAPI()
|
|
82
|
+
app.add_middleware(WshtlibMiddleware)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Initialises request context, logs `method`, `path`, `status`, `duration_ms` per request, and injects `X-Trace-Id` into the response.
|
|
86
|
+
|
|
87
|
+
### Utilities
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from wshtlib import require_env, require_https_url, require_secret
|
|
91
|
+
|
|
92
|
+
db_url = require_env("DATABASE_URL") # raises RuntimeError if missing/empty
|
|
93
|
+
endpoint = require_https_url(require_env("API_URL")) # raises ValueError if not https
|
|
94
|
+
api_key = require_secret("api/key") # raises RuntimeError if missing/empty, cached
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Environment variables
|
|
98
|
+
|
|
99
|
+
| Variable | Default | Description |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `LOG_LEVEL` | `INFO` | Logger level |
|
|
102
|
+
| `METRICS_NAMESPACE` | `Wholeshoot` | CloudWatch namespace |
|
|
103
|
+
| `ENVIRONMENT` | — | Added as a metrics dimension if set |
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv sync --group dev
|
|
109
|
+
uv run pytest
|
|
110
|
+
uv run mypy wshtlib
|
|
111
|
+
uv run ruff check wshtlib
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wshtlib"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared runtime library for Wholeshoot services"
|
|
5
|
+
authors = [{ name = "Wholeshoot" }]
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
fastapi = ["starlette>=0.27"]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/polsen/wshtlib"
|
|
15
|
+
Repository = "https://github.com/polsen/wshtlib"
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest",
|
|
20
|
+
"pytest-cov",
|
|
21
|
+
"pytest-asyncio",
|
|
22
|
+
"black",
|
|
23
|
+
"ruff",
|
|
24
|
+
"mypy",
|
|
25
|
+
"starlette>=0.27",
|
|
26
|
+
"httpx",
|
|
27
|
+
"boto3-stubs[secretsmanager]",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["wshtlib"]
|
|
36
|
+
|
|
37
|
+
[tool.black]
|
|
38
|
+
line-length = 88
|
|
39
|
+
|
|
40
|
+
[tool.isort]
|
|
41
|
+
profile = "black"
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "DTZ"]
|
|
45
|
+
|
|
46
|
+
[tool.mypy]
|
|
47
|
+
strict = true
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
addopts = "--tb=short -q"
|
|
52
|
+
|
|
53
|
+
[tool.coverage.report]
|
|
54
|
+
fail_under = 90
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Shared test helpers for wshtlib test suite."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
from wshtlib.logger import _Logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def capture_log(log_fn, *args, **kwargs) -> dict:
|
|
12
|
+
"""Invoke a logger method and return the parsed JSON entry.
|
|
13
|
+
|
|
14
|
+
log_fn: Bound logger method (e.g., logger.info).
|
|
15
|
+
*args, **kwargs: Arguments to pass to log_fn.
|
|
16
|
+
Returns the parsed JSON log entry as a dict.
|
|
17
|
+
Raises AttributeError if log_fn is not a bound _Logger method.
|
|
18
|
+
Raises json.JSONDecodeError if the emitted output is not valid JSON.
|
|
19
|
+
"""
|
|
20
|
+
buf = StringIO()
|
|
21
|
+
handler = logging.StreamHandler(buf)
|
|
22
|
+
handler.setFormatter(log_fn.__self__._formatter)
|
|
23
|
+
log_fn.__self__.handlers = [handler]
|
|
24
|
+
log_fn(*args, **kwargs)
|
|
25
|
+
return json.loads(buf.getvalue().strip())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fresh_logger(name: str = "test-svc") -> _Logger:
|
|
29
|
+
"""Create a new _Logger instance, bypassing the registry cache.
|
|
30
|
+
|
|
31
|
+
name: Logger name. Defaults to "test-svc".
|
|
32
|
+
Returns a _Logger with DEBUG level.
|
|
33
|
+
"""
|
|
34
|
+
lg = _Logger(name)
|
|
35
|
+
lg.setLevel(logging.DEBUG)
|
|
36
|
+
return lg
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def make_lambda_context(**kwargs) -> MagicMock:
|
|
40
|
+
"""Create a mock Lambda context object with specified attributes.
|
|
41
|
+
|
|
42
|
+
**kwargs: Attributes to set (function_name, arn, memory, request_id).
|
|
43
|
+
Returns a MagicMock with Lambda context attributes.
|
|
44
|
+
"""
|
|
45
|
+
ctx = MagicMock()
|
|
46
|
+
ctx.function_name = kwargs.get("function_name", "my-lambda")
|
|
47
|
+
ctx.invoked_function_arn = kwargs.get(
|
|
48
|
+
"arn", "arn:aws:lambda:us-west-2:123:function:my-lambda"
|
|
49
|
+
)
|
|
50
|
+
ctx.memory_limit_in_mb = kwargs.get("memory", "256")
|
|
51
|
+
ctx.aws_request_id = kwargs.get("request_id", "req-abc-123")
|
|
52
|
+
return ctx
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def emit_with_lambda_context(ctx: MagicMock) -> dict:
|
|
56
|
+
"""Log a message with Lambda context set and return the parsed JSON entry.
|
|
57
|
+
|
|
58
|
+
ctx: Lambda context object.
|
|
59
|
+
Returns the parsed JSON log entry as a dict.
|
|
60
|
+
"""
|
|
61
|
+
lg = fresh_logger("lambda-svc")
|
|
62
|
+
lg.set_lambda_context(ctx)
|
|
63
|
+
buf = StringIO()
|
|
64
|
+
handler = logging.StreamHandler(buf)
|
|
65
|
+
handler.setFormatter(lg._formatter)
|
|
66
|
+
lg.handlers = [handler]
|
|
67
|
+
lg.info("lambda log")
|
|
68
|
+
return json.loads(buf.getvalue().strip())
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Test request-scoped context store."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
|
|
5
|
+
from wshtlib.context import (
|
|
6
|
+
clear_context,
|
|
7
|
+
get_context,
|
|
8
|
+
init_context,
|
|
9
|
+
init_context_from_request,
|
|
10
|
+
set_user_id,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def setup_function() -> None:
|
|
15
|
+
clear_context()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- init_context ---
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_init_context_trace_id_from_header() -> None:
|
|
22
|
+
event = {"headers": {"x-amzn-trace-id": "Root=1-abc"}}
|
|
23
|
+
lctx = MagicMock(aws_request_id="req-123")
|
|
24
|
+
init_context(event, lctx)
|
|
25
|
+
c = get_context()
|
|
26
|
+
assert c["trace_id"] == "Root=1-abc"
|
|
27
|
+
assert c["correlation_id"] == "req-123"
|
|
28
|
+
assert c["user_id"] is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_init_context_trace_id_from_request_context() -> None:
|
|
32
|
+
event = {"requestContext": {"requestId": "apigw-req-id"}}
|
|
33
|
+
lctx = MagicMock(aws_request_id="req-456")
|
|
34
|
+
init_context(event, lctx)
|
|
35
|
+
c = get_context()
|
|
36
|
+
assert c["trace_id"] == "apigw-req-id"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_init_context_header_takes_precedence_over_request_context() -> None:
|
|
40
|
+
event = {
|
|
41
|
+
"headers": {"x-amzn-trace-id": "Root=1-header"},
|
|
42
|
+
"requestContext": {"requestId": "apigw-id"},
|
|
43
|
+
}
|
|
44
|
+
lctx = MagicMock(aws_request_id="req-789")
|
|
45
|
+
init_context(event, lctx)
|
|
46
|
+
assert get_context()["trace_id"] == "Root=1-header"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_init_context_no_trace_id() -> None:
|
|
50
|
+
init_context({}, MagicMock(aws_request_id="req-000"))
|
|
51
|
+
assert get_context()["trace_id"] is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_init_context_null_headers() -> None:
|
|
55
|
+
event = {"headers": None}
|
|
56
|
+
init_context(event, MagicMock(aws_request_id="req-null"))
|
|
57
|
+
assert get_context()["trace_id"] is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_init_context_no_aws_request_id() -> None:
|
|
61
|
+
lctx = object() # no aws_request_id attribute
|
|
62
|
+
init_context({}, lctx)
|
|
63
|
+
assert get_context()["correlation_id"] is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- init_context_from_request ---
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _make_request(headers: dict, path: str = "/test") -> MagicMock:
|
|
70
|
+
req = MagicMock()
|
|
71
|
+
req.headers = headers
|
|
72
|
+
req.scope = {"path": path}
|
|
73
|
+
return req
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_init_context_from_request_trace_id() -> None:
|
|
77
|
+
req = _make_request({"x-amzn-trace-id": "Root=1-req", "x-correlation-id": "corr-1"})
|
|
78
|
+
init_context_from_request(req)
|
|
79
|
+
c = get_context()
|
|
80
|
+
assert c["trace_id"] == "Root=1-req"
|
|
81
|
+
assert c["correlation_id"] == "corr-1"
|
|
82
|
+
assert c["user_id"] is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_init_context_from_request_correlation_id_fallback_to_path() -> None:
|
|
86
|
+
req = _make_request({"x-amzn-trace-id": "Root=1-x"}, path="/shoots/abc")
|
|
87
|
+
init_context_from_request(req)
|
|
88
|
+
assert get_context()["correlation_id"] == "/shoots/abc"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_init_context_from_request_no_trace_id() -> None:
|
|
92
|
+
req = _make_request({})
|
|
93
|
+
init_context_from_request(req)
|
|
94
|
+
assert get_context()["trace_id"] is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- get_context returns a copy ---
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_get_context_returns_copy() -> None:
|
|
101
|
+
init_context({}, MagicMock(aws_request_id="r"))
|
|
102
|
+
c1 = get_context()
|
|
103
|
+
c1["injected"] = True
|
|
104
|
+
assert "injected" not in get_context()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- set_user_id ---
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_set_user_id() -> None:
|
|
111
|
+
init_context({}, MagicMock(aws_request_id="r"))
|
|
112
|
+
set_user_id("user-sub-abc")
|
|
113
|
+
assert get_context()["user_id"] == "user-sub-abc"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_set_user_id_none() -> None:
|
|
117
|
+
init_context({}, MagicMock(aws_request_id="r"))
|
|
118
|
+
set_user_id("user-sub-abc")
|
|
119
|
+
set_user_id(None)
|
|
120
|
+
assert get_context()["user_id"] is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_set_user_id_preserves_other_fields() -> None:
|
|
124
|
+
init_context(
|
|
125
|
+
{"headers": {"x-amzn-trace-id": "Root=1-keep"}},
|
|
126
|
+
MagicMock(aws_request_id="r"),
|
|
127
|
+
)
|
|
128
|
+
set_user_id("u")
|
|
129
|
+
c = get_context()
|
|
130
|
+
assert c["trace_id"] == "Root=1-keep"
|
|
131
|
+
assert c["user_id"] == "u"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- clear_context ---
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_clear_context() -> None:
|
|
138
|
+
init_context({}, MagicMock(aws_request_id="r"))
|
|
139
|
+
clear_context()
|
|
140
|
+
assert get_context() == {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --- context isolation between coroutines ---
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_context_isolation_between_tasks() -> None:
|
|
147
|
+
"""Each asyncio task gets its own context copy via contextvars."""
|
|
148
|
+
import asyncio
|
|
149
|
+
|
|
150
|
+
async def task_a():
|
|
151
|
+
init_context(
|
|
152
|
+
{"headers": {"x-amzn-trace-id": "A"}}, MagicMock(aws_request_id="a")
|
|
153
|
+
)
|
|
154
|
+
await asyncio.sleep(0)
|
|
155
|
+
return get_context()["trace_id"]
|
|
156
|
+
|
|
157
|
+
async def task_b():
|
|
158
|
+
init_context(
|
|
159
|
+
{"headers": {"x-amzn-trace-id": "B"}}, MagicMock(aws_request_id="b")
|
|
160
|
+
)
|
|
161
|
+
await asyncio.sleep(0)
|
|
162
|
+
return get_context()["trace_id"]
|
|
163
|
+
|
|
164
|
+
async def run():
|
|
165
|
+
results = await asyncio.gather(task_a(), task_b())
|
|
166
|
+
return results
|
|
167
|
+
|
|
168
|
+
a, b = asyncio.run(run())
|
|
169
|
+
assert a == "A"
|
|
170
|
+
assert b == "B"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Test Lambda handler decorator."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from wshtlib.context import clear_context, get_context
|
|
8
|
+
from wshtlib.decorators import bootstrap
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def reset_context() -> None:
|
|
13
|
+
clear_context()
|
|
14
|
+
yield
|
|
15
|
+
clear_context()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_lambda_context(request_id: str = "req-test") -> MagicMock:
|
|
19
|
+
ctx = MagicMock()
|
|
20
|
+
ctx.aws_request_id = request_id
|
|
21
|
+
return ctx
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- warming events ---
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_warming_event_returns_200() -> None:
|
|
28
|
+
@bootstrap
|
|
29
|
+
def handler(event, context):
|
|
30
|
+
raise AssertionError("should not be called")
|
|
31
|
+
|
|
32
|
+
result = handler({"source": "lambda-warming"}, _make_lambda_context())
|
|
33
|
+
assert result == {"statusCode": 200}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_warming_event_skips_context_init() -> None:
|
|
37
|
+
@bootstrap
|
|
38
|
+
def handler(event, context):
|
|
39
|
+
pass # pragma: no cover
|
|
40
|
+
|
|
41
|
+
handler({"source": "lambda-warming"}, _make_lambda_context("req-warm"))
|
|
42
|
+
assert get_context() == {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_non_warming_source_does_not_short_circuit() -> None:
|
|
46
|
+
called = []
|
|
47
|
+
|
|
48
|
+
@bootstrap
|
|
49
|
+
def handler(event, context):
|
|
50
|
+
called.append(True)
|
|
51
|
+
return {"statusCode": 200}
|
|
52
|
+
|
|
53
|
+
handler({"source": "other"}, _make_lambda_context())
|
|
54
|
+
assert called == [True]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- context initialisation ---
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_init_context_called_before_handler() -> None:
|
|
61
|
+
captured = {}
|
|
62
|
+
|
|
63
|
+
@bootstrap
|
|
64
|
+
def handler(event, context):
|
|
65
|
+
captured.update(get_context())
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
event = {"headers": {"x-amzn-trace-id": "Root=1-abc"}}
|
|
69
|
+
handler(event, _make_lambda_context("req-ctx"))
|
|
70
|
+
assert captured["trace_id"] == "Root=1-abc"
|
|
71
|
+
assert captured["correlation_id"] == "req-ctx"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_set_lambda_context_called() -> None:
|
|
75
|
+
with patch("wshtlib.decorators.set_lambda_context") as mock_set:
|
|
76
|
+
|
|
77
|
+
@bootstrap
|
|
78
|
+
def handler(event, context):
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
lctx = _make_lambda_context()
|
|
82
|
+
handler({}, lctx)
|
|
83
|
+
mock_set.assert_called_once_with(lctx)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- error handling ---
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_unhandled_exception_returns_500() -> None:
|
|
90
|
+
@bootstrap
|
|
91
|
+
def handler(event, context):
|
|
92
|
+
raise ValueError("boom")
|
|
93
|
+
|
|
94
|
+
result = handler({}, _make_lambda_context())
|
|
95
|
+
assert result == {"statusCode": 500}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_unhandled_exception_logs_error() -> None:
|
|
99
|
+
with patch("wshtlib.decorators.logger") as mock_logger:
|
|
100
|
+
|
|
101
|
+
@bootstrap
|
|
102
|
+
def handler(event, context):
|
|
103
|
+
raise RuntimeError("oops")
|
|
104
|
+
|
|
105
|
+
handler({}, _make_lambda_context())
|
|
106
|
+
mock_logger.error.assert_called_once()
|
|
107
|
+
call_args = mock_logger.error.call_args[0]
|
|
108
|
+
assert call_args[1] == "handler"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_successful_return_value_passed_through() -> None:
|
|
112
|
+
@bootstrap
|
|
113
|
+
def handler(event, context):
|
|
114
|
+
return {"statusCode": 201, "body": "ok"}
|
|
115
|
+
|
|
116
|
+
result = handler({}, _make_lambda_context())
|
|
117
|
+
assert result == {"statusCode": 201, "body": "ok"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- functools.wraps ---
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_decorator_preserves_function_name() -> None:
|
|
124
|
+
@bootstrap
|
|
125
|
+
def my_special_handler(event, context):
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
assert my_special_handler.__name__ == "my_special_handler"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_decorator_preserves_docstring() -> None:
|
|
132
|
+
@bootstrap
|
|
133
|
+
def handler(event, context):
|
|
134
|
+
"""My handler docstring."""
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
assert handler.__doc__ == "My handler docstring."
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- public API ---
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_bootstrap_exported_from_wshtlib() -> None:
|
|
144
|
+
import wshtlib
|
|
145
|
+
|
|
146
|
+
assert hasattr(wshtlib, "bootstrap")
|
|
147
|
+
assert callable(wshtlib.bootstrap)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_lambda_handler_not_exported_from_wshtlib() -> None:
|
|
151
|
+
import wshtlib
|
|
152
|
+
|
|
153
|
+
assert not hasattr(wshtlib, "lambda_handler")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_bootstrap_in_all() -> None:
|
|
157
|
+
import wshtlib
|
|
158
|
+
|
|
159
|
+
assert "bootstrap" in wshtlib.__all__
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_lambda_handler_not_in_all() -> None:
|
|
163
|
+
import wshtlib
|
|
164
|
+
|
|
165
|
+
assert "lambda_handler" not in wshtlib.__all__
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Tests for wshtlib.env"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from wshtlib.env import require_env
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_require_env_returns_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
9
|
+
monkeypatch.setenv("MY_VAR", "hello")
|
|
10
|
+
assert require_env("MY_VAR") == "hello"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_require_env_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
14
|
+
monkeypatch.delenv("MY_VAR", raising=False)
|
|
15
|
+
with pytest.raises(RuntimeError, match="MY_VAR"):
|
|
16
|
+
require_env("MY_VAR")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_require_env_raises_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
20
|
+
monkeypatch.setenv("MY_VAR", "")
|
|
21
|
+
with pytest.raises(RuntimeError, match="MY_VAR"):
|
|
22
|
+
require_env("MY_VAR")
|