unitysvc-services 0.2.6__py3-none-any.whl → 0.3.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.
- unitysvc_services/models/base.py +123 -0
- unitysvc_services/models/listing_v1.py +24 -2
- unitysvc_services/models/provider_v1.py +17 -2
- unitysvc_services/models/seller_v1.py +8 -2
- unitysvc_services/models/service_v1.py +8 -1
- unitysvc_services/publisher.py +510 -147
- unitysvc_services/query.py +3 -3
- unitysvc_services/validator.py +79 -23
- {unitysvc_services-0.2.6.dist-info → unitysvc_services-0.3.0.dist-info}/METADATA +1 -1
- unitysvc_services-0.3.0.dist-info/RECORD +24 -0
- unitysvc_services-0.2.6.dist-info/RECORD +0 -24
- {unitysvc_services-0.2.6.dist-info → unitysvc_services-0.3.0.dist-info}/WHEEL +0 -0
- {unitysvc_services-0.2.6.dist-info → unitysvc_services-0.3.0.dist-info}/entry_points.txt +0 -0
- {unitysvc_services-0.2.6.dist-info → unitysvc_services-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {unitysvc_services-0.2.6.dist-info → unitysvc_services-0.3.0.dist-info}/top_level.txt +0 -0
unitysvc_services/publisher.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
"""Data publisher module for posting service data to UnitySVC backend."""
|
2
2
|
|
3
|
+
import asyncio
|
3
4
|
import base64
|
4
5
|
import json
|
5
6
|
import os
|
@@ -29,13 +30,15 @@ class ServiceDataPublisher:
|
|
29
30
|
raise ValueError("UNITYSVC_API_KEY environment variable not set")
|
30
31
|
|
31
32
|
self.base_url = self.base_url.rstrip("/")
|
32
|
-
self.
|
33
|
+
self.async_client = httpx.AsyncClient(
|
33
34
|
headers={
|
34
35
|
"X-API-Key": self.api_key,
|
35
36
|
"Content-Type": "application/json",
|
36
37
|
},
|
37
38
|
timeout=30.0,
|
38
39
|
)
|
40
|
+
# Semaphore to limit concurrent requests and prevent connection pool exhaustion
|
41
|
+
self.max_concurrent_requests = 15
|
39
42
|
|
40
43
|
def load_data_file(self, file_path: Path) -> dict[str, Any]:
|
41
44
|
"""Load data from JSON or TOML file."""
|
@@ -94,106 +97,221 @@ class ServiceDataPublisher:
|
|
94
97
|
|
95
98
|
return result
|
96
99
|
|
97
|
-
def
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
100
|
+
async def _poll_task_status(
|
101
|
+
self,
|
102
|
+
task_id: str,
|
103
|
+
entity_type: str,
|
104
|
+
entity_name: str,
|
105
|
+
context_info: str = "",
|
106
|
+
poll_interval: float = 2.0,
|
107
|
+
timeout: float = 300.0,
|
108
|
+
) -> dict[str, Any]:
|
102
109
|
"""
|
110
|
+
Poll task status until completion or timeout.
|
103
111
|
|
104
|
-
|
105
|
-
|
112
|
+
Args:
|
113
|
+
task_id: Celery task ID
|
114
|
+
entity_type: Type of entity being published (for error messages)
|
115
|
+
entity_name: Name of the entity being published (for error messages)
|
116
|
+
context_info: Additional context for error messages
|
117
|
+
poll_interval: Seconds between status checks
|
118
|
+
timeout: Maximum seconds to wait
|
106
119
|
|
107
|
-
|
108
|
-
|
109
|
-
data = convert_convenience_fields_to_documents(
|
110
|
-
data, base_path, logo_field="logo", terms_field="terms_of_service"
|
111
|
-
)
|
120
|
+
Returns:
|
121
|
+
Task result dictionary
|
112
122
|
|
113
|
-
|
114
|
-
|
123
|
+
Raises:
|
124
|
+
ValueError: If task fails or times out
|
125
|
+
"""
|
126
|
+
import time
|
115
127
|
|
116
|
-
|
117
|
-
# Find the 'services' directory and use its parent as provider_name
|
118
|
-
parts = data_file.parts
|
119
|
-
try:
|
120
|
-
services_idx = parts.index("services")
|
121
|
-
provider_name = parts[services_idx - 1]
|
122
|
-
data_with_content["provider_name"] = provider_name
|
128
|
+
start_time = time.time()
|
123
129
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
f"
|
129
|
-
f"Expected path to contain .../{{provider_name}}/services/..."
|
130
|
-
)
|
130
|
+
while True:
|
131
|
+
elapsed = time.time() - start_time
|
132
|
+
if elapsed > timeout:
|
133
|
+
context_msg = f" ({context_info})" if context_info else ""
|
134
|
+
raise ValueError(f"Task timed out after {timeout}s for {entity_type} '{entity_name}'{context_msg}")
|
131
135
|
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
136
|
+
# Check task status
|
137
|
+
try:
|
138
|
+
response = await self.async_client.get(f"{self.base_url}/tasks/{task_id}")
|
139
|
+
response.raise_for_status()
|
140
|
+
status = response.json()
|
141
|
+
except (httpx.HTTPError, httpx.NetworkError, httpx.TimeoutException):
|
142
|
+
# Network error while checking status - retry
|
143
|
+
await asyncio.sleep(poll_interval)
|
144
|
+
continue
|
145
|
+
|
146
|
+
state = status.get("state", "PENDING")
|
147
|
+
|
148
|
+
# Check if task is complete
|
149
|
+
if status.get("status") == "completed" or state == "SUCCESS":
|
150
|
+
# Task succeeded
|
151
|
+
return status.get("result", {})
|
152
|
+
elif status.get("status") == "failed" or state == "FAILURE":
|
153
|
+
# Task failed
|
154
|
+
error = status.get("error", "Unknown error")
|
155
|
+
context_msg = f" ({context_info})" if context_info else ""
|
156
|
+
raise ValueError(f"Task failed for {entity_type} '{entity_name}'{context_msg}: {error}")
|
157
|
+
|
158
|
+
# Still processing - wait and retry
|
159
|
+
await asyncio.sleep(poll_interval)
|
160
|
+
|
161
|
+
async def _post_with_retry(
|
162
|
+
self,
|
163
|
+
endpoint: str,
|
164
|
+
data: dict[str, Any],
|
165
|
+
entity_type: str,
|
166
|
+
entity_name: str,
|
167
|
+
context_info: str = "",
|
168
|
+
max_retries: int = 3,
|
169
|
+
) -> dict[str, Any]:
|
170
|
+
"""
|
171
|
+
Generic retry wrapper for posting data to backend API with task polling.
|
172
|
+
|
173
|
+
The backend now returns HTTP 202 with a task_id. This method:
|
174
|
+
1. Submits the publish request
|
175
|
+
2. Gets the task_id from the response
|
176
|
+
3. Polls /tasks/{task_id} until completion
|
177
|
+
4. Returns the final result
|
178
|
+
|
179
|
+
Args:
|
180
|
+
endpoint: API endpoint path (e.g., "/publish/listing")
|
181
|
+
data: JSON data to post
|
182
|
+
entity_type: Type of entity being published (for error messages)
|
183
|
+
entity_name: Name of the entity being published (for error messages)
|
184
|
+
context_info: Additional context for error messages (e.g., provider, service info)
|
185
|
+
max_retries: Maximum number of retry attempts
|
186
|
+
|
187
|
+
Returns:
|
188
|
+
Response JSON from successful API call
|
189
|
+
|
190
|
+
Raises:
|
191
|
+
ValueError: On client errors (4xx) or after exhausting retries
|
192
|
+
"""
|
193
|
+
last_exception = None
|
194
|
+
for attempt in range(max_retries):
|
195
|
+
try:
|
196
|
+
response = await self.async_client.post(
|
197
|
+
f"{self.base_url}{endpoint}",
|
198
|
+
json=data,
|
199
|
+
)
|
144
200
|
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
201
|
+
# Handle task-based response (HTTP 202)
|
202
|
+
if response.status_code == 202:
|
203
|
+
# Backend returns task_id - poll for completion
|
204
|
+
response_data = response.json()
|
205
|
+
task_id = response_data.get("task_id")
|
206
|
+
|
207
|
+
if not task_id:
|
208
|
+
context_msg = f" ({context_info})" if context_info else ""
|
209
|
+
raise ValueError(f"No task_id in response for {entity_type} '{entity_name}'{context_msg}")
|
210
|
+
|
211
|
+
# Poll task status until completion
|
212
|
+
result = await self._poll_task_status(
|
213
|
+
task_id=task_id,
|
214
|
+
entity_type=entity_type,
|
215
|
+
entity_name=entity_name,
|
216
|
+
context_info=context_info,
|
217
|
+
)
|
218
|
+
return result
|
219
|
+
|
220
|
+
# Provide detailed error information if request fails
|
221
|
+
if not response.is_success:
|
222
|
+
# Don't retry on 4xx errors (client errors) - they won't succeed on retry
|
223
|
+
if 400 <= response.status_code < 500:
|
224
|
+
error_detail = "Unknown error"
|
225
|
+
try:
|
226
|
+
error_json = response.json()
|
227
|
+
error_detail = error_json.get("detail", str(error_json))
|
228
|
+
except Exception:
|
229
|
+
error_detail = response.text or f"HTTP {response.status_code}"
|
230
|
+
|
231
|
+
context_msg = f" ({context_info})" if context_info else ""
|
232
|
+
raise ValueError(
|
233
|
+
f"Failed to publish {entity_type} '{entity_name}'{context_msg}: {error_detail}"
|
234
|
+
)
|
235
|
+
|
236
|
+
# 5xx errors or network errors - retry with exponential backoff
|
237
|
+
if attempt < max_retries - 1:
|
238
|
+
wait_time = 2**attempt # Exponential backoff: 1s, 2s, 4s
|
239
|
+
await asyncio.sleep(wait_time)
|
240
|
+
continue
|
241
|
+
else:
|
242
|
+
# Last attempt failed
|
243
|
+
error_detail = "Unknown error"
|
244
|
+
try:
|
245
|
+
error_json = response.json()
|
246
|
+
error_detail = error_json.get("detail", str(error_json))
|
247
|
+
except Exception:
|
248
|
+
error_detail = response.text or f"HTTP {response.status_code}"
|
249
|
+
|
250
|
+
context_msg = f" ({context_info})" if context_info else ""
|
251
|
+
raise ValueError(
|
252
|
+
f"Failed to publish {entity_type} after {max_retries} attempts: "
|
253
|
+
f"'{entity_name}'{context_msg}: {error_detail}"
|
254
|
+
)
|
255
|
+
|
256
|
+
# For non-202 success responses, return the body
|
257
|
+
return response.json()
|
258
|
+
|
259
|
+
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
260
|
+
last_exception = e
|
261
|
+
if attempt < max_retries - 1:
|
262
|
+
wait_time = 2**attempt # Exponential backoff: 1s, 2s, 4s
|
263
|
+
await asyncio.sleep(wait_time)
|
264
|
+
continue
|
265
|
+
else:
|
266
|
+
raise ValueError(
|
267
|
+
f"Network error after {max_retries} attempts for {entity_type} '{entity_name}': {str(e)}"
|
268
|
+
)
|
152
269
|
|
153
|
-
|
154
|
-
|
270
|
+
# Should never reach here, but just in case
|
271
|
+
if last_exception:
|
272
|
+
raise last_exception
|
273
|
+
raise ValueError("Unexpected error in retry logic")
|
155
274
|
|
156
|
-
|
157
|
-
|
158
|
-
"""
|
275
|
+
async def post_service_listing_async(self, listing_file: Path, max_retries: int = 3) -> dict[str, Any]:
|
276
|
+
"""Async version of post_service_listing for concurrent publishing with retry logic."""
|
159
277
|
# Load the listing data file
|
160
|
-
data = self.load_data_file(
|
278
|
+
data = self.load_data_file(listing_file)
|
161
279
|
|
162
280
|
# If name is not provided, use filename (without extension)
|
163
281
|
if "name" not in data or not data.get("name"):
|
164
|
-
data["name"] =
|
282
|
+
data["name"] = listing_file.stem
|
165
283
|
|
166
284
|
# Resolve file references and include content
|
167
|
-
base_path =
|
285
|
+
base_path = listing_file.parent
|
168
286
|
data_with_content = self.resolve_file_references(data, base_path)
|
169
287
|
|
170
288
|
# Extract provider_name from directory structure
|
171
|
-
parts =
|
289
|
+
parts = listing_file.parts
|
172
290
|
try:
|
173
291
|
services_idx = parts.index("services")
|
174
292
|
provider_name = parts[services_idx - 1]
|
175
293
|
data_with_content["provider_name"] = provider_name
|
176
294
|
except (ValueError, IndexError):
|
177
295
|
raise ValueError(
|
178
|
-
f"Cannot extract provider_name from path: {
|
296
|
+
f"Cannot extract provider_name from path: {listing_file}. "
|
179
297
|
f"Expected path to contain .../{{provider_name}}/services/..."
|
180
298
|
)
|
181
299
|
|
182
300
|
# If service_name is not in listing data, find it from service files in the same directory
|
183
301
|
if "service_name" not in data_with_content or not data_with_content["service_name"]:
|
184
302
|
# Find all service files in the same directory
|
185
|
-
service_files = find_files_by_schema(
|
303
|
+
service_files = find_files_by_schema(listing_file.parent, "service_v1")
|
186
304
|
|
187
305
|
if len(service_files) == 0:
|
188
306
|
raise ValueError(
|
189
|
-
f"Cannot find any service_v1 files in {
|
307
|
+
f"Cannot find any service_v1 files in {listing_file.parent}. "
|
190
308
|
f"Listing files must be in the same directory as a service definition."
|
191
309
|
)
|
192
310
|
elif len(service_files) > 1:
|
193
311
|
service_names = [data.get("name", "unknown") for _, _, data in service_files]
|
194
312
|
raise ValueError(
|
195
|
-
f"Multiple services found in {
|
196
|
-
f"Please add 'service_name' field to {
|
313
|
+
f"Multiple services found in {listing_file.parent}: {', '.join(service_names)}. "
|
314
|
+
f"Please add 'service_name' field to {listing_file.name} to specify which "
|
197
315
|
f"service this listing belongs to."
|
198
316
|
)
|
199
317
|
else:
|
@@ -204,11 +322,13 @@ class ServiceDataPublisher:
|
|
204
322
|
else:
|
205
323
|
# service_name is provided in listing data, find the matching service to get version
|
206
324
|
service_name = data_with_content["service_name"]
|
207
|
-
service_files = find_files_by_schema(
|
325
|
+
service_files = find_files_by_schema(
|
326
|
+
listing_file.parent, "service_v1", field_filter=(("name", service_name),)
|
327
|
+
)
|
208
328
|
|
209
329
|
if not service_files:
|
210
330
|
raise ValueError(
|
211
|
-
f"Service '{service_name}' specified in {
|
331
|
+
f"Service '{service_name}' specified in {listing_file.name} not found in {listing_file.parent}."
|
212
332
|
)
|
213
333
|
|
214
334
|
# Get version from the found service
|
@@ -217,13 +337,13 @@ class ServiceDataPublisher:
|
|
217
337
|
|
218
338
|
# Find seller_name from seller definition in the data directory
|
219
339
|
# Navigate up to find the data directory and look for seller file
|
220
|
-
data_dir =
|
340
|
+
data_dir = listing_file.parent
|
221
341
|
while data_dir.name != "data" and data_dir.parent != data_dir:
|
222
342
|
data_dir = data_dir.parent
|
223
343
|
|
224
344
|
if data_dir.name != "data":
|
225
345
|
raise ValueError(
|
226
|
-
f"Cannot find 'data' directory in path: {
|
346
|
+
f"Cannot find 'data' directory in path: {listing_file}. "
|
227
347
|
f"Expected path structure includes a 'data' directory."
|
228
348
|
)
|
229
349
|
|
@@ -257,17 +377,77 @@ class ServiceDataPublisher:
|
|
257
377
|
if "listing_status" in data_with_content:
|
258
378
|
data_with_content["status"] = data_with_content.pop("listing_status")
|
259
379
|
|
260
|
-
# Post to the endpoint
|
261
|
-
|
262
|
-
f"{
|
263
|
-
|
380
|
+
# Post to the endpoint using retry helper
|
381
|
+
context_info = (
|
382
|
+
f"service: {data_with_content.get('service_name')}, "
|
383
|
+
f"provider: {data_with_content.get('provider_name')}, "
|
384
|
+
f"seller: {data_with_content.get('seller_name')}"
|
385
|
+
)
|
386
|
+
return await self._post_with_retry(
|
387
|
+
endpoint="/publish/listing",
|
388
|
+
data=data_with_content,
|
389
|
+
entity_type="listing",
|
390
|
+
entity_name=data.get("name", "unknown"),
|
391
|
+
context_info=context_info,
|
392
|
+
max_retries=max_retries,
|
393
|
+
)
|
394
|
+
|
395
|
+
async def post_service_offering_async(self, data_file: Path, max_retries: int = 3) -> dict[str, Any]:
|
396
|
+
"""Async version of post_service_offering for concurrent publishing with retry logic."""
|
397
|
+
# Load the data file
|
398
|
+
data = self.load_data_file(data_file)
|
399
|
+
|
400
|
+
# Resolve file references and include content
|
401
|
+
base_path = data_file.parent
|
402
|
+
data = convert_convenience_fields_to_documents(
|
403
|
+
data, base_path, logo_field="logo", terms_field="terms_of_service"
|
264
404
|
)
|
265
|
-
response.raise_for_status()
|
266
|
-
return response.json()
|
267
405
|
|
268
|
-
|
269
|
-
|
406
|
+
# Resolve file references and include content
|
407
|
+
data_with_content = self.resolve_file_references(data, base_path)
|
270
408
|
|
409
|
+
# Extract provider_name from directory structure
|
410
|
+
# Find the 'services' directory and use its parent as provider_name
|
411
|
+
parts = data_file.parts
|
412
|
+
try:
|
413
|
+
services_idx = parts.index("services")
|
414
|
+
provider_name = parts[services_idx - 1]
|
415
|
+
data_with_content["provider_name"] = provider_name
|
416
|
+
|
417
|
+
# Find provider directory to check status
|
418
|
+
provider_dir = Path(*parts[:services_idx])
|
419
|
+
except (ValueError, IndexError):
|
420
|
+
raise ValueError(
|
421
|
+
f"Cannot extract provider_name from path: {data_file}. "
|
422
|
+
f"Expected path to contain .../{{provider_name}}/services/..."
|
423
|
+
)
|
424
|
+
|
425
|
+
# Check provider status - skip if incomplete
|
426
|
+
provider_files = find_files_by_schema(provider_dir, "provider_v1")
|
427
|
+
if provider_files:
|
428
|
+
# Should only be one provider file in the directory
|
429
|
+
_provider_file, _format, provider_data = provider_files[0]
|
430
|
+
provider_status = provider_data.get("status", ProviderStatusEnum.active)
|
431
|
+
if provider_status == ProviderStatusEnum.incomplete:
|
432
|
+
return {
|
433
|
+
"skipped": True,
|
434
|
+
"reason": f"Provider status is '{provider_status}' - not publishing offering to backend",
|
435
|
+
"name": data.get("name", "unknown"),
|
436
|
+
}
|
437
|
+
|
438
|
+
# Post to the endpoint using retry helper
|
439
|
+
context_info = f"provider: {data_with_content.get('provider_name')}"
|
440
|
+
return await self._post_with_retry(
|
441
|
+
endpoint="/publish/offering",
|
442
|
+
data=data_with_content,
|
443
|
+
entity_type="offering",
|
444
|
+
entity_name=data.get("name", "unknown"),
|
445
|
+
context_info=context_info,
|
446
|
+
max_retries=max_retries,
|
447
|
+
)
|
448
|
+
|
449
|
+
async def post_provider_async(self, data_file: Path, max_retries: int = 3) -> dict[str, Any]:
|
450
|
+
"""Async version of post_provider for concurrent publishing with retry logic."""
|
271
451
|
# Load the data file
|
272
452
|
data = self.load_data_file(data_file)
|
273
453
|
|
@@ -290,22 +470,17 @@ class ServiceDataPublisher:
|
|
290
470
|
# Resolve file references and include content
|
291
471
|
data_with_content = self.resolve_file_references(data, base_path)
|
292
472
|
|
293
|
-
#
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
f"{self.base_url}/publish/provider",
|
301
|
-
json=data_with_content,
|
473
|
+
# Post to the endpoint using retry helper
|
474
|
+
return await self._post_with_retry(
|
475
|
+
endpoint="/publish/provider",
|
476
|
+
data=data_with_content,
|
477
|
+
entity_type="provider",
|
478
|
+
entity_name=data.get("name", "unknown"),
|
479
|
+
max_retries=max_retries,
|
302
480
|
)
|
303
|
-
response.raise_for_status()
|
304
|
-
return response.json()
|
305
|
-
|
306
|
-
def post_seller(self, data_file: Path) -> dict[str, Any]:
|
307
|
-
"""Post seller data to the backend."""
|
308
481
|
|
482
|
+
async def post_seller_async(self, data_file: Path, max_retries: int = 3) -> dict[str, Any]:
|
483
|
+
"""Async version of post_seller for concurrent publishing with retry logic."""
|
309
484
|
# Load the data file
|
310
485
|
data = self.load_data_file(data_file)
|
311
486
|
|
@@ -326,18 +501,14 @@ class ServiceDataPublisher:
|
|
326
501
|
# Resolve file references and include content
|
327
502
|
data_with_content = self.resolve_file_references(data, base_path)
|
328
503
|
|
329
|
-
#
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
f"{self.base_url}/publish/seller",
|
337
|
-
json=data_with_content,
|
504
|
+
# Post to the endpoint using retry helper
|
505
|
+
return await self._post_with_retry(
|
506
|
+
endpoint="/publish/seller",
|
507
|
+
data=data_with_content,
|
508
|
+
entity_type="seller",
|
509
|
+
entity_name=data.get("name", "unknown"),
|
510
|
+
max_retries=max_retries,
|
338
511
|
)
|
339
|
-
response.raise_for_status()
|
340
|
-
return response.json()
|
341
512
|
|
342
513
|
def find_offering_files(self, data_dir: Path) -> list[Path]:
|
343
514
|
"""Find all service offering files in a directory tree."""
|
@@ -359,14 +530,48 @@ class ServiceDataPublisher:
|
|
359
530
|
files = find_files_by_schema(data_dir, "seller_v1")
|
360
531
|
return sorted([f[0] for f in files])
|
361
532
|
|
362
|
-
def
|
533
|
+
async def _publish_offering_task(
|
534
|
+
self, offering_file: Path, console: Console, semaphore: asyncio.Semaphore
|
535
|
+
) -> tuple[Path, dict[str, Any] | Exception]:
|
536
|
+
"""
|
537
|
+
Async task to publish a single offering with concurrency control.
|
538
|
+
|
539
|
+
Returns tuple of (offering_file, result_or_exception).
|
363
540
|
"""
|
364
|
-
|
541
|
+
async with semaphore: # Limit concurrent requests
|
542
|
+
try:
|
543
|
+
# Load offering data to get the name
|
544
|
+
data = self.load_data_file(offering_file)
|
545
|
+
offering_name = data.get("name", offering_file.stem)
|
546
|
+
|
547
|
+
# Publish the offering
|
548
|
+
result = await self.post_service_offering_async(offering_file)
|
549
|
+
|
550
|
+
# Print complete statement after publication
|
551
|
+
if result.get("skipped"):
|
552
|
+
reason = result.get("reason", "unknown")
|
553
|
+
console.print(f" [yellow]⊘[/yellow] Skipped offering: [cyan]{offering_name}[/cyan] - {reason}")
|
554
|
+
else:
|
555
|
+
provider_name = result.get("provider_name")
|
556
|
+
console.print(
|
557
|
+
f" [green]✓[/green] Published offering: [cyan]{offering_name}[/cyan] "
|
558
|
+
f"(provider: {provider_name})"
|
559
|
+
)
|
560
|
+
|
561
|
+
return (offering_file, result)
|
562
|
+
except Exception as e:
|
563
|
+
data = self.load_data_file(offering_file)
|
564
|
+
offering_name = data.get("name", offering_file.stem)
|
565
|
+
console.print(f" [red]✗[/red] Failed to publish offering: [cyan]{offering_name}[/cyan] - {str(e)}")
|
566
|
+
return (offering_file, e)
|
567
|
+
|
568
|
+
async def publish_all_offerings(self, data_dir: Path) -> dict[str, Any]:
|
569
|
+
"""
|
570
|
+
Publish all service offerings found in a directory tree concurrently.
|
365
571
|
|
366
572
|
Validates data consistency before publishing.
|
367
573
|
Returns a summary of successes and failures.
|
368
574
|
"""
|
369
|
-
|
370
575
|
# Validate all service directories first
|
371
576
|
validator = DataValidator(data_dir, data_dir.parent / "schema")
|
372
577
|
validation_errors = validator.validate_all_service_directories(data_dir)
|
@@ -386,19 +591,66 @@ class ServiceDataPublisher:
|
|
386
591
|
"errors": [],
|
387
592
|
}
|
388
593
|
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
594
|
+
if not offering_files:
|
595
|
+
return results
|
596
|
+
|
597
|
+
console = Console()
|
598
|
+
|
599
|
+
# Run all offering publications concurrently with rate limiting
|
600
|
+
# Create semaphore to limit concurrent requests
|
601
|
+
semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
602
|
+
tasks = [self._publish_offering_task(offering_file, console, semaphore) for offering_file in offering_files]
|
603
|
+
task_results = await asyncio.gather(*tasks)
|
604
|
+
|
605
|
+
# Process results
|
606
|
+
for offering_file, result in task_results:
|
607
|
+
if isinstance(result, Exception):
|
394
608
|
results["failed"] += 1
|
395
|
-
results["errors"].append({"file": str(offering_file), "error": str(
|
609
|
+
results["errors"].append({"file": str(offering_file), "error": str(result)})
|
610
|
+
else:
|
611
|
+
results["success"] += 1
|
396
612
|
|
397
613
|
return results
|
398
614
|
|
399
|
-
def
|
615
|
+
async def _publish_listing_task(
|
616
|
+
self, listing_file: Path, console: Console, semaphore: asyncio.Semaphore
|
617
|
+
) -> tuple[Path, dict[str, Any] | Exception]:
|
400
618
|
"""
|
401
|
-
|
619
|
+
Async task to publish a single listing with concurrency control.
|
620
|
+
|
621
|
+
Returns tuple of (listing_file, result_or_exception).
|
622
|
+
"""
|
623
|
+
async with semaphore: # Limit concurrent requests
|
624
|
+
try:
|
625
|
+
# Load listing data to get the name
|
626
|
+
data = self.load_data_file(listing_file)
|
627
|
+
listing_name = data.get("name", listing_file.stem)
|
628
|
+
|
629
|
+
# Publish the listing
|
630
|
+
result = await self.post_service_listing_async(listing_file)
|
631
|
+
|
632
|
+
# Print complete statement after publication
|
633
|
+
if result.get("skipped"):
|
634
|
+
reason = result.get("reason", "unknown")
|
635
|
+
console.print(f" [yellow]⊘[/yellow] Skipped listing: [cyan]{listing_name}[/cyan] - {reason}")
|
636
|
+
else:
|
637
|
+
service_name = result.get("service_name")
|
638
|
+
provider_name = result.get("provider_name")
|
639
|
+
console.print(
|
640
|
+
f" [green]✓[/green] Published listing: [cyan]{listing_name}[/cyan] "
|
641
|
+
f"(service: {service_name}, provider: {provider_name})"
|
642
|
+
)
|
643
|
+
|
644
|
+
return (listing_file, result)
|
645
|
+
except Exception as e:
|
646
|
+
data = self.load_data_file(listing_file)
|
647
|
+
listing_name = data.get("name", listing_file.stem)
|
648
|
+
console.print(f" [red]✗[/red] Failed to publish listing: [cyan]{listing_file}[/cyan] - {str(e)}")
|
649
|
+
return (listing_file, e)
|
650
|
+
|
651
|
+
async def publish_all_listings(self, data_dir: Path) -> dict[str, Any]:
|
652
|
+
"""
|
653
|
+
Publish all service listings found in a directory tree concurrently.
|
402
654
|
|
403
655
|
Validates data consistency before publishing.
|
404
656
|
Returns a summary of successes and failures.
|
@@ -422,19 +674,61 @@ class ServiceDataPublisher:
|
|
422
674
|
"errors": [],
|
423
675
|
}
|
424
676
|
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
677
|
+
if not listing_files:
|
678
|
+
return results
|
679
|
+
|
680
|
+
console = Console()
|
681
|
+
|
682
|
+
# Run all listing publications concurrently with rate limiting
|
683
|
+
# Create semaphore to limit concurrent requests
|
684
|
+
semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
685
|
+
tasks = [self._publish_listing_task(listing_file, console, semaphore) for listing_file in listing_files]
|
686
|
+
task_results = await asyncio.gather(*tasks)
|
687
|
+
|
688
|
+
# Process results
|
689
|
+
for listing_file, result in task_results:
|
690
|
+
if isinstance(result, Exception):
|
430
691
|
results["failed"] += 1
|
431
|
-
results["errors"].append({"file": str(listing_file), "error": str(
|
692
|
+
results["errors"].append({"file": str(listing_file), "error": str(result)})
|
693
|
+
else:
|
694
|
+
results["success"] += 1
|
432
695
|
|
433
696
|
return results
|
434
697
|
|
435
|
-
def
|
698
|
+
async def _publish_provider_task(
|
699
|
+
self, provider_file: Path, console: Console, semaphore: asyncio.Semaphore
|
700
|
+
) -> tuple[Path, dict[str, Any] | Exception]:
|
701
|
+
"""
|
702
|
+
Async task to publish a single provider with concurrency control.
|
703
|
+
|
704
|
+
Returns tuple of (provider_file, result_or_exception).
|
705
|
+
"""
|
706
|
+
async with semaphore: # Limit concurrent requests
|
707
|
+
try:
|
708
|
+
# Load provider data to get the name
|
709
|
+
data = self.load_data_file(provider_file)
|
710
|
+
provider_name = data.get("name", provider_file.stem)
|
711
|
+
|
712
|
+
# Publish the provider
|
713
|
+
result = await self.post_provider_async(provider_file)
|
714
|
+
|
715
|
+
# Print complete statement after publication
|
716
|
+
if result.get("skipped"):
|
717
|
+
reason = result.get("reason", "unknown")
|
718
|
+
console.print(f" [yellow]⊘[/yellow] Skipped provider: [cyan]{provider_name}[/cyan] - {reason}")
|
719
|
+
else:
|
720
|
+
console.print(f" [green]✓[/green] Published provider: [cyan]{provider_name}[/cyan]")
|
721
|
+
|
722
|
+
return (provider_file, result)
|
723
|
+
except Exception as e:
|
724
|
+
data = self.load_data_file(provider_file)
|
725
|
+
provider_name = data.get("name", provider_file.stem)
|
726
|
+
console.print(f" [red]✗[/red] Failed to publish provider: [cyan]{provider_name}[/cyan] - {str(e)}")
|
727
|
+
return (provider_file, e)
|
728
|
+
|
729
|
+
async def publish_all_providers(self, data_dir: Path) -> dict[str, Any]:
|
436
730
|
"""
|
437
|
-
Publish all providers found in a directory tree.
|
731
|
+
Publish all providers found in a directory tree concurrently.
|
438
732
|
|
439
733
|
Returns a summary of successes and failures.
|
440
734
|
"""
|
@@ -446,19 +740,61 @@ class ServiceDataPublisher:
|
|
446
740
|
"errors": [],
|
447
741
|
}
|
448
742
|
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
743
|
+
if not provider_files:
|
744
|
+
return results
|
745
|
+
|
746
|
+
console = Console()
|
747
|
+
|
748
|
+
# Run all provider publications concurrently with rate limiting
|
749
|
+
# Create semaphore to limit concurrent requests
|
750
|
+
semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
751
|
+
tasks = [self._publish_provider_task(provider_file, console, semaphore) for provider_file in provider_files]
|
752
|
+
task_results = await asyncio.gather(*tasks)
|
753
|
+
|
754
|
+
# Process results
|
755
|
+
for provider_file, result in task_results:
|
756
|
+
if isinstance(result, Exception):
|
454
757
|
results["failed"] += 1
|
455
|
-
results["errors"].append({"file": str(provider_file), "error": str(
|
758
|
+
results["errors"].append({"file": str(provider_file), "error": str(result)})
|
759
|
+
else:
|
760
|
+
results["success"] += 1
|
456
761
|
|
457
762
|
return results
|
458
763
|
|
459
|
-
def
|
764
|
+
async def _publish_seller_task(
|
765
|
+
self, seller_file: Path, console: Console, semaphore: asyncio.Semaphore
|
766
|
+
) -> tuple[Path, dict[str, Any] | Exception]:
|
460
767
|
"""
|
461
|
-
|
768
|
+
Async task to publish a single seller with concurrency control.
|
769
|
+
|
770
|
+
Returns tuple of (seller_file, result_or_exception).
|
771
|
+
"""
|
772
|
+
async with semaphore: # Limit concurrent requests
|
773
|
+
try:
|
774
|
+
# Load seller data to get the name
|
775
|
+
data = self.load_data_file(seller_file)
|
776
|
+
seller_name = data.get("name", seller_file.stem)
|
777
|
+
|
778
|
+
# Publish the seller
|
779
|
+
result = await self.post_seller_async(seller_file)
|
780
|
+
|
781
|
+
# Print complete statement after publication
|
782
|
+
if result.get("skipped"):
|
783
|
+
reason = result.get("reason", "unknown")
|
784
|
+
console.print(f" [yellow]⊘[/yellow] Skipped seller: [cyan]{seller_name}[/cyan] - {reason}")
|
785
|
+
else:
|
786
|
+
console.print(f" [green]✓[/green] Published seller: [cyan]{seller_name}[/cyan]")
|
787
|
+
|
788
|
+
return (seller_file, result)
|
789
|
+
except Exception as e:
|
790
|
+
data = self.load_data_file(seller_file)
|
791
|
+
seller_name = data.get("name", seller_file.stem)
|
792
|
+
console.print(f" [red]✗[/red] Failed to publish seller: [cyan]{seller_name}[/cyan] - {str(e)}")
|
793
|
+
return (seller_file, e)
|
794
|
+
|
795
|
+
async def publish_all_sellers(self, data_dir: Path) -> dict[str, Any]:
|
796
|
+
"""
|
797
|
+
Publish all sellers found in a directory tree concurrently.
|
462
798
|
|
463
799
|
Returns a summary of successes and failures.
|
464
800
|
"""
|
@@ -470,17 +806,28 @@ class ServiceDataPublisher:
|
|
470
806
|
"errors": [],
|
471
807
|
}
|
472
808
|
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
809
|
+
if not seller_files:
|
810
|
+
return results
|
811
|
+
|
812
|
+
console = Console()
|
813
|
+
|
814
|
+
# Run all seller publications concurrently with rate limiting
|
815
|
+
# Create semaphore to limit concurrent requests
|
816
|
+
semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
817
|
+
tasks = [self._publish_seller_task(seller_file, console, semaphore) for seller_file in seller_files]
|
818
|
+
task_results = await asyncio.gather(*tasks)
|
819
|
+
|
820
|
+
# Process results
|
821
|
+
for seller_file, result in task_results:
|
822
|
+
if isinstance(result, Exception):
|
478
823
|
results["failed"] += 1
|
479
|
-
results["errors"].append({"file": str(seller_file), "error": str(
|
824
|
+
results["errors"].append({"file": str(seller_file), "error": str(result)})
|
825
|
+
else:
|
826
|
+
results["success"] += 1
|
480
827
|
|
481
828
|
return results
|
482
829
|
|
483
|
-
def publish_all_models(self, data_dir: Path) -> dict[str, Any]:
|
830
|
+
async def publish_all_models(self, data_dir: Path) -> dict[str, Any]:
|
484
831
|
"""
|
485
832
|
Publish all data types in the correct order.
|
486
833
|
|
@@ -512,7 +859,7 @@ class ServiceDataPublisher:
|
|
512
859
|
|
513
860
|
for data_type, publish_method in publish_order:
|
514
861
|
try:
|
515
|
-
results = publish_method(data_dir)
|
862
|
+
results = await publish_method(data_dir)
|
516
863
|
all_results[data_type] = results
|
517
864
|
all_results["total_success"] += results["success"]
|
518
865
|
all_results["total_failed"] += results["failed"]
|
@@ -529,9 +876,25 @@ class ServiceDataPublisher:
|
|
529
876
|
|
530
877
|
return all_results
|
531
878
|
|
879
|
+
async def aclose(self):
|
880
|
+
"""Close HTTP client asynchronously."""
|
881
|
+
await self.async_client.aclose()
|
882
|
+
|
532
883
|
def close(self):
|
533
|
-
"""Close
|
534
|
-
|
884
|
+
"""Close HTTP client synchronously (best effort)."""
|
885
|
+
try:
|
886
|
+
# Try to close if there's an event loop running
|
887
|
+
loop = asyncio.get_event_loop()
|
888
|
+
if loop.is_running():
|
889
|
+
# Can't close synchronously if loop is running
|
890
|
+
# The client will be garbage collected
|
891
|
+
return
|
892
|
+
else:
|
893
|
+
# Loop exists but not running, we can use it
|
894
|
+
loop.run_until_complete(self.async_client.aclose())
|
895
|
+
except RuntimeError:
|
896
|
+
# No event loop or loop is closed - just let it be garbage collected
|
897
|
+
pass
|
535
898
|
|
536
899
|
def __enter__(self):
|
537
900
|
"""Context manager entry."""
|
@@ -594,8 +957,8 @@ def publish_callback(
|
|
594
957
|
|
595
958
|
try:
|
596
959
|
with ServiceDataPublisher() as publisher:
|
597
|
-
# Call the publish_all_models method
|
598
|
-
all_results = publisher.publish_all_models(data_path)
|
960
|
+
# Call the publish_all_models method (now async)
|
961
|
+
all_results = asyncio.run(publisher.publish_all_models(data_path))
|
599
962
|
|
600
963
|
# Display results for each data type
|
601
964
|
data_type_display_names = {
|
@@ -682,14 +1045,14 @@ def publish_providers(
|
|
682
1045
|
if data_path.is_file():
|
683
1046
|
console.print(f"[blue]Publishing provider:[/blue] {data_path}")
|
684
1047
|
console.print(f"[blue]Backend URL:[/blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
685
|
-
result = publisher.
|
1048
|
+
result = asyncio.run(publisher.post_provider_async(data_path))
|
686
1049
|
console.print("[green]✓[/green] Provider published successfully!")
|
687
1050
|
console.print(f"[cyan]Response:[/cyan] {json.dumps(result, indent=2)}")
|
688
1051
|
# Handle directory
|
689
1052
|
else:
|
690
1053
|
console.print(f"[blue]Scanning for providers in:[/blue] {data_path}")
|
691
1054
|
console.print(f"[blue]Backend URL:[/blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
692
|
-
results = publisher.publish_all_providers(data_path)
|
1055
|
+
results = asyncio.run(publisher.publish_all_providers(data_path))
|
693
1056
|
|
694
1057
|
# Display summary
|
695
1058
|
console.print("\n[bold]Publishing Summary:[/bold]")
|
@@ -741,14 +1104,14 @@ def publish_sellers(
|
|
741
1104
|
if data_path.is_file():
|
742
1105
|
console.print(f"[blue]Publishing seller:[/blue] {data_path}")
|
743
1106
|
console.print(f"[blue]Backend URL:[/blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
744
|
-
result = publisher.
|
1107
|
+
result = asyncio.run(publisher.post_seller_async(data_path))
|
745
1108
|
console.print("[green]✓[/green] Seller published successfully!")
|
746
1109
|
console.print(f"[cyan]Response:[/cyan] {json.dumps(result, indent=2)}")
|
747
1110
|
# Handle directory
|
748
1111
|
else:
|
749
1112
|
console.print(f"[blue]Scanning for sellers in:[/blue] {data_path}")
|
750
1113
|
console.print(f"[blue]Backend URL:[/blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
751
|
-
results = publisher.publish_all_sellers(data_path)
|
1114
|
+
results = asyncio.run(publisher.publish_all_sellers(data_path))
|
752
1115
|
|
753
1116
|
console.print("\n[bold]Publishing Summary:[/bold]")
|
754
1117
|
console.print(f" Total found: {results['total']}")
|
@@ -798,14 +1161,14 @@ def publish_offerings(
|
|
798
1161
|
if data_path.is_file():
|
799
1162
|
console.print(f"[blue]Publishing service offering:[/blue] {data_path}")
|
800
1163
|
console.print(f"[blue]Backend URL:[/blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
801
|
-
result = publisher.
|
1164
|
+
result = asyncio.run(publisher.post_service_offering_async(data_path))
|
802
1165
|
console.print("[green]✓[/green] Service offering published successfully!")
|
803
1166
|
console.print(f"[cyan]Response:[/cyan] {json.dumps(result, indent=2)}")
|
804
1167
|
# Handle directory
|
805
1168
|
else:
|
806
1169
|
console.print(f"[blue]Scanning for service offerings in:[/blue] {data_path}")
|
807
|
-
console.print(f"[blue]Backend URL:[/blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
808
|
-
results = publisher.publish_all_offerings(data_path)
|
1170
|
+
console.print(f"[blue]Backend URL:[/bold blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
1171
|
+
results = asyncio.run(publisher.publish_all_offerings(data_path))
|
809
1172
|
|
810
1173
|
console.print("\n[bold]Publishing Summary:[/bold]")
|
811
1174
|
console.print(f" Total found: {results['total']}")
|
@@ -856,14 +1219,14 @@ def publish_listings(
|
|
856
1219
|
if data_path.is_file():
|
857
1220
|
console.print(f"[blue]Publishing service listing:[/blue] {data_path}")
|
858
1221
|
console.print(f"[blue]Backend URL:[/blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
859
|
-
result = publisher.
|
1222
|
+
result = asyncio.run(publisher.post_service_listing_async(data_path))
|
860
1223
|
console.print("[green]✓[/green] Service listing published successfully!")
|
861
1224
|
console.print(f"[cyan]Response:[/cyan] {json.dumps(result, indent=2)}")
|
862
1225
|
# Handle directory
|
863
1226
|
else:
|
864
1227
|
console.print(f"[blue]Scanning for service listings in:[/blue] {data_path}")
|
865
1228
|
console.print(f"[blue]Backend URL:[/blue] {os.getenv('UNITYSVC_BASE_URL', 'N/A')}\n")
|
866
|
-
results = publisher.publish_all_listings(data_path)
|
1229
|
+
results = asyncio.run(publisher.publish_all_listings(data_path))
|
867
1230
|
|
868
1231
|
console.print("\n[bold]Publishing Summary:[/bold]")
|
869
1232
|
console.print(f" Total found: {results['total']}")
|