Skip to content

Commit cbc3a04

Browse files
committed
Implement instantiated services option.
This introduces an option to enable instantiated services while maintaining backwards compatibility with the singleton pattern. It also allows passing caller arguments into the `Endpoint` declaration. Resolve #119. While I think the singleton pattern should be deprecated, put under a feature flag, and discouraged, the first step is probably to give instantiated services some field experience as an optional feature.
1 parent 7d1d059 commit cbc3a04

File tree

8 files changed

+187
-72
lines changed

8 files changed

+187
-72
lines changed

docs/advanced-usage.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,40 @@ This is useful for workflows where cookies or other information need to persist
113113
It's often more useful in logs to know which module initiated the code doing the logging.
114114
``apiron`` allows for an existing logger object to be passed to an endpoint call using the ``logger`` argument
115115
so that logs will indicate the caller module rather than :mod:`apiron.client`.
116+
117+
118+
**********************
119+
Instantiated endpoints
120+
**********************
121+
122+
While the other documented usage patterns implement the singleton pattern, you may wish to use instantiated
123+
services for reasons such as those mentioned `in this issue <https://github.com/ithaka/apiron/issues/119>`_.
124+
125+
This feature can be enabled by setting ``APIRON_INSTANTIATED_SERVICES=1`` either in the shell in which your
126+
program runs or early in the entrypoint to your program, prior to the evaluation of your service classes.
127+
128+
Endpoints should then be called on instances of ``Service`` subclasses, rather than the class itself:
129+
130+
As an additional benefit, arguments passed into the constructor will be passed through to the endpoint as arguments
131+
to the caller that forms the request. See ``aprion.client.call`` for all the available options.
132+
133+
.. code-block:: python
134+
135+
import os
136+
137+
import requests
138+
139+
from apiron import JsonEndpoint, Service
140+
141+
os.environ['APIRON_INSTANTIATED_SERVICES'] = "1"
142+
143+
144+
class GitHub(Service):
145+
domain = 'https://api.github.com'
146+
user = JsonEndpoint(path='/users/{username}')
147+
repo = JsonEndpoint(path='/repos/{org}/{repo}')
148+
149+
150+
service = GitHub(session=requests.Session())
151+
response = service.user(username='defunkt')
152+
print(response)

src/apiron/endpoint/endpoint.py

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,8 @@
22

33
import logging
44
import string
5-
import sys
65
import warnings
7-
from functools import partial, update_wrapper
8-
from typing import Optional, Any, Callable, Dict, Iterable, List, TypeVar, Union, TYPE_CHECKING
9-
10-
if TYPE_CHECKING:
11-
if sys.version_info >= (3, 10):
12-
from typing import Concatenate, ParamSpec
13-
else:
14-
from typing_extensions import Concatenate, ParamSpec
15-
16-
from apiron.service import Service
17-
18-
P = ParamSpec("P")
19-
R = TypeVar("R")
6+
from typing import Optional, Iterable, Any, Dict, List, Union
207

218
import requests
229

@@ -27,26 +14,13 @@
2714
LOGGER = logging.getLogger(__name__)
2815

2916

30-
def _create_caller(
31-
call_fn: Callable["Concatenate[Service, Endpoint, P]", "R"],
32-
instance: Any,
33-
owner: Any,
34-
) -> Callable["P", "R"]:
35-
return partial(call_fn, instance, owner)
36-
37-
3817
class Endpoint:
3918
"""
4019
A basic service endpoint that responds with the default ``Content-Type`` for that endpoint
4120
"""
4221

43-
def __get__(self, instance, owner):
44-
caller = _create_caller(client.call, owner, self)
45-
update_wrapper(caller, client.call)
46-
return caller
47-
48-
def __call__(self):
49-
raise TypeError("Endpoints are only callable in conjunction with a Service class.")
22+
def __call__(self, service, *args, **kwargs):
23+
return client.call(service, self, *args, **{**self.kwargs, **service._kwargs, **kwargs})
5024

5125
def __init__(
5226
self,
@@ -55,6 +29,7 @@ def __init__(
5529
default_params: Optional[Dict[str, Any]] = None,
5630
required_params: Optional[Iterable[str]] = None,
5731
return_raw_response_object: bool = False,
32+
**kwargs,
5833
):
5934
"""
6035
:param str path:
@@ -72,8 +47,11 @@ def __init__(
7247
Whether to return a :class:`requests.Response` object or call :func:`format_response` on it first.
7348
This can be overridden when calling the endpoint.
7449
(Default ``False``)
50+
:param kwargs:
51+
Default arguments to pass through to `apiron.client.call`.
7552
"""
7653
self.default_method = default_method
54+
self.kwargs = kwargs
7755

7856
if "?" in path:
7957
warnings.warn(

src/apiron/endpoint/stub.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ class StubEndpoint(Endpoint):
1111
before the endpoint is complete.
1212
"""
1313

14-
def __get__(self, instance, owner):
15-
return self.stub_response
14+
def __call__(self, service, *args, **kwargs):
15+
return self.stub_response(*args, **kwargs)
1616

1717
def __init__(self, stub_response: Optional[Any] = None, **kwargs):
1818
"""

src/apiron/service/base.py

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
import types
13
from typing import Any, Dict, List, Set
24

35
from apiron import Endpoint
@@ -8,24 +10,70 @@ class ServiceMeta(type):
810
def required_headers(cls) -> Dict[str, str]:
911
return cls().required_headers
1012

11-
@property
12-
def endpoints(cls) -> Set[Endpoint]:
13-
return {attr for attr_name, attr in cls.__dict__.items() if isinstance(attr, Endpoint)}
13+
@classmethod
14+
def _instantiated_services(cls) -> bool:
15+
setting_variable = "APIRON_INSTANTIATED_SERVICES"
16+
false_values = ["0", "false"]
17+
true_values = ["1", "true"]
18+
environment_setting = os.getenv(setting_variable, "false").lower()
19+
if environment_setting in false_values:
20+
return False
21+
elif environment_setting in true_values:
22+
return True
23+
24+
setting_values = false_values + true_values
25+
raise ValueError(
26+
f'Invalid {setting_variable}, "{environment_setting}"\n',
27+
f"{setting_variable} must be one of {setting_values}\n",
28+
)
1429

1530
def __str__(cls) -> str:
1631
return str(cls())
1732

1833
def __repr__(cls) -> str:
1934
return repr(cls())
2035

36+
def __new__(cls, name, bases, namespace, **kwargs):
37+
klass = super().__new__(cls, name, bases, namespace, **kwargs)
38+
39+
# Behave as a normal class if instantiated services are enabled or if
40+
# this is an apiron base class.
41+
if cls._instantiated_services() or klass.__module__.split(".")[:2] == ["apiron", "service"]:
42+
return klass
43+
44+
# Singleton class.
45+
if not hasattr(klass, "_instance"):
46+
klass._instance = klass()
47+
48+
# Mask declared Endpoints with bound instance methods. (singleton)
49+
for k, v in namespace.items():
50+
if isinstance(v, Endpoint):
51+
setattr(klass, k, types.MethodType(v, klass._instance))
52+
53+
return klass._instance
54+
2155

2256
class ServiceBase(metaclass=ServiceMeta):
2357
required_headers: Dict[str, Any] = {}
2458
auth = ()
2559
proxies: Dict[str, str] = {}
2660

27-
@classmethod
28-
def get_hosts(cls) -> List[str]:
61+
def __setattr__(self, name, value):
62+
"""Transform assigned Endpoints into bound instance methods."""
63+
if isinstance(value, Endpoint):
64+
value = types.MethodType(value, self)
65+
super().__setattr__(name, value)
66+
67+
@property
68+
def endpoints(self) -> Set[Endpoint]:
69+
endpoints = set()
70+
for attr in self.__dict__.values():
71+
func = getattr(attr, "__func__", None)
72+
if isinstance(func, Endpoint):
73+
endpoints.add(func)
74+
return endpoints
75+
76+
def get_hosts(self) -> List[str]:
2977
"""
3078
The fully-qualified hostnames that correspond to this service.
3179
These are often determined by asking a load balancer or service discovery mechanism.
@@ -35,7 +83,7 @@ def get_hosts(cls) -> List[str]:
3583
:rtype:
3684
list
3785
"""
38-
return []
86+
return [self.domain]
3987

4088

4189
class Service(ServiceBase):
@@ -47,21 +95,21 @@ class Service(ServiceBase):
4795

4896
domain: str
4997

50-
@classmethod
51-
def get_hosts(cls) -> List[str]:
52-
"""
53-
The fully-qualified hostnames that correspond to this service.
54-
These are often determined by asking a load balancer or service discovery mechanism.
98+
@property
99+
def domain(self):
100+
return self._domain if self._domain else self.__class__.domain
55101

56-
:return:
57-
The hostname strings corresponding to this service
58-
:rtype:
59-
list
60-
"""
61-
return [cls.domain]
102+
def __init__(self, domain=None, **kwargs):
103+
self._domain = domain
104+
self._kwargs = kwargs
105+
106+
# Mask declared Endpoints with bound instance methods. (instantiated)
107+
for name, attr in self.__class__.__dict__.items():
108+
if isinstance(attr, Endpoint):
109+
setattr(self, name, types.MethodType(attr, self))
62110

63111
def __str__(self) -> str:
64-
return self.__class__.domain
112+
return self.domain
65113

66114
def __repr__(self) -> str:
67-
return f"{self.__class__.__name__}(domain={self.__class__.domain})"
115+
return f"{self.__class__.__name__}(domain={self.domain})"

tests/conftest.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import os
2+
3+
import pytest
4+
5+
import apiron
6+
7+
8+
def instantiated_service(returntype="instance"):
9+
os.environ["APIRON_INSTANTIATED_SERVICES"] = "1"
10+
11+
class SomeService(apiron.Service):
12+
pass
13+
14+
if returntype == "instance":
15+
return SomeService(domain="http://foo.com")
16+
elif returntype == "class":
17+
return SomeService
18+
19+
raise ValueError('Expected "returntype" value to be "instance" or "class".')
20+
21+
22+
def singleton_service():
23+
os.environ["APIRON_INSTANTIATED_SERVICES"] = "0"
24+
25+
class SomeService(apiron.Service):
26+
domain = "http://foo.com"
27+
28+
return SomeService
29+
30+
31+
@pytest.fixture(scope="function", params=["singleton", "instance"])
32+
def service(request):
33+
if request.param == "singleton":
34+
yield singleton_service()
35+
elif request.param == "instance":
36+
yield instantiated_service()
37+
else:
38+
raise ValueError(f'unknown service type "{request.param}"')

tests/service/test_base.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,7 @@
1-
import pytest
2-
3-
from apiron import Endpoint, Service, ServiceBase
4-
5-
6-
@pytest.fixture
7-
def service():
8-
class SomeService(Service):
9-
domain = "http://foo.com"
10-
11-
return SomeService
1+
from apiron import Endpoint
122

133

144
class TestServiceBase:
15-
def test_get_hosts_returns_empty_list_by_default(self):
16-
assert [] == ServiceBase.get_hosts()
17-
185
def test_required_headers_returns_empty_dict_by_default(self, service):
196
assert {} == service.required_headers
207

tests/service/test_instantiated.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import os
2+
3+
import pytest
4+
5+
from apiron.service.base import ServiceMeta
6+
7+
from .. import conftest
8+
9+
10+
class TestInstantiatedServices:
11+
@pytest.mark.parametrize("value,result", [("0", False), ("false", False), ("1", True), ("true", True)])
12+
def test_instantiated_services_variable_true(self, value, result):
13+
os.environ["APIRON_INSTANTIATED_SERVICES"] = value
14+
15+
assert ServiceMeta._instantiated_services() is result
16+
17+
@pytest.mark.parametrize("value", ["", "YES"])
18+
def test_instantiated_services_variable_other(self, value):
19+
os.environ["APIRON_INSTANTIATED_SERVICES"] = value
20+
21+
with pytest.raises(ValueError, match="Invalid"):
22+
ServiceMeta._instantiated_services()
23+
24+
def test_singleton_constructor_arguments(self):
25+
"""Singleton services do not accept arguments."""
26+
service = conftest.singleton_service()
27+
28+
with pytest.raises(TypeError, match="object is not callable"):
29+
service(foo="bar")
30+
31+
def test_instantiated_services_constructor_arguments(self):
32+
"""Instantiated services accept arguments."""
33+
service = conftest.instantiated_service(returntype="class")
34+
35+
service(foo="bar")

tests/test_endpoint.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,6 @@
66
import apiron
77

88

9-
@pytest.fixture
10-
def service():
11-
class SomeService(apiron.Service):
12-
domain = "http://foo.com"
13-
14-
return SomeService
15-
16-
179
@pytest.fixture
1810
def stub_function():
1911
def stub_response(**kwargs):

0 commit comments

Comments
 (0)