fruxon 0.2.0__tar.gz → 0.3.1__tar.gz
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.
- {fruxon-0.2.0 → fruxon-0.3.1}/PKG-INFO +33 -4
- {fruxon-0.2.0 → fruxon-0.3.1}/README.md +32 -3
- {fruxon-0.2.0 → fruxon-0.3.1}/pyproject.toml +1 -1
- fruxon-0.3.1/src/fruxon/__init__.py +28 -0
- fruxon-0.3.1/src/fruxon/cli.py +165 -0
- fruxon-0.3.1/src/fruxon/exceptions.py +45 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon/export.py +35 -19
- fruxon-0.3.1/src/fruxon/fruxon.py +150 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/PKG-INFO +33 -4
- {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/SOURCES.txt +2 -1
- fruxon-0.3.1/tests/test_client.py +268 -0
- fruxon-0.2.0/src/fruxon/__init__.py +0 -4
- fruxon-0.2.0/src/fruxon/cli.py +0 -85
- fruxon-0.2.0/src/fruxon/fruxon.py +0 -1
- fruxon-0.2.0/src/fruxon/utils.py +0 -2
- {fruxon-0.2.0 → fruxon-0.3.1}/CONTRIBUTING.md +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/HISTORY.md +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/LICENSE +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/MANIFEST.in +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/docs/index.md +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/docs/installation.md +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/docs/usage.md +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/setup.cfg +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/src/__init__.py +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon/__main__.py +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/dependency_links.txt +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/entry_points.txt +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/requires.txt +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/top_level.txt +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/tests/__init__.py +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/tests/test_export.py +0 -0
- {fruxon-0.2.0 → fruxon-0.3.1}/tests/test_fruxon.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fruxon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: The Fruxon SDK is a lightweight Python client for integrating with the Fruxon platform.
|
|
5
5
|
Author-email: Hagai Cohen <hagai@fruxon.com>
|
|
6
6
|
Maintainer-email: Hagai Cohen <hagai@fruxon.com>
|
|
@@ -45,6 +45,38 @@ pip install fruxon
|
|
|
45
45
|
|
|
46
46
|
## Features
|
|
47
47
|
|
|
48
|
+
### Python Client — Execute agents via API
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fruxon import FruxonClient
|
|
52
|
+
|
|
53
|
+
client = FruxonClient(api_key="frx_...", tenant="acme-corp")
|
|
54
|
+
|
|
55
|
+
result = client.execute("support-agent", parameters={"question": "How do I reset my password?"})
|
|
56
|
+
print(result.response)
|
|
57
|
+
print(f"{result.trace.duration}ms | ${result.trace.total_cost:.4f}")
|
|
58
|
+
|
|
59
|
+
# Multi-turn conversation
|
|
60
|
+
result2 = client.execute("support-agent", parameters={"question": "Tell me more"}, session_id=result.session_id)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `fruxon run` — Execute agents from the CLI
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Basic execution
|
|
67
|
+
fruxon run my-agent -t acme-corp -k frx_...
|
|
68
|
+
|
|
69
|
+
# With parameters
|
|
70
|
+
fruxon run my-agent -t acme-corp -p question="Hello" -p lang=en
|
|
71
|
+
|
|
72
|
+
# Full JSON output (for scripting)
|
|
73
|
+
fruxon run my-agent -t acme-corp --json
|
|
74
|
+
|
|
75
|
+
# Use environment variable for API key
|
|
76
|
+
export FRUXON_API_KEY=frx_...
|
|
77
|
+
fruxon run my-agent -t acme-corp
|
|
78
|
+
```
|
|
79
|
+
|
|
48
80
|
### `fruxon export` — Consolidate multi-file agents
|
|
49
81
|
|
|
50
82
|
Export a multi-file Python agent project into a single file for importing into Fruxon.
|
|
@@ -71,6 +103,3 @@ fruxon export graph.py --copy
|
|
|
71
103
|
3. Traces all local imports using Python's AST (skips third-party packages)
|
|
72
104
|
4. Outputs a single consolidated file with all local code and source markers
|
|
73
105
|
|
|
74
|
-
## Credits
|
|
75
|
-
|
|
76
|
-
Built by [Fruxon](https://fruxon.com).
|
|
@@ -15,6 +15,38 @@ pip install fruxon
|
|
|
15
15
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
18
|
+
### Python Client — Execute agents via API
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from fruxon import FruxonClient
|
|
22
|
+
|
|
23
|
+
client = FruxonClient(api_key="frx_...", tenant="acme-corp")
|
|
24
|
+
|
|
25
|
+
result = client.execute("support-agent", parameters={"question": "How do I reset my password?"})
|
|
26
|
+
print(result.response)
|
|
27
|
+
print(f"{result.trace.duration}ms | ${result.trace.total_cost:.4f}")
|
|
28
|
+
|
|
29
|
+
# Multi-turn conversation
|
|
30
|
+
result2 = client.execute("support-agent", parameters={"question": "Tell me more"}, session_id=result.session_id)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### `fruxon run` — Execute agents from the CLI
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Basic execution
|
|
37
|
+
fruxon run my-agent -t acme-corp -k frx_...
|
|
38
|
+
|
|
39
|
+
# With parameters
|
|
40
|
+
fruxon run my-agent -t acme-corp -p question="Hello" -p lang=en
|
|
41
|
+
|
|
42
|
+
# Full JSON output (for scripting)
|
|
43
|
+
fruxon run my-agent -t acme-corp --json
|
|
44
|
+
|
|
45
|
+
# Use environment variable for API key
|
|
46
|
+
export FRUXON_API_KEY=frx_...
|
|
47
|
+
fruxon run my-agent -t acme-corp
|
|
48
|
+
```
|
|
49
|
+
|
|
18
50
|
### `fruxon export` — Consolidate multi-file agents
|
|
19
51
|
|
|
20
52
|
Export a multi-file Python agent project into a single file for importing into Fruxon.
|
|
@@ -41,6 +73,3 @@ fruxon export graph.py --copy
|
|
|
41
73
|
3. Traces all local imports using Python's AST (skips third-party packages)
|
|
42
74
|
4. Outputs a single consolidated file with all local code and source markers
|
|
43
75
|
|
|
44
|
-
## Credits
|
|
45
|
-
|
|
46
|
-
Built by [Fruxon](https://fruxon.com).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Top-level package for fruxon-sdk."""
|
|
2
|
+
|
|
3
|
+
__author__ = "Hagai Cohen"
|
|
4
|
+
__email__ = "hagai@fruxon.com"
|
|
5
|
+
|
|
6
|
+
from fruxon.exceptions import (
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ForbiddenError,
|
|
9
|
+
FruxonAPIError,
|
|
10
|
+
FruxonConnectionError,
|
|
11
|
+
FruxonError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
from fruxon.fruxon import ExecutionResult, ExecutionTrace, FruxonClient
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"ExecutionResult",
|
|
20
|
+
"ExecutionTrace",
|
|
21
|
+
"ForbiddenError",
|
|
22
|
+
"FruxonAPIError",
|
|
23
|
+
"FruxonClient",
|
|
24
|
+
"FruxonConnectionError",
|
|
25
|
+
"FruxonError",
|
|
26
|
+
"NotFoundError",
|
|
27
|
+
"ValidationError",
|
|
28
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Console script for fruxon-sdk."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.prompt import IntPrompt
|
|
11
|
+
|
|
12
|
+
from fruxon.exceptions import FruxonError, MultipleAgentsError
|
|
13
|
+
from fruxon.export import export_agent
|
|
14
|
+
from fruxon.fruxon import FruxonClient
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
help="Fruxon CLI - tools for working with the Fruxon platform.",
|
|
18
|
+
pretty_exceptions_enable=False,
|
|
19
|
+
)
|
|
20
|
+
stderr = Console(stderr=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback()
|
|
24
|
+
def main():
|
|
25
|
+
"""Fruxon CLI - tools for working with the Fruxon platform."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def export(
|
|
30
|
+
entry_point: Annotated[
|
|
31
|
+
Path | None, typer.Argument(help="Path to the main Python file. Auto-detected if omitted.")
|
|
32
|
+
] = None,
|
|
33
|
+
output: Annotated[
|
|
34
|
+
Path | None, typer.Option("--output", "-o", help="Write output to file instead of stdout")
|
|
35
|
+
] = None,
|
|
36
|
+
copy: Annotated[bool, typer.Option("--copy", "-c", help="Copy output to clipboard")] = False,
|
|
37
|
+
):
|
|
38
|
+
"""Export a multi-file Python agent into a single file for Fruxon import.
|
|
39
|
+
|
|
40
|
+
Auto-detects the agent entry point by scanning for framework imports
|
|
41
|
+
(LangChain, CrewAI, Google ADK, etc.). You can also specify the entry
|
|
42
|
+
point explicitly.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
fruxon export
|
|
46
|
+
fruxon export graph.py
|
|
47
|
+
fruxon export my_agent/main.py -o export.py
|
|
48
|
+
fruxon export --copy
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
result = export_agent(str(entry_point) if entry_point else None, str(output) if output else None, stderr)
|
|
52
|
+
except MultipleAgentsError as e:
|
|
53
|
+
choice = IntPrompt.ask(
|
|
54
|
+
"\nWhich agent do you want to export?",
|
|
55
|
+
choices=[str(i) for i in range(1, len(e.entry_points) + 1)],
|
|
56
|
+
)
|
|
57
|
+
selected_path = e.entry_points[choice - 1][0]
|
|
58
|
+
result = export_agent(str(selected_path), str(output) if output else None, stderr)
|
|
59
|
+
|
|
60
|
+
_handle_output(result, output, copy)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _handle_output(result: str, output: Path | None, copy: bool):
|
|
64
|
+
"""Handle clipboard copy and stdout output."""
|
|
65
|
+
if copy:
|
|
66
|
+
_copy_to_clipboard(result)
|
|
67
|
+
|
|
68
|
+
if not output and not copy:
|
|
69
|
+
# Print code to stdout (not stderr) so it can be piped
|
|
70
|
+
print(result)
|
|
71
|
+
elif not output and copy:
|
|
72
|
+
lines = result.count("\n") + 1
|
|
73
|
+
stderr.print(f"[green]>[/green] {lines} lines ready to paste into Fruxon.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def run(
|
|
78
|
+
agent: Annotated[str, typer.Argument(help="Agent identifier to execute")],
|
|
79
|
+
tenant: Annotated[str, typer.Option("--tenant", "-t", help="Tenant identifier")],
|
|
80
|
+
api_key: Annotated[str | None, typer.Option("--api-key", "-k", envvar="FRUXON_API_KEY", help="API key")] = None,
|
|
81
|
+
param: Annotated[
|
|
82
|
+
list[str] | None, typer.Option("--param", "-p", help="Parameter as key=value (repeatable)")
|
|
83
|
+
] = None,
|
|
84
|
+
session_id: Annotated[str | None, typer.Option("--session", "-s", help="Session ID")] = None,
|
|
85
|
+
base_url: Annotated[str, typer.Option("--base-url", help="API base URL")] = FruxonClient.DEFAULT_BASE_URL,
|
|
86
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output full JSON response")] = False,
|
|
87
|
+
):
|
|
88
|
+
"""Execute a Fruxon agent and print the response.
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
fruxon run my-agent -t acme-corp -k frx_...
|
|
92
|
+
fruxon run my-agent -t acme-corp -p question="Hello" -p lang=en
|
|
93
|
+
fruxon run my-agent -t acme-corp --session abc123 --json
|
|
94
|
+
"""
|
|
95
|
+
if not api_key:
|
|
96
|
+
stderr.print("[bold red]Error:[/bold red] API key required. Use --api-key or set FRUXON_API_KEY.")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
parameters: dict[str, object] | None = None
|
|
100
|
+
if param:
|
|
101
|
+
parameters = {}
|
|
102
|
+
for p in param:
|
|
103
|
+
if "=" not in p:
|
|
104
|
+
stderr.print(f"[bold red]Error:[/bold red] Invalid parameter format '{p}'. Use key=value.")
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
key, value = p.split("=", 1)
|
|
107
|
+
parameters[key] = value
|
|
108
|
+
|
|
109
|
+
client = FruxonClient(api_key=api_key, tenant=tenant, base_url=base_url)
|
|
110
|
+
|
|
111
|
+
with stderr.status(f"[bold]Executing agent [cyan]{agent}[/cyan]...[/bold]"):
|
|
112
|
+
try:
|
|
113
|
+
result = client.execute(agent, parameters=parameters, session_id=session_id)
|
|
114
|
+
except FruxonError as e:
|
|
115
|
+
stderr.print(f"[bold red]Error:[/bold red] {e}")
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
if json_output:
|
|
119
|
+
output = {
|
|
120
|
+
"response": result.response,
|
|
121
|
+
"sessionId": result.session_id,
|
|
122
|
+
"executionRecordId": result.execution_record_id,
|
|
123
|
+
"trace": {
|
|
124
|
+
"agentId": result.trace.agent_id,
|
|
125
|
+
"agentRevision": result.trace.agent_revision,
|
|
126
|
+
"duration": result.trace.duration,
|
|
127
|
+
"inputCost": result.trace.input_cost,
|
|
128
|
+
"outputCost": result.trace.output_cost,
|
|
129
|
+
"totalCost": result.trace.total_cost,
|
|
130
|
+
},
|
|
131
|
+
"links": result.links,
|
|
132
|
+
}
|
|
133
|
+
Console().print_json(json.dumps(output))
|
|
134
|
+
else:
|
|
135
|
+
print(result.response)
|
|
136
|
+
|
|
137
|
+
# Summary to stderr so it doesn't pollute piped output
|
|
138
|
+
duration = result.trace.duration
|
|
139
|
+
cost = result.trace.total_cost
|
|
140
|
+
if duration or cost:
|
|
141
|
+
parts = []
|
|
142
|
+
if duration:
|
|
143
|
+
parts.append(f"{duration}ms")
|
|
144
|
+
if cost:
|
|
145
|
+
parts.append(f"${cost:.4f}")
|
|
146
|
+
stderr.print(f"[dim]{' | '.join(parts)}[/dim]")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _copy_to_clipboard(text: str):
|
|
150
|
+
"""Copy text to system clipboard."""
|
|
151
|
+
import subprocess
|
|
152
|
+
|
|
153
|
+
for cmd in [["pbcopy"], ["xclip", "-selection", "clipboard"]]:
|
|
154
|
+
try:
|
|
155
|
+
process = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
|
156
|
+
process.communicate(text.encode("utf-8"))
|
|
157
|
+
stderr.print("[green]>[/green] Copied to clipboard.")
|
|
158
|
+
return
|
|
159
|
+
except FileNotFoundError:
|
|
160
|
+
continue
|
|
161
|
+
stderr.print("[yellow]Clipboard not available. Install xclip or use -o to write to file.[/yellow]")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
app()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Exceptions for the Fruxon SDK."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FruxonError(Exception):
|
|
7
|
+
"""Base exception for all Fruxon SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FruxonAPIError(FruxonError):
|
|
11
|
+
"""Raised when the Fruxon API returns an error response."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status: int, title: str, detail: str):
|
|
14
|
+
self.status = status
|
|
15
|
+
self.title = title
|
|
16
|
+
self.detail = detail
|
|
17
|
+
super().__init__(f"{status} {title}: {detail}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthenticationError(FruxonAPIError):
|
|
21
|
+
"""Raised on 401 Unauthorized responses."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ForbiddenError(FruxonAPIError):
|
|
25
|
+
"""Raised on 403 Forbidden responses."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundError(FruxonAPIError):
|
|
29
|
+
"""Raised on 404 Not Found responses."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ValidationError(FruxonAPIError):
|
|
33
|
+
"""Raised on 400 or 422 responses."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FruxonConnectionError(FruxonError):
|
|
37
|
+
"""Raised when the API is unreachable."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MultipleAgentsError(FruxonError):
|
|
41
|
+
"""Raised when multiple agent entry points are detected."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, entry_points: list[tuple[Path, str]]):
|
|
44
|
+
self.entry_points = entry_points
|
|
45
|
+
super().__init__(f"Multiple agents detected: {len(entry_points)} entry points found")
|
|
@@ -8,6 +8,12 @@ Supports auto-detection of agent entry points by scanning for framework imports.
|
|
|
8
8
|
import ast
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from fruxon.exceptions import MultipleAgentsError as MultipleAgentsError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from rich.console import Console
|
|
11
17
|
|
|
12
18
|
# Known agent framework module prefixes. A file that imports any of these
|
|
13
19
|
# is considered an agent-related file.
|
|
@@ -275,21 +281,38 @@ def find_agent_entry_points(project_root: Path) -> list[tuple[Path, str]]:
|
|
|
275
281
|
return entry_points
|
|
276
282
|
|
|
277
283
|
|
|
278
|
-
def export_agent(
|
|
284
|
+
def export_agent(
|
|
285
|
+
entry_path: str | None = None,
|
|
286
|
+
output_path: str | None = None,
|
|
287
|
+
console: "Console | None" = None,
|
|
288
|
+
) -> str:
|
|
279
289
|
"""Main export function. Returns the consolidated source and optionally writes to file.
|
|
280
290
|
|
|
281
291
|
If entry_path is None, auto-detects the agent entry point by scanning
|
|
282
292
|
for framework imports. Raises SystemExit on errors.
|
|
283
293
|
"""
|
|
294
|
+
|
|
295
|
+
def _msg(text: str) -> None:
|
|
296
|
+
if console:
|
|
297
|
+
console.print(text, highlight=False)
|
|
298
|
+
else:
|
|
299
|
+
print(text, file=sys.stderr)
|
|
300
|
+
|
|
301
|
+
def _err(text: str) -> None:
|
|
302
|
+
if console:
|
|
303
|
+
console.print(f"[bold red]Error:[/bold red] {text}")
|
|
304
|
+
else:
|
|
305
|
+
print(f"Error: {text}", file=sys.stderr)
|
|
306
|
+
|
|
284
307
|
if entry_path:
|
|
285
308
|
entry_file = Path(entry_path).resolve()
|
|
286
309
|
|
|
287
310
|
if not entry_file.exists():
|
|
288
|
-
|
|
311
|
+
_err(f"File not found: {entry_file}")
|
|
289
312
|
raise SystemExit(1)
|
|
290
313
|
|
|
291
314
|
if not entry_file.suffix == ".py":
|
|
292
|
-
|
|
315
|
+
_err(f"Entry point must be a .py file, got: {entry_file.suffix}")
|
|
293
316
|
raise SystemExit(1)
|
|
294
317
|
|
|
295
318
|
project_root = find_project_root(entry_file)
|
|
@@ -299,39 +322,32 @@ def export_agent(entry_path: str | None = None, output_path: str | None = None)
|
|
|
299
322
|
entry_points = find_agent_entry_points(project_root)
|
|
300
323
|
|
|
301
324
|
if not entry_points:
|
|
302
|
-
|
|
303
|
-
"
|
|
325
|
+
_err(
|
|
326
|
+
"No agent framework detected. "
|
|
304
327
|
"Make sure you're in a directory with Python agent files, "
|
|
305
|
-
"or specify the entry point: fruxon export <file.py>"
|
|
306
|
-
file=sys.stderr,
|
|
328
|
+
"or specify the entry point: [bold]fruxon export <file.py>[/bold]"
|
|
307
329
|
)
|
|
308
330
|
raise SystemExit(1)
|
|
309
331
|
|
|
310
332
|
if len(entry_points) == 1:
|
|
311
333
|
entry_file = entry_points[0][0]
|
|
312
334
|
framework = entry_points[0][1]
|
|
313
|
-
|
|
335
|
+
relative = entry_file.relative_to(project_root)
|
|
336
|
+
_msg(f"[green]>[/green] Detected [bold]{framework}[/bold] agent in [cyan]{relative}[/cyan]")
|
|
314
337
|
else:
|
|
315
338
|
# Multiple entry points found — let caller handle selection
|
|
316
|
-
|
|
339
|
+
_msg("[yellow]Multiple agents detected:[/yellow]")
|
|
317
340
|
for i, (fp, fw) in enumerate(entry_points, 1):
|
|
318
341
|
relative = fp.relative_to(project_root) if fp.is_relative_to(project_root) else fp
|
|
319
|
-
|
|
342
|
+
_msg(f" [bold]{i}.[/bold] {relative} [dim]({fw})[/dim]")
|
|
320
343
|
raise MultipleAgentsError(entry_points)
|
|
321
344
|
|
|
322
345
|
result = build_export(entry_file, project_root)
|
|
346
|
+
file_count = len(collect_files(entry_file, project_root))
|
|
323
347
|
|
|
324
348
|
if output_path:
|
|
325
349
|
out = Path(output_path)
|
|
326
350
|
out.write_text(result, encoding="utf-8")
|
|
327
|
-
|
|
351
|
+
_msg(f"[green]>[/green] Exported {file_count} file(s) to [cyan]{out}[/cyan]")
|
|
328
352
|
|
|
329
353
|
return result
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
class MultipleAgentsError(Exception):
|
|
333
|
-
"""Raised when multiple agent entry points are detected."""
|
|
334
|
-
|
|
335
|
-
def __init__(self, entry_points: list[tuple[Path, str]]):
|
|
336
|
-
self.entry_points = entry_points
|
|
337
|
-
super().__init__(f"Multiple agents detected: {len(entry_points)} entry points found")
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Fruxon API client for executing agents on the Fruxon platform."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from fruxon.exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
FruxonAPIError,
|
|
12
|
+
FruxonConnectionError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_STATUS_EXCEPTIONS: dict[int, type[FruxonAPIError]] = {
|
|
18
|
+
400: ValidationError,
|
|
19
|
+
401: AuthenticationError,
|
|
20
|
+
403: ForbiddenError,
|
|
21
|
+
404: NotFoundError,
|
|
22
|
+
422: ValidationError,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ExecutionTrace:
|
|
28
|
+
"""Execution trace metadata."""
|
|
29
|
+
|
|
30
|
+
agent_id: str
|
|
31
|
+
agent_revision: int
|
|
32
|
+
duration: int
|
|
33
|
+
input_cost: float
|
|
34
|
+
output_cost: float
|
|
35
|
+
total_cost: float
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ExecutionResult:
|
|
40
|
+
"""Result of an agent execution."""
|
|
41
|
+
|
|
42
|
+
response: str
|
|
43
|
+
trace: ExecutionTrace
|
|
44
|
+
session_id: str
|
|
45
|
+
links: list[dict[str, object]]
|
|
46
|
+
execution_record_id: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FruxonClient:
|
|
50
|
+
"""Client for the Fruxon platform API."""
|
|
51
|
+
|
|
52
|
+
DEFAULT_BASE_URL = "https://api.fruxon.com"
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
api_key: str,
|
|
58
|
+
tenant: str,
|
|
59
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
60
|
+
timeout: float = 120.0,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._api_key = api_key
|
|
63
|
+
self._tenant = tenant
|
|
64
|
+
self._base_url = base_url.rstrip("/")
|
|
65
|
+
self._timeout = timeout
|
|
66
|
+
|
|
67
|
+
def execute(
|
|
68
|
+
self,
|
|
69
|
+
agent: str,
|
|
70
|
+
*,
|
|
71
|
+
parameters: dict[str, object] | None = None,
|
|
72
|
+
attachments: list[dict[str, object]] | None = None,
|
|
73
|
+
chat_user: dict[str, object] | None = None,
|
|
74
|
+
session_id: str | None = None,
|
|
75
|
+
) -> ExecutionResult:
|
|
76
|
+
"""Execute an agent and return the result.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
agent: The agent identifier.
|
|
80
|
+
parameters: Execution parameters and inputs.
|
|
81
|
+
attachments: File attachments.
|
|
82
|
+
chat_user: Chat user information.
|
|
83
|
+
session_id: Session identifier for multi-turn conversations.
|
|
84
|
+
"""
|
|
85
|
+
url = f"{self._base_url}/v1/tenants/{self._tenant}/agents/{agent}:execute"
|
|
86
|
+
|
|
87
|
+
body: dict[str, object] = {}
|
|
88
|
+
if parameters is not None:
|
|
89
|
+
body["parameters"] = parameters
|
|
90
|
+
if attachments is not None:
|
|
91
|
+
body["attachments"] = attachments
|
|
92
|
+
if chat_user is not None:
|
|
93
|
+
body["chatUser"] = chat_user
|
|
94
|
+
if session_id is not None:
|
|
95
|
+
body["sessionId"] = session_id
|
|
96
|
+
|
|
97
|
+
data = json.dumps(body).encode("utf-8")
|
|
98
|
+
req = urllib.request.Request(
|
|
99
|
+
url,
|
|
100
|
+
data=data,
|
|
101
|
+
headers={
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
"X-API-KEY": self._api_key,
|
|
104
|
+
},
|
|
105
|
+
method="POST",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
110
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
111
|
+
except urllib.error.HTTPError as e:
|
|
112
|
+
_raise_api_error(e)
|
|
113
|
+
except urllib.error.URLError as e:
|
|
114
|
+
raise FruxonConnectionError(str(e.reason)) from e
|
|
115
|
+
|
|
116
|
+
return _parse_execution_result(result)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _raise_api_error(error: urllib.error.HTTPError) -> None:
|
|
120
|
+
"""Parse an HTTP error response and raise the appropriate exception."""
|
|
121
|
+
try:
|
|
122
|
+
body = json.loads(error.read().decode("utf-8"))
|
|
123
|
+
title = body.get("title") or body.get("message") or "Error"
|
|
124
|
+
detail = body.get("detail") or body.get("details") or ""
|
|
125
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
126
|
+
title = "Error"
|
|
127
|
+
detail = str(error)
|
|
128
|
+
|
|
129
|
+
exc_class = _STATUS_EXCEPTIONS.get(error.code, FruxonAPIError)
|
|
130
|
+
raise exc_class(status=error.code, title=title, detail=detail) from error
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_execution_result(data: dict) -> ExecutionResult:
|
|
134
|
+
"""Parse raw API response into an ExecutionResult."""
|
|
135
|
+
trace_data: dict = data.get("trace", {})
|
|
136
|
+
trace = ExecutionTrace(
|
|
137
|
+
agent_id=trace_data.get("agentId", ""),
|
|
138
|
+
agent_revision=trace_data.get("agentRevision", 0),
|
|
139
|
+
duration=trace_data.get("duration", 0),
|
|
140
|
+
input_cost=trace_data.get("inputCost", 0.0),
|
|
141
|
+
output_cost=trace_data.get("outputCost", 0.0),
|
|
142
|
+
total_cost=trace_data.get("totalCost", 0.0),
|
|
143
|
+
)
|
|
144
|
+
return ExecutionResult(
|
|
145
|
+
response=data.get("response", ""),
|
|
146
|
+
trace=trace,
|
|
147
|
+
session_id=data.get("sessionId", ""),
|
|
148
|
+
links=data.get("links", []),
|
|
149
|
+
execution_record_id=data.get("executionRecordId", ""),
|
|
150
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fruxon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: The Fruxon SDK is a lightweight Python client for integrating with the Fruxon platform.
|
|
5
5
|
Author-email: Hagai Cohen <hagai@fruxon.com>
|
|
6
6
|
Maintainer-email: Hagai Cohen <hagai@fruxon.com>
|
|
@@ -45,6 +45,38 @@ pip install fruxon
|
|
|
45
45
|
|
|
46
46
|
## Features
|
|
47
47
|
|
|
48
|
+
### Python Client — Execute agents via API
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fruxon import FruxonClient
|
|
52
|
+
|
|
53
|
+
client = FruxonClient(api_key="frx_...", tenant="acme-corp")
|
|
54
|
+
|
|
55
|
+
result = client.execute("support-agent", parameters={"question": "How do I reset my password?"})
|
|
56
|
+
print(result.response)
|
|
57
|
+
print(f"{result.trace.duration}ms | ${result.trace.total_cost:.4f}")
|
|
58
|
+
|
|
59
|
+
# Multi-turn conversation
|
|
60
|
+
result2 = client.execute("support-agent", parameters={"question": "Tell me more"}, session_id=result.session_id)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `fruxon run` — Execute agents from the CLI
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Basic execution
|
|
67
|
+
fruxon run my-agent -t acme-corp -k frx_...
|
|
68
|
+
|
|
69
|
+
# With parameters
|
|
70
|
+
fruxon run my-agent -t acme-corp -p question="Hello" -p lang=en
|
|
71
|
+
|
|
72
|
+
# Full JSON output (for scripting)
|
|
73
|
+
fruxon run my-agent -t acme-corp --json
|
|
74
|
+
|
|
75
|
+
# Use environment variable for API key
|
|
76
|
+
export FRUXON_API_KEY=frx_...
|
|
77
|
+
fruxon run my-agent -t acme-corp
|
|
78
|
+
```
|
|
79
|
+
|
|
48
80
|
### `fruxon export` — Consolidate multi-file agents
|
|
49
81
|
|
|
50
82
|
Export a multi-file Python agent project into a single file for importing into Fruxon.
|
|
@@ -71,6 +103,3 @@ fruxon export graph.py --copy
|
|
|
71
103
|
3. Traces all local imports using Python's AST (skips third-party packages)
|
|
72
104
|
4. Outputs a single consolidated file with all local code and source markers
|
|
73
105
|
|
|
74
|
-
## Credits
|
|
75
|
-
|
|
76
|
-
Built by [Fruxon](https://fruxon.com).
|
|
@@ -11,9 +11,9 @@ src/__init__.py
|
|
|
11
11
|
src/fruxon/__init__.py
|
|
12
12
|
src/fruxon/__main__.py
|
|
13
13
|
src/fruxon/cli.py
|
|
14
|
+
src/fruxon/exceptions.py
|
|
14
15
|
src/fruxon/export.py
|
|
15
16
|
src/fruxon/fruxon.py
|
|
16
|
-
src/fruxon/utils.py
|
|
17
17
|
src/fruxon.egg-info/PKG-INFO
|
|
18
18
|
src/fruxon.egg-info/SOURCES.txt
|
|
19
19
|
src/fruxon.egg-info/dependency_links.txt
|
|
@@ -21,5 +21,6 @@ src/fruxon.egg-info/entry_points.txt
|
|
|
21
21
|
src/fruxon.egg-info/requires.txt
|
|
22
22
|
src/fruxon.egg-info/top_level.txt
|
|
23
23
|
tests/__init__.py
|
|
24
|
+
tests/test_client.py
|
|
24
25
|
tests/test_export.py
|
|
25
26
|
tests/test_fruxon.py
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Tests for the fruxon API client."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from fruxon.exceptions import (
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
ForbiddenError,
|
|
13
|
+
FruxonAPIError,
|
|
14
|
+
FruxonConnectionError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from fruxon.fruxon import (
|
|
19
|
+
ExecutionResult,
|
|
20
|
+
ExecutionTrace,
|
|
21
|
+
FruxonClient,
|
|
22
|
+
_parse_execution_result,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
SAMPLE_RESPONSE = {
|
|
26
|
+
"response": "Here is your answer.",
|
|
27
|
+
"trace": {
|
|
28
|
+
"agentId": "agent-1",
|
|
29
|
+
"agentRevision": 3,
|
|
30
|
+
"createdAt": 1700000000,
|
|
31
|
+
"parameters": {},
|
|
32
|
+
"startTime": 1700000000,
|
|
33
|
+
"endTime": 1700000005,
|
|
34
|
+
"duration": 5000,
|
|
35
|
+
"traces": [],
|
|
36
|
+
"result": {"strValue": "Here is your answer."},
|
|
37
|
+
"inputCost": 0.001,
|
|
38
|
+
"outputCost": 0.002,
|
|
39
|
+
"totalCost": 0.003,
|
|
40
|
+
},
|
|
41
|
+
"sessionId": "sess-abc",
|
|
42
|
+
"links": [],
|
|
43
|
+
"executionRecordId": "rec-123",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _MockResponse:
|
|
48
|
+
"""Mock urllib response with context manager support."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, data: dict):
|
|
51
|
+
self._data = json.dumps(data).encode("utf-8")
|
|
52
|
+
|
|
53
|
+
def read(self):
|
|
54
|
+
return self._data
|
|
55
|
+
|
|
56
|
+
def __enter__(self):
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *args):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def client():
|
|
65
|
+
return FruxonClient(api_key="test-key", tenant="test-tenant")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestFruxonClientInit:
|
|
69
|
+
def test_stores_config(self):
|
|
70
|
+
c = FruxonClient(api_key="k", tenant="t")
|
|
71
|
+
assert c._api_key == "k"
|
|
72
|
+
assert c._tenant == "t"
|
|
73
|
+
assert c._base_url == "https://api.fruxon.com"
|
|
74
|
+
assert c._timeout == 120.0
|
|
75
|
+
|
|
76
|
+
def test_custom_base_url(self):
|
|
77
|
+
c = FruxonClient(api_key="k", tenant="t", base_url="https://staging.fruxon.com/")
|
|
78
|
+
assert c._base_url == "https://staging.fruxon.com"
|
|
79
|
+
|
|
80
|
+
def test_custom_timeout(self):
|
|
81
|
+
c = FruxonClient(api_key="k", tenant="t", timeout=30.0)
|
|
82
|
+
assert c._timeout == 30.0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestFruxonClientExecute:
|
|
86
|
+
def test_sends_correct_request(self, client, monkeypatch):
|
|
87
|
+
"""Verify URL, headers, and body are correctly constructed."""
|
|
88
|
+
captured = {}
|
|
89
|
+
|
|
90
|
+
def mock_urlopen(req, timeout=None):
|
|
91
|
+
captured["url"] = req.full_url
|
|
92
|
+
captured["method"] = req.method
|
|
93
|
+
captured["headers"] = dict(req.headers)
|
|
94
|
+
captured["body"] = json.loads(req.data.decode("utf-8"))
|
|
95
|
+
captured["timeout"] = timeout
|
|
96
|
+
return _MockResponse(SAMPLE_RESPONSE)
|
|
97
|
+
|
|
98
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
99
|
+
|
|
100
|
+
client.execute("my-agent", parameters={"q": "hello"}, session_id="sess-1")
|
|
101
|
+
|
|
102
|
+
assert captured["url"] == "https://api.fruxon.com/v1/tenants/test-tenant/agents/my-agent:execute"
|
|
103
|
+
assert captured["method"] == "POST"
|
|
104
|
+
assert captured["headers"]["Content-type"] == "application/json"
|
|
105
|
+
assert captured["headers"]["X-api-key"] == "test-key"
|
|
106
|
+
assert captured["body"] == {"parameters": {"q": "hello"}, "sessionId": "sess-1"}
|
|
107
|
+
assert captured["timeout"] == 120.0
|
|
108
|
+
|
|
109
|
+
def test_omits_none_fields(self, client, monkeypatch):
|
|
110
|
+
"""Only non-None fields appear in the request body."""
|
|
111
|
+
captured = {}
|
|
112
|
+
|
|
113
|
+
def mock_urlopen(req, timeout=None):
|
|
114
|
+
captured["body"] = json.loads(req.data.decode("utf-8"))
|
|
115
|
+
return _MockResponse(SAMPLE_RESPONSE)
|
|
116
|
+
|
|
117
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
118
|
+
|
|
119
|
+
client.execute("my-agent")
|
|
120
|
+
assert captured["body"] == {}
|
|
121
|
+
|
|
122
|
+
def test_maps_chat_user(self, client, monkeypatch):
|
|
123
|
+
captured = {}
|
|
124
|
+
|
|
125
|
+
def mock_urlopen(req, timeout=None):
|
|
126
|
+
captured["body"] = json.loads(req.data.decode("utf-8"))
|
|
127
|
+
return _MockResponse(SAMPLE_RESPONSE)
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
130
|
+
|
|
131
|
+
client.execute("my-agent", chat_user={"id": "user-1", "name": "Alice"})
|
|
132
|
+
assert captured["body"]["chatUser"] == {"id": "user-1", "name": "Alice"}
|
|
133
|
+
|
|
134
|
+
def test_returns_execution_result(self, client, monkeypatch):
|
|
135
|
+
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: _MockResponse(SAMPLE_RESPONSE))
|
|
136
|
+
|
|
137
|
+
result = client.execute("my-agent")
|
|
138
|
+
|
|
139
|
+
assert isinstance(result, ExecutionResult)
|
|
140
|
+
assert result.response == "Here is your answer."
|
|
141
|
+
assert result.session_id == "sess-abc"
|
|
142
|
+
assert result.execution_record_id == "rec-123"
|
|
143
|
+
assert isinstance(result.trace, ExecutionTrace)
|
|
144
|
+
assert result.trace.agent_id == "agent-1"
|
|
145
|
+
assert result.trace.agent_revision == 3
|
|
146
|
+
assert result.trace.duration == 5000
|
|
147
|
+
assert result.trace.input_cost == 0.001
|
|
148
|
+
assert result.trace.output_cost == 0.002
|
|
149
|
+
assert result.trace.total_cost == 0.003
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestErrorHandling:
|
|
153
|
+
def _make_http_error(self, status: int, body: dict | None = None):
|
|
154
|
+
if body is None:
|
|
155
|
+
body = {"title": "Test Error", "detail": "Something went wrong"}
|
|
156
|
+
return urllib.error.HTTPError(
|
|
157
|
+
url="https://api.fruxon.com/v1/test",
|
|
158
|
+
code=status,
|
|
159
|
+
msg="Error",
|
|
160
|
+
hdrs=None,
|
|
161
|
+
fp=io.BytesIO(json.dumps(body).encode("utf-8")),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def test_401_raises_authentication_error(self, client, monkeypatch):
|
|
165
|
+
def mock_urlopen(req, timeout=None):
|
|
166
|
+
raise self._make_http_error(401)
|
|
167
|
+
|
|
168
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
169
|
+
|
|
170
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
171
|
+
client.execute("my-agent")
|
|
172
|
+
assert exc_info.value.status == 401
|
|
173
|
+
|
|
174
|
+
def test_403_raises_forbidden_error(self, client, monkeypatch):
|
|
175
|
+
def mock_urlopen(req, timeout=None):
|
|
176
|
+
raise self._make_http_error(403)
|
|
177
|
+
|
|
178
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
179
|
+
|
|
180
|
+
with pytest.raises(ForbiddenError) as exc_info:
|
|
181
|
+
client.execute("my-agent")
|
|
182
|
+
assert exc_info.value.status == 403
|
|
183
|
+
|
|
184
|
+
def test_404_raises_not_found_error(self, client, monkeypatch):
|
|
185
|
+
def mock_urlopen(req, timeout=None):
|
|
186
|
+
raise self._make_http_error(404)
|
|
187
|
+
|
|
188
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
189
|
+
|
|
190
|
+
with pytest.raises(NotFoundError) as exc_info:
|
|
191
|
+
client.execute("my-agent")
|
|
192
|
+
assert exc_info.value.status == 404
|
|
193
|
+
|
|
194
|
+
def test_400_raises_validation_error(self, client, monkeypatch):
|
|
195
|
+
def mock_urlopen(req, timeout=None):
|
|
196
|
+
raise self._make_http_error(400)
|
|
197
|
+
|
|
198
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
199
|
+
|
|
200
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
201
|
+
client.execute("my-agent")
|
|
202
|
+
assert exc_info.value.status == 400
|
|
203
|
+
|
|
204
|
+
def test_422_raises_validation_error(self, client, monkeypatch):
|
|
205
|
+
def mock_urlopen(req, timeout=None):
|
|
206
|
+
raise self._make_http_error(422)
|
|
207
|
+
|
|
208
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
209
|
+
|
|
210
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
211
|
+
client.execute("my-agent")
|
|
212
|
+
assert exc_info.value.status == 422
|
|
213
|
+
|
|
214
|
+
def test_unknown_status_raises_api_error(self, client, monkeypatch):
|
|
215
|
+
def mock_urlopen(req, timeout=None):
|
|
216
|
+
raise self._make_http_error(500)
|
|
217
|
+
|
|
218
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
219
|
+
|
|
220
|
+
with pytest.raises(FruxonAPIError) as exc_info:
|
|
221
|
+
client.execute("my-agent")
|
|
222
|
+
assert exc_info.value.status == 500
|
|
223
|
+
|
|
224
|
+
def test_malformed_error_body(self, client, monkeypatch):
|
|
225
|
+
"""Non-JSON error response falls back gracefully."""
|
|
226
|
+
error = urllib.error.HTTPError(
|
|
227
|
+
url="https://api.fruxon.com/v1/test",
|
|
228
|
+
code=502,
|
|
229
|
+
msg="Bad Gateway",
|
|
230
|
+
hdrs=None,
|
|
231
|
+
fp=io.BytesIO(b"<html>Bad Gateway</html>"),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: (_ for _ in ()).throw(error))
|
|
235
|
+
|
|
236
|
+
with pytest.raises(FruxonAPIError) as exc_info:
|
|
237
|
+
client.execute("my-agent")
|
|
238
|
+
assert exc_info.value.status == 502
|
|
239
|
+
assert exc_info.value.title == "Error"
|
|
240
|
+
|
|
241
|
+
def test_network_error_raises_connection_error(self, client, monkeypatch):
|
|
242
|
+
def mock_urlopen(req, timeout=None):
|
|
243
|
+
raise urllib.error.URLError("Connection refused")
|
|
244
|
+
|
|
245
|
+
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
|
246
|
+
|
|
247
|
+
with pytest.raises(FruxonConnectionError, match="Connection refused"):
|
|
248
|
+
client.execute("my-agent")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TestParseResult:
|
|
252
|
+
def test_full_response(self):
|
|
253
|
+
result = _parse_execution_result(SAMPLE_RESPONSE)
|
|
254
|
+
assert result.response == "Here is your answer."
|
|
255
|
+
assert result.session_id == "sess-abc"
|
|
256
|
+
assert result.execution_record_id == "rec-123"
|
|
257
|
+
assert result.trace.agent_id == "agent-1"
|
|
258
|
+
assert result.trace.duration == 5000
|
|
259
|
+
assert result.trace.total_cost == 0.003
|
|
260
|
+
|
|
261
|
+
def test_missing_fields_default(self):
|
|
262
|
+
result = _parse_execution_result({})
|
|
263
|
+
assert result.response == ""
|
|
264
|
+
assert result.session_id == ""
|
|
265
|
+
assert result.execution_record_id == ""
|
|
266
|
+
assert result.trace.agent_id == ""
|
|
267
|
+
assert result.trace.duration == 0
|
|
268
|
+
assert result.trace.total_cost == 0.0
|
fruxon-0.2.0/src/fruxon/cli.py
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
"""Console script for fruxon-sdk."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Annotated
|
|
5
|
-
|
|
6
|
-
import typer
|
|
7
|
-
from rich.console import Console
|
|
8
|
-
from rich.prompt import IntPrompt
|
|
9
|
-
|
|
10
|
-
from fruxon.export import MultipleAgentsError, export_agent
|
|
11
|
-
|
|
12
|
-
app = typer.Typer(help="Fruxon CLI - tools for working with the Fruxon platform.")
|
|
13
|
-
console = Console()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@app.callback()
|
|
17
|
-
def main():
|
|
18
|
-
"""Fruxon CLI - tools for working with the Fruxon platform."""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@app.command()
|
|
22
|
-
def export(
|
|
23
|
-
entry_point: Annotated[
|
|
24
|
-
Path | None, typer.Argument(help="Path to the main Python file. Auto-detected if omitted.")
|
|
25
|
-
] = None,
|
|
26
|
-
output: Annotated[
|
|
27
|
-
Path | None, typer.Option("--output", "-o", help="Write output to file instead of stdout")
|
|
28
|
-
] = None,
|
|
29
|
-
copy: Annotated[bool, typer.Option("--copy", "-c", help="Copy output to clipboard")] = False,
|
|
30
|
-
):
|
|
31
|
-
"""Export a multi-file Python agent into a single file for Fruxon import.
|
|
32
|
-
|
|
33
|
-
Auto-detects the agent entry point by scanning for framework imports
|
|
34
|
-
(LangChain, CrewAI, Google ADK, etc.). You can also specify the entry
|
|
35
|
-
point explicitly.
|
|
36
|
-
|
|
37
|
-
Examples:
|
|
38
|
-
fruxon export
|
|
39
|
-
fruxon export graph.py
|
|
40
|
-
fruxon export my_agent/main.py -o export.py
|
|
41
|
-
fruxon export --copy
|
|
42
|
-
"""
|
|
43
|
-
try:
|
|
44
|
-
result = export_agent(str(entry_point) if entry_point else None, str(output) if output else None)
|
|
45
|
-
except MultipleAgentsError as e:
|
|
46
|
-
# Prompt user to select which agent to export
|
|
47
|
-
choice = IntPrompt.ask(
|
|
48
|
-
"\nWhich agent do you want to export?",
|
|
49
|
-
choices=[str(i) for i in range(1, len(e.entry_points) + 1)],
|
|
50
|
-
)
|
|
51
|
-
selected_path = e.entry_points[choice - 1][0]
|
|
52
|
-
result = export_agent(str(selected_path), str(output) if output else None)
|
|
53
|
-
|
|
54
|
-
_handle_output(result, output, copy)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _handle_output(result: str, output: Path | None, copy: bool):
|
|
58
|
-
"""Handle clipboard copy and stdout output."""
|
|
59
|
-
if copy:
|
|
60
|
-
_copy_to_clipboard(result)
|
|
61
|
-
|
|
62
|
-
if not output and not copy:
|
|
63
|
-
console.print(result)
|
|
64
|
-
elif not output and copy:
|
|
65
|
-
lines = result.count("\n") + 1
|
|
66
|
-
console.print(f"[dim]{lines} lines ready to paste into Fruxon.[/dim]")
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _copy_to_clipboard(text: str):
|
|
70
|
-
"""Copy text to system clipboard."""
|
|
71
|
-
import subprocess
|
|
72
|
-
|
|
73
|
-
for cmd in [["pbcopy"], ["xclip", "-selection", "clipboard"]]:
|
|
74
|
-
try:
|
|
75
|
-
process = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
|
76
|
-
process.communicate(text.encode("utf-8"))
|
|
77
|
-
console.print("[green]Copied to clipboard.[/green]")
|
|
78
|
-
return
|
|
79
|
-
except FileNotFoundError:
|
|
80
|
-
continue
|
|
81
|
-
console.print("[yellow]Clipboard not available. Install xclip or use -o to write to file.[/yellow]")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if __name__ == "__main__":
|
|
85
|
-
app()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Main module."""
|
fruxon-0.2.0/src/fruxon/utils.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|