somewhere-tech 0.2.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.
somewhere/__init__.py ADDED
@@ -0,0 +1,168 @@
1
+ """Official Python SDK for the somewhere.tech platform API.
2
+
3
+ Supabase-compatible interface with query builder, auth, storage, email,
4
+ and OpenAI-compatible chat completions.
5
+
6
+ Usage:
7
+ from somewhere import Somewhere
8
+
9
+ sw = Somewhere(key='smt_...', project_id='my-project')
10
+
11
+ # Database (Supabase-style query builder)
12
+ result = sw.from_('users').select('*').eq('role', 'admin').execute()
13
+
14
+ # Auth
15
+ result = sw.auth.sign_up(email='u@test.com', password='pass123')
16
+
17
+ # Storage
18
+ result = sw.storage.from_('avatars').upload('photo.png', file_bytes)
19
+
20
+ # Email
21
+ result = sw.emails.send(to='user@test.com', subject='Hi', html='<h1>Hello</h1>')
22
+
23
+ # AI (OpenAI-compatible)
24
+ completion = sw.chat.completions.create(model='claude-sonnet-4-6', messages=[...])
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from typing import Dict, Optional
29
+
30
+ import httpx
31
+
32
+ from .auth import AuthClient
33
+ from .chat import ChatClient
34
+ from .client import (
35
+ BillingClient,
36
+ CronClient,
37
+ DeployClient,
38
+ DomainsClient,
39
+ EnvClient,
40
+ FeedbackClient,
41
+ HttpClient,
42
+ JobsClient,
43
+ LogsClient,
44
+ PreviewClient,
45
+ ProjectsClient,
46
+ QueueClient,
47
+ UsageClient,
48
+ )
49
+ from .emails import EmailsClient
50
+ from .errors import SomewhereError
51
+ from .query_builder import QueryBuilder, Result
52
+ from .storage import StorageClient
53
+
54
+ __all__ = ["Somewhere", "SomewhereError", "Result"]
55
+ __version__ = "0.2.0"
56
+
57
+
58
+ class Somewhere:
59
+ """Entry point for the somewhere.tech SDK.
60
+
61
+ Provides a Supabase-compatible interface with query builder, auth,
62
+ storage, email, and OpenAI-compatible chat completions.
63
+
64
+ Args:
65
+ key: Developer API key (smt_...). Mutually exclusive with token.
66
+ token: App-user JWT. Mutually exclusive with key.
67
+ project_id: Default project ID for all operations.
68
+ base_url: API base URL (defaults to https://api.somewhere.tech/v1).
69
+ http_client: Custom httpx.Client instance (optional).
70
+ headers: Extra headers to send with every request (optional).
71
+ timeout: Request timeout in seconds (default 60).
72
+
73
+ Example:
74
+ sw = Somewhere(key='smt_...', project_id='booking-app')
75
+ result = sw.from_('users').select('*').eq('role', 'admin').execute()
76
+ print(result.data)
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ *,
82
+ key: Optional[str] = None,
83
+ token: Optional[str] = None,
84
+ project_id: Optional[str] = None,
85
+ base_url: str = "https://api.somewhere.tech/v1",
86
+ http_client: Optional[httpx.Client] = None,
87
+ headers: Optional[Dict[str, str]] = None,
88
+ timeout: float = 60.0,
89
+ ) -> None:
90
+ self._http = HttpClient(
91
+ key=key,
92
+ token=token,
93
+ project_id=project_id,
94
+ base_url=base_url,
95
+ http_client=http_client,
96
+ headers=headers,
97
+ timeout=timeout,
98
+ )
99
+
100
+ # Supabase-compatible clients
101
+ self.auth = AuthClient(
102
+ project_id=self._http.project_id,
103
+ http_client=self._http.client,
104
+ base_url=self._http.base_url,
105
+ auth_header=self._http.auth_header,
106
+ extra_headers=self._http.extra_headers,
107
+ )
108
+ self.storage = StorageClient(
109
+ project_id=self._http.project_id,
110
+ http_client=self._http.client,
111
+ base_url=self._http.base_url,
112
+ auth_header=self._http.auth_header,
113
+ extra_headers=self._http.extra_headers,
114
+ )
115
+ self.emails = EmailsClient(
116
+ project_id=self._http.project_id,
117
+ http_client=self._http.client,
118
+ base_url=self._http.base_url,
119
+ auth_header=self._http.auth_header,
120
+ extra_headers=self._http.extra_headers,
121
+ )
122
+ self.chat = ChatClient(
123
+ project_id=self._http.project_id,
124
+ http_client=self._http.client,
125
+ base_url=self._http.base_url,
126
+ auth_header=self._http.auth_header,
127
+ extra_headers=self._http.extra_headers,
128
+ )
129
+
130
+ # Simple resource clients
131
+ self.env = EnvClient(self._http)
132
+ self.jobs = JobsClient(self._http)
133
+ self.cron = CronClient(self._http)
134
+ self.domains = DomainsClient(self._http)
135
+ self.projects = ProjectsClient(self._http)
136
+ self.deploy = DeployClient(self._http)
137
+ self.logs = LogsClient(self._http)
138
+ self.queue = QueueClient(self._http)
139
+ self.billing = BillingClient(self._http)
140
+ self.usage = UsageClient(self._http)
141
+ self.feedback = FeedbackClient(self._http)
142
+ self.preview = PreviewClient(self._http)
143
+
144
+ def from_(self, table: str) -> QueryBuilder:
145
+ """Start a Supabase-style query builder for the given table.
146
+
147
+ Uses `from_` because `from` is a reserved word in Python.
148
+
149
+ Args:
150
+ table: The database table name.
151
+
152
+ Returns:
153
+ A QueryBuilder that supports .select(), .insert(), .update(),
154
+ .delete(), .upsert(), plus filter chains (.eq(), .gt(), etc.)
155
+ ending with .execute().
156
+
157
+ Example:
158
+ result = sw.from_('users').select('*').eq('role', 'admin').execute()
159
+ result = sw.from_('users').insert({'name': 'A'}).execute()
160
+ """
161
+ return QueryBuilder(
162
+ table=table,
163
+ project_id=self._http.project_id,
164
+ http_client=self._http.client,
165
+ base_url=self._http.base_url,
166
+ auth_header=self._http.auth_header,
167
+ extra_headers=self._http.extra_headers,
168
+ )
somewhere/auth.py ADDED
@@ -0,0 +1,210 @@
1
+ """Auth client for the somewhere.tech platform -- Supabase-compatible interface.
2
+
3
+ Provides sign_up, sign_in_with_password, get_user, sign_out, and other
4
+ auth operations. All methods return Result objects (never raise).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import httpx
11
+
12
+ from .query_builder import Result
13
+
14
+
15
+ class AuthClient:
16
+ """Supabase-compatible auth interface.
17
+
18
+ Usage:
19
+ result = sw.auth.sign_up(email='u@test.com', password='pass123')
20
+ result = sw.auth.sign_in_with_password(email='u@test.com', password='pass123')
21
+ result = sw.auth.get_user()
22
+ result = sw.auth.sign_out()
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ project_id: str,
28
+ http_client: httpx.Client,
29
+ base_url: str,
30
+ auth_header: str,
31
+ extra_headers: Dict[str, str],
32
+ ) -> None:
33
+ self._project_id = project_id
34
+ self._http = http_client
35
+ self._base_url = base_url
36
+ self._auth_header = auth_header
37
+ self._extra_headers = extra_headers
38
+ self._session_token: Optional[str] = None
39
+
40
+ def sign_up(
41
+ self,
42
+ *,
43
+ email: str,
44
+ password: str,
45
+ metadata: Optional[Dict[str, Any]] = None,
46
+ ) -> Result:
47
+ """Create a new user account."""
48
+ return self._post("/auth/signup", {
49
+ "project_id": self._project_id,
50
+ "email": email,
51
+ "password": password,
52
+ "metadata": metadata,
53
+ })
54
+
55
+ def sign_in_with_password(
56
+ self,
57
+ *,
58
+ email: str,
59
+ password: str,
60
+ ) -> Result:
61
+ """Sign in with email and password. Stores session token internally."""
62
+ result = self._post("/auth/login", {
63
+ "project_id": self._project_id,
64
+ "email": email,
65
+ "password": password,
66
+ })
67
+ if result.data and isinstance(result.data, list) and len(result.data) > 0:
68
+ session = result.data[0]
69
+ if isinstance(session, dict):
70
+ self._session_token = session.get("token") or session.get("session_token")
71
+ return result
72
+
73
+ def get_user(self) -> Result:
74
+ """Get the currently authenticated user."""
75
+ return self._get("/auth/me")
76
+
77
+ def sign_out(self) -> Result:
78
+ """Sign out the current user."""
79
+ payload: Dict[str, Any] = {"project_id": self._project_id}
80
+ if self._session_token:
81
+ payload["session_token"] = self._session_token
82
+ result = self._post("/auth/logout", payload)
83
+ if not result.error:
84
+ self._session_token = None
85
+ return result
86
+
87
+ def forgot_password(self, *, email: str) -> Result:
88
+ """Send a password reset email."""
89
+ return self._post("/auth/forgot", {
90
+ "project_id": self._project_id,
91
+ "email": email,
92
+ })
93
+
94
+ def reset_password(self, *, token: str, new_password: str) -> Result:
95
+ """Reset password using a reset token."""
96
+ return self._post("/auth/reset", {
97
+ "project_id": self._project_id,
98
+ "token": token,
99
+ "new_password": new_password,
100
+ })
101
+
102
+ def verify_email(self, *, code: str) -> Result:
103
+ """Verify email with a verification code."""
104
+ return self._post("/auth/verify-email", {"code": code})
105
+
106
+ def request_verification(self) -> Result:
107
+ """Request a new email verification code."""
108
+ return self._post("/auth/request-email-verification", {})
109
+
110
+ def update_user(
111
+ self,
112
+ *,
113
+ display_name: Optional[str] = None,
114
+ metadata: Optional[Dict[str, Any]] = None,
115
+ ) -> Result:
116
+ """Update the current user's profile."""
117
+ payload: Dict[str, Any] = {}
118
+ if display_name is not None:
119
+ payload["display_name"] = display_name
120
+ if metadata is not None:
121
+ payload["metadata"] = metadata
122
+ return self._request("PATCH", "/auth/users/me", json_body=payload)
123
+
124
+ def delete_user(self) -> Result:
125
+ """Delete the current user's account."""
126
+ return self._request("DELETE", "/auth/users/me")
127
+
128
+ def list_users(
129
+ self,
130
+ *,
131
+ search: Optional[str] = None,
132
+ limit: Optional[int] = None,
133
+ cursor: Optional[str] = None,
134
+ ids: Optional[List[str]] = None,
135
+ ) -> Result:
136
+ """List users (admin operation)."""
137
+ params: Dict[str, Any] = {"project_id": self._project_id}
138
+ if search is not None:
139
+ params["search"] = search
140
+ if limit is not None:
141
+ params["limit"] = limit
142
+ if cursor is not None:
143
+ params["cursor"] = cursor
144
+ if ids is not None:
145
+ params["ids"] = ",".join(ids)
146
+ return self._get("/auth/users", params=params)
147
+
148
+ # --- Internal helpers ---
149
+
150
+ def _headers(self) -> Dict[str, str]:
151
+ return {
152
+ "Authorization": self._auth_header,
153
+ "Accept": "application/json",
154
+ "Content-Type": "application/json",
155
+ **self._extra_headers,
156
+ }
157
+
158
+ def _post(self, path: str, payload: Dict[str, Any]) -> Result:
159
+ return self._request("POST", path, json_body=payload)
160
+
161
+ def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Result:
162
+ return self._request("GET", path, params=params)
163
+
164
+ def _request(
165
+ self,
166
+ method: str,
167
+ path: str,
168
+ json_body: Optional[Dict[str, Any]] = None,
169
+ params: Optional[Dict[str, Any]] = None,
170
+ ) -> Result:
171
+ url = f"{self._base_url}{path}"
172
+ try:
173
+ if json_body is not None:
174
+ # Strip None values
175
+ json_body = {k: v for k, v in json_body.items() if v is not None}
176
+ if params is not None:
177
+ params = {k: v for k, v in params.items() if v is not None}
178
+
179
+ response = self._http.request(
180
+ method,
181
+ url,
182
+ json=json_body,
183
+ params=params,
184
+ headers=self._headers(),
185
+ )
186
+ return self._parse(response)
187
+ except httpx.HTTPError as exc:
188
+ return Result(error={"code": "NETWORK_ERROR", "message": str(exc)})
189
+
190
+ @staticmethod
191
+ def _parse(response: httpx.Response) -> Result:
192
+ try:
193
+ body = response.json()
194
+ except ValueError:
195
+ return Result(error={
196
+ "code": "INVALID_RESPONSE",
197
+ "message": f"Non-JSON response (status {response.status_code})",
198
+ })
199
+
200
+ if isinstance(body, dict) and body.get("ok") is True:
201
+ data = body.get("data")
202
+ if isinstance(data, list):
203
+ return Result(data=data, count=len(data))
204
+ if isinstance(data, dict):
205
+ return Result(data=[data], count=1)
206
+ return Result(data=[data] if data is not None else [], count=1 if data else 0)
207
+
208
+ error_code = body.get("error", "UNKNOWN_ERROR") if isinstance(body, dict) else "UNKNOWN_ERROR"
209
+ error_message = body.get("message", "Unknown error") if isinstance(body, dict) else str(body)
210
+ return Result(error={"code": error_code, "message": error_message})
somewhere/chat.py ADDED
@@ -0,0 +1,297 @@
1
+ """OpenAI-compatible chat completions client for the somewhere.tech AI API.
2
+
3
+ This module deliberately raises SomewhereError on failure (instead of returning
4
+ Result) to match the OpenAI SDK convention where chat.completions.create raises
5
+ on error.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import httpx
12
+
13
+ from .errors import SomewhereError
14
+
15
+
16
+ class ChatMessage:
17
+ """A single message in a chat completion response."""
18
+
19
+ def __init__(self, role: str, content: str) -> None:
20
+ self.role = role
21
+ self.content = content
22
+
23
+ def __repr__(self) -> str:
24
+ preview = self.content[:80] + "..." if len(self.content) > 80 else self.content
25
+ return f"ChatMessage(role={self.role!r}, content={preview!r})"
26
+
27
+
28
+ class ChatChoice:
29
+ """A single choice in a chat completion response."""
30
+
31
+ def __init__(
32
+ self,
33
+ index: int,
34
+ message: ChatMessage,
35
+ finish_reason: Optional[str] = None,
36
+ ) -> None:
37
+ self.index = index
38
+ self.message = message
39
+ self.finish_reason = finish_reason
40
+
41
+ def __repr__(self) -> str:
42
+ return f"ChatChoice(index={self.index}, finish_reason={self.finish_reason!r})"
43
+
44
+
45
+ class ChatUsage:
46
+ """Token usage for a chat completion."""
47
+
48
+ def __init__(
49
+ self,
50
+ prompt_tokens: int = 0,
51
+ completion_tokens: int = 0,
52
+ total_tokens: int = 0,
53
+ ) -> None:
54
+ self.prompt_tokens = prompt_tokens
55
+ self.completion_tokens = completion_tokens
56
+ self.total_tokens = total_tokens
57
+
58
+ def __repr__(self) -> str:
59
+ return f"ChatUsage(prompt={self.prompt_tokens}, completion={self.completion_tokens}, total={self.total_tokens})"
60
+
61
+
62
+ class ChatCompletion:
63
+ """OpenAI-compatible chat completion response object.
64
+
65
+ Usage:
66
+ completion = sw.chat.completions.create(
67
+ model='claude-sonnet-4-6',
68
+ messages=[{'role': 'user', 'content': 'Hello'}],
69
+ )
70
+ print(completion.choices[0].message.content)
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ id: str,
76
+ model: str,
77
+ choices: List[ChatChoice],
78
+ usage: Optional[ChatUsage] = None,
79
+ created: Optional[int] = None,
80
+ ) -> None:
81
+ self.id = id
82
+ self.object = "chat.completion"
83
+ self.model = model
84
+ self.choices = choices
85
+ self.usage = usage
86
+ self.created = created
87
+
88
+ def __repr__(self) -> str:
89
+ return f"ChatCompletion(id={self.id!r}, model={self.model!r}, choices={len(self.choices)})"
90
+
91
+
92
+ class ChatCompletionsClient:
93
+ """OpenAI-compatible completions interface.
94
+
95
+ Accessed via sw.chat.completions.create(...).
96
+ Raises SomewhereError on failure (matches OpenAI SDK behavior).
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ project_id: str,
102
+ http_client: httpx.Client,
103
+ base_url: str,
104
+ auth_header: str,
105
+ extra_headers: Dict[str, str],
106
+ ) -> None:
107
+ self._project_id = project_id
108
+ self._http = http_client
109
+ self._base_url = base_url
110
+ self._auth_header = auth_header
111
+ self._extra_headers = extra_headers
112
+
113
+ def create(
114
+ self,
115
+ *,
116
+ model: str,
117
+ messages: List[Dict[str, str]],
118
+ system: Optional[str] = None,
119
+ max_tokens: Optional[int] = None,
120
+ temperature: Optional[float] = None,
121
+ top_p: Optional[float] = None,
122
+ stop: Optional[List[str]] = None,
123
+ provider: Optional[str] = None,
124
+ ) -> ChatCompletion:
125
+ """Create a chat completion.
126
+
127
+ This method raises SomewhereError on failure (matching OpenAI SDK behavior).
128
+
129
+ Args:
130
+ model: Model identifier (e.g. 'claude-sonnet-4-6', 'gpt-4o').
131
+ messages: List of message dicts with 'role' and 'content' keys.
132
+ system: Optional system prompt.
133
+ max_tokens: Maximum tokens to generate.
134
+ temperature: Sampling temperature (0-2).
135
+ top_p: Nucleus sampling parameter.
136
+ stop: Stop sequences.
137
+ provider: Override the AI provider.
138
+
139
+ Returns:
140
+ ChatCompletion with choices[0].message.content containing the response.
141
+
142
+ Raises:
143
+ SomewhereError: On API errors (matches OpenAI SDK convention).
144
+ """
145
+ payload: Dict[str, Any] = {
146
+ "project_id": self._project_id,
147
+ "model": model,
148
+ "messages": messages,
149
+ }
150
+ if system is not None:
151
+ payload["system"] = system
152
+ if max_tokens is not None:
153
+ payload["max_tokens"] = max_tokens
154
+ if temperature is not None:
155
+ payload["temperature"] = temperature
156
+ if top_p is not None:
157
+ payload["top_p"] = top_p
158
+ if stop is not None:
159
+ payload["stop"] = stop
160
+ if provider is not None:
161
+ payload["provider"] = provider
162
+
163
+ url = f"{self._base_url}/ai/complete"
164
+ headers = {
165
+ "Authorization": self._auth_header,
166
+ "Accept": "application/json",
167
+ "Content-Type": "application/json",
168
+ **self._extra_headers,
169
+ }
170
+
171
+ try:
172
+ response = self._http.post(url, json=payload, headers=headers)
173
+ except httpx.HTTPError as exc:
174
+ raise SomewhereError(
175
+ code="NETWORK_ERROR",
176
+ message=str(exc),
177
+ status_code=0,
178
+ )
179
+
180
+ try:
181
+ body = response.json()
182
+ except ValueError:
183
+ raise SomewhereError(
184
+ code="INVALID_RESPONSE",
185
+ message=f"Non-JSON response (status {response.status_code})",
186
+ status_code=response.status_code,
187
+ )
188
+
189
+ # Error response
190
+ if isinstance(body, dict) and body.get("ok") is False:
191
+ raise SomewhereError(
192
+ code=body.get("error", "UNKNOWN_ERROR"),
193
+ message=body.get("message", "Unknown error"),
194
+ status_code=response.status_code,
195
+ retry=bool(body.get("retry", False)),
196
+ retry_after_ms=body.get("retry_after_ms"),
197
+ body=body,
198
+ )
199
+
200
+ if not isinstance(body, dict) or body.get("ok") is not True:
201
+ raise SomewhereError(
202
+ code="INVALID_RESPONSE",
203
+ message=f"Unexpected response shape (status {response.status_code})",
204
+ status_code=response.status_code,
205
+ body=body,
206
+ )
207
+
208
+ data = body.get("data", {})
209
+ return self._to_completion(data, model)
210
+
211
+ @staticmethod
212
+ def _to_completion(data: Any, requested_model: str) -> ChatCompletion:
213
+ """Convert API response data into an OpenAI-compatible ChatCompletion."""
214
+ if not isinstance(data, dict):
215
+ data = {}
216
+
217
+ # The API may return data in different shapes. Handle both
218
+ # OpenAI-compatible and platform-native formats.
219
+
220
+ # If the response already has 'choices', use them directly
221
+ if "choices" in data:
222
+ choices = []
223
+ for i, choice_data in enumerate(data["choices"]):
224
+ msg = choice_data.get("message", {})
225
+ choices.append(ChatChoice(
226
+ index=choice_data.get("index", i),
227
+ message=ChatMessage(
228
+ role=msg.get("role", "assistant"),
229
+ content=msg.get("content", ""),
230
+ ),
231
+ finish_reason=choice_data.get("finish_reason"),
232
+ ))
233
+ usage_data = data.get("usage", {})
234
+ usage = ChatUsage(
235
+ prompt_tokens=usage_data.get("prompt_tokens", 0),
236
+ completion_tokens=usage_data.get("completion_tokens", 0),
237
+ total_tokens=usage_data.get("total_tokens", 0),
238
+ ) if usage_data else None
239
+
240
+ return ChatCompletion(
241
+ id=data.get("id", ""),
242
+ model=data.get("model", requested_model),
243
+ choices=choices,
244
+ usage=usage,
245
+ created=data.get("created"),
246
+ )
247
+
248
+ # Platform-native format: data might have 'content' or 'text' directly
249
+ content = data.get("content") or data.get("text") or data.get("response") or ""
250
+ model_used = data.get("model", requested_model)
251
+
252
+ usage_data = data.get("usage", {})
253
+ # Platform returns input_tokens/output_tokens, OpenAI uses prompt/completion
254
+ prompt = usage_data.get("prompt_tokens") or usage_data.get("input_tokens") or 0
255
+ completion = usage_data.get("completion_tokens") or usage_data.get("output_tokens") or 0
256
+ usage = ChatUsage(
257
+ prompt_tokens=prompt,
258
+ completion_tokens=completion,
259
+ total_tokens=usage_data.get("total_tokens") or (prompt + completion),
260
+ ) if usage_data else None
261
+
262
+ return ChatCompletion(
263
+ id=data.get("id", ""),
264
+ model=model_used,
265
+ choices=[
266
+ ChatChoice(
267
+ index=0,
268
+ message=ChatMessage(role="assistant", content=str(content)),
269
+ finish_reason=data.get("finish_reason", "stop"),
270
+ ),
271
+ ],
272
+ usage=usage,
273
+ created=data.get("created"),
274
+ )
275
+
276
+
277
+ class ChatClient:
278
+ """Top-level chat client. Access via sw.chat.
279
+
280
+ Provides sw.chat.completions.create(...) for OpenAI-compatible chat.
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ project_id: str,
286
+ http_client: httpx.Client,
287
+ base_url: str,
288
+ auth_header: str,
289
+ extra_headers: Dict[str, str],
290
+ ) -> None:
291
+ self.completions = ChatCompletionsClient(
292
+ project_id=project_id,
293
+ http_client=http_client,
294
+ base_url=base_url,
295
+ auth_header=auth_header,
296
+ extra_headers=extra_headers,
297
+ )