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.
- unitysvc_services/format_data.py +2 -7
- unitysvc_services/list.py +14 -43
- unitysvc_services/models/base.py +16 -0
- unitysvc_services/models/listing_v1.py +1 -3
- unitysvc_services/models/provider_v1.py +7 -1
- unitysvc_services/models/seller_v1.py +5 -5
- unitysvc_services/populate.py +2 -6
- unitysvc_services/publisher.py +328 -293
- unitysvc_services/query.py +18 -98
- unitysvc_services/update.py +10 -14
- unitysvc_services/utils.py +105 -7
- unitysvc_services/validator.py +137 -9
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.2.0.dist-info}/METADATA +38 -38
- unitysvc_services-0.2.0.dist-info/RECORD +23 -0
- unitysvc_services-0.1.0.dist-info/RECORD +0 -23
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.2.0.dist-info}/WHEEL +0 -0
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.2.0.dist-info}/entry_points.txt +0 -0
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.2.0.dist-info}/top_level.txt +0 -0
unitysvc_services/publisher.py
CHANGED
@@ -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
|
-
|
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
|
-
|
191
|
+
service_files = find_files_by_schema(data_file.parent, "service_v1", field_filter=(("name", service_name),))
|
179
192
|
|
180
|
-
|
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
|
-
|
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
|
217
|
+
if not seller_files:
|
219
218
|
raise ValueError(
|
220
|
-
f"Cannot find
|
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
|
-
#
|
225
|
-
seller_data =
|
226
|
-
|
227
|
-
|
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(
|
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
|
-
#
|
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}/
|
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
|
-
#
|
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}/
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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] = {
|
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.
|
563
|
-
def
|
564
|
-
|
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
|
-
|
539
|
+
"--data-path",
|
540
|
+
"-d",
|
541
|
+
help="Path to data directory (default: current directory)",
|
567
542
|
),
|
568
|
-
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
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
|
-
"--
|
577
|
-
"-
|
578
|
-
help="
|
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
|
-
|
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
|
600
|
-
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]
|
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
|
609
|
-
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]
|
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.
|
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
|
-
"--
|
670
|
-
"-
|
671
|
-
help="
|
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
|
-
|
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 =
|
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]
|
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 =
|
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]
|
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.
|
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
|
-
"--
|
755
|
-
"-
|
756
|
-
help="
|
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
|
-
|
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
|
784
|
-
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]
|
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
|
793
|
-
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]
|
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.
|
889
|
+
data_path: Path | None = typer.Option(
|
840
890
|
None,
|
841
|
-
|
842
|
-
|
843
|
-
|
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
|
-
|
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
|
875
|
-
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]
|
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
|
884
|
-
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]
|
922
|
+
"[red]✗[/red] UNITYSVC_API_KEY environment variable not set.",
|
888
923
|
style="bold red",
|
889
924
|
)
|
890
925
|
raise typer.Exit(code=1)
|