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/_exceptions.py ADDED
@@ -0,0 +1,131 @@
1
+ """Exception classes for the ModuleX SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class ModulexError(Exception):
11
+ """Base exception for all ModuleX SDK errors."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ *,
17
+ status_code: int | None = None,
18
+ response: httpx.Response | None = None,
19
+ body: Any = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.status_code = status_code
24
+ self.response = response
25
+ self.body = body
26
+
27
+
28
+ class AuthenticationError(ModulexError):
29
+ """Raised when authentication fails (401)."""
30
+
31
+
32
+ class PermissionError(ModulexError):
33
+ """Raised when the user lacks permissions (403)."""
34
+
35
+
36
+ class NotFoundError(ModulexError):
37
+ """Raised when a resource is not found (404)."""
38
+
39
+
40
+ class BadRequestError(ModulexError):
41
+ """Raised for malformed requests (400)."""
42
+
43
+
44
+ class ValidationError(ModulexError):
45
+ """Raised for validation errors (422)."""
46
+
47
+
48
+ class ConflictError(ModulexError):
49
+ """Raised for resource conflicts (409)."""
50
+
51
+
52
+ class RateLimitError(ModulexError):
53
+ """Raised when rate limited (429)."""
54
+
55
+ def __init__(
56
+ self,
57
+ message: str,
58
+ *,
59
+ status_code: int | None = 429,
60
+ response: httpx.Response | None = None,
61
+ body: Any = None,
62
+ retry_after: float | None = None,
63
+ ) -> None:
64
+ super().__init__(message, status_code=status_code, response=response, body=body)
65
+ self.retry_after = retry_after
66
+
67
+
68
+ class InternalError(ModulexError):
69
+ """Raised for internal server errors (500)."""
70
+
71
+
72
+ class ExternalServiceError(ModulexError):
73
+ """Raised for external service errors (502)."""
74
+
75
+
76
+ class ServiceUnavailableError(ModulexError):
77
+ """Raised when the service is unavailable (503)."""
78
+
79
+
80
+ class StreamError(ModulexError):
81
+ """Raised for SSE stream errors."""
82
+
83
+
84
+ class TimeoutError(ModulexError):
85
+ """Raised when a request times out."""
86
+
87
+
88
+ _STATUS_CODE_MAP: dict[int, type[ModulexError]] = {
89
+ 400: BadRequestError,
90
+ 401: AuthenticationError,
91
+ 403: PermissionError,
92
+ 404: NotFoundError,
93
+ 409: ConflictError,
94
+ 422: ValidationError,
95
+ 429: RateLimitError,
96
+ 500: InternalError,
97
+ 502: ExternalServiceError,
98
+ 503: ServiceUnavailableError,
99
+ }
100
+
101
+ RETRYABLE_STATUS_CODES = {429, 500, 502, 503}
102
+
103
+
104
+ def raise_for_status(response: httpx.Response) -> None:
105
+ """Raise an appropriate exception for error HTTP status codes."""
106
+ if response.status_code < 400:
107
+ return
108
+
109
+ try:
110
+ body = response.json()
111
+ except Exception:
112
+ body = {"detail": response.text}
113
+
114
+ detail = body.get("detail", response.text) if isinstance(body, dict) else str(body)
115
+ if isinstance(detail, list):
116
+ detail = "; ".join(item.get("msg", str(item)) for item in detail)
117
+
118
+ exc_class = _STATUS_CODE_MAP.get(response.status_code, ModulexError)
119
+
120
+ kwargs: dict[str, Any] = {
121
+ "status_code": response.status_code,
122
+ "response": response,
123
+ "body": body,
124
+ }
125
+
126
+ if exc_class is RateLimitError:
127
+ retry_after_header = response.headers.get("Retry-After")
128
+ retry_after = float(retry_after_header) if retry_after_header else None
129
+ kwargs["retry_after"] = retry_after
130
+
131
+ raise exc_class(str(detail), **kwargs)
modulex/_streaming.py ADDED
@@ -0,0 +1,118 @@
1
+ """SSE streaming support for the ModuleX SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import AsyncIterator
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from httpx_sse import aconnect_sse
12
+
13
+ from modulex._exceptions import StreamError
14
+
15
+
16
+ @dataclass
17
+ class SSEEvent:
18
+ """A Server-Sent Event."""
19
+
20
+ event: str
21
+ data: dict[str, Any]
22
+ id: str | None = None
23
+ retry: int | None = None
24
+
25
+
26
+ class SSEStream:
27
+ """Async iterator for Server-Sent Events streams."""
28
+
29
+ def __init__(self, response: httpx.Response) -> None:
30
+ self._response = response
31
+ self._closed = False
32
+
33
+ def __aiter__(self) -> AsyncIterator[SSEEvent]:
34
+ return self._iterate()
35
+
36
+ async def _iterate(self) -> AsyncIterator[SSEEvent]:
37
+ """Iterate over SSE events from the response."""
38
+ try:
39
+ async for line in self._response.aiter_lines():
40
+ if self._closed:
41
+ break
42
+
43
+ line = line.strip()
44
+ if not line or line.startswith(":"):
45
+ continue
46
+
47
+ event = self._parse_event(line)
48
+ if event and event.event != "keepalive":
49
+ yield event
50
+ except httpx.StreamClosed:
51
+ return
52
+ except Exception as e:
53
+ if not self._closed:
54
+ raise StreamError(f"SSE stream error: {e}") from e
55
+
56
+ @staticmethod
57
+ def _parse_event(line: str) -> SSEEvent | None:
58
+ """Parse a single SSE line into an event."""
59
+ if line.startswith("data:"):
60
+ data_str = line[5:].strip()
61
+ try:
62
+ data = json.loads(data_str)
63
+ except json.JSONDecodeError:
64
+ data = {"raw": data_str}
65
+ return SSEEvent(event="message", data=data)
66
+ return None
67
+
68
+ async def close(self) -> None:
69
+ """Close the SSE stream."""
70
+ self._closed = True
71
+ await self._response.aclose()
72
+
73
+
74
+ class EventSourceStream:
75
+ """Full SSE parser that handles multi-line event blocks."""
76
+
77
+ def __init__(self, client: httpx.AsyncClient, method: str, url: str, **kwargs: Any) -> None:
78
+ self._client = client
79
+ self._method = method
80
+ self._url = url
81
+ self._kwargs = kwargs
82
+ self._closed = False
83
+
84
+ def __aiter__(self) -> AsyncIterator[SSEEvent]:
85
+ return self._iterate()
86
+
87
+ async def _iterate(self) -> AsyncIterator[SSEEvent]:
88
+ """Connect and iterate over SSE events."""
89
+ try:
90
+ async with aconnect_sse(self._client, self._method, self._url, **self._kwargs) as event_source:
91
+ async for sse in event_source.aiter_sse():
92
+ if self._closed:
93
+ break
94
+
95
+ event_type = sse.event or "message"
96
+ if event_type == "keepalive":
97
+ continue
98
+
99
+ try:
100
+ data = json.loads(sse.data) if sse.data else {}
101
+ except json.JSONDecodeError:
102
+ data = {"raw": sse.data}
103
+
104
+ yield SSEEvent(
105
+ event=event_type,
106
+ data=data,
107
+ id=sse.id,
108
+ retry=sse.retry,
109
+ )
110
+ except httpx.StreamClosed:
111
+ return
112
+ except Exception as e:
113
+ if not self._closed:
114
+ raise StreamError(f"SSE stream error: {e}") from e
115
+
116
+ async def close(self) -> None:
117
+ """Close the SSE stream."""
118
+ self._closed = True
modulex/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ # Resources package for the ModuleX Python SDK.
@@ -0,0 +1,39 @@
1
+ """ApiKeys resource for the ModuleX Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from modulex._base import _BaseResource
8
+
9
+
10
+ class ApiKeys(_BaseResource):
11
+ """Resource for managing API keys."""
12
+
13
+ async def create(
14
+ self,
15
+ name: str,
16
+ *,
17
+ organization_id: str | None = None,
18
+ expires_at: str | None = None,
19
+ rate_limit_per_minute: int = 60,
20
+ ) -> Any:
21
+ """Create a new API key with the given name and options."""
22
+ body: dict[str, Any] = {"name": name, "rate_limit_per_minute": rate_limit_per_minute}
23
+ if organization_id is not None:
24
+ body["organization_id"] = organization_id
25
+ if expires_at is not None:
26
+ body["expires_at"] = expires_at
27
+ return await self._post("/api-keys", json=body)
28
+
29
+ async def list(self, *, include_revoked: bool = False) -> Any:
30
+ """Return all API keys, optionally including revoked ones."""
31
+ return await self._get("/api-keys", params={"include_revoked": include_revoked})
32
+
33
+ async def get(self, key_id: str) -> Any:
34
+ """Return a single API key by its ID."""
35
+ return await self._get(f"/api-keys/{key_id}")
36
+
37
+ async def revoke(self, key_id: str) -> Any:
38
+ """Revoke an API key by its ID."""
39
+ return await self._delete(f"/api-keys/{key_id}")
@@ -0,0 +1,38 @@
1
+ """Auth resource for the ModuleX Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from modulex._base import _BaseResource
8
+
9
+
10
+ class Auth(_BaseResource):
11
+ """Resource for authentication and identity endpoints."""
12
+
13
+ async def me(self) -> Any:
14
+ """Return the currently authenticated user's profile."""
15
+ return await self._get("/auth/me")
16
+
17
+ async def organizations(self, *, role: str | None = None) -> Any:
18
+ """Return organizations the current user belongs to, optionally filtered by role."""
19
+ params: dict[str, Any] = {}
20
+ if role is not None:
21
+ params["role"] = role
22
+ return await self._get("/auth/me/organizations", params=params or None)
23
+
24
+ async def invitations(self) -> Any:
25
+ """Return all pending invitations for the current user."""
26
+ return await self._get("/auth/invitations/my")
27
+
28
+ async def accept_invitation(self, invitation_id: str) -> Any:
29
+ """Accept a pending organization invitation by its ID."""
30
+ return await self._post(f"/auth/invitations/{invitation_id}/accept")
31
+
32
+ async def reject_invitation(self, invitation_id: str) -> Any:
33
+ """Reject a pending organization invitation by its ID."""
34
+ return await self._post(f"/auth/invitations/{invitation_id}/reject")
35
+
36
+ async def leave_organization(self, *, organization_id: str | None = None) -> Any:
37
+ """Leave the organization identified by the organization_id header."""
38
+ return await self._post("/auth/organizations/leave", organization_id=organization_id)
@@ -0,0 +1,62 @@
1
+ """Chats resource for the ModuleX Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from modulex._base import _BaseResource
8
+ from modulex._streaming import EventSourceStream
9
+
10
+
11
+ class Chats(_BaseResource):
12
+ """Resource for managing chat sessions and their messages."""
13
+
14
+ async def list(self, *, organization_id: str | None = None) -> Any:
15
+ """Return all chat sessions for the current user or organization."""
16
+ return await self._get("/chats", organization_id=organization_id)
17
+
18
+ async def get(self, chat_id: str, *, organization_id: str | None = None) -> Any:
19
+ """Return a single chat session by its ID."""
20
+ return await self._get(f"/chats/{chat_id}", organization_id=organization_id)
21
+
22
+ async def messages(
23
+ self,
24
+ chat_id: str,
25
+ *,
26
+ limit: int = 20,
27
+ offset: int = 0,
28
+ organization_id: str | None = None,
29
+ ) -> Any:
30
+ """Return a paginated list of messages for a chat session."""
31
+ return await self._get(
32
+ f"/chats/{chat_id}/messages",
33
+ params={"limit": limit, "offset": offset},
34
+ organization_id=organization_id,
35
+ )
36
+
37
+ async def update(
38
+ self,
39
+ chat_id: str,
40
+ *,
41
+ title: str | None = None,
42
+ is_private: bool | None = None,
43
+ folder: str | None = None,
44
+ organization_id: str | None = None,
45
+ ) -> Any:
46
+ """Update metadata for a chat session such as title, privacy, or folder."""
47
+ body: dict[str, Any] = {}
48
+ if title is not None:
49
+ body["title"] = title
50
+ if is_private is not None:
51
+ body["is_private"] = is_private
52
+ if folder is not None:
53
+ body["folder"] = folder
54
+ return await self._patch(f"/chats/{chat_id}", json=body or None, organization_id=organization_id)
55
+
56
+ async def delete(self, chat_id: str, *, organization_id: str | None = None) -> Any:
57
+ """Delete a chat session by its ID."""
58
+ return await self._delete(f"/chats/{chat_id}", organization_id=organization_id)
59
+
60
+ def stream(self, *, organization_id: str | None = None) -> EventSourceStream:
61
+ """Open an SSE stream to receive real-time chat events."""
62
+ return self._stream_sse("/chats/stream", organization_id=organization_id)
@@ -0,0 +1,134 @@
1
+ """Composer resource for the ModuleX Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from modulex._base import _BaseResource
8
+ from modulex._streaming import EventSourceStream
9
+
10
+
11
+ class Composer(_BaseResource):
12
+ """Resource for the AI-assisted workflow composer and its chat sessions."""
13
+
14
+ async def chat(
15
+ self,
16
+ message: str,
17
+ *,
18
+ workflow_id: str | None = None,
19
+ composer_chat_id: str | None = None,
20
+ llm: str | None = None,
21
+ organization_id: str | None = None,
22
+ ) -> Any:
23
+ """Send a message to the composer and receive an AI-generated workflow response."""
24
+ body: dict[str, Any] = {
25
+ k: v
26
+ for k, v in {
27
+ "message": message,
28
+ "workflow_id": workflow_id,
29
+ "composer_chat_id": composer_chat_id,
30
+ "llm": llm,
31
+ }.items()
32
+ if v is not None
33
+ }
34
+ return await self._post("/composer/chat", json=body, organization_id=organization_id)
35
+
36
+ async def get(
37
+ self,
38
+ composer_chat_id: str,
39
+ *,
40
+ organization_id: str | None = None,
41
+ ) -> Any:
42
+ """Return a composer chat session by its ID."""
43
+ return await self._get(f"/composer/chat/{composer_chat_id}", organization_id=organization_id)
44
+
45
+ def listen(
46
+ self,
47
+ composer_chat_id: str,
48
+ run_id: str,
49
+ *,
50
+ organization_id: str | None = None,
51
+ ) -> EventSourceStream:
52
+ """Open an SSE stream to receive live events for a composer chat run."""
53
+ return self._stream_sse(
54
+ f"/composer/chat/{composer_chat_id}/listen/{run_id}",
55
+ organization_id=organization_id,
56
+ )
57
+
58
+ async def save(
59
+ self,
60
+ composer_chat_id: str,
61
+ *,
62
+ organization_id: str | None = None,
63
+ ) -> Any:
64
+ """Save the current workflow state generated by a composer chat session."""
65
+ return await self._post(
66
+ f"/composer/chat/{composer_chat_id}/save",
67
+ organization_id=organization_id,
68
+ )
69
+
70
+ async def revert(
71
+ self,
72
+ composer_chat_id: str,
73
+ *,
74
+ organization_id: str | None = None,
75
+ ) -> Any:
76
+ """Revert the composer chat session to its last saved workflow state."""
77
+ return await self._post(
78
+ f"/composer/chat/{composer_chat_id}/revert",
79
+ organization_id=organization_id,
80
+ )
81
+
82
+ async def history(
83
+ self,
84
+ workflow_id: str,
85
+ *,
86
+ limit: int = 10,
87
+ organization_id: str | None = None,
88
+ ) -> Any:
89
+ """Return the composer chat history associated with a workflow."""
90
+ params: dict[str, Any] = {"limit": limit}
91
+ return await self._get(
92
+ f"/composer/chat/workflow/{workflow_id}/history",
93
+ params=params,
94
+ organization_id=organization_id,
95
+ )
96
+
97
+ async def delete(
98
+ self,
99
+ composer_chat_id: str,
100
+ *,
101
+ permanent: bool = False,
102
+ organization_id: str | None = None,
103
+ ) -> Any:
104
+ """Delete a composer chat session, optionally removing it permanently."""
105
+ params: dict[str, Any] = {"permanent": permanent}
106
+ return await self._delete(
107
+ f"/composer/chat/{composer_chat_id}",
108
+ params=params,
109
+ organization_id=organization_id,
110
+ )
111
+
112
+ async def status(
113
+ self,
114
+ composer_chat_id: str,
115
+ *,
116
+ organization_id: str | None = None,
117
+ ) -> Any:
118
+ """Return the current processing status of a composer chat session."""
119
+ return await self._get(
120
+ f"/composer/chat/{composer_chat_id}/status",
121
+ organization_id=organization_id,
122
+ )
123
+
124
+ async def cancel(
125
+ self,
126
+ composer_chat_id: str,
127
+ *,
128
+ organization_id: str | None = None,
129
+ ) -> Any:
130
+ """Cancel an in-progress composer chat run."""
131
+ return await self._post(
132
+ f"/composer/chat/{composer_chat_id}/cancel",
133
+ organization_id=organization_id,
134
+ )