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.
@@ -0,0 +1,3 @@
1
+ """Backwards-compatibility shim. Use nui_shared_utils.snowflake_client instead."""
2
+
3
+ from nui_shared_utils.snowflake_client import * # noqa: F401,F403
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nui-python-shared-utils
3
- Version: 1.3.3
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] # All integrations
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.3.3.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
24
- nui_shared_utils/__init__.py,sha256=Myt_55yTIZO71iyz2wkM9wySd7xhOCCky4EBOs-aI0A,10964
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=jtQZSbghNTyWYylGxFiHWeL28Q1JUpLTrnGIICU4R3k,6815
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.3.3.dist-info/METADATA,sha256=y_FgR8-pq8PWqEayZQfYAtOqzijcsdG2UOQDVxd4-7E,19387
47
- nui_python_shared_utils-1.3.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
48
- nui_python_shared_utils-1.3.3.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
49
- nui_python_shared_utils-1.3.3.dist-info/top_level.txt,sha256=sNceq5okmEB54L-gm4a0OgGhnPlU62zYmlUoXKn-Fa8,41
50
- nui_python_shared_utils-1.3.3.dist-info/RECORD,,
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,,
@@ -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
 
@@ -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
- client = session.client(service_name="secretsmanager", region_name=session.region_name or "ap-southeast-2")
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)