binaaz-python-sdk 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.
- binaaz_python_sdk-0.1.0.dist-info/METADATA +205 -0
- binaaz_python_sdk-0.1.0.dist-info/RECORD +17 -0
- binaaz_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- binaaz_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- binaaz_sdk/__init__.py +97 -0
- binaaz_sdk/_core.py +418 -0
- binaaz_sdk/_version.py +1 -0
- binaaz_sdk/client.py +50 -0
- binaaz_sdk/config.py +83 -0
- binaaz_sdk/enums.py +73 -0
- binaaz_sdk/errors.py +107 -0
- binaaz_sdk/messages.py +60 -0
- binaaz_sdk/messages.yaml +62 -0
- binaaz_sdk/models.py +365 -0
- binaaz_sdk/py.typed +0 -0
- binaaz_sdk/queries.py +54 -0
- binaaz_sdk/resources.py +450 -0
binaaz_sdk/_core.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable, Generator, Mapping, Optional, TypeVar, Union
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .config import Settings, load_settings
|
|
14
|
+
from .errors import (
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
BadRequestError,
|
|
17
|
+
BlockedError,
|
|
18
|
+
GraphQLError,
|
|
19
|
+
InvalidRequestError,
|
|
20
|
+
NotSentError,
|
|
21
|
+
OutcomeUnknownError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ServerError,
|
|
24
|
+
TransportError,
|
|
25
|
+
for_status,
|
|
26
|
+
)
|
|
27
|
+
from .messages import text
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout, httpx.UnsupportedProtocol)
|
|
32
|
+
RETRY_STATUSES = frozenset({500, 502, 503, 504})
|
|
33
|
+
CLOUDFLARE_COOKIES = frozenset({"cf_clearance", "__cf_bm", "__cflb", "_cfuvid", "cf_chl_rc_m"})
|
|
34
|
+
AUTH_CODES = frozenset({"unauthorized", "unauthenticated", "forbidden"})
|
|
35
|
+
AUTH_HINTS = ("unauthorized", "not authorized", "unauthenticated", "sign in", "log in", "login required",
|
|
36
|
+
"daxil olun", "giriş")
|
|
37
|
+
CODE_ERRORS = frozenset({"code_requests", "failed_attempts", "phone", "code"})
|
|
38
|
+
|
|
39
|
+
CookieInput = Union[str, Mapping[str, Any], list, None]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Op:
|
|
44
|
+
method: str
|
|
45
|
+
service: str
|
|
46
|
+
endpoint: str
|
|
47
|
+
parse: Callable[[Any], Any] = lambda body: body
|
|
48
|
+
query: Optional[dict[str, Any]] = None
|
|
49
|
+
body: Optional[dict[str, Any]] = None
|
|
50
|
+
files: Optional[list[Any]] = None
|
|
51
|
+
safe: bool = False
|
|
52
|
+
flaky: bool = False
|
|
53
|
+
session: bool = True
|
|
54
|
+
action: str = "The request"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Pause:
|
|
59
|
+
seconds: float
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class CodeReply:
|
|
64
|
+
status: int
|
|
65
|
+
body: Mapping[str, Any]
|
|
66
|
+
attempts: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Flow = Generator[Any, Any, T]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Outcome:
|
|
74
|
+
value: Any = None
|
|
75
|
+
error: Optional[Exception] = None
|
|
76
|
+
wait: Optional[float] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _number(value: Optional[str]) -> Optional[float]:
|
|
80
|
+
try:
|
|
81
|
+
return float(value) if value not in (None, "") else None
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cloudflare_blocked(response: httpx.Response) -> bool:
|
|
87
|
+
if response.headers.get("cf-mitigated", "").lower() == "challenge":
|
|
88
|
+
return True
|
|
89
|
+
if response.status_code in (403, 503) and "cloudflare" in response.headers.get("server", "").lower():
|
|
90
|
+
return "text/html" in response.headers.get("content-type", "")
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_cookie(raw: CookieInput) -> dict[str, str]:
|
|
95
|
+
pairs: list[tuple[str, str]] = []
|
|
96
|
+
if isinstance(raw, str):
|
|
97
|
+
value = raw.strip()
|
|
98
|
+
if value[:1] in "[{":
|
|
99
|
+
try:
|
|
100
|
+
return parse_cookie(json.loads(value))
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
pass
|
|
103
|
+
if value.lower().startswith("cookie:"):
|
|
104
|
+
value = value[7:].strip()
|
|
105
|
+
for part in value.replace("\n", ";").split(";"):
|
|
106
|
+
name, sep, content = part.strip().partition("=")
|
|
107
|
+
if sep and name.strip():
|
|
108
|
+
pairs.append((name.strip(), content.strip()))
|
|
109
|
+
elif isinstance(raw, Mapping):
|
|
110
|
+
pairs = [(str(k), str(v)) for k, v in raw.items()]
|
|
111
|
+
elif isinstance(raw, list):
|
|
112
|
+
pairs = [(str(c["name"]), str(c.get("value", ""))) for c in raw if isinstance(c, Mapping) and "name" in c]
|
|
113
|
+
return {k: v for k, v in pairs if v and k.lower() not in CLOUDFLARE_COOKIES}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _graphql_detail(errors: list[Any]) -> str:
|
|
117
|
+
parts = []
|
|
118
|
+
for error in errors:
|
|
119
|
+
if isinstance(error, Mapping):
|
|
120
|
+
where = ".".join(str(p) for p in error.get("path") or [])
|
|
121
|
+
parts.append(f"{where}: {error.get('message')}" if where else str(error.get("message")))
|
|
122
|
+
return "; ".join(parts) or "unknown error"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Engine:
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
cookie: CookieInput = None,
|
|
129
|
+
*,
|
|
130
|
+
settings: Optional[Settings] = None,
|
|
131
|
+
config_file: Optional[str] = None,
|
|
132
|
+
**overrides: Any,
|
|
133
|
+
) -> None:
|
|
134
|
+
if settings is None:
|
|
135
|
+
settings = load_settings(config_file, **overrides)
|
|
136
|
+
elif overrides:
|
|
137
|
+
current = settings.model_dump()
|
|
138
|
+
current["cookie"] = settings.cookie.get_secret_value()
|
|
139
|
+
settings = load_settings(None, **{**current, **{k: v for k, v in overrides.items() if v is not None}})
|
|
140
|
+
self.settings = settings
|
|
141
|
+
settings.check_transport()
|
|
142
|
+
self._jar: dict[str, str] = parse_cookie(settings.cookie.get_secret_value())
|
|
143
|
+
if cookie is not None:
|
|
144
|
+
self.set_cookie(cookie)
|
|
145
|
+
|
|
146
|
+
def __repr__(self) -> str:
|
|
147
|
+
return f"{type(self).__name__}(base_url={self.settings.base_url!r}, signed_in={bool(self._jar)})"
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def cookie(self) -> str:
|
|
151
|
+
return "; ".join(f"{k}={v}" for k, v in self._jar.items())
|
|
152
|
+
|
|
153
|
+
def set_cookie(self, cookie: CookieInput) -> None:
|
|
154
|
+
jar = parse_cookie(cookie)
|
|
155
|
+
if cookie not in (None, "") and not jar:
|
|
156
|
+
raise InvalidRequestError(text("value.cookie"))
|
|
157
|
+
self._jar = jar
|
|
158
|
+
|
|
159
|
+
def clear_session(self) -> None:
|
|
160
|
+
self._jar = {}
|
|
161
|
+
|
|
162
|
+
def _timeout(self) -> httpx.Timeout:
|
|
163
|
+
return httpx.Timeout(self.settings.timeout, connect=self.settings.connect_timeout)
|
|
164
|
+
|
|
165
|
+
def _url(self, op: Op) -> str:
|
|
166
|
+
return self.settings.url(op.service, op.endpoint, op.query)
|
|
167
|
+
|
|
168
|
+
def _headers(self, op: Op) -> dict[str, str]:
|
|
169
|
+
headers = {"Accept": "application/json", "User-Agent": self.settings.resolved_user_agent()}
|
|
170
|
+
if op.service == "login_url":
|
|
171
|
+
headers["Accept-Language"] = self.settings.language
|
|
172
|
+
return headers
|
|
173
|
+
origin = self.settings.base_url
|
|
174
|
+
headers["Origin"] = origin
|
|
175
|
+
headers["Referer"] = origin + ("/items/new" if op.service == "photos_url" else "/")
|
|
176
|
+
if op.session and self._jar:
|
|
177
|
+
headers["Cookie"] = self.cookie
|
|
178
|
+
return headers
|
|
179
|
+
|
|
180
|
+
def _request_args(self, op: Op) -> dict[str, Any]:
|
|
181
|
+
args: dict[str, Any] = {"headers": self._headers(op)}
|
|
182
|
+
if op.files is not None:
|
|
183
|
+
args["files"] = op.files
|
|
184
|
+
elif op.body is not None:
|
|
185
|
+
args["json"] = op.body
|
|
186
|
+
return args
|
|
187
|
+
|
|
188
|
+
def _backoff(self, attempt: int) -> float:
|
|
189
|
+
base = self.settings.backoff_base
|
|
190
|
+
return min(self.settings.backoff_cap, base * (2**attempt)) + random.uniform(0, base / 2)
|
|
191
|
+
|
|
192
|
+
def _absorb(self, op: Op, response: httpx.Response) -> None:
|
|
193
|
+
if op.service == "login_url":
|
|
194
|
+
return
|
|
195
|
+
for raw in response.headers.get_list("set-cookie"):
|
|
196
|
+
first, _, rest = raw.partition(";")
|
|
197
|
+
name, sep, value = first.strip().partition("=")
|
|
198
|
+
name = name.strip()
|
|
199
|
+
if not sep or not name or name.lower() in CLOUDFLARE_COOKIES:
|
|
200
|
+
continue
|
|
201
|
+
expired = "01 jan 1970" in rest.lower() or "max-age=0" in rest.lower().replace(" ", "")
|
|
202
|
+
if value.strip() and not expired:
|
|
203
|
+
self._jar[name] = value.strip()
|
|
204
|
+
else:
|
|
205
|
+
self._jar.pop(name, None)
|
|
206
|
+
|
|
207
|
+
def _on_exception(self, op: Op, exc: Exception, attempt: int, url: str) -> Outcome:
|
|
208
|
+
host = urlsplit(url).hostname or url
|
|
209
|
+
path = urlsplit(url).path
|
|
210
|
+
can_retry = attempt < self.settings.max_retries
|
|
211
|
+
if isinstance(exc, NOT_SENT):
|
|
212
|
+
if can_retry:
|
|
213
|
+
return Outcome(wait=self._backoff(attempt))
|
|
214
|
+
return Outcome(error=NotSentError(text("error.connect", host=host, error=exc), path=path))
|
|
215
|
+
if op.safe:
|
|
216
|
+
if can_retry:
|
|
217
|
+
return Outcome(wait=self._backoff(attempt))
|
|
218
|
+
return Outcome(error=TransportError(text("error.timeout", host=host, error=exc), path=path))
|
|
219
|
+
return Outcome(error=OutcomeUnknownError(text("error.unknown_outcome", action=op.action,
|
|
220
|
+
reason=type(exc).__name__), path=path))
|
|
221
|
+
|
|
222
|
+
def _on_code_reply(self, op: Op, response: httpx.Response, attempt: int, path: str) -> Outcome:
|
|
223
|
+
try:
|
|
224
|
+
data = response.json()
|
|
225
|
+
except ValueError:
|
|
226
|
+
data = None
|
|
227
|
+
if isinstance(data, Mapping):
|
|
228
|
+
if response.status_code < 400 or data.get("error") in CODE_ERRORS:
|
|
229
|
+
return Outcome(value=op.parse(CodeReply(response.status_code, data, attempt + 1)))
|
|
230
|
+
if attempt + 1 < self.settings.code_attempts:
|
|
231
|
+
return Outcome(wait=self._backoff(attempt))
|
|
232
|
+
return Outcome(error=ServerError(text("error.code_service", attempts=attempt + 1,
|
|
233
|
+
last=f"HTTP {response.status_code}"),
|
|
234
|
+
status_code=response.status_code, body=data, path=path))
|
|
235
|
+
|
|
236
|
+
def _on_response(self, op: Op, response: httpx.Response, attempt: int, url: str) -> Outcome:
|
|
237
|
+
host = urlsplit(url).hostname or url
|
|
238
|
+
path = urlsplit(url).path
|
|
239
|
+
status = response.status_code
|
|
240
|
+
can_retry = attempt < self.settings.max_retries
|
|
241
|
+
self._absorb(op, response)
|
|
242
|
+
if cloudflare_blocked(response):
|
|
243
|
+
return Outcome(error=BlockedError(text("error.blocked", host=host), status_code=status, path=path))
|
|
244
|
+
if status == 429:
|
|
245
|
+
hint = _number(response.headers.get("retry-after"))
|
|
246
|
+
if can_retry:
|
|
247
|
+
wait = min(self.settings.backoff_cap, hint) if hint is not None else self._backoff(attempt)
|
|
248
|
+
return Outcome(wait=wait)
|
|
249
|
+
return Outcome(error=RateLimitError(text("error.status", host=host, status=status), status_code=status,
|
|
250
|
+
path=path, retry_after=hint))
|
|
251
|
+
if op.flaky:
|
|
252
|
+
return self._on_code_reply(op, response, attempt, path)
|
|
253
|
+
if status in RETRY_STATUSES:
|
|
254
|
+
if op.safe and can_retry:
|
|
255
|
+
return Outcome(wait=self._backoff(attempt))
|
|
256
|
+
if not op.safe:
|
|
257
|
+
return Outcome(error=OutcomeUnknownError(text("error.unknown_outcome", action=op.action,
|
|
258
|
+
reason=f"HTTP {status}"), status_code=status, path=path))
|
|
259
|
+
if status in (401, 419):
|
|
260
|
+
return Outcome(error=AuthenticationError(text("error.session"), status_code=status, path=path))
|
|
261
|
+
if status == 413:
|
|
262
|
+
return Outcome(error=BadRequestError(text("error.photo_size"), status_code=status, path=path))
|
|
263
|
+
try:
|
|
264
|
+
body = response.json()
|
|
265
|
+
except ValueError:
|
|
266
|
+
snippet = response.text[:200]
|
|
267
|
+
if status < 400 and not op.safe:
|
|
268
|
+
return Outcome(error=OutcomeUnknownError(text("error.body", host=host, body=snippet),
|
|
269
|
+
status_code=status, path=path))
|
|
270
|
+
kind = for_status(status) if status >= 400 else ServerError
|
|
271
|
+
return Outcome(error=kind(text("error.body", host=host, body=snippet), status_code=status,
|
|
272
|
+
body=snippet, path=path))
|
|
273
|
+
if op.service == "base_url":
|
|
274
|
+
return self._graphql(op, body, status, path, host)
|
|
275
|
+
if status >= 400:
|
|
276
|
+
return Outcome(error=for_status(status)(text("error.status", host=host, status=status),
|
|
277
|
+
status_code=status, body=body, path=path))
|
|
278
|
+
return Outcome(value=op.parse(body))
|
|
279
|
+
|
|
280
|
+
def _graphql(self, op: Op, body: Any, status: int, path: str, host: str) -> Outcome:
|
|
281
|
+
if not isinstance(body, Mapping):
|
|
282
|
+
return Outcome(error=ServerError(text("error.body", host=host, body=str(body)[:200]),
|
|
283
|
+
status_code=status, body=body, path=path))
|
|
284
|
+
errors = [e for e in body.get("errors") or [] if isinstance(e, Mapping)]
|
|
285
|
+
if errors:
|
|
286
|
+
joined = " ".join(str(e.get("message", "")) for e in errors).lower()
|
|
287
|
+
codes = {str(_ext(e).get("code", "")).lower() for e in errors}
|
|
288
|
+
if codes & AUTH_CODES or any(hint in joined for hint in AUTH_HINTS):
|
|
289
|
+
return Outcome(error=AuthenticationError(text("error.session"), status_code=status, body=body,
|
|
290
|
+
path=path))
|
|
291
|
+
return Outcome(error=GraphQLError(text("error.graphql", detail=_graphql_detail(errors)),
|
|
292
|
+
errors=errors, status_code=status, body=body, path=path))
|
|
293
|
+
if status >= 400:
|
|
294
|
+
return Outcome(error=for_status(status)(text("error.status", host=host, status=status),
|
|
295
|
+
status_code=status, body=body, path=path))
|
|
296
|
+
data = body.get("data")
|
|
297
|
+
return Outcome(value=op.parse(data if isinstance(data, Mapping) else {}))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _ext(error: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
301
|
+
value = error.get("extensions")
|
|
302
|
+
return value if isinstance(value, Mapping) else {}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class SyncEngine(Engine):
|
|
306
|
+
def __init__(self, *args: Any, http_client: Optional[httpx.Client] = None, **kwargs: Any) -> None:
|
|
307
|
+
super().__init__(*args, **kwargs)
|
|
308
|
+
self._owns_http = http_client is None
|
|
309
|
+
self._http = http_client or httpx.Client(timeout=self._timeout())
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
if self._owns_http:
|
|
313
|
+
self._http.close()
|
|
314
|
+
|
|
315
|
+
def __enter__(self: T) -> T:
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
def __exit__(self, *exc: Any) -> None:
|
|
319
|
+
self.close()
|
|
320
|
+
|
|
321
|
+
def _sleep(self, seconds: float) -> None:
|
|
322
|
+
if seconds > 0:
|
|
323
|
+
time.sleep(seconds)
|
|
324
|
+
|
|
325
|
+
def _run(self, op: Op) -> Any:
|
|
326
|
+
url = self._url(op)
|
|
327
|
+
attempt = 0
|
|
328
|
+
while True:
|
|
329
|
+
try:
|
|
330
|
+
response = self._http.request(op.method, url, **self._request_args(op))
|
|
331
|
+
except httpx.HTTPError as exc:
|
|
332
|
+
outcome = self._on_exception(op, exc, attempt, url)
|
|
333
|
+
else:
|
|
334
|
+
self._http.cookies.clear()
|
|
335
|
+
outcome = self._on_response(op, response, attempt, url)
|
|
336
|
+
if outcome.wait is not None:
|
|
337
|
+
self._sleep(outcome.wait)
|
|
338
|
+
attempt += 1
|
|
339
|
+
continue
|
|
340
|
+
if outcome.error is not None:
|
|
341
|
+
raise outcome.error
|
|
342
|
+
return outcome.value
|
|
343
|
+
|
|
344
|
+
def _flow(self, flow: Flow[T]) -> T:
|
|
345
|
+
reply: Any = None
|
|
346
|
+
error: Optional[Exception] = None
|
|
347
|
+
while True:
|
|
348
|
+
try:
|
|
349
|
+
step = flow.throw(error) if error is not None else flow.send(reply)
|
|
350
|
+
except StopIteration as done:
|
|
351
|
+
result: T = done.value
|
|
352
|
+
return result
|
|
353
|
+
error, reply = None, None
|
|
354
|
+
if isinstance(step, Pause):
|
|
355
|
+
self._sleep(step.seconds)
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
reply = self._run(step)
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
error = exc
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class AsyncEngine(Engine):
|
|
364
|
+
def __init__(self, *args: Any, http_client: Optional[httpx.AsyncClient] = None, **kwargs: Any) -> None:
|
|
365
|
+
super().__init__(*args, **kwargs)
|
|
366
|
+
self._owns_http = http_client is None
|
|
367
|
+
self._http = http_client or httpx.AsyncClient(timeout=self._timeout())
|
|
368
|
+
|
|
369
|
+
async def close(self) -> None:
|
|
370
|
+
if self._owns_http:
|
|
371
|
+
await self._http.aclose()
|
|
372
|
+
|
|
373
|
+
async def __aenter__(self: T) -> T:
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
377
|
+
await self.close()
|
|
378
|
+
|
|
379
|
+
async def _sleep(self, seconds: float) -> None:
|
|
380
|
+
if seconds > 0:
|
|
381
|
+
await asyncio.sleep(seconds)
|
|
382
|
+
|
|
383
|
+
async def _run(self, op: Op) -> Any:
|
|
384
|
+
url = self._url(op)
|
|
385
|
+
attempt = 0
|
|
386
|
+
while True:
|
|
387
|
+
try:
|
|
388
|
+
response = await self._http.request(op.method, url, **self._request_args(op))
|
|
389
|
+
except httpx.HTTPError as exc:
|
|
390
|
+
outcome = self._on_exception(op, exc, attempt, url)
|
|
391
|
+
else:
|
|
392
|
+
self._http.cookies.clear()
|
|
393
|
+
outcome = self._on_response(op, response, attempt, url)
|
|
394
|
+
if outcome.wait is not None:
|
|
395
|
+
await self._sleep(outcome.wait)
|
|
396
|
+
attempt += 1
|
|
397
|
+
continue
|
|
398
|
+
if outcome.error is not None:
|
|
399
|
+
raise outcome.error
|
|
400
|
+
return outcome.value
|
|
401
|
+
|
|
402
|
+
async def _flow(self, flow: Flow[T]) -> T:
|
|
403
|
+
reply: Any = None
|
|
404
|
+
error: Optional[Exception] = None
|
|
405
|
+
while True:
|
|
406
|
+
try:
|
|
407
|
+
step = flow.throw(error) if error is not None else flow.send(reply)
|
|
408
|
+
except StopIteration as done:
|
|
409
|
+
result: T = done.value
|
|
410
|
+
return result
|
|
411
|
+
error, reply = None, None
|
|
412
|
+
if isinstance(step, Pause):
|
|
413
|
+
await self._sleep(step.seconds)
|
|
414
|
+
continue
|
|
415
|
+
try:
|
|
416
|
+
reply = await self._run(step)
|
|
417
|
+
except Exception as exc:
|
|
418
|
+
error = exc
|
binaaz_sdk/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
binaaz_sdk/client.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
from ._core import AsyncEngine, Op, SyncEngine
|
|
6
|
+
from .resources import Auth, Catalog, Items, Photos
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _Namespaces:
|
|
10
|
+
settings: Any
|
|
11
|
+
|
|
12
|
+
def _mount(self) -> None:
|
|
13
|
+
self.auth = Auth(self)
|
|
14
|
+
self.items = Items(self)
|
|
15
|
+
self.photos = Photos(self)
|
|
16
|
+
self.catalog = Catalog(self)
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def _query(query: str, variables: Optional[Mapping[str, Any]], operation_name: Optional[str],
|
|
20
|
+
safe: bool) -> Op:
|
|
21
|
+
body: dict[str, Any] = {"query": query, "variables": dict(variables or {})}
|
|
22
|
+
if operation_name:
|
|
23
|
+
body["operationName"] = operation_name
|
|
24
|
+
return Op("POST", "base_url", "graphql", body=body, safe=safe)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BinaAz(SyncEngine, _Namespaces):
|
|
28
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
29
|
+
super().__init__(*args, **kwargs)
|
|
30
|
+
self._mount()
|
|
31
|
+
|
|
32
|
+
def profile(self) -> Any:
|
|
33
|
+
return self.auth.profile()
|
|
34
|
+
|
|
35
|
+
def graphql(self, query: str, variables: Optional[Mapping[str, Any]] = None, *,
|
|
36
|
+
operation_name: Optional[str] = None, safe: bool = False) -> Any:
|
|
37
|
+
return self._run(self._query(query, variables, operation_name, safe))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AsyncBinaAz(AsyncEngine, _Namespaces):
|
|
41
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
42
|
+
super().__init__(*args, **kwargs)
|
|
43
|
+
self._mount()
|
|
44
|
+
|
|
45
|
+
async def profile(self) -> Any:
|
|
46
|
+
return await self.auth.profile()
|
|
47
|
+
|
|
48
|
+
async def graphql(self, query: str, variables: Optional[Mapping[str, Any]] = None, *,
|
|
49
|
+
operation_name: Optional[str] = None, safe: bool = False) -> Any:
|
|
50
|
+
return await self._run(self._query(query, variables, operation_name, safe))
|
binaaz_sdk/config.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from urllib.parse import urlencode, urlsplit
|
|
5
|
+
|
|
6
|
+
from goodconf import Field, GoodConf, GoodConfConfigDict
|
|
7
|
+
from pydantic import BaseModel, SecretStr, field_validator
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .errors import ConfigurationError
|
|
11
|
+
from .messages import text
|
|
12
|
+
|
|
13
|
+
LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
14
|
+
SERVICES = ("base_url", "photos_url", "login_url")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Endpoints(BaseModel):
|
|
18
|
+
graphql: str = "/graphql"
|
|
19
|
+
photo_upload: str = "/pond"
|
|
20
|
+
photo_rotate: str = "/pond/rotate"
|
|
21
|
+
code_send: str = "/auth"
|
|
22
|
+
code_verify: str = "/auth/verify"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Settings(GoodConf):
|
|
26
|
+
model_config = GoodConfConfigDict(
|
|
27
|
+
env_prefix="BINAAZ_",
|
|
28
|
+
file_env_var="BINAAZ_CONFIG",
|
|
29
|
+
default_files=["binaaz.toml", "binaaz.yaml", "binaaz.yml", "binaaz.json"],
|
|
30
|
+
extra="ignore",
|
|
31
|
+
env_nested_delimiter="__",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
cookie: SecretStr = Field(default=SecretStr(""), description=text("settings.cookie"))
|
|
35
|
+
base_url: str = Field(default="https://bina.az", description=text("settings.base_url"))
|
|
36
|
+
photos_url: str = Field(default="https://photos.bina.az", description=text("settings.photos_url"))
|
|
37
|
+
login_url: str = Field(default="https://api.digit-u.id", description=text("settings.login_url"))
|
|
38
|
+
return_to: str = Field(default="https://bina.az/", description=text("settings.return_to"))
|
|
39
|
+
endpoints: Endpoints = Field(default_factory=Endpoints, description=text("settings.endpoints"))
|
|
40
|
+
language: str = Field(default="az", description=text("settings.language"))
|
|
41
|
+
timeout: float = Field(default=30.0, gt=0, description=text("settings.timeout"))
|
|
42
|
+
connect_timeout: float = Field(default=10.0, gt=0, description=text("settings.connect_timeout"))
|
|
43
|
+
max_retries: int = Field(default=2, ge=0, le=10, description=text("settings.max_retries"))
|
|
44
|
+
code_attempts: int = Field(default=3, ge=1, le=10, description=text("settings.code_attempts"))
|
|
45
|
+
backoff_base: float = Field(default=0.6, ge=0, description=text("settings.backoff_base"))
|
|
46
|
+
backoff_cap: float = Field(default=8.0, ge=0, description=text("settings.backoff_cap"))
|
|
47
|
+
user_agent: str = Field(default="binaaz-python-sdk/{version}", description=text("settings.user_agent"))
|
|
48
|
+
allow_insecure_http: bool = Field(default=False, description=text("settings.allow_insecure_http"))
|
|
49
|
+
|
|
50
|
+
@field_validator("base_url", "photos_url", "login_url", "return_to")
|
|
51
|
+
@classmethod
|
|
52
|
+
def _address(cls, value: str, info: Any) -> str:
|
|
53
|
+
parts = urlsplit(value.strip())
|
|
54
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
55
|
+
raise ValueError(text("error.base_url", field=info.field_name, url=value))
|
|
56
|
+
return value.strip() if info.field_name == "return_to" else value.strip().rstrip("/")
|
|
57
|
+
|
|
58
|
+
def check_transport(self) -> None:
|
|
59
|
+
for name in SERVICES:
|
|
60
|
+
address = getattr(self, name)
|
|
61
|
+
parts = urlsplit(address)
|
|
62
|
+
if parts.scheme == "https" or self.allow_insecure_http or parts.hostname in LOCAL_HOSTS:
|
|
63
|
+
continue
|
|
64
|
+
raise ConfigurationError(text("error.insecure", url=address))
|
|
65
|
+
|
|
66
|
+
def path(self, endpoint: str) -> str:
|
|
67
|
+
return endpoint if endpoint.startswith("/") else str(getattr(self.endpoints, endpoint))
|
|
68
|
+
|
|
69
|
+
def url(self, service: str, endpoint: str, query: Optional[dict[str, Any]] = None) -> str:
|
|
70
|
+
address = str(getattr(self, service)) + self.path(endpoint)
|
|
71
|
+
given = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
|
|
72
|
+
return f"{address}?{urlencode(given)}" if given else address
|
|
73
|
+
|
|
74
|
+
def resolved_user_agent(self) -> str:
|
|
75
|
+
return self.user_agent.replace("{version}", __version__)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Settings.__doc__ = text("settings.description")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_settings(config_file: Optional[str] = None, **overrides: Any) -> Settings:
|
|
82
|
+
given = {key: value for key, value in overrides.items() if value is not None}
|
|
83
|
+
return Settings(load=True, config_file=config_file, **given)
|
binaaz_sdk/enums.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum, IntEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Category(IntEnum):
|
|
7
|
+
APARTMENT = 1
|
|
8
|
+
APARTMENT_NEW_BUILDING = 2
|
|
9
|
+
APARTMENT_OLD_BUILDING = 3
|
|
10
|
+
HOUSE = 5
|
|
11
|
+
OFFICE = 7
|
|
12
|
+
GARAGE = 8
|
|
13
|
+
LAND = 9
|
|
14
|
+
COMMERCIAL = 10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContactType(str, Enum):
|
|
18
|
+
OWNER = "OWNER"
|
|
19
|
+
AGENT = "AGENT"
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
return self.value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BuildingType(str, Enum):
|
|
26
|
+
BUSINESS_CENTER = "BUSINESS_CENTER"
|
|
27
|
+
APARTMENT = "APARTMENT"
|
|
28
|
+
VILLA = "VILLA"
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str:
|
|
31
|
+
return self.value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ItemTab(str, Enum):
|
|
35
|
+
PENDING = "PENDING"
|
|
36
|
+
PUBLISHED = "PUBLISHED"
|
|
37
|
+
REJECTED = "REJECTED"
|
|
38
|
+
EXPIRED = "EXPIRED"
|
|
39
|
+
AWAITING_PAYMENT = "VALIDATED_AND_AWAITING_PAYMENT"
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return self.value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ItemState(str, Enum):
|
|
46
|
+
PENDING = "pending"
|
|
47
|
+
PUBLISHED = "published"
|
|
48
|
+
REJECTED = "rejected"
|
|
49
|
+
EXPIRED = "expired"
|
|
50
|
+
AWAITING_PAYMENT = "awaiting_payment"
|
|
51
|
+
UNKNOWN = "unknown"
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
return self.value
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_site(cls, value: object) -> ItemState:
|
|
58
|
+
return SITE_STATES.get(str(value or "").upper(), cls.UNKNOWN)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
SITE_STATES = {
|
|
62
|
+
"PENDING": ItemState.PENDING,
|
|
63
|
+
"PUBLISHED": ItemState.PUBLISHED,
|
|
64
|
+
"REJECTED": ItemState.REJECTED,
|
|
65
|
+
"EXPIRED": ItemState.EXPIRED,
|
|
66
|
+
"VALIDATED_AND_AWAITING_PAYMENT": ItemState.AWAITING_PAYMENT,
|
|
67
|
+
"AWAITING_PAYMENT": ItemState.AWAITING_PAYMENT,
|
|
68
|
+
"VALIDATED": ItemState.AWAITING_PAYMENT,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
PHOTO_ANGLES = (90, 180, 270, -90)
|
|
72
|
+
PHOTOS_MIN = 4
|
|
73
|
+
PHOTOS_MAX = 30
|