vantage-python 0.3.1__tar.gz → 0.3.3__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.1
3
+ Version: 0.3.3
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
@@ -20,6 +20,23 @@ from typing import Any
20
20
  OPENAPI_URL = "https://api.vantage.sh/v2/oas_v3.json"
21
21
  OUTPUT_DIR = Path(__file__).parent / "src" / "vantage"
22
22
 
23
+ # Maps a substring found in a response description to the internal client method
24
+ # that should handle it, and the Python return type to emit.
25
+ # Checked against each HTTP status code's description during endpoint parsing.
26
+ RESPONSE_HANDLERS: list[tuple[str, str, str]] = [
27
+ (
28
+ "will be available at the location specified in the Location header",
29
+ "_request_for_location",
30
+ "str",
31
+ ),
32
+ ]
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
+
23
40
 
24
41
  @dataclass
25
42
  class Parameter:
@@ -48,6 +65,9 @@ class Endpoint:
48
65
  request_body_type: str | None = None
49
66
  response_type: str | None = None
50
67
  is_multipart: bool = False
68
+ response_handler: str | None = None # internal client method to call, if not the default
69
+ response_handler_return_type: str | None = None
70
+ boolean_status: bool = False # 404->False, 2xx->True, else raise VantageAPIError
51
71
 
52
72
 
53
73
  @dataclass
@@ -183,10 +203,16 @@ def preprocess_inline_models(schemas: dict[str, Any]) -> None:
183
203
  existing_names.add(model_name)
184
204
 
185
205
 
186
- 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:
187
211
  """Convert OpenAPI type to Python type hint."""
188
212
  if "$ref" in schema:
189
213
  ref_name = schema["$ref"].split("/")[-1]
214
+ if name_map and ref_name in name_map:
215
+ return name_map[ref_name]
190
216
  return to_pascal_case(ref_name)
191
217
 
192
218
  schema_type = schema.get("type", "any")
@@ -203,12 +229,12 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s
203
229
  return "bool"
204
230
  elif schema_type == "array":
205
231
  items = schema.get("items", {})
206
- item_type = openapi_type_to_python(items, schemas)
232
+ item_type = openapi_type_to_python(items, schemas, name_map)
207
233
  return f"List[{item_type}]"
208
234
  elif schema_type == "object":
209
235
  additional = schema.get("additionalProperties")
210
236
  if additional:
211
- value_type = openapi_type_to_python(additional, schemas)
237
+ value_type = openapi_type_to_python(additional, schemas, name_map)
212
238
  return f"Dict[str, {value_type}]"
213
239
  # Check if inline properties match an existing named schema
214
240
  inline_props = schema.get("properties")
@@ -217,6 +243,8 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s
217
243
  for schema_name, schema_def in schemas.items():
218
244
  defined_keys = sorted(schema_def.get("properties", {}).keys())
219
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]
220
248
  return to_pascal_case(schema_name)
221
249
  return "Dict[str, Any]"
222
250
  else:
@@ -224,7 +252,9 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s
224
252
 
225
253
 
226
254
  def extract_request_body_type(
227
- 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,
228
258
  ) -> tuple[str | None, bool]:
229
259
  """Extract request body type and whether it's multipart."""
230
260
  if not request_body:
@@ -239,13 +269,15 @@ def extract_request_body_type(
239
269
  # Check for JSON
240
270
  if "application/json" in content:
241
271
  schema = content["application/json"].get("schema", {})
242
- return openapi_type_to_python(schema, schemas), False
272
+ return openapi_type_to_python(schema, schemas, name_map), False
243
273
 
244
274
  return None, False
245
275
 
246
276
 
247
277
  def extract_response_type(
248
- 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,
249
281
  ) -> str | None:
250
282
  """Extract successful response type."""
251
283
  for code in ["200", "201", "202", "203"]:
@@ -255,15 +287,59 @@ def extract_response_type(
255
287
  content = response.get("content", {})
256
288
  if "application/json" in content:
257
289
  schema = content["application/json"].get("schema", {})
258
- return openapi_type_to_python(schema, schemas)
290
+ return openapi_type_to_python(schema, schemas, name_map)
259
291
  return None
260
292
 
261
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
+
262
337
  def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
263
338
  """Parse all endpoints from OpenAPI schema."""
264
339
  endpoints = []
265
340
  paths = schema.get("paths", {})
266
341
  schemas = schema.get("components", {}).get("schemas", {})
342
+ name_map = build_class_name_map(schemas, find_request_body_schemas(schema))
267
343
 
268
344
  for path, methods in paths.items():
269
345
  for method, spec in methods.items():
@@ -275,7 +351,7 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
275
351
  parameters = []
276
352
  for param in spec.get("parameters", []):
277
353
  param_schema = param.get("schema", {})
278
- param_type = openapi_type_to_python(param_schema, schemas)
354
+ param_type = openapi_type_to_python(param_schema, schemas, name_map)
279
355
  parameters.append(
280
356
  Parameter(
281
357
  name=param["name"],
@@ -288,9 +364,27 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
288
364
  )
289
365
 
290
366
  request_body = spec.get("requestBody")
291
- body_type, is_multipart = extract_request_body_type(request_body, schemas)
292
-
293
- response_type = extract_response_type(spec.get("responses", {}), schemas)
367
+ body_type, is_multipart = extract_request_body_type(request_body, schemas, name_map)
368
+
369
+ response_type = extract_response_type(spec.get("responses", {}), schemas, name_map)
370
+
371
+ description = spec.get("description")
372
+
373
+ response_handler = None
374
+ response_handler_return_type = None
375
+ for resp_desc in spec.get("responses", {}).values():
376
+ text = resp_desc.get("description", "")
377
+ for phrase, handler, return_type in RESPONSE_HANDLERS:
378
+ if phrase in text:
379
+ response_handler = handler
380
+ response_handler_return_type = return_type
381
+ break
382
+ if response_handler:
383
+ break
384
+
385
+ boolean_status = (method.upper(), path) in {
386
+ (m.upper(), p) for m, p in BOOLEAN_STATUS_ROUTES
387
+ }
294
388
 
295
389
  endpoints.append(
296
390
  Endpoint(
@@ -298,7 +392,7 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
298
392
  method=method.upper(),
299
393
  operation_id=operation_id,
300
394
  summary=spec.get("summary"),
301
- description=spec.get("description"),
395
+ description=description,
302
396
  deprecated=spec.get("deprecated", False),
303
397
  parameters=parameters,
304
398
  request_body_required=request_body.get("required", False)
@@ -307,6 +401,9 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
307
401
  request_body_type=body_type,
308
402
  response_type=response_type,
309
403
  is_multipart=is_multipart,
404
+ response_handler=response_handler,
405
+ response_handler_return_type=response_handler_return_type,
406
+ boolean_status=boolean_status,
310
407
  )
311
408
  )
312
409
 
@@ -440,6 +537,7 @@ def _append_response_mapping(lines: list[str], return_type: str, data_var: str)
440
537
  def generate_pydantic_models(schema: dict[str, Any]) -> str:
441
538
  """Generate Pydantic models from OpenAPI schemas."""
442
539
  schemas = schema.get("components", {}).get("schemas", {})
540
+ name_map = build_class_name_map(schemas, find_request_body_schemas(schema))
443
541
  lines = [
444
542
  '"""Auto-generated Pydantic models from OpenAPI schema."""',
445
543
  "",
@@ -453,7 +551,7 @@ def generate_pydantic_models(schema: dict[str, Any]) -> str:
453
551
  ]
454
552
 
455
553
  for name, spec in schemas.items():
456
- class_name = to_pascal_case(name)
554
+ class_name = name_map.get(name, to_pascal_case(name))
457
555
  description = spec.get("description", "")
458
556
 
459
557
  lines.append(f"class {class_name}(BaseModel):")
@@ -479,7 +577,7 @@ def generate_pydantic_models(schema: dict[str, Any]) -> str:
479
577
  python_name = python_name + "_"
480
578
  needs_alias = True
481
579
 
482
- prop_type = openapi_type_to_python(prop_spec, schemas)
580
+ prop_type = openapi_type_to_python(prop_spec, schemas, name_map)
483
581
 
484
582
  # Handle nullable
485
583
  if prop_spec.get("x-nullable") or prop_spec.get("nullable"):
@@ -523,6 +621,33 @@ def generate_pydantic_models(schema: dict[str, Any]) -> str:
523
621
  return "\n".join(lines)
524
622
 
525
623
 
624
+ def _collect_handler_routes(resources: dict[str, Resource]) -> dict[str, list[tuple[str, str]]]:
625
+ """Scan all endpoints and group (method, path) pairs by their response_handler."""
626
+ handler_routes: dict[str, list[tuple[str, str]]] = {}
627
+ for resource in resources.values():
628
+ for endpoint in resource.endpoints:
629
+ if endpoint.response_handler:
630
+ handler_routes.setdefault(endpoint.response_handler, []).append(
631
+ (endpoint.method, endpoint.path)
632
+ )
633
+ return handler_routes
634
+
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 = endpoint.path.split("{")[0]
647
+ result.append((endpoint.method, prefix))
648
+ return result
649
+
650
+
526
651
  def generate_sync_client(resources: dict[str, Resource]) -> str:
527
652
  """Generate synchronous client code."""
528
653
  lines = [
@@ -611,6 +736,29 @@ def generate_sync_client(resources: dict[str, Resource]) -> str:
611
736
  " json=body,",
612
737
  " )",
613
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
+ [
614
762
  " if not response.is_success:",
615
763
  " raise VantageAPIError(",
616
764
  " status=response.status_code,",
@@ -618,6 +766,19 @@ def generate_sync_client(resources: dict[str, Resource]) -> str:
618
766
  " body=response.text,",
619
767
  " )",
620
768
  "",
769
+ ]
770
+ )
771
+
772
+ # Inject generated routing: one if-block per handler, checking (method, path)
773
+ handler_routes = _collect_handler_routes(resources)
774
+ for handler, routes in sorted(handler_routes.items()):
775
+ route_set = "{" + ", ".join(f'("{m}", "{p}")' for m, p in sorted(routes)) + "}"
776
+ lines.append(f" if (method, path) in {route_set}:")
777
+ lines.append(f" return self.{handler}(response)")
778
+ lines.append("")
779
+
780
+ lines.extend(
781
+ [
621
782
  " try:",
622
783
  " data = response.json()",
623
784
  " except Exception:",
@@ -625,6 +786,10 @@ def generate_sync_client(resources: dict[str, Resource]) -> str:
625
786
  "",
626
787
  " return data",
627
788
  "",
789
+ " def _request_for_location(self, response: Any) -> str:",
790
+ ' """Extract the Location header from a response."""',
791
+ ' return response.headers["Location"]',
792
+ "",
628
793
  "",
629
794
  ]
630
795
  )
@@ -699,7 +864,12 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]:
699
864
 
700
865
  # Method signature
701
866
  param_str = ", ".join(["self"] + params) if params else "self"
702
- return_type = endpoint.response_type or "Any"
867
+ if endpoint.boolean_status:
868
+ return_type = "bool"
869
+ elif endpoint.response_handler:
870
+ return_type = endpoint.response_handler_return_type or "Any"
871
+ else:
872
+ return_type = endpoint.response_type or "None"
703
873
  lines.append(f" def {method_name}({param_str}) -> {return_type}:")
704
874
 
705
875
  # Docstring
@@ -740,11 +910,20 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]:
740
910
  lines.append(" body_data = None")
741
911
 
742
912
  # Make request and coerce response payload into typed models where possible
743
- lines.append(
744
- f' data = self._client.request("{endpoint.method}", path, params=params, body=body_data)'
745
- )
746
- _append_response_mapping(lines, return_type, "data")
747
- lines.append(" return data")
913
+ if endpoint.boolean_status or endpoint.response_handler:
914
+ lines.append(
915
+ f' return self._client.request("{endpoint.method}", path, params=params, body=body_data)'
916
+ )
917
+ elif endpoint.response_type is None:
918
+ lines.append(
919
+ f' self._client.request("{endpoint.method}", path, params=params, body=body_data)'
920
+ )
921
+ else:
922
+ lines.append(
923
+ f' data = self._client.request("{endpoint.method}", path, params=params, body=body_data)'
924
+ )
925
+ _append_response_mapping(lines, return_type, "data")
926
+ lines.append(" return data")
748
927
 
749
928
  return lines
750
929
 
@@ -837,6 +1016,29 @@ def generate_async_client(resources: dict[str, Resource]) -> str:
837
1016
  " json=body,",
838
1017
  " )",
839
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
+ [
840
1042
  " if not response.is_success:",
841
1043
  " raise VantageAPIError(",
842
1044
  " status=response.status_code,",
@@ -844,6 +1046,19 @@ def generate_async_client(resources: dict[str, Resource]) -> str:
844
1046
  " body=response.text,",
845
1047
  " )",
846
1048
  "",
1049
+ ]
1050
+ )
1051
+
1052
+ # Inject generated routing: one if-block per handler, checking (method, path)
1053
+ handler_routes = _collect_handler_routes(resources)
1054
+ for handler, routes in sorted(handler_routes.items()):
1055
+ route_set = "{" + ", ".join(f'("{m}", "{p}")' for m, p in sorted(routes)) + "}"
1056
+ lines.append(f" if (method, path) in {route_set}:")
1057
+ lines.append(f" return self.{handler}(response)")
1058
+ lines.append("")
1059
+
1060
+ lines.extend(
1061
+ [
847
1062
  " try:",
848
1063
  " data = response.json()",
849
1064
  " except Exception:",
@@ -851,6 +1066,10 @@ def generate_async_client(resources: dict[str, Resource]) -> str:
851
1066
  "",
852
1067
  " return data",
853
1068
  "",
1069
+ " def _request_for_location(self, response: Any) -> str:",
1070
+ ' """Extract the Location header from a response."""',
1071
+ ' return response.headers["Location"]',
1072
+ "",
854
1073
  "",
855
1074
  ]
856
1075
  )
@@ -925,7 +1144,12 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]:
925
1144
 
926
1145
  # Method signature
927
1146
  param_str = ", ".join(["self"] + params) if params else "self"
928
- return_type = endpoint.response_type or "Any"
1147
+ if endpoint.boolean_status:
1148
+ return_type = "bool"
1149
+ elif endpoint.response_handler:
1150
+ return_type = endpoint.response_handler_return_type or "Any"
1151
+ else:
1152
+ return_type = endpoint.response_type or "None"
929
1153
  lines.append(f" async def {method_name}({param_str}) -> {return_type}:")
930
1154
 
931
1155
  # Docstring
@@ -966,11 +1190,20 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]:
966
1190
  lines.append(" body_data = None")
967
1191
 
968
1192
  # Make request and coerce response payload into typed models where possible
969
- lines.append(
970
- f' data = await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
971
- )
972
- _append_response_mapping(lines, return_type, "data")
973
- lines.append(" return data")
1193
+ if endpoint.boolean_status or endpoint.response_handler:
1194
+ lines.append(
1195
+ f' return await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
1196
+ )
1197
+ elif endpoint.response_type is None:
1198
+ lines.append(
1199
+ f' await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
1200
+ )
1201
+ else:
1202
+ lines.append(
1203
+ f' data = await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
1204
+ )
1205
+ _append_response_mapping(lines, return_type, "data")
1206
+ lines.append(" return data")
974
1207
 
975
1208
  return lines
976
1209
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vantage-python"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Python SDK for the Vantage API"
9
9
  readme = "README.md"
10
10
  license = "MIT"