nui-python-shared-utils 2.4.1__tar.gz → 2.5.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.
Files changed (106) hide show
  1. nui_python_shared_utils-2.5.0/.github/workflows/lambda-validation.yml +71 -0
  2. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/AGENTS.md +14 -0
  3. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/CHANGELOG.md +28 -0
  4. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/MANIFEST.in +2 -0
  5. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/PKG-INFO +4 -2
  6. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/README.md +3 -1
  7. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/ADR-004-credential-resolution-precedence.md +20 -5
  8. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/getting-started/configuration.md +18 -2
  9. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/powertools-integration.md +2 -2
  10. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_python_shared_utils.egg-info/SOURCES.txt +1 -0
  11. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/config.py +3 -1
  12. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/db_client.py +7 -86
  13. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/es_client.py +15 -17
  14. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/powertools_helpers.py +2 -2
  15. nui_python_shared_utils-2.5.0/nui_shared_utils/secrets_helper.py +334 -0
  16. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/slack_client.py +7 -8
  17. nui_python_shared_utils-2.5.0/tests/test_aws_utils.py +119 -0
  18. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_db_client.py +97 -23
  19. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_es_client.py +142 -4
  20. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_powertools_helpers.py +17 -7
  21. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_secrets_helper.py +148 -0
  22. nui_python_shared_utils-2.4.1/nui_shared_utils/secrets_helper.py +0 -208
  23. nui_python_shared_utils-2.4.1/tests/test_aws_utils.py +0 -325
  24. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/.editorconfig +0 -0
  25. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/.github/workflows/ci.yml +0 -0
  26. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/.github/workflows/publish.yml +0 -0
  27. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/.github/workflows/test.yml +0 -0
  28. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/.markdownlint-base.yaml +0 -0
  29. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/.markdownlint-cli2.yaml +0 -0
  30. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/CLAUDE.md +0 -0
  31. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/CONTRIBUTING.md +0 -0
  32. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/LICENSE +0 -0
  33. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/README.md +0 -0
  34. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/ADR-001-structured-logging-field-vocabulary.md +0 -0
  35. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/ADR-002-keep-shared-utilities-generic.md +0 -0
  36. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/ADR-003-lazy-load-integrations.md +0 -0
  37. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/ADR-005-explicit-secrets-manager-region.md +0 -0
  38. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/ADR-006-snowflake-sql-api-client.md +0 -0
  39. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/ADR-007-elasticsearch-client-version-range.md +0 -0
  40. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/ADR-008-stdlib-zoneinfo-over-pytz.md +0 -0
  41. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/decisions/README.md +0 -0
  42. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/development/testing.md +0 -0
  43. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/getting-started/installation.md +0 -0
  44. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/getting-started/quickstart.md +0 -0
  45. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/cli-tools.md +0 -0
  46. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/elasticsearch-integration.md +0 -0
  47. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/jwt-authentication.md +0 -0
  48. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/lambda-utilities.md +0 -0
  49. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/llm-integration.md +0 -0
  50. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/log-processing.md +0 -0
  51. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/shared-types.md +0 -0
  52. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/slack-integration.md +0 -0
  53. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/snowflake-integration.md +0 -0
  54. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/docs/guides/structured-logging-contract.md +0 -0
  55. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/mypy.ini +0 -0
  56. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/__init__.py +0 -0
  57. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/base_client.py +0 -0
  58. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/cli.py +0 -0
  59. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/cloudwatch_metrics.py +0 -0
  60. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/error_handler.py +0 -0
  61. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/es_query_builder.py +0 -0
  62. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/jwt_auth.py +0 -0
  63. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/lambda_helpers.py +0 -0
  64. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/llm.py +0 -0
  65. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/log_processors.py +0 -0
  66. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/logging/__init__.py +0 -0
  67. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/logging/binding.py +0 -0
  68. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/logging/fields.py +0 -0
  69. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/logging/redaction.py +0 -0
  70. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/slack_formatter.py +0 -0
  71. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/slack_setup/__init__.py +0 -0
  72. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/slack_setup/channel_creator.py +0 -0
  73. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/slack_setup/channel_definitions.py +0 -0
  74. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/slack_setup/setup_helpers.py +0 -0
  75. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/snowflake_client.py +0 -0
  76. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/timezone.py +0 -0
  77. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/nui_shared_utils/utils.py +0 -0
  78. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/pyproject.toml +0 -0
  79. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/pytest.ini +0 -0
  80. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/redirect/README.md +0 -0
  81. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/redirect/pyproject.toml +0 -0
  82. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/redirect/setup.py +0 -0
  83. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/requirements-test.txt +0 -0
  84. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/scripts/bench_imports.py +0 -0
  85. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/setup.cfg +0 -0
  86. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/setup.py +0 -0
  87. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/__init__.py +0 -0
  88. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_base_client.py +0 -0
  89. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_cloudwatch_metrics.py +0 -0
  90. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_config.py +0 -0
  91. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_error_handler.py +0 -0
  92. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_es_client_compat.py +0 -0
  93. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_es_query_builder.py +0 -0
  94. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_jwt_auth.py +0 -0
  95. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_lambda_helpers.py +0 -0
  96. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_lazy_imports.py +0 -0
  97. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_llm.py +0 -0
  98. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_log_processors.py +0 -0
  99. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_logging_binding.py +0 -0
  100. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_logging_fields.py +0 -0
  101. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_redaction.py +0 -0
  102. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_slack_client.py +0 -0
  103. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_slack_formatter.py +0 -0
  104. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_snowflake_client.py +0 -0
  105. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_timezone.py +0 -0
  106. {nui_python_shared_utils-2.4.1 → nui_python_shared_utils-2.5.0}/tests/test_utils.py +0 -0
@@ -0,0 +1,71 @@
1
+ name: Lambda validation tool
2
+
3
+ # tools/lambda-validation is a separately versioned build-time package. Its consumers
4
+ # pin it by commit, so every change runs its own suite, including the container checks.
5
+ on:
6
+ push:
7
+ branches: [master]
8
+ pull_request:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ tool:
15
+ runs-on: ubuntu-latest
16
+ timeout-minutes: 20
17
+ strategy:
18
+ matrix:
19
+ python-version: ['3.11', '3.12', '3.13', '3.14']
20
+ defaults:
21
+ run:
22
+ working-directory: tools/lambda-validation
23
+ steps:
24
+ - uses: actions/checkout@v5
25
+ with:
26
+ persist-credentials: false
27
+ - uses: actions/setup-python@v6
28
+ with:
29
+ python-version: ${{ matrix.python-version }}
30
+ - name: Install the tool and its test runner
31
+ run: |
32
+ python -m venv "$RUNNER_TEMP/lv"
33
+ "$RUNNER_TEMP/lv/bin/pip" install . pytest==8.4.2
34
+ - name: Lint the tool
35
+ run: |
36
+ python -m venv "$RUNNER_TEMP/lint"
37
+ "$RUNNER_TEMP/lint/bin/pip" install ruff==0.16.6 black==24.10.0
38
+ "$RUNNER_TEMP/lint/bin/ruff" check --select F,E9,B .
39
+ "$RUNNER_TEMP/lint/bin/black" -l 120 --check .
40
+ - name: Pull the Lambda runtime images the container tests use
41
+ run: |
42
+ for version in 3.11 3.12 3.13 3.14; do
43
+ docker pull --platform linux/amd64 "public.ecr.aws/lambda/python:$version"
44
+ done
45
+ - name: Tool tests, zero skips
46
+ run: |
47
+ "$RUNNER_TEMP/lv/bin/python" -m pytest -p no:cacheprovider -c /dev/null --rootdir . \
48
+ --junitxml="$RUNNER_TEMP/tool.xml" tests
49
+ "$RUNNER_TEMP/lv/bin/python" -c 'import os, xml.etree.ElementTree as ET; r = ET.parse(os.environ["RUNNER_TEMP"] + "/tool.xml").getroot(); assert sum(int(s.get("skipped", 0)) for s in r.iter("testsuite")) == 0'
50
+
51
+ root-distribution:
52
+ # The root nui-python-shared-utils wheel must never carry the tool.
53
+ runs-on: ubuntu-latest
54
+ timeout-minutes: 10
55
+ steps:
56
+ - uses: actions/checkout@v5
57
+ with:
58
+ persist-credentials: false
59
+ fetch-depth: 0
60
+ - uses: actions/setup-python@v6
61
+ with:
62
+ python-version: '3.12'
63
+ - run: python -m pip install build
64
+ - run: python -m build --outdir "$RUNNER_TEMP/dist" .
65
+ - name: No tool members in the root wheel or sdist
66
+ # `set -e` ignores a `!`-negated command, so each test exits explicitly.
67
+ run: |
68
+ if unzip -Z1 "$RUNNER_TEMP"/dist/*.whl | grep -E '(^|/)(tools/|nui_lambda_validation)'; then exit 1; fi
69
+ if tar -tzf "$RUNNER_TEMP"/dist/*.tar.gz | grep -E '/tools/'; then exit 1; fi
70
+ unzip -Z1 "$RUNNER_TEMP"/dist/*.whl | grep '^nui_shared_utils/__init__.py$' >/dev/null
71
+ tar -tzf "$RUNNER_TEMP"/dist/*.tar.gz | grep '/nui_shared_utils/__init__.py$' >/dev/null # no -q: an early exit would SIGPIPE the lister under pipefail
@@ -65,6 +65,15 @@ The `slack_setup/` submodule provides automated Slack workspace configuration:
65
65
  - **CLI Tool** - `slack-channel-setup` command for automation
66
66
  - **Generic Design** - Adaptable for any Slack workspace, not NUI-specific
67
67
 
68
+ ### Build-time tools (`tools/`)
69
+
70
+ `tools/lambda-validation/` is a separate distribution, `nui-lambda-validation`, with its own
71
+ `pyproject.toml`, tests and workflow (`.github/workflows/lambda-validation.yml`). It must not
72
+ import or depend on `nui_shared_utils`, and `MANIFEST.in` prunes `tools/` so it never ships in
73
+ this package. The root `pytest` run does not collect its tests; run them from the tool
74
+ directory as its README describes. Consumers pin it by full commit SHA, so a change there is a
75
+ new release for every repository that bumps its pin.
76
+
68
77
  ## Design Principles
69
78
 
70
79
  ### Keeping the Package Generic
@@ -194,6 +203,11 @@ Clients resolve credentials in this order (first match wins):
194
203
  2. **Environment variables** — per-client patterns, skips Secrets Manager
195
204
  3. **AWS Secrets Manager** — default behavior
196
205
 
206
+ The `get_*_credentials` helpers in `secrets_helper.py` follow tiers 2 and 3 through the same
207
+ per-service env resolver and secret normaliser the clients use. Elasticsearch host:
208
+ `host` argument > `ES_HOST`/`ELASTICSEARCH_HOST` > `configure(es_host=...)` > secret `host` >
209
+ `localhost:9200`.
210
+
197
211
  ### Environment Variables
198
212
 
199
213
  Runtime configuration:
@@ -10,11 +10,39 @@ that contained them.
10
10
 
11
11
  ### Changed
12
12
 
13
+ - **BREAKING**: `get_database_credentials` and `get_slack_credentials` check environment
14
+ variables before Secrets Manager, matching `DatabaseClient`, `SlackClient` and
15
+ `get_elasticsearch_credentials`. With `DB_HOST` and `DB_PASSWORD` set, or `SLACK_BOT_TOKEN`
16
+ set, they return the env values (MySQL defaults for unset `DB_PORT`, `DB_USERNAME`,
17
+ `DB_DATABASE`) and make no AWS call, even when a `secret_name` is passed, and they no longer
18
+ raise for a missing secret name in that case. A caller that reads a second database or
19
+ workspace through `get_database_credentials("<other secret>")` or
20
+ `get_slack_credentials("<other secret>")` while the `DB_*` or `SLACK_BOT_TOKEN` env vars
21
+ describe the first gets the first; read that secret with `get_secret` and map its fields.
22
+ - `ElasticsearchClient` reads the `host` field of its resolved credentials. Host order on the
23
+ client and `get_elasticsearch_credentials` is: `host` argument (client only), `ES_HOST` or
24
+ `ELASTICSEARCH_HOST`, a host set with `configure(es_host=...)`, the credentials' `host`, then
25
+ `localhost:9200`. A client built from a secret that carries a host, with neither `ES_HOST` nor
26
+ `configure(es_host=...)` set, connects to that host instead of `localhost:9200`. The helper
27
+ appends `:9200` to any host that is not a URL and names no port, including one that starts
28
+ with `http` (`httpd-es`), as the client does.
29
+ - `ElasticsearchClient` and `SlackClient` add the normalised fields the helpers return to
30
+ credentials fetched from Secrets Manager (`host`/`username`/`password`, and
31
+ `bot_token`/`webhook_url`), keeping the secret's other keys. A legacy `token` field is used
32
+ when `bot_token` is missing, null or empty; the helper used to keep a null or empty
33
+ `bot_token`. `SlackClient.credentials` from env vars always carries a `webhook_url` key,
34
+ `None` when `SLACK_WEBHOOK_URL` is unset.
35
+
13
36
  - **BREAKING**: require Python 3.10 or newer in both the main package and the legacy
14
37
  redirect package. Python 3.9 is no longer supported or included in CI test matrices.
38
+ - Powertools docstring and guide examples use generic service and namespace names
39
+ (`order-processor`, `Ecommerce/Orders`) instead of internal repo names. Docs only, no
40
+ behaviour change.
15
41
 
16
42
  ### Fixed
17
43
 
44
+ - `PostgreSQLClient` given a secret with no `port` or `database` fills PostgreSQL defaults
45
+ (`5432`, `postgres`) instead of the MySQL ones (`3306`, `app`).
18
46
  - Use canonical IANA names `America/New_York` and `America/Los_Angeles` for US timezone
19
47
  constants and option labels, so importing timezone utilities and Slack formatting works
20
48
  on systems without legacy timezone links.
@@ -7,6 +7,8 @@ recursive-include nui_shared_utils *.py
7
7
  recursive-include nui_shared_utils/slack_setup *.yaml
8
8
 
9
9
  # Exclude development and build files
10
+ # tools/ holds separately versioned build-time packages, never part of this distribution
11
+ prune tools
10
12
  exclude .gitignore
11
13
  exclude .github
12
14
  recursive-exclude * __pycache__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nui-python-shared-utils
3
- Version: 2.4.1
3
+ Version: 2.5.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
@@ -152,6 +152,8 @@ Production-ready shared Python utilities for AWS Lambda functions, CLI tools, an
152
152
 
153
153
  **Complete documentation**: See [docs/](docs/README.md) for all guides and references.
154
154
 
155
+ **Build-time Lambda artifact checks** live in a separate package, `nui-lambda-validation`, pinned by commit and never shipped in this distribution or in a Lambda ZIP: [tools/lambda-validation/README.md](tools/lambda-validation/README.md).
156
+
155
157
  **Component Guides:**
156
158
 
157
159
  - **[AWS Powertools Integration Guide](docs/guides/powertools-integration.md)** - Logging, metrics, error handling
@@ -221,7 +223,7 @@ from nui_shared_utils import get_secret, get_slack_credentials
221
223
  api_keys = get_secret("my-service/api-keys")
222
224
 
223
225
  # Specialized getters with normalized field names
224
- slack_creds = get_slack_credentials() # Uses configured secret name
226
+ slack_creds = get_slack_credentials() # SLACK_BOT_TOKEN if set, else the configured secret
225
227
  ```
226
228
 
227
229
  **[→ See full secrets management guide](docs/getting-started/configuration.md#aws-secrets-manager)**
@@ -52,6 +52,8 @@ Production-ready shared Python utilities for AWS Lambda functions, CLI tools, an
52
52
 
53
53
  **Complete documentation**: See [docs/](docs/README.md) for all guides and references.
54
54
 
55
+ **Build-time Lambda artifact checks** live in a separate package, `nui-lambda-validation`, pinned by commit and never shipped in this distribution or in a Lambda ZIP: [tools/lambda-validation/README.md](tools/lambda-validation/README.md).
56
+
55
57
  **Component Guides:**
56
58
 
57
59
  - **[AWS Powertools Integration Guide](docs/guides/powertools-integration.md)** - Logging, metrics, error handling
@@ -121,7 +123,7 @@ from nui_shared_utils import get_secret, get_slack_credentials
121
123
  api_keys = get_secret("my-service/api-keys")
122
124
 
123
125
  # Specialized getters with normalized field names
124
- slack_creds = get_slack_credentials() # Uses configured secret name
126
+ slack_creds = get_slack_credentials() # SLACK_BOT_TOKEN if set, else the configured secret
125
127
  ```
126
128
 
127
129
  **[→ See full secrets management guide](docs/getting-started/configuration.md#aws-secrets-manager)**
@@ -24,14 +24,19 @@ The function-based helper historically undercut this. `get_elasticsearch_credent
24
24
  (`secrets_helper.py`) called Secrets Manager first even when `ES_PASSWORD` was set, so CLI and
25
25
  local-dev paths paid an avoidable AWS call. Commit `0d3f8e8` (PR #23) added the env-var check
26
26
  ahead of the store lookup, aligning the helper with the precedence the clients already enforced.
27
+ The database and Slack helpers kept calling the store first until they were routed through the
28
+ clients' own env resolvers, and the Elasticsearch client ignored a secret's `host` field that the
29
+ helper honoured.
27
30
 
28
31
  ## Decision
29
32
 
30
33
  Credentials resolve in a fixed order, first match wins, owned by `BaseClient._resolve_credentials`
31
34
  in `base_client.py`:
32
35
 
33
- 1. **Explicit `credentials` dict** passed to the constructor. Returned as-is; nothing else is
34
- consulted. The caller owns these values and the package does not merge or second-guess them.
36
+ 1. **Explicit `credentials` dict** passed to the constructor. Returned as-is; no other credential
37
+ source is consulted. The caller owns these values and the package does not merge or
38
+ second-guess them. An Elasticsearch `host` key in the dict is the one exception: it follows
39
+ the host order below.
35
40
  2. **Per-client environment variables**, via each client's `_resolve_credentials_from_env` override
36
41
  (Elasticsearch reads `ES_PASSWORD`/`ES_PASS` in `es_client.py`; database requires
37
42
  `DB_HOST` + `DB_PASSWORD` in `db_client.py`; Slack requires `SLACK_BOT_TOKEN` in
@@ -42,9 +47,19 @@ in `base_client.py`:
42
47
 
43
48
  A later tier fires only when every earlier tier is absent. An AWS call happens only when neither
44
49
  an explicit dict nor env vars resolve, so local-dev, CLI, and test paths that supply explicit or
45
- environment credentials make **zero** Secrets Manager calls. The direct function helper mirrors
46
- the tail of this ladder for callers that bypass the client classes:
47
- `get_elasticsearch_credentials` checks env vars, then the store.
50
+ environment credentials make **zero** Secrets Manager calls. The direct function helpers mirror
51
+ the tail of this ladder for callers that bypass the client classes: `get_elasticsearch_credentials`,
52
+ `get_database_credentials` and `get_slack_credentials` each check the same env vars as their
53
+ client, then the store. Client hook and helper call the same per-service functions in
54
+ `secrets_helper.py` (an env resolver and a secret normaliser per service, with per-engine defaults
55
+ for MySQL and PostgreSQL), so the two paths cannot drift apart.
56
+
57
+ The Elasticsearch host follows its own order on both paths: the client's `host` argument, then
58
+ `ES_HOST` (or `ELASTICSEARCH_HOST`), then the `host` field of the resolved credentials (the
59
+ secret's, or an explicit dict's), then `localhost:9200`. A host set with `configure(es_host=...)`
60
+ ranks between the env var and the credentials' `host`, because a caller that set it chose it; an
61
+ `ES_HOST` the config picked up from the environment at startup does not. To pin the host of one
62
+ client whatever the environment holds, pass its `host` argument.
48
63
 
49
64
  ## Alternatives considered
50
65
 
@@ -18,6 +18,18 @@ All clients (Slack, Elasticsearch, Database) resolve credentials in this order:
18
18
  2. **Credential environment variables** - Per-client env vars, skips Secrets Manager
19
19
  3. **AWS Secrets Manager** - Default behavior (unchanged)
20
20
 
21
+ The helper functions `get_elasticsearch_credentials`, `get_database_credentials` and
22
+ `get_slack_credentials` follow the same order from step 2: when the client's env vars are set
23
+ they return those values without calling Secrets Manager. `get_database_credentials` applies the
24
+ MySQL defaults; `PostgreSQLClient` applies the PostgreSQL ones on both the env and the secret path.
25
+
26
+ The Elasticsearch host resolves as: the client's `host` argument, then `ES_HOST` (or
27
+ `ELASTICSEARCH_HOST`), then a host set with `nui.configure(es_host=...)`, then the secret's
28
+ `host` field, then the built-in default
29
+ (`localhost:9200`). The client and `get_elasticsearch_credentials` use the same order. A `host`
30
+ key in an explicit `credentials` dict ranks where the secret's does, so to pin one client's host
31
+ pass `host=` instead.
32
+
21
33
  ## Environment Variables
22
34
 
23
35
  ### Core Configuration
@@ -163,7 +175,8 @@ The package expects AWS secrets in specific JSON formats:
163
175
 
164
176
  **Optional Fields:**
165
177
 
166
- - `host` - Can override ES_HOST if provided
178
+ - `host` - Used when the credentials come from this secret (no `ES_PASSWORD` or `ES_PASS`) and
179
+ no `host=` argument, `ES_HOST`, `ELASTICSEARCH_HOST` or `configure(es_host=...)` names one
167
180
  - `username` - Defaults to "elastic" if not provided
168
181
 
169
182
  #### Database Credentials
@@ -178,7 +191,8 @@ The package expects AWS secrets in specific JSON formats:
178
191
  }
179
192
  ```
180
193
 
181
- **All fields are required** for database connections.
194
+ `host`, `username` and `password` are required. A missing `port` or `database` takes the
195
+ engine's default: `3306`/`app` for MySQL, `5432`/`postgres` for `PostgreSQLClient`.
182
196
 
183
197
  #### Slack Credentials
184
198
 
@@ -274,6 +288,8 @@ slack = nui.SlackClient(secret_name="custom-slack-secret")
274
288
  es_creds = nui.get_elasticsearch_credentials("custom-es-secret")
275
289
  db_creds = nui.get_database_credentials("custom-db-secret")
276
290
  slack_creds = nui.get_slack_credentials("custom-slack-secret")
291
+ # These three return the env values instead, ignoring the secret name, when the
292
+ # client's credential env vars are set (ES_PASSWORD, DB_HOST + DB_PASSWORD, SLACK_BOT_TOKEN).
277
293
 
278
294
  # Generic secret retrieval
279
295
  api_keys = nui.get_secret("my-service/api-keys")
@@ -80,7 +80,7 @@ def get_powertools_logger(
80
80
  Create AWS Powertools Logger with Elasticsearch-compatible formatting.
81
81
 
82
82
  Args:
83
- service_name: Service identifier (e.g., "nui-tender-analyser")
83
+ service_name: Service identifier (e.g., "order-processor")
84
84
  level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
85
85
  local_dev_colors: Enable coloredlogs for local development
86
86
 
@@ -177,7 +177,7 @@ def powertools_handler(
177
177
 
178
178
  Args:
179
179
  service_name: Service identifier for logging and metrics dimensions
180
- metrics_namespace: CloudWatch namespace (e.g., "NUI/TenderAnalyser")
180
+ metrics_namespace: CloudWatch namespace (e.g., "Ecommerce/Orders")
181
181
  slack_alert_channel: Slack channel for error alerts (e.g., "#alerts")
182
182
  slack_account_names: AWS account ID to display-name mapping for alerts
183
183
  slack_account_names_config: YAML file containing account-name defaults
@@ -14,6 +14,7 @@ pytest.ini
14
14
  requirements-test.txt
15
15
  setup.py
16
16
  .github/workflows/ci.yml
17
+ .github/workflows/lambda-validation.yml
17
18
  .github/workflows/publish.yml
18
19
  .github/workflows/test.yml
19
20
  docs/README.md
@@ -34,7 +34,9 @@ class Config:
34
34
  slack_credentials_secret: AWS secret name for Slack credentials
35
35
  aws_region: AWS region for secrets/services
36
36
  """
37
- # Elasticsearch settings
37
+ # Elasticsearch settings. An es_host passed here outranks a host carried in a
38
+ # credentials secret; the env-derived and built-in values do not.
39
+ self._es_host_explicit = es_host is not None
38
40
  if es_host is not None:
39
41
  self.es_host = es_host
40
42
  else:
@@ -2,7 +2,6 @@
2
2
  Refactored Database client using BaseClient for DRY code patterns.
3
3
  """
4
4
 
5
- import os
6
5
  import time
7
6
  import logging
8
7
  from typing import Dict, List, Optional, Any
@@ -11,7 +10,7 @@ import pymysql
11
10
 
12
11
  from .base_client import BaseClient, ServiceHealthMixin
13
12
  from .utils import handle_client_errors, safe_close_connection
14
- from .secrets_helper import get_database_credentials
13
+ from .secrets_helper import get_database_credentials, _db_credentials_from_env, _normalise_db_secret
15
14
 
16
15
  # Optional PostgreSQL support
17
16
  try:
@@ -27,47 +26,6 @@ log = logging.getLogger(__name__)
27
26
  _connection_pool = {}
28
27
 
29
28
 
30
- def _clean_expired_connections(pool_key: str, pool_recycle: int) -> None:
31
- """
32
- Clean expired connections from the pool.
33
-
34
- Args:
35
- pool_key: Pool identifier
36
- pool_recycle: Maximum connection age in seconds (0 disables recycling)
37
- """
38
- if not pool_recycle or pool_recycle <= 0:
39
- return
40
-
41
- if pool_key not in _connection_pool:
42
- return
43
-
44
- current_time = time.time()
45
- pool_entries = _connection_pool[pool_key]
46
-
47
- # Find expired connections
48
- expired_connections = []
49
- fresh_connections = []
50
-
51
- for entry in pool_entries:
52
- age = current_time - entry["timestamp"]
53
- if age > pool_recycle:
54
- expired_connections.append(entry["connection"])
55
- else:
56
- fresh_connections.append(entry)
57
-
58
- # Close expired connections
59
- for conn in expired_connections:
60
- safe_close_connection(conn)
61
-
62
- # Update pool with only fresh connections
63
- _connection_pool[pool_key] = fresh_connections
64
-
65
- if expired_connections:
66
- log.debug(
67
- f"Cleaned {len(expired_connections)} expired connections from pool {pool_key}"
68
- )
69
-
70
-
71
29
  def get_pool_stats() -> Dict[str, Any]:
72
30
  """
73
31
  Get current connection pool statistics.
@@ -158,26 +116,8 @@ class DatabaseClient(BaseClient, ServiceHealthMixin):
158
116
  return None
159
117
 
160
118
  def _resolve_credentials_from_env(self) -> Optional[Dict[str, Any]]:
161
- """Resolve MySQL credentials from environment variables.
162
-
163
- Requires both DB_HOST and DB_PASSWORD to trigger.
164
- """
165
- host = os.environ.get("DB_HOST")
166
- password = os.environ.get("DB_PASSWORD")
167
- if not host or not password:
168
- return None
169
- port_str = os.environ.get("DB_PORT", "3306")
170
- try:
171
- port = int(port_str)
172
- except ValueError:
173
- raise ValueError(f"DB_PORT must be an integer, got: {port_str!r}")
174
- return {
175
- "host": host,
176
- "port": port,
177
- "username": os.environ.get("DB_USERNAME", "root"),
178
- "password": password,
179
- "database": os.environ.get("DB_DATABASE", "app"),
180
- }
119
+ """Resolve MySQL credentials from DB_* env vars (DB_HOST + DB_PASSWORD required)."""
120
+ return _db_credentials_from_env("mysql")
181
121
 
182
122
  def _fetch_credentials_from_sm(self, secret_name: Optional[str]) -> Dict[str, Any]:
183
123
  """Override to use get_database_credentials for normalized field names."""
@@ -511,29 +451,11 @@ class PostgreSQLClient(BaseClient, ServiceHealthMixin):
511
451
  return None
512
452
 
513
453
  def _resolve_credentials_from_env(self) -> Optional[Dict[str, Any]]:
514
- """Resolve PostgreSQL credentials from environment variables.
515
-
516
- Requires both DB_HOST and DB_PASSWORD to trigger.
517
- """
518
- host = os.environ.get("DB_HOST")
519
- password = os.environ.get("DB_PASSWORD")
520
- if not host or not password:
521
- return None
522
- port_str = os.environ.get("DB_PORT", "5432")
523
- try:
524
- port = int(port_str)
525
- except ValueError:
526
- raise ValueError(f"DB_PORT must be an integer, got: {port_str!r}")
527
- return {
528
- "host": host,
529
- "port": port,
530
- "username": os.environ.get("DB_USERNAME", "postgres"),
531
- "password": password,
532
- "database": os.environ.get("DB_DATABASE", "postgres"),
533
- }
454
+ """Resolve PostgreSQL credentials from DB_* env vars (DB_HOST + DB_PASSWORD required)."""
455
+ return _db_credentials_from_env("postgresql")
534
456
 
535
457
  def _fetch_credentials_from_sm(self, secret_name: Optional[str]) -> Dict[str, Any]:
536
- """Override with auth-specific credential handling."""
458
+ """Fetch from Secrets Manager, preferring the auth_* keys when requested and present."""
537
459
  from .secrets_helper import get_secret
538
460
 
539
461
  resolved_secret_name = self._resolve_secret_name(secret_name)
@@ -548,8 +470,7 @@ class PostgreSQLClient(BaseClient, ServiceHealthMixin):
548
470
  "password": raw_creds.get("auth_password"),
549
471
  "database": raw_creds.get("auth_database", "auth-service-db"),
550
472
  }
551
- else:
552
- return get_database_credentials(resolved_secret_name)
473
+ return _normalise_db_secret(raw_creds, "postgresql")
553
474
 
554
475
  @contextmanager
555
476
  def get_connection(self, database: Optional[str] = None):
@@ -2,7 +2,6 @@
2
2
  Refactored Elasticsearch client using BaseClient for DRY code patterns.
3
3
  """
4
4
 
5
- import os
6
5
  import logging
7
6
  from datetime import datetime, timedelta
8
7
  from typing import Dict, Iterator, List, Optional, Any, Tuple
@@ -11,7 +10,8 @@ from elasticsearch import Elasticsearch
11
10
  from elasticsearch.helpers import streaming_bulk as es_streaming_bulk
12
11
 
13
12
  from .base_client import BaseClient, ServiceHealthMixin
14
- from .utils import handle_client_errors, resolve_config_value
13
+ from .utils import handle_client_errors
14
+ from .secrets_helper import _es_credentials_from_env, _es_host_with_port, _normalise_es_secret, _resolve_es_host
15
15
 
16
16
  log = logging.getLogger(__name__)
17
17
 
@@ -55,29 +55,27 @@ class ElasticsearchClient(BaseClient, ServiceHealthMixin):
55
55
  Accepts both conventions: ES_PASSWORD/ES_USERNAME and ES_PASS/ES_USER.
56
56
  Password is required to trigger. Username defaults to "elastic".
57
57
  """
58
- password = os.environ.get("ES_PASSWORD") or os.environ.get("ES_PASS")
59
- if not password:
60
- return None
61
- return {
62
- "username": os.environ.get("ES_USERNAME") or os.environ.get("ES_USER") or "elastic",
63
- "password": password,
64
- }
58
+ return _es_credentials_from_env()
59
+
60
+ def _fetch_credentials_from_sm(self, secret_name: Optional[str]) -> Dict[str, Any]:
61
+ """Fetch from Secrets Manager; normalise host, username, password and keep any other keys."""
62
+ raw = super()._fetch_credentials_from_sm(secret_name)
63
+ return {**raw, **_normalise_es_secret(raw)}
65
64
 
66
65
  def _create_service_client(self) -> Elasticsearch:
67
66
  """Create Elasticsearch client with resolved configuration."""
68
- # Resolve host using utility
69
- host = resolve_config_value(
70
- self._host_override,
71
- ["ES_HOST", "ELASTICSEARCH_HOST"],
72
- getattr(self.config, "es_host", "localhost:9200")
73
- )
67
+ # Host precedence: constructor arg > ES_HOST/ELASTICSEARCH_HOST > configure(es_host=...)
68
+ # > credentials "host" (e.g. from the secret) > default. Shared with get_elasticsearch_credentials.
69
+ if self._host_override is not None:
70
+ host = self._host_override
71
+ else:
72
+ host = _resolve_es_host(self.credentials.get("host"))
73
+ host = _es_host_with_port(host)
74
74
 
75
75
  parsed = urlparse(host)
76
76
  if parsed.scheme and parsed.netloc: # Valid URL with scheme://netloc
77
77
  es_url = host
78
78
  else:
79
- if ":" not in host:
80
- host = f"{host}:9200"
81
79
  scheme = self.client_config.get("scheme", "http")
82
80
  es_url = f"{scheme}://{host}"
83
81
 
@@ -75,7 +75,7 @@ def get_powertools_logger(
75
75
  and enforces UTC timezone for consistency with log aggregation systems.
76
76
 
77
77
  Args:
78
- service_name: Service identifier (e.g., "nui-tender-analyser", "connect-email-ingest")
78
+ service_name: Service identifier (e.g., "order-processor", "email-ingest")
79
79
  level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Default: INFO
80
80
  local_dev_colors: Enable coloredlogs for local development. Default: True
81
81
 
@@ -182,7 +182,7 @@ def powertools_handler(
182
182
 
183
183
  Args:
184
184
  service_name: Service identifier for logging and metrics dimensions
185
- metrics_namespace: CloudWatch namespace for metrics (e.g., "NUI/TenderAnalyser").
185
+ metrics_namespace: CloudWatch namespace for metrics (e.g., "Ecommerce/Orders").
186
186
  If None, metrics publishing is disabled.
187
187
  slack_alert_channel: Slack channel for error alerts (e.g., "#alerts", "#errors").
188
188
  If None, Slack alerting is disabled.