ads 0.12.6__py3-none-any.whl

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.
ads/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+
2
+ # coding: utf-8
3
+
4
+ """ A Python tool for interacting with NASA's ADS """
5
+
6
+ __version__ = "0.12.6"
7
+
8
+ from .metrics import MetricsQuery
9
+ from .export import ExportQuery
10
+ from .search import SearchQuery, query
11
+ from .base import RateLimits
12
+ #from .libraries import LibraryQuery, Library #soon
ads/base.py ADDED
@@ -0,0 +1,165 @@
1
+ """
2
+ Base classes for the ads client
3
+ """
4
+
5
+ import requests
6
+ import warnings
7
+ import json
8
+ import os
9
+
10
+ from .exceptions import APIResponseError
11
+ from .config import TOKEN_FILES, TOKEN_ENVIRON_VARS
12
+ from . import __version__
13
+ import ads.config # For manually setting the token
14
+
15
+
16
+ class _Singleton(type):
17
+ _instances = {}
18
+
19
+ def __call__(cls, name, *args, **kwargs):
20
+ if name not in cls._instances:
21
+ cls._instances[name] = super(_Singleton, cls).__call__(name, *args, **kwargs)
22
+ return cls._instances[name]
23
+
24
+ @classmethod
25
+ def get_info(cls):
26
+ """
27
+ Print all of the instantiated Singletons
28
+ """
29
+ return '\n'.join(
30
+ [str(cls._instances[key]) for key in cls._instances]
31
+ )
32
+
33
+
34
+ class Singleton(_Singleton('SingletonMeta', (object,), {})):
35
+ """
36
+ Wrapper class so that the Singleton metaclass works for both Python 2&3
37
+ """
38
+ pass
39
+
40
+
41
+ class RateLimits(Singleton):
42
+ response_to_query = {
43
+ 'SolrResponse': 'SearchQuery',
44
+ 'MetricsResponse': 'MetricsQuery',
45
+ 'ExportResponse': 'ExportQuery'
46
+ }
47
+
48
+ def __init__(self, name):
49
+ self.limits = {}
50
+ self.name = self.response_to_query.get(name, name)
51
+
52
+ @classmethod
53
+ def getRateLimits(cls, name):
54
+ return cls(cls.response_to_query.get(name, name))
55
+
56
+ def set(self, headers):
57
+ self.limits = {
58
+ 'limit': headers.get('x-ratelimit-limit', ''),
59
+ 'remaining': headers.get('x-ratelimit-remaining', ''),
60
+ 'reset': headers.get('x-ratelimit-reset', '')
61
+ }
62
+
63
+ def to_dict(self):
64
+ return self.limits
65
+
66
+ def __str__(self):
67
+ return '{}: {}'.format(
68
+ self.name,
69
+ json.dumps(self.limits)
70
+ )
71
+
72
+
73
+ class APIResponse(object):
74
+ """
75
+ Represents an adsws-api http response
76
+ """
77
+ response = None
78
+
79
+ @classmethod
80
+ def get_ratelimits(cls):
81
+ """
82
+ Return the current, maximum, and reset rate limits from the response
83
+ header as a dictionary. The values will be strings.
84
+ """
85
+ return RateLimits.getRateLimits(cls.__name__).to_dict()
86
+
87
+ @classmethod
88
+ def load_http_response(cls, http_response):
89
+ """
90
+ This method should return an instantiated class and set its response
91
+ to the requests.Response object.
92
+ """
93
+ if not http_response.ok:
94
+ raise APIResponseError(http_response.text)
95
+ c = cls(http_response)
96
+ c.response = http_response
97
+
98
+ RateLimits.getRateLimits(cls.__name__).set(c.response.headers)
99
+
100
+ return c
101
+
102
+
103
+ class BaseQuery(object):
104
+ """
105
+ Represents an arbitrary query to the adsws-api
106
+ """
107
+ _session = None
108
+ _token = ads.config.token
109
+
110
+ @property
111
+ def token(self):
112
+ """
113
+ set the instance attribute `token` following the following logic,
114
+ stopping whenever a token is found. Raises NoTokenFound is no token
115
+ is found
116
+ - environment variables TOKEN_ENVIRON_VARS
117
+ - file containing plaintext as the contents in TOKEN_FILES
118
+ - ads.config.token
119
+ """
120
+ if self._token is None:
121
+ for v in map(os.environ.get, TOKEN_ENVIRON_VARS):
122
+ if v is not None:
123
+ self._token = v
124
+ return self._token
125
+ for f in TOKEN_FILES:
126
+ try:
127
+ with open(f) as fp:
128
+ self._token = fp.read().strip()
129
+ return self._token
130
+ except IOError:
131
+ pass
132
+ if ads.config.token is not None:
133
+ self._token = ads.config.token
134
+ return self._token
135
+ warnings.warn("No token found", RuntimeWarning)
136
+ return self._token
137
+
138
+ @token.setter
139
+ def token(self, value):
140
+ self._token = value
141
+
142
+ @property
143
+ def session(self):
144
+ """
145
+ http session interface, transparent proxy to requests.session
146
+ """
147
+ if self._session is None:
148
+ self._session = requests.session()
149
+ self._session.headers.update(
150
+ {
151
+ "Authorization": "Bearer {}".format(self.token),
152
+ "User-Agent": "ads-api-client/{}".format(__version__),
153
+ "Content-Type": "application/json",
154
+ }
155
+ )
156
+ return self._session
157
+
158
+ def __call__(self):
159
+ return self.execute()
160
+
161
+ def execute(self):
162
+ """
163
+ Each subclass should define their own execute method
164
+ """
165
+ raise NotImplementedError
ads/config.py ADDED
@@ -0,0 +1,21 @@
1
+ """
2
+ configuration
3
+ """
4
+ import os
5
+
6
+ # API urls
7
+ ADSWS_API_URL = 'https://api.adsabs.harvard.edu/v1'
8
+ SEARCH_URL = '{}/search/query/'.format(ADSWS_API_URL)
9
+ BIGQUERY_URL = '{}/search/bigquery/'.format(ADSWS_API_URL)
10
+ METRICS_URL = '{}/metrics/'.format(ADSWS_API_URL)
11
+ EXPORT_URL = '{}/export/'.format(ADSWS_API_URL)
12
+
13
+ # Token discovery variables
14
+ TOKEN_FILES = list(map(os.path.expanduser,
15
+ [
16
+ "~/.ads/token",
17
+ "~/.ads/dev_key",
18
+ ]
19
+ ))
20
+ TOKEN_ENVIRON_VARS = ["ADS_API_TOKEN", "ADS_DEV_KEY"]
21
+ token = None # for setting in-situ
ads/exceptions.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ Custom exceptions
3
+ """
4
+
5
+
6
+ class APIResponseError(Exception):
7
+ """
8
+ Raised on a HTTP error from the API
9
+ """
10
+ def __init__(self, value=None):
11
+ self.value = value
12
+
13
+ def __str__(self):
14
+ return repr(self.value)
15
+
16
+
17
+ class SolrResponseParseError(Exception):
18
+ """
19
+ Raised when solr returns an error
20
+ """
21
+ def __init__(self, value=None):
22
+ self.value = value
23
+
24
+ def __str__(self):
25
+ return repr(self.value)
ads/export.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ interfaces to the adsws export service
3
+ """
4
+
5
+ import json
6
+ import six
7
+ import os
8
+
9
+ from .base import APIResponse, BaseQuery
10
+ from .config import EXPORT_URL
11
+
12
+
13
+ class ExportResponse(APIResponse):
14
+ """
15
+ Data structure that represents a response from the ads export service
16
+ """
17
+ def __init__(self, http_response):
18
+ self._raw = http_response.text
19
+ self.result = http_response.json()['export']
20
+
21
+ def __str__(self):
22
+ if six.PY3:
23
+ return self.__unicode__()
24
+ return self.__unicode__().encode("utf-8")
25
+
26
+ def __unicode__(self):
27
+ return self.result
28
+
29
+
30
+ class ExportQuery(BaseQuery):
31
+ """
32
+ Represents a query to the adsws export service
33
+ """
34
+
35
+ HTTP_ENDPOINT = EXPORT_URL
36
+ FORMATS = ['bibtex', 'bibtexabs', 'ads','endnote', 'aastex',
37
+ 'ris', 'icarus', 'mnras', 'soph', 'votable']
38
+
39
+ def __init__(self, bibcodes, format="bibtex"):
40
+ """
41
+ :param bibcodes: Bibcodes to send to in the export query
42
+ :type bibcodes: list or string
43
+ :param format: format to export to
44
+ """
45
+ assert format in self.FORMATS, "Format must be one of {}".format(
46
+ self.FORMATS)
47
+
48
+ self.format = format
49
+
50
+ self.response = None # current ExportResponse object
51
+ if isinstance(bibcodes, six.string_types):
52
+ bibcodes = [bibcodes]
53
+ self.bibcodes = bibcodes
54
+ self.json_payload = json.dumps({"bibcode": bibcodes})
55
+
56
+ def execute(self):
57
+ """
58
+ Execute the http request to the export service
59
+ :return ads-classic formatted export string
60
+ """
61
+ url = os.path.join(self.HTTP_ENDPOINT, self.format)
62
+ self.response = ExportResponse.load_http_response(
63
+ self.session.post(url, data=self.json_payload)
64
+ )
65
+ return self.response.result
ads/libraries.py ADDED
@@ -0,0 +1,263 @@
1
+ """Functions to interact with ADS libraries."""
2
+ from .base import BaseQuery
3
+ from .config import ADSWS_API_URL
4
+ from .search import SearchQuery, Article
5
+ import json
6
+ import six
7
+
8
+ def delete_library(id: str):
9
+ q = BaseQuery()
10
+ lib = Library(id)
11
+ q.session.delete(lib._docs_url)
12
+
13
+ def get_user_libraries():
14
+ q = BaseQuery()
15
+ out = q.session.get(Library._libraries_url).json()
16
+ return [Library(id=o.pop('id'), **o) for o in out]
17
+
18
+ class Library(BaseQuery):
19
+ """An object representing an ADS library."""
20
+ _biblib_url = f"{ADSWS_API_URL}/biblib"
21
+ _libraries_url = f"{_biblib_url}/libraries"
22
+
23
+ def __init__(self, id: str, **meta):
24
+
25
+ self.id = id
26
+ self._library_url = f"{self._libraries_url}/{id}"
27
+ self._docs_url = f"{self._biblib_url}/documents/{id}"
28
+ self._permissions_url = f"{self._biblib_url}/permissions/{id}"
29
+ self._ops_url = f"{self._libraries_url}/operations/{id}"
30
+
31
+ if not meta:
32
+ self._refresh_metadata()
33
+ else:
34
+ for key, val in meta.items():
35
+ setattr(self, key, val)
36
+
37
+ def __eq__(self, other):
38
+ if not isinstance(other, self.__class__):
39
+ return False
40
+
41
+ return self.id == other.id
42
+
43
+ def _refresh_metadata(self):
44
+ meta = self.session.get(self._library_url).json()
45
+ for key, val in meta.items():
46
+ setattr(self, key, val)
47
+
48
+ def set(self, **items):
49
+ assert all(name in ['name', 'description', 'public'] for name in items)
50
+ self.session.put(self._docs_url, payload=json.dumps(items))
51
+ self._refresh_metadata()
52
+
53
+ def get_documents(self, **kwargs):
54
+ """Return a :class:`~search.SearchQuery` representing all documents in this library."""
55
+ return SearchQuery(q=f'docs(library/{self.id})', **kwargs)
56
+
57
+ @staticmethod
58
+ def _to_bibcodes(docs):
59
+ if isinstance(docs, six.string_types):
60
+ return [docs]
61
+
62
+ if not hasattr(docs, '__iter__'):
63
+ raise TypeError("docs must be a str or iterable of strings or Articles")
64
+
65
+ out = []
66
+ for d in docs:
67
+ if isinstance(d, six.string_types):
68
+ out.append(d)
69
+ elif isinstance(d, Article):
70
+ out.append(d.bibcode)
71
+
72
+ return out
73
+
74
+ def add_documents(self, docs) -> int:
75
+ """Add a list of documents to the library.
76
+
77
+ :param docs: documents to add to the library.
78
+ :type docs: string bibcode, or sequence of string bibcodes, or sequence of
79
+ :class:`search.Article`
80
+ """
81
+ docs = self._to_bibcodes(docs)
82
+ payload = {
83
+ 'bibcode': docs,
84
+ 'action': 'add'
85
+ }
86
+
87
+ result = self.session.post(self._docs_url, data=json.dumps(payload))
88
+
89
+ self._refresh_metadata()
90
+ return result.json().get('number_added', -1)
91
+
92
+
93
+ def remove_documents(self, docs) -> int:
94
+ """Remove a list of documents from the library.
95
+
96
+ :param docs: documents to remove from the library.
97
+ :type docs: string bibcode, or sequence of string bibcodes, or sequence of
98
+ :class:`search.Article`
99
+ """
100
+ docs = self._to_bibcodes(docs)
101
+
102
+ payload = {
103
+ 'bibcode': docs,
104
+ 'action': 'remove'
105
+ }
106
+
107
+ self._refresh_metadata()
108
+ result = self.session.post(self._docs_url, data=json.dumps(payload))
109
+ return result.json()['number_removed']
110
+
111
+ @classmethod
112
+ def new(cls, name=None, description='My ADS Library', public=False, docs=None):
113
+ """Create a new library and return it.
114
+
115
+ :param name: Name of the library, default "Untitled Library" with incremented
116
+ integer appended.
117
+ :type name: string
118
+ :param description: description of the library.
119
+ :type description: str
120
+ :param public: whether the library should be public
121
+ :type public: bool
122
+ :param docs: documents to add to the library
123
+ :type docs: string bibcode, or sequence of string bibcodes, or sequence of
124
+ :class:`search.Article`
125
+ """
126
+ docs = cls._to_bibcodes(docs)
127
+
128
+ payload = {'description': description, 'public': bool(public)}
129
+ if name:
130
+ payload['name'] = name
131
+
132
+ if docs:
133
+ payload['docs'] = docs
134
+
135
+ q = BaseQuery()
136
+ result = q.session.post(cls._libraries_url, data=json.dumps(payload))
137
+
138
+ return cls(result.json()['id'])
139
+
140
+ def get_user_permissions(self):
141
+ """Get persmissions of different users."""
142
+ return self.session.get(self._permissions_url).json()
143
+
144
+ def edit_user_persmissions(self, email, permission):
145
+ """Edit permissions of a user (or add new user).
146
+
147
+ :param email: email of the (new) user
148
+ :type email: str
149
+ :param permission: the associated permissions to set, should be a string
150
+ containing any combination of 'rwa' (read, write, admin).
151
+ """
152
+
153
+ assert [p in 'rwa' for p in permission]
154
+
155
+ permission = {
156
+ 'read': 'r' in permission,
157
+ 'write': 'w' in permission,
158
+ 'admin': 'a' in permission
159
+ }
160
+
161
+ payload = {'permission': permission, 'email': email}
162
+ res = self.session.post(self._permissions_url, payload=json.dumps(payload))
163
+
164
+ self._refresh_metadata()
165
+
166
+ return res.json()['message']
167
+
168
+ def _set_operations(self, action, libraries, **kwargs):
169
+ kw = {k: v for k, v in kwargs.items() if v is not None}
170
+ payload = {
171
+ 'action': action,
172
+ 'libraries': [l.id if isinstance(l, Library) else l for l in libraries],
173
+ **kw
174
+ }
175
+ res = self.session.post(self._ops_url, payload = json.dumps(payload))
176
+ return res.json()
177
+
178
+ def union(self, libraries, name=None, description=None, public=False):
179
+ """Form a new library from the union of this library and other libraries.
180
+
181
+ Returns the new :class:`Library` object.
182
+
183
+ :param libraries: libraries to take the union of.
184
+ :type libraries: str identifier, :class:`Library` or sequence of such.
185
+ :param name: Name of the library, default "Untitled Library" with incremented
186
+ integer appended.
187
+ :type name: string
188
+ :param description: description of the library.
189
+ :type description: str
190
+ :param public: whether the library should be public
191
+ :type public: bool
192
+ """
193
+ if isinstance(libraries, (str, Library)):
194
+ libraries = [libraries]
195
+
196
+ res = self._set_operations('union', libraries, name=name, description=description, public=public)
197
+ return self.__class__(res['id'])
198
+
199
+ def difference(self, libraries, name=None, description=None, public=False):
200
+ """Form a new library from the difference of this library and other libraries.
201
+
202
+ Returns the new :class:`Library` object.
203
+
204
+ :param libraries: libraries to difference with this one.
205
+ :type libraries: str identifier, :class:`Library` or sequence of such.
206
+ :param name: Name of the library, default "Untitled Library" with incremented
207
+ integer appended.
208
+ :type name: string
209
+ :param description: description of the library.
210
+ :type description: str
211
+ :param public: whether the library should be public
212
+ :type public: bool
213
+ """
214
+ if isinstance(libraries, (Library, str)):
215
+ libraries = [libraries]
216
+
217
+ res = self._set_operations('difference', libraries, name=name, description=description, public=public)
218
+ return self.__class__(res['id'])
219
+
220
+ def intersection(self, libraries, name=None, description=None, public=False):
221
+ """Form a new library from the intersection of this library and other libraries.
222
+
223
+ Returns the new :class:`Library` object.
224
+
225
+ :param libraries: libraries to take the intersect.
226
+ :type libraries: str identifier, :class:`Library` or sequence of such.
227
+ :param name: Name of the library, default "Untitled Library" with incremented
228
+ integer appended.
229
+ :type name: string
230
+ :param description: description of the library.
231
+ :type description: str
232
+ :param public: whether the library should be public
233
+ :type public: bool
234
+ """
235
+ if isinstance(libraries, (Library, str)):
236
+ libraries = [libraries]
237
+
238
+ res = self._set_operations('intersection', libraries, name=name, description=description, public=public)
239
+ return self.__class__(res['id'])
240
+
241
+ def empty(self):
242
+ """Empty this library of all documents."""
243
+ self._set_operations('empty', libraries=[])
244
+ self._refresh_metadata()
245
+
246
+ def copy_to(self, library):
247
+ """Copy documents in this library to another library.
248
+
249
+ :param library: the library to copy to
250
+ :type library: str id or :class:`Library`.
251
+ """
252
+ library = [library]
253
+ res = self._set_operations('copy', library)
254
+ return self.__class__(res['id'])
255
+
256
+ def transfer_to(self, email):
257
+ """Transfer the library ownership to another user.
258
+
259
+ :param email: the email to transfer ownership to.
260
+ """
261
+ self.session.post(f"{self._biblib_url}/transfer/{self.id}", payload=json.dumps({'email': email}))
262
+ self._refresh_metadata()
263
+
ads/metrics.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ interfaces to the adsws metrics service
3
+ """
4
+
5
+ import json
6
+ import six
7
+
8
+ from .base import APIResponse, BaseQuery
9
+ from .config import METRICS_URL
10
+
11
+
12
+ class MetricsResponse(APIResponse):
13
+ """
14
+ Data structure that represents a response from the ads metrics service
15
+ """
16
+ def __init__(self, http_response):
17
+ self._raw = http_response.text
18
+ self.metrics = http_response.json()
19
+
20
+ def __str__(self):
21
+ if six.PY3:
22
+ return self.__unicode__()
23
+ return self.__unicode__().encode("utf-8")
24
+
25
+ def __unicode__(self):
26
+ return self.metrics
27
+
28
+
29
+ class MetricsQuery(BaseQuery):
30
+ """
31
+ Represents a query to the adsws metrics service
32
+ """
33
+
34
+ HTTP_ENDPOINT = METRICS_URL
35
+
36
+ def __init__(self, bibcodes):
37
+ """
38
+ :param bibcodes: Bibcodes to send to in the metrics query
39
+ :type bibcodes: list or string
40
+ """
41
+ self.response = None # current MetricsResponse object
42
+ if isinstance(bibcodes, six.string_types):
43
+ bibcodes = [bibcodes]
44
+ self.bibcodes = bibcodes
45
+ self.json_payload = json.dumps({"bibcodes": bibcodes})
46
+
47
+ def execute(self):
48
+ """
49
+ Execute the http request to the metrics service
50
+ """
51
+ self.response = MetricsResponse.load_http_response(
52
+ self.session.post(self.HTTP_ENDPOINT, data=self.json_payload)
53
+ )
54
+ return self.response.metrics
ads/sandbox.py ADDED
@@ -0,0 +1,58 @@
1
+ """
2
+ Sandbox environment that wraps relevant classes so that they receive mock
3
+ responses rather than contact the live API
4
+ """
5
+
6
+ import re
7
+
8
+ from ads import search
9
+ from .search import SearchQuery as _SearchQuery, Article as _Article
10
+ from .metrics import MetricsQuery as _MetricsQuery
11
+ from .export import ExportQuery as _ExportQuery
12
+
13
+ from .tests.mocks import MockSolrResponse, MockMetricsResponse, \
14
+ MockExportResponse
15
+
16
+
17
+ class Article(_Article):
18
+ """
19
+ Wrapper for ads.search.Article
20
+ """
21
+ def _get_field(self, field):
22
+ with MockSolrResponse(_SearchQuery.HTTP_ENDPOINT):
23
+ return super(Article, self)._get_field(field)
24
+
25
+
26
+ class SearchQuery(_SearchQuery):
27
+ """
28
+ Wrapper for ads.SearchQuery
29
+ """
30
+ def execute(self):
31
+ with MockSolrResponse(SearchQuery.HTTP_ENDPOINT):
32
+ super(SearchQuery, self).execute()
33
+
34
+
35
+ class MetricsQuery(_MetricsQuery):
36
+ """
37
+ Wrapper for ads.SearchQuery
38
+ """
39
+
40
+ def execute(self):
41
+ with MockMetricsResponse(MetricsQuery.HTTP_ENDPOINT):
42
+ return super(MetricsQuery, self).execute()
43
+
44
+
45
+ class ExportQuery(_ExportQuery):
46
+ """
47
+ Wrapper for ads.SearchQuery
48
+ """
49
+
50
+ def execute(self):
51
+ with MockExportResponse(re.compile(ExportQuery.HTTP_ENDPOINT)):
52
+ return super(ExportQuery, self).execute()
53
+
54
+
55
+ # Monkey patch relevant classes that are called in ads.search
56
+ search.Article = Article
57
+ search.MetricsQuery = MetricsQuery
58
+ search.ExportQuery = ExportQuery