modulex-python 0.1.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.
Files changed (47) hide show
  1. modulex/__init__.py +39 -0
  2. modulex/_base.py +281 -0
  3. modulex/_client.py +237 -0
  4. modulex/_compat.py +39 -0
  5. modulex/_config.py +26 -0
  6. modulex/_exceptions.py +131 -0
  7. modulex/_streaming.py +118 -0
  8. modulex/py.typed +0 -0
  9. modulex/resources/__init__.py +1 -0
  10. modulex/resources/api_keys.py +39 -0
  11. modulex/resources/auth.py +38 -0
  12. modulex/resources/chats.py +62 -0
  13. modulex/resources/composer.py +134 -0
  14. modulex/resources/credentials.py +197 -0
  15. modulex/resources/dashboard.py +110 -0
  16. modulex/resources/deployments.py +92 -0
  17. modulex/resources/executions.py +97 -0
  18. modulex/resources/integrations.py +110 -0
  19. modulex/resources/knowledge.py +343 -0
  20. modulex/resources/notifications.py +39 -0
  21. modulex/resources/organizations.py +72 -0
  22. modulex/resources/schedules.py +172 -0
  23. modulex/resources/subscriptions.py +38 -0
  24. modulex/resources/system.py +28 -0
  25. modulex/resources/templates.py +115 -0
  26. modulex/resources/workflows.py +156 -0
  27. modulex/types/__init__.py +294 -0
  28. modulex/types/api_keys.py +19 -0
  29. modulex/types/auth.py +62 -0
  30. modulex/types/chats.py +55 -0
  31. modulex/types/composer.py +27 -0
  32. modulex/types/credentials.py +79 -0
  33. modulex/types/dashboard.py +54 -0
  34. modulex/types/executions.py +104 -0
  35. modulex/types/integrations.py +29 -0
  36. modulex/types/knowledge.py +75 -0
  37. modulex/types/notifications.py +16 -0
  38. modulex/types/organizations.py +43 -0
  39. modulex/types/schedules.py +48 -0
  40. modulex/types/shared.py +39 -0
  41. modulex/types/subscriptions.py +59 -0
  42. modulex/types/templates.py +50 -0
  43. modulex/types/workflows.py +253 -0
  44. modulex_python-0.1.0.dist-info/METADATA +435 -0
  45. modulex_python-0.1.0.dist-info/RECORD +47 -0
  46. modulex_python-0.1.0.dist-info/WHEEL +4 -0
  47. modulex_python-0.1.0.dist-info/licenses/LICENSE +21 -0
modulex/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """ModuleX Python SDK — Official client for the ModuleX AI workflow orchestration platform."""
2
+
3
+ from modulex._client import Modulex
4
+ from modulex._exceptions import (
5
+ AuthenticationError,
6
+ BadRequestError,
7
+ ConflictError,
8
+ ExternalServiceError,
9
+ InternalError,
10
+ ModulexError,
11
+ NotFoundError,
12
+ PermissionError,
13
+ RateLimitError,
14
+ ServiceUnavailableError,
15
+ StreamError,
16
+ TimeoutError,
17
+ ValidationError,
18
+ )
19
+ from modulex._streaming import SSEEvent
20
+
21
+ __all__ = [
22
+ "Modulex",
23
+ "ModulexError",
24
+ "AuthenticationError",
25
+ "PermissionError",
26
+ "NotFoundError",
27
+ "BadRequestError",
28
+ "ValidationError",
29
+ "ConflictError",
30
+ "RateLimitError",
31
+ "InternalError",
32
+ "ExternalServiceError",
33
+ "ServiceUnavailableError",
34
+ "StreamError",
35
+ "TimeoutError",
36
+ "SSEEvent",
37
+ ]
38
+
39
+ __version__ = "0.1.0"
modulex/_base.py ADDED
@@ -0,0 +1,281 @@
1
+ """Base resource class with HTTP methods, retry logic, and error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections.abc import AsyncIterator
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import httpx
10
+
11
+ from modulex._exceptions import (
12
+ RETRYABLE_STATUS_CODES,
13
+ TimeoutError,
14
+ raise_for_status,
15
+ )
16
+ from modulex._streaming import EventSourceStream
17
+
18
+ if TYPE_CHECKING:
19
+ from modulex._client import Modulex
20
+
21
+
22
+ class _BaseResource:
23
+ """Base class for all API resource classes."""
24
+
25
+ def __init__(self, client: Modulex) -> None:
26
+ self._client = client
27
+
28
+ def _resolve_org_id(self, organization_id: str | None) -> str | None:
29
+ """Resolve organization ID: per-request > client default > None."""
30
+ if organization_id is not None:
31
+ return organization_id
32
+ return self._client._config.organization_id
33
+
34
+ def _build_headers(self, organization_id: str | None = None) -> dict[str, str]:
35
+ """Build request headers with auth and optional org context."""
36
+ headers: dict[str, str] = {
37
+ "Authorization": f"Bearer {self._client._config.api_key}",
38
+ "Content-Type": "application/json",
39
+ }
40
+ org_id = self._resolve_org_id(organization_id)
41
+ if org_id:
42
+ headers["X-Organization-ID"] = org_id
43
+ return headers
44
+
45
+ def _should_retry(self, method: str, status_code: int, attempt: int) -> bool:
46
+ """Determine if a request should be retried."""
47
+ if attempt >= self._client._config.max_retries:
48
+ return False
49
+ if status_code not in RETRYABLE_STATUS_CODES:
50
+ return False
51
+ if method.upper() not in ("GET", "HEAD"):
52
+ return False
53
+ return True
54
+
55
+ @staticmethod
56
+ def _backoff_delay(attempt: int, retry_after: float | None = None) -> float:
57
+ """Calculate backoff delay with jitter."""
58
+ if retry_after is not None:
59
+ return retry_after
60
+ base = 0.5
61
+ delay: float = min(base * (2**attempt) + random.random() * 0.5, 30.0)
62
+ return delay
63
+
64
+ async def _request(
65
+ self,
66
+ method: str,
67
+ path: str,
68
+ *,
69
+ params: dict[str, Any] | None = None,
70
+ json: dict[str, Any] | None = None,
71
+ organization_id: str | None = None,
72
+ **kwargs: Any,
73
+ ) -> Any:
74
+ """Execute an HTTP request with retry logic."""
75
+ url = f"{self._client._config.base_url}{path}"
76
+ headers = self._build_headers(organization_id)
77
+
78
+ # Filter None values from params
79
+ if params:
80
+ params = {k: v for k, v in params.items() if v is not None}
81
+
82
+ last_exc: Exception | None = None
83
+ for attempt in range(self._client._config.max_retries + 1):
84
+ try:
85
+ response = await self._client._http.request(
86
+ method,
87
+ url,
88
+ headers=headers,
89
+ params=params,
90
+ json=json,
91
+ timeout=self._client._config.timeout,
92
+ **kwargs,
93
+ )
94
+ except httpx.TimeoutException as e:
95
+ if attempt < self._client._config.max_retries and method.upper() in ("GET", "HEAD"):
96
+ last_exc = e
97
+ import asyncio
98
+
99
+ await asyncio.sleep(self._backoff_delay(attempt))
100
+ continue
101
+ raise TimeoutError(f"Request timed out: {e}") from e
102
+
103
+ if response.status_code < 400:
104
+ if response.status_code == 204:
105
+ return None
106
+ content_type = response.headers.get("content-type", "")
107
+ if "application/json" in content_type:
108
+ return response.json()
109
+ try:
110
+ return response.json()
111
+ except Exception:
112
+ return response.text
113
+
114
+ if self._should_retry(method, response.status_code, attempt):
115
+ retry_after: float | None = None
116
+ if response.status_code == 429:
117
+ retry_after_header = response.headers.get("Retry-After")
118
+ if retry_after_header:
119
+ retry_after = float(retry_after_header)
120
+
121
+ import asyncio
122
+
123
+ await asyncio.sleep(self._backoff_delay(attempt, retry_after))
124
+ continue
125
+
126
+ raise_for_status(response)
127
+
128
+ if last_exc:
129
+ raise TimeoutError(f"Request timed out after {self._client._config.max_retries} retries") from last_exc
130
+ raise_for_status(response)
131
+
132
+ async def _get(
133
+ self,
134
+ path: str,
135
+ *,
136
+ params: dict[str, Any] | None = None,
137
+ organization_id: str | None = None,
138
+ **kwargs: Any,
139
+ ) -> Any:
140
+ """Execute a GET request."""
141
+ return await self._request("GET", path, params=params, organization_id=organization_id, **kwargs)
142
+
143
+ async def _post(
144
+ self,
145
+ path: str,
146
+ *,
147
+ json: dict[str, Any] | None = None,
148
+ organization_id: str | None = None,
149
+ **kwargs: Any,
150
+ ) -> Any:
151
+ """Execute a POST request."""
152
+ return await self._request("POST", path, json=json, organization_id=organization_id, **kwargs)
153
+
154
+ async def _put(
155
+ self,
156
+ path: str,
157
+ *,
158
+ json: dict[str, Any] | None = None,
159
+ organization_id: str | None = None,
160
+ **kwargs: Any,
161
+ ) -> Any:
162
+ """Execute a PUT request."""
163
+ return await self._request("PUT", path, json=json, organization_id=organization_id, **kwargs)
164
+
165
+ async def _patch(
166
+ self,
167
+ path: str,
168
+ *,
169
+ json: dict[str, Any] | None = None,
170
+ organization_id: str | None = None,
171
+ **kwargs: Any,
172
+ ) -> Any:
173
+ """Execute a PATCH request."""
174
+ return await self._request("PATCH", path, json=json, organization_id=organization_id, **kwargs)
175
+
176
+ async def _delete(
177
+ self,
178
+ path: str,
179
+ *,
180
+ params: dict[str, Any] | None = None,
181
+ json: dict[str, Any] | None = None,
182
+ organization_id: str | None = None,
183
+ **kwargs: Any,
184
+ ) -> Any:
185
+ """Execute a DELETE request."""
186
+ return await self._request("DELETE", path, params=params, json=json, organization_id=organization_id, **kwargs)
187
+
188
+ def _stream_sse(
189
+ self,
190
+ path: str,
191
+ *,
192
+ method: str = "GET",
193
+ organization_id: str | None = None,
194
+ **kwargs: Any,
195
+ ) -> EventSourceStream:
196
+ """Create an SSE stream connection."""
197
+ url = f"{self._client._config.base_url}{path}"
198
+ headers = self._build_headers(organization_id)
199
+ headers.pop("Content-Type", None)
200
+ return EventSourceStream(
201
+ self._client._http,
202
+ method,
203
+ url,
204
+ headers=headers,
205
+ timeout=httpx.Timeout(self._client._config.timeout, read=None),
206
+ **kwargs,
207
+ )
208
+
209
+ async def _upload(
210
+ self,
211
+ path: str,
212
+ *,
213
+ file: Any,
214
+ filename: str,
215
+ data: dict[str, str] | None = None,
216
+ organization_id: str | None = None,
217
+ **kwargs: Any,
218
+ ) -> Any:
219
+ """Execute a multipart file upload."""
220
+ url = f"{self._client._config.base_url}{path}"
221
+ headers = self._build_headers(organization_id)
222
+ headers.pop("Content-Type", None)
223
+
224
+ files = {"file": (filename, file)}
225
+
226
+ response = await self._client._http.post(
227
+ url,
228
+ headers=headers,
229
+ files=files,
230
+ data=data,
231
+ timeout=self._client._config.timeout,
232
+ **kwargs,
233
+ )
234
+
235
+ if response.status_code >= 400:
236
+ raise_for_status(response)
237
+
238
+ return response.json()
239
+
240
+ async def _paginate(
241
+ self,
242
+ path: str,
243
+ *,
244
+ items_key: str = "items",
245
+ params: dict[str, Any] | None = None,
246
+ organization_id: str | None = None,
247
+ page_size: int = 20,
248
+ **kwargs: Any,
249
+ ) -> AsyncIterator[dict[str, Any]]:
250
+ """Auto-paginate through a list endpoint."""
251
+ params = dict(params or {})
252
+
253
+ # Detect pagination style
254
+ if "page" in params or "page_size" in params:
255
+ # Page-based pagination
256
+ page = params.pop("page", 1)
257
+ params["page_size"] = params.pop("page_size", page_size)
258
+ while True:
259
+ params["page"] = page
260
+ result = await self._get(path, params=params, organization_id=organization_id, **kwargs)
261
+ items = result.get(items_key, [])
262
+ for item in items:
263
+ yield item
264
+ total_pages = result.get("total_pages", 1)
265
+ if page >= total_pages:
266
+ break
267
+ page += 1
268
+ else:
269
+ # Limit/offset pagination
270
+ offset = params.pop("offset", 0)
271
+ limit = params.pop("limit", page_size)
272
+ params["limit"] = limit
273
+ while True:
274
+ params["offset"] = offset
275
+ result = await self._get(path, params=params, organization_id=organization_id, **kwargs)
276
+ items = result.get(items_key, [])
277
+ for item in items:
278
+ yield item
279
+ if not result.get("has_next", False) or len(items) < limit:
280
+ break
281
+ offset += limit
modulex/_client.py ADDED
@@ -0,0 +1,237 @@
1
+ """Main ModuleX client class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import httpx
8
+
9
+ from modulex._config import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, ClientConfig
10
+
11
+ if TYPE_CHECKING:
12
+ from modulex.resources.api_keys import ApiKeys
13
+ from modulex.resources.auth import Auth
14
+ from modulex.resources.chats import Chats
15
+ from modulex.resources.composer import Composer
16
+ from modulex.resources.credentials import Credentials
17
+ from modulex.resources.dashboard import Dashboard
18
+ from modulex.resources.deployments import Deployments
19
+ from modulex.resources.executions import Executions
20
+ from modulex.resources.integrations import Integrations
21
+ from modulex.resources.knowledge import Knowledge
22
+ from modulex.resources.notifications import Notifications
23
+ from modulex.resources.organizations import Organizations
24
+ from modulex.resources.schedules import Schedules
25
+ from modulex.resources.subscriptions import Subscriptions
26
+ from modulex.resources.system import System
27
+ from modulex.resources.templates import Templates
28
+ from modulex.resources.workflows import Workflows
29
+
30
+
31
+ class Modulex:
32
+ """Async client for the ModuleX API.
33
+
34
+ Usage:
35
+ async with Modulex(api_key="mx_live_...") as client:
36
+ me = await client.auth.me()
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ api_key: str,
42
+ *,
43
+ organization_id: str | None = None,
44
+ base_url: str = DEFAULT_BASE_URL,
45
+ timeout: float = DEFAULT_TIMEOUT,
46
+ max_retries: int = DEFAULT_MAX_RETRIES,
47
+ ) -> None:
48
+ self._config = ClientConfig(
49
+ api_key=api_key,
50
+ organization_id=organization_id,
51
+ base_url=base_url,
52
+ timeout=timeout,
53
+ max_retries=max_retries,
54
+ )
55
+ self._http = httpx.AsyncClient()
56
+
57
+ # Lazy resource cache
58
+ self._auth: object | None = None
59
+ self._workflows: object | None = None
60
+ self._executions: object | None = None
61
+ self._deployments: object | None = None
62
+ self._chats: object | None = None
63
+ self._credentials: object | None = None
64
+ self._integrations: object | None = None
65
+ self._knowledge: object | None = None
66
+ self._schedules: object | None = None
67
+ self._templates: object | None = None
68
+ self._composer: object | None = None
69
+ self._dashboard: object | None = None
70
+ self._subscriptions: object | None = None
71
+ self._notifications: object | None = None
72
+ self._api_keys: object | None = None
73
+ self._system: object | None = None
74
+ self._organizations: object | None = None
75
+
76
+ async def __aenter__(self) -> Modulex:
77
+ return self
78
+
79
+ async def __aexit__(self, *args: object) -> None:
80
+ await self.close()
81
+
82
+ async def close(self) -> None:
83
+ """Close the HTTP client."""
84
+ await self._http.aclose()
85
+
86
+ @property
87
+ def auth(self) -> Auth:
88
+ """Access auth endpoints."""
89
+ if self._auth is None:
90
+ from modulex.resources.auth import Auth
91
+
92
+ self._auth = Auth(self)
93
+ return self._auth # type: ignore[return-value]
94
+
95
+ @property
96
+ def workflows(self) -> Workflows:
97
+ """Access workflow endpoints."""
98
+ if self._workflows is None:
99
+ from modulex.resources.workflows import Workflows
100
+
101
+ self._workflows = Workflows(self)
102
+ return self._workflows # type: ignore[return-value]
103
+
104
+ @property
105
+ def executions(self) -> Executions:
106
+ """Access execution endpoints."""
107
+ if self._executions is None:
108
+ from modulex.resources.executions import Executions
109
+
110
+ self._executions = Executions(self)
111
+ return self._executions # type: ignore[return-value]
112
+
113
+ @property
114
+ def deployments(self) -> Deployments:
115
+ """Access deployment endpoints."""
116
+ if self._deployments is None:
117
+ from modulex.resources.deployments import Deployments
118
+
119
+ self._deployments = Deployments(self)
120
+ return self._deployments # type: ignore[return-value]
121
+
122
+ @property
123
+ def chats(self) -> Chats:
124
+ """Access chat endpoints."""
125
+ if self._chats is None:
126
+ from modulex.resources.chats import Chats
127
+
128
+ self._chats = Chats(self)
129
+ return self._chats # type: ignore[return-value]
130
+
131
+ @property
132
+ def credentials(self) -> Credentials:
133
+ """Access credential endpoints."""
134
+ if self._credentials is None:
135
+ from modulex.resources.credentials import Credentials
136
+
137
+ self._credentials = Credentials(self)
138
+ return self._credentials # type: ignore[return-value]
139
+
140
+ @property
141
+ def integrations(self) -> Integrations:
142
+ """Access integration endpoints."""
143
+ if self._integrations is None:
144
+ from modulex.resources.integrations import Integrations
145
+
146
+ self._integrations = Integrations(self)
147
+ return self._integrations # type: ignore[return-value]
148
+
149
+ @property
150
+ def knowledge(self) -> Knowledge:
151
+ """Access knowledge base endpoints."""
152
+ if self._knowledge is None:
153
+ from modulex.resources.knowledge import Knowledge
154
+
155
+ self._knowledge = Knowledge(self)
156
+ return self._knowledge # type: ignore[return-value]
157
+
158
+ @property
159
+ def schedules(self) -> Schedules:
160
+ """Access schedule endpoints."""
161
+ if self._schedules is None:
162
+ from modulex.resources.schedules import Schedules
163
+
164
+ self._schedules = Schedules(self)
165
+ return self._schedules # type: ignore[return-value]
166
+
167
+ @property
168
+ def templates(self) -> Templates:
169
+ """Access template endpoints."""
170
+ if self._templates is None:
171
+ from modulex.resources.templates import Templates
172
+
173
+ self._templates = Templates(self)
174
+ return self._templates # type: ignore[return-value]
175
+
176
+ @property
177
+ def composer(self) -> Composer:
178
+ """Access composer endpoints."""
179
+ if self._composer is None:
180
+ from modulex.resources.composer import Composer
181
+
182
+ self._composer = Composer(self)
183
+ return self._composer # type: ignore[return-value]
184
+
185
+ @property
186
+ def dashboard(self) -> Dashboard:
187
+ """Access dashboard endpoints."""
188
+ if self._dashboard is None:
189
+ from modulex.resources.dashboard import Dashboard
190
+
191
+ self._dashboard = Dashboard(self)
192
+ return self._dashboard # type: ignore[return-value]
193
+
194
+ @property
195
+ def subscriptions(self) -> Subscriptions:
196
+ """Access subscription endpoints."""
197
+ if self._subscriptions is None:
198
+ from modulex.resources.subscriptions import Subscriptions
199
+
200
+ self._subscriptions = Subscriptions(self)
201
+ return self._subscriptions # type: ignore[return-value]
202
+
203
+ @property
204
+ def notifications(self) -> Notifications:
205
+ """Access notification endpoints."""
206
+ if self._notifications is None:
207
+ from modulex.resources.notifications import Notifications
208
+
209
+ self._notifications = Notifications(self)
210
+ return self._notifications # type: ignore[return-value]
211
+
212
+ @property
213
+ def api_keys(self) -> ApiKeys:
214
+ """Access API key endpoints."""
215
+ if self._api_keys is None:
216
+ from modulex.resources.api_keys import ApiKeys
217
+
218
+ self._api_keys = ApiKeys(self)
219
+ return self._api_keys # type: ignore[return-value]
220
+
221
+ @property
222
+ def system(self) -> System:
223
+ """Access system endpoints."""
224
+ if self._system is None:
225
+ from modulex.resources.system import System
226
+
227
+ self._system = System(self)
228
+ return self._system # type: ignore[return-value]
229
+
230
+ @property
231
+ def organizations(self) -> Organizations:
232
+ """Access organization endpoints."""
233
+ if self._organizations is None:
234
+ from modulex.resources.organizations import Organizations
235
+
236
+ self._organizations = Organizations(self)
237
+ return self._organizations # type: ignore[return-value]
modulex/_compat.py ADDED
@@ -0,0 +1,39 @@
1
+ """Synchronous compatibility wrapper for the ModuleX async client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ def _get_or_create_event_loop() -> asyncio.AbstractEventLoop:
12
+ """Get the current event loop or create a new one."""
13
+ try:
14
+ loop = asyncio.get_event_loop()
15
+ if loop.is_closed():
16
+ loop = asyncio.new_event_loop()
17
+ asyncio.set_event_loop(loop)
18
+ return loop
19
+ except RuntimeError:
20
+ loop = asyncio.new_event_loop()
21
+ asyncio.set_event_loop(loop)
22
+ return loop
23
+
24
+
25
+ def run_sync(coro: Any) -> Any:
26
+ """Run an async coroutine synchronously."""
27
+ try:
28
+ loop = asyncio.get_running_loop()
29
+ except RuntimeError:
30
+ loop = None
31
+
32
+ if loop and loop.is_running():
33
+ import concurrent.futures
34
+
35
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
36
+ future = pool.submit(asyncio.run, coro)
37
+ return future.result()
38
+ else:
39
+ return asyncio.run(coro)
modulex/_config.py ADDED
@@ -0,0 +1,26 @@
1
+ """Client configuration for the ModuleX SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ DEFAULT_BASE_URL = "https://api.modulex.dev"
8
+ DEFAULT_TIMEOUT = 30.0
9
+ DEFAULT_MAX_RETRIES = 3
10
+
11
+
12
+ @dataclass
13
+ class ClientConfig:
14
+ """Configuration for the ModuleX client."""
15
+
16
+ api_key: str
17
+ organization_id: str | None = None
18
+ base_url: str = DEFAULT_BASE_URL
19
+ timeout: float = DEFAULT_TIMEOUT
20
+ max_retries: int = DEFAULT_MAX_RETRIES
21
+ default_headers: dict[str, str] = field(default_factory=dict)
22
+
23
+ def __post_init__(self) -> None:
24
+ self.base_url = self.base_url.rstrip("/")
25
+ if not self.api_key:
26
+ raise ValueError("api_key is required")