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.
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import mimetypes
5
+ import os
6
+ from pathlib import Path
7
+ from typing import IO, Any, Mapping, Optional, Union
8
+
9
+ from . import queries
10
+ from ._core import CodeReply, Flow, Op
11
+ from .enums import PHOTO_ANGLES, PHOTOS_MAX, PHOTOS_MIN, ItemTab
12
+ from .errors import (
13
+ AuthenticationError,
14
+ BadRequestError,
15
+ CodeRejectedError,
16
+ InvalidRequestError,
17
+ ItemError,
18
+ NotFoundError,
19
+ NotSupportedError,
20
+ RateLimitError,
21
+ ServerError,
22
+ )
23
+ from .messages import text
24
+ from .models import (
25
+ CategoryNode,
26
+ CodeSent,
27
+ Item,
28
+ ItemPage,
29
+ ItemResult,
30
+ Listing,
31
+ PaidService,
32
+ Payment,
33
+ Photo,
34
+ Place,
35
+ Profile,
36
+ )
37
+
38
+ OPERATORS = ("10", "50", "51", "55", "60", "70", "77", "99")
39
+ PAGE_MAX = 100
40
+
41
+ ListingLike = Union[Listing, Mapping[str, Any]]
42
+ PhotoLike = Union[bytes, bytearray, str, "os.PathLike[str]", IO[bytes]]
43
+ TabLike = Union[ItemTab, str, None]
44
+
45
+ CREATE_STATUS = {
46
+ "RESTRICTED": "error.status_restricted",
47
+ "UNVERIFIED": "error.status_unverified",
48
+ "INVALID": "error.status_invalid",
49
+ "FAILURE": "error.status_failure",
50
+ }
51
+ UPDATE_STATUS = {
52
+ "LOCKED": "error.status_locked",
53
+ "FORBIDDEN": "error.status_forbidden",
54
+ "NOT_FOUND": "error.status_not_found",
55
+ "INVALID": "error.status_invalid",
56
+ }
57
+
58
+
59
+ def required(value: Any, name: str, limit: int = 255) -> str:
60
+ result = str(value if value is not None else "").strip()
61
+ if not result:
62
+ raise InvalidRequestError(text("value.empty", field=name))
63
+ if len(result) > limit:
64
+ raise InvalidRequestError(text("value.too_long", field=name, limit=limit))
65
+ return result
66
+
67
+
68
+ def site_id(value: Any, name: str) -> str:
69
+ result = required(value, name, 64)
70
+ if any(ch in result for ch in "/?#& "):
71
+ raise InvalidRequestError(text("value.id", field=name, value=value))
72
+ return result
73
+
74
+
75
+ def whole(value: Any, name: str, low: int, high: int) -> int:
76
+ if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
77
+ raise InvalidRequestError(text("value.range", field=name, low=low, high=high, value=value))
78
+ return value
79
+
80
+
81
+ def local_phone(raw: Any) -> str:
82
+ digits = "".join(ch for ch in str(raw or "") if ch.isdigit())
83
+ if digits.startswith("994"):
84
+ digits = digits[3:]
85
+ if digits.startswith("0"):
86
+ digits = digits[1:]
87
+ if len(digits) != 9 or not digits.startswith(OPERATORS):
88
+ raise InvalidRequestError(text("value.phone", value=raw))
89
+ return "0" + digits
90
+
91
+
92
+ def listing(value: ListingLike) -> Listing:
93
+ if isinstance(value, Listing):
94
+ return value
95
+ try:
96
+ return Listing.model_validate(dict(value))
97
+ except ValueError as exc:
98
+ raise InvalidRequestError(str(exc)) from None
99
+
100
+
101
+ def item_tab(value: TabLike) -> Optional[str]:
102
+ if value is None or value == "":
103
+ return None
104
+ try:
105
+ return ItemTab(str(value).upper()).value
106
+ except ValueError:
107
+ named = {t.name: t.value for t in ItemTab}
108
+ if str(value).upper() in named:
109
+ return named[str(value).upper()]
110
+ raise InvalidRequestError(text("value.choice", field="tab", choices=tuple(named), value=value)) from None
111
+
112
+
113
+ def _detail(errors: list[Any]) -> str:
114
+ parts = []
115
+ for error in errors:
116
+ if isinstance(error, Mapping):
117
+ where = ".".join(str(p) for p in error.get("path") or []) or "item"
118
+ parts.append(f"{where}: {error.get('message')}")
119
+ return "; ".join(parts)
120
+
121
+
122
+ def unwrap(data: Mapping[str, Any], name: str, statuses: Optional[Mapping[str, str]] = None) -> Mapping[str, Any]:
123
+ payload = data.get(name)
124
+ if not isinstance(payload, Mapping):
125
+ raise AuthenticationError(text("error.session"))
126
+ errors = [e for e in payload.get("errors") or [] if isinstance(e, Mapping)]
127
+ status = payload.get("status")
128
+ detail = _detail(errors)
129
+ if statuses and status in statuses:
130
+ until = payload.get("lockedUntil")
131
+ params = {"until": f" until {until}" if until else ""} if status == "LOCKED" else {}
132
+ message = text(statuses[str(status)], **params) + (f": {detail}" if detail else "")
133
+ kind = NotFoundError if status == "NOT_FOUND" else ItemError
134
+ if kind is ItemError:
135
+ raise ItemError(message, status=str(status), errors=errors, locked_until=until, body=payload)
136
+ raise NotFoundError(message, body=payload)
137
+ if errors:
138
+ raise ItemError(text("error.validation", detail=detail), errors=errors, body=payload)
139
+ return payload
140
+
141
+
142
+ def read_photo(photo: PhotoLike, filename: Optional[str], content_type: Optional[str]) -> tuple[str, bytes, str]:
143
+ if isinstance(photo, (bytes, bytearray)):
144
+ content, name = bytes(photo), filename or "photo.jpg"
145
+ elif isinstance(photo, (str, os.PathLike)):
146
+ path = Path(photo)
147
+ content, name = path.read_bytes(), filename or path.name
148
+ elif hasattr(photo, "read"):
149
+ content = photo.read()
150
+ if not isinstance(content, (bytes, bytearray)):
151
+ raise InvalidRequestError(text("value.photo_file", value=photo))
152
+ content = bytes(content)
153
+ name = filename or os.path.basename(str(getattr(photo, "name", "") or "")) or "photo.jpg"
154
+ else:
155
+ raise InvalidRequestError(text("value.photo_file", value=photo))
156
+ if not content:
157
+ raise InvalidRequestError(text("value.empty", field="photo"))
158
+ kind = content_type or mimetypes.guess_type(name)[0] or "image/jpeg"
159
+ return name, content, kind
160
+
161
+
162
+ def _graphql(query: str, parse: Any, variables: Optional[Mapping[str, Any]] = None, *, name: str = "",
163
+ safe: bool = False, session: bool = True, action: str = "The request") -> Op:
164
+ body: dict[str, Any] = {"query": query, "variables": dict(variables or {})}
165
+ if name:
166
+ body["operationName"] = name
167
+ return Op("POST", "base_url", "graphql", parse, body=body, safe=safe, session=session, action=action)
168
+
169
+
170
+ class _Resource:
171
+ def __init__(self, client: Any) -> None:
172
+ self._client = client
173
+
174
+ @property
175
+ def _settings(self) -> Any:
176
+ return self._client.settings
177
+
178
+ def _run(self, op: Op) -> Any:
179
+ return self._client._run(op)
180
+
181
+ def _flow(self, flow: Flow[Any]) -> Any:
182
+ return self._client._flow(flow)
183
+
184
+
185
+ def _profile(data: Mapping[str, Any]) -> Profile:
186
+ body = data.get("profile")
187
+ if not isinstance(body, Mapping) or not body:
188
+ raise AuthenticationError(text("error.no_profile"))
189
+ return Profile.from_api(body)
190
+
191
+
192
+ def _company(data: Mapping[str, Any]) -> Mapping[str, Any]:
193
+ body = data.get("profile")
194
+ if not isinstance(body, Mapping) or not body:
195
+ raise AuthenticationError(text("error.no_profile"))
196
+ if body.get("__typename") != "CompanyProfile":
197
+ raise NotSupportedError(text("error.not_company", kind=body.get("__typename")))
198
+ return body
199
+
200
+
201
+ def _sign_in_payload(data: Mapping[str, Any]) -> Mapping[str, Any]:
202
+ payload = data.get("signIn")
203
+ payload = payload if isinstance(payload, Mapping) else {}
204
+ errors = [e for e in payload.get("errors") or [] if isinstance(e, Mapping)]
205
+ if errors or not payload.get("profile"):
206
+ raise BadRequestError(text("error.login", detail=_detail(errors) or "no profile"), body=payload)
207
+ return payload
208
+
209
+
210
+ def _code_error(reply: CodeReply, retried_message: bool = False) -> Exception:
211
+ kind = str(reply.body.get("error") or "")
212
+ detail = str(reply.body.get("message") or kind)
213
+ if kind in ("code_requests", "failed_attempts"):
214
+ return RateLimitError(text("error.code_limit", detail=detail), status_code=reply.status, body=reply.body)
215
+ if kind == "phone":
216
+ return BadRequestError(text("error.code_phone", detail=detail), status_code=reply.status, body=reply.body)
217
+ key = "error.code_reused" if retried_message and reply.attempts > 1 else "error.code_rejected"
218
+ return CodeRejectedError(text(key, detail=detail), status_code=reply.status, body=reply.body)
219
+
220
+
221
+ class Auth(_Resource):
222
+ def profile(self) -> Any:
223
+ return self._run(_graphql(queries.PROFILE, _profile, name="Profile", safe=True))
224
+
225
+ def login(self, email: str, password: str) -> Any:
226
+ variables = {"input": {"emailAndPassword": {"email": required(email, "email"),
227
+ "password": required(password, "password", 200)}}}
228
+ return self._flow(self._sign_in(variables))
229
+
230
+ def send_code(self, phone: str) -> Any:
231
+ number = local_phone(phone)
232
+
233
+ def parse(reply: CodeReply) -> CodeSent:
234
+ if reply.status >= 400:
235
+ raise _code_error(reply)
236
+ left = reply.body.get("code_requests_left")
237
+ return CodeSent(number, left if isinstance(left, int) and not isinstance(left, bool) else None,
238
+ reply.attempts > 1, dict(reply.body))
239
+
240
+ return self._run(Op("POST", "login_url", "code_send", parse, body={"phone": number}, flaky=True,
241
+ session=False, action="Sending the SMS code"))
242
+
243
+ def verify_code(self, phone: str, code: str) -> Any:
244
+ return self._flow(self._verify(local_phone(phone), required(code, "code", 12)))
245
+
246
+ def _verify(self, number: str, code: str) -> Flow[Profile]:
247
+ def parse(reply: CodeReply) -> str:
248
+ if reply.status >= 400:
249
+ raise _code_error(reply, retried_message=True)
250
+ token = reply.body.get("access_token")
251
+ if not token:
252
+ raise ServerError(text("error.no_token"), status_code=reply.status, body=reply.body)
253
+ return str(token)
254
+
255
+ return_to = base64.b64encode(self._settings.return_to.encode()).decode()
256
+ token: str = yield Op("POST", "login_url", "code_verify", parse,
257
+ body={"phone": number, "code": code, "return_to": return_to}, flaky=True,
258
+ session=False, action="Checking the SMS code")
259
+ profile: Profile = yield from self._sign_in({"input": {"accessToken": token}})
260
+ return profile
261
+
262
+ def _sign_in(self, variables: Mapping[str, Any]) -> Flow[Profile]:
263
+ previous = dict(self._client._jar)
264
+ self._client._jar = {}
265
+ signed = False
266
+ try:
267
+ yield _graphql(queries.SIGN_IN, _sign_in_payload, variables, name="SignIn", session=False,
268
+ action="Signing in")
269
+ if not self._client._jar:
270
+ raise ServerError(text("error.no_cookie"))
271
+ signed = True
272
+ profile: Profile = yield _graphql(queries.PROFILE, _profile, name="Profile", safe=True)
273
+ except Exception:
274
+ if not signed:
275
+ self._client._jar = previous
276
+ raise
277
+ return profile
278
+
279
+
280
+ class Items(_Resource):
281
+ def create(self, item: ListingLike) -> Any:
282
+ data = listing(item)
283
+ count = len(data.photo_ids)
284
+ if not PHOTOS_MIN <= count <= PHOTOS_MAX:
285
+ raise InvalidRequestError(text("value.photos", low=PHOTOS_MIN, high=PHOTOS_MAX, count=count))
286
+ parse = lambda body: ItemResult.from_api(unwrap(body, "createItem", CREATE_STATUS))
287
+ return self._run(_graphql(queries.CREATE_ITEM, parse, {"input": data.to_create_input()}, name="CreateItem",
288
+ action="Creating the listing"))
289
+
290
+ def update(self, item_id: str, item: ListingLike) -> Any:
291
+ given = site_id(item_id, "item_id")
292
+ data = listing(item)
293
+ parse = lambda body: ItemResult.from_api(unwrap(body, "updateItem", UPDATE_STATUS))
294
+ return self._run(_graphql(queries.UPDATE_ITEM, parse, {"input": data.to_update_input(given)},
295
+ name="UpdateItem", action="Updating the listing"))
296
+
297
+ def delete(self, item_id: str) -> Any:
298
+ given = site_id(item_id, "item_id")
299
+ parse = lambda body: ItemResult.from_api(unwrap(body, "deleteItem"))
300
+ return self._run(_graphql(queries.DELETE_ITEM, parse, {"input": {"itemId": given}}, name="DeleteItem",
301
+ action="Deleting the listing"))
302
+
303
+ def prolong(self, item_id: str) -> Any:
304
+ given = site_id(item_id, "item_id")
305
+ parse = lambda body: ItemResult.from_api(unwrap(body, "prolongItem"))
306
+ return self._run(_graphql(queries.PROLONG_ITEM, parse, {"input": {"itemId": given}}, name="ProlongItem",
307
+ action="Prolonging the listing"))
308
+
309
+ def get(self, item_id: str) -> Any:
310
+ given = site_id(item_id, "item_id")
311
+
312
+ def parse(body: Mapping[str, Any]) -> Item:
313
+ edit = body.get("itemEdit")
314
+ found = edit.get("item") if isinstance(edit, Mapping) else None
315
+ if not isinstance(found, Mapping) or not found:
316
+ raise NotFoundError(text("error.not_found", item_id=given), status_code=404)
317
+ return Item.from_api(found)
318
+
319
+ return self._run(_graphql(queries.ITEM_EDIT, parse, {"id": given}, name="ItemEdit", safe=True))
320
+
321
+ def list(self, tab: TabLike = None, first: int = 20, after: Optional[str] = None) -> Any:
322
+ variables = {"state": item_tab(tab), "first": whole(first, "first", 1, PAGE_MAX), "after": after or None}
323
+ return self._run(self._page_op(variables))
324
+
325
+ def all(self, tab: TabLike = None, max_pages: int = 50) -> Any:
326
+ return self._flow(self._every(item_tab(tab), whole(max_pages, "max_pages", 1, 10**4)))
327
+
328
+ def _page_op(self, variables: Mapping[str, Any]) -> Op:
329
+ parse = lambda body: ItemPage.from_api(_company(body).get("items") or {})
330
+ return _graphql(queries.MY_ITEMS, parse, variables, name="MyItems", safe=True)
331
+
332
+ def _every(self, state: Optional[str], max_pages: int) -> Flow[tuple[Item, ...]]:
333
+ items: list[Item] = []
334
+ cursor: Optional[str] = None
335
+ for _ in range(max_pages):
336
+ page: ItemPage = yield self._page_op({"state": state, "first": PAGE_MAX, "after": cursor})
337
+ items.extend(page.items)
338
+ cursor = page.next_cursor
339
+ if not cursor:
340
+ break
341
+ return tuple(items)
342
+
343
+ def pay(self, item_id: str, *, confirm: bool = False) -> Any:
344
+ given = site_id(item_id, "item_id")
345
+ if confirm is not True:
346
+ raise InvalidRequestError(text("value.confirm", action="Paying for a listing"))
347
+ parse = lambda body: ItemResult.from_api(unwrap(body, "payForItem"))
348
+ return self._run(_graphql(queries.PAY_FOR_ITEM, parse, {"input": {"itemId": given}}, name="PayForItem",
349
+ action="Paying for the listing"))
350
+
351
+ def paid_services(self, item_id: str) -> Any:
352
+ return self._flow(self._services(site_id(item_id, "item_id")))
353
+
354
+ def _services(self, item_id: str) -> Flow[tuple[PaidService, ...]]:
355
+ ids = [item_id]
356
+ data: Mapping[str, Any] = yield _graphql(queries.PAID_SERVICES, lambda body: body, {"ids": ids},
357
+ name="PaidServices", safe=True)
358
+ out = []
359
+ for service in data.get("paidServices") or []:
360
+ if not isinstance(service, Mapping):
361
+ continue
362
+ details: Mapping[str, Any] = yield _graphql(queries.PAID_SERVICE_DETAILS, lambda body: body,
363
+ {"sid": service.get("id"), "ids": ids},
364
+ name="PaidServiceDetails", safe=True)
365
+ out.append(PaidService.from_api(service, details))
366
+ return tuple(out)
367
+
368
+ def buy_service(self, item_id: str, service_id: str, payment_method_id: str, *, option_id: Optional[str] = None,
369
+ amount: Optional[float] = None, confirm: bool = False) -> Any:
370
+ given = site_id(item_id, "item_id")
371
+ if confirm is not True:
372
+ raise InvalidRequestError(text("value.confirm", action="A paid service"))
373
+ data: dict[str, Any] = {"paidServiceId": required(service_id, "service_id", 64),
374
+ "paymentMethodId": required(payment_method_id, "payment_method_id", 64),
375
+ "targetId": [given], "platform": "DESKTOP"}
376
+ if option_id is not None:
377
+ data["paidServiceOptionId"] = required(option_id, "option_id", 64)
378
+ if amount is not None:
379
+ data["amount"] = amount
380
+ parse = lambda body: Payment.from_api(unwrap(body, "createPayment"))
381
+ return self._run(_graphql(queries.CREATE_PAYMENT, parse, {"input": data}, name="CreatePayment",
382
+ action="The payment"))
383
+
384
+
385
+ def _photo(body: Mapping[str, Any]) -> Photo:
386
+ photos = body.get("photos") or []
387
+ first = photos[0] if isinstance(photos, list) and photos else None
388
+ if not isinstance(first, Mapping) or first.get("id") in (None, ""):
389
+ raise ServerError(text("error.photo_id"), body=body)
390
+ return Photo.from_api(first)
391
+
392
+
393
+ def _rotated(body: Mapping[str, Any]) -> Photo:
394
+ if not isinstance(body, Mapping) or body.get("id") in (None, ""):
395
+ raise ServerError(text("error.photo_id"), body=body)
396
+ return Photo.from_api(body)
397
+
398
+
399
+ class Photos(_Resource):
400
+ def upload(self, photo: PhotoLike, *, filename: Optional[str] = None, content_type: Optional[str] = None) -> Any:
401
+ return self._run(self._upload_op(photo, filename, content_type))
402
+
403
+ def upload_many(self, photos: Any) -> Any:
404
+ ops = [self._upload_op(photo, None, None) for photo in photos]
405
+ return self._flow(self._many(ops))
406
+
407
+ def _many(self, ops: list[Op]) -> Flow[tuple[Photo, ...]]:
408
+ out = []
409
+ for op in ops:
410
+ uploaded: Photo = yield op
411
+ out.append(uploaded)
412
+ return tuple(out)
413
+
414
+ def _upload_op(self, photo: PhotoLike, filename: Optional[str], content_type: Optional[str]) -> Op:
415
+ name, content, kind = read_photo(photo, filename, content_type)
416
+ return Op("POST", "photos_url", "photo_upload", _photo, query={"lang": "az"},
417
+ files=[("images[]", (name, content, kind))], action="The photo upload")
418
+
419
+ def rotate(self, photo_id: str, angle: int) -> Any:
420
+ if angle not in PHOTO_ANGLES or isinstance(angle, bool):
421
+ raise InvalidRequestError(text("value.choice", field="angle", choices=PHOTO_ANGLES, value=angle))
422
+ query = {"id": required(photo_id, "photo_id", 300), "angle": str(angle), "lang": "az"}
423
+ return self._run(Op("POST", "photos_url", "photo_rotate", _rotated, query=query,
424
+ action="Rotating the photo"))
425
+
426
+
427
+ def _places(name: str) -> Any:
428
+ return lambda body: tuple(Place.from_api(p) for p in body.get(name) or [] if isinstance(p, Mapping))
429
+
430
+
431
+ class Catalog(_Resource):
432
+ def categories(self) -> Any:
433
+ parse = lambda body: tuple(CategoryNode.from_api(c) for c in body.get("categories") or []
434
+ if isinstance(c, Mapping))
435
+ return self._run(_graphql(queries.CATEGORIES, parse, safe=True, session=False))
436
+
437
+ def cities(self) -> Any:
438
+ return self._run(_graphql(queries.CITIES, _places("cities"), safe=True, session=False))
439
+
440
+ def regions(self, city_id: str) -> Any:
441
+ return self._run(_graphql(queries.REGIONS, _places("regions"), {"c": site_id(city_id, "city_id")},
442
+ safe=True, session=False))
443
+
444
+ def townships(self, region_id: str) -> Any:
445
+ return self._run(_graphql(queries.TOWNSHIPS, _places("townships"), {"r": site_id(region_id, "region_id")},
446
+ safe=True, session=False))
447
+
448
+ def landmarks(self, city_id: str, latitude: float, longitude: float) -> Any:
449
+ variables = {"c": site_id(city_id, "city_id"), "lat": float(latitude), "lng": float(longitude)}
450
+ return self._run(_graphql(queries.LANDMARKS, _places("landmarks"), variables, safe=True, session=False))