codespar 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.
codespar/_http.py ADDED
@@ -0,0 +1,140 @@
1
+ """
2
+ HTTP primitives shared by the async + sync clients.
3
+
4
+ Every call through the SDK goes through ``request_json`` / ``stream_sse``
5
+ so header injection, auth, project-id threading, and error mapping live
6
+ in one place. Neither the public ``AsyncCodeSpar`` nor ``Session``
7
+ classes import httpx directly — if we ever swap the transport, only
8
+ this file changes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import AsyncIterator
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from .errors import ApiError, StreamError
20
+
21
+ DEFAULT_BASE_URL = "https://api.codespar.dev"
22
+
23
+
24
+ def build_headers(
25
+ api_key: str, project_id: str | None, *, accept_sse: bool = False
26
+ ) -> dict[str, str]:
27
+ """Standard header set for every authenticated request."""
28
+ headers = {
29
+ "Content-Type": "application/json",
30
+ "Authorization": f"Bearer {api_key}",
31
+ "User-Agent": "codespar-python/0.1.0",
32
+ }
33
+ if project_id:
34
+ headers["x-codespar-project"] = project_id
35
+ if accept_sse:
36
+ headers["Accept"] = "text/event-stream"
37
+ else:
38
+ headers["Accept"] = "application/json"
39
+ return headers
40
+
41
+
42
+ async def request_json(
43
+ client: httpx.AsyncClient,
44
+ method: str,
45
+ path: str,
46
+ *,
47
+ api_key: str,
48
+ project_id: str | None,
49
+ body: Any = None,
50
+ ) -> Any:
51
+ """Do an authenticated JSON request, return parsed body or raise ApiError."""
52
+ headers = build_headers(api_key, project_id)
53
+ try:
54
+ response = await client.request(
55
+ method,
56
+ path,
57
+ headers=headers,
58
+ json=body if body is not None else None,
59
+ )
60
+ except httpx.HTTPError as exc: # network, timeout, connect failure
61
+ raise ApiError(f"{method} {path} failed: {exc}", status=0) from exc
62
+
63
+ # 204 No Content — used by close()
64
+ if response.status_code == 204:
65
+ return None
66
+
67
+ raw = response.text
68
+ parsed: Any = None
69
+ if raw:
70
+ try:
71
+ parsed = response.json()
72
+ except json.JSONDecodeError:
73
+ parsed = raw
74
+
75
+ if not response.is_success:
76
+ code: str | None = None
77
+ message = f"{method} {path} failed: {response.status_code}"
78
+ if isinstance(parsed, dict):
79
+ code = parsed.get("error") if isinstance(parsed.get("error"), str) else None
80
+ msg = parsed.get("message")
81
+ if isinstance(msg, str):
82
+ message = f"{message} — {msg}"
83
+ elif code:
84
+ message = f"{message} — {code}"
85
+ raise ApiError(message, status=response.status_code, body=parsed, code=code)
86
+
87
+ return parsed
88
+
89
+
90
+ async def stream_sse(
91
+ client: httpx.AsyncClient,
92
+ path: str,
93
+ *,
94
+ api_key: str,
95
+ project_id: str | None,
96
+ body: Any,
97
+ ) -> AsyncIterator[dict[str, Any]]:
98
+ """
99
+ POST a JSON body and yield parsed SSE data frames from the response.
100
+
101
+ Only ``data:`` lines are surfaced to the caller; ``event:`` and
102
+ comment lines (``: keep-alive``) are swallowed. Each yielded dict
103
+ is the JSON payload from a single SSE frame. Callers interpret
104
+ the ``type`` field themselves.
105
+ """
106
+ headers = build_headers(api_key, project_id, accept_sse=True)
107
+ try:
108
+ async with client.stream(
109
+ "POST",
110
+ path,
111
+ headers=headers,
112
+ json=body,
113
+ ) as response:
114
+ if not response.is_success:
115
+ raw = await response.aread()
116
+ text = raw.decode("utf-8", errors="replace")
117
+ raise ApiError(
118
+ f"POST {path} (stream) failed: {response.status_code} — {text[:200]}",
119
+ status=response.status_code,
120
+ body=text,
121
+ )
122
+
123
+ buffer = ""
124
+ async for chunk in response.aiter_text():
125
+ buffer += chunk
126
+ # SSE frames are separated by blank lines ("\n\n").
127
+ while "\n\n" in buffer:
128
+ frame, buffer = buffer.split("\n\n", 1)
129
+ payload: str | None = None
130
+ for line in frame.split("\n"):
131
+ if line.startswith("data:"):
132
+ payload = (payload or "") + line[len("data:") :].strip()
133
+ if not payload:
134
+ continue
135
+ try:
136
+ yield json.loads(payload)
137
+ except json.JSONDecodeError as exc:
138
+ raise StreamError(f"malformed SSE payload: {payload[:120]}") from exc
139
+ except httpx.HTTPError as exc:
140
+ raise StreamError(f"POST {path} (stream) transport error: {exc}") from exc
codespar/_presets.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ Server presets — a shortcut so callers can say ``preset="brazilian"``
3
+ instead of listing every server id.
4
+
5
+ Must stay byte-for-byte identical to the TypeScript SDK
6
+ (``@codespar/sdk``, ``packages/core/src/session.ts``) so the same
7
+ preset yields the same servers across runtimes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .types import Preset
13
+
14
+ _PRESET_SERVERS: dict[Preset, list[str]] = {
15
+ "brazilian": ["zoop", "nuvem-fiscal", "melhor-envio", "z-api", "omie"],
16
+ "mexican": ["conekta", "facturapi", "skydropx"],
17
+ "argentinian": ["afip", "andreani"],
18
+ "colombian": ["wompi", "siigo", "coordinadora"],
19
+ "all": [
20
+ "zoop",
21
+ "nuvem-fiscal",
22
+ "melhor-envio",
23
+ "z-api",
24
+ "omie",
25
+ "conekta",
26
+ "facturapi",
27
+ "afip",
28
+ "wompi",
29
+ ],
30
+ }
31
+
32
+ # Sandbox default when the caller gives no preset + no servers list —
33
+ # enough to demo Brazilian Pix + NF-e without the user picking.
34
+ _SANDBOX_DEFAULT = ["zoop", "nuvem-fiscal"]
35
+
36
+
37
+ def preset_to_servers(preset: Preset | None) -> list[str]:
38
+ """Expand a preset into a list of server ids. Falls back to sandbox default."""
39
+ if preset is None:
40
+ return list(_SANDBOX_DEFAULT)
41
+ return list(_PRESET_SERVERS[preset])
@@ -0,0 +1,232 @@
1
+ """
2
+ Sync wrappers over ``AsyncCodeSpar`` / ``AsyncSession``.
3
+
4
+ The async implementation is canonical; these classes do one thing:
5
+ run async methods to completion on a dedicated background event loop
6
+ so sync Python code can use the SDK without rewriting to asyncio.
7
+
8
+ Using a dedicated loop (not ``asyncio.run`` per call) keeps the
9
+ underlying httpx connection pool alive across calls, which matters
10
+ for scripts that create a session and then iterate over send() ten
11
+ times — we don't want to tear down TLS on every turn.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import threading
18
+ from collections.abc import Coroutine, Iterator
19
+ from types import TracebackType
20
+ from typing import Any, TypeVar
21
+
22
+ from ._async_client import AsyncCodeSpar
23
+ from ._async_session import AsyncSession
24
+ from .errors import ConfigError
25
+ from .types import (
26
+ AuthConfig,
27
+ AuthResult,
28
+ ProxyRequest,
29
+ ProxyResult,
30
+ SendResult,
31
+ ServerConnection,
32
+ SessionConfig,
33
+ SessionInfo,
34
+ StreamEvent,
35
+ Tool,
36
+ ToolResult,
37
+ )
38
+
39
+ T = TypeVar("T")
40
+
41
+
42
+ class _LoopRunner:
43
+ """Background thread hosting a persistent asyncio loop."""
44
+
45
+ def __init__(self) -> None:
46
+ self._loop = asyncio.new_event_loop()
47
+ self._thread = threading.Thread(
48
+ target=self._loop.run_forever,
49
+ name="codespar-async-loop",
50
+ daemon=True,
51
+ )
52
+ self._thread.start()
53
+
54
+ def run(self, coro: Coroutine[Any, Any, T]) -> T:
55
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
56
+ return future.result()
57
+
58
+ def close(self) -> None:
59
+ self._loop.call_soon_threadsafe(self._loop.stop)
60
+ self._thread.join(timeout=5)
61
+ self._loop.close()
62
+
63
+
64
+ class Session:
65
+ """Sync Session — blocking wrapper around ``AsyncSession``."""
66
+
67
+ def __init__(self, async_session: AsyncSession, runner: _LoopRunner) -> None:
68
+ self._async = async_session
69
+ self._runner = runner
70
+
71
+ # ── identity passthroughs ───────────────────────────────────────────
72
+
73
+ @property
74
+ def id(self) -> str:
75
+ return self._async.id
76
+
77
+ @property
78
+ def user_id(self) -> str:
79
+ return self._async.user_id
80
+
81
+ @property
82
+ def servers(self) -> list[str]:
83
+ return self._async.servers
84
+
85
+ @property
86
+ def info(self) -> SessionInfo:
87
+ return self._async.info
88
+
89
+ @property
90
+ def mcp(self) -> dict[str, Any]:
91
+ return self._async.mcp
92
+
93
+ # ── blocking calls ──────────────────────────────────────────────────
94
+
95
+ def tools(self) -> list[Tool]:
96
+ return self._runner.run(self._async.tools())
97
+
98
+ def find_tools(self, intent: str) -> list[Tool]:
99
+ return self._runner.run(self._async.find_tools(intent))
100
+
101
+ def execute(self, tool_name: str, params: dict[str, Any]) -> ToolResult:
102
+ return self._runner.run(self._async.execute(tool_name, params))
103
+
104
+ def proxy_execute(self, request: ProxyRequest) -> ProxyResult:
105
+ return self._runner.run(self._async.proxy_execute(request))
106
+
107
+ def send(self, message: str) -> SendResult:
108
+ return self._runner.run(self._async.send(message))
109
+
110
+ def send_stream(self, message: str) -> Iterator[StreamEvent]:
111
+ """
112
+ Sync generator that yields stream events as they arrive on the
113
+ background event loop. Bridges the async iterator via a queue
114
+ so calling code stays plain-``for event in session.send_stream``.
115
+ """
116
+ import queue
117
+
118
+ q: queue.Queue[StreamEvent | object] = queue.Queue()
119
+ sentinel = object()
120
+
121
+ async def pump() -> None:
122
+ try:
123
+ async for event in self._async.send_stream(message):
124
+ q.put(event)
125
+ except Exception as exc:
126
+ q.put(exc)
127
+ finally:
128
+ q.put(sentinel)
129
+
130
+ future = asyncio.run_coroutine_threadsafe(pump(), self._runner._loop)
131
+ try:
132
+ while True:
133
+ item = q.get()
134
+ if item is sentinel:
135
+ break
136
+ if isinstance(item, BaseException):
137
+ raise item
138
+ yield item # type: ignore[misc]
139
+ finally:
140
+ # Make sure pump() finishes before the caller moves on.
141
+ future.result(timeout=1)
142
+
143
+ def authorize(self, server_id: str, config: AuthConfig) -> AuthResult:
144
+ return self._runner.run(self._async.authorize(server_id, config))
145
+
146
+ def connections(self) -> list[ServerConnection]:
147
+ return self._runner.run(self._async.connections())
148
+
149
+ def close(self) -> None:
150
+ self._runner.run(self._async.close())
151
+
152
+
153
+ class CodeSpar:
154
+ """
155
+ Sync CodeSpar client. Drop-in replacement for the async client when
156
+ you're writing a script or a sync framework handler.
157
+
158
+ Example::
159
+
160
+ cs = CodeSpar(api_key="csk_live_...")
161
+ try:
162
+ session = cs.create("user_123", preset="brazilian")
163
+ print(session.send("charge R$500 via Pix").message)
164
+ finally:
165
+ cs.close()
166
+
167
+ Or as a context manager::
168
+
169
+ with CodeSpar(api_key="csk_live_...") as cs:
170
+ ...
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ *,
176
+ api_key: str,
177
+ base_url: str | None = None,
178
+ project_id: str | None = None,
179
+ timeout: float = 60.0,
180
+ ) -> None:
181
+ self._runner = _LoopRunner()
182
+ # Build the async client *on* the runner's loop so its httpx
183
+ # transport binds to the right loop from day one.
184
+ kwargs: dict[str, Any] = {"api_key": api_key, "timeout": timeout}
185
+ if base_url is not None:
186
+ kwargs["base_url"] = base_url
187
+ if project_id is not None:
188
+ kwargs["project_id"] = project_id
189
+
190
+ async def factory() -> AsyncCodeSpar:
191
+ return AsyncCodeSpar(**kwargs)
192
+
193
+ self._async = self._runner.run(factory())
194
+
195
+ @property
196
+ def base_url(self) -> str:
197
+ return self._async.base_url
198
+
199
+ @property
200
+ def project_id(self) -> str | None:
201
+ return self._async.project_id
202
+
203
+ def create(
204
+ self,
205
+ user_id: str,
206
+ config: SessionConfig | None = None,
207
+ /,
208
+ **kwargs: object,
209
+ ) -> Session:
210
+ if config is not None and kwargs:
211
+ raise ConfigError("Pass SessionConfig or keyword arguments, not both.")
212
+ async_session = self._runner.run(
213
+ self._async.create(user_id, config, **kwargs)
214
+ )
215
+ return Session(async_session, self._runner)
216
+
217
+ def close(self) -> None:
218
+ try:
219
+ self._runner.run(self._async.aclose())
220
+ finally:
221
+ self._runner.close()
222
+
223
+ def __enter__(self) -> CodeSpar:
224
+ return self
225
+
226
+ def __exit__(
227
+ self,
228
+ exc_type: type[BaseException] | None,
229
+ exc: BaseException | None,
230
+ tb: TracebackType | None,
231
+ ) -> None:
232
+ self.close()
codespar/errors.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ Exception hierarchy for the CodeSpar SDK.
3
+
4
+ Kept shallow — one ``CodeSparError`` root with a handful of
5
+ specialised subclasses. Every network / API failure is wrapped so
6
+ callers can ``except CodeSparError`` without catching the raw httpx
7
+ exception tree (which would bleed the transport into user code).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ class CodeSparError(Exception):
16
+ """Base class for every error raised by the SDK."""
17
+
18
+ def __init__(self, message: str, *, cause: BaseException | None = None):
19
+ super().__init__(message)
20
+ self.__cause__ = cause
21
+
22
+
23
+ class ApiError(CodeSparError):
24
+ """HTTP-level failure returned by the CodeSpar backend."""
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ status: int,
31
+ body: Any = None,
32
+ code: str | None = None,
33
+ ):
34
+ super().__init__(message)
35
+ self.status = status
36
+ self.body = body
37
+ self.code = code
38
+
39
+
40
+ class ConfigError(CodeSparError):
41
+ """Raised when the SDK is constructed with invalid / missing config."""
42
+
43
+
44
+ class NotConnectedError(CodeSparError):
45
+ """Raised when an operation needs a connected session that isn't ready."""
46
+
47
+
48
+ class StreamError(CodeSparError):
49
+ """Raised when the SSE stream itself fails (parse / transport)."""
codespar/types.py ADDED
@@ -0,0 +1,209 @@
1
+ """
2
+ Type definitions for the CodeSpar Python SDK.
3
+
4
+ Every shape here mirrors the TypeScript @codespar/sdk types so the
5
+ payloads on the wire match byte-for-byte — the backend
6
+ (codespar-enterprise) is the single source of truth, and both SDKs
7
+ are just client-side adapters over the same HTTP contract.
8
+
9
+ Using plain dataclasses (not pydantic) to keep the dependency footprint
10
+ small and imports fast. If we need runtime validation later, we can
11
+ layer pydantic on without changing this surface.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from typing import Any, Literal
19
+
20
+ Preset = Literal["brazilian", "mexican", "argentinian", "colombian", "all"]
21
+ HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
22
+ SessionStatus = Literal["active", "closed", "error"]
23
+ AuthType = Literal["oauth", "api_key", "cert", "none"]
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ManageConnections:
28
+ """Options for blocking session creation until servers are connected."""
29
+
30
+ wait_for_connections: bool = False
31
+ timeout: int = 30_000
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class SessionConfig:
36
+ """Per-session configuration passed to ``CodeSpar.create``."""
37
+
38
+ servers: list[str] | None = None
39
+ preset: Preset | None = None
40
+ manage_connections: ManageConnections | None = None
41
+ metadata: dict[str, str] | None = None
42
+ project_id: str | None = None
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class Tool:
47
+ """A tool exposed by a connected server."""
48
+
49
+ name: str
50
+ description: str
51
+ input_schema: dict[str, Any]
52
+ server: str
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class ToolResult:
57
+ """Result of a single tool execution."""
58
+
59
+ success: bool
60
+ data: Any
61
+ error: str | None
62
+ duration: int
63
+ server: str
64
+ tool: str
65
+ tool_call_id: str | None = None
66
+ called_at: str | None = None
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class ToolCallRecord:
71
+ """A row in the backend's session_tool_calls table, surfaced on send/sendStream."""
72
+
73
+ id: str
74
+ tool_name: str
75
+ server_id: str
76
+ status: Literal["success", "error"]
77
+ duration_ms: int
78
+ input: Any
79
+ output: Any
80
+ error_code: str | None
81
+
82
+
83
+ @dataclass(slots=True)
84
+ class SendResult:
85
+ """Final payload of a Session.send natural-language turn."""
86
+
87
+ message: str
88
+ tool_calls: list[ToolCallRecord] = field(default_factory=list)
89
+ iterations: int = 0
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class ServerConnection:
94
+ """A server the session can call, as seen by the backend."""
95
+
96
+ id: str
97
+ name: str
98
+ category: str
99
+ country: str
100
+ auth_type: AuthType
101
+ connected: bool
102
+
103
+
104
+ @dataclass(slots=True)
105
+ class ProxyRequest:
106
+ """Raw HTTP proxy call to a connected server's upstream API."""
107
+
108
+ server: str
109
+ endpoint: str
110
+ method: HttpMethod
111
+ body: Any = None
112
+ params: dict[str, Any] | None = None
113
+ headers: dict[str, str] | None = None
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class ProxyResult:
118
+ """Response of a raw proxy call. `data` is parsed JSON when possible."""
119
+
120
+ status: int
121
+ data: Any
122
+ headers: dict[str, str]
123
+ duration: int
124
+ proxy_call_id: str | None = None
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class AuthConfig:
129
+ """Input to ``Session.authorize`` — where the provider redirects the user."""
130
+
131
+ redirect_uri: str
132
+ scopes: str | None = None
133
+
134
+
135
+ @dataclass(slots=True)
136
+ class AuthResult:
137
+ """Output of ``Session.authorize`` — the Connect Link the end user opens."""
138
+
139
+ link_token: str
140
+ authorize_url: str
141
+ expires_at: str
142
+
143
+
144
+ # Streaming event shapes. These mirror the TS StreamEvent discriminated
145
+ # union; Python doesn't have discriminated unions as first-class, so we
146
+ # use a base + subclasses with a literal `type` tag.
147
+
148
+
149
+ @dataclass(slots=True)
150
+ class UserMessageEvent:
151
+ content: str
152
+ type: Literal["user_message"] = "user_message"
153
+
154
+
155
+ @dataclass(slots=True)
156
+ class AssistantTextEvent:
157
+ content: str
158
+ iteration: int
159
+ type: Literal["assistant_text"] = "assistant_text"
160
+
161
+
162
+ @dataclass(slots=True)
163
+ class ToolUseEvent:
164
+ id: str
165
+ name: str
166
+ input: dict[str, Any]
167
+ type: Literal["tool_use"] = "tool_use"
168
+
169
+
170
+ @dataclass(slots=True)
171
+ class ToolResultEvent:
172
+ tool_call: ToolCallRecord
173
+ type: Literal["tool_result"] = "tool_result"
174
+
175
+
176
+ @dataclass(slots=True)
177
+ class DoneEvent:
178
+ result: SendResult
179
+ type: Literal["done"] = "done"
180
+
181
+
182
+ @dataclass(slots=True)
183
+ class ErrorEvent:
184
+ error: str
185
+ message: str | None = None
186
+ type: Literal["error"] = "error"
187
+
188
+
189
+ StreamEvent = (
190
+ UserMessageEvent
191
+ | AssistantTextEvent
192
+ | ToolUseEvent
193
+ | ToolResultEvent
194
+ | DoneEvent
195
+ | ErrorEvent
196
+ )
197
+
198
+
199
+ @dataclass(slots=True)
200
+ class SessionInfo:
201
+ """Read-only metadata about a session, attached to the Session instance."""
202
+
203
+ id: str
204
+ user_id: str
205
+ servers: list[str]
206
+ created_at: datetime
207
+ status: SessionStatus
208
+ mcp_url: str
209
+ mcp_headers: dict[str, str]