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.
@@ -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
@@ -0,0 +1,14 @@
1
+ .ltc/
2
+ .kiro/
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ __pycache__/
7
+ .venv/
8
+ .coverage
9
+ htmlcov/
10
+ .mypy_cache/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ QUEUE_STATUS.md
14
+ ltc.toml
@@ -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'
@@ -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")