nui-python-shared-utils 1.6.0__tar.gz → 2.1__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 (127) hide show
  1. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/.github/workflows/ci.yml +40 -2
  2. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/.github/workflows/publish.yml +1 -29
  3. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/AGENTS.md +2 -1
  4. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/MANIFEST.in +0 -2
  5. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/PKG-INFO +7 -6
  6. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/README.md +0 -1
  7. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/README.md +1 -1
  8. nui_python_shared_utils-2.1/docs/decisions/ADR-002-keep-shared-utilities-generic.md +94 -0
  9. nui_python_shared_utils-2.1/docs/decisions/ADR-003-lazy-load-integrations.md +110 -0
  10. nui_python_shared_utils-2.1/docs/decisions/ADR-004-credential-resolution-precedence.md +98 -0
  11. nui_python_shared_utils-2.1/docs/decisions/ADR-005-explicit-secrets-manager-region.md +88 -0
  12. nui_python_shared_utils-2.1/docs/decisions/ADR-006-snowflake-sql-api-client.md +105 -0
  13. nui_python_shared_utils-2.1/docs/decisions/ADR-007-elasticsearch-client-version-range.md +85 -0
  14. nui_python_shared_utils-2.1/docs/decisions/ADR-008-stdlib-zoneinfo-over-pytz.md +89 -0
  15. nui_python_shared_utils-2.1/docs/decisions/README.md +33 -0
  16. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/getting-started/installation.md +26 -3
  17. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/elasticsearch-integration.md +17 -0
  18. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_python_shared_utils.egg-info/SOURCES.txt +8 -23
  19. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/es_client.py +22 -8
  20. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/slack_formatter.py +10 -11
  21. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/timezone.py +13 -9
  22. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/pyproject.toml +16 -5
  23. nui_python_shared_utils-2.1/redirect/README.md +22 -0
  24. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/requirements-test.txt +7 -2
  25. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/setup.py +15 -4
  26. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_es_client.py +42 -5
  27. nui_python_shared_utils-2.1/tests/test_es_client_compat.py +159 -0
  28. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_es_query_builder.py +24 -25
  29. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_lazy_imports.py +0 -24
  30. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_slack_formatter.py +42 -7
  31. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_snowflake_client.py +1 -12
  32. nui_python_shared_utils-2.1/tests/test_timezone.py +277 -0
  33. nui_python_shared_utils-1.6.0/docs/decisions/README.md +0 -26
  34. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/__init__.py +0 -44
  35. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/base_client.py +0 -2
  36. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/cli.py +0 -2
  37. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/cloudwatch_metrics.py +0 -2
  38. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/config.py +0 -2
  39. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/db_client.py +0 -2
  40. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/error_handler.py +0 -2
  41. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/es_client.py +0 -2
  42. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/es_query_builder.py +0 -2
  43. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/jwt_auth.py +0 -2
  44. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/lambda_helpers.py +0 -2
  45. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/log_processors.py +0 -2
  46. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/powertools_helpers.py +0 -2
  47. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/secrets_helper.py +0 -2
  48. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/slack_client.py +0 -2
  49. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/slack_formatter.py +0 -2
  50. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/slack_setup/__init__.py +0 -2
  51. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/slack_setup/channel_creator.py +0 -2
  52. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/slack_setup/channel_definitions.py +0 -2
  53. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/slack_setup/setup_helpers.py +0 -2
  54. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/snowflake_client.py +0 -3
  55. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/timezone.py +0 -2
  56. nui_python_shared_utils-1.6.0/nui_lambda_shared_utils/utils.py +0 -2
  57. nui_python_shared_utils-1.6.0/redirect/README.md +0 -27
  58. nui_python_shared_utils-1.6.0/tests/test_timezone.py +0 -148
  59. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/.editorconfig +0 -0
  60. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/.github/workflows/test.yml +0 -0
  61. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/.markdownlint-cli2.yaml +0 -0
  62. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/CLAUDE.md +0 -0
  63. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/CONTRIBUTING.md +0 -0
  64. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/LICENSE +0 -0
  65. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/decisions/ADR-001-structured-logging-field-vocabulary.md +0 -0
  66. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/development/testing.md +0 -0
  67. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/getting-started/configuration.md +0 -0
  68. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/getting-started/quickstart.md +0 -0
  69. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/cli-tools.md +0 -0
  70. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/jwt-authentication.md +0 -0
  71. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/lambda-utilities.md +0 -0
  72. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/llm-integration.md +0 -0
  73. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/log-processing.md +0 -0
  74. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/powertools-integration.md +0 -0
  75. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/shared-types.md +0 -0
  76. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/slack-integration.md +0 -0
  77. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/snowflake-integration.md +0 -0
  78. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/docs/guides/structured-logging-contract.md +0 -0
  79. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/mypy.ini +0 -0
  80. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/__init__.py +0 -0
  81. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/base_client.py +0 -0
  82. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/cli.py +0 -0
  83. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/cloudwatch_metrics.py +0 -0
  84. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/config.py +0 -0
  85. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/db_client.py +0 -0
  86. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/error_handler.py +0 -0
  87. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/es_query_builder.py +0 -0
  88. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/jwt_auth.py +0 -0
  89. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/lambda_helpers.py +0 -0
  90. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/llm.py +0 -0
  91. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/log_processors.py +0 -0
  92. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/logging/__init__.py +0 -0
  93. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/logging/binding.py +0 -0
  94. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/logging/fields.py +0 -0
  95. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/logging/redaction.py +0 -0
  96. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/powertools_helpers.py +0 -0
  97. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/secrets_helper.py +0 -0
  98. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/slack_client.py +0 -0
  99. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/slack_setup/__init__.py +0 -0
  100. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/slack_setup/channel_creator.py +0 -0
  101. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/slack_setup/channel_definitions.py +0 -0
  102. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/slack_setup/setup_helpers.py +0 -0
  103. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/snowflake_client.py +0 -0
  104. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/nui_shared_utils/utils.py +0 -0
  105. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/pytest.ini +0 -0
  106. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/redirect/pyproject.toml +0 -0
  107. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/redirect/setup.py +0 -0
  108. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/scripts/bench_imports.py +0 -0
  109. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/setup.cfg +0 -0
  110. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/__init__.py +0 -0
  111. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_aws_utils.py +0 -0
  112. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_base_client.py +0 -0
  113. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_cloudwatch_metrics.py +0 -0
  114. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_config.py +0 -0
  115. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_db_client.py +0 -0
  116. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_error_handler.py +0 -0
  117. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_jwt_auth.py +0 -0
  118. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_lambda_helpers.py +0 -0
  119. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_llm.py +0 -0
  120. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_log_processors.py +0 -0
  121. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_logging_binding.py +0 -0
  122. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_logging_fields.py +0 -0
  123. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_powertools_helpers.py +0 -0
  124. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_redaction.py +0 -0
  125. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_secrets_helper.py +0 -0
  126. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_slack_client.py +0 -0
  127. {nui_python_shared_utils-1.6.0 → nui_python_shared_utils-2.1}/tests/test_utils.py +0 -0
@@ -6,6 +6,9 @@ on:
6
6
  pull_request:
7
7
  branches: [ main, master, develop ]
8
8
 
9
+ permissions:
10
+ contents: read
11
+
9
12
  jobs:
10
13
  test:
11
14
  name: Test Python ${{ matrix.python-version }}
@@ -74,6 +77,42 @@ jobs:
74
77
  name: codecov-umbrella
75
78
  fail_ci_if_error: false
76
79
 
80
+ es-compat:
81
+ name: Elasticsearch client ${{ matrix.elasticsearch-version }}
82
+ runs-on: ubuntu-latest
83
+ strategy:
84
+ fail-fast: false
85
+ matrix:
86
+ # Every major the [elasticsearch] extra allows. The ES tests drive the
87
+ # real client with a stubbed transport, so a call shape that a major
88
+ # stops accepting fails here. 8.x is pinned at three points because the
89
+ # client handles `body=` differently across the major: 8.0-8.11 route it
90
+ # through the parameter-rewriting fallback, 8.12+ take it as a declared
91
+ # parameter, and a bare `8.*` only ever resolves to the latter.
92
+ elasticsearch-version: ['7.17.*', '8.0.*', '8.11.*', '8.*', '9.*']
93
+
94
+ steps:
95
+ - uses: actions/checkout@v5
96
+ with:
97
+ persist-credentials: false
98
+
99
+ - name: Set up Python
100
+ uses: actions/setup-python@v6
101
+ with:
102
+ python-version: '3.11'
103
+
104
+ - name: Install dependencies
105
+ run: |
106
+ python -m pip install --upgrade pip
107
+ pip install pytest pytest-mock
108
+ pip install -e .
109
+ pip install "elasticsearch==${{ matrix.elasticsearch-version }}"
110
+ python -c "import elasticsearch; print('elasticsearch', elasticsearch.__version__)"
111
+
112
+ - name: Test Elasticsearch client
113
+ run: |
114
+ pytest tests/test_es_client.py tests/test_es_client_compat.py tests/test_es_query_builder.py -v
115
+
77
116
  build:
78
117
  name: Build Package
79
118
  runs-on: ubuntu-latest
@@ -109,5 +148,4 @@ jobs:
109
148
  run: |
110
149
  pip install dist/*.whl
111
150
  python -c "import nui_shared_utils; print('✅ Package imports successfully')"
112
- python -c "from nui_shared_utils import Config, get_secret; print('✅ Core imports work')"
113
- python -W ignore::DeprecationWarning -c "import nui_lambda_shared_utils; print('✅ Backwards-compat shim works')"
151
+ python -c "from nui_shared_utils import Config, get_secret; print('✅ Core imports work')"
@@ -107,32 +107,4 @@ jobs:
107
107
  with:
108
108
  verbose: true
109
109
  print-hash: true
110
- skip-existing: true # No-op instead of failing if the version is already on PyPI (safe re-runs)
111
-
112
- publish-test:
113
- name: Publish to TestPyPI
114
- runs-on: ubuntu-latest
115
- needs: [test, build]
116
- # Publish to TestPyPI on manual (non-tag) dispatch only; tag pushes go to PyPI above
117
- if: github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/')
118
- environment:
119
- name: testpypi
120
- url: https://test.pypi.org/project/nui-python-shared-utils/
121
-
122
- permissions:
123
- id-token: write
124
- contents: read
125
-
126
- steps:
127
- - name: Download artifacts
128
- uses: actions/download-artifact@v7
129
- with:
130
- name: dist
131
- path: dist/
132
-
133
- - name: Publish to TestPyPI
134
- uses: pypa/gh-action-pypi-publish@release/v1
135
- with:
136
- repository-url: https://test.pypi.org/legacy/
137
- verbose: true
138
- print-hash: true
110
+ skip-existing: true # No-op instead of failing if the version is already on PyPI (safe re-runs)
@@ -40,7 +40,7 @@ This is `nui-python-shared-utils`, a Python package providing production-ready u
40
40
  - **JWT Authentication** (`jwt_auth.py`) - RS256 token validation for API Gateway Lambdas (uses `rsa` package)
41
41
  - **LLM Helper** (`llm.py`) - Generic Anthropic (Claude) client plumbing: API-key/Bedrock auth, forced tool-use call, text call (uses `anthropic`)
42
42
  - **Error Handling** (`error_handler.py`) - Retry patterns with exponential backoff
43
- - **Timezone Utils** (`timezone.py`) - Timezone conversion and formatting utilities
43
+ - **Timezone Utils** (`timezone.py`) - Timezone conversion and formatting utilities (stdlib `zoneinfo`)
44
44
 
45
45
  ### Optional Dependencies
46
46
 
@@ -52,6 +52,7 @@ The package uses optional extras to minimize Lambda bundle size:
52
52
  - `jwt` - RS256 JWT validation (`rsa` package)
53
53
  - `snowflake` - Pure-Python Snowflake SQL API client (`snowflake-sql-api`)
54
54
  - `llm` - Anthropic (Claude) client helper (`anthropic[bedrock]`, both API-key and Bedrock IAM auth)
55
+ - `timezone` - `tzdata`, for minimal images with no system IANA database (Alpine, distroless)
55
56
  - `all` - All integrations
56
57
  - `dev` - Development and testing tools
57
58
 
@@ -1,12 +1,10 @@
1
1
  # Include the license and readme
2
2
  include LICENSE
3
3
  include README.md
4
- include CHANGELOG.md
5
4
 
6
5
  # Include package data
7
6
  recursive-include nui_shared_utils *.py
8
7
  recursive-include nui_shared_utils/slack_setup *.yaml
9
- recursive-include nui_lambda_shared_utils *.py
10
8
 
11
9
  # Exclude development and build files
12
10
  exclude .gitignore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nui-python-shared-utils
3
- Version: 1.6.0
3
+ Version: 2.1
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
@@ -49,11 +49,11 @@ Requires-Python: >=3.9
49
49
  Description-Content-Type: text/markdown
50
50
  License-File: LICENSE
51
51
  Requires-Dist: boto3>=1.20.0
52
- Requires-Dist: pytz>=2021.3
53
52
  Requires-Dist: click>=8.0.0
54
53
  Requires-Dist: pyyaml>=6.0
54
+ Requires-Dist: tzdata>=2023.3; platform_system == "Windows"
55
55
  Provides-Extra: elasticsearch
56
- Requires-Dist: elasticsearch<8.0.0,>=7.17.0; extra == "elasticsearch"
56
+ Requires-Dist: elasticsearch<10.0.0,>=7.17.0; extra == "elasticsearch"
57
57
  Provides-Extra: database
58
58
  Requires-Dist: pymysql>=1.0.0; extra == "database"
59
59
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "database"
@@ -64,12 +64,14 @@ Requires-Dist: aws-lambda-powertools<4.0.0,>=3.6.0; extra == "powertools"
64
64
  Requires-Dist: coloredlogs>=15.0; extra == "powertools"
65
65
  Provides-Extra: jwt
66
66
  Requires-Dist: rsa>=4.9; extra == "jwt"
67
+ Provides-Extra: timezone
68
+ Requires-Dist: tzdata>=2023.3; extra == "timezone"
67
69
  Provides-Extra: snowflake
68
70
  Requires-Dist: snowflake-sql-api<0.2.0,>=0.1.1; extra == "snowflake"
69
71
  Provides-Extra: llm
70
72
  Requires-Dist: anthropic[bedrock]<1.0.0,>=0.45.0; extra == "llm"
71
73
  Provides-Extra: all
72
- Requires-Dist: elasticsearch<8.0.0,>=7.17.0; extra == "all"
74
+ Requires-Dist: elasticsearch<10.0.0,>=7.17.0; extra == "all"
73
75
  Requires-Dist: pymysql>=1.0.0; extra == "all"
74
76
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "all"
75
77
  Requires-Dist: slack-sdk>=3.19.0; extra == "all"
@@ -85,11 +87,11 @@ Requires-Dist: black>=22.0.0; extra == "dev"
85
87
  Requires-Dist: mypy>=0.990; extra == "dev"
86
88
  Requires-Dist: boto3-stubs[essential]>=1.20.0; extra == "dev"
87
89
  Requires-Dist: types-PyYAML>=6.0.0; extra == "dev"
88
- Requires-Dist: types-pytz>=2021.3.0; extra == "dev"
89
90
  Requires-Dist: twine>=4.0.0; extra == "dev"
90
91
  Requires-Dist: build>=0.8.0; extra == "dev"
91
92
  Requires-Dist: rsa>=4.9; extra == "dev"
92
93
  Requires-Dist: cryptography>=41.0.0; extra == "dev"
94
+ Requires-Dist: tzdata>=2023.3; extra == "dev"
93
95
  Requires-Dist: snowflake-sql-api<0.2.0,>=0.1.1; extra == "dev"
94
96
  Requires-Dist: anthropic[bedrock]<1.0.0,>=0.45.0; extra == "dev"
95
97
  Dynamic: author
@@ -526,7 +528,6 @@ slack-channel-setup --config channels.yaml
526
528
  - **GitHub Repository**: https://github.com/nuimarkets/nui-python-shared-utils
527
529
  - **Issue Tracker**: https://github.com/nuimarkets/nui-python-shared-utils/issues
528
530
  - **PyPI Package**: https://pypi.org/project/nui-python-shared-utils/
529
- - **Changelog**: [CHANGELOG.md](CHANGELOG.md)
530
531
 
531
532
  ### 💬 Support
532
533
 
@@ -427,7 +427,6 @@ slack-channel-setup --config channels.yaml
427
427
  - **GitHub Repository**: https://github.com/nuimarkets/nui-python-shared-utils
428
428
  - **Issue Tracker**: https://github.com/nuimarkets/nui-python-shared-utils/issues
429
429
  - **PyPI Package**: https://pypi.org/project/nui-python-shared-utils/
430
- - **Changelog**: [CHANGELOG.md](CHANGELOG.md)
431
430
 
432
431
  ### 💬 Support
433
432
 
@@ -1,6 +1,6 @@
1
1
  # Documentation
2
2
 
3
- Welcome to the comprehensive documentation for `nui-python-shared-utils` (formerly `nui-lambda-shared-utils`).
3
+ Welcome to the comprehensive documentation for `nui-python-shared-utils`.
4
4
 
5
5
  **Last Updated**: 2026-02-13
6
6
 
@@ -0,0 +1,94 @@
1
+ # ADR-002: Keep shared utilities generic
2
+
3
+ **Status**: Proposed
4
+ **Date**: 2026-07-13
5
+
6
+ ## Context
7
+
8
+ This package ships to PyPI as `nui-python-shared-utils` and is imported by many independent Lambda
9
+ functions and CLI tools. `README.md` targets two audiences on purpose: the origin team, whose
10
+ defaults match, and external teams, who share none of its schema, currency, or service taxonomy. A
11
+ shared library that bakes one consumer's domain into its public surface forces every other consumer
12
+ to either carry dead weight or fork.
13
+
14
+ The pressure to bake it in is recurring and structural, not a one-time slip. It is always faster to
15
+ hardcode a caller's known table names, service list, or home currency into a "convenience" method
16
+ than to thread those through as parameters, and each such shortcut looks locally harmless. So the
17
+ constraint has to be a standing rule that every new method is measured against, not a cleanup done
18
+ once.
19
+
20
+ The line that survives that pressure is **infrastructure vs. business**. The domain-neutral half
21
+ (Slack messaging, Elasticsearch query building, database connection and query execution, CloudWatch
22
+ metrics, JWT validation, secrets, timezone) is what belongs in the shared core. The business half
23
+ (what a row means, which services exist, what currency a number is in, which status values are
24
+ terminal) is the consumer's, and it changes per deployment.
25
+
26
+ ## Decision
27
+
28
+ Public APIs expose configurable infrastructure utilities and leave business-specific queries, names,
29
+ currencies, and mappings to the consumer.
30
+
31
+ - **Database access is generic.** `query()` / `execute()` take SQL and parameters. No method assumes
32
+ a table schema, column names, or a soft-delete/status convention.
33
+ - **Values that vary by geography or deployment are required parameters or explicit configuration**,
34
+ never defaulted to one org's convention. Currency, service name, timezone, and environment label
35
+ are supplied by the caller.
36
+ - **No org-specific mapping constants** (service-to-emoji tables, status vocabularies) live in the
37
+ shipped surface. A caller that wants one passes it in.
38
+
39
+ The rule is enforced continuously by `AGENTS.md`: the "Keeping the Package Generic" DO/DON'T list,
40
+ the three-question "When Adding New Features" gate ("Is this specific to NUI, or useful for any
41
+ Lambda project? / Could this be made configurable rather than hardcoded? / Can someone use this
42
+ without NUI-specific knowledge?"), the Pull Request Checklist item **"No hardcoded
43
+ organization-specific values (service names, currencies, business logic)"**, and the "Code Review
44
+ Focus Areas" ("Generic utility patterns vs vendor-specific code"). Commit `ce48031` applied the rule
45
+ (removed `get_entity_stats` / `get_record_stats`, the `SERVICE_EMOJI` map, the `currency="NZD"`
46
+ default, and value-key currency inference); that is evidence the rule bites, not the decision itself.
47
+
48
+ ## Alternatives considered
49
+
50
+ - **Ship domain convenience helpers in the core** (org-specific stats queries, a service-emoji map,
51
+ an assumed row schema). Ergonomic for the one consumer whose schema matches. Rejected: every other
52
+ consumer imports code that queries tables it does not have, and a schema change in one consumer
53
+ becomes a shared-library release. `get_entity_stats`/`get_record_stats` embedded `entities`/`users`
54
+ tables, an `entity_id` foreign key, a `deleted_at` soft-delete column, and hardcoded
55
+ `'confirmed'`/`'cancelled'` status literals: none of that is a shared library's business.
56
+ - **Keep configurable defaults but seed them with the origin org's values** (e.g. `currency="NZD"`).
57
+ Rejected: a default silently encodes one geography. A caller in another currency gets numerically
58
+ wrong output with no error to catch it. A required parameter forces the choice at the call site.
59
+ - **A kwargs escape hatch** (`get_record_stats(status_col=..., value_col=..., created_col=...)`).
60
+ Rejected: the mapping kwargs prove the method is not generic, it is one schema with holes punched in
61
+ it. A raw `query()` taking the caller's own SQL is both simpler and actually neutral.
62
+ - **Split the business helpers into a separate consumer-specific package.** Viable, and the correct
63
+ home if such a helper is ever genuinely shared across consumers. Not built now: the removed helpers
64
+ were thin logic wrapping a SQL string, so it is cheaper for each consumer to own its query than to
65
+ version a shared domain layer for it.
66
+
67
+ ## Consequences
68
+
69
+ - Consumers write their own domain queries and pass their own currency / service / schema values.
70
+ More boilerplate per consumer, in exchange for zero cross-consumer coupling.
71
+ - The package is adoptable by a team with no knowledge of the origin platform, which is the third
72
+ gate question made real.
73
+ - Reviewers get a bright-line test (does this name a table, currency, service, or status a specific
74
+ org uses?) instead of a per-PR judgment call.
75
+ - Convenience is pushed to the consuming edges; the core stays small, which also serves the
76
+ optional-extras bundle-size goal.
77
+ - One deliberate exception is on record: ADR-006 ships overridable `Pacific/Auckland` / `NUI_LAMBDA`
78
+ Snowflake session defaults, governed as a bounded exception in that ADR. The bright-line test still
79
+ holds everywhere else; a new baked geography/org default needs the same explicit, ADR-level
80
+ justification, or it is out.
81
+
82
+ ## Load-bearing premises (supersede triggers)
83
+
84
+ - **This package stays a general-purpose shared library with multiple independent consumers**
85
+ (including external PyPI users). If it collapsed to a single internal consumer, folded into one
86
+ application as an internal module, the generic constraint reopens: a library with one caller has no
87
+ one to stay neutral for, and inlining that caller's schema becomes reasonable.
88
+ - **The infrastructure/business split stays clean.** Connection, query execution, formatting, and
89
+ metrics stay domain-neutral; row meaning, service list, currency, and status vocabulary stay the
90
+ consumer's. If a domain concept emerged that every consumer implemented identically, a shared
91
+ domain layer (likely a separate package, not a bulge in the generic core) would be reconsidered.
92
+ - **Requiring the caller to supply currency / service / schema stays cheap enough that no default is
93
+ worth the geography lock-in.** If a default ever became strictly necessary, it would come from
94
+ explicit configuration, not from a baked geography or currency constant.
@@ -0,0 +1,110 @@
1
+ # ADR-003: Lazy-load integrations to keep package import cheap
2
+
3
+ **Status**: Proposed
4
+ **Date**: 2026-07-13
5
+
6
+ ## Context
7
+
8
+ This package is a shared utility layer for a fleet of AWS Lambda functions and CLI tools. Both
9
+ workloads are startup-latency sensitive: a Lambda pays the package's import cost on every cold start,
10
+ and a CLI pays it on every invocation. The package spans many integrations (Slack, Elasticsearch,
11
+ database drivers, CloudWatch/boto3, RS256 JWT via `rsa`, AWS Lambda Powertools, Snowflake, and an
12
+ Anthropic LLM helper), and most of those pull heavy third-party dependencies.
13
+
14
+ The public surface is a flat namespace: consumers write `import nui_shared_utils as nui` and reach
15
+ `nui.SlackClient`, `nui.get_secret`, `nui.bind_request`, etc. A naive way to offer that flat surface
16
+ is to import every submodule at the top of the package `__init__`, which means `import
17
+ nui_shared_utils` transitively imports boto3, the Slack SDK, elasticsearch, and everything else,
18
+ whether or not the caller touches them.
19
+
20
+ Two properties of the stack make that cost avoidable:
21
+
22
+ - **The integrations are independent.** A Lambda that only validates a JWT never needs boto3; one
23
+ that only publishes metrics never needs the Slack SDK. Import cost is dominated by dependencies the
24
+ caller does not use.
25
+ - **The dependencies are already optional at install time.** The package exposes per-integration
26
+ extras (`elasticsearch`, `database`, `slack`, `jwt`, `snowflake`, `llm`, plus `all`) so a consumer
27
+ installs only the heavy deps it needs. Eager top-level imports would either break (an uninstalled
28
+ extra raises `ImportError` at package import) or force every consumer onto the full dependency set.
29
+
30
+ ## Decision
31
+
32
+ The package's public API resolves lazily through a PEP 562 module-level `__getattr__`
33
+ (`nui_shared_utils/__init__.py`). `import nui_shared_utils` executes almost no integration code; a
34
+ heavy dependency loads only when the export that needs it is first accessed.
35
+
36
+ - **An export map, not eager imports.** `_LAZY_EXPORTS` maps each public name to its
37
+ `(submodule, attr)` pair. On first access to a missing attribute, `__getattr__` imports that one
38
+ submodule, pulls the attribute, and caches it into the module `globals()` so subsequent access
39
+ skips the loader entirely (PEP 562 `__getattr__` fires only for names not already in the module
40
+ dict).
41
+ - **Optional integrations degrade to `None`.** Submodules whose heavy dep is an optional extra are
42
+ listed in `_OPTIONAL_SUBMODULES`; an `ImportError` during lazy resolution yields `None` instead of
43
+ propagating, so a capability check like `if nui.SlackClient is not None` still works when the extra
44
+ is not installed. Non-optional submodules still raise.
45
+ - **The flat surface is preserved.** `__all__` and `__dir__` enumerate the full export set, so star
46
+ imports and interactive/tab completion behave as if everything were eagerly imported. A
47
+ `TYPE_CHECKING`-guarded block holds the real `from .submodule import ...` statements for static type
48
+ checkers and IDEs; that block is never executed at runtime.
49
+ - **Core boto3 use defers too.** Modules that back always-present exports (`secrets_helper`, `utils`,
50
+ `cloudwatch_metrics`) construct their boto3 clients on first call rather than at import, so even the
51
+ non-extra AWS path does not tax a cold start that never reaches it.
52
+
53
+ Commit `c301214` (PR #24) records the effect: `import nui_shared_utils` drops from ~700ms to ~10ms
54
+ locally. `tests/test_lazy_imports.py` locks it in, asserting in fresh subprocesses that a bare
55
+ package import loads neither boto3/botocore, nor `anthropic`, nor the `logging` subpackage, and that
56
+ representative integrations stay lazy until first touched (`jwt_auth` and `powertools_helpers` pull no
57
+ heavy dep on access, the legacy shim reaches an attribute without loading `db_client`/`es_client`, and
58
+ `secrets_helper` builds its boto3 client only on first call). The `__getattr__` mechanism makes every
59
+ export lazy; the suite pins this representative subset rather than asserting it for each integration.
60
+
61
+ ## Alternatives considered
62
+
63
+ - **Eager import of every integration submodule (and its heavy deps) at package import.** The
64
+ simplest flat-API implementation, and the standing rejected option-class. Rejected because it makes
65
+ every consumer pay every dependency's import cost regardless of use (the ~700ms cold-start tax), and
66
+ because it forces the full dependency set to be installed or `import nui_shared_utils` breaks,
67
+ defeating the optional-extras model.
68
+ - **Drop the flat re-exports; require explicit submodule imports** (`from nui_shared_utils.slack_client
69
+ import SlackClient`). Achieves laziness but breaks the established `nui.X` API and star imports for
70
+ every caller. PEP 562 delivers the same laziness while keeping the flat surface.
71
+ - **`try/except ImportError` guards at module top.** A common optional-dependency idiom, but it still
72
+ imports every *installed* dependency eagerly, so an environment with the `all` extra pays the full
73
+ import cost on startup. It solves availability, not cold start.
74
+ - **Split each integration into its own distribution.** Per-integration install granularity already
75
+ comes from extras; N packages multiply versioning and release overhead without improving import
76
+ cost beyond what lazy loading already gives.
77
+
78
+ ## Consequences
79
+
80
+ - `import nui_shared_utils` is cheap (~10ms); heavy deps load on first use of the export that needs
81
+ them. Cold-start and CLI-startup cost scale with what a caller uses, not with what the package
82
+ ships.
83
+ - The flat public API and star imports are unchanged, so no consumer edit is required to gain the
84
+ saving. The legacy import shim (`nui_lambda_shared_utils`) forwards through the same PEP 562 path,
85
+ so callers on the old name benefit too.
86
+ - New integrations must follow the pattern: add the export to `_LAZY_EXPORTS`, add the submodule to
87
+ `_OPTIONAL_SUBMODULES` if its dep is an optional extra, mirror it in the `TYPE_CHECKING` block for
88
+ IDE/type-checker visibility, and add a laziness assertion to `tests/test_lazy_imports.py`. A missing
89
+ `_LAZY_EXPORTS` entry breaks runtime access; a missing `TYPE_CHECKING` entry only costs static
90
+ completion.
91
+ - Two lists (`_LAZY_EXPORTS` and the `TYPE_CHECKING` imports) describe the same surface and must stay
92
+ in sync by hand. `__all__` and `__dir__` derive from `_LAZY_EXPORTS`, so the runtime export set stays
93
+ self-consistent (`test_dir_includes_lazy_exports` guards a sample of it); nothing cross-checks the
94
+ `TYPE_CHECKING` block against `_LAZY_EXPORTS`, so a gap there costs only static completion, as above.
95
+ - Import errors surface on first use rather than at package import. For optional integrations that is
96
+ intentional (`None` sentinel); for a genuinely missing required dependency the failure moves from
97
+ import time to first access, which the lazy tests and normal exercise cover.
98
+
99
+ ## Load-bearing premises (supersede triggers)
100
+
101
+ - Package-import latency and Lambda bundle/cold-start cost remain a real, recurring cost paid per new
102
+ integration. If all consumers moved to always-on, long-running services where cold start is
103
+ irrelevant, the eager-import simplicity could be reconsidered.
104
+ - The optional-extras model stays: a given integration's heavy dependency is installed only when its
105
+ extra is requested. If every dependency became a hard requirement of the package, eager import would
106
+ no longer risk breaking on a missing dep (though the startup-cost argument would still stand on its
107
+ own).
108
+ - PEP 562 module-level `__getattr__` stays available on the supported interpreters. If the package
109
+ had to run where it is unavailable, the mechanism would need replacing (explicit submodule imports)
110
+ and this decision would reopen.
@@ -0,0 +1,98 @@
1
+ # ADR-004: Credential resolution precedence
2
+
3
+ **Status**: Proposed
4
+ **Date**: 2026-07-13
5
+
6
+ ## Context
7
+
8
+ This package ships service clients (Slack, Elasticsearch, database, Snowflake) that run in two
9
+ very different places. The primary runtime is short-lived functions with an IAM role and no
10
+ secrets in the environment, where a named secret in a managed secret store (AWS Secrets Manager)
11
+ is the idiomatic credential source: rotatable, IAM-scoped, absent from the process listing. The
12
+ secondary runtime is local development, CLI tools, and the unit-test suite, where AWS may be
13
+ unreachable, no role is attached, and the caller already holds the credentials or has them in the
14
+ environment.
15
+
16
+ A client that reaches for Secrets Manager unconditionally imposes that store on both runtimes. It
17
+ forces every construction to have AWS reachable plus IAM permission plus a network round-trip,
18
+ even when the caller passed the credentials in directly, and it couples what should be a pure
19
+ object construction to AWS mocking in tests. Callers legitimately supply credentials three ways:
20
+ an explicit dict at construction, per-client environment variables, or (the default) a named
21
+ secret. The store cannot be the only path without breaking local dev, CLI, and test isolation.
22
+
23
+ The function-based helper historically undercut this. `get_elasticsearch_credentials`
24
+ (`secrets_helper.py`) called Secrets Manager first even when `ES_PASSWORD` was set, so CLI and
25
+ local-dev paths paid an avoidable AWS call. Commit `0d3f8e8` (PR #23) added the env-var check
26
+ ahead of the store lookup, aligning the helper with the precedence the clients already enforced.
27
+
28
+ ## Decision
29
+
30
+ Credentials resolve in a fixed order, first match wins, owned by `BaseClient._resolve_credentials`
31
+ in `base_client.py`:
32
+
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.
35
+ 2. **Per-client environment variables**, via each client's `_resolve_credentials_from_env` override
36
+ (Elasticsearch reads `ES_PASSWORD`/`ES_PASS` in `es_client.py`; database requires
37
+ `DB_HOST` + `DB_PASSWORD` in `db_client.py`; Slack requires `SLACK_BOT_TOKEN` in
38
+ `slack_client.py`). When the required vars are present, the resolved dict is returned without
39
+ touching AWS.
40
+ 3. **AWS Secrets Manager** as the default fallback, via `_fetch_credentials_from_sm` and
41
+ `get_secret`, reading the named secret resolved from constructor arg, env var, or config.
42
+
43
+ A later tier fires only when every earlier tier is absent. An AWS call happens only when neither
44
+ 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.
48
+
49
+ ## Alternatives considered
50
+
51
+ - **Always call Secrets Manager (first, or unconditionally).** Simplest single code path, and the
52
+ right default for the Lambda runtime taken alone. Rejected: it forces AWS reachability, IAM
53
+ permission, and a network round-trip onto every construction in every runtime, including local
54
+ dev, CLI, and unit tests that have no reason to touch AWS. Explicit and env credentials degrade
55
+ to dead options or a bolted-on override, and the test suite has to mock AWS to build an object.
56
+ This is the live rejected option: precedence exists specifically so the store is a fallback, not
57
+ a precondition.
58
+ - **Environment variables only (strict 12-factor).** Clean for containers, but the primary runtime's
59
+ idiomatic store is Secrets Manager (rotation, IAM scoping, secrets kept out of the env). Dropping
60
+ the store would push plaintext secrets into every function's environment.
61
+ - **Explicit injection everywhere (no env, no store).** Maximally testable, but forces every
62
+ entrypoint to hand-write secret retrieval, defeating the point of a shared client that just works
63
+ under an attached role.
64
+ - **Per-client ad-hoc ordering.** Each client invents its own precedence. Rejected: callers cannot
65
+ reason uniformly about where credentials come from, and the orders drift. One ladder lives in the
66
+ base class; clients override only the env-detection and default-secret hooks.
67
+
68
+ ## Consequences
69
+
70
+ - One predictable resolution order across every client. A new client implements
71
+ `_resolve_credentials_from_env` and `_get_default_secret_name` and inherits the full ladder.
72
+ - Unit tests construct clients from an explicit dict with no AWS mock needed to do so; the explicit-
73
+ and env-credential tests assert the store is not called (`get_secret_value.assert_not_called()` in
74
+ `tests/test_secrets_helper.py`). Fallback-path tests still patch `get_secret` where they exercise the
75
+ store tier.
76
+ - Local dev and CLI run against real services using environment variables with no AWS credentials
77
+ present.
78
+ - Secrets Manager stays the zero-config default in the Lambda runtime: moving from local env creds
79
+ to the store needs no code change, only unsetting the env vars.
80
+ - Explicit-over-remote is a deliberate trust choice. A caller passing a `credentials` dict is
81
+ trusted to own those values in-process; the package neither validates them against the store nor
82
+ falls back when they are wrong.
83
+
84
+ ## Load-bearing premises (supersede triggers)
85
+
86
+ - Explicit dict and environment variables stay first-class, supported paths for local dev, CLI, and
87
+ tests. If every runtime became Secrets-Manager-only (no consumer ever supplies explicit or env
88
+ credentials), tiers 1 and 2 are dead weight and the precedence collapses to "always store".
89
+ - The caller owns any credentials it passes explicitly or sets in the environment. The
90
+ explicit/env-beats-remote posture holds only while supplying credentials is an intentional act by
91
+ a trusted in-process caller, not attacker-controlled input. If untrusted input could populate the
92
+ `credentials` dict or the environment, the ordering needs re-evaluation.
93
+ - Secrets Manager remains the right default store for the primary runtime. A move to a different
94
+ managed store changes tier 3's implementation but not the ladder's shape.
95
+ - Env-var **presence** is the trigger, not correctness. A required var being set means "use env"
96
+ (`ES_PASSWORD`; `DB_HOST` + `DB_PASSWORD`; `SLACK_BOT_TOKEN`); a set-but-wrong value fails loudly
97
+ rather than silently falling through to the store. If callers expected fallback-on-bad-env, the
98
+ trigger semantics reopen.
@@ -0,0 +1,88 @@
1
+ # ADR-005: Explicit Secrets Manager region resolution
2
+
3
+ **Status**: Proposed
4
+ **Date**: 2026-07-13
5
+
6
+ ## Context
7
+
8
+ `get_secret` in `secrets_helper.py` is the single door through which every consumer of this package
9
+ reaches AWS Secrets Manager. It is a shared credential entry point for a fleet of Lambda functions
10
+ and CLI tools that run across multiple AWS accounts and multiple regions.
11
+
12
+ A boto3 Secrets Manager client cannot be built without a region, and where that region comes from
13
+ decides whether a lookup hits the right account's secret store. The region is available in different
14
+ places depending on the runtime: a Lambda execution environment exports `AWS_REGION`; a CLI or a
15
+ differently-wired process gets it from the boto3 session (a configured profile / config file) or
16
+ `AWS_DEFAULT_REGION`; and in some runtimes it is simply absent.
17
+
18
+ Getting the region wrong is **not** a loud failure by default. A client built against the wrong
19
+ region reads a different account/region's secret namespace and typically returns
20
+ `ResourceNotFoundException`, which reads to the caller as "this secret does not exist" rather than
21
+ "you are pointed at the wrong region." A region *default* baked into the helper is the worst version
22
+ of this: it masks a missing config as one specific (usually wrong) region, and does so silently for
23
+ every consumer that never set a region. Commit `342b24b` (PR #26) is the point of record for
24
+ removing such a default from this helper.
25
+
26
+ ## Decision
27
+
28
+ `get_secret` derives its region from an **explicit resolution chain, first match wins**, and never
29
+ supplies a region of its own:
30
+
31
+ 1. **boto3 session region**, from `boto3.session.Session().region_name` (a configured profile or
32
+ `AWS_DEFAULT_REGION`, which the Lambda runtime also sets). boto3 does not natively resolve
33
+ `AWS_REGION`, so step 2 adds it explicitly for runtimes that set only that variable.
34
+ 2. **`AWS_REGION`** environment variable.
35
+ 3. **`AWS_DEFAULT_REGION`** environment variable.
36
+
37
+ When none of these resolve, the call **fails loudly before any client is constructed**, raising with
38
+ an explicit message that names the fix
39
+ (`"AWS region not configured for Secrets Manager lookup; set AWS_REGION or AWS_DEFAULT_REGION"`). No
40
+ client is ever built against a guessed region.
41
+
42
+ The helper carries **no hardcoded region**. A missing region is a configuration error surfaced to
43
+ the caller, not a value the package invents. This behaviour is pinned by two tests in
44
+ `tests/test_secrets_helper.py`: `test_get_secret_uses_aws_region_when_session_region_missing`
45
+ (the env-var fallback path builds the client with the configured region) and
46
+ `test_get_secret_requires_region_when_session_and_env_missing` (no session region and empty env
47
+ raises the clear error and asserts no client is created).
48
+
49
+ ## Alternatives considered
50
+
51
+ - **A hidden hardcoded region default** (e.g. silently defaulting to `ap-southeast-2` inside the
52
+ helper). This is the cheapest thing to reach for whenever a new AWS call needs a region, so it
53
+ stays a live temptation. Rejected: it binds a store-neutral, geography-neutral package to one
54
+ region, and any consumer running elsewhere silently reads the wrong region's secret namespace and
55
+ gets a `ResourceNotFoundException` that reads as "secret missing," not "wrong region." Removed in
56
+ commit `342b24b` (PR #26).
57
+ - **Best-effort silent fallthrough**: skip the region entirely and let boto3 pick whatever it can,
58
+ or return empty on a miss. Rejected: fail-loud on missing config beats silent best-effort. A
59
+ lookup that cannot name its own region should stop with a clear error, not guess and read from an
60
+ unknown place.
61
+ - **Require region as an explicit function argument.** Rejected: consumers already carry region in
62
+ the standard boto3/session and environment locations, and Lambda sets `AWS_REGION` for free.
63
+ Threading a region parameter through every call site duplicates what the session/env already
64
+ answer.
65
+
66
+ ## Consequences
67
+
68
+ - A consumer in **any** AWS account/region works as long as region is configured the standard way
69
+ (Lambda ambient, boto3 profile, or `AWS_REGION` / `AWS_DEFAULT_REGION`). The package assumes no
70
+ home region.
71
+ - Misconfiguration **fails fast with a message that names the fix**, instead of a downstream
72
+ `ResourceNotFoundException` that misattributes the cause to a missing secret and costs an operator
73
+ a debugging session.
74
+ - Every future AWS client added to this package inherits the same rule: **no hidden region default;
75
+ resolve explicitly or fail loud.** This is ADR-002's keep-generic principle applied to region: a
76
+ hidden geo default is exactly the vendor/geography assumption the package forbids (see ADR-002, and
77
+ the "Keeping the Package Generic" design principle in `AGENTS.md`).
78
+
79
+ ## Load-bearing premises (supersede triggers)
80
+
81
+ - The package must run in **arbitrary AWS accounts/regions with no assumed home region**. If it were
82
+ ever deliberately scoped to a single fixed region (a single-account, single-region product), a
83
+ region default could be reconsidered.
84
+ - **Fail-loud on missing config is preferred over silent best-effort.** If that preference inverted
85
+ under some "degrade quietly" runtime contract, the explicit raise would reopen.
86
+ - **Region is reachable from the boto3 session or the environment at call time.** If a supported
87
+ runtime cannot expose region through either path, an explicit-argument region parameter would
88
+ reopen.