glaip-sdk 0.0.14__py3-none-any.whl → 0.0.16__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.
Files changed (44) hide show
  1. glaip_sdk/branding.py +27 -1
  2. glaip_sdk/cli/commands/agents.py +27 -20
  3. glaip_sdk/cli/commands/configure.py +39 -50
  4. glaip_sdk/cli/commands/mcps.py +2 -6
  5. glaip_sdk/cli/commands/models.py +1 -1
  6. glaip_sdk/cli/commands/tools.py +1 -3
  7. glaip_sdk/cli/config.py +42 -0
  8. glaip_sdk/cli/context.py +142 -0
  9. glaip_sdk/cli/display.py +92 -26
  10. glaip_sdk/cli/main.py +141 -124
  11. glaip_sdk/cli/masking.py +148 -0
  12. glaip_sdk/cli/mcp_validators.py +2 -2
  13. glaip_sdk/cli/pager.py +272 -0
  14. glaip_sdk/cli/parsers/json_input.py +2 -2
  15. glaip_sdk/cli/resolution.py +12 -10
  16. glaip_sdk/cli/slash/agent_session.py +7 -0
  17. glaip_sdk/cli/slash/prompt.py +21 -2
  18. glaip_sdk/cli/slash/session.py +15 -21
  19. glaip_sdk/cli/update_notifier.py +8 -2
  20. glaip_sdk/cli/utils.py +99 -369
  21. glaip_sdk/client/_agent_payloads.py +504 -0
  22. glaip_sdk/client/agents.py +194 -551
  23. glaip_sdk/client/base.py +92 -20
  24. glaip_sdk/client/main.py +6 -0
  25. glaip_sdk/client/run_rendering.py +275 -0
  26. glaip_sdk/config/constants.py +3 -0
  27. glaip_sdk/exceptions.py +15 -0
  28. glaip_sdk/models.py +5 -0
  29. glaip_sdk/payload_schemas/__init__.py +19 -0
  30. glaip_sdk/payload_schemas/agent.py +87 -0
  31. glaip_sdk/rich_components.py +12 -0
  32. glaip_sdk/utils/client_utils.py +12 -0
  33. glaip_sdk/utils/import_export.py +2 -2
  34. glaip_sdk/utils/rendering/formatting.py +5 -0
  35. glaip_sdk/utils/rendering/models.py +22 -0
  36. glaip_sdk/utils/rendering/renderer/base.py +9 -1
  37. glaip_sdk/utils/rendering/renderer/panels.py +0 -1
  38. glaip_sdk/utils/rendering/steps.py +59 -0
  39. glaip_sdk/utils/serialization.py +24 -3
  40. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/METADATA +1 -1
  41. glaip_sdk-0.0.16.dist-info/RECORD +72 -0
  42. glaip_sdk-0.0.14.dist-info/RECORD +0 -64
  43. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/WHEEL +0 -0
  44. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/display.py CHANGED
@@ -15,6 +15,7 @@ from rich.console import Console
15
15
  from rich.panel import Panel
16
16
  from rich.text import Text
17
17
 
18
+ from glaip_sdk.cli.utils import command_hint
18
19
  from glaip_sdk.rich_components import AIPPanel
19
20
 
20
21
  console = Console()
@@ -102,30 +103,80 @@ def print_api_error(e: Exception) -> None:
102
103
  - Extracts status_code, error_type, and payload from APIError exceptions
103
104
  - Provides consistent error reporting across CLI commands
104
105
  - Handles both JSON and Rich output formats
106
+ - Special handling for validation errors with detailed field-level errors
105
107
  """
106
- if hasattr(e, "__dict__"): # Check if it's an APIError-like object
107
- error_info = {
108
- "error": str(e),
109
- "status_code": getattr(e, "status_code", None),
110
- "error_type": getattr(e, "error_type", None),
111
- "details": getattr(e, "payload", None),
112
- }
113
-
114
- # Filter out None values
115
- error_info = {k: v for k, v in error_info.items() if v is not None}
116
-
117
- # For JSON view, just return the structured error
118
- # (CLI commands handle the JSON formatting)
119
- if hasattr(e, "status_code"):
120
- console.print(f"[red]API Error: {e}[/red]")
121
- if hasattr(e, "status_code"):
122
- console.print(f"[yellow]Status: {e.status_code}[/yellow]")
123
- if hasattr(e, "payload"):
124
- console.print(f"[yellow]Details: {e.payload}[/yellow]")
108
+ if not hasattr(e, "__dict__"):
109
+ console.print(f"[red]Error: {e}[/red]")
110
+ return
111
+
112
+ if not hasattr(e, "status_code"):
113
+ console.print(f"[red]Error: {e}[/red]")
114
+ return
115
+
116
+ console.print(f"[red]API Error: {e}[/red]")
117
+ status_code = getattr(e, "status_code", None)
118
+ if status_code is not None:
119
+ console.print(f"[yellow]Status: {status_code}[/yellow]")
120
+
121
+ payload = getattr(e, "payload", _MISSING)
122
+ if payload is _MISSING:
123
+ return
124
+
125
+ if payload:
126
+ if not _print_structured_payload(payload):
127
+ console.print(f"[yellow]Details: {payload}[/yellow]")
128
+ else:
129
+ console.print(f"[yellow]Details: {payload}[/yellow]")
130
+
131
+
132
+ def _print_structured_payload(payload: Any) -> bool:
133
+ """Print structured payloads with enhanced formatting. Returns True if handled."""
134
+ if not isinstance(payload, dict):
135
+ return False
136
+
137
+ if "detail" in payload and _print_validation_details(payload["detail"]):
138
+ return True
139
+
140
+ if "details" in payload and _print_details_field(payload["details"]):
141
+ return True
142
+
143
+ return False
144
+
145
+
146
+ def _print_validation_details(detail: Any) -> bool:
147
+ """Render FastAPI-style validation errors."""
148
+ if not isinstance(detail, list) or not detail:
149
+ return False
150
+
151
+ console.print("[red]Validation Errors:[/red]")
152
+ for error in detail:
153
+ if isinstance(error, dict):
154
+ loc = " -> ".join(str(x) for x in error.get("loc", []))
155
+ msg = error.get("msg", "Unknown error")
156
+ error_type = error.get("type", "unknown")
157
+ location = loc if loc else "field"
158
+ console.print(f" [yellow]• {location}:[/yellow] {msg}")
159
+ if error_type != "unknown":
160
+ console.print(f" [dim]({error_type})[/dim]")
125
161
  else:
126
- console.print(f"[red]Error: {e}[/red]")
162
+ console.print(f" [yellow]•[/yellow] {error}")
163
+ return True
164
+
165
+
166
+ def _print_details_field(details: Any) -> bool:
167
+ """Render custom error details from API payloads."""
168
+ if not details:
169
+ return False
170
+
171
+ console.print("[red]Error Details:[/red]")
172
+ if isinstance(details, str):
173
+ console.print(f" [yellow]•[/yellow] {details}")
174
+ elif isinstance(details, list):
175
+ for detail in details:
176
+ console.print(f" [yellow]•[/yellow] {detail}")
127
177
  else:
128
- console.print(f"[red]Error: {e}[/red]")
178
+ console.print(f" [yellow]•[/yellow] {details}")
179
+ return True
129
180
 
130
181
 
131
182
  _MISSING = object()
@@ -133,7 +184,6 @@ _MISSING = object()
133
184
 
134
185
  def build_resource_result_data(resource: Any, fields: list[str]) -> dict[str, Any]:
135
186
  """Return a normalized mapping of ``fields`` extracted from ``resource``."""
136
-
137
187
  result: dict[str, Any] = {}
138
188
  for field in fields:
139
189
  try:
@@ -244,14 +294,30 @@ def display_confirmation_prompt(resource_type: str, resource_name: str) -> bool:
244
294
 
245
295
  def display_agent_run_suggestions(agent: Any) -> Panel:
246
296
  """Return a panel with post-creation suggestions for an agent."""
297
+ agent_id = getattr(agent, "id", "")
298
+ agent_name = getattr(agent, "name", "")
299
+ run_hint_id = command_hint(
300
+ f'agents run {agent_id} "Your message here"',
301
+ slash_command=None,
302
+ )
303
+ run_hint_name = command_hint(
304
+ f'agents run "{agent_name}" "Your message here"',
305
+ slash_command=None,
306
+ )
307
+
308
+ cli_section = ""
309
+ if run_hint_id and run_hint_name:
310
+ cli_section = (
311
+ "📋 Prefer the CLI instead?\n"
312
+ f" [green]{run_hint_id}[/green]\n"
313
+ f" [green]{run_hint_name}[/green]\n\n"
314
+ )
247
315
 
248
316
  return AIPPanel(
249
317
  f"[bold blue]💡 Next Steps:[/bold blue]\n\n"
250
- f"🚀 Start chatting with [bold]{agent.name}[/bold] right here:\n"
318
+ f"🚀 Start chatting with [bold]{agent_name}[/bold] right here:\n"
251
319
  f" Type your message below and press Enter to run it immediately.\n\n"
252
- f"📋 Prefer the CLI instead?\n"
253
- f' [green]aip agents run {agent.id} "Your message here"[/green]\n'
254
- f' [green]aip agents run "{agent.name}" "Your message here"[/green]\n\n'
320
+ f"{cli_section}"
255
321
  f"🔧 Available options:\n"
256
322
  f" [dim]--chat-history[/dim] Include previous conversation\n"
257
323
  f" [dim]--file[/dim] Attach files\n"
glaip_sdk/cli/main.py CHANGED
@@ -19,11 +19,11 @@ from glaip_sdk.cli.commands.agents import agents_group
19
19
  from glaip_sdk.cli.commands.configure import (
20
20
  config_group,
21
21
  configure_command,
22
- load_config,
23
22
  )
24
23
  from glaip_sdk.cli.commands.mcps import mcps_group
25
24
  from glaip_sdk.cli.commands.models import models_group
26
25
  from glaip_sdk.cli.commands.tools import tools_group
26
+ from glaip_sdk.cli.config import load_config
27
27
  from glaip_sdk.cli.update_notifier import maybe_notify_update
28
28
  from glaip_sdk.cli.utils import spinner_context, update_spinner
29
29
  from glaip_sdk.config.constants import (
@@ -73,7 +73,6 @@ def main(
73
73
  aip tools create my_tool.py # Create a new tool
74
74
  aip agents run my-agent "Hello world" # Run an agent
75
75
  """
76
-
77
76
  # Store configuration in context
78
77
  ctx.ensure_object(dict)
79
78
  ctx.obj["api_url"] = api_url
@@ -116,7 +115,6 @@ main.add_command(configure_command)
116
115
 
117
116
  def _should_launch_slash(ctx: click.Context) -> bool:
118
117
  """Determine whether to open the command palette automatically."""
119
-
120
118
  ctx_obj = ctx.obj or {}
121
119
  if not bool(ctx_obj.get("tty", True)):
122
120
  return False
@@ -127,6 +125,137 @@ def _should_launch_slash(ctx: click.Context) -> bool:
127
125
  return True
128
126
 
129
127
 
128
+ def _load_and_merge_config(ctx: click.Context) -> dict:
129
+ """Load configuration from multiple sources and merge them."""
130
+ # Load config from file and merge with context
131
+ file_config = load_config()
132
+ context_config = ctx.obj or {}
133
+
134
+ # Load environment variables (middle priority)
135
+ env_config = {}
136
+ if os.getenv("AIP_API_URL"):
137
+ env_config["api_url"] = os.getenv("AIP_API_URL")
138
+ if os.getenv("AIP_API_KEY"):
139
+ env_config["api_key"] = os.getenv("AIP_API_KEY")
140
+
141
+ # Filter out None values from context config to avoid overriding other configs
142
+ filtered_context = {k: v for k, v in context_config.items() if v is not None}
143
+
144
+ # Merge configs: file (low) -> env (mid) -> CLI args (high)
145
+ return {**file_config, **env_config, **filtered_context}
146
+
147
+
148
+ def _validate_config_and_show_error(config: dict, console: Console) -> None:
149
+ """Validate configuration and show error if incomplete."""
150
+ if not config.get("api_url") or not config.get("api_key"):
151
+ console.print(
152
+ AIPPanel(
153
+ "[bold red]❌ Configuration incomplete[/bold red]\n\n"
154
+ f"🔍 Current config:\n"
155
+ f" • API URL: {config.get('api_url', 'Not set')}\n"
156
+ f" • API Key: {'***' + config.get('api_key', '')[-4:] if config.get('api_key') else 'Not set'}\n\n"
157
+ f"💡 To fix this:\n"
158
+ f" • Run 'aip configure' to set up credentials\n"
159
+ f" • Or run 'aip config list' to see current config",
160
+ title="❌ Configuration Error",
161
+ border_style="red",
162
+ )
163
+ )
164
+ console.print(
165
+ f"\n[bold green]✅ AIP - Ready[/bold green] (SDK v{_SDK_VERSION}) - Configure to connect"
166
+ )
167
+ sys.exit(1)
168
+
169
+
170
+ def _create_and_test_client(config: dict, console: Console) -> Client:
171
+ """Create client and test connection by fetching resources."""
172
+ # Try to create client
173
+ client = Client(
174
+ api_url=config["api_url"],
175
+ api_key=config["api_key"],
176
+ timeout=config.get("timeout", 30.0),
177
+ )
178
+
179
+ # Test connection by listing resources
180
+ try:
181
+ with spinner_context(
182
+ None, # We'll pass ctx later
183
+ "[bold blue]Checking GL AIP status…[/bold blue]",
184
+ console_override=console,
185
+ spinner_style="cyan",
186
+ ) as status_indicator:
187
+ update_spinner(status_indicator, "[bold blue]Fetching agents…[/bold blue]")
188
+ agents = client.list_agents()
189
+
190
+ update_spinner(status_indicator, "[bold blue]Fetching tools…[/bold blue]")
191
+ tools = client.list_tools()
192
+
193
+ update_spinner(status_indicator, "[bold blue]Fetching MCPs…[/bold blue]")
194
+ mcps = client.list_mcps()
195
+
196
+ # Create status table
197
+ table = AIPTable(title="🔗 GL AIP Status")
198
+ table.add_column("Resource", style="cyan", width=15)
199
+ table.add_column("Count", style="green", width=10)
200
+ table.add_column("Status", style="green", width=15)
201
+
202
+ table.add_row("Agents", str(len(agents)), "✅ Available")
203
+ table.add_row("Tools", str(len(tools)), "✅ Available")
204
+ table.add_row("MCPs", str(len(mcps)), "✅ Available")
205
+
206
+ console.print(
207
+ AIPPanel(
208
+ f"[bold green]✅ Connected to GL AIP[/bold green]\n"
209
+ f"🔗 API URL: {client.api_url}\n"
210
+ f"🤖 Agent Run Timeout: {DEFAULT_AGENT_RUN_TIMEOUT}s",
211
+ title="🚀 Connection Status",
212
+ border_style="green",
213
+ )
214
+ )
215
+
216
+ console.print(table)
217
+
218
+ except Exception as e:
219
+ # Show AIP Ready status even if connection fails
220
+ console.print(
221
+ f"\n[bold green]✅ AIP - Ready[/bold green] (SDK v{_SDK_VERSION})"
222
+ )
223
+
224
+ console.print(
225
+ AIPPanel(
226
+ f"[bold yellow]⚠️ Connection established but API call failed[/bold yellow]\n"
227
+ f"🔗 API URL: {client.api_url}\n"
228
+ f"❌ Error: {e}\n\n"
229
+ f"💡 This usually means:\n"
230
+ f" • Network connectivity issues\n"
231
+ f" • API permissions problems\n"
232
+ f" • Backend service issues",
233
+ title="⚠️ Partial Connection",
234
+ border_style="yellow",
235
+ )
236
+ )
237
+
238
+ return client
239
+
240
+
241
+ def _handle_connection_error(config: dict, console: Console, error: Exception) -> None:
242
+ """Handle connection errors and show troubleshooting information."""
243
+ console.print(
244
+ AIPPanel(
245
+ f"[bold red]❌ Connection failed[/bold red]\n\n"
246
+ f"🔍 Error: {error}\n\n"
247
+ f"💡 Troubleshooting steps:\n"
248
+ f" • Verify your API URL and key are correct\n"
249
+ f" • Check network connectivity to {config.get('api_url', 'your API')}\n"
250
+ f" • Run 'aip configure' to update credentials\n"
251
+ f" • Run 'aip config list' to check configuration",
252
+ title="❌ Connection Error",
253
+ border_style="red",
254
+ )
255
+ )
256
+ sys.exit(1)
257
+
258
+
130
259
  @main.command()
131
260
  @click.pass_context
132
261
  def status(ctx: Any) -> None:
@@ -146,132 +275,20 @@ def status(ctx: Any) -> None:
146
275
  f"\n[bold green]✅ AIP - Ready[/bold green] (SDK v{_SDK_VERSION})"
147
276
  )
148
277
 
149
- # Load config from file and merge with context
150
- file_config = load_config()
151
- context_config = ctx.obj or {}
152
-
153
- # Load environment variables (middle priority)
154
-
155
- env_config = {}
156
- if os.getenv("AIP_API_URL"):
157
- env_config["api_url"] = os.getenv("AIP_API_URL")
158
- if os.getenv("AIP_API_KEY"):
159
- env_config["api_key"] = os.getenv("AIP_API_KEY")
160
-
161
- # Filter out None values from context config to avoid overriding other configs
162
- filtered_context = {k: v for k, v in context_config.items() if v is not None}
163
-
164
- # Merge configs: file (low) -> env (mid) -> CLI args (high)
165
- config = {**file_config, **env_config, **filtered_context}
166
-
167
- if not config.get("api_url") or not config.get("api_key"):
168
- console.print(
169
- AIPPanel(
170
- "[bold red]❌ Configuration incomplete[/bold red]\n\n"
171
- f"🔍 Current config:\n"
172
- f" • API URL: {config.get('api_url', 'Not set')}\n"
173
- f" • API Key: {'***' + config.get('api_key', '')[-4:] if config.get('api_key') else 'Not set'}\n\n"
174
- f"💡 To fix this:\n"
175
- f" • Run 'aip configure' to set up credentials\n"
176
- f" • Or run 'aip config list' to see current config",
177
- title="❌ Configuration Error",
178
- border_style="red",
179
- )
180
- )
181
- console.print(
182
- f"\n[bold green]✅ AIP - Ready[/bold green] (SDK v{_SDK_VERSION}) - Configure to connect"
183
- )
184
- sys.exit(1)
185
-
186
- # Try to create client
187
- client = Client(
188
- api_url=config["api_url"],
189
- api_key=config["api_key"],
190
- timeout=config.get("timeout", 30.0),
191
- )
192
-
193
- # Test connection by listing resources
194
- try:
195
- with spinner_context(
196
- ctx,
197
- "[bold blue]Checking GL AIP status…[/bold blue]",
198
- console_override=console,
199
- spinner_style="cyan",
200
- ) as status_indicator:
201
- update_spinner(
202
- status_indicator, "[bold blue]Fetching agents…[/bold blue]"
203
- )
204
- agents = client.list_agents()
278
+ # Load and merge configuration
279
+ config = _load_and_merge_config(ctx)
205
280
 
206
- update_spinner(
207
- status_indicator, "[bold blue]Fetching tools…[/bold blue]"
208
- )
209
- tools = client.list_tools()
210
-
211
- update_spinner(
212
- status_indicator, "[bold blue]Fetching MCPs…[/bold blue]"
213
- )
214
- mcps = client.list_mcps()
215
-
216
- # Create status table
217
- table = AIPTable(title="🔗 GL AIP Status")
218
- table.add_column("Resource", style="cyan", width=15)
219
- table.add_column("Count", style="green", width=10)
220
- table.add_column("Status", style="green", width=15)
221
-
222
- table.add_row("Agents", str(len(agents)), "✅ Available")
223
- table.add_row("Tools", str(len(tools)), "✅ Available")
224
- table.add_row("MCPs", str(len(mcps)), "✅ Available")
225
-
226
- console.print(
227
- AIPPanel(
228
- f"[bold green]✅ Connected to GL AIP[/bold green]\n"
229
- f"🔗 API URL: {client.api_url}\n"
230
- f"🤖 Agent Run Timeout: {DEFAULT_AGENT_RUN_TIMEOUT}s",
231
- title="🚀 Connection Status",
232
- border_style="green",
233
- )
234
- )
235
-
236
- console.print(table)
237
-
238
- except Exception as e:
239
- # Show AIP Ready status even if connection fails
240
- console.print(
241
- f"\n[bold green]✅ AIP - Ready[/bold green] (SDK v{_SDK_VERSION})"
242
- )
243
-
244
- console.print(
245
- AIPPanel(
246
- f"[bold yellow]⚠️ Connection established but API call failed[/bold yellow]\n"
247
- f"🔗 API URL: {client.api_url}\n"
248
- f"❌ Error: {e}\n\n"
249
- f"💡 This usually means:\n"
250
- f" • Network connectivity issues\n"
251
- f" • API permissions problems\n"
252
- f" • Backend service issues",
253
- title="⚠️ Partial Connection",
254
- border_style="yellow",
255
- )
256
- )
281
+ # Validate configuration
282
+ _validate_config_and_show_error(config, console)
257
283
 
284
+ # Create and test client connection
285
+ client = _create_and_test_client(config, console)
258
286
  client.close()
259
287
 
260
288
  except Exception as e:
261
- console.print(
262
- AIPPanel(
263
- f"[bold red]❌ Connection failed[/bold red]\n\n"
264
- f"🔍 Error: {e}\n\n"
265
- f"💡 Troubleshooting steps:\n"
266
- f" • Run 'aip config list' to check configuration\n"
267
- f" • Run 'aip configure' to update credentials\n"
268
- f" • Verify your API URL and key are correct\n"
269
- f" • Check network connectivity to {config.get('api_url', 'your API')}",
270
- title="❌ Connection Error",
271
- border_style="red",
272
- )
273
- )
274
- sys.exit(1)
289
+ # Handle any unexpected errors during the process
290
+ console = Console()
291
+ _handle_connection_error(config if "config" in locals() else {}, console, e)
275
292
 
276
293
 
277
294
  @main.command()
@@ -0,0 +1,148 @@
1
+ """Masking helpers for CLI output.
2
+
3
+ Authors:
4
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any
11
+
12
+ __all__ = [
13
+ "mask_payload",
14
+ "mask_rows",
15
+ "_mask_value",
16
+ "_mask_any",
17
+ "_maybe_mask_row",
18
+ "_resolve_mask_fields",
19
+ ]
20
+
21
+ _DEFAULT_MASK_FIELDS = {
22
+ "api_key",
23
+ "apikey",
24
+ "token",
25
+ "access_token",
26
+ "secret",
27
+ "client_secret",
28
+ "password",
29
+ "private_key",
30
+ "bearer",
31
+ }
32
+
33
+
34
+ def _mask_value(raw: Any) -> str:
35
+ """Return a masked representation of the provided value.
36
+
37
+ Args:
38
+ raw: The raw value to mask, converted to string.
39
+
40
+ Returns:
41
+ str: A masked representation showing first 4 and last 4 characters
42
+ separated by dots, or "••••" for strings ≤ 8 characters.
43
+ """
44
+ text = str(raw)
45
+ if len(text) <= 8:
46
+ return "••••"
47
+ return f"{text[:4]}••••••••{text[-4:]}"
48
+
49
+
50
+ def _mask_any(value: Any, mask_fields: set[str]) -> Any:
51
+ """Recursively mask sensitive fields in mappings and iterables.
52
+
53
+ Args:
54
+ value: The value to process - can be dict, list, or any other type.
55
+ mask_fields: Set of field names (lowercase) that should be masked.
56
+
57
+ Returns:
58
+ Any: The processed value with sensitive fields masked. Dicts and lists
59
+ are processed recursively, other values are returned unchanged.
60
+ """
61
+ if isinstance(value, dict):
62
+ masked: dict[Any, Any] = {}
63
+ for key, raw in value.items():
64
+ if isinstance(key, str) and key.lower() in mask_fields and raw is not None:
65
+ masked[key] = _mask_value(raw)
66
+ else:
67
+ masked[key] = _mask_any(raw, mask_fields)
68
+ return masked
69
+
70
+ if isinstance(value, list):
71
+ return [_mask_any(item, mask_fields) for item in value]
72
+
73
+ return value
74
+
75
+
76
+ def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any]:
77
+ """Mask a single row when masking is enabled.
78
+
79
+ Args:
80
+ row: A dictionary representing a single row of data.
81
+ mask_fields: Set of field names to mask. If empty, returns row unchanged.
82
+
83
+ Returns:
84
+ dict[str, Any]: The row with sensitive fields masked, or the original
85
+ row if no mask_fields are provided.
86
+ """
87
+ if not mask_fields:
88
+ return row
89
+ return _mask_any(row, mask_fields)
90
+
91
+
92
+ def _resolve_mask_fields() -> set[str]:
93
+ """Resolve the set of sensitive fields to mask based on environment.
94
+
95
+ Returns:
96
+ set[str]: Set of field names to mask. Empty set if masking is disabled
97
+ via AIP_MASK_OFF environment variable, custom fields from
98
+ AIP_MASK_FIELDS, or default fields if neither is set.
99
+ """
100
+ if os.getenv("AIP_MASK_OFF", "0") in {"1", "true", "on", "yes"}:
101
+ return set()
102
+
103
+ env_fields = (os.getenv("AIP_MASK_FIELDS") or "").strip()
104
+ if env_fields:
105
+ parts = [part.strip().lower() for part in env_fields.split(",") if part.strip()]
106
+ return set(parts)
107
+
108
+ return set(_DEFAULT_MASK_FIELDS)
109
+
110
+
111
+ def mask_payload(payload: Any) -> Any:
112
+ """Mask sensitive values in an arbitrary payload when masking is enabled.
113
+
114
+ Args:
115
+ payload: Any data structure (dict, list, or primitive) to mask.
116
+
117
+ Returns:
118
+ Any: The payload with sensitive fields masked based on environment
119
+ configuration. Returns original payload if masking is disabled
120
+ or if an error occurs during masking.
121
+ """
122
+ mask_fields = _resolve_mask_fields()
123
+ if not mask_fields:
124
+ return payload
125
+ try:
126
+ return _mask_any(payload, mask_fields)
127
+ except Exception:
128
+ return payload
129
+
130
+
131
+ def mask_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
132
+ """Mask sensitive values in row-oriented data when masking is enabled.
133
+
134
+ Args:
135
+ rows: List of dictionaries representing rows of tabular data.
136
+
137
+ Returns:
138
+ list[dict[str, Any]]: List of rows with sensitive fields masked based
139
+ on environment configuration. Returns original
140
+ rows if masking is disabled or if an error occurs.
141
+ """
142
+ mask_fields = _resolve_mask_fields()
143
+ if not mask_fields:
144
+ return rows
145
+ try:
146
+ return [_maybe_mask_row(row, mask_fields) for row in rows]
147
+ except Exception:
148
+ return rows
@@ -15,7 +15,7 @@ import click
15
15
 
16
16
 
17
17
  def format_validation_error(prefix: str, detail: str | None = None) -> str:
18
- """Format a validation error message with optional detail.
18
+ r"""Format a validation error message with optional detail.
19
19
 
20
20
  Args:
21
21
  prefix: Main error message
@@ -26,7 +26,7 @@ def format_validation_error(prefix: str, detail: str | None = None) -> str:
26
26
 
27
27
  Examples:
28
28
  >>> format_validation_error("Invalid config", "Missing 'url' field")
29
- "Invalid config\\nMissing 'url' field"
29
+ "Invalid config\nMissing 'url' field"
30
30
  """
31
31
  parts = [prefix]
32
32
  if detail: