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.
Files changed (32) hide show
  1. {fruxon-0.2.0 → fruxon-0.3.1}/PKG-INFO +33 -4
  2. {fruxon-0.2.0 → fruxon-0.3.1}/README.md +32 -3
  3. {fruxon-0.2.0 → fruxon-0.3.1}/pyproject.toml +1 -1
  4. fruxon-0.3.1/src/fruxon/__init__.py +28 -0
  5. fruxon-0.3.1/src/fruxon/cli.py +165 -0
  6. fruxon-0.3.1/src/fruxon/exceptions.py +45 -0
  7. {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon/export.py +35 -19
  8. fruxon-0.3.1/src/fruxon/fruxon.py +150 -0
  9. {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/PKG-INFO +33 -4
  10. {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/SOURCES.txt +2 -1
  11. fruxon-0.3.1/tests/test_client.py +268 -0
  12. fruxon-0.2.0/src/fruxon/__init__.py +0 -4
  13. fruxon-0.2.0/src/fruxon/cli.py +0 -85
  14. fruxon-0.2.0/src/fruxon/fruxon.py +0 -1
  15. fruxon-0.2.0/src/fruxon/utils.py +0 -2
  16. {fruxon-0.2.0 → fruxon-0.3.1}/CONTRIBUTING.md +0 -0
  17. {fruxon-0.2.0 → fruxon-0.3.1}/HISTORY.md +0 -0
  18. {fruxon-0.2.0 → fruxon-0.3.1}/LICENSE +0 -0
  19. {fruxon-0.2.0 → fruxon-0.3.1}/MANIFEST.in +0 -0
  20. {fruxon-0.2.0 → fruxon-0.3.1}/docs/index.md +0 -0
  21. {fruxon-0.2.0 → fruxon-0.3.1}/docs/installation.md +0 -0
  22. {fruxon-0.2.0 → fruxon-0.3.1}/docs/usage.md +0 -0
  23. {fruxon-0.2.0 → fruxon-0.3.1}/setup.cfg +0 -0
  24. {fruxon-0.2.0 → fruxon-0.3.1}/src/__init__.py +0 -0
  25. {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon/__main__.py +0 -0
  26. {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/dependency_links.txt +0 -0
  27. {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/entry_points.txt +0 -0
  28. {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/requires.txt +0 -0
  29. {fruxon-0.2.0 → fruxon-0.3.1}/src/fruxon.egg-info/top_level.txt +0 -0
  30. {fruxon-0.2.0 → fruxon-0.3.1}/tests/__init__.py +0 -0
  31. {fruxon-0.2.0 → fruxon-0.3.1}/tests/test_export.py +0 -0
  32. {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.2.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).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fruxon"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "The Fruxon SDK is a lightweight Python client for integrating with the Fruxon platform."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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(entry_path: str | None = None, output_path: str | None = None) -> str:
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
- print(f"Error: File not found: {entry_file}", file=sys.stderr)
311
+ _err(f"File not found: {entry_file}")
289
312
  raise SystemExit(1)
290
313
 
291
314
  if not entry_file.suffix == ".py":
292
- print(f"Error: Entry point must be a .py file, got: {entry_file.suffix}", file=sys.stderr)
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
- print(
303
- "Error: No agent framework detected. "
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
- print(f"Detected {framework} agent in {entry_file.relative_to(project_root)}", file=sys.stderr)
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
- print("Multiple agents detected:", file=sys.stderr)
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
- print(f" {i}. {relative} ({fw})", file=sys.stderr)
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
- print(f"Exported to {out}", file=sys.stderr)
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.2.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
@@ -1,4 +0,0 @@
1
- """Top-level package for fruxon-sdk."""
2
-
3
- __author__ = "Hagai Cohen"
4
- __email__ = "hagai@fruxon.com"
@@ -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."""
@@ -1,2 +0,0 @@
1
- def do_something_useful():
2
- print("Replace this with a utility function")
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