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/errors.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BinaAzError(Exception):
|
|
7
|
+
def __init__(self, message: str) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(BinaAzError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidRequestError(BinaAzError, ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransportError(BinaAzError):
|
|
21
|
+
def __init__(self, message: str, *, path: str = "") -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.path = path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotSentError(TransportError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OutcomeUnknownError(TransportError):
|
|
31
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None, **kwargs: Any) -> None:
|
|
32
|
+
super().__init__(message, **kwargs)
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class APIError(BinaAzError):
|
|
37
|
+
def __init__(self, message: str, *, status_code: int = 0, body: Any = None, path: str = "") -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.status_code = status_code
|
|
40
|
+
self.body = body
|
|
41
|
+
self.path = path
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
return f"[{self.status_code}] {self.message}" if self.status_code else self.message
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BlockedError(APIError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BadRequestError(APIError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuthenticationError(APIError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NotFoundError(APIError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RateLimitError(APIError):
|
|
64
|
+
def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
65
|
+
super().__init__(message, **kwargs)
|
|
66
|
+
self.retry_after = retry_after
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ServerError(APIError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GraphQLError(BadRequestError):
|
|
74
|
+
def __init__(self, message: str, *, errors: Any = None, **kwargs: Any) -> None:
|
|
75
|
+
super().__init__(message, **kwargs)
|
|
76
|
+
self.errors = list(errors or [])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ItemError(BadRequestError):
|
|
80
|
+
def __init__(self, message: str, *, status: Optional[str] = None, errors: Any = None,
|
|
81
|
+
locked_until: Optional[str] = None, **kwargs: Any) -> None:
|
|
82
|
+
super().__init__(message, **kwargs)
|
|
83
|
+
self.status = status
|
|
84
|
+
self.errors = list(errors or [])
|
|
85
|
+
self.locked_until = locked_until
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NotSupportedError(BinaAzError):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CodeRejectedError(BadRequestError):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def for_status(status: int) -> type[APIError]:
|
|
97
|
+
if status in (401, 403, 419):
|
|
98
|
+
return AuthenticationError
|
|
99
|
+
if status == 404:
|
|
100
|
+
return NotFoundError
|
|
101
|
+
if status == 429:
|
|
102
|
+
return RateLimitError
|
|
103
|
+
if status >= 500:
|
|
104
|
+
return ServerError
|
|
105
|
+
if status >= 400:
|
|
106
|
+
return BadRequestError
|
|
107
|
+
return APIError
|
binaaz_sdk/messages.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Union
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
PathLike = Union[str, Path]
|
|
11
|
+
|
|
12
|
+
_PACKAGE_FILE = "messages.yaml"
|
|
13
|
+
_catalog: dict[str, str] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _flatten(node: Mapping[str, Any], prefix: str = "") -> dict[str, str]:
|
|
17
|
+
flat: dict[str, str] = {}
|
|
18
|
+
for key, value in node.items():
|
|
19
|
+
path = f"{prefix}{key}"
|
|
20
|
+
if isinstance(value, Mapping):
|
|
21
|
+
flat.update(_flatten(value, f"{path}."))
|
|
22
|
+
else:
|
|
23
|
+
flat[path] = str(value)
|
|
24
|
+
return flat
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse(raw: str) -> dict[str, str]:
|
|
28
|
+
data = yaml.safe_load(raw) or {}
|
|
29
|
+
if not isinstance(data, Mapping):
|
|
30
|
+
raise ValueError("messages file must contain a mapping")
|
|
31
|
+
return _flatten(data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_packaged() -> dict[str, str]:
|
|
35
|
+
raw = resources.files(__package__).joinpath(_PACKAGE_FILE).read_text(encoding="utf-8")
|
|
36
|
+
return _parse(raw)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def catalog() -> Mapping[str, str]:
|
|
40
|
+
if not _catalog:
|
|
41
|
+
_catalog.update(_read_packaged())
|
|
42
|
+
return _catalog
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def use(source: PathLike | Mapping[str, Any]) -> None:
|
|
46
|
+
overrides = _flatten(source) if isinstance(source, Mapping) else _parse(Path(source).read_text(encoding="utf-8"))
|
|
47
|
+
unknown = set(overrides) - set(_read_packaged())
|
|
48
|
+
if unknown:
|
|
49
|
+
raise KeyError(", ".join(sorted(unknown)))
|
|
50
|
+
catalog()
|
|
51
|
+
_catalog.update(overrides)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reset() -> None:
|
|
55
|
+
_catalog.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def text(key: str, **params: Any) -> str:
|
|
59
|
+
template = catalog()[key]
|
|
60
|
+
return template.format(**params) if params else template
|
binaaz_sdk/messages.yaml
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
settings:
|
|
2
|
+
description: Settings for the bina.az client. Values come from code, BINAAZ_* environment variables or a config file.
|
|
3
|
+
cookie: Signed in bina.az session, as a Cookie header value. Empty until you sign in or pass one
|
|
4
|
+
base_url: bina.az address. The GraphQL endpoint lives under it
|
|
5
|
+
photos_url: Photo service address
|
|
6
|
+
login_url: Address of the SMS login service bina.az uses
|
|
7
|
+
return_to: Page the SMS login service is told to return to
|
|
8
|
+
endpoints: Path of every call, relative to its service address
|
|
9
|
+
language: Language sent to the SMS login service
|
|
10
|
+
timeout: Seconds to wait for an answer
|
|
11
|
+
connect_timeout: Seconds to wait for a connection
|
|
12
|
+
max_retries: Extra attempts for calls that are safe to repeat
|
|
13
|
+
code_attempts: Attempts for an SMS login call whose answer has no known error, the login service drops some answers
|
|
14
|
+
backoff_base: First wait between retries, in seconds
|
|
15
|
+
backoff_cap: Longest wait between retries, in seconds
|
|
16
|
+
user_agent: User-Agent header
|
|
17
|
+
allow_insecure_http: Allow plain http addresses outside localhost, for a test server on a private network only
|
|
18
|
+
|
|
19
|
+
value:
|
|
20
|
+
empty: "{field} cannot be empty"
|
|
21
|
+
too_long: "{field} is longer than {limit} characters"
|
|
22
|
+
range: "{field} must be a whole number from {low} to {high}, got {value!r}"
|
|
23
|
+
choice: "{field} must be one of {choices}, got {value!r}"
|
|
24
|
+
phone: "{value!r} is not an Azerbaijani mobile number such as 050 123 45 67"
|
|
25
|
+
id: "{field} must be a bina.az id, got {value!r}"
|
|
26
|
+
photos: "bina.az takes {low} to {high} photos on a listing, got {count}"
|
|
27
|
+
photo_file: "photo must be bytes, a file object or a path, got {value!r}"
|
|
28
|
+
confirm: "{action} costs money. Pass confirm=True to go ahead"
|
|
29
|
+
cookie: Cookie is empty or not in a known format
|
|
30
|
+
|
|
31
|
+
error:
|
|
32
|
+
base_url: "{field} must be an absolute http or https address, got {url}"
|
|
33
|
+
insecure: "Refusing to send a session over plain http to {url}. Use https, or set allow_insecure_http for a private test server."
|
|
34
|
+
connect: "Could not reach {host}, nothing was sent: {error}"
|
|
35
|
+
timeout: "{host} did not answer: {error}"
|
|
36
|
+
unknown_outcome: "{action} may or may not have gone through ({reason}). Check the account before trying again."
|
|
37
|
+
body: "{host} sent an answer that is not JSON: {body}"
|
|
38
|
+
status: "{host} answered with HTTP {status}"
|
|
39
|
+
blocked: "{host} answered with a Cloudflare challenge page. This client does not solve challenges; the site has to let your address through."
|
|
40
|
+
session: bina.az did not accept the session. Sign in again
|
|
41
|
+
no_profile: bina.az returned no profile, the session is not signed in
|
|
42
|
+
no_cookie: bina.az accepted the sign in but set no session cookie
|
|
43
|
+
login: "bina.az did not accept the sign in: {detail}"
|
|
44
|
+
graphql: "bina.az refused the request: {detail}"
|
|
45
|
+
validation: "bina.az rejected the listing: {detail}"
|
|
46
|
+
not_company: "Listing management needs a bina.az company profile, this session is a {kind}"
|
|
47
|
+
not_found: "bina.az item {item_id} was not found for this account"
|
|
48
|
+
photo_id: bina.az did not return a photo id
|
|
49
|
+
photo_size: The photo is too large for bina.az
|
|
50
|
+
code_service: "The SMS login service did not answer after {attempts} attempts ({last}). Try again in a moment."
|
|
51
|
+
code_limit: "Too many SMS codes or wrong codes for this number. Wait before asking again: {detail}"
|
|
52
|
+
code_phone: "The SMS login service refused the number: {detail}"
|
|
53
|
+
code_rejected: "The code was not accepted: {detail}"
|
|
54
|
+
code_reused: "The code was not accepted: {detail}. It may have been used by an earlier attempt, ask for a new one."
|
|
55
|
+
no_token: The SMS login service returned no access token
|
|
56
|
+
status_restricted: bina.az wants the account's phone number confirmed before posting
|
|
57
|
+
status_unverified: The bina.az account is not verified yet
|
|
58
|
+
status_invalid: bina.az rejected the listing data
|
|
59
|
+
status_failure: bina.az could not create the listing
|
|
60
|
+
status_locked: "The listing is locked on bina.az, for example under moderation{until}"
|
|
61
|
+
status_forbidden: This bina.az account can not edit the listing
|
|
62
|
+
status_not_found: The listing was not found on bina.az
|
binaaz_sdk/models.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from decimal import Decimal, InvalidOperation
|
|
6
|
+
from typing import Any, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
9
|
+
from pydantic.alias_generators import to_camel
|
|
10
|
+
|
|
11
|
+
from .enums import PHOTOS_MAX, BuildingType, Category, ContactType, ItemState
|
|
12
|
+
|
|
13
|
+
ORIGIN = "https://bina.az"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _pick(body: Mapping[str, Any], *keys: str) -> Any:
|
|
17
|
+
for key in keys:
|
|
18
|
+
value = body.get(key)
|
|
19
|
+
if value is not None and value != "":
|
|
20
|
+
return value
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _str(body: Mapping[str, Any], *keys: str) -> Optional[str]:
|
|
25
|
+
value = _pick(body, *keys)
|
|
26
|
+
return None if value is None else str(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _int(body: Mapping[str, Any], *keys: str) -> Optional[int]:
|
|
30
|
+
value = _pick(body, *keys)
|
|
31
|
+
if value is None or isinstance(value, bool):
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return int(value)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _money(value: Any) -> Optional[Decimal]:
|
|
40
|
+
if value is None or isinstance(value, bool):
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
result = Decimal(str(value))
|
|
44
|
+
except (InvalidOperation, ValueError):
|
|
45
|
+
return None
|
|
46
|
+
return result if result.is_finite() else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _when(body: Mapping[str, Any], *keys: str) -> Optional[datetime]:
|
|
50
|
+
value = _pick(body, *keys)
|
|
51
|
+
if not isinstance(value, str):
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _map(value: Any) -> Mapping[str, Any]:
|
|
60
|
+
return value if isinstance(value, Mapping) else {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _list(value: Any) -> list[Any]:
|
|
64
|
+
return list(value) if isinstance(value, (list, tuple)) else []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def site_url(path: Optional[str], origin: str = ORIGIN) -> Optional[str]:
|
|
68
|
+
if not path:
|
|
69
|
+
return None
|
|
70
|
+
return path if path.startswith("http") else origin.rstrip("/") + path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Camel(BaseModel):
|
|
74
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="forbid")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Area(_Camel):
|
|
78
|
+
value: float = Field(gt=0)
|
|
79
|
+
land: Optional[float] = Field(default=None, gt=0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Floor(_Camel):
|
|
83
|
+
value: float
|
|
84
|
+
total: float = Field(gt=0)
|
|
85
|
+
|
|
86
|
+
@model_validator(mode="after")
|
|
87
|
+
def _check(self) -> Floor:
|
|
88
|
+
if self.value > self.total:
|
|
89
|
+
raise ValueError("floor.value cannot exceed floor.total")
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Location(_Camel):
|
|
94
|
+
address: str = Field(min_length=1, max_length=500)
|
|
95
|
+
city_id: str
|
|
96
|
+
latitude: float = Field(ge=-90, le=90)
|
|
97
|
+
longitude: float = Field(ge=-180, le=180)
|
|
98
|
+
region_id: Optional[str] = None
|
|
99
|
+
township_id: Optional[str] = None
|
|
100
|
+
landmark_ids: Optional[list[str]] = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Contact(_Camel):
|
|
104
|
+
type: ContactType
|
|
105
|
+
name: Optional[str] = Field(default=None, max_length=100)
|
|
106
|
+
email: Optional[str] = Field(default=None, max_length=255)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Listing(_Camel):
|
|
110
|
+
category_id: Category
|
|
111
|
+
is_leased: bool
|
|
112
|
+
price: int = Field(gt=0)
|
|
113
|
+
area: Area
|
|
114
|
+
location: Location
|
|
115
|
+
contact: Contact
|
|
116
|
+
photo_ids: list[str] = Field(default_factory=list, max_length=PHOTOS_MAX)
|
|
117
|
+
description: Optional[str] = Field(default=None, max_length=5000)
|
|
118
|
+
rooms: Optional[int] = Field(default=None, ge=1, le=50)
|
|
119
|
+
floor: Optional[Floor] = None
|
|
120
|
+
has_repair: Optional[bool] = None
|
|
121
|
+
has_mortgage: Optional[bool] = None
|
|
122
|
+
has_bill_of_sale: Optional[bool] = None
|
|
123
|
+
paid_daily: Optional[bool] = None
|
|
124
|
+
building_type: Optional[BuildingType] = None
|
|
125
|
+
|
|
126
|
+
@model_validator(mode="after")
|
|
127
|
+
def _check(self) -> Listing:
|
|
128
|
+
if self.paid_daily and not self.is_leased:
|
|
129
|
+
raise ValueError("paid_daily needs is_leased=True")
|
|
130
|
+
if self.building_type and self.category_id != Category.OFFICE:
|
|
131
|
+
raise ValueError("building_type is only valid for offices (category 7)")
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def _dump(self, **kw: Any) -> dict[str, Any]:
|
|
135
|
+
data = self.model_dump(mode="json", by_alias=True, exclude_none=True, **kw)
|
|
136
|
+
if "categoryId" in data:
|
|
137
|
+
data["categoryId"] = str(data["categoryId"])
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
def to_create_input(self) -> dict[str, Any]:
|
|
141
|
+
return self._dump()
|
|
142
|
+
|
|
143
|
+
def to_update_input(self, item_id: str) -> dict[str, Any]:
|
|
144
|
+
data = self._dump(exclude={"category_id", "is_leased", "contact", "building_type"})
|
|
145
|
+
if self.contact.name:
|
|
146
|
+
data["contactName"] = self.contact.name
|
|
147
|
+
return {"itemId": str(item_id), **data}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class Profile:
|
|
152
|
+
kind: str
|
|
153
|
+
id: Optional[str] = None
|
|
154
|
+
name: Optional[str] = None
|
|
155
|
+
email: Optional[str] = None
|
|
156
|
+
phone: Optional[str] = None
|
|
157
|
+
contact_name: Optional[str] = None
|
|
158
|
+
path: Optional[str] = None
|
|
159
|
+
agency: Optional[str] = None
|
|
160
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def is_company(self) -> bool:
|
|
164
|
+
return self.kind == "CompanyProfile"
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_api(cls, body: Mapping[str, Any]) -> Profile:
|
|
168
|
+
return cls(
|
|
169
|
+
kind=_str(body, "__typename") or "",
|
|
170
|
+
id=_str(body, "id"),
|
|
171
|
+
name=_str(body, "name"),
|
|
172
|
+
email=_str(body, "email"),
|
|
173
|
+
phone=_str(body, "phone"),
|
|
174
|
+
contact_name=_str(body, "contactName"),
|
|
175
|
+
path=_str(body, "path"),
|
|
176
|
+
agency=_str(_map(body.get("agency")), "name"),
|
|
177
|
+
raw=dict(body),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class Price:
|
|
183
|
+
total: Optional[Decimal]
|
|
184
|
+
currency: str
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_api(cls, body: Any) -> Optional[Price]:
|
|
188
|
+
data = _map(body)
|
|
189
|
+
if not data:
|
|
190
|
+
return None
|
|
191
|
+
return cls(_money(data.get("total")), _str(data, "currency") or "AZN")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class Item:
|
|
196
|
+
id: str
|
|
197
|
+
state: ItemState
|
|
198
|
+
site_state: Optional[str] = None
|
|
199
|
+
title: Optional[str] = None
|
|
200
|
+
path: Optional[str] = None
|
|
201
|
+
url: Optional[str] = None
|
|
202
|
+
price: Optional[Price] = None
|
|
203
|
+
expires_at: Optional[datetime] = None
|
|
204
|
+
updated_at: Optional[datetime] = None
|
|
205
|
+
thumbnail: Optional[str] = None
|
|
206
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def from_api(cls, body: Mapping[str, Any]) -> Item:
|
|
210
|
+
photos = [p for p in _list(body.get("photos")) if isinstance(p, Mapping)]
|
|
211
|
+
return cls(
|
|
212
|
+
id=_str(body, "id") or "",
|
|
213
|
+
state=ItemState.from_site(body.get("state")),
|
|
214
|
+
site_state=_str(body, "state"),
|
|
215
|
+
title=_str(body, "title"),
|
|
216
|
+
path=_str(body, "path"),
|
|
217
|
+
url=site_url(_str(body, "path")),
|
|
218
|
+
price=Price.from_api(body.get("price")),
|
|
219
|
+
expires_at=_when(body, "expiresAt"),
|
|
220
|
+
updated_at=_when(body, "updatedAt"),
|
|
221
|
+
thumbnail=_str(photos[0], "thumbnail") if photos else None,
|
|
222
|
+
raw=dict(body),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True)
|
|
227
|
+
class ItemPage:
|
|
228
|
+
items: tuple[Item, ...]
|
|
229
|
+
total: Optional[int]
|
|
230
|
+
next_cursor: Optional[str]
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def has_more(self) -> bool:
|
|
234
|
+
return self.next_cursor is not None
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def from_api(cls, body: Mapping[str, Any]) -> ItemPage:
|
|
238
|
+
info = _map(body.get("pageInfo"))
|
|
239
|
+
nodes = tuple(Item.from_api(n) for n in _list(body.get("nodes")) if isinstance(n, Mapping))
|
|
240
|
+
cursor = _str(info, "endCursor") if info.get("hasNextPage") else None
|
|
241
|
+
return cls(nodes, _int(body, "totalCount"), cursor)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class ItemResult:
|
|
246
|
+
id: Optional[str]
|
|
247
|
+
state: Optional[ItemState]
|
|
248
|
+
site_state: Optional[str] = None
|
|
249
|
+
path: Optional[str] = None
|
|
250
|
+
url: Optional[str] = None
|
|
251
|
+
status: Optional[str] = None
|
|
252
|
+
locked_until: Optional[str] = None
|
|
253
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def from_api(cls, body: Mapping[str, Any]) -> ItemResult:
|
|
257
|
+
item = _map(body.get("item"))
|
|
258
|
+
site_state = _str(item, "state")
|
|
259
|
+
return cls(
|
|
260
|
+
id=_str(item, "id"),
|
|
261
|
+
state=ItemState.from_site(site_state) if site_state else None,
|
|
262
|
+
site_state=site_state,
|
|
263
|
+
path=_str(item, "path"),
|
|
264
|
+
url=site_url(_str(item, "path")),
|
|
265
|
+
status=_str(body, "status"),
|
|
266
|
+
locked_until=_str(body, "lockedUntil"),
|
|
267
|
+
raw=dict(body),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class Photo:
|
|
273
|
+
id: str
|
|
274
|
+
thumbnail_url: Optional[str] = None
|
|
275
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def from_api(cls, body: Mapping[str, Any]) -> Photo:
|
|
279
|
+
return cls(_str(body, "id") or "", _str(body, "thumbnail_url", "thumbnailUrl", "thumbnail"), dict(body))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class Place:
|
|
284
|
+
id: str
|
|
285
|
+
name: str
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def from_api(cls, body: Mapping[str, Any]) -> Place:
|
|
289
|
+
return cls(_str(body, "id") or "", _str(body, "name") or "")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(frozen=True)
|
|
293
|
+
class CategoryNode:
|
|
294
|
+
id: str
|
|
295
|
+
name: str
|
|
296
|
+
children: tuple[Place, ...] = ()
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def from_api(cls, body: Mapping[str, Any]) -> CategoryNode:
|
|
300
|
+
kids = tuple(Place.from_api(c) for c in _list(body.get("children")) if isinstance(c, Mapping))
|
|
301
|
+
return cls(_str(body, "id") or "", _str(body, "name") or "", kids)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True)
|
|
305
|
+
class ServiceOption:
|
|
306
|
+
id: str
|
|
307
|
+
name: str
|
|
308
|
+
price: Optional[Decimal]
|
|
309
|
+
price_with_discount: Optional[Decimal]
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def from_api(cls, body: Mapping[str, Any]) -> ServiceOption:
|
|
313
|
+
return cls(_str(body, "id") or "", _str(body, "name") or "", _money(body.get("price")),
|
|
314
|
+
_money(body.get("priceWithDiscount")))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclass(frozen=True)
|
|
318
|
+
class PaymentMethod:
|
|
319
|
+
id: str
|
|
320
|
+
name: str
|
|
321
|
+
balance: Optional[Decimal]
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def from_api(cls, body: Mapping[str, Any]) -> PaymentMethod:
|
|
325
|
+
return cls(_str(body, "id") or "", _str(body, "name") or "", _money(body.get("balance")))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True)
|
|
329
|
+
class PaidService:
|
|
330
|
+
id: str
|
|
331
|
+
title: str
|
|
332
|
+
options: tuple[ServiceOption, ...] = ()
|
|
333
|
+
payment_methods: tuple[PaymentMethod, ...] = ()
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def from_api(cls, service: Mapping[str, Any], details: Mapping[str, Any]) -> PaidService:
|
|
337
|
+
options = tuple(ServiceOption.from_api(o) for o in _list(details.get("paidServiceOptions"))
|
|
338
|
+
if isinstance(o, Mapping))
|
|
339
|
+
methods = tuple(PaymentMethod.from_api(m) for m in _list(details.get("paymentMethods"))
|
|
340
|
+
if isinstance(m, Mapping))
|
|
341
|
+
return cls(_str(service, "id") or "", _str(service, "title") or "", options, methods)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass(frozen=True)
|
|
345
|
+
class Payment:
|
|
346
|
+
status: Optional[str]
|
|
347
|
+
redirect_url: Optional[str]
|
|
348
|
+
payment_id: Optional[str]
|
|
349
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
350
|
+
|
|
351
|
+
@classmethod
|
|
352
|
+
def from_api(cls, body: Mapping[str, Any]) -> Payment:
|
|
353
|
+
return cls(_str(body, "status"), _str(body, "redirectUrl"), _str(body, "paymentId"), dict(body))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(frozen=True)
|
|
357
|
+
class CodeSent:
|
|
358
|
+
phone: str
|
|
359
|
+
code_requests_left: Optional[int]
|
|
360
|
+
retried: bool = False
|
|
361
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def more_than_one_sms(self) -> bool:
|
|
365
|
+
return self.retried
|
binaaz_sdk/py.typed
ADDED
|
File without changes
|
binaaz_sdk/queries.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
PROFILE = (
|
|
4
|
+
"query Profile { profile { __typename "
|
|
5
|
+
"... on CompanyProfile { id name email phone contactName agency { name } } "
|
|
6
|
+
"... on Business { __typename ... on Agency { id name path } ... on Residence { id name path } } } }"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
ITEM_FIELDS = "id title state path expiresAt updatedAt price { total currency } photos { thumbnail }"
|
|
10
|
+
|
|
11
|
+
ITEM_EDIT = f"query ItemEdit($id: ID!) {{ itemEdit(itemId: $id) {{ item {{ {ITEM_FIELDS} }} }} }}"
|
|
12
|
+
|
|
13
|
+
MY_ITEMS = (
|
|
14
|
+
"query MyItems($state: ProfileItemTabStatesEnum, $first: Int, $after: String) { profile { __typename "
|
|
15
|
+
"... on CompanyProfile { items(state: $state, first: $first, after: $after) { totalCount "
|
|
16
|
+
f"pageInfo {{ hasNextPage endCursor }} nodes {{ {ITEM_FIELDS} }} }} }} }} }}"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_ERRORS = "errors { code message path }"
|
|
20
|
+
_ITEM_RESULT = f"item {{ id path state }} {_ERRORS}"
|
|
21
|
+
|
|
22
|
+
CREATE_ITEM = (f"mutation CreateItem($input: ItemCreateInput!) "
|
|
23
|
+
f"{{ createItem(input: $input) {{ status {_ITEM_RESULT} }} }}")
|
|
24
|
+
UPDATE_ITEM = (f"mutation UpdateItem($input: ItemUpdateInput!) "
|
|
25
|
+
f"{{ updateItem(input: $input) {{ status lockedUntil {_ITEM_RESULT} }} }}")
|
|
26
|
+
DELETE_ITEM = (f"mutation DeleteItem($input: ItemDeleteInput!) "
|
|
27
|
+
f"{{ deleteItem(input: $input) {{ item {{ id state }} {_ERRORS} }} }}")
|
|
28
|
+
PROLONG_ITEM = (f"mutation ProlongItem($input: ItemProlongInput!) "
|
|
29
|
+
f"{{ prolongItem(input: $input) {{ {_ITEM_RESULT} }} }}")
|
|
30
|
+
PAY_FOR_ITEM = (f"mutation PayForItem($input: ItemPayInput!) "
|
|
31
|
+
f"{{ payForItem(input: $input) {{ item {{ id state }} {_ERRORS} }} }}")
|
|
32
|
+
CREATE_PAYMENT = (f"mutation CreatePayment($input: PaymentCreateInput!) "
|
|
33
|
+
f"{{ createPayment(input: $input) {{ status redirectUrl paymentId {_ERRORS} }} }}")
|
|
34
|
+
|
|
35
|
+
PAID_SERVICES = "query PaidServices($ids: [ID!]!) { paidServices(targetIds: $ids, targetType: ITEM) { id title } }"
|
|
36
|
+
|
|
37
|
+
PAID_SERVICE_DETAILS = (
|
|
38
|
+
"query PaidServiceDetails($sid: ID!, $ids: [ID!]!) { "
|
|
39
|
+
"paidServiceOptions(paidServiceId: $sid, targetIds: $ids) "
|
|
40
|
+
"{ id name price priceWithDiscount(paymentMethodId: \"wallet\") } "
|
|
41
|
+
"paymentMethods(paidServiceId: $sid, targetIds: $ids, targetType: ITEM) { id name balance } }"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
SIGN_IN = (
|
|
45
|
+
"mutation SignIn($input: ProfileSignInInput!) { signIn(input: $input) { "
|
|
46
|
+
"profile { __typename ... on CompanyProfile { id name } } errors { message path } } }"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
CATEGORIES = "{ categories { id name children { id name } } }"
|
|
50
|
+
CITIES = "{ cities { id name } }"
|
|
51
|
+
REGIONS = "query($c: ID!) { regions(cityId: $c) { id name } }"
|
|
52
|
+
TOWNSHIPS = "query($r: ID!) { townships(regionId: $r) { id name } }"
|
|
53
|
+
LANDMARKS = ("query($c: ID!, $lat: Float!, $lng: Float!) "
|
|
54
|
+
"{ landmarks(cityId: $c, latitude: $lat, longitude: $lng) { id name } }")
|