cloro 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.
cloro/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """cloro — the official Python SDK.
2
+
3
+ One API for Google Search and every AI answer engine (ChatGPT, Gemini,
4
+ Perplexity, Copilot, Grok, AI Overview, AI Mode). Real-time, structured JSON.
5
+
6
+ from cloro import Cloro
7
+
8
+ client = Cloro(api_key="sk_live_...")
9
+ res = client.monitor.chatgpt(prompt="What is cloro?", country="US")
10
+ print(res["result"]["text"])
11
+ """
12
+ from ._client import Cloro
13
+ from ._exceptions import (
14
+ APIConnectionError,
15
+ APIError,
16
+ APIStatusError,
17
+ APITimeoutError,
18
+ AuthenticationError,
19
+ BadRequestError,
20
+ CloroError,
21
+ ConflictError,
22
+ InternalServerError,
23
+ NotFoundError,
24
+ PermissionDeniedError,
25
+ RateLimitError,
26
+ TaskError,
27
+ TaskFailedError,
28
+ TaskTimeoutError,
29
+ )
30
+ from ._version import __version__
31
+ from .resources import AsyncTask
32
+
33
+ __all__ = [
34
+ "Cloro",
35
+ "AsyncTask",
36
+ "__version__",
37
+ "CloroError",
38
+ "APIError",
39
+ "APIConnectionError",
40
+ "APITimeoutError",
41
+ "APIStatusError",
42
+ "BadRequestError",
43
+ "AuthenticationError",
44
+ "PermissionDeniedError",
45
+ "NotFoundError",
46
+ "ConflictError",
47
+ "RateLimitError",
48
+ "InternalServerError",
49
+ "TaskError",
50
+ "TaskFailedError",
51
+ "TaskTimeoutError",
52
+ ]
cloro/_client.py ADDED
@@ -0,0 +1,208 @@
1
+ """HTTP client for the cloro API."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import platform
6
+ import time
7
+ from typing import Any, Dict, Optional
8
+
9
+ import httpx
10
+
11
+ from ._exceptions import (
12
+ APIConnectionError,
13
+ APIStatusError,
14
+ APITimeoutError,
15
+ AuthenticationError,
16
+ BadRequestError,
17
+ CloroError,
18
+ ConflictError,
19
+ InternalServerError,
20
+ NotFoundError,
21
+ PermissionDeniedError,
22
+ RateLimitError,
23
+ )
24
+ from ._version import __version__
25
+ from .resources.async_tasks import AsyncTasksResource
26
+ from .resources.monitor import MonitorResource
27
+
28
+ DEFAULT_BASE_URL = "https://api.cloro.dev"
29
+ DEFAULT_TIMEOUT = 60.0
30
+ DEFAULT_MAX_RETRIES = 2
31
+
32
+ _STATUS_MAP = {
33
+ 400: BadRequestError,
34
+ 401: AuthenticationError,
35
+ 403: PermissionDeniedError,
36
+ 404: NotFoundError,
37
+ 409: ConflictError,
38
+ 429: RateLimitError,
39
+ }
40
+
41
+ # Status codes that are safe to retry with backoff.
42
+ _RETRY_STATUS = {429, 500, 502, 503, 504}
43
+
44
+
45
+ def _backoff(attempt: int) -> float:
46
+ """Exponential backoff: 0.5s, 1s, 2s, ... capped at 8s."""
47
+ return min(0.5 * (2 ** (attempt - 1)), 8.0)
48
+
49
+
50
+ def _parse_retry_after(response: httpx.Response) -> Optional[float]:
51
+ value = response.headers.get("retry-after")
52
+ if not value:
53
+ return None
54
+ try:
55
+ return float(value)
56
+ except ValueError:
57
+ return None
58
+
59
+
60
+ def _make_status_error(response: httpx.Response) -> APIStatusError:
61
+ try:
62
+ body: Any = response.json()
63
+ except Exception:
64
+ body = None
65
+
66
+ message: Optional[str] = None
67
+ if isinstance(body, dict):
68
+ err = body.get("error")
69
+ if isinstance(err, dict):
70
+ message = err.get("message")
71
+ elif isinstance(err, str):
72
+ message = err
73
+ message = message or (response.text or f"HTTP {response.status_code}")
74
+
75
+ cls = _STATUS_MAP.get(response.status_code)
76
+ if cls is None:
77
+ cls = InternalServerError if response.status_code >= 500 else APIStatusError
78
+ return cls(
79
+ message,
80
+ status_code=response.status_code,
81
+ response=response,
82
+ body=body,
83
+ )
84
+
85
+
86
+ class Cloro:
87
+ """Client for the cloro API.
88
+
89
+ One API for Google Search and every AI answer engine (ChatGPT, Gemini,
90
+ Perplexity, Copilot, Grok, AI Overview, AI Mode). Real-time, structured JSON.
91
+
92
+ Args:
93
+ api_key: Your cloro API key (``sk_live_...``). Falls back to the
94
+ ``CLORO_API_KEY`` environment variable.
95
+ base_url: Override the API base URL (default ``https://api.cloro.dev``).
96
+ timeout: Per-request timeout in seconds.
97
+ max_retries: Retries for timeouts, connection errors, and 429/5xx.
98
+ http_client: Inject a preconfigured ``httpx.Client`` (proxies, custom
99
+ transport, testing). If provided, ``timeout`` is ignored.
100
+
101
+ Example:
102
+ >>> from cloro import Cloro
103
+ >>> client = Cloro(api_key="sk_live_...")
104
+ >>> res = client.monitor.chatgpt(
105
+ ... prompt="What do you know about Acme Corp?",
106
+ ... country="US",
107
+ ... include={"markdown": True},
108
+ ... )
109
+ >>> res["result"]["text"]
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ api_key: Optional[str] = None,
115
+ *,
116
+ base_url: str = DEFAULT_BASE_URL,
117
+ timeout: float = DEFAULT_TIMEOUT,
118
+ max_retries: int = DEFAULT_MAX_RETRIES,
119
+ http_client: Optional[httpx.Client] = None,
120
+ ) -> None:
121
+ api_key = api_key or os.environ.get("CLORO_API_KEY")
122
+ if not api_key:
123
+ raise CloroError(
124
+ "No API key provided. Pass api_key=... or set the "
125
+ "CLORO_API_KEY environment variable."
126
+ )
127
+ self.api_key = api_key
128
+ self.base_url = base_url.rstrip("/")
129
+ self.max_retries = max_retries
130
+ self._owns_client = http_client is None
131
+ self._client = http_client or httpx.Client(timeout=timeout)
132
+
133
+ self.monitor = MonitorResource(self)
134
+ self.async_tasks = AsyncTasksResource(self)
135
+
136
+ # -- reference data -------------------------------------------------
137
+
138
+ def countries(self, model: Optional[str] = None) -> Any:
139
+ """List supported countries, optionally filtered by engine ``model``."""
140
+ params = {"model": model} if model else None
141
+ return self.request("GET", "/v1/countries", params=params)
142
+
143
+ def states(self) -> Any:
144
+ """List supported US states (for location-targeted Google requests)."""
145
+ return self.request("GET", "/v1/states")
146
+
147
+ # -- transport ------------------------------------------------------
148
+
149
+ def _headers(self) -> Dict[str, str]:
150
+ return {
151
+ "Authorization": f"Bearer {self.api_key}",
152
+ "Content-Type": "application/json",
153
+ "Accept": "application/json",
154
+ "User-Agent": f"cloro-python/{__version__} (python {platform.python_version()})",
155
+ }
156
+
157
+ def request(
158
+ self,
159
+ method: str,
160
+ path: str,
161
+ *,
162
+ json: Any = None,
163
+ params: Optional[Dict[str, Any]] = None,
164
+ ) -> Any:
165
+ url = f"{self.base_url}{path}"
166
+ attempt = 0
167
+ while True:
168
+ try:
169
+ response = self._client.request(
170
+ method, url, json=json, params=params, headers=self._headers()
171
+ )
172
+ except httpx.TimeoutException as exc:
173
+ if attempt < self.max_retries:
174
+ attempt += 1
175
+ time.sleep(_backoff(attempt))
176
+ continue
177
+ raise APITimeoutError(f"Request to {url} timed out") from exc
178
+ except httpx.HTTPError as exc:
179
+ if attempt < self.max_retries:
180
+ attempt += 1
181
+ time.sleep(_backoff(attempt))
182
+ continue
183
+ raise APIConnectionError(str(exc)) from exc
184
+
185
+ if response.status_code >= 400:
186
+ if response.status_code in _RETRY_STATUS and attempt < self.max_retries:
187
+ attempt += 1
188
+ retry_after = _parse_retry_after(response)
189
+ time.sleep(retry_after if retry_after is not None else _backoff(attempt))
190
+ continue
191
+ raise _make_status_error(response)
192
+
193
+ if not response.content:
194
+ return None
195
+ return response.json()
196
+
197
+ # -- lifecycle ------------------------------------------------------
198
+
199
+ def close(self) -> None:
200
+ """Close the underlying HTTP client (only if the SDK created it)."""
201
+ if self._owns_client:
202
+ self._client.close()
203
+
204
+ def __enter__(self) -> "Cloro":
205
+ return self
206
+
207
+ def __exit__(self, *_exc: Any) -> None:
208
+ self.close()
cloro/_exceptions.py ADDED
@@ -0,0 +1,117 @@
1
+ """Exception hierarchy for the cloro SDK.
2
+
3
+ All errors derive from :class:`CloroError`, so a single ``except CloroError``
4
+ catches everything the SDK can raise. HTTP failures map to
5
+ :class:`APIStatusError` subclasses by status code; the async task poller raises
6
+ :class:`TaskFailedError` / :class:`TaskTimeoutError`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+ __all__ = [
13
+ "CloroError",
14
+ "APIError",
15
+ "APIConnectionError",
16
+ "APITimeoutError",
17
+ "APIStatusError",
18
+ "BadRequestError",
19
+ "AuthenticationError",
20
+ "PermissionDeniedError",
21
+ "NotFoundError",
22
+ "ConflictError",
23
+ "RateLimitError",
24
+ "InternalServerError",
25
+ "TaskError",
26
+ "TaskFailedError",
27
+ "TaskTimeoutError",
28
+ ]
29
+
30
+
31
+ class CloroError(Exception):
32
+ """Base class for every error raised by the SDK."""
33
+
34
+
35
+ class APIError(CloroError):
36
+ """Base class for errors originating from an HTTP request."""
37
+
38
+ def __init__(
39
+ self,
40
+ message: str,
41
+ *,
42
+ status_code: Optional[int] = None,
43
+ response: Any = None,
44
+ body: Any = None,
45
+ ) -> None:
46
+ super().__init__(message)
47
+ self.message = message
48
+ self.status_code = status_code
49
+ self.response = response
50
+ self.body = body
51
+
52
+
53
+ class APIConnectionError(APIError):
54
+ """The request could not reach the cloro API (DNS, TLS, socket errors)."""
55
+
56
+
57
+ class APITimeoutError(APIConnectionError):
58
+ """The request timed out before a response was received."""
59
+
60
+
61
+ class APIStatusError(APIError):
62
+ """The API returned a non-2xx status code."""
63
+
64
+
65
+ class BadRequestError(APIStatusError):
66
+ """400 — the request was malformed or failed validation."""
67
+
68
+
69
+ class AuthenticationError(APIStatusError):
70
+ """401 — the API key is missing or invalid."""
71
+
72
+
73
+ class PermissionDeniedError(APIStatusError):
74
+ """403 — the API key is not allowed to perform this action."""
75
+
76
+
77
+ class NotFoundError(APIStatusError):
78
+ """404 — the requested resource does not exist."""
79
+
80
+
81
+ class ConflictError(APIStatusError):
82
+ """409 — the request conflicts with current state (e.g. concurrency limit)."""
83
+
84
+
85
+ class RateLimitError(APIStatusError):
86
+ """429 — too many requests; retry after backing off."""
87
+
88
+
89
+ class InternalServerError(APIStatusError):
90
+ """5xx — the API failed to process the request."""
91
+
92
+
93
+ class TaskError(CloroError):
94
+ """Base class for async task queue errors."""
95
+
96
+
97
+ class TaskFailedError(TaskError):
98
+ """An async task reached the ``FAILED`` terminal status while polling."""
99
+
100
+ def __init__(
101
+ self,
102
+ message: str,
103
+ *,
104
+ task: Any = None,
105
+ response: Optional[Dict[str, Any]] = None,
106
+ ) -> None:
107
+ super().__init__(message)
108
+ self.task = task
109
+ self.response = response
110
+
111
+
112
+ class TaskTimeoutError(TaskError):
113
+ """An async task did not reach a terminal status within the poll timeout."""
114
+
115
+ def __init__(self, message: str, *, task: Any = None) -> None:
116
+ super().__init__(message)
117
+ self.task = task
cloro/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
cloro/py.typed ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ from .async_tasks import AsyncTask, AsyncTasksResource
2
+ from .monitor import MonitorResource
3
+
4
+ __all__ = ["MonitorResource", "AsyncTasksResource", "AsyncTask"]
@@ -0,0 +1,215 @@
1
+ """Async task queue — enqueue work, then poll to completion.
2
+
3
+ This is where the SDK earns its keep over raw HTTP: :meth:`AsyncTasksResource.wait`
4
+ polls ``GET /v1/async/task/{id}`` with interval backoff and a timeout, and
5
+ :meth:`AsyncTasksResource.run` collapses create-then-wait into a single call.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
12
+
13
+ from .._exceptions import TaskFailedError, TaskTimeoutError
14
+
15
+ if TYPE_CHECKING:
16
+ from .._client import Cloro
17
+
18
+ TERMINAL_STATUSES = frozenset({"COMPLETED", "FAILED"})
19
+
20
+ # Provider identifiers accepted by the ``taskType`` field.
21
+ TaskType = str
22
+
23
+ TaskRef = Union["AsyncTask", str]
24
+
25
+
26
+ @dataclass
27
+ class AsyncTask:
28
+ """A handle to a queued task. Returned by :meth:`AsyncTasksResource.create`."""
29
+
30
+ id: str
31
+ status: str = "QUEUED"
32
+ task_type: Optional[str] = None
33
+ priority: Optional[int] = None
34
+ created_at: Optional[str] = None
35
+ raw: Dict[str, Any] = field(default_factory=dict)
36
+
37
+ @classmethod
38
+ def _from_summary(cls, summary: Dict[str, Any]) -> "AsyncTask":
39
+ return cls(
40
+ id=summary["id"],
41
+ status=summary.get("status", "QUEUED"),
42
+ task_type=summary.get("taskType"),
43
+ priority=summary.get("priority"),
44
+ created_at=summary.get("createdAt"),
45
+ raw=summary,
46
+ )
47
+
48
+
49
+ def _task_id(task: TaskRef) -> str:
50
+ return task.id if isinstance(task, AsyncTask) else task
51
+
52
+
53
+ def _to_request(task: Dict[str, Any]) -> Dict[str, Any]:
54
+ """Normalize a task dict (snake_case or camelCase) into the API's shape."""
55
+ out: Dict[str, Any] = {
56
+ "taskType": task.get("task_type", task.get("taskType")),
57
+ "payload": task.get("payload"),
58
+ }
59
+ for snake, camel in (
60
+ ("priority", "priority"),
61
+ ("idempotency_key", "idempotencyKey"),
62
+ ("webhook", "webhook"),
63
+ ):
64
+ value = task.get(snake, task.get(camel))
65
+ if value is not None:
66
+ out[camel] = value
67
+ return out
68
+
69
+
70
+ class AsyncTasksResource:
71
+ """The ``/v1/async/*`` task queue."""
72
+
73
+ def __init__(self, client: "Cloro") -> None:
74
+ self._client = client
75
+
76
+ # -- create ---------------------------------------------------------
77
+
78
+ def create(
79
+ self,
80
+ task_type: TaskType,
81
+ payload: Dict[str, Any],
82
+ *,
83
+ priority: Optional[int] = None,
84
+ idempotency_key: Optional[str] = None,
85
+ webhook: Optional[Dict[str, Any]] = None,
86
+ ) -> AsyncTask:
87
+ """Enqueue a single task. ``POST /v1/async/task``.
88
+
89
+ Args:
90
+ task_type: One of ``CHATGPT``, ``GEMINI``, ``PERPLEXITY``, ``COPILOT``,
91
+ ``GROK``, ``AIMODE``, ``GOOGLE``, ``GOOGLE_NEWS``.
92
+ payload: Provider-specific body (e.g. ``{"prompt": ..., "country": ...}``,
93
+ or ``{"query": ..., "country": ...}`` for Google Search).
94
+ priority: 1-10, higher runs first (default 1).
95
+ idempotency_key: Unique string to dedupe task creation.
96
+ webhook: ``{"url": ...}`` to be notified on completion.
97
+ """
98
+ body = _to_request(
99
+ {
100
+ "task_type": task_type,
101
+ "payload": payload,
102
+ "priority": priority,
103
+ "idempotency_key": idempotency_key,
104
+ "webhook": webhook,
105
+ }
106
+ )
107
+ resp = self._client.request("POST", "/v1/async/task", json=body)
108
+ return AsyncTask._from_summary(resp["task"])
109
+
110
+ def create_batch(self, tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
111
+ """Enqueue up to 500 tasks in one call. ``POST /v1/async/task/batch``.
112
+
113
+ Each item is a dict with ``task_type`` and ``payload`` (plus optional
114
+ ``priority`` / ``idempotency_key`` / ``webhook``). Returns the per-task
115
+ ``results`` array, preserving input order. Successful items carry a
116
+ ``task`` field; failed items carry an ``error`` field.
117
+ """
118
+ body = [_to_request(t) for t in tasks]
119
+ resp = self._client.request("POST", "/v1/async/task/batch", json=body)
120
+ return resp.get("results", [])
121
+
122
+ # -- read -----------------------------------------------------------
123
+
124
+ def retrieve(self, task: TaskRef) -> Dict[str, Any]:
125
+ """Fetch a task's current status. ``GET /v1/async/task/{id}``.
126
+
127
+ The ``response`` field is present only once the task is ``COMPLETED``
128
+ or ``FAILED``.
129
+ """
130
+ return self._client.request("GET", f"/v1/async/task/{_task_id(task)}")
131
+
132
+ def status(self) -> Dict[str, Any]:
133
+ """Queue-wide status for your organization. ``GET /v1/async/status``."""
134
+ return self._client.request("GET", "/v1/async/status")
135
+
136
+ # -- poll -----------------------------------------------------------
137
+
138
+ def wait(
139
+ self,
140
+ task: TaskRef,
141
+ *,
142
+ poll_interval: float = 2.0,
143
+ timeout: float = 300.0,
144
+ backoff: float = 1.5,
145
+ max_interval: float = 15.0,
146
+ ) -> Dict[str, Any]:
147
+ """Poll a task until it reaches a terminal status, then return it.
148
+
149
+ Args:
150
+ task: An :class:`AsyncTask` or a task id string.
151
+ poll_interval: Seconds before the first poll; grows by ``backoff``.
152
+ timeout: Give up after this many seconds (raises
153
+ :class:`TaskTimeoutError`).
154
+ backoff: Multiplier applied to the interval after each poll.
155
+ max_interval: Ceiling for the polling interval.
156
+
157
+ Returns:
158
+ The full ``TaskStatusResponse`` dict, including ``response`` once
159
+ ``COMPLETED``.
160
+
161
+ Raises:
162
+ TaskFailedError: the task reached ``FAILED``.
163
+ TaskTimeoutError: the task did not finish within ``timeout``.
164
+ """
165
+ deadline = time.monotonic() + timeout
166
+ interval = poll_interval
167
+ while True:
168
+ status = self.retrieve(task)
169
+ state = status.get("task", {}).get("status")
170
+ if state == "COMPLETED":
171
+ return status
172
+ if state == "FAILED":
173
+ raise TaskFailedError(
174
+ f"Task {_task_id(task)} failed", task=task, response=status
175
+ )
176
+
177
+ remaining = deadline - time.monotonic()
178
+ if remaining <= 0:
179
+ raise TaskTimeoutError(
180
+ f"Task {_task_id(task)} did not complete within {timeout}s "
181
+ f"(last status: {state})",
182
+ task=task,
183
+ )
184
+ time.sleep(min(interval, remaining))
185
+ interval = min(interval * backoff, max_interval)
186
+
187
+ def run(
188
+ self,
189
+ task_type: TaskType,
190
+ payload: Dict[str, Any],
191
+ *,
192
+ priority: Optional[int] = None,
193
+ idempotency_key: Optional[str] = None,
194
+ webhook: Optional[Dict[str, Any]] = None,
195
+ poll_interval: float = 2.0,
196
+ timeout: float = 300.0,
197
+ backoff: float = 1.5,
198
+ max_interval: float = 15.0,
199
+ ) -> Dict[str, Any]:
200
+ """Create a task and block until it completes. Convenience for
201
+ :meth:`create` + :meth:`wait`."""
202
+ task = self.create(
203
+ task_type,
204
+ payload,
205
+ priority=priority,
206
+ idempotency_key=idempotency_key,
207
+ webhook=webhook,
208
+ )
209
+ return self.wait(
210
+ task,
211
+ poll_interval=poll_interval,
212
+ timeout=timeout,
213
+ backoff=backoff,
214
+ max_interval=max_interval,
215
+ )
@@ -0,0 +1,117 @@
1
+ """Synchronous monitor endpoints — one call per AI engine or Google Search."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any, Dict, Optional
5
+
6
+ if TYPE_CHECKING:
7
+ from .._client import Cloro
8
+
9
+
10
+ class MonitorResource:
11
+ """Real-time ``POST /v1/monitor/*`` endpoints.
12
+
13
+ Each method blocks until the engine responds and returns the parsed
14
+ ``{"success": ..., "result": {...}}`` envelope. For high-volume or
15
+ long-running work, prefer the async task queue (:attr:`Cloro.async_tasks`).
16
+ """
17
+
18
+ def __init__(self, client: "Cloro") -> None:
19
+ self._client = client
20
+
21
+ def _run(self, path: str, body: Dict[str, Any]) -> Any:
22
+ payload = {k: v for k, v in body.items() if v is not None}
23
+ return self._client.request("POST", path, json=payload)
24
+
25
+ # -- AI answer engines (prompt-based) -------------------------------
26
+
27
+ def chatgpt(
28
+ self, prompt: str, country: str, *, include: Optional[Dict[str, bool]] = None, **extra: Any
29
+ ) -> Any:
30
+ """Monitor ChatGPT. ``POST /v1/monitor/chatgpt``."""
31
+ return self._run(
32
+ "/v1/monitor/chatgpt",
33
+ {"prompt": prompt, "country": country, "include": include, **extra},
34
+ )
35
+
36
+ def gemini(
37
+ self, prompt: str, country: str, *, include: Optional[Dict[str, bool]] = None, **extra: Any
38
+ ) -> Any:
39
+ """Monitor Google Gemini. ``POST /v1/monitor/gemini``."""
40
+ return self._run(
41
+ "/v1/monitor/gemini",
42
+ {"prompt": prompt, "country": country, "include": include, **extra},
43
+ )
44
+
45
+ def perplexity(
46
+ self, prompt: str, country: str, *, include: Optional[Dict[str, bool]] = None, **extra: Any
47
+ ) -> Any:
48
+ """Monitor Perplexity. ``POST /v1/monitor/perplexity``."""
49
+ return self._run(
50
+ "/v1/monitor/perplexity",
51
+ {"prompt": prompt, "country": country, "include": include, **extra},
52
+ )
53
+
54
+ def copilot(
55
+ self, prompt: str, country: str, *, include: Optional[Dict[str, bool]] = None, **extra: Any
56
+ ) -> Any:
57
+ """Monitor Microsoft Copilot. ``POST /v1/monitor/copilot``."""
58
+ return self._run(
59
+ "/v1/monitor/copilot",
60
+ {"prompt": prompt, "country": country, "include": include, **extra},
61
+ )
62
+
63
+ def grok(
64
+ self, prompt: str, country: str, *, include: Optional[Dict[str, bool]] = None, **extra: Any
65
+ ) -> Any:
66
+ """Monitor Grok. ``POST /v1/monitor/grok``."""
67
+ return self._run(
68
+ "/v1/monitor/grok",
69
+ {"prompt": prompt, "country": country, "include": include, **extra},
70
+ )
71
+
72
+ def aimode(
73
+ self, prompt: str, country: str, *, include: Optional[Dict[str, bool]] = None, **extra: Any
74
+ ) -> Any:
75
+ """Monitor Google AI Mode. ``POST /v1/monitor/aimode``."""
76
+ return self._run(
77
+ "/v1/monitor/aimode",
78
+ {"prompt": prompt, "country": country, "include": include, **extra},
79
+ )
80
+
81
+ # -- Google Search (query-based) ------------------------------------
82
+
83
+ def google(
84
+ self,
85
+ query: str,
86
+ country: str,
87
+ *,
88
+ location: Optional[str] = None,
89
+ uule: Optional[str] = None,
90
+ device: Optional[str] = None,
91
+ pages: Optional[int] = None,
92
+ include: Optional[Dict[str, bool]] = None,
93
+ **extra: Any,
94
+ ) -> Any:
95
+ """Monitor Google Search (SERP). ``POST /v1/monitor/google``."""
96
+ return self._run(
97
+ "/v1/monitor/google",
98
+ {
99
+ "query": query,
100
+ "country": country,
101
+ "location": location,
102
+ "uule": uule,
103
+ "device": device,
104
+ "pages": pages,
105
+ "include": include,
106
+ **extra,
107
+ },
108
+ )
109
+
110
+ def google_news(
111
+ self, query: str, country: str, *, include: Optional[Dict[str, bool]] = None, **extra: Any
112
+ ) -> Any:
113
+ """Monitor Google News. ``POST /v1/monitor/google/news``."""
114
+ return self._run(
115
+ "/v1/monitor/google/news",
116
+ {"query": query, "country": country, "include": include, **extra},
117
+ )
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloro
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the cloro API — one API for Google Search and every AI answer engine (ChatGPT, Gemini, Perplexity, Copilot, Grok, AI Overview, AI Mode).
5
+ Project-URL: Homepage, https://cloro.dev
6
+ Project-URL: Documentation, https://cloro.dev/docs
7
+ Project-URL: Source, https://github.com/cloro-dev/cloro-python
8
+ Project-URL: Issues, https://github.com/cloro-dev/cloro-python/issues
9
+ Author-email: cloro <support@cloro.dev>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai search,ai visibility,chatgpt,cloro,copilot,gemini,geo,google search,perplexity,seo,serp,serp api,web scraping
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.8
25
+ Requires-Dist: httpx<1,>=0.23
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # cloro Python SDK
32
+
33
+ The official Python client for [**cloro**](https://cloro.dev) — one API for
34
+ Google Search and every AI answer engine (ChatGPT, Gemini, Perplexity, Copilot,
35
+ Grok, AI Overview, AI Mode). Real-time, structured JSON.
36
+
37
+ Use the SDK, or [just `curl` it](https://cloro.dev/docs) — same clean response
38
+ either way. The SDK adds typed methods, sensible retries, and a polling helper
39
+ for the async task queue so you don't hand-roll it.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install cloro
45
+ ```
46
+
47
+ Requires Python 3.8+.
48
+
49
+ ## Quickstart
50
+
51
+ ```python
52
+ from cloro import Cloro
53
+
54
+ client = Cloro(api_key="sk_live_...") # or set CLORO_API_KEY
55
+
56
+ res = client.monitor.chatgpt(
57
+ prompt="What do you know about Acme Corp?",
58
+ country="US",
59
+ include={"markdown": True},
60
+ )
61
+
62
+ print(res["result"]["text"])
63
+ for source in res["result"]["sources"]:
64
+ print(source["position"], source["url"], source["label"])
65
+ ```
66
+
67
+ The API key is read from the `CLORO_API_KEY` environment variable when you don't
68
+ pass `api_key=`. Get a key (and 500 free credits) at
69
+ [dashboard.cloro.dev](https://dashboard.cloro.dev/).
70
+
71
+ ## Engines
72
+
73
+ Every engine is a method on `client.monitor`. AI engines take a `prompt`;
74
+ Google Search and Google News take a `query`.
75
+
76
+ ```python
77
+ client.monitor.chatgpt(prompt="...", country="US")
78
+ client.monitor.gemini(prompt="...", country="US")
79
+ client.monitor.perplexity(prompt="...", country="US")
80
+ client.monitor.copilot(prompt="...", country="US")
81
+ client.monitor.grok(prompt="...", country="US")
82
+ client.monitor.aimode(prompt="...", country="US") # Google AI Mode
83
+
84
+ client.monitor.google(query="best crm", country="US", pages=1)
85
+ client.monitor.google_news(query="acme corp", country="US")
86
+ ```
87
+
88
+ Pass `include={...}` to request extra formats — `markdown`, `html`,
89
+ `searchQueries`, `shopping`, and more, depending on the engine.
90
+
91
+ ## Async task queue
92
+
93
+ For high-volume or long-running work, enqueue tasks and poll them to completion.
94
+ `run()` does create-then-wait in one call:
95
+
96
+ ```python
97
+ result = client.async_tasks.run(
98
+ task_type="CHATGPT",
99
+ payload={"prompt": "What is cloro?", "country": "US"},
100
+ )
101
+ print(result["response"]) # present once COMPLETED
102
+ print(result["credits"]) # credits reserved / charged
103
+ ```
104
+
105
+ Prefer to manage the lifecycle yourself:
106
+
107
+ ```python
108
+ task = client.async_tasks.create(
109
+ task_type="GOOGLE",
110
+ payload={"query": "serp api", "country": "US"},
111
+ priority=5,
112
+ )
113
+ status = client.async_tasks.retrieve(task) # non-blocking snapshot
114
+ result = client.async_tasks.wait(task, timeout=120, poll_interval=2)
115
+ ```
116
+
117
+ Batch up to 500 tasks in a single request (results preserve input order):
118
+
119
+ ```python
120
+ results = client.async_tasks.create_batch([
121
+ {"task_type": "CHATGPT", "payload": {"prompt": "q1", "country": "US"}},
122
+ {"task_type": "PERPLEXITY", "payload": {"prompt": "q2", "country": "GB"}},
123
+ ])
124
+ for item in results:
125
+ if item["success"]:
126
+ client.async_tasks.wait(item["task"]["id"])
127
+ else:
128
+ print("failed:", item["error"]["message"])
129
+ ```
130
+
131
+ Queue-wide status:
132
+
133
+ ```python
134
+ client.async_tasks.status() # queued / processing counts, concurrency usage
135
+ ```
136
+
137
+ Valid `task_type` values: `CHATGPT`, `GEMINI`, `PERPLEXITY`, `COPILOT`, `GROK`,
138
+ `AIMODE`, `GOOGLE`, `GOOGLE_NEWS`.
139
+
140
+ ## Configuration
141
+
142
+ ```python
143
+ client = Cloro(
144
+ api_key="sk_live_...",
145
+ base_url="https://api.cloro.dev", # override if needed
146
+ timeout=60.0, # per-request seconds
147
+ max_retries=2, # timeouts, connection errors, 429/5xx
148
+ )
149
+ ```
150
+
151
+ The client is a context manager and pools connections:
152
+
153
+ ```python
154
+ with Cloro() as client:
155
+ client.monitor.chatgpt(prompt="...", country="US")
156
+ ```
157
+
158
+ ## Error handling
159
+
160
+ All errors subclass `CloroError`. HTTP failures map to status-specific types:
161
+
162
+ ```python
163
+ from cloro import Cloro, AuthenticationError, RateLimitError, CloroError
164
+
165
+ try:
166
+ client.monitor.chatgpt(prompt="...", country="US")
167
+ except AuthenticationError:
168
+ ... # 401 — bad or missing API key
169
+ except RateLimitError as e:
170
+ ... # 429 — back off and retry
171
+ except CloroError as e:
172
+ ... # everything else
173
+ ```
174
+
175
+ `BadRequestError` (400), `PermissionDeniedError` (403), `NotFoundError` (404),
176
+ `ConflictError` (409), and `InternalServerError` (5xx) are also available, along
177
+ with `TaskFailedError` / `TaskTimeoutError` from the poller and
178
+ `APITimeoutError` / `APIConnectionError` from the transport.
179
+
180
+ ## Reference data
181
+
182
+ ```python
183
+ client.countries() # supported countries
184
+ client.countries(model="chatgpt")
185
+ client.states() # US states for location-targeted Google
186
+ ```
187
+
188
+ ## Links
189
+
190
+ - Docs: <https://cloro.dev/docs>
191
+ - API reference (OpenAPI): <https://cloro.dev/docs/api-reference/openapi.json>
192
+ - Dashboard: <https://dashboard.cloro.dev/>
193
+ - TypeScript SDK: [cloro-node](https://github.com/cloro-dev/cloro-node)
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,12 @@
1
+ cloro/__init__.py,sha256=CNVRYtwYprHiVJ9eC-nPB4uKGKarVISV5BP3hIy5eKE,1204
2
+ cloro/_client.py,sha256=9lYAFL17yjrk1ntZB-KhyYVwsjaHDGWDWYo8_NUQetM,6676
3
+ cloro/_exceptions.py,sha256=h2ATxunpGCKK7Jany9RYjfKYmogeWgiPvOjP1419V8Y,3017
4
+ cloro/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
5
+ cloro/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ cloro/resources/__init__.py,sha256=xc--FAgJpPJ8tZfnbGSTNLzdP_ptcxB1SfMj-SW4270,158
7
+ cloro/resources/async_tasks.py,sha256=KXem2ZCNpmWoKHqkq22DaMXuL3Zj4SXt1UsJGXqEA14,7543
8
+ cloro/resources/monitor.py,sha256=Kjgjs7dDFe0AAfDVyF6x0BzIytZPieJVSKgs6-7SOfI,4154
9
+ cloro-0.1.0.dist-info/METADATA,sha256=Hxl7A01mU65HS2iZ9pdRJdeVfookDGcm3Ga2pfkyrWc,6181
10
+ cloro-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ cloro-0.1.0.dist-info/licenses/LICENSE,sha256=4sdWt3wrB2gINd2nu57FAMdVqjr_bnpwB3aK4NJ5LxE,1062
12
+ cloro-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cloro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.