diff --git a/.travis.yml b/.travis.yml index 60f3afc03..66370b9fb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -41,8 +41,11 @@ env: - SDK=PHP74 - SDK=PHP80 - SDK=Python38 + - SDK=Python38Async - SDK=Python39 + - SDK=Python39Async - SDK=Python310 + - SDK=Python310Async - SDK=Ruby27 - SDK=Ruby30 - SDK=Ruby31 diff --git a/src/SDK/Language/Python.php b/src/SDK/Language/Python.php index c8d19e639..ed52b2e56 100644 --- a/src/SDK/Language/Python.php +++ b/src/SDK/Language/Python.php @@ -184,6 +184,34 @@ public function getFiles(): array 'destination' => '.travis.yml', 'template' => 'python/.travis.yml.twig', ], + + /* Async */ + [ + 'scope' => 'default', + 'destination' => '{{ spec.title | caseSnake}}/aio/__init__.py', + 'template' => 'python/package/aio/__init__.py.twig', + 'minify' => false, + ], + [ + 'scope' => 'default', + 'destination' => '{{ spec.title | caseSnake}}/aio/client.py', + 'template' => 'python/package/aio/client.py.twig', + 'minify' => false, + ], + [ + 'scope' => 'default', + 'destination' => '{{ spec.title | caseSnake}}/aio/services/__init__.py', + 'template' => 'python/package/aio/services/__init__.py.twig', + 'minify' => false, + ], + [ + 'scope' => 'service', + 'destination' => '{{ spec.title | caseSnake}}/aio/services/{{service.name | caseSnake}}.py', + 'template' => 'python/package/aio/services/service.py.twig', + 'minify' => false, + ], + + ]; } diff --git a/templates/python/package/aio/__init__.py.twig b/templates/python/package/aio/__init__.py.twig new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/templates/python/package/aio/__init__.py.twig @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/python/package/aio/client.py.twig b/templates/python/package/aio/client.py.twig new file mode 100644 index 000000000..867f905f3 --- /dev/null +++ b/templates/python/package/aio/client.py.twig @@ -0,0 +1,141 @@ +import io +import httpx +import os +from ..input_file import InputFile +from ..exception import {{spec.title | caseUcfirst}}Exception +from ..client import Client + +class AsyncClient(Client): + + async def call(self, method, path='', headers=None, params=None, timeout=None): + if headers is None: + headers = {} + + if params is None: + params = {} + + data = {} + json = {} + files = {} + stringify = False + + headers = {**self._global_headers, **headers} + + if method != 'get': + data = params + params = {} + + if headers['content-type'].startswith('application/json'): + json = data + 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].name, data[key].file) + del data[key] + response = None + try: + async with httpx.AsyncClient(verify=(not self._self_signed), follow_redirects=True) as client: + response = await client.request( + method=method, + url=self._endpoint + path, + params=self.flatten(params, stringify=stringify), + data=self.flatten(data), + json=json, + files=files, + headers=headers, + timeout=timeout + ) + + response.raise_for_status() + + content_type = response.headers['Content-Type'] + + 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.json()) + else: + raise {{spec.title | caseUcfirst}}Exception(response.text, response.status_code) + else: + raise {{spec.title | caseUcfirst}}Exception(e) + + async def chunked_upload( + self, + path, + headers = None, + params = None, + param_name = '', + on_progress = None, + upload_id = '', + ): + file_path = str(params[param_name]) + file_name = os.path.basename(file_path) + size = os.stat(file_path).st_size + + if size < self._chunk_size: + slice = open(file_path, 'rb').read() + params[param_name] = InputFile(file_path, file_name, slice) + return await self.call( + 'post', + path, + headers, + params + ) + + input = open(file_path, 'rb') + offset = 0 + counter = 0 + + if upload_id != 'unique()': + try: + result = await 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: + slice = input.read(self._chunk_size) or input.read(size - offset) + + params[param_name] = InputFile(file_path, file_name, slice) + headers["content-range"] = f'bytes {offset}-{min((offset + self._chunk_size) - 1, size)}/{size}' + + result = await 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) + 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 + + diff --git a/templates/python/package/aio/services/__init__.py.twig b/templates/python/package/aio/services/__init__.py.twig new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/templates/python/package/aio/services/__init__.py.twig @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/python/package/aio/services/service.py.twig b/templates/python/package/aio/services/service.py.twig new file mode 100644 index 000000000..b0262b1ff --- /dev/null +++ b/templates/python/package/aio/services/service.py.twig @@ -0,0 +1,83 @@ +from ...service import Service +from ...exception import AppwriteException + +class {{ service.name | caseUcfirst }}(Service): + + def __init__(self, client): + super({{ service.name | caseUcfirst }}, self).__init__(client) +{% for method in service.methods %} + + async def {{ method.name | caseSnake }}(self{% if method.parameters.all|length > 0 %}, {% endif %}{% for parameter in method.parameters.all %}{{ parameter.name | escapeKeyword | caseSnake }}{% if not parameter.required %} = None{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, on_progress = None{% endif %}): +{% if method.title %} + """{{ method.title }}""" + +{% endif %} +{% for parameter in method.parameters.all %} +{% if parameter.required %} + if {{ parameter.name | escapeKeyword | caseSnake }} is None: + raise {{spec.title | caseUcfirst}}Exception('Missing required parameter: "{{ parameter.name | escapeKeyword | caseSnake }}"') + +{% endif %} +{% endfor %} + params = {} + path = '{{ method.path }}' +{% for parameter in method.parameters.path %} + path = path.replace('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', {{ parameter.name | escapeKeyword | caseSnake }}) +{% endfor %} + +{% for parameter in method.parameters.query %} + if {{ parameter.name | escapeKeyword | caseSnake }} is not None: + params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} + +{% endfor %} +{% for parameter in method.parameters.body %} + if {{ parameter.name | escapeKeyword | caseSnake }} is not None: +{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %} + params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if type({{ parameter.name | escapeKeyword | caseSnake }}) is bool else {{ parameter.name | escapeKeyword | caseSnake }} +{% else %} + params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% endif %} +{% endfor %} +{% for parameter in method.parameters.formData %} + if {{ parameter.name | escapeKeyword | caseSnake }} is not None: +{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %} + params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if type({{ parameter.name | escapeKeyword | caseSnake }}) is bool else {{ parameter.name | escapeKeyword | caseSnake }} +{% else %} + params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }} +{% endif %} + +{% endfor %} +{% if 'multipart/form-data' in method.consumes %} +{% 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 %} + + return await self.client.chunked_upload(path, { +{% for parameter in method.parameters.header %} + '{{ parameter.name }}': {{ parameter.name | escapeKeyword | caseSnake }}, +{% endfor %} +{% for key, header in method.headers %} + '{{ key }}': '{{ header }}', +{% endfor %} + }, params, param_name, on_progress, upload_id) +{% else %} + return await self.client.call('{{ method.method | caseLower }}', path, { +{% for parameter in method.parameters.header %} + '{{ parameter.name }}': {{ parameter.name | escapeKeyword | caseSnake }}, +{% endfor %} +{% for key, header in method.headers %} + '{{ key }}': '{{ header }}', +{% endfor %} + }, params) +{% endif %} +{% endfor %} diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 0d5b2fbb9..234c18c33 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -1,5 +1,5 @@ import io -import requests +import httpx import os from .input_file import InputFile from .exception import {{spec.title | caseUcfirst}}Exception @@ -42,7 +42,7 @@ class Client: return self {% endfor %} - def call(self, method, path='', headers=None, params=None): + def call(self, method, path='', headers=None, params=None, timeout=None): if headers is None: headers = {} @@ -73,7 +73,7 @@ class Client: del data[key] response = None try: - response = requests.request( # call method dynamically https://stackoverflow.com/a/4246075/2299554 + response = httpx.request( method=method, url=self._endpoint + path, params=self.flatten(params, stringify=stringify), @@ -82,6 +82,8 @@ class Client: files=files, headers=headers, verify=(not self._self_signed), + follow_redirects=True, + timeout=timeout ) response.raise_for_status() diff --git a/templates/python/requirements.txt.twig b/templates/python/requirements.txt.twig index 5e7740568..be5be59e3 100644 --- a/templates/python/requirements.txt.twig +++ b/templates/python/requirements.txt.twig @@ -1 +1 @@ -requests==2.28.1 \ No newline at end of file +httpx==0.22.0 diff --git a/templates/python/setup.py.twig b/templates/python/setup.py.twig index 82867e2fe..7a4d6eb4b 100644 --- a/templates/python/setup.py.twig +++ b/templates/python/setup.py.twig @@ -14,7 +14,7 @@ setuptools.setup( download_url='https://github.com/{{sdk.gitUserName}}/{{sdk.gitRepoName}}/archive/{{sdk.version}}.tar.gz', # keywords = ['SOME', 'MEANINGFULL', 'KEYWORDS'], install_requires=[ - 'requests', + 'httpx', ], classifiers=[ 'Development Status :: 5 - Production/Stable', diff --git a/tests/Python310AsyncTest.php b/tests/Python310AsyncTest.php new file mode 100644 index 000000000..4daf453ca --- /dev/null +++ b/tests/Python310AsyncTest.php @@ -0,0 +1,38 @@ +<?php + +namespace Tests; + +/** + * @group asyncPython + * Tests python + */ +class Python310AsyncTest extends Base +{ + protected string $sdkName = 'python'; + protected string $sdkPlatform = 'server'; + protected string $sdkLanguage = 'python'; + protected string $version = '0.0.1'; + + protected string $language = 'python'; + protected string $class = 'Appwrite\SDK\Language\Python'; + protected array $build = [ + 'cp tests/languages/python/tests_async.py tests/sdks/python/test.py', + 'echo "" > tests/sdks/python/__init__.py', + 'docker run --rm -v $(pwd):/app -w /app --env PIP_TARGET=tests/sdks/python/vendor python:3.10 pip install -r tests/sdks/python/requirements.txt --upgrade', + ]; + protected string $command = + 'docker run --rm -v $(pwd):/app -w /app --env PIP_TARGET=tests/sdks/python/vendor --env PYTHONPATH=tests/sdks/python/vendor python:3.10-alpine python tests/sdks/python/test.py'; + + protected array $expectedOutput = [ + ...Base::FOO_RESPONSES, + ...Base::BAR_RESPONSES, + ...Base::GENERAL_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::EXCEPTION_RESPONSES, + ...Base::QUERY_HELPER_RESPONSES, + ...Base::PERMISSION_HELPER_RESPONSES, + ...Base::ID_HELPER_RESPONSES + ]; +} diff --git a/tests/Python310Test.php b/tests/Python310Test.php index a8c5f2cef..33b453e66 100644 --- a/tests/Python310Test.php +++ b/tests/Python310Test.php @@ -2,6 +2,10 @@ namespace Tests; +/** + * @group python + * Tests python + */ class Python310Test extends Base { protected string $sdkName = 'python'; diff --git a/tests/Python38AsyncTest.php b/tests/Python38AsyncTest.php new file mode 100644 index 000000000..b1abd0f53 --- /dev/null +++ b/tests/Python38AsyncTest.php @@ -0,0 +1,38 @@ +<?php + +namespace Tests; + +/** + * @group asyncPython + * Tests python + */ +class Python38AsyncTest extends Base +{ + protected string $sdkName = 'python'; + protected string $sdkPlatform = 'server'; + protected string $sdkLanguage = 'python'; + protected string $version = '0.0.1'; + + protected string $language = 'python'; + protected string $class = 'Appwrite\SDK\Language\Python'; + protected array $build = [ + 'cp tests/languages/python/tests_async.py tests/sdks/python/test.py', + 'echo "" > tests/sdks/python/__init__.py', + 'docker run --rm -v $(pwd):/app -w /app --env PIP_TARGET=tests/sdks/python/vendor python:3.8 pip install -r tests/sdks/python/requirements.txt --upgrade', + ]; + protected string $command = + 'docker run --rm -v $(pwd):/app -w /app --env PIP_TARGET=tests/sdks/python/vendor --env PYTHONPATH=tests/sdks/python/vendor python:3.8-alpine python tests/sdks/python/test.py'; + + protected array $expectedOutput = [ + ...Base::FOO_RESPONSES, + ...Base::BAR_RESPONSES, + ...Base::GENERAL_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::EXCEPTION_RESPONSES, + ...Base::QUERY_HELPER_RESPONSES, + ...Base::PERMISSION_HELPER_RESPONSES, + ...Base::ID_HELPER_RESPONSES + ]; +} diff --git a/tests/Python38Test.php b/tests/Python38Test.php index 0fc836224..2d7666396 100644 --- a/tests/Python38Test.php +++ b/tests/Python38Test.php @@ -2,6 +2,10 @@ namespace Tests; +/** + * @group python + * Tests python + */ class Python38Test extends Base { protected string $sdkName = 'python'; diff --git a/tests/Python39AsyncTest.php b/tests/Python39AsyncTest.php new file mode 100644 index 000000000..f0d4e31ec --- /dev/null +++ b/tests/Python39AsyncTest.php @@ -0,0 +1,38 @@ +<?php + +namespace Tests; + +/** + * @group python + * Tests python + */ +class Python39AsyncTest extends Base +{ + protected string $sdkName = 'python'; + protected string $sdkPlatform = 'server'; + protected string $sdkLanguage = 'python'; + protected string $version = '0.0.1'; + + protected string $language = 'python'; + protected string $class = 'Appwrite\SDK\Language\Python'; + protected array $build = [ + 'cp tests/languages/python/tests_async.py tests/sdks/python/test.py', + 'echo "" > tests/sdks/python/__init__.py', + 'docker run --rm -v $(pwd):/app -w /app --env PIP_TARGET=tests/sdks/python/vendor python:3.9 pip install -r tests/sdks/python/requirements.txt --upgrade', + ]; + protected string $command = + 'docker run --rm -v $(pwd):/app -w /app --env PIP_TARGET=tests/sdks/python/vendor --env PYTHONPATH=tests/sdks/python/vendor python:3.9-alpine python tests/sdks/python/test.py'; + + protected array $expectedOutput = [ + ...Base::FOO_RESPONSES, + ...Base::BAR_RESPONSES, + ...Base::GENERAL_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::EXCEPTION_RESPONSES, + ...Base::QUERY_HELPER_RESPONSES, + ...Base::PERMISSION_HELPER_RESPONSES, + ...Base::ID_HELPER_RESPONSES + ]; +} diff --git a/tests/Python39Test.php b/tests/Python39Test.php index 5dbfe4fcf..ff52920c0 100644 --- a/tests/Python39Test.php +++ b/tests/Python39Test.php @@ -2,6 +2,10 @@ namespace Tests; +/** + * @group python + * Tests python + */ class Python39Test extends Base { protected string $sdkName = 'python'; diff --git a/tests/languages/python/tests_async.py b/tests/languages/python/tests_async.py new file mode 100644 index 000000000..920160e85 --- /dev/null +++ b/tests/languages/python/tests_async.py @@ -0,0 +1,195 @@ +from appwrite.aio.client import AsyncClient +from appwrite.services.foo import Foo +from appwrite.services.bar import Bar +from appwrite.services.general import General +from appwrite.exception import AppwriteException +from appwrite.input_file import InputFile +from appwrite.query import Query +from appwrite.permission import Permission +from appwrite.role import Role +from appwrite.id import ID + +import os.path +import asyncio + + +loop = asyncio.get_event_loop() + +client = AsyncClient() +foo = Foo(client) +bar = Bar(client) +general = General(client) + +client.add_header('Origin', 'http://localhost') +client.set_self_signed() + +print("\nTest Started") + +# Foo Tests + + +async def test_foo_get(): + response = await foo.get('string',123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_foo_get()) + +async def test_foo_post(): + response = await foo.post('string', 123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_foo_post()) + +async def test_foo_put(): + response = await foo.put('string', 123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_foo_put()) + + +async def test_foo_patch(): + response = await foo.patch('string', 123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_foo_patch()) + + +async def test_foo_delete(): + response = await foo.delete('string', 123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_foo_delete()) + + +# Bar Tests + +async def test_bar_get(): + response = await bar.get('string',123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_bar_get()) + +async def test_bar_post(): + response = await bar.post('string', 123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_bar_post()) + + +async def test_bar_put(): + response = await bar.put('string', 123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_bar_put()) + +async def test_bar_patch(): + response = await bar.patch('string', 123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_bar_patch()) + +async def test_bar_delete(): + response = await bar.delete('string', 123, ['string in array']) + print(response['result']) + +loop.run_until_complete(test_bar_delete()) + +# General Tests + +async def test_general_redirect(): + response = await general.redirect() + print(response['result']) + +loop.run_until_complete(test_general_redirect()) + +async def test_general_file_png_upload(): + response = await general.upload('string', 123, ['string in array'], InputFile.from_path('./tests/resources/file.png')) + print(response['result']) + +loop.run_until_complete(test_general_file_png_upload()) + +async def test_general_file_png_bytes_upload(): + data = open('./tests/resources/file.png', 'rb').read() + response = await general.upload('string', 123, ['string in array'], InputFile.from_bytes(data, 'file.png', 'image/png')) + print(response['result']) + +loop.run_until_complete(test_general_file_png_bytes_upload()) + + +async def test_general_large_file_mp4_bytes_upload(): + data = open('./tests/resources/large_file.mp4', 'rb').read() + response = await general.upload('string', 123, ['string in array'], InputFile.from_bytes(data, 'large_file.mp4','video/mp4')) + print(response['result']) + +loop.run_until_complete(test_general_large_file_mp4_bytes_upload()) + + +async def test_general_large_file_mp4(): + response = await general.upload('string', 123, ['string in array'], InputFile.from_path('./tests/resources/large_file.mp4')) + print(response['result']) + +loop.run_until_complete(test_general_large_file_mp4()) + + +async def test_general_400(): + try: + response = await general.error400() + except AppwriteException as e: + print(e.message) + +loop.run_until_complete(test_general_400()) + +async def test_general_error_500(): + try: + response = await general.error500() + except AppwriteException as e: + print(e.message) + +loop.run_until_complete(test_general_error_500()) + +async def test_general_502(): + try: + response = await general.error502() + except AppwriteException as e: + print(e.message) + +loop.run_until_complete(test_general_502()) + +async def test_general_empty(): + await general.empty() + +loop.run_until_complete(test_general_empty()) + + + +# Query helper tests +print(Query.equal('released', [True])) +print(Query.equal('title', ['Spiderman', 'Dr. Strange'])) +print(Query.notEqual('title', 'Spiderman')) +print(Query.lessThan('releasedYear', 1990)) +print(Query.greaterThan('releasedYear', 1990)) +print(Query.search('name', 'john')) +print(Query.orderAsc("title")) +print(Query.orderDesc("title")) +print(Query.cursorAfter("my_movie_id")) +print(Query.cursorBefore("my_movie_id")) +print(Query.limit(50)) +print(Query.offset(20)) + +# Permission & Role helper tests +print(Permission.read(Role.any())) +print(Permission.write(Role.user(ID.custom('userid')))) +print(Permission.create(Role.users())) +print(Permission.update(Role.guests())) +print(Permission.delete(Role.team('teamId', 'owner'))) +print(Permission.delete(Role.team('teamId'))) +print(Permission.create(Role.member('memberId'))) +print(Permission.update(Role.users('verified'))) +print(Permission.update(Role.user(ID.custom('userid'), 'unverified'))) + +# ID helper tests +print(ID.unique()) +print(ID.custom('custom_id')) + +response = general.headers() +print(response['result'])