glaip-sdk 0.0.4__py3-none-any.whl → 0.0.5b1__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/__init__.py +5 -5
- glaip_sdk/branding.py +18 -17
- glaip_sdk/cli/__init__.py +1 -1
- glaip_sdk/cli/agent_config.py +82 -0
- glaip_sdk/cli/commands/__init__.py +3 -3
- glaip_sdk/cli/commands/agents.py +570 -673
- glaip_sdk/cli/commands/configure.py +2 -2
- glaip_sdk/cli/commands/mcps.py +148 -143
- glaip_sdk/cli/commands/models.py +1 -1
- glaip_sdk/cli/commands/tools.py +250 -179
- glaip_sdk/cli/display.py +244 -0
- glaip_sdk/cli/io.py +106 -0
- glaip_sdk/cli/main.py +14 -18
- glaip_sdk/cli/resolution.py +59 -0
- glaip_sdk/cli/utils.py +305 -264
- glaip_sdk/cli/validators.py +235 -0
- glaip_sdk/client/__init__.py +3 -224
- glaip_sdk/client/agents.py +631 -191
- glaip_sdk/client/base.py +66 -4
- glaip_sdk/client/main.py +226 -0
- glaip_sdk/client/mcps.py +143 -18
- glaip_sdk/client/tools.py +146 -11
- glaip_sdk/config/constants.py +10 -1
- glaip_sdk/models.py +42 -2
- glaip_sdk/rich_components.py +29 -0
- glaip_sdk/utils/__init__.py +18 -171
- glaip_sdk/utils/agent_config.py +181 -0
- glaip_sdk/utils/client_utils.py +159 -79
- glaip_sdk/utils/display.py +100 -0
- glaip_sdk/utils/general.py +94 -0
- glaip_sdk/utils/import_export.py +140 -0
- glaip_sdk/utils/rendering/formatting.py +6 -1
- glaip_sdk/utils/rendering/renderer/__init__.py +67 -8
- glaip_sdk/utils/rendering/renderer/base.py +340 -247
- glaip_sdk/utils/rendering/renderer/debug.py +3 -2
- glaip_sdk/utils/rendering/renderer/panels.py +11 -10
- glaip_sdk/utils/rendering/steps.py +1 -1
- glaip_sdk/utils/resource_refs.py +192 -0
- glaip_sdk/utils/rich_utils.py +29 -0
- glaip_sdk/utils/serialization.py +285 -0
- glaip_sdk/utils/validation.py +273 -0
- {glaip_sdk-0.0.4.dist-info → glaip_sdk-0.0.5b1.dist-info}/METADATA +22 -21
- glaip_sdk-0.0.5b1.dist-info/RECORD +55 -0
- {glaip_sdk-0.0.4.dist-info → glaip_sdk-0.0.5b1.dist-info}/WHEEL +1 -1
- glaip_sdk-0.0.5b1.dist-info/entry_points.txt +3 -0
- glaip_sdk/cli/commands/init.py +0 -93
- glaip_sdk-0.0.4.dist-info/RECORD +0 -41
- glaip_sdk-0.0.4.dist-info/entry_points.txt +0 -2
|
@@ -11,7 +11,8 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
from rich.console import Console
|
|
13
13
|
from rich.markdown import Markdown
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
from glaip_sdk.rich_components import AIPPanel
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def render_debug_event(
|
|
@@ -76,7 +77,7 @@ def render_debug_event(
|
|
|
76
77
|
|
|
77
78
|
# Render using Markdown with JSON code block (consistent with tool panels)
|
|
78
79
|
md = Markdown(f"```json\n{event_json}\n```", code_theme="monokai")
|
|
79
|
-
console.print(
|
|
80
|
+
console.print(AIPPanel(md, title=title, border_style=border))
|
|
80
81
|
except Exception as e:
|
|
81
82
|
# Debug helpers must not break streaming
|
|
82
83
|
print(f"Debug error: {e}") # Fallback debug output
|
|
@@ -7,11 +7,12 @@ Authors:
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from rich.markdown import Markdown
|
|
10
|
-
from rich.panel import Panel
|
|
11
10
|
from rich.text import Text
|
|
12
11
|
|
|
12
|
+
from glaip_sdk.rich_components import AIPPanel
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
def create_main_panel(content: str, title: str, theme: str = "dark") -> AIPPanel:
|
|
15
16
|
"""Create a main content panel.
|
|
16
17
|
|
|
17
18
|
Args:
|
|
@@ -23,7 +24,7 @@ def create_main_panel(content: str, title: str, theme: str = "dark") -> Panel:
|
|
|
23
24
|
Rich Panel instance
|
|
24
25
|
"""
|
|
25
26
|
if content.strip():
|
|
26
|
-
return
|
|
27
|
+
return AIPPanel(
|
|
27
28
|
Markdown(content, code_theme=("monokai" if theme == "dark" else "github")),
|
|
28
29
|
title=title,
|
|
29
30
|
border_style="green",
|
|
@@ -31,7 +32,7 @@ def create_main_panel(content: str, title: str, theme: str = "dark") -> Panel:
|
|
|
31
32
|
else:
|
|
32
33
|
# Placeholder panel
|
|
33
34
|
placeholder = Text("Processing...", style="dim")
|
|
34
|
-
return
|
|
35
|
+
return AIPPanel(
|
|
35
36
|
placeholder,
|
|
36
37
|
title=title,
|
|
37
38
|
border_style="green",
|
|
@@ -44,7 +45,7 @@ def create_tool_panel(
|
|
|
44
45
|
status: str = "running",
|
|
45
46
|
theme: str = "dark",
|
|
46
47
|
is_delegation: bool = False,
|
|
47
|
-
) ->
|
|
48
|
+
) -> AIPPanel:
|
|
48
49
|
"""Create a tool execution panel.
|
|
49
50
|
|
|
50
51
|
Args:
|
|
@@ -60,7 +61,7 @@ def create_tool_panel(
|
|
|
60
61
|
mark = "✓" if status == "finished" else "⟳"
|
|
61
62
|
border_style = "magenta" if is_delegation else "blue"
|
|
62
63
|
|
|
63
|
-
return
|
|
64
|
+
return AIPPanel(
|
|
64
65
|
Markdown(
|
|
65
66
|
content or "Processing...",
|
|
66
67
|
code_theme=("monokai" if theme == "dark" else "github"),
|
|
@@ -76,7 +77,7 @@ def create_context_panel(
|
|
|
76
77
|
status: str = "running",
|
|
77
78
|
theme: str = "dark",
|
|
78
79
|
is_delegation: bool = False,
|
|
79
|
-
) ->
|
|
80
|
+
) -> AIPPanel:
|
|
80
81
|
"""Create a context/sub-agent panel.
|
|
81
82
|
|
|
82
83
|
Args:
|
|
@@ -92,7 +93,7 @@ def create_context_panel(
|
|
|
92
93
|
mark = "✓" if status == "finished" else "⟳"
|
|
93
94
|
border_style = "magenta" if is_delegation else "cyan"
|
|
94
95
|
|
|
95
|
-
return
|
|
96
|
+
return AIPPanel(
|
|
96
97
|
Markdown(
|
|
97
98
|
content,
|
|
98
99
|
code_theme=("monokai" if theme == "dark" else "github"),
|
|
@@ -104,7 +105,7 @@ def create_context_panel(
|
|
|
104
105
|
|
|
105
106
|
def create_final_panel(
|
|
106
107
|
content: str, title: str = "Final Result", theme: str = "dark"
|
|
107
|
-
) ->
|
|
108
|
+
) -> AIPPanel:
|
|
108
109
|
"""Create a final result panel.
|
|
109
110
|
|
|
110
111
|
Args:
|
|
@@ -115,7 +116,7 @@ def create_final_panel(
|
|
|
115
116
|
Returns:
|
|
116
117
|
Rich Panel instance
|
|
117
118
|
"""
|
|
118
|
-
return
|
|
119
|
+
return AIPPanel(
|
|
119
120
|
Markdown(content, code_theme=("monokai" if theme == "dark" else "github")),
|
|
120
121
|
title=title,
|
|
121
122
|
border_style="green",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Resource reference utilities for ID/name extraction and UUID detection.
|
|
2
|
+
|
|
3
|
+
This module provides normalized helpers for working with resource references
|
|
4
|
+
across the SDK, consolidating logic that was previously duplicated between
|
|
5
|
+
CLI and SDK layers.
|
|
6
|
+
|
|
7
|
+
Authors:
|
|
8
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
|
|
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 extract_ids(items: list[str | Any] | None) -> list[str]:
|
|
33
|
+
"""Extract IDs from a list of objects or strings.
|
|
34
|
+
|
|
35
|
+
This function unifies the behavior between CLI and SDK layers, always
|
|
36
|
+
returning a list (empty list for None/empty input) rather than None.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
items: List of items that may be strings, objects with .id, or other types
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of extracted IDs (empty list if items is None/empty)
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
extract_ids([{"id": "123"}, "456"]) -> ["123", "456"]
|
|
46
|
+
extract_ids(None) -> []
|
|
47
|
+
extract_ids([]) -> []
|
|
48
|
+
"""
|
|
49
|
+
if not items:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
ids = []
|
|
53
|
+
for item in items:
|
|
54
|
+
if isinstance(item, str):
|
|
55
|
+
ids.append(item)
|
|
56
|
+
elif hasattr(item, "id"):
|
|
57
|
+
ids.append(str(item.id))
|
|
58
|
+
elif isinstance(item, dict) and "id" in item:
|
|
59
|
+
ids.append(str(item["id"]))
|
|
60
|
+
else:
|
|
61
|
+
# Fallback: convert to string
|
|
62
|
+
ids.append(str(item))
|
|
63
|
+
|
|
64
|
+
return ids
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_names(items: list[str | Any] | None) -> list[str]:
|
|
68
|
+
"""Extract names from a list of objects or strings.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
items: List of items that may be strings, objects with .name, or other types
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of extracted names (empty list if items is None/empty)
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
extract_names([{"name": "tool1"}, "tool2"]) -> ["tool1", "tool2"]
|
|
78
|
+
extract_names(None) -> []
|
|
79
|
+
"""
|
|
80
|
+
if not items:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
names = []
|
|
84
|
+
for item in items:
|
|
85
|
+
if isinstance(item, str):
|
|
86
|
+
names.append(item)
|
|
87
|
+
elif hasattr(item, "name"):
|
|
88
|
+
names.append(str(item.name))
|
|
89
|
+
elif isinstance(item, dict) and "name" in item:
|
|
90
|
+
names.append(str(item["name"]))
|
|
91
|
+
else:
|
|
92
|
+
# Fallback: convert to string
|
|
93
|
+
names.append(str(item))
|
|
94
|
+
|
|
95
|
+
return names
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_by_name(
|
|
99
|
+
items: list[Any], name: str, case_sensitive: bool = False
|
|
100
|
+
) -> list[Any]:
|
|
101
|
+
"""Filter items by name with optional case sensitivity.
|
|
102
|
+
|
|
103
|
+
This is a common pattern used across different clients for client-side
|
|
104
|
+
filtering when the backend doesn't support name query parameters.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
items: List of items to filter
|
|
108
|
+
name: Name to search for
|
|
109
|
+
case_sensitive: Whether the search should be case sensitive
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Filtered list of items matching the name
|
|
113
|
+
"""
|
|
114
|
+
if not name:
|
|
115
|
+
return items
|
|
116
|
+
|
|
117
|
+
if case_sensitive:
|
|
118
|
+
return [item for item in items if name in item.name]
|
|
119
|
+
else:
|
|
120
|
+
return [item for item in items if name.lower() in item.name.lower()]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def sanitize_name(name: str) -> str:
|
|
124
|
+
"""Sanitize a name for resource creation.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
name: Raw name input
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Sanitized name suitable for resource creation
|
|
131
|
+
"""
|
|
132
|
+
# Remove special characters and normalize
|
|
133
|
+
sanitized = re.sub(r"[^a-zA-Z0-9\-_]", "-", name.strip())
|
|
134
|
+
sanitized = re.sub(r"-+", "-", sanitized) # Collapse multiple dashes
|
|
135
|
+
return sanitized.lower().strip("-")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def validate_name_format(name: str, resource_type: str = "resource") -> str:
|
|
139
|
+
"""Validate resource name format and return cleaned version.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
name: Name to validate
|
|
143
|
+
resource_type: Type of resource (for error messages)
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Cleaned name
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If name format is invalid
|
|
150
|
+
"""
|
|
151
|
+
# Map resource types to proper display names
|
|
152
|
+
type_display = {"agent": "Agent", "tool": "Tool", "mcp": "MCP"}
|
|
153
|
+
display_type = type_display.get(resource_type.lower(), resource_type.title())
|
|
154
|
+
|
|
155
|
+
if not name or not name.strip():
|
|
156
|
+
raise ValueError(f"{display_type} name cannot be empty")
|
|
157
|
+
|
|
158
|
+
cleaned_name = name.strip()
|
|
159
|
+
|
|
160
|
+
if len(cleaned_name) < 1:
|
|
161
|
+
raise ValueError(f"{display_type} name cannot be empty")
|
|
162
|
+
|
|
163
|
+
if len(cleaned_name) > 100:
|
|
164
|
+
raise ValueError(f"{display_type} name cannot be longer than 100 characters")
|
|
165
|
+
|
|
166
|
+
# Check for valid characters (alphanumeric, hyphens, underscores)
|
|
167
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", cleaned_name):
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"{display_type} name can only contain letters, numbers, hyphens, and underscores"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return cleaned_name
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def validate_name_uniqueness(
|
|
176
|
+
name: str, existing_names: list[str], resource_type: str = "resource"
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Validate that a resource name is unique.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
name: Name to validate
|
|
182
|
+
existing_names: List of existing names to check against
|
|
183
|
+
resource_type: Type of resource (for error messages)
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If name is not unique
|
|
187
|
+
"""
|
|
188
|
+
if name.lower() in [existing.lower() for existing in existing_names]:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"A {resource_type.lower()} named '{name}' already exists. "
|
|
191
|
+
"Please choose a unique name."
|
|
192
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Rich utility functions and availability checking.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _check_rich_available():
|
|
9
|
+
"""Check if Rich is available by attempting imports."""
|
|
10
|
+
try:
|
|
11
|
+
import importlib.util
|
|
12
|
+
|
|
13
|
+
# Check if rich modules are available without importing them
|
|
14
|
+
if (
|
|
15
|
+
importlib.util.find_spec("rich.console") is None
|
|
16
|
+
or importlib.util.find_spec("rich.text") is None
|
|
17
|
+
):
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
# Check if our rich components are available
|
|
21
|
+
if importlib.util.find_spec("glaip_sdk.rich_components") is None:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
return True
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
RICH_AVAILABLE = _check_rich_available()
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Serialization utilities for JSON/YAML read/write and resource attribute collection.
|
|
2
|
+
|
|
3
|
+
This module provides pure functions for file I/O operations and data serialization
|
|
4
|
+
that can be used by both CLI and SDK layers without coupling to Click or Rich.
|
|
5
|
+
|
|
6
|
+
Authors:
|
|
7
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_json(file_path: Path) -> dict[str, Any]:
|
|
19
|
+
"""Read data from JSON file.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
file_path: Path to JSON file
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Parsed JSON data as dictionary
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
FileNotFoundError: If file doesn't exist
|
|
29
|
+
ValueError: If file is not valid JSON
|
|
30
|
+
"""
|
|
31
|
+
with open(file_path, encoding="utf-8") as f:
|
|
32
|
+
return json.load(f)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_json(file_path: Path, data: dict[str, Any], indent: int = 2) -> None:
|
|
36
|
+
"""Write data to JSON file.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_path: Path to write JSON file
|
|
40
|
+
data: Data to write
|
|
41
|
+
indent: JSON indentation level (default: 2)
|
|
42
|
+
"""
|
|
43
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
44
|
+
json.dump(data, f, indent=indent, default=str)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_yaml(file_path: Path) -> dict[str, Any]:
|
|
48
|
+
"""Read data from YAML file.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
file_path: Path to YAML file
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Parsed YAML data as dictionary
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
FileNotFoundError: If file doesn't exist
|
|
58
|
+
ValueError: If file is not valid YAML
|
|
59
|
+
"""
|
|
60
|
+
with open(file_path, encoding="utf-8") as f:
|
|
61
|
+
data = yaml.safe_load(f)
|
|
62
|
+
|
|
63
|
+
# Handle instruction_lines array format for user-friendly YAML
|
|
64
|
+
if (
|
|
65
|
+
isinstance(data, dict)
|
|
66
|
+
and "instruction_lines" in data
|
|
67
|
+
and isinstance(data["instruction_lines"], list)
|
|
68
|
+
):
|
|
69
|
+
data["instruction"] = "\n\n".join(data["instruction_lines"])
|
|
70
|
+
del data["instruction_lines"]
|
|
71
|
+
|
|
72
|
+
# Handle instruction as list from YAML export (convert back to string)
|
|
73
|
+
if (
|
|
74
|
+
isinstance(data, dict)
|
|
75
|
+
and "instruction" in data
|
|
76
|
+
and isinstance(data["instruction"], list)
|
|
77
|
+
):
|
|
78
|
+
data["instruction"] = "\n\n".join(data["instruction"])
|
|
79
|
+
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_yaml(file_path: Path, data: dict[str, Any]) -> None:
|
|
84
|
+
"""Write data to YAML file with user-friendly formatting.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
file_path: Path to write YAML file
|
|
88
|
+
data: Data to write
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
# Custom YAML dumper for user-friendly instruction formatting
|
|
92
|
+
class LiteralString(str):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def literal_string_representer(dumper, data):
|
|
96
|
+
# Use literal block scalar (|) for multiline strings to preserve formatting
|
|
97
|
+
if "\n" in data:
|
|
98
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
|
99
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
|
100
|
+
|
|
101
|
+
# Add custom representer to the YAML dumper
|
|
102
|
+
yaml.add_representer(LiteralString, literal_string_representer)
|
|
103
|
+
|
|
104
|
+
# Convert instruction to LiteralString for proper formatting
|
|
105
|
+
if isinstance(data, dict) and "instruction" in data and data["instruction"]:
|
|
106
|
+
data = data.copy() # Don't modify original
|
|
107
|
+
data["instruction"] = LiteralString(data["instruction"])
|
|
108
|
+
|
|
109
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
110
|
+
yaml.dump(
|
|
111
|
+
data, f, default_flow_style=False, allow_unicode=True, sort_keys=False
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_resource_from_file(file_path: Path) -> dict[str, Any]:
|
|
116
|
+
"""Load resource data from JSON or YAML file.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
file_path: Path to the file
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dictionary with resource data
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If file format is not supported
|
|
126
|
+
"""
|
|
127
|
+
if file_path.suffix.lower() in [".yaml", ".yml"]:
|
|
128
|
+
return read_yaml(file_path)
|
|
129
|
+
elif file_path.suffix.lower() == ".json":
|
|
130
|
+
return read_json(file_path)
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"Unsupported file format: {file_path.suffix}. Only JSON and YAML files are supported."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_resource_export(
|
|
138
|
+
file_path: Path, data: dict[str, Any], format: str = "json"
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Write resource export data to file.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
file_path: Path to export file
|
|
144
|
+
data: Resource data to export
|
|
145
|
+
format: Export format ("json" or "yaml")
|
|
146
|
+
"""
|
|
147
|
+
if format.lower() == "yaml" or file_path.suffix.lower() in [".yaml", ".yml"]:
|
|
148
|
+
write_yaml(file_path, data)
|
|
149
|
+
else:
|
|
150
|
+
write_json(file_path, data)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_EXCLUDED_ATTRS = {
|
|
154
|
+
"id",
|
|
155
|
+
"created_at",
|
|
156
|
+
"updated_at",
|
|
157
|
+
"_client",
|
|
158
|
+
"_raw_data",
|
|
159
|
+
}
|
|
160
|
+
_EXCLUDED_NAMES = {
|
|
161
|
+
"model_dump",
|
|
162
|
+
"dict",
|
|
163
|
+
"json",
|
|
164
|
+
"get",
|
|
165
|
+
"post",
|
|
166
|
+
"put",
|
|
167
|
+
"delete",
|
|
168
|
+
"save",
|
|
169
|
+
"refresh",
|
|
170
|
+
"update",
|
|
171
|
+
}
|
|
172
|
+
_PREFERRED_MAPPERS: tuple[str, ...] = ("model_dump", "dict", "to_dict")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def collect_attributes_for_export(resource: Any) -> dict[str, Any]:
|
|
176
|
+
"""Collect resource attributes suitable for export.
|
|
177
|
+
|
|
178
|
+
The helper prefers structured dump methods when available and gracefully
|
|
179
|
+
falls back to the object's attribute space. Internal fields, identifiers,
|
|
180
|
+
and callables are filtered out so the result only contains user-configurable
|
|
181
|
+
data.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
mapping = _coerce_resource_to_mapping(resource)
|
|
185
|
+
if mapping is None:
|
|
186
|
+
items = (
|
|
187
|
+
(name, _safe_getattr(resource, name))
|
|
188
|
+
for name in _iter_public_attribute_names(resource)
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
items = mapping.items()
|
|
192
|
+
|
|
193
|
+
export: dict[str, Any] = {}
|
|
194
|
+
for key, value in items:
|
|
195
|
+
if _should_include_attribute(key, value):
|
|
196
|
+
export[key] = value
|
|
197
|
+
return export
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _coerce_resource_to_mapping(resource: Any) -> dict[str, Any] | None:
|
|
201
|
+
"""Return a mapping representation of ``resource`` when possible."""
|
|
202
|
+
|
|
203
|
+
for attr in _PREFERRED_MAPPERS:
|
|
204
|
+
method = getattr(resource, attr, None)
|
|
205
|
+
if callable(method):
|
|
206
|
+
try:
|
|
207
|
+
data = method()
|
|
208
|
+
except Exception:
|
|
209
|
+
continue
|
|
210
|
+
if isinstance(data, dict):
|
|
211
|
+
return data
|
|
212
|
+
|
|
213
|
+
if isinstance(resource, dict):
|
|
214
|
+
return resource
|
|
215
|
+
|
|
216
|
+
if hasattr(resource, "__dict__"):
|
|
217
|
+
try:
|
|
218
|
+
return dict(resource.__dict__)
|
|
219
|
+
except Exception:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _iter_public_attribute_names(resource: Any) -> Iterable[str]:
|
|
226
|
+
"""Yield attribute names we should inspect on ``resource``."""
|
|
227
|
+
|
|
228
|
+
seen: set[str] = set()
|
|
229
|
+
names: list[str] = []
|
|
230
|
+
|
|
231
|
+
def _collect(candidates: Iterable[str] | None) -> None:
|
|
232
|
+
if candidates is None:
|
|
233
|
+
return
|
|
234
|
+
for candidate in candidates:
|
|
235
|
+
if candidate not in seen:
|
|
236
|
+
seen.add(candidate)
|
|
237
|
+
names.append(candidate)
|
|
238
|
+
|
|
239
|
+
_collect(
|
|
240
|
+
getattr(resource, "__dict__", {}).keys()
|
|
241
|
+
if hasattr(resource, "__dict__")
|
|
242
|
+
else None
|
|
243
|
+
)
|
|
244
|
+
_collect(getattr(resource, "__annotations__", {}).keys())
|
|
245
|
+
_collect(getattr(resource, "__slots__", ()))
|
|
246
|
+
|
|
247
|
+
if not names:
|
|
248
|
+
_collect(name for name in dir(resource) if not name.startswith("__"))
|
|
249
|
+
|
|
250
|
+
return iter(names)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _safe_getattr(resource: Any, name: str) -> Any:
|
|
254
|
+
try:
|
|
255
|
+
return getattr(resource, name)
|
|
256
|
+
except Exception:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _should_include_attribute(key: str, value: Any) -> bool:
|
|
261
|
+
if key in _EXCLUDED_ATTRS or key in _EXCLUDED_NAMES:
|
|
262
|
+
return False
|
|
263
|
+
if key.startswith("_"):
|
|
264
|
+
return False
|
|
265
|
+
if callable(value):
|
|
266
|
+
return False
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def validate_json_string(json_str: str) -> dict[str, Any]:
|
|
271
|
+
"""Validate JSON string and return parsed data.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
json_str: JSON string to validate
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Parsed JSON data
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: If JSON is invalid
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
return json.loads(json_str)
|
|
284
|
+
except json.JSONDecodeError as e:
|
|
285
|
+
raise ValueError(f"Invalid JSON: {e}")
|