cs-service-api 20260531__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cs-service_api
|
|
3
|
+
Version: 20260531
|
|
4
|
+
Summary: ServiceAPI, a base class for APIs which talk to a service, typically a web service via HTTP.
|
|
5
|
+
Keywords: python3
|
|
6
|
+
Author-email: Cameron Simpson <cs@cskk.id.au>
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
15
|
+
Requires-Dist: cs.deco>=20260525
|
|
16
|
+
Requires-Dist: cs.fstags>=20260531
|
|
17
|
+
Requires-Dist: cs.logutils>=20250323
|
|
18
|
+
Requires-Dist: cs.pfx>=20250914
|
|
19
|
+
Requires-Dist: cs.resources>=20250915
|
|
20
|
+
Requires-Dist: cs.sqltags>=20260531
|
|
21
|
+
Requires-Dist: cs.tagset>=20260531
|
|
22
|
+
Requires-Dist: cs.upd>=20260526
|
|
23
|
+
Requires-Dist: icontract
|
|
24
|
+
Requires-Dist: requests
|
|
25
|
+
Project-URL: MonoRepo Commits, https://bitbucket.org/cameron_simpson/css/commits/branch/main
|
|
26
|
+
Project-URL: Monorepo Git Mirror, https://github.com/cameron-simpson/css
|
|
27
|
+
Project-URL: Monorepo Hg/Mercurial Mirror, https://hg.sr.ht/~cameron-simpson/css
|
|
28
|
+
Project-URL: Source, https://github.com/cameron-simpson/css/blob/main/lib/python/cs/service_api.py
|
|
29
|
+
|
|
30
|
+
ServiceAPI, a base class for APIs which talk to a service,
|
|
31
|
+
typically a web service via HTTP.
|
|
32
|
+
|
|
33
|
+
*Latest release 20260531*:
|
|
34
|
+
* HTTPServiceAPI: add __truediv__(suburl) which does a GET.
|
|
35
|
+
* HTTPServiceAPI: default .API_HOSTNAME comes from the subclass.
|
|
36
|
+
* HTTPServiceAPI.json: catch requests.exceptions.JSONDecodeError also.
|
|
37
|
+
* HTTPServiceAPI: new mode ("response", "json", "data", callable) parameter.
|
|
38
|
+
* HTTPServiceAPI: new check_json optional dict of expected fields and values in JSON responses.
|
|
39
|
+
|
|
40
|
+
An instance of a `ServiceAPI` embodies some basic features
|
|
41
|
+
that feel common to web based services:
|
|
42
|
+
- a notion of a login
|
|
43
|
+
- local state, an `SQLTags` for data about entities of the service
|
|
44
|
+
- downloads, if that is a thing, with `FSTags` for file annotations
|
|
45
|
+
|
|
46
|
+
Short summary:
|
|
47
|
+
* `HTTPServiceAPI`: `HTTPServiceAPI` base class for other APIs talking to HTTP services.
|
|
48
|
+
* `RequestsNoAuth`: This is a special purpose subclass of `requests.auth.AuthBase` to apply no authorisation at all. This is for services with their own special purpose authorisation and avoids things like automatic netrc based auth.
|
|
49
|
+
* `ServiceAPI`: `SewrviceAPI` base class for other APIs talking to services.
|
|
50
|
+
|
|
51
|
+
Module contents:
|
|
52
|
+
- <a name="HTTPServiceAPI"></a>`class HTTPServiceAPI(ServiceAPI)`: `HTTPServiceAPI` base class for other APIs talking to HTTP services.
|
|
53
|
+
|
|
54
|
+
Subclasses must define:
|
|
55
|
+
* `API_BASE`: the base URL of API calls.
|
|
56
|
+
For example, the `PlayOnAPI` defines this as `f'https://{API_HOSTNAME}/v3/'`.
|
|
57
|
+
|
|
58
|
+
*`HTTPServiceAPI.get(self, suburl, **kw) -> requests.models.Response`*:
|
|
59
|
+
Call `slef.suburl` with `method="GET"`.
|
|
60
|
+
|
|
61
|
+
*`HTTPServiceAPI.post(self, suburl, **kw) -> requests.models.Response`*:
|
|
62
|
+
Call `slef.suburl` with `method="POST"`.
|
|
63
|
+
|
|
64
|
+
*`HTTPServiceAPI.response_as_json(self, rsp: requests.models.Response) -> dict`*:
|
|
65
|
+
Return `rsp.json()`.
|
|
66
|
+
|
|
67
|
+
*`HTTPServiceAPI.response_as_json_data(self, rsp: requests.models.Response) -> dict`*:
|
|
68
|
+
Return the `"data"` element from a JSON response.
|
|
69
|
+
|
|
70
|
+
*`HTTPServiceAPI.suburl(self, suburl, *, base_url=None, method='GET', mode=None, check=True, cookies=None, headers=None, runstate: Optional[cs.resources.RunState] = <function uses_runstate.<locals>.<lambda> at 0x111844fe0>, verbose: bool, **rqkw) -> Union[requests.models.Response, dict]`*:
|
|
71
|
+
Request `suburl` from the service, by default using a `GET`.
|
|
72
|
+
The `suburl` must be a URL subpath not commencing with `'/'`.
|
|
73
|
+
|
|
74
|
+
Return:
|
|
75
|
+
- `mode(Response)` if `mode` is callable
|
|
76
|
+
- the `Response` if `mode=="response"`
|
|
77
|
+
- the `Response.json()` if `mode=="json"`
|
|
78
|
+
- the `Response.json()["data"]` if `mode=="data"`
|
|
79
|
+
|
|
80
|
+
Keyword parameters:
|
|
81
|
+
* `base_url`: the base request domain, default from `self.API_BASE`
|
|
82
|
+
* `method`: optional request method, default `'GET'`
|
|
83
|
+
* `check`: if true, raise an HTTP error if the response
|
|
84
|
+
status is not 200; default `True`
|
|
85
|
+
* `cookies`: optional cookie jar, default from `self.cookies`
|
|
86
|
+
* `mode`: optional result mode, default from `self.mode`
|
|
87
|
+
Other keyword parameters are passed to the requests method.
|
|
88
|
+
- <a name="RequestsNoAuth"></a>`class RequestsNoAuth(requests.auth.AuthBase)`: This is a special purpose subclass of `requests.auth.AuthBase`
|
|
89
|
+
to apply no authorisation at all.
|
|
90
|
+
This is for services with their own special purpose authorisation
|
|
91
|
+
and avoids things like automatic netrc based auth.
|
|
92
|
+
- <a name="ServiceAPI"></a>`class ServiceAPI(cs.resources.MultiOpenMixin, cs.sqltags.UsesSQLTags)`: `SewrviceAPI` base class for other APIs talking to services.
|
|
93
|
+
|
|
94
|
+
*`ServiceAPI.available(self) -> Set[cs.sqltags.SQLTagSet]`*:
|
|
95
|
+
Return a set of the `SQLTagSet` instances representing available
|
|
96
|
+
items at the service, for example purchased books
|
|
97
|
+
available to your login.
|
|
98
|
+
|
|
99
|
+
*`ServiceAPI.get_login_state(self, do_refresh=False) -> cs.tagset.HasTags`*:
|
|
100
|
+
The login state, a `HasTags`, stored as `login.state.`*login_userid*.
|
|
101
|
+
This performs a login if necessary or if `do_refresh` is true
|
|
102
|
+
(default `False`).
|
|
103
|
+
|
|
104
|
+
*`ServiceAPI.login(self) -> Mapping`*:
|
|
105
|
+
Do a login: authenticate to the service, return a mapping of related information.
|
|
106
|
+
|
|
107
|
+
Not all services require this and we expect such subclasses
|
|
108
|
+
to avoid use of login-based methods.
|
|
109
|
+
|
|
110
|
+
*`ServiceAPI.login_expiry`*:
|
|
111
|
+
Expiry UNIX time for the login state.
|
|
112
|
+
This implementation returns `None`.
|
|
113
|
+
|
|
114
|
+
*`ServiceAPI.startup_shutdown(self)`*:
|
|
115
|
+
Open/close the `FSTags` and `UsesTagSets`.
|
|
116
|
+
|
|
117
|
+
# Release Log
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
*Release 20260531*:
|
|
122
|
+
* HTTPServiceAPI: add __truediv__(suburl) which does a GET.
|
|
123
|
+
* HTTPServiceAPI: default .API_HOSTNAME comes from the subclass.
|
|
124
|
+
* HTTPServiceAPI.json: catch requests.exceptions.JSONDecodeError also.
|
|
125
|
+
* HTTPServiceAPI: new mode ("response", "json", "data", callable) parameter.
|
|
126
|
+
* HTTPServiceAPI: new check_json optional dict of expected fields and values in JSON responses.
|
|
127
|
+
|
|
128
|
+
*Release 20241007*:
|
|
129
|
+
HTTPServiceAPI.suburl: support interruption by RunState.cancel.
|
|
130
|
+
|
|
131
|
+
*Release 20240723*:
|
|
132
|
+
ServiceAPI: acquire the fstags automatically at init.
|
|
133
|
+
|
|
134
|
+
*Release 20230703*:
|
|
135
|
+
Retry logic for requests.
|
|
136
|
+
|
|
137
|
+
*Release 20230217*:
|
|
138
|
+
Initial release.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cs-service_api"
|
|
3
|
+
description = "ServiceAPI, a base class for APIs which talk to a service, typically a web service via HTTP."
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Cameron Simpson", email = "cs@cskk.id.au" },
|
|
6
|
+
]
|
|
7
|
+
keywords = [
|
|
8
|
+
"python3",
|
|
9
|
+
]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cs.deco>=20260525",
|
|
12
|
+
"cs.fstags>=20260531",
|
|
13
|
+
"cs.logutils>=20250323",
|
|
14
|
+
"cs.pfx>=20250914",
|
|
15
|
+
"cs.resources>=20250915",
|
|
16
|
+
"cs.sqltags>=20260531",
|
|
17
|
+
"cs.tagset>=20260531",
|
|
18
|
+
"cs.upd>=20260526",
|
|
19
|
+
"icontract",
|
|
20
|
+
"requests",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 3 - Alpha",
|
|
24
|
+
"Programming Language :: Python",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
30
|
+
]
|
|
31
|
+
version = "20260531"
|
|
32
|
+
|
|
33
|
+
[project.license]
|
|
34
|
+
text = "GNU General Public License v3 or later (GPLv3+)"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
"Monorepo Hg/Mercurial Mirror" = "https://hg.sr.ht/~cameron-simpson/css"
|
|
38
|
+
"Monorepo Git Mirror" = "https://github.com/cameron-simpson/css"
|
|
39
|
+
"MonoRepo Commits" = "https://bitbucket.org/cameron_simpson/css/commits/branch/main"
|
|
40
|
+
Source = "https://github.com/cameron-simpson/css/blob/main/lib/python/cs/service_api.py"
|
|
41
|
+
|
|
42
|
+
[project.readme]
|
|
43
|
+
text = """
|
|
44
|
+
ServiceAPI, a base class for APIs which talk to a service,
|
|
45
|
+
typically a web service via HTTP.
|
|
46
|
+
|
|
47
|
+
*Latest release 20260531*:
|
|
48
|
+
* HTTPServiceAPI: add __truediv__(suburl) which does a GET.
|
|
49
|
+
* HTTPServiceAPI: default .API_HOSTNAME comes from the subclass.
|
|
50
|
+
* HTTPServiceAPI.json: catch requests.exceptions.JSONDecodeError also.
|
|
51
|
+
* HTTPServiceAPI: new mode (\"response\", \"json\", \"data\", callable) parameter.
|
|
52
|
+
* HTTPServiceAPI: new check_json optional dict of expected fields and values in JSON responses.
|
|
53
|
+
|
|
54
|
+
An instance of a `ServiceAPI` embodies some basic features
|
|
55
|
+
that feel common to web based services:
|
|
56
|
+
- a notion of a login
|
|
57
|
+
- local state, an `SQLTags` for data about entities of the service
|
|
58
|
+
- downloads, if that is a thing, with `FSTags` for file annotations
|
|
59
|
+
|
|
60
|
+
Short summary:
|
|
61
|
+
* `HTTPServiceAPI`: `HTTPServiceAPI` base class for other APIs talking to HTTP services.
|
|
62
|
+
* `RequestsNoAuth`: This is a special purpose subclass of `requests.auth.AuthBase` to apply no authorisation at all. This is for services with their own special purpose authorisation and avoids things like automatic netrc based auth.
|
|
63
|
+
* `ServiceAPI`: `SewrviceAPI` base class for other APIs talking to services.
|
|
64
|
+
|
|
65
|
+
Module contents:
|
|
66
|
+
- <a name=\"HTTPServiceAPI\"></a>`class HTTPServiceAPI(ServiceAPI)`: `HTTPServiceAPI` base class for other APIs talking to HTTP services.
|
|
67
|
+
|
|
68
|
+
Subclasses must define:
|
|
69
|
+
* `API_BASE`: the base URL of API calls.
|
|
70
|
+
For example, the `PlayOnAPI` defines this as `f'https://{API_HOSTNAME}/v3/'`.
|
|
71
|
+
|
|
72
|
+
*`HTTPServiceAPI.get(self, suburl, **kw) -> requests.models.Response`*:
|
|
73
|
+
Call `slef.suburl` with `method=\"GET\"`.
|
|
74
|
+
|
|
75
|
+
*`HTTPServiceAPI.post(self, suburl, **kw) -> requests.models.Response`*:
|
|
76
|
+
Call `slef.suburl` with `method=\"POST\"`.
|
|
77
|
+
|
|
78
|
+
*`HTTPServiceAPI.response_as_json(self, rsp: requests.models.Response) -> dict`*:
|
|
79
|
+
Return `rsp.json()`.
|
|
80
|
+
|
|
81
|
+
*`HTTPServiceAPI.response_as_json_data(self, rsp: requests.models.Response) -> dict`*:
|
|
82
|
+
Return the `\"data\"` element from a JSON response.
|
|
83
|
+
|
|
84
|
+
*`HTTPServiceAPI.suburl(self, suburl, *, base_url=None, method='GET', mode=None, check=True, cookies=None, headers=None, runstate: Optional[cs.resources.RunState] = <function uses_runstate.<locals>.<lambda> at 0x111844fe0>, verbose: bool, **rqkw) -> Union[requests.models.Response, dict]`*:
|
|
85
|
+
Request `suburl` from the service, by default using a `GET`.
|
|
86
|
+
The `suburl` must be a URL subpath not commencing with `'/'`.
|
|
87
|
+
|
|
88
|
+
Return:
|
|
89
|
+
- `mode(Response)` if `mode` is callable
|
|
90
|
+
- the `Response` if `mode==\"response\"`
|
|
91
|
+
- the `Response.json()` if `mode==\"json\"`
|
|
92
|
+
- the `Response.json()[\"data\"]` if `mode==\"data\"`
|
|
93
|
+
|
|
94
|
+
Keyword parameters:
|
|
95
|
+
* `base_url`: the base request domain, default from `self.API_BASE`
|
|
96
|
+
* `method`: optional request method, default `'GET'`
|
|
97
|
+
* `check`: if true, raise an HTTP error if the response
|
|
98
|
+
status is not 200; default `True`
|
|
99
|
+
* `cookies`: optional cookie jar, default from `self.cookies`
|
|
100
|
+
* `mode`: optional result mode, default from `self.mode`
|
|
101
|
+
Other keyword parameters are passed to the requests method.
|
|
102
|
+
- <a name=\"RequestsNoAuth\"></a>`class RequestsNoAuth(requests.auth.AuthBase)`: This is a special purpose subclass of `requests.auth.AuthBase`
|
|
103
|
+
to apply no authorisation at all.
|
|
104
|
+
This is for services with their own special purpose authorisation
|
|
105
|
+
and avoids things like automatic netrc based auth.
|
|
106
|
+
- <a name=\"ServiceAPI\"></a>`class ServiceAPI(cs.resources.MultiOpenMixin, cs.sqltags.UsesSQLTags)`: `SewrviceAPI` base class for other APIs talking to services.
|
|
107
|
+
|
|
108
|
+
*`ServiceAPI.available(self) -> Set[cs.sqltags.SQLTagSet]`*:
|
|
109
|
+
Return a set of the `SQLTagSet` instances representing available
|
|
110
|
+
items at the service, for example purchased books
|
|
111
|
+
available to your login.
|
|
112
|
+
|
|
113
|
+
*`ServiceAPI.get_login_state(self, do_refresh=False) -> cs.tagset.HasTags`*:
|
|
114
|
+
The login state, a `HasTags`, stored as `login.state.`*login_userid*.
|
|
115
|
+
This performs a login if necessary or if `do_refresh` is true
|
|
116
|
+
(default `False`).
|
|
117
|
+
|
|
118
|
+
*`ServiceAPI.login(self) -> Mapping`*:
|
|
119
|
+
Do a login: authenticate to the service, return a mapping of related information.
|
|
120
|
+
|
|
121
|
+
Not all services require this and we expect such subclasses
|
|
122
|
+
to avoid use of login-based methods.
|
|
123
|
+
|
|
124
|
+
*`ServiceAPI.login_expiry`*:
|
|
125
|
+
Expiry UNIX time for the login state.
|
|
126
|
+
This implementation returns `None`.
|
|
127
|
+
|
|
128
|
+
*`ServiceAPI.startup_shutdown(self)`*:
|
|
129
|
+
Open/close the `FSTags` and `UsesTagSets`.
|
|
130
|
+
|
|
131
|
+
# Release Log
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
*Release 20260531*:
|
|
136
|
+
* HTTPServiceAPI: add __truediv__(suburl) which does a GET.
|
|
137
|
+
* HTTPServiceAPI: default .API_HOSTNAME comes from the subclass.
|
|
138
|
+
* HTTPServiceAPI.json: catch requests.exceptions.JSONDecodeError also.
|
|
139
|
+
* HTTPServiceAPI: new mode (\"response\", \"json\", \"data\", callable) parameter.
|
|
140
|
+
* HTTPServiceAPI: new check_json optional dict of expected fields and values in JSON responses.
|
|
141
|
+
|
|
142
|
+
*Release 20241007*:
|
|
143
|
+
HTTPServiceAPI.suburl: support interruption by RunState.cancel.
|
|
144
|
+
|
|
145
|
+
*Release 20240723*:
|
|
146
|
+
ServiceAPI: acquire the fstags automatically at init.
|
|
147
|
+
|
|
148
|
+
*Release 20230703*:
|
|
149
|
+
Retry logic for requests.
|
|
150
|
+
|
|
151
|
+
*Release 20230217*:
|
|
152
|
+
Initial release."""
|
|
153
|
+
content-type = "text/markdown"
|
|
154
|
+
|
|
155
|
+
[build-system]
|
|
156
|
+
build-backend = "flit_core.buildapi"
|
|
157
|
+
requires = [
|
|
158
|
+
"flit_core >=3.2,<4",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
[tool.flit.module]
|
|
162
|
+
name = "cs.service_api"
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
''' ServiceAPI, a base class for APIs which talk to a service,
|
|
4
|
+
typically a web service via HTTP.
|
|
5
|
+
|
|
6
|
+
An instance of a `ServiceAPI` embodies some basic features
|
|
7
|
+
that feel common to web based services:
|
|
8
|
+
- a notion of a login
|
|
9
|
+
- local state, an `SQLTags` for data about entities of the service
|
|
10
|
+
- downloads, if that is a thing, with `FSTags` for file annotations
|
|
11
|
+
'''
|
|
12
|
+
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from functools import cached_property
|
|
15
|
+
from json import JSONDecodeError
|
|
16
|
+
from threading import RLock
|
|
17
|
+
import time
|
|
18
|
+
from typing import Mapping, Set, Union
|
|
19
|
+
|
|
20
|
+
from icontract import require
|
|
21
|
+
import requests
|
|
22
|
+
from requests import Response
|
|
23
|
+
try:
|
|
24
|
+
from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError
|
|
25
|
+
except ImportError:
|
|
26
|
+
RequestsJSONDecodeError = JSONDecodeError
|
|
27
|
+
|
|
28
|
+
from cs.deco import uses_verbose
|
|
29
|
+
from cs.fstags import FSTags, uses_fstags
|
|
30
|
+
from cs.logutils import warning
|
|
31
|
+
from cs.pfx import pfx_call
|
|
32
|
+
from cs.resources import MultiOpenMixin, RunState, uses_runstate
|
|
33
|
+
from cs.tagset import HasTags, UsesTagSets
|
|
34
|
+
from cs.sqltags import SQLTagSet, UsesSQLTags
|
|
35
|
+
from cs.upd import run_task
|
|
36
|
+
|
|
37
|
+
__version__ = '20260531'
|
|
38
|
+
|
|
39
|
+
DISTINFO = {
|
|
40
|
+
'keywords': ["python3"],
|
|
41
|
+
'classifiers': [
|
|
42
|
+
"Development Status :: 3 - Alpha",
|
|
43
|
+
"Programming Language :: Python",
|
|
44
|
+
"Programming Language :: Python :: 3",
|
|
45
|
+
],
|
|
46
|
+
'install_requires': [
|
|
47
|
+
'cs.deco',
|
|
48
|
+
'cs.fstags',
|
|
49
|
+
'cs.logutils',
|
|
50
|
+
'cs.pfx',
|
|
51
|
+
'cs.resources',
|
|
52
|
+
'cs.tagset',
|
|
53
|
+
'cs.sqltags',
|
|
54
|
+
'cs.upd',
|
|
55
|
+
'icontract',
|
|
56
|
+
'requests',
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class ServiceAPI(MultiOpenMixin, UsesSQLTags):
|
|
61
|
+
''' `SewrviceAPI` base class for other APIs talking to services.
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
API_AUTH_GRACETIME = None
|
|
65
|
+
API_RETRY_COUNT = 3 # number of request attempts
|
|
66
|
+
API_RETRY_DELAY = 5 # interval between request retries
|
|
67
|
+
|
|
68
|
+
@uses_fstags
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
fstags: FSTags,
|
|
73
|
+
tagsets: UsesTagSets = None,
|
|
74
|
+
):
|
|
75
|
+
super().__init__(tagsets=tagsets)
|
|
76
|
+
self.fstags = fstags
|
|
77
|
+
self._lock = RLock()
|
|
78
|
+
self.login_state_mapping = None
|
|
79
|
+
|
|
80
|
+
@contextmanager
|
|
81
|
+
def startup_shutdown(self):
|
|
82
|
+
''' Open/close the `FSTags` and `UsesTagSets`.
|
|
83
|
+
'''
|
|
84
|
+
with self.tagsets:
|
|
85
|
+
with self.fstags:
|
|
86
|
+
yield
|
|
87
|
+
|
|
88
|
+
def login(self) -> Mapping:
|
|
89
|
+
''' Do a login: authenticate to the service, return a mapping of related information.
|
|
90
|
+
|
|
91
|
+
Not all services require this and we expect such subclasses
|
|
92
|
+
to avoid use of login-based methods.
|
|
93
|
+
'''
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def login_expiry(self):
|
|
98
|
+
''' Expiry UNIX time for the login state.
|
|
99
|
+
This implementation returns `None`.
|
|
100
|
+
'''
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def get_login_state(self, do_refresh=False) -> HasTags:
|
|
104
|
+
''' The login state, a `HasTags`, stored as `login.state.`*login_userid*.
|
|
105
|
+
This performs a login if necessary or if `do_refresh` is true
|
|
106
|
+
(default `False`).
|
|
107
|
+
'''
|
|
108
|
+
with self._lock:
|
|
109
|
+
state = self['login.state', self.login_userid.replace('.', '_')]
|
|
110
|
+
if do_refresh or (self.API_AUTH_GRACETIME is not None and
|
|
111
|
+
time.time() + self.API_AUTH_GRACETIME >= state.expiry):
|
|
112
|
+
for k, v in self.login().items():
|
|
113
|
+
if k not in ('id', 'name'):
|
|
114
|
+
state[k] = v
|
|
115
|
+
return state
|
|
116
|
+
|
|
117
|
+
@cached_property
|
|
118
|
+
def login_state(self) -> HasTags:
|
|
119
|
+
''' The login state, a mapping. Performs a login if necessary.
|
|
120
|
+
'''
|
|
121
|
+
return self.get_login_state()
|
|
122
|
+
|
|
123
|
+
def available(self) -> Set[SQLTagSet]:
|
|
124
|
+
''' Return a set of the `SQLTagSet` instances representing available
|
|
125
|
+
items at the service, for example purchased books
|
|
126
|
+
available to your login.
|
|
127
|
+
'''
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
class HTTPServiceAPI(ServiceAPI):
|
|
131
|
+
''' `HTTPServiceAPI` base class for other APIs talking to HTTP services.
|
|
132
|
+
|
|
133
|
+
Subclasses must define:
|
|
134
|
+
* `API_BASE`: the base URL of API calls.
|
|
135
|
+
For example, the `PlayOnAPI` defines this as `f'https://{API_HOSTNAME}/v3/'`.
|
|
136
|
+
'''
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
api_hostname=None,
|
|
141
|
+
*,
|
|
142
|
+
default_headers=None,
|
|
143
|
+
mode='response',
|
|
144
|
+
check_json=None,
|
|
145
|
+
**service_api_kw
|
|
146
|
+
):
|
|
147
|
+
if api_hostname is None:
|
|
148
|
+
api_hostname = type(self).API_HOSTNAME
|
|
149
|
+
else:
|
|
150
|
+
self.API_HOSTNAME = api_hostname
|
|
151
|
+
self.API_BASE = f'https://{api_hostname}/'
|
|
152
|
+
if default_headers is None:
|
|
153
|
+
default_headers = {}
|
|
154
|
+
super().__init__(**service_api_kw)
|
|
155
|
+
session = self.session = requests.Session()
|
|
156
|
+
# mapping of method names to requests convenience calls
|
|
157
|
+
self.REQUESTS_METHOD_CALLS = {
|
|
158
|
+
'GET': session.get,
|
|
159
|
+
'POST': session.post,
|
|
160
|
+
'HEAD': session.head,
|
|
161
|
+
}
|
|
162
|
+
self.cookies = session.cookies
|
|
163
|
+
self.default_headers = default_headers
|
|
164
|
+
self.mode = mode
|
|
165
|
+
self.check_json = check_json
|
|
166
|
+
|
|
167
|
+
def response_as_json(self, rsp: Response) -> dict:
|
|
168
|
+
''' Return `rsp.json()`.
|
|
169
|
+
'''
|
|
170
|
+
try:
|
|
171
|
+
js = rsp.json()
|
|
172
|
+
except (JSONDecodeError, RequestsJSONDecodeError) as e:
|
|
173
|
+
warning("response is not JSON: %s\n%r", e, rsp)
|
|
174
|
+
raise
|
|
175
|
+
if self.check_json:
|
|
176
|
+
for field, value in self.check_json.items():
|
|
177
|
+
js_value = js.get(field)
|
|
178
|
+
if js_value != value:
|
|
179
|
+
warning(f'expected {field}={value!r}, got {js_value!r} from {js!r}')
|
|
180
|
+
return js
|
|
181
|
+
|
|
182
|
+
def response_as_json_data(self, rsp: Response) -> dict:
|
|
183
|
+
''' Return the `"data"` element from a JSON response.
|
|
184
|
+
'''
|
|
185
|
+
js = self.response_as_json(rsp)
|
|
186
|
+
return js["data"]
|
|
187
|
+
|
|
188
|
+
@uses_runstate
|
|
189
|
+
@uses_verbose
|
|
190
|
+
@require(lambda suburl: not suburl.startswith('/'))
|
|
191
|
+
def suburl(
|
|
192
|
+
self,
|
|
193
|
+
suburl,
|
|
194
|
+
*,
|
|
195
|
+
base_url=None,
|
|
196
|
+
method='GET',
|
|
197
|
+
mode=None,
|
|
198
|
+
check=True,
|
|
199
|
+
cookies=None,
|
|
200
|
+
headers=None,
|
|
201
|
+
runstate: RunState,
|
|
202
|
+
verbose: bool,
|
|
203
|
+
**rqkw,
|
|
204
|
+
) -> Union[Response, dict]:
|
|
205
|
+
''' Request `suburl` from the service, by default using a `GET`.
|
|
206
|
+
The `suburl` must be a URL subpath not commencing with `'/'`.
|
|
207
|
+
|
|
208
|
+
Return:
|
|
209
|
+
- `mode(Response)` if `mode` is callable
|
|
210
|
+
- the `Response` if `mode=="response"`
|
|
211
|
+
- the `Response.json()` if `mode=="json"`
|
|
212
|
+
- the `Response.json()["data"]` if `mode=="data"`
|
|
213
|
+
|
|
214
|
+
Keyword parameters:
|
|
215
|
+
* `base_url`: the base request domain, default from `self.API_BASE`
|
|
216
|
+
* `method`: optional request method, default `'GET'`
|
|
217
|
+
* `check`: if true, raise an HTTP error if the response
|
|
218
|
+
status is not 200; default `True`
|
|
219
|
+
* `cookies`: optional cookie jar, default from `self.cookies`
|
|
220
|
+
* `mode`: optional result mode, default from `self.mode`
|
|
221
|
+
Other keyword parameters are passed to the requests method.
|
|
222
|
+
'''
|
|
223
|
+
rqm = self.REQUESTS_METHOD_CALLS[method]
|
|
224
|
+
if base_url is None:
|
|
225
|
+
base_url = self.API_BASE
|
|
226
|
+
if cookies is None:
|
|
227
|
+
cookies = self.cookies
|
|
228
|
+
if mode is None:
|
|
229
|
+
mode = self.mode
|
|
230
|
+
url = base_url + suburl
|
|
231
|
+
rq_headers = {}
|
|
232
|
+
rq_headers.update(self.default_headers)
|
|
233
|
+
if headers is not None:
|
|
234
|
+
rq_headers.update(headers)
|
|
235
|
+
with run_task(f'{method} {url}', report_print=verbose):
|
|
236
|
+
for retry in range(self.API_RETRY_COUNT, 0, -1):
|
|
237
|
+
runstate.raiseif()
|
|
238
|
+
try:
|
|
239
|
+
rsp = pfx_call(
|
|
240
|
+
rqm,
|
|
241
|
+
url,
|
|
242
|
+
cookies=cookies,
|
|
243
|
+
headers=rq_headers,
|
|
244
|
+
**rqkw,
|
|
245
|
+
)
|
|
246
|
+
break
|
|
247
|
+
except requests.ConnectionError as e:
|
|
248
|
+
if retry <= 1:
|
|
249
|
+
# last retry
|
|
250
|
+
raise
|
|
251
|
+
warning("%s: %s, retrying in %ds", url, e, self.API_RETRY_DELAY)
|
|
252
|
+
runstate.sleep(self.API_RETRY_DELAY)
|
|
253
|
+
if check:
|
|
254
|
+
rsp.raise_for_status()
|
|
255
|
+
if callable(mode):
|
|
256
|
+
return mode(rsp)
|
|
257
|
+
if mode == 'response':
|
|
258
|
+
return rsp
|
|
259
|
+
if mode == 'json':
|
|
260
|
+
return self.response_as_json(rsp)
|
|
261
|
+
if mode == 'data':
|
|
262
|
+
return self.response_as_json_data(rsp)
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f'unsupported {mode=}, expected "data", "json", "response" or a callable'
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def get(self, suburl, **kw) -> Response:
|
|
268
|
+
''' Call `slef.suburl` with `method="GET"`.
|
|
269
|
+
'''
|
|
270
|
+
return self, suburl(suburl, method='GET', **kw)
|
|
271
|
+
|
|
272
|
+
def post(self, suburl, **kw) -> Response:
|
|
273
|
+
''' Call `slef.suburl` with `method="POST"`.
|
|
274
|
+
'''
|
|
275
|
+
return self.suburl(suburl, method='POST', **kw)
|
|
276
|
+
|
|
277
|
+
def __truediv__(self, suburl):
|
|
278
|
+
return self.suburl(suburl)
|
|
279
|
+
|
|
280
|
+
# pylint: disable=too-few-public-methods
|
|
281
|
+
class RequestsNoAuth(requests.auth.AuthBase):
|
|
282
|
+
''' This is a special purpose subclass of `requests.auth.AuthBase`
|
|
283
|
+
to apply no authorisation at all.
|
|
284
|
+
This is for services with their own special purpose authorisation
|
|
285
|
+
and avoids things like automatic netrc based auth.
|
|
286
|
+
'''
|
|
287
|
+
|
|
288
|
+
def __call__(self, r):
|
|
289
|
+
return r
|