slidecold 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.
slidecold/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Official Python SDK for the SlideCold API."""
2
+
3
+ from ._client import AsyncSlideCold, SlideCold
4
+ from ._config import DEFAULT_BASE_URL
5
+ from ._exceptions import (
6
+ APIConnectionError,
7
+ APIError,
8
+ AuthenticationError,
9
+ NotFoundError,
10
+ PermissionDeniedError,
11
+ RateLimitError,
12
+ SlideColdError,
13
+ )
14
+ from ._operations import OPERATIONS
15
+ from ._version import __version__
16
+ from ._webhooks import verify_webhook_signature
17
+
18
+ __all__ = [
19
+ "SlideCold",
20
+ "AsyncSlideCold",
21
+ "SlideColdError",
22
+ "APIError",
23
+ "APIConnectionError",
24
+ "AuthenticationError",
25
+ "PermissionDeniedError",
26
+ "NotFoundError",
27
+ "RateLimitError",
28
+ "verify_webhook_signature",
29
+ "OPERATIONS",
30
+ "DEFAULT_BASE_URL",
31
+ "__version__",
32
+ ]
slidecold/_client.py ADDED
@@ -0,0 +1,235 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Any, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ import httpx
7
+
8
+ from . import _config
9
+ from ._exceptions import APIConnectionError, AuthenticationError, error_for_status
10
+ from ._files import FileInput, to_upload
11
+ from ._operations import OPERATIONS
12
+ from ._resources import AsyncResources, SyncResources
13
+ from ._version import __version__
14
+
15
+ RETRY_STATUSES = {429, 500, 502, 503, 504}
16
+ TERMINAL_JOB_STATES = {"completed", "failed", "unknown"}
17
+ NO_KEY = "No SlideCold API key found. Pass api_key=..., set SLIDECOLD_API_KEY, or run `slidecold login`"
18
+
19
+
20
+ class _BaseClient:
21
+ def __init__(
22
+ self,
23
+ api_key: Optional[str] = None,
24
+ base_url: Optional[str] = None,
25
+ timeout: float = 30,
26
+ max_retries: int = 2,
27
+ ) -> None:
28
+ key, url = _config.resolve(api_key, base_url)
29
+ if not key:
30
+ raise AuthenticationError(None, NO_KEY)
31
+ self.api_key = key
32
+ self.base_url = url
33
+ self.timeout = timeout
34
+ self.max_retries = max_retries
35
+ self._headers = {
36
+ "Authorization": f"Bearer {key}",
37
+ "User-Agent": f"slidecold-python/{__version__}",
38
+ "Accept": "application/json",
39
+ }
40
+
41
+ def _build(
42
+ self,
43
+ operation_id: str,
44
+ path: Optional[Dict[str, Any]],
45
+ query: Optional[Dict[str, Any]],
46
+ body: Optional[Any],
47
+ file: Optional[FileInput],
48
+ ) -> Dict[str, Any]:
49
+ op = OPERATIONS[operation_id]
50
+ url = op.path
51
+ for p in op.path_params:
52
+ value = (path or {}).get(p.name)
53
+ if value is None or value == "":
54
+ raise ValueError(f"{operation_id}: missing path parameter {p.py_name!r}")
55
+ url = url.replace("{" + p.name + "}", quote(str(value), safe=""))
56
+ params = {}
57
+ for k, v in (query or {}).items():
58
+ if v is not None:
59
+ params[k] = ("true" if v else "false") if isinstance(v, bool) else v
60
+ request: Dict[str, Any] = {"method": op.method, "url": self.base_url + url, "params": params}
61
+ if file is not None:
62
+ if not op.file_field:
63
+ raise ValueError(f"{operation_id} does not accept a file")
64
+ request["files"] = {op.file_field: to_upload(file)}
65
+ elif op.body == "multipart":
66
+ raise ValueError(f"{operation_id} requires a file")
67
+ elif op.body:
68
+ if body is None and op.body_required:
69
+ body = {}
70
+ if body is not None:
71
+ request["json"] = body
72
+ return request
73
+
74
+ def _backoff(self, attempt: int, response: Optional[httpx.Response]) -> float:
75
+ retry_after = response.headers.get("retry-after") if response is not None else None
76
+ if retry_after and retry_after.replace(".", "", 1).isdigit():
77
+ return min(float(retry_after), 30.0)
78
+ return min(0.5 * 2.0**attempt, 8.0)
79
+
80
+ @staticmethod
81
+ def _parse(response: httpx.Response) -> Any:
82
+ ctype = response.headers.get("content-type", "")
83
+ if response.status_code >= 400:
84
+ body: Any = response.text
85
+ if "json" in ctype:
86
+ try:
87
+ body = response.json()
88
+ except ValueError:
89
+ pass
90
+ message = body.get("error") if isinstance(body, dict) else None
91
+ if not isinstance(message, str):
92
+ message = (
93
+ (body if isinstance(body, str) and body else None) or response.reason_phrase or "Error"
94
+ )
95
+ raise error_for_status(response.status_code, message, body)
96
+ if not response.content:
97
+ return None
98
+ if "json" in ctype:
99
+ return response.json()
100
+ return response.text
101
+
102
+
103
+ class SlideCold(_BaseClient, SyncResources):
104
+ """Synchronous SlideCold client.
105
+
106
+ >>> with SlideCold() as client:
107
+ ... campaigns = client.campaigns.list()
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ api_key: Optional[str] = None,
113
+ base_url: Optional[str] = None,
114
+ timeout: float = 30,
115
+ max_retries: int = 2,
116
+ ) -> None:
117
+ super().__init__(api_key, base_url, timeout, max_retries)
118
+ self._http = httpx.Client(headers=self._headers, timeout=timeout)
119
+ self._init_resources()
120
+
121
+ def request(
122
+ self,
123
+ operation_id: str,
124
+ *,
125
+ path: Optional[Dict[str, Any]] = None,
126
+ query: Optional[Dict[str, Any]] = None,
127
+ body: Optional[Any] = None,
128
+ file: Optional[FileInput] = None,
129
+ ) -> Any:
130
+ """Call any operation by its OpenAPI operationId. Only GETs are retried."""
131
+ req = self._build(operation_id, path, query, body, file)
132
+ retries = self.max_retries if req["method"] == "GET" else 0
133
+ attempt = 0
134
+ while True:
135
+ try:
136
+ response = self._http.request(**req)
137
+ except httpx.TransportError as e:
138
+ if attempt >= retries:
139
+ raise APIConnectionError(None, f"Could not reach {self.base_url}: {e}") from e
140
+ time.sleep(self._backoff(attempt, None))
141
+ else:
142
+ if response.status_code not in RETRY_STATUSES or attempt >= retries:
143
+ return self._parse(response)
144
+ time.sleep(self._backoff(attempt, response))
145
+ attempt += 1
146
+
147
+ def wait_for_login_job(self, job_id: str, interval: float = 2, timeout: float = 120) -> Dict[str, Any]:
148
+ """Poll `accounts.login_status` until the job is completed, failed or unknown; return the last status.
149
+
150
+ Raises TimeoutError if the job is still running after `timeout` seconds.
151
+ """
152
+ deadline = time.monotonic() + timeout
153
+ while True:
154
+ status: Dict[str, Any] = self.accounts.login_status(job_id)
155
+ if status.get("state") in TERMINAL_JOB_STATES:
156
+ return status
157
+ if time.monotonic() + interval > deadline:
158
+ raise TimeoutError(f"Login job {job_id} still {status.get('state')!r} after {timeout}s")
159
+ time.sleep(interval)
160
+
161
+ def close(self) -> None:
162
+ self._http.close()
163
+
164
+ def __enter__(self) -> "SlideCold":
165
+ return self
166
+
167
+ def __exit__(self, *exc: object) -> None:
168
+ self.close()
169
+
170
+
171
+ class AsyncSlideCold(_BaseClient, AsyncResources):
172
+ """Asynchronous SlideCold client (asyncio).
173
+
174
+ >>> async with AsyncSlideCold() as client:
175
+ ... campaigns = await client.campaigns.list()
176
+ """
177
+
178
+ def __init__(
179
+ self,
180
+ api_key: Optional[str] = None,
181
+ base_url: Optional[str] = None,
182
+ timeout: float = 30,
183
+ max_retries: int = 2,
184
+ ) -> None:
185
+ super().__init__(api_key, base_url, timeout, max_retries)
186
+ self._http = httpx.AsyncClient(headers=self._headers, timeout=timeout)
187
+ self._init_resources()
188
+
189
+ async def request(
190
+ self,
191
+ operation_id: str,
192
+ *,
193
+ path: Optional[Dict[str, Any]] = None,
194
+ query: Optional[Dict[str, Any]] = None,
195
+ body: Optional[Any] = None,
196
+ file: Optional[FileInput] = None,
197
+ ) -> Any:
198
+ """Call any operation by its OpenAPI operationId. Only GETs are retried."""
199
+ req = self._build(operation_id, path, query, body, file)
200
+ retries = self.max_retries if req["method"] == "GET" else 0
201
+ attempt = 0
202
+ while True:
203
+ try:
204
+ response = await self._http.request(**req)
205
+ except httpx.TransportError as e:
206
+ if attempt >= retries:
207
+ raise APIConnectionError(None, f"Could not reach {self.base_url}: {e}") from e
208
+ await asyncio.sleep(self._backoff(attempt, None))
209
+ else:
210
+ if response.status_code not in RETRY_STATUSES or attempt >= retries:
211
+ return self._parse(response)
212
+ await asyncio.sleep(self._backoff(attempt, response))
213
+ attempt += 1
214
+
215
+ async def wait_for_login_job(
216
+ self, job_id: str, interval: float = 2, timeout: float = 120
217
+ ) -> Dict[str, Any]:
218
+ """Async version of `SlideCold.wait_for_login_job`."""
219
+ deadline = time.monotonic() + timeout
220
+ while True:
221
+ status: Dict[str, Any] = await self.accounts.login_status(job_id)
222
+ if status.get("state") in TERMINAL_JOB_STATES:
223
+ return status
224
+ if time.monotonic() + interval > deadline:
225
+ raise TimeoutError(f"Login job {job_id} still {status.get('state')!r} after {timeout}s")
226
+ await asyncio.sleep(interval)
227
+
228
+ async def close(self) -> None:
229
+ await self._http.aclose()
230
+
231
+ async def __aenter__(self) -> "AsyncSlideCold":
232
+ return self
233
+
234
+ async def __aexit__(self, *exc: object) -> None:
235
+ await self.close()
slidecold/_config.py ADDED
@@ -0,0 +1,44 @@
1
+ """API key and base URL resolution, shared with the Node and Go CLIs (`config.json`: {"apiKey", "baseUrl"})."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional, Tuple
7
+
8
+ DEFAULT_BASE_URL = "https://slidecold.com/api/v1"
9
+
10
+
11
+ def config_path() -> Path:
12
+ if os.name == "nt":
13
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
14
+ else:
15
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
16
+ return Path(base) / "slidecold" / "config.json"
17
+
18
+
19
+ def load_config() -> Dict[str, Any]:
20
+ try:
21
+ data = json.loads(config_path().read_text(encoding="utf-8"))
22
+ except (OSError, ValueError):
23
+ return {}
24
+ return data if isinstance(data, dict) else {}
25
+
26
+
27
+ def save_config(data: Dict[str, Any]) -> Path:
28
+ """Write the config file readable only by the current user (0600)."""
29
+ path = config_path()
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
32
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
33
+ json.dump(data, f, indent=2)
34
+ f.write("\n")
35
+ os.chmod(path, 0o600) # the file may have existed with wider permissions
36
+ return path
37
+
38
+
39
+ def resolve(api_key: Optional[str] = None, base_url: Optional[str] = None) -> Tuple[Optional[str], str]:
40
+ """Explicit argument, then SLIDECOLD_API_KEY / SLIDECOLD_BASE_URL, then the config file, then prod."""
41
+ config = load_config()
42
+ key = api_key or os.environ.get("SLIDECOLD_API_KEY") or config.get("apiKey") or None
43
+ url = base_url or os.environ.get("SLIDECOLD_BASE_URL") or config.get("baseUrl") or DEFAULT_BASE_URL
44
+ return key, str(url).rstrip("/")
@@ -0,0 +1,48 @@
1
+ from typing import Any, Optional
2
+
3
+ KEYS_URL = "https://slidecold.com/settings"
4
+
5
+
6
+ class SlideColdError(Exception):
7
+ """Base error. `status` is the HTTP status (None for connection errors), `body` the parsed response."""
8
+
9
+ def __init__(self, status: Optional[int], message: str, body: Any = None) -> None:
10
+ super().__init__(message)
11
+ self.status = status
12
+ self.message = message
13
+ self.body = body
14
+
15
+ def __str__(self) -> str:
16
+ return f"{self.message} (HTTP {self.status})" if self.status else self.message
17
+
18
+
19
+ class APIConnectionError(SlideColdError):
20
+ """The request never got a response (DNS, refused connection, timeout)."""
21
+
22
+
23
+ class APIError(SlideColdError):
24
+ """The API returned an error status. Specific statuses use the subclasses below."""
25
+
26
+
27
+ class AuthenticationError(APIError):
28
+ """401: the API key is missing or invalid."""
29
+
30
+ def __init__(self, status: Optional[int], message: str, body: Any = None) -> None:
31
+ super().__init__(status, f"{message}. Create an API key at {KEYS_URL}", body)
32
+
33
+
34
+ class PermissionDeniedError(APIError):
35
+ """403: the key is missing a scope (e.g. `campaigns:write`) or a plan limit was hit."""
36
+
37
+
38
+ class NotFoundError(APIError):
39
+ """404."""
40
+
41
+
42
+ class RateLimitError(APIError):
43
+ """429."""
44
+
45
+
46
+ def error_for_status(status: int, message: str, body: Any) -> APIError:
47
+ cls = {401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError, 429: RateLimitError}
48
+ return cls.get(status, APIError)(status, message, body)
slidecold/_files.py ADDED
@@ -0,0 +1,21 @@
1
+ import mimetypes
2
+ import os
3
+ from pathlib import Path
4
+ from typing import IO, Tuple, Union
5
+
6
+ FileInput = Union[str, "os.PathLike[str]", bytes, IO[bytes], Tuple[str, bytes]]
7
+
8
+
9
+ def to_upload(file: FileInput, default_name: str = "upload") -> Tuple[str, bytes, str]:
10
+ """Normalize a file argument into an httpx multipart tuple (filename, content, content_type)."""
11
+ # shortcut: reads the whole file into memory (fine for photos/CSVs); stream it if large uploads appear.
12
+ if isinstance(file, tuple):
13
+ name, content = file
14
+ elif isinstance(file, bytes):
15
+ name, content = default_name, file
16
+ elif isinstance(file, (str, os.PathLike)):
17
+ path = Path(file)
18
+ name, content = path.name, path.read_bytes()
19
+ else:
20
+ name, content = Path(getattr(file, "name", default_name)).name, file.read()
21
+ return name, content, mimetypes.guess_type(name)[0] or "application/octet-stream"
slidecold/_mcp.py ADDED
@@ -0,0 +1,121 @@
1
+ """MCP server exposing one tool per SlideCold API operation (requires the `mcp` extra)."""
2
+
3
+ import json
4
+ import os
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from mcp import types
8
+ from mcp.server import Server
9
+ from mcp.server.stdio import stdio_server
10
+
11
+ from ._client import AsyncSlideCold
12
+ from ._exceptions import SlideColdError
13
+ from ._operations import OPERATIONS, Operation
14
+ from ._version import __version__
15
+
16
+ INSTRUCTIONS = (
17
+ "Tools for the SlideCold API (Instagram cold-DM outreach). Each tool needs the API key scope named in its "
18
+ "description; a 403 means the key lacks that scope or a plan limit was hit. Login jobs (create_account, "
19
+ "relogin_account, check_account_session) return a jobId to poll with get_login_status."
20
+ )
21
+
22
+
23
+ def tool_schema(op: Operation) -> Dict[str, Any]:
24
+ props: Dict[str, Any] = {}
25
+ required: List[str] = []
26
+ for p in (*op.path_params, *op.query_params):
27
+ props[p.name] = {**p.schema, **({"description": p.description} if p.description else {})}
28
+ if p.required:
29
+ required.append(p.name)
30
+ if op.body_schema is not None:
31
+ if op.body_flat:
32
+ props.update(op.body_schema.get("properties", {}))
33
+ if op.body_required:
34
+ required += op.body_schema.get("required", [])
35
+ else:
36
+ props["body"] = op.body_schema
37
+ if op.body_required and not op.file_field:
38
+ required.append("body")
39
+ if op.file_field:
40
+ props["file_path"] = {
41
+ "type": "string",
42
+ "description": f"Path to a local file to upload as `{op.file_field}`.",
43
+ }
44
+ if op.body == "multipart":
45
+ required.append("file_path")
46
+ schema: Dict[str, Any] = {"type": "object", "properties": props}
47
+ if required:
48
+ schema["required"] = required
49
+ return schema
50
+
51
+
52
+ def make_tool(op: Operation) -> types.Tool:
53
+ description = op.summary + "."
54
+ if op.description:
55
+ description += "\n\n" + op.description
56
+ description += f"\n\nRequired API key scope: {op.scope}"
57
+ read_only = op.method == "GET"
58
+ return types.Tool(
59
+ name=op.tool,
60
+ description=description,
61
+ input_schema=tool_schema(op),
62
+ annotations=types.ToolAnnotations(
63
+ title=op.summary,
64
+ read_only_hint=read_only,
65
+ destructive_hint=None if read_only else op.destructive,
66
+ ),
67
+ )
68
+
69
+
70
+ TOOLS = {op.tool: (op, make_tool(op)) for op in OPERATIONS.values()}
71
+
72
+
73
+ def _text(text: str, error: bool = False) -> types.CallToolResult:
74
+ return types.CallToolResult(content=[types.TextContent(type="text", text=text)], is_error=error)
75
+
76
+
77
+ def build_server(client: Optional[AsyncSlideCold] = None) -> Server:
78
+ """Build the server. Without `client`, one is created on the first tool call from env/config."""
79
+ state: Dict[str, AsyncSlideCold] = {"client": client} if client else {}
80
+
81
+ async def list_tools(ctx: Any, params: Any) -> types.ListToolsResult:
82
+ return types.ListToolsResult(tools=[tool for _, tool in TOOLS.values()])
83
+
84
+ async def call_tool(ctx: Any, params: types.CallToolRequestParams) -> types.CallToolResult:
85
+ if params.name not in TOOLS:
86
+ return _text(f"Unknown tool: {params.name}", error=True)
87
+ op = TOOLS[params.name][0]
88
+ args = dict(params.arguments or {})
89
+ try:
90
+ if "client" not in state:
91
+ state["client"] = AsyncSlideCold()
92
+ file_path = args.pop("file_path", None)
93
+ path = {p.name: args.pop(p.name, None) for p in op.path_params}
94
+ query = {q.name: args.pop(q.name, None) for q in op.query_params}
95
+ body = (args or None) if op.body_flat else args.pop("body", None)
96
+ result = await state["client"].request(
97
+ op.id,
98
+ path=path,
99
+ query=query,
100
+ body=body,
101
+ file=os.path.expanduser(file_path) if file_path else None,
102
+ )
103
+ except (SlideColdError, ValueError, OSError) as e:
104
+ return _text(str(e), error=True)
105
+ if result is None:
106
+ return _text("OK")
107
+ return _text(result if isinstance(result, str) else json.dumps(result, indent=2, ensure_ascii=False))
108
+
109
+ return Server(
110
+ "slidecold",
111
+ version=__version__,
112
+ instructions=INSTRUCTIONS,
113
+ on_list_tools=list_tools,
114
+ on_call_tool=call_tool,
115
+ )
116
+
117
+
118
+ async def serve_stdio() -> None:
119
+ server = build_server()
120
+ async with stdio_server() as (read_stream, write_stream):
121
+ await server.run(read_stream, write_stream, server.create_initialization_options())