seolyticize 0.4.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.
@@ -0,0 +1,31 @@
1
+ """Seolyticize: the API client, and on-site helpers in :mod:`seolyticize.site`."""
2
+
3
+ from ._client import DEFAULT_BASE_URL, VERSION, Seolyticize
4
+ from ._tools import TOOL_COUNT
5
+ from .errors import (
6
+ AuthenticationError,
7
+ NetworkError,
8
+ NotFoundError,
9
+ PermissionError,
10
+ RateLimitError,
11
+ RefusedError,
12
+ SeolyticizeError,
13
+ ServerError,
14
+ ValidationError,
15
+ )
16
+
17
+ __version__ = VERSION
18
+ __all__ = [
19
+ "Seolyticize",
20
+ "DEFAULT_BASE_URL",
21
+ "TOOL_COUNT",
22
+ "SeolyticizeError",
23
+ "ValidationError",
24
+ "AuthenticationError",
25
+ "PermissionError",
26
+ "NotFoundError",
27
+ "RefusedError",
28
+ "RateLimitError",
29
+ "ServerError",
30
+ "NetworkError",
31
+ ]
seolyticize/_client.py ADDED
@@ -0,0 +1,152 @@
1
+ """The client: one call() for any tool, and a typed method per tool on top."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Any, Callable, Dict, Optional
12
+
13
+ from ._tools import READ_ONLY_TOOLS, Tools
14
+ from .errors import NetworkError, RateLimitError, SeolyticizeError, error_for_status
15
+
16
+ DEFAULT_BASE_URL = "https://seolyticize.com"
17
+ VERSION = "0.4.0"
18
+
19
+
20
+ class Seolyticize(Tools):
21
+ """A Seolyticize API client.
22
+
23
+ >>> client = Seolyticize() # reads SEOLYTICIZE_API_KEY
24
+ >>> client.get_next_steps(client_id="client_…")
25
+
26
+ Every tool is a method (``client.onboard_site(domain="example.com")``), and
27
+ ``client.call("tool_name", {...})`` reaches tools newer than this SDK.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ api_key: Optional[str] = None,
33
+ *,
34
+ base_url: Optional[str] = None,
35
+ timeout: float = 60.0,
36
+ max_retries: int = 2,
37
+ max_retry_after: float = 60.0,
38
+ sleep: Callable[[float], None] = time.sleep,
39
+ ) -> None:
40
+ key = api_key or os.environ.get("SEOLYTICIZE_API_KEY")
41
+ if not key:
42
+ raise SeolyticizeError(
43
+ "An API key is required: pass api_key or set SEOLYTICIZE_API_KEY.",
44
+ code="no_api_key",
45
+ )
46
+ self.api_key = key
47
+ self.base_url = (base_url or os.environ.get("SEOLYTICIZE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
48
+ self.timeout = timeout
49
+ self.max_retries = max_retries
50
+ self.max_retry_after = max_retry_after
51
+ self._sleep = sleep
52
+ #: The X-RateLimit-* headers from the latest response.
53
+ self.last_rate_limit: Dict[str, Optional[int]] = {}
54
+
55
+ # -- requests ----------------------------------------------------------
56
+
57
+ def _request(self, method: str, path: str, body: Optional[Dict[str, Any]], tool: Optional[str], retry_transient: bool) -> Any:
58
+ data = None if body is None else json.dumps(body).encode("utf-8")
59
+ attempt = 0
60
+ while True:
61
+ request = urllib.request.Request(
62
+ self.base_url + path,
63
+ data=data,
64
+ method=method,
65
+ headers={
66
+ "authorization": f"Bearer {self.api_key}",
67
+ "content-type": "application/json",
68
+ "accept": "application/json",
69
+ "user-agent": f"seolyticize-python/{VERSION}",
70
+ },
71
+ )
72
+ try:
73
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
74
+ self._remember_rate(response.headers)
75
+ payload = json.loads(response.read().decode("utf-8") or "{}")
76
+ return payload.get("data", payload)
77
+ except urllib.error.HTTPError as failure:
78
+ self._remember_rate(failure.headers)
79
+ status = failure.code
80
+ details = _details(failure)
81
+ retry_after = _float(failure.headers.get("retry-after"))
82
+ can_retry = attempt < self.max_retries
83
+ if status == 429 and can_retry and (retry_after or 0) <= self.max_retry_after:
84
+ self._sleep(retry_after if retry_after is not None else _backoff(attempt))
85
+ attempt += 1
86
+ continue
87
+ if status >= 500 and retry_transient and can_retry:
88
+ self._sleep(_backoff(attempt))
89
+ attempt += 1
90
+ continue
91
+ extra = {"retry_after": retry_after} if status == 429 else {}
92
+ raise error_for_status(status, details["message"], code=details["code"], next=details["next"], request_id=details["request_id"], tool=tool, **extra) from None
93
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as failure:
94
+ if retry_transient and attempt < self.max_retries:
95
+ self._sleep(_backoff(attempt))
96
+ attempt += 1
97
+ continue
98
+ raise NetworkError(f"No response from {self.base_url}: {failure}", tool=tool) from None
99
+
100
+ def _remember_rate(self, headers: Any) -> None:
101
+ if headers is None:
102
+ return
103
+ self.last_rate_limit = {
104
+ "limit": _int(headers.get("x-ratelimit-limit")),
105
+ "remaining": _int(headers.get("x-ratelimit-remaining")),
106
+ "reset_seconds": _int(headers.get("x-ratelimit-reset")),
107
+ }
108
+
109
+ # -- public ------------------------------------------------------------
110
+
111
+ def call(self, tool: str, input: Optional[Dict[str, Any]] = None) -> Any:
112
+ """Calls any tool by name with a JSON-shaped input (camelCase keys, as the API takes them)."""
113
+ # A tool this SDK has never heard of may be a write; it is not retried
114
+ # on server errors, since repeating a write could do it twice.
115
+ return self._request("POST", f"/api/v1/tools/{tool}", input or {}, tool, tool in READ_ONLY_TOOLS)
116
+
117
+ def list_tools(self, group: Optional[str] = None) -> Dict[str, Any]:
118
+ """The tools this key may call, with their schemas; ``group`` narrows to one job (onboarding, weekly, technical, research, reporting)."""
119
+ query = f"?group={group}" if group else ""
120
+ return self._request("GET", f"/api/v1/tools{query}", None, None, True)
121
+
122
+
123
+ def _details(failure: urllib.error.HTTPError) -> Dict[str, Optional[str]]:
124
+ try:
125
+ body = json.loads(failure.read().decode("utf-8") or "{}")
126
+ except Exception: # an HTML error page from a proxy
127
+ body = {}
128
+ text = lambda value: value if isinstance(value, str) and value else None # noqa: E731
129
+ return {
130
+ "message": text(body.get("error")) or f"HTTP {failure.code}",
131
+ "code": text(body.get("code")),
132
+ "next": text(body.get("next")),
133
+ "request_id": text(body.get("requestId")) or failure.headers.get("x-request-id"),
134
+ }
135
+
136
+
137
+ def _backoff(attempt: int) -> float:
138
+ return min(8.0, 0.5 * (2**attempt)) * (0.5 + random.random() / 2)
139
+
140
+
141
+ def _int(value: Optional[str]) -> Optional[int]:
142
+ try:
143
+ return int(value) if value is not None else None
144
+ except ValueError:
145
+ return None
146
+
147
+
148
+ def _float(value: Optional[str]) -> Optional[float]:
149
+ try:
150
+ return float(value) if value is not None else None
151
+ except ValueError:
152
+ return None