unitysvc-services 0.1.0__py3-none-any.whl → 0.2.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,6 +1,8 @@
1
1
  """Data publisher module for posting service data to UnitySVC backend."""
2
2
 
3
+ import base64
3
4
  import json
5
+ import os
4
6
  import tomllib as toml
5
7
  from pathlib import Path
6
8
  from typing import Any
@@ -9,6 +11,10 @@ import httpx
9
11
  import typer
10
12
  from rich.console import Console
11
13
 
14
+ from .models.base import ProviderStatusEnum, SellerStatusEnum
15
+ from .utils import convert_convenience_fields_to_documents, find_files_by_schema
16
+ from .validator import DataValidator
17
+
12
18
 
13
19
  class ServiceDataPublisher:
14
20
  """Publishes service data to UnitySVC backend endpoints."""
@@ -48,8 +54,6 @@ class ServiceDataPublisher:
48
54
  return f.read()
49
55
  except UnicodeDecodeError:
50
56
  # If it fails, read as binary and encode as base64
51
- import base64
52
-
53
57
  with open(full_path, "rb") as f:
54
58
  return base64.b64encode(f.read()).decode("ascii")
55
59
 
@@ -89,6 +93,7 @@ class ServiceDataPublisher:
89
93
  Extracts provider_name from the directory structure.
90
94
  Expected path: .../{provider_name}/services/{service_name}/...
91
95
  """
96
+
92
97
  # Load the data file
93
98
  data = self.load_data_file(data_file)
94
99
 
@@ -103,12 +108,28 @@ class ServiceDataPublisher:
103
108
  services_idx = parts.index("services")
104
109
  provider_name = parts[services_idx - 1]
105
110
  data_with_content["provider_name"] = provider_name
111
+
112
+ # Find provider directory to check status
113
+ provider_dir = Path(*parts[:services_idx])
106
114
  except (ValueError, IndexError):
107
115
  raise ValueError(
108
116
  f"Cannot extract provider_name from path: {data_file}. "
109
117
  f"Expected path to contain .../{{provider_name}}/services/..."
110
118
  )
111
119
 
120
+ # Check provider status - skip if incomplete
121
+ provider_files = find_files_by_schema(provider_dir, "provider_v1")
122
+ if provider_files:
123
+ # Should only be one provider file in the directory
124
+ _provider_file, _format, provider_data = provider_files[0]
125
+ provider_status = provider_data.get("status", ProviderStatusEnum.active)
126
+ if provider_status == ProviderStatusEnum.incomplete:
127
+ return {
128
+ "skipped": True,
129
+ "reason": f"Provider status is '{provider_status}' - not publishing offering to backend",
130
+ "name": data.get("name", "unknown"),
131
+ }
132
+
112
133
  # Post to the endpoint
113
134
  response = self.client.post(
114
135
  f"{self.base_url}/publish/service_offering",
@@ -145,15 +166,7 @@ class ServiceDataPublisher:
145
166
  # If service_name is not in listing data, find it from service files in the same directory
146
167
  if "service_name" not in data_with_content or not data_with_content["service_name"]:
147
168
  # Find all service files in the same directory
148
- service_files = []
149
- for pattern in ["*.json", "*.toml"]:
150
- for file_path in data_file.parent.glob(pattern):
151
- try:
152
- file_data = self.load_data_file(file_path)
153
- if file_data.get("schema") == "service_v1":
154
- service_files.append((file_path, file_data))
155
- except Exception:
156
- continue
169
+ service_files = find_files_by_schema(data_file.parent, "service_v1")
157
170
 
158
171
  if len(service_files) == 0:
159
172
  raise ValueError(
@@ -161,7 +174,7 @@ class ServiceDataPublisher:
161
174
  f"Listing files must be in the same directory as a service definition."
162
175
  )
163
176
  elif len(service_files) > 1:
164
- service_names = [data.get("name", "unknown") for _, data in service_files]
177
+ service_names = [data.get("name", "unknown") for _, _, data in service_files]
165
178
  raise ValueError(
166
179
  f"Multiple services found in {data_file.parent}: {', '.join(service_names)}. "
167
180
  f"Please add 'service_name' field to {data_file.name} to specify which "
@@ -169,32 +182,23 @@ class ServiceDataPublisher:
169
182
  )
170
183
  else:
171
184
  # Exactly one service found - use it
172
- service_file, service_data = service_files[0]
185
+ _service_file, _format, service_data = service_files[0]
173
186
  data_with_content["service_name"] = service_data.get("name")
174
187
  data_with_content["service_version"] = service_data.get("version")
175
188
  else:
176
189
  # service_name is provided in listing data, find the matching service to get version
177
190
  service_name = data_with_content["service_name"]
178
- service_found = False
191
+ service_files = find_files_by_schema(data_file.parent, "service_v1", field_filter=(("name", service_name),))
179
192
 
180
- for pattern in ["*.json", "*.toml"]:
181
- for file_path in data_file.parent.glob(pattern):
182
- try:
183
- file_data = self.load_data_file(file_path)
184
- if file_data.get("schema") == "service_v1" and file_data.get("name") == service_name:
185
- data_with_content["service_version"] = file_data.get("version")
186
- service_found = True
187
- break
188
- except Exception:
189
- continue
190
- if service_found:
191
- break
192
-
193
- if not service_found:
193
+ if not service_files:
194
194
  raise ValueError(
195
195
  f"Service '{service_name}' specified in {data_file.name} not found in {data_file.parent}."
196
196
  )
197
197
 
198
+ # Get version from the found service
199
+ _service_file, _format, service_data = service_files[0]
200
+ data_with_content["service_version"] = service_data.get("version")
201
+
198
202
  # Find seller_name from seller definition in the data directory
199
203
  # Navigate up to find the data directory and look for seller file
200
204
  data_dir = data_file.parent
@@ -207,28 +211,29 @@ class ServiceDataPublisher:
207
211
  f"Expected path structure includes a 'data' directory."
208
212
  )
209
213
 
210
- # Look for seller file in the data directory
211
- seller_file = None
212
- for pattern in ["seller.json", "seller.toml"]:
213
- potential_seller = data_dir / pattern
214
- if potential_seller.exists():
215
- seller_file = potential_seller
216
- break
214
+ # Look for seller file in the data directory by checking schema field
215
+ seller_files = find_files_by_schema(data_dir, "seller_v1")
217
216
 
218
- if not seller_file:
217
+ if not seller_files:
219
218
  raise ValueError(
220
- f"Cannot find seller.json or seller.toml in {data_dir}. "
221
- f"A seller definition is required in the data directory."
219
+ f"Cannot find seller_v1 file in {data_dir}. A seller definition is required in the data directory."
222
220
  )
223
221
 
224
- # Load seller data and extract name
225
- seller_data = self.load_data_file(seller_file)
226
- if seller_data.get("schema") != "seller_v1":
227
- raise ValueError(f"Seller file {seller_file} does not have schema='seller_v1'")
222
+ # Should only be one seller file in the data directory
223
+ _seller_file, _format, seller_data = seller_files[0]
224
+
225
+ # Check seller status - skip if incomplete
226
+ seller_status = seller_data.get("status", SellerStatusEnum.active)
227
+ if seller_status == SellerStatusEnum.incomplete:
228
+ return {
229
+ "skipped": True,
230
+ "reason": f"Seller status is '{seller_status}' - not publishing listing to backend",
231
+ "name": data.get("name", "unknown"),
232
+ }
228
233
 
229
234
  seller_name = seller_data.get("name")
230
235
  if not seller_name:
231
- raise ValueError(f"Seller file {seller_file} missing 'name' field")
236
+ raise ValueError("Seller data missing 'name' field")
232
237
 
233
238
  data_with_content["seller_name"] = seller_name
234
239
 
@@ -246,16 +251,37 @@ class ServiceDataPublisher:
246
251
 
247
252
  def post_provider(self, data_file: Path) -> dict[str, Any]:
248
253
  """Post provider data to the backend."""
254
+
249
255
  # Load the data file
250
256
  data = self.load_data_file(data_file)
251
257
 
252
- # Resolve file references and include content
258
+ # Check provider status - skip if incomplete
259
+ provider_status = data.get("status", ProviderStatusEnum.active)
260
+ if provider_status == ProviderStatusEnum.incomplete:
261
+ # Return success without publishing - provider is incomplete
262
+ return {
263
+ "skipped": True,
264
+ "reason": f"Provider status is '{provider_status}' - not publishing to backend",
265
+ "name": data.get("name", "unknown"),
266
+ }
267
+
268
+ # Convert convenience fields (logo, terms_of_service) to documents
253
269
  base_path = data_file.parent
270
+ data = convert_convenience_fields_to_documents(
271
+ data, base_path, logo_field="logo", terms_field="terms_of_service"
272
+ )
273
+
274
+ # Resolve file references and include content
254
275
  data_with_content = self.resolve_file_references(data, base_path)
255
276
 
277
+ # Remove status field before sending to backend (backend uses is_active)
278
+ status = data_with_content.pop("status", ProviderStatusEnum.active)
279
+ # Map status to is_active: active and disabled -> True (published), incomplete -> False (not published)
280
+ data_with_content["is_active"] = status != ProviderStatusEnum.disabled
281
+
256
282
  # Post to the endpoint
257
283
  response = self.client.post(
258
- f"{self.base_url}/providers/",
284
+ f"{self.base_url}/publish/provider",
259
285
  json=data_with_content,
260
286
  )
261
287
  response.raise_for_status()
@@ -263,164 +289,59 @@ class ServiceDataPublisher:
263
289
 
264
290
  def post_seller(self, data_file: Path) -> dict[str, Any]:
265
291
  """Post seller data to the backend."""
292
+
266
293
  # Load the data file
267
294
  data = self.load_data_file(data_file)
268
295
 
269
- # Resolve file references and include content
296
+ # Check seller status - skip if incomplete
297
+ seller_status = data.get("status", SellerStatusEnum.active)
298
+ if seller_status == SellerStatusEnum.incomplete:
299
+ # Return success without publishing - seller is incomplete
300
+ return {
301
+ "skipped": True,
302
+ "reason": f"Seller status is '{seller_status}' - not publishing to backend",
303
+ "name": data.get("name", "unknown"),
304
+ }
305
+
306
+ # Convert convenience fields (logo only for sellers, no terms_of_service)
270
307
  base_path = data_file.parent
308
+ data = convert_convenience_fields_to_documents(data, base_path, logo_field="logo", terms_field=None)
309
+
310
+ # Resolve file references and include content
271
311
  data_with_content = self.resolve_file_references(data, base_path)
272
312
 
313
+ # Remove status field before sending to backend (backend uses is_active)
314
+ status = data_with_content.pop("status", SellerStatusEnum.active)
315
+ # Map status to is_active: active and disabled -> True (published), incomplete -> False (not published)
316
+ data_with_content["is_active"] = status != SellerStatusEnum.disabled
317
+
273
318
  # Post to the endpoint
274
319
  response = self.client.post(
275
- f"{self.base_url}/sellers/",
320
+ f"{self.base_url}/publish/seller",
276
321
  json=data_with_content,
277
322
  )
278
323
  response.raise_for_status()
279
324
  return response.json()
280
325
 
281
- def list_service_offerings(self) -> list[dict[str, Any]]:
282
- """List all service offerings from the backend.
283
-
284
- Note: This endpoint doesn't exist yet in the backend.
285
- TODO: Add GET /publish/service_offering endpoint.
286
- """
287
- response = self.client.get(f"{self.base_url}/publish/service_offering")
288
- response.raise_for_status()
289
- result = response.json()
290
- # Backend returns {"data": [...], "count": N}
291
- return result.get("data", result) if isinstance(result, dict) else result
292
-
293
- def list_service_listings(self) -> list[dict[str, Any]]:
294
- """List all service listings from the backend."""
295
- response = self.client.get(f"{self.base_url}/services/")
296
- response.raise_for_status()
297
- result = response.json()
298
- # Backend returns {"data": [...], "count": N}
299
- return result.get("data", result) if isinstance(result, dict) else result
300
-
301
- def list_providers(self) -> list[dict[str, Any]]:
302
- """List all providers from the backend."""
303
- response = self.client.get(f"{self.base_url}/providers/")
304
- response.raise_for_status()
305
- result = response.json()
306
- # Backend returns {"data": [...], "count": N}
307
- return result.get("data", result) if isinstance(result, dict) else result
308
-
309
- def list_sellers(self) -> list[dict[str, Any]]:
310
- """List all sellers from the backend."""
311
- response = self.client.get(f"{self.base_url}/sellers/")
312
- response.raise_for_status()
313
- result = response.json()
314
- # Backend returns {"data": [...], "count": N}
315
- return result.get("data", result) if isinstance(result, dict) else result
316
-
317
- def update_service_offering_status(self, offering_id: int | str, status: str) -> dict[str, Any]:
318
- """
319
- Update the status of a service offering.
320
-
321
- Allowed statuses (UpstreamStatusEnum):
322
- - uploading: Service is being uploaded (not ready)
323
- - ready: Service is ready to be used
324
- - deprecated: Service is deprecated from upstream
325
- """
326
- response = self.client.patch(
327
- f"{self.base_url}/service_offering/{offering_id}/",
328
- json={"upstream_status": status},
329
- )
330
- response.raise_for_status()
331
- return response.json()
332
-
333
- def update_service_listing_status(self, listing_id: int | str, status: str) -> dict[str, Any]:
334
- """
335
- Update the status of a service listing.
336
-
337
- Allowed statuses (ListingStatusEnum):
338
- - unknown: Not yet determined
339
- - upstream_ready: Upstream is ready to be used
340
- - downstream_ready: Downstream is ready with proper routing, logging, and billing
341
- - ready: Operationally ready (with docs, metrics, and pricing)
342
- - in_service: Service is in service
343
- - upstream_deprecated: Service is deprecated from upstream
344
- - deprecated: Service is no longer offered to users
345
- """
346
- response = self.client.patch(
347
- f"{self.base_url}/service_listing/{listing_id}/",
348
- json={"listing_status": status},
349
- )
350
- response.raise_for_status()
351
- return response.json()
352
-
353
326
  def find_offering_files(self, data_dir: Path) -> list[Path]:
354
- """
355
- Find all service offering files in a directory tree.
356
-
357
- Searches all JSON and TOML files and checks for schema="service_v1".
358
- """
359
- offerings = []
360
- for pattern in ["*.json", "*.toml"]:
361
- for file_path in data_dir.rglob(pattern):
362
- try:
363
- data = self.load_data_file(file_path)
364
- if data.get("schema") == "service_v1":
365
- offerings.append(file_path)
366
- except Exception:
367
- # Skip files that can't be loaded or don't have schema field
368
- pass
369
- return sorted(offerings)
327
+ """Find all service offering files in a directory tree."""
328
+ files = find_files_by_schema(data_dir, "service_v1")
329
+ return sorted([f[0] for f in files])
370
330
 
371
331
  def find_listing_files(self, data_dir: Path) -> list[Path]:
372
- """
373
- Find all service listing files in a directory tree.
374
-
375
- Searches all JSON and TOML files and checks for schema="listing_v1".
376
- """
377
- listings = []
378
- for pattern in ["*.json", "*.toml"]:
379
- for file_path in data_dir.rglob(pattern):
380
- try:
381
- data = self.load_data_file(file_path)
382
- if data.get("schema") == "listing_v1":
383
- listings.append(file_path)
384
- except Exception:
385
- # Skip files that can't be loaded or don't have schema field
386
- pass
387
- return sorted(listings)
332
+ """Find all service listing files in a directory tree."""
333
+ files = find_files_by_schema(data_dir, "listing_v1")
334
+ return sorted([f[0] for f in files])
388
335
 
389
336
  def find_provider_files(self, data_dir: Path) -> list[Path]:
390
- """
391
- Find all provider files in a directory tree.
392
-
393
- Searches all JSON and TOML files and checks for schema="provider_v1".
394
- """
395
- providers = []
396
- for pattern in ["*.json", "*.toml"]:
397
- for file_path in data_dir.rglob(pattern):
398
- try:
399
- data = self.load_data_file(file_path)
400
- if data.get("schema") == "provider_v1":
401
- providers.append(file_path)
402
- except Exception:
403
- # Skip files that can't be loaded or don't have schema field
404
- pass
405
- return sorted(providers)
337
+ """Find all provider files in a directory tree."""
338
+ files = find_files_by_schema(data_dir, "provider_v1")
339
+ return sorted([f[0] for f in files])
406
340
 
407
341
  def find_seller_files(self, data_dir: Path) -> list[Path]:
408
- """
409
- Find all seller files in a directory tree.
410
-
411
- Searches all JSON and TOML files and checks for schema="seller_v1".
412
- """
413
- sellers = []
414
- for pattern in ["*.json", "*.toml"]:
415
- for file_path in data_dir.rglob(pattern):
416
- try:
417
- data = self.load_data_file(file_path)
418
- if data.get("schema") == "seller_v1":
419
- sellers.append(file_path)
420
- except Exception:
421
- # Skip files that can't be loaded or don't have schema field
422
- pass
423
- return sorted(sellers)
342
+ """Find all seller files in a directory tree."""
343
+ files = find_files_by_schema(data_dir, "seller_v1")
344
+ return sorted([f[0] for f in files])
424
345
 
425
346
  def publish_all_offerings(self, data_dir: Path) -> dict[str, Any]:
426
347
  """
@@ -429,7 +350,6 @@ class ServiceDataPublisher:
429
350
  Validates data consistency before publishing.
430
351
  Returns a summary of successes and failures.
431
352
  """
432
- from .validator import DataValidator
433
353
 
434
354
  # Validate all service directories first
435
355
  validator = DataValidator(data_dir, data_dir.parent / "schema")
@@ -467,8 +387,6 @@ class ServiceDataPublisher:
467
387
  Validates data consistency before publishing.
468
388
  Returns a summary of successes and failures.
469
389
  """
470
- from .validator import DataValidator
471
-
472
390
  # Validate all service directories first
473
391
  validator = DataValidator(data_dir, data_dir.parent / "schema")
474
392
  validation_errors = validator.validate_all_service_directories(data_dir)
@@ -481,7 +399,12 @@ class ServiceDataPublisher:
481
399
  }
482
400
 
483
401
  listing_files = self.find_listing_files(data_dir)
484
- results: dict[str, Any] = {"total": len(listing_files), "success": 0, "failed": 0, "errors": []}
402
+ results: dict[str, Any] = {
403
+ "total": len(listing_files),
404
+ "success": 0,
405
+ "failed": 0,
406
+ "errors": [],
407
+ }
485
408
 
486
409
  for listing_file in listing_files:
487
410
  try:
@@ -541,6 +464,55 @@ class ServiceDataPublisher:
541
464
 
542
465
  return results
543
466
 
467
+ def publish_all_models(self, data_dir: Path) -> dict[str, Any]:
468
+ """
469
+ Publish all data types in the correct order.
470
+
471
+ Publishing order:
472
+ 1. Sellers - Must exist before listings
473
+ 2. Providers - Must exist before offerings
474
+ 3. Service Offerings - Must exist before listings
475
+ 4. Service Listings - Depends on sellers, providers, and offerings
476
+
477
+ Returns a dict with results for each data type and overall summary.
478
+ """
479
+ all_results: dict[str, Any] = {
480
+ "sellers": {},
481
+ "providers": {},
482
+ "offerings": {},
483
+ "listings": {},
484
+ "total_success": 0,
485
+ "total_failed": 0,
486
+ "total_found": 0,
487
+ }
488
+
489
+ # Publish in order: sellers -> providers -> offerings -> listings
490
+ publish_order = [
491
+ ("sellers", self.publish_all_sellers),
492
+ ("providers", self.publish_all_providers),
493
+ ("offerings", self.publish_all_offerings),
494
+ ("listings", self.publish_all_listings),
495
+ ]
496
+
497
+ for data_type, publish_method in publish_order:
498
+ try:
499
+ results = publish_method(data_dir)
500
+ all_results[data_type] = results
501
+ all_results["total_success"] += results["success"]
502
+ all_results["total_failed"] += results["failed"]
503
+ all_results["total_found"] += results["total"]
504
+ except Exception as e:
505
+ # If a publish method fails catastrophically, record the error
506
+ all_results[data_type] = {
507
+ "total": 0,
508
+ "success": 0,
509
+ "failed": 1,
510
+ "errors": [{"file": "N/A", "error": str(e)}],
511
+ }
512
+ all_results["total_failed"] += 1
513
+
514
+ return all_results
515
+
544
516
  def close(self):
545
517
  """Close the HTTP client."""
546
518
  self.client.close()
@@ -559,35 +531,145 @@ app = typer.Typer(help="Publish data to backend")
559
531
  console = Console()
560
532
 
561
533
 
562
- @app.command("providers")
563
- def publish_providers(
564
- data_path: Path | None = typer.Argument(
534
+ @app.callback(invoke_without_command=True)
535
+ def publish_callback(
536
+ ctx: typer.Context,
537
+ data_path: Path | None = typer.Option(
565
538
  None,
566
- help="Path to provider file or directory (default: ./data or UNITYSVC_DATA_DIR env var)",
539
+ "--data-path",
540
+ "-d",
541
+ help="Path to data directory (default: current directory)",
567
542
  ),
568
- backend_url: str | None = typer.Option(
569
- None,
570
- "--backend-url",
571
- "-u",
572
- help="UnitySVC backend URL (default: from UNITYSVC_BACKEND_URL env var)",
573
- ),
574
- api_key: str | None = typer.Option(
543
+ ):
544
+ """
545
+ Publish data to backend.
546
+
547
+ When called without a subcommand, publishes all data types in order:
548
+ sellers → providers → offerings → listings.
549
+
550
+ Use subcommands to publish specific data types:
551
+ - providers: Publish only providers
552
+ - sellers: Publish only sellers
553
+ - offerings: Publish only service offerings
554
+ - listings: Publish only service listings
555
+
556
+ Required environment variables:
557
+ - UNITYSVC_BASE_URL: Backend API URL
558
+ - UNITYSVC_API_KEY: API key for authentication
559
+ """
560
+ # If a subcommand was invoked, skip this callback logic
561
+ if ctx.invoked_subcommand is not None:
562
+ return
563
+
564
+ # No subcommand - publish all
565
+ # Set data path
566
+ if data_path is None:
567
+ data_path = Path.cwd()
568
+
569
+ if not data_path.is_absolute():
570
+ data_path = Path.cwd() / data_path
571
+
572
+ if not data_path.exists():
573
+ console.print(f"[red]✗[/red] Path not found: {data_path}", style="bold red")
574
+ raise typer.Exit(code=1)
575
+
576
+ # Get backend URL from environment
577
+ backend_url = os.getenv("UNITYSVC_BASE_URL")
578
+ if not backend_url:
579
+ console.print(
580
+ "[red]✗[/red] UNITYSVC_BASE_URL environment variable not set.",
581
+ style="bold red",
582
+ )
583
+ raise typer.Exit(code=1)
584
+
585
+ # Get API key from environment
586
+ api_key = os.getenv("UNITYSVC_API_KEY")
587
+ if not api_key:
588
+ console.print(
589
+ "[red]✗[/red] UNITYSVC_API_KEY environment variable not set.",
590
+ style="bold red",
591
+ )
592
+ raise typer.Exit(code=1)
593
+
594
+ console.print(f"[bold blue]Publishing all data from:[/bold blue] {data_path}")
595
+ console.print(f"[bold blue]Backend URL:[/bold blue] {backend_url}\n")
596
+
597
+ try:
598
+ with ServiceDataPublisher(backend_url, api_key) as publisher:
599
+ # Call the publish_all_models method
600
+ all_results = publisher.publish_all_models(data_path)
601
+
602
+ # Display results for each data type
603
+ data_type_display_names = {
604
+ "sellers": "Sellers",
605
+ "providers": "Providers",
606
+ "offerings": "Service Offerings",
607
+ "listings": "Service Listings",
608
+ }
609
+
610
+ for data_type in ["sellers", "providers", "offerings", "listings"]:
611
+ display_name = data_type_display_names[data_type]
612
+ results = all_results[data_type]
613
+
614
+ console.print(f"\n[bold cyan]{'=' * 60}[/bold cyan]")
615
+ console.print(f"[bold cyan]{display_name}[/bold cyan]")
616
+ console.print(f"[bold cyan]{'=' * 60}[/bold cyan]\n")
617
+
618
+ console.print(f" Total found: {results['total']}")
619
+ console.print(f" [green]✓ Success:[/green] {results['success']}")
620
+ console.print(f" [red]✗ Failed:[/red] {results['failed']}")
621
+
622
+ # Display errors if any
623
+ if results.get("errors"):
624
+ console.print(f"\n[bold red]Errors in {display_name}:[/bold red]")
625
+ for error in results["errors"]:
626
+ # Check if this is a skipped item
627
+ if isinstance(error, dict) and error.get("error", "").startswith("skipped"):
628
+ continue
629
+ console.print(f" [red]✗[/red] {error.get('file', 'unknown')}")
630
+ console.print(f" {error.get('error', 'unknown error')}")
631
+
632
+ # Final summary
633
+ console.print(f"\n[bold cyan]{'=' * 60}[/bold cyan]")
634
+ console.print("[bold]Final Publishing Summary[/bold]")
635
+ console.print(f"[bold cyan]{'=' * 60}[/bold cyan]\n")
636
+ console.print(f" Total found: {all_results['total_found']}")
637
+ console.print(f" [green]✓ Success:[/green] {all_results['total_success']}")
638
+ console.print(f" [red]✗ Failed:[/red] {all_results['total_failed']}")
639
+
640
+ if all_results["total_failed"] > 0:
641
+ console.print(
642
+ f"\n[yellow]⚠[/yellow] Completed with {all_results['total_failed']} failure(s)",
643
+ style="bold yellow",
644
+ )
645
+ raise typer.Exit(code=1)
646
+ else:
647
+ console.print(
648
+ "\n[green]✓[/green] All data published successfully!",
649
+ style="bold green",
650
+ )
651
+
652
+ except typer.Exit:
653
+ raise
654
+ except Exception as e:
655
+ console.print(f"[red]✗[/red] Failed to publish all data: {e}", style="bold red")
656
+ raise typer.Exit(code=1)
657
+
658
+
659
+ @app.command("providers")
660
+ def publish_providers(
661
+ data_path: Path | None = typer.Option(
575
662
  None,
576
- "--api-key",
577
- "-k",
578
- help="API key for authentication (default: from UNITYSVC_API_KEY env var)",
663
+ "--data-path",
664
+ "-d",
665
+ help="Path to provider file or directory (default: current directory)",
579
666
  ),
580
667
  ):
581
668
  """Publish provider(s) from a file or directory."""
582
- import os
583
669
 
584
670
  # Set data path
585
671
  if data_path is None:
586
- data_path_str = os.getenv("UNITYSVC_DATA_DIR")
587
- if data_path_str:
588
- data_path = Path(data_path_str)
589
- else:
590
- data_path = Path.cwd() / "data"
672
+ data_path = Path.cwd()
591
673
 
592
674
  if not data_path.is_absolute():
593
675
  data_path = Path.cwd() / data_path
@@ -596,20 +678,20 @@ def publish_providers(
596
678
  console.print(f"[red]✗[/red] Path not found: {data_path}", style="bold red")
597
679
  raise typer.Exit(code=1)
598
680
 
599
- # Get backend URL from argument or environment
600
- backend_url = backend_url or os.getenv("UNITYSVC_BACKEND_URL")
681
+ # Get backend URL from environment
682
+ backend_url = os.getenv("UNITYSVC_BASE_URL")
601
683
  if not backend_url:
602
684
  console.print(
603
- "[red]✗[/red] Backend URL not provided. Use --backend-url or set UNITYSVC_BACKEND_URL env var.",
685
+ "[red]✗[/red] UNITYSVC_BASE_URL environment variable not set.",
604
686
  style="bold red",
605
687
  )
606
688
  raise typer.Exit(code=1)
607
689
 
608
- # Get API key from argument or environment
609
- api_key = api_key or os.getenv("UNITYSVC_API_KEY")
690
+ # Get API key from environment
691
+ api_key = os.getenv("UNITYSVC_API_KEY")
610
692
  if not api_key:
611
693
  console.print(
612
- "[red]✗[/red] API key not provided. Use --api-key or set UNITYSVC_API_KEY env var.",
694
+ "[red]✗[/red] UNITYSVC_API_KEY environment variable not set.",
613
695
  style="bold red",
614
696
  )
615
697
  raise typer.Exit(code=1)
@@ -654,33 +736,17 @@ def publish_providers(
654
736
 
655
737
  @app.command("sellers")
656
738
  def publish_sellers(
657
- data_path: Path | None = typer.Argument(
658
- None,
659
- help="Path to seller file or directory (default: ./data or UNITYSVC_DATA_DIR env var)",
660
- ),
661
- backend_url: str | None = typer.Option(
662
- None,
663
- "--backend-url",
664
- "-u",
665
- help="UnitySVC backend URL (default: from UNITYSVC_BACKEND_URL env var)",
666
- ),
667
- api_key: str | None = typer.Option(
739
+ data_path: Path | None = typer.Option(
668
740
  None,
669
- "--api-key",
670
- "-k",
671
- help="API key for authentication (default: from UNITYSVC_API_KEY env var)",
741
+ "--data-path",
742
+ "-d",
743
+ help="Path to seller file or directory (default: current directory)",
672
744
  ),
673
745
  ):
674
746
  """Publish seller(s) from a file or directory."""
675
- import os
676
-
677
747
  # Set data path
678
748
  if data_path is None:
679
- data_path_str = os.getenv("UNITYSVC_DATA_DIR")
680
- if data_path_str:
681
- data_path = Path(data_path_str)
682
- else:
683
- data_path = Path.cwd() / "data"
749
+ data_path = Path.cwd()
684
750
 
685
751
  if not data_path.is_absolute():
686
752
  data_path = Path.cwd() / data_path
@@ -689,20 +755,20 @@ def publish_sellers(
689
755
  console.print(f"[red]✗[/red] Path not found: {data_path}", style="bold red")
690
756
  raise typer.Exit(code=1)
691
757
 
692
- # Get backend URL
693
- backend_url = backend_url or os.getenv("UNITYSVC_BACKEND_URL")
758
+ # Get backend URL from environment
759
+ backend_url = os.getenv("UNITYSVC_BASE_URL")
694
760
  if not backend_url:
695
761
  console.print(
696
- "[red]✗[/red] Backend URL not provided. Use --backend-url or set UNITYSVC_BACKEND_URL env var.",
762
+ "[red]✗[/red] UNITYSVC_BASE_URL environment variable not set.",
697
763
  style="bold red",
698
764
  )
699
765
  raise typer.Exit(code=1)
700
766
 
701
- # Get API key
702
- api_key = api_key or os.getenv("UNITYSVC_API_KEY")
767
+ # Get API key from environment
768
+ api_key = os.getenv("UNITYSVC_API_KEY")
703
769
  if not api_key:
704
770
  console.print(
705
- "[red]✗[/red] API key not provided. Use --api-key or set UNITYSVC_API_KEY env var.",
771
+ "[red]✗[/red] UNITYSVC_API_KEY environment variable not set.",
706
772
  style="bold red",
707
773
  )
708
774
  raise typer.Exit(code=1)
@@ -745,33 +811,17 @@ def publish_sellers(
745
811
 
746
812
  @app.command("offerings")
747
813
  def publish_offerings(
748
- data_path: Path | None = typer.Argument(
749
- None,
750
- help="Path to service offering file or directory (default: ./data or UNITYSVC_DATA_DIR env var)",
751
- ),
752
- backend_url: str | None = typer.Option(
814
+ data_path: Path | None = typer.Option(
753
815
  None,
754
- "--backend-url",
755
- "-u",
756
- help="UnitySVC backend URL (default: from UNITYSVC_BACKEND_URL env var)",
757
- ),
758
- api_key: str | None = typer.Option(
759
- None,
760
- "--api-key",
761
- "-k",
762
- help="API key for authentication (default: from UNITYSVC_API_KEY env var)",
816
+ "--data-path",
817
+ "-d",
818
+ help="Path to service offering file or directory (default: current directory)",
763
819
  ),
764
820
  ):
765
821
  """Publish service offering(s) from a file or directory."""
766
- import os
767
-
768
822
  # Set data path
769
823
  if data_path is None:
770
- data_path_str = os.getenv("UNITYSVC_DATA_DIR")
771
- if data_path_str:
772
- data_path = Path(data_path_str)
773
- else:
774
- data_path = Path.cwd() / "data"
824
+ data_path = Path.cwd()
775
825
 
776
826
  if not data_path.is_absolute():
777
827
  data_path = Path.cwd() / data_path
@@ -780,20 +830,20 @@ def publish_offerings(
780
830
  console.print(f"[red]✗[/red] Path not found: {data_path}", style="bold red")
781
831
  raise typer.Exit(code=1)
782
832
 
783
- # Get backend URL from argument or environment
784
- backend_url = backend_url or os.getenv("UNITYSVC_BACKEND_URL")
833
+ # Get backend URL from environment
834
+ backend_url = os.getenv("UNITYSVC_BASE_URL")
785
835
  if not backend_url:
786
836
  console.print(
787
- "[red]✗[/red] Backend URL not provided. Use --backend-url or set UNITYSVC_BACKEND_URL env var.",
837
+ "[red]✗[/red] UNITYSVC_BASE_URL environment variable not set.",
788
838
  style="bold red",
789
839
  )
790
840
  raise typer.Exit(code=1)
791
841
 
792
- # Get API key from argument or environment
793
- api_key = api_key or os.getenv("UNITYSVC_API_KEY")
842
+ # Get API key from environment
843
+ api_key = os.getenv("UNITYSVC_API_KEY")
794
844
  if not api_key:
795
845
  console.print(
796
- "[red]✗[/red] API key not provided. Use --api-key or set UNITYSVC_API_KEY env var.",
846
+ "[red]✗[/red] UNITYSVC_API_KEY environment variable not set.",
797
847
  style="bold red",
798
848
  )
799
849
  raise typer.Exit(code=1)
@@ -836,33 +886,18 @@ def publish_offerings(
836
886
 
837
887
  @app.command("listings")
838
888
  def publish_listings(
839
- data_path: Path | None = typer.Argument(
889
+ data_path: Path | None = typer.Option(
840
890
  None,
841
- help="Path to service listing file or directory (default: ./data or UNITYSVC_DATA_DIR env var)",
842
- ),
843
- backend_url: str | None = typer.Option(
844
- None,
845
- "--backend-url",
846
- "-u",
847
- help="UnitySVC backend URL (default: from UNITYSVC_BACKEND_URL env var)",
848
- ),
849
- api_key: str | None = typer.Option(
850
- None,
851
- "--api-key",
852
- "-k",
853
- help="API key for authentication (default: from UNITYSVC_API_KEY env var)",
891
+ "--data-path",
892
+ "-d",
893
+ help="Path to service listing file or directory (default: current directory)",
854
894
  ),
855
895
  ):
856
896
  """Publish service listing(s) from a file or directory."""
857
- import os
858
897
 
859
898
  # Set data path
860
899
  if data_path is None:
861
- data_path_str = os.getenv("UNITYSVC_DATA_DIR")
862
- if data_path_str:
863
- data_path = Path(data_path_str)
864
- else:
865
- data_path = Path.cwd() / "data"
900
+ data_path = Path.cwd()
866
901
 
867
902
  if not data_path.is_absolute():
868
903
  data_path = Path.cwd() / data_path
@@ -871,20 +906,20 @@ def publish_listings(
871
906
  console.print(f"[red]✗[/red] Path not found: {data_path}", style="bold red")
872
907
  raise typer.Exit(code=1)
873
908
 
874
- # Get backend URL from argument or environment
875
- backend_url = backend_url or os.getenv("UNITYSVC_BACKEND_URL")
909
+ # Get backend URL from environment
910
+ backend_url = os.getenv("UNITYSVC_BASE_URL")
876
911
  if not backend_url:
877
912
  console.print(
878
- "[red]✗[/red] Backend URL not provided. Use --backend-url or set UNITYSVC_BACKEND_URL env var.",
913
+ "[red]✗[/red] UNITYSVC_BASE_URL environment variable not set.",
879
914
  style="bold red",
880
915
  )
881
916
  raise typer.Exit(code=1)
882
917
 
883
- # Get API key from argument or environment
884
- api_key = api_key or os.getenv("UNITYSVC_API_KEY")
918
+ # Get API key from environment
919
+ api_key = os.getenv("UNITYSVC_API_KEY")
885
920
  if not api_key:
886
921
  console.print(
887
- "[red]✗[/red] API key not provided. Use --api-key or set UNITYSVC_API_KEY env var.",
922
+ "[red]✗[/red] UNITYSVC_API_KEY environment variable not set.",
888
923
  style="bold red",
889
924
  )
890
925
  raise typer.Exit(code=1)