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.
@@ -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 {})