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.
@@ -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.client = httpx.Client(
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 post_service_offering(self, data_file: Path) -> dict[str, Any]:
98
- """Post service offering data to the backend.
99
-
100
- Extracts provider_name from the directory structure.
101
- Expected path: .../{provider_name}/services/{service_name}/...
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
- # Load the data file
105
- data = self.load_data_file(data_file)
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
- # Resolve file references and include content
108
- base_path = data_file.parent
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
- # Resolve file references and include content
114
- data_with_content = self.resolve_file_references(data, base_path)
123
+ Raises:
124
+ ValueError: If task fails or times out
125
+ """
126
+ import time
115
127
 
116
- # Extract provider_name from directory structure
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
- # Find provider directory to check status
125
- provider_dir = Path(*parts[:services_idx])
126
- except (ValueError, IndexError):
127
- raise ValueError(
128
- f"Cannot extract provider_name from path: {data_file}. "
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
- # Check provider status - skip if incomplete
133
- provider_files = find_files_by_schema(provider_dir, "provider_v1")
134
- if provider_files:
135
- # Should only be one provider file in the directory
136
- _provider_file, _format, provider_data = provider_files[0]
137
- provider_status = provider_data.get("status", ProviderStatusEnum.active)
138
- if provider_status == ProviderStatusEnum.incomplete:
139
- return {
140
- "skipped": True,
141
- "reason": f"Provider status is '{provider_status}' - not publishing offering to backend",
142
- "name": data.get("name", "unknown"),
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
- # Post to the endpoint
146
- response = self.client.post(
147
- f"{self.base_url}/publish/offering",
148
- json=data_with_content,
149
- )
150
- response.raise_for_status()
151
- return response.json()
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
- def post_service_listing(self, data_file: Path) -> dict[str, Any]:
154
- """Post service listing data to the backend.
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
- Extracts provider_name from directory structure and service info from service.json.
157
- Expected path: .../{provider_name}/services/{service_name}/svcreseller.json
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(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"] = data_file.stem
282
+ data["name"] = listing_file.stem
165
283
 
166
284
  # Resolve file references and include content
167
- base_path = data_file.parent
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 = data_file.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: {data_file}. "
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(data_file.parent, "service_v1")
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 {data_file.parent}. "
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 {data_file.parent}: {', '.join(service_names)}. "
196
- f"Please add 'service_name' field to {data_file.name} to specify which "
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(data_file.parent, "service_v1", field_filter=(("name", service_name),))
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 {data_file.name} not found in {data_file.parent}."
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 = data_file.parent
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: {data_file}. "
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
- response = self.client.post(
262
- f"{self.base_url}/publish/listing",
263
- json=data_with_content,
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
- def post_provider(self, data_file: Path) -> dict[str, Any]:
269
- """Post provider data to the backend."""
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
- # Remove status field before sending to backend (backend uses is_active)
294
- status = data_with_content.pop("status", ProviderStatusEnum.active)
295
- # Map status to is_active: active and disabled -> True (published), incomplete -> False (not published)
296
- data_with_content["is_active"] = status != ProviderStatusEnum.disabled
297
-
298
- # Post to the endpoint
299
- response = self.client.post(
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
- # Remove status field before sending to backend (backend uses is_active)
330
- status = data_with_content.pop("status", SellerStatusEnum.active)
331
- # Map status to is_active: active and disabled -> True (published), incomplete -> False (not published)
332
- data_with_content["is_active"] = status != SellerStatusEnum.disabled
333
-
334
- # Post to the endpoint
335
- response = self.client.post(
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 publish_all_offerings(self, data_dir: Path) -> dict[str, Any]:
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
- Publish all service offerings found in a directory tree.
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
- for offering_file in offering_files:
390
- try:
391
- self.post_service_offering(offering_file)
392
- results["success"] += 1
393
- except Exception as e:
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(e)})
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 publish_all_listings(self, data_dir: Path) -> dict[str, Any]:
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
- Publish all service listings found in a directory tree.
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
- for listing_file in listing_files:
426
- try:
427
- self.post_service_listing(listing_file)
428
- results["success"] += 1
429
- except Exception as e:
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(e)})
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 publish_all_providers(self, data_dir: Path) -> dict[str, Any]:
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
- for provider_file in provider_files:
450
- try:
451
- self.post_provider(provider_file)
452
- results["success"] += 1
453
- except Exception as e:
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(e)})
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 publish_all_sellers(self, data_dir: Path) -> dict[str, Any]:
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
- Publish all sellers found in a directory tree.
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
- for seller_file in seller_files:
474
- try:
475
- self.post_seller(seller_file)
476
- results["success"] += 1
477
- except Exception as e:
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(e)})
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 the HTTP client."""
534
- self.client.close()
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.post_provider(data_path)
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.post_seller(data_path)
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.post_service_offering(data_path)
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.post_service_listing(data_path)
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']}")