├── .coveragerc ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── orcid ├── __init__.py ├── orcid.py └── testsuite │ ├── __init__.py │ ├── helpers.py │ └── test_orcid.py ├── pytest.ini └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = orcid 3 | omit = */testsuite/* */__init__.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | - '3.3' 5 | - '3.4' 6 | - '3.5' 7 | - '3.6' 8 | install: 9 | - pip install pep257 pytest pep8 coverage coveralls html5lib beautifulsoup4 lxml 10 | script: 11 | - pep257 orcid 12 | - pep8 orcid 13 | - coverage run --omit="*/__init__.py" --source orcid setup.py test 14 | - coverage report -m 15 | after_success: 16 | - coveralls 17 | notifications: 18 | email: false 19 | env: 20 | global: 21 | - secure: kLsh166uLrQhiQ5p/FmMql/bMDiym2YwuUGf1FZtDYurszdLjdHYbQoXv5pni6DEGggG1dxOmxBs2YaOv7T3LU55TfNZCl4wysiRFBOuXs4mQncyOCvdgqKZ8QPeHkdhYNY7Q+lkQeL8AFTR0SzBdFTFnd5AmiqK1JZBVMDAUIQ= 22 | - secure: SuvoY00SdZH4unqjdrkbEYz6qQLbOTVY82e4mXwZbsuNzKmOmoxeTEZ3M5wcCpzQOBOVP5bYv7jIxI6YXXlZYcNyyFmYi6WOLNZX+8KSCsEZH9WfvjNrMzJR3O8TXncnBi24ITFq47I85mh596x9Pux5bxu1wTwga7tkLTHAK1w= 23 | - secure: qPK+ozleM7VDLOEihpLmhy9EQn0XHGn51IHb8DdEgB4/LjPNvAMOjXidQHZq/v44FQFQalViATCsL4bzbr1s85PhTASeM3JzPCAFyvJjWkc8jvI16StsmY6DLPE9vHmUAT3Q3sAiWRaj+TBwUJbokGYAjpb9HlUHkEW7wtvtDMM= 24 | - secure: ONADG+fgfbDfDrflSpZKIwepQTLEqdxk+HGrGJKQMHtDoctsE4CciVPU45W+/ggCRMxf2uccbWXOvb2n9dkuFzWgv7B/2qIHV+ywYz+nlgoSmEA8zIgygvNexHDLJawa0M+Jnz2U9NdRGAljm4g1AmnlsI0F1TUsIFK9Ylh/QYU= 25 | - secure: r5z28MNdzrR8lYEqcB6rwQydZ7widO2rGLLe/UkbsaiqMSGycd3hZ7QP4iJZzLAG08ltjybCIaUydwf/IChQ9BCbD4Owah3/KTn4qdxZRLJk41a0ltnTy9ttSPZrHdd/5BeszdHtJXPc3j6nxlqA6KetY6s4+eE2WkN8w/j8Ags= 26 | - secure: DU4rtKSvP4FuL1HqHeCwbUF8dc2xJxPxSr21E69pOSgd96M0CpSGO+Vjz0gzOzO01Lj2Dtl3+eUb40vEuZFRbDKA9FnXR16pOX582BVEfE5dxcGVHyRAaue96Dm4J8ls4WyVo6VF/psgkDXSogZ7xBodCz/IiqAkO1BMrZv2Xps= 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | 2 | Pull requests and issues are always welcome! Still, the code should follow 3 | some quality rules in order to be merged. 4 | 5 | Coding style 6 | ------------ 7 | 8 | * Please follow PEP8 and PEP257 rules. 9 | * Please don't repeat the code if it is unnecessary. 10 | * Please cover the code with tests. 11 | 12 | Submitting issues 13 | ----------------- 14 | 15 | In case of bugs please create a comprehensive description. For example, if 16 | there is a bug in ``add_record`` method, please include the dictionary/xml 17 | passed and a snippet of the code. Don't include the secrets and passwords in 18 | the content! 19 | 20 | In case of a suggestion for a new feature, please show an example. A test 21 | case (in spirit of the TDD) would be perfect, but is not required. 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 - 2017 CERN 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of python-orcid nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | python-orcid 2 | ============ 3 | 4 | .. image:: https://badges.gitter.im/ORCID/python-orcid.svg 5 | :alt: Join the chat at https://gitter.im/ORCID/python-orcid 6 | :target: https://gitter.im/ORCID/python-orcid?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 7 | 8 | .. image:: https://img.shields.io/travis/ORCID/python-orcid.svg?style=flat-square 9 | :target: https://travis-ci.org/ORCID/python-orcid 10 | .. image:: https://img.shields.io/coveralls/ORCID/python-orcid.svg?style=flat-square 11 | :target: https://coveralls.io/r/ORCID/python-orcid?branch=master 12 | .. image:: https://img.shields.io/pypi/l/orcid.svg?style=flat-square 13 | :target: https://pypi.python.org/pypi/orcid/ 14 | .. image:: https://img.shields.io/badge/status-beta-red.svg?style=flat-square 15 | :target: https://pypi.python.org/pypi/orcid/ 16 | 17 | Authors 18 | ------- 19 | 20 | Mateusz Susik 21 | 22 | Installation 23 | ------------ 24 | 25 | .. code-block:: bash 26 | 27 | pip install orcid 28 | 29 | 30 | Notes 31 | ----- 32 | 33 | This README might be slightly outdated. You can help by submitting a pull request. 34 | 35 | Exception handling 36 | -------------- 37 | 38 | The methods of this library might throw client or server errors. An error is 39 | an exception coming from the proven 40 | `requests `_ library. The usual 41 | way to work with them should be: 42 | 43 | .. code-block:: python 44 | 45 | from requests import RequestException 46 | import orcid 47 | api = orcid.MemberAPI(key, secret, sandbox=True) 48 | try: 49 | api.add_record(author-orcid, token, 'work', 50 | {'title': 'Title', 'type': 'artistic-performance'}) 51 | except RequestException as e: 52 | # Here the error should be handled. As the exception message might be 53 | # too generic, additional info can be obtained by: 54 | print(e.response.text) 55 | # The response is a requests Response instance. 56 | 57 | 58 | Introduction 59 | ------------ 60 | 61 | `ORCID `_ is an open, non-profit, community-based effort to 62 | provide a registry of unique researcher identifiers and a transparent method 63 | of linking research activities and outputs to these identifiers. ORCID is 64 | unique in its ability to reach across disciplines, research sectors, and 65 | national boundaries and its cooperation with other identifier systems. 66 | 67 | ORCID offers an API (Application Programming Interface) that allows your 68 | systems and applications to connect to the ORCID registry, including reading 69 | from and writing to ORCID records. 70 | 71 | There are two types of API available for developers. 72 | 73 | 74 | PublicAPI 75 | ========= 76 | 77 | The public API allows the developers to use the search engine and read author 78 | records. In order to use it, you need to pass institution's key and secret. 79 | 80 | The functionality of this API is also available in the member API. 81 | 82 | Token 83 | ----- 84 | 85 | In order to read or update records, the ``token`` is needed. The tokens come 86 | from OAuth 3-legged authorization. You can perform the authorization using 87 | this library (examples below). 88 | 89 | However, if the user is already connected to ORCiD and authenticated (so you 90 | have an authorization code), this process can be simplified: 91 | 92 | .. code-block:: python 93 | 94 | import orcid 95 | api = orcid.PublicAPI(institution_key, institution_secret, sandbox=True) 96 | token = api.get_token_from_authorization_code(authorization_code, 97 | redirect_uri) 98 | 99 | A special case are the tokens for performing search queries. Such queries 100 | do not need user authentication, only institution credentials are needed. 101 | 102 | .. code-block:: python 103 | 104 | import orcid 105 | api = orcid.PublicAPI(institution_key, institution_secret, sandbox=True) 106 | search_token = api.get_search_token_from_orcid() 107 | 108 | By reusing the same token, the search functions will run faster skipping 109 | the authentication process. 110 | 111 | 112 | Searching 113 | --------- 114 | 115 | .. code-block:: python 116 | 117 | import orcid 118 | api = orcid.PublicAPI(institution_key, institution_secret, sandbox=True) 119 | search_results = api.search('text:English', access_token=Token) 120 | 121 | 122 | While creating a search query, it is possible to use a generator in 123 | order to reduce time needed to fetch a record. 124 | 125 | .. code-block:: python 126 | 127 | search_results = api.search_generator('text:English', 128 | pagination=20) 129 | first_result = next(search_results) 130 | 131 | 132 | Reading records 133 | --------------- 134 | 135 | .. code-block:: python 136 | 137 | import orcid 138 | api = orcid.PublicAPI(institution_key, institution_secret, sandbox=True) 139 | search_results = api.search_public('text:English') 140 | # Get the summary 141 | token = api.get_token(user_id, user_password, redirect_uri) 142 | summary = api.read_record_public('0000-0001-1111-1111', 'activities', 143 | token) 144 | summary = api.read_record_public('0000-0001-1111-1111', 'record', 145 | token) 146 | 147 | 148 | Every record in the `summary` dictionary should contain *put-codes*. Using 149 | them, it is possible to query the specific record for details. Type of the 150 | record and the put-code need to be provided. 151 | 152 | .. code-block:: python 153 | 154 | # Get the specific record 155 | work = api.read_record_public('0000-0001-1111-1111', 'work', token, 156 | '1111') 157 | 158 | An exception is made for ``works`` `request_type`. It is possible to 159 | fetch multiple selected works at once by selecting multiple 160 | ``put_codes`` in a list. 161 | 162 | .. code-block:: python 163 | 164 | work = api.read_record_public('0000-0001-1111-1111', 'works', token, 165 | ['1111', '2222', '3333']) 166 | 167 | Additional utilities 168 | -------------------- 169 | 170 | Python-orcid offers a function for creating a login/register URL. 171 | 172 | .. code-block:: python 173 | 174 | url = api.get_login_url('/authenticate', redirect_uri, email=email) 175 | 176 | 177 | MemberAPI 178 | ========= 179 | 180 | The member API allows the developers to add/change/remove records. 181 | To modify the records one needs a token which can be obtained following 182 | the OAuth 3-legged authorization process. 183 | 184 | The member API lets the developer obtain more information when using the 185 | search API or fetching the records. 186 | 187 | To create an instance of the member API handler, the institution key and the 188 | institution secret have to be provided. 189 | 190 | .. code-block:: python 191 | 192 | import orcid 193 | api = orcid.MemberAPI(institution_key, institution_secret, 194 | sandbox=True) 195 | search_results = api.search('text:English') 196 | # Get the summary 197 | token = api.get_token(user_id, user_password, redirect_uri, 198 | '/read-limited') 199 | summary = api.read_record_member('0000-0001-1111-1111', 'activities', 200 | token) 201 | 202 | All the methods from the public API are available in the member API. 203 | 204 | Getting ORCID 205 | ------------- 206 | 207 | If the ORCID of an author is not known, one can obtain it by authorizing the 208 | user: 209 | 210 | .. code-block:: python 211 | 212 | orcid = api.get_user_orcid(user_id, password, redirect_uri) 213 | 214 | 215 | Adding/updating/removing records 216 | -------------------------------- 217 | 218 | Using the member API, one can add/update/remove records from the ORCID profile. 219 | 220 | All the types of records are supported. 221 | 222 | .. code-block:: python 223 | 224 | put_code = api.add_record(author-orcid, token, 'work', json) 225 | # Change the type to 'other' 226 | api.update_record(author-orcid, token, 'work', put-code, 227 | {'type': 'OTHER'}) 228 | api.remove_record(author-orcid, token, 'work', put-code) 229 | 230 | 231 | The ``token`` is the string received from OAuth 3-legged authorization. 232 | 233 | The last argument is the record itself. The record should 234 | follow ORCID's JSON records definitions. Here is an 235 | example of a dictionary that can be passed as an argument: 236 | 237 | .. code-block:: python 238 | 239 | { 240 | "title": { 241 | "title": { 242 | "value": "Work # 1" 243 | }, 244 | "subtitle": null, 245 | "translated-title": null 246 | }, 247 | "journal-title": { 248 | "value": "journal # 1" 249 | }, 250 | "short-description": null, 251 | "type": "JOURNAL_ARTICLE", 252 | "external-ids": { 253 | "external-id": [{ 254 | "external-id-type": "doi", 255 | "external-id-value": "ext-id-1", 256 | "external-id-url": { 257 | "value": "http://dx.doi.org/ext-id-1" 258 | }, 259 | "external-id-relationship": "SELF" 260 | }] 261 | } 262 | } 263 | 264 | If you do not know how to structure your JSON, visit 265 | `ORCID swagger `_ 266 | 267 | It is possible to update many works in the same time! 268 | Us ``works`` request type and pass a JSON like this one: 269 | 270 | .. code-block:: python 271 | 272 | "bulk": [ 273 | { 274 | "work": { 275 | "title": { 276 | "title": { 277 | "value": "Work # 1" 278 | }, 279 | }, 280 | "journal-title": { 281 | "value": "journal # 1" 282 | }, 283 | "type": "JOURNAL_ARTICLE", 284 | "external-ids": { 285 | "external-id": [{ 286 | "external-id-type": "doi", 287 | "external-id-value": "ext-id-1", 288 | "external-id-url": { 289 | "value": "http://dx.doi.org/ext-id-1" 290 | }, 291 | "external-id-relationship": "SELF" 292 | }] 293 | } 294 | } 295 | }, 296 | { 297 | "work": { 298 | "title": { 299 | "title": { 300 | "value": "Work # 2" 301 | }, 302 | }, 303 | "journal-title": { 304 | "value": "journal # 2" 305 | }, 306 | "type": "JOURNAL_ARTICLE", 307 | "external-ids": { 308 | "external-id": [{ 309 | "external-id-type": "doi", 310 | "external-id-value": "ext-id-2", 311 | "external-id-url": { 312 | "value": "http://dx.doi.org/ext-id-2" 313 | }, 314 | "external-id-relationship": "SELF" 315 | }] 316 | } 317 | } 318 | } 319 | ] 320 | -------------------------------------------------------------------------------- /orcid/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialization file for python-orcid.""" 2 | 3 | from .orcid import MemberAPI 4 | from .orcid import PublicAPI 5 | 6 | __all__ = ('MemberAPI', 'PublicAPI') 7 | -------------------------------------------------------------------------------- /orcid/orcid.py: -------------------------------------------------------------------------------- 1 | """Implementation of python-orcid library.""" 2 | 3 | from bs4 import BeautifulSoup 4 | import requests 5 | import simplejson as json 6 | import sys 7 | from lxml import etree 8 | if sys.version_info[0] == 2: 9 | from urllib import urlencode 10 | string_types = basestring, 11 | else: 12 | from urllib.parse import urlencode 13 | string_types = str, 14 | 15 | 16 | SEARCH_VERSION = "/v2.0" 17 | VERSION = "/v2.0" 18 | 19 | __version__ = "1.0.3" 20 | 21 | 22 | class PublicAPI(object): 23 | """Public API.""" 24 | 25 | TYPES_WITH_PUTCODES = set(['address', 26 | 'education', 27 | 'email', 28 | 'employment', 29 | 'external-identifier', 30 | 'funding', 31 | 'keywords', 32 | 'other-names', 33 | 'peer-review', 34 | 'researcher-urls', 35 | 'work']) 36 | 37 | TYPES_WITH_MULTIPLE_PUTCODES = set(['works']) 38 | 39 | def __init__(self, institution_key, institution_secret, sandbox=False, 40 | timeout=None, do_store_raw_response=False): 41 | """Initialize public API. 42 | 43 | Parameters 44 | ---------- 45 | :param institution_key: string 46 | The ORCID key given to the institution 47 | :param institution_secret: string 48 | The ORCID secret given to the institution 49 | :param sandbox: boolean 50 | Should the sandbox be used. False (default) indicates production 51 | mode. 52 | :param timeout: float or tuple 53 | The request timeout in seconds. If None, no timeout is used. See 54 | `requests documentation 55 | `_ 56 | for more information. 57 | """ 58 | self._key = institution_key 59 | self._secret = institution_secret 60 | self._timeout = timeout 61 | self.raw_response = None 62 | self.do_store_raw_response = do_store_raw_response 63 | if sandbox: 64 | self._host = "sandbox.orcid.org" 65 | self._login_or_register_endpoint = \ 66 | "https://sandbox.orcid.org/oauth/authorize" 67 | self._login_url = \ 68 | "https://sandbox.orcid.org/oauth/custom/login.json" 69 | self._token_url = "https://api.sandbox.orcid.org/oauth/token" 70 | self._endpoint = "https://pub.sandbox.orcid.org" 71 | else: 72 | self._host = "orcid.org" 73 | self._login_or_register_endpoint = \ 74 | "https://orcid.org/oauth/authorize" 75 | self._login_url = \ 76 | 'https://orcid.org/oauth/custom/login.json' 77 | self._token_url = "https://api.orcid.org/oauth/token" 78 | self._endpoint = "https://pub.orcid.org" 79 | 80 | def get_login_url(self, scope, redirect_uri, state=None, 81 | family_names=None, given_names=None, email=None, 82 | lang=None, show_login=None): 83 | """Return a URL for a user to login/register with ORCID. 84 | 85 | Parameters 86 | ---------- 87 | :param scope: string or iterable of strings 88 | The scope(s) of the authorization request. 89 | For example '/authenticate' 90 | :param redirect_uri: string 91 | The URI to which the user's browser should be redirected after the 92 | login. 93 | :param state: string 94 | An arbitrary token to prevent CSRF. See the OAuth 2 docs for 95 | details. 96 | :param family_names: string 97 | The user's family name, used to fill the registration form. 98 | :param given_names: string 99 | The user's given name, used to fill the registration form. 100 | :param email: string 101 | The user's email address, used to fill the sign-in or registration 102 | form. 103 | :param lang: string 104 | The language in which to display the authorization page. 105 | :param show_login: bool 106 | Determines whether the log-in or registration form will be shown by 107 | default. 108 | 109 | Returns 110 | ------- 111 | :returns: string 112 | The URL ready to be offered as a link to the user. 113 | """ 114 | if not isinstance(scope, string_types): 115 | scope = " ".join(sorted(set(scope))) 116 | data = [("client_id", self._key), ("scope", scope), 117 | ("response_type", "code"), ("redirect_uri", redirect_uri)] 118 | if state: 119 | data.append(("state", state)) 120 | if family_names: 121 | data.append(("family_names", family_names.encode("utf-8"))) 122 | if given_names: 123 | data.append(("given_names", given_names.encode("utf-8"))) 124 | if email: 125 | data.append(("email", email)) 126 | if lang: 127 | data.append(("lang", lang)) 128 | if show_login is not None: 129 | data.append(("show_login", "true" if show_login else "false")) 130 | return self._login_or_register_endpoint + "?" + urlencode(data) 131 | 132 | def search(self, query, method="lucene", start=None, 133 | rows=None, access_token=None): 134 | """Search the ORCID database. 135 | 136 | Parameters 137 | ---------- 138 | :param query: string 139 | Query in line with the chosen method. 140 | :param method: string 141 | One of 'lucene', 'edismax', 'dismax' 142 | :param start: string 143 | Index of the first record requested. Use for pagination. 144 | :param rows: string 145 | Number of records requested. Use for pagination. 146 | :param access_token: string 147 | If obtained before, the access token to use to pass through 148 | authorization. Note that if this argument is not provided, 149 | the function will take more time. 150 | 151 | Returns 152 | ------- 153 | :returns: dict 154 | Search result with error description available. The results can 155 | be obtained by accessing key 'result'. To get the number 156 | of all results, access the key 'num-found'. 157 | """ 158 | if access_token is None: 159 | access_token = self. \ 160 | get_search_token_from_orcid() 161 | 162 | headers = {'Accept': 'application/orcid+json', 163 | 'Authorization': 'Bearer %s' % access_token} 164 | 165 | return self._search(query, method, start, rows, headers, 166 | self._endpoint) 167 | 168 | def search_generator(self, query, method="lucene", 169 | pagination=10, access_token=None): 170 | """Search the ORCID database with a generator. 171 | 172 | The generator will yield every result. 173 | 174 | Parameters 175 | ---------- 176 | :param query: string 177 | Query in line with the chosen method. 178 | :param method: string 179 | One of 'lucene', 'edismax', 'dismax' 180 | :param pagination: integer 181 | How many papers should be fetched with the request. 182 | :param access_token: string 183 | If obtained before, the access token to use to pass through 184 | authorization. Note that if this argument is not provided, 185 | the function will take more time. 186 | 187 | Yields 188 | ------- 189 | :yields: dict 190 | Single profile from the search results. 191 | """ 192 | if access_token is None: 193 | access_token = self. \ 194 | get_search_token_from_orcid() 195 | 196 | headers = {'Accept': 'application/orcid+json', 197 | 'Authorization': 'Bearer %s' % access_token} 198 | 199 | index = 0 200 | 201 | while True: 202 | paginated_result = self._search(query, method, index, pagination, 203 | headers, self._endpoint) 204 | if not paginated_result['result']: 205 | return 206 | 207 | for result in paginated_result['result']: 208 | yield result 209 | index += pagination 210 | 211 | def get_search_token_from_orcid(self, scope='/read-public'): 212 | """Get a token for searching ORCID records. 213 | 214 | Parameters 215 | ---------- 216 | :param scope: string 217 | /read-public or /read-member 218 | 219 | Returns 220 | ------- 221 | :returns: string 222 | The token. 223 | """ 224 | payload = {'client_id': self._key, 225 | 'client_secret': self._secret, 226 | 'scope': scope, 227 | 'grant_type': 'client_credentials' 228 | } 229 | 230 | url = "%s/oauth/token" % self._endpoint 231 | headers = {'Accept': 'application/json'} 232 | 233 | response = requests.post(url, data=payload, headers=headers, 234 | timeout=self._timeout) 235 | response.raise_for_status() 236 | if self.do_store_raw_response: 237 | self.raw_response = response 238 | return response.json()['access_token'] 239 | 240 | def get_token(self, user_id, password, redirect_uri, 241 | scope='/read-limited'): 242 | """Get the token. 243 | 244 | Parameters 245 | ---------- 246 | :param user_id: string 247 | The id of the user used for authentication. 248 | :param password: string 249 | The user password. 250 | :param redirect_uri: string 251 | The redirect uri of the institution. 252 | :param scope: string 253 | The desired scope. For example '/activities/update', 254 | '/read-limited', etc. 255 | 256 | Returns 257 | ------- 258 | :returns: string 259 | The token. 260 | """ 261 | response = self._authenticate(user_id, password, redirect_uri, 262 | scope) 263 | return response['access_token'] 264 | 265 | def get_token_from_authorization_code(self, 266 | authorization_code, redirect_uri): 267 | """Like `get_token`, but using an OAuth 2 authorization code. 268 | 269 | Use this method if you run a webserver that serves as an endpoint for 270 | the redirect URI. The webserver can retrieve the authorization code 271 | from the URL that is requested by ORCID. 272 | 273 | Parameters 274 | ---------- 275 | :param redirect_uri: string 276 | The redirect uri of the institution. 277 | :param authorization_code: string 278 | The authorization code. 279 | 280 | Returns 281 | ------- 282 | :returns: dict 283 | All data of the access token. The access token itself is in the 284 | ``"access_token"`` key. 285 | """ 286 | token_dict = { 287 | "client_id": self._key, 288 | "client_secret": self._secret, 289 | "grant_type": "authorization_code", 290 | "code": authorization_code, 291 | "redirect_uri": redirect_uri, 292 | } 293 | response = requests.post(self._token_url, data=token_dict, 294 | headers={'Accept': 'application/json'}, 295 | timeout=self._timeout) 296 | response.raise_for_status() 297 | if self.do_store_raw_response: 298 | self.raw_response = response 299 | return json.loads(response.text) 300 | 301 | def read_record_public(self, orcid_id, request_type, token, put_code=None, 302 | accept_type='application/orcid+json'): 303 | """Get the public info about the researcher. 304 | 305 | Parameters 306 | ---------- 307 | :param orcid_id: string 308 | Id of the queried author. 309 | :param request_type: string 310 | For example: 'record'. 311 | See https://members.orcid.org/api/tutorial/read-orcid-records 312 | for possible values. 313 | :param token: string 314 | Token received from OAuth 2 3-legged authorization. 315 | :param put_code: string | list of strings 316 | The id of the queried work. In case of 'works' request_type 317 | might be a list of strings 318 | :param accept_type: expected MIME type of received data 319 | 320 | Returns 321 | ------- 322 | :returns: dict | lxml.etree._Element 323 | Record(s) in JSON-compatible dictionary representation or 324 | in XML E-tree, depending on accept_type specified. 325 | """ 326 | return self._get_info(orcid_id, self._get_public_info, request_type, 327 | token, put_code, accept_type) 328 | 329 | def _authenticate(self, user_id, password, redirect_uri, scope): 330 | 331 | session = requests.session() 332 | session.get('https://' + self._host + '/signout', 333 | timeout=self._timeout) 334 | params = { 335 | 'client_id': self._key, 336 | 'response_type': 'code', 337 | 'scope': scope, 338 | 'redirect_uri': redirect_uri 339 | } 340 | 341 | response = session.get(self._login_or_register_endpoint, 342 | params=params, 343 | headers={'Host': self._host}, 344 | timeout=self._timeout) 345 | 346 | response.raise_for_status() 347 | 348 | soup = BeautifulSoup(response.content, 'html5lib') 349 | csrf = soup.find(attrs={'name': '_csrf'}).attrs['content'] 350 | headers = { 351 | 'Host': self._host, 352 | 'Origin': 'https://' + self._host, 353 | 'Content-Type': 'application/json;charset=UTF-8', 354 | 'X-CSRF-TOKEN': csrf 355 | } 356 | 357 | data = { 358 | "userName": user_id, 359 | "password": password, 360 | "approved": True, 361 | "persistentTokenEnabled": True, 362 | "redirectUrl": None 363 | } 364 | 365 | response = session.post( 366 | self._login_url, 367 | data=json.dumps(data), 368 | headers=headers 369 | ) 370 | response.raise_for_status() 371 | 372 | uri = json.loads(response.text)['redirectUrl'] 373 | authorization_code = uri[uri.rfind('=') + 1:] 374 | 375 | return self.get_token_from_authorization_code(authorization_code, 376 | redirect_uri) 377 | 378 | def _get_info(self, orcid_id, function, request_type, token, 379 | put_code=None, accept_type='application/orcid+json'): 380 | if request_type in self.TYPES_WITH_PUTCODES and not put_code: 381 | raise ValueError("""In order to fetch specific record, 382 | please specify the 'put_code' argument.""") 383 | elif request_type not in self.TYPES_WITH_PUTCODES and \ 384 | request_type not in self.TYPES_WITH_MULTIPLE_PUTCODES \ 385 | and isinstance(put_code, str): 386 | raise ValueError("""In order to fetch a summary, the 387 | 'put_code' argument is redundant.""") 388 | elif request_type in self.TYPES_WITH_MULTIPLE_PUTCODES \ 389 | and put_code is not None and not isinstance(put_code, list): 390 | raise ValueError("""In order to fetch multiple records, 391 | the 'put_code' should be a list.""") 392 | response = function(orcid_id, request_type, token, 393 | put_code, accept_type) 394 | response.raise_for_status() 395 | if self.do_store_raw_response: 396 | self.raw_response = response 397 | return self._deserialize_by_content_type(response.content, accept_type) 398 | 399 | def _get_public_info(self, orcid_id, request_type, access_token, put_code, 400 | accept_type): 401 | request_url = '%s/%s/%s' % (self._endpoint + VERSION, 402 | orcid_id, request_type) 403 | if put_code: 404 | if request_type in self.TYPES_WITH_MULTIPLE_PUTCODES: 405 | request_url += '/%s' % ','.join(put_code) 406 | else: 407 | request_url += '/%s' % put_code 408 | headers = {'Accept': accept_type, 409 | 'Authorization': 'Bearer %s' % access_token} 410 | return requests.get(request_url, headers=headers, 411 | timeout=self._timeout) 412 | 413 | def _search(self, query, method, start, rows, headers, 414 | endpoint): 415 | url = endpoint + SEARCH_VERSION + \ 416 | "/search/?defType=" + method + "&q=" + query 417 | if start: 418 | url += "&start=%s" % start 419 | if rows: 420 | url += "&rows=%s" % rows 421 | 422 | response = requests.get(url, headers=headers, 423 | timeout=self._timeout) 424 | response.raise_for_status() 425 | if self.do_store_raw_response: 426 | self.raw_response = response 427 | return response.json() 428 | 429 | def _deserialize_by_content_type(self, data, content_type): 430 | if content_type == 'application/orcid+json': 431 | return json.loads(data) 432 | if content_type == 'application/orcid+xml': 433 | return etree.XML(data) 434 | raise NotImplementedError('No deserializer for content of type %s' 435 | % content_type) 436 | 437 | 438 | class MemberAPI(PublicAPI): 439 | """Member API.""" 440 | 441 | def __init__(self, institution_key, institution_secret, sandbox=False, 442 | timeout=None, do_store_raw_response=False): 443 | """Initialize member API. 444 | 445 | Parameters 446 | ---------- 447 | :param institution_key: string 448 | The ORCID key given to the institution 449 | :param institution_secret: string 450 | The ORCID secret given to the institution 451 | :param sandbox: boolean 452 | Should the sandbox be used. False (default) indicates production 453 | mode. 454 | :param timeout: float or tuple 455 | The request timeout in seconds. If None, no timeout is used. See 456 | `requests documentation 457 | `_ 458 | for more information. 459 | """ 460 | super(MemberAPI, self).__init__(institution_key, 461 | institution_secret, sandbox, timeout) 462 | self.raw_response = None 463 | self.do_store_raw_response = do_store_raw_response 464 | if sandbox: 465 | self._endpoint = "https://api.sandbox.orcid.org" 466 | self._auth_url = 'https://sandbox.orcid.org/signin/auth.json' 467 | self._authorize_url = \ 468 | 'https://sandbox.orcid.org/oauth/custom/authorize.json' 469 | else: 470 | self._endpoint = "https://api.orcid.org" 471 | self._auth_url = 'https://orcid.org/signin/auth.json' 472 | self._authorize_url = \ 473 | 'https://orcid.org/oauth/custom/authorize.json' 474 | 475 | def add_record(self, orcid_id, token, request_type, data, 476 | content_type='application/orcid+json'): 477 | """Add a record to a profile. 478 | 479 | Parameters 480 | ---------- 481 | :param orcid_id: string 482 | Id of the author. 483 | :param token: string 484 | Token received from OAuth 2 3-legged authorization. 485 | :param request_type: string 486 | One of 'activities', 'education', 'employment', 'funding', 487 | 'peer-review', 'work'. 488 | :param data: dict | lxml.etree._Element 489 | The record in Python-friendly format, as either JSON-compatible 490 | dictionary (content_type == 'application/orcid+json') or 491 | XML (content_type == 'application/orcid+xml') 492 | :param content_type: string 493 | MIME type of the passed record. 494 | 495 | Returns 496 | ------- 497 | :returns: string 498 | Put-code of the new work. 499 | """ 500 | return self._update_activities(orcid_id, token, requests.post, 501 | request_type, data, 502 | content_type=content_type) 503 | 504 | def get_token(self, user_id, password, redirect_uri, 505 | scope='/activities/update'): 506 | """Get the token. 507 | 508 | Parameters 509 | ---------- 510 | :param user_id: string 511 | 512 | The id of the user used for authentication. 513 | :param password: string 514 | The user password. 515 | :param redirect_uri: string 516 | The redirect uri of the institution. 517 | :param scope: string 518 | The desired scope. For example '/activities/update', 519 | '/read-limited', etc. 520 | 521 | Returns 522 | ------- 523 | :returns: string 524 | The token. 525 | """ 526 | return super(MemberAPI, self).get_token(user_id, password, 527 | redirect_uri, scope) 528 | 529 | def get_user_orcid(self, user_id, password, redirect_uri): 530 | """Get the user orcid from authentication process. 531 | 532 | Parameters 533 | ---------- 534 | :param user_id: string 535 | The id of the user used for authentication. 536 | :param password: string 537 | The user password. 538 | :param redirect_uri: string 539 | The redirect uri of the institution. 540 | 541 | Returns 542 | ------- 543 | :returns: string 544 | The orcid. 545 | """ 546 | response = self._authenticate(user_id, password, redirect_uri, 547 | '/authenticate') 548 | 549 | return response['orcid'] 550 | 551 | def read_record_member(self, orcid_id, request_type, token, put_code=None, 552 | accept_type='application/orcid+json'): 553 | """Get the member info about the researcher. 554 | 555 | Parameters 556 | ---------- 557 | :param orcid_id: string 558 | Id of the queried author. 559 | :param request_type: string 560 | For example: 'record'. 561 | See https://members.orcid.org/api/tutorial/read-orcid-records 562 | for possible values.. 563 | :param response_format: string 564 | One of json, xml. 565 | :param token: string 566 | Token received from OAuth 2 3-legged authorization. 567 | :param put_code: string | list of strings 568 | The id of the queried work. In case of 'works' request_type 569 | might be a list of strings 570 | :param accept_type: expected MIME type of received data 571 | 572 | Returns 573 | ------- 574 | :returns: dict | lxml.etree._Element 575 | Record(s) in JSON-compatible dictionary representation or 576 | in XML E-tree, depending on accept_type specified. 577 | """ 578 | return self._get_info(orcid_id, self._get_member_info, request_type, 579 | token, put_code, accept_type) 580 | 581 | def remove_record(self, orcid_id, token, request_type, put_code): 582 | """Add a record to a profile. 583 | 584 | Parameters 585 | ---------- 586 | :param orcid_id: string 587 | Id of the author. 588 | :param token: string 589 | Token received from OAuth 2 3-legged authorization. 590 | :param request_type: string 591 | One of 'activities', 'education', 'employment', 'funding', 592 | 'peer-review', 'work'. 593 | :param put_code: string 594 | The id of the record. Can be retrieved using read_record_* method. 595 | In the result of it, it will be called 'put-code'. 596 | """ 597 | self._update_activities(orcid_id, token, requests.delete, request_type, 598 | put_code=put_code) 599 | 600 | def search(self, query, method="lucene", start=None, rows=None, 601 | access_token=None): 602 | """Search the ORCID database. 603 | 604 | Parameters 605 | ---------- 606 | :param query: string 607 | Query in line with the chosen method. 608 | :param method: string 609 | One of 'lucene', 'edismax', 'dismax' 610 | :param start: string 611 | Index of the first record requested. Use for pagination. 612 | :param rows: string 613 | Number of records requested. Use for pagination. 614 | :param access_token: string 615 | If obtained before, the access token to use to pass through 616 | authorization. Note that if this argument is not provided, 617 | the function will take more time. 618 | 619 | Returns 620 | ------- 621 | :returns: dict 622 | Search result with error description available. The results can 623 | be obtained by accessing key 'result'. 624 | To get the number of all results, access the key 'num-found'. 625 | """ 626 | if access_token is None: 627 | access_token = self. \ 628 | get_search_token_from_orcid() 629 | 630 | headers = {'Accept': 'application/orcid+json', 631 | 'Authorization': 'Bearer %s' % access_token} 632 | 633 | return self._search(query, method, start, rows, headers, 634 | self._endpoint) 635 | 636 | def search_generator(self, query, method="lucene", pagination=10, 637 | access_token=None): 638 | """Search the ORCID database with a generator. 639 | 640 | The generator will yield every result. 641 | 642 | Parameters 643 | ---------- 644 | :param query: string 645 | Query in line with the chosen method. 646 | :param method: string 647 | One of 'lucene', 'edismax', 'dismax' 648 | :param pagination: integer 649 | How many papers should be fetched with the request. 650 | :param access_token: string 651 | If obtained before, the access token to use to pass through 652 | authorization. Note that if this argument is not provided, 653 | the function will take more time. 654 | """ 655 | if access_token is None: 656 | access_token = self. \ 657 | get_search_token_from_orcid() 658 | 659 | headers = {'Accept': 'application/orcid+json', 660 | 'Authorization': 'Bearer %s' % access_token} 661 | 662 | index = 0 663 | 664 | while True: 665 | paginated_result = self._search(query, method, index, pagination, 666 | headers, self._endpoint) 667 | if not paginated_result['result']: 668 | return 669 | 670 | for result in paginated_result['result']: 671 | yield result 672 | index += pagination 673 | 674 | def update_record(self, orcid_id, token, request_type, data, put_code, 675 | content_type='application/orcid+json'): 676 | """Add a record to a profile. 677 | 678 | Parameters 679 | ---------- 680 | :param orcid_id: string 681 | Id of the author. 682 | :param token: string 683 | Token received from OAuth 2 3-legged authorization. 684 | :param request_type: string 685 | One of 'activities', 'education', 'employment', 'funding', 686 | 'peer-review', 'work'. 687 | :param data: dict | lxml.etree._Element 688 | The record in Python-friendly format, as either JSON-compatible 689 | dictionary (content_type == 'application/orcid+json') or 690 | XML (content_type == 'application/orcid+xml') 691 | :param put_code: string 692 | The id of the record. Can be retrieved using read_record_* method. 693 | In the result of it, it will be called 'put-code'. 694 | :param content_type: string 695 | MIME type of the data being sent. 696 | """ 697 | self._update_activities(orcid_id, token, requests.put, request_type, 698 | data, put_code, content_type) 699 | 700 | def _get_member_info(self, orcid_id, request_type, access_token, put_code, 701 | accept_type): 702 | request_url = '%s/%s/%s' % (self._endpoint + VERSION, 703 | orcid_id, request_type) 704 | if put_code: 705 | if request_type in self.TYPES_WITH_MULTIPLE_PUTCODES: 706 | request_url += '/%s' % ','.join(put_code) 707 | else: 708 | request_url += '/%s' % put_code 709 | headers = {'Accept': accept_type, 710 | 'Authorization': 'Bearer %s' % access_token} 711 | return requests.get(request_url, headers=headers, 712 | timeout=self._timeout) 713 | 714 | def _update_activities(self, orcid_id, token, method, request_type, 715 | data=None, put_code=None, 716 | content_type='application/orcid+json'): 717 | url = "%s/%s/%s" % (self._endpoint + VERSION, orcid_id, 718 | request_type) 719 | 720 | if put_code: 721 | url += ('/%s' % put_code) 722 | if data is not None: 723 | self._add_put_code_by_content_type(content_type, data, 724 | put_code) 725 | 726 | headers = {'Accept': 'application/orcid+json', 727 | 'Content-Type': content_type, 728 | 'Authorization': 'Bearer ' + token} 729 | 730 | if method == requests.delete: 731 | response = method(url, headers=headers, timeout=self._timeout) 732 | else: 733 | xml = self._serialize_by_content_type(data, content_type) 734 | response = method(url, xml, headers=headers, timeout=self._timeout) 735 | 736 | response.raise_for_status() 737 | if self.do_store_raw_response: 738 | self.raw_response = response 739 | 740 | if 'location' in response.headers: 741 | # Return the new put-code 742 | return response.headers['location'].split('/')[-1] 743 | 744 | def _add_put_code_by_content_type(self, content_type, data, put_code): 745 | if content_type == 'application/orcid+json': 746 | data['put-code'] = put_code 747 | elif content_type == 'application/orcid+xml': 748 | data.attrib['put-code'] = '%s' % put_code 749 | else: 750 | raise NotImplementedError('Cannot add to content of type %s' 751 | % content_type) 752 | 753 | def _serialize_by_content_type(self, data, content_type): 754 | if content_type == 'application/orcid+json': 755 | return json.dumps(data) 756 | if content_type == 'application/orcid+xml': 757 | return etree.tostring(data) 758 | raise NotImplementedError('No serializer for content of type %s' 759 | % content_type) 760 | -------------------------------------------------------------------------------- /orcid/testsuite/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for ORCID library.""" 2 | -------------------------------------------------------------------------------- /orcid/testsuite/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Constants and helpers for python-orcid library.""" 3 | 4 | from lxml.etree import XML 5 | 6 | WORK_NAME2 = u'WY51MF0OCMU37MVGMUX1M92G6FR1IQUW0' 7 | WORK_NAME3 = u'BN237DFI2GF69DFH4Y60FK2J48DF0SKJ5' 8 | 9 | exemplary_work = { 10 | "title": { 11 | "title": { 12 | "value": WORK_NAME2 13 | }, 14 | }, 15 | "journal-title": { 16 | "value": "journal # 1" 17 | }, 18 | "type": "JOURNAL_ARTICLE", 19 | "external-ids": { 20 | "external-id": [{ 21 | "external-id-type": "doi", 22 | "external-id-value": "next-id-2", 23 | "external-id-url": { 24 | "value": "http://dx.doi.org/ext-id-1" 25 | }, 26 | "external-id-relationship": "SELF" 27 | }] 28 | } 29 | } 30 | 31 | exemplary_work_xml = XML(""" 32 | 35 | 36 | """ + WORK_NAME3 + """ 37 | 38 | journal # 2 39 | journal-article 40 | 41 | 1961 42 | 43 | 44 | 45 | doi 46 | ext-id-3 47 | http://dx.doi.org/ext-id-3 48 | self 49 | 50 | 51 | 52 | """) 53 | -------------------------------------------------------------------------------- /orcid/testsuite/test_orcid.py: -------------------------------------------------------------------------------- 1 | """Tests for ORCID library.""" 2 | 3 | import os 4 | import pytest 5 | import re 6 | from uuid import uuid4 7 | 8 | from orcid import MemberAPI 9 | from orcid import PublicAPI 10 | from time import sleep 11 | from requests.exceptions import Timeout 12 | from requests.models import Response 13 | 14 | from .helpers import exemplary_work, exemplary_work_xml 15 | from .helpers import WORK_NAME2, WORK_NAME3 16 | 17 | CLIENT_KEY = os.environ['CLIENT_KEY'] 18 | CLIENT_SECRET = os.environ['CLIENT_SECRET'] 19 | USER_PASSWORD = os.environ['USER_PASSWORD'] 20 | USER_EMAIL = os.environ['USER_EMAIL'] 21 | REDIRECT_URL = os.environ['REDIRECT_URL'] 22 | USER_ORCID = os.environ['USER_ORCID'] 23 | TOKEN_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' 24 | WORK_NAME = u'WY51MF0OCMU37MVGMUX1M92G6FR1IQUW' 25 | 26 | 27 | def assert_with_retry(assertion, retries=3): 28 | for i in range(retries): 29 | try: 30 | assert assertion() 31 | return 32 | except AssertionError: 33 | if i == retries - 1: 34 | raise 35 | sleep(5) 36 | 37 | 38 | def fullmatch(regex, string, flags=0): 39 | """Emulate python-3.4 re.fullmatch().""" 40 | return re.match("(?:" + regex + r")\Z", string, flags=flags) 41 | 42 | 43 | @pytest.fixture 44 | def publicAPI(): 45 | """Get PublicAPI handler.""" 46 | return PublicAPI(sandbox=True, 47 | institution_key=CLIENT_KEY, 48 | institution_secret=CLIENT_SECRET) 49 | 50 | 51 | @pytest.fixture 52 | def publicAPIStoreResponse(): 53 | """Get PublicAPI handler that stores request.""" 54 | return PublicAPI(sandbox=True, 55 | institution_key=CLIENT_KEY, 56 | institution_secret=CLIENT_SECRET, 57 | do_store_raw_response=True) 58 | 59 | 60 | def test_get_login_url(publicAPI): 61 | """Test constructing a login/registration URL.""" 62 | redirect_uri = "https://www.inspirehep.net" 63 | expected_url = "https://sandbox.orcid.org/oauth/authorize?client_id=" + \ 64 | CLIENT_KEY + \ 65 | "&scope=%2Forcid-profile%2Fread-limited&response_type=code&" \ 66 | "redirect_uri=https%3A%2F%2Fwww.inspirehep.net" 67 | assert publicAPI.get_login_url("/orcid-profile/read-limited", 68 | redirect_uri) == expected_url 69 | assert publicAPI.get_login_url(["/orcid-profile/read-limited"], 70 | redirect_uri) == expected_url 71 | assert publicAPI.get_login_url(2 * ["/orcid-profile/read-limited"], 72 | redirect_uri) == expected_url 73 | import sys 74 | if sys.version_info[0] == 2: 75 | family_names = "M\xc3\xb6\xc3\x9fbauer".decode("utf-8") 76 | else: 77 | family_names = "M\xf6\xdfbauer" 78 | kwargs = {"state": "F0OCMU37MV3GMUX1", 79 | "family_names": family_names, "given_names": "Rudolf Ludwig", 80 | "email": "r.moessbauer@example.com", 81 | "lang": "en", "show_login": True} 82 | assert publicAPI.get_login_url( 83 | ["/orcid-profile/read-limited", "/affiliations/create", 84 | "/orcid-works/create"], redirect_uri, **kwargs) == \ 85 | "https://sandbox.orcid.org/oauth/authorize?client_id=" + CLIENT_KEY + \ 86 | "&scope=%2Faffiliations%2Fcreate+%2Forcid-profile%2Fread-limited+" \ 87 | "%2Forcid-works%2Fcreate&response_type=code&" \ 88 | "redirect_uri=https%3A%2F%2Fwww.inspirehep.net&" \ 89 | "state=F0OCMU37MV3GMUX1&family_names=M%C3%B6%C3%9Fbauer&" \ 90 | "given_names=Rudolf+Ludwig&email=r.moessbauer%40example.com&" \ 91 | "lang=en&show_login=true" 92 | 93 | 94 | def test_read_record_public(publicAPI): 95 | """Test reading records.""" 96 | token = publicAPI.get_token(USER_EMAIL, 97 | USER_PASSWORD, 98 | REDIRECT_URL, 99 | '/read-limited') 100 | 101 | activities = publicAPI.read_record_public(USER_ORCID, 102 | 'activities', 103 | token) 104 | first_work = activities['works']['group'][0]['work-summary'][0] 105 | assert first_work['title'][ 106 | 'title']['value'] == WORK_NAME 107 | put_code = first_work['put-code'] 108 | work = publicAPI.read_record_public(USER_ORCID, 'work', token, 109 | put_code) 110 | assert work['type'] == u'JOURNAL_ARTICLE' 111 | 112 | with pytest.raises(ValueError) as excinfo: 113 | publicAPI.read_record_public(USER_ORCID, 'activities', token, '1') 114 | assert 'argument is redundant' in str(excinfo.value) 115 | 116 | with pytest.raises(ValueError) as excinfo: 117 | publicAPI.read_record_public(USER_ORCID, 'work', token) 118 | assert "please specify the 'put_code' argument" in str(excinfo.value) 119 | 120 | 121 | def test_search_public(publicAPI): 122 | """Test search_public.""" 123 | 124 | results = publicAPI.search('text:%s' % WORK_NAME) 125 | assert results['result'][0]['orcid-identifier']['path'] == USER_ORCID 126 | 127 | results = publicAPI.search('family-name:Sanchez', start=2, rows=6) 128 | # Just check if the request suceeded 129 | 130 | assert results['num-found'] > 0 131 | 132 | 133 | def test_search_public_generator(publicAPI): 134 | """Test search public with a generator.""" 135 | 136 | results = publicAPI.search('text:%s' % WORK_NAME) 137 | assert results['result'][0]['orcid-identifier']['path'] == USER_ORCID 138 | 139 | generator = publicAPI.search_generator('family-name:Sanchez') 140 | result = next(generator) 141 | result = next(generator) 142 | # Just check if the request suceeded 143 | 144 | assert 'orcid-identifier' in result 145 | 146 | 147 | def test_search_public_generator_no_results(publicAPI): 148 | generator = publicAPI.search_generator('family-name:' + 149 | str(uuid4())) 150 | 151 | with pytest.raises(StopIteration): 152 | next(generator) 153 | 154 | 155 | def test_search_public_generator_pagination(publicAPI): 156 | generator = publicAPI.search_generator('family-name:Sanchez', 157 | pagination=1) 158 | result = next(generator) 159 | result = next(generator) 160 | # Just check if the request suceeded 161 | 162 | assert 'orcid-identifier' in result 163 | 164 | 165 | def test_publicapi_http_response(publicAPIStoreResponse): 166 | publicAPIStoreResponse.get_token(USER_EMAIL, 167 | USER_PASSWORD, 168 | REDIRECT_URL, 169 | '/read-limited') 170 | assert isinstance(publicAPIStoreResponse.raw_response, Response) 171 | assert publicAPIStoreResponse.raw_response.status_code == 200 172 | 173 | 174 | @pytest.fixture 175 | def memberAPI(): 176 | """Get memberAPI handler.""" 177 | return MemberAPI(CLIENT_KEY, CLIENT_SECRET, 178 | sandbox=True) 179 | 180 | 181 | @pytest.fixture 182 | def memberAPIStoreResponse(): 183 | """Get memberAPI handler that stores response.""" 184 | return MemberAPI(CLIENT_KEY, 185 | CLIENT_SECRET, 186 | sandbox=True, 187 | do_store_raw_response=True) 188 | 189 | 190 | def test_apis_common_functionalities(memberAPI): 191 | """Check if the member API has functionalities of the other apis.""" 192 | assert hasattr(getattr(memberAPI, 'search'), '__call__') 193 | assert hasattr(getattr(memberAPI, 'get_token'), '__call__') 194 | 195 | 196 | def test_search_member(memberAPI): 197 | """Test search_member.""" 198 | results = memberAPI.search('text:%s' % WORK_NAME) 199 | assert results['result'][0]['orcid-identifier']['path'] == USER_ORCID 200 | 201 | 202 | def test_search_member_generator(memberAPI): 203 | """Test search_member with generator.""" 204 | generator = memberAPI.search_generator('text:%s' % WORK_NAME) 205 | results = next(generator) 206 | assert results['orcid-identifier']['path'] == USER_ORCID 207 | 208 | 209 | def test_read_record_member(memberAPI): 210 | """Test reading records.""" 211 | token = memberAPI.get_token(USER_EMAIL, 212 | USER_PASSWORD, 213 | REDIRECT_URL, 214 | '/read-limited') 215 | activities = memberAPI.read_record_member(USER_ORCID, 216 | 'activities', 217 | token) 218 | first_work = activities['works']['group'][0]['work-summary'][0] 219 | assert first_work['title'][ 220 | 'title']['value'] == WORK_NAME 221 | put_code = first_work['put-code'] 222 | work = memberAPI.read_record_member(USER_ORCID, 'work', token, 223 | put_code) 224 | assert work['type'] == u'JOURNAL_ARTICLE' 225 | 226 | 227 | def test_read_record_member_xml(memberAPI): 228 | """Test reading records from XML.""" 229 | token = memberAPI.get_token(USER_EMAIL, 230 | USER_PASSWORD, 231 | REDIRECT_URL, 232 | '/read-limited') 233 | activities = memberAPI.read_record_member( 234 | USER_ORCID, 'activities', token, accept_type='application/orcid+xml') 235 | 236 | first_work = activities.xpath( 237 | '/activities:activities-summary/activities:works' 238 | '/activities:group/work:work-summary', 239 | namespaces=activities.nsmap 240 | )[0] 241 | 242 | first_work_title = first_work.xpath( 243 | 'work:title/common:title', 244 | namespaces=activities.nsmap 245 | )[0].text 246 | 247 | assert first_work_title == WORK_NAME 248 | 249 | put_code = first_work.attrib['put-code'] 250 | work = memberAPI.read_record_member(USER_ORCID, 'work', token, 251 | put_code, 252 | accept_type='application/orcid+xml') 253 | 254 | work_type = work.xpath( 255 | '/work:work/work:type', 256 | namespaces=work.nsmap 257 | )[0].text 258 | 259 | assert work_type == u'journal-article' 260 | 261 | 262 | def test_work_simple(memberAPI): 263 | """Test adding, updating and removing an example of a simple work.""" 264 | 265 | def get_added_works(token): 266 | activities = memberAPI.read_record_member(USER_ORCID, 267 | 'activities', 268 | token) 269 | return list(filter(lambda x: x['work-summary'][ 270 | 0]['title']['title'][ 271 | 'value'] == WORK_NAME2, 272 | activities['works']['group'])) 273 | 274 | # Add 275 | work = exemplary_work 276 | token = memberAPI.get_token(USER_EMAIL, 277 | USER_PASSWORD, 278 | REDIRECT_URL) 279 | 280 | memberAPI.add_record(USER_ORCID, token, 'work', work) 281 | 282 | assert_with_retry(lambda: len(get_added_works(token)) == 1) 283 | added_works = get_added_works(token) 284 | assert added_works[0]['work-summary'][0]['type'] == u'JOURNAL_ARTICLE' 285 | put_code = added_works[0]['work-summary'][0]['put-code'] 286 | 287 | # Update 288 | work['type'] = 'OTHER' 289 | 290 | memberAPI.update_record(USER_ORCID, token, 'work', work, put_code) 291 | 292 | assert_with_retry(lambda: len(get_added_works(token)) == 1) 293 | 294 | # Remove 295 | memberAPI.remove_record(USER_ORCID, token, 296 | 'work', put_code) 297 | assert_with_retry(lambda: len(get_added_works(token)) == 0) 298 | 299 | 300 | def test_work_simple_xml(memberAPI): 301 | """Test adding, updating, removing an example of a simple work in XML.""" 302 | 303 | def get_added_works(token): 304 | activities = memberAPI.read_record_member( 305 | USER_ORCID, 'activities', token, 306 | accept_type='application/orcid+xml') 307 | xpath = "/activities:activities-summary/activities:works/" \ 308 | "activities:group/work:work-summary/work:title/" \ 309 | "common:title[text() = '%s']/../.." % WORK_NAME3 310 | return activities.xpath(xpath, namespaces=activities.nsmap) 311 | 312 | # Add 313 | work = exemplary_work_xml 314 | token = memberAPI.get_token(USER_EMAIL, 315 | USER_PASSWORD, 316 | REDIRECT_URL) 317 | 318 | memberAPI.add_record(USER_ORCID, token, 'work', work, 319 | content_type='application/orcid+xml') 320 | 321 | assert_with_retry(lambda: len(get_added_works(token)) == 1) 322 | added_works = get_added_works(token) 323 | 324 | added_work = added_works[0] 325 | added_work_title = added_work.xpath("work:type", 326 | namespaces=added_work.nsmap)[0].text 327 | assert added_work_title == u'journal-article' 328 | put_code = added_work.attrib['put-code'] 329 | 330 | # Update 331 | work.xpath("work:type", namespaces=added_work.nsmap)[0].text = 'other' 332 | 333 | memberAPI.update_record(USER_ORCID, token, 'work', work, put_code, 334 | content_type='application/orcid+xml') 335 | 336 | assert_with_retry(lambda: len(get_added_works(token)) == 1) 337 | 338 | # Remove 339 | memberAPI.remove_record(USER_ORCID, token, 340 | 'work', put_code) 341 | assert_with_retry(lambda: len(get_added_works(token)) == 0) 342 | 343 | 344 | def test_get_orcid(memberAPI): 345 | """Test fetching user id from authentication.""" 346 | orcid = memberAPI.get_user_orcid(USER_EMAIL, 347 | USER_PASSWORD, 348 | REDIRECT_URL) 349 | assert orcid == USER_ORCID 350 | 351 | 352 | def test_get_token(memberAPI): 353 | """Test getting token.""" 354 | token = memberAPI.get_token(USER_EMAIL, 355 | USER_PASSWORD, 356 | REDIRECT_URL) 357 | 358 | assert fullmatch(TOKEN_RE, token) is not None 359 | 360 | 361 | def test_memberapi_http_response(memberAPIStoreResponse): 362 | memberAPIStoreResponse.get_token(USER_EMAIL, 363 | USER_PASSWORD, 364 | REDIRECT_URL) 365 | assert isinstance(memberAPIStoreResponse.raw_response, Response) 366 | assert memberAPIStoreResponse.raw_response.status_code == 200 367 | 368 | 369 | def test_timeout(): 370 | publicAPI = PublicAPI(sandbox=True, 371 | institution_key=CLIENT_KEY, 372 | institution_secret=CLIENT_SECRET, 373 | timeout=0.000001) 374 | 375 | with pytest.raises(Timeout): 376 | publicAPI.get_token(USER_EMAIL, 377 | USER_PASSWORD, 378 | REDIRECT_URL, 379 | '/read-limited') 380 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --ignore=setup.py -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup file for python-orcid.""" 2 | 3 | from setuptools import setup 4 | from setuptools.command.test import test as TestCommand 5 | 6 | 7 | class PyTest(TestCommand): 8 | 9 | def finalize_options(self): 10 | TestCommand.finalize_options(self) 11 | self.test_args = [] 12 | self.test_suite = True 13 | 14 | def run_tests(self): 15 | import pytest 16 | errno = pytest.main(self.test_args) 17 | raise SystemExit(errno) 18 | 19 | setup(author='Mateusz Susik', 20 | author_email='mateuszsusik@protonmail.ch', 21 | classifiers=[ 22 | 'Development Status :: 4 - Beta', 23 | 'Intended Audience :: Developers', 24 | 'Intended Audience :: Science/Research', 25 | 'License :: OSI Approved :: BSD License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python', 28 | 'Programming Language :: Python :: 2', 29 | 'Programming Language :: Python :: 2.6', 30 | 'Programming Language :: Python :: 2.7', 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3.3', 33 | 'Programming Language :: Python :: 3.4', 34 | 'Programming Language :: Python :: 3.5', 35 | 'Programming Language :: Python :: 3.6', 36 | 'Topic :: Internet', 37 | 'Topic :: Utilities' 38 | ], 39 | cmdclass={'test': PyTest}, 40 | description='A python wrapper over the ORCID API', 41 | install_requires=['html5lib', 'beautifulsoup4', 'requests', 'simplejson', 'lxml'], 42 | keywords=['orcid', 'api', 'wrapper'], 43 | license='BSD', 44 | long_description=open('README.rst', 'r').read(), 45 | name='orcid', 46 | packages=['orcid'], 47 | tests_require=['pytest', 'coverage', 'httpretty'], 48 | url='https://github.com/ORCID/python-orcid', 49 | version='1.0.3' 50 | ) 51 | --------------------------------------------------------------------------------