nexusflow-sdk 0.3.0__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.
@@ -0,0 +1,36 @@
1
+ from .client import NexusFlowClient
2
+ from .component import Component, PortRef
3
+ from .connection import Connection
4
+ from .exceptions import APIError, AuthError, NexusFlowError, NotFoundError, ValidationError
5
+ from .job import Job
6
+ from .meta import MetaService
7
+ from .model import Model
8
+ from .result import (
9
+ ContainerMessage,
10
+ InspectMessage,
11
+ LogMessage,
12
+ PlotMessage,
13
+ ResultStream,
14
+ TableMessage,
15
+ )
16
+
17
+ __all__ = [
18
+ "APIError",
19
+ "AuthError",
20
+ "Component",
21
+ "Connection",
22
+ "ContainerMessage",
23
+ "NexusFlowClient",
24
+ "NexusFlowError",
25
+ "InspectMessage",
26
+ "Job",
27
+ "LogMessage",
28
+ "MetaService",
29
+ "Model",
30
+ "NotFoundError",
31
+ "PlotMessage",
32
+ "PortRef",
33
+ "ResultStream",
34
+ "TableMessage",
35
+ "ValidationError",
36
+ ]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ from ._expression import evaluate_condition, evaluate_expression
6
+
7
+
8
+ def _constraint_scope(
9
+ *,
10
+ value: Any,
11
+ params: Mapping[str, Any] | None,
12
+ global_params: Mapping[str, Any] | None,
13
+ context: Mapping[str, Any] | None,
14
+ ) -> dict[str, Any]:
15
+ return {
16
+ "value": value,
17
+ "params": dict(params or {}),
18
+ "global": dict(global_params or params or {}),
19
+ "context": dict(context or {}),
20
+ }
21
+
22
+
23
+ def validate_constraints(
24
+ arg_def: Mapping[str, Any],
25
+ value: Any,
26
+ *,
27
+ params: Mapping[str, Any] | None,
28
+ global_params: Mapping[str, Any] | None = None,
29
+ context: Mapping[str, Any] | None = None,
30
+ ) -> list[str]:
31
+ constraints = arg_def.get("constraints", [])
32
+ if not isinstance(constraints, list):
33
+ return []
34
+
35
+ issues: list[str] = []
36
+ scope = _constraint_scope(
37
+ value=value,
38
+ params=params,
39
+ global_params=global_params,
40
+ context={
41
+ **dict(context or {}),
42
+ "key": arg_def.get("key", ""),
43
+ "type": arg_def.get("type", ""),
44
+ },
45
+ )
46
+ for constraint in constraints:
47
+ if not isinstance(constraint, Mapping):
48
+ continue
49
+ expr = str(constraint.get("expr", "")).strip()
50
+ if not expr:
51
+ continue
52
+ message = str(constraint.get("message") or "constraint failed")
53
+ when = str(constraint.get("when", "")).strip()
54
+ try:
55
+ if when and not evaluate_condition(when, scope):
56
+ continue
57
+ if not bool(evaluate_expression(expr, scope)):
58
+ issues.append(message)
59
+ except Exception as exc:
60
+ issues.append(f"constraint rule failed: {message} ({exc})")
61
+ return issues
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from types import SimpleNamespace
5
+ from typing import Any, Mapping
6
+
7
+
8
+ _NOT_PATTERN = re.compile(r"(?<![=!<>])!(?!=)")
9
+
10
+
11
+ def _to_namespace(value: Any) -> Any:
12
+ if isinstance(value, Mapping):
13
+ return SimpleNamespace(**{key: _to_namespace(item) for key, item in value.items()})
14
+ if isinstance(value, list):
15
+ return [_to_namespace(item) for item in value]
16
+ return value
17
+
18
+
19
+ def _equal_text(left: Any, right: Any) -> bool:
20
+ return str(left) == str(right)
21
+
22
+
23
+ def _as_list(value: Any) -> list[Any]:
24
+ if value is None:
25
+ return []
26
+ if isinstance(value, list):
27
+ return value
28
+ if isinstance(value, tuple):
29
+ return list(value)
30
+ return [value]
31
+
32
+
33
+ def _stable_marker(value: Any) -> str:
34
+ if isinstance(value, Mapping):
35
+ return "dict:" + repr(sorted((str(key), _stable_marker(item)) for key, item in value.items()))
36
+ if isinstance(value, (list, tuple)):
37
+ return "list:" + repr([_stable_marker(item) for item in value])
38
+ return f"{type(value).__name__}:{value!r}"
39
+
40
+
41
+ def _column(rows: Any, index: Any) -> list[Any]:
42
+ col_index = int(index)
43
+ result: list[Any] = []
44
+ for row in _as_list(rows):
45
+ if isinstance(row, Mapping):
46
+ result.append(row.get(col_index, row.get(str(col_index))))
47
+ continue
48
+ if isinstance(row, (list, tuple)) and -len(row) <= col_index < len(row):
49
+ result.append(row[col_index])
50
+ else:
51
+ result.append(None)
52
+ return result
53
+
54
+
55
+ def _unique(items: Any) -> bool:
56
+ seen: set[str] = set()
57
+ for item in _as_list(items):
58
+ marker = _stable_marker(item)
59
+ if marker in seen:
60
+ return False
61
+ seen.add(marker)
62
+ return True
63
+
64
+
65
+ def _empty(value: Any) -> bool:
66
+ if value is None:
67
+ return True
68
+ if isinstance(value, (str, list, tuple, dict, set)):
69
+ return len(value) == 0
70
+ return False
71
+
72
+
73
+ def _not_empty(value: Any) -> bool:
74
+ return not _empty(value)
75
+
76
+
77
+ def _matches(value: Any, pattern: Any) -> bool:
78
+ return re.search(str(pattern), "" if value is None else str(value)) is not None
79
+
80
+
81
+ def _number(value: Any) -> float:
82
+ return float(value)
83
+
84
+
85
+ def _string(value: Any) -> str:
86
+ return "" if value is None else str(value)
87
+
88
+
89
+ def _transform(expr: str) -> str:
90
+ transformed = expr.strip()
91
+ transformed = transformed.replace("&&", " and ")
92
+ transformed = transformed.replace("||", " or ")
93
+ transformed = re.sub(r"\btrue\b", "True", transformed, flags=re.IGNORECASE)
94
+ transformed = re.sub(r"\bfalse\b", "False", transformed, flags=re.IGNORECASE)
95
+ transformed = re.sub(r"\bnull\b", "None", transformed, flags=re.IGNORECASE)
96
+ transformed = re.sub(r"\bglobal\b", "global_", transformed)
97
+ transformed = _NOT_PATTERN.sub(" not ", transformed)
98
+ return transformed
99
+
100
+
101
+ def evaluate_expression(expr: str | None, scope: Mapping[str, Any] | None = None) -> Any:
102
+ if expr is None:
103
+ raise ValueError("expression is empty")
104
+ text = expr.strip()
105
+ if not text:
106
+ raise ValueError("expression is empty")
107
+ transformed = _transform(text)
108
+ locals_scope = {key: _to_namespace(value) for key, value in (scope or {}).items()}
109
+ if "global" in locals_scope and "global_" not in locals_scope:
110
+ locals_scope["global_"] = locals_scope["global"]
111
+ return eval(
112
+ transformed,
113
+ {
114
+ "__builtins__": {},
115
+ "equalText": _equal_text,
116
+ "min": min,
117
+ "max": max,
118
+ "abs": abs,
119
+ "len": len,
120
+ "empty": _empty,
121
+ "notEmpty": _not_empty,
122
+ "column": _column,
123
+ "unique": _unique,
124
+ "matches": _matches,
125
+ "number": _number,
126
+ "string": _string,
127
+ },
128
+ locals_scope,
129
+ )
130
+
131
+
132
+ def evaluate_condition(expr: str | None, scope: Mapping[str, Any] | None = None) -> bool:
133
+ try:
134
+ result = evaluate_expression(expr, scope)
135
+ except Exception:
136
+ return False
137
+ return bool(result)
nexusflow_sdk/auth.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import requests
7
+
8
+ from .exceptions import AuthError
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class TokenPair:
13
+ access_token: str
14
+ refresh_token: str | None = None
15
+
16
+
17
+ class TokenAuth:
18
+ def __init__(
19
+ self,
20
+ *,
21
+ base_url: str,
22
+ session: requests.Session,
23
+ access_token: str | None = None,
24
+ refresh_token: str | None = None,
25
+ username: str | None = None,
26
+ password: str | None = None,
27
+ timeout: float = 30.0,
28
+ ) -> None:
29
+ self._base_url = base_url.rstrip("/")
30
+ self._session = session
31
+ self._timeout = timeout
32
+ self._username = username
33
+ self._password = password
34
+ self._tokens = TokenPair(access_token or "", refresh_token)
35
+
36
+ @property
37
+ def access_token(self) -> str | None:
38
+ return self._tokens.access_token or None
39
+
40
+ @property
41
+ def refresh_token(self) -> str | None:
42
+ return self._tokens.refresh_token
43
+
44
+ @property
45
+ def authorization_header(self) -> dict[str, str]:
46
+ if not self.access_token:
47
+ return {}
48
+ return {"Authorization": f"Bearer {self.access_token}"}
49
+
50
+ @property
51
+ def can_refresh(self) -> bool:
52
+ return bool(self.refresh_token or (self._username and self._password))
53
+
54
+ def ensure_login(self) -> None:
55
+ if self.access_token:
56
+ return
57
+ if not (self._username and self._password):
58
+ raise AuthError("No access token or username/password was provided.")
59
+ self.login(self._username, self._password)
60
+
61
+ def login(self, username: str, password: str) -> TokenPair:
62
+ response = self._session.post(
63
+ f"{self._base_url}/api/token/",
64
+ json={"username": username, "password": password},
65
+ timeout=self._timeout,
66
+ )
67
+ payload = _parse_json(response)
68
+ if response.status_code >= 400:
69
+ raise AuthError(_extract_error_message(payload, response.text))
70
+
71
+ access = payload.get("access") or payload.get("access_token")
72
+ refresh = payload.get("refresh") or payload.get("refresh_token")
73
+ if not access:
74
+ raise AuthError("Token endpoint did not return an access token.")
75
+ self._username = username
76
+ self._password = password
77
+ self._tokens = TokenPair(access, refresh)
78
+ return self._tokens
79
+
80
+ def refresh(self) -> TokenPair:
81
+ if self.refresh_token:
82
+ response = self._session.post(
83
+ f"{self._base_url}/api/token/refresh/",
84
+ json={"refresh": self.refresh_token},
85
+ timeout=self._timeout,
86
+ )
87
+ payload = _parse_json(response)
88
+ if response.status_code < 400:
89
+ access = payload.get("access") or payload.get("access_token")
90
+ refresh = payload.get("refresh") or payload.get("refresh_token") or self.refresh_token
91
+ if access:
92
+ self._tokens = TokenPair(access, refresh)
93
+ return self._tokens
94
+
95
+ if self._username and self._password:
96
+ return self.login(self._username, self._password)
97
+ raise AuthError("Unable to refresh access token.")
98
+
99
+
100
+ def _parse_json(response: requests.Response) -> dict[str, Any]:
101
+ try:
102
+ payload = response.json()
103
+ except ValueError:
104
+ return {}
105
+ return payload if isinstance(payload, dict) else {}
106
+
107
+
108
+ def _extract_error_message(payload: dict[str, Any], fallback: str) -> str:
109
+ for key in ("detail", "message", "error", "msg"):
110
+ value = payload.get(key)
111
+ if isinstance(value, str) and value.strip():
112
+ return value
113
+ return fallback or "Authentication failed."
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import requests
6
+
7
+ from .auth import TokenAuth
8
+ from .http import HttpClient
9
+ from .job import Job
10
+ from .meta import MetaService
11
+ from .model import Model
12
+
13
+
14
+ class NexusFlowClient:
15
+ def __init__(
16
+ self,
17
+ *,
18
+ base_url: str,
19
+ token: str | None = None,
20
+ refresh_token: str | None = None,
21
+ username: str | None = None,
22
+ password: str | None = None,
23
+ timeout: float = 30.0,
24
+ session: requests.Session | None = None,
25
+ ) -> None:
26
+ self.base_url = base_url.rstrip("/")
27
+ self.timeout = timeout
28
+ self.session = session or requests.Session()
29
+ self.auth = TokenAuth(
30
+ base_url=self.base_url,
31
+ session=self.session,
32
+ access_token=token,
33
+ refresh_token=refresh_token,
34
+ username=username,
35
+ password=password,
36
+ timeout=timeout,
37
+ )
38
+ self.http = HttpClient(base_url=self.base_url, auth=self.auth, timeout=timeout, session=self.session)
39
+ self.meta = MetaService(self.http)
40
+
41
+ def __enter__(self) -> "NexusFlowClient":
42
+ return self
43
+
44
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
45
+ self.close()
46
+
47
+ def close(self) -> None:
48
+ self.session.close()
49
+
50
+ def login(self, username: str, password: str) -> None:
51
+ self.auth.login(username, password)
52
+
53
+ def list_models(self, **params: Any) -> list[Model]:
54
+ return Model.list(self, **params)
55
+
56
+ def get_model(self, model_id: str) -> Model:
57
+ return Model.get(self, model_id)
58
+
59
+ def create_model(
60
+ self,
61
+ *,
62
+ name: str,
63
+ workspace_type: str,
64
+ description: str = "",
65
+ publicity: str = "private",
66
+ global_params: dict[str, Any] | None = None,
67
+ ) -> Model:
68
+ return Model.create(
69
+ self,
70
+ name=name,
71
+ workspace_type=workspace_type,
72
+ description=description,
73
+ publicity=publicity,
74
+ global_params=global_params,
75
+ )
76
+
77
+ def list_jobs(self, **params: Any) -> list[Job]:
78
+ return Job.list(self, **params)
79
+
80
+ def get_job(self, job_id: str) -> Job:
81
+ return Job.get(self, job_id)
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, Iterable
5
+
6
+ from ._constraints import validate_constraints
7
+ from ._expression import evaluate_condition
8
+ from .exceptions import ValidationError
9
+ from .types import NodeData, NodeViewStatus, PortDirection, PortType
10
+
11
+ if TYPE_CHECKING:
12
+ from .model import Model
13
+
14
+
15
+ def default_view_status(position: tuple[float, float] | None = None) -> NodeViewStatus:
16
+ x, y = position or (0.0, 0.0)
17
+ return {
18
+ "flip": False,
19
+ "angle": 0.0,
20
+ "size": {"width": 120.0, "height": 80.0},
21
+ "position": {"x": float(x), "y": float(y)},
22
+ "zIndex": 0,
23
+ }
24
+
25
+
26
+ def iter_arg_definitions(args_group: Any) -> Iterable[dict[str, Any]]:
27
+ if not isinstance(args_group, list):
28
+ return []
29
+ definitions: list[dict[str, Any]] = []
30
+ for group in args_group:
31
+ if not isinstance(group, dict):
32
+ continue
33
+ for item in group.get("argsDefinition", []) or []:
34
+ if isinstance(item, dict):
35
+ definitions.append(item)
36
+ return definitions
37
+
38
+
39
+ def build_default_params(component_definition: dict[str, Any]) -> dict[str, Any]:
40
+ defaults: dict[str, Any] = {}
41
+ for arg_def in iter_arg_definitions(component_definition.get("argsGroup", [])):
42
+ key = arg_def.get("key")
43
+ if not key:
44
+ continue
45
+ if "defaultValue" in arg_def:
46
+ defaults[key] = arg_def.get("defaultValue")
47
+ elif "default" in arg_def:
48
+ defaults[key] = arg_def.get("default")
49
+ elif arg_def.get("type") == "table":
50
+ defaults[key] = []
51
+ else:
52
+ defaults[key] = ""
53
+ return defaults
54
+
55
+
56
+ def validate_parameter_value(
57
+ arg_def: dict[str, Any],
58
+ value: Any,
59
+ *,
60
+ params: dict[str, Any],
61
+ global_params: dict[str, Any] | None = None,
62
+ context: dict[str, Any] | None = None,
63
+ subject: str = "property",
64
+ ) -> list[str]:
65
+ issues: list[str] = []
66
+ key = arg_def.get("key")
67
+ label = arg_def.get("label", key)
68
+ arg_type = arg_def.get("type")
69
+ prefix = f"{subject} {label}"
70
+
71
+ if arg_type in {"real", "integer"}:
72
+ try:
73
+ numeric = float(value)
74
+ except (TypeError, ValueError):
75
+ issues.append(f"{prefix} must be numeric")
76
+ else:
77
+ if arg_type == "integer" and int(numeric) != numeric:
78
+ issues.append(f"{prefix} must be an integer")
79
+ minimum = arg_def.get("min")
80
+ maximum = arg_def.get("max")
81
+ range_type = str(arg_def.get("rangeType", "CLOSE")).upper()
82
+ if minimum is not None:
83
+ if range_type in {"LEFT_OPEN", "OPEN"} and not numeric > float(minimum):
84
+ issues.append(f"{prefix} must be > {minimum}")
85
+ if range_type not in {"LEFT_OPEN", "OPEN"} and not numeric >= float(minimum):
86
+ issues.append(f"{prefix} must be >= {minimum}")
87
+ if maximum is not None:
88
+ if range_type in {"RIGHT_OPEN", "OPEN"} and not numeric < float(maximum):
89
+ issues.append(f"{prefix} must be < {maximum}")
90
+ if range_type not in {"RIGHT_OPEN", "OPEN"} and not numeric <= float(maximum):
91
+ issues.append(f"{prefix} must be <= {maximum}")
92
+ elif arg_type == "choice":
93
+ choices = arg_def.get("choices", []) or []
94
+ allowed = {item.get("key") for item in choices if isinstance(item, dict) and "key" in item}
95
+ if allowed and value not in allowed:
96
+ issues.append(f"{prefix} must be one of {sorted(allowed)!r}")
97
+ elif arg_type == "table":
98
+ if not isinstance(value, list):
99
+ issues.append(f"{prefix} must be a list")
100
+ else:
101
+ min_row = arg_def.get("minRow")
102
+ max_row = arg_def.get("maxRow")
103
+ if min_row is not None and len(value) < int(min_row):
104
+ issues.append(f"{prefix} must contain at least {min_row} rows")
105
+ if max_row is not None and len(value) > int(max_row):
106
+ issues.append(f"{prefix} must contain no more than {max_row} rows")
107
+ elif arg_type in {"text", "datetime", "signal", "color", "python-script"}:
108
+ if value is None:
109
+ issues.append(f"{prefix} cannot be null")
110
+
111
+ if not issues:
112
+ issues.extend(
113
+ f"{prefix} {item}"
114
+ for item in validate_constraints(
115
+ arg_def,
116
+ value,
117
+ params=params,
118
+ global_params=global_params,
119
+ context=context,
120
+ )
121
+ )
122
+ return issues
123
+
124
+
125
+ @dataclass(slots=True)
126
+ class PortRef:
127
+ component: "Component"
128
+ key: str
129
+ name: str
130
+ type: PortType
131
+ condition: str
132
+ direction: PortDirection | None = None
133
+
134
+ @property
135
+ def is_active(self) -> bool:
136
+ return evaluate_condition(self.condition or "true", {**self.component.params, "context": {}})
137
+
138
+
139
+ class Component:
140
+ def __init__(
141
+ self,
142
+ *,
143
+ model: "Model",
144
+ component_id: str,
145
+ class_id: str,
146
+ label: str,
147
+ params: dict[str, Any] | None = None,
148
+ pins_label: dict[str, str] | None = None,
149
+ view_status: NodeViewStatus | None = None,
150
+ ) -> None:
151
+ self.model = model
152
+ self.id = component_id
153
+ self.class_id = class_id
154
+ self.label = label
155
+ self.params = dict(params or {})
156
+ self.pins_label = dict(pins_label or {})
157
+ self.view_status = view_status or default_view_status()
158
+
159
+ @property
160
+ def definition(self) -> dict[str, Any]:
161
+ return self.model.client.meta.get_component_definition(self.model.workspace_type, self.class_id)
162
+
163
+ @property
164
+ def commands(self) -> Any:
165
+ return self.definition.get("commands", [])
166
+
167
+ @property
168
+ def ports(self) -> list[PortRef]:
169
+ ports: list[PortRef] = []
170
+ for port in self.definition.get("portsDefinition", []) or []:
171
+ if not isinstance(port, dict):
172
+ continue
173
+ ports.append(
174
+ PortRef(
175
+ component=self,
176
+ key=str(port.get("key", "")),
177
+ name=str(port.get("name", port.get("label", port.get("key", "")))),
178
+ type=port.get("type", "FLUID"),
179
+ condition=str(port.get("condition", "true")),
180
+ direction=port.get("direction"),
181
+ )
182
+ )
183
+ return ports
184
+
185
+ @property
186
+ def active_ports(self) -> list[PortRef]:
187
+ return [port for port in self.ports if port.is_active]
188
+
189
+ def port(self, key: str) -> PortRef:
190
+ for port in self.ports:
191
+ if port.key == key:
192
+ return port
193
+ raise ValidationError(f"Component {self.id} does not have port {key!r}.")
194
+
195
+ def set_param(self, key: str, value: Any) -> "Component":
196
+ self.params[key] = value
197
+ return self
198
+
199
+ def set_params(self, **kwargs: Any) -> "Component":
200
+ self.params.update(kwargs)
201
+ return self
202
+
203
+ def get_param(self, key: str, default: Any = None) -> Any:
204
+ return self.params.get(key, default)
205
+
206
+ def set_pin_label(self, port_key: str, label: str) -> "Component":
207
+ self.port(port_key)
208
+ self.pins_label[port_key] = label
209
+ return self
210
+
211
+ def get_pin_label(self, port_key: str, default: str = "") -> str:
212
+ return str(self.pins_label.get(port_key, default))
213
+
214
+ def signal_values(self) -> dict[str, str]:
215
+ values: dict[str, str] = {}
216
+ for arg_def in iter_arg_definitions(self.definition.get("argsGroup", [])):
217
+ if arg_def.get("type") != "signal":
218
+ continue
219
+ key = arg_def.get("key")
220
+ if not key:
221
+ continue
222
+ if not evaluate_condition(arg_def.get("condition", "true"), {**self.params, "context": {}}):
223
+ continue
224
+ value = str(self.params.get(key, "")).strip()
225
+ if value:
226
+ values[key] = value
227
+ return values
228
+
229
+ def validate(self) -> list[str]:
230
+ issues: list[str] = []
231
+ for arg_def in iter_arg_definitions(self.definition.get("argsGroup", [])):
232
+ key = arg_def.get("key")
233
+ if not key:
234
+ continue
235
+ if not evaluate_condition(arg_def.get("condition", "true"), {**self.params, "context": {}}):
236
+ continue
237
+ value = self.params.get(key)
238
+ issues.extend(
239
+ validate_parameter_value(
240
+ arg_def,
241
+ value,
242
+ params=self.params,
243
+ global_params=self.model.global_params,
244
+ context={
245
+ "scope": "component",
246
+ "componentID": self.id,
247
+ "componentClassID": self.class_id,
248
+ },
249
+ )
250
+ )
251
+ return issues
252
+
253
+ def to_node_data(self) -> NodeData:
254
+ return {
255
+ "id": self.id,
256
+ "label": self.label,
257
+ "nodeClassID": self.class_id,
258
+ "viewStatus": self.view_status,
259
+ "data": self.params,
260
+ "pinsLabel": self.pins_label,
261
+ }