unitysvc-services 0.1.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.
@@ -0,0 +1,293 @@
1
+ """Update command group - update local data files."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from .utils import find_file_by_schema_and_name, find_files_by_schema, write_data_file
11
+
12
+ app = typer.Typer(help="Update local data files")
13
+ console = Console()
14
+
15
+
16
+ @app.command("offering")
17
+ def update_offering(
18
+ name: str = typer.Option(
19
+ ...,
20
+ "--name",
21
+ "-n",
22
+ help="Name of the service offering to update (matches 'name' field in service file)",
23
+ ),
24
+ status: str | None = typer.Option(
25
+ None,
26
+ "--status",
27
+ "-s",
28
+ help="New upstream_status (uploading, ready, deprecated)",
29
+ ),
30
+ display_name: str | None = typer.Option(
31
+ None,
32
+ "--display-name",
33
+ help="New display name for the offering",
34
+ ),
35
+ description: str | None = typer.Option(
36
+ None,
37
+ "--description",
38
+ help="New description for the offering",
39
+ ),
40
+ version: str | None = typer.Option(
41
+ None,
42
+ "--version",
43
+ help="New version for the offering",
44
+ ),
45
+ data_dir: Path | None = typer.Option(
46
+ None,
47
+ "--data-dir",
48
+ "-d",
49
+ help="Directory containing data files (default: ./data or UNITYSVC_DATA_DIR env var)",
50
+ ),
51
+ ):
52
+ """
53
+ Update fields in a service offering's local data file.
54
+
55
+ Searches for files with schema 'service_v1' by offering name and updates the specified fields.
56
+
57
+ Allowed upstream_status values:
58
+ - uploading: Service is being uploaded (not ready)
59
+ - ready: Service is ready to be used
60
+ - deprecated: Service is deprecated from upstream
61
+ """
62
+ # Validate status if provided
63
+ if status:
64
+ valid_statuses = ["uploading", "ready", "deprecated"]
65
+ if status not in valid_statuses:
66
+ console.print(
67
+ f"[red]✗[/red] Invalid status: {status}",
68
+ style="bold red",
69
+ )
70
+ console.print(f"[yellow]Allowed statuses:[/yellow] {', '.join(valid_statuses)}")
71
+ raise typer.Exit(code=1)
72
+
73
+ # Check if any update field is provided
74
+ if not any([status, display_name, description, version]):
75
+ console.print(
76
+ (
77
+ "[red]✗[/red] No fields to update. Provide at least one of: "
78
+ "--status, --display-name, --description, --version"
79
+ ),
80
+ style="bold red",
81
+ )
82
+ raise typer.Exit(code=1)
83
+
84
+ # Set data directory
85
+ if data_dir is None:
86
+ data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
87
+ if data_dir_str:
88
+ data_dir = Path(data_dir_str)
89
+ else:
90
+ data_dir = Path.cwd() / "data"
91
+
92
+ if not data_dir.is_absolute():
93
+ data_dir = Path.cwd() / data_dir
94
+
95
+ if not data_dir.exists():
96
+ console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
97
+ raise typer.Exit(code=1)
98
+
99
+ console.print(f"[blue]Searching for offering:[/blue] {name}")
100
+ console.print(f"[blue]In directory:[/blue] {data_dir}\n")
101
+
102
+ # Find the matching offering file
103
+ result = find_file_by_schema_and_name(data_dir, "service_v1", "name", name)
104
+
105
+ if not result:
106
+ console.print(
107
+ f"[red]✗[/red] No offering found with name: {name}",
108
+ style="bold red",
109
+ )
110
+ raise typer.Exit(code=1)
111
+
112
+ matching_file, matching_format, data = result
113
+
114
+ # Update the fields
115
+ try:
116
+ updates: dict[str, tuple[Any, Any]] = {} # field: (old_value, new_value)
117
+
118
+ if status:
119
+ updates["upstream_status"] = (data.get("upstream_status", "unknown"), status)
120
+ data["upstream_status"] = status
121
+
122
+ if display_name:
123
+ updates["display_name"] = (data.get("display_name", ""), display_name)
124
+ data["display_name"] = display_name
125
+
126
+ if description:
127
+ updates["description"] = (data.get("description", ""), description)
128
+ data["description"] = description
129
+
130
+ if version:
131
+ updates["version"] = (data.get("version", ""), version)
132
+ data["version"] = version
133
+
134
+ # Write back in same format
135
+ write_data_file(matching_file, data, matching_format)
136
+
137
+ console.print("[green]✓[/green] Updated offering successfully!")
138
+ console.print(f"[cyan]File:[/cyan] {matching_file.relative_to(data_dir)}")
139
+ console.print(f"[cyan]Format:[/cyan] {matching_format.upper()}\n")
140
+
141
+ for field, (old, new) in updates.items():
142
+ console.print(f"[cyan]{field}:[/cyan]")
143
+ if len(str(old)) > 60 or len(str(new)) > 60:
144
+ console.print(f" [dim]Old:[/dim] {str(old)[:60]}...")
145
+ console.print(f" [dim]New:[/dim] {str(new)[:60]}...")
146
+ else:
147
+ console.print(f" [dim]Old:[/dim] {old}")
148
+ console.print(f" [dim]New:[/dim] {new}")
149
+
150
+ except Exception as e:
151
+ console.print(
152
+ f"[red]✗[/red] Failed to update offering: {e}",
153
+ style="bold red",
154
+ )
155
+ raise typer.Exit(code=1)
156
+
157
+
158
+ @app.command("listing")
159
+ def update_listing(
160
+ service_name: str = typer.Option(
161
+ ...,
162
+ "--service-name",
163
+ "-n",
164
+ help="Name of the service (to search for listing files in service directory)",
165
+ ),
166
+ status: str | None = typer.Option(
167
+ None,
168
+ "--status",
169
+ "-s",
170
+ help=(
171
+ "New listing_status (unknown, upstream_ready, downstream_ready, "
172
+ "ready, in_service, upstream_deprecated, deprecated)"
173
+ ),
174
+ ),
175
+ seller_name: str | None = typer.Option(
176
+ None,
177
+ "--seller",
178
+ help="Seller name to filter listings (updates only matching seller's listing)",
179
+ ),
180
+ data_dir: Path | None = typer.Option(
181
+ None,
182
+ "--data-dir",
183
+ "-d",
184
+ help="Directory containing data files (default: ./data or UNITYSVC_DATA_DIR env var)",
185
+ ),
186
+ ):
187
+ """
188
+ Update fields in service listing(s) local data files.
189
+
190
+ Searches for files with schema 'listing_v1' in the service directory and updates the specified fields.
191
+
192
+ Allowed listing_status values:
193
+ - unknown: Not yet determined
194
+ - upstream_ready: Upstream is ready to be used
195
+ - downstream_ready: Downstream is ready with proper routing, logging, and billing
196
+ - ready: Operationally ready (with docs, metrics, and pricing)
197
+ - in_service: Service is in service
198
+ - upstream_deprecated: Service is deprecated from upstream
199
+ - deprecated: Service is no longer offered to users
200
+ """
201
+ # Validate status if provided
202
+ if status:
203
+ valid_statuses = [
204
+ "unknown",
205
+ "upstream_ready",
206
+ "downstream_ready",
207
+ "ready",
208
+ "in_service",
209
+ "upstream_deprecated",
210
+ "deprecated",
211
+ ]
212
+ if status not in valid_statuses:
213
+ console.print(
214
+ f"[red]✗[/red] Invalid status: {status}",
215
+ style="bold red",
216
+ )
217
+ console.print(f"[yellow]Allowed statuses:[/yellow] {', '.join(valid_statuses)}")
218
+ raise typer.Exit(code=1)
219
+
220
+ # Check if any update field is provided
221
+ if not status:
222
+ console.print(
223
+ "[red]✗[/red] No fields to update. Provide at least one of: --status",
224
+ style="bold red",
225
+ )
226
+ raise typer.Exit(code=1)
227
+
228
+ # Set data directory
229
+ if data_dir is None:
230
+ data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
231
+ if data_dir_str:
232
+ data_dir = Path(data_dir_str)
233
+ else:
234
+ data_dir = Path.cwd() / "data"
235
+
236
+ if not data_dir.is_absolute():
237
+ data_dir = Path.cwd() / data_dir
238
+
239
+ if not data_dir.exists():
240
+ console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
241
+ raise typer.Exit(code=1)
242
+
243
+ console.print(f"[blue]Searching for service:[/blue] {service_name}")
244
+ console.print(f"[blue]In directory:[/blue] {data_dir}")
245
+ if seller_name:
246
+ console.print(f"[blue]Filtering by seller:[/blue] {seller_name}")
247
+ console.print()
248
+
249
+ # Build field filter
250
+ field_filter = {}
251
+ if seller_name:
252
+ field_filter["seller_name"] = seller_name
253
+
254
+ # Find listing files matching criteria
255
+ listing_files = find_files_by_schema(data_dir, "listing_v1", path_filter=service_name, field_filter=field_filter)
256
+
257
+ if not listing_files:
258
+ console.print(
259
+ "[red]✗[/red] No listing files found matching criteria",
260
+ style="bold red",
261
+ )
262
+ raise typer.Exit(code=1)
263
+
264
+ # Update all matching listings
265
+ updated_count = 0
266
+ for listing_file, file_format, data in listing_files:
267
+ try:
268
+ old_status = data.get("listing_status", "unknown")
269
+ if status:
270
+ data["listing_status"] = status
271
+
272
+ # Write back in same format
273
+ write_data_file(listing_file, data, file_format)
274
+
275
+ console.print(f"[green]✓[/green] Updated: {listing_file.relative_to(data_dir)}")
276
+ console.print(f" [dim]Seller: {data.get('seller_name', 'N/A')}[/dim]")
277
+ console.print(f" [dim]Format: {file_format.upper()}[/dim]")
278
+ if status:
279
+ console.print(f" [dim]Old status: {old_status} → New status: {status}[/dim]")
280
+ console.print()
281
+ updated_count += 1
282
+
283
+ except Exception as e:
284
+ console.print(
285
+ f"[red]✗[/red] Failed to update {listing_file.relative_to(data_dir)}: {e}",
286
+ style="bold red",
287
+ )
288
+
289
+ if updated_count > 0:
290
+ console.print(f"[green]✓[/green] Successfully updated {updated_count} listing(s)")
291
+ else:
292
+ console.print("[red]✗[/red] No listings were updated", style="bold red")
293
+ raise typer.Exit(code=1)
@@ -0,0 +1,240 @@
1
+ """Utility functions for file handling and data operations."""
2
+
3
+ import json
4
+ import tomllib
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import tomli_w
9
+
10
+
11
+ def load_data_file(file_path: Path) -> tuple[dict[str, Any], str]:
12
+ """
13
+ Load a data file (JSON or TOML) and return (data, format).
14
+
15
+ Args:
16
+ file_path: Path to the data file
17
+
18
+ Returns:
19
+ Tuple of (data dict, format string "json" or "toml")
20
+
21
+ Raises:
22
+ ValueError: If file format is not supported
23
+ """
24
+ if file_path.suffix == ".json":
25
+ with open(file_path, encoding="utf-8") as f:
26
+ return json.load(f), "json"
27
+ elif file_path.suffix == ".toml":
28
+ with open(file_path, "rb") as f:
29
+ return tomllib.load(f), "toml"
30
+ else:
31
+ raise ValueError(f"Unsupported file format: {file_path.suffix}")
32
+
33
+
34
+ def write_data_file(file_path: Path, data: dict[str, Any], format: str) -> None:
35
+ """
36
+ Write data back to file in the specified format.
37
+
38
+ Args:
39
+ file_path: Path to the data file
40
+ data: Data dictionary to write
41
+ format: Format string ("json" or "toml")
42
+
43
+ Raises:
44
+ ValueError: If format is not supported
45
+ """
46
+ if format == "json":
47
+ with open(file_path, "w", encoding="utf-8") as f:
48
+ json.dump(data, f, indent=2, sort_keys=True)
49
+ f.write("\n")
50
+ elif format == "toml":
51
+ with open(file_path, "wb") as f:
52
+ tomli_w.dump(data, f)
53
+ else:
54
+ raise ValueError(f"Unsupported format: {format}")
55
+
56
+
57
+ def find_data_files(data_dir: Path, extensions: list[str] | None = None) -> list[Path]:
58
+ """
59
+ Find all data files in a directory with specified extensions.
60
+
61
+ Args:
62
+ data_dir: Directory to search
63
+ extensions: List of extensions to search for (default: ["json", "toml"])
64
+
65
+ Returns:
66
+ List of Path objects for matching files
67
+ """
68
+ if extensions is None:
69
+ extensions = ["json", "toml"]
70
+
71
+ data_files: list[Path] = []
72
+ for ext in extensions:
73
+ data_files.extend(data_dir.rglob(f"*.{ext}"))
74
+ return data_files
75
+
76
+
77
+ def find_file_by_schema_and_name(
78
+ data_dir: Path, schema: str, name_field: str, name_value: str
79
+ ) -> tuple[Path, str, dict[str, Any]] | None:
80
+ """
81
+ Find a data file by schema type and name field value.
82
+
83
+ Args:
84
+ data_dir: Directory to search
85
+ schema: Schema identifier (e.g., "service_v1", "listing_v1")
86
+ name_field: Field name to match (e.g., "name", "seller_name")
87
+ name_value: Value to match in the name field
88
+
89
+ Returns:
90
+ Tuple of (file_path, format, data) if found, None otherwise
91
+ """
92
+ data_files = find_data_files(data_dir)
93
+
94
+ for data_file in data_files:
95
+ try:
96
+ data, file_format = load_data_file(data_file)
97
+ if data.get("schema") == schema and data.get(name_field) == name_value:
98
+ return data_file, file_format, data
99
+ except Exception:
100
+ # Skip files that can't be loaded
101
+ continue
102
+
103
+ return None
104
+
105
+
106
+ def find_files_by_schema(
107
+ data_dir: Path,
108
+ schema: str,
109
+ path_filter: str | None = None,
110
+ field_filter: dict[str, Any] | None = None,
111
+ ) -> list[tuple[Path, str, dict[str, Any]]]:
112
+ """
113
+ Find all data files matching a schema with optional filters.
114
+
115
+ Args:
116
+ data_dir: Directory to search
117
+ schema: Schema identifier (e.g., "service_v1", "listing_v1")
118
+ path_filter: Optional string that must be in the file path
119
+ field_filter: Optional dict of field:value pairs to filter by
120
+
121
+ Returns:
122
+ List of tuples (file_path, format, data) for matching files
123
+ """
124
+ data_files = find_data_files(data_dir)
125
+ matching_files: list[tuple[Path, str, dict[str, Any]]] = []
126
+
127
+ for data_file in data_files:
128
+ try:
129
+ # Apply path filter
130
+ if path_filter and path_filter not in str(data_file):
131
+ continue
132
+
133
+ data, file_format = load_data_file(data_file)
134
+
135
+ # Check schema
136
+ if data.get("schema") != schema:
137
+ continue
138
+
139
+ # Apply field filters
140
+ if field_filter:
141
+ if not all(data.get(k) == v for k, v in field_filter.items()):
142
+ continue
143
+
144
+ matching_files.append((data_file, file_format, data))
145
+
146
+ except Exception:
147
+ # Skip files that can't be loaded
148
+ continue
149
+
150
+ return matching_files
151
+
152
+
153
+ def resolve_provider_name(file_path: Path) -> str | None:
154
+ """
155
+ Resolve the provider name from the file path.
156
+
157
+ The provider name is determined by the directory structure:
158
+ - For service offerings: <provider_name>/services/<service_name>/service.{json,toml}
159
+ - For service listings: <provider_name>/services/<service_name>/listing-*.{json,toml}
160
+
161
+ Args:
162
+ file_path: Path to the service offering or listing file
163
+
164
+ Returns:
165
+ Provider name if found in directory structure, None otherwise
166
+ """
167
+ # Check if file is under a "services" directory
168
+ parts = file_path.parts
169
+
170
+ try:
171
+ # Find the "services" directory in the path
172
+ services_idx = parts.index("services")
173
+
174
+ # Provider name is the directory before "services"
175
+ if services_idx > 0:
176
+ provider_dir = parts[services_idx - 1]
177
+
178
+ # The provider directory should contain a provider data file
179
+ # Get the full path to the provider directory
180
+ provider_path = Path(*parts[:services_idx])
181
+
182
+ # Look for provider data file to validate and get the actual provider name
183
+ for data_file in find_data_files(provider_path):
184
+ try:
185
+ # Only check files in the provider directory itself, not subdirectories
186
+ if data_file.parent == provider_path:
187
+ data, _file_format = load_data_file(data_file)
188
+ if data.get("schema") == "provider_v1":
189
+ return data.get("name")
190
+ except Exception:
191
+ continue
192
+
193
+ # Fallback to directory name if no provider file found
194
+ return provider_dir
195
+ except (ValueError, IndexError):
196
+ # "services" not in path or invalid structure
197
+ pass
198
+
199
+ return None
200
+
201
+
202
+ def resolve_service_name_for_listing(listing_file: Path, listing_data: dict[str, Any]) -> str | None:
203
+ """
204
+ Resolve the service name for a listing file.
205
+
206
+ Rules:
207
+ 1. If service_name is defined in listing_data, return it
208
+ 2. Otherwise, find the only service offering in the same directory and return its name
209
+
210
+ Args:
211
+ listing_file: Path to the listing file
212
+ listing_data: Listing data dictionary
213
+
214
+ Returns:
215
+ Service name if found, None otherwise
216
+ """
217
+ # Rule 1: If service_name is already defined, use it
218
+ if "service_name" in listing_data and listing_data["service_name"]:
219
+ return listing_data["service_name"]
220
+
221
+ # Rule 2: Find the only service offering in the same directory
222
+ listing_dir = listing_file.parent
223
+
224
+ # Find all service offering files in the same directory
225
+ service_files: list[tuple[Path, str, dict[str, Any]]] = []
226
+ for data_file in find_data_files(listing_dir):
227
+ try:
228
+ data, file_format = load_data_file(data_file)
229
+ if data.get("schema") == "service_v1":
230
+ service_files.append((data_file, file_format, data))
231
+ except Exception:
232
+ continue
233
+
234
+ # If there's exactly one service file, use its name
235
+ if len(service_files) == 1:
236
+ _service_file, _service_format, service_data = service_files[0]
237
+ return service_data.get("name")
238
+
239
+ # Otherwise, return None (either no service files or multiple service files)
240
+ return None