From aeca32fd824a187b50f7f47371b84e268e29dbd7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 1 Jul 2025 18:16:24 +0530 Subject: [PATCH 1/9] feat: add support for nullable parameters in API calls in python --- templates/python/base/params.twig | 6 ++++++ templates/python/base/requests/api.twig | 2 +- templates/python/package/client.py.twig | 8 ++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/templates/python/base/params.twig b/templates/python/base/params.twig index 8a574ec969..0609239f0a 100644 --- a/templates/python/base/params.twig +++ b/templates/python/base/params.twig @@ -1,10 +1,16 @@ api_params = {} + nullable_params = [] + {% if method.parameters.all | length %} {% for parameter in method.parameters.all %} {% if parameter.required and not parameter.nullable %} if {{ parameter.name | escapeKeyword | caseSnake }} is None: raise {{spec.title | caseUcfirst}}Exception('Missing required parameter: "{{ parameter.name | escapeKeyword | caseSnake }}"') +{% endif %} +{% if parameter.nullable %} + nullable_params.append('{{ parameter.name }}') + {% endif %} {% endfor %} {% for parameter in method.parameters.path %} diff --git a/templates/python/base/requests/api.twig b/templates/python/base/requests/api.twig index 82ef6299f6..84ec4ffc9a 100644 --- a/templates/python/base/requests/api.twig +++ b/templates/python/base/requests/api.twig @@ -5,4 +5,4 @@ {% for key, header in method.headers %} '{{ key }}': '{{ header }}', {% endfor %} - }, api_params{% if method.type == 'webAuth' %}, response_type='location'{% endif %}) \ No newline at end of file + }, api_params, nullable_params{% if method.type == 'webAuth' %}, response_type='location'{% endif %}) \ No newline at end of file diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index f9d4b90f13..95028bd793 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -49,14 +49,18 @@ class Client: return self {% endfor %} - def call(self, method, path='', headers=None, params=None, response_type='json'): + def call(self, method, path='', headers=None, params=None, response_type='json', nullable_params=None): if headers is None: headers = {} if params is None: params = {} - params = {k: v for k, v in params.items() if v is not None} # Remove None values from params dictionary + if nullable_params is None: + nullable_params = [] + + # Only filter out None values for non-nullable params + params = {k: v for k, v in params.items() if v is not None or k in nullable_params} data = {} files = {} From a1811b8f2000197ead0615abc05cfec51170cb9b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 1 Jul 2025 18:31:35 +0530 Subject: [PATCH 2/9] fix: position of params --- templates/python/package/client.py.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 95028bd793..db2ad5f7bc 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -49,7 +49,7 @@ class Client: return self {% endfor %} - def call(self, method, path='', headers=None, params=None, response_type='json', nullable_params=None): + def call(self, method, path='', headers=None, params=None, nullable_params=None, response_type='json'): if headers is None: headers = {} From 5c535155d56d4ecd49cfc10b2024221eb61db6da Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Thu, 16 Oct 2025 12:51:21 +0530 Subject: [PATCH 3/9] fix: null & none values --- templates/python/package/client.py.twig | 89 +++++++------------------ 1 file changed, 25 insertions(+), 64 deletions(-) diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index db2ad5f7bc..76ab6eff30 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -24,6 +24,19 @@ class Client: {% endfor %} } + @staticmethod + def _is_explicitly_null(value): + """Helper method to distinguish between None (undefined) and explicit null values""" + # Check if value is a special marker indicating an explicit null + return isinstance(value, type('NullValue', (), {'is_null': True})) + + @staticmethod + def null(): + """Helper method to create an explicit null value to be sent to the API + Use this when you want to explicitly set a field to null, as opposed to + not sending the field at all (which happens when you use None)""" + return type('NullValue', (), {'is_null': True})() + def set_self_signed(self, status=True): self._self_signed = status return self @@ -49,80 +62,22 @@ class Client: return self {% endfor %} - def call(self, method, path='', headers=None, params=None, nullable_params=None, response_type='json'): + def call(self, method, path='', headers=None, params=None, response_type='json'): if headers is None: headers = {} if params is None: params = {} - if nullable_params is None: - nullable_params = [] - - # Only filter out None values for non-nullable params - params = {k: v for k, v in params.items() if v is not None or k in nullable_params} + # Convert params to keep explicit nulls while removing undefined (None) values + params = {k: v for k, v in params.items() if v is not None or self._is_explicitly_null(v)} + # Replace explicit null markers with None for JSON serialization + params = {k: None if self._is_explicitly_null(v) else v for k, v in params.items()} data = {} files = {} stringify = False - headers = {**self._global_headers, **headers} - - if method != 'get': - data = params - params = {} - - if headers['content-type'].startswith('application/json'): - data = json.dumps(data, cls=ValueClassEncoder) - - if headers['content-type'].startswith('multipart/form-data'): - del headers['content-type'] - stringify = True - for key in data.copy(): - if isinstance(data[key], InputFile): - files[key] = (data[key].filename, data[key].data) - del data[key] - data = self.flatten(data, stringify=stringify) - - response = None - try: - response = requests.request( # call method dynamically https://stackoverflow.com/a/4246075/2299554 - method=method, - url=self._endpoint + path, - params=self.flatten(params, stringify=stringify), - data=data, - files=files, - headers=headers, - verify=(not self._self_signed), - allow_redirects=False if response_type == 'location' else True - ) - - response.raise_for_status() - - warnings = response.headers.get('x-{{ spec.title | lower }}-warning') - if warnings: - for warning in warnings.split(';'): - print(f'Warning: {warning}') - - content_type = response.headers['Content-Type'] - - if response_type == 'location': - return response.headers.get('Location') - - if content_type.startswith('application/json'): - return response.json() - - return response._content - except Exception as e: - if response != None: - content_type = response.headers['Content-Type'] - if content_type.startswith('application/json'): - raise {{spec.title | caseUcfirst}}Exception(response.json()['message'], response.status_code, response.json().get('type'), response.text) - else: - raise {{spec.title | caseUcfirst}}Exception(response.text, response.status_code, None, response.text) - else: - raise {{spec.title | caseUcfirst}}Exception(e) - def chunked_upload( self, path, @@ -206,6 +161,7 @@ class Client: return result def flatten(self, data, prefix='', stringify=False): + """Flatten a nested dictionary/list into a flat dictionary with dot notation.""" output = {} i = 0 @@ -215,7 +171,12 @@ class Client: finalKey = prefix + '[' + str(i) +']' if isinstance(data, list) else finalKey i += 1 - if isinstance(value, list) or isinstance(value, dict): + if value is None: # Handle null values + if stringify: + output[finalKey] = '' + else: + output[finalKey] = None + elif isinstance(value, (list, dict)): output = {**output, **self.flatten(value, finalKey, stringify)} else: if stringify: From 81e48175b8771c71fb6c03f9c127d032bfdd114b Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Thu, 16 Oct 2025 22:23:34 +0530 Subject: [PATCH 4/9] feat: handle null, `None` & `Optional` py values --- templates/python/base/params.twig | 86 ++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/templates/python/base/params.twig b/templates/python/base/params.twig index 0609239f0a..152752244c 100644 --- a/templates/python/base/params.twig +++ b/templates/python/base/params.twig @@ -2,36 +2,96 @@ nullable_params = [] {% if method.parameters.all | length %} -{% for parameter in method.parameters.all %} -{% if parameter.required and not parameter.nullable %} - if {{ parameter.name | escapeKeyword | caseSnake }} is None: - raise {{spec.title | caseUcfirst}}Exception('Missing required parameter: "{{ parameter.name | escapeKeyword | caseSnake }}"') - -{% endif %} +{% for parameter in method.parameters.all %} {% if parameter.nullable %} nullable_params.append('{{ parameter.name }}') - {% endif %} {% endfor %} + {% for parameter in method.parameters.path %} - api_path = api_path.replace('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', {{ parameter.name | escapeKeyword | caseSnake }}) +{% if parameter.required %} + # Required path parameters - convert None to explicit null + if {{ parameter.name | escapeKeyword | caseSnake }} is None: + path_value = 'null' + else: + path_value = str({{ parameter.name | escapeKeyword | caseSnake }}) + api_path = api_path.replace('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', path_value) +{% else %} + # Optional path parameters - only include if not None + if {{ parameter.name | escapeKeyword | caseSnake }} is not None: + api_path = api_path.replace('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', str({{ parameter.name | escapeKeyword | caseSnake }})) +{% endif %} {% endfor %} {% for parameter in method.parameters.query %} - api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% if parameter.required %} + # Required query parameters - convert None to explicit null + if {{ parameter.name | escapeKeyword | caseSnake }} is None: + api_params['{{ parameter.name }}'] = self.client.null() + else: + api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% else %} + # Optional query parameters - only include if not None + if {{ parameter.name | escapeKeyword | caseSnake }} is not None: + api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% endif %} {% endfor %} + {% for parameter in method.parameters.body %} +{% if parameter.required %} + # Required body parameters - convert None to explicit null + if {{ parameter.name | escapeKeyword | caseSnake }} is None: + api_params['{{ parameter.name }}'] = self.client.null() + else: +{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %} + api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if isinstance({{ parameter.name | escapeKeyword | caseSnake }}, bool) else {{ parameter.name | escapeKeyword | caseSnake }} +{% else %} + api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% endif %} +{% else %} + # Optional body parameters - only include if not None + if {{ parameter.name | escapeKeyword | caseSnake }} is not None: {% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %} - api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if type({{ parameter.name | escapeKeyword | caseSnake }}) is bool else {{ parameter.name | escapeKeyword | caseSnake }} + api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if isinstance({{ parameter.name | escapeKeyword | caseSnake }}, bool) else {{ parameter.name | escapeKeyword | caseSnake }} {% else %} - api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} + api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% endif %} {% endif %} {% endfor %} + {% for parameter in method.parameters.formData %} +{% if parameter.required %} + # Required form data parameters - convert None to explicit null + if {{ parameter.name | escapeKeyword | caseSnake }} is None: + api_params['{{ parameter.name }}'] = self.client.null() + else: {% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %} - api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if type({{ parameter.name | escapeKeyword | caseSnake }}) is bool else {{ parameter.name | escapeKeyword | caseSnake }} + api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if isinstance({{ parameter.name | escapeKeyword | caseSnake }}, bool) else {{ parameter.name | escapeKeyword | caseSnake }} +{% else %} + api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% endif %} +{% else %} + # Optional form data parameters - only include if not None + if {{ parameter.name | escapeKeyword | caseSnake }} is not None: +{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %} + api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if isinstance({{ parameter.name | escapeKeyword | caseSnake }}, bool) else {{ parameter.name | escapeKeyword | caseSnake }} +{% else %} + api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% endif %} +{% endif %} +{% endfor %} + +{% for parameter in method.parameters.header %} +{% if parameter.required %} + # Required header parameters - convert None to explicit null + if {{ parameter.name | escapeKeyword | caseSnake }} is None: + self.client.add_header('{{ parameter.name }}', self.client.null()) + else: + self.client.add_header('{{ parameter.name }}', {{ parameter.name | escapeKeyword | caseSnake }}) {% else %} - api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} + # Optional header parameters - only include if not None + if {{ parameter.name | escapeKeyword | caseSnake }} is not None: + self.client.add_header('{{ parameter.name }}', {{ parameter.name | escapeKeyword | caseSnake }}) {% endif %} {% endfor %} {% endif %} \ No newline at end of file From a530b32a9f6f217780a072d5fa69428be2132b26 Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Mon, 20 Oct 2025 23:58:20 +0530 Subject: [PATCH 5/9] revert: restore client HTTP n/w call --- templates/python/package/client.py.twig | 70 ++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 76ab6eff30..1d314387ae 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -69,15 +69,83 @@ class Client: if params is None: params = {} - # Convert params to keep explicit nulls while removing undefined (None) values + # Process headers and params to handle explicit nulls while removing undefined (None) values + headers = {k: v for k, v in headers.items() if v is not None or self._is_explicitly_null(v)} params = {k: v for k, v in params.items() if v is not None or self._is_explicitly_null(v)} + # Replace explicit null markers with None for JSON serialization + headers = {k: None if self._is_explicitly_null(v) else v for k, v in headers.items()} params = {k: None if self._is_explicitly_null(v) else v for k, v in params.items()} + # Merge with global headers + headers = {**self._global_headers, **headers} + data = {} files = {} stringify = False + # Move params to data for non-GET requests + if method != 'get': + data = params + params = {} + + # Handle JSON content + if headers['content-type'].startswith('application/json'): + data = json.dumps(data, cls=ValueClassEncoder) + + # Handle multipart form data + if headers['content-type'].startswith('multipart/form-data'): + del headers['content-type'] + stringify = True + for key in data.copy(): + if isinstance(data[key], InputFile): + files[key] = (data[key].filename, data[key].data) + del data[key] + data = self.flatten(data, stringify=stringify) + + # Make the HTTP request + response = None + try: + response = requests.request( + method=method, + url=self._endpoint + path, + params=self.flatten(params, stringify=stringify), + data=data, + files=files, + headers=headers, + verify=(not self._self_signed), + allow_redirects=False if response_type == 'location' else True + ) + + response.raise_for_status() + + # Handle warnings + warnings = response.headers.get('x-{{ spec.title | lower }}-warning') + if warnings: + for warning in warnings.split(';'): + print(f'Warning: {warning}') + + content_type = response.headers['Content-Type'] + + # Handle different response types + if response_type == 'location': + return response.headers.get('Location') + + if content_type.startswith('application/json'): + return response.json() + + return response._content + + except Exception as e: + if response is not None: + content_type = response.headers['Content-Type'] + if content_type.startswith('application/json'): + raise {{spec.title | caseUcfirst}}Exception(response.json()['message'], response.status_code, response.json().get('type'), response.text) + else: + raise {{spec.title | caseUcfirst}}Exception(response.text, response.status_code, None, response.text) + else: + raise {{spec.title | caseUcfirst}}Exception(e) + def chunked_upload( self, path, From 1dbb3cc7e60601895502b265697bc188ea2f1a3c Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Tue, 21 Oct 2025 00:06:27 +0530 Subject: [PATCH 6/9] fix: handle nullvalue serialization --- .../encoders/value_class_encoder.py.twig | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/templates/python/package/encoders/value_class_encoder.py.twig b/templates/python/package/encoders/value_class_encoder.py.twig index ee0bb49c60..d74ea42c6d 100644 --- a/templates/python/package/encoders/value_class_encoder.py.twig +++ b/templates/python/package/encoders/value_class_encoder.py.twig @@ -1,13 +1,20 @@ import json {%~ for enum in spec.enums %} -from ..enums.{{ enum.name | caseSnake }} import {{ enum.name | caseUcfirst | overrideIdentifier }} +from ..enums.{{ enum.name | caseSnake }} +import +{{ enum.name | caseUcfirst | overrideIdentifier }} {%~ endfor %} class ValueClassEncoder(json.JSONEncoder): def default(self, o): - {%~ for enum in spec.enums %} - if isinstance(o, {{ enum.name | caseUcfirst | overrideIdentifier }}): + # Handle explicit null values + if hasattr(o, 'is_null') and o.is_null: + return None + +{%~ for enum in spec.enums %} +if isinstance(o, +{{ enum.name | caseUcfirst | overrideIdentifier }}): return o.value - {%~ endfor %} - return super().default(o) \ No newline at end of file +{%~ endfor %} +return super().default(o) From 704040dee8883585251d781d080996739050482b Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Tue, 21 Oct 2025 00:06:51 +0530 Subject: [PATCH 7/9] fix: parameter order mismatch --- templates/python/package/client.py.twig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 1d314387ae..51f2239212 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -62,20 +62,23 @@ class Client: return self {% endfor %} - def call(self, method, path='', headers=None, params=None, response_type='json'): + def call(self, method, path='', headers=None, params=None, nullable_params=None, response_type='json'): if headers is None: headers = {} if params is None: params = {} + + if nullable_params is None: + nullable_params = [] # Process headers and params to handle explicit nulls while removing undefined (None) values headers = {k: v for k, v in headers.items() if v is not None or self._is_explicitly_null(v)} - params = {k: v for k, v in params.items() if v is not None or self._is_explicitly_null(v)} + params = {k: v for k, v in params.items() if v is not None or self._is_explicitly_null(v) or k in nullable_params} # Replace explicit null markers with None for JSON serialization headers = {k: None if self._is_explicitly_null(v) else v for k, v in headers.items()} - params = {k: None if self._is_explicitly_null(v) else v for k, v in params.items()} + params = {k: None if self._is_explicitly_null(v) or (v is None and k in nullable_params) else v for k, v in params.items()} # Merge with global headers headers = {**self._global_headers, **headers} @@ -239,7 +242,7 @@ class Client: finalKey = prefix + '[' + str(i) +']' if isinstance(data, list) else finalKey i += 1 - if value is None: # Handle null values + if value is None or self._is_explicitly_null(value): # Handle null values if stringify: output[finalKey] = '' else: From 16d56fbc251366755f8dc37d50d3caf0596783ea Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Tue, 21 Oct 2025 00:12:51 +0530 Subject: [PATCH 8/9] fix: formatting issue --- .../encoders/value_class_encoder.py.twig | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/templates/python/package/encoders/value_class_encoder.py.twig b/templates/python/package/encoders/value_class_encoder.py.twig index d74ea42c6d..b20b9e1841 100644 --- a/templates/python/package/encoders/value_class_encoder.py.twig +++ b/templates/python/package/encoders/value_class_encoder.py.twig @@ -1,20 +1,18 @@ import json {%~ for enum in spec.enums %} -from ..enums.{{ enum.name | caseSnake }} -import -{{ enum.name | caseUcfirst | overrideIdentifier }} +from ..enums.{{ enum.name | caseSnake }} import {{ enum.name | caseUcfirst | overrideIdentifier }} {%~ endfor %} + class ValueClassEncoder(json.JSONEncoder): def default(self, o): # Handle explicit null values if hasattr(o, 'is_null') and o.is_null: return None - -{%~ for enum in spec.enums %} -if isinstance(o, -{{ enum.name | caseUcfirst | overrideIdentifier }}): + + {%~ for enum in spec.enums %} + if isinstance(o, {{ enum.name | caseUcfirst | overrideIdentifier }}): return o.value - -{%~ endfor %} -return super().default(o) + {%~ endfor %} + + return super().default(o) From 6043a412660312f1314957eca6a535e1199ea221 Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Tue, 21 Oct 2025 00:31:09 +0530 Subject: [PATCH 9/9] fix: test failure; handle all enum type --- .../python/package/encoders/value_class_encoder.py.twig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/templates/python/package/encoders/value_class_encoder.py.twig b/templates/python/package/encoders/value_class_encoder.py.twig index b20b9e1841..cd8bcf2980 100644 --- a/templates/python/package/encoders/value_class_encoder.py.twig +++ b/templates/python/package/encoders/value_class_encoder.py.twig @@ -4,12 +4,18 @@ from ..enums.{{ enum.name | caseSnake }} import {{ enum.name | caseUcfirst | ove {%~ endfor %} +from enum import Enum + class ValueClassEncoder(json.JSONEncoder): def default(self, o): # Handle explicit null values if hasattr(o, 'is_null') and o.is_null: return None + # Handle any enum type + if isinstance(o, Enum): + return o.value + {%~ for enum in spec.enums %} if isinstance(o, {{ enum.name | caseUcfirst | overrideIdentifier }}): return o.value