glaip-sdk 0.1.4__py3-none-any.whl → 0.2.0__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 (36) hide show
  1. glaip_sdk/__init__.py +1 -1
  2. glaip_sdk/_version.py +1 -0
  3. glaip_sdk/cli/commands/__init__.py +2 -2
  4. glaip_sdk/cli/commands/agents.py +22 -37
  5. glaip_sdk/cli/commands/configure.py +67 -6
  6. glaip_sdk/cli/commands/mcps.py +19 -23
  7. glaip_sdk/cli/commands/tools.py +17 -41
  8. glaip_sdk/cli/commands/transcripts.py +747 -0
  9. glaip_sdk/cli/config.py +6 -1
  10. glaip_sdk/cli/display.py +1 -0
  11. glaip_sdk/cli/main.py +12 -31
  12. glaip_sdk/cli/parsers/__init__.py +1 -3
  13. glaip_sdk/cli/slash/__init__.py +0 -9
  14. glaip_sdk/cli/slash/prompt.py +2 -0
  15. glaip_sdk/cli/slash/session.py +251 -88
  16. glaip_sdk/cli/transcript/__init__.py +12 -52
  17. glaip_sdk/cli/transcript/cache.py +255 -44
  18. glaip_sdk/cli/transcript/capture.py +32 -0
  19. glaip_sdk/cli/transcript/history.py +815 -0
  20. glaip_sdk/cli/transcript/viewer.py +6 -2
  21. glaip_sdk/cli/utils.py +170 -0
  22. glaip_sdk/client/_agent_payloads.py +5 -0
  23. glaip_sdk/payload_schemas/__init__.py +1 -13
  24. glaip_sdk/utils/__init__.py +12 -7
  25. glaip_sdk/utils/datetime_helpers.py +58 -0
  26. glaip_sdk/utils/general.py +0 -33
  27. glaip_sdk/utils/import_export.py +9 -1
  28. glaip_sdk/utils/rendering/renderer/__init__.py +0 -20
  29. glaip_sdk/utils/rendering/renderer/debug.py +3 -20
  30. glaip_sdk/utils/rendering/steps.py +5 -6
  31. glaip_sdk/utils/resource_refs.py +2 -1
  32. glaip_sdk/utils/serialization.py +2 -0
  33. {glaip_sdk-0.1.4.dist-info → glaip_sdk-0.2.0.dist-info}/METADATA +1 -1
  34. {glaip_sdk-0.1.4.dist-info → glaip_sdk-0.2.0.dist-info}/RECORD +36 -33
  35. {glaip_sdk-0.1.4.dist-info → glaip_sdk-0.2.0.dist-info}/WHEEL +0 -0
  36. {glaip_sdk-0.1.4.dist-info → glaip_sdk-0.2.0.dist-info}/entry_points.txt +0 -0
@@ -63,12 +63,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
63
63
  console: Console,
64
64
  ctx: ViewerContext,
65
65
  export_callback: Callable[[Path], Path],
66
+ *,
67
+ initial_view: str = "default",
66
68
  ) -> None:
67
69
  """Initialize viewer state for a captured transcript."""
68
70
  self.console = console
69
71
  self.ctx = ctx
70
72
  self._export_callback = export_callback
71
- self._view_mode = "default"
73
+ self._view_mode = initial_view if initial_view in {"default", "transcript"} else "default"
72
74
 
73
75
  def run(self) -> None:
74
76
  """Enter the interactive loop."""
@@ -795,7 +797,9 @@ def run_viewer_session(
795
797
  console: Console,
796
798
  ctx: ViewerContext,
797
799
  export_callback: Callable[[Path], Path],
800
+ *,
801
+ initial_view: str = "default",
798
802
  ) -> None:
799
803
  """Entry point for creating and running the post-run viewer."""
800
- viewer = PostRunViewer(console, ctx, export_callback)
804
+ viewer = PostRunViewer(console, ctx, export_callback, initial_view=initial_view)
801
805
  viewer.run()
glaip_sdk/cli/utils.py CHANGED
@@ -14,6 +14,7 @@ import os
14
14
  import sys
15
15
  from collections.abc import Callable, Iterable
16
16
  from contextlib import AbstractContextManager, nullcontext
17
+ from pathlib import Path
17
18
  from typing import TYPE_CHECKING, Any, cast
18
19
 
19
20
  import click
@@ -87,6 +88,166 @@ def detect_export_format(file_path: str | os.PathLike[str]) -> str:
87
88
  return _detect_export_format(file_path)
88
89
 
89
90
 
91
+ def format_size(num: int | None) -> str:
92
+ """Format byte counts using short human-friendly units.
93
+
94
+ Args:
95
+ num: Number of bytes to format (can be None or 0)
96
+
97
+ Returns:
98
+ Human-readable size string (e.g., "1.5KB", "2MB")
99
+ """
100
+ if not num or num <= 0:
101
+ return "0B"
102
+
103
+ units = ["B", "KB", "MB", "GB", "TB"]
104
+ value = float(num)
105
+ for unit in units:
106
+ if value < 1024 or unit == units[-1]:
107
+ if unit == "B" or value >= 100:
108
+ return f"{value:.0f}{unit}"
109
+ if value >= 10:
110
+ return f"{value:.1f}{unit}"
111
+ return f"{value:.2f}{unit}"
112
+ value /= 1024
113
+ return f"{value:.1f}TB" # pragma: no cover - defensive fallback
114
+
115
+
116
+ def parse_json_line(line: str) -> dict[str, Any] | None:
117
+ """Parse a JSON line into a dictionary payload.
118
+
119
+ Args:
120
+ line: JSON line string to parse
121
+
122
+ Returns:
123
+ Parsed dictionary or None if parsing fails or result is not a dict
124
+ """
125
+ line = line.strip()
126
+ if not line:
127
+ return None
128
+ try:
129
+ payload = json.loads(line)
130
+ except json.JSONDecodeError:
131
+ return None
132
+ return payload if isinstance(payload, dict) else None
133
+
134
+
135
+ def format_datetime_fields(
136
+ data: dict[str, Any], fields: tuple[str, ...] = ("created_at", "updated_at")
137
+ ) -> dict[str, Any]:
138
+ """Format datetime fields in a data dictionary for display.
139
+
140
+ Args:
141
+ data: Dictionary containing the data to format
142
+ fields: Tuple of field names to format (default: created_at, updated_at)
143
+
144
+ Returns:
145
+ New dictionary with formatted datetime fields
146
+ """
147
+ from glaip_sdk.utils import format_datetime
148
+
149
+ formatted = data.copy()
150
+ for field in fields:
151
+ if field in formatted:
152
+ formatted[field] = format_datetime(formatted[field])
153
+ return formatted
154
+
155
+
156
+ def fetch_resource_for_export(
157
+ ctx: Any,
158
+ resource: Any,
159
+ resource_type: str,
160
+ get_by_id_func: Callable[[str], Any],
161
+ console_override: Console | None = None,
162
+ ) -> Any:
163
+ """Fetch full resource details for export, handling errors gracefully.
164
+
165
+ Args:
166
+ ctx: Click context for spinner management
167
+ resource: Resource object to fetch details for
168
+ resource_type: Type of resource (e.g., "MCP", "Agent", "Tool")
169
+ get_by_id_func: Function to fetch resource by ID
170
+ console_override: Optional console override
171
+
172
+ Returns:
173
+ Resource object with full details, or original resource if fetch fails
174
+ """
175
+ active_console = console_override or console
176
+ resource_id = str(getattr(resource, "id", "")).strip()
177
+
178
+ if not resource_id:
179
+ return resource
180
+
181
+ try:
182
+ with spinner_context(
183
+ ctx,
184
+ f"[bold blue]Fetching {resource_type} details…[/bold blue]",
185
+ console_override=active_console,
186
+ ):
187
+ return get_by_id_func(resource_id)
188
+ except Exception:
189
+ # Return original resource if fetch fails
190
+ return resource
191
+
192
+
193
+ def handle_resource_export(
194
+ ctx: Any,
195
+ resource: Any,
196
+ export_path: Path,
197
+ resource_type: str,
198
+ get_by_id_func: Callable[[str], Any],
199
+ console_override: Console | None = None,
200
+ ) -> None:
201
+ """Handle resource export to file with format detection and error handling.
202
+
203
+ Args:
204
+ ctx: Click context for spinner management
205
+ resource: Resource object to export
206
+ export_path: Target file path (format detected from extension)
207
+ resource_type: Type of resource (e.g., "agent", "tool")
208
+ get_by_id_func: Function to fetch resource by ID
209
+ console_override: Optional console override
210
+ """
211
+ from glaip_sdk.cli.display import handle_rich_output
212
+ from glaip_sdk.cli.io import export_resource_to_file_with_validation
213
+ from glaip_sdk.cli.rich_helpers import print_markup
214
+
215
+ active_console = console_override or console
216
+
217
+ # Auto-detect format from file extension
218
+ detected_format = detect_export_format(export_path)
219
+
220
+ # Try to fetch full details for export
221
+ full_resource = fetch_resource_for_export(
222
+ ctx,
223
+ resource,
224
+ resource_type.capitalize(),
225
+ get_by_id_func,
226
+ console_override=active_console,
227
+ )
228
+
229
+ # Export the resource
230
+ try:
231
+ with spinner_context(
232
+ ctx,
233
+ f"[bold blue]Exporting {resource_type}…[/bold blue]",
234
+ console_override=active_console,
235
+ ):
236
+ export_resource_to_file_with_validation(full_resource, export_path, detected_format)
237
+ except Exception:
238
+ handle_rich_output(
239
+ ctx,
240
+ markup_text(f"[{WARNING_STYLE}]⚠️ Failed to fetch full details, using available data[/]"),
241
+ )
242
+ # Fallback: export with available data
243
+ export_resource_to_file_with_validation(resource, export_path, detected_format)
244
+
245
+ print_markup(
246
+ f"[{SUCCESS_STYLE}]✅ {resource_type.capitalize()} exported to: {export_path} (format: {detected_format})[/]",
247
+ console=active_console,
248
+ )
249
+
250
+
90
251
  def in_slash_mode(ctx: click.Context | None = None) -> bool:
91
252
  """Return True when running inside the slash command palette."""
92
253
  if ctx is None:
@@ -612,6 +773,7 @@ def _fuzzy_score(search: str, target: str) -> int:
612
773
 
613
774
 
614
775
  def _coerce_result_payload(result: Any) -> Any:
776
+ """Convert renderer outputs into plain dict/list structures when possible."""
615
777
  try:
616
778
  to_dict = getattr(result, "to_dict", None)
617
779
  if callable(to_dict):
@@ -622,6 +784,7 @@ def _coerce_result_payload(result: Any) -> Any:
622
784
 
623
785
 
624
786
  def _ensure_displayable(payload: Any) -> Any:
787
+ """Best-effort coercion into JSON/str-safe payloads for console rendering."""
625
788
  if isinstance(payload, (dict, list, str, int, float, bool)) or payload is None:
626
789
  return payload
627
790
 
@@ -641,6 +804,7 @@ def _ensure_displayable(payload: Any) -> Any:
641
804
 
642
805
 
643
806
  def _render_markdown_output(data: Any) -> None:
807
+ """Render markdown output using Rich when available."""
644
808
  try:
645
809
  console.print(Markdown(str(data)))
646
810
  except ImportError:
@@ -694,6 +858,7 @@ def output_result(
694
858
 
695
859
 
696
860
  def _normalise_rows(items: list[Any], transform_func: Callable[[Any], dict[str, Any]] | None) -> list[dict[str, Any]]:
861
+ """Convert heterogeneous item lists into table rows."""
697
862
  try:
698
863
  rows: list[dict[str, Any]] = []
699
864
  for item in items:
@@ -713,6 +878,7 @@ def _normalise_rows(items: list[Any], transform_func: Callable[[Any], dict[str,
713
878
 
714
879
 
715
880
  def _render_plain_list(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
881
+ """Display tabular data as a simple pipe-delimited list."""
716
882
  if not rows:
717
883
  click.echo(f"No {title.lower()} found.")
718
884
  return
@@ -722,6 +888,7 @@ def _render_plain_list(rows: list[dict[str, Any]], title: str, columns: list[tup
722
888
 
723
889
 
724
890
  def _render_markdown_list(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
891
+ """Display tabular data using markdown table syntax."""
725
892
  if not rows:
726
893
  click.echo(f"No {title.lower()} found.")
727
894
  return
@@ -734,6 +901,7 @@ def _render_markdown_list(rows: list[dict[str, Any]], title: str, columns: list[
734
901
 
735
902
 
736
903
  def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
904
+ """Return True when rows should be name-sorted prior to rendering."""
737
905
  return (
738
906
  os.getenv("AIP_TABLE_NO_SORT", "0") not in ("1", "true", "on")
739
907
  and rows
@@ -743,6 +911,7 @@ def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
743
911
 
744
912
 
745
913
  def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -> Any:
914
+ """Build a configured Rich table for the provided columns."""
746
915
  table = AIPTable(title=title, expand=True)
747
916
  for _key, header, style, width in columns:
748
917
  table.add_column(header, style=style, width=width)
@@ -750,6 +919,7 @@ def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -
750
919
 
751
920
 
752
921
  def _build_table_group(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> Group:
922
+ """Return a Rich group containing the table and a small footer summary."""
753
923
  table = _create_table(columns, title)
754
924
  for row in rows:
755
925
  table.add_row(*[str(row.get(key, "N/A")) for key, _, _, _ in columns])
@@ -406,6 +406,7 @@ __all__ = [
406
406
 
407
407
 
408
408
  def _build_base_update_payload(request: AgentUpdateRequest, current_agent: Any) -> dict[str, Any]:
409
+ """Populate immutable agent update fields using request data or existing agent defaults."""
409
410
  return {
410
411
  "name": request.name.strip() if request.name is not None else getattr(current_agent, "name", None),
411
412
  "instruction": request.instruction.strip()
@@ -418,6 +419,7 @@ def _build_base_update_payload(request: AgentUpdateRequest, current_agent: Any)
418
419
 
419
420
 
420
421
  def _resolve_update_language_model_fields(request: AgentUpdateRequest, current_agent: Any) -> dict[str, Any]:
422
+ """Resolve the language-model portion of an update request with sensible fallbacks."""
421
423
  fields = resolve_language_model_fields(
422
424
  model=request.model,
423
425
  language_model_id=request.language_model_id,
@@ -430,6 +432,7 @@ def _resolve_update_language_model_fields(request: AgentUpdateRequest, current_a
430
432
 
431
433
 
432
434
  def _collect_optional_update_fields(request: AgentUpdateRequest, current_agent: Any) -> dict[str, Any]:
435
+ """Collect optional agent fields, preserving current values when updates are absent."""
433
436
  result: dict[str, Any] = {}
434
437
 
435
438
  for field_name, value in (
@@ -468,6 +471,7 @@ def _collect_optional_update_fields(request: AgentUpdateRequest, current_agent:
468
471
 
469
472
 
470
473
  def _collect_relationship_fields(request: AgentUpdateRequest, current_agent: Any) -> dict[str, Any]:
474
+ """Return relationship identifiers (tools/agents/mcps) for an update request."""
471
475
  return {
472
476
  "tools": _resolve_relation_ids(request.tools, current_agent, "tools"),
473
477
  "agents": _resolve_relation_ids(request.agents, current_agent, "agents"),
@@ -476,6 +480,7 @@ def _collect_relationship_fields(request: AgentUpdateRequest, current_agent: Any
476
480
 
477
481
 
478
482
  def _resolve_agent_config_update(request: AgentUpdateRequest, current_agent: Any) -> dict[str, Any] | None:
483
+ """Determine the agent_config payload to send, if any."""
479
484
  effective_agent_config = _sanitize_agent_config(request.agent_config)
480
485
  if effective_agent_config is not None:
481
486
  return effective_agent_config
@@ -4,16 +4,4 @@ Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
5
  """
6
6
 
7
- from glaip_sdk.payload_schemas.agent import (
8
- AgentImportOperation,
9
- ImportFieldPlan,
10
- get_import_field_plan,
11
- list_server_only_fields,
12
- )
13
-
14
- __all__ = [
15
- "AgentImportOperation",
16
- "ImportFieldPlan",
17
- "get_import_field_plan",
18
- "list_server_only_fields",
19
- ]
7
+ __all__: list[str] = []
@@ -4,6 +4,10 @@ Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
5
  """
6
6
 
7
+ from glaip_sdk.utils.datetime_helpers import (
8
+ coerce_datetime,
9
+ from_numeric_timestamp,
10
+ )
7
11
  from glaip_sdk.utils.display import (
8
12
  RICH_AVAILABLE,
9
13
  print_agent_created,
@@ -14,20 +18,15 @@ from glaip_sdk.utils.display import (
14
18
  from glaip_sdk.utils.general import (
15
19
  format_datetime,
16
20
  format_file_size,
17
- is_uuid,
18
21
  progress_bar,
19
- sanitize_name,
20
22
  )
21
23
  from glaip_sdk.utils.rendering.models import RunStats, Step
24
+ from glaip_sdk.utils.rendering.renderer.base import RichStreamRenderer
22
25
  from glaip_sdk.utils.rendering.steps import StepManager
23
- from glaip_sdk.utils.run_renderer import RichStreamRenderer
26
+ from glaip_sdk.utils.resource_refs import is_uuid, sanitize_name
24
27
 
25
28
  __all__ = [
26
29
  "RICH_AVAILABLE",
27
- "RichStreamRenderer",
28
- "RunStats",
29
- "Step",
30
- "StepManager",
31
30
  "format_datetime",
32
31
  "format_file_size",
33
32
  "is_uuid",
@@ -37,4 +36,10 @@ __all__ = [
37
36
  "print_agent_updated",
38
37
  "progress_bar",
39
38
  "sanitize_name",
39
+ "RichStreamRenderer",
40
+ "RunStats",
41
+ "Step",
42
+ "StepManager",
43
+ "coerce_datetime",
44
+ "from_numeric_timestamp",
40
45
  ]
@@ -0,0 +1,58 @@
1
+ """Shared datetime parsing helpers used across CLI and rendering modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ __all__ = ["coerce_datetime", "from_numeric_timestamp"]
9
+
10
+ _Z_SUFFIX = "+00:00"
11
+
12
+
13
+ def from_numeric_timestamp(raw_value: Any) -> datetime | None:
14
+ """Convert unix timestamp-like values to datetime with sanity checks."""
15
+ try:
16
+ candidate = float(raw_value)
17
+ except Exception:
18
+ return None
19
+
20
+ if candidate < 1_000_000_000:
21
+ return None
22
+
23
+ try:
24
+ return datetime.fromtimestamp(candidate, tz=timezone.utc)
25
+ except Exception:
26
+ return None
27
+
28
+
29
+ def _parse_iso(value: str | None) -> datetime | None:
30
+ """Parse ISO8601 strings while tolerating legacy 'Z' suffixes."""
31
+ if not value:
32
+ return None
33
+ try:
34
+ return datetime.fromisoformat(value.replace("Z", _Z_SUFFIX))
35
+ except Exception:
36
+ return None
37
+
38
+
39
+ def coerce_datetime(value: Any) -> datetime | None:
40
+ """Best-effort conversion of assorted timestamp inputs to aware UTC datetimes."""
41
+ if value is None:
42
+ return None
43
+
44
+ if isinstance(value, datetime):
45
+ dt = value
46
+ elif isinstance(value, (int, float)):
47
+ dt = from_numeric_timestamp(value)
48
+ elif isinstance(value, str):
49
+ dt = _parse_iso(value) or from_numeric_timestamp(value)
50
+ else:
51
+ return None
52
+
53
+ if dt is None:
54
+ return None
55
+
56
+ if dt.tzinfo is None:
57
+ dt = dt.replace(tzinfo=timezone.utc)
58
+ return dt.astimezone(timezone.utc)
@@ -4,46 +4,13 @@ Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
5
  """
6
6
 
7
- import re
8
7
  from collections.abc import Iterable, Iterator
9
8
  from datetime import datetime
10
9
  from typing import Any
11
- from uuid import UUID
12
10
 
13
11
  import click
14
12
 
15
13
 
16
- def is_uuid(value: str) -> bool:
17
- """Check if a string is a valid UUID.
18
-
19
- Args:
20
- value: String to check
21
-
22
- Returns:
23
- True if value is a valid UUID, False otherwise
24
- """
25
- try:
26
- UUID(value)
27
- return True
28
- except (ValueError, TypeError):
29
- return False
30
-
31
-
32
- def sanitize_name(name: str) -> str:
33
- """Sanitize a name for resource creation.
34
-
35
- Args:
36
- name: Raw name input
37
-
38
- Returns:
39
- Sanitized name suitable for resource creation
40
- """
41
- # Remove special characters and normalize
42
- sanitized = re.sub(r"[^a-zA-Z0-9\-_]", "-", name.strip())
43
- sanitized = re.sub(r"-+", "-", sanitized) # Collapse multiple dashes
44
- return sanitized.lower().strip("-")
45
-
46
-
47
14
  def format_file_size(size_bytes: int) -> str:
48
15
  """Format file size in human readable format.
49
16
 
@@ -10,9 +10,16 @@ Authors:
10
10
  from typing import Any
11
11
 
12
12
 
13
- def extract_ids_from_export(items: list[Any]) -> list[str]:
13
+ def extract_ids_from_export(items: list[Any]) -> list[str]: # pylint: disable=duplicate-code
14
14
  """Extract IDs from export format (list of dicts with id/name fields).
15
15
 
16
+ This function is similar to `extract_ids` in `resource_refs.py` but differs in behavior:
17
+ - This function SKIPS items without IDs (doesn't convert to string)
18
+ - `extract_ids` converts items without IDs to strings as fallback
19
+
20
+ This difference is intentional: export format should only include actual IDs,
21
+ while general resource reference extraction may need fallback string conversion.
22
+
16
23
  Args:
17
24
  items: List of items (dicts with id/name or strings)
18
25
 
@@ -36,6 +43,7 @@ def extract_ids_from_export(items: list[Any]) -> list[str]:
36
43
  elif isinstance(item, dict) and "id" in item:
37
44
  ids.append(str(item["id"]))
38
45
  # Skip items without ID (don't convert to string)
46
+ # Note: This differs from extract_ids() in resource_refs.py which converts all items to strings
39
47
 
40
48
  return ids
41
49
 
@@ -13,18 +13,6 @@ from glaip_sdk.rich_components import AIPPanel
13
13
  from glaip_sdk.utils.rendering.renderer.base import RichStreamRenderer
14
14
  from glaip_sdk.utils.rendering.renderer.config import RendererConfig
15
15
  from glaip_sdk.utils.rendering.renderer.console import CapturingConsole
16
- from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
17
- from glaip_sdk.utils.rendering.renderer.panels import (
18
- create_context_panel,
19
- create_final_panel,
20
- create_main_panel,
21
- create_tool_panel,
22
- )
23
- from glaip_sdk.utils.rendering.renderer.progress import (
24
- format_tool_title,
25
- is_delegation_tool,
26
- )
27
- from glaip_sdk.utils.rendering.renderer.stream import StreamProcessor
28
16
 
29
17
 
30
18
  def make_silent_renderer() -> RichStreamRenderer:
@@ -79,15 +67,7 @@ __all__ = [
79
67
  "RichStreamRenderer",
80
68
  "RendererConfig",
81
69
  "CapturingConsole",
82
- "StreamProcessor",
83
70
  "make_silent_renderer",
84
71
  "make_minimal_renderer",
85
72
  "print_panel",
86
- "render_debug_event",
87
- "create_main_panel",
88
- "create_tool_panel",
89
- "create_context_panel",
90
- "create_final_panel",
91
- "format_tool_title",
92
- "is_delegation_tool",
93
73
  ]
@@ -4,6 +4,7 @@ Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
5
  """
6
6
 
7
+ # pylint: disable=duplicate-code
7
8
  import json
8
9
  from datetime import datetime, timezone
9
10
  from typing import Any
@@ -13,25 +14,7 @@ from rich.markdown import Markdown
13
14
 
14
15
  from glaip_sdk.branding import PRIMARY, SUCCESS, WARNING
15
16
  from glaip_sdk.rich_components import AIPPanel
16
-
17
-
18
- def _coerce_datetime(value: Any) -> datetime | None:
19
- """Attempt to coerce an arbitrary value to an aware datetime."""
20
- if value is None:
21
- return None
22
-
23
- if isinstance(value, datetime):
24
- return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
25
-
26
- if isinstance(value, str):
27
- try:
28
- normalised = value.replace("Z", "+00:00")
29
- dt = datetime.fromisoformat(normalised)
30
- except ValueError:
31
- return None
32
- return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
33
-
34
- return None
17
+ from glaip_sdk.utils.datetime_helpers import coerce_datetime
35
18
 
36
19
 
37
20
  def _parse_event_timestamp(event: dict[str, Any], received_ts: datetime | None = None) -> datetime | None:
@@ -40,7 +23,7 @@ def _parse_event_timestamp(event: dict[str, Any], received_ts: datetime | None =
40
23
  return received_ts if received_ts.tzinfo else received_ts.replace(tzinfo=timezone.utc)
41
24
 
42
25
  ts_value = event.get("timestamp") or (event.get("metadata") or {}).get("timestamp")
43
- return _coerce_datetime(ts_value)
26
+ return coerce_datetime(ts_value)
44
27
 
45
28
 
46
29
  def _format_timestamp_for_display(dt: datetime) -> str:
@@ -996,12 +996,11 @@ class StepManager:
996
996
 
997
997
  @staticmethod
998
998
  def _coerce_server_time(value: Any) -> float | None:
999
- if isinstance(value, (int, float)):
1000
- return float(value)
1001
- try:
1002
- return float(value)
1003
- except (TypeError, ValueError):
1004
- return None
999
+ """Convert a raw SSE time payload into a float if possible."""
1000
+ # Reuse the implementation from base renderer
1001
+ from glaip_sdk.utils.rendering.renderer.base import RichStreamRenderer
1002
+
1003
+ return RichStreamRenderer._coerce_server_time(value)
1005
1004
 
1006
1005
  def _update_server_timestamps(self, step: Step, server_time: float | None, status: str) -> None:
1007
1006
  if server_time is None:
@@ -8,6 +8,7 @@ Authors:
8
8
  Raymond Christopher (raymond.christopher@gdplabs.id)
9
9
  """
10
10
 
11
+ # pylint: disable=duplicate-code
11
12
  import re
12
13
  from typing import Any
13
14
  from uuid import UUID
@@ -29,7 +30,7 @@ def is_uuid(value: str) -> bool:
29
30
  return False
30
31
 
31
32
 
32
- def extract_ids(items: list[str | Any] | None) -> list[str]:
33
+ def extract_ids(items: list[str | Any] | None) -> list[str]: # pylint: disable=duplicate-code
33
34
  """Extract IDs from a list of objects or strings.
34
35
 
35
36
  This function unifies the behavior between CLI and SDK layers, always
@@ -287,6 +287,7 @@ def _collect_from_dir(resource: Any, collect_func: Callable[[Iterable[str]], Non
287
287
 
288
288
 
289
289
  def _safe_getattr(resource: Any, name: str) -> Any:
290
+ """Return getattr(resource, name) but swallow any exception and return None."""
290
291
  try:
291
292
  return getattr(resource, name)
292
293
  except Exception:
@@ -294,6 +295,7 @@ def _safe_getattr(resource: Any, name: str) -> Any:
294
295
 
295
296
 
296
297
  def _should_include_attribute(key: str, value: Any) -> bool:
298
+ """Return True when an attribute should be serialized."""
297
299
  if key in _EXCLUDED_ATTRS or key in _EXCLUDED_NAMES:
298
300
  return False
299
301
  if key.startswith("_"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: glaip-sdk
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Python SDK for GL AIP (GDP Labs AI Agent Package) - Simplified CLI Design
5
5
  License: MIT
6
6
  Author: Raymond Christopher