nui-python-shared-utils 1.5.0__py3-none-any.whl → 2.0__py3-none-any.whl

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. {nui_python_shared_utils-1.5.0.dist-info → nui_python_shared_utils-2.0.dist-info}/METADATA +1 -2
  2. nui_python_shared_utils-2.0.dist-info/RECORD +34 -0
  3. {nui_python_shared_utils-1.5.0.dist-info → nui_python_shared_utils-2.0.dist-info}/WHEEL +1 -1
  4. nui_python_shared_utils-2.0.dist-info/top_level.txt +1 -0
  5. nui_shared_utils/__init__.py +39 -0
  6. nui_shared_utils/logging/__init__.py +68 -0
  7. nui_shared_utils/logging/binding.py +355 -0
  8. nui_shared_utils/logging/fields.py +253 -0
  9. nui_shared_utils/logging/redaction.py +189 -0
  10. nui_lambda_shared_utils/__init__.py +0 -44
  11. nui_lambda_shared_utils/base_client.py +0 -2
  12. nui_lambda_shared_utils/cli.py +0 -2
  13. nui_lambda_shared_utils/cloudwatch_metrics.py +0 -2
  14. nui_lambda_shared_utils/config.py +0 -2
  15. nui_lambda_shared_utils/db_client.py +0 -2
  16. nui_lambda_shared_utils/error_handler.py +0 -2
  17. nui_lambda_shared_utils/es_client.py +0 -2
  18. nui_lambda_shared_utils/es_query_builder.py +0 -2
  19. nui_lambda_shared_utils/jwt_auth.py +0 -2
  20. nui_lambda_shared_utils/lambda_helpers.py +0 -2
  21. nui_lambda_shared_utils/log_processors.py +0 -2
  22. nui_lambda_shared_utils/powertools_helpers.py +0 -2
  23. nui_lambda_shared_utils/secrets_helper.py +0 -2
  24. nui_lambda_shared_utils/slack_client.py +0 -2
  25. nui_lambda_shared_utils/slack_formatter.py +0 -2
  26. nui_lambda_shared_utils/slack_setup/__init__.py +0 -2
  27. nui_lambda_shared_utils/slack_setup/channel_creator.py +0 -2
  28. nui_lambda_shared_utils/slack_setup/channel_definitions.py +0 -2
  29. nui_lambda_shared_utils/slack_setup/setup_helpers.py +0 -2
  30. nui_lambda_shared_utils/snowflake_client.py +0 -3
  31. nui_lambda_shared_utils/timezone.py +0 -2
  32. nui_lambda_shared_utils/utils.py +0 -2
  33. nui_python_shared_utils-1.5.0.dist-info/RECORD +0 -53
  34. nui_python_shared_utils-1.5.0.dist-info/top_level.txt +0 -2
  35. {nui_python_shared_utils-1.5.0.dist-info → nui_python_shared_utils-2.0.dist-info}/entry_points.txt +0 -0
  36. {nui_python_shared_utils-1.5.0.dist-info → nui_python_shared_utils-2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nui-python-shared-utils
3
- Version: 1.5.0
3
+ Version: 2.0
4
4
  Summary: Shared Python utilities for AWS Lambda, CLI tools, and agents with Slack, Elasticsearch, and monitoring integrations
5
5
  Home-page: https://github.com/nuimarkets/nui-python-shared-utils
6
6
  Author: NUI Markets
@@ -526,7 +526,6 @@ slack-channel-setup --config channels.yaml
526
526
  - **GitHub Repository**: https://github.com/nuimarkets/nui-python-shared-utils
527
527
  - **Issue Tracker**: https://github.com/nuimarkets/nui-python-shared-utils/issues
528
528
  - **PyPI Package**: https://pypi.org/project/nui-python-shared-utils/
529
- - **Changelog**: [CHANGELOG.md](CHANGELOG.md)
530
529
 
531
530
  ### 💬 Support
532
531
 
@@ -0,0 +1,34 @@
1
+ nui_python_shared_utils-2.0.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
2
+ nui_shared_utils/__init__.py,sha256=vFS5oaw7_iHgHnLRifDYYyb7au6G2nYJ6ztqMs-hrQs,13289
3
+ nui_shared_utils/base_client.py,sha256=I1lKQGhrKyvujV2zps0TrtEdPDqMCwRQaIh6ceGONXQ,11016
4
+ nui_shared_utils/cli.py,sha256=JJpSoQWKvAz4b8cO30yFNi5vY9jmqrCHzbFpvrVTkbU,8747
5
+ nui_shared_utils/cloudwatch_metrics.py,sha256=p0J-s63UJDGK14TRGt0k8yoj7aWSSM55UKdgcuaT1dU,12098
6
+ nui_shared_utils/config.py,sha256=ZbkUKDxnHMJiHNv-TlWKjotmf2mH1Vz3GaIXgGfWg7E,4524
7
+ nui_shared_utils/db_client.py,sha256=HyVKJ1Su0dVPV1QI2MU5UtT8myDRCjKgV60xRL4SEdE,21726
8
+ nui_shared_utils/error_handler.py,sha256=pJ6b_mI0Ait8QRbs9UmLzYUN-Ft0l_dnpudVO_6LRSE,12171
9
+ nui_shared_utils/es_client.py,sha256=rLyexuL6_RXQpuMC4A2SeJDk30medx2qxOgWIG3A_7o,15765
10
+ nui_shared_utils/es_query_builder.py,sha256=UuheQf8b2UXaOiJlYCKYIfKfVERDZ3ag1P0OOOWm9do,12158
11
+ nui_shared_utils/jwt_auth.py,sha256=2Ag1zZKxd2R8QBz3aQA4h8OtKHQvUVKrjUp6lXpJxJU,9143
12
+ nui_shared_utils/lambda_helpers.py,sha256=psHVotpmOfnmyQCoOt4MSIEj7VwWcy6gZM3vYSZwjOk,3041
13
+ nui_shared_utils/llm.py,sha256=adNUO8Qbeb3__GvpROxWSUERkpiazKiGJFE5xzU5eZ0,8351
14
+ nui_shared_utils/log_processors.py,sha256=x5gz1LEkbmoMCZ3ZB6q-mbn26dSzwh1trz4wSkBGxqk,5538
15
+ nui_shared_utils/powertools_helpers.py,sha256=qc-lYLfY-QX20gALfPOJcihrtJhiCU-fJW8rsPT7LJE,11037
16
+ nui_shared_utils/secrets_helper.py,sha256=_jQkMWzjayZ8vvyHG2yuuxur-SQYUUONfcsZ0MF8qOw,7025
17
+ nui_shared_utils/slack_client.py,sha256=wXE0D4T4FWt1giz9Xmp71RustpaJYeLAZo6JxulDVko,25550
18
+ nui_shared_utils/slack_formatter.py,sha256=95g6XfAJst7RVhd0M0ahiF3gWWW5j96WYkCg5tLS6Zg,10894
19
+ nui_shared_utils/snowflake_client.py,sha256=e3jH6_2mMqbtP1TE8-mUFFYrfGW9eqUPT5tlcgeCqx8,16181
20
+ nui_shared_utils/timezone.py,sha256=TvtSZV7w3Vvz3NbMTcJSNbUf0ZxPwpFS-xfgye1h-4Q,3583
21
+ nui_shared_utils/utils.py,sha256=gOpw80tZGC24QsyesR8EySRODY-TFXG8cXUvggPBsRI,8184
22
+ nui_shared_utils/logging/__init__.py,sha256=gVC853q3ja4RhcBVbf5jKO10lFX4upfsTvkKZx4wu54,1805
23
+ nui_shared_utils/logging/binding.py,sha256=I5VNmtf4U2yt54aS4F0Djw4u22mfjwCpWvORUnJNkPU,13577
24
+ nui_shared_utils/logging/fields.py,sha256=7jhBdyEXAJod61aKPCk68idECTAV-_BNjiABp9IRFZo,11194
25
+ nui_shared_utils/logging/redaction.py,sha256=t-EtNhR1nK6IOae70GnieHxn_36_jYuuta32T1dMlP8,6928
26
+ nui_shared_utils/slack_setup/__init__.py,sha256=OElyS3xk4F_YKH5uUUTDpN0ah1dOO3e52muIPjAMPW8,320
27
+ nui_shared_utils/slack_setup/channel_creator.py,sha256=0gyCBIS0EC96SVn1Z2dD4Fpzk0xNdHSWyiCxoUQIUe8,10667
28
+ nui_shared_utils/slack_setup/channel_definitions.py,sha256=atfz5ZhpqefOeLh1gShbWd-TLzgjPmhv95bfuTdmZog,5676
29
+ nui_shared_utils/slack_setup/setup_helpers.py,sha256=pzzXMs12GI9sdZttWeYzgLPgC0xqPz7ZLHM1GUPNNXc,7219
30
+ nui_python_shared_utils-2.0.dist-info/METADATA,sha256=bK7P8bMGhOLpSyVQsZ3gwrWRZ6KZksIoAJIlj6V-qHM,22584
31
+ nui_python_shared_utils-2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
32
+ nui_python_shared_utils-2.0.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
33
+ nui_python_shared_utils-2.0.dist-info/top_level.txt,sha256=YMRu5cnn3hxHoYq9wNvaI9atVHnTNVdswO5_cQSfnJI,17
34
+ nui_python_shared_utils-2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1 @@
1
+ nui_shared_utils
@@ -120,6 +120,25 @@ _LAZY_EXPORTS = {
120
120
  "build_anthropic_client": ("llm", "build_anthropic_client"),
121
121
  "call_tool": ("llm", "call_tool"),
122
122
  "call_text": ("llm", "call_text"),
123
+ # Structured logging contract (stdlib-only field registry + binders)
124
+ "F": ("logging", "F"),
125
+ "FIELDS": ("logging", "FIELDS"),
126
+ "Field": ("logging", "Field"),
127
+ "validate_fields": ("logging", "validate_fields"),
128
+ "validate_registry": ("logging", "validate_registry"),
129
+ "redaction_keys": ("logging", "redaction_keys"),
130
+ "alias_map": ("logging", "alias_map"),
131
+ "default_redactor": ("logging", "default_redactor"),
132
+ "powertools_binder": ("logging", "powertools_binder"),
133
+ "stdlib_binder": ("logging", "stdlib_binder"),
134
+ "PowertoolsBinder": ("logging", "PowertoolsBinder"),
135
+ "StdlibBinder": ("logging", "StdlibBinder"),
136
+ "bind_request": ("logging", "bind_request"),
137
+ "bind_user": ("logging", "bind_user"),
138
+ "bind_build_context": ("logging", "bind_build_context"),
139
+ "log_exception": ("logging", "log_exception"),
140
+ "request_body_fields": ("logging", "request_body_fields"),
141
+ "response_output_fields": ("logging", "response_output_fields"),
123
142
  }
124
143
 
125
144
  # Submodules that are optional integrations; ImportError during lazy load
@@ -275,6 +294,26 @@ if TYPE_CHECKING:
275
294
  validate_jwt,
276
295
  )
277
296
  from .llm import build_anthropic_client, call_text, call_tool
297
+ from .logging import (
298
+ FIELDS,
299
+ F,
300
+ Field,
301
+ PowertoolsBinder,
302
+ StdlibBinder,
303
+ alias_map,
304
+ bind_build_context,
305
+ bind_request,
306
+ bind_user,
307
+ default_redactor,
308
+ log_exception,
309
+ powertools_binder,
310
+ redaction_keys,
311
+ request_body_fields,
312
+ response_output_fields,
313
+ stdlib_binder,
314
+ validate_fields,
315
+ validate_registry,
316
+ )
278
317
  from . import slack_setup
279
318
 
280
319
 
@@ -0,0 +1,68 @@
1
+ """Structured logging contract: canonical field registry + backend-agnostic binders.
2
+
3
+ The fleet's dotted, snake_case logging vocabulary and the helpers that emit it.
4
+ Stdlib-only, so importing this package pulls in no third-party dependency (the
5
+ Powertools binder calls ``append_keys`` / ``remove_keys`` on a logger the caller
6
+ constructs; it never imports ``aws_lambda_powertools`` itself).
7
+
8
+ Typical use::
9
+
10
+ from nui_shared_utils.logging import F, stdlib_binder, bind_request, bind_user
11
+
12
+ binder = stdlib_binder(logger)
13
+ with binder.request_scope():
14
+ bind_request(binder, method="POST", path="/orders", request_id=rid)
15
+ bind_user(binder, id=u.id, email=u.email, org_id=u.org_id)
16
+ logger.info("response", extra={F.RESPONSE_STATUS: 200})
17
+
18
+ See ``docs/guides/structured-logging-contract.md`` for the full guide and
19
+ ``docs/decisions/ADR-001-structured-logging-field-vocabulary.md`` for the rationale.
20
+ """
21
+
22
+ from .binding import (
23
+ PowertoolsBinder,
24
+ StdlibBinder,
25
+ bind_build_context,
26
+ bind_request,
27
+ bind_user,
28
+ log_exception,
29
+ powertools_binder,
30
+ request_body_fields,
31
+ response_output_fields,
32
+ stdlib_binder,
33
+ )
34
+ from .fields import (
35
+ FIELDS,
36
+ F,
37
+ Field,
38
+ alias_map,
39
+ redaction_keys,
40
+ validate_fields,
41
+ validate_registry,
42
+ )
43
+ from .redaction import MASK, default_redactor
44
+
45
+ __all__ = [
46
+ # registry
47
+ "F",
48
+ "FIELDS",
49
+ "Field",
50
+ "validate_fields",
51
+ "validate_registry",
52
+ "redaction_keys",
53
+ "alias_map",
54
+ # redaction
55
+ "default_redactor",
56
+ "MASK",
57
+ # binders + helpers
58
+ "powertools_binder",
59
+ "stdlib_binder",
60
+ "PowertoolsBinder",
61
+ "StdlibBinder",
62
+ "bind_request",
63
+ "bind_user",
64
+ "bind_build_context",
65
+ "log_exception",
66
+ "request_body_fields",
67
+ "response_output_fields",
68
+ ]
@@ -0,0 +1,355 @@
1
+ """Backend-agnostic, request-scoped binding helpers for structured logging.
2
+
3
+ Two logging backends are in use across the fleet and this module drives both from
4
+ one call site:
5
+
6
+ - **Powertools** (:func:`powertools_binder`): persistent context via
7
+ ``Logger.append_keys`` / ``remove_keys``. Used by short-lived Lambda functions.
8
+ - **stdlib** (:func:`stdlib_binder`): a ``contextvars``-backed ``logging.Filter``
9
+ that injects bound fields onto each record. Used by long-running services on the
10
+ ``pythonjsonlogger`` path, which has no native persistent context.
11
+
12
+ Both binders are **request-scoped**: :meth:`request_scope` sets a ``contextvars``
13
+ token on entry and unbinds on exit, so bound context cannot leak across concurrent
14
+ requests on a long-running service or across warm Lambda invocations. ``contextvars``
15
+ (not thread-locals) so isolation holds under ``asyncio`` too. Wire ``request_scope``
16
+ into the request-end tween / handler ``finally``.
17
+
18
+ The backend is chosen **once at construction**, not by ``hasattr`` duck-typing
19
+ (which breaks on ``LoggerAdapter`` and other wrappers).
20
+
21
+ binder = powertools_binder(logger) # or stdlib_binder(logger)
22
+ with binder.request_scope():
23
+ bind_request(binder, method="POST", path=p, request_id=rid)
24
+ bind_user(binder, id=u.id, email=u.email, org_id=u.org_id)
25
+ logger.info("response", extra={F.RESPONSE_STATUS: 200}) # carries bound keys
26
+ # on exit: bound keys removed, no cross-request / cross-invocation leakage
27
+
28
+ Values are always emitted as dotted keys (``request.method``), never nested dicts.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextvars
34
+ import json
35
+ import logging
36
+ import traceback
37
+ from contextlib import contextmanager
38
+ from typing import Any, Dict, Iterable, Iterator, Mapping, Optional
39
+
40
+ from .fields import F
41
+ from .redaction import default_redactor
42
+
43
+ # Monotonic counter so each binder gets a distinct ContextVar name (ContextVar
44
+ # names are for debugging only, but distinct names avoid confusion in tracebacks).
45
+ _binder_seq = 0
46
+
47
+
48
+ def _next_seq() -> int:
49
+ global _binder_seq
50
+ _binder_seq += 1
51
+ return _binder_seq
52
+
53
+
54
+ def _drop_none(mapping: Mapping[str, Any]) -> Dict[str, Any]:
55
+ """Return ``mapping`` without keys whose value is ``None``."""
56
+ return {k: v for k, v in mapping.items() if v is not None}
57
+
58
+
59
+ class PowertoolsBinder:
60
+ """Request-scoped binder over an AWS Powertools ``Logger``.
61
+
62
+ Binding calls ``logger.append_keys``; :meth:`request_scope` tracks the keys
63
+ bound within the scope and calls ``logger.remove_keys`` on exit so a warm
64
+ invocation does not inherit the previous request's context.
65
+ """
66
+
67
+ def __init__(self, logger: Any):
68
+ self.logger = logger
69
+ self._scope_keys: contextvars.ContextVar[Optional[set]] = contextvars.ContextVar(
70
+ f"nui_log_pt_scope_{_next_seq()}", default=None
71
+ )
72
+
73
+ def bind(self, mapping: Mapping[str, Any]) -> None:
74
+ """Append the given dotted keys to the logger context (``None`` dropped)."""
75
+ clean = _drop_none(mapping)
76
+ if not clean:
77
+ return
78
+ self.logger.append_keys(**clean)
79
+ tracked = self._scope_keys.get()
80
+ if tracked is not None:
81
+ tracked.update(clean.keys())
82
+
83
+ def unbind(self, keys: Iterable[str]) -> None:
84
+ """Remove the given keys from the logger context."""
85
+ keys = list(keys)
86
+ if keys:
87
+ self.logger.remove_keys(keys)
88
+
89
+ @contextmanager
90
+ def request_scope(self) -> Iterator["PowertoolsBinder"]:
91
+ """Scope bound keys to the block and restore prior state on exit.
92
+
93
+ Keys bound inside the block are removed on exit, but a key that already held
94
+ a process-level value before the scope (e.g. build context bound at startup
95
+ and then rebound in-request) is restored to that prior value rather than
96
+ removed. Restoration uses the logger's ``get_current_keys`` when available;
97
+ without it, scoped keys are simply removed.
98
+ """
99
+ get_keys = getattr(self.logger, "get_current_keys", None)
100
+ prior = dict(get_keys()) if callable(get_keys) else None
101
+ token = self._scope_keys.set(set())
102
+ try:
103
+ yield self
104
+ finally:
105
+ scoped = self._scope_keys.get() or set()
106
+ if scoped:
107
+ if prior is not None:
108
+ added = [k for k in scoped if k not in prior]
109
+ restore = {k: prior[k] for k in scoped if k in prior}
110
+ if added:
111
+ self.logger.remove_keys(added)
112
+ if restore:
113
+ self.logger.append_keys(**restore)
114
+ else:
115
+ self.logger.remove_keys(list(scoped))
116
+ self._scope_keys.reset(token)
117
+
118
+
119
+ class _ContextVarLogFilter(logging.Filter):
120
+ """A ``logging.Filter`` that injects the current bound fields onto each record.
121
+
122
+ Reads the binder's ``contextvars`` value at emit time, so concurrent requests
123
+ (each in its own task / thread context) see only their own bound fields. An
124
+ explicit per-call ``extra=`` value already on the record wins (bound context
125
+ never clobbers it).
126
+ """
127
+
128
+ def __init__(self, ctx: "contextvars.ContextVar[Optional[Dict[str, Any]]]"):
129
+ super().__init__()
130
+ self._ctx = ctx
131
+
132
+ def filter(self, record: logging.LogRecord) -> bool:
133
+ fields = self._ctx.get()
134
+ if fields:
135
+ for key, value in fields.items():
136
+ if not hasattr(record, key):
137
+ setattr(record, key, value)
138
+ return True
139
+
140
+
141
+ class StdlibBinder:
142
+ """Request-scoped binder over a standard-library ``logging.Logger``.
143
+
144
+ Bound fields live in a ``contextvars.ContextVar`` and are injected onto every
145
+ record by a filter installed on the logger. :meth:`request_scope` snapshots the
146
+ context on entry and resets it on exit, so a long-running service does not bleed
147
+ one request's context into another (async-safe under ``contextvars``).
148
+ """
149
+
150
+ def __init__(self, logger: logging.Logger):
151
+ self.logger = logger
152
+ self._ctx: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar(
153
+ f"nui_log_std_ctx_{_next_seq()}", default=None
154
+ )
155
+ self._filter = _ContextVarLogFilter(self._ctx)
156
+ self.install_on(logger)
157
+
158
+ def install_on(self, target: Any) -> None:
159
+ """Attach the context filter to a ``Logger`` or ``Handler`` (idempotent).
160
+
161
+ A logger's *filters* run only for records emitted via that exact logger, not
162
+ for records propagated up from child loggers (``getLogger("svc.module")``).
163
+ A *handler's* filters, by contrast, run for every record that reaches the
164
+ handler, including propagated ones. So the filter is installed on the
165
+ logger (direct emits) and on the effective handlers up the hierarchy (child
166
+ emits). The filter is a no-op outside a ``request_scope`` (empty context),
167
+ so attaching it to a shared / root handler is safe.
168
+
169
+ Call this explicitly for handlers configured *after* the binder is built, or
170
+ when your JSON handler lives on a logger other than the one passed here.
171
+ """
172
+ if isinstance(target, logging.Handler):
173
+ if self._filter not in target.filters:
174
+ target.addFilter(self._filter)
175
+ return
176
+ if self._filter not in target.filters:
177
+ target.addFilter(self._filter)
178
+ node: Optional[logging.Logger] = target
179
+ while node is not None:
180
+ for handler in node.handlers:
181
+ if self._filter not in handler.filters:
182
+ handler.addFilter(self._filter)
183
+ if not node.propagate:
184
+ break
185
+ node = node.parent
186
+
187
+ def bind(self, mapping: Mapping[str, Any]) -> None:
188
+ """Merge the given dotted keys into the current context (``None`` dropped)."""
189
+ clean = _drop_none(mapping)
190
+ if not clean:
191
+ return
192
+ current = self._ctx.get()
193
+ merged = dict(current) if current else {}
194
+ merged.update(clean)
195
+ self._ctx.set(merged)
196
+
197
+ def unbind(self, keys: Iterable[str]) -> None:
198
+ """Remove the given keys from the current context."""
199
+ current = self._ctx.get()
200
+ if not current:
201
+ return
202
+ drop = set(keys)
203
+ self._ctx.set({k: v for k, v in current.items() if k not in drop})
204
+
205
+ def current_fields(self) -> Dict[str, Any]:
206
+ """Return a snapshot of the currently-bound fields (introspection / tests)."""
207
+ current = self._ctx.get()
208
+ return dict(current) if current else {}
209
+
210
+ @contextmanager
211
+ def request_scope(self) -> Iterator["StdlibBinder"]:
212
+ """Isolate binds made inside the block; restore prior context on exit."""
213
+ current = self._ctx.get()
214
+ token = self._ctx.set(dict(current) if current else {})
215
+ try:
216
+ yield self
217
+ finally:
218
+ self._ctx.reset(token)
219
+
220
+
221
+ def powertools_binder(logger: Any) -> PowertoolsBinder:
222
+ """Build a request-scoped binder over an AWS Powertools ``Logger``."""
223
+ return PowertoolsBinder(logger)
224
+
225
+
226
+ def stdlib_binder(logger: logging.Logger) -> StdlibBinder:
227
+ """Build a request-scoped binder over a standard-library ``logging.Logger``."""
228
+ return StdlibBinder(logger)
229
+
230
+
231
+ # --- Field-specific binding helpers (callers never hand-type a dotted string) ---
232
+
233
+
234
+ def bind_request(
235
+ binder: Any,
236
+ *,
237
+ method: Optional[str] = None,
238
+ path: Optional[str] = None,
239
+ ip: Optional[str] = None,
240
+ request_id: Optional[str] = None,
241
+ ) -> None:
242
+ """Bind request context (``request.method`` / ``.path`` / ``.ip`` / ``.id``)."""
243
+ binder.bind(
244
+ {
245
+ F.REQUEST_METHOD: method,
246
+ F.REQUEST_PATH: path,
247
+ F.REQUEST_IP: ip,
248
+ F.REQUEST_ID: request_id,
249
+ }
250
+ )
251
+
252
+
253
+ def bind_user(
254
+ binder: Any,
255
+ *,
256
+ id: Optional[str] = None,
257
+ email: Optional[str] = None,
258
+ org_id: Optional[str] = None,
259
+ roles: Optional[Any] = None,
260
+ ) -> None:
261
+ """Bind principal identity (``user.id`` / ``.email`` / ``.org_id`` / ``.roles``)."""
262
+ binder.bind(
263
+ {
264
+ F.USER_ID: id,
265
+ F.USER_EMAIL: email,
266
+ F.USER_ORG_ID: org_id,
267
+ F.USER_ROLES: roles,
268
+ }
269
+ )
270
+
271
+
272
+ def bind_build_context(
273
+ binder: Any,
274
+ *,
275
+ commit: Optional[str] = None,
276
+ branch: Optional[str] = None,
277
+ tag: Optional[str] = None,
278
+ ) -> None:
279
+ """Bind optional build metadata (``git.commit`` / ``.branch`` / ``.tag``).
280
+
281
+ Not bound automatically; the consumer decides whether build metadata is
282
+ available at runtime and calls this once at startup / per request.
283
+ """
284
+ binder.bind(
285
+ {
286
+ F.GIT_COMMIT: commit,
287
+ F.GIT_BRANCH: branch,
288
+ F.GIT_TAG: tag,
289
+ }
290
+ )
291
+
292
+
293
+ def log_exception(binder: Any, exc: BaseException, message: str) -> None:
294
+ """Log ``exc`` at ERROR with explicit ``error.*`` fields.
295
+
296
+ ``error.stack`` is set explicitly from the traceback so the canonical field
297
+ always carries the trace. This uses ``logger.error`` (not ``logger.exception``)
298
+ deliberately: ``logger.exception`` sets ``exc_info=True``, which makes the
299
+ backend emit its *own* traceback field (Powertools' ``stack_trace``, stdlib's
300
+ ``exc_text``) on top of ``error.stack`` when called inside an ``except`` block,
301
+ i.e. the same trace twice under two names. With ``logger.error``, ``error.stack``
302
+ is the single source.
303
+ """
304
+ binder.logger.error(
305
+ message,
306
+ extra={
307
+ F.ERROR_TYPE: type(exc).__name__,
308
+ F.ERROR_MESSAGE: str(exc),
309
+ # 3-arg form for Python 3.9 compatibility (single-arg added in 3.10).
310
+ F.ERROR_STACK: "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
311
+ },
312
+ )
313
+
314
+
315
+ def _redact_kwargs(allow_only: Optional[Iterable[str]], deny_extra: Optional[Iterable[str]]) -> Dict[str, Any]:
316
+ # ``deny_extra`` is only forwarded when set, so a custom ``redactor`` that does
317
+ # not accept it still works when the caller does not use it.
318
+ kwargs: Dict[str, Any] = {"allow_only": allow_only}
319
+ if deny_extra is not None:
320
+ kwargs["deny_extra"] = deny_extra
321
+ return kwargs
322
+
323
+
324
+ def request_body_fields(
325
+ payload: Any,
326
+ *,
327
+ allow_only: Optional[Iterable[str]] = None,
328
+ deny_extra: Optional[Iterable[str]] = None,
329
+ redactor: Any = default_redactor,
330
+ ) -> Dict[str, str]:
331
+ """Redact and serialise a request body into a single ``request.body_json`` field.
332
+
333
+ Returns a mapping ready to splat into ``extra=`` (or pass to ``binder.bind``).
334
+ The body is redacted (deny-list by default, allow-list via ``allow_only``,
335
+ extended for this call via ``deny_extra``) then JSON-serialised with
336
+ ``default=str`` so non-serialisable values do not raise.
337
+ """
338
+ redacted = redactor(payload, **_redact_kwargs(allow_only, deny_extra))
339
+ return {F.REQUEST_BODY_JSON: json.dumps(redacted, default=str)}
340
+
341
+
342
+ def response_output_fields(
343
+ payload: Any,
344
+ *,
345
+ allow_only: Optional[Iterable[str]] = None,
346
+ deny_extra: Optional[Iterable[str]] = None,
347
+ redactor: Any = default_redactor,
348
+ ) -> Dict[str, str]:
349
+ """Redact and serialise a response payload into a single ``response.output_json`` field.
350
+
351
+ The response twin of :func:`request_body_fields`, for services that log
352
+ unbounded response bodies.
353
+ """
354
+ redacted = redactor(payload, **_redact_kwargs(allow_only, deny_extra))
355
+ return {F.RESPONSE_OUTPUT_JSON: json.dumps(redacted, default=str)}
@@ -0,0 +1,253 @@
1
+ """Canonical structured-logging field registry (the fleet's logging contract).
2
+
3
+ This module is the single source of truth for the dotted, snake_case field names
4
+ that structured logs use. It is deliberately **stdlib-only** so it stays cheap to
5
+ import and light in a Lambda bundle: no elasticsearch, powertools, or third-party
6
+ dependency is touched here.
7
+
8
+ Two layers:
9
+
10
+ - A **generic core** registry (:data:`FIELDS`) of namespaces every web / event
11
+ service has: ``request.*``, ``user.*``, ``route.*``, ``response.*``,
12
+ ``error.*``, ``trace.*``, ``git.*``, plus the ``target`` routing field and the
13
+ opaque ``request.body_json`` / ``response.output_json`` retrieval fields.
14
+ - An **extension pattern**: domain fields (``order.*``, ``tender.*``, ``api.*``,
15
+ ``auth.*``, ...) are defined by each consumer in its own constants module and
16
+ drift-checked with :func:`validate_fields`, so the shared package stays generic.
17
+
18
+ Each :class:`Field` carries a type, an indexed flag, a sensitivity, an owner, and
19
+ any deprecated aliases. That one structure is the derive-once source for two lists
20
+ that were otherwise hand-maintained:
21
+
22
+ - :func:`redaction_keys` -- the default redaction deny set (from ``sensitivity``).
23
+ - :func:`alias_map` -- the old-name -> canonical-name migration map (from ``aliases``).
24
+
25
+ The contract is **store-neutral**: it governs how services *emit* fields, not how
26
+ any log store indexes them. Its value is a consistent, typed field vocabulary
27
+ across the fleet, which matters under any backend. Schema-enforcing stores turn
28
+ field drift into loud errors; schema-less stores accept the drift silently and let
29
+ queries and alerts skew. A producer-side registry prevents both.
30
+
31
+ Wire shape is **dotted** (``request.method``), not nested dicts: dotted keys
32
+ survive Powertools ``append_keys`` incremental binding where nested dicts clobber
33
+ (a producer-side property, independent of the store). See
34
+ ``docs/decisions/ADR-001-structured-logging-field-vocabulary.md``.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from dataclasses import dataclass, field as _dc_field
41
+ from typing import Dict, List, Tuple
42
+
43
+ # Leaf datatypes a field may declare. These document producer intent (what a field
44
+ # is meant to hold) and give a schema-enforcing store a type to map to; a schema-less
45
+ # store coerces at query time, so the declared type is what keeps emitters honest.
46
+ _KNOWN_TYPES = frozenset({"keyword", "text", "long", "boolean", "date", "geo_point"})
47
+ # Sensitivity levels that drive default redaction.
48
+ _SENSITIVITIES = frozenset({"none", "pii", "redact"})
49
+ # A single dotted segment: lowercase snake_case, must start with a letter.
50
+ _SEGMENT_RE = re.compile(r"^[a-z][a-z0-9_]*$")
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Field:
55
+ """One canonical log field.
56
+
57
+ Attributes:
58
+ key: Dotted, snake_case field name (e.g. ``"request.method"``).
59
+ type: Leaf datatype the field is meant to hold (``keyword`` / ``text`` /
60
+ ``long`` / ``boolean`` / ``date`` / ``geo_point``). Producer intent that
61
+ keeps emitters consistent; not enforced by this module.
62
+ indexed: ``False`` marks a retrieval-only field: stored for lookup but not
63
+ meant to be queried or faceted (opaque, unbounded payloads like
64
+ ``request.body_json``).
65
+ sensitivity: ``none`` | ``pii`` | ``redact``. ``pii`` / ``redact`` fields
66
+ contribute their leaf name to the default redaction deny set.
67
+ owner: Namespace owner; ``core`` for shared fields.
68
+ aliases: Deprecated dotted names that map to this field, used to build the
69
+ migration / dual-read map.
70
+ """
71
+
72
+ key: str
73
+ type: str
74
+ indexed: bool = True
75
+ sensitivity: str = "none"
76
+ owner: str = "core"
77
+ aliases: Tuple[str, ...] = _dc_field(default=())
78
+
79
+
80
+ class F:
81
+ """Field-name constants for ergonomic call sites (``F.REQUEST_METHOD``).
82
+
83
+ Every constant here has a matching entry in :data:`FIELDS`; the invariant is
84
+ asserted by :func:`validate_registry` at import time.
85
+ """
86
+
87
+ # request.*
88
+ REQUEST_ID = "request.id"
89
+ REQUEST_METHOD = "request.method"
90
+ REQUEST_PATH = "request.path"
91
+ REQUEST_IP = "request.ip"
92
+ REQUEST_BODY_JSON = "request.body_json"
93
+ # user.*
94
+ USER_ID = "user.id"
95
+ USER_EMAIL = "user.email"
96
+ USER_ORG_ID = "user.org_id"
97
+ USER_ROLES = "user.roles"
98
+ # response.*
99
+ RESPONSE_STATUS = "response.status"
100
+ RESPONSE_DURATION_MS = "response.duration_ms"
101
+ RESPONSE_OUTPUT_JSON = "response.output_json"
102
+ # route.*
103
+ ROUTE_NAME = "route.name"
104
+ ROUTE_PATH = "route.path"
105
+ # error.*
106
+ ERROR_TYPE = "error.type"
107
+ ERROR_MESSAGE = "error.message"
108
+ ERROR_STACK = "error.stack"
109
+ # trace.*
110
+ TRACE_ID = "trace.id"
111
+ TRACE_SPAN_ID = "trace.span_id"
112
+ # routing + build context
113
+ TARGET = "target"
114
+ GIT_COMMIT = "git.commit"
115
+ GIT_BRANCH = "git.branch"
116
+ GIT_TAG = "git.tag"
117
+
118
+
119
+ # The canonical registry. Keyed by dotted name; the key must equal ``Field.key``.
120
+ FIELDS: Dict[str, Field] = {
121
+ "request.id": Field("request.id", "keyword"),
122
+ "request.method": Field("request.method", "keyword"),
123
+ "request.path": Field("request.path", "keyword", aliases=("request.url",)),
124
+ "request.ip": Field("request.ip", "keyword"),
125
+ # Opaque, retrieval-only: unbounded caller-controlled body, redacted then
126
+ # serialised to one string so it cannot explode the index field count.
127
+ "request.body_json": Field("request.body_json", "keyword", indexed=False, sensitivity="redact"),
128
+ "user.id": Field("user.id", "keyword"),
129
+ "user.email": Field("user.email", "keyword", sensitivity="pii", aliases=("request.user",)),
130
+ # Tenant. company.* folds in here (a distinct company name, if ever needed,
131
+ # is a consumer domain field).
132
+ "user.org_id": Field("user.org_id", "keyword", aliases=("request.company", "request.company_id")),
133
+ "user.roles": Field("user.roles", "keyword"), # array; ES arrays are implicit
134
+ "response.status": Field("response.status", "long"),
135
+ "response.duration_ms": Field(
136
+ "response.duration_ms",
137
+ "long",
138
+ aliases=("response.duration", "api.duration_ms", "duration.ms"),
139
+ ),
140
+ # The response twin of request.body_json (unbounded response payloads).
141
+ "response.output_json": Field("response.output_json", "keyword", indexed=False, sensitivity="redact"),
142
+ "route.name": Field("route.name", "keyword"),
143
+ "route.path": Field("route.path", "keyword"),
144
+ "error.type": Field("error.type", "keyword"),
145
+ "error.message": Field("error.message", "text"),
146
+ "error.stack": Field("error.stack", "text"),
147
+ "trace.id": Field("trace.id", "keyword", aliases=("request.trace_id",)),
148
+ "trace.span_id": Field("trace.span_id", "keyword"),
149
+ # ES index routing. Constrain to known values at the consumer (misroute risk).
150
+ "target": Field("target", "keyword"),
151
+ # Optional build context; consumer-bound via bind_build_context, not automatic.
152
+ "git.commit": Field("git.commit", "keyword"),
153
+ "git.branch": Field("git.branch", "keyword"),
154
+ "git.tag": Field("git.tag", "keyword"),
155
+ }
156
+
157
+
158
+ def _key_errors(key: object) -> List[str]:
159
+ """Return a list of naming problems for ``key`` (empty means valid).
160
+
161
+ A valid key is one or more ``.``-joined snake_case segments, each starting
162
+ with a lowercase letter: ``target``, ``request.method``, ``user.org_id``.
163
+ Rejects camelCase, hyphens, leading digits, and empty segments.
164
+ """
165
+ if not isinstance(key, str) or not key:
166
+ return [f"{key!r}: not a non-empty string"]
167
+ errors: List[str] = []
168
+ for seg in key.split("."):
169
+ if not _SEGMENT_RE.match(seg):
170
+ errors.append(f"{key!r}: segment {seg!r} is not lowercase snake_case")
171
+ return errors
172
+
173
+
174
+ def validate_registry() -> List[str]:
175
+ """Validate the core :data:`FIELDS` registry, returning a list of errors.
176
+
177
+ Checks that every key matches its ``Field.key``, is dotted snake_case, has a
178
+ known type and sensitivity, and that aliases are globally unique and do not
179
+ collide with any canonical key. Also asserts every :class:`F` constant is a
180
+ registered field. An empty list means the registry is internally consistent.
181
+ """
182
+ errors: List[str] = []
183
+ seen_aliases: Dict[str, str] = {}
184
+ for key, fld in FIELDS.items():
185
+ if fld.key != key:
186
+ errors.append(f"{key!r}: Field.key {fld.key!r} does not match registry key")
187
+ errors.extend(_key_errors(key))
188
+ if fld.type not in _KNOWN_TYPES:
189
+ errors.append(f"{key!r}: unknown type {fld.type!r}")
190
+ if fld.sensitivity not in _SENSITIVITIES:
191
+ errors.append(f"{key!r}: unknown sensitivity {fld.sensitivity!r}")
192
+ for alias in fld.aliases:
193
+ errors.extend(_key_errors(alias))
194
+ if alias in FIELDS:
195
+ errors.append(f"{key!r}: alias {alias!r} collides with a canonical field key")
196
+ if alias in seen_aliases:
197
+ errors.append(f"alias {alias!r} used by both {seen_aliases[alias]!r} and {key!r}")
198
+ else:
199
+ seen_aliases[alias] = key
200
+ # Every F constant must be a real field.
201
+ for name in dir(F):
202
+ if name.startswith("_"):
203
+ continue
204
+ value = getattr(F, name)
205
+ if isinstance(value, str) and value not in FIELDS:
206
+ errors.append(f"F.{name} = {value!r} is not in FIELDS")
207
+ return errors
208
+
209
+
210
+ def validate_fields(*modules: object) -> List[str]:
211
+ """Validate that a consumer's field constants are dotted snake_case names.
212
+
213
+ Pass one or more classes / modules whose public (non-underscore) string
214
+ attributes are log field names. Returns a list of naming errors (empty means
215
+ all valid), so a consumer's CI can assert ``validate_fields(OrderFields) == []``
216
+ and catch drift from the shared convention.
217
+ """
218
+ errors: List[str] = []
219
+ for mod in modules:
220
+ label = getattr(mod, "__name__", type(mod).__name__)
221
+ for name in dir(mod):
222
+ if name.startswith("_"):
223
+ continue
224
+ value = getattr(mod, name)
225
+ if not isinstance(value, str):
226
+ continue
227
+ errors.extend(f"{label}.{name}: {err}" for err in _key_errors(value))
228
+ return errors
229
+
230
+
231
+ def redaction_keys() -> frozenset:
232
+ """Return the leaf names of ``pii`` / ``redact`` fields for the deny set.
233
+
234
+ These extend the redactor's base pattern list so that any payload key with a
235
+ sensitive name (e.g. ``email`` from ``user.email``) is masked by default.
236
+ """
237
+ return frozenset(fld.key.split(".")[-1] for fld in FIELDS.values() if fld.sensitivity in ("pii", "redact"))
238
+
239
+
240
+ def alias_map() -> Dict[str, str]:
241
+ """Return the ``old_name -> canonical_key`` migration map from all aliases."""
242
+ mapping: Dict[str, str] = {}
243
+ for fld in FIELDS.values():
244
+ for alias in fld.aliases:
245
+ mapping[alias] = fld.key
246
+ return mapping
247
+
248
+
249
+ # Guard the registry at import: a malformed core registry is a bug that should
250
+ # surface the moment the logging package is first used, not silently at query time.
251
+ _REGISTRY_ERRORS = validate_registry()
252
+ if _REGISTRY_ERRORS:
253
+ raise ValueError("Invalid logging field registry:\n " + "\n ".join(_REGISTRY_ERRORS))
@@ -0,0 +1,189 @@
1
+ """Recursive redaction for opaque log payloads (deny-list default, allow-list opt-in).
2
+
3
+ Bodies and response payloads carry unbounded, caller-controlled keys, so they are
4
+ scrubbed before being serialised into ``request.body_json`` / ``response.output_json``.
5
+ Two strategies, layered:
6
+
7
+ - **Deny-list (default)**: a base set of sensitive key-name patterns (password,
8
+ token, secret, authorization, api_key, cookie, ...) plus the ``pii`` / ``redact``
9
+ leaf names from the field registry, matched recursively and masked. Suitable for
10
+ general debug payloads where most keys are legitimate and only known-sensitive
11
+ ones must go.
12
+ - **Allow-list (opt-in via ``allow_only``)**: only the named keys survive, every
13
+ other key is masked. PII-heavy surfaces (auth, profile, anything logging headers
14
+ or authorization) should pass ``allow_only`` so the payload is a known safe
15
+ subset rather than "everything minus the deny-list".
16
+
17
+ The redactor never mutates the caller's structure: it rebuilds the dict / list
18
+ spine and shares only leaf scalars (which are treated as immutable and are
19
+ serialised with ``json.dumps(default=str)`` downstream).
20
+
21
+ Documented v1 gaps (not silently accepted, tracked for v2):
22
+
23
+ - **Value-embedded secrets**: a bearer token or JWT inside a URL string, or a
24
+ secret in a free-text value, is not caught (only key *names* are matched).
25
+ - **Leaky custom objects**: an object whose ``__str__`` / ``__repr__`` exposes a
26
+ secret is serialised as-is.
27
+
28
+ Value-pattern masking (matching secret-shaped *values*, not just key names) is v2.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from typing import Any, Dict, Iterable, Optional
35
+
36
+ from .fields import redaction_keys
37
+
38
+ #: Mask substituted for redacted values.
39
+ MASK = "***REDACTED***"
40
+
41
+ #: Base deny-list of sensitive key-name patterns. Deny matching is by whole
42
+ #: snake_case *word* (or phrase), so header variants like ``X-Api-Key`` and
43
+ #: camelCase like ``accessToken`` match, while ``mapping`` / ``author`` /
44
+ #: ``wildcard`` do NOT falsely match ``pin`` / ``auth`` / ``card``. Extend per
45
+ #: call via ``deny_extra`` rather than editing this set.
46
+ _BASE_DENY = frozenset(
47
+ {
48
+ "password",
49
+ "passwd",
50
+ "pwd",
51
+ "secret",
52
+ "token",
53
+ "access_token",
54
+ "refresh_token",
55
+ "id_token",
56
+ "authorization",
57
+ "auth",
58
+ "jwt",
59
+ "bearer",
60
+ "api_key",
61
+ "apikey",
62
+ "access_key",
63
+ "secret_key",
64
+ "private_key",
65
+ "session_id",
66
+ "cookie",
67
+ "set_cookie",
68
+ "headers",
69
+ "credentials",
70
+ "card_number",
71
+ "cvv",
72
+ "cvc",
73
+ "ssn",
74
+ }
75
+ )
76
+
77
+ #: High-signal secret tokens also matched as a plain substring of the
78
+ #: separator-stripped key, so all-lowercase concatenations without a delimiter
79
+ #: (``accesstoken``, ``apitoken``, ``privatekey``) are caught too. Kept to long,
80
+ #: unambiguous words to avoid masking innocent keys (``key`` is deliberately NOT
81
+ #: here: it would mask ``monkey`` / ``primary_key``).
82
+ _SUBSTRING_DENY = frozenset(
83
+ {
84
+ "password",
85
+ "passwd",
86
+ "secret",
87
+ "token",
88
+ "authorization",
89
+ "apikey",
90
+ "credential",
91
+ "privatekey",
92
+ "accesskey",
93
+ "secretkey",
94
+ }
95
+ )
96
+
97
+ _NON_ALNUM = re.compile(r"[^a-z0-9]")
98
+ _CAMEL_1 = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
99
+ _CAMEL_2 = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
100
+ _SEP = re.compile(r"[-.\s]+")
101
+ _MULTI_US = re.compile(r"_+")
102
+
103
+
104
+ def _to_snake(name: object) -> str:
105
+ """Normalise a key to lowercase snake_case (camelCase and ``-`` / ``.`` unified).
106
+
107
+ ``apiKey`` / ``X-Api-Key`` / ``api.key`` all normalise to ``api_key``, so the
108
+ matcher is agnostic to the caller's naming style.
109
+ """
110
+ text = str(name)
111
+ text = _CAMEL_1.sub("_", text)
112
+ text = _CAMEL_2.sub("_", text)
113
+ text = _SEP.sub("_", text.lower())
114
+ return _MULTI_US.sub("_", text).strip("_")
115
+
116
+
117
+ def _in_deny(snake_key: str, deny: frozenset) -> bool:
118
+ """True if ``snake_key`` matches a deny phrase.
119
+
120
+ Two layers:
121
+
122
+ - Whole-word / phrase match on ``_``-delimited words (avoids short-token false
123
+ positives: ``card`` matches ``card_number`` and ``card`` but not ``wildcard``).
124
+ - Plain substring match for the curated high-signal tokens in
125
+ :data:`_SUBSTRING_DENY`, over the separator-stripped key, so all-lowercase
126
+ concatenations (``accesstoken``, ``apitoken``) are caught too.
127
+ """
128
+ for phrase in deny:
129
+ if (
130
+ snake_key == phrase
131
+ or snake_key.startswith(phrase + "_")
132
+ or snake_key.endswith("_" + phrase)
133
+ or ("_" + phrase + "_") in snake_key
134
+ ):
135
+ return True
136
+ collapsed = _NON_ALNUM.sub("", snake_key)
137
+ return any(token in collapsed for token in _SUBSTRING_DENY)
138
+
139
+
140
+ def _build_deny(deny_extra: Optional[Iterable[str]]) -> frozenset:
141
+ keys = set(_BASE_DENY) | set(redaction_keys())
142
+ if deny_extra:
143
+ keys |= set(deny_extra)
144
+ return frozenset(_to_snake(k) for k in keys)
145
+
146
+
147
+ def _redact(value: object, deny: frozenset, allow: Optional[frozenset]) -> object:
148
+ if isinstance(value, dict):
149
+ out: Dict[Any, Any] = {}
150
+ for key, val in value.items():
151
+ snake_key = _to_snake(key)
152
+ if _in_deny(snake_key, deny):
153
+ out[key] = MASK
154
+ elif allow is not None and snake_key not in allow:
155
+ out[key] = MASK
156
+ else:
157
+ out[key] = _redact(val, deny, allow)
158
+ return out
159
+ if isinstance(value, (list, tuple)):
160
+ return [_redact(item, deny, allow) for item in value]
161
+ return value
162
+
163
+
164
+ def default_redactor(
165
+ payload: object,
166
+ *,
167
+ allow_only: Optional[Iterable[str]] = None,
168
+ deny_extra: Optional[Iterable[str]] = None,
169
+ ) -> object:
170
+ """Return a redacted copy of ``payload`` without mutating the original.
171
+
172
+ Descends dicts and lists. In the default deny-list mode, keys whose name
173
+ matches the base pattern set or a registry-derived sensitive leaf (by whole
174
+ snake_case word) are masked. Passing ``allow_only`` switches to allow-list
175
+ mode: only keys whose normalised name exactly equals a named key survive at any
176
+ depth. The deny-list still applies as a backstop, so a key that is both allowed
177
+ and sensitive is still masked (identity fields belong in ``bind_user``, not in a
178
+ redacted body). Non-container payloads (str, int, ``None``, ...) pass through
179
+ unchanged.
180
+
181
+ Args:
182
+ payload: The structure to redact (typically a request / response body).
183
+ allow_only: If given, keep only these key names (exact, normalised); mask
184
+ the rest. Use on PII-heavy routes.
185
+ deny_extra: Extra sensitive key names to add to the deny-list for this call.
186
+ """
187
+ deny = _build_deny(deny_extra)
188
+ allow = frozenset(_to_snake(k) for k in allow_only) if allow_only is not None else None
189
+ return _redact(payload, deny, allow)
@@ -1,44 +0,0 @@
1
- """
2
- Backwards-compatibility shim for nui-lambda-shared-utils.
3
-
4
- This package has been renamed to nui-python-shared-utils.
5
- The import name has changed from nui_lambda_shared_utils to nui_shared_utils.
6
-
7
- This shim forwards attribute access to nui_shared_utils so existing consumers
8
- continue to work without changes. New code should use:
9
-
10
- from nui_shared_utils import ...
11
-
12
- This shim will be removed in the next major version (2.0.0).
13
-
14
- Forwarding is lazy (PEP 562 ``__getattr__``) to preserve the cold-start
15
- optimisation in the underlying package: ``from nui_lambda_shared_utils.jwt_auth
16
- import check_auth`` only imports ``jwt_auth`` and its dependencies, not the
17
- full slack/es/db client surface.
18
- """
19
-
20
- import warnings
21
- from typing import Any, List
22
-
23
- warnings.warn(
24
- "nui_lambda_shared_utils is deprecated. Use nui_shared_utils instead. "
25
- "This shim will be removed in version 2.0.0.",
26
- DeprecationWarning,
27
- stacklevel=2,
28
- )
29
-
30
- import nui_shared_utils as _target
31
-
32
- __all__ = list(_target.__all__)
33
-
34
-
35
- def __getattr__(name: str) -> Any:
36
- # Delegate to the new package's lazy resolver. Cache on this module so
37
- # subsequent accesses avoid the round-trip.
38
- value = getattr(_target, name)
39
- globals()[name] = value
40
- return value
41
-
42
-
43
- def __dir__() -> List[str]:
44
- return sorted(set(globals()) | set(__all__))
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.base_client import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.cli import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.cloudwatch_metrics import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.config import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.db_client import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.error_handler import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.es_client import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.es_query_builder import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.jwt_auth import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.lambda_helpers import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.log_processors import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.powertools_helpers import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.secrets_helper import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.slack_client import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.slack_formatter import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.slack_setup instead."""
2
- from nui_shared_utils.slack_setup import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.slack_setup.channel_creator instead."""
2
- from nui_shared_utils.slack_setup.channel_creator import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.slack_setup.channel_definitions instead."""
2
- from nui_shared_utils.slack_setup.channel_definitions import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.slack_setup.setup_helpers instead."""
2
- from nui_shared_utils.slack_setup.setup_helpers import * # noqa: F401,F403
@@ -1,3 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.snowflake_client instead."""
2
-
3
- from nui_shared_utils.snowflake_client import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.timezone import * # noqa: F401,F403
@@ -1,2 +0,0 @@
1
- """Backwards-compatibility shim. Use nui_shared_utils.MODULE instead."""
2
- from nui_shared_utils.utils import * # noqa: F401,F403
@@ -1,53 +0,0 @@
1
- nui_lambda_shared_utils/__init__.py,sha256=FaaYBqgNxSQ0x7jlf5fw2pQoMh8Pbmw7uZiVfavI_IY,1320
2
- nui_lambda_shared_utils/base_client.py,sha256=c7t2_IfISQ39Y1AY6MOj-SlX2tkpzNpp47RRMfu5xqY,135
3
- nui_lambda_shared_utils/cli.py,sha256=3TtfQy0x3P0QKiNDiz0j3kze7Hs26CMeQE8X_948OEQ,127
4
- nui_lambda_shared_utils/cloudwatch_metrics.py,sha256=Qwj52AIVrxg7HEEMjmgvDptCJ5Csr_w3NJBI1v34sFw,142
5
- nui_lambda_shared_utils/config.py,sha256=_vC6lOPLyfbaxqcEC2tKb4oQsxf084cBafspyVPdALo,130
6
- nui_lambda_shared_utils/db_client.py,sha256=DxmBYOmjp8qs5blD2-8eHreFsetqhfoA5Fp-vv2vL_E,133
7
- nui_lambda_shared_utils/error_handler.py,sha256=wXsPQEmr-Grx3EXRSIoBx8ZITLlt_1iWwqS_OBNCiH4,137
8
- nui_lambda_shared_utils/es_client.py,sha256=r_0z0t6-mv5JAot6h3uEMcxgSWXamV0_QvTZnAdoXq4,133
9
- nui_lambda_shared_utils/es_query_builder.py,sha256=I7zkJBoScpuMREBLu8WQ9H-Cu3ZI7DzCcNV06CgF74M,140
10
- nui_lambda_shared_utils/jwt_auth.py,sha256=SCb1uZQ4TAc1XD_ixrVU-kuOEaVBuZ5l3y1iSX5xPwg,132
11
- nui_lambda_shared_utils/lambda_helpers.py,sha256=azLm_ie9CmjTw-Np3v_X1h-XlPN3zhEaBNNmHMbWd1c,138
12
- nui_lambda_shared_utils/log_processors.py,sha256=jKq0qVUlIp6CxLvZbbOltYFjKsK6b0XRrkvqgE_477I,138
13
- nui_lambda_shared_utils/powertools_helpers.py,sha256=xTnbiyE_5bl9saizsKYd1U4rQBP8E8bKUCy_L1lNg_8,142
14
- nui_lambda_shared_utils/secrets_helper.py,sha256=wuThNFPFnU0K8N7I0UGMsyk9oJmOSYTR08xJy2MC8-M,138
15
- nui_lambda_shared_utils/slack_client.py,sha256=_itnosOoODXq1dAX2dMReQi0gZBARjcGqwtRjh1MFPc,136
16
- nui_lambda_shared_utils/slack_formatter.py,sha256=gwCkhfVdOvg44WW6hBogvfmcHsZxSZ-mzXUq6ft1k84,139
17
- nui_lambda_shared_utils/snowflake_client.py,sha256=p-gI8vEF_QyFU0Zts8I5jwIPzrAuYopAKXsv76MNZio,151
18
- nui_lambda_shared_utils/timezone.py,sha256=eLXPmhgFazj76K1aWiO5CSZbuiSlbXEGY8X-MxDKbjw,132
19
- nui_lambda_shared_utils/utils.py,sha256=sWDeHr9Jm3A6yaIqgP9b6_lHoPJox8gD6G1-eiYAgq4,129
20
- nui_lambda_shared_utils/slack_setup/__init__.py,sha256=q78NzSznsSd3ri5OUohWxQt7r6q0zdrE3gF2hvpHnD8,140
21
- nui_lambda_shared_utils/slack_setup/channel_creator.py,sha256=xaeqzyEAqVqYj2EhN69UvcyZQ6LdFKYODPPCwxHInLA,172
22
- nui_lambda_shared_utils/slack_setup/channel_definitions.py,sha256=3TkdLzWvEMBMDy5jqOPMUGRgKQ3z9TN58D43V7O7LpE,180
23
- nui_lambda_shared_utils/slack_setup/setup_helpers.py,sha256=ex_RiUfiZqH9qGLQwmedezMbfp35uI-VQPevducfbBk,168
24
- nui_python_shared_utils-1.5.0.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
25
- nui_shared_utils/__init__.py,sha256=WOpBwxBqJK_wq8l3s5TCCs4j__lMTbI7TjraTXfmH4c,11851
26
- nui_shared_utils/base_client.py,sha256=I1lKQGhrKyvujV2zps0TrtEdPDqMCwRQaIh6ceGONXQ,11016
27
- nui_shared_utils/cli.py,sha256=JJpSoQWKvAz4b8cO30yFNi5vY9jmqrCHzbFpvrVTkbU,8747
28
- nui_shared_utils/cloudwatch_metrics.py,sha256=p0J-s63UJDGK14TRGt0k8yoj7aWSSM55UKdgcuaT1dU,12098
29
- nui_shared_utils/config.py,sha256=ZbkUKDxnHMJiHNv-TlWKjotmf2mH1Vz3GaIXgGfWg7E,4524
30
- nui_shared_utils/db_client.py,sha256=HyVKJ1Su0dVPV1QI2MU5UtT8myDRCjKgV60xRL4SEdE,21726
31
- nui_shared_utils/error_handler.py,sha256=pJ6b_mI0Ait8QRbs9UmLzYUN-Ft0l_dnpudVO_6LRSE,12171
32
- nui_shared_utils/es_client.py,sha256=rLyexuL6_RXQpuMC4A2SeJDk30medx2qxOgWIG3A_7o,15765
33
- nui_shared_utils/es_query_builder.py,sha256=UuheQf8b2UXaOiJlYCKYIfKfVERDZ3ag1P0OOOWm9do,12158
34
- nui_shared_utils/jwt_auth.py,sha256=2Ag1zZKxd2R8QBz3aQA4h8OtKHQvUVKrjUp6lXpJxJU,9143
35
- nui_shared_utils/lambda_helpers.py,sha256=psHVotpmOfnmyQCoOt4MSIEj7VwWcy6gZM3vYSZwjOk,3041
36
- nui_shared_utils/llm.py,sha256=adNUO8Qbeb3__GvpROxWSUERkpiazKiGJFE5xzU5eZ0,8351
37
- nui_shared_utils/log_processors.py,sha256=x5gz1LEkbmoMCZ3ZB6q-mbn26dSzwh1trz4wSkBGxqk,5538
38
- nui_shared_utils/powertools_helpers.py,sha256=qc-lYLfY-QX20gALfPOJcihrtJhiCU-fJW8rsPT7LJE,11037
39
- nui_shared_utils/secrets_helper.py,sha256=_jQkMWzjayZ8vvyHG2yuuxur-SQYUUONfcsZ0MF8qOw,7025
40
- nui_shared_utils/slack_client.py,sha256=wXE0D4T4FWt1giz9Xmp71RustpaJYeLAZo6JxulDVko,25550
41
- nui_shared_utils/slack_formatter.py,sha256=95g6XfAJst7RVhd0M0ahiF3gWWW5j96WYkCg5tLS6Zg,10894
42
- nui_shared_utils/snowflake_client.py,sha256=e3jH6_2mMqbtP1TE8-mUFFYrfGW9eqUPT5tlcgeCqx8,16181
43
- nui_shared_utils/timezone.py,sha256=TvtSZV7w3Vvz3NbMTcJSNbUf0ZxPwpFS-xfgye1h-4Q,3583
44
- nui_shared_utils/utils.py,sha256=gOpw80tZGC24QsyesR8EySRODY-TFXG8cXUvggPBsRI,8184
45
- nui_shared_utils/slack_setup/__init__.py,sha256=OElyS3xk4F_YKH5uUUTDpN0ah1dOO3e52muIPjAMPW8,320
46
- nui_shared_utils/slack_setup/channel_creator.py,sha256=0gyCBIS0EC96SVn1Z2dD4Fpzk0xNdHSWyiCxoUQIUe8,10667
47
- nui_shared_utils/slack_setup/channel_definitions.py,sha256=atfz5ZhpqefOeLh1gShbWd-TLzgjPmhv95bfuTdmZog,5676
48
- nui_shared_utils/slack_setup/setup_helpers.py,sha256=pzzXMs12GI9sdZttWeYzgLPgC0xqPz7ZLHM1GUPNNXc,7219
49
- nui_python_shared_utils-1.5.0.dist-info/METADATA,sha256=DYwyqAyRcrN3Kxy9CVVTf4CRXmqNdlqcfYF_yA2MKuQ,22632
50
- nui_python_shared_utils-1.5.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
51
- nui_python_shared_utils-1.5.0.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
52
- nui_python_shared_utils-1.5.0.dist-info/top_level.txt,sha256=sNceq5okmEB54L-gm4a0OgGhnPlU62zYmlUoXKn-Fa8,41
53
- nui_python_shared_utils-1.5.0.dist-info/RECORD,,
@@ -1,2 +0,0 @@
1
- nui_lambda_shared_utils
2
- nui_shared_utils