Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:

- name: Install dependencies
run: |
pip install vcrpy pytest==7.4.2 requests pytest-mock python-documentcloud pytest-xdist pytest-recording
pip install vcrpy pytest==7.4.2 requests pytest-mock python-documentcloud pytest-xdist pytest-recording python-squarelet

- name: Run pre-recorded tests
run: |
Expand All @@ -45,7 +45,7 @@ jobs:

- name: Install dependencies for imports
run: |
pip install python-dateutil requests urllib3 fastjsonschema ratelimit listcrunch pyyaml pytest vcrpy
pip install python-dateutil requests urllib3 fastjsonschema ratelimit listcrunch pyyaml pytest vcrpy python-squarelet

- name: Install pylint and black
run: |
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Changelog
---------

4.3.0
~~~~~
* Uses python-squarelet to client handle authentication.

4.1.3
~~~~~
* Fix DELETE URL for documents
Expand Down
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@
# built documents.
#
# The short X.Y version.
version = "4.0"
version = "4.3"
# The full version, including alpha/beta/rc tags.
release = "4.0.1"
release = "4.3.0"

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
167 changes: 26 additions & 141 deletions documentcloud/client.py
Original file line number Diff line number Diff line change
@@ -1,177 +1,62 @@
"""
The public interface for the DocumentCloud API
"""

# Import SquareletClient from python-squarelet
# Standard Library
import logging
from functools import partial
from urllib.parse import parse_qs, urlparse

# Third Party
import ratelimit
import requests
from squarelet import SquareletClient

# Local
from .constants import AUTH_URI, BASE_URI, RATE_LIMIT, RATE_PERIOD, TIMEOUT
# Local Imports
from .documents import DocumentClient
from .exceptions import APIError, CredentialsFailedError, DoesNotExistError
from .organizations import OrganizationClient
from .projects import ProjectClient
from .toolbox import requests_retry_session
from .users import UserClient

logger = logging.getLogger("documentcloud")


class DocumentCloud(object):
class DocumentCloud(SquareletClient):
"""
The public interface for the DocumentCloud API
The public interface for the DocumentCloud API, now integrated with SquareletClient
"""

# pylint:disable=too-many-positional-arguments
def __init__(
self,
username=None,
password=None,
base_uri=BASE_URI,
auth_uri=AUTH_URI,
timeout=TIMEOUT,
base_uri="https://api.www.documentcloud.org/api/",
auth_uri="https://accounts.muckrock.com/api/",
timeout=20,
loglevel=None,
rate_limit=True,
rate_limit_sleep=True,
):
self.base_uri = base_uri
self.auth_uri = auth_uri
self.username = username
self.password = password
self._user_id = None
self.timeout = timeout
self.refresh_token = None
self.session = requests.Session()
self._set_tokens()
# Initialize SquareletClient for authentication and request handling
super().__init__(
base_uri=base_uri,
username=username,
password=password,
auth_uri=auth_uri,
timeout=timeout,
rate_limit=rate_limit,
rate_limit_sleep=rate_limit_sleep
)

if loglevel: # pragma: no cover
# Set up logging
if loglevel:
logging.basicConfig(
level=loglevel,
format="%(asctime)s %(levelname)-8s %(name)-25s %(message)s",
)
else:
logger.addHandler(logging.NullHandler())

# Initialize the sub-clients using SquareletClient
self.documents = DocumentClient(self)
self.projects = ProjectClient(self)
self.users = UserClient(self)
self.organizations = OrganizationClient(self)

if rate_limit:
self._request = ratelimit.limits(calls=RATE_LIMIT, period=RATE_PERIOD)(
self._request
)
if rate_limit_sleep:
self._request = ratelimit.sleep_and_retry(self._request)

def _set_tokens(self):
"""Set the refresh and access tokens"""
if self.refresh_token:
access_token, self.refresh_token = self._refresh_tokens(self.refresh_token)
elif self.username and self.password:
access_token, self.refresh_token = self._get_tokens(
self.username, self.password
)
else:
access_token = None

if access_token:
self.session.headers.update({"Authorization": f"Bearer {access_token}"})

def _get_tokens(self, username, password):
"""Get an access and refresh token in exchange for the username and password"""
response = requests_retry_session().post(
f"{self.auth_uri}token/",
json={"username": username, "password": password},
timeout=self.timeout,
)

if response.status_code == requests.codes.UNAUTHORIZED:
raise CredentialsFailedError("The username and password are incorrect")

self.raise_for_status(response)

json = response.json()
return (json["access"], json["refresh"])

def _refresh_tokens(self, refresh_token):
"""Refresh the access and refresh tokens"""
response = requests_retry_session().post(
f"{self.auth_uri}refresh/",
json={"refresh": refresh_token},
timeout=self.timeout,
)

if response.status_code == requests.codes.UNAUTHORIZED:
# refresh token is expired
return self._get_tokens(self.username, self.password)

self.raise_for_status(response)

json = response.json()
return (json["access"], json["refresh"])

@property
def user_id(self):
if self._user_id is None:
user = self.users.get("me")
self._user_id = user.id
return self._user_id

def _request(self, method, url, raise_error=True, **kwargs):
"""Generic method to make API requests"""
# pylint: disable=method-hidden
logger.info("request: %s - %s - %s", method, url, kwargs)
set_tokens = kwargs.pop("set_tokens", True)
full_url = kwargs.pop("full_url", False)

if not full_url:
url = f"{self.base_uri}{url}"

# set the API to version 2.0
parsed_url = urlparse(url)
if "version" not in parse_qs(parsed_url.query):
# check to avoid double setting version
kwargs.setdefault("params", {}).update({"version": "2.0"})

response = requests_retry_session(session=self.session).request(
method, url, timeout=self.timeout, **kwargs
)
logger.debug("response: %s - %s", response.status_code, response.content)
if (
response.status_code in [requests.codes.FORBIDDEN, requests.codes.TOO_MANY]
and set_tokens
):
self._set_tokens()
# track set_tokens to not enter an infinite loop
kwargs["set_tokens"] = False
return self._request(method, url, full_url=True, **kwargs)

if raise_error:
self.raise_for_status(response)

return response

def __getattr__(self, attr):
"""Generate methods for each HTTP request type"""
methods = ["get", "options", "head", "post", "put", "patch", "delete"]
if attr in methods:
return partial(self._request, attr)
raise AttributeError(
f"'{self.__class__.__name__}' object has no attribute '{attr}'"
)

def raise_for_status(self, response):
"""Raise for status with a custom error class"""
try:
response.raise_for_status()
except requests.exceptions.RequestException as exc:
if exc.response.status_code == 404:
raise DoesNotExistError(response=exc.response) from exc
else:
raise APIError(response=exc.response) from exc
"""def _request(self, method, url, raise_error=True, **kwargs):
Delegates request to the SquareletClient's _request method
return self.squarelet_client.request(method, url, raise_error, **kwargs)
"""
43 changes: 8 additions & 35 deletions documentcloud/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,11 @@
Custom exceptions for python-documentcloud
"""


class DocumentCloudError(Exception):
"""Base class for errors for python-documentcloud"""

def __init__(self, *args, **kwargs):
self.response = kwargs.pop("response", None)
if self.response is not None:
self.error = self.response.text
self.status_code = self.response.status_code
if not args:
args = [f"{self.status_code} - {self.error}"]
else:
self.error = None
self.status_code = None
super().__init__(*args, **kwargs)


class DuplicateObjectError(DocumentCloudError):
"""Raised when an object is added to a unique list more than once"""


class CredentialsFailedError(DocumentCloudError):
"""Raised if unable to obtain an access token due to bad login credentials"""


class APIError(DocumentCloudError):
"""Any other error calling the API"""


class DoesNotExistError(APIError):
"""Raised when the user asks the API for something it cannot find"""


class MultipleObjectsReturnedError(APIError):
"""Raised when the API returns multiple objects when it expected one"""
# pylint: disable=unused-import
# Import exceptions from python-squarelet
from squarelet.exceptions import SquareletError as DocumentCloudError
from squarelet.exceptions import DuplicateObjectError
from squarelet.exceptions import CredentialsFailedError
from squarelet.exceptions import APIError
from squarelet.exceptions import DoesNotExistError
from squarelet.exceptions import MultipleObjectsReturnedError
Loading
Loading