From d53c5b406037159d3ee7c790fee41978f37857be Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Mon, 11 May 2026 14:54:30 +0000 Subject: [PATCH 1/8] feat(python): implement native asyncio support with dual-mode architecture --- templates/python/base/requests/api_async.twig | 38 +++ .../python/base/requests/file_async.twig | 59 ++++ templates/python/package/client.py.twig | 318 +++++++++++++----- .../python/package/services/service.py.twig | 32 +- templates/python/pyproject.toml.twig | 4 +- templates/python/requirements.txt.twig | 4 +- templates/python/setup.py.twig | 2 +- .../python/test/services/test_service.py.twig | 38 ++- 8 files changed, 395 insertions(+), 100 deletions(-) create mode 100644 templates/python/base/requests/api_async.twig create mode 100644 templates/python/base/requests/file_async.twig diff --git a/templates/python/base/requests/api_async.twig b/templates/python/base/requests/api_async.twig new file mode 100644 index 0000000000..22d1136966 --- /dev/null +++ b/templates/python/base/requests/api_async.twig @@ -0,0 +1,38 @@ + response = await self.client.call_async('{{ method.method | caseLower }}', api_path, { +{% for parameter in method.parameters.header %} + '{{ parameter.name }}': self._normalize_value({{ parameter.name | escapeKeyword | caseSnake }}), +{% endfor %} +{% for key, header in method.headers %} + '{{ key }}': '{{ header }}', +{% endfor %} + }, api_params{% if method.type == 'webAuth' %}, response_type='location'{% endif %}) +{% if method.responseModels is defined and method.responseModels|length > 1 %} +{% set hasResponseDiscriminator = method.responseDiscriminator is defined and method.responseDiscriminator is not empty %} +{% set responseDiscriminator = method.responseDiscriminator ?? {} %} +{% if hasResponseDiscriminator %} + if not isinstance(response, dict): + raise AppwriteException('Expected object response when hydrating a response model') +{% for modelName, conditions in responseDiscriminator %} + + if {% for field, expectedValue in conditions %}response.get('{{ field }}') == '{{ expectedValue }}'{% if not loop.last %} and {% endif %}{% endfor %}: + return self._parse_response(response, model={% if (modelName | caseUcfirst) == (service.name | caseUcfirst) %}{{ modelName | caseUcfirst }}Model{% else %}{{ modelName | caseUcfirst }}{% endif %}) +{% endfor %} + + raise AppwriteException('Unable to match response to any known model') +{% else %} + + return self._parse_response(response, model={% if (method.responseModel | caseUcfirst) == (service.name | caseUcfirst) %}{{ method.responseModel | caseUcfirst }}Model{% else %}{{ method.responseModel | caseUcfirst }}{% endif %}) +{% endif %} +{% elseif method.responseModel and method.responseModel != 'any' %} +{% set isGenericResponse = method.responseModel | hasGenericType(spec) %} +{% if isGenericResponse %} + + return {{ method.responseModel | caseUcfirst }}.with_data(response, model_type) +{% else %} + + return self._parse_response(response, model={% if (method.responseModel | caseUcfirst) == (service.name | caseUcfirst) %}{{ method.responseModel | caseUcfirst }}Model{% else %}{{ method.responseModel | caseUcfirst }}{% endif %}) +{% endif %} +{% else %} + + return response +{% endif %} diff --git a/templates/python/base/requests/file_async.twig b/templates/python/base/requests/file_async.twig new file mode 100644 index 0000000000..66220a26c6 --- /dev/null +++ b/templates/python/base/requests/file_async.twig @@ -0,0 +1,59 @@ +{% for parameter in method.parameters.all %} +{% if parameter.type == 'file' %} + param_name = '{{ parameter.name }}' + +{% endif %} +{% endfor %} + + upload_id = '' +{% for parameter in method.parameters.all %} +{% if parameter.isUploadID %} + upload_id = {{ parameter.name | escapeKeyword | caseSnake }} +{% endif %} +{% endfor %} + + response = await self.client.chunked_upload_async( + api_path, + { +{% for parameter in method.parameters.header %} + '{{ parameter.name }}': self._normalize_value({{ parameter.name | escapeKeyword | caseSnake }}), +{% endfor %} +{% for key, header in method.headers %} + '{{ key }}': '{{ header }}', +{% endfor %} + }, + api_params, + param_name, + on_progress, + upload_id + ) +{% if method.responseModels is defined and method.responseModels|length > 1 %} +{% set hasResponseDiscriminator = method.responseDiscriminator is defined and method.responseDiscriminator is not empty %} +{% set responseDiscriminator = method.responseDiscriminator ?? {} %} +{% if hasResponseDiscriminator %} + if not isinstance(response, dict): + raise AppwriteException('Expected object response when hydrating a response model') +{% for modelName, conditions in responseDiscriminator %} + + if {% for field, expectedValue in conditions %}response.get('{{ field }}') == '{{ expectedValue }}'{% if not loop.last %} and {% endif %}{% endfor %}: + return self._parse_response(response, model={% if (modelName | caseUcfirst) == (service.name | caseUcfirst) %}{{ modelName | caseUcfirst }}Model{% else %}{{ modelName | caseUcfirst }}{% endif %}) +{% endfor %} + + raise AppwriteException('Unable to match response to any known model') +{% else %} + + return self._parse_response(response, model={% if (method.responseModel | caseUcfirst) == (service.name | caseUcfirst) %}{{ method.responseModel | caseUcfirst }}Model{% else %}{{ method.responseModel | caseUcfirst }}{% endif %}) +{% endif %} +{% elseif method.responseModel and method.responseModel != 'any' %} +{% set isGenericResponse = method.responseModel | hasGenericType(spec) %} +{% if isGenericResponse %} + + return {{ method.responseModel | caseUcfirst }}.with_data(response, model_type) +{% else %} + + return self._parse_response(response, model={% if (method.responseModel | caseUcfirst) == (service.name | caseUcfirst) %}{{ method.responseModel | caseUcfirst }}Model{% else %}{{ method.responseModel | caseUcfirst }}{% endif %}) +{% endif %} +{% else %} + + return response +{% endif %} diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 75f5b6e6b0..d24c918855 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -1,9 +1,11 @@ +import asyncio +import inspect import io import json import os import platform import sys -import requests +import httpx from .input_file import InputFile from .exception import {{spec.title | caseUcfirst}}Exception from .encoders.value_class_encoder import ValueClassEncoder @@ -53,7 +55,7 @@ class Client: return self {% endfor %} - def call(self, method, path='', headers=None, params=None, response_type='json'): + def _prepare_request(self, method, path='', headers=None, params=None): if headers is None: headers = {} @@ -73,53 +75,87 @@ class Client: if headers['content-type'].startswith('application/json'): data = json.dumps(data, cls=ValueClassEncoder) - if headers['content-type'].startswith('multipart/form-data'): + if headers.get('content-type', '').startswith('multipart/form-data'): del headers['content-type'] stringify = True - for key in data.copy(): + for key in list(data.keys()): if isinstance(data[key], InputFile): - files[key] = (data[key].filename, data[key].data) + file_content = data[key].data + if isinstance(file_content, bytearray): + file_content = bytes(file_content) + files[key] = (data[key].filename, file_content) del data[key] data = self.flatten(data, stringify=stringify) + return self._endpoint + path, headers, self.flatten(params, stringify=stringify), data, files + + def _handle_response(self, response, response_type): + warnings = response.headers.get('x-{{ spec.title | lower }}-warning') + if warnings: + for warning in warnings.split(';'): + print(f'Warning: {warning}', file=sys.stderr) + + content_type = response.headers.get('Content-Type', '') + + if response_type == 'location': + return response.headers.get('Location') + + if content_type.startswith('application/json'): + return response.json() + + return response.content + + def _handle_exception(self, e, response=None): + if response is not None: + content_type = response.headers.get('Content-Type', '') + if content_type.startswith('application/json'): + raise {{spec.title | caseUcfirst}}Exception(response.json().get('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(str(e)) + + def call(self, method, path='', headers=None, params=None, response_type='json'): + url, headers, params, data, files = self._prepare_request(method, path, headers, params) + 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 - ) + with httpx.Client(verify=(not self._self_signed), follow_redirects=False if response_type == 'location' else True) as http_client: + response = http_client.request( + method=method, + url=url, + params=params, + data=data, + files=files, + headers=headers, + ) response.raise_for_status() - warnings = response.headers.get('x-{{ spec.title | lower }}-warning') - if warnings: - for warning in warnings.split(';'): - print(f'Warning: {warning}', file=sys.stderr) + return self._handle_response(response, response_type) + except Exception as e: + self._handle_exception(e, response) - content_type = response.headers['Content-Type'] + async def call_async(self, method, path='', headers=None, params=None, response_type='json'): + url, headers, params, data, files = self._prepare_request(method, path, headers, params) - if response_type == 'location': - return response.headers.get('Location') + response = None + try: + async with httpx.AsyncClient(verify=(not self._self_signed), follow_redirects=False if response_type == 'location' else True) as http_client: + response = await http_client.request( + method=method, + url=url, + params=params, + data=data, + files=files, + headers=headers, + ) - if content_type.startswith('application/json'): - return response.json() + response.raise_for_status() - return response._content + return self._handle_response(response, response_type) 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) + self._handle_exception(e, response) def chunked_upload( self, @@ -130,6 +166,7 @@ class Client: on_progress = None, upload_id = '' ): + headers = headers.copy() if headers else {} input_file = params[param_name] if input_file.source_type == 'path': @@ -139,69 +176,170 @@ class Client: size = len(input_file.data) input = input_file.data - if size < self._chunk_size: + try: + if size < self._chunk_size: + if input_file.source_type == 'path': + input_file.data = input.read() + + params[param_name] = input_file + return self.call( + 'post', + path, + headers, + params + ) + + offset = 0 + counter = 0 + + try: + result = self.call('get', path + '/' + upload_id, headers) + counter = result['chunksUploaded'] + except Exception: + pass + + if counter > 0: + offset = counter * self._chunk_size + if hasattr(input, 'seek'): + input.seek(offset) + + while offset < size: + if input_file.source_type == 'path': + input_file.data = input.read(self._chunk_size) or input.read(size - offset) + elif input_file.source_type == 'bytes': + if offset + self._chunk_size < size: + end = offset + self._chunk_size + else: + end = size + input_file.data = input[offset:end] + + params[param_name] = input_file + headers["content-range"] = f'bytes {offset}-{min((offset + self._chunk_size) - 1, size - 1)}/{size}' + + result = self.call( + 'post', + path, + headers, + params, + ) + + offset = offset + self._chunk_size + + if "$id" in result: + headers["x-{{ spec.title | caseLower }}-id"] = result["$id"] + + if on_progress is not None: + end = min((((counter * self._chunk_size) + self._chunk_size) - 1), size - 1) + on_progress({ + "$id": result.get("$id"), + "progress": min(offset, size)/size * 100, + "sizeUploaded": end+1, + "chunksTotal": result.get("chunksTotal", 0), + "chunksUploaded": result.get("chunksUploaded", 0), + }) + + counter = counter + 1 + + return result + finally: if input_file.source_type == 'path': - input_file.data = input.read() + input.close() - params[param_name] = input_file - return self.call( - 'post', - path, - headers, - params - ) + async def chunked_upload_async( + self, + path, + headers = None, + params = None, + param_name = '', + on_progress = None, + upload_id = '' + ): + headers = headers.copy() if headers else {} + input_file = params[param_name] - offset = 0 - counter = 0 + if input_file.source_type == 'path': + size = os.stat(input_file.path).st_size + input = await asyncio.to_thread(open, input_file.path, 'rb') + elif input_file.source_type == 'bytes': + size = len(input_file.data) + input = input_file.data try: - result = self.call('get', path + '/' + upload_id, headers) - counter = result['chunksUploaded'] - except: - pass - - if counter > 0: - offset = counter * self._chunk_size - input.seek(offset) - - while offset < size: + if size < self._chunk_size: + if input_file.source_type == 'path': + input_file.data = await asyncio.to_thread(input.read) + + params[param_name] = input_file + return await self.call_async( + 'post', + path, + headers, + params + ) + + offset = 0 + counter = 0 + + try: + result = await self.call_async('get', path + '/' + upload_id, headers) + counter = result['chunksUploaded'] + except Exception: + pass + + if counter > 0: + offset = counter * self._chunk_size + if hasattr(input, 'seek'): + if input_file.source_type == 'path': + await asyncio.to_thread(input.seek, offset) + else: + input.seek(offset) + + while offset < size: + if input_file.source_type == 'path': + current_offset = offset + input_file.data = await asyncio.to_thread( + lambda cs=self._chunk_size, co=current_offset: input.read(cs) or input.read(size - co) + ) + elif input_file.source_type == 'bytes': + if offset + self._chunk_size < size: + end = offset + self._chunk_size + else: + end = size + input_file.data = input[offset:end] + + params[param_name] = input_file + headers["content-range"] = f'bytes {offset}-{min((offset + self._chunk_size) - 1, size - 1)}/{size}' + + result = await self.call_async( + 'post', + path, + headers, + params, + ) + + offset = offset + self._chunk_size + + if "$id" in result: + headers["x-{{ spec.title | caseLower }}-id"] = result["$id"] + + if on_progress is not None: + end = min((((counter * self._chunk_size) + self._chunk_size) - 1), size - 1) + result_cb = on_progress({ + "$id": result.get("$id"), + "progress": min(offset, size)/size * 100, + "sizeUploaded": end+1, + "chunksTotal": result.get("chunksTotal", 0), + "chunksUploaded": result.get("chunksUploaded", 0), + }) + if inspect.isawaitable(result_cb): + await result_cb + + counter = counter + 1 + + return result + finally: if input_file.source_type == 'path': - input_file.data = input.read(self._chunk_size) or input.read(size - offset) - elif input_file.source_type == 'bytes': - if offset + self._chunk_size < size: - end = offset + self._chunk_size - else: - end = size - input_file.data = input[offset:end] - - params[param_name] = input_file - headers["content-range"] = f'bytes {offset}-{min((offset + self._chunk_size) - 1, size - 1)}/{size}' - - result = self.call( - 'post', - path, - headers, - params, - ) - - offset = offset + self._chunk_size - - if "$id" in result: - headers["x-{{ spec.title | caseLower }}-id"] = result["$id"] - - if on_progress is not None: - end = min((((counter * self._chunk_size) + self._chunk_size) - 1), size - 1) - on_progress({ - "$id": result["$id"], - "progress": min(offset, size)/size * 100, - "sizeUploaded": end+1, - "chunksTotal": result["chunksTotal"], - "chunksUploaded": result["chunksUploaded"], - }) - - counter = counter + 1 - - return result + input.close() def flatten(self, data, prefix='', stringify=False): output = {} diff --git a/templates/python/package/services/service.py.twig b/templates/python/package/services/service.py.twig index b185a4951b..c387668930 100644 --- a/templates/python/package/services/service.py.twig +++ b/templates/python/package/services/service.py.twig @@ -12,10 +12,15 @@ from ..input_file import InputFile {% endif %} {% if parameter.enumValues is not empty%} {% if parameter.enumName not in added %} +{% if parameter.enumName | caseUcfirst == 'Type' %} +from ..enums.{{ parameter.enumName | caseSnake }} import {{ parameter.enumName | caseUcfirst }} as {{ parameter.enumName | caseUcfirst }}Enum; +{% set added = added|merge([parameter.enumName]) %} +{% else %} from ..enums.{{ parameter.enumName | caseSnake }} import {{ parameter.enumName | caseUcfirst }}; {% set added = added|merge([parameter.enumName]) %} {% endif %} {% endif %} +{% endif %} {% set modelName = parameter.model ?? parameter.array.model ?? null %} {% if modelName %} {% if modelName not in added %} @@ -52,7 +57,6 @@ from ..models.{{ responseModel | caseSnake }} import {{ responseModel | caseUcfi {% endif %} {% endfor %} {% if hasGenericModel %} - T = TypeVar('T') {% endif %} @@ -168,5 +172,31 @@ Dict[str, Any] {% else %} {{ include('python/base/requests/api.twig') }} {% endif %} + +{% if method.deprecated %} +{% if method.since and method.replaceWith %} + @deprecated("This API has been deprecated since {{ method.since }}. Please use `{{ method.replaceWith | caseSnakeExceptFirstDot }}` instead.") +{% else %} + @deprecated("This API has been deprecated.") +{% endif %} +{% endif %} + async def {{ method.name | caseSnake }}_async( + self{% for parameter in method.parameters.all %}, + {{ parameter.name | escapeKeyword | caseSnake }}: {{ parameter | getServicePropertyType(service.name) | raw }}{% if not parameter.required %} = None{% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, + on_progress = None{% endif %}{% if isGenericResponse %}, + model_type: Type[T] = dict{% endif %} + + ) -> {{ returnType | trim }}: + """ + Async version of :meth:`{{ method.name | caseSnake }}`. + """ + + api_path = '{{ method.path }}' +{{ include('python/base/params.twig') }} +{% if 'multipart/form-data' in method.consumes %} +{{ include('python/base/requests/file_async.twig') }} +{% else %} +{{ include('python/base/requests/api_async.twig') }} +{% endif %} {% endif %} {% endfor %} diff --git a/templates/python/pyproject.toml.twig b/templates/python/pyproject.toml.twig index 03b410a300..8de1e7a49b 100644 --- a/templates/python/pyproject.toml.twig +++ b/templates/python/pyproject.toml.twig @@ -16,7 +16,7 @@ maintainers = [ { name = "{{ spec.contactName }}", email = "{{ spec.contactEmail }}" } ] dependencies = [ - "requests", + "httpx>=0.27", "pydantic>=2,<3", ] classifiers = [ @@ -34,7 +34,7 @@ classifiers = [ [project.optional-dependencies] test = [ - "requests_mock==1.11.0", + "respx", ] [project.urls] diff --git a/templates/python/requirements.txt.twig b/templates/python/requirements.txt.twig index 43f1420262..018d0ca217 100644 --- a/templates/python/requirements.txt.twig +++ b/templates/python/requirements.txt.twig @@ -1,3 +1,3 @@ -requests>=2.31,<3 -requests_mock==1.11.0 +httpx>=0.27 +respx>=0.21.0 pydantic>=2,<3 diff --git a/templates/python/setup.py.twig b/templates/python/setup.py.twig index 2363c1f72e..7222e67ec8 100644 --- a/templates/python/setup.py.twig +++ b/templates/python/setup.py.twig @@ -20,7 +20,7 @@ setuptools.setup( url = '{{spec.contactURL}}', download_url='https://github.com/{{sdk.gitUserName}}/{{sdk.gitRepoName}}/archive/{{sdk.version}}.tar.gz', install_requires=[ - 'requests', + 'httpx>=0.27', 'pydantic>=2,<3', ], python_requires='>=3.9', diff --git a/templates/python/test/services/test_service.py.twig b/templates/python/test/services/test_service.py.twig index fc8f1d4b09..bc074fbfb8 100644 --- a/templates/python/test/services/test_service.py.twig +++ b/templates/python/test/services/test_service.py.twig @@ -1,5 +1,7 @@ +import asyncio import json -import requests_mock +import respx +import httpx import unittest from appwrite.client import Client @@ -14,8 +16,8 @@ class {{ service.name | caseUcfirst }}ServiceTest(unittest.TestCase): self.{{ service.name | caseSnake }} = {{ service.name | caseUcfirst }}(self.client) {% for method in service.methods %} - @requests_mock.Mocker() - def test_{{ method.name | caseSnake }}(self, m): + @respx.mock + def test_{{ method.name | caseSnake }}(self): {%~ if method.type == 'webAuth' %} data = None {%~ elseif method.type == 'location' %} @@ -28,12 +30,40 @@ class {{ service.name | caseUcfirst }}ServiceTest(unittest.TestCase): {%~ endif %} {%~ endif %} headers = {'Content-Type': {% if method.type == 'location' %}'application/octet-stream'{% else %}'application/json'{% endif %}} - m.request(requests_mock.ANY, requests_mock.ANY, {% if method.type == 'location' %}body=data{% else %}text=json.dumps(data){% endif %}, headers=headers) + respx.route().mock(return_value=httpx.Response(200, {% if method.type == 'location' %}content=bytes(data){% else %}text=json.dumps(data){% endif %}, headers=headers)) response = self.{{ service.name | caseSnake }}.{{ method.name | caseSnake }}({%~ for parameter in method.parameters.all | filter((param) => param.required) ~%} {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}InputFile.from_bytes(bytearray(), "example.file"){% elseif parameter.type == 'boolean' %}True{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%},{%~ endfor ~%} ) + {%~ if method.type != 'webAuth' and method.responseModel and method.responseModel != 'any' %} + {%~ if method.responseModel == 'row' or method.responseModel == 'document' or method.responseModel == 'preferences' %} + data['data'] = {} + {%~ endif %} + self.assertEqual(response.to_dict(), data) + {%~ else %} + self.assertEqual(response, data) + {%~ endif %} + @respx.mock + def test_{{ method.name | caseSnake }}_async(self): + {%~ if method.type == 'webAuth' %} + data = None + {%~ elseif method.type == 'location' %} + data = bytearray() + {%~ else %} + {%~ if method.responseModel and method.responseModel != 'any' %} + data = {{ method.responseModel | responseModelExample(spec) | raw }} + {%~ else %} + data = '' + {%~ endif %} + {%~ endif %} + headers = {'Content-Type': {% if method.type == 'location' %}'application/octet-stream'{% else %}'application/json'{% endif %}} + respx.route().mock(return_value=httpx.Response(200, {% if method.type == 'location' %}content=bytes(data){% else %}text=json.dumps(data){% endif %}, headers=headers)) + + response = asyncio.run(self.{{ service.name | caseSnake }}.{{ method.name | caseSnake }}_async({%~ for parameter in method.parameters.all | filter((param) => param.required) ~%} + {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}InputFile.from_bytes(bytearray(), "example.file"){% elseif parameter.type == 'boolean' %}True{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%},{%~ endfor ~%} + )) + {%~ if method.type != 'webAuth' and method.responseModel and method.responseModel != 'any' %} {%~ if method.responseModel == 'row' or method.responseModel == 'document' or method.responseModel == 'preferences' %} data['data'] = {} From 73cd7e64de214f138ba3ce1bd25be1f3ccd06e9c Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Mon, 11 May 2026 15:11:05 +0000 Subject: [PATCH 2/8] fix(python): resolve TypeEnum collision and async hygiene issues --- src/SDK/Language/Python.php | 3 +++ templates/python/package/client.py.twig | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/SDK/Language/Python.php b/src/SDK/Language/Python.php index 195bc27cf1..d835f23e82 100644 --- a/src/SDK/Language/Python.php +++ b/src/SDK/Language/Python.php @@ -329,12 +329,15 @@ public function getTypeName(array $parameter, array $spec = []): string $enumType = isset($parameter['enumName']) ? \ucfirst($parameter['enumName']) : \ucfirst($parameter['name']); + if ($enumType === 'Type') { $enumType = 'TypeEnum'; } $typeName = 'List[' . $enumType . ']'; } elseif (isset($parameter['enumName'])) { $typeName = \ucfirst($parameter['enumName']); + if ($typeName === 'Type') { $typeName = 'TypeEnum'; } } elseif (!empty($parameter['enumValues'])) { $typeName = \ucfirst($parameter['name']); + if ($typeName === 'Type') { $typeName = 'TypeEnum'; } } elseif (!empty($parameter['array']['model'])) { $typeName = 'List[' . $this->toPascalCase($parameter['array']['model']) . ']'; } elseif (!empty($parameter['model'])) { diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index d24c918855..8cc9277176 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -109,7 +109,8 @@ class Client: if response is not None: content_type = response.headers.get('Content-Type', '') if content_type.startswith('application/json'): - raise {{spec.title | caseUcfirst}}Exception(response.json().get('message', ''), response.status_code, response.json().get('type'), response.text) + body = response.json() + raise {{spec.title | caseUcfirst}}Exception(body.get('message', ''), response.status_code, body.get('type'), response.text) else: raise {{spec.title | caseUcfirst}}Exception(response.text, response.status_code, None, response.text) else: @@ -258,7 +259,8 @@ class Client: input_file = params[param_name] if input_file.source_type == 'path': - size = os.stat(input_file.path).st_size + stat_result = await asyncio.to_thread(os.stat, input_file.path) + size = stat_result.st_size input = await asyncio.to_thread(open, input_file.path, 'rb') elif input_file.source_type == 'bytes': size = len(input_file.data) @@ -339,7 +341,7 @@ class Client: return result finally: if input_file.source_type == 'path': - input.close() + await asyncio.to_thread(input.close) def flatten(self, data, prefix='', stringify=False): output = {} From 76e6ee48f730ad519b74252d0270588e5a31afbb Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Mon, 11 May 2026 15:42:06 +0000 Subject: [PATCH 3/8] fix(python): enforce AppwriteException contract on malformed JSON errors --- templates/python/package/client.py.twig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 8cc9277176..d7f2af8a5a 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -109,8 +109,11 @@ class Client: if response is not None: content_type = response.headers.get('Content-Type', '') if content_type.startswith('application/json'): - body = response.json() - raise {{spec.title | caseUcfirst}}Exception(body.get('message', ''), response.status_code, body.get('type'), response.text) + try: + body = response.json() + raise {{spec.title | caseUcfirst}}Exception(body.get('message', ''), response.status_code, body.get('type'), response.text) + except Exception: + raise {{spec.title | caseUcfirst}}Exception(response.text, response.status_code, None, response.text) else: raise {{spec.title | caseUcfirst}}Exception(response.text, response.status_code, None, response.text) else: From c5f6f67614bc6db15602c14d58157a0db49d372c Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Mon, 11 May 2026 15:48:18 +0000 Subject: [PATCH 4/8] fix(python): move raise outside try to prevent AppwriteException from being caught --- 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 d7f2af8a5a..4332f322db 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -111,9 +111,9 @@ class Client: if content_type.startswith('application/json'): try: body = response.json() - raise {{spec.title | caseUcfirst}}Exception(body.get('message', ''), response.status_code, body.get('type'), response.text) except Exception: raise {{spec.title | caseUcfirst}}Exception(response.text, response.status_code, None, response.text) + raise {{spec.title | caseUcfirst}}Exception(body.get('message', ''), response.status_code, body.get('type'), response.text) else: raise {{spec.title | caseUcfirst}}Exception(response.text, response.status_code, None, response.text) else: From 2023c06c2727544937af6073222ce23bd7991cd4 Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Mon, 11 May 2026 16:06:03 +0000 Subject: [PATCH 5/8] fix(python): skip response assertion for location-type test methods --- templates/python/test/services/test_service.py.twig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/python/test/services/test_service.py.twig b/templates/python/test/services/test_service.py.twig index bc074fbfb8..0a3ad7fa36 100644 --- a/templates/python/test/services/test_service.py.twig +++ b/templates/python/test/services/test_service.py.twig @@ -36,12 +36,12 @@ class {{ service.name | caseUcfirst }}ServiceTest(unittest.TestCase): {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}InputFile.from_bytes(bytearray(), "example.file"){% elseif parameter.type == 'boolean' %}True{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%},{%~ endfor ~%} ) - {%~ if method.type != 'webAuth' and method.responseModel and method.responseModel != 'any' %} + {%~ if method.type != 'webAuth' and method.type != 'location' and method.responseModel and method.responseModel != 'any' %} {%~ if method.responseModel == 'row' or method.responseModel == 'document' or method.responseModel == 'preferences' %} data['data'] = {} {%~ endif %} self.assertEqual(response.to_dict(), data) - {%~ else %} + {%~ elseif method.type != 'webAuth' and method.type != 'location' %} self.assertEqual(response, data) {%~ endif %} @respx.mock @@ -64,12 +64,12 @@ class {{ service.name | caseUcfirst }}ServiceTest(unittest.TestCase): {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}InputFile.from_bytes(bytearray(), "example.file"){% elseif parameter.type == 'boolean' %}True{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%},{%~ endfor ~%} )) - {%~ if method.type != 'webAuth' and method.responseModel and method.responseModel != 'any' %} + {%~ if method.type != 'webAuth' and method.type != 'location' and method.responseModel and method.responseModel != 'any' %} {%~ if method.responseModel == 'row' or method.responseModel == 'document' or method.responseModel == 'preferences' %} data['data'] = {} {%~ endif %} self.assertEqual(response.to_dict(), data) - {%~ else %} + {%~ elseif method.type != 'webAuth' and method.type != 'location' %} self.assertEqual(response, data) {%~ endif %} From 11a59861feb0700688084313089f6f81ce2dd18c Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Tue, 12 May 2026 10:04:03 +0000 Subject: [PATCH 6/8] fix(python): skip raise_for_status for location-type responses --- templates/python/package/client.py.twig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 4332f322db..4af3bf42d7 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -134,7 +134,8 @@ class Client: headers=headers, ) - response.raise_for_status() + if response_type != 'location': + response.raise_for_status() return self._handle_response(response, response_type) except Exception as e: @@ -155,7 +156,8 @@ class Client: headers=headers, ) - response.raise_for_status() + if response_type != 'location': + response.raise_for_status() return self._handle_response(response, response_type) except Exception as e: From 50bb26d7274cc2151aa751cb600df95ee53169da Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Tue, 12 May 2026 10:11:43 +0000 Subject: [PATCH 7/8] fix(python): always call raise_for_status regardless of response_type --- templates/python/package/client.py.twig | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 4af3bf42d7..4332f322db 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -134,8 +134,7 @@ class Client: headers=headers, ) - if response_type != 'location': - response.raise_for_status() + response.raise_for_status() return self._handle_response(response, response_type) except Exception as e: @@ -156,8 +155,7 @@ class Client: headers=headers, ) - if response_type != 'location': - response.raise_for_status() + response.raise_for_status() return self._handle_response(response, response_type) except Exception as e: From 05880333598008a8bc4ade63f3db4bb9c2bb6519 Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Tue, 12 May 2026 10:25:33 +0000 Subject: [PATCH 8/8] style(python): fix PSR-12 brace style in Python.php TypeEnum guards --- src/SDK/Language/Python.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/SDK/Language/Python.php b/src/SDK/Language/Python.php index d835f23e82..34204b626d 100644 --- a/src/SDK/Language/Python.php +++ b/src/SDK/Language/Python.php @@ -329,15 +329,21 @@ public function getTypeName(array $parameter, array $spec = []): string $enumType = isset($parameter['enumName']) ? \ucfirst($parameter['enumName']) : \ucfirst($parameter['name']); - if ($enumType === 'Type') { $enumType = 'TypeEnum'; } + if ($enumType === 'Type') { + $enumType = 'TypeEnum'; + } $typeName = 'List[' . $enumType . ']'; } elseif (isset($parameter['enumName'])) { $typeName = \ucfirst($parameter['enumName']); - if ($typeName === 'Type') { $typeName = 'TypeEnum'; } + if ($typeName === 'Type') { + $typeName = 'TypeEnum'; + } } elseif (!empty($parameter['enumValues'])) { $typeName = \ucfirst($parameter['name']); - if ($typeName === 'Type') { $typeName = 'TypeEnum'; } + if ($typeName === 'Type') { + $typeName = 'TypeEnum'; + } } elseif (!empty($parameter['array']['model'])) { $typeName = 'List[' . $this->toPascalCase($parameter['array']['model']) . ']'; } elseif (!empty($parameter['model'])) {