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_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