unitysvc-services 0.1.1__py3-none-any.whl → 0.1.5__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.
@@ -0,0 +1,321 @@
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
+ httpx.HTTPStatusError: If HTTP status code indicates error (with response details)
66
+ RuntimeError: If curl command fails or times out
67
+ """
68
+ url = f"{self.base_url}{endpoint}"
69
+ if params:
70
+ url = f"{url}?{urlencode(params)}"
71
+
72
+ cmd = [
73
+ "curl",
74
+ "-s", # Silent mode
75
+ "-w",
76
+ "\n%{http_code}", # Write status code on new line
77
+ "-H",
78
+ f"X-API-Key: {self.api_key}",
79
+ "-H",
80
+ "Accept: application/json",
81
+ url,
82
+ ]
83
+
84
+ try:
85
+ proc = await asyncio.create_subprocess_exec(
86
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
87
+ )
88
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
89
+
90
+ if proc.returncode != 0:
91
+ error_msg = stderr.decode().strip() if stderr else "Curl command failed"
92
+ raise RuntimeError(f"Curl error: {error_msg}")
93
+
94
+ # Parse response: last line is status code, rest is body
95
+ output = stdout.decode().strip()
96
+ lines = output.split("\n")
97
+ status_code = int(lines[-1])
98
+ body = "\n".join(lines[:-1])
99
+
100
+ # Parse JSON response
101
+ try:
102
+ response_data = json.loads(body) if body else {}
103
+ except json.JSONDecodeError:
104
+ response_data = {"error": body}
105
+
106
+ # Raise exception for non-2xx status codes (mimics httpx behavior)
107
+ if status_code < 200 or status_code >= 300:
108
+ # Create a mock response object to raise HTTPStatusError
109
+ mock_request = httpx.Request("GET", url)
110
+ mock_response = httpx.Response(status_code=status_code, content=body.encode(), request=mock_request)
111
+ raise httpx.HTTPStatusError(f"HTTP {status_code}", request=mock_request, response=mock_response)
112
+
113
+ return response_data
114
+ except TimeoutError:
115
+ raise RuntimeError("Request timed out after 30 seconds")
116
+ except httpx.HTTPStatusError:
117
+ # Re-raise HTTP errors as-is
118
+ raise
119
+
120
+ async def _make_post_request_curl(
121
+ self, endpoint: str, json_data: dict[str, Any] | None = None, params: dict[str, Any] | None = None
122
+ ) -> dict[str, Any]:
123
+ """Make HTTP POST request using curl fallback (async).
124
+
125
+ Args:
126
+ endpoint: API endpoint path (e.g., "/admin/subscriptions")
127
+ json_data: JSON body data
128
+ params: Query parameters
129
+
130
+ Returns:
131
+ JSON response as dictionary
132
+
133
+ Raises:
134
+ httpx.HTTPStatusError: If HTTP status code indicates error (with response details)
135
+ RuntimeError: If curl command fails or times out
136
+ """
137
+ url = f"{self.base_url}{endpoint}"
138
+ if params:
139
+ url = f"{url}?{urlencode(params)}"
140
+
141
+ cmd = [
142
+ "curl",
143
+ "-s", # Silent mode
144
+ "-w",
145
+ "\n%{http_code}", # Write status code on new line
146
+ "-X",
147
+ "POST",
148
+ "-H",
149
+ f"X-API-Key: {self.api_key}",
150
+ "-H",
151
+ "Content-Type: application/json",
152
+ "-H",
153
+ "Accept: application/json",
154
+ ]
155
+
156
+ if json_data:
157
+ cmd.extend(["-d", json.dumps(json_data)])
158
+
159
+ cmd.append(url)
160
+
161
+ try:
162
+ proc = await asyncio.create_subprocess_exec(
163
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
164
+ )
165
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
166
+
167
+ if proc.returncode != 0:
168
+ error_msg = stderr.decode().strip() if stderr else "Curl command failed"
169
+ raise RuntimeError(f"Curl error: {error_msg}")
170
+
171
+ # Parse response: last line is status code, rest is body
172
+ output = stdout.decode().strip()
173
+ lines = output.split("\n")
174
+ status_code = int(lines[-1])
175
+ body = "\n".join(lines[:-1])
176
+
177
+ # Parse JSON response
178
+ try:
179
+ response_data = json.loads(body) if body else {}
180
+ except json.JSONDecodeError:
181
+ response_data = {"error": body}
182
+
183
+ # Raise exception for non-2xx status codes (mimics httpx behavior)
184
+ if status_code < 200 or status_code >= 300:
185
+ # Create a mock response object to raise HTTPStatusError
186
+ mock_request = httpx.Request("POST", url)
187
+ mock_response = httpx.Response(status_code=status_code, content=body.encode(), request=mock_request)
188
+ raise httpx.HTTPStatusError(f"HTTP {status_code}", request=mock_request, response=mock_response)
189
+
190
+ return response_data
191
+ except TimeoutError:
192
+ raise RuntimeError("Request timed out after 30 seconds")
193
+ except httpx.HTTPStatusError:
194
+ # Re-raise HTTP errors as-is
195
+ raise
196
+
197
+ async def get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
198
+ """Make a GET request to the backend API with automatic curl fallback.
199
+
200
+ Public async utility method for making GET requests. Tries httpx first for performance,
201
+ automatically falls back to curl if network restrictions are detected (e.g., macOS
202
+ with conda Python).
203
+
204
+ Args:
205
+ endpoint: API endpoint path (e.g., "/publish/sellers", "/admin/documents")
206
+ params: Query parameters
207
+
208
+ Returns:
209
+ JSON response as dictionary
210
+
211
+ Raises:
212
+ RuntimeError: If both httpx and curl fail
213
+ """
214
+ # If we already know curl is needed, use it directly
215
+ if self.use_curl_fallback:
216
+ return await self._make_request_curl(endpoint, params)
217
+
218
+ # Try httpx first
219
+ try:
220
+ response = await self.client.get(f"{self.base_url}{endpoint}", params=params)
221
+ response.raise_for_status()
222
+ return response.json()
223
+ except (httpx.ConnectError, OSError):
224
+ # Connection failed - likely network restrictions
225
+ # Fall back to curl and remember this for future requests
226
+ self.use_curl_fallback = True
227
+ return await self._make_request_curl(endpoint, params)
228
+
229
+ async def post(
230
+ self, endpoint: str, json_data: dict[str, Any] | None = None, params: dict[str, Any] | None = None
231
+ ) -> dict[str, Any]:
232
+ """Make a POST request to the backend API with automatic curl fallback.
233
+
234
+ Public async utility method for making POST requests. Tries httpx first for performance,
235
+ automatically falls back to curl if network restrictions are detected (e.g., macOS
236
+ with conda Python).
237
+
238
+ Args:
239
+ endpoint: API endpoint path (e.g., "/admin/subscriptions")
240
+ json_data: JSON body data
241
+ params: Query parameters
242
+
243
+ Returns:
244
+ JSON response as dictionary
245
+
246
+ Raises:
247
+ RuntimeError: If both httpx and curl fail
248
+ """
249
+ # If we already know curl is needed, use it directly
250
+ if self.use_curl_fallback:
251
+ return await self._make_post_request_curl(endpoint, json_data, params)
252
+
253
+ # Try httpx first
254
+ try:
255
+ response = await self.client.post(f"{self.base_url}{endpoint}", json=json_data, params=params)
256
+ response.raise_for_status()
257
+ return response.json()
258
+ except (httpx.ConnectError, OSError):
259
+ # Connection failed - likely network restrictions
260
+ # Fall back to curl and remember this for future requests
261
+ self.use_curl_fallback = True
262
+ return await self._make_post_request_curl(endpoint, json_data, params)
263
+
264
+ async def check_task(self, task_id: str, poll_interval: float = 2.0, timeout: float = 300.0) -> dict[str, Any]:
265
+ """Check and wait for task completion (async version).
266
+
267
+ Utility function to poll a Celery task until it completes or times out.
268
+ Uses the async HTTP client with curl fallback.
269
+
270
+ Args:
271
+ task_id: Celery task ID to poll
272
+ poll_interval: Seconds between status checks (default: 2.0)
273
+ timeout: Maximum seconds to wait (default: 300.0)
274
+
275
+ Returns:
276
+ Task result dictionary
277
+
278
+ Raises:
279
+ ValueError: If task fails or times out
280
+ """
281
+ import time
282
+
283
+ start_time = time.time()
284
+
285
+ while True:
286
+ elapsed = time.time() - start_time
287
+ if elapsed > timeout:
288
+ raise ValueError(f"Task {task_id} timed out after {timeout}s")
289
+
290
+ # Check task status using get() with automatic curl fallback
291
+ # Use UnitySvcAPI.get to ensure we call the async version, not sync wrapper
292
+ try:
293
+ status = await UnitySvcAPI.get(self, f"/tasks/{task_id}")
294
+ except Exception:
295
+ # Network error while checking status - retry
296
+ await asyncio.sleep(poll_interval)
297
+ continue
298
+
299
+ state = status.get("state", "PENDING")
300
+
301
+ # Check if task is complete
302
+ if status.get("status") == "completed" or state == "SUCCESS":
303
+ return status.get("result", {})
304
+ elif status.get("status") == "failed" or state == "FAILURE":
305
+ error = status.get("error", "Unknown error")
306
+ raise ValueError(f"Task {task_id} failed: {error}")
307
+
308
+ # Still processing - wait and retry
309
+ await asyncio.sleep(poll_interval)
310
+
311
+ async def aclose(self):
312
+ """Close the HTTP client."""
313
+ await self.client.aclose()
314
+
315
+ async def __aenter__(self):
316
+ """Async context manager entry."""
317
+ return self
318
+
319
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
320
+ """Async context manager exit."""
321
+ await self.aclose()
unitysvc_services/cli.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import typer
4
4
 
5
- from . import format_data, populate, publisher, query, scaffold, update, validator
5
+ from . import format_data, populate, publisher, query, scaffold, test, update, validator
6
6
  from . import list as list_cmd
7
7
 
8
8
  app = typer.Typer()
@@ -14,6 +14,7 @@ app.add_typer(list_cmd.app, name="list")
14
14
  app.add_typer(query.app, name="query")
15
15
  app.add_typer(publisher.app, name="publish")
16
16
  app.add_typer(update.app, name="update")
17
+ app.add_typer(test.app, name="test")
17
18
 
18
19
  # Register standalone commands at root level
19
20
  app.command("format")(format_data.format_data)
@@ -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: ./data or UNITYSVC_DATA_DIR env var)",
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
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
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: ./data or UNITYSVC_DATA_DIR env var)",
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
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
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: ./data or UNITYSVC_DATA_DIR env var)",
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
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
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: ./data or UNITYSVC_DATA_DIR env var)",
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
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
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: ./data or UNITYSVC_DATA_DIR env var)",
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
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
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)")