unitysvc-services 0.1.0__py3-none-any.whl → 0.1.4__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/api.py +278 -0
- unitysvc_services/format_data.py +2 -7
- unitysvc_services/list.py +14 -43
- unitysvc_services/models/base.py +139 -0
- unitysvc_services/models/listing_v1.py +23 -3
- unitysvc_services/models/provider_v1.py +23 -2
- unitysvc_services/models/seller_v1.py +12 -6
- unitysvc_services/models/service_v1.py +8 -1
- unitysvc_services/populate.py +2 -6
- unitysvc_services/publisher.py +732 -467
- unitysvc_services/py.typed +0 -0
- unitysvc_services/query.py +521 -318
- unitysvc_services/update.py +10 -14
- unitysvc_services/utils.py +105 -7
- unitysvc_services/validator.py +194 -10
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.1.4.dist-info}/METADATA +42 -39
- unitysvc_services-0.1.4.dist-info/RECORD +25 -0
- unitysvc_services-0.1.0.dist-info/RECORD +0 -23
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.1.4.dist-info}/WHEEL +0 -0
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.1.4.dist-info}/entry_points.txt +0 -0
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {unitysvc_services-0.1.0.dist-info → unitysvc_services-0.1.4.dist-info}/top_level.txt +0 -0
unitysvc_services/api.py
ADDED
@@ -0,0 +1,278 @@
|
|
1
|
+
"""Base API client for UnitySVC with automatic curl fallback.
|
2
|
+
|
3
|
+
This module provides the base class for all UnitySVC API clients with
|
4
|
+
automatic network fallback from httpx to curl for systems with network
|
5
|
+
restrictions (e.g., macOS with conda Python).
|
6
|
+
"""
|
7
|
+
|
8
|
+
import asyncio
|
9
|
+
import json
|
10
|
+
import os
|
11
|
+
from typing import Any
|
12
|
+
from urllib.parse import urlencode
|
13
|
+
|
14
|
+
import httpx
|
15
|
+
|
16
|
+
|
17
|
+
class UnitySvcAPI:
|
18
|
+
"""Base class for UnitySVC API clients with automatic curl fallback.
|
19
|
+
|
20
|
+
Provides async HTTP GET/POST methods that try httpx first for performance,
|
21
|
+
then automatically fall back to curl if network restrictions are detected
|
22
|
+
(e.g., macOS with conda Python).
|
23
|
+
|
24
|
+
This base class can be used by:
|
25
|
+
- ServiceDataQuery (query/read operations)
|
26
|
+
- ServiceDataPublisher (publish/write operations)
|
27
|
+
- AdminQuery (administrative operations)
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(self) -> None:
|
31
|
+
"""Initialize API client from environment variables.
|
32
|
+
|
33
|
+
Raises:
|
34
|
+
ValueError: If required environment variables are not set
|
35
|
+
"""
|
36
|
+
self.base_url = os.environ.get("UNITYSVC_BASE_URL")
|
37
|
+
if not self.base_url:
|
38
|
+
raise ValueError("UNITYSVC_BASE_URL environment variable not set")
|
39
|
+
|
40
|
+
self.api_key = os.environ.get("UNITYSVC_API_KEY")
|
41
|
+
if not self.api_key:
|
42
|
+
raise ValueError("UNITYSVC_API_KEY environment variable not set")
|
43
|
+
|
44
|
+
self.base_url = self.base_url.rstrip("/")
|
45
|
+
self.use_curl_fallback = False
|
46
|
+
self.client = httpx.AsyncClient(
|
47
|
+
headers={
|
48
|
+
"X-API-Key": self.api_key,
|
49
|
+
"Content-Type": "application/json",
|
50
|
+
},
|
51
|
+
timeout=30.0,
|
52
|
+
)
|
53
|
+
|
54
|
+
async def _make_request_curl(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
55
|
+
"""Make HTTP GET request using curl fallback (async).
|
56
|
+
|
57
|
+
Args:
|
58
|
+
endpoint: API endpoint path (e.g., "/publish/sellers")
|
59
|
+
params: Query parameters
|
60
|
+
|
61
|
+
Returns:
|
62
|
+
JSON response as dictionary
|
63
|
+
|
64
|
+
Raises:
|
65
|
+
RuntimeError: If curl command fails or returns non-200 status
|
66
|
+
"""
|
67
|
+
url = f"{self.base_url}{endpoint}"
|
68
|
+
if params:
|
69
|
+
url = f"{url}?{urlencode(params)}"
|
70
|
+
|
71
|
+
cmd = [
|
72
|
+
"curl",
|
73
|
+
"-s", # Silent mode
|
74
|
+
"-f", # Fail on HTTP errors
|
75
|
+
"-H",
|
76
|
+
f"X-API-Key: {self.api_key}",
|
77
|
+
"-H",
|
78
|
+
"Accept: application/json",
|
79
|
+
url,
|
80
|
+
]
|
81
|
+
|
82
|
+
try:
|
83
|
+
proc = await asyncio.create_subprocess_exec(
|
84
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
85
|
+
)
|
86
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
|
87
|
+
|
88
|
+
if proc.returncode != 0:
|
89
|
+
error_msg = stderr.decode().strip() if stderr else "Unknown error"
|
90
|
+
raise RuntimeError(f"HTTP request failed: {error_msg}")
|
91
|
+
|
92
|
+
output = stdout.decode().strip()
|
93
|
+
return json.loads(output)
|
94
|
+
except TimeoutError:
|
95
|
+
raise RuntimeError("Request timed out after 30 seconds")
|
96
|
+
except json.JSONDecodeError as e:
|
97
|
+
raise RuntimeError(f"Invalid JSON response: {e}")
|
98
|
+
|
99
|
+
async def _make_post_request_curl(
|
100
|
+
self, endpoint: str, json_data: dict[str, Any] | None = None, params: dict[str, Any] | None = None
|
101
|
+
) -> dict[str, Any]:
|
102
|
+
"""Make HTTP POST request using curl fallback (async).
|
103
|
+
|
104
|
+
Args:
|
105
|
+
endpoint: API endpoint path (e.g., "/admin/subscriptions")
|
106
|
+
json_data: JSON body data
|
107
|
+
params: Query parameters
|
108
|
+
|
109
|
+
Returns:
|
110
|
+
JSON response as dictionary
|
111
|
+
|
112
|
+
Raises:
|
113
|
+
RuntimeError: If curl command fails or returns non-200 status
|
114
|
+
"""
|
115
|
+
url = f"{self.base_url}{endpoint}"
|
116
|
+
if params:
|
117
|
+
url = f"{url}?{urlencode(params)}"
|
118
|
+
|
119
|
+
cmd = [
|
120
|
+
"curl",
|
121
|
+
"-s", # Silent mode
|
122
|
+
"-f", # Fail on HTTP errors
|
123
|
+
"-X",
|
124
|
+
"POST",
|
125
|
+
"-H",
|
126
|
+
f"X-API-Key: {self.api_key}",
|
127
|
+
"-H",
|
128
|
+
"Content-Type: application/json",
|
129
|
+
"-H",
|
130
|
+
"Accept: application/json",
|
131
|
+
]
|
132
|
+
|
133
|
+
if json_data:
|
134
|
+
cmd.extend(["-d", json.dumps(json_data)])
|
135
|
+
|
136
|
+
cmd.append(url)
|
137
|
+
|
138
|
+
try:
|
139
|
+
proc = await asyncio.create_subprocess_exec(
|
140
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
141
|
+
)
|
142
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
|
143
|
+
|
144
|
+
if proc.returncode != 0:
|
145
|
+
error_msg = stderr.decode().strip() if stderr else "Unknown error"
|
146
|
+
raise RuntimeError(f"HTTP request failed: {error_msg}")
|
147
|
+
|
148
|
+
output = stdout.decode().strip()
|
149
|
+
return json.loads(output)
|
150
|
+
except TimeoutError:
|
151
|
+
raise RuntimeError("Request timed out after 30 seconds")
|
152
|
+
except json.JSONDecodeError as e:
|
153
|
+
raise RuntimeError(f"Invalid JSON response: {e}")
|
154
|
+
|
155
|
+
async def get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
156
|
+
"""Make a GET request to the backend API with automatic curl fallback.
|
157
|
+
|
158
|
+
Public async utility method for making GET requests. Tries httpx first for performance,
|
159
|
+
automatically falls back to curl if network restrictions are detected (e.g., macOS
|
160
|
+
with conda Python).
|
161
|
+
|
162
|
+
Args:
|
163
|
+
endpoint: API endpoint path (e.g., "/publish/sellers", "/admin/documents")
|
164
|
+
params: Query parameters
|
165
|
+
|
166
|
+
Returns:
|
167
|
+
JSON response as dictionary
|
168
|
+
|
169
|
+
Raises:
|
170
|
+
RuntimeError: If both httpx and curl fail
|
171
|
+
"""
|
172
|
+
# If we already know curl is needed, use it directly
|
173
|
+
if self.use_curl_fallback:
|
174
|
+
return await self._make_request_curl(endpoint, params)
|
175
|
+
|
176
|
+
# Try httpx first
|
177
|
+
try:
|
178
|
+
response = await self.client.get(f"{self.base_url}{endpoint}", params=params)
|
179
|
+
response.raise_for_status()
|
180
|
+
return response.json()
|
181
|
+
except (httpx.ConnectError, OSError):
|
182
|
+
# Connection failed - likely network restrictions
|
183
|
+
# Fall back to curl and remember this for future requests
|
184
|
+
self.use_curl_fallback = True
|
185
|
+
return await self._make_request_curl(endpoint, params)
|
186
|
+
|
187
|
+
async def post(
|
188
|
+
self, endpoint: str, json_data: dict[str, Any] | None = None, params: dict[str, Any] | None = None
|
189
|
+
) -> dict[str, Any]:
|
190
|
+
"""Make a POST request to the backend API with automatic curl fallback.
|
191
|
+
|
192
|
+
Public async utility method for making POST requests. Tries httpx first for performance,
|
193
|
+
automatically falls back to curl if network restrictions are detected (e.g., macOS
|
194
|
+
with conda Python).
|
195
|
+
|
196
|
+
Args:
|
197
|
+
endpoint: API endpoint path (e.g., "/admin/subscriptions")
|
198
|
+
json_data: JSON body data
|
199
|
+
params: Query parameters
|
200
|
+
|
201
|
+
Returns:
|
202
|
+
JSON response as dictionary
|
203
|
+
|
204
|
+
Raises:
|
205
|
+
RuntimeError: If both httpx and curl fail
|
206
|
+
"""
|
207
|
+
# If we already know curl is needed, use it directly
|
208
|
+
if self.use_curl_fallback:
|
209
|
+
return await self._make_post_request_curl(endpoint, json_data, params)
|
210
|
+
|
211
|
+
# Try httpx first
|
212
|
+
try:
|
213
|
+
response = await self.client.post(f"{self.base_url}{endpoint}", json=json_data, params=params)
|
214
|
+
response.raise_for_status()
|
215
|
+
return response.json()
|
216
|
+
except (httpx.ConnectError, OSError):
|
217
|
+
# Connection failed - likely network restrictions
|
218
|
+
# Fall back to curl and remember this for future requests
|
219
|
+
self.use_curl_fallback = True
|
220
|
+
return await self._make_post_request_curl(endpoint, json_data, params)
|
221
|
+
|
222
|
+
async def check_task(self, task_id: str, poll_interval: float = 2.0, timeout: float = 300.0) -> dict[str, Any]:
|
223
|
+
"""Check and wait for task completion (async version).
|
224
|
+
|
225
|
+
Utility function to poll a Celery task until it completes or times out.
|
226
|
+
Uses the async HTTP client with curl fallback.
|
227
|
+
|
228
|
+
Args:
|
229
|
+
task_id: Celery task ID to poll
|
230
|
+
poll_interval: Seconds between status checks (default: 2.0)
|
231
|
+
timeout: Maximum seconds to wait (default: 300.0)
|
232
|
+
|
233
|
+
Returns:
|
234
|
+
Task result dictionary
|
235
|
+
|
236
|
+
Raises:
|
237
|
+
ValueError: If task fails or times out
|
238
|
+
"""
|
239
|
+
import time
|
240
|
+
|
241
|
+
start_time = time.time()
|
242
|
+
|
243
|
+
while True:
|
244
|
+
elapsed = time.time() - start_time
|
245
|
+
if elapsed > timeout:
|
246
|
+
raise ValueError(f"Task {task_id} timed out after {timeout}s")
|
247
|
+
|
248
|
+
# Check task status using get() with automatic curl fallback
|
249
|
+
try:
|
250
|
+
status = await self.get(f"/tasks/{task_id}")
|
251
|
+
except Exception:
|
252
|
+
# Network error while checking status - retry
|
253
|
+
await asyncio.sleep(poll_interval)
|
254
|
+
continue
|
255
|
+
|
256
|
+
state = status.get("state", "PENDING")
|
257
|
+
|
258
|
+
# Check if task is complete
|
259
|
+
if status.get("status") == "completed" or state == "SUCCESS":
|
260
|
+
return status.get("result", {})
|
261
|
+
elif status.get("status") == "failed" or state == "FAILURE":
|
262
|
+
error = status.get("error", "Unknown error")
|
263
|
+
raise ValueError(f"Task {task_id} failed: {error}")
|
264
|
+
|
265
|
+
# Still processing - wait and retry
|
266
|
+
await asyncio.sleep(poll_interval)
|
267
|
+
|
268
|
+
async def aclose(self):
|
269
|
+
"""Close the HTTP client."""
|
270
|
+
await self.client.aclose()
|
271
|
+
|
272
|
+
async def __aenter__(self):
|
273
|
+
"""Async context manager entry."""
|
274
|
+
return self
|
275
|
+
|
276
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
277
|
+
"""Async context manager exit."""
|
278
|
+
await self.aclose()
|
unitysvc_services/format_data.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
"""Format command - format data files."""
|
2
2
|
|
3
|
-
import os
|
4
3
|
from pathlib import Path
|
5
4
|
|
6
5
|
import typer
|
@@ -14,7 +13,7 @@ console = Console()
|
|
14
13
|
def format_data(
|
15
14
|
data_dir: Path | None = typer.Argument(
|
16
15
|
None,
|
17
|
-
help="Directory containing data files to format (default:
|
16
|
+
help="Directory containing data files to format (default: current directory)",
|
18
17
|
),
|
19
18
|
check_only: bool = typer.Option(
|
20
19
|
False,
|
@@ -35,11 +34,7 @@ def format_data(
|
|
35
34
|
|
36
35
|
# Set data directory
|
37
36
|
if data_dir is None:
|
38
|
-
|
39
|
-
if data_dir_str:
|
40
|
-
data_dir = Path(data_dir_str)
|
41
|
-
else:
|
42
|
-
data_dir = Path.cwd() / "data"
|
37
|
+
data_dir = Path.cwd()
|
43
38
|
|
44
39
|
if not data_dir.is_absolute():
|
45
40
|
data_dir = Path.cwd() / data_dir
|
unitysvc_services/list.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
"""List command group - list local data files."""
|
2
2
|
|
3
|
-
import os
|
4
3
|
from pathlib import Path
|
5
4
|
|
6
5
|
import typer
|
@@ -21,25 +20,19 @@ console = Console()
|
|
21
20
|
def list_providers(
|
22
21
|
data_dir: Path | None = typer.Argument(
|
23
22
|
None,
|
24
|
-
help="Directory containing provider files (default:
|
23
|
+
help="Directory containing provider files (default: current directory)",
|
25
24
|
),
|
26
25
|
):
|
27
26
|
"""List all provider files found in the data directory."""
|
28
27
|
# Set data directory
|
29
28
|
if data_dir is None:
|
30
|
-
|
31
|
-
if data_dir_str:
|
32
|
-
data_dir = Path(data_dir_str)
|
33
|
-
else:
|
34
|
-
data_dir = Path.cwd() / "data"
|
29
|
+
data_dir = Path.cwd()
|
35
30
|
|
36
31
|
if not data_dir.is_absolute():
|
37
32
|
data_dir = Path.cwd() / data_dir
|
38
33
|
|
39
34
|
if not data_dir.exists():
|
40
|
-
console.print(
|
41
|
-
f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red"
|
42
|
-
)
|
35
|
+
console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
|
43
36
|
raise typer.Exit(code=1)
|
44
37
|
|
45
38
|
console.print(f"[blue]Searching for providers in:[/blue] {data_dir}\n")
|
@@ -72,25 +65,19 @@ def list_providers(
|
|
72
65
|
def list_sellers(
|
73
66
|
data_dir: Path | None = typer.Argument(
|
74
67
|
None,
|
75
|
-
help="Directory containing seller files (default:
|
68
|
+
help="Directory containing seller files (default: current directory)",
|
76
69
|
),
|
77
70
|
):
|
78
71
|
"""List all seller files found in the data directory."""
|
79
72
|
# Set data directory
|
80
73
|
if data_dir is None:
|
81
|
-
|
82
|
-
if data_dir_str:
|
83
|
-
data_dir = Path(data_dir_str)
|
84
|
-
else:
|
85
|
-
data_dir = Path.cwd() / "data"
|
74
|
+
data_dir = Path.cwd()
|
86
75
|
|
87
76
|
if not data_dir.is_absolute():
|
88
77
|
data_dir = Path.cwd() / data_dir
|
89
78
|
|
90
79
|
if not data_dir.exists():
|
91
|
-
console.print(
|
92
|
-
f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red"
|
93
|
-
)
|
80
|
+
console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
|
94
81
|
raise typer.Exit(code=1)
|
95
82
|
|
96
83
|
console.print(f"[blue]Searching for sellers in:[/blue] {data_dir}\n")
|
@@ -123,25 +110,19 @@ def list_sellers(
|
|
123
110
|
def list_offerings(
|
124
111
|
data_dir: Path | None = typer.Argument(
|
125
112
|
None,
|
126
|
-
help="Directory containing service files (default:
|
113
|
+
help="Directory containing service files (default: current directory)",
|
127
114
|
),
|
128
115
|
):
|
129
116
|
"""List all service offering files found in the data directory."""
|
130
117
|
# Set data directory
|
131
118
|
if data_dir is None:
|
132
|
-
|
133
|
-
if data_dir_str:
|
134
|
-
data_dir = Path(data_dir_str)
|
135
|
-
else:
|
136
|
-
data_dir = Path.cwd() / "data"
|
119
|
+
data_dir = Path.cwd()
|
137
120
|
|
138
121
|
if not data_dir.is_absolute():
|
139
122
|
data_dir = Path.cwd() / data_dir
|
140
123
|
|
141
124
|
if not data_dir.exists():
|
142
|
-
console.print(
|
143
|
-
f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red"
|
144
|
-
)
|
125
|
+
console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
|
145
126
|
raise typer.Exit(code=1)
|
146
127
|
|
147
128
|
console.print(f"[blue]Searching for service offerings in:[/blue] {data_dir}\n")
|
@@ -172,34 +153,26 @@ def list_offerings(
|
|
172
153
|
)
|
173
154
|
|
174
155
|
console.print(table)
|
175
|
-
console.print(
|
176
|
-
f"\n[green]Total:[/green] {len(service_files)} service offering file(s)"
|
177
|
-
)
|
156
|
+
console.print(f"\n[green]Total:[/green] {len(service_files)} service offering file(s)")
|
178
157
|
|
179
158
|
|
180
159
|
@app.command("listings")
|
181
160
|
def list_listings(
|
182
161
|
data_dir: Path | None = typer.Argument(
|
183
162
|
None,
|
184
|
-
help="Directory containing listing files (default:
|
163
|
+
help="Directory containing listing files (default: current directory)",
|
185
164
|
),
|
186
165
|
):
|
187
166
|
"""List all service listing files found in the data directory."""
|
188
167
|
# Set data directory
|
189
168
|
if data_dir is None:
|
190
|
-
|
191
|
-
if data_dir_str:
|
192
|
-
data_dir = Path(data_dir_str)
|
193
|
-
else:
|
194
|
-
data_dir = Path.cwd() / "data"
|
169
|
+
data_dir = Path.cwd()
|
195
170
|
|
196
171
|
if not data_dir.is_absolute():
|
197
172
|
data_dir = Path.cwd() / data_dir
|
198
173
|
|
199
174
|
if not data_dir.exists():
|
200
|
-
console.print(
|
201
|
-
f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red"
|
202
|
-
)
|
175
|
+
console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
|
203
176
|
raise typer.Exit(code=1)
|
204
177
|
|
205
178
|
console.print(f"[blue]Searching for service listings in:[/blue] {data_dir}\n")
|
@@ -240,6 +213,4 @@ def list_listings(
|
|
240
213
|
)
|
241
214
|
|
242
215
|
console.print(table)
|
243
|
-
console.print(
|
244
|
-
f"\n[green]Total:[/green] {len(listing_files)} service listing file(s)"
|
245
|
-
)
|
216
|
+
console.print(f"\n[green]Total:[/green] {len(listing_files)} service listing file(s)")
|
unitysvc_services/models/base.py
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
import re
|
1
2
|
from enum import StrEnum
|
2
3
|
from typing import Any
|
3
4
|
|
@@ -206,6 +207,24 @@ class UpstreamStatusEnum(StrEnum):
|
|
206
207
|
deprecated = "deprecated"
|
207
208
|
|
208
209
|
|
210
|
+
class ProviderStatusEnum(StrEnum):
|
211
|
+
"""Provider status enum."""
|
212
|
+
|
213
|
+
active = "active"
|
214
|
+
pending = "pending"
|
215
|
+
disabled = "disabled"
|
216
|
+
incomplete = "incomplete" # Provider information is incomplete
|
217
|
+
|
218
|
+
|
219
|
+
class SellerStatusEnum(StrEnum):
|
220
|
+
"""Seller status enum."""
|
221
|
+
|
222
|
+
active = "active"
|
223
|
+
pending = "pending"
|
224
|
+
disabled = "disabled"
|
225
|
+
incomplete = "incomplete" # Seller information is incomplete
|
226
|
+
|
227
|
+
|
209
228
|
class Document(BaseModel):
|
210
229
|
model_config = ConfigDict(extra="forbid")
|
211
230
|
|
@@ -350,3 +369,123 @@ class Pricing(BaseModel):
|
|
350
369
|
|
351
370
|
# Optional reference to upstream pricing
|
352
371
|
reference: str | None = Field(default=None, description="Reference URL to upstream pricing")
|
372
|
+
|
373
|
+
|
374
|
+
def validate_name(name: str, entity_type: str, display_name: str | None = None, *, allow_slash: bool = False) -> str:
|
375
|
+
"""
|
376
|
+
Validate that a name field uses valid identifiers.
|
377
|
+
|
378
|
+
Name format rules:
|
379
|
+
- Only letters (upper/lowercase), numbers, dots, dashes, and underscores allowed
|
380
|
+
- If allow_slash=True, slashes are also allowed for hierarchical names
|
381
|
+
- Must start and end with alphanumeric characters (not special characters)
|
382
|
+
- Cannot have consecutive slashes (when allow_slash=True)
|
383
|
+
- Cannot be empty
|
384
|
+
|
385
|
+
Args:
|
386
|
+
name: The name value to validate
|
387
|
+
entity_type: Type of entity (provider, seller, service, listing) for error messages
|
388
|
+
display_name: Optional display name to suggest a valid name from
|
389
|
+
allow_slash: Whether to allow slashes for hierarchical names (default: False)
|
390
|
+
|
391
|
+
Returns:
|
392
|
+
The validated name (unchanged if valid)
|
393
|
+
|
394
|
+
Raises:
|
395
|
+
ValueError: If the name doesn't match the required pattern
|
396
|
+
|
397
|
+
Examples:
|
398
|
+
Without slashes (providers, sellers):
|
399
|
+
- name='amazon-bedrock' or name='Amazon-Bedrock'
|
400
|
+
- name='fireworks.ai' or name='Fireworks.ai'
|
401
|
+
- name='llama-3.1' or name='Llama-3.1'
|
402
|
+
|
403
|
+
With slashes (services, listings):
|
404
|
+
- name='gpt-4' or name='GPT-4'
|
405
|
+
- name='models/gpt-4' or name='models/GPT-4'
|
406
|
+
- name='black-forest-labs/FLUX.1-dev'
|
407
|
+
- name='api/v1/completion'
|
408
|
+
"""
|
409
|
+
# Build pattern based on allow_slash parameter
|
410
|
+
if allow_slash:
|
411
|
+
# Pattern: starts with alphanumeric, can contain alphanumeric/dot/dash/underscore/slash, ends with alphanumeric
|
412
|
+
name_pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9._/-]*[a-zA-Z0-9])?$"
|
413
|
+
allowed_chars = "letters, numbers, dots, dashes, underscores, and slashes"
|
414
|
+
else:
|
415
|
+
# Pattern: starts with alphanumeric, can contain alphanumeric/dot/dash/underscore, ends with alphanumeric
|
416
|
+
name_pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$"
|
417
|
+
allowed_chars = "letters, numbers, dots, dashes, and underscores"
|
418
|
+
|
419
|
+
# Check for consecutive slashes if slashes are allowed
|
420
|
+
if allow_slash and "//" in name:
|
421
|
+
raise ValueError(f"Invalid {entity_type} name '{name}'. Name cannot contain consecutive slashes.")
|
422
|
+
|
423
|
+
if not re.match(name_pattern, name):
|
424
|
+
# Build helpful error message
|
425
|
+
error_msg = (
|
426
|
+
f"Invalid {entity_type} name '{name}'. "
|
427
|
+
f"Name must contain only {allowed_chars}. "
|
428
|
+
f"It must start and end with an alphanumeric character.\n"
|
429
|
+
)
|
430
|
+
|
431
|
+
# Suggest a valid name based on display_name if available
|
432
|
+
if display_name:
|
433
|
+
suggested_name = suggest_valid_name(display_name, allow_slash=allow_slash)
|
434
|
+
if suggested_name and suggested_name != name:
|
435
|
+
error_msg += f" Suggestion: Set name='{suggested_name}' and display_name='{display_name}'\n"
|
436
|
+
|
437
|
+
# Add appropriate examples based on allow_slash
|
438
|
+
if allow_slash:
|
439
|
+
error_msg += (
|
440
|
+
" Examples:\n"
|
441
|
+
" - name='gpt-4' or name='GPT-4'\n"
|
442
|
+
" - name='models/gpt-4' or name='models/GPT-4'\n"
|
443
|
+
" - name='black-forest-labs/FLUX.1-dev'\n"
|
444
|
+
" - name='api/v1/completion'"
|
445
|
+
)
|
446
|
+
else:
|
447
|
+
error_msg += (
|
448
|
+
" Note: Use 'display_name' field for brand names with spaces and special characters.\n"
|
449
|
+
" Examples:\n"
|
450
|
+
" - name='amazon-bedrock' or name='Amazon-Bedrock'\n"
|
451
|
+
" - name='fireworks.ai' or name='Fireworks.ai'\n"
|
452
|
+
" - name='llama-3.1' or name='Llama-3.1'"
|
453
|
+
)
|
454
|
+
|
455
|
+
raise ValueError(error_msg)
|
456
|
+
|
457
|
+
return name
|
458
|
+
|
459
|
+
|
460
|
+
def suggest_valid_name(display_name: str, *, allow_slash: bool = False) -> str:
|
461
|
+
"""
|
462
|
+
Suggest a valid name based on a display name.
|
463
|
+
|
464
|
+
Replaces invalid characters with hyphens and ensures it follows the naming rules.
|
465
|
+
Preserves the original case.
|
466
|
+
|
467
|
+
Args:
|
468
|
+
display_name: The display name to convert
|
469
|
+
allow_slash: Whether to allow slashes for hierarchical names (default: False)
|
470
|
+
|
471
|
+
Returns:
|
472
|
+
A suggested valid name
|
473
|
+
"""
|
474
|
+
if allow_slash:
|
475
|
+
# Replace characters that aren't alphanumeric, dot, dash, underscore, or slash with hyphens
|
476
|
+
suggested = re.sub(r"[^a-zA-Z0-9._/-]+", "-", display_name)
|
477
|
+
# Remove leading/trailing special characters
|
478
|
+
suggested = suggested.strip("._/-")
|
479
|
+
# Collapse multiple consecutive dashes
|
480
|
+
suggested = re.sub(r"-+", "-", suggested)
|
481
|
+
# Remove consecutive slashes
|
482
|
+
suggested = re.sub(r"/+", "/", suggested)
|
483
|
+
else:
|
484
|
+
# Replace characters that aren't alphanumeric, dot, dash, or underscore with hyphens
|
485
|
+
suggested = re.sub(r"[^a-zA-Z0-9._-]+", "-", display_name)
|
486
|
+
# Remove leading/trailing dots, dashes, or underscores
|
487
|
+
suggested = suggested.strip("._-")
|
488
|
+
# Collapse multiple consecutive dashes
|
489
|
+
suggested = re.sub(r"-+", "-", suggested)
|
490
|
+
|
491
|
+
return suggested
|
@@ -1,13 +1,14 @@
|
|
1
1
|
from datetime import datetime
|
2
2
|
from typing import Any
|
3
3
|
|
4
|
-
from pydantic import BaseModel, ConfigDict, Field
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
5
5
|
|
6
6
|
from unitysvc_services.models.base import (
|
7
7
|
AccessInterface,
|
8
8
|
Document,
|
9
9
|
ListingStatusEnum,
|
10
10
|
Pricing,
|
11
|
+
validate_name,
|
11
12
|
)
|
12
13
|
|
13
14
|
|
@@ -30,8 +31,19 @@ class ListingV1(BaseModel):
|
|
30
31
|
),
|
31
32
|
)
|
32
33
|
|
33
|
-
seller_name: str = Field(
|
34
|
-
|
34
|
+
seller_name: str | None = Field(default=None, description="Name of the seller offering this service listing")
|
35
|
+
|
36
|
+
name: str | None = Field(
|
37
|
+
default=None,
|
38
|
+
max_length=255,
|
39
|
+
description="Name identifier for the service listing, default to filename",
|
40
|
+
)
|
41
|
+
|
42
|
+
# Display name for UI (human-readable listing name)
|
43
|
+
display_name: str | None = Field(
|
44
|
+
default=None,
|
45
|
+
max_length=200,
|
46
|
+
description="Human-readable listing name (e.g., 'Premium GPT-4 Access', 'Enterprise AI Services')",
|
35
47
|
)
|
36
48
|
|
37
49
|
# unique name for each provider, usually following upstream naming convention
|
@@ -70,3 +82,11 @@ class ListingV1(BaseModel):
|
|
70
82
|
user_parameters_ui_schema: dict[str, Any] | None = Field(
|
71
83
|
default=None, description="Dictionary of user parameters UI schema"
|
72
84
|
)
|
85
|
+
|
86
|
+
@field_validator("name")
|
87
|
+
@classmethod
|
88
|
+
def validate_name_format(cls, v: str | None) -> str | None:
|
89
|
+
"""Validate that listing name uses valid identifiers (allows slashes for hierarchical names)."""
|
90
|
+
if v is None:
|
91
|
+
return v
|
92
|
+
return validate_name(v, "listing", allow_slash=True)
|
@@ -1,9 +1,9 @@
|
|
1
1
|
from datetime import datetime
|
2
2
|
from typing import Any
|
3
3
|
|
4
|
-
from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl
|
4
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl, field_validator
|
5
5
|
|
6
|
-
from unitysvc_services.models.base import AccessInterface, Document
|
6
|
+
from unitysvc_services.models.base import AccessInterface, Document, ProviderStatusEnum, validate_name
|
7
7
|
|
8
8
|
|
9
9
|
class ProviderV1(BaseModel):
|
@@ -26,6 +26,13 @@ class ProviderV1(BaseModel):
|
|
26
26
|
# name of the provider should be the same as directory name
|
27
27
|
name: str
|
28
28
|
|
29
|
+
# Display name for UI (human-readable brand name)
|
30
|
+
display_name: str | None = Field(
|
31
|
+
default=None,
|
32
|
+
max_length=200,
|
33
|
+
description="Human-readable provider name (e.g., 'Amazon Bedrock', 'Fireworks.ai')",
|
34
|
+
)
|
35
|
+
|
29
36
|
# this field is added for convenience. It will be converted to
|
30
37
|
# documents during importing.
|
31
38
|
logo: str | HttpUrl | None = None
|
@@ -51,3 +58,17 @@ class ProviderV1(BaseModel):
|
|
51
58
|
homepage: HttpUrl
|
52
59
|
contact_email: EmailStr
|
53
60
|
secondary_contact_email: EmailStr | None = None
|
61
|
+
|
62
|
+
# Status field to track provider state
|
63
|
+
status: ProviderStatusEnum = Field(
|
64
|
+
default=ProviderStatusEnum.active,
|
65
|
+
description="Provider status: active, disabled, or incomplete",
|
66
|
+
)
|
67
|
+
|
68
|
+
@field_validator("name")
|
69
|
+
@classmethod
|
70
|
+
def validate_name_format(cls, v: str) -> str:
|
71
|
+
"""Validate that provider name uses URL-safe identifiers."""
|
72
|
+
# Note: display_name is not available in the validator context for suggesting
|
73
|
+
# Display name will be shown in error if user provides it
|
74
|
+
return validate_name(v, "provider", allow_slash=False)
|