genxai-framework 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. cli/commands/__init__.py +3 -1
  2. cli/commands/connector.py +309 -0
  3. cli/commands/workflow.py +80 -0
  4. cli/main.py +3 -1
  5. genxai/__init__.py +33 -0
  6. genxai/agents/__init__.py +8 -0
  7. genxai/agents/presets.py +53 -0
  8. genxai/connectors/__init__.py +10 -0
  9. genxai/connectors/config_store.py +106 -0
  10. genxai/connectors/github.py +117 -0
  11. genxai/connectors/google_workspace.py +124 -0
  12. genxai/connectors/jira.py +108 -0
  13. genxai/connectors/notion.py +97 -0
  14. genxai/connectors/slack.py +121 -0
  15. genxai/core/agent/config_io.py +32 -1
  16. genxai/core/agent/runtime.py +41 -4
  17. genxai/core/graph/__init__.py +3 -0
  18. genxai/core/graph/engine.py +218 -11
  19. genxai/core/graph/executor.py +103 -10
  20. genxai/core/graph/nodes.py +28 -0
  21. genxai/core/graph/workflow_io.py +199 -0
  22. genxai/flows/__init__.py +33 -0
  23. genxai/flows/auction.py +66 -0
  24. genxai/flows/base.py +134 -0
  25. genxai/flows/conditional.py +45 -0
  26. genxai/flows/coordinator_worker.py +62 -0
  27. genxai/flows/critic_review.py +62 -0
  28. genxai/flows/ensemble_voting.py +49 -0
  29. genxai/flows/loop.py +42 -0
  30. genxai/flows/map_reduce.py +61 -0
  31. genxai/flows/p2p.py +146 -0
  32. genxai/flows/parallel.py +27 -0
  33. genxai/flows/round_robin.py +24 -0
  34. genxai/flows/router.py +45 -0
  35. genxai/flows/selector.py +63 -0
  36. genxai/flows/subworkflow.py +35 -0
  37. genxai/llm/factory.py +17 -10
  38. genxai/llm/providers/anthropic.py +116 -1
  39. genxai/tools/builtin/__init__.py +3 -0
  40. genxai/tools/builtin/communication/human_input.py +32 -0
  41. genxai/tools/custom/test-2.py +19 -0
  42. genxai/tools/custom/test_tool_ui.py +9 -0
  43. genxai/tools/persistence/service.py +3 -3
  44. genxai/utils/tokens.py +6 -0
  45. {genxai_framework-0.1.0.dist-info → genxai_framework-0.1.1.dist-info}/METADATA +63 -12
  46. {genxai_framework-0.1.0.dist-info → genxai_framework-0.1.1.dist-info}/RECORD +50 -21
  47. {genxai_framework-0.1.0.dist-info → genxai_framework-0.1.1.dist-info}/WHEEL +0 -0
  48. {genxai_framework-0.1.0.dist-info → genxai_framework-0.1.1.dist-info}/entry_points.txt +0 -0
  49. {genxai_framework-0.1.0.dist-info → genxai_framework-0.1.1.dist-info}/licenses/LICENSE +0 -0
  50. {genxai_framework-0.1.0.dist-info → genxai_framework-0.1.1.dist-info}/top_level.txt +0 -0
cli/commands/__init__.py CHANGED
@@ -2,5 +2,7 @@
2
2
 
3
3
  from cli.commands.tool import tool
4
4
  from cli.commands.metrics import metrics
5
+ from cli.commands.connector import connector
6
+ from cli.commands.workflow import workflow
5
7
 
6
- __all__ = ["tool", "metrics"]
8
+ __all__ = ["tool", "metrics", "connector", "workflow"]
@@ -0,0 +1,309 @@
1
+ """Connector management CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any, Dict, Optional
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from genxai.connectors import (
14
+ Connector,
15
+ KafkaConnector,
16
+ SQSConnector,
17
+ PostgresCDCConnector,
18
+ WebhookConnector,
19
+ SlackConnector,
20
+ GitHubConnector,
21
+ NotionConnector,
22
+ JiraConnector,
23
+ GoogleWorkspaceConnector,
24
+ )
25
+ from genxai.connectors.config_store import ConnectorConfigEntry, ConnectorConfigStore
26
+
27
+ console = Console()
28
+
29
+
30
+ CONNECTOR_CATALOG: Dict[str, Dict[str, Any]] = {
31
+ "kafka": {
32
+ "class": KafkaConnector,
33
+ "required": ["topic", "bootstrap_servers"],
34
+ "description": "Kafka consumer connector",
35
+ },
36
+ "sqs": {
37
+ "class": SQSConnector,
38
+ "required": ["queue_url"],
39
+ "description": "AWS SQS connector",
40
+ },
41
+ "postgres_cdc": {
42
+ "class": PostgresCDCConnector,
43
+ "required": ["dsn"],
44
+ "description": "Postgres CDC connector",
45
+ },
46
+ "webhook": {
47
+ "class": WebhookConnector,
48
+ "required": [],
49
+ "description": "Inbound webhook connector",
50
+ },
51
+ "slack": {
52
+ "class": SlackConnector,
53
+ "required": ["bot_token"],
54
+ "description": "Slack Web API connector",
55
+ },
56
+ "github": {
57
+ "class": GitHubConnector,
58
+ "required": ["token"],
59
+ "description": "GitHub REST API connector",
60
+ },
61
+ "notion": {
62
+ "class": NotionConnector,
63
+ "required": ["token"],
64
+ "description": "Notion API connector",
65
+ },
66
+ "jira": {
67
+ "class": JiraConnector,
68
+ "required": ["email", "api_token", "base_url"],
69
+ "description": "Jira Cloud REST API connector",
70
+ },
71
+ "google_workspace": {
72
+ "class": GoogleWorkspaceConnector,
73
+ "required": ["access_token"],
74
+ "description": "Google Workspace APIs connector",
75
+ },
76
+ }
77
+
78
+
79
+ @click.group()
80
+ def connector() -> None:
81
+ """Manage GenXAI connectors."""
82
+ pass
83
+
84
+
85
+ @connector.command("list")
86
+ @click.option("--format", "output_format", type=click.Choice(["table", "json"]), default="table")
87
+ def list_connectors(output_format: str) -> None:
88
+ """List available connector types."""
89
+ if output_format == "json":
90
+ payload = {
91
+ name: {
92
+ "required": meta["required"],
93
+ "description": meta["description"],
94
+ }
95
+ for name, meta in CONNECTOR_CATALOG.items()
96
+ }
97
+ click.echo(json.dumps(payload, indent=2))
98
+ return
99
+
100
+ table = Table(title="GenXAI Connectors")
101
+ table.add_column("Type", style="cyan")
102
+ table.add_column("Required Fields", style="green")
103
+ table.add_column("Description", style="white")
104
+ for name, meta in CONNECTOR_CATALOG.items():
105
+ table.add_row(name, ", ".join(meta["required"]) or "(none)", meta["description"])
106
+ console.print(table)
107
+
108
+
109
+ @connector.command("validate")
110
+ @click.option("--type", "connector_type", required=True, help="Connector type")
111
+ @click.option("--connector-id", default="connector", show_default=True)
112
+ @click.option("--config", help="JSON config payload for the connector")
113
+ @click.option("--config-name", help="Use a saved connector config")
114
+ def validate(
115
+ connector_type: str,
116
+ connector_id: str,
117
+ config: Optional[str],
118
+ config_name: Optional[str],
119
+ ) -> None:
120
+ """Validate connector configuration without starting it."""
121
+ connector_meta = CONNECTOR_CATALOG.get(connector_type)
122
+ if not connector_meta:
123
+ raise click.ClickException(
124
+ f"Unknown connector type '{connector_type}'. Use 'genxai connector list' to see options."
125
+ )
126
+
127
+ config_data = _load_config(config, config_name)
128
+ missing = [field for field in connector_meta["required"] if field not in config_data]
129
+ if missing:
130
+ raise click.ClickException(f"Missing required fields: {', '.join(missing)}")
131
+
132
+ connector_instance = _build_connector(connector_id, connector_meta["class"], config_data)
133
+ try:
134
+ asyncio.run(connector_instance.validate_config())
135
+ console.print("[green]✓ Connector configuration valid[/green]")
136
+ except Exception as exc:
137
+ raise click.ClickException(str(exc)) from exc
138
+
139
+
140
+ @connector.command("start")
141
+ @click.option("--type", "connector_type", required=True, help="Connector type")
142
+ @click.option("--connector-id", default="connector", show_default=True)
143
+ @click.option("--config", help="JSON config payload for the connector")
144
+ @click.option("--config-name", help="Use a saved connector config")
145
+ def start(connector_type: str, connector_id: str, config: Optional[str], config_name: Optional[str]) -> None:
146
+ """Start a connector instance for quick validation."""
147
+ connector_instance = _build_from_cli(connector_type, connector_id, config, config_name)
148
+ try:
149
+ asyncio.run(connector_instance.start())
150
+ console.print("[green]✓ Connector started[/green]")
151
+ except Exception as exc:
152
+ raise click.ClickException(str(exc)) from exc
153
+
154
+
155
+ @connector.command("stop")
156
+ @click.option("--type", "connector_type", required=True, help="Connector type")
157
+ @click.option("--connector-id", default="connector", show_default=True)
158
+ @click.option("--config", help="JSON config payload for the connector")
159
+ @click.option("--config-name", help="Use a saved connector config")
160
+ def stop(connector_type: str, connector_id: str, config: Optional[str], config_name: Optional[str]) -> None:
161
+ """Stop a connector instance for quick validation."""
162
+ connector_instance = _build_from_cli(connector_type, connector_id, config, config_name)
163
+ try:
164
+ asyncio.run(connector_instance.stop())
165
+ console.print("[green]✓ Connector stopped[/green]")
166
+ except Exception as exc:
167
+ raise click.ClickException(str(exc)) from exc
168
+
169
+
170
+ @connector.command("health")
171
+ @click.option("--type", "connector_type", required=True, help="Connector type")
172
+ @click.option("--connector-id", default="connector", show_default=True)
173
+ @click.option("--config", help="JSON config payload for the connector")
174
+ @click.option("--format", "output_format", type=click.Choice(["json", "table"]), default="json")
175
+ @click.option("--config-name", help="Use a saved connector config")
176
+ def health(
177
+ connector_type: str,
178
+ connector_id: str,
179
+ config: Optional[str],
180
+ output_format: str,
181
+ config_name: Optional[str],
182
+ ) -> None:
183
+ """Run a connector health check without starting it."""
184
+ connector_instance = _build_from_cli(connector_type, connector_id, config, config_name)
185
+ try:
186
+ payload = asyncio.run(connector_instance.health_check())
187
+ except Exception as exc:
188
+ raise click.ClickException(str(exc)) from exc
189
+
190
+ if output_format == "json":
191
+ click.echo(json.dumps(payload, indent=2))
192
+ return
193
+
194
+ table = Table(title="Connector Health")
195
+ table.add_column("Field", style="cyan")
196
+ table.add_column("Value", style="white")
197
+ for key, value in payload.items():
198
+ table.add_row(str(key), str(value))
199
+ console.print(table)
200
+
201
+
202
+ @connector.command("save")
203
+ @click.option("--name", "config_name", required=True, help="Name for the saved config")
204
+ @click.option("--type", "connector_type", required=True, help="Connector type")
205
+ @click.option("--config", required=True, help="JSON config payload")
206
+ def save(config_name: str, connector_type: str, config: str) -> None:
207
+ """Save a connector config for reuse."""
208
+ connector_meta = CONNECTOR_CATALOG.get(connector_type)
209
+ if not connector_meta:
210
+ raise click.ClickException(
211
+ f"Unknown connector type '{connector_type}'. Use 'genxai connector list' to see options."
212
+ )
213
+ config_data = _load_config(config, None)
214
+ missing = [field for field in connector_meta["required"] if field not in config_data]
215
+ if missing:
216
+ raise click.ClickException(f"Missing required fields: {', '.join(missing)}")
217
+
218
+ store = ConnectorConfigStore()
219
+ store.save(
220
+ ConnectorConfigEntry(
221
+ name=config_name,
222
+ connector_type=connector_type,
223
+ config=config_data,
224
+ )
225
+ )
226
+ console.print(f"[green]✓ Saved connector config '{config_name}'[/green]")
227
+
228
+
229
+ @connector.command("saved")
230
+ @click.option("--format", "output_format", type=click.Choice(["table", "json"]), default="table")
231
+ def list_saved(output_format: str) -> None:
232
+ """List saved connector configs."""
233
+ store = ConnectorConfigStore()
234
+ entries = store.list()
235
+ if output_format == "json":
236
+ payload = {
237
+ name: {"connector_type": entry.connector_type, "config": entry.config}
238
+ for name, entry in entries.items()
239
+ }
240
+ click.echo(json.dumps(payload, indent=2))
241
+ return
242
+
243
+ table = Table(title="Saved Connector Configs")
244
+ table.add_column("Name", style="cyan")
245
+ table.add_column("Type", style="green")
246
+ table.add_column("Config", style="white")
247
+ for name, entry in entries.items():
248
+ table.add_row(name, entry.connector_type, json.dumps(entry.config))
249
+ console.print(table)
250
+
251
+
252
+ @connector.command("remove")
253
+ @click.option("--name", "config_name", required=True, help="Saved config name")
254
+ def remove(config_name: str) -> None:
255
+ """Remove a saved connector config."""
256
+ store = ConnectorConfigStore()
257
+ if not store.delete(config_name):
258
+ raise click.ClickException(f"Config '{config_name}' not found")
259
+ console.print(f"[green]✓ Removed connector config '{config_name}'[/green]")
260
+
261
+
262
+ @connector.command("keygen")
263
+ def keygen() -> None:
264
+ """Generate a Fernet key for encrypted connector configs."""
265
+ try:
266
+ from cryptography.fernet import Fernet
267
+ except ImportError as exc:
268
+ raise click.ClickException(
269
+ "cryptography is required for key generation. Install with: pip install cryptography"
270
+ ) from exc
271
+ key = Fernet.generate_key().decode()
272
+ click.echo(key)
273
+
274
+
275
+ def _build_connector(connector_id: str, connector_class: type[Connector], config: Dict[str, Any]) -> Connector:
276
+ params = {"connector_id": connector_id, **config}
277
+ return connector_class(**params)
278
+
279
+
280
+ def _build_from_cli(
281
+ connector_type: str,
282
+ connector_id: str,
283
+ config: Optional[str],
284
+ config_name: Optional[str],
285
+ ) -> Connector:
286
+ connector_meta = CONNECTOR_CATALOG.get(connector_type)
287
+ if not connector_meta:
288
+ raise click.ClickException(
289
+ f"Unknown connector type '{connector_type}'. Use 'genxai connector list' to see options."
290
+ )
291
+ config_data = _load_config(config, config_name)
292
+ missing = [field for field in connector_meta["required"] if field not in config_data]
293
+ if missing:
294
+ raise click.ClickException(f"Missing required fields: {', '.join(missing)}")
295
+ return _build_connector(connector_id, connector_meta["class"], config_data)
296
+
297
+
298
+ def _load_config(config: Optional[str], config_name: Optional[str]) -> Dict[str, Any]:
299
+ if config_name:
300
+ store = ConnectorConfigStore()
301
+ entry = store.get(config_name)
302
+ if not entry:
303
+ raise click.ClickException(f"Config '{config_name}' not found")
304
+ return entry.config
305
+ return json.loads(config) if config else {}
306
+
307
+
308
+ if __name__ == "__main__":
309
+ connector()
@@ -0,0 +1,80 @@
1
+ """Workflow CLI commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict
5
+
6
+ import click
7
+
8
+ from genxai.core.graph import load_workflow_yaml, register_workflow_agents
9
+ from genxai.core.graph.executor import WorkflowExecutor
10
+
11
+
12
+ @click.group()
13
+ def workflow() -> None:
14
+ """Manage and run workflows."""
15
+
16
+
17
+ @workflow.command("run")
18
+ @click.argument("workflow_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
19
+ @click.option("--input", "input_payload", required=True, help="JSON input payload")
20
+ def run_workflow(workflow_path: Path, input_payload: str) -> None:
21
+ """Run a workflow from a YAML file."""
22
+ import json
23
+
24
+ try:
25
+ workflow = load_workflow_yaml(workflow_path)
26
+ except (ValueError, FileNotFoundError) as exc:
27
+ raise click.ClickException(str(exc)) from exc
28
+ register_workflow_agents(workflow)
29
+
30
+ nodes = _build_nodes_from_workflow(workflow)
31
+ edges = _build_edges_from_workflow(workflow)
32
+
33
+ executor = WorkflowExecutor()
34
+ input_data = json.loads(input_payload)
35
+ shared_memory = workflow.get("memory", {}).get("shared", False)
36
+
37
+ result = _run_executor(executor, nodes, edges, input_data, shared_memory=shared_memory)
38
+ click.echo(json.dumps(result, indent=2))
39
+
40
+
41
+ def _build_nodes_from_workflow(workflow: Dict[str, Any]):
42
+ nodes = workflow.get("graph", {}).get("nodes", [])
43
+ if not isinstance(nodes, list):
44
+ raise click.ClickException("workflow.graph.nodes must be a list")
45
+ return nodes
46
+
47
+
48
+ def _build_edges_from_workflow(workflow: Dict[str, Any]):
49
+ edges = workflow.get("graph", {}).get("edges", [])
50
+ if not isinstance(edges, list):
51
+ raise click.ClickException("workflow.graph.edges must be a list")
52
+ # Map YAML edge keys to executor expectations.
53
+ return [
54
+ {
55
+ "source": edge.get("from"),
56
+ "target": edge.get("to"),
57
+ "condition": edge.get("condition"),
58
+ }
59
+ for edge in edges
60
+ ]
61
+
62
+
63
+ def _run_executor(
64
+ executor: WorkflowExecutor,
65
+ nodes,
66
+ edges,
67
+ input_data,
68
+ shared_memory: bool = False,
69
+ ):
70
+ import asyncio
71
+
72
+ async def _execute():
73
+ return await executor.execute(
74
+ nodes=nodes,
75
+ edges=edges,
76
+ input_data=input_data,
77
+ shared_memory=shared_memory,
78
+ )
79
+
80
+ return asyncio.run(_execute())
cli/main.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """GenXAI CLI - Main entry point."""
2
2
 
3
3
  import click
4
- from cli.commands import tool, metrics
4
+ from cli.commands import tool, metrics, connector, workflow
5
5
  from cli.commands.approval import approval
6
6
  from cli.commands.audit import audit
7
7
 
@@ -19,6 +19,8 @@ def cli():
19
19
  # Register command groups
20
20
  cli.add_command(tool)
21
21
  cli.add_command(metrics)
22
+ cli.add_command(connector)
23
+ cli.add_command(workflow)
22
24
  cli.add_command(approval)
23
25
  cli.add_command(audit)
24
26
 
genxai/__init__.py CHANGED
@@ -17,6 +17,7 @@ from genxai.core.agent import (
17
17
  AgentRuntime,
18
18
  AgentType,
19
19
  )
20
+ from genxai.agents import AssistantAgent, UserProxyAgent
20
21
  from genxai.core.graph import (
21
22
  Edge,
22
23
  EnhancedGraph,
@@ -27,6 +28,22 @@ from genxai.core.graph import (
27
28
  WorkflowExecutor,
28
29
  execute_workflow_sync,
29
30
  )
31
+ from genxai.flows import (
32
+ FlowOrchestrator,
33
+ RoundRobinFlow,
34
+ SelectorFlow,
35
+ P2PFlow,
36
+ ParallelFlow,
37
+ ConditionalFlow,
38
+ LoopFlow,
39
+ RouterFlow,
40
+ EnsembleVotingFlow,
41
+ CriticReviewFlow,
42
+ CoordinatorWorkerFlow,
43
+ MapReduceFlow,
44
+ SubworkflowFlow,
45
+ AuctionFlow,
46
+ )
30
47
  from genxai.core.memory.manager import MemorySystem
31
48
  from genxai.tools import (
32
49
  DynamicTool,
@@ -55,6 +72,8 @@ __all__ = [
55
72
  "AgentRegistry",
56
73
  "AgentRuntime",
57
74
  "AgentType",
75
+ "AssistantAgent",
76
+ "UserProxyAgent",
58
77
  "Graph",
59
78
  "EnhancedGraph",
60
79
  "WorkflowExecutor",
@@ -71,6 +90,20 @@ __all__ = [
71
90
  "ToolResult",
72
91
  "DynamicTool",
73
92
  "MemorySystem",
93
+ "FlowOrchestrator",
94
+ "RoundRobinFlow",
95
+ "SelectorFlow",
96
+ "P2PFlow",
97
+ "ParallelFlow",
98
+ "ConditionalFlow",
99
+ "LoopFlow",
100
+ "RouterFlow",
101
+ "EnsembleVotingFlow",
102
+ "CriticReviewFlow",
103
+ "CoordinatorWorkerFlow",
104
+ "MapReduceFlow",
105
+ "SubworkflowFlow",
106
+ "AuctionFlow",
74
107
  "BaseTrigger",
75
108
  "TriggerEvent",
76
109
  "TriggerRegistry",
@@ -0,0 +1,8 @@
1
+ """Preset agent wrappers and convenience helpers."""
2
+
3
+ from genxai.agents.presets import AssistantAgent, UserProxyAgent
4
+
5
+ __all__ = [
6
+ "AssistantAgent",
7
+ "UserProxyAgent",
8
+ ]
@@ -0,0 +1,53 @@
1
+ """Preset agent wrappers for convenience usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from genxai.core.agent.base import Agent, AgentFactory, AgentType
8
+
9
+
10
+ class AssistantAgent(Agent):
11
+ """Thin wrapper around AgentFactory for assistant-style agents."""
12
+
13
+ @classmethod
14
+ def create(
15
+ cls,
16
+ id: str,
17
+ goal: str,
18
+ role: str = "Assistant",
19
+ **kwargs: Any,
20
+ ) -> Agent:
21
+ return AgentFactory.create_agent(
22
+ id=id,
23
+ role=role,
24
+ goal=goal,
25
+ agent_type=kwargs.pop("agent_type", AgentType.DELIBERATIVE),
26
+ **kwargs,
27
+ )
28
+
29
+
30
+ class UserProxyAgent(Agent):
31
+ """Thin wrapper for user-proxy style agents.
32
+
33
+ This is intended to be paired with a human-input tool and runtime execution.
34
+ """
35
+
36
+ @classmethod
37
+ def create(
38
+ cls,
39
+ id: str,
40
+ goal: str = "Provide user input",
41
+ role: str = "User",
42
+ tools: Optional[list[str]] = None,
43
+ **kwargs: Any,
44
+ ) -> Agent:
45
+ tool_list = tools or []
46
+ return AgentFactory.create_agent(
47
+ id=id,
48
+ role=role,
49
+ goal=goal,
50
+ tools=tool_list,
51
+ agent_type=kwargs.pop("agent_type", AgentType.REACTIVE),
52
+ **kwargs,
53
+ )
@@ -6,6 +6,11 @@ from genxai.connectors.webhook import WebhookConnector
6
6
  from genxai.connectors.kafka import KafkaConnector
7
7
  from genxai.connectors.sqs import SQSConnector
8
8
  from genxai.connectors.postgres_cdc import PostgresCDCConnector
9
+ from genxai.connectors.slack import SlackConnector
10
+ from genxai.connectors.github import GitHubConnector
11
+ from genxai.connectors.notion import NotionConnector
12
+ from genxai.connectors.jira import JiraConnector
13
+ from genxai.connectors.google_workspace import GoogleWorkspaceConnector
9
14
 
10
15
  __all__ = [
11
16
  "Connector",
@@ -16,4 +21,9 @@ __all__ = [
16
21
  "KafkaConnector",
17
22
  "SQSConnector",
18
23
  "PostgresCDCConnector",
24
+ "SlackConnector",
25
+ "GitHubConnector",
26
+ "NotionConnector",
27
+ "JiraConnector",
28
+ "GoogleWorkspaceConnector",
19
29
  ]
@@ -0,0 +1,106 @@
1
+ """Connector configuration persistence helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+ import json
9
+ import os
10
+
11
+
12
+ @dataclass
13
+ class ConnectorConfigEntry:
14
+ name: str
15
+ connector_type: str
16
+ config: Dict[str, Any]
17
+
18
+
19
+ class ConnectorConfigStore:
20
+ """Persist connector configurations as JSON on disk.
21
+
22
+ Supports optional encryption when a key is provided via the
23
+ `GENXAI_CONNECTOR_CONFIG_KEY` environment variable or `encryption_key`.
24
+ """
25
+
26
+ def __init__(self, path: Optional[Path] = None, encryption_key: Optional[str] = None) -> None:
27
+ self.path = path or Path(".genxai/connectors.json")
28
+ self.encryption_key = encryption_key or os.getenv("GENXAI_CONNECTOR_CONFIG_KEY")
29
+
30
+ def list(self) -> Dict[str, ConnectorConfigEntry]:
31
+ data = self._read_raw()
32
+ return {
33
+ name: ConnectorConfigEntry(
34
+ name=name,
35
+ connector_type=payload["connector_type"],
36
+ config=payload.get("config", {}),
37
+ )
38
+ for name, payload in data.items()
39
+ }
40
+
41
+ def get(self, name: str) -> Optional[ConnectorConfigEntry]:
42
+ data = self._read_raw()
43
+ payload = data.get(name)
44
+ if not payload:
45
+ return None
46
+ return ConnectorConfigEntry(
47
+ name=name,
48
+ connector_type=payload["connector_type"],
49
+ config=payload.get("config", {}),
50
+ )
51
+
52
+ def save(self, entry: ConnectorConfigEntry) -> None:
53
+ data = self._read_raw()
54
+ data[entry.name] = {
55
+ "connector_type": entry.connector_type,
56
+ "config": entry.config,
57
+ }
58
+ self._write_raw(data)
59
+
60
+ def delete(self, name: str) -> bool:
61
+ data = self._read_raw()
62
+ if name not in data:
63
+ return False
64
+ data.pop(name)
65
+ self._write_raw(data)
66
+ return True
67
+
68
+ def _read_raw(self) -> Dict[str, Any]:
69
+ if not self.path.exists():
70
+ return {}
71
+ raw = json.loads(self.path.read_text())
72
+ if isinstance(raw, dict) and raw.get("encrypted"):
73
+ payload = raw.get("payload")
74
+ if not payload:
75
+ raise ValueError("Encrypted connector config missing payload")
76
+ return json.loads(self._decrypt(payload))
77
+ return raw
78
+
79
+ def _write_raw(self, data: Dict[str, Any]) -> None:
80
+ self.path.parent.mkdir(parents=True, exist_ok=True)
81
+ if self.encryption_key:
82
+ encrypted = self._encrypt(json.dumps(data))
83
+ payload = {"encrypted": True, "payload": encrypted}
84
+ self.path.write_text(json.dumps(payload, indent=2))
85
+ return
86
+ self.path.write_text(json.dumps(data, indent=2))
87
+
88
+ def _get_fernet(self):
89
+ if not self.encryption_key:
90
+ raise ValueError("Encryption key not configured")
91
+ try:
92
+ from cryptography.fernet import Fernet
93
+ except ImportError as exc:
94
+ raise ImportError(
95
+ "cryptography is required for encrypted connector configs. "
96
+ "Install with: pip install cryptography"
97
+ ) from exc
98
+ return Fernet(self.encryption_key.encode())
99
+
100
+ def _encrypt(self, payload: str) -> str:
101
+ fernet = self._get_fernet()
102
+ return fernet.encrypt(payload.encode()).decode()
103
+
104
+ def _decrypt(self, payload: str) -> str:
105
+ fernet = self._get_fernet()
106
+ return fernet.decrypt(payload.encode()).decode()