noclick 0.1.0__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.
- noclick-0.1.0/PKG-INFO +93 -0
- noclick-0.1.0/README.md +69 -0
- noclick-0.1.0/noclick/__init__.py +38 -0
- noclick-0.1.0/noclick/client.py +140 -0
- noclick-0.1.0/noclick/namespaces.py +345 -0
- noclick-0.1.0/noclick.egg-info/PKG-INFO +93 -0
- noclick-0.1.0/noclick.egg-info/SOURCES.txt +10 -0
- noclick-0.1.0/noclick.egg-info/dependency_links.txt +1 -0
- noclick-0.1.0/noclick.egg-info/requires.txt +6 -0
- noclick-0.1.0/noclick.egg-info/top_level.txt +1 -0
- noclick-0.1.0/pyproject.toml +36 -0
- noclick-0.1.0/setup.cfg +4 -0
noclick-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: noclick
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for NoClick workflow automation
|
|
5
|
+
License: BUSL-1.1
|
|
6
|
+
Project-URL: Homepage, https://docs.noclick.com/sdk/overview
|
|
7
|
+
Project-URL: Documentation, https://docs.noclick.com/sdk/external-apps
|
|
8
|
+
Project-URL: Repository, https://github.com/noclickapp/noclick
|
|
9
|
+
Keywords: noclick,workflow,automation,sdk
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: Other/Proprietary License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: python-socketio[asyncio_client]>=5.0
|
|
20
|
+
Requires-Dist: aiohttp>=3.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# noclick-sdk
|
|
26
|
+
|
|
27
|
+
Python SDK for [NoClick](https://noclick.com) workflow automation. Build external applications that interact with NoClick workflows — read node outputs, trigger execution, manage state, and work with files and datasets.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install noclick
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
from noclick import NoClickSDK
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
sdk = NoClickSDK(
|
|
43
|
+
url="https://api.noclick.io",
|
|
44
|
+
api_key="nk_live_...",
|
|
45
|
+
workflow_id="your-workflow-id",
|
|
46
|
+
)
|
|
47
|
+
await sdk.connect()
|
|
48
|
+
|
|
49
|
+
# List nodes
|
|
50
|
+
nodes = await sdk.nodes.list()
|
|
51
|
+
for node in nodes:
|
|
52
|
+
print(f"{node['label']} ({node['type']})")
|
|
53
|
+
|
|
54
|
+
# Read node output
|
|
55
|
+
output = await sdk.nodes.get_output("gmail-node-id")
|
|
56
|
+
|
|
57
|
+
# Run a node and get results
|
|
58
|
+
results = await sdk.execution.run_nodes_and_get_output(
|
|
59
|
+
["data-fetcher"], ["formatter"]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# State management
|
|
63
|
+
await sdk.state.set("counter", 42)
|
|
64
|
+
val = await sdk.state.get("counter")
|
|
65
|
+
|
|
66
|
+
# Dataset CRUD
|
|
67
|
+
ds_id = await sdk.dataset.create("My Data")
|
|
68
|
+
await sdk.dataset.append_rows(ds_id, [{"name": "Alice", "score": 95}])
|
|
69
|
+
|
|
70
|
+
await sdk.disconnect()
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## API
|
|
76
|
+
|
|
77
|
+
| Namespace | Methods |
|
|
78
|
+
|-----------|---------|
|
|
79
|
+
| `sdk.nodes` | `get_output`, `get_config`, `list` |
|
|
80
|
+
| `sdk.execution` | `run_nodes_and_get_output`, `run_nodes_in_background`, `on_node_output`, `on_node_state` |
|
|
81
|
+
| `sdk.state` | `get`, `set`, `delete`, `update`, `keys` |
|
|
82
|
+
| `sdk.auth` | `list_credentials`, `has_credential`, `create_credential` |
|
|
83
|
+
| `sdk.resources` | `upload`, `get_url`, `remove`, `list` |
|
|
84
|
+
| `sdk.dataset` | `create`, `list`, `get_rows`, `append_rows`, `update_row`, `delete_rows` |
|
|
85
|
+
| `sdk.workflow` | `get_info` |
|
|
86
|
+
|
|
87
|
+
## Documentation
|
|
88
|
+
|
|
89
|
+
Full documentation at [docs.noclick.com/sdk](https://docs.noclick.com/sdk/overview).
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
[Business Source License 1.1](../LICENSE) — free to use with NoClick, converts to Apache 2.0 after 4 years.
|
noclick-0.1.0/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# noclick-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for [NoClick](https://noclick.com) workflow automation. Build external applications that interact with NoClick workflows — read node outputs, trigger execution, manage state, and work with files and datasets.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install noclick
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from noclick import NoClickSDK
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
sdk = NoClickSDK(
|
|
19
|
+
url="https://api.noclick.io",
|
|
20
|
+
api_key="nk_live_...",
|
|
21
|
+
workflow_id="your-workflow-id",
|
|
22
|
+
)
|
|
23
|
+
await sdk.connect()
|
|
24
|
+
|
|
25
|
+
# List nodes
|
|
26
|
+
nodes = await sdk.nodes.list()
|
|
27
|
+
for node in nodes:
|
|
28
|
+
print(f"{node['label']} ({node['type']})")
|
|
29
|
+
|
|
30
|
+
# Read node output
|
|
31
|
+
output = await sdk.nodes.get_output("gmail-node-id")
|
|
32
|
+
|
|
33
|
+
# Run a node and get results
|
|
34
|
+
results = await sdk.execution.run_nodes_and_get_output(
|
|
35
|
+
["data-fetcher"], ["formatter"]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# State management
|
|
39
|
+
await sdk.state.set("counter", 42)
|
|
40
|
+
val = await sdk.state.get("counter")
|
|
41
|
+
|
|
42
|
+
# Dataset CRUD
|
|
43
|
+
ds_id = await sdk.dataset.create("My Data")
|
|
44
|
+
await sdk.dataset.append_rows(ds_id, [{"name": "Alice", "score": 95}])
|
|
45
|
+
|
|
46
|
+
await sdk.disconnect()
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
| Namespace | Methods |
|
|
54
|
+
|-----------|---------|
|
|
55
|
+
| `sdk.nodes` | `get_output`, `get_config`, `list` |
|
|
56
|
+
| `sdk.execution` | `run_nodes_and_get_output`, `run_nodes_in_background`, `on_node_output`, `on_node_state` |
|
|
57
|
+
| `sdk.state` | `get`, `set`, `delete`, `update`, `keys` |
|
|
58
|
+
| `sdk.auth` | `list_credentials`, `has_credential`, `create_credential` |
|
|
59
|
+
| `sdk.resources` | `upload`, `get_url`, `remove`, `list` |
|
|
60
|
+
| `sdk.dataset` | `create`, `list`, `get_rows`, `append_rows`, `update_row`, `delete_rows` |
|
|
61
|
+
| `sdk.workflow` | `get_info` |
|
|
62
|
+
|
|
63
|
+
## Documentation
|
|
64
|
+
|
|
65
|
+
Full documentation at [docs.noclick.com/sdk](https://docs.noclick.com/sdk/overview).
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
[Business Source License 1.1](../LICENSE) — free to use with NoClick, converts to Apache 2.0 after 4 years.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@noclick/sdk — Python SDK for NoClick workflow automation.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
import asyncio
|
|
6
|
+
from noclick import NoClickSDK
|
|
7
|
+
|
|
8
|
+
async def main():
|
|
9
|
+
sdk = NoClickSDK(
|
|
10
|
+
url="http://localhost:8005",
|
|
11
|
+
api_key="nk_live_...",
|
|
12
|
+
workflow_id="..."
|
|
13
|
+
)
|
|
14
|
+
await sdk.connect()
|
|
15
|
+
|
|
16
|
+
# Read node output
|
|
17
|
+
output = await sdk.nodes.get_output("node-id")
|
|
18
|
+
|
|
19
|
+
# Run nodes
|
|
20
|
+
await sdk.execution.run_nodes_in_background(["node-id"])
|
|
21
|
+
|
|
22
|
+
# State CRUD
|
|
23
|
+
await sdk.state.set("counter", 42)
|
|
24
|
+
val = await sdk.state.get("counter")
|
|
25
|
+
|
|
26
|
+
# Dataset CRUD
|
|
27
|
+
resource_id = await sdk.dataset.create("My Data")
|
|
28
|
+
await sdk.dataset.append_rows(resource_id, [{"name": "Alice"}])
|
|
29
|
+
|
|
30
|
+
await sdk.disconnect()
|
|
31
|
+
|
|
32
|
+
asyncio.run(main())
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from noclick.client import NoClickSDK
|
|
36
|
+
|
|
37
|
+
__all__ = ["NoClickSDK"]
|
|
38
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main SDK client. Connects to NoClick backend via Socket.IO.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import socketio
|
|
12
|
+
|
|
13
|
+
from noclick.namespaces import (
|
|
14
|
+
NodesNamespace,
|
|
15
|
+
ExecutionNamespace,
|
|
16
|
+
StateNamespace,
|
|
17
|
+
AuthNamespace,
|
|
18
|
+
ResourcesNamespace,
|
|
19
|
+
DatasetNamespace,
|
|
20
|
+
WorkflowNamespace,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("noclick")
|
|
24
|
+
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NoClickSDK:
|
|
29
|
+
"""
|
|
30
|
+
NoClick SDK client for Python.
|
|
31
|
+
|
|
32
|
+
Connects to the NoClick backend via Socket.IO using an API key.
|
|
33
|
+
Provides namespaced access to nodes, execution, state, auth, resources, and dataset APIs.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
url: str,
|
|
39
|
+
api_key: str,
|
|
40
|
+
workflow_id: Optional[str] = None,
|
|
41
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
42
|
+
):
|
|
43
|
+
self.url = url
|
|
44
|
+
self.api_key = api_key
|
|
45
|
+
self.workflow_id = workflow_id
|
|
46
|
+
self.timeout = timeout
|
|
47
|
+
|
|
48
|
+
self._sio = socketio.AsyncClient(reconnection=True, reconnection_attempts=5)
|
|
49
|
+
self._pending: Dict[str, asyncio.Future] = {}
|
|
50
|
+
self._event_handlers: Dict[str, List[Any]] = {}
|
|
51
|
+
|
|
52
|
+
# Register response handler
|
|
53
|
+
self._sio.on("response", self._handle_response)
|
|
54
|
+
self._sio.on("workflow:node:output", self._handle_node_output)
|
|
55
|
+
self._sio.on("workflow:node:state", self._handle_node_state)
|
|
56
|
+
self._sio.on("workflow:complete", self._handle_workflow_complete)
|
|
57
|
+
|
|
58
|
+
# Namespaces
|
|
59
|
+
self.nodes = NodesNamespace(self)
|
|
60
|
+
self.execution = ExecutionNamespace(self)
|
|
61
|
+
self.state = StateNamespace(self)
|
|
62
|
+
self.auth = AuthNamespace(self)
|
|
63
|
+
self.resources = ResourcesNamespace(self)
|
|
64
|
+
self.dataset = DatasetNamespace(self)
|
|
65
|
+
self.workflow = WorkflowNamespace(self)
|
|
66
|
+
|
|
67
|
+
async def connect(self) -> None:
|
|
68
|
+
"""Connect to the NoClick backend."""
|
|
69
|
+
logger.info(f"Connecting to {self.url}...")
|
|
70
|
+
await self._sio.connect(
|
|
71
|
+
self.url,
|
|
72
|
+
auth={"api_key": self.api_key},
|
|
73
|
+
transports=["websocket"],
|
|
74
|
+
)
|
|
75
|
+
logger.info("Connected")
|
|
76
|
+
|
|
77
|
+
async def disconnect(self) -> None:
|
|
78
|
+
"""Disconnect from the backend."""
|
|
79
|
+
await self._sio.disconnect()
|
|
80
|
+
logger.info("Disconnected")
|
|
81
|
+
|
|
82
|
+
async def send_event(self, event: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Send a socket event and wait for a response.
|
|
85
|
+
|
|
86
|
+
Uses request_id correlation to match request → response.
|
|
87
|
+
"""
|
|
88
|
+
request_id = f"py-{uuid.uuid4().hex[:12]}"
|
|
89
|
+
data["request_id"] = request_id
|
|
90
|
+
|
|
91
|
+
loop = asyncio.get_event_loop()
|
|
92
|
+
future: asyncio.Future = loop.create_future()
|
|
93
|
+
self._pending[request_id] = future
|
|
94
|
+
|
|
95
|
+
await self._sio.emit(event, data)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = await asyncio.wait_for(future, timeout=self.timeout)
|
|
99
|
+
return result
|
|
100
|
+
except asyncio.TimeoutError:
|
|
101
|
+
self._pending.pop(request_id, None)
|
|
102
|
+
raise TimeoutError(f"Request '{event}' timed out after {self.timeout}s")
|
|
103
|
+
|
|
104
|
+
async def send_event_no_wait(self, event: str, data: Dict[str, Any]) -> None:
|
|
105
|
+
"""Send a socket event without waiting for a response."""
|
|
106
|
+
request_id = f"py-{uuid.uuid4().hex[:12]}"
|
|
107
|
+
data["request_id"] = request_id
|
|
108
|
+
await self._sio.emit(event, data)
|
|
109
|
+
|
|
110
|
+
def on_event(self, event: str, handler) -> None:
|
|
111
|
+
"""Subscribe to push events."""
|
|
112
|
+
if event not in self._event_handlers:
|
|
113
|
+
self._event_handlers[event] = []
|
|
114
|
+
self._event_handlers[event].append(handler)
|
|
115
|
+
|
|
116
|
+
def _handle_response(self, data: Any) -> None:
|
|
117
|
+
"""Handle response events, resolving pending futures."""
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
return
|
|
120
|
+
request_id = data.get("request_id")
|
|
121
|
+
if not request_id:
|
|
122
|
+
return
|
|
123
|
+
future = self._pending.pop(request_id, None)
|
|
124
|
+
if future and not future.done():
|
|
125
|
+
if data.get("error"):
|
|
126
|
+
future.set_exception(Exception(str(data["error"])))
|
|
127
|
+
else:
|
|
128
|
+
future.set_result(data.get("data", data))
|
|
129
|
+
|
|
130
|
+
def _handle_node_output(self, data: Any) -> None:
|
|
131
|
+
for handler in self._event_handlers.get("node:output", []):
|
|
132
|
+
handler(data.get("node_id"), data.get("output"))
|
|
133
|
+
|
|
134
|
+
def _handle_node_state(self, data: Any) -> None:
|
|
135
|
+
for handler in self._event_handlers.get("node:state", []):
|
|
136
|
+
handler(data.get("node_id"), data.get("state"))
|
|
137
|
+
|
|
138
|
+
def _handle_workflow_complete(self, data: Any) -> None:
|
|
139
|
+
for handler in self._event_handlers.get("workflow:complete", []):
|
|
140
|
+
handler(data)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDK namespace classes — thin wrappers that map to socket events.
|
|
3
|
+
Each method mirrors the TypeScript SDK API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from noclick.client import NoClickSDK
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NodesNamespace:
|
|
13
|
+
def __init__(self, sdk: "NoClickSDK"):
|
|
14
|
+
self._sdk = sdk
|
|
15
|
+
|
|
16
|
+
async def get_output(self, node_id: str) -> Any:
|
|
17
|
+
"""Read a node's last output."""
|
|
18
|
+
resp = await self._sdk.send_event("workflow:get_node_outputs", {
|
|
19
|
+
"workflow_id": self._sdk.workflow_id,
|
|
20
|
+
"node_ids": [node_id],
|
|
21
|
+
})
|
|
22
|
+
outputs = resp.get("outputs", {})
|
|
23
|
+
node_output = outputs.get(node_id, {})
|
|
24
|
+
return node_output.get("output")
|
|
25
|
+
|
|
26
|
+
async def get_config(self, node_id: str) -> Dict[str, Any]:
|
|
27
|
+
"""Read a node's config."""
|
|
28
|
+
resp = await self._sdk.send_event("workflow:get", {
|
|
29
|
+
"workflow_id": self._sdk.workflow_id,
|
|
30
|
+
})
|
|
31
|
+
workflow_data = resp.get("workflow", {}).get("workflow_data", {})
|
|
32
|
+
for node in workflow_data.get("nodes", []):
|
|
33
|
+
if node.get("id") == node_id:
|
|
34
|
+
return node.get("config", {})
|
|
35
|
+
raise ValueError(f"Node not found: {node_id}")
|
|
36
|
+
|
|
37
|
+
async def list(self) -> List[Dict[str, Any]]:
|
|
38
|
+
"""List all nodes in the workflow."""
|
|
39
|
+
resp = await self._sdk.send_event("workflow:get", {
|
|
40
|
+
"workflow_id": self._sdk.workflow_id,
|
|
41
|
+
})
|
|
42
|
+
workflow_data = resp.get("workflow", {}).get("workflow_data", {})
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
"id": n.get("id"),
|
|
46
|
+
"type": n.get("type"),
|
|
47
|
+
"label": n.get("config", {}).get("label", n.get("type", "")),
|
|
48
|
+
"hasOutput": False, # Would need separate output check
|
|
49
|
+
}
|
|
50
|
+
for n in workflow_data.get("nodes", [])
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ExecutionNamespace:
|
|
55
|
+
def __init__(self, sdk: "NoClickSDK"):
|
|
56
|
+
self._sdk = sdk
|
|
57
|
+
|
|
58
|
+
async def run_nodes_in_background(self, node_ids: List[str]) -> None:
|
|
59
|
+
"""Run nodes without waiting for output."""
|
|
60
|
+
await self._sdk.send_event_no_wait("workflow:execute", {
|
|
61
|
+
"workflow_id": self._sdk.workflow_id,
|
|
62
|
+
"start_node_id": node_ids[0] if node_ids else None,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
async def run_nodes_and_get_output(
|
|
66
|
+
self, run_nodes: List[str], target_nodes: List[str], timeout: Optional[float] = None
|
|
67
|
+
) -> Dict[str, Any]:
|
|
68
|
+
"""Run nodes and wait for target outputs."""
|
|
69
|
+
import asyncio
|
|
70
|
+
|
|
71
|
+
# Start execution
|
|
72
|
+
await self._sdk.send_event_no_wait("workflow:execute", {
|
|
73
|
+
"workflow_id": self._sdk.workflow_id,
|
|
74
|
+
"start_node_id": run_nodes[0] if run_nodes else None,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
# Collect outputs via node:output events
|
|
78
|
+
results: Dict[str, Any] = {}
|
|
79
|
+
remaining = set(target_nodes)
|
|
80
|
+
done_event = asyncio.Event()
|
|
81
|
+
|
|
82
|
+
def on_output(node_id: str, output: Any):
|
|
83
|
+
if node_id in remaining:
|
|
84
|
+
results[node_id] = output
|
|
85
|
+
remaining.discard(node_id)
|
|
86
|
+
if not remaining:
|
|
87
|
+
done_event.set()
|
|
88
|
+
|
|
89
|
+
self._sdk.on_event("node:output", on_output)
|
|
90
|
+
try:
|
|
91
|
+
await asyncio.wait_for(done_event.wait(), timeout=timeout or self._sdk.timeout)
|
|
92
|
+
finally:
|
|
93
|
+
if on_output in self._sdk._event_handlers.get("node:output", []):
|
|
94
|
+
self._sdk._event_handlers["node:output"].remove(on_output)
|
|
95
|
+
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
def stop(self) -> None:
|
|
99
|
+
"""Stop the current execution."""
|
|
100
|
+
# Fire and forget
|
|
101
|
+
import asyncio
|
|
102
|
+
asyncio.create_task(self._sdk.send_event_no_wait("workflow:stop", {
|
|
103
|
+
"workflow_id": self._sdk.workflow_id,
|
|
104
|
+
}))
|
|
105
|
+
|
|
106
|
+
def on_node_state(self, node_id: str, handler: Callable) -> None:
|
|
107
|
+
"""Subscribe to a node's state changes."""
|
|
108
|
+
def filtered_handler(nid: str, state: str):
|
|
109
|
+
if nid == node_id:
|
|
110
|
+
handler(state)
|
|
111
|
+
self._sdk.on_event("node:state", filtered_handler)
|
|
112
|
+
|
|
113
|
+
def on_node_output(self, node_id: str, handler: Callable) -> None:
|
|
114
|
+
"""Subscribe to a node's output changes."""
|
|
115
|
+
def filtered_handler(nid: str, output: Any):
|
|
116
|
+
if nid == node_id:
|
|
117
|
+
handler(output)
|
|
118
|
+
self._sdk.on_event("node:output", filtered_handler)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class StateNamespace:
|
|
122
|
+
def __init__(self, sdk: "NoClickSDK"):
|
|
123
|
+
self._sdk = sdk
|
|
124
|
+
|
|
125
|
+
async def get(self, key: str, node_id: Optional[str] = None) -> Any:
|
|
126
|
+
"""Read a state value."""
|
|
127
|
+
resp = await self._sdk.send_event("workflow:load_node_state", {
|
|
128
|
+
"workflow_id": self._sdk.workflow_id,
|
|
129
|
+
"node_id": node_id, # Handler resolves state-manager if None
|
|
130
|
+
"key": key,
|
|
131
|
+
})
|
|
132
|
+
return resp.get("state", {}).get(key)
|
|
133
|
+
|
|
134
|
+
async def set(self, key: str, value: Any, node_id: Optional[str] = None) -> None:
|
|
135
|
+
"""Set a state value."""
|
|
136
|
+
await self._sdk.send_event("workflow:save_node_state", {
|
|
137
|
+
"workflow_id": self._sdk.workflow_id,
|
|
138
|
+
"node_id": node_id,
|
|
139
|
+
"state": {key: value},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
async def delete(self, key: str, node_id: Optional[str] = None) -> None:
|
|
143
|
+
"""Delete a state key."""
|
|
144
|
+
# Read current, remove key, write back
|
|
145
|
+
current = await self.get(key, node_id)
|
|
146
|
+
if current is not None:
|
|
147
|
+
await self._sdk.send_event("workflow:save_node_state", {
|
|
148
|
+
"workflow_id": self._sdk.workflow_id,
|
|
149
|
+
"node_id": node_id,
|
|
150
|
+
"state": {key: None}, # Convention: None = delete
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
async def update(self, key: str, updater: Callable, node_id: Optional[str] = None) -> None:
|
|
154
|
+
"""Update a state value with a function."""
|
|
155
|
+
current = await self.get(key, node_id)
|
|
156
|
+
new_value = updater(current)
|
|
157
|
+
await self.set(key, new_value, node_id)
|
|
158
|
+
|
|
159
|
+
async def keys(self, node_id: Optional[str] = None) -> List[str]:
|
|
160
|
+
"""List available state keys."""
|
|
161
|
+
resp = await self._sdk.send_event("workflow:load_node_state", {
|
|
162
|
+
"workflow_id": self._sdk.workflow_id,
|
|
163
|
+
"node_id": node_id,
|
|
164
|
+
})
|
|
165
|
+
state_data = resp.get("state", {})
|
|
166
|
+
return list(state_data.keys()) if isinstance(state_data, dict) else []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AuthNamespace:
|
|
170
|
+
def __init__(self, sdk: "NoClickSDK"):
|
|
171
|
+
self._sdk = sdk
|
|
172
|
+
|
|
173
|
+
async def list_credentials(self) -> List[Dict[str, str]]:
|
|
174
|
+
"""List available credentials."""
|
|
175
|
+
resp = await self._sdk.send_event("credential:list", {})
|
|
176
|
+
return [
|
|
177
|
+
{"id": c["id"], "type": c.get("credential_type", ""), "name": c.get("name", "")}
|
|
178
|
+
for c in resp.get("credentials", [])
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
async def has_credential(self, credential_type: str) -> bool:
|
|
182
|
+
"""Check if a credential of the given type exists."""
|
|
183
|
+
creds = await self.list_credentials()
|
|
184
|
+
return any(c["type"] == credential_type for c in creds)
|
|
185
|
+
|
|
186
|
+
async def create_credential(
|
|
187
|
+
self, credential_type: str, data: Dict[str, Any], name: Optional[str] = None
|
|
188
|
+
) -> Dict[str, str]:
|
|
189
|
+
"""Create a non-OAuth credential (API key, token, etc)."""
|
|
190
|
+
resp = await self._sdk.send_event("credential:create", {
|
|
191
|
+
"name": name or credential_type,
|
|
192
|
+
"credential_type": credential_type,
|
|
193
|
+
"credential_data": data,
|
|
194
|
+
"metadata": {},
|
|
195
|
+
})
|
|
196
|
+
cred = resp.get("credential", {})
|
|
197
|
+
return {"id": cred.get("id", ""), "type": credential_type, "name": cred.get("name", "")}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ResourcesNamespace:
|
|
201
|
+
def __init__(self, sdk: "NoClickSDK"):
|
|
202
|
+
self._sdk = sdk
|
|
203
|
+
|
|
204
|
+
async def upload(self, name: str, mime_type: str, size_bytes: int, resource_type: str = "file"):
|
|
205
|
+
"""Create a resource and get a presigned upload URL."""
|
|
206
|
+
create_resp = await self._sdk.send_event("resource:create", {
|
|
207
|
+
"workflow_id": self._sdk.workflow_id,
|
|
208
|
+
"resource_type": resource_type,
|
|
209
|
+
"name": name,
|
|
210
|
+
"mime_type": mime_type,
|
|
211
|
+
"size_bytes": size_bytes,
|
|
212
|
+
})
|
|
213
|
+
resource = create_resp.get("resource", {})
|
|
214
|
+
resource_id = resource.get("id")
|
|
215
|
+
if not resource_id:
|
|
216
|
+
raise ValueError("Failed to create resource")
|
|
217
|
+
|
|
218
|
+
upload_resp = await self._sdk.send_event("resource:upload_url", {
|
|
219
|
+
"resource_id": resource_id,
|
|
220
|
+
"filename": name,
|
|
221
|
+
"content_type": mime_type,
|
|
222
|
+
})
|
|
223
|
+
return {
|
|
224
|
+
"resource_id": resource_id,
|
|
225
|
+
"upload_url": upload_resp.get("upload_url", ""),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async def get_url(self, resource_id: str) -> str:
|
|
229
|
+
"""Get a presigned download URL."""
|
|
230
|
+
resp = await self._sdk.send_event("resource:download_url", {
|
|
231
|
+
"resource_id": resource_id,
|
|
232
|
+
})
|
|
233
|
+
return resp.get("download_url", "")
|
|
234
|
+
|
|
235
|
+
async def remove(self, resource_id: str) -> None:
|
|
236
|
+
"""Delete a resource."""
|
|
237
|
+
await self._sdk.send_event("resource:delete", {
|
|
238
|
+
"resource_id": resource_id,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
async def list(self, resource_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
242
|
+
"""List resources in the workflow."""
|
|
243
|
+
resp = await self._sdk.send_event("resource:list", {
|
|
244
|
+
"workflow_id": self._sdk.workflow_id,
|
|
245
|
+
"resource_type": resource_type,
|
|
246
|
+
})
|
|
247
|
+
return [
|
|
248
|
+
{
|
|
249
|
+
"id": r.get("id"),
|
|
250
|
+
"name": r.get("name"),
|
|
251
|
+
"resource_type": r.get("resource_type"),
|
|
252
|
+
"mime_type": r.get("mime_type"),
|
|
253
|
+
"size_bytes": r.get("size_bytes"),
|
|
254
|
+
}
|
|
255
|
+
for r in resp.get("resources", [])
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class DatasetNamespace:
|
|
260
|
+
def __init__(self, sdk: "NoClickSDK"):
|
|
261
|
+
self._sdk = sdk
|
|
262
|
+
|
|
263
|
+
async def create(self, name: str) -> str:
|
|
264
|
+
"""Create a new dataset. Returns resource_id."""
|
|
265
|
+
resp = await self._sdk.send_event("resource:create", {
|
|
266
|
+
"workflow_id": self._sdk.workflow_id,
|
|
267
|
+
"resource_type": "dataset",
|
|
268
|
+
"name": name,
|
|
269
|
+
})
|
|
270
|
+
resource = resp.get("resource", {})
|
|
271
|
+
resource_id = resource.get("id")
|
|
272
|
+
if not resource_id:
|
|
273
|
+
raise ValueError("Failed to create dataset")
|
|
274
|
+
return resource_id
|
|
275
|
+
|
|
276
|
+
async def list(self) -> List[Dict[str, Any]]:
|
|
277
|
+
"""List all datasets in the workflow."""
|
|
278
|
+
resp = await self._sdk.send_event("resource:list", {
|
|
279
|
+
"workflow_id": self._sdk.workflow_id,
|
|
280
|
+
"resource_type": "dataset",
|
|
281
|
+
})
|
|
282
|
+
return [
|
|
283
|
+
{
|
|
284
|
+
"id": r.get("id"),
|
|
285
|
+
"name": r.get("name"),
|
|
286
|
+
"row_count": r.get("metadata", {}).get("row_count", 0),
|
|
287
|
+
}
|
|
288
|
+
for r in resp.get("resources", [])
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
async def get_rows(
|
|
292
|
+
self, resource_id: str, limit: int = 100, offset: int = 0
|
|
293
|
+
) -> Dict[str, Any]:
|
|
294
|
+
"""Get paginated rows from a dataset."""
|
|
295
|
+
resp = await self._sdk.send_event("resource:dataset:rows", {
|
|
296
|
+
"resource_id": resource_id,
|
|
297
|
+
"limit": limit,
|
|
298
|
+
"offset": offset,
|
|
299
|
+
})
|
|
300
|
+
return {
|
|
301
|
+
"rows": resp.get("rows", []),
|
|
302
|
+
"total_count": resp.get("total_count", 0),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async def append_rows(self, resource_id: str, rows: List[Dict[str, Any]]) -> int:
|
|
306
|
+
"""Append rows to a dataset. Returns inserted count."""
|
|
307
|
+
resp = await self._sdk.send_event("resource:dataset:append", {
|
|
308
|
+
"resource_id": resource_id,
|
|
309
|
+
"rows": rows,
|
|
310
|
+
})
|
|
311
|
+
return resp.get("inserted_count", 0)
|
|
312
|
+
|
|
313
|
+
async def update_row(self, resource_id: str, row_id: str, data: Dict[str, Any]) -> None:
|
|
314
|
+
"""Update a single row."""
|
|
315
|
+
await self._sdk.send_event("resource:dataset:update_row", {
|
|
316
|
+
"resource_id": resource_id,
|
|
317
|
+
"row_id": row_id,
|
|
318
|
+
"data": data,
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
async def delete_rows(self, resource_id: str, row_ids: List[str]) -> int:
|
|
322
|
+
"""Delete rows. Returns deleted count."""
|
|
323
|
+
resp = await self._sdk.send_event("resource:dataset:delete_rows", {
|
|
324
|
+
"resource_id": resource_id,
|
|
325
|
+
"row_ids": row_ids,
|
|
326
|
+
})
|
|
327
|
+
return resp.get("deleted_count", 0)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class WorkflowNamespace:
|
|
331
|
+
def __init__(self, sdk: "NoClickSDK"):
|
|
332
|
+
self._sdk = sdk
|
|
333
|
+
|
|
334
|
+
async def get_info(self) -> Dict[str, Any]:
|
|
335
|
+
"""Get workflow info."""
|
|
336
|
+
resp = await self._sdk.send_event("workflow:get", {
|
|
337
|
+
"workflow_id": self._sdk.workflow_id,
|
|
338
|
+
})
|
|
339
|
+
wf = resp.get("workflow", {})
|
|
340
|
+
nodes = wf.get("workflow_data", {}).get("nodes", [])
|
|
341
|
+
return {
|
|
342
|
+
"id": wf.get("id", ""),
|
|
343
|
+
"name": wf.get("name", ""),
|
|
344
|
+
"node_count": len(nodes),
|
|
345
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: noclick
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for NoClick workflow automation
|
|
5
|
+
License: BUSL-1.1
|
|
6
|
+
Project-URL: Homepage, https://docs.noclick.com/sdk/overview
|
|
7
|
+
Project-URL: Documentation, https://docs.noclick.com/sdk/external-apps
|
|
8
|
+
Project-URL: Repository, https://github.com/noclickapp/noclick
|
|
9
|
+
Keywords: noclick,workflow,automation,sdk
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: Other/Proprietary License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: python-socketio[asyncio_client]>=5.0
|
|
20
|
+
Requires-Dist: aiohttp>=3.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# noclick-sdk
|
|
26
|
+
|
|
27
|
+
Python SDK for [NoClick](https://noclick.com) workflow automation. Build external applications that interact with NoClick workflows — read node outputs, trigger execution, manage state, and work with files and datasets.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install noclick
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
from noclick import NoClickSDK
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
sdk = NoClickSDK(
|
|
43
|
+
url="https://api.noclick.io",
|
|
44
|
+
api_key="nk_live_...",
|
|
45
|
+
workflow_id="your-workflow-id",
|
|
46
|
+
)
|
|
47
|
+
await sdk.connect()
|
|
48
|
+
|
|
49
|
+
# List nodes
|
|
50
|
+
nodes = await sdk.nodes.list()
|
|
51
|
+
for node in nodes:
|
|
52
|
+
print(f"{node['label']} ({node['type']})")
|
|
53
|
+
|
|
54
|
+
# Read node output
|
|
55
|
+
output = await sdk.nodes.get_output("gmail-node-id")
|
|
56
|
+
|
|
57
|
+
# Run a node and get results
|
|
58
|
+
results = await sdk.execution.run_nodes_and_get_output(
|
|
59
|
+
["data-fetcher"], ["formatter"]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# State management
|
|
63
|
+
await sdk.state.set("counter", 42)
|
|
64
|
+
val = await sdk.state.get("counter")
|
|
65
|
+
|
|
66
|
+
# Dataset CRUD
|
|
67
|
+
ds_id = await sdk.dataset.create("My Data")
|
|
68
|
+
await sdk.dataset.append_rows(ds_id, [{"name": "Alice", "score": 95}])
|
|
69
|
+
|
|
70
|
+
await sdk.disconnect()
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## API
|
|
76
|
+
|
|
77
|
+
| Namespace | Methods |
|
|
78
|
+
|-----------|---------|
|
|
79
|
+
| `sdk.nodes` | `get_output`, `get_config`, `list` |
|
|
80
|
+
| `sdk.execution` | `run_nodes_and_get_output`, `run_nodes_in_background`, `on_node_output`, `on_node_state` |
|
|
81
|
+
| `sdk.state` | `get`, `set`, `delete`, `update`, `keys` |
|
|
82
|
+
| `sdk.auth` | `list_credentials`, `has_credential`, `create_credential` |
|
|
83
|
+
| `sdk.resources` | `upload`, `get_url`, `remove`, `list` |
|
|
84
|
+
| `sdk.dataset` | `create`, `list`, `get_rows`, `append_rows`, `update_row`, `delete_rows` |
|
|
85
|
+
| `sdk.workflow` | `get_info` |
|
|
86
|
+
|
|
87
|
+
## Documentation
|
|
88
|
+
|
|
89
|
+
Full documentation at [docs.noclick.com/sdk](https://docs.noclick.com/sdk/overview).
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
[Business Source License 1.1](../LICENSE) — free to use with NoClick, converts to Apache 2.0 after 4 years.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
noclick
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "noclick"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for NoClick workflow automation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "BUSL-1.1"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["noclick", "workflow", "automation", "sdk"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: Other/Proprietary License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"python-socketio[asyncio_client]>=5.0",
|
|
24
|
+
"aiohttp>=3.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://docs.noclick.com/sdk/overview"
|
|
29
|
+
Documentation = "https://docs.noclick.com/sdk/external-apps"
|
|
30
|
+
Repository = "https://github.com/noclickapp/noclick"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest", "pytest-asyncio"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
include = ["noclick*"]
|
noclick-0.1.0/setup.cfg
ADDED