diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e8a93a..7a845a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,13 @@ -# Change Log +# Changelog All notable changes to this project will be documented here. +## [3.0.0] - 2024-05-14 +### Added +- Type hints for most functions +### Changed +- Now requires Python 3.11+ + ## [2.0.0] - 2019-10-14 ### Changed - When requesting a single resource using the dictionary way, only a single diff --git a/README.md b/README.md index fe268fd..300186f 100644 --- a/README.md +++ b/README.md @@ -14,19 +14,29 @@ In order to handle pagination calls to API are done inside a generator. As a consequence, even post and deletes have to be "nexted" if using the *hit_* method. +## Requirements + +Pythom 3.11+, `2.0.0` was the last version to support Python 2.7-3.10 + ## Installation -The package can be installed cloning the repository and doing -`python setup.py install` or `pip install .`. +Using PyPI into a [venv](https://docs.python.org/3/library/venv.html): +```bash +pip install python-helpscout-v2 --require-virtualenv` +``` + +Manually by cloning the repository and executing pip into a [venv](https://docs.python.org/3/library/venv.html): +```bash +pip install . --require-virtualenv` +``` -It can also be install from pypi.org doing `pip install python-helpscout-v2`. ## Authentication In order to use the API you need an app id and app secret. More about credentials can be found in -[helpscout's documentation](https://developer.helpscout.com/mailbox-api/overview/authentication/). +[Help Scout's documentation](https://developer.helpscout.com/mailbox-api/overview/authentication/). ## General use diff --git a/helpscout/client.py b/helpscout/client.py index 46a617c..29ff384 100644 --- a/helpscout/client.py +++ b/helpscout/client.py @@ -1,13 +1,18 @@ +# 3.13 defaults to this, TODO remove +from __future__ import annotations + import logging import time +import requests +import typing +from typing import overload -from functools import partial -try: # Python 3 - from urllib.parse import urljoin -except ImportError: # Python 2 - from urlparse import urljoin +if typing.TYPE_CHECKING: + from typing import Callable, Literal + from collections.abc import Generator -import requests +from functools import partial +from urllib.parse import urljoin from helpscout.exceptions import (HelpScoutException, HelpScoutAuthenticationException, @@ -22,10 +27,10 @@ class HelpScout: - def __init__(self, app_id, app_secret, - base_url='https://api.helpscout.net/v2/', - sleep_on_rate_limit_exceeded=True, - rate_limit_sleep=10): + def __init__(self, app_id:str, app_secret:str, + base_url:str='https://api.helpscout.net/v2/', + sleep_on_rate_limit_exceeded:bool=True, + rate_limit_sleep:int=10): """Help Scout API v2 client wrapper. The app credentials are created on the My App section in your profile. @@ -40,7 +45,7 @@ def __init__(self, app_id, app_secret, The application secret. base_url: str The API's base url. - sleep_on_rate_limit_exceeded: Boolean + sleep_on_rate_limit_exceeded: bool True to sleep and retry on rate limits exceeded. Otherwise raises an HelpScoutRateLimitExceededException exception. rate_limit_sleep: int @@ -54,7 +59,7 @@ def __init__(self, app_id, app_secret, self.rate_limit_sleep = rate_limit_sleep self.access_token = None - def __getattr__(self, endpoint): + def __getattr__(self, endpoint:str) -> HelpScoutEndpointRequester: """Returns a request to hit the API in a nicer way. E.g.: > client = HelpScout(app_id='asdasd', app_secret='1021') > client.conversations.get() @@ -76,19 +81,18 @@ def __getattr__(self, endpoint): """ return HelpScoutEndpointRequester(self, endpoint, False) - def get_objects(self, endpoint, resource_id=None, params=None, - specific_resource=False): + def get_objects(self, endpoint:str, resource_id:int|str|None=None, params:dict|str|None=None, specific_resource:bool=False) -> HelpScoutObject|list[HelpScoutObject]: """Returns the objects from the endpoint filtering by the parameters. Parameters ---------- endpoint: str One of the endpoints in the API. E.g.: conversations, mailboxes. - resource_id: int or str or None + resource_id: int | str | None The id of the resource in the endpoint to query. E.g.: in "GET /v2/conversations/123 HTTP/1.1" the id would be 123. If None is provided, nothing will be done - params: dict or str or None + params: dict | str | None Dictionary with the parameters to send to the url. Or the parameters already un url format. specific_resource: bool @@ -98,17 +102,16 @@ def get_objects(self, endpoint, resource_id=None, params=None, Returns ------- - [HelpScoutObject] + HelpScoutObject | list[HelpScoutObject] A list of objects returned by the api. """ cls = HelpScoutObject.cls(endpoint, endpoint) - results = cls.from_results( - self.hit_(endpoint, 'get', resource_id, params=params)) + results:list[HelpScoutObject] = cls.from_results( self.hit_(endpoint, 'get', resource_id, params=params) ) if resource_id is not None or specific_resource: return results[0] return results - def hit(self, endpoint, method, resource_id=None, data=None, params=None): + def hit(self, endpoint:str, method:str, resource_id:int|str|None=None, data:dict|None=None, params:dict|str|None=None) -> list[dict|None]: """Hits the api and returns all the data. If several calls are needed due to pagination, control won't be returned to the caller until all is retrieved. @@ -120,26 +123,26 @@ def hit(self, endpoint, method, resource_id=None, data=None, params=None): method: str The http method to hit the endpoint with. One of {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'} - resource_id: int or str or None + resource_id: int | str | None The id of the resource in the endpoint to query. E.g.: in "GET /v2/conversations/123 HTTP/1.1" the id would be 123. If None is provided, nothing will be done - data: dict or None + dict: dict | None A dictionary with the data to send to the API as json. - params: dict or str or None + params: dict | str | None Dictionary with the parameters to send to the url. Or the parameters already un url format. Returns ------- - [dict] or [None] - dict: when several objects are received from the API, a list of + list[dict] | list[None] + list: when several objects are received from the API, a list of dictionaries with HelpScout's _embedded data will be returned None if http 201 created or 204 no content are received. """ return list(self.hit_(endpoint, method, resource_id, data, params)) - def hit_(self, endpoint, method, resource_id=None, data=None, params=None): + def hit_(self, endpoint:str, method:str, resource_id:int|str|None=None, data:dict|None=None, params:dict|str|None=None) -> Generator[dict|None, None, None]: """Hits the api and yields the data. Parameters @@ -149,19 +152,19 @@ def hit_(self, endpoint, method, resource_id=None, data=None, params=None): method: str The http method to hit the endpoint with. One of {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'} - resource_id: int or str or None + resource_id: int | str | None The id of the resource in the endpoint to query. E.g.: in "GET /v2/conversations/123 HTTP/1.1" the id would be 123. If None is provided, nothing will be done - data: dict or None + data: dict | None A dictionary with the data to send to the API as json. - params: dict or str or None + params: dict | str | None Dictionary with the parameters to send to the url. Or the parameters already un url format. Yields ------ - dict or None + dict | None Dictionary with HelpScout's _embedded data. None if http 201 created or 204 no content are received. """ @@ -197,7 +200,7 @@ def hit_(self, endpoint, method, resource_id=None, data=None, params=None): else: raise HelpScoutException(r.text) - def _results_with_pagination(self, response, method): + def _results_with_pagination(self, response:dict, method:str) -> Generator[dict, None, None]: """Requests and yields pagination results. Parameters @@ -243,8 +246,7 @@ def _results_with_pagination(self, response, method): raise HelpScoutException(r.text) def _authenticate(self): - """Authenticates with the API and gets a token for subsequent requests. - """ + """Authenticates with the API and gets a token for subsequent requests.""" url = urljoin(self.base_url, 'oauth2/token') data = { 'grant_type': 'client_credentials', @@ -261,6 +263,8 @@ def _authenticate(self): def _authentication_headers(self): """Returns authentication headers.""" + if self.access_token is None: + raise HelpScoutAuthenticationException('Tried to get access_token without prior authentication') return { 'Authorization': 'Bearer ' + self.access_token, 'content-type': 'application/json', @@ -287,7 +291,7 @@ def __eq__(self, other): self.sleep_on_rate_limit_exceeded == other.sleep_on_rate_limit_exceeded) - def __repr__(self): + def __repr__(self) -> str: """Returns the object as a string.""" name = self.__class__.__name__ attrs = ( @@ -307,12 +311,12 @@ def __repr__(self): class HelpScoutEndpointRequester: - def __init__(self, client, endpoint, specific_resource): + def __init__(self, client:HelpScout, endpoint:str, specific_resource:bool): """Client wrapper to perform requester.get/post/put/patch/delete. Parameters ---------- - client: HelpScoutClient + client: HelpScout A help scout client instance to query the API. endpoint: str One of the endpoints in the API. E.g.: conversations, mailboxes. @@ -324,7 +328,13 @@ def __init__(self, client, endpoint, specific_resource): self.endpoint = endpoint self.specific_resource = specific_resource - def __getattr__(self, method): + @overload + def __getattr__(self, method: Literal['get']) -> Callable: ... + + @overload + def __getattr__(self, method:str) -> Callable | partial | HelpScoutEndpointRequester: ... + + def __getattr__(self, method:str) -> Callable | partial | HelpScoutEndpointRequester: """Catches http methods like get, post, patch, put and delete. Returns a subrequester when methods not named after http methods are requested, as this are considered attributes of the main object, like @@ -337,8 +347,8 @@ def __getattr__(self, method): Returns ------- - client.get_objects return value for the *get* method. - client.hit return value for other http named methods. + Callable - client.get_objects return value for the *get* method. + partial - client.hit return value for other http named methods. HelpScoutEndpointRequester when other attributes are accessed, this is expected to be used mainly for subattributes of an endpoint or subendpoints of specific resources, like tags from a conversation. @@ -359,7 +369,7 @@ def __getattr__(self, method): False, ) - def __getitem__(self, resource_id): + def __getitem__(self, resource_id:int|str) -> HelpScoutEndpointRequester: """Returns a second endpoint requester extending the endpoint to a specific resource_id or resource_name. @@ -368,7 +378,7 @@ def __getitem__(self, resource_id): Parameters ---------- - resource_id: int or str + resource_id: int | str The resource id or attribute available in the API through a specific call. @@ -384,7 +394,7 @@ def __getitem__(self, resource_id): True, ) - def _yielded_function(self, method, *args, **kwargs): + def _yielded_function(self, method:str, *args, **kwargs): """Calls a generator function and calls next. It is intended to be used with post, put, patch and delete which do not return objects, but as hit is a generator, still have to be nexted. @@ -395,6 +405,8 @@ def _yielded_function(self, method, *args, **kwargs): Positional arguments after *method* to forward to client.hit . *kwargs: keyword arguments Keyword arguments after *method* to forward to client.hit. + method: str + Inherited Returns ------- @@ -408,7 +420,7 @@ def __eq__(self, other): self.endpoint == other.endpoint and self.client == other.client) - def __repr__(self): + def __repr__(self) -> str: """Returns the object as a string.""" name = self.__class__.__name__ return '%s(app_id="%s", endpoint="%s")' % ( diff --git a/helpscout/model.py b/helpscout/model.py index e6234c9..4eb20d0 100644 --- a/helpscout/model.py +++ b/helpscout/model.py @@ -1,9 +1,12 @@ +# 3.13 defaults to this, TODO remove +from __future__ import annotations + class HelpScoutObject(object): key = '' - def __init__(self, api_object): - """Object build from an API dictionary. + def __init__(self, api_object:dict): + """Object built from an API dictionary. Variable assignments to initialized objects is not expected to be done. Parameters @@ -24,18 +27,18 @@ def __init__(self, api_object): setattr(self, key, value) @classmethod - def from_results(cls, api_results): + def from_results(cls, api_results) -> list[HelpScoutObject]: """Generates HelpScout objects from API results. Parameters ---------- api_results: generator({cls.key: [dict]}) or generator(dict) - A generator returning API responses that cointain a list of + A generator returning API responses that contain a list of objects each under the class key. Returns ------- - [HelpScoutObject] + list[HelpScoutObject] """ results = [] for api_result in api_results: @@ -45,7 +48,7 @@ def from_results(cls, api_results): return results @classmethod - def cls(cls, entity_name, key): + def cls(cls, entity_name:str, key:str): """Returns the object class based on the entity_name. Parameters @@ -77,7 +80,7 @@ def cls(cls, entity_name, key): globals()[class_name] = cls = type(class_name, (cls,), {'key': key}) return cls - def __setattr__(self, attr, value): + def __setattr__(self, attr:str, value:object): """Sets an attribute to an object and adds it to the attributes list. Parameters @@ -108,7 +111,7 @@ def __setstate__(self, state): for attr, value in zip(self._attrs, state[1]): setattr(self, attr, value) - def __eq__(self, other): + def __eq__(self, other) -> bool: """Equality comparison.""" if self.__class__ is not other.__class__: return False @@ -130,7 +133,7 @@ def flatten(obj): values = tuple(getattr(self, attr) for attr in self._attrs) return hash(self._attrs + flatten(values)) - def __repr__(self): + def __repr__(self) -> str: """Returns the object as a string.""" name = self.__class__.__name__ attrs = self._attrs @@ -145,13 +148,15 @@ def __repr__(self): __str__ = __repr__ -def get_subclass_instance(class_name, key): +def get_subclass_instance(class_name: str, key): """Gets a dynamic class from a class name for unpickling. Parameters ---------- - name: str + class_name: str A class name, expected to start with Upper case. + key: ??? + TODO Missing desc Returns ------- diff --git a/helpscout/py.typed b/helpscout/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index da3fa4c..86dabd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests==2.21.0 +requests~=2.31.0 diff --git a/setup.py b/setup.py index bca97e1..5c80397 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,8 @@ def readme(): classifiers=[ 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 3' - ] + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12' + ] )