diff --git a/.github/workflows/sdk-build-validation.yml b/.github/workflows/sdk-build-validation.yml index b095bcc88e..c207458cb0 100644 --- a/.github/workflows/sdk-build-validation.yml +++ b/.github/workflows/sdk-build-validation.yml @@ -201,8 +201,9 @@ jobs: composer test ;; python) - pip install -e . + pip install -e .[test] python -m compileall appwrite/ + python -m unittest ;; ruby) bundle install diff --git a/src/SDK/Language/Python.php b/src/SDK/Language/Python.php index 70538b9db3..195bc27cf1 100644 --- a/src/SDK/Language/Python.php +++ b/src/SDK/Language/Python.php @@ -145,6 +145,11 @@ public function getFiles(): array 'destination' => '{{ spec.title | caseSnake}}/__init__.py', 'template' => 'python/package/__init__.py.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/__init__.py', + 'template' => 'python/test/__init__.py.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.title | caseSnake}}/utils/deprecated.py', @@ -165,26 +170,51 @@ public function getFiles(): array 'destination' => '{{ spec.title | caseSnake}}/permission.py', 'template' => 'python/package/permission.py.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/test_permission.py', + 'template' => 'python/test/test_permission.py.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.title | caseSnake}}/role.py', 'template' => 'python/package/role.py.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/test_role.py', + 'template' => 'python/test/test_role.py.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.title | caseSnake}}/id.py', 'template' => 'python/package/id.py.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/test_id.py', + 'template' => 'python/test/test_id.py.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.title | caseSnake}}/query.py', 'template' => 'python/package/query.py.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/test_query.py', + 'template' => 'python/test/test_query.py.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.title | caseSnake}}/operator.py', 'template' => 'python/package/operator.py.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/test_operator.py', + 'template' => 'python/test/test_operator.py.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.title | caseSnake}}/exception.py', @@ -225,6 +255,11 @@ public function getFiles(): array 'destination' => '{{ spec.title | caseSnake}}/enums/__init__.py', 'template' => 'python/package/services/__init__.py.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/services/__init__.py', + 'template' => 'python/test/services/__init__.py.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.title | caseSnake}}/encoders/value_class_encoder.py', @@ -240,6 +275,11 @@ public function getFiles(): array 'destination' => '{{ spec.title | caseSnake}}/services/{{service.name | caseSnake}}.py', 'template' => 'python/package/services/service.py.twig', ], + [ + 'scope' => 'service', + 'destination' => 'test/services/test_{{service.name | caseSnake}}.py', + 'template' => 'python/test/services/test_service.py.twig', + ], [ 'scope' => 'method', 'destination' => 'docs/examples/{{service.name | caseLower}}/{{method.name | caseKebab}}.md', @@ -704,6 +744,41 @@ protected function hasGenericType(?string $model, array $spec): bool return false; } + /** + * Creates an example for a response model with the given name + * + * @param string $model + * @param array $spec + * @return string + */ + protected function getResponseModelExample(?string $model, array $spec): mixed + { + if (!$model) { + return (object) []; + } + + $modelDef = $spec['definitions'][$model]; + + $result = []; + foreach ($modelDef['properties'] ?? [] as $property) { + if (!$property['required']) { + continue; + } + + $result[$property['name']] = match ($property['type']) { + 'object' => (array_key_exists('sub_schema', $property) && $property['sub_schema']) ? ((object) $this->getResponseModelExample($property['sub_schema'], $spec)) : new \stdClass(), + 'array' => array(), + 'string' => $property['example'] ?? '', + 'boolean' => true, + 'float' => (float) $property['example'], + 'integer' => (float) $property['example'], + default => $property['example'] ?? null, + }; + } + + return (object) $result; + } + public function getFilters(): array { return [ @@ -793,6 +868,12 @@ public function getFilters(): array new TwigFilter('requestModelExample', function (array $parameter, array $spec, string $serviceName = '') { return $this->getRequestModelExample($parameter, $spec, $serviceName); }), + new TwigFilter('responseModelExample', function (string $model, array $spec) { + $result = $this->getResponseModelExample($model, $spec); + $json = json_encode($result, JSON_PRETTY_PRINT | JSON_PRESERVE_ZERO_FRACTION); + + return str_replace([ 'true', 'false', 'null' ], [ "True", "False", "None" ], $json); + }) ]; } } diff --git a/templates/python/pyproject.toml.twig b/templates/python/pyproject.toml.twig index 91c78e3464..03b410a300 100644 --- a/templates/python/pyproject.toml.twig +++ b/templates/python/pyproject.toml.twig @@ -32,6 +32,11 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] +[project.optional-dependencies] +test = [ + "requests_mock==1.11.0", +] + [project.urls] Homepage = "{{ spec.contactURL }}" Repository = "https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }}" diff --git a/templates/python/requirements.txt.twig b/templates/python/requirements.txt.twig index 71762bc4f2..43f1420262 100644 --- a/templates/python/requirements.txt.twig +++ b/templates/python/requirements.txt.twig @@ -1,2 +1,3 @@ requests>=2.31,<3 +requests_mock==1.11.0 pydantic>=2,<3 diff --git a/templates/python/test/__init__.py.twig b/templates/python/test/__init__.py.twig new file mode 100644 index 0000000000..e69de29bb2 diff --git a/templates/python/test/services/__init__.py.twig b/templates/python/test/services/__init__.py.twig new file mode 100644 index 0000000000..e69de29bb2 diff --git a/templates/python/test/services/test_service.py.twig b/templates/python/test/services/test_service.py.twig new file mode 100644 index 0000000000..fc8f1d4b09 --- /dev/null +++ b/templates/python/test/services/test_service.py.twig @@ -0,0 +1,46 @@ +import json +import requests_mock +import unittest + +from appwrite.client import Client +from appwrite.input_file import InputFile +from appwrite.models import * +from appwrite.services.{{ service.name | caseSnake }} import {{ service.name | caseUcfirst }} + +class {{ service.name | caseUcfirst }}ServiceTest(unittest.TestCase): + + def setUp(self): + self.client = Client() + self.{{ service.name | caseSnake }} = {{ service.name | caseUcfirst }}(self.client) + +{% for method in service.methods %} + @requests_mock.Mocker() + def test_{{ method.name | caseSnake }}(self, m): + {%~ 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 %}} + m.request(requests_mock.ANY, requests_mock.ANY, {% if method.type == 'location' %}body=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 %} + +{% endfor %} diff --git a/templates/python/test/test_id.py.twig b/templates/python/test/test_id.py.twig new file mode 100644 index 0000000000..e30438868c --- /dev/null +++ b/templates/python/test/test_id.py.twig @@ -0,0 +1,11 @@ +import unittest + +from appwrite.id import ID + +class TestIDMethods(unittest.TestCase): + + def test_unique(self): + self.assertEqual(len(ID.unique()), 20) + + def test_custom(self): + self.assertEqual(ID.custom('custom'), 'custom') diff --git a/templates/python/test/test_operator.py.twig b/templates/python/test/test_operator.py.twig new file mode 100644 index 0000000000..60cb664368 --- /dev/null +++ b/templates/python/test/test_operator.py.twig @@ -0,0 +1,80 @@ +import unittest + +from appwrite.operator import Operator, Condition + +class TestOperatorMethods(unittest.TestCase): + + def test_increment(self): + self.assertEqual(Operator.increment(1), '{"method":"increment","values":[1]}') + + def test_increment_with_max(self): + self.assertEqual(Operator.increment(5, 100), '{"method":"increment","values":[5,100]}') + + def test_decrement(self): + self.assertEqual(Operator.decrement(1), '{"method":"decrement","values":[1]}') + + def test_decrement_with_min(self): + self.assertEqual(Operator.decrement(3, 0), '{"method":"decrement","values":[3,0]}') + + def test_multiply(self): + self.assertEqual(Operator.multiply(2), '{"method":"multiply","values":[2]}') + + def test_multiply_with_max(self): + self.assertEqual(Operator.multiply(3, 1000), '{"method":"multiply","values":[3,1000]}') + + def test_divide(self): + self.assertEqual(Operator.divide(2), '{"method":"divide","values":[2]}') + + def test_divide_with_min(self): + self.assertEqual(Operator.divide(4, 1), '{"method":"divide","values":[4,1]}') + + def test_modulo(self): + self.assertEqual(Operator.modulo(5), '{"method":"modulo","values":[5]}') + + def test_power(self): + self.assertEqual(Operator.power(2), '{"method":"power","values":[2]}') + + def test_power_with_max(self): + self.assertEqual(Operator.power(3, 100), '{"method":"power","values":[3,100]}') + + def test_array_append(self): + self.assertEqual(Operator.array_append(['item1', 'item2']), '{"method":"arrayAppend","values":["item1","item2"]}') + + def test_array_prepend(self): + self.assertEqual(Operator.array_prepend(['first', 'second']), '{"method":"arrayPrepend","values":["first","second"]}') + + def test_array_insert(self): + self.assertEqual(Operator.array_insert(0, 'newItem'), '{"method":"arrayInsert","values":[0,"newItem"]}') + + def test_array_remove(self): + self.assertEqual(Operator.array_remove('oldItem'), '{"method":"arrayRemove","values":["oldItem"]}') + + def test_array_unique(self): + self.assertEqual(Operator.array_unique(), '{"method":"arrayUnique","values":[]}') + + def test_array_intersect(self): + self.assertEqual(Operator.array_intersect(['a', 'b', 'c']), '{"method":"arrayIntersect","values":["a","b","c"]}') + + def test_array_diff(self): + self.assertEqual(Operator.array_diff(['x', 'y']), '{"method":"arrayDiff","values":["x","y"]}') + + def test_array_filter(self): + self.assertEqual(Operator.array_filter(Condition.EQUAL, 'test'), '{"method":"arrayFilter","values":["equal","test"]}') + + def test_string_concat(self): + self.assertEqual(Operator.string_concat('suffix'), '{"method":"stringConcat","values":["suffix"]}') + + def test_string_replace(self): + self.assertEqual(Operator.string_replace('old', 'new'), '{"method":"stringReplace","values":["old","new"]}') + + def test_toggle(self): + self.assertEqual(Operator.toggle(), '{"method":"toggle","values":[]}') + + def test_date_add_days(self): + self.assertEqual(Operator.date_add_days(7), '{"method":"dateAddDays","values":[7]}') + + def test_date_sub_days(self): + self.assertEqual(Operator.date_sub_days(3), '{"method":"dateSubDays","values":[3]}') + + def test_date_set_now(self): + self.assertEqual(Operator.date_set_now(), '{"method":"dateSetNow","values":[]}') diff --git a/templates/python/test/test_permission.py.twig b/templates/python/test/test_permission.py.twig new file mode 100644 index 0000000000..e85d25afaa --- /dev/null +++ b/templates/python/test/test_permission.py.twig @@ -0,0 +1,21 @@ +import unittest + +from appwrite.permission import Permission +from appwrite.role import Role + +class TestPermissionMethods(unittest.TestCase): + + def test_read(self): + self.assertEqual(Permission.read(Role.any()), 'read("any")') + + def test_write(self): + self.assertEqual(Permission.write(Role.any()), 'write("any")') + + def test_create(self): + self.assertEqual(Permission.create(Role.any()), 'create("any")') + + def test_update(self): + self.assertEqual(Permission.update(Role.any()), 'update("any")') + + def test_delete(self): + self.assertEqual(Permission.delete(Role.any()), 'delete("any")') diff --git a/templates/python/test/test_query.py.twig b/templates/python/test/test_query.py.twig new file mode 100644 index 0000000000..b8731c2a63 --- /dev/null +++ b/templates/python/test/test_query.py.twig @@ -0,0 +1,107 @@ +import unittest + +from appwrite.query import Query + +class BasicFilterQueryTest: + def __init__(self, description: str, value, expected_values: str): + self.description = description + self.value = value + self.expected_values = expected_values + +tests = [ + BasicFilterQueryTest('with a string', 's', '["s"]'), + BasicFilterQueryTest('with an integer', 1, '[1]'), + BasicFilterQueryTest('with a double', 1.2, '[1.2]'), + BasicFilterQueryTest('with a whole number double', 1.0, '[1.0]'), + BasicFilterQueryTest('with a bool', False, '[false]'), + BasicFilterQueryTest('with a list', ['a', 'b', 'c'], '["a","b","c"]'), +] + +class TestQueryMethods(unittest.TestCase): + + def test_equal(self): + for t in tests: + self.assertEqual( + Query.equal('attr', t.value), + {% verbatim %}f'{{"method":"equal","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + t.description + ) + + def test_not_equal(self): + for t in tests: + self.assertEqual( + Query.not_equal('attr', t.value), + {% verbatim %}f'{{"method":"notEqual","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + t.description + ) + + def test_less_than(self): + for t in tests: + self.assertEqual( + Query.less_than('attr', t.value), + {% verbatim %}f'{{"method":"lessThan","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + t.description + ) + + def test_less_than_equal(self): + for t in tests: + self.assertEqual( + Query.less_than_equal('attr', t.value), + {% verbatim %}f'{{"method":"lessThanEqual","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + t.description + ) + + def test_greater_than(self): + for t in tests: + self.assertEqual( + Query.greater_than('attr', t.value), + {% verbatim %}f'{{"method":"greaterThan","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + t.description + ) + + def test_greater_than_equal(self): + for t in tests: + self.assertEqual( + Query.greater_than_equal('attr', t.value), + {% verbatim %}f'{{"method":"greaterThanEqual","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + t.description + ) + + def test_search(self): + self.assertEqual(Query.search('attr', 'keyword1 keyword2'), '{"method":"search","attribute":"attr","values":["keyword1 keyword2"]}') + + def test_is_null(self): + self.assertEqual(Query.is_null('attr'), '{"method":"isNull","attribute":"attr"}') + + def test_is_not_null(self): + self.assertEqual(Query.is_not_null('attr'), '{"method":"isNotNull","attribute":"attr"}') + + def test_between_with_integers(self): + self.assertEqual(Query.between('attr', 1, 2), '{"method":"between","attribute":"attr","values":[1,2]}') + + def test_between_with_doubles(self): + self.assertEqual(Query.between('attr', 1.0, 2.0), '{"method":"between","attribute":"attr","values":[1.0,2.0]}') + + def test_between_with_strings(self): + self.assertEqual(Query.between('attr', 'a', 'z'), '{"method":"between","attribute":"attr","values":["a","z"]}') + + def test_select(self): + self.assertEqual(Query.select(['attr1', 'attr2']), '{"method":"select","values":["attr1","attr2"]}') + + def test_order_asc(self): + self.assertEqual(Query.order_asc('attr'), '{"method":"orderAsc","attribute":"attr"}') + + def test_order_desc(self): + self.assertEqual(Query.order_desc('attr'), '{"method":"orderDesc","attribute":"attr"}') + + def test_cursor_before(self): + self.assertEqual(Query.cursor_before('custom'), '{"method":"cursorBefore","values":["custom"]}') + + def test_cursor_after(self): + self.assertEqual(Query.cursor_after('custom'), '{"method":"cursorAfter","values":["custom"]}') + + def test_limit(self): + self.assertEqual(Query.limit(1), '{"method":"limit","values":[1]}') + + def test_offset(self): + self.assertEqual(Query.offset(1), '{"method":"offset","values":[1]}') diff --git a/templates/python/test/test_role.py.twig b/templates/python/test/test_role.py.twig new file mode 100644 index 0000000000..eda4276e8e --- /dev/null +++ b/templates/python/test/test_role.py.twig @@ -0,0 +1,35 @@ +import unittest + +from appwrite.role import Role + +class TestRoleMethods(unittest.TestCase): + + def test_any(self): + self.assertEqual(Role.any(), 'any') + + def test_user_without_status(self): + self.assertEqual(Role.user('custom'), 'user:custom') + + def test_user_with_status(self): + self.assertEqual(Role.user('custom', 'verified'), 'user:custom/verified') + + def test_users_without_status(self): + self.assertEqual(Role.users(), 'users') + + def test_users_with_status(self): + self.assertEqual(Role.users('verified'), 'users/verified') + + def test_guests(self): + self.assertEqual(Role.guests(), 'guests') + + def test_team_without_role(self): + self.assertEqual(Role.team('custom'), 'team:custom') + + def test_team_with_role(self): + self.assertEqual(Role.team('custom', 'owner'), 'team:custom/owner') + + def test_member(self): + self.assertEqual(Role.member('custom'), 'member:custom') + + def test_label(self): + self.assertEqual(Role.label('admin'), 'label:admin')