vantage-python 0.3.0__tar.gz → 0.3.2__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.
@@ -17,6 +17,8 @@ jobs:
17
17
  TAG_NOT_PRESENT: ${{ steps.get-tag.outputs.TAG_NOT_PRESENT }}
18
18
  steps:
19
19
  - uses: actions/checkout@v4
20
+ with:
21
+ fetch-depth: 0
20
22
  - name: Get the tag from Python and check if it's present
21
23
  id: get-tag
22
24
  run: |
@@ -14,7 +14,6 @@ jobs:
14
14
  with:
15
15
  python-version: ${{ matrix.python-version }}
16
16
  - run: pip install -e ".[dev]"
17
- - run: python autogen.py
18
17
  - run: pytest tests/test_e2e.py
19
18
  env:
20
19
  VANTAGE_API_TOKEN: ${{ secrets.VANTAGE_API_TOKEN }}
@@ -32,7 +31,6 @@ jobs:
32
31
  with:
33
32
  python-version: ${{ matrix.python-version }}
34
33
  - run: pip install -e ".[dev]"
35
- - run: python autogen.py
36
34
  - run: python -c "from vantage import Client, AsyncClient"
37
35
 
38
36
  compare-to-api:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vantage-python
3
- Version: 0.3.0
3
+ Version: 0.3.2
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,17 @@ 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
+
23
34
 
24
35
  @dataclass
25
36
  class Parameter:
@@ -48,6 +59,8 @@ class Endpoint:
48
59
  request_body_type: str | None = None
49
60
  response_type: str | None = None
50
61
  is_multipart: bool = False
62
+ response_handler: str | None = None # internal client method to call, if not the default
63
+ response_handler_return_type: str | None = None
51
64
 
52
65
 
53
66
  @dataclass
@@ -292,13 +305,27 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
292
305
 
293
306
  response_type = extract_response_type(spec.get("responses", {}), schemas)
294
307
 
308
+ description = spec.get("description")
309
+
310
+ response_handler = None
311
+ response_handler_return_type = None
312
+ for resp_desc in spec.get("responses", {}).values():
313
+ text = resp_desc.get("description", "")
314
+ for phrase, handler, return_type in RESPONSE_HANDLERS:
315
+ if phrase in text:
316
+ response_handler = handler
317
+ response_handler_return_type = return_type
318
+ break
319
+ if response_handler:
320
+ break
321
+
295
322
  endpoints.append(
296
323
  Endpoint(
297
324
  path=path,
298
325
  method=method.upper(),
299
326
  operation_id=operation_id,
300
327
  summary=spec.get("summary"),
301
- description=spec.get("description"),
328
+ description=description,
302
329
  deprecated=spec.get("deprecated", False),
303
330
  parameters=parameters,
304
331
  request_body_required=request_body.get("required", False)
@@ -307,6 +334,8 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]:
307
334
  request_body_type=body_type,
308
335
  response_type=response_type,
309
336
  is_multipart=is_multipart,
337
+ response_handler=response_handler,
338
+ response_handler_return_type=response_handler_return_type,
310
339
  )
311
340
  )
312
341
 
@@ -373,6 +402,70 @@ def generate_method_name(endpoint: Endpoint, resource_name: str) -> str:
373
402
  return to_snake_case(op_id)
374
403
 
375
404
 
405
+ def _extract_inner_type(type_hint: str, generic_prefix: str) -> str | None:
406
+ """Extract inner type from simple generic forms like Prefix[Inner]."""
407
+ if not type_hint.startswith(generic_prefix) or not type_hint.endswith("]"):
408
+ return None
409
+ return type_hint[len(generic_prefix):-1].strip()
410
+
411
+
412
+ def _extract_dict_value_type(type_hint: str) -> str | None:
413
+ """Extract value type from Dict[str, ValueType]."""
414
+ if not type_hint.startswith("Dict[") or not type_hint.endswith("]"):
415
+ return None
416
+ inner = type_hint[len("Dict["):-1].strip()
417
+ key_and_value = inner.split(",", 1)
418
+ if len(key_and_value) != 2:
419
+ return None
420
+ key_type = key_and_value[0].strip()
421
+ value_type = key_and_value[1].strip()
422
+ if key_type not in {"str", "Optional[str]"}:
423
+ return None
424
+ return value_type
425
+
426
+
427
+ def _is_model_type(type_hint: str) -> bool:
428
+ """Return True for generated Pydantic model type names."""
429
+ return bool(re.match(r"^[A-Z][A-Za-z0-9_]*$", type_hint))
430
+
431
+
432
+ def _append_response_mapping(lines: list[str], return_type: str, data_var: str) -> None:
433
+ """Append generated code that coerces dict payloads into typed models."""
434
+ type_hint = return_type.strip()
435
+
436
+ optional_inner = _extract_inner_type(type_hint, "Optional[")
437
+ if optional_inner:
438
+ type_hint = optional_inner
439
+
440
+ list_inner = _extract_inner_type(type_hint, "List[")
441
+ if list_inner and _is_model_type(list_inner):
442
+ lines.extend(
443
+ [
444
+ f" if isinstance({data_var}, list):",
445
+ f" return [{list_inner}.model_validate(item) if isinstance(item, dict) else item for item in {data_var}]",
446
+ ]
447
+ )
448
+ return
449
+
450
+ dict_inner = _extract_dict_value_type(type_hint)
451
+ if dict_inner and _is_model_type(dict_inner):
452
+ lines.extend(
453
+ [
454
+ f" if isinstance({data_var}, dict):",
455
+ f" return {{k: {dict_inner}.model_validate(v) if isinstance(v, dict) else v for k, v in {data_var}.items()}}",
456
+ ]
457
+ )
458
+ return
459
+
460
+ if _is_model_type(type_hint):
461
+ lines.extend(
462
+ [
463
+ f" if isinstance({data_var}, dict):",
464
+ f" return {type_hint}.model_validate({data_var})",
465
+ ]
466
+ )
467
+
468
+
376
469
  def generate_pydantic_models(schema: dict[str, Any]) -> str:
377
470
  """Generate Pydantic models from OpenAPI schemas."""
378
471
  schemas = schema.get("components", {}).get("schemas", {})
@@ -459,6 +552,18 @@ def generate_pydantic_models(schema: dict[str, Any]) -> str:
459
552
  return "\n".join(lines)
460
553
 
461
554
 
555
+ def _collect_handler_routes(resources: dict[str, Resource]) -> dict[str, list[tuple[str, str]]]:
556
+ """Scan all endpoints and group (method, path) pairs by their response_handler."""
557
+ handler_routes: dict[str, list[tuple[str, str]]] = {}
558
+ for resource in resources.values():
559
+ for endpoint in resource.endpoints:
560
+ if endpoint.response_handler:
561
+ handler_routes.setdefault(endpoint.response_handler, []).append(
562
+ (endpoint.method, endpoint.path)
563
+ )
564
+ return handler_routes
565
+
566
+
462
567
  def generate_sync_client(resources: dict[str, Resource]) -> str:
463
568
  """Generate synchronous client code."""
464
569
  lines = [
@@ -554,6 +659,19 @@ def generate_sync_client(resources: dict[str, Resource]) -> str:
554
659
  " body=response.text,",
555
660
  " )",
556
661
  "",
662
+ ]
663
+ )
664
+
665
+ # Inject generated routing: one if-block per handler, checking (method, path)
666
+ handler_routes = _collect_handler_routes(resources)
667
+ for handler, routes in sorted(handler_routes.items()):
668
+ route_set = "{" + ", ".join(f'("{m}", "{p}")' for m, p in sorted(routes)) + "}"
669
+ lines.append(f" if (method, path) in {route_set}:")
670
+ lines.append(f" return self.{handler}(response)")
671
+ lines.append("")
672
+
673
+ lines.extend(
674
+ [
557
675
  " try:",
558
676
  " data = response.json()",
559
677
  " except Exception:",
@@ -561,6 +679,10 @@ def generate_sync_client(resources: dict[str, Resource]) -> str:
561
679
  "",
562
680
  " return data",
563
681
  "",
682
+ " def _request_for_location(self, response: Any) -> str:",
683
+ ' """Extract the Location header from a response."""',
684
+ ' return response.headers["Location"]',
685
+ "",
564
686
  "",
565
687
  ]
566
688
  )
@@ -635,7 +757,10 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]:
635
757
 
636
758
  # Method signature
637
759
  param_str = ", ".join(["self"] + params) if params else "self"
638
- return_type = endpoint.response_type or "Any"
760
+ if endpoint.response_handler:
761
+ return_type = endpoint.response_handler_return_type or "Any"
762
+ else:
763
+ return_type = endpoint.response_type or "None"
639
764
  lines.append(f" def {method_name}({param_str}) -> {return_type}:")
640
765
 
641
766
  # Docstring
@@ -675,10 +800,21 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]:
675
800
  else:
676
801
  lines.append(" body_data = None")
677
802
 
678
- # Make request
679
- lines.append(
680
- f' return self._client.request("{endpoint.method}", path, params=params, body=body_data)'
681
- )
803
+ # Make request and coerce response payload into typed models where possible
804
+ if endpoint.response_handler:
805
+ lines.append(
806
+ f' return self._client.request("{endpoint.method}", path, params=params, body=body_data)'
807
+ )
808
+ elif endpoint.response_type is None:
809
+ lines.append(
810
+ f' self._client.request("{endpoint.method}", path, params=params, body=body_data)'
811
+ )
812
+ else:
813
+ lines.append(
814
+ f' data = self._client.request("{endpoint.method}", path, params=params, body=body_data)'
815
+ )
816
+ _append_response_mapping(lines, return_type, "data")
817
+ lines.append(" return data")
682
818
 
683
819
  return lines
684
820
 
@@ -778,6 +914,19 @@ def generate_async_client(resources: dict[str, Resource]) -> str:
778
914
  " body=response.text,",
779
915
  " )",
780
916
  "",
917
+ ]
918
+ )
919
+
920
+ # Inject generated routing: one if-block per handler, checking (method, path)
921
+ handler_routes = _collect_handler_routes(resources)
922
+ for handler, routes in sorted(handler_routes.items()):
923
+ route_set = "{" + ", ".join(f'("{m}", "{p}")' for m, p in sorted(routes)) + "}"
924
+ lines.append(f" if (method, path) in {route_set}:")
925
+ lines.append(f" return self.{handler}(response)")
926
+ lines.append("")
927
+
928
+ lines.extend(
929
+ [
781
930
  " try:",
782
931
  " data = response.json()",
783
932
  " except Exception:",
@@ -785,6 +934,10 @@ def generate_async_client(resources: dict[str, Resource]) -> str:
785
934
  "",
786
935
  " return data",
787
936
  "",
937
+ " def _request_for_location(self, response: Any) -> str:",
938
+ ' """Extract the Location header from a response."""',
939
+ ' return response.headers["Location"]',
940
+ "",
788
941
  "",
789
942
  ]
790
943
  )
@@ -859,7 +1012,10 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]:
859
1012
 
860
1013
  # Method signature
861
1014
  param_str = ", ".join(["self"] + params) if params else "self"
862
- return_type = endpoint.response_type or "Any"
1015
+ if endpoint.response_handler:
1016
+ return_type = endpoint.response_handler_return_type or "Any"
1017
+ else:
1018
+ return_type = endpoint.response_type or "None"
863
1019
  lines.append(f" async def {method_name}({param_str}) -> {return_type}:")
864
1020
 
865
1021
  # Docstring
@@ -899,10 +1055,21 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]:
899
1055
  else:
900
1056
  lines.append(" body_data = None")
901
1057
 
902
- # Make request
903
- lines.append(
904
- f' return await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
905
- )
1058
+ # Make request and coerce response payload into typed models where possible
1059
+ if endpoint.response_handler:
1060
+ lines.append(
1061
+ f' return await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
1062
+ )
1063
+ elif endpoint.response_type is None:
1064
+ lines.append(
1065
+ f' await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
1066
+ )
1067
+ else:
1068
+ lines.append(
1069
+ f' data = await self._client.request("{endpoint.method}", path, params=params, body=body_data)'
1070
+ )
1071
+ _append_response_mapping(lines, return_type, "data")
1072
+ lines.append(" return data")
906
1073
 
907
1074
  return lines
908
1075
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vantage-python"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Python SDK for the Vantage API"
9
9
  readme = "README.md"
10
10
  license = "MIT"