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 +12 -0
- ads/base.py +165 -0
- ads/config.py +21 -0
- ads/exceptions.py +25 -0
- ads/export.py +65 -0
- ads/libraries.py +263 -0
- ads/metrics.py +54 -0
- ads/sandbox.py +58 -0
- ads/search.py +581 -0
- ads/tests/__init__.py +0 -0
- ads/tests/mocks.py +162 -0
- ads/tests/stubdata/__init__.py +0 -0
- ads/tests/stubdata/export.py +6 -0
- ads/tests/stubdata/metrics.py +940 -0
- ads/tests/stubdata/solr.py +2032 -0
- ads/tests/test_base.py +191 -0
- ads/tests/test_export.py +54 -0
- ads/tests/test_metrics.py +53 -0
- ads/tests/test_sandbox.py +72 -0
- ads/tests/test_search.py +428 -0
- ads/tests/test_utils.py +47 -0
- ads/utils.py +35 -0
- ads-0.12.6.dist-info/LICENSE +21 -0
- ads-0.12.6.dist-info/METADATA +55 -0
- ads-0.12.6.dist-info/RECORD +27 -0
- ads-0.12.6.dist-info/WHEEL +5 -0
- ads-0.12.6.dist-info/top_level.txt +1 -0
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
|