pymlsapi 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.
- pymlsapi/__init__.py +103 -0
- pymlsapi/async_client.py +67 -0
- pymlsapi/async_http.py +104 -0
- pymlsapi/client.py +67 -0
- pymlsapi/config.py +58 -0
- pymlsapi/errors.py +95 -0
- pymlsapi/http.py +155 -0
- pymlsapi/models/__init__.py +100 -0
- pymlsapi/models/common.py +66 -0
- pymlsapi/models/content.py +92 -0
- pymlsapi/models/intelligence.py +65 -0
- pymlsapi/models/listings.py +91 -0
- pymlsapi/models/studio.py +171 -0
- pymlsapi/poller.py +102 -0
- pymlsapi/resources/__init__.py +20 -0
- pymlsapi/resources/account/__init__.py +38 -0
- pymlsapi/resources/account/billing.py +74 -0
- pymlsapi/resources/account/keys.py +46 -0
- pymlsapi/resources/content.py +77 -0
- pymlsapi/resources/intelligence.py +47 -0
- pymlsapi/resources/listings.py +179 -0
- pymlsapi/resources/studio/__init__.py +56 -0
- pymlsapi/resources/studio/creatives.py +124 -0
- pymlsapi/resources/studio/custom.py +100 -0
- pymlsapi/resources/studio/enhance.py +170 -0
- pymlsapi/resources/studio/floorplan.py +166 -0
- pymlsapi/resources/studio/jobs.py +86 -0
- pymlsapi/resources/studio/render.py +96 -0
- pymlsapi/resources/studio/social.py +57 -0
- pymlsapi/resources/studio/staging.py +694 -0
- pymlsapi/resources/studio/upload.py +93 -0
- pymlsapi/resources/studio/video.py +346 -0
- pymlsapi-0.1.0.dist-info/METADATA +570 -0
- pymlsapi-0.1.0.dist-info/RECORD +35 -0
- pymlsapi-0.1.0.dist-info/WHEEL +4 -0
pymlsapi/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pymlsapi.async_client import AsyncMLS, AsyncMlsApiClient
|
|
4
|
+
from pymlsapi.client import MLS, MlsApiClient
|
|
5
|
+
from pymlsapi.config import ClientConfig
|
|
6
|
+
from pymlsapi.errors import (
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
InsufficientCreditsError,
|
|
9
|
+
InvalidRequestError,
|
|
10
|
+
JobTimeoutError,
|
|
11
|
+
MlsApiError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
StudioJobFailedError,
|
|
16
|
+
)
|
|
17
|
+
from pymlsapi.models import (
|
|
18
|
+
AdCreativesResult,
|
|
19
|
+
Address,
|
|
20
|
+
ArchitecturalRenderResult,
|
|
21
|
+
BaseListing,
|
|
22
|
+
ContentGenerationResponse,
|
|
23
|
+
Coordinates,
|
|
24
|
+
CustomStudioResult,
|
|
25
|
+
DeclutterResult,
|
|
26
|
+
DeStageEmptyResult,
|
|
27
|
+
ExteriorEnhanceResult,
|
|
28
|
+
FloorPlanAnalysisResponse,
|
|
29
|
+
HouseTourResult,
|
|
30
|
+
IngestJob,
|
|
31
|
+
InteriorStyle,
|
|
32
|
+
JobStatus,
|
|
33
|
+
PropertyIntelligence,
|
|
34
|
+
PropertySpecifications,
|
|
35
|
+
Render3dResult,
|
|
36
|
+
ReplaceFurnitureResult,
|
|
37
|
+
ReplaceMaterialResult,
|
|
38
|
+
RestyleResult,
|
|
39
|
+
RoomType,
|
|
40
|
+
SocialPlatform,
|
|
41
|
+
SocialPublishResult,
|
|
42
|
+
StagingResult,
|
|
43
|
+
StudioJob,
|
|
44
|
+
TwilightResult,
|
|
45
|
+
UploadResult,
|
|
46
|
+
UpscaleResult,
|
|
47
|
+
VideoEnhanceResult,
|
|
48
|
+
VideoTransitionResult,
|
|
49
|
+
VideoWalkthroughResult,
|
|
50
|
+
WallColorsResult,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"MLS",
|
|
57
|
+
"AdCreativesResult",
|
|
58
|
+
"Address",
|
|
59
|
+
"ArchitecturalRenderResult",
|
|
60
|
+
"AsyncMLS",
|
|
61
|
+
"AsyncMlsApiClient",
|
|
62
|
+
"AuthenticationError",
|
|
63
|
+
"BaseListing",
|
|
64
|
+
"ClientConfig",
|
|
65
|
+
"ContentGenerationResponse",
|
|
66
|
+
"Coordinates",
|
|
67
|
+
"CustomStudioResult",
|
|
68
|
+
"DeStageEmptyResult",
|
|
69
|
+
"DeclutterResult",
|
|
70
|
+
"ExteriorEnhanceResult",
|
|
71
|
+
"FloorPlanAnalysisResponse",
|
|
72
|
+
"HouseTourResult",
|
|
73
|
+
"IngestJob",
|
|
74
|
+
"InsufficientCreditsError",
|
|
75
|
+
"InteriorStyle",
|
|
76
|
+
"InvalidRequestError",
|
|
77
|
+
"JobStatus",
|
|
78
|
+
"JobTimeoutError",
|
|
79
|
+
"MlsApiClient",
|
|
80
|
+
"MlsApiError",
|
|
81
|
+
"NotFoundError",
|
|
82
|
+
"PermissionDeniedError",
|
|
83
|
+
"PropertyIntelligence",
|
|
84
|
+
"PropertySpecifications",
|
|
85
|
+
"RateLimitError",
|
|
86
|
+
"Render3dResult",
|
|
87
|
+
"ReplaceFurnitureResult",
|
|
88
|
+
"ReplaceMaterialResult",
|
|
89
|
+
"RestyleResult",
|
|
90
|
+
"RoomType",
|
|
91
|
+
"SocialPlatform",
|
|
92
|
+
"SocialPublishResult",
|
|
93
|
+
"StagingResult",
|
|
94
|
+
"StudioJob",
|
|
95
|
+
"StudioJobFailedError",
|
|
96
|
+
"TwilightResult",
|
|
97
|
+
"UploadResult",
|
|
98
|
+
"UpscaleResult",
|
|
99
|
+
"VideoEnhanceResult",
|
|
100
|
+
"VideoTransitionResult",
|
|
101
|
+
"VideoWalkthroughResult",
|
|
102
|
+
"WallColorsResult",
|
|
103
|
+
]
|
pymlsapi/async_client.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
6
|
+
from pymlsapi.config import (
|
|
7
|
+
DEFAULT_JOB_TIMEOUT_SECONDS,
|
|
8
|
+
DEFAULT_MAX_RETRIES,
|
|
9
|
+
DEFAULT_POLL_INTERVAL_SECONDS,
|
|
10
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
11
|
+
ClientConfig,
|
|
12
|
+
)
|
|
13
|
+
from pymlsapi.resources.account import AsyncAccountResource
|
|
14
|
+
from pymlsapi.resources.content import AsyncContentResource
|
|
15
|
+
from pymlsapi.resources.intelligence import AsyncIntelligenceResource
|
|
16
|
+
from pymlsapi.resources.listings import AsyncListingsResource
|
|
17
|
+
from pymlsapi.resources.studio import AsyncStudioResource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyncMlsApiClient:
|
|
21
|
+
"""Official asynchronous Python client for mlsapi.dev.
|
|
22
|
+
|
|
23
|
+
High-concurrency asyncio client for real-time MLS listings, property intelligence,
|
|
24
|
+
marketing copy, and the complete suite of Studio Visual AI generative tools.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: Optional[str] = None,
|
|
30
|
+
environment: str = "live",
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
33
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
34
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
35
|
+
job_timeout_seconds: float = DEFAULT_JOB_TIMEOUT_SECONDS,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.config = ClientConfig.create(
|
|
38
|
+
api_key=api_key,
|
|
39
|
+
environment=environment,
|
|
40
|
+
base_url=base_url,
|
|
41
|
+
timeout_seconds=timeout_seconds,
|
|
42
|
+
max_retries=max_retries,
|
|
43
|
+
poll_interval=poll_interval,
|
|
44
|
+
job_timeout_seconds=job_timeout_seconds,
|
|
45
|
+
)
|
|
46
|
+
self._http = AsyncHttpClient(self.config)
|
|
47
|
+
|
|
48
|
+
# Namespaced resources
|
|
49
|
+
self.listings = AsyncListingsResource(self._http)
|
|
50
|
+
self.intelligence = AsyncIntelligenceResource(self._http)
|
|
51
|
+
self.content = AsyncContentResource(self._http)
|
|
52
|
+
self.studio = AsyncStudioResource(self._http)
|
|
53
|
+
self.account = AsyncAccountResource(self._http)
|
|
54
|
+
|
|
55
|
+
async def close(self) -> None:
|
|
56
|
+
"""Close the underlying HTTP client transport."""
|
|
57
|
+
await self._http.aclose()
|
|
58
|
+
|
|
59
|
+
async def __aenter__(self) -> AsyncMlsApiClient:
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
async def __aexit__(self, *args: object) -> None:
|
|
63
|
+
await self.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Convenient alias
|
|
67
|
+
AsyncMLS = AsyncMlsApiClient
|
pymlsapi/async_http.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from pymlsapi.config import ClientConfig
|
|
10
|
+
from pymlsapi.errors import MlsApiError
|
|
11
|
+
from pymlsapi.http import _handle_error_response
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncHttpClient:
|
|
15
|
+
"""Asynchronous HTTP transport engine with automatic retries and exponential backoff."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
18
|
+
self.config = config
|
|
19
|
+
headers = {
|
|
20
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
21
|
+
"x-api-key": config.api_key,
|
|
22
|
+
"User-Agent": "pymlsapi/0.1.0",
|
|
23
|
+
"Accept": "application/json",
|
|
24
|
+
}
|
|
25
|
+
self._client = httpx.AsyncClient(
|
|
26
|
+
base_url=config.base_url,
|
|
27
|
+
headers=headers,
|
|
28
|
+
timeout=config.timeout_seconds,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
async def aclose(self) -> None:
|
|
32
|
+
await self._client.aclose()
|
|
33
|
+
|
|
34
|
+
async def __aenter__(self) -> AsyncHttpClient:
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
async def __aexit__(self, *args: object) -> None:
|
|
38
|
+
await self.aclose()
|
|
39
|
+
|
|
40
|
+
async def request(
|
|
41
|
+
self,
|
|
42
|
+
method: str,
|
|
43
|
+
path: str,
|
|
44
|
+
*,
|
|
45
|
+
params: Optional[Dict[str, Any]] = None,
|
|
46
|
+
json: Optional[Any] = None,
|
|
47
|
+
files: Optional[Dict[str, Any]] = None,
|
|
48
|
+
data: Optional[Dict[str, Any]] = None,
|
|
49
|
+
) -> Any:
|
|
50
|
+
url = path if path.startswith("http") else path.lstrip("/")
|
|
51
|
+
attempts = 0
|
|
52
|
+
max_retries = self.config.max_retries
|
|
53
|
+
|
|
54
|
+
while True:
|
|
55
|
+
attempts += 1
|
|
56
|
+
try:
|
|
57
|
+
response = await self._client.request(
|
|
58
|
+
method=method,
|
|
59
|
+
url=url,
|
|
60
|
+
params=params,
|
|
61
|
+
json=json,
|
|
62
|
+
files=files,
|
|
63
|
+
data=data,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if response.status_code in (429, 500, 502, 503, 504) and attempts <= max_retries:
|
|
67
|
+
backoff = (2 ** (attempts - 1)) * 0.5 + random.uniform(0.1, 0.4)
|
|
68
|
+
await asyncio.sleep(backoff)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
if response.is_error:
|
|
72
|
+
_handle_error_response(response)
|
|
73
|
+
|
|
74
|
+
if response.status_code == 204:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
return response.json()
|
|
78
|
+
|
|
79
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
|
|
80
|
+
if attempts <= max_retries:
|
|
81
|
+
backoff = (2 ** (attempts - 1)) * 0.5 + random.uniform(0.1, 0.4)
|
|
82
|
+
await asyncio.sleep(backoff)
|
|
83
|
+
continue
|
|
84
|
+
raise MlsApiError(f"Network error during {method} {url}: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
async def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
87
|
+
return await self.request("GET", path, params=params)
|
|
88
|
+
|
|
89
|
+
async def post(
|
|
90
|
+
self,
|
|
91
|
+
path: str,
|
|
92
|
+
*,
|
|
93
|
+
json: Optional[Any] = None,
|
|
94
|
+
files: Optional[Dict[str, Any]] = None,
|
|
95
|
+
data: Optional[Dict[str, Any]] = None,
|
|
96
|
+
params: Optional[Dict[str, Any]] = None,
|
|
97
|
+
) -> Any:
|
|
98
|
+
return await self.request("POST", path, json=json, files=files, data=data, params=params)
|
|
99
|
+
|
|
100
|
+
async def put(self, path: str, *, json: Optional[Any] = None) -> Any:
|
|
101
|
+
return await self.request("PUT", path, json=json)
|
|
102
|
+
|
|
103
|
+
async def delete(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
104
|
+
return await self.request("DELETE", path, params=params)
|
pymlsapi/client.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pymlsapi.config import (
|
|
6
|
+
DEFAULT_JOB_TIMEOUT_SECONDS,
|
|
7
|
+
DEFAULT_MAX_RETRIES,
|
|
8
|
+
DEFAULT_POLL_INTERVAL_SECONDS,
|
|
9
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
10
|
+
ClientConfig,
|
|
11
|
+
)
|
|
12
|
+
from pymlsapi.http import HttpClient
|
|
13
|
+
from pymlsapi.resources.account import AccountResource
|
|
14
|
+
from pymlsapi.resources.content import ContentResource
|
|
15
|
+
from pymlsapi.resources.intelligence import IntelligenceResource
|
|
16
|
+
from pymlsapi.resources.listings import ListingsResource
|
|
17
|
+
from pymlsapi.resources.studio import StudioResource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MlsApiClient:
|
|
21
|
+
"""Official synchronous Python client for mlsapi.dev.
|
|
22
|
+
|
|
23
|
+
Access real-time MLS listings, property intelligence, marketing copy,
|
|
24
|
+
and the complete suite of Studio Visual AI generative tools.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: Optional[str] = None,
|
|
30
|
+
environment: str = "live",
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
33
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
34
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
35
|
+
job_timeout_seconds: float = DEFAULT_JOB_TIMEOUT_SECONDS,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.config = ClientConfig.create(
|
|
38
|
+
api_key=api_key,
|
|
39
|
+
environment=environment,
|
|
40
|
+
base_url=base_url,
|
|
41
|
+
timeout_seconds=timeout_seconds,
|
|
42
|
+
max_retries=max_retries,
|
|
43
|
+
poll_interval=poll_interval,
|
|
44
|
+
job_timeout_seconds=job_timeout_seconds,
|
|
45
|
+
)
|
|
46
|
+
self._http = HttpClient(self.config)
|
|
47
|
+
|
|
48
|
+
# Namespaced resources
|
|
49
|
+
self.listings = ListingsResource(self._http)
|
|
50
|
+
self.intelligence = IntelligenceResource(self._http)
|
|
51
|
+
self.content = ContentResource(self._http)
|
|
52
|
+
self.studio = StudioResource(self._http)
|
|
53
|
+
self.account = AccountResource(self._http)
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Close the underlying HTTP client transport."""
|
|
57
|
+
self._http.close()
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> MlsApiClient:
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, *args: object) -> None:
|
|
63
|
+
self.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Convenient alias
|
|
67
|
+
MLS = MlsApiClient
|
pymlsapi/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
DEFAULT_LIVE_URL = "https://mlsapi.dev"
|
|
8
|
+
DEFAULT_TEST_URL = "https://api.mlsapi.dev"
|
|
9
|
+
DEFAULT_TIMEOUT_SECONDS = 60.0
|
|
10
|
+
DEFAULT_MAX_RETRIES = 3
|
|
11
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 2.0
|
|
12
|
+
DEFAULT_JOB_TIMEOUT_SECONDS = 90.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ClientConfig:
|
|
17
|
+
"""Configuration options for the MLS API Client."""
|
|
18
|
+
|
|
19
|
+
api_key: str
|
|
20
|
+
environment: str = "live"
|
|
21
|
+
base_url: str = DEFAULT_LIVE_URL
|
|
22
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
|
23
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
24
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS
|
|
25
|
+
job_timeout_seconds: float = DEFAULT_JOB_TIMEOUT_SECONDS
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def create(
|
|
29
|
+
cls,
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
environment: str = "live",
|
|
32
|
+
base_url: Optional[str] = None,
|
|
33
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
34
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
35
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
36
|
+
job_timeout_seconds: float = DEFAULT_JOB_TIMEOUT_SECONDS,
|
|
37
|
+
) -> ClientConfig:
|
|
38
|
+
resolved_key = api_key or os.environ.get("MLSAPI_KEY")
|
|
39
|
+
if not resolved_key:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"MLS API key is required. Pass `api_key` to client or set MLSAPI_KEY environment variable."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if base_url is None:
|
|
45
|
+
base_url = DEFAULT_LIVE_URL if environment == "live" else DEFAULT_TEST_URL
|
|
46
|
+
|
|
47
|
+
# Strip trailing slashes
|
|
48
|
+
base_url = base_url.rstrip("/")
|
|
49
|
+
|
|
50
|
+
return cls(
|
|
51
|
+
api_key=resolved_key,
|
|
52
|
+
environment=environment,
|
|
53
|
+
base_url=base_url,
|
|
54
|
+
timeout_seconds=timeout_seconds,
|
|
55
|
+
max_retries=max_retries,
|
|
56
|
+
poll_interval=poll_interval,
|
|
57
|
+
job_timeout_seconds=job_timeout_seconds,
|
|
58
|
+
)
|
pymlsapi/errors.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MlsApiError(Exception):
|
|
7
|
+
"""Base exception for all mlsapi.dev SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
status_code: Optional[int] = None,
|
|
13
|
+
code: Optional[str] = None,
|
|
14
|
+
raw_response: Optional[Any] = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.message = message
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.code = code or "UNKNOWN_ERROR"
|
|
20
|
+
self.raw_response = raw_response
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
parts = []
|
|
24
|
+
if self.status_code:
|
|
25
|
+
parts.append(f"HTTP {self.status_code}")
|
|
26
|
+
if self.code and self.code != "UNKNOWN_ERROR":
|
|
27
|
+
parts.append(f"[{self.code}]")
|
|
28
|
+
parts.append(self.message)
|
|
29
|
+
return " - ".join(parts)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthenticationError(MlsApiError):
|
|
33
|
+
"""Raised on HTTP 401 when the provided API key is invalid, missing, or revoked."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PermissionDeniedError(MlsApiError):
|
|
37
|
+
"""Raised on HTTP 403 when the API key lacks necessary permissions or plan tier."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotFoundError(MlsApiError):
|
|
41
|
+
"""Raised on HTTP 404 when the requested MLS listing, job, or resource is not found."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InvalidRequestError(MlsApiError):
|
|
45
|
+
"""Raised on HTTP 400 when request parameters are malformed or missing."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InsufficientCreditsError(MlsApiError):
|
|
49
|
+
"""Raised on HTTP 402 when workspace credit balance is insufficient for the requested action."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RateLimitError(MlsApiError):
|
|
53
|
+
"""Raised on HTTP 429 when concurrency or rate limits have been exceeded."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
message: str,
|
|
58
|
+
status_code: int = 429,
|
|
59
|
+
code: str = "RATE_LIMIT_EXCEEDED",
|
|
60
|
+
retry_after_seconds: Optional[float] = None,
|
|
61
|
+
raw_response: Optional[Any] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(message, status_code, code, raw_response)
|
|
64
|
+
self.retry_after_seconds = retry_after_seconds
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class StudioJobFailedError(MlsApiError):
|
|
68
|
+
"""Raised when an asynchronous Studio AI job completes with a failed status."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
job_id: str,
|
|
73
|
+
message: str,
|
|
74
|
+
error_details: Optional[Any] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(
|
|
77
|
+
f"Studio job '{job_id}' failed: {message}",
|
|
78
|
+
status_code=500,
|
|
79
|
+
code="JOB_FAILED",
|
|
80
|
+
raw_response=error_details,
|
|
81
|
+
)
|
|
82
|
+
self.job_id = job_id
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class JobTimeoutError(MlsApiError):
|
|
86
|
+
"""Raised when polling for an asynchronous job exceeds the configured timeout."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, job_id: str, timeout_seconds: float) -> None:
|
|
89
|
+
super().__init__(
|
|
90
|
+
f"Job '{job_id}' did not complete within {timeout_seconds:.1f} seconds",
|
|
91
|
+
status_code=408,
|
|
92
|
+
code="JOB_TIMEOUT",
|
|
93
|
+
)
|
|
94
|
+
self.job_id = job_id
|
|
95
|
+
self.timeout_seconds = timeout_seconds
|
pymlsapi/http.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from pymlsapi.config import ClientConfig
|
|
10
|
+
from pymlsapi.errors import (
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
InsufficientCreditsError,
|
|
13
|
+
InvalidRequestError,
|
|
14
|
+
MlsApiError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
PermissionDeniedError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _handle_error_response(response: httpx.Response) -> None:
|
|
22
|
+
status = response.status_code
|
|
23
|
+
try:
|
|
24
|
+
body = response.json()
|
|
25
|
+
except Exception:
|
|
26
|
+
body = {"message": response.text}
|
|
27
|
+
|
|
28
|
+
msg = body.get("error", {})
|
|
29
|
+
if isinstance(msg, dict):
|
|
30
|
+
message = msg.get("message") or str(body)
|
|
31
|
+
code = msg.get("code", "API_ERROR")
|
|
32
|
+
else:
|
|
33
|
+
message = str(msg) if msg else body.get("message", response.text or "Unknown error")
|
|
34
|
+
code = body.get("code", "API_ERROR")
|
|
35
|
+
|
|
36
|
+
if status == 401:
|
|
37
|
+
raise AuthenticationError(message, status_code=status, code=code, raw_response=body)
|
|
38
|
+
elif status == 402:
|
|
39
|
+
raise InsufficientCreditsError(message, status_code=status, code=code, raw_response=body)
|
|
40
|
+
elif status == 403:
|
|
41
|
+
raise PermissionDeniedError(message, status_code=status, code=code, raw_response=body)
|
|
42
|
+
elif status == 404:
|
|
43
|
+
raise NotFoundError(message, status_code=status, code=code, raw_response=body)
|
|
44
|
+
elif status == 400:
|
|
45
|
+
raise InvalidRequestError(message, status_code=status, code=code, raw_response=body)
|
|
46
|
+
elif status == 429:
|
|
47
|
+
retry_after = None
|
|
48
|
+
if "retry-after" in response.headers:
|
|
49
|
+
try:
|
|
50
|
+
retry_after = float(response.headers["retry-after"])
|
|
51
|
+
except ValueError:
|
|
52
|
+
pass
|
|
53
|
+
raise RateLimitError(
|
|
54
|
+
message,
|
|
55
|
+
status_code=status,
|
|
56
|
+
code=code,
|
|
57
|
+
retry_after_seconds=retry_after,
|
|
58
|
+
raw_response=body,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
raise MlsApiError(message, status_code=status, code=code, raw_response=body)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HttpClient:
|
|
65
|
+
"""Synchronous HTTP transport engine with automatic retries and exponential backoff."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
68
|
+
self.config = config
|
|
69
|
+
headers = {
|
|
70
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
71
|
+
"x-api-key": config.api_key,
|
|
72
|
+
"User-Agent": "pymlsapi/0.1.0",
|
|
73
|
+
"Accept": "application/json",
|
|
74
|
+
}
|
|
75
|
+
self._client = httpx.Client(
|
|
76
|
+
base_url=config.base_url,
|
|
77
|
+
headers=headers,
|
|
78
|
+
timeout=config.timeout_seconds,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
self._client.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> HttpClient:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *args: object) -> None:
|
|
88
|
+
self.close()
|
|
89
|
+
|
|
90
|
+
def request(
|
|
91
|
+
self,
|
|
92
|
+
method: str,
|
|
93
|
+
path: str,
|
|
94
|
+
*,
|
|
95
|
+
params: Optional[Dict[str, Any]] = None,
|
|
96
|
+
json: Optional[Any] = None,
|
|
97
|
+
files: Optional[Dict[str, Any]] = None,
|
|
98
|
+
data: Optional[Dict[str, Any]] = None,
|
|
99
|
+
) -> Any:
|
|
100
|
+
url = path if path.startswith("http") else path.lstrip("/")
|
|
101
|
+
attempts = 0
|
|
102
|
+
max_retries = self.config.max_retries
|
|
103
|
+
|
|
104
|
+
while True:
|
|
105
|
+
attempts += 1
|
|
106
|
+
try:
|
|
107
|
+
response = self._client.request(
|
|
108
|
+
method=method,
|
|
109
|
+
url=url,
|
|
110
|
+
params=params,
|
|
111
|
+
json=json,
|
|
112
|
+
files=files,
|
|
113
|
+
data=data,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if response.status_code in (429, 500, 502, 503, 504) and attempts <= max_retries:
|
|
117
|
+
# Exponential backoff with jitter
|
|
118
|
+
backoff = (2 ** (attempts - 1)) * 0.5 + random.uniform(0.1, 0.4)
|
|
119
|
+
time.sleep(backoff)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if response.is_error:
|
|
123
|
+
_handle_error_response(response)
|
|
124
|
+
|
|
125
|
+
if response.status_code == 204:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
return response.json()
|
|
129
|
+
|
|
130
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
|
|
131
|
+
if attempts <= max_retries:
|
|
132
|
+
backoff = (2 ** (attempts - 1)) * 0.5 + random.uniform(0.1, 0.4)
|
|
133
|
+
time.sleep(backoff)
|
|
134
|
+
continue
|
|
135
|
+
raise MlsApiError(f"Network error during {method} {url}: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
138
|
+
return self.request("GET", path, params=params)
|
|
139
|
+
|
|
140
|
+
def post(
|
|
141
|
+
self,
|
|
142
|
+
path: str,
|
|
143
|
+
*,
|
|
144
|
+
json: Optional[Any] = None,
|
|
145
|
+
files: Optional[Dict[str, Any]] = None,
|
|
146
|
+
data: Optional[Dict[str, Any]] = None,
|
|
147
|
+
params: Optional[Dict[str, Any]] = None,
|
|
148
|
+
) -> Any:
|
|
149
|
+
return self.request("POST", path, json=json, files=files, data=data, params=params)
|
|
150
|
+
|
|
151
|
+
def put(self, path: str, *, json: Optional[Any] = None) -> Any:
|
|
152
|
+
return self.request("PUT", path, json=json)
|
|
153
|
+
|
|
154
|
+
def delete(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
155
|
+
return self.request("DELETE", path, params=params)
|