vantage-python 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vantage-python
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for the Vantage API
5
5
  Project-URL: Homepage, https://github.com/vantage-sh/vantage-python
6
6
  Project-URL: Repository, https://github.com/vantage-sh/vantage-python
@@ -31,6 +31,12 @@ RESPONSE_HANDLERS: list[tuple[str, str, str]] = [
31
31
  ),
32
32
  ]
33
33
 
34
+ # Endpoints that return bool based on HTTP status: 404 -> False, 2xx -> True, else raise.
35
+ # Each entry is (METHOD, openapi_path_template).
36
+ BOOLEAN_STATUS_ROUTES: list[tuple[str, str]] = [
37
+ ("GET", "/virtual_tag_configs/async/{request_id}"),
38
+ ]
39
+
34
40
 
35
41
  @dataclass
36
42
  class Parameter:
@@ -61,6 +67,7 @@ class Endpoint:
61
67
  is_multipart: bool = False
62
68
  response_handler: str | None = None # internal client method to call, if not the default
63
69
  response_handler_return_type: str | None = None
70
+ boolean_status: bool = False # 404->False, 2xx->True, else raise VantageAPIError
64
71
 
65
72
 
66
73
  @dataclass
@@ -196,10 +203,16 @@ def preprocess_inline_models(schemas: dict[str, Any]) -> None:
196
203
  existing_names.add(model_name)
197
204
 
198
205
 
199
- def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> str:
206
+ def openapi_type_to_python(
207
+ schema: dict[str, Any],
208
+ schemas: dict[str, Any],
209
+ name_map: dict[str, str] | None = None,
210
+ ) -> str:
200
211
  """Convert OpenAPI type to Python type hint."""
201
212
  if "$ref" in schema:
202
213
  ref_name = schema["$ref"].split("/")[-1]
214
+ if name_map and ref_name in name_map:
215
+ return name_map[ref_name]
203
216
  return to_pascal_case(ref_name)
204
217
 
205
218
  schema_type = schema.get("type", "any")
@@ -216,12 +229,12 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s
216
229
  return "bool"
217
230
  elif schema_type == "array":
218
231
  items = schema.get("items", {})
219
- item_type = openapi_type_to_python(items, schemas)
232
+ item_type = openapi_type_to_python(items, schemas, name_map)
220
233
  return f"List[{item_type}]"
221
234
  elif schema_type == "object":
222
235
  additional = schema.get("additionalProperties")
223
236
  if additional:
224
- value_type = openapi_type_to_python(additional, schemas)
237
+ value_type = openapi_type_to_python(additional, schemas, name_map)
225
238
  return f"Dict[str, {value_type}]"
226
239
  # Check if inline properties match an existing named schema
227
240
  inline_props = schema.get("properties")
@@ -230,6 +243,8 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s
230
243
  for schema_name, schema_def in schemas.items():
231
244
  defined_keys = sorted(schema_def.get("properties", {}).keys())
232
245
  if defined_keys and inline_keys == defined_keys:
246
+ if name_map and schema_name in name_map:
247
+ return name_map[schema_name]
233
248
  return to_pascal_case(schema_name)
234
249
  return "Dict[str, Any]"
235
250
  else:
@@ -237,7 +252,9 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s
237
252
 
238
253
 
239
254
  def extract_request_body_type(
240
- request_body: dict[str, Any] | None, schemas: dict[str, Any]
255
+ request_body: dict[str, Any] | None,
256
+ schemas: dict[str, Any],
257
+ name_map: dict[str, str] | None = None,
241
258
  ) -> tuple[str | None, bool]:
242
259
  """Extract request body type and whether it's multipart."""
243
260
  if not request_body:
@@ -252,13 +269,15 @@ def extract_request_body_type(
252
269
  # Check for JSON
253
270
  if "application/json" in content:
254
271
  schema = content["application/json"].get("schema", {})
255
- return openapi_type_to_python(schema, schemas), False
272
+ return openapi_type_to_python(schema, schemas, name_map), False
256
273
 
257
274
  return None, False
258
275
 
259
276
 
260
277
  def extract_response_type(
261
- responses: dict[str, Any], schemas: dict[str, Any]
278
+ responses: dict[str, Any],
279
+ schemas: dict[str, Any],
280
+ name_map: dict[str, str] | None = None,
262
281
  ) -> str | None:
263
282
  """Extract successful response type."""
264
283
  for code in ["200", "201", "202", "203"]:
@@ -268,15 +287,59 @@ def extract_response_type(
268
287
  content = response.get("content", {})
269
288
  if "application/json" in content:
270
289
  schema = content["application/json"].get("schema", {})
271
- return openapi_type_to_python(schema, schemas)
290
+ return openapi_type_to_python(schema, schemas, name_map)
272
291
  return None
273
292
 
274
293
 
294
+ def find_request_body_schemas(schema: dict[str, Any]) -> set[str]:
295
+ """Return the set of schema names referenced as request bodies in any endpoint."""
296
+ result = set()
297
+ paths = schema.get("paths", {})
298
+ for path_item in paths.values():
299
+ for method, spec in path_item.items():
300
+ if method in ("parameters", "servers", "summary", "description"):
301
+ continue
302
+ request_body = spec.get("requestBody", {})
303
+ content = request_body.get("content", {})
304
+ for media_type in content.values():
305
+ ref_schema = media_type.get("schema", {})
306
+ if "$ref" in ref_schema:
307
+ name = ref_schema["$ref"].split("/")[-1]
308
+ result.add(name)
309
+ return result
310
+
311
+
312
+ def build_class_name_map(schemas: dict[str, Any], request_body_schemas: set[str]) -> dict[str, str]:
313
+ """Build a mapping from raw schema names to Python class names, resolving conflicts.
314
+
315
+ If two schema names map to the same PascalCase name, the one used as a
316
+ request body is suffixed with 'Request'.
317
+ """
318
+ initial = {name: to_pascal_case(name) for name in schemas}
319
+
320
+ by_class_name: dict[str, list[str]] = {}
321
+ for raw_name, class_name in initial.items():
322
+ by_class_name.setdefault(class_name, []).append(raw_name)
323
+
324
+ result: dict[str, str] = {}
325
+ for class_name, raw_names in by_class_name.items():
326
+ if len(raw_names) == 1:
327
+ result[raw_names[0]] = class_name
328
+ else:
329
+ for raw_name in raw_names:
330
+ if raw_name in request_body_schemas:
331
+ result[raw_name] = class_name + "Request"
332
+ else:
333
+ result[raw_name] = class_name
334
+ return result
335
+
336
+
275
337
  def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
276
338
  """Parse all endpoints from OpenAPI schema."""
277
339
  endpoints = []
278
340
  paths = schema.get("paths", {})
279
341
  schemas = schema.get("components", {}).get("schemas", {})
342
+ name_map = build_class_name_map(schemas, find_request_body_schemas(schema))
280
343
 
281
344
  for path, methods in paths.items():
282
345
  for method, spec in methods.items():
@@ -288,7 +351,7 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
288
351
  parameters = []
289
352
  for param in spec.get("parameters", []):
290
353
  param_schema = param.get("schema", {})
291
- param_type = openapi_type_to_python(param_schema, schemas)
354
+ param_type = openapi_type_to_python(param_schema, schemas, name_map)
292
355
  parameters.append(
293
356
  Parameter(
294
357
  name=param["name"],
@@ -301,9 +364,9 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
301
364
  )
302
365
 
303
366
  request_body = spec.get("requestBody")
304
- body_type, is_multipart = extract_request_body_type(request_body, schemas)
367
+ body_type, is_multipart = extract_request_body_type(request_body, schemas, name_map)
305
368
 
306
- response_type = extract_response_type(spec.get("responses", {}), schemas)
369
+ response_type = extract_response_type(spec.get("responses", {}), schemas, name_map)
307
370
 
308
371
  description = spec.get("description")
309
372
 
@@ -319,6 +382,10 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
319
382
  if response_handler:
320
383
  break
321
384
 
385
+ boolean_status = (method.upper(), path) in {
386
+ (m.upper(), p) for m, p in BOOLEAN_STATUS_ROUTES
387
+ }
388
+
322
389
  endpoints.append(
323
390
  Endpoint(
324
391
  path=path,
@@ -336,6 +403,7 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
336
403
  is_multipart=is_multipart,
337
404
  response_handler=response_handler,
338
405
  response_handler_return_type=response_handler_return_type,
406
+ boolean_status=boolean_status,
339
407
  )
340
408
  )
341
409
 
@@ -469,6 +537,7 @@ def _append_response_mapping(lines: list[str], return_type: str, data_var: str)
469
537
  def generate_pydantic_models(schema: dict[str, Any]) -> str:
470
538
  """Generate Pydantic models from OpenAPI schemas."""
471
539
  schemas = schema.get("components", {}).get("schemas", {})
540
+ name_map = build_class_name_map(schemas, find_request_body_schemas(schema))
472
541
  lines = [
473
542
  '"""Auto-generated Pydantic models from OpenAPI schema."""',
474
543
  "",
@@ -482,7 +551,7 @@ def generate_pydantic_models(schema: dict[str, Any]) -> str:
482
551
  ]
483
552
 
484
553
  for name, spec in schemas.items():
485
- class_name = to_pascal_case(name)
554
+ class_name = name_map.get(name, to_pascal_case(name))
486
555
  description = spec.get("description", "")
487
556
 
488
557
  lines.append(f"class {class_name}(BaseModel):")
@@ -508,7 +577,7 @@ def generate_pydantic_models(schema: dict[str, Any]) -> str:
508
577
  python_name = python_name + "_"
509
578
  needs_alias = True
510
579
 
511
- prop_type = openapi_type_to_python(prop_spec, schemas)
580
+ prop_type = openapi_type_to_python(prop_spec, schemas, name_map)
512
581
 
513
582
  # Handle nullable
514
583
  if prop_spec.get("x-nullable") or prop_spec.get("nullable"):
@@ -559,11 +628,26 @@ def _collect_handler_routes(resources: dict[str, Resource]) -> dict[str, list[tu
559
628
  for endpoint in resource.endpoints:
560
629
  if endpoint.response_handler:
561
630
  handler_routes.setdefault(endpoint.response_handler, []).append(
562
- (endpoint.method, endpoint.path)
631
+ (endpoint.method, "/v2" + endpoint.path)
563
632
  )
564
633
  return handler_routes
565
634
 
566
635
 
636
+ def _collect_boolean_status_prefixes(resources: dict[str, Resource]) -> list[tuple[str, str]]:
637
+ """Collect (method, path_prefix) pairs for boolean-status endpoints.
638
+
639
+ The prefix is derived by taking everything before the first path parameter
640
+ so it can be matched with str.startswith() at runtime.
641
+ """
642
+ result = []
643
+ for resource in resources.values():
644
+ for endpoint in resource.endpoints:
645
+ if endpoint.boolean_status:
646
+ prefix = ("/v2" + endpoint.path).split("{")[0]
647
+ result.append((endpoint.method, prefix))
648
+ return result
649
+
650
+
567
651
  def generate_sync_client(resources: dict[str, Resource]) -> str:
568
652
  """Generate synchronous client code."""
569
653
  lines = [
@@ -652,6 +736,29 @@ def generate_sync_client(resources: dict[str, Resource]) -> str:
652
736
  " json=body,",
653
737
  " )",
654
738
  "",
739
+ ]
740
+ )
741
+
742
+ # Inject boolean-status path checks (before the generic error check)
743
+ boolean_prefixes = _collect_boolean_status_prefixes(resources)
744
+ for method, prefix in boolean_prefixes:
745
+ lines.extend([
746
+ f' if method.upper() == "{method}" and path.startswith("{prefix}"):',
747
+ " if response.status_code == 404:",
748
+ " return False",
749
+ " elif response.is_success:",
750
+ " return True",
751
+ " else:",
752
+ " raise VantageAPIError(",
753
+ " status=response.status_code,",
754
+ " status_text=response.reason_phrase,",
755
+ " body=response.text,",
756
+ " )",
757
+ "",
758
+ ])
759
+
760
+ lines.extend(
761
+ [
655
762
  " if not response.is_success:",
656
763
  " raise VantageAPIError(",
657
764
  " status=response.status_code,",
@@ -757,7 +864,9 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]:
757
864
 
758
865
  # Method signature
759
866
  param_str = ", ".join(["self"] + params) if params else "self"
760
- if endpoint.response_handler:
867
+ if endpoint.boolean_status:
868
+ return_type = "bool"
869
+ elif endpoint.response_handler:
761
870
  return_type = endpoint.response_handler_return_type or "Any"
762
871
  else:
763
872
  return_type = endpoint.response_type or "None"
@@ -774,7 +883,7 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]:
774
883
  lines.append(' """')
775
884
 
776
885
  # Build path with parameters (URL-encode each path arg)
777
- path = endpoint.path
886
+ path = "/v2" + endpoint.path
778
887
  for pp in path_params:
779
888
  path = path.replace(f"{{{pp.name}}}", f"{{quote(str({pp.python_name}), safe='')}}")
780
889
 
@@ -801,7 +910,7 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]:
801
910
  lines.append(" body_data = None")
802
911
 
803
912
  # Make request and coerce response payload into typed models where possible
804
- if endpoint.response_handler:
913
+ if endpoint.boolean_status or endpoint.response_handler:
805
914
  lines.append(
806
915
  f' return self._client.request("{endpoint.method}", path, params=params, body=body_data)'
807
916
  )
@@ -907,6 +1016,29 @@ def generate_async_client(resources: dict[str, Resource]) -> str:
907
1016
  " json=body,",
908
1017
  " )",
909
1018
  "",
1019
+ ]
1020
+ )
1021
+
1022
+ # Inject boolean-status path checks (before the generic error check)
1023
+ boolean_prefixes = _collect_boolean_status_prefixes(resources)
1024
+ for method, prefix in boolean_prefixes:
1025
+ lines.extend([
1026
+ f' if method.upper() == "{method}" and path.startswith("{prefix}"):',
1027
+ " if response.status_code == 404:",
1028
+ " return False",
1029
+ " elif response.is_success:",
1030
+ " return True",
1031
+ " else:",
1032
+ " raise VantageAPIError(",
1033
+ " status=response.status_code,",
1034
+ " status_text=response.reason_phrase,",
1035
+ " body=response.text,",
1036
+ " )",
1037
+ "",
1038
+ ])
1039
+
1040
+ lines.extend(
1041
+ [
910
1042
  " if not response.is_success:",
911
1043
  " raise VantageAPIError(",
912
1044
  " status=response.status_code,",
@@ -1012,7 +1144,9 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]:
1012
1144
 
1013
1145
  # Method signature
1014
1146
  param_str = ", ".join(["self"] + params) if params else "self"
1015
- if endpoint.response_handler:
1147
+ if endpoint.boolean_status:
1148
+ return_type = "bool"
1149
+ elif endpoint.response_handler:
1016
1150
  return_type = endpoint.response_handler_return_type or "Any"
1017
1151
  else:
1018
1152
  return_type = endpoint.response_type or "None"
@@ -1029,7 +1163,7 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]:
1029
1163
  lines.append(' """')
1030
1164
 
1031
1165
  # Build path with parameters (URL-encode each path arg)
1032
- path = endpoint.path
1166
+ path = "/v2" + endpoint.path
1033
1167
  for pp in path_params:
1034
1168
  path = path.replace(f"{{{pp.name}}}", f"{{quote(str({pp.python_name}), safe='')}}")
1035
1169
 
@@ -1056,7 +1190,7 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]:
1056
1190
  lines.append(" body_data = None")
1057
1191
 
1058
1192
  # Make request and coerce response payload into typed models where possible
1059
- if endpoint.response_handler:
1193
+ if endpoint.boolean_status or endpoint.response_handler:
1060
1194
  lines.append(
1061
1195
  f' return await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
1062
1196
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vantage-python"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Python SDK for the Vantage API"
9
9
  readme = "README.md"
10
10
  license = "MIT"