From dfbe210e5ff26678e8450460abc3c4704bea645c Mon Sep 17 00:00:00 2001 From: Konstantinos Bairaktaris Date: Thu, 4 Feb 2021 12:18:20 +0200 Subject: [PATCH 1/6] Support multiple API connection instances instead of only a global one --- src/jsonapi/apis.py | 66 ++++++++++++++++++++++++++++++----- src/transifex_api/__init__.py | 36 ++++++++++--------- 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/jsonapi/apis.py b/src/jsonapi/apis.py index b9a8474..f218520 100644 --- a/src/jsonapi/apis.py +++ b/src/jsonapi/apis.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import requests +import six from .auth import BearerAuthentication from .compat import JSONDecodeError @@ -8,7 +9,17 @@ from .resources import Resource -class JsonApi(object): +class _JsonApiMetaclass(type): + def __new__(cls, *args, **kwargs): + result = super().__new__(cls, *args, **kwargs) + + # Use a copy, not reference to parent's registry + result.registry = list(getattr(result, 'registry', [])) + + return result + + +class JsonApi(six.with_metaclass(_JsonApiMetaclass, object)): """ Inteface for a new {json:api} API. - host: The URL of the API @@ -34,8 +45,22 @@ class JsonApi(object): ... TYPE = "foos" """ + HOST = None + def __init__(self, **kwargs): - self.registry = {} + self.type_registry = {} + self.class_registry = {} + + for base_class in self.__class__.registry: + # Dynamically create a subclass adding 'self' (the API connection + # instance) as a class variable to it + child_class = type(base_class.__name__, + (base_class, ), + {'API': self}) + self.type_registry[base_class.TYPE] = child_class + self.class_registry[base_class.__name__] = child_class + + self.host = self.HOST self.headers = {} self.setup(**kwargs) @@ -52,12 +77,37 @@ def setup(self, host=None, auth=None, headers=None): if headers is not None: self.headers = headers - def register(self, klass): - if klass.TYPE is not None: - self.registry[klass.TYPE] = klass - klass.API = self + @classmethod + def register(cls, klass): + cls.registry.append(klass) return klass + def __getattr__(self, attr): + """ Access a registered API resource class. A class name or API + resource type can be used. + + >>> class FooApi(JsonApi): + ... HOST = "https://api.foo.com" + + >>> @FooApi.register + ... class Foo(Resource): + ... TYPE = "foos" + + >>> foo_api = FooApi() + + >>> foo_api.Foo.list() + >>> # or + >>> foo_api.foos.list() + """ + + try: + return self.class_registry[attr] + except KeyError: + try: + return self.type_registry[attr] + except KeyError: + raise AttributeError(attr) + # Required args def request(self, method, url, # Not passed to requests, used to determine Content-Type @@ -125,8 +175,8 @@ def new(self, data=None, type=None, **kwargs): data = data['data'] return self.new(**data) else: - if type in self.registry: - klass = self.registry[type] + if type in self.type_registry: + klass = self.type_registry[type] else: # Lets make a new class on the fly class klass(Resource): diff --git a/src/transifex_api/__init__.py b/src/transifex_api/__init__.py index 5b5bdb3..8fd46e4 100644 --- a/src/transifex_api/__init__.py +++ b/src/transifex_api/__init__.py @@ -4,34 +4,32 @@ from jsonapi.exceptions import JsonApiException -_api = jsonapi.JsonApi(host="https://rest.api.transifex.com") +class TransifexApi(jsonapi.JsonApi): + HOST = "https://rest.api.transifex.com" -def setup(auth, host=None, headers=None): - _api.setup(host=host, auth=auth, headers=headers) - -@_api.register +@TransifexApi.register class Organization(jsonapi.Resource): TYPE = "organizations" -@_api.register +@TransifexApi.register class Team(jsonapi.Resource): TYPE = "teams" -@_api.register +@TransifexApi.register class Project(jsonapi.Resource): TYPE = "projects" -@_api.register +@TransifexApi.register class Language(jsonapi.Resource): TYPE = "languages" -@_api.register +@TransifexApi.register class Resource(jsonapi.Resource): TYPE = "resources" @@ -45,18 +43,18 @@ def purge(self): return count -@_api.register +@TransifexApi.register class ResourceString(jsonapi.Resource): TYPE = "resource_strings" -@_api.register +@TransifexApi.register class ResourceTranslation(jsonapi.Resource): TYPE = "resource_translations" EDITABLE = ["strings", 'reviewed', "proofread"] -@_api.register +@TransifexApi.register class ResourceStringsAsyncUpload(jsonapi.Resource): TYPE = "resource_strings_async_uploads" @@ -95,7 +93,7 @@ def upload(cls, resource, content, interval=5): upload.reload() -@_api.register +@TransifexApi.register class ResourceTranslationsAsyncUpload(Resource): TYPE = "resource_translations_async_uploads" @@ -139,22 +137,22 @@ def upload(cls, resource, content, language, interval=5, upload.reload() -@_api.register +@TransifexApi.register class User(jsonapi.Resource): TYPE = "users" -@_api.register +@TransifexApi.register class TeamMembership(jsonapi.Resource): TYPE = "team_memberships" -@_api.register +@TransifexApi.register class ResourceLanguageStats(jsonapi.Resource): TYPE = "resource_language_stats" -@_api.register +@TransifexApi.register class ResourceTranslationsAsyncDownload(jsonapi.Resource): TYPE = "resource_translations_async_downloads" @@ -173,3 +171,7 @@ def download(cls, interval=5, *args, **kwargs): return download.redirect time.sleep(interval) download.reload() + + +# This is our global object +transifex_api = TransifexApi() From e64954fbf374dcaa401f98a5ad557139f98f6ae9 Mon Sep 17 00:00:00 2001 From: Konstantinos Bairaktaris Date: Thu, 4 Feb 2021 12:59:50 +0200 Subject: [PATCH 2/6] Fix tests --- src/jsonapi/apis.py | 2 +- src/jsonapi/resources.py | 4 +- src/tests/test_apis.py | 48 +++++++++++++---------- src/tests/test_bulk.py | 23 ++++++----- src/tests/test_exceptions.py | 11 ++++-- src/tests/test_plural_lists.py | 23 ++++++----- src/tests/test_queryset.py | 33 +++++++++------- src/tests/test_relationships.py | 39 ++++++++++--------- src/tests/test_single_resource.py | 63 +++++++++++++++++-------------- 9 files changed, 143 insertions(+), 103 deletions(-) diff --git a/src/jsonapi/apis.py b/src/jsonapi/apis.py index f218520..47165b9 100644 --- a/src/jsonapi/apis.py +++ b/src/jsonapi/apis.py @@ -11,7 +11,7 @@ class _JsonApiMetaclass(type): def __new__(cls, *args, **kwargs): - result = super().__new__(cls, *args, **kwargs) + result = super(_JsonApiMetaclass, cls).__new__(cls, *args, **kwargs) # Use a copy, not reference to parent's registry result.registry = list(getattr(result, 'registry', [])) diff --git a/src/jsonapi/resources.py b/src/jsonapi/resources.py index ee67172..42dc4b1 100644 --- a/src/jsonapi/resources.py +++ b/src/jsonapi/resources.py @@ -86,7 +86,7 @@ def _overwrite(self, # Relationships self.relationships, self.related = {}, {} - for key, value in deepcopy(relationships).items(): + for key, value in relationships.items(): self._set_relationship(key, value) relationship = self.relationships[key] if is_null(relationship) or has_data(relationship): @@ -443,7 +443,7 @@ def _post_save(self, response_body): data = response_body['data'] - related = deepcopy(self.related) + related = dict(self.related) for relationship_name, related_instance in list(related.items()): if is_collection(related_instance): continue # Plural relationship diff --git a/src/tests/test_apis.py b/src/tests/test_apis.py index 985a218..9721388 100644 --- a/src/tests/test_apis.py +++ b/src/tests/test_apis.py @@ -5,43 +5,53 @@ from .constants import host -_api = jsonapi.JsonApi() - -def reset_setup(): - _api.setup(host, "test_api_key") - assert _api.make_auth_headers() == {'Authorization': "Bearer test_api_key"} - assert _api.host == host +class ATestApi(jsonapi.JsonApi): + HOST = host -@_api.register +@ATestApi.register class GlobalTest(jsonapi.Resource): TYPE = "globaltests" -def test_class_registry(): - assert _api.registry['globaltests'] is GlobalTest +test_api = ATestApi() + + +def reset_setup(): + test_api.setup(host=host, auth="test_api_key") + assert (test_api.make_auth_headers() == + {'Authorization': "Bearer test_api_key"}) + assert test_api.host == host + + +def test_registries(): + assert issubclass(test_api.type_registry['globaltests'], GlobalTest) + assert issubclass(test_api.class_registry['GlobalTest'], GlobalTest) def test_setup_plaintext(): - _api.setup("http://some.host", "another_key") - assert _api.make_auth_headers() == {'Authorization': "Bearer another_key"} - assert _api.host == "http://some.host" + test_api.setup(host="http://some.host", auth="another_key") + assert (test_api.make_auth_headers() == + {'Authorization': "Bearer another_key"}) + assert test_api.host == "http://some.host" reset_setup() def test_setup_ulf(): - _api.setup(host, ULFAuthentication('public')) - assert _api.make_auth_headers() == {'Authorization': "ULF public"} + test_api.setup(auth=ULFAuthentication('public')) + assert test_api.make_auth_headers() == {'Authorization': "ULF public"} - _api.setup(host, ULFAuthentication('public', 'secret')) - assert _api.make_auth_headers() == {'Authorization': "ULF public:secret"} + test_api.setup(auth=ULFAuthentication('public', 'secret')) + assert (test_api.make_auth_headers() == + {'Authorization': "ULF public:secret"}) reset_setup() def test_setup_any_callable(): - _api.setup("http://some.host2", lambda: {'Authorization': "Another key2"}) - assert _api.make_auth_headers() == {'Authorization': "Another key2"} - assert _api.host == "http://some.host2" + test_api.setup(host="http://some.host2", + auth=lambda: {'Authorization': "Another key2"}) + assert test_api.make_auth_headers() == {'Authorization': "Another key2"} + assert test_api.host == "http://some.host2" reset_setup() diff --git a/src/tests/test_bulk.py b/src/tests/test_bulk.py index bb32390..b31902b 100644 --- a/src/tests/test_bulk.py +++ b/src/tests/test_bulk.py @@ -9,14 +9,19 @@ from .constants import host from .payloads import Payloads -_api = jsonapi.JsonApi(host=host, auth="test_api_key") +class ATestApi(jsonapi.JsonApi): + HOST = host -@_api.register + +@ATestApi.register class BulkItem(jsonapi.Resource): TYPE = "bulk_items" +test_api = ATestApi(host=host, auth="test_api_key") + + payloads = Payloads('bulk_items') @@ -24,8 +29,8 @@ class BulkItem(jsonapi.Resource): def test_bulk_delete(): responses.add(responses.DELETE, "{}/bulk_items".format(host)) - items = [BulkItem(payload) for payload in payloads[1:6]] - BulkItem.bulk_delete([items[0], + items = [test_api.BulkItem(payload) for payload in payloads[1:6]] + test_api.BulkItem.bulk_delete([items[0], items[1].as_resource_identifier(), items[2].as_relationship(), items[3].id]) @@ -46,7 +51,7 @@ def test_bulk_create(): responses.add(responses.POST, "{}/bulk_items".format(host), json={'data': response_payload}) - result = BulkItem.bulk_create([ + result = test_api.BulkItem.bulk_create([ BulkItem(attributes={'name': "bulk_item 1"}), {'attributes': {'name': "bulk_item 2"}}, ({'name': "bulk_item 3"}, None), @@ -64,7 +69,7 @@ def test_bulk_create(): assert isinstance(result[0], BulkItem) assert result[1].id == "2" - assert result[2] == BulkItem(id="3") + assert result[2] == test_api.BulkItem(id="3") for i in range(4): assert result[i].id == str(i + 1) @@ -85,8 +90,8 @@ def test_bulk_update(): responses.add(responses.PATCH, "{}/bulk_items".format(host), json={'data': response_payload}) - result = BulkItem.bulk_update([ - BulkItem(id="1", attributes={'name': "modified name 1"}), + result = test_api.BulkItem.bulk_update([ + test_api.BulkItem(id="1", attributes={'name': "modified name 1"}), {'id': "2", 'attributes': {'name': "modified name 2"}}, ("3", {'name': "modified name 3"}, None), ("4", {'name': "modified name 4"}), @@ -106,7 +111,7 @@ def test_bulk_update(): assert isinstance(result[0], BulkItem) assert result[1].id == "2" - assert result[2] == BulkItem(id="3") + assert result[2] == test_api.BulkItem(id="3") assert result[3].name == "modified name 4" for i in range(5): diff --git a/src/tests/test_exceptions.py b/src/tests/test_exceptions.py index 2ef56d3..694e2df 100644 --- a/src/tests/test_exceptions.py +++ b/src/tests/test_exceptions.py @@ -7,14 +7,19 @@ from .constants import host -_api = jsonapi.JsonApi(host=host, auth="test_api_key") +class ATestApi(jsonapi.JsonApi): + HOST = host -@_api.register + +@ATestApi.register class Foo(jsonapi.Resource): TYPE = "foos" +test_api = ATestApi(auth="test_api_key") + + @responses.activate def test_exception_during_create(): responses.add( @@ -32,7 +37,7 @@ def test_exception_during_create(): exc = None try: - Foo.create(attributes={'username': "Foo"}) + test_api.Foo.create(attributes={'username': "Foo"}) except JsonApiException as e: exc = e diff --git a/src/tests/test_plural_lists.py b/src/tests/test_plural_lists.py index 9f7e26f..f7fcd35 100644 --- a/src/tests/test_plural_lists.py +++ b/src/tests/test_plural_lists.py @@ -10,19 +10,24 @@ from .constants import host from .payloads import Payloads -_api = jsonapi.JsonApi(host=host, auth="test_api_key") +class ATestApi(jsonapi.JsonApi): + HOST = host -@_api.register + +@ATestApi.register class Child(jsonapi.Resource): TYPE = "children" -@_api.register +@ATestApi.register class Parent(jsonapi.Resource): TYPE = "parents" +test_api = ATestApi(host=host, auth="test_api_key") + + child_payloads = Payloads('children', singular_type="child") @@ -45,18 +50,18 @@ def make_simple_assertions(parent): def test_plural_list(): - parent = Parent(PAYLOAD) + parent = test_api.Parent(PAYLOAD) make_simple_assertions(parent) - parent = Parent(PAYLOAD['data']) + parent = test_api.Parent(PAYLOAD['data']) make_simple_assertions(parent) - parent = Parent(**PAYLOAD['data']) + parent = test_api.Parent(**PAYLOAD['data']) make_simple_assertions(parent) def test_included(): payload = deepcopy(PAYLOAD) payload['included'] = child_payloads[1:3] - parent = Parent(payload) + parent = test_api.Parent(payload) make_simple_assertions(parent) assert parent.children[0].name == "child 1" @@ -68,7 +73,7 @@ def test_refetch(): responses.add(responses.GET, "{}/parents/1/children".format(host), json={'data': child_payloads[1:4]}) - parent = Parent(PAYLOAD) + parent = test_api.Parent(PAYLOAD) assert len(parent.children) == 2 parent.fetch('children') @@ -82,6 +87,6 @@ def test_save_with_included(): payload = deepcopy(PAYLOAD) payload['included'] = child_payloads[1:3] responses.add(responses.PATCH, "{}/parents/1".format(host), json=payload) - parent = Parent(PAYLOAD) + parent = test_api.Parent(payload) parent.save(name="parent 1") assert [child.name for child in parent.children] == ["child 1", "child 2"] diff --git a/src/tests/test_queryset.py b/src/tests/test_queryset.py index 1675838..e299cf8 100644 --- a/src/tests/test_queryset.py +++ b/src/tests/test_queryset.py @@ -8,19 +8,24 @@ from .constants import host from .payloads import Payloads -_api = jsonapi.JsonApi(host=host, auth="test_api_key") +class ATestApi(jsonapi.JsonApi): + HOST = host -@_api.register + +@ATestApi.register class Item(jsonapi.Resource): TYPE = "items" -@_api.register +@ATestApi.register class Tag(jsonapi.Resource): TYPE = "tags" +test_api = ATestApi(host=host, auth="test_api_key") + + payloads = Payloads('items') @@ -29,7 +34,7 @@ def test_collection(): responses.add(responses.GET, "{}/items".format(host), json={'data': payloads[1:4]}) - collection = Collection(_api, '/items') + collection = Collection(test_api, '/items') list(collection) assert len(collection) == 3 @@ -44,7 +49,7 @@ def test_collection(): def test_from_data(): - collection = Collection.from_data(_api, {'data': payloads[1:4]}) + collection = Collection.from_data(test_api, {'data': payloads[1:4]}) assert len(collection) == 3 assert isinstance(collection[0], Item) @@ -63,7 +68,7 @@ def test_pagination(): json={'data': payloads[4:7], 'links': {'previous': "/items?page=1"}}) - first_page = Collection.from_data(_api, + first_page = Collection.from_data(test_api, {'data': payloads[1:4], 'links': {'next': "/items?page=2"}}) assert first_page.has_next() @@ -89,7 +94,7 @@ def test_pagination(): def test_all(): responses.add(responses.GET, "{}/items".format(host), json={'data': payloads[1:4]}) - collection = Item.list() + collection = test_api.Item.list() assert len(collection) == 3 assert isinstance(collection[0], Item) @@ -113,7 +118,7 @@ def test_all_with_pagination(): 'links': {'previous': "/items?page=1"}}, match_querystring=True) - first_page = Item.list() + first_page = test_api.Item.list() assert first_page.has_next() second_page = first_page.next() @@ -141,17 +146,17 @@ def test_filter(): responses.add(responses.GET, "{}/items?filter[odd]=1".format(host), json={'data': payloads[1:5:2]}, match_querystring=True) - all_items = Item.list() - odd_items = Item.filter(odd=1) + all_items = test_api.Item.list() + odd_items = test_api.Item.filter(odd=1) assert len(all_items) == 4 assert len(odd_items) == 2 assert list(odd_items) == [all_items[0], all_items[2]] - assert (list(Item.filter(odd=1)) == - list(Item.list().filter(odd=1)) == - list(Item.filter(odd=2).filter(odd=1))) + assert (list(test_api.Item.filter(odd=1)) == + list(test_api.Item.list().filter(odd=1)) == + list(test_api.Item.filter(odd=2).filter(odd=1))) @responses.activate @@ -173,6 +178,6 @@ def test_include(): 'attributes': {'name': "tag2"}}], }) - item1, item2 = Item.list() + item1, item2 = test_api.Item.list() assert item1.tag.name == "tag1" assert item2.tag.name == "tag2" diff --git a/src/tests/test_relationships.py b/src/tests/test_relationships.py index 68d67b8..c97c0dc 100644 --- a/src/tests/test_relationships.py +++ b/src/tests/test_relationships.py @@ -10,19 +10,24 @@ from .constants import host from .payloads import Payloads -_api = jsonapi.JsonApi(host=host, auth="test_api_key") +class ATestApi(jsonapi.JsonApi): + HOST = host -@_api.register + +@ATestApi.register class Child(jsonapi.Resource): TYPE = "children" -@_api.register +@ATestApi.register class Parent(jsonapi.Resource): TYPE = "parents" +test_api = ATestApi(auth="test_api_key") + + child_payloads = Payloads( 'children', 'child', extra={'relationships': { @@ -44,11 +49,11 @@ class Parent(jsonapi.Resource): def test_initialization(): responses.add(responses.GET, "{}/parents/1".format(host), json={'data': {'type': "parents", 'id': "1"}}) - parents = [Parent.get('1'), - Parent(id='1'), + parents = [test_api.Parent.get('1'), + test_api.Parent(id='1'), {'data': {'type': "parents", 'id': '1'}}, {'type': "parents", 'id': '1'}] - children = [Child(relationships={'parent': parent}) + children = [test_api.Child(relationships={'parent': parent}) for parent in parents] assert all((children[i] == children[i + 1] for i in range(len(children) - 1))) @@ -57,7 +62,7 @@ def test_initialization(): assert all((children[i].parent.__dict__ == children[i + 1].parent.__dict__ for i in range(len(children) - 1))) - child = Child(relationships={'parent': None}) + child = test_api.Child(relationships={'parent': None}) assert child.relationships == child.related == {'parent': None} @@ -66,7 +71,7 @@ def test_singular_fetch(): responses.add(responses.GET, "{}/parents/1".format(host), json={'data': parent_payloads[1]}) - child = Child(child_payloads[1]) + child = test_api.Child(child_payloads[1]) assert (child.relationships == {'parent': {'data': {'type': "parents", 'id': "1"}, @@ -74,7 +79,7 @@ def test_singular_fetch(): 'related': "/parents/1"}}}) assert (child.related['parent'] == child.parent == - Parent(id="1")) + test_api.Parent(id="1")) assert child.parent.attributes == child.parent.attributes == {} child.fetch('parent') @@ -82,7 +87,7 @@ def test_singular_fetch(): assert len(responses.calls) == 1 assert (child.related['parent'] == child.parent == - Parent(id="1")) + test_api.Parent(id="1")) assert child.parent.attributes == {'name': "parent 1"} assert child.parent.name == "parent 1" @@ -98,7 +103,7 @@ def test_fetch_plural(): 'links': {'previous': "/parents/1/children?page=1"}}, match_querystring=True) - parent = Parent(parent_payloads[1]) + parent = test_api.Parent(parent_payloads[1]) assert 'children' not in parent.related parent.fetch('children') list(parent.children) @@ -126,8 +131,8 @@ def test_change_parent_with_save(): responses.add(responses.PATCH, "{}/children/1".format(host), json={'data': response_body}) - child = Child(child_payloads[1]) - child.parent = Parent(parent_payloads[2]) + child = test_api.Child(child_payloads[1]) + child.parent = test_api.Parent(parent_payloads[2]) assert child.relationships['parent']['data']['id'] == "2" assert child.related['parent'].id == child.parent.id == "2" @@ -146,8 +151,8 @@ def test_change_parent_with_change(): responses.add(responses.PATCH, "{}/children/1/relationships/parent".format(host)) - child = Child(child_payloads[1]) - new_parent = Parent(id="2") + child = test_api.Child(child_payloads[1]) + new_parent = test_api.Parent(id="2") child.change('parent', new_parent) assert child.relationships['parent']['data']['id'] == "2" @@ -165,8 +170,8 @@ def test_add(): responses.add(responses.POST, "{}/parents/1/relationships/children".format(host)) - parent = Parent(parent_payloads[1]) - children = [Child(payload) for payload in child_payloads[1:4]] + parent = test_api.Parent(parent_payloads[1]) + children = [test_api.Child(payload) for payload in child_payloads[1:4]] parent.add('children', [children[0], children[1].as_relationship(), children[2].as_resource_identifier()]) diff --git a/src/tests/test_single_resource.py b/src/tests/test_single_resource.py index 7dbe10e..2841de6 100644 --- a/src/tests/test_single_resource.py +++ b/src/tests/test_single_resource.py @@ -9,14 +9,19 @@ from .constants import host -_api = jsonapi.JsonApi(host=host, auth="test_api_key") +class ATestApi(jsonapi.JsonApi): + HOST = host -@_api.register + +@ATestApi.register class Foo(jsonapi.Resource): TYPE = "foos" +test_api = ATestApi(host=host, auth="test_api_key") + + SIMPLE_PAYLOAD = {'type': "foos", 'id': "1", 'attributes': {'hello': "world"}} @@ -31,40 +36,40 @@ def make_simple_assertions(foo): def test_init(): - foo = Foo(id="1", attributes={'hello': "world"}) + foo = test_api.Foo(id="1", attributes={'hello': "world"}) make_simple_assertions(foo) - foo = Foo({'data': SIMPLE_PAYLOAD}) + foo = test_api.Foo({'data': SIMPLE_PAYLOAD}) make_simple_assertions(foo) - foo = Foo(SIMPLE_PAYLOAD) + foo = test_api.Foo(SIMPLE_PAYLOAD) make_simple_assertions(foo) - foo = Foo(id="1", hello="world") + foo = test_api.Foo(id="1", hello="world") make_simple_assertions(foo) def test_new(): - foo = _api.new(type="foos", id="1", attributes={'hello': "world"}) + foo = test_api.new(type="foos", id="1", attributes={'hello': "world"}) make_simple_assertions(foo) - foo = _api.new({'data': SIMPLE_PAYLOAD}) + foo = test_api.new({'data': SIMPLE_PAYLOAD}) make_simple_assertions(foo) - foo = _api.new(SIMPLE_PAYLOAD) + foo = test_api.new(SIMPLE_PAYLOAD) make_simple_assertions(foo) - foo = _api.new(type="foos", id="1", hello="world") + foo = test_api.new(type="foos", id="1", hello="world") make_simple_assertions(foo) def test_as_resource(): - foo = Foo(SIMPLE_PAYLOAD) - assert (_api.as_resource(foo).as_resource_identifier() == + foo = test_api.Foo(SIMPLE_PAYLOAD) + assert (test_api.as_resource(foo).as_resource_identifier() == {'type': "foos", 'id': "1"}) - assert (_api.as_resource({'data': SIMPLE_PAYLOAD}). + assert (test_api.as_resource({'data': SIMPLE_PAYLOAD}). as_resource_identifier() == {'type': "foos", 'id': "1"}) - assert (_api.as_resource(SIMPLE_PAYLOAD). + assert (test_api.as_resource(SIMPLE_PAYLOAD). as_resource_identifier() == {'type': "foos", 'id': "1"}) def test_setattr(): - foo = Foo(SIMPLE_PAYLOAD) + foo = test_api.Foo(SIMPLE_PAYLOAD) foo.hello = "WORLD" assert foo.hello == "WORLD" assert foo.attributes == {'hello': "WORLD"} @@ -72,7 +77,7 @@ def test_setattr(): @responses.activate def test_reload(): - foo = Foo(SIMPLE_PAYLOAD) + foo = test_api.Foo(SIMPLE_PAYLOAD) new_payload = deepcopy(SIMPLE_PAYLOAD) new_payload['attributes']['hello'] = "WORLD" @@ -89,7 +94,7 @@ def test_get_one(): responses.add(responses.GET, "{}/foos/1".format(host), json={'data': SIMPLE_PAYLOAD}) - foo = Foo.get('1') + foo = test_api.Foo.get('1') assert len(responses.calls) == 1 call = responses.calls[0] @@ -104,7 +109,7 @@ def test_get_one_with_filters(): responses.add(responses.GET, "{}/foos?filter[hello]=world".format(host), json={'data': [SIMPLE_PAYLOAD]}, match_querystring=True) - foo = Foo.get(hello="world") + foo = test_api.Foo.get(hello="world") assert len(responses.calls) == 1 call = responses.calls[0] @@ -129,7 +134,7 @@ def test_get_one_with_include(): 'attributes': {'name': "Foo2"}}]}, ) - foo = Foo.get('1', include=['sibling']) + foo = test_api.Foo.get('1', include=['sibling']) assert len(responses.calls) == 1 call = responses.calls[0] @@ -150,7 +155,7 @@ def test_get_one_with_include(): @responses.activate def test_save_existing(): - foo = Foo(SIMPLE_PAYLOAD) + foo = test_api.Foo(SIMPLE_PAYLOAD) new_payload = deepcopy(SIMPLE_PAYLOAD) new_payload['attributes']['hello'] = "WORLD" @@ -179,7 +184,7 @@ def test_save_new(): responses.add(responses.POST, "{}/foos".format(host), json={'data': new_payload}) - foo = Foo(attributes={'hello': "world"}) + foo = test_api.Foo(attributes={'hello': "world"}) foo.save() assert foo.id == "1" @@ -194,7 +199,7 @@ def test_create(): responses.add(responses.POST, "{}/foos".format(host), json={'data': new_payload}) - foo = Foo.create(attributes={'hello': "world"}) + foo = test_api.Foo.create(attributes={'hello': "world"}) assert foo.created == "NOW!!!" assert foo.attributes == {'hello': "world", 'created': "NOW!!!"} @@ -207,7 +212,7 @@ def test_create_with_id(): responses.add(responses.POST, "{}/foos".format(host), json={'data': new_payload}) - foo = Foo.create(id="2", attributes={'hello': "world"}) + foo = test_api.Foo.create(id="2", attributes={'hello': "world"}) assert len(responses.calls) == 1 assert json.loads(responses.calls[0].request.body.decode()) == {'data': { @@ -224,7 +229,7 @@ def test_create_with_id(): def test_delete(): responses.add(responses.DELETE, "{}/foos/1".format(host)) - foo = Foo(SIMPLE_PAYLOAD) + foo = test_api.Foo(SIMPLE_PAYLOAD) foo.delete() assert len(responses.calls) == 1 @@ -232,20 +237,20 @@ def test_delete(): def test_eq(): - foo = Foo(SIMPLE_PAYLOAD) + foo = test_api.Foo(SIMPLE_PAYLOAD) assert foo == {'type': "foos", 'id': "1"} assert {'type': "foos", 'id': "1"} == foo assert foo == {'data': {'type': "foos", 'id': "1"}} assert {'data': {'type': "foos", 'id': "1"}} == foo - assert foo == Foo(id="1") - assert Foo(id="1") == foo + assert foo == test_api.Foo(id="1") + assert test_api.Foo(id="1") == foo def test_as_resource_identifier(): - foo = Foo(SIMPLE_PAYLOAD) + foo = test_api.Foo(SIMPLE_PAYLOAD) assert foo.as_resource_identifier() == {'type': "foos", 'id': "1"} def test_as_relationship(): - foo = Foo(SIMPLE_PAYLOAD) + foo = test_api.Foo(SIMPLE_PAYLOAD) assert foo.as_relationship() == {'data': {'type': "foos", 'id': "1"}} From 8226dee580a8549979c412655b391f27182c8471 Mon Sep 17 00:00:00 2001 From: Konstantinos Bairaktaris Date: Thu, 4 Feb 2021 13:39:35 +0200 Subject: [PATCH 3/6] Updated README --- README.md | 307 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 184 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 94a6356..2479ebe 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ A python SDK for the [Transifex API (v3)](https://transifex.github.io/openapi/) * [Installation](#installation) * [jsonapi usage](#jsonapi-usage) * [Setting up](#setting-up) - * [Registering Resource subclasses](#registering-resource-subclasses) - * [Customizing setup configuration](#customizing-setup-configuration) + * [Global API connection instances](#global-api-connection-instances) * [Authentication](#authentication) + * [Custom headers](#custom-headers) * [Retrieval](#retrieval) * [URLs](#urls) * [Getting a single resource object from the API](#getting-a-single-resource-object-from-the-api) - * [Fetching relationships](#fetching-relationships) + * [Relationships](#relationships) * [Shortcuts](#shortcuts) * [Getting Resource collections](#getting-resource-collections) * [Prefetching relationships with include](#prefetching-relationships-with-include) @@ -29,7 +29,7 @@ A python SDK for the [Transifex API (v3)](https://transifex.github.io/openapi/) * [transifex_api usage](#transifex_api-usage) * [Testing](#testing) - + @@ -58,67 +58,118 @@ pip install -e . # If you want to work on the SDK's source code ### Setting up -In order to use `jsonapi`, you need to create a `JsonApi` object that -represents a connection with the API server: +Using `jsonapi` means creating your own API SDK for a remote service. In order +to do that, you need to first define an _API connection type_. This is done by +subclassing `jsonapi.JsonApi`: ```python import jsonapi -_api = jsonapi.JsonApi(host="https://api.someservice.com", auth="") -``` -#### Registering Resource subclasses +class FamilyApi(jsonapi.JsonApi): + HOST = "https://api.families.com" +``` -Resource subclasses must be registered to that API instance with: +Next, you have to define some _API resource types_ and register them to the +_API connection type_. This is done by subclassing `jsonapi.Resource` and +decorating it with the connection type's `register` method: ```python -@_api.register +@FamilyApi.register class Parent(jsonapi.Resource): - TYPE = "parents" + TYPE = "parents" -@_api.register +@FamilyApi.register class Child(jsonapi.Resource): - TYPE = "children" + TYPE = "children" +``` + +Users of your SDK can then instantiate your _API connection type_, providing +authentication credentials and/or overriding the host, in case you want to test +against a sandbox API server and not the production one: + +```python +family_api = FamilyApi(host="https://sandbox.api.families.com", + auth="") +``` + +Finally the API resource types you have registered can be accessed as +attributes on this _API connection instance_. You can either use the class's +name or the API resource's type: + +```python +child = family_api.Child.get('1') +child = family_api.children.get('1') ``` This is enough to get you started since the library will be able to provide you with a lot of functionality based on the structure of the responses you get from the server. Make sure you define and register Resource subclasses for every type you intend to encounter, because `jsonapi` will use the API -instance's registry to resolve the appropriate subclass the items included in -the API's responses. +instance's registry to resolve the appropriate subclass for the items included +in the API's responses. + +#### Global _API connection instances_ + +You can configure an already created _API connection instance_ by calling the +`setup` method, which accepts the same keyword arguments as the constructor. In +fact, `JsonApi`'s `__init__` and `setup` methods have been written in such a +way that the following two snippets should produce an identical outcome: + +```python +kwargs = ... +family_api = FamilyApi(**kwargs) +``` -#### Customizing setup configuration +```python +kwargs = ... +family_api = FamilyApi() +family_api.setup(**kwargs) +``` -The arguments to `JsonApi` are optional. You can add or edit them later using -the `.setup` method (which accepts the same arguments). This way, you can -implement an interface to a server as a library and offer the option to users -to set their authentication method and/or host: +This way, you can implement your SDK in a way that offers the option to users +to either use a _global API connection instance_ or multiple instances. In +fact, this is exactly how `transifex_api` has been set up: ```python # src/transifex_api/__init__.py import jsonapi -_api = jsonapi.JsonApi(host="https://rest.api.transifex.com") +class TransifexApi(jsonapi.JsonApi): + HOST = "https://rest.api.transifex.com" -@_api.register +@TransifexApi.register class Organization(jsonapi.Resource): TYPE = "organizations" -def setup(auth): - _api.setup(auth=auth) +transifex_api = TransifexApi() ``` ```python -# app.py +# app.py (uses the global API connection instance) -import transifex_api +from transifex_api import transifex_api -transifex_api.setup("") +transifex_api.setup(auth="") organization = transifex_api.Organization.get("1") -... + +``` + +```python +# app.py (uses multiple custom API connection instances) + +from transifex_api import TransifexApi + +api_1 = TransifexApi(auth="") +api_2 = TransifexApi(auth="") + +organization_1 = api_1.Organization.get("1") +organization_2 = api_2.Organization.get("2") ``` +_(The whole logic behind this initialization process is further explained +[here](https://www.kbairak.net/programming/python/2020/09/16/global-singleton-vs-instance-for-libraries.html))_ + #### Authentication The `auth` argument to `JsonApi` or `setup` can either be: @@ -131,13 +182,15 @@ The `auth` argument to `JsonApi` or `setup` can either be: ```python import datetime import jsonapi + + from family_api import FamilyApi from .secrets import KEY from .crypto import sign def myauth(): return {'x-signature': sign(KEY, datetime.datetime.now())} - _api = jsonapi.JsonApi(host="https://my.api.com", auth=myauth) + family_api = FamilyApi(auth=myauth) ``` #### Custom headers @@ -147,8 +200,8 @@ server using the `headers` keyword argument to the `JsonApi` constructor or the `setup` method. ```python -import jsonapi -_api = jsonapi.JsonApi(..., headers={'X-Application': "My-client"}) +from family_api import FamilyApi +family_api = FamilyApi(..., headers={'X-Application': "My-client"}) ``` ### Retrieval @@ -162,8 +215,8 @@ override the `get_collection_url` classmethod and the `get_item_url()` method of the resource's subclass: ```python -@_api.register -class Children(jsonapi.Resource): +@FamilyApi.register +class Child(jsonapi.Resource): TYPE = "children" @classmethod @@ -181,7 +234,7 @@ If you know the ID of the resource object, you can fetch its {json:api} representation with: ```python -child = Child.get("1") +child = family_api.Child.get("1") ``` The attributes of a resource object are `id`, `attributes`, `relationships`, @@ -189,7 +242,7 @@ The attributes of a resource object are `id`, `attributes`, `relationships`, exactly the same value as in the API response. ```python -parent = Parent.get("1") +parent = family_api.Parent.get("1") parent.id # "1" parent.attributes @@ -198,7 +251,7 @@ parent.relationships # {'children': {'links': {'self': "/parent/1/relationships/children", # 'related': "/children?filter[parent]=1"}}} -child = Child.get("1") +child = family_api.Child.get("1") child.id # "1" child.attributes @@ -214,7 +267,7 @@ You can reload an object from the server by calling `.reload()`: ```python child.reload() # equivalent to -child = Child.get(child.id) +child = family_api.Child.get(child.id) ``` #### Relationships @@ -295,14 +348,14 @@ these have been fetched from the API. Lets revisit the last example and inspect the `relationships` and `related` fields: ```python -parent = Parent.get("1") +parent = family_api.Parent.get("1") parent.relationships # {'children': {'links': {'self': "/parent/1/relationships/children", # 'related': "/children?filter[parent]=1"}}} parent.related # {} -child = Child.get("1") +child = family_api.Child.get("1") child.relationships # {'parent': {'data': {'type': "parents", 'id': "1"}, # 'links': {'self': "/children/1/relationships/parent", @@ -360,7 +413,7 @@ If `.fetch()` is only provided with one positional argument, it will return the relation: ```python -parent = Parent.get("1") +parent = family_api.Parent.get("1") print(parent.fetch('children')[1].name) # "Hercules" @@ -424,7 +477,7 @@ You can access a collection of resource objects using one of the `list`, classmethods of Resource subclass. ```python -children = Child.list() +children = family_api.Child.list() # [, , ...] ``` @@ -444,11 +497,11 @@ Each method does the following: of a filter operation will result in using its `id` field_ ```python - parent = Parent.get("1") + parent = family_api.Parent.get("1") - Child.filter(parent=parent) + family_api.Child.filter(parent=parent) # is equivalent to - Child.filter(parent=parent.id) + family_api.Child.filter(parent=parent.id) ``` - `page` applies pagination; it accepts either one positional argument which @@ -502,17 +555,17 @@ Each method does the following: All the above methods can be chained to each other. So: ```python -Child.list().filter(a=1) +family_api.Child.list().filter(a=1) # is equivalent to -Child.filter(a=1) +family_api.Child.filter(a=1) -Child.filter(a=1).filter(b=2) +family_api.Child.filter(a=1).filter(b=2) # is equivalent to -Child.filter(a=1, b=2) +family_api.Child.filter(a=1, b=2) -Child.list().all() +family_api.Child.list().all() # is equivalent to -Child.all() +family_api.Child.all() ``` The collections are also lazy (Django-style). You will not actually make any @@ -521,7 +574,7 @@ this: ```python def get_children(gender=None, hair_color=None): - result = Child.list() + result = family_api.Child.list() if gender is not None: result = result.filter(gender=gender) if hair_color is not None: @@ -560,11 +613,11 @@ included values of the response will be used to prefill the relevant fields of `related`: ```python -child = Child.get("1", include=['parent']) +child = family_api.Child.get("1", include=['parent']) child.parent.name # No need to fetch the parent # "Zeus" -children = Child.list().include('parent') +children = family_api.Child.list().include('parent') [child.parent.name for child in children] # No need to fetch the parents # ["Zeus", "Zeus", ...] ``` @@ -574,7 +627,7 @@ supplies the related items in the `included` section, these too will be prefilled. ```python -parent = Parent.get("1", include=['children']) +parent = family_api.Parent.get("1", include=['children']) # Assuming the response looks like: # {'data': {'type': "parents", @@ -602,7 +655,7 @@ it will raise a `jsonapi.DoesNotExist` or `jsonapi.MultipleObjectsReturned` exception accordingly (both are subclasses of `jsonapi.NotSingleItem`). ```python -child = Child.filter(name="Bill").get() +child = family_api.Child.filter(name="Bill").get() ``` The `Resource`'s `.get()` classmethod, which we covered before, also accepts @@ -611,9 +664,9 @@ way, will apply the filters and use the collection's `.get()` method on the result. ```python -child = Child.get(name="Bill") +child = family_api.Child.get(name="Bill") # is equivalent to -child = Child.filter(name="Bill").get() +child = family_api.Child.filter(name="Bill").get() ``` _Note: The `Resource`'s `.get()` classmethod accepts an `include` keyword @@ -622,12 +675,12 @@ called 'include'_ ```python # Don't do this -Child.get(name="Bill", include="parent") +family_api.Child.get(name="Bill", include="parent") # equivalent to -Child.filter(name="Bill").include('parent').get() +family_api.Child.filter(name="Bill").include('parent').get() # Do this instead -child = Child.filter(name="Bill", include="parent").get() +child = family_api.Child.filter(name="Bill", include="parent").get() ``` ### Editing @@ -644,17 +697,18 @@ can specify which fields will be sent with: - the `EDITABLE` class attribute of the Resource subclass ```python -child = Child.get("1") +child = family_api.Child.get("1") child.name += " the Great" child.save('name') # or +@FamilyApi.register class Child(Resource): TYPE = "children" EDITABLE = ['name'] -child = Child.get("1") +child = family_api.Child.get("1") child.name += " the Great" child.save() ``` @@ -676,8 +730,8 @@ Calling `.save()` on an object whose `id` is not set will result in a POST request which will (attempt to) create the resource on the server. ```python -parent = Parent.get("1") -child = Child(attributes={'name': "Hercules"}, +parent = family_api.Parent.get("1") +child = family_api.Child(attributes={'name': "Hercules"}, relationships={'parent': parent}) child.save() ``` @@ -689,8 +743,8 @@ timestamps). There is a shortcut for the above, called `.create()` ```python -parent = Parent.get("1") -child = Child.create(attributes={'name': "Hercules"}, +parent = family_api.Parent.get("1") +child = family_api.Child.create(attributes={'name': "Hercules"}, relationships={'parent': parent}) ``` @@ -701,14 +755,14 @@ relationship from another resource. So, the following are equivalent:_ ```python # Well, almost equivalent, the first example will trigger a request to fetch # the parent's data from the server -child = Child.create(attributes={'name': "Hercules"}, - relationships={'parent': Parent.get("1")}) -child = Child.create(attributes={'name': "Hercules"}, - relationships={'parent': Parent(id="1")}) -child = Child.create(attributes={'name': "Hercules"}, - relationships={'parent': {'type': "parents": 'id': "1"}}) -child = Child.create(attributes={'name': "Hercules"}, - relationships={'parent': {'data': {'type': "parents": 'id': "1"}}}) +child = family_api.Child.create(attributes={'name': "Hercules"}, + relationships={'parent': family_api.Parent.get("1")}) +child = family_api.Child.create(attributes={'name': "Hercules"}, + relationships={'parent': family_api.Parent(id="1")}) +child = family_api.Child.create(attributes={'name': "Hercules"}, + relationships={'parent': {'type': "parents": 'id': "1"}}) +child = family_api.Child.create(attributes={'name': "Hercules"}, + relationships={'parent': {'data': {'type': "parents": 'id': "1"}}}) ``` @@ -716,8 +770,8 @@ This way, you can reuse a relationship from another object when creating, without having to fetch the relationship: ```python -new_child = Child.create(attributes={'name': "Achilles"}, - relationships={'parent': old_child.parent}) +new_child = family_api.Child.create(attributes={'name': "Achilles"}, + relationships={'parent': old_child.parent}) ``` ##### Magic kwargs @@ -738,29 +792,29 @@ Things that are interpreted as relationships are: So ```python -Child(name="Hercules") +family_api.Child(name="Hercules") # is equivalent to -Child(attributes={'name': "Hercules"}) +family_api.Child(attributes={'name': "Hercules"}) -Child(parent={'type': "parents", 'id': "1"}) +family_api.Child(parent={'type': "parents", 'id': "1"}) # is equivalent to -Child(relationships={'parent': {'type': "parents", 'id': "1"}}) +family_api.Child(relationships={'parent': {'type': "parents", 'id': "1"}}) -Child(parent=Parent(id="1")) +family_api.Child(parent=family_api.Parent(id="1")) # is equivalent to -Child(relationships={'parent': Parent(id="1")}) +family_api.Child(relationships={'parent': family_api.Parent(id="1")}) ``` If you are worried about naming conflicts, for example if you want to have a relationship called 'attributes', an attribute that looks like a relationship -and an attribute called 'id', you should back to using 'attributes' and +and an attribute called 'id', you should fall back to using 'attributes' and 'relationships' directly. ```python # Don't do this -child = Child(attributes={'type': "attributes", 'id': "1"}, - stats={'type': "stats", 'id': "2"}, - id="3") +child = family_api.Child(attributes={'type': "attributes", 'id': "1"}, + stats={'type': "stats", 'id': "2"}, + id="3") child.to_dict() # {'type': "children", # 'attributes': {'type': "attributes", 'id': "1"}, @@ -768,9 +822,8 @@ child.to_dict() # 'id': "3"} # Do this instead -child = Child(relationships={'attributes': {'type': "attributes", 'id': "1"}}, - attributes={'stats': {'type': "stats", 'id': "2"}, - 'id': "3"}) +child = family_api.Child(relationships={'attributes': {'type': "attributes", 'id': "1"}} + attributes={'stats': {'type': "stats", 'id': "2"}, 'id': "3"}) child.to_dict() # {'type': "children", # 'attributes': {'stats': {'type': "stats", 'id': "2"}, @@ -789,16 +842,16 @@ ID, if you want to supply your own client-generated ID during creation, you **have** to use `.create()`, which will always issue a POST request. ```python -Child(attributes={'name': "Hercules"}).save() +family_api.Child(attributes={'name': "Hercules"}).save() # POST: {data: {type: "children", attributes: {name: "Hercules"}}} -Child(id="1", attributes={'name': "Hercules"}).save() +family_api.Child(id="1", attributes={'name': "Hercules"}).save() # PATCH: {data: {type: "children", id: "1", attributes: {name: "Hercules"}}} -Child.create(attributes={'name': "Hercules"}) +family_api.Child.create(attributes={'name': "Hercules"}) # POST: {data: {type: "children", attributes: {name: "Hercules"}}} -Child.create(id="1", attributes={'name': "Hercules"}) +family_api.Child.create(id="1", attributes={'name': "Hercules"}) # POST: {data: {type: "children", id: "1", attributes: {name: "Hercules"}}} # ^^^^ ``` @@ -812,7 +865,7 @@ the object will have the same data as before, except its `id` will be set to re-create it, with a different ID. ```python -child = Child.get("1") +child = family_api.Child.get("1") child.delete() # Will create a new child with the same name and parent as the previous one @@ -830,7 +883,7 @@ Changing a singular relationship can happen in two ways (this also depends on what the server supports). ```python -child = Child.get("1") +child = family_api.Child.get("1") child.parent = new_parent child.save('parent') @@ -873,13 +926,13 @@ want to be safe, you should use the `.set_related()` method to edit relationships: ```python -child.set_related('parent', Parent(id="2")) +child.set_related('parent', family_api.Parent(id="2")) ``` or use the relationship's name shortcut: ```python -child.parent = Parent(id="2") +child.parent = family_api.Parent(id="2") ``` (the shortcut uses `.set_related()` during assignment internally anyway) @@ -890,7 +943,7 @@ For changing plural relationships, you can use one of the `add`, `remove` and `reset` methods: ```python -parent = Parent.get("1") +parent = family_api.Parent.get("1") parent.add('children', [new_child, ...]) parent.remove('children', [existing_child, ...]) parent.reset('children', [child_a, child_b, ...]) @@ -912,8 +965,8 @@ values passed to the above methods can either be resource objects, "resource identifiers" or entire relationship objects: ```python -parent.add('children', [Child.get("1"), - Child(id="2"), +parent.add('children', [family_api.Child.get("1"), + family_api.Child(id="2"), {'type': "children", 'id': "3"}, {'data': {'type': "children", 'id': "4"}}]) ``` @@ -921,8 +974,8 @@ parent.add('children', [Child.get("1"), This way, you can easily use another object's plural relationship: ```python -parent_a = Parent.get('1') -parent_b = Parent.get('2') +parent_a = family_api.Parent.get('1') +parent_b = family_api.Parent.get('2') # Make sure 'parent_b' has the same children as 'parent_a' parent_b.reset('children', list(parent_a.fetch('children').all())) @@ -940,28 +993,29 @@ Furthermore, `bulk_update` accepts a `fields` keyword argument with the ```python # Bulk-create -Child.bulk_create([Child(attributes={'name': "One"}, - relationships={'parent': parent}), - {'attributes': {'name': "Two"}, - 'relationships': {'parent': parent}}, - ({'name': "Three"}, {'parent': parent})]) +family_api.Child.bulk_create([ + family_api.Child(attributes={'name': "One"}, relationships={'parent': parent}), + {'attributes': {'name': "Two"}, 'relationships': {'parent': parent}}, + ({'name': "Three"}, {'parent': parent}), +]) # Bulk-update -child_a = Child.get("a") +child_a = family_api.Child.get("a") child_a.married = True -Child.bulk_update([child_a, - {'id': "b", 'attributes': {'married': True}}, - ("c", {'married': True}), - "d"], - fields=['married']) +family_api.Child.bulk_update( + [child_a, + {'id': "b", 'attributes': {'married': True}}, + ("c", {'married': True}), "d"], + fields=['married'], +) # Bulk delete -child_a = Child.get("a") -Child.bulk_delete([child_a, {'id': "b"}, "c"]) +child_a = family_api.Child.get("a") +family_api.Child.bulk_delete([child_a, {'id': "b"}, "c"]) -parent = Parent.get("1") -Child.delete(list(parent.children.all())) +parent = family_api.Parent.get("1") +family_api.Child.delete(list(parent.children.all())) ``` For more details, see our @@ -988,16 +1042,23 @@ Given these two mechanisms, here is how you might go about performing a in Transifex API: ```python +@TransifexApi.register class TxResource(Resource) TYPE = "resources" + +@TransifexApi.register class ResourceStringsAsyncUpload(Resource) TYPE = "resource_strings_async_uploads" + +@TransifexApi.register class ResourceString(Resource) TYPE = "resource_strings" -resource = TxResource.get(...) +transifex_api = TransifexApi(...) + +resource = transifex_api.TxResource.get(...) with open(...) as f: - upload = ResourceStringsAsyncUpload.create_with_form( + upload = transifex_api.ResourceStringsAsyncUpload.create_with_form( data={'resource': resource.id}, files={'content': f}, ) @@ -1024,10 +1085,10 @@ Sample usage: ```python import os -import transifex_api +from transifex_api import transifex_api # There is a default host for transifex -transifex_api.setup(os.environ['API_TOKEN']) +transifex_api.setup(auth=os.environ['API_TOKEN']) organizations = {organization.slug: organization for organization in transifex_api.Organization.all()} @@ -1041,7 +1102,7 @@ languages = {language.code: language for language in project.fetch('languages').all()} language = languages['el'] -translations = ResourceTranslation.\ +translations = transifex_api.ResourceTranslation.\ filter(resource=resource, language=language).\ include('resource_string') translation = translations[0] From 557a5d0ab5bb22cd8f1ad3fdff4b0a8b0411959f Mon Sep 17 00:00:00 2001 From: Konstantinos Bairaktaris Date: Thu, 4 Feb 2021 15:21:23 +0200 Subject: [PATCH 4/6] Fix annoying heizentest --- src/jsonapi/resources.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jsonapi/resources.py b/src/jsonapi/resources.py index 42dc4b1..8cb2988 100644 --- a/src/jsonapi/resources.py +++ b/src/jsonapi/resources.py @@ -139,6 +139,7 @@ def _set_relationship(self, key, value): elif is_resource(value): self.relationships[key] = value.as_relationship() else: + value = deepcopy(value) if not is_null(value) and is_resource_identifier(value): value = {'data': value} if is_null(value) or has_data(value) or has_links(value): From 36410e8e010aad11331777a5eab3089a1612de83 Mon Sep 17 00:00:00 2001 From: Konstantinos Bairaktaris Date: Thu, 4 Feb 2021 15:23:00 +0200 Subject: [PATCH 5/6] Add Python 3.9 to tox.ini --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index be4d39a..0d3131f 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py27, py36, py37, py38, pypy2, pypy3 +envlist = py27, py36, py37, py38, py39, pypy2, pypy3 [testenv] deps = From e727a561878397dfe75534915e6cfea48b5f09a0 Mon Sep 17 00:00:00 2001 From: Konstantinos Bairaktaris Date: Thu, 4 Feb 2021 15:39:34 +0200 Subject: [PATCH 6/6] Improve docstrings and comments --- src/jsonapi/apis.py | 85 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/src/jsonapi/apis.py b/src/jsonapi/apis.py index 47165b9..ce65ef1 100644 --- a/src/jsonapi/apis.py +++ b/src/jsonapi/apis.py @@ -9,7 +9,47 @@ from .resources import Resource -class _JsonApiMetaclass(type): +type_ = type # alias to avoid naming conflicts + + +class _JsonApiMetaclass(type_): + """ Simple metaclass that overwrites the `registry` class variable on + `JsonApi` subclasses. This is so that if multiple API connection types + are defined on the same application, one won't "contaminate" the other. + + eg + + >>> class API_A(jsonapi.JsonApi): + ... HOST = "..." + + >>> @API_A.register + ... class ResourceA(jsonapi.Resource): + ... TYPE = "resource_a" + + >>> class API_AA(API_A): + ... HOST = "..." + + >>> @API_AA.register + ... class ResourceAA(jsonapi.Resource): + ... TYPE = "resource_aa" + + >>> class API_B(jsonapi.JsonApi): + ... HOST = "..." + + >>> @API_B.register + ... class ResourceB(jsonapi.Resource): + ... TYPE = "resource_b" + + >>> API_A.registry + <<< [ResourceA] + + >>> API_AA.registry + <<< [ResourceA, ResourceAA] + + >>> API_B.registry + <<< [ResourceB] + """ + def __new__(cls, *args, **kwargs): result = super(_JsonApiMetaclass, cls).__new__(cls, *args, **kwargs) @@ -20,9 +60,12 @@ def __new__(cls, *args, **kwargs): class JsonApi(six.with_metaclass(_JsonApiMetaclass, object)): - """ Inteface for a new {json:api} API. + """ Inteface for a new {json:api} API connection. Initialization + parameters: - host: The URL of the API + - headers: A dict of HTTP headers that will be included in every + request to the server - auth: The authentication method. Can either be: 1. A callable, whose return value should be a dictionary which will @@ -30,17 +73,19 @@ class JsonApi(six.with_metaclass(_JsonApiMetaclass, object)): 2. A string, in which case the 'Authorization' header will be `Bearer ` - >>> _api = jsonapi.JsonApi(host=..., auth=...) + >>> class API(jsonapi.JsonApi): + ... HOST = "..." + >>> api = API(host=..., auth=...) The arguments are optional and can be edited later with `.setup()` - >>> _api = jsonapi.JsonApi() - >>> _api.setup(host=..., auth=...) + >>> api = API() + >>> api.setup(host=..., auth=...) All Resource classes that use this API should be registered to this API - instance: + class: - >>> @_api.register + >>> @API.register ... class Foo(jsonapi.Resource): ... TYPE = "foos" """ @@ -48,15 +93,23 @@ class JsonApi(six.with_metaclass(_JsonApiMetaclass, object)): HOST = None def __init__(self, **kwargs): + """ Create a new API connection instance. It will use the class's + registry to build the instance's registries in order to be able to + lookup API resource classes from their class names or API types. + + Delegates configuration to `setup` method. + """ + self.type_registry = {} self.class_registry = {} for base_class in self.__class__.registry: # Dynamically create a subclass adding 'self' (the API connection # instance) as a class variable to it - child_class = type(base_class.__name__, - (base_class, ), - {'API': self}) + child_class = type_(base_class.__name__, + (base_class, ), + {'API': self}) + # Lookup the new class by it's name or its TYPE class attribute self.type_registry[base_class.TYPE] = child_class self.class_registry[base_class.__name__] = child_class @@ -79,6 +132,13 @@ def setup(self, host=None, auth=None, headers=None): @classmethod def register(cls, klass): + """ Register a API resource type with this API connection *type* (since + this is a classmethod). When a new API connection *instance* is + created (see `__init__`), it will use this to build its own + registry in order to identify class names or API types with the + relevant API resource classes. + """ + cls.registry.append(klass) return klass @@ -179,8 +239,9 @@ def new(self, data=None, type=None, **kwargs): klass = self.type_registry[type] else: # Lets make a new class on the fly - class klass(Resource): - API = self + klass = type_(type.capitalize(), + (Resource, ), + {'API': self, 'TYPE': type}) return klass(**kwargs) def as_resource(self, data):