archastro-sdk 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. archastro/__init__.py +4 -0
  2. archastro/phx_channel/__init__.py +11 -0
  3. archastro/phx_channel/channel.py +290 -0
  4. archastro/phx_channel/harness.py +120 -0
  5. archastro/phx_channel/socket.py +280 -0
  6. archastro/platform/__init__.py +11 -0
  7. archastro/platform/auth.py +187 -0
  8. archastro/platform/channels/__init__.py +7 -0
  9. archastro/platform/channels/api_activity_feed_channel.py +71 -0
  10. archastro/platform/channels/api_chat_channel.py +573 -0
  11. archastro/platform/channels/api_object_channel.py +76 -0
  12. archastro/platform/client.py +135 -0
  13. archastro/platform/runtime/__init__.py +0 -0
  14. archastro/platform/runtime/http_client.py +210 -0
  15. archastro/platform/types/__init__.py +16 -0
  16. archastro/platform/types/ai.py +56 -0
  17. archastro/platform/types/artifacts.py +31 -0
  18. archastro/platform/types/automations.py +23 -0
  19. archastro/platform/types/chat.py +77 -0
  20. archastro/platform/types/common.py +1179 -0
  21. archastro/platform/types/config.py +77 -0
  22. archastro/platform/types/image.py +17 -0
  23. archastro/platform/types/invites.py +19 -0
  24. archastro/platform/types/notifications.py +37 -0
  25. archastro/platform/types/teams.py +60 -0
  26. archastro/platform/types/threads.py +64 -0
  27. archastro/platform/types/users.py +32 -0
  28. archastro/platform/v1/__init__.py +79 -0
  29. archastro/platform/v1/resources/__init__.py +39 -0
  30. archastro/platform/v1/resources/activity_feed.py +83 -0
  31. archastro/platform/v1/resources/agent_computers.py +39 -0
  32. archastro/platform/v1/resources/agent_env_vars.py +33 -0
  33. archastro/platform/v1/resources/agent_health_actions.py +22 -0
  34. archastro/platform/v1/resources/agent_installations.py +82 -0
  35. archastro/platform/v1/resources/agent_routine_runs.py +35 -0
  36. archastro/platform/v1/resources/agent_routines.py +176 -0
  37. archastro/platform/v1/resources/agent_sessions.py +89 -0
  38. archastro/platform/v1/resources/agent_skills.py +62 -0
  39. archastro/platform/v1/resources/agent_tools.py +63 -0
  40. archastro/platform/v1/resources/agents.py +565 -0
  41. archastro/platform/v1/resources/ai.py +149 -0
  42. archastro/platform/v1/resources/artifacts.py +42 -0
  43. archastro/platform/v1/resources/automation_runs.py +22 -0
  44. archastro/platform/v1/resources/automations.py +27 -0
  45. archastro/platform/v1/resources/bug_reports.py +25 -0
  46. archastro/platform/v1/resources/config.py +427 -0
  47. archastro/platform/v1/resources/custom_objects.py +116 -0
  48. archastro/platform/v1/resources/files.py +17 -0
  49. archastro/platform/v1/resources/installation_sources.py +15 -0
  50. archastro/platform/v1/resources/invites.py +22 -0
  51. archastro/platform/v1/resources/knowledge_documents.py +93 -0
  52. archastro/platform/v1/resources/knowledge_sources.py +143 -0
  53. archastro/platform/v1/resources/kv.py +63 -0
  54. archastro/platform/v1/resources/notification_preferences.py +35 -0
  55. archastro/platform/v1/resources/notifications.py +80 -0
  56. archastro/platform/v1/resources/orgs.py +42 -0
  57. archastro/platform/v1/resources/solution_categories.py +41 -0
  58. archastro/platform/v1/resources/solution_tags.py +38 -0
  59. archastro/platform/v1/resources/solutions.py +242 -0
  60. archastro/platform/v1/resources/team_memberships.py +38 -0
  61. archastro/platform/v1/resources/teams.py +719 -0
  62. archastro/platform/v1/resources/thread_messages.py +86 -0
  63. archastro/platform/v1/resources/threads.py +337 -0
  64. archastro/platform/v1/resources/users.py +405 -0
  65. archastro_sdk-0.1.1.dist-info/METADATA +139 -0
  66. archastro_sdk-0.1.1.dist-info/RECORD +68 -0
  67. archastro_sdk-0.1.1.dist-info/WHEEL +4 -0
  68. archastro_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
archastro/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # Copyright (c) 2026 ArchAstro Inc. All Rights Reserved.
2
+ # This file is auto-generated by @archastro/sdk-generator. Do not edit.
3
+ # Content hash: e3b0c44298fc
4
+
@@ -0,0 +1,11 @@
1
+ from .channel import Channel
2
+ from .harness import HarnessServiceClient, HarnessServiceError
3
+ from .socket import Socket
4
+
5
+ __all__ = [
6
+ "Socket",
7
+ "Channel",
8
+ "HarnessServiceClient",
9
+ "HarnessServiceError",
10
+ ]
11
+ __version__ = "0.1.0"
@@ -0,0 +1,290 @@
1
+ """
2
+ Phoenix Channel — manages a single topic subscription, push/reply, and events.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ from collections.abc import Callable
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from .socket import Socket
14
+
15
+ logger = logging.getLogger("archastro.phx_channel")
16
+
17
+ DEFAULT_TIMEOUT_S = 10
18
+
19
+
20
+ class Channel:
21
+ """
22
+ Represents a single Phoenix Channel subscription on a topic.
23
+
24
+ Created via ``socket.channel("topic", params)``.
25
+ Call ``await channel.join()`` to subscribe.
26
+ """
27
+
28
+ def __init__(self, socket: Socket, topic: str, params: dict[str, Any]):
29
+ self._socket = socket
30
+ self._topic = topic
31
+ self._params = params
32
+
33
+ self._state: str = "closed" # closed | joining | joined | leaving | errored
34
+ self._join_ref: str | None = None
35
+ self._join_push_ref: str | None = None
36
+
37
+ self._event_handlers: dict[str, list[Callable[..., Any]]] = {}
38
+ self._pending_replies: dict[str, asyncio.Future[dict[str, Any]]] = {}
39
+ self._push_buffer: list[tuple[str, Any, asyncio.Future[dict[str, Any]]]] = []
40
+ # Inbound pushes that arrived before any handler was registered.
41
+ # Server scenarios can `autoPush` in response to a join frame, and
42
+ # that push can land on the asyncio queue before the caller has
43
+ # had a chance to register a handler post-join. We replay these
44
+ # to the first matching handler registered within the window.
45
+ # Bounded so a forgotten handler doesn't leak unbounded memory.
46
+ self._pending_pushes: dict[str, list[Any]] = {}
47
+ self._pending_pushes_cap: int = 32
48
+
49
+ @property
50
+ def topic(self) -> str:
51
+ return self._topic
52
+
53
+ @property
54
+ def state(self) -> str:
55
+ return self._state
56
+
57
+ @property
58
+ def is_joined(self) -> bool:
59
+ return self._state == "joined"
60
+
61
+ # ─── Join / Leave ─────────────────────────────────────────
62
+
63
+ async def join(
64
+ self,
65
+ payload: dict[str, Any] | None = None,
66
+ *,
67
+ timeout: float = DEFAULT_TIMEOUT_S,
68
+ ) -> dict[str, Any]:
69
+ """
70
+ Join the channel. Returns the join response payload.
71
+
72
+ ``payload`` overrides the params passed to ``socket.channel(topic, params)``
73
+ when provided — generated SDK channel classes pass the join payload
74
+ directly here, while hand-written callers typically rely on the params
75
+ captured at channel creation time.
76
+
77
+ Raises ``TimeoutError`` if the server doesn't reply within ``timeout``.
78
+ Raises ``ChannelError`` if the server rejects the join.
79
+ Raises ``ChannelError`` if the channel is already joined — callers
80
+ should ``leave()`` before re-joining. Silently returning an empty
81
+ response would let a double-call (e.g. two ``LiveDocChannel.join_*``
82
+ invocations reusing the same underlying channel) masquerade as a
83
+ successful join while dropping the real server response.
84
+ """
85
+ if self._state == "joined":
86
+ raise ChannelError(f"Channel {self._topic} is already joined; call leave() first")
87
+
88
+ self._state = "joining"
89
+ ref = self._socket._make_ref()
90
+ self._join_ref = ref
91
+ self._join_push_ref = ref
92
+
93
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
94
+ self._pending_replies[ref] = future
95
+
96
+ params = self._params if payload is None else payload
97
+ await self._socket._send(ref, ref, self._topic, "phx_join", params)
98
+
99
+ try:
100
+ result = await asyncio.wait_for(future, timeout=timeout)
101
+ except TimeoutError:
102
+ self._pending_replies.pop(ref, None)
103
+ self._state = "errored"
104
+ raise TimeoutError(f"Join timed out for {self._topic}") from None
105
+
106
+ status = result.get("status")
107
+ if status == "ok":
108
+ self._state = "joined"
109
+ logger.info("Joined %s", self._topic)
110
+ # Flush buffered pushes
111
+ await self._flush_push_buffer()
112
+ return result.get("response", {})
113
+ else:
114
+ self._state = "errored"
115
+ raise ChannelError(f"Join rejected for {self._topic}: {result.get('response', {})}")
116
+
117
+ async def leave(self, timeout: float = DEFAULT_TIMEOUT_S) -> None:
118
+ """Leave the channel."""
119
+ if self._state == "closed":
120
+ return
121
+
122
+ self._state = "leaving"
123
+ ref = self._socket._make_ref()
124
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
125
+ self._pending_replies[ref] = future
126
+
127
+ await self._socket._send(self._join_ref, ref, self._topic, "phx_leave", {})
128
+
129
+ try:
130
+ await asyncio.wait_for(future, timeout=timeout)
131
+ except TimeoutError:
132
+ pass # Leave is best-effort
133
+ finally:
134
+ self._state = "closed"
135
+ self._join_ref = None
136
+ self._socket._remove_channel(self._topic)
137
+ logger.info("Left %s", self._topic)
138
+
139
+ async def _rejoin(self) -> None:
140
+ """Rejoin after reconnection."""
141
+ if self._state in ("closed", "leaving"):
142
+ return
143
+ self._state = "closed"
144
+ self._join_ref = None
145
+ try:
146
+ await self.join()
147
+ except Exception as exc:
148
+ logger.warning("Rejoin failed for %s: %s", self._topic, exc)
149
+ self._state = "errored"
150
+
151
+ # ─── Push / Reply ─────────────────────────────────────────
152
+
153
+ async def push(
154
+ self, event: str, payload: Any = None, timeout: float = DEFAULT_TIMEOUT_S
155
+ ) -> dict[str, Any]:
156
+ """
157
+ Push an event to the channel and wait for a reply.
158
+
159
+ Returns the reply ``{"status": ..., "response": ...}``.
160
+ Raises ``TimeoutError`` if no reply within ``timeout``.
161
+ """
162
+ if payload is None:
163
+ payload = {}
164
+
165
+ if self._state != "joined":
166
+ # Buffer the push for when we rejoin
167
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
168
+ self._push_buffer.append((event, payload, future))
169
+ return await asyncio.wait_for(future, timeout=timeout)
170
+
171
+ return await self._do_push(event, payload, timeout)
172
+
173
+ async def _do_push(self, event: str, payload: Any, timeout: float) -> dict[str, Any]:
174
+ ref = self._socket._make_ref()
175
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
176
+ self._pending_replies[ref] = future
177
+
178
+ await self._socket._send(self._join_ref, ref, self._topic, event, payload)
179
+
180
+ try:
181
+ result = await asyncio.wait_for(future, timeout=timeout)
182
+ except TimeoutError:
183
+ self._pending_replies.pop(ref, None)
184
+ raise TimeoutError(f"Push '{event}' timed out on {self._topic}") from None
185
+
186
+ return result
187
+
188
+ async def _flush_push_buffer(self) -> None:
189
+ buffer = self._push_buffer[:]
190
+ self._push_buffer.clear()
191
+ for event, payload, future in buffer:
192
+ try:
193
+ result = await self._do_push(event, payload, DEFAULT_TIMEOUT_S)
194
+ if not future.done():
195
+ future.set_result(result)
196
+ except Exception as exc:
197
+ if not future.done():
198
+ future.set_exception(exc)
199
+
200
+ # ─── Event handlers ──────────────────────────────────────
201
+
202
+ def on(self, event: str, callback: Callable[..., Any]) -> Callable[[], None]:
203
+ """
204
+ Register a callback for a channel event.
205
+
206
+ Returns an unsubscribe function. If pushes for this event already
207
+ arrived before any handler was registered (e.g. server-side
208
+ autoPush fired on join), they're replayed to this callback in
209
+ arrival order so the caller can't miss them due to scheduling.
210
+ """
211
+ handlers = self._event_handlers.setdefault(event, [])
212
+ handlers.append(callback)
213
+
214
+ pending = self._pending_pushes.pop(event, None)
215
+ if pending:
216
+ for payload in pending:
217
+ try:
218
+ callback(payload)
219
+ except Exception:
220
+ logger.exception("Error in handler for %s:%s (replayed)", self._topic, event)
221
+
222
+ def unsubscribe() -> None:
223
+ handlers.remove(callback)
224
+
225
+ return unsubscribe
226
+
227
+ # ─── Internal message dispatch ────────────────────────────
228
+
229
+ def _on_message(
230
+ self,
231
+ join_ref: str | None,
232
+ ref: str | None,
233
+ event: str,
234
+ payload: Any,
235
+ ) -> None:
236
+ # Ignore messages from stale join
237
+ if join_ref is not None and join_ref != self._join_ref:
238
+ return
239
+
240
+ if event == "phx_reply":
241
+ self._handle_reply(ref, payload)
242
+ elif event == "phx_close":
243
+ self._handle_close()
244
+ elif event == "phx_error":
245
+ self._handle_error(payload)
246
+ else:
247
+ # User event — dispatch to handlers, or buffer if none yet.
248
+ handlers = self._event_handlers.get(event)
249
+ if handlers:
250
+ for handler in handlers:
251
+ try:
252
+ handler(payload)
253
+ except Exception:
254
+ logger.exception("Error in handler for %s:%s", self._topic, event)
255
+ else:
256
+ buf = self._pending_pushes.setdefault(event, [])
257
+ if len(buf) < self._pending_pushes_cap:
258
+ buf.append(payload)
259
+ else:
260
+ # Drop the oldest to bound memory for orphan events.
261
+ buf.pop(0)
262
+ buf.append(payload)
263
+
264
+ def _handle_reply(self, ref: str | None, payload: Any) -> None:
265
+ if ref and ref in self._pending_replies:
266
+ future = self._pending_replies.pop(ref)
267
+ if not future.done():
268
+ future.set_result(payload)
269
+
270
+ def _handle_close(self) -> None:
271
+ logger.info("Channel closed: %s", self._topic)
272
+ self._state = "closed"
273
+ self._trigger_event("phx_close", {})
274
+
275
+ def _handle_error(self, payload: Any) -> None:
276
+ logger.warning("Channel error: %s — %s", self._topic, payload)
277
+ self._state = "errored"
278
+ self._trigger_event("phx_error", payload)
279
+
280
+ def _trigger_event(self, event: str, payload: Any) -> None:
281
+ handlers = self._event_handlers.get(event, [])
282
+ for handler in handlers:
283
+ try:
284
+ handler(payload)
285
+ except Exception:
286
+ logger.exception("Error in handler for %s:%s", self._topic, event)
287
+
288
+
289
+ class ChannelError(Exception):
290
+ """Raised when a channel operation fails (e.g., join rejected)."""
@@ -0,0 +1,120 @@
1
+ """
2
+ HarnessServiceClient — Python counterpart to the TypeScript client in
3
+ ``@archastro/sdk-generator/channel-harness``.
4
+
5
+ The channel-harness service exposes two surfaces that this client speaks to:
6
+
7
+ 1. A **WebSocket** endpoint carrying the real Phoenix channel protocol.
8
+ Generated SDKs connect here to exercise their channel classes
9
+ end-to-end.
10
+
11
+ 2. An **HTTP control** endpoint for scenario management and observation
12
+ queries. Callers POST a JSON scenario to set up per-topic behavior,
13
+ GET observations to assert on what the SDK actually put on the wire,
14
+ and POST ``/reset`` between tests to drop state.
15
+
16
+ There is no in-process shortcut. Tests drive the SAME service the TypeScript
17
+ tests (or any other language) drive — the only differences are which SDK is
18
+ under test and which client library is opening the socket.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from .socket import Socket
28
+
29
+
30
+ class HarnessServiceClient:
31
+ """Thin client over the harness service's HTTP + WebSocket surfaces.
32
+
33
+ One instance per test is typical::
34
+
35
+ client = HarnessServiceClient(ws_url, control_url)
36
+ try:
37
+ await client.reset()
38
+ await client.register_scenario({
39
+ "topic": "doc:doc_42",
40
+ "onJoin": [{"type": "autoReply"}],
41
+ })
42
+ socket = await client.open_socket()
43
+ channel = await LiveDocChannel.join_document(
44
+ socket, "doc_42", user_id="user_1"
45
+ )
46
+ assert channel.join_response is not None
47
+ finally:
48
+ await client.close()
49
+ """
50
+
51
+ def __init__(self, ws_url: str, control_url: str, *, request_timeout: float = 5.0):
52
+ self.ws_url = ws_url
53
+ self.control_url = control_url.rstrip("/")
54
+ self._http = httpx.AsyncClient(timeout=request_timeout)
55
+ self._sockets: list[Socket] = []
56
+
57
+ async def close(self) -> None:
58
+ """Disconnect every socket opened through this client + close HTTP."""
59
+ for s in self._sockets:
60
+ try:
61
+ await s.disconnect()
62
+ except Exception:
63
+ pass
64
+ self._sockets.clear()
65
+ await self._http.aclose()
66
+
67
+ # ─── HTTP control ────────────────────────────────────────────
68
+
69
+ async def reset(self) -> None:
70
+ """Clear every scenario, observation, and handler error on the server."""
71
+ r = await self._http.post(f"{self.control_url}/reset")
72
+ r.raise_for_status()
73
+
74
+ async def register_scenario(self, scenario: dict[str, Any]) -> None:
75
+ """Register a scenario for an exact topic.
76
+
77
+ See the TS ``ScenarioRequest`` / ``ScenarioAction`` types for the
78
+ JSON shape — the server validates and returns 400 on invalid bodies.
79
+ """
80
+ r = await self._http.post(f"{self.control_url}/scenarios", json=scenario)
81
+ if r.status_code != 201:
82
+ raise HarnessServiceError(f"register_scenario failed: {r.status_code} {r.text}")
83
+
84
+ async def observations(
85
+ self, topic: str | None = None, event: str | None = None
86
+ ) -> list[dict[str, Any]]:
87
+ """Fetch inbound frames the server validated, optionally filtered."""
88
+ params: dict[str, str] = {}
89
+ if topic is not None:
90
+ params["topic"] = topic
91
+ if event is not None:
92
+ params["event"] = event
93
+ r = await self._http.get(f"{self.control_url}/observations", params=params)
94
+ r.raise_for_status()
95
+ return r.json()
96
+
97
+ async def handler_errors(self) -> list[dict[str, Any]]:
98
+ """Fetch scenario handler errors recorded by the server."""
99
+ r = await self._http.get(f"{self.control_url}/handler-errors")
100
+ r.raise_for_status()
101
+ return r.json()
102
+
103
+ # ─── Socket lifecycle ────────────────────────────────────────
104
+
105
+ async def open_socket(self, *, auto_reconnect: bool = False) -> Socket:
106
+ """Open a fresh Phoenix socket to the service's WebSocket endpoint.
107
+
108
+ Every call produces a new connection — the generated SDK receives
109
+ the same ``Socket`` it would talk to in production. ``auto_reconnect``
110
+ defaults to ``False`` so a disconnected test surfaces immediately
111
+ rather than silently retrying in the background.
112
+ """
113
+ socket = Socket(self.ws_url, auto_reconnect=auto_reconnect)
114
+ await socket.connect()
115
+ self._sockets.append(socket)
116
+ return socket
117
+
118
+
119
+ class HarnessServiceError(RuntimeError):
120
+ """Raised when the harness service returns a non-success HTTP status."""