glaip-sdk 0.4.0__py3-none-any.whl → 0.5.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.
glaip_sdk/cli/auth.py CHANGED
@@ -1,23 +1,24 @@
1
- """Authentication export helpers for MCP CLI commands.
1
+ """Authentication export helpers for MCP CLI commands and credential resolution.
2
2
 
3
3
  This module provides utilities for preparing authentication data for export,
4
4
  including interactive secret capture and placeholder generation.
5
5
 
6
- These helpers are distinct from the AIP CLI's own authentication, which always
7
- relies on the API URL and API key managed via ``aip configure`` / `AIP_API_*`
8
- environment variables.
6
+ It also provides credential resolution for the AIP CLI, supporting multiple
7
+ account profiles and environment variable overrides.
9
8
 
10
9
  Authors:
11
10
  Raymond Christopher (raymond.christopher@gdplabs.id)
12
11
  """
13
12
 
14
- from collections.abc import Iterable, Mapping
13
+ import os
14
+ from collections.abc import Callable, Iterable, Mapping
15
15
  from typing import Any
16
16
 
17
17
  import click
18
18
  from rich.console import Console
19
19
 
20
20
  from glaip_sdk.branding import HINT_PREFIX_STYLE, WARNING_STYLE
21
+ from glaip_sdk.cli.account_store import AccountNotFoundError, AccountStoreError, get_account_store
21
22
  from glaip_sdk.cli.hints import format_command_hint
22
23
  from glaip_sdk.cli.utils import command_hint
23
24
 
@@ -108,6 +109,24 @@ def _get_token_value(prompt_for_secrets: bool, placeholder: str, console: Consol
108
109
  return placeholder
109
110
 
110
111
 
112
+ def _normalize_header_keys(
113
+ header_keys: Iterable[str] | str | None,
114
+ *,
115
+ default: Iterable[str] | None = None,
116
+ ) -> list[str]:
117
+ """Normalize header_keys to a list, handling strings and None safely."""
118
+ if header_keys is None:
119
+ return list(default or [])
120
+ if isinstance(header_keys, str):
121
+ return [header_keys] if header_keys else list(default or [])
122
+ try:
123
+ return list(header_keys)
124
+ except TypeError:
125
+ raise click.ClickException(
126
+ f"Invalid header_keys type: expected string or iterable, got {type(header_keys).__name__}"
127
+ ) from None
128
+
129
+
111
130
  def _build_bearer_headers(auth: dict[str, Any], token_value: str) -> dict[str, str]:
112
131
  """Build headers for bearer token authentication.
113
132
 
@@ -119,7 +138,10 @@ def _build_bearer_headers(auth: dict[str, Any], token_value: str) -> dict[str, s
119
138
  A dictionary of HTTP headers including the Authorization header when
120
139
  applicable.
121
140
  """
122
- header_keys = auth.get("header_keys", ["Authorization"])
141
+ default_header_keys = ["Authorization"]
142
+ has_header_keys = "header_keys" in auth
143
+ header_keys_raw = auth.get("header_keys") if has_header_keys else default_header_keys
144
+ header_keys = _normalize_header_keys(header_keys_raw, default=None if has_header_keys else default_header_keys)
123
145
  headers = {}
124
146
  for key in header_keys:
125
147
  # Prepend "Bearer " if this is Authorization header
@@ -178,8 +200,8 @@ def _extract_api_key_name(auth: dict[str, Any]) -> str | None:
178
200
  """
179
201
  key_name = auth.get("key")
180
202
  if not key_name and "header_keys" in auth:
181
- header_keys = auth["header_keys"]
182
- if isinstance(header_keys, list) and header_keys:
203
+ header_keys = _normalize_header_keys(auth["header_keys"])
204
+ if header_keys:
183
205
  key_name = header_keys[0]
184
206
  return key_name
185
207
 
@@ -227,8 +249,12 @@ def _build_api_key_headers(auth: dict[str, Any], key_name: str | None, key_value
227
249
  Returns:
228
250
  A dictionary of HTTP headers for API key authentication.
229
251
  """
230
- header_keys = auth.get("header_keys", [key_name] if key_name else [])
231
- return {key: key_value for key in header_keys}
252
+ default_header_keys = [key_name] if key_name else []
253
+ has_header_keys = "header_keys" in auth
254
+ header_keys_raw = auth.get("header_keys") if has_header_keys else default_header_keys
255
+ header_keys_list = _normalize_header_keys(header_keys_raw, default=None if has_header_keys else default_header_keys)
256
+ filtered_keys = [k for k in header_keys_list if k]
257
+ return dict.fromkeys(filtered_keys, key_value)
232
258
 
233
259
 
234
260
  def _prepare_api_key_auth(
@@ -314,9 +340,7 @@ def _extract_header_names(existing_headers: Mapping[str, Any] | None, header_key
314
340
  """
315
341
  if existing_headers:
316
342
  return list(existing_headers.keys())
317
- if header_keys:
318
- return list(header_keys)
319
- return []
343
+ return _normalize_header_keys(header_keys)
320
344
 
321
345
 
322
346
  def _is_valid_secret(value: Any) -> bool:
@@ -459,3 +483,217 @@ def _prompt_secret_with_placeholder(
459
483
 
460
484
  # This line is unreachable as the loop always returns
461
485
  # return placeholder
486
+
487
+
488
+ # ----------------------------- Credential Resolution ----------------------------- #
489
+
490
+
491
+ def resolve_api_url_from_context(
492
+ ctx: Any,
493
+ *,
494
+ get_api_url: Callable[[Any], str | None] | None = None,
495
+ get_account_name: Callable[[Any], str | None] | None = None,
496
+ ) -> str | None:
497
+ """Resolve API URL from context using account store (CLI/palette ignores env creds).
498
+
499
+ Helper function to extract API URL from various context formats.
500
+ Used by transcript capture and slash session to avoid code duplication.
501
+
502
+ Args:
503
+ ctx: Context object (can be dict, click.Context, or any object with attributes).
504
+ get_api_url: Optional function to extract api_url from context.
505
+ If None, tries ctx.obj.get("api_url") or ctx.get("api_url").
506
+ get_account_name: Optional function to extract account_name from context.
507
+ If None, tries ctx.obj.get("account_name") or ctx.get("account_name").
508
+
509
+ Returns:
510
+ Resolved API URL or None.
511
+ """
512
+ api_url = None
513
+ account_name = None
514
+
515
+ if get_api_url:
516
+ api_url = get_api_url(ctx)
517
+ elif isinstance(ctx, dict):
518
+ api_url = ctx.get("api_url")
519
+ elif hasattr(ctx, "obj") and isinstance(ctx.obj, dict):
520
+ api_url = ctx.obj.get("api_url")
521
+
522
+ if get_account_name:
523
+ account_name = get_account_name(ctx)
524
+ elif isinstance(ctx, dict):
525
+ account_name = ctx.get("account_name")
526
+ elif hasattr(ctx, "obj") and isinstance(ctx.obj, dict):
527
+ account_name = ctx.obj.get("account_name")
528
+
529
+ resolved_url, _, _ = resolve_credentials(
530
+ account_name=account_name,
531
+ api_url=api_url,
532
+ api_key=None,
533
+ ignore_env_creds=True,
534
+ )
535
+ return resolved_url
536
+
537
+
538
+ def _resolve_account_name(account_name: str | None) -> str | None:
539
+ """Resolve account name from parameter (env var removed for CLI/palette)."""
540
+ return account_name
541
+
542
+
543
+ def _validate_account_exists(account_name: str | None, store: Any) -> None:
544
+ """Validate that the specified account exists.
545
+
546
+ Raises:
547
+ AccountNotFoundError: If account_name is specified but account doesn't exist.
548
+ """
549
+ if account_name:
550
+ account = store.get_account(account_name)
551
+ if not account:
552
+ raise AccountNotFoundError(
553
+ f"Account '{account_name}' not found. Run 'aip accounts list' to see available accounts."
554
+ )
555
+
556
+
557
+ def _merge_credentials(
558
+ api_url: str | None,
559
+ api_key: str | None,
560
+ profile_url: str | None,
561
+ profile_key: str | None,
562
+ ignore_env_creds: bool,
563
+ ) -> tuple[str | None, str | None]:
564
+ """Merge credentials from multiple sources.
565
+
566
+ Args:
567
+ api_url: Explicit API URL override.
568
+ api_key: Explicit API key override.
569
+ profile_url: Profile API URL.
570
+ profile_key: Profile API key.
571
+ ignore_env_creds: If True, ignore env vars.
572
+
573
+ Returns:
574
+ Tuple of (final_url, final_key).
575
+ """
576
+ if not ignore_env_creds:
577
+ env_url = os.getenv("AIP_API_URL")
578
+ env_key = os.getenv("AIP_API_KEY")
579
+ final_url = api_url or env_url or profile_url
580
+ final_key = api_key or env_key or profile_key
581
+ else:
582
+ final_url = api_url or profile_url
583
+ final_key = api_key or profile_key
584
+ return final_url, final_key
585
+
586
+
587
+ def _determine_source(
588
+ api_url: str | None,
589
+ api_key: str | None,
590
+ account_name: str | None,
591
+ store: Any,
592
+ ) -> str:
593
+ """Determine the source of credentials.
594
+
595
+ Returns:
596
+ Source string describing where credentials came from.
597
+ """
598
+ if api_url or api_key:
599
+ return "flag"
600
+ if account_name:
601
+ return f"account:{account_name}"
602
+ active = store.get_active_account()
603
+ return f"active_profile:{active}" if active else "none"
604
+
605
+
606
+ _ENV_WARNING_EMITTED = False
607
+
608
+
609
+ def _maybe_warn_env_creds_ignored(ignore_env_creds: bool) -> None:
610
+ """Emit a one-time warning when env credentials are present but ignored."""
611
+ global _ENV_WARNING_EMITTED
612
+
613
+ if _ENV_WARNING_EMITTED or not ignore_env_creds:
614
+ return
615
+
616
+ if os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"):
617
+ click.echo(
618
+ "Warning: CLI ignores AIP_API_URL/AIP_API_KEY; use account profiles via 'aip accounts add/use'. "
619
+ "Python SDK callers can opt in with ignore_env_creds=False.",
620
+ err=True,
621
+ )
622
+ _ENV_WARNING_EMITTED = True
623
+
624
+
625
+ def resolve_credentials(
626
+ account_name: str | None = None,
627
+ api_url: str | None = None,
628
+ api_key: str | None = None,
629
+ *,
630
+ ignore_env_creds: bool = True,
631
+ ) -> tuple[str | None, str | None, str]:
632
+ """Resolve credentials from multiple sources with precedence.
633
+
634
+ For CLI/palette: ignores raw credential env vars (AIP_API_URL/AIP_API_KEY),
635
+ and only uses explicit account selection (no AIP_ACCOUNT env). Python SDK can use
636
+ ignore_env_creds=False to honor env vars if needed.
637
+
638
+ Precedence order (CLI/palette):
639
+ 1. Explicit parameters (api_url, api_key)
640
+ 2. Account profile (from account_name or active_account)
641
+
642
+ Args:
643
+ account_name: Account name to use, or None for active account.
644
+ api_url: Explicit API URL override.
645
+ api_key: Explicit API key override.
646
+ ignore_env_creds: If True (default), ignore AIP_API_URL/AIP_API_KEY env vars.
647
+
648
+ Returns:
649
+ Tuple of (api_url, api_key, source) where source describes where
650
+ credentials came from (e.g., "flag", "active_profile", "account:name").
651
+
652
+ Raises:
653
+ click.ClickException: If a requested account does not exist.
654
+ """
655
+ _maybe_warn_env_creds_ignored(ignore_env_creds)
656
+
657
+ # 1. Explicit parameters take highest precedence
658
+ if api_url and api_key:
659
+ return api_url, api_key, "flag"
660
+
661
+ # 2. Account profile resolution
662
+ account_name = _resolve_account_name(account_name)
663
+ store = get_account_store()
664
+ try:
665
+ _validate_account_exists(account_name, store)
666
+ except AccountNotFoundError as exc:
667
+ raise click.ClickException(str(exc)) from exc
668
+
669
+ try:
670
+ profile_url, profile_key = store.get_credentials(account_name)
671
+ except AccountStoreError:
672
+ profile_url, profile_key = None, None
673
+
674
+ final_url, final_key = _merge_credentials(api_url, api_key, profile_url, profile_key, ignore_env_creds)
675
+ source = _determine_source(api_url, api_key, account_name, store)
676
+
677
+ return final_url, final_key, source
678
+
679
+
680
+ def get_credentials(
681
+ account_name: str | None = None,
682
+ api_url: str | None = None,
683
+ api_key: str | None = None,
684
+ ) -> tuple[str | None, str | None]:
685
+ """Get credentials for CLI commands (backward compatible wrapper).
686
+
687
+ This function maintains backward compatibility with existing code that
688
+ expects (url, key) tuple. For source information, use resolve_credentials.
689
+
690
+ Args:
691
+ account_name: Account name to use, or None for active account.
692
+ api_url: Explicit API URL override.
693
+ api_key: Explicit API key override.
694
+
695
+ Returns:
696
+ Tuple of (api_url, api_key).
697
+ """
698
+ url, key, _ = resolve_credentials(account_name, api_url, api_key)
699
+ return url, key