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 +52 -0
- cloro/_client.py +208 -0
- cloro/_exceptions.py +117 -0
- cloro/_version.py +1 -0
- cloro/py.typed +0 -0
- cloro/resources/__init__.py +4 -0
- cloro/resources/async_tasks.py +215 -0
- cloro/resources/monitor.py +117 -0
- cloro-0.1.0.dist-info/METADATA +197 -0
- cloro-0.1.0.dist-info/RECORD +12 -0
- cloro-0.1.0.dist-info/WHEEL +4 -0
- cloro-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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,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.
|