From f45b34bc9da1402932a75adb4a16647ee18001b3 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 23 Feb 2026 16:40:30 +0000 Subject: [PATCH 1/3] fix: Make a edgecase much nicer in the API spec. --- autogen.py | 85 ++++++++++++++++++++++++++++++++++-- src/vantage/_async/client.py | 16 ++++++- src/vantage/_sync/client.py | 16 ++++++- 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/autogen.py b/autogen.py index 1df9368..8659a75 100644 --- a/autogen.py +++ b/autogen.py @@ -31,6 +31,12 @@ ), ] +# Endpoints that return bool based on HTTP status: 404 -> False, 2xx -> True, else raise. +# Each entry is (METHOD, openapi_path_template). +BOOLEAN_STATUS_ROUTES: list[tuple[str, str]] = [ + ("GET", "/virtual_tag_configs/async/{request_id}"), +] + @dataclass class Parameter: @@ -61,6 +67,7 @@ class Endpoint: is_multipart: bool = False response_handler: str | None = None # internal client method to call, if not the default response_handler_return_type: str | None = None + boolean_status: bool = False # 404->False, 2xx->True, else raise VantageAPIError @dataclass @@ -319,6 +326,10 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]: if response_handler: break + boolean_status = (method.upper(), path) in { + (m.upper(), p) for m, p in BOOLEAN_STATUS_ROUTES + } + endpoints.append( Endpoint( path=path, @@ -336,6 +347,7 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]: is_multipart=is_multipart, response_handler=response_handler, response_handler_return_type=response_handler_return_type, + boolean_status=boolean_status, ) ) @@ -564,6 +576,21 @@ def _collect_handler_routes(resources: dict[str, Resource]) -> dict[str, list[tu return handler_routes +def _collect_boolean_status_prefixes(resources: dict[str, Resource]) -> list[tuple[str, str]]: + """Collect (method, path_prefix) pairs for boolean-status endpoints. + + The prefix is derived by taking everything before the first path parameter + so it can be matched with str.startswith() at runtime. + """ + result = [] + for resource in resources.values(): + for endpoint in resource.endpoints: + if endpoint.boolean_status: + prefix = endpoint.path.split("{")[0] + result.append((endpoint.method, prefix)) + return result + + def generate_sync_client(resources: dict[str, Resource]) -> str: """Generate synchronous client code.""" lines = [ @@ -652,6 +679,29 @@ def generate_sync_client(resources: dict[str, Resource]) -> str: " json=body,", " )", "", + ] + ) + + # Inject boolean-status path checks (before the generic error check) + boolean_prefixes = _collect_boolean_status_prefixes(resources) + for method, prefix in boolean_prefixes: + lines.extend([ + f' if method.upper() == "{method}" and path.startswith("{prefix}"):', + " if response.status_code == 404:", + " return False", + " elif response.is_success:", + " return True", + " else:", + " raise VantageAPIError(", + " status=response.status_code,", + " status_text=response.reason_phrase,", + " body=response.text,", + " )", + "", + ]) + + lines.extend( + [ " if not response.is_success:", " raise VantageAPIError(", " status=response.status_code,", @@ -757,7 +807,9 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]: # Method signature param_str = ", ".join(["self"] + params) if params else "self" - if endpoint.response_handler: + if endpoint.boolean_status: + return_type = "bool" + elif endpoint.response_handler: return_type = endpoint.response_handler_return_type or "Any" else: return_type = endpoint.response_type or "None" @@ -801,7 +853,7 @@ def generate_sync_method(endpoint: Endpoint, method_name: str) -> list[str]: lines.append(" body_data = None") # Make request and coerce response payload into typed models where possible - if endpoint.response_handler: + if endpoint.boolean_status or endpoint.response_handler: lines.append( f' return self._client.request("{endpoint.method}", path, params=params, body=body_data)' ) @@ -907,6 +959,29 @@ def generate_async_client(resources: dict[str, Resource]) -> str: " json=body,", " )", "", + ] + ) + + # Inject boolean-status path checks (before the generic error check) + boolean_prefixes = _collect_boolean_status_prefixes(resources) + for method, prefix in boolean_prefixes: + lines.extend([ + f' if method.upper() == "{method}" and path.startswith("{prefix}"):', + " if response.status_code == 404:", + " return False", + " elif response.is_success:", + " return True", + " else:", + " raise VantageAPIError(", + " status=response.status_code,", + " status_text=response.reason_phrase,", + " body=response.text,", + " )", + "", + ]) + + lines.extend( + [ " if not response.is_success:", " raise VantageAPIError(", " status=response.status_code,", @@ -1012,7 +1087,9 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]: # Method signature param_str = ", ".join(["self"] + params) if params else "self" - if endpoint.response_handler: + if endpoint.boolean_status: + return_type = "bool" + elif endpoint.response_handler: return_type = endpoint.response_handler_return_type or "Any" else: return_type = endpoint.response_type or "None" @@ -1056,7 +1133,7 @@ def generate_async_method(endpoint: Endpoint, method_name: str) -> list[str]: lines.append(" body_data = None") # Make request and coerce response payload into typed models where possible - if endpoint.response_handler: + if endpoint.boolean_status or endpoint.response_handler: lines.append( f' return await self._client.request("{endpoint.method}", path, params=params, body=body_data)' ) diff --git a/src/vantage/_async/client.py b/src/vantage/_async/client.py index 151aac8..13de49d 100644 --- a/src/vantage/_async/client.py +++ b/src/vantage/_async/client.py @@ -116,6 +116,18 @@ async def request( json=body, ) + if method.upper() == "GET" and path.startswith("/virtual_tag_configs/async/"): + if response.status_code == 404: + return False + elif response.is_success: + return True + else: + raise VantageAPIError( + status=response.status_code, + status_text=response.reason_phrase, + body=response.text, + ) + if not response.is_success: raise VantageAPIError( status=response.status_code, @@ -3053,7 +3065,7 @@ async def update_async(self, token: str, body: UpdateAsyncVirtualTagConfig) -> N body_data = body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, 'model_dump') else body await self._client.request("PUT", path, params=params, body=body_data) - async def get_async_virtual_tag_config_status(self, request_id: str) -> None: + async def get_async_virtual_tag_config_status(self, request_id: str) -> bool: """ Get async virtual tag config update status @@ -3062,7 +3074,7 @@ async def get_async_virtual_tag_config_status(self, request_id: str) -> None: path = f"/virtual_tag_configs/async/{quote(str(request_id), safe='')}" params = None body_data = None - await self._client.request("GET", path, params=params, body=body_data) + return await self._client.request("GET", path, params=params, body=body_data) class WorkspacesAsyncApi: diff --git a/src/vantage/_sync/client.py b/src/vantage/_sync/client.py index b96b753..c73cc2f 100644 --- a/src/vantage/_sync/client.py +++ b/src/vantage/_sync/client.py @@ -116,6 +116,18 @@ def request( json=body, ) + if method.upper() == "GET" and path.startswith("/virtual_tag_configs/async/"): + if response.status_code == 404: + return False + elif response.is_success: + return True + else: + raise VantageAPIError( + status=response.status_code, + status_text=response.reason_phrase, + body=response.text, + ) + if not response.is_success: raise VantageAPIError( status=response.status_code, @@ -3053,7 +3065,7 @@ def update_async(self, token: str, body: UpdateAsyncVirtualTagConfig) -> None: body_data = body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, 'model_dump') else body self._client.request("PUT", path, params=params, body=body_data) - def get_async_virtual_tag_config_status(self, request_id: str) -> None: + def get_async_virtual_tag_config_status(self, request_id: str) -> bool: """ Get async virtual tag config update status @@ -3062,7 +3074,7 @@ def get_async_virtual_tag_config_status(self, request_id: str) -> None: path = f"/virtual_tag_configs/async/{quote(str(request_id), safe='')}" params = None body_data = None - self._client.request("GET", path, params=params, body=body_data) + return self._client.request("GET", path, params=params, body=body_data) class WorkspacesApi: From a2b3b492d8388d270ac4e0114ce67535635d2940 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 23 Feb 2026 17:05:26 +0000 Subject: [PATCH 2/3] fix: Update the API data based on the specification. --- autogen.py | 81 ++++++++++++++++++++++++++++++------ pyproject.toml | 2 +- src/vantage/_async/client.py | 14 +++++-- src/vantage/_sync/client.py | 14 +++++-- src/vantage/_types.py | 15 ++++++- 5 files changed, 104 insertions(+), 22 deletions(-) diff --git a/autogen.py b/autogen.py index 8659a75..13d9e9a 100644 --- a/autogen.py +++ b/autogen.py @@ -203,10 +203,16 @@ def preprocess_inline_models(schemas: dict[str, Any]) -> None: existing_names.add(model_name) -def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> str: +def openapi_type_to_python( + schema: dict[str, Any], + schemas: dict[str, Any], + name_map: dict[str, str] | None = None, +) -> str: """Convert OpenAPI type to Python type hint.""" if "$ref" in schema: ref_name = schema["$ref"].split("/")[-1] + if name_map and ref_name in name_map: + return name_map[ref_name] return to_pascal_case(ref_name) schema_type = schema.get("type", "any") @@ -223,12 +229,12 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s return "bool" elif schema_type == "array": items = schema.get("items", {}) - item_type = openapi_type_to_python(items, schemas) + item_type = openapi_type_to_python(items, schemas, name_map) return f"List[{item_type}]" elif schema_type == "object": additional = schema.get("additionalProperties") if additional: - value_type = openapi_type_to_python(additional, schemas) + value_type = openapi_type_to_python(additional, schemas, name_map) return f"Dict[str, {value_type}]" # Check if inline properties match an existing named schema inline_props = schema.get("properties") @@ -237,6 +243,8 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s for schema_name, schema_def in schemas.items(): defined_keys = sorted(schema_def.get("properties", {}).keys()) if defined_keys and inline_keys == defined_keys: + if name_map and schema_name in name_map: + return name_map[schema_name] return to_pascal_case(schema_name) return "Dict[str, Any]" else: @@ -244,7 +252,9 @@ def openapi_type_to_python(schema: dict[str, Any], schemas: dict[str, Any]) -> s def extract_request_body_type( - request_body: dict[str, Any] | None, schemas: dict[str, Any] + request_body: dict[str, Any] | None, + schemas: dict[str, Any], + name_map: dict[str, str] | None = None, ) -> tuple[str | None, bool]: """Extract request body type and whether it's multipart.""" if not request_body: @@ -259,13 +269,15 @@ def extract_request_body_type( # Check for JSON if "application/json" in content: schema = content["application/json"].get("schema", {}) - return openapi_type_to_python(schema, schemas), False + return openapi_type_to_python(schema, schemas, name_map), False return None, False def extract_response_type( - responses: dict[str, Any], schemas: dict[str, Any] + responses: dict[str, Any], + schemas: dict[str, Any], + name_map: dict[str, str] | None = None, ) -> str | None: """Extract successful response type.""" for code in ["200", "201", "202", "203"]: @@ -275,15 +287,59 @@ def extract_response_type( content = response.get("content", {}) if "application/json" in content: schema = content["application/json"].get("schema", {}) - return openapi_type_to_python(schema, schemas) + return openapi_type_to_python(schema, schemas, name_map) return None +def find_request_body_schemas(schema: dict[str, Any]) -> set[str]: + """Return the set of schema names referenced as request bodies in any endpoint.""" + result = set() + paths = schema.get("paths", {}) + for path_item in paths.values(): + for method, spec in path_item.items(): + if method in ("parameters", "servers", "summary", "description"): + continue + request_body = spec.get("requestBody", {}) + content = request_body.get("content", {}) + for media_type in content.values(): + ref_schema = media_type.get("schema", {}) + if "$ref" in ref_schema: + name = ref_schema["$ref"].split("/")[-1] + result.add(name) + return result + + +def build_class_name_map(schemas: dict[str, Any], request_body_schemas: set[str]) -> dict[str, str]: + """Build a mapping from raw schema names to Python class names, resolving conflicts. + + If two schema names map to the same PascalCase name, the one used as a + request body is suffixed with 'Request'. + """ + initial = {name: to_pascal_case(name) for name in schemas} + + by_class_name: dict[str, list[str]] = {} + for raw_name, class_name in initial.items(): + by_class_name.setdefault(class_name, []).append(raw_name) + + result: dict[str, str] = {} + for class_name, raw_names in by_class_name.items(): + if len(raw_names) == 1: + result[raw_names[0]] = class_name + else: + for raw_name in raw_names: + if raw_name in request_body_schemas: + result[raw_name] = class_name + "Request" + else: + result[raw_name] = class_name + return result + + def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]: """Parse all endpoints from OpenAPI schema.""" endpoints = [] paths = schema.get("paths", {}) schemas = schema.get("components", {}).get("schemas", {}) + name_map = build_class_name_map(schemas, find_request_body_schemas(schema)) for path, methods in paths.items(): for method, spec in methods.items(): @@ -295,7 +351,7 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]: parameters = [] for param in spec.get("parameters", []): param_schema = param.get("schema", {}) - param_type = openapi_type_to_python(param_schema, schemas) + param_type = openapi_type_to_python(param_schema, schemas, name_map) parameters.append( Parameter( name=param["name"], @@ -308,9 +364,9 @@ def parse_endpoints(schema: dict[str, Any]) -> list[Endpoint]: ) request_body = spec.get("requestBody") - body_type, is_multipart = extract_request_body_type(request_body, schemas) + body_type, is_multipart = extract_request_body_type(request_body, schemas, name_map) - response_type = extract_response_type(spec.get("responses", {}), schemas) + response_type = extract_response_type(spec.get("responses", {}), schemas, name_map) description = spec.get("description") @@ -481,6 +537,7 @@ def _append_response_mapping(lines: list[str], return_type: str, data_var: str) def generate_pydantic_models(schema: dict[str, Any]) -> str: """Generate Pydantic models from OpenAPI schemas.""" schemas = schema.get("components", {}).get("schemas", {}) + name_map = build_class_name_map(schemas, find_request_body_schemas(schema)) lines = [ '"""Auto-generated Pydantic models from OpenAPI schema."""', "", @@ -494,7 +551,7 @@ def generate_pydantic_models(schema: dict[str, Any]) -> str: ] for name, spec in schemas.items(): - class_name = to_pascal_case(name) + class_name = name_map.get(name, to_pascal_case(name)) description = spec.get("description", "") lines.append(f"class {class_name}(BaseModel):") @@ -520,7 +577,7 @@ def generate_pydantic_models(schema: dict[str, Any]) -> str: python_name = python_name + "_" needs_alias = True - prop_type = openapi_type_to_python(prop_spec, schemas) + prop_type = openapi_type_to_python(prop_spec, schemas, name_map) # Handle nullable if prop_spec.get("x-nullable") or prop_spec.get("nullable"): diff --git a/pyproject.toml b/pyproject.toml index 9c2f5a1..3b1225a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "vantage-python" -version = "0.3.2" +version = "0.3.3" description = "Python SDK for the Vantage API" readme = "README.md" license = "MIT" diff --git a/src/vantage/_async/client.py b/src/vantage/_async/client.py index 13de49d..65fbd4f 100644 --- a/src/vantage/_async/client.py +++ b/src/vantage/_async/client.py @@ -1686,7 +1686,7 @@ async def get(self, invoice_token: str) -> Invoice: return Invoice.model_validate(data) return data - async def download(self, invoice_token: str, body: DownloadInvoice) -> None: + async def download(self, invoice_token: str, body: DownloadInvoiceRequest) -> DownloadInvoice: """ Get invoice file @@ -1695,7 +1695,10 @@ async def download(self, invoice_token: str, body: DownloadInvoice) -> None: path = f"/invoices/{quote(str(invoice_token), safe='')}/download" params = None body_data = body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, 'model_dump') else body - await self._client.request("POST", path, params=params, body=body_data) + data = await self._client.request("POST", path, params=params, body=body_data) + if isinstance(data, dict): + return DownloadInvoice.model_validate(data) + return data async def send(self, invoice_token: str) -> SendInvoice: """ @@ -3054,7 +3057,7 @@ async def get_status(self, token: str) -> VirtualTagConfigStatus: return VirtualTagConfigStatus.model_validate(data) return data - async def update_async(self, token: str, body: UpdateAsyncVirtualTagConfig) -> None: + async def update_async(self, token: str, body: UpdateAsyncVirtualTagConfig) -> AsyncVirtualTagConfigUpdate: """ Update virtual tag config asynchronously @@ -3063,7 +3066,10 @@ async def update_async(self, token: str, body: UpdateAsyncVirtualTagConfig) -> N path = f"/virtual_tag_configs/{quote(str(token), safe='')}/async" params = None body_data = body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, 'model_dump') else body - await self._client.request("PUT", path, params=params, body=body_data) + data = await self._client.request("PUT", path, params=params, body=body_data) + if isinstance(data, dict): + return AsyncVirtualTagConfigUpdate.model_validate(data) + return data async def get_async_virtual_tag_config_status(self, request_id: str) -> bool: """ diff --git a/src/vantage/_sync/client.py b/src/vantage/_sync/client.py index c73cc2f..737b4bd 100644 --- a/src/vantage/_sync/client.py +++ b/src/vantage/_sync/client.py @@ -1686,7 +1686,7 @@ def get(self, invoice_token: str) -> Invoice: return Invoice.model_validate(data) return data - def download(self, invoice_token: str, body: DownloadInvoice) -> None: + def download(self, invoice_token: str, body: DownloadInvoiceRequest) -> DownloadInvoice: """ Get invoice file @@ -1695,7 +1695,10 @@ def download(self, invoice_token: str, body: DownloadInvoice) -> None: path = f"/invoices/{quote(str(invoice_token), safe='')}/download" params = None body_data = body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, 'model_dump') else body - self._client.request("POST", path, params=params, body=body_data) + data = self._client.request("POST", path, params=params, body=body_data) + if isinstance(data, dict): + return DownloadInvoice.model_validate(data) + return data def send(self, invoice_token: str) -> SendInvoice: """ @@ -3054,7 +3057,7 @@ def get_status(self, token: str) -> VirtualTagConfigStatus: return VirtualTagConfigStatus.model_validate(data) return data - def update_async(self, token: str, body: UpdateAsyncVirtualTagConfig) -> None: + def update_async(self, token: str, body: UpdateAsyncVirtualTagConfig) -> AsyncVirtualTagConfigUpdate: """ Update virtual tag config asynchronously @@ -3063,7 +3066,10 @@ def update_async(self, token: str, body: UpdateAsyncVirtualTagConfig) -> None: path = f"/virtual_tag_configs/{quote(str(token), safe='')}/async" params = None body_data = body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, 'model_dump') else body - self._client.request("PUT", path, params=params, body=body_data) + data = self._client.request("PUT", path, params=params, body=body_data) + if isinstance(data, dict): + return AsyncVirtualTagConfigUpdate.model_validate(data) + return data def get_async_virtual_tag_config_status(self, request_id: str) -> bool: """ diff --git a/src/vantage/_types.py b/src/vantage/_types.py index cecc769..6b8b97a 100644 --- a/src/vantage/_types.py +++ b/src/vantage/_types.py @@ -1021,12 +1021,18 @@ class CreateInvoice(BaseModel): account_token: str = Field(description="Token of the managed account to invoice") -class DownloadInvoice(BaseModel): +class DownloadInvoiceRequest(BaseModel): """Download invoice file (PDF or CSV).""" file_type: str = Field(description="Type of file to download (pdf or csv)") +class DownloadInvoice(BaseModel): + """DownloadInvoice model""" + + download_url: str = Field(description="The URL to download the invoice file.") + + class SendInvoice(BaseModel): """SendInvoice model""" @@ -1847,6 +1853,13 @@ class UpdateAsyncVirtualTagConfig(BaseModel): values: Optional[List[VirtualTagConfigValue]] = Field(default=None, description="Values for the VirtualTagConfig, with match precedence determined by order in the list.") +class AsyncVirtualTagConfigUpdate(BaseModel): + """AsyncVirtualTagConfigUpdate model""" + + request_id: str = Field(description="The request ID of the async virtual tag config update.") + status_url: str = Field(description="The status path of the async virtual tag config update.") + + class Workspaces(BaseModel): """Workspaces model""" From e2accfb42a303eeecd155733e56823060fa18957 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Tue, 24 Feb 2026 11:47:15 +0000 Subject: [PATCH 3/3] chore: Bump the types. --- src/vantage/_types.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vantage/_types.py b/src/vantage/_types.py index 6b8b97a..a6767e9 100644 --- a/src/vantage/_types.py +++ b/src/vantage/_types.py @@ -1126,6 +1126,8 @@ class ManagedAccount(BaseModel): billing_rule_tokens: List[str] = Field(description="The tokens for the Billing Rules assigned to the Managed Account.") email_domain: Optional[str] = Field(default=None, description="Email domain associated with this Managed Account for SSO.") msp_billing_profile_token: Optional[str] = Field(default=None, description="Token of the MSP billing profile used for this managed account (MSP invoicing accounts only)") + payment_terms_days: Optional[int] = Field(default=None, description="Number of days until payment is due after invoice date (MSP invoicing accounts only)") + include_managed_account_integrations: Optional[bool] = Field(default=None, description="Whether to include managed account's own integrations in invoice cost calculations (MSP invoicing accounts only)") billing_information_attributes: Optional[BillingInformation] = Field(default=None) business_information_attributes: Optional[BusinessInformation] = Field(default=None) @@ -1149,6 +1151,8 @@ class UpdateManagedAccount(BaseModel): billing_rule_tokens: Optional[List[str]] = Field(default=None, description="Billing Rule tokens to assign to the Managed Account.") email_domain: Optional[str] = Field(default=None, description="Email domain to associate with this Managed Account for SSO.") msp_billing_profile_token: Optional[str] = Field(default=None, description="Token of the MSP billing profile to use for this managed account (MSP invoicing accounts only).") + payment_terms_days: Optional[int] = Field(default=None, description="Number of days until payment is due after invoice date (MSP invoicing accounts only). Defaults to 10.") + include_managed_account_integrations: Optional[bool] = Field(default=None, description="Whether to include managed account's own integrations in invoice cost calculations (MSP invoicing accounts only). Defaults to false.") billing_information_attributes: Optional[BillingInformationAttributes] = Field(default=None, description="Billing address and contact information (MSP invoicing accounts only)") business_information_attributes: Optional[BusinessInformationAttributes] = Field(default=None, description="Business information and custom fields (MSP invoicing accounts only)")