nui-python-shared-utils 1.3.3__py3-none-any.whl → 1.4.1__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.
- nui_lambda_shared_utils/snowflake_client.py +3 -0
- {nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/METADATA +75 -2
- {nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/RECORD +11 -8
- nui_shared_utils/__init__.py +18 -0
- nui_shared_utils/llm.py +209 -0
- nui_shared_utils/secrets_helper.py +5 -1
- nui_shared_utils/snowflake_client.py +422 -0
- {nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/WHEEL +0 -0
- {nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/entry_points.txt +0 -0
- {nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/licenses/LICENSE +0 -0
- {nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/top_level.txt +0 -0
{nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nui-python-shared-utils
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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
|
|
@@ -64,6 +64,10 @@ 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: snowflake
|
|
68
|
+
Requires-Dist: snowflake-sql-api<0.2.0,>=0.1.1; extra == "snowflake"
|
|
69
|
+
Provides-Extra: llm
|
|
70
|
+
Requires-Dist: anthropic[bedrock]<1.0.0,>=0.45.0; extra == "llm"
|
|
67
71
|
Provides-Extra: all
|
|
68
72
|
Requires-Dist: elasticsearch<8.0.0,>=7.17.0; extra == "all"
|
|
69
73
|
Requires-Dist: pymysql>=1.0.0; extra == "all"
|
|
@@ -86,6 +90,8 @@ Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
|
86
90
|
Requires-Dist: build>=0.8.0; extra == "dev"
|
|
87
91
|
Requires-Dist: rsa>=4.9; extra == "dev"
|
|
88
92
|
Requires-Dist: cryptography>=41.0.0; extra == "dev"
|
|
93
|
+
Requires-Dist: snowflake-sql-api<0.2.0,>=0.1.1; extra == "dev"
|
|
94
|
+
Requires-Dist: anthropic[bedrock]<1.0.0,>=0.45.0; extra == "dev"
|
|
89
95
|
Dynamic: author
|
|
90
96
|
Dynamic: home-page
|
|
91
97
|
Dynamic: license-file
|
|
@@ -128,6 +134,7 @@ Production-ready shared Python utilities for AWS Lambda functions, CLI tools, an
|
|
|
128
134
|
- **Database Connections** - Connection pooling, automatic retries, and transaction management
|
|
129
135
|
- **CloudWatch Metrics** - Batched publishing with custom dimensions
|
|
130
136
|
- **JWT Authentication** - RS256 token validation for API Gateway Lambdas (lightweight, no PyJWT needed)
|
|
137
|
+
- **Anthropic (Claude) Helper** - Generic client plumbing for LLM calls: API-key or Bedrock IAM auth, forced tool-use, and text calls (prompts and schemas stay in your code)
|
|
131
138
|
- **Error Handling** - Intelligent retry patterns with exponential backoff
|
|
132
139
|
- **Timezone Utilities** - Timezone handling and formatting
|
|
133
140
|
- **Configurable Defaults** - Environment-aware configuration system
|
|
@@ -158,12 +165,14 @@ Production-ready shared Python utilities for AWS Lambda functions, CLI tools, an
|
|
|
158
165
|
pip install nui-python-shared-utils
|
|
159
166
|
|
|
160
167
|
# With specific extras for optional dependencies
|
|
161
|
-
pip install nui-python-shared-utils[all] #
|
|
168
|
+
pip install nui-python-shared-utils[all] # Core optional integrations (excludes Snowflake and LLM)
|
|
162
169
|
pip install nui-python-shared-utils[powertools] # AWS Powertools only
|
|
163
170
|
pip install nui-python-shared-utils[slack] # Slack only
|
|
164
171
|
pip install nui-python-shared-utils[elasticsearch] # Elasticsearch only
|
|
165
172
|
pip install nui-python-shared-utils[database] # Database only
|
|
166
173
|
pip install nui-python-shared-utils[jwt] # JWT authentication only
|
|
174
|
+
pip install nui-python-shared-utils[snowflake] # Snowflake SQL API client
|
|
175
|
+
pip install nui-python-shared-utils[llm] # Anthropic (Claude) client helper
|
|
167
176
|
```
|
|
168
177
|
|
|
169
178
|
### Basic Configuration
|
|
@@ -291,6 +300,35 @@ async with db.get_connection() as conn:
|
|
|
291
300
|
|
|
292
301
|
**[→ See full database guide](docs/getting-started/quickstart.md#database-connections)**
|
|
293
302
|
|
|
303
|
+
### Snowflake (SQL API)
|
|
304
|
+
|
|
305
|
+
Pure-Python Snowflake client (no `snowflake-connector-python`), keypair auth via
|
|
306
|
+
Secrets Manager, with NUI session defaults (`TIMEZONE=Pacific/Auckland`, role
|
|
307
|
+
`NUI_LAMBDA`) that you can override via `timezone=` and `role=`, plus a redacting
|
|
308
|
+
query-logging hook.
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
from nui_shared_utils import create_snowflake_client
|
|
312
|
+
|
|
313
|
+
# Loads account/user/private_key from Secrets Manager ("snowflake-credentials"
|
|
314
|
+
# by default; override with SNOWFLAKE_CREDENTIALS_SECRET or secret_name=).
|
|
315
|
+
# The NUI defaults are overridable, so the client stays generic for any account.
|
|
316
|
+
client = create_snowflake_client(
|
|
317
|
+
warehouse="COMPUTE_WH",
|
|
318
|
+
database="ANALYTICS",
|
|
319
|
+
timezone="UTC", # override the Pacific/Auckland default
|
|
320
|
+
role="MY_APP_ROLE", # override the NUI_LAMBDA default
|
|
321
|
+
)
|
|
322
|
+
rows = client.query("SELECT id, name FROM orders WHERE status = ?", ["confirmed"])
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Sync is the default; use `create_async_snowflake_client(...)` inside a Lambda
|
|
326
|
+
or FastAPI app already on an event loop. Snowflake SQL API bindings use
|
|
327
|
+
positional `?` placeholders, not connector-style `%s` placeholders. Requires
|
|
328
|
+
the `[snowflake]` extra.
|
|
329
|
+
|
|
330
|
+
**[→ See full Snowflake guide](docs/guides/snowflake-integration.md)**
|
|
331
|
+
|
|
294
332
|
### CloudWatch Metrics
|
|
295
333
|
|
|
296
334
|
```python
|
|
@@ -323,6 +361,41 @@ def lambda_handler(event, context):
|
|
|
323
361
|
|
|
324
362
|
**[→ See JWT authentication guide](docs/guides/jwt-authentication.md)**
|
|
325
363
|
|
|
364
|
+
### Anthropic (Claude) Helper
|
|
365
|
+
|
|
366
|
+
Generic plumbing for Claude calls: build a client (API-key or Bedrock IAM), make
|
|
367
|
+
a forced tool-use call or a text call, and get the parsed result back. Prompts,
|
|
368
|
+
tool schemas, model ids, and result post-processing stay in your code.
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
from nui_shared_utils import build_anthropic_client, call_tool
|
|
372
|
+
|
|
373
|
+
# API-key auth: explicit key -> ANTHROPIC_API_KEY env -> Secrets Manager
|
|
374
|
+
client = build_anthropic_client(secret_name="my/anthropic-key")
|
|
375
|
+
# Or Bedrock IAM (no key): build_anthropic_client(mode="bedrock", region="us-east-1")
|
|
376
|
+
|
|
377
|
+
tool = {
|
|
378
|
+
"name": "classify",
|
|
379
|
+
"description": "Classify the text.",
|
|
380
|
+
"input_schema": {
|
|
381
|
+
"type": "object",
|
|
382
|
+
"properties": {"label": {"type": "string"}, "score": {"type": "number"}},
|
|
383
|
+
"required": ["label", "score"],
|
|
384
|
+
"additionalProperties": False,
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# Forced tool-use; returns the tool's input dict, or None on any model/parse failure.
|
|
389
|
+
result = call_tool(client, tool=tool, prompt="Great product!", model="claude-haiku-4-5", max_tokens=256)
|
|
390
|
+
if result is not None:
|
|
391
|
+
print(result["label"], result["score"])
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Requires the `[llm]` extra. `call_tool` is best-effort (returns `None`, never
|
|
395
|
+
raises); `call_text` returns `{text, input_tokens, output_tokens}`.
|
|
396
|
+
|
|
397
|
+
**[→ See full LLM integration guide](docs/guides/llm-integration.md)**
|
|
398
|
+
|
|
326
399
|
### Error Handling
|
|
327
400
|
|
|
328
401
|
```python
|
|
@@ -14,14 +14,15 @@ nui_lambda_shared_utils/powertools_helpers.py,sha256=xTnbiyE_5bl9saizsKYd1U4rQBP
|
|
|
14
14
|
nui_lambda_shared_utils/secrets_helper.py,sha256=wuThNFPFnU0K8N7I0UGMsyk9oJmOSYTR08xJy2MC8-M,138
|
|
15
15
|
nui_lambda_shared_utils/slack_client.py,sha256=_itnosOoODXq1dAX2dMReQi0gZBARjcGqwtRjh1MFPc,136
|
|
16
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
|
|
17
18
|
nui_lambda_shared_utils/timezone.py,sha256=eLXPmhgFazj76K1aWiO5CSZbuiSlbXEGY8X-MxDKbjw,132
|
|
18
19
|
nui_lambda_shared_utils/utils.py,sha256=sWDeHr9Jm3A6yaIqgP9b6_lHoPJox8gD6G1-eiYAgq4,129
|
|
19
20
|
nui_lambda_shared_utils/slack_setup/__init__.py,sha256=q78NzSznsSd3ri5OUohWxQt7r6q0zdrE3gF2hvpHnD8,140
|
|
20
21
|
nui_lambda_shared_utils/slack_setup/channel_creator.py,sha256=xaeqzyEAqVqYj2EhN69UvcyZQ6LdFKYODPPCwxHInLA,172
|
|
21
22
|
nui_lambda_shared_utils/slack_setup/channel_definitions.py,sha256=3TkdLzWvEMBMDy5jqOPMUGRgKQ3z9TN58D43V7O7LpE,180
|
|
22
23
|
nui_lambda_shared_utils/slack_setup/setup_helpers.py,sha256=ex_RiUfiZqH9qGLQwmedezMbfp35uI-VQPevducfbBk,168
|
|
23
|
-
nui_python_shared_utils-1.
|
|
24
|
-
nui_shared_utils/__init__.py,sha256=
|
|
24
|
+
nui_python_shared_utils-1.4.1.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
|
|
25
|
+
nui_shared_utils/__init__.py,sha256=WOpBwxBqJK_wq8l3s5TCCs4j__lMTbI7TjraTXfmH4c,11851
|
|
25
26
|
nui_shared_utils/base_client.py,sha256=I1lKQGhrKyvujV2zps0TrtEdPDqMCwRQaIh6ceGONXQ,11016
|
|
26
27
|
nui_shared_utils/cli.py,sha256=JJpSoQWKvAz4b8cO30yFNi5vY9jmqrCHzbFpvrVTkbU,8747
|
|
27
28
|
nui_shared_utils/cloudwatch_metrics.py,sha256=p0J-s63UJDGK14TRGt0k8yoj7aWSSM55UKdgcuaT1dU,12098
|
|
@@ -32,19 +33,21 @@ nui_shared_utils/es_client.py,sha256=rLyexuL6_RXQpuMC4A2SeJDk30medx2qxOgWIG3A_7o
|
|
|
32
33
|
nui_shared_utils/es_query_builder.py,sha256=UuheQf8b2UXaOiJlYCKYIfKfVERDZ3ag1P0OOOWm9do,12158
|
|
33
34
|
nui_shared_utils/jwt_auth.py,sha256=2Ag1zZKxd2R8QBz3aQA4h8OtKHQvUVKrjUp6lXpJxJU,9143
|
|
34
35
|
nui_shared_utils/lambda_helpers.py,sha256=psHVotpmOfnmyQCoOt4MSIEj7VwWcy6gZM3vYSZwjOk,3041
|
|
36
|
+
nui_shared_utils/llm.py,sha256=adNUO8Qbeb3__GvpROxWSUERkpiazKiGJFE5xzU5eZ0,8351
|
|
35
37
|
nui_shared_utils/log_processors.py,sha256=x5gz1LEkbmoMCZ3ZB6q-mbn26dSzwh1trz4wSkBGxqk,5538
|
|
36
38
|
nui_shared_utils/powertools_helpers.py,sha256=qc-lYLfY-QX20gALfPOJcihrtJhiCU-fJW8rsPT7LJE,11037
|
|
37
|
-
nui_shared_utils/secrets_helper.py,sha256=
|
|
39
|
+
nui_shared_utils/secrets_helper.py,sha256=_jQkMWzjayZ8vvyHG2yuuxur-SQYUUONfcsZ0MF8qOw,7025
|
|
38
40
|
nui_shared_utils/slack_client.py,sha256=_qR7Q1GU7gvYhUxiaBxEohhoEYznqr8kz9enGfeDtn8,24759
|
|
39
41
|
nui_shared_utils/slack_formatter.py,sha256=95g6XfAJst7RVhd0M0ahiF3gWWW5j96WYkCg5tLS6Zg,10894
|
|
42
|
+
nui_shared_utils/snowflake_client.py,sha256=e3jH6_2mMqbtP1TE8-mUFFYrfGW9eqUPT5tlcgeCqx8,16181
|
|
40
43
|
nui_shared_utils/timezone.py,sha256=TvtSZV7w3Vvz3NbMTcJSNbUf0ZxPwpFS-xfgye1h-4Q,3583
|
|
41
44
|
nui_shared_utils/utils.py,sha256=gOpw80tZGC24QsyesR8EySRODY-TFXG8cXUvggPBsRI,8184
|
|
42
45
|
nui_shared_utils/slack_setup/__init__.py,sha256=OElyS3xk4F_YKH5uUUTDpN0ah1dOO3e52muIPjAMPW8,320
|
|
43
46
|
nui_shared_utils/slack_setup/channel_creator.py,sha256=0gyCBIS0EC96SVn1Z2dD4Fpzk0xNdHSWyiCxoUQIUe8,10667
|
|
44
47
|
nui_shared_utils/slack_setup/channel_definitions.py,sha256=atfz5ZhpqefOeLh1gShbWd-TLzgjPmhv95bfuTdmZog,5676
|
|
45
48
|
nui_shared_utils/slack_setup/setup_helpers.py,sha256=pzzXMs12GI9sdZttWeYzgLPgC0xqPz7ZLHM1GUPNNXc,7219
|
|
46
|
-
nui_python_shared_utils-1.
|
|
47
|
-
nui_python_shared_utils-1.
|
|
48
|
-
nui_python_shared_utils-1.
|
|
49
|
-
nui_python_shared_utils-1.
|
|
50
|
-
nui_python_shared_utils-1.
|
|
49
|
+
nui_python_shared_utils-1.4.1.dist-info/METADATA,sha256=jYHBmx1R9T3PEGS2aLOY0DvcuZFm7DuebJL4VaV3z_c,22632
|
|
50
|
+
nui_python_shared_utils-1.4.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
51
|
+
nui_python_shared_utils-1.4.1.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
|
|
52
|
+
nui_python_shared_utils-1.4.1.dist-info/top_level.txt,sha256=sNceq5okmEB54L-gm4a0OgGhnPlU62zYmlUoXKn-Fa8,41
|
|
53
|
+
nui_python_shared_utils-1.4.1.dist-info/RECORD,,
|
nui_shared_utils/__init__.py
CHANGED
|
@@ -104,6 +104,11 @@ _LAZY_EXPORTS = {
|
|
|
104
104
|
# Optional: AWS Powertools
|
|
105
105
|
"get_powertools_logger": ("powertools_helpers", "get_powertools_logger"),
|
|
106
106
|
"powertools_handler": ("powertools_helpers", "powertools_handler"),
|
|
107
|
+
# Optional: Snowflake client adapter (snowflake-sql-api)
|
|
108
|
+
"create_snowflake_client": ("snowflake_client", "create_snowflake_client"),
|
|
109
|
+
"create_async_snowflake_client": ("snowflake_client", "create_async_snowflake_client"),
|
|
110
|
+
"get_snowflake_credentials": ("snowflake_client", "get_snowflake_credentials"),
|
|
111
|
+
"redacting_query_logger": ("snowflake_client", "redacting_query_logger"),
|
|
107
112
|
# Optional: JWT validation (rsa)
|
|
108
113
|
"validate_jwt": ("jwt_auth", "validate_jwt"),
|
|
109
114
|
"require_auth": ("jwt_auth", "require_auth"),
|
|
@@ -111,6 +116,10 @@ _LAZY_EXPORTS = {
|
|
|
111
116
|
"get_jwt_public_key": ("jwt_auth", "get_jwt_public_key"),
|
|
112
117
|
"JWTValidationError": ("jwt_auth", "JWTValidationError"),
|
|
113
118
|
"AuthenticationError": ("jwt_auth", "AuthenticationError"),
|
|
119
|
+
# Optional: Anthropic (Claude) LLM helper (anthropic[bedrock])
|
|
120
|
+
"build_anthropic_client": ("llm", "build_anthropic_client"),
|
|
121
|
+
"call_tool": ("llm", "call_tool"),
|
|
122
|
+
"call_text": ("llm", "call_text"),
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
# Submodules that are optional integrations; ImportError during lazy load
|
|
@@ -124,6 +133,8 @@ _OPTIONAL_SUBMODULES = {
|
|
|
124
133
|
"db_client",
|
|
125
134
|
"powertools_helpers",
|
|
126
135
|
"jwt_auth",
|
|
136
|
+
"snowflake_client",
|
|
137
|
+
"llm",
|
|
127
138
|
"slack_setup",
|
|
128
139
|
}
|
|
129
140
|
|
|
@@ -248,6 +259,12 @@ if TYPE_CHECKING:
|
|
|
248
259
|
build_user_activity_query,
|
|
249
260
|
)
|
|
250
261
|
from .db_client import DatabaseClient, PostgreSQLClient, get_pool_stats
|
|
262
|
+
from .snowflake_client import (
|
|
263
|
+
create_async_snowflake_client,
|
|
264
|
+
create_snowflake_client,
|
|
265
|
+
get_snowflake_credentials,
|
|
266
|
+
redacting_query_logger,
|
|
267
|
+
)
|
|
251
268
|
from .powertools_helpers import get_powertools_logger, powertools_handler
|
|
252
269
|
from .jwt_auth import (
|
|
253
270
|
AuthenticationError,
|
|
@@ -257,6 +274,7 @@ if TYPE_CHECKING:
|
|
|
257
274
|
require_auth,
|
|
258
275
|
validate_jwt,
|
|
259
276
|
)
|
|
277
|
+
from .llm import build_anthropic_client, call_text, call_tool
|
|
260
278
|
from . import slack_setup
|
|
261
279
|
|
|
262
280
|
|
nui_shared_utils/llm.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Generic Anthropic (Claude) client helper for AWS Lambda functions and CLI tools.
|
|
2
|
+
|
|
3
|
+
Plumbing only. Three repos independently hand-rolled the same Anthropic client +
|
|
4
|
+
forced-tool-use call + ``tool_use``-block parse; this consolidates that into one
|
|
5
|
+
tested surface. Prompts, tool schemas, model ids, and any domain post-processing
|
|
6
|
+
of the result stay in the consuming repo.
|
|
7
|
+
|
|
8
|
+
What lives here:
|
|
9
|
+
|
|
10
|
+
- :func:`build_anthropic_client` - API-key auth (explicit -> ``ANTHROPIC_API_KEY``
|
|
11
|
+
env -> Secrets Manager) or Bedrock IAM auth, one function for both.
|
|
12
|
+
- :func:`call_tool` - a forced tool-use call that returns the named tool's
|
|
13
|
+
``input`` dict, or ``None`` on any model/parse failure (best-effort; never
|
|
14
|
+
raises, matching the dominant Lambda enrichment pattern).
|
|
15
|
+
- :func:`call_text` - a plain text call returning ``{text, input_tokens,
|
|
16
|
+
output_tokens}``.
|
|
17
|
+
|
|
18
|
+
The ``anthropic`` SDK is a module-level import (matching the package's other
|
|
19
|
+
optional integrations), so the bare ``[llm]`` extra is required to use anything
|
|
20
|
+
here. The cold-start guarantee is preserved by the package ``__init__`` PEP 562
|
|
21
|
+
lazy loader: ``import nui_shared_utils`` never imports this module (and so never
|
|
22
|
+
imports ``anthropic``) until a consumer first touches an ``llm`` attribute. See
|
|
23
|
+
``tests/test_lazy_imports.py``.
|
|
24
|
+
|
|
25
|
+
Install the extra::
|
|
26
|
+
|
|
27
|
+
pip install 'nui-python-shared-utils[llm]'
|
|
28
|
+
|
|
29
|
+
``anthropic[bedrock]`` covers both auth modes (``anthropic.Anthropic`` for the
|
|
30
|
+
API key, ``anthropic.AnthropicBedrock`` for IAM); the ``[bedrock]`` sub-extra is
|
|
31
|
+
what makes ``AnthropicBedrock`` available.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
from typing import Any, Dict, Optional
|
|
39
|
+
|
|
40
|
+
import anthropic
|
|
41
|
+
|
|
42
|
+
from .secrets_helper import get_api_key
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_anthropic_client(
|
|
48
|
+
mode: str = "api_key",
|
|
49
|
+
*,
|
|
50
|
+
api_key: Optional[str] = None,
|
|
51
|
+
secret_name: Optional[str] = None,
|
|
52
|
+
region: Optional[str] = None,
|
|
53
|
+
max_retries: int = 5,
|
|
54
|
+
) -> Any:
|
|
55
|
+
"""Build an Anthropic SDK client for the requested auth mode.
|
|
56
|
+
|
|
57
|
+
``mode="api_key"`` (default) returns an :class:`anthropic.Anthropic`. The key
|
|
58
|
+
is resolved in order: the explicit ``api_key`` argument, then the
|
|
59
|
+
``ANTHROPIC_API_KEY`` environment variable, then
|
|
60
|
+
:func:`nui_shared_utils.get_api_key` against ``secret_name`` (AWS Secrets
|
|
61
|
+
Manager, ``api_key`` field). A consumer whose key lives behind a CLI keyring
|
|
62
|
+
or a non-default secret field should resolve it and pass ``api_key=``.
|
|
63
|
+
|
|
64
|
+
``mode="bedrock"`` returns an :class:`anthropic.AnthropicBedrock` (IAM auth,
|
|
65
|
+
no key). ``region`` sets ``aws_region``; it falls back to ``AWS_REGION`` /
|
|
66
|
+
``AWS_DEFAULT_REGION`` and then to the SDK's own default region resolution.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
mode: ``"api_key"`` or ``"bedrock"``.
|
|
70
|
+
api_key: Explicit Anthropic API key (api_key mode only). Skips env and
|
|
71
|
+
Secrets Manager when set.
|
|
72
|
+
secret_name: Secrets Manager secret name to read the key from when no
|
|
73
|
+
explicit key and no env var are present (api_key mode only).
|
|
74
|
+
region: AWS region for Bedrock (bedrock mode only).
|
|
75
|
+
max_retries: Passed through to the SDK client (default 5).
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: Unknown ``mode``, or api_key mode with no key resolvable
|
|
79
|
+
(no ``api_key``, no env var, and no ``secret_name``).
|
|
80
|
+
"""
|
|
81
|
+
if mode == "bedrock":
|
|
82
|
+
aws_region = region or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
|
83
|
+
return anthropic.AnthropicBedrock(aws_region=aws_region, max_retries=max_retries)
|
|
84
|
+
|
|
85
|
+
if mode == "api_key":
|
|
86
|
+
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
87
|
+
if not key:
|
|
88
|
+
if not secret_name:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"build_anthropic_client(mode='api_key'): no api_key argument, "
|
|
91
|
+
"no ANTHROPIC_API_KEY env var, and no secret_name to read from "
|
|
92
|
+
"Secrets Manager"
|
|
93
|
+
)
|
|
94
|
+
key = get_api_key(secret_name, key_field="api_key")
|
|
95
|
+
return anthropic.Anthropic(api_key=key, max_retries=max_retries)
|
|
96
|
+
|
|
97
|
+
raise ValueError(f"build_anthropic_client: unknown mode {mode!r} (expected 'api_key' or 'bedrock')")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def call_tool(
|
|
101
|
+
client: Any,
|
|
102
|
+
*,
|
|
103
|
+
tool: Dict[str, Any],
|
|
104
|
+
prompt: str,
|
|
105
|
+
model: str,
|
|
106
|
+
max_tokens: int,
|
|
107
|
+
system: Optional[str] = None,
|
|
108
|
+
) -> Optional[Dict[str, Any]]:
|
|
109
|
+
"""Make a forced tool-use call and return the named tool's ``input`` dict.
|
|
110
|
+
|
|
111
|
+
Best-effort by design: a transport error, a malformed tool definition, a
|
|
112
|
+
response with no matching ``tool_use`` block, or a non-object tool input
|
|
113
|
+
logs a warning and returns ``None`` so the caller can leave the item
|
|
114
|
+
unprocessed and retry on the next run. It never raises on a model or network
|
|
115
|
+
failure (the dominant Lambda enrichment pattern). Validation and any
|
|
116
|
+
coercion of the returned dict stay in the consumer.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
client: An Anthropic client (from :func:`build_anthropic_client` or any
|
|
120
|
+
object exposing ``messages.create``).
|
|
121
|
+
tool: A single Anthropic tool definition. Must carry a ``name``.
|
|
122
|
+
prompt: The user-turn prompt text.
|
|
123
|
+
model: Model id (kept in the consumer; this helper imposes no default).
|
|
124
|
+
max_tokens: Response token cap.
|
|
125
|
+
system: Optional system prompt.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The tool's ``input`` dict, or ``None`` on any failure.
|
|
129
|
+
"""
|
|
130
|
+
if not isinstance(tool, dict):
|
|
131
|
+
log.warning("call_tool: tool is not a dict; cannot force tool use")
|
|
132
|
+
return None
|
|
133
|
+
tool_name = str(tool.get("name") or "").strip()
|
|
134
|
+
if not tool_name:
|
|
135
|
+
log.warning("call_tool: tool definition has no usable 'name'; cannot force tool use")
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
kwargs: Dict[str, Any] = {
|
|
139
|
+
"model": model,
|
|
140
|
+
"max_tokens": max_tokens,
|
|
141
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
142
|
+
"tools": [tool],
|
|
143
|
+
"tool_choice": {"type": "tool", "name": tool_name},
|
|
144
|
+
}
|
|
145
|
+
if system is not None:
|
|
146
|
+
kwargs["system"] = system
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
response = client.messages.create(**kwargs)
|
|
150
|
+
except Exception as exc: # noqa: BLE001 - best-effort; never abort the caller's run
|
|
151
|
+
log.warning("call_tool: model call failed for tool '%s': %s", tool_name, exc)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
for block in getattr(response, "content", None) or []:
|
|
155
|
+
if getattr(block, "type", None) == "tool_use" and getattr(block, "name", "") == tool_name:
|
|
156
|
+
tool_input = block.input
|
|
157
|
+
if isinstance(tool_input, dict):
|
|
158
|
+
return tool_input
|
|
159
|
+
log.warning("call_tool: tool_use input for '%s' was not an object", tool_name)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
log.warning("call_tool: response contained no tool_use block for '%s'", tool_name)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def call_text(
|
|
167
|
+
client: Any,
|
|
168
|
+
*,
|
|
169
|
+
prompt: str,
|
|
170
|
+
model: str,
|
|
171
|
+
max_tokens: int,
|
|
172
|
+
system: Optional[str] = None,
|
|
173
|
+
) -> Dict[str, Any]:
|
|
174
|
+
"""Make a plain text call and return the text plus token usage.
|
|
175
|
+
|
|
176
|
+
Returns ``{"text", "input_tokens", "output_tokens"}``. ``text`` is the
|
|
177
|
+
concatenation of all ``text`` content blocks (empty string if the response
|
|
178
|
+
carried none). Unlike :func:`call_tool` this is not best-effort: a transport
|
|
179
|
+
error propagates to the caller, who wanted the text or an exception.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
client: An Anthropic client (from :func:`build_anthropic_client` or any
|
|
183
|
+
object exposing ``messages.create``).
|
|
184
|
+
prompt: The user-turn prompt text.
|
|
185
|
+
model: Model id (kept in the consumer; this helper imposes no default).
|
|
186
|
+
max_tokens: Response token cap.
|
|
187
|
+
system: Optional system prompt.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
``{"text": str, "input_tokens": int | None, "output_tokens": int | None}``.
|
|
191
|
+
"""
|
|
192
|
+
kwargs: Dict[str, Any] = {
|
|
193
|
+
"model": model,
|
|
194
|
+
"max_tokens": max_tokens,
|
|
195
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
196
|
+
}
|
|
197
|
+
if system is not None:
|
|
198
|
+
kwargs["system"] = system
|
|
199
|
+
|
|
200
|
+
response = client.messages.create(**kwargs)
|
|
201
|
+
text = "".join(
|
|
202
|
+
block.text for block in (getattr(response, "content", None) or []) if getattr(block, "type", None) == "text"
|
|
203
|
+
)
|
|
204
|
+
usage = getattr(response, "usage", None)
|
|
205
|
+
return {
|
|
206
|
+
"text": text,
|
|
207
|
+
"input_tokens": getattr(usage, "input_tokens", None),
|
|
208
|
+
"output_tokens": getattr(usage, "output_tokens", None),
|
|
209
|
+
}
|
|
@@ -38,7 +38,11 @@ def get_secret(secret_name: str) -> Dict:
|
|
|
38
38
|
|
|
39
39
|
# Create a Secrets Manager client
|
|
40
40
|
session = boto3.session.Session()
|
|
41
|
-
|
|
41
|
+
region = session.region_name or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
|
42
|
+
if not region:
|
|
43
|
+
raise Exception("AWS region not configured for Secrets Manager lookup; set AWS_REGION or AWS_DEFAULT_REGION")
|
|
44
|
+
|
|
45
|
+
client = session.client(service_name="secretsmanager", region_name=region)
|
|
42
46
|
|
|
43
47
|
try:
|
|
44
48
|
response = client.get_secret_value(SecretId=secret_name)
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""NUI adapter for the ``snowflake-sql-api`` pure-Python Snowflake client.
|
|
2
|
+
|
|
3
|
+
This is the NUI-opinionated layer over the generic
|
|
4
|
+
`snowflake-sql-api <https://github.com/hampsterx/snowflake-sql-api>`_ package.
|
|
5
|
+
The generic package stays vendor-neutral (no account/role/timezone defaults);
|
|
6
|
+
all NUI specifics live here:
|
|
7
|
+
|
|
8
|
+
- **Keypair from Secrets Manager.** Credentials (account, user, PEM private key,
|
|
9
|
+
optional passphrase) are loaded from AWS Secrets Manager by default, with
|
|
10
|
+
environment-variable and explicit-argument overrides.
|
|
11
|
+
- **NUI session defaults.** ``TIMEZONE='Pacific/Auckland'`` and role
|
|
12
|
+
``NUI_LAMBDA`` are applied unless overridden. (``WEEK_START`` is *not* set: the
|
|
13
|
+
SQL API only accepts an allow-list of session parameters in a request and
|
|
14
|
+
rejects ``WEEK_START`` with HTTP 400. The API is stateless per statement, so
|
|
15
|
+
``ALTER SESSION`` cannot substitute. Set it on the Snowflake user/role default
|
|
16
|
+
if a consumer needs Monday-first weeks.)
|
|
17
|
+
- **Redacted query logging.** A logging hook records the statement text
|
|
18
|
+
(truncated) and the *number* of bind parameters, never the bind values, JWTs,
|
|
19
|
+
key material, or secret names.
|
|
20
|
+
- **Sync by default.** :func:`create_snowflake_client` returns the synchronous
|
|
21
|
+
client; use :func:`create_async_snowflake_client` only for a Lambda already
|
|
22
|
+
running on an event loop.
|
|
23
|
+
|
|
24
|
+
Install the extra to pull the client in::
|
|
25
|
+
|
|
26
|
+
pip install 'nui-python-shared-utils[snowflake]'
|
|
27
|
+
|
|
28
|
+
Using any factory here without that extra raises a clear :class:`ImportError`
|
|
29
|
+
rather than failing at package import time.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
from typing import Any, Callable, Dict, Optional, Sequence, Union
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Type alias for the on_query callback: receives SQL text and optional bind params.
|
|
39
|
+
QueryHook = Callable[[str, Optional[Sequence[Any]]], None]
|
|
40
|
+
|
|
41
|
+
# Optional dependency: the generic snowflake-sql-api client. Guarded so the
|
|
42
|
+
# package imports without the [snowflake] extra; factories raise a clear error.
|
|
43
|
+
try:
|
|
44
|
+
from snowflake_sql_api import AsyncSnowflakeClient, SnowflakeClient
|
|
45
|
+
|
|
46
|
+
SNOWFLAKE_SQL_API_AVAILABLE = True
|
|
47
|
+
except ModuleNotFoundError as exc: # pragma: no cover - flag is patched in tests
|
|
48
|
+
# Only the missing extra itself flips the availability flag. If the package
|
|
49
|
+
# is installed but a *dependency of it* is missing, re-raise so the user sees
|
|
50
|
+
# the real traceback instead of a misleading "not installed" message.
|
|
51
|
+
if exc.name and exc.name.split(".")[0] != "snowflake_sql_api":
|
|
52
|
+
raise
|
|
53
|
+
AsyncSnowflakeClient = None # type: ignore[assignment,misc]
|
|
54
|
+
SnowflakeClient = None # type: ignore[assignment,misc]
|
|
55
|
+
SNOWFLAKE_SQL_API_AVAILABLE = False
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"DEFAULT_TIMEZONE",
|
|
59
|
+
"DEFAULT_ROLE",
|
|
60
|
+
"DEFAULT_SECRET_NAME",
|
|
61
|
+
"get_snowflake_credentials",
|
|
62
|
+
"create_snowflake_client",
|
|
63
|
+
"create_async_snowflake_client",
|
|
64
|
+
"redacting_query_logger",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
#: NUI session defaults. Every one of these is overridable per call.
|
|
68
|
+
DEFAULT_TIMEZONE = "Pacific/Auckland"
|
|
69
|
+
DEFAULT_ROLE = "NUI_LAMBDA"
|
|
70
|
+
|
|
71
|
+
#: Secrets Manager secret name used when none is supplied via argument or the
|
|
72
|
+
#: ``SNOWFLAKE_CREDENTIALS_SECRET`` environment variable.
|
|
73
|
+
DEFAULT_SECRET_NAME = "snowflake-credentials"
|
|
74
|
+
|
|
75
|
+
#: Default ``User-Agent`` sent by NUI clients (overridable via ``user_agent``).
|
|
76
|
+
_USER_AGENT = "nui-python-shared-utils-snowflake"
|
|
77
|
+
|
|
78
|
+
# Secret/credential field aliases. The secret JSON may use any of these.
|
|
79
|
+
_ACCOUNT_FIELDS = ("account", "snowflake_account")
|
|
80
|
+
_USER_FIELDS = ("user", "username", "snowflake_user")
|
|
81
|
+
_PRIVATE_KEY_FIELDS = ("private_key", "privateKey", "snowflake_private_key")
|
|
82
|
+
_PASSPHRASE_FIELDS = (
|
|
83
|
+
"private_key_passphrase",
|
|
84
|
+
"passphrase",
|
|
85
|
+
"private_key_password",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _require_snowflake_sql_api() -> None:
|
|
90
|
+
"""Raise a clear, actionable error when the optional extra is missing."""
|
|
91
|
+
if not SNOWFLAKE_SQL_API_AVAILABLE:
|
|
92
|
+
raise ImportError(
|
|
93
|
+
"snowflake-sql-api is not installed. Install it with: pip install 'nui-python-shared-utils[snowflake]'"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _first(mapping: Dict[str, Any], keys: Sequence[str]) -> Optional[Any]:
|
|
98
|
+
"""Return the first present, non-empty value among ``keys`` in ``mapping``."""
|
|
99
|
+
for key in keys:
|
|
100
|
+
value = mapping.get(key)
|
|
101
|
+
if value:
|
|
102
|
+
return value
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _as_key_bytes(value: Any) -> bytes:
|
|
107
|
+
"""Coerce a PEM private key (``str`` or ``bytes``) to ``bytes``."""
|
|
108
|
+
if isinstance(value, bytes):
|
|
109
|
+
return value
|
|
110
|
+
if isinstance(value, str):
|
|
111
|
+
return value.encode("utf-8")
|
|
112
|
+
raise ValueError("private_key must be PEM text (str) or bytes")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_secret_name(secret_name: Optional[str]) -> str:
|
|
116
|
+
"""Resolve the secret name: explicit arg > env var > default."""
|
|
117
|
+
return secret_name or os.environ.get("SNOWFLAKE_CREDENTIALS_SECRET") or DEFAULT_SECRET_NAME
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _env_private_key() -> Optional[bytes]:
|
|
121
|
+
"""Read key bytes from ``SNOWFLAKE_PRIVATE_KEY`` (inline PEM) or ``SNOWFLAKE_PRIVATE_KEY_PATH`` (file)."""
|
|
122
|
+
key_pem = os.environ.get("SNOWFLAKE_PRIVATE_KEY")
|
|
123
|
+
if key_pem:
|
|
124
|
+
return _as_key_bytes(key_pem)
|
|
125
|
+
key_path = os.environ.get("SNOWFLAKE_PRIVATE_KEY_PATH")
|
|
126
|
+
if key_path:
|
|
127
|
+
with open(key_path, "rb") as handle:
|
|
128
|
+
return handle.read()
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_snowflake_credentials(
|
|
133
|
+
secret_name: Optional[str] = None,
|
|
134
|
+
*,
|
|
135
|
+
account: Optional[str] = None,
|
|
136
|
+
user: Optional[str] = None,
|
|
137
|
+
private_key: Optional[Union[bytes, str]] = None,
|
|
138
|
+
private_key_passphrase: Optional[str] = None,
|
|
139
|
+
) -> Dict[str, Any]:
|
|
140
|
+
"""Resolve Snowflake keypair credentials.
|
|
141
|
+
|
|
142
|
+
Each field is resolved independently with strict **explicit arg > env var >
|
|
143
|
+
Secrets Manager** precedence, so an explicit value is never shadowed by a
|
|
144
|
+
lower tier:
|
|
145
|
+
|
|
146
|
+
- ``account`` / ``user``: explicit arg, else ``SNOWFLAKE_ACCOUNT`` /
|
|
147
|
+
``SNOWFLAKE_USER``, else the secret's ``account`` / ``user`` field.
|
|
148
|
+
- ``private_key``: explicit arg (bytes/PEM str), else ``SNOWFLAKE_PRIVATE_KEY``
|
|
149
|
+
(inline PEM) / ``SNOWFLAKE_PRIVATE_KEY_PATH`` (file), else the secret's
|
|
150
|
+
``private_key`` field (PEM text).
|
|
151
|
+
- ``private_key_passphrase`` is paired with the key's source: an explicit
|
|
152
|
+
passphrase arg always wins, otherwise the passphrase comes from the **same
|
|
153
|
+
tier the key came from** (env key -> ``SNOWFLAKE_PRIVATE_KEY_PASSPHRASE``;
|
|
154
|
+
secret key -> the secret's passphrase field). An explicit key is therefore
|
|
155
|
+
never silently paired with a stale env/secret passphrase.
|
|
156
|
+
|
|
157
|
+
Secrets Manager is only contacted when ``account``, ``user`` or the key is
|
|
158
|
+
still unresolved after the explicit/env tiers.
|
|
159
|
+
|
|
160
|
+
Returns a dict with ``account``, ``user``, ``private_key`` (bytes) and
|
|
161
|
+
``private_key_passphrase`` keys.
|
|
162
|
+
"""
|
|
163
|
+
resolved_account = account or os.environ.get("SNOWFLAKE_ACCOUNT")
|
|
164
|
+
resolved_user = user or os.environ.get("SNOWFLAKE_USER")
|
|
165
|
+
|
|
166
|
+
# Resolve key + passphrase together so they always come from one source.
|
|
167
|
+
if private_key is not None:
|
|
168
|
+
resolved_key: Optional[bytes] = _as_key_bytes(private_key)
|
|
169
|
+
resolved_passphrase = private_key_passphrase # explicit arg only
|
|
170
|
+
else:
|
|
171
|
+
env_key = _env_private_key()
|
|
172
|
+
if env_key is not None:
|
|
173
|
+
resolved_key = env_key
|
|
174
|
+
resolved_passphrase = (
|
|
175
|
+
private_key_passphrase
|
|
176
|
+
if private_key_passphrase is not None
|
|
177
|
+
else os.environ.get("SNOWFLAKE_PRIVATE_KEY_PASSPHRASE")
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
resolved_key = None
|
|
181
|
+
resolved_passphrase = private_key_passphrase # may be filled from secret
|
|
182
|
+
|
|
183
|
+
# Contact Secrets Manager only for what is still missing.
|
|
184
|
+
if not (resolved_account and resolved_user and resolved_key is not None):
|
|
185
|
+
from .secrets_helper import get_secret
|
|
186
|
+
|
|
187
|
+
resolved_name = _resolve_secret_name(secret_name)
|
|
188
|
+
secret = get_secret(resolved_name)
|
|
189
|
+
|
|
190
|
+
resolved_account = resolved_account or _first(secret, _ACCOUNT_FIELDS)
|
|
191
|
+
resolved_user = resolved_user or _first(secret, _USER_FIELDS)
|
|
192
|
+
if resolved_key is None:
|
|
193
|
+
secret_key = _first(secret, _PRIVATE_KEY_FIELDS)
|
|
194
|
+
resolved_key = _as_key_bytes(secret_key) if secret_key else None
|
|
195
|
+
if resolved_passphrase is None:
|
|
196
|
+
resolved_passphrase = _first(secret, _PASSPHRASE_FIELDS)
|
|
197
|
+
|
|
198
|
+
missing = [
|
|
199
|
+
name
|
|
200
|
+
for name, value in (
|
|
201
|
+
("account", resolved_account),
|
|
202
|
+
("user", resolved_user),
|
|
203
|
+
("private_key", resolved_key),
|
|
204
|
+
)
|
|
205
|
+
if not value
|
|
206
|
+
]
|
|
207
|
+
if missing:
|
|
208
|
+
# Name the secret but never its contents.
|
|
209
|
+
raise ValueError(f"Snowflake secret '{resolved_name}' is missing required field(s): " + ", ".join(missing))
|
|
210
|
+
|
|
211
|
+
if not resolved_key:
|
|
212
|
+
# An explicit empty key (``b""``) or an empty key file would otherwise
|
|
213
|
+
# slip past the missing-field check above (which only runs in the secret
|
|
214
|
+
# branch). Fail clearly here regardless of the key's source.
|
|
215
|
+
raise ValueError("a non-empty private_key is required")
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"account": resolved_account,
|
|
219
|
+
"user": resolved_user,
|
|
220
|
+
"private_key": resolved_key,
|
|
221
|
+
"private_key_passphrase": resolved_passphrase,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def redacting_query_logger(
|
|
226
|
+
logger: Optional[logging.Logger] = None,
|
|
227
|
+
*,
|
|
228
|
+
level: int = logging.DEBUG,
|
|
229
|
+
max_sql_chars: int = 1000,
|
|
230
|
+
) -> QueryHook:
|
|
231
|
+
"""Build an ``on_query`` hook that logs statements without leaking values.
|
|
232
|
+
|
|
233
|
+
The returned callback logs the (truncated) SQL text and the *count* of bind
|
|
234
|
+
parameters. It never logs the bind values themselves, so user data, secrets,
|
|
235
|
+
and PII passed as parameters stay out of the logs.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
logger: Destination logger. Defaults to this module's logger.
|
|
239
|
+
level: Log level for the query record (default ``DEBUG``).
|
|
240
|
+
max_sql_chars: Truncate the logged SQL beyond this many characters.
|
|
241
|
+
"""
|
|
242
|
+
target = logger or log
|
|
243
|
+
|
|
244
|
+
def _hook(sql: str, params: Optional[Sequence[Any]]) -> None:
|
|
245
|
+
truncated = sql if len(sql) <= max_sql_chars else sql[:max_sql_chars] + "...(truncated)"
|
|
246
|
+
param_count = len(params) if params else 0
|
|
247
|
+
target.log(
|
|
248
|
+
level,
|
|
249
|
+
"snowflake query",
|
|
250
|
+
extra={"sql": truncated, "bind_param_count": param_count},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return _hook
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _resolve_on_query(
|
|
257
|
+
on_query: Optional[QueryHook],
|
|
258
|
+
logger: Optional[logging.Logger],
|
|
259
|
+
log_queries: bool,
|
|
260
|
+
log_sql_max_chars: int,
|
|
261
|
+
) -> Optional[QueryHook]:
|
|
262
|
+
"""Pick the query hook: explicit override, redacting logger, or none."""
|
|
263
|
+
if on_query is not None:
|
|
264
|
+
return on_query
|
|
265
|
+
if log_queries:
|
|
266
|
+
return redacting_query_logger(logger, max_sql_chars=log_sql_max_chars)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _client_kwargs(
|
|
271
|
+
creds: Dict[str, Any],
|
|
272
|
+
*,
|
|
273
|
+
role: Optional[str],
|
|
274
|
+
warehouse: Optional[str],
|
|
275
|
+
database: Optional[str],
|
|
276
|
+
schema: Optional[str],
|
|
277
|
+
timezone: Optional[str],
|
|
278
|
+
parameters: Optional[Dict[str, Any]],
|
|
279
|
+
on_query: Optional[QueryHook],
|
|
280
|
+
user_agent: Optional[str],
|
|
281
|
+
extra: Dict[str, Any],
|
|
282
|
+
) -> Dict[str, Any]:
|
|
283
|
+
"""Shared keyword assembly for both sync and async client construction.
|
|
284
|
+
|
|
285
|
+
``TIMEZONE`` rides the client's ``timezone`` argument; any caller-supplied
|
|
286
|
+
``parameters`` pass straight through to the SQL API ``parameters`` map. Note
|
|
287
|
+
the SQL API only accepts an allow-list of session parameters there (TIMEZONE
|
|
288
|
+
and output-format params are fine; WEEK_START is rejected, see module docs).
|
|
289
|
+
"""
|
|
290
|
+
# Start from passthrough kwargs, then let adapter-managed keys win, so an
|
|
291
|
+
# unexpected ``extra`` key can never silently override a managed one (the
|
|
292
|
+
# factories' named params already capture managed keys, so a caller cannot
|
|
293
|
+
# reach these via ``**client_kwargs`` today; this guards a future key add).
|
|
294
|
+
kwargs: Dict[str, Any] = dict(extra)
|
|
295
|
+
kwargs.update(
|
|
296
|
+
{
|
|
297
|
+
"account": creds["account"],
|
|
298
|
+
"user": creds["user"],
|
|
299
|
+
"private_key": creds["private_key"],
|
|
300
|
+
"private_key_passphrase": creds.get("private_key_passphrase"),
|
|
301
|
+
"role": role,
|
|
302
|
+
"warehouse": warehouse,
|
|
303
|
+
"database": database,
|
|
304
|
+
"schema": schema,
|
|
305
|
+
"timezone": timezone,
|
|
306
|
+
"parameters": dict(parameters) if parameters else None,
|
|
307
|
+
"on_query": on_query,
|
|
308
|
+
"user_agent": user_agent or _USER_AGENT,
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
return kwargs
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def create_snowflake_client(
|
|
315
|
+
*,
|
|
316
|
+
secret_name: Optional[str] = None,
|
|
317
|
+
account: Optional[str] = None,
|
|
318
|
+
user: Optional[str] = None,
|
|
319
|
+
private_key: Optional[Union[bytes, str]] = None,
|
|
320
|
+
private_key_passphrase: Optional[str] = None,
|
|
321
|
+
role: Optional[str] = DEFAULT_ROLE,
|
|
322
|
+
warehouse: Optional[str] = None,
|
|
323
|
+
database: Optional[str] = None,
|
|
324
|
+
schema: Optional[str] = None,
|
|
325
|
+
timezone: Optional[str] = DEFAULT_TIMEZONE,
|
|
326
|
+
parameters: Optional[Dict[str, Any]] = None,
|
|
327
|
+
logger: Optional[logging.Logger] = None,
|
|
328
|
+
log_queries: bool = True,
|
|
329
|
+
log_sql_max_chars: int = 1000,
|
|
330
|
+
on_query: Optional[QueryHook] = None,
|
|
331
|
+
user_agent: Optional[str] = None,
|
|
332
|
+
**client_kwargs: Any,
|
|
333
|
+
) -> "SnowflakeClient":
|
|
334
|
+
"""Construct a synchronous Snowflake client with NUI defaults.
|
|
335
|
+
|
|
336
|
+
This is the adapter's default entry point. Credentials are resolved via
|
|
337
|
+
:func:`get_snowflake_credentials`; NUI session defaults (timezone, role) are
|
|
338
|
+
applied unless overridden; a redacting query-logging hook is wired in unless
|
|
339
|
+
``log_queries=False`` or a custom ``on_query`` is given.
|
|
340
|
+
|
|
341
|
+
Extra keyword arguments (``timeout``, ``statement_timeout``,
|
|
342
|
+
``poll_interval``, ``retry_policy``, ``host`` ...) pass straight through to
|
|
343
|
+
:class:`snowflake_sql_api.SnowflakeClient`.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
A ready-to-use :class:`snowflake_sql_api.SnowflakeClient`.
|
|
347
|
+
"""
|
|
348
|
+
_require_snowflake_sql_api()
|
|
349
|
+
creds = get_snowflake_credentials(
|
|
350
|
+
secret_name,
|
|
351
|
+
account=account,
|
|
352
|
+
user=user,
|
|
353
|
+
private_key=private_key,
|
|
354
|
+
private_key_passphrase=private_key_passphrase,
|
|
355
|
+
)
|
|
356
|
+
hook = _resolve_on_query(on_query, logger, log_queries, log_sql_max_chars)
|
|
357
|
+
kwargs = _client_kwargs(
|
|
358
|
+
creds,
|
|
359
|
+
role=role,
|
|
360
|
+
warehouse=warehouse,
|
|
361
|
+
database=database,
|
|
362
|
+
schema=schema,
|
|
363
|
+
timezone=timezone,
|
|
364
|
+
parameters=parameters,
|
|
365
|
+
on_query=hook,
|
|
366
|
+
user_agent=user_agent,
|
|
367
|
+
extra=client_kwargs,
|
|
368
|
+
)
|
|
369
|
+
return SnowflakeClient(**kwargs)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def create_async_snowflake_client(
|
|
373
|
+
*,
|
|
374
|
+
secret_name: Optional[str] = None,
|
|
375
|
+
account: Optional[str] = None,
|
|
376
|
+
user: Optional[str] = None,
|
|
377
|
+
private_key: Optional[Union[bytes, str]] = None,
|
|
378
|
+
private_key_passphrase: Optional[str] = None,
|
|
379
|
+
role: Optional[str] = DEFAULT_ROLE,
|
|
380
|
+
warehouse: Optional[str] = None,
|
|
381
|
+
database: Optional[str] = None,
|
|
382
|
+
schema: Optional[str] = None,
|
|
383
|
+
timezone: Optional[str] = DEFAULT_TIMEZONE,
|
|
384
|
+
parameters: Optional[Dict[str, Any]] = None,
|
|
385
|
+
logger: Optional[logging.Logger] = None,
|
|
386
|
+
log_queries: bool = True,
|
|
387
|
+
log_sql_max_chars: int = 1000,
|
|
388
|
+
on_query: Optional[QueryHook] = None,
|
|
389
|
+
user_agent: Optional[str] = None,
|
|
390
|
+
**client_kwargs: Any,
|
|
391
|
+
) -> "AsyncSnowflakeClient":
|
|
392
|
+
"""Construct an asynchronous Snowflake client with NUI defaults.
|
|
393
|
+
|
|
394
|
+
Async parity with :func:`create_snowflake_client`. Use only inside a Lambda
|
|
395
|
+
already running on an event loop; the sync client is the NUI default
|
|
396
|
+
otherwise.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
A ready-to-use :class:`snowflake_sql_api.AsyncSnowflakeClient`.
|
|
400
|
+
"""
|
|
401
|
+
_require_snowflake_sql_api()
|
|
402
|
+
creds = get_snowflake_credentials(
|
|
403
|
+
secret_name,
|
|
404
|
+
account=account,
|
|
405
|
+
user=user,
|
|
406
|
+
private_key=private_key,
|
|
407
|
+
private_key_passphrase=private_key_passphrase,
|
|
408
|
+
)
|
|
409
|
+
hook = _resolve_on_query(on_query, logger, log_queries, log_sql_max_chars)
|
|
410
|
+
kwargs = _client_kwargs(
|
|
411
|
+
creds,
|
|
412
|
+
role=role,
|
|
413
|
+
warehouse=warehouse,
|
|
414
|
+
database=database,
|
|
415
|
+
schema=schema,
|
|
416
|
+
timezone=timezone,
|
|
417
|
+
parameters=parameters,
|
|
418
|
+
on_query=hook,
|
|
419
|
+
user_agent=user_agent,
|
|
420
|
+
extra=client_kwargs,
|
|
421
|
+
)
|
|
422
|
+
return AsyncSnowflakeClient(**kwargs)
|
|
File without changes
|
{nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{nui_python_shared_utils-1.3.3.dist-info → nui_python_shared_utils-1.4.1.dist-info}/top_level.txt
RENAMED
|
File without changes
|