mitra-sdk 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.
mitra/__init__.py ADDED
@@ -0,0 +1,129 @@
1
+ """Python client for the Matih public API."""
2
+
3
+ from ._version import __version__
4
+ from .client import AsyncMitra, Mitra
5
+ from .errors import (
6
+ EMBED_REFUSAL_CODES,
7
+ AuthError,
8
+ BusyError,
9
+ CreditsError,
10
+ EmbedNotFoundError,
11
+ EmbedRefusedError,
12
+ JobFailedError,
13
+ JobTimeoutError,
14
+ MitraError,
15
+ PermissionDeniedError,
16
+ RateLimitError,
17
+ SourceTestFailedError,
18
+ WebhookVerificationError,
19
+ )
20
+ from .models import (
21
+ Answer,
22
+ Catalog,
23
+ Clarification,
24
+ Connector,
25
+ ConnectorField,
26
+ ConnectorList,
27
+ Conversation,
28
+ ConversationList,
29
+ ConversationMessage,
30
+ Dashboard,
31
+ DashboardList,
32
+ DashboardWidget,
33
+ Embed,
34
+ EmbedKey,
35
+ EmbedList,
36
+ EmbedMember,
37
+ EmbedResource,
38
+ EmbedSummary,
39
+ File,
40
+ FileList,
41
+ Gem,
42
+ GemList,
43
+ Heard,
44
+ Invitation,
45
+ InvitationList,
46
+ Job,
47
+ JobError,
48
+ Member,
49
+ MemberList,
50
+ ResultTable,
51
+ Source,
52
+ SourceCreated,
53
+ SourceList,
54
+ SourceReadiness,
55
+ SourceTest,
56
+ StreamEvent,
57
+ Webhook,
58
+ WebhookDelivery,
59
+ WebhookTestResult,
60
+ Workspace,
61
+ WorkspaceInvitation,
62
+ WorkspaceList,
63
+ embed_src,
64
+ )
65
+ from .webhooks import assert_webhook, verify_webhook
66
+
67
+ __all__ = [
68
+ "EMBED_REFUSAL_CODES",
69
+ "Answer",
70
+ "AsyncMitra",
71
+ "AuthError",
72
+ "BusyError",
73
+ "Catalog",
74
+ "Clarification",
75
+ "Connector",
76
+ "ConnectorField",
77
+ "ConnectorList",
78
+ "Conversation",
79
+ "ConversationList",
80
+ "ConversationMessage",
81
+ "CreditsError",
82
+ "Dashboard",
83
+ "DashboardList",
84
+ "DashboardWidget",
85
+ "Embed",
86
+ "EmbedKey",
87
+ "EmbedList",
88
+ "EmbedMember",
89
+ "EmbedNotFoundError",
90
+ "EmbedRefusedError",
91
+ "EmbedResource",
92
+ "EmbedSummary",
93
+ "File",
94
+ "FileList",
95
+ "Gem",
96
+ "GemList",
97
+ "Heard",
98
+ "Invitation",
99
+ "InvitationList",
100
+ "Job",
101
+ "JobError",
102
+ "JobFailedError",
103
+ "JobTimeoutError",
104
+ "Member",
105
+ "MemberList",
106
+ "Mitra",
107
+ "MitraError",
108
+ "PermissionDeniedError",
109
+ "RateLimitError",
110
+ "ResultTable",
111
+ "Source",
112
+ "SourceCreated",
113
+ "SourceList",
114
+ "SourceReadiness",
115
+ "SourceTest",
116
+ "SourceTestFailedError",
117
+ "StreamEvent",
118
+ "Webhook",
119
+ "WebhookDelivery",
120
+ "WebhookTestResult",
121
+ "WebhookVerificationError",
122
+ "Workspace",
123
+ "WorkspaceInvitation",
124
+ "WorkspaceList",
125
+ "assert_webhook",
126
+ "embed_src",
127
+ "verify_webhook",
128
+ "__version__",
129
+ ]
mitra/_core.py ADDED
@@ -0,0 +1,205 @@
1
+ """Shared request plumbing for the sync and async clients (internal)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from ._version import __version__
14
+ from .errors import MitraError, error_from_response
15
+
16
+ DEFAULT_BASE_URL = "https://workspace.matih.ai"
17
+ API_PREFIX = "/api/v1"
18
+ RETRY_STATUSES = frozenset({429, 502, 503, 504})
19
+
20
+
21
+ def api_root(base_url: str) -> str:
22
+ """The API root for a host: ``https://workspace.matih.ai`` -> ``.../api/v1``.
23
+
24
+ ``base_url`` is the host only. A value that already ends in ``/api/v1`` is accepted as is."""
25
+ root = base_url.strip().rstrip("/")
26
+ return root if root.endswith(API_PREFIX) else root + API_PREFIX
27
+
28
+
29
+ class Settings:
30
+ def __init__(
31
+ self,
32
+ *,
33
+ api_key: str | None,
34
+ access_token: str | None,
35
+ base_url: str | None,
36
+ workspace_id: str | None,
37
+ language: str | None,
38
+ timeout: float,
39
+ max_retries: int,
40
+ ) -> None:
41
+ if api_key and access_token:
42
+ raise ValueError("pass either api_key or access_token, not both")
43
+ credential = (
44
+ api_key
45
+ or access_token
46
+ or os.environ.get("MATIH_API_KEY")
47
+ or os.environ.get("MATIH_ACCESS_TOKEN")
48
+ )
49
+ if not credential:
50
+ raise ValueError("pass api_key=... or access_token=... (or set MATIH_API_KEY)")
51
+ self.credential = credential
52
+ self.base_url = api_root(base_url or os.environ.get("MATIH_BASE_URL") or DEFAULT_BASE_URL)
53
+ self.workspace_id = workspace_id
54
+ self.language = language
55
+ self.timeout = timeout
56
+ self.max_retries = max_retries
57
+
58
+ def headers(
59
+ self,
60
+ *,
61
+ workspace_id: str | None = None,
62
+ language: str | None = None,
63
+ idempotency_key: str | None = None,
64
+ accept: str = "application/json",
65
+ prefer: str | None = None,
66
+ ) -> dict[str, str]:
67
+ headers = {
68
+ "Authorization": f"Bearer {self.credential}",
69
+ "Accept": accept,
70
+ "User-Agent": f"mitra-sdk-python/{__version__}",
71
+ }
72
+ workspace = workspace_id or self.workspace_id
73
+ if workspace:
74
+ headers["X-Matih-Workspace"] = workspace
75
+ lang = language or self.language
76
+ if lang:
77
+ headers["Accept-Language"] = lang
78
+ if idempotency_key:
79
+ headers["Idempotency-Key"] = idempotency_key
80
+ if prefer:
81
+ headers["Prefer"] = prefer
82
+ return headers
83
+
84
+
85
+ def prepare(
86
+ settings: Settings,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ json_body: Any = None,
91
+ params: dict[str, Any] | None = None,
92
+ files: Any = None,
93
+ data: dict[str, Any] | None = None,
94
+ workspace_id: str | None = None,
95
+ language: str | None = None,
96
+ idempotent: bool = False,
97
+ idempotency_key: str | None = None,
98
+ accept: str = "application/json",
99
+ prefer: str | None = None,
100
+ ) -> dict[str, Any]:
101
+ """Build kwargs for ``httpx.Client.request``. The idempotency key is fixed here, once."""
102
+ key = idempotency_key or (str(uuid.uuid4()) if idempotent else None)
103
+ kwargs: dict[str, Any] = {
104
+ "method": method,
105
+ "url": settings.base_url + path,
106
+ "headers": settings.headers(
107
+ workspace_id=workspace_id,
108
+ language=language,
109
+ idempotency_key=key,
110
+ accept=accept,
111
+ prefer=prefer,
112
+ ),
113
+ }
114
+ if params:
115
+ kwargs["params"] = {k: v for k, v in params.items() if v is not None}
116
+ if json_body is not None:
117
+ kwargs["json"] = json_body
118
+ if files is not None:
119
+ kwargs["files"] = files
120
+ if data:
121
+ kwargs["data"] = data
122
+ return kwargs
123
+
124
+
125
+ def should_retry(kwargs: dict[str, Any], status: int | None, attempt: int, max_retries: int) -> bool:
126
+ """Retry 429 always (not processed), and 5xx or network errors when the call is safe to repeat."""
127
+ if attempt >= max_retries:
128
+ return False
129
+ safe = kwargs["method"] in ("GET", "HEAD", "DELETE") or "Idempotency-Key" in kwargs["headers"]
130
+ if status == 429:
131
+ return True
132
+ return safe
133
+
134
+
135
+ def retry_delay(response: httpx.Response | None, attempt: int) -> float:
136
+ if response is not None:
137
+ value = response.headers.get("retry-after")
138
+ if value:
139
+ try:
140
+ return min(max(float(value), 0.0), 60.0)
141
+ except ValueError:
142
+ pass
143
+ return min(0.5 * (2**attempt), 8.0)
144
+
145
+
146
+ def raise_for_response(response: httpx.Response) -> None:
147
+ if response.status_code < 400:
148
+ return
149
+ try:
150
+ body: Any = response.json()
151
+ except ValueError:
152
+ body = {"detail": {"message": response.text[:500] or f"HTTP {response.status_code}"}}
153
+ retry_after = None
154
+ raw = response.headers.get("retry-after")
155
+ if raw:
156
+ try:
157
+ retry_after = float(raw)
158
+ except ValueError:
159
+ retry_after = None
160
+ raise error_from_response(
161
+ response.status_code,
162
+ body,
163
+ request_id=response.headers.get("x-request-id"),
164
+ retry_after=retry_after,
165
+ method=response.request.method,
166
+ path=response.request.url.path,
167
+ )
168
+
169
+
170
+ def decode(response: httpx.Response, parse: Callable[[Any], Any] | None, raw: bool) -> Any:
171
+ if raw:
172
+ return response.content
173
+ if not response.content:
174
+ return None
175
+ try:
176
+ payload = response.json()
177
+ except ValueError as exc:
178
+ raise MitraError(
179
+ "response was not JSON",
180
+ status=response.status_code,
181
+ request_id=response.headers.get("x-request-id"),
182
+ ) from exc
183
+ return parse(payload) if parse else payload
184
+
185
+
186
+ def parse_sse(lines: list[str]) -> tuple[str, Any] | None:
187
+ """Turn the lines of one SSE frame into ``(event, data)``; ``None`` for comments/empty."""
188
+ event = "message"
189
+ data_lines: list[str] = []
190
+ for line in lines:
191
+ if not line or line.startswith(":"):
192
+ continue
193
+ field, _, value = line.partition(":")
194
+ value = value[1:] if value.startswith(" ") else value
195
+ if field == "event":
196
+ event = value
197
+ elif field == "data":
198
+ data_lines.append(value)
199
+ if not data_lines:
200
+ return None
201
+ text = "\n".join(data_lines)
202
+ try:
203
+ return event, json.loads(text)
204
+ except ValueError:
205
+ return event, text
mitra/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"