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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
4
|
+
from pymlsapi.http import HttpClient
|
|
5
|
+
from pymlsapi.resources.account.billing import AsyncBillingResource, BillingResource
|
|
6
|
+
from pymlsapi.resources.account.keys import AsyncKeysResource, KeysResource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AccountResource:
|
|
10
|
+
"""Synchronous Account, Billing and Keys namespace."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, http: HttpClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
self.billing = BillingResource(http)
|
|
15
|
+
self.keys = KeysResource(http)
|
|
16
|
+
|
|
17
|
+
def get_billing_overview(self):
|
|
18
|
+
return self.billing.get_overview()
|
|
19
|
+
|
|
20
|
+
def verify_key(self):
|
|
21
|
+
keys = self.keys.list()
|
|
22
|
+
return keys[0] if keys else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncAccountResource:
|
|
26
|
+
"""Asynchronous Account, Billing and Keys namespace."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
29
|
+
self._http = http
|
|
30
|
+
self.billing = AsyncBillingResource(http)
|
|
31
|
+
self.keys = AsyncKeysResource(http)
|
|
32
|
+
|
|
33
|
+
async def get_billing_overview(self):
|
|
34
|
+
return await self.billing.get_overview()
|
|
35
|
+
|
|
36
|
+
async def verify_key(self):
|
|
37
|
+
keys = await self.keys.list()
|
|
38
|
+
return keys[0] if keys else None
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
6
|
+
from pymlsapi.http import HttpClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BillingResource:
|
|
10
|
+
"""Synchronous billing and credit operations."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, http: HttpClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
|
|
15
|
+
def get_overview(self) -> Dict[str, Any]:
|
|
16
|
+
"""Fetch subscription tier, credit allowances, and usage breakdown."""
|
|
17
|
+
return self._http.get("/api/billing/overview")
|
|
18
|
+
|
|
19
|
+
def create_checkout_session(
|
|
20
|
+
self,
|
|
21
|
+
plan_id: Optional[str] = None,
|
|
22
|
+
topup_credits: Optional[int] = None,
|
|
23
|
+
success_url: Optional[str] = None,
|
|
24
|
+
cancel_url: Optional[str] = None,
|
|
25
|
+
) -> Dict[str, Any]:
|
|
26
|
+
"""Create a Stripe checkout session for plan upgrade or top-up credits."""
|
|
27
|
+
payload: Dict[str, Any] = {}
|
|
28
|
+
if plan_id:
|
|
29
|
+
payload["planId"] = plan_id
|
|
30
|
+
if topup_credits:
|
|
31
|
+
payload["topupCredits"] = topup_credits
|
|
32
|
+
if success_url:
|
|
33
|
+
payload["successUrl"] = success_url
|
|
34
|
+
if cancel_url:
|
|
35
|
+
payload["cancelUrl"] = cancel_url
|
|
36
|
+
return self._http.post("/api/billing/checkout", json=payload)
|
|
37
|
+
|
|
38
|
+
def create_portal_session(self) -> Dict[str, Any]:
|
|
39
|
+
"""Generate self-service Stripe Customer Portal URL."""
|
|
40
|
+
return self._http.post("/api/billing/portal")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AsyncBillingResource:
|
|
44
|
+
"""Asynchronous billing and credit operations."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
47
|
+
self._http = http
|
|
48
|
+
|
|
49
|
+
async def get_overview(self) -> Dict[str, Any]:
|
|
50
|
+
"""Fetch subscription tier, credit allowances, and usage breakdown."""
|
|
51
|
+
return await self._http.get("/api/billing/overview")
|
|
52
|
+
|
|
53
|
+
async def create_checkout_session(
|
|
54
|
+
self,
|
|
55
|
+
plan_id: Optional[str] = None,
|
|
56
|
+
topup_credits: Optional[int] = None,
|
|
57
|
+
success_url: Optional[str] = None,
|
|
58
|
+
cancel_url: Optional[str] = None,
|
|
59
|
+
) -> Dict[str, Any]:
|
|
60
|
+
"""Create a Stripe checkout session for plan upgrade or top-up credits."""
|
|
61
|
+
payload: Dict[str, Any] = {}
|
|
62
|
+
if plan_id:
|
|
63
|
+
payload["planId"] = plan_id
|
|
64
|
+
if topup_credits:
|
|
65
|
+
payload["topupCredits"] = topup_credits
|
|
66
|
+
if success_url:
|
|
67
|
+
payload["successUrl"] = success_url
|
|
68
|
+
if cancel_url:
|
|
69
|
+
payload["cancelUrl"] = cancel_url
|
|
70
|
+
return await self._http.post("/api/billing/checkout", json=payload)
|
|
71
|
+
|
|
72
|
+
async def create_portal_session(self) -> Dict[str, Any]:
|
|
73
|
+
"""Generate self-service Stripe Customer Portal URL."""
|
|
74
|
+
return await self._http.post("/api/billing/portal")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
6
|
+
from pymlsapi.http import HttpClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KeysResource:
|
|
10
|
+
"""Synchronous API key lifecycle and zero-downtime rotation."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, http: HttpClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
|
|
15
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
16
|
+
"""List active and grace-period expiring API keys."""
|
|
17
|
+
data = self._http.get("/api/keys")
|
|
18
|
+
return data.get("keys", [])
|
|
19
|
+
|
|
20
|
+
def create(self, name: str, env: str = "test") -> Dict[str, Any]:
|
|
21
|
+
"""Create a new live or test API key."""
|
|
22
|
+
return self._http.post("/api/keys", json={"name": name, "env": env})
|
|
23
|
+
|
|
24
|
+
def rotate(self, key_id: str, grace_hours: int = 24) -> Dict[str, Any]:
|
|
25
|
+
"""Rotate key with a 24-hour grace overlap window."""
|
|
26
|
+
return self._http.post(f"/api/keys/{key_id}/rotate", json={"graceHours": grace_hours})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncKeysResource:
|
|
30
|
+
"""Asynchronous API key lifecycle and zero-downtime rotation."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
33
|
+
self._http = http
|
|
34
|
+
|
|
35
|
+
async def list(self) -> List[Dict[str, Any]]:
|
|
36
|
+
"""List active and grace-period expiring API keys."""
|
|
37
|
+
data = await self._http.get("/api/keys")
|
|
38
|
+
return data.get("keys", [])
|
|
39
|
+
|
|
40
|
+
async def create(self, name: str, env: str = "test") -> Dict[str, Any]:
|
|
41
|
+
"""Create a new live or test API key."""
|
|
42
|
+
return await self._http.post("/api/keys", json={"name": name, "env": env})
|
|
43
|
+
|
|
44
|
+
async def rotate(self, key_id: str, grace_hours: int = 24) -> Dict[str, Any]:
|
|
45
|
+
"""Rotate key with a 24-hour grace overlap window."""
|
|
46
|
+
return await self._http.post(f"/api/keys/{key_id}/rotate", json={"graceHours": grace_hours})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
6
|
+
from pymlsapi.http import HttpClient
|
|
7
|
+
from pymlsapi.models.content import ContentGenerationResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContentResource:
|
|
11
|
+
"""Synchronous context-aware marketing copy generation operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, http: HttpClient) -> None:
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def generate(
|
|
17
|
+
self,
|
|
18
|
+
mls_id: str,
|
|
19
|
+
outputs: Optional[List[str]] = None,
|
|
20
|
+
tone: str = "luxury",
|
|
21
|
+
social_platforms: Optional[List[str]] = None,
|
|
22
|
+
target_audience: Optional[str] = None,
|
|
23
|
+
custom_notes: Optional[str] = None,
|
|
24
|
+
property_details: Optional[Dict[str, Any]] = None,
|
|
25
|
+
) -> ContentGenerationResponse:
|
|
26
|
+
"""Generate context-aware marketing copy across social channels, video scripts, and email."""
|
|
27
|
+
payload: Dict[str, Any] = {
|
|
28
|
+
"outputs": outputs
|
|
29
|
+
or ["social", "email_blast", "video_script", "flyer_bullets", "mls_remarks"],
|
|
30
|
+
"tone": tone,
|
|
31
|
+
}
|
|
32
|
+
if social_platforms:
|
|
33
|
+
payload["social_platforms"] = social_platforms
|
|
34
|
+
if target_audience:
|
|
35
|
+
payload["target_audience"] = target_audience
|
|
36
|
+
if custom_notes:
|
|
37
|
+
payload["custom_notes"] = custom_notes
|
|
38
|
+
if property_details:
|
|
39
|
+
payload["property_details"] = property_details
|
|
40
|
+
|
|
41
|
+
data = self._http.post(f"/v1/listing/{mls_id}/content", json=payload)
|
|
42
|
+
return ContentGenerationResponse.model_validate(data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AsyncContentResource:
|
|
46
|
+
"""Asynchronous context-aware marketing copy generation operations."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
49
|
+
self._http = http
|
|
50
|
+
|
|
51
|
+
async def generate(
|
|
52
|
+
self,
|
|
53
|
+
mls_id: str,
|
|
54
|
+
outputs: Optional[List[str]] = None,
|
|
55
|
+
tone: str = "luxury",
|
|
56
|
+
social_platforms: Optional[List[str]] = None,
|
|
57
|
+
target_audience: Optional[str] = None,
|
|
58
|
+
custom_notes: Optional[str] = None,
|
|
59
|
+
property_details: Optional[Dict[str, Any]] = None,
|
|
60
|
+
) -> ContentGenerationResponse:
|
|
61
|
+
"""Generate context-aware marketing copy across social channels, video scripts, and email."""
|
|
62
|
+
payload: Dict[str, Any] = {
|
|
63
|
+
"outputs": outputs
|
|
64
|
+
or ["social", "email_blast", "video_script", "flyer_bullets", "mls_remarks"],
|
|
65
|
+
"tone": tone,
|
|
66
|
+
}
|
|
67
|
+
if social_platforms:
|
|
68
|
+
payload["social_platforms"] = social_platforms
|
|
69
|
+
if target_audience:
|
|
70
|
+
payload["target_audience"] = target_audience
|
|
71
|
+
if custom_notes:
|
|
72
|
+
payload["custom_notes"] = custom_notes
|
|
73
|
+
if property_details:
|
|
74
|
+
payload["property_details"] = property_details
|
|
75
|
+
|
|
76
|
+
data = await self._http.post(f"/v1/listing/{mls_id}/content", json=payload)
|
|
77
|
+
return ContentGenerationResponse.model_validate(data)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
4
|
+
from pymlsapi.http import HttpClient
|
|
5
|
+
from pymlsapi.models.intelligence import PropertyIntelligence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IntelligenceResource:
|
|
9
|
+
"""Synchronous property intelligence and CapEx operations."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, http: HttpClient) -> None:
|
|
12
|
+
self._http = http
|
|
13
|
+
|
|
14
|
+
def get(
|
|
15
|
+
self,
|
|
16
|
+
mls_id: str,
|
|
17
|
+
include_llm: bool = True,
|
|
18
|
+
investor_mode: bool = False,
|
|
19
|
+
) -> PropertyIntelligence:
|
|
20
|
+
"""Synthesize public records, tax assessment history, and structural CapEx lifespan analysis."""
|
|
21
|
+
params = {
|
|
22
|
+
"include_llm": str(include_llm).lower(),
|
|
23
|
+
"investor_mode": str(investor_mode).lower(),
|
|
24
|
+
}
|
|
25
|
+
data = self._http.get(f"/v1/listing/{mls_id}/intelligence", params=params)
|
|
26
|
+
return PropertyIntelligence.model_validate(data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncIntelligenceResource:
|
|
30
|
+
"""Asynchronous property intelligence and CapEx operations."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
33
|
+
self._http = http
|
|
34
|
+
|
|
35
|
+
async def get(
|
|
36
|
+
self,
|
|
37
|
+
mls_id: str,
|
|
38
|
+
include_llm: bool = True,
|
|
39
|
+
investor_mode: bool = False,
|
|
40
|
+
) -> PropertyIntelligence:
|
|
41
|
+
"""Synthesize public records, tax assessment history, and structural CapEx lifespan analysis."""
|
|
42
|
+
params = {
|
|
43
|
+
"include_llm": str(include_llm).lower(),
|
|
44
|
+
"investor_mode": str(investor_mode).lower(),
|
|
45
|
+
}
|
|
46
|
+
data = await self._http.get(f"/v1/listing/{mls_id}/intelligence", params=params)
|
|
47
|
+
return PropertyIntelligence.model_validate(data)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
6
|
+
from pymlsapi.http import HttpClient
|
|
7
|
+
from pymlsapi.models.listings import BaseListing, IngestJob
|
|
8
|
+
from pymlsapi.poller import poll_job_async, poll_job_sync
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ListingsResource:
|
|
12
|
+
"""Synchronous MLS listings and ingestion operations."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: HttpClient) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def get(self, mls_id: str) -> Union[BaseListing, IngestJob]:
|
|
18
|
+
"""Lookup an MLS listing. If not cached, returns an IngestJob in progress."""
|
|
19
|
+
data = self._http.get(f"/v1/listing/{mls_id}")
|
|
20
|
+
if "status" in data and data["status"] in ("processing", "queued"):
|
|
21
|
+
return IngestJob.model_validate(data)
|
|
22
|
+
return BaseListing.model_validate(data)
|
|
23
|
+
|
|
24
|
+
def get_and_wait(
|
|
25
|
+
self,
|
|
26
|
+
mls_id: str,
|
|
27
|
+
timeout_seconds: float = 60.0,
|
|
28
|
+
poll_interval: float = 2.0,
|
|
29
|
+
on_progress: Optional[Callable[[IngestJob], None]] = None,
|
|
30
|
+
) -> BaseListing:
|
|
31
|
+
"""Fetch listing and automatically poll until ingestion completes."""
|
|
32
|
+
initial = self.get(mls_id)
|
|
33
|
+
if isinstance(initial, BaseListing):
|
|
34
|
+
return initial
|
|
35
|
+
|
|
36
|
+
job_id = initial.job_id or (getattr(initial, "job_id", None) or mls_id)
|
|
37
|
+
|
|
38
|
+
def fetch_job() -> IngestJob:
|
|
39
|
+
data = self._http.get(f"/jobs/{job_id}")
|
|
40
|
+
return IngestJob.model_validate(data)
|
|
41
|
+
|
|
42
|
+
def is_done(job: IngestJob) -> bool:
|
|
43
|
+
return job.status == "completed"
|
|
44
|
+
|
|
45
|
+
def is_failed(job: IngestJob) -> bool:
|
|
46
|
+
return job.status == "failed"
|
|
47
|
+
|
|
48
|
+
poll_job_sync(
|
|
49
|
+
fetch_fn=fetch_job,
|
|
50
|
+
is_done_fn=is_done,
|
|
51
|
+
is_failed_fn=is_failed,
|
|
52
|
+
timeout_seconds=timeout_seconds,
|
|
53
|
+
initial_interval=poll_interval,
|
|
54
|
+
on_progress=on_progress,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# After completed, fetch normalized listing
|
|
58
|
+
data = self._http.get(f"/v1/listing/{mls_id}")
|
|
59
|
+
return BaseListing.model_validate(data)
|
|
60
|
+
|
|
61
|
+
def enqueue(
|
|
62
|
+
self,
|
|
63
|
+
mls_id: str,
|
|
64
|
+
download_photos: bool = True,
|
|
65
|
+
upload_to_r2: bool = True,
|
|
66
|
+
save_local: bool = False,
|
|
67
|
+
photos_concurrency: Optional[int] = None,
|
|
68
|
+
webhook_url: Optional[str] = None,
|
|
69
|
+
) -> IngestJob:
|
|
70
|
+
"""Explicitly enqueue background MLS scraping and photo downloading."""
|
|
71
|
+
payload: Dict[str, Any] = {
|
|
72
|
+
"mlsId": mls_id,
|
|
73
|
+
"downloadPhotos": download_photos,
|
|
74
|
+
"uploadToR2": upload_to_r2,
|
|
75
|
+
"saveLocal": save_local,
|
|
76
|
+
}
|
|
77
|
+
if photos_concurrency is not None:
|
|
78
|
+
payload["photosConcurrency"] = photos_concurrency
|
|
79
|
+
if webhook_url:
|
|
80
|
+
payload["webhookUrl"] = webhook_url
|
|
81
|
+
|
|
82
|
+
data = self._http.post("/jobs", json=payload)
|
|
83
|
+
return IngestJob.model_validate(data)
|
|
84
|
+
|
|
85
|
+
def get_job(self, job_id: str) -> IngestJob:
|
|
86
|
+
"""Get live ingestion progress and status."""
|
|
87
|
+
data = self._http.get(f"/jobs/{job_id}")
|
|
88
|
+
return IngestJob.model_validate(data)
|
|
89
|
+
|
|
90
|
+
def list_jobs(self, limit: int = 20) -> List[IngestJob]:
|
|
91
|
+
"""List recent background ingestion jobs."""
|
|
92
|
+
data = self._http.get("/jobs", params={"limit": limit})
|
|
93
|
+
jobs = data.get("jobs", [])
|
|
94
|
+
return [IngestJob.model_validate(j) for j in jobs]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AsyncListingsResource:
|
|
98
|
+
"""Asynchronous MLS listings and ingestion operations."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
101
|
+
self._http = http
|
|
102
|
+
|
|
103
|
+
async def get(self, mls_id: str) -> Union[BaseListing, IngestJob]:
|
|
104
|
+
"""Lookup an MLS listing. If not cached, returns an IngestJob in progress."""
|
|
105
|
+
data = await self._http.get(f"/v1/listing/{mls_id}")
|
|
106
|
+
if "status" in data and data["status"] in ("processing", "queued"):
|
|
107
|
+
return IngestJob.model_validate(data)
|
|
108
|
+
return BaseListing.model_validate(data)
|
|
109
|
+
|
|
110
|
+
async def get_and_wait(
|
|
111
|
+
self,
|
|
112
|
+
mls_id: str,
|
|
113
|
+
timeout_seconds: float = 60.0,
|
|
114
|
+
poll_interval: float = 2.0,
|
|
115
|
+
on_progress: Optional[Callable[[IngestJob], None]] = None,
|
|
116
|
+
) -> BaseListing:
|
|
117
|
+
"""Fetch listing and automatically poll until ingestion completes."""
|
|
118
|
+
initial = await self.get(mls_id)
|
|
119
|
+
if isinstance(initial, BaseListing):
|
|
120
|
+
return initial
|
|
121
|
+
|
|
122
|
+
job_id = initial.job_id or mls_id
|
|
123
|
+
|
|
124
|
+
async def fetch_job() -> IngestJob:
|
|
125
|
+
data = await self._http.get(f"/jobs/{job_id}")
|
|
126
|
+
return IngestJob.model_validate(data)
|
|
127
|
+
|
|
128
|
+
def is_done(job: IngestJob) -> bool:
|
|
129
|
+
return job.status == "completed"
|
|
130
|
+
|
|
131
|
+
def is_failed(job: IngestJob) -> bool:
|
|
132
|
+
return job.status == "failed"
|
|
133
|
+
|
|
134
|
+
await poll_job_async(
|
|
135
|
+
fetch_fn=fetch_job,
|
|
136
|
+
is_done_fn=is_done,
|
|
137
|
+
is_failed_fn=is_failed,
|
|
138
|
+
timeout_seconds=timeout_seconds,
|
|
139
|
+
initial_interval=poll_interval,
|
|
140
|
+
on_progress=on_progress,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
data = await self._http.get(f"/v1/listing/{mls_id}")
|
|
144
|
+
return BaseListing.model_validate(data)
|
|
145
|
+
|
|
146
|
+
async def enqueue(
|
|
147
|
+
self,
|
|
148
|
+
mls_id: str,
|
|
149
|
+
download_photos: bool = True,
|
|
150
|
+
upload_to_r2: bool = True,
|
|
151
|
+
save_local: bool = False,
|
|
152
|
+
photos_concurrency: Optional[int] = None,
|
|
153
|
+
webhook_url: Optional[str] = None,
|
|
154
|
+
) -> IngestJob:
|
|
155
|
+
"""Explicitly enqueue background MLS scraping and photo downloading."""
|
|
156
|
+
payload: Dict[str, Any] = {
|
|
157
|
+
"mlsId": mls_id,
|
|
158
|
+
"downloadPhotos": download_photos,
|
|
159
|
+
"uploadToR2": upload_to_r2,
|
|
160
|
+
"saveLocal": save_local,
|
|
161
|
+
}
|
|
162
|
+
if photos_concurrency is not None:
|
|
163
|
+
payload["photosConcurrency"] = photos_concurrency
|
|
164
|
+
if webhook_url:
|
|
165
|
+
payload["webhookUrl"] = webhook_url
|
|
166
|
+
|
|
167
|
+
data = await self._http.post("/jobs", json=payload)
|
|
168
|
+
return IngestJob.model_validate(data)
|
|
169
|
+
|
|
170
|
+
async def get_job(self, job_id: str) -> IngestJob:
|
|
171
|
+
"""Get live ingestion progress and status."""
|
|
172
|
+
data = await self._http.get(f"/jobs/{job_id}")
|
|
173
|
+
return IngestJob.model_validate(data)
|
|
174
|
+
|
|
175
|
+
async def list_jobs(self, limit: int = 20) -> List[IngestJob]:
|
|
176
|
+
"""List recent background ingestion jobs."""
|
|
177
|
+
data = await self._http.get("/jobs", params={"limit": limit})
|
|
178
|
+
jobs = data.get("jobs", [])
|
|
179
|
+
return [IngestJob.model_validate(j) for j in jobs]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
4
|
+
from pymlsapi.http import HttpClient
|
|
5
|
+
from pymlsapi.resources.studio.creatives import AsyncCreativesResource, CreativesResource
|
|
6
|
+
from pymlsapi.resources.studio.custom import AsyncCustomResource, CustomResource
|
|
7
|
+
from pymlsapi.resources.studio.enhance import AsyncEnhanceResource, EnhanceResource
|
|
8
|
+
from pymlsapi.resources.studio.floorplan import AsyncFloorPlanResource, FloorPlanResource
|
|
9
|
+
from pymlsapi.resources.studio.jobs import AsyncStudioJobsResource, StudioJobsResource
|
|
10
|
+
from pymlsapi.resources.studio.render import AsyncRenderResource, RenderResource
|
|
11
|
+
from pymlsapi.resources.studio.social import AsyncSocialResource, SocialResource
|
|
12
|
+
from pymlsapi.resources.studio.staging import AsyncStagingResource, StagingResource
|
|
13
|
+
from pymlsapi.resources.studio.upload import AsyncUploadResource, UploadResource
|
|
14
|
+
from pymlsapi.resources.studio.video import AsyncVideoResource, VideoResource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StudioResource:
|
|
18
|
+
"""Aggregated Studio Visual AI namespace."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, http: HttpClient) -> None:
|
|
21
|
+
self._http = http
|
|
22
|
+
self.jobs = StudioJobsResource(http)
|
|
23
|
+
self.staging = StagingResource(http, self.jobs)
|
|
24
|
+
self.enhance = EnhanceResource(http, self.jobs)
|
|
25
|
+
self.floorplan = FloorPlanResource(http, self.jobs)
|
|
26
|
+
self.render = RenderResource(http, self.jobs)
|
|
27
|
+
self.creatives = CreativesResource(http, self.jobs)
|
|
28
|
+
self.social = SocialResource(http)
|
|
29
|
+
self.video = VideoResource(http, self.jobs)
|
|
30
|
+
self.custom = CustomResource(http, self.jobs)
|
|
31
|
+
self._upload = UploadResource(http)
|
|
32
|
+
|
|
33
|
+
def upload(self, *args, **kwargs):
|
|
34
|
+
"""Upload image or video file directly to mlsapi CDN."""
|
|
35
|
+
return self._upload.upload(*args, **kwargs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncStudioResource:
|
|
39
|
+
"""Aggregated Asynchronous Studio Visual AI namespace."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
42
|
+
self._http = http
|
|
43
|
+
self.jobs = AsyncStudioJobsResource(http)
|
|
44
|
+
self.staging = AsyncStagingResource(http, self.jobs)
|
|
45
|
+
self.enhance = AsyncEnhanceResource(http, self.jobs)
|
|
46
|
+
self.floorplan = AsyncFloorPlanResource(http, self.jobs)
|
|
47
|
+
self.render = AsyncRenderResource(http, self.jobs)
|
|
48
|
+
self.creatives = AsyncCreativesResource(http, self.jobs)
|
|
49
|
+
self.social = AsyncSocialResource(http)
|
|
50
|
+
self.video = AsyncVideoResource(http, self.jobs)
|
|
51
|
+
self.custom = AsyncCustomResource(http, self.jobs)
|
|
52
|
+
self._upload = AsyncUploadResource(http)
|
|
53
|
+
|
|
54
|
+
async def upload(self, *args, **kwargs):
|
|
55
|
+
"""Upload image or video file directly to mlsapi CDN."""
|
|
56
|
+
return await self._upload.upload(*args, **kwargs)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from pymlsapi.async_http import AsyncHttpClient
|
|
6
|
+
from pymlsapi.http import HttpClient
|
|
7
|
+
from pymlsapi.models.studio import AdCreativesResult, StudioJob
|
|
8
|
+
from pymlsapi.resources.studio.jobs import AsyncStudioJobsResource, StudioJobsResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CreativesResource:
|
|
12
|
+
"""Synchronous branded real estate multi-placement ad creative generation."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: HttpClient, jobs: StudioJobsResource) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
self._jobs = jobs
|
|
17
|
+
|
|
18
|
+
def generate(
|
|
19
|
+
self,
|
|
20
|
+
mls_id: str,
|
|
21
|
+
trigger: str = "just_listed",
|
|
22
|
+
direction: str = "magazine",
|
|
23
|
+
placements: Optional[List[str]] = None,
|
|
24
|
+
brand_kit: Optional[Dict[str, Any]] = None,
|
|
25
|
+
webhook_url: Optional[str] = None,
|
|
26
|
+
) -> StudioJob:
|
|
27
|
+
payload: Dict[str, Any] = {
|
|
28
|
+
"mls_id": mls_id,
|
|
29
|
+
"trigger": trigger,
|
|
30
|
+
"direction": direction,
|
|
31
|
+
"placements": placements or ["feed_portrait", "square", "link", "flyer"],
|
|
32
|
+
}
|
|
33
|
+
if brand_kit:
|
|
34
|
+
payload["brand_kit"] = brand_kit
|
|
35
|
+
if webhook_url:
|
|
36
|
+
payload["webhook_url"] = webhook_url
|
|
37
|
+
data = self._http.post("/v1/studio/creatives/generate", json=payload)
|
|
38
|
+
return StudioJob.model_validate(data)
|
|
39
|
+
|
|
40
|
+
def generate_and_wait(
|
|
41
|
+
self,
|
|
42
|
+
mls_id: str,
|
|
43
|
+
trigger: str = "just_listed",
|
|
44
|
+
direction: str = "magazine",
|
|
45
|
+
placements: Optional[List[str]] = None,
|
|
46
|
+
brand_kit: Optional[Dict[str, Any]] = None,
|
|
47
|
+
webhook_url: Optional[str] = None,
|
|
48
|
+
timeout_seconds: float = 90.0,
|
|
49
|
+
poll_interval: float = 2.0,
|
|
50
|
+
on_progress: Optional[Callable[[StudioJob], None]] = None,
|
|
51
|
+
) -> AdCreativesResult:
|
|
52
|
+
job = self.generate(
|
|
53
|
+
mls_id=mls_id,
|
|
54
|
+
trigger=trigger,
|
|
55
|
+
direction=direction,
|
|
56
|
+
placements=placements,
|
|
57
|
+
brand_kit=brand_kit,
|
|
58
|
+
webhook_url=webhook_url,
|
|
59
|
+
)
|
|
60
|
+
completed = self._jobs.wait_for(
|
|
61
|
+
job.job_id,
|
|
62
|
+
timeout_seconds=timeout_seconds,
|
|
63
|
+
poll_interval=poll_interval,
|
|
64
|
+
on_progress=on_progress,
|
|
65
|
+
)
|
|
66
|
+
return AdCreativesResult.model_validate(completed.result or {})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AsyncCreativesResource:
|
|
70
|
+
"""Asynchronous branded real estate multi-placement ad creative generation."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, http: AsyncHttpClient, jobs: AsyncStudioJobsResource) -> None:
|
|
73
|
+
self._http = http
|
|
74
|
+
self._jobs = jobs
|
|
75
|
+
|
|
76
|
+
async def generate(
|
|
77
|
+
self,
|
|
78
|
+
mls_id: str,
|
|
79
|
+
trigger: str = "just_listed",
|
|
80
|
+
direction: str = "magazine",
|
|
81
|
+
placements: Optional[List[str]] = None,
|
|
82
|
+
brand_kit: Optional[Dict[str, Any]] = None,
|
|
83
|
+
webhook_url: Optional[str] = None,
|
|
84
|
+
) -> StudioJob:
|
|
85
|
+
payload: Dict[str, Any] = {
|
|
86
|
+
"mls_id": mls_id,
|
|
87
|
+
"trigger": trigger,
|
|
88
|
+
"direction": direction,
|
|
89
|
+
"placements": placements or ["feed_portrait", "square", "link", "flyer"],
|
|
90
|
+
}
|
|
91
|
+
if brand_kit:
|
|
92
|
+
payload["brand_kit"] = brand_kit
|
|
93
|
+
if webhook_url:
|
|
94
|
+
payload["webhook_url"] = webhook_url
|
|
95
|
+
data = await self._http.post("/v1/studio/creatives/generate", json=payload)
|
|
96
|
+
return StudioJob.model_validate(data)
|
|
97
|
+
|
|
98
|
+
async def generate_and_wait(
|
|
99
|
+
self,
|
|
100
|
+
mls_id: str,
|
|
101
|
+
trigger: str = "just_listed",
|
|
102
|
+
direction: str = "magazine",
|
|
103
|
+
placements: Optional[List[str]] = None,
|
|
104
|
+
brand_kit: Optional[Dict[str, Any]] = None,
|
|
105
|
+
webhook_url: Optional[str] = None,
|
|
106
|
+
timeout_seconds: float = 90.0,
|
|
107
|
+
poll_interval: float = 2.0,
|
|
108
|
+
on_progress: Optional[Callable[[StudioJob], None]] = None,
|
|
109
|
+
) -> AdCreativesResult:
|
|
110
|
+
job = await self.generate(
|
|
111
|
+
mls_id=mls_id,
|
|
112
|
+
trigger=trigger,
|
|
113
|
+
direction=direction,
|
|
114
|
+
placements=placements,
|
|
115
|
+
brand_kit=brand_kit,
|
|
116
|
+
webhook_url=webhook_url,
|
|
117
|
+
)
|
|
118
|
+
completed = await self._jobs.wait_for(
|
|
119
|
+
job.job_id,
|
|
120
|
+
timeout_seconds=timeout_seconds,
|
|
121
|
+
poll_interval=poll_interval,
|
|
122
|
+
on_progress=on_progress,
|
|
123
|
+
)
|
|
124
|
+
return AdCreativesResult.model_validate(completed.result or {})
|