From 134a30f46074435a97e18faad03afad8af4bd75f Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Tue, 14 May 2024 15:40:39 +0200 Subject: [PATCH 1/7] Add typehints for most functions, require Python 3.11+ Fix wrong documentation for types/names Remove python setup.py install instructions and add virtualenv req to pip Fixes #6 --- CHANGELOG.md | 8 ++++- README.md | 18 ++++++++--- helpscout/client.py | 78 ++++++++++++++++++++++----------------------- helpscout/model.py | 24 +++++++------- requirements.txt | 2 +- setup.py | 7 ++-- 6 files changed, 77 insertions(+), 60 deletions(-) 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..543b60d 100644 --- a/helpscout/client.py +++ b/helpscout/client.py @@ -1,13 +1,14 @@ import logging import time +import requests +import typing -from functools import partial -try: # Python 3 - from urllib.parse import urljoin -except ImportError: # Python 2 - from urlparse import urljoin +#import helpscout +if typing.TYPE_CHECKING: + from collections.abc import Generator -import requests +from functools import partial +from urllib.parse import urljoin from helpscout.exceptions import (HelpScoutException, HelpScoutAuthenticationException, @@ -22,10 +23,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 +41,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 +55,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 +77,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 +98,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 +119,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 +148,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 +196,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 +242,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', @@ -287,7 +285,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 +305,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 +322,7 @@ def __init__(self, client, endpoint, specific_resource): self.endpoint = endpoint self.specific_resource = specific_resource - def __getattr__(self, method): + def __getattr__(self, method:str) -> '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 @@ -359,7 +357,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 +366,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. @@ -408,7 +406,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..3aca737 100644 --- a/helpscout/model.py +++ b/helpscout/model.py @@ -2,8 +2,8 @@ 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 +24,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 +45,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 +77,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 +108,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 +130,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 +145,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/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' + ] ) From a745f90af10533b2b85ace3e5fc4553896c16420 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Tue, 14 May 2024 15:49:43 +0200 Subject: [PATCH 2/7] Throw an error if _authentication_headers() is misused --- helpscout/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helpscout/client.py b/helpscout/client.py index 543b60d..9ddc6d6 100644 --- a/helpscout/client.py +++ b/helpscout/client.py @@ -259,6 +259,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', From d8e7975c8041380deeb6c6b8a925391d9199a77e Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Tue, 14 May 2024 16:09:14 +0200 Subject: [PATCH 3/7] Stringify Generator to prevent runtime errors due to unimported types --- helpscout/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpscout/client.py b/helpscout/client.py index 9ddc6d6..e3dc9b2 100644 --- a/helpscout/client.py +++ b/helpscout/client.py @@ -138,7 +138,7 @@ def hit(self, endpoint:str, method:str, resource_id:int|str|None=None, data:dict """ return list(self.hit_(endpoint, method, resource_id, data, params)) - 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]: + 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 @@ -196,7 +196,7 @@ def hit_(self, endpoint:str, method:str, resource_id:int|str|None=None, data:dic else: raise HelpScoutException(r.text) - def _results_with_pagination(self, response:dict, method:str) -> Generator[dict, None, None]: + def _results_with_pagination(self, response:dict, method:str) -> 'Generator[dict, None, None]': """Requests and yields pagination results. Parameters From 8327d26dc57ded60482f585e96d0835ebc271945 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Tue, 14 May 2024 16:26:04 +0200 Subject: [PATCH 4/7] Import annotations from future and destringify them in code --- helpscout/client.py | 14 ++++++++------ helpscout/model.py | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/helpscout/client.py b/helpscout/client.py index e3dc9b2..9b0a401 100644 --- a/helpscout/client.py +++ b/helpscout/client.py @@ -1,9 +1,11 @@ +# 3.13 defaults to this, TODO remove +from __future__ import annotations + import logging import time import requests import typing -#import helpscout if typing.TYPE_CHECKING: from collections.abc import Generator @@ -55,7 +57,7 @@ def __init__(self, app_id:str, app_secret:str, self.rate_limit_sleep = rate_limit_sleep self.access_token = None - def __getattr__(self, endpoint:str) -> 'HelpScoutEndpointRequester': + 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() @@ -138,7 +140,7 @@ def hit(self, endpoint:str, method:str, resource_id:int|str|None=None, data:dict """ return list(self.hit_(endpoint, method, resource_id, data, params)) - 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]': + 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 @@ -196,7 +198,7 @@ def hit_(self, endpoint:str, method:str, resource_id:int|str|None=None, data:dic else: raise HelpScoutException(r.text) - def _results_with_pagination(self, response:dict, method:str) -> 'Generator[dict, None, None]': + def _results_with_pagination(self, response:dict, method:str) -> Generator[dict, None, None]: """Requests and yields pagination results. Parameters @@ -324,7 +326,7 @@ def __init__(self, client:HelpScout, endpoint:str, specific_resource:bool): self.endpoint = endpoint self.specific_resource = specific_resource - def __getattr__(self, method:str) -> 'partial | HelpScoutEndpointRequester': + def __getattr__(self, method:str) -> 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 @@ -359,7 +361,7 @@ def __getattr__(self, method:str) -> 'partial | HelpScoutEndpointRequester': False, ) - def __getitem__(self, resource_id:int|str) -> 'HelpScoutEndpointRequester': + def __getitem__(self, resource_id:int|str) -> HelpScoutEndpointRequester: """Returns a second endpoint requester extending the endpoint to a specific resource_id or resource_name. diff --git a/helpscout/model.py b/helpscout/model.py index 3aca737..4eb20d0 100644 --- a/helpscout/model.py +++ b/helpscout/model.py @@ -1,3 +1,6 @@ +# 3.13 defaults to this, TODO remove +from __future__ import annotations + class HelpScoutObject(object): key = '' @@ -24,7 +27,7 @@ def __init__(self, api_object:dict): setattr(self, key, value) @classmethod - def from_results(cls, api_results) -> 'list[HelpScoutObject]': + def from_results(cls, api_results) -> list[HelpScoutObject]: """Generates HelpScout objects from API results. Parameters From d8d2783bb6ebe5260c0db5b6dd2d002f1c81cf57 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Tue, 14 May 2024 22:01:07 +0200 Subject: [PATCH 5/7] Return Callable in __getattr__ when method is 'get' --- helpscout/client.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/helpscout/client.py b/helpscout/client.py index 9b0a401..6ebc2d6 100644 --- a/helpscout/client.py +++ b/helpscout/client.py @@ -5,8 +5,10 @@ import time import requests import typing +from typing import overload if typing.TYPE_CHECKING: + from typing import Callable, Literal from collections.abc import Generator from functools import partial @@ -326,7 +328,13 @@ def __init__(self, client:HelpScout, endpoint:str, specific_resource:bool): self.endpoint = endpoint self.specific_resource = specific_resource - def __getattr__(self, method:str) -> partial | HelpScoutEndpointRequester: + @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 @@ -339,8 +347,8 @@ def __getattr__(self, method:str) -> partial | HelpScoutEndpointRequester: 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. From debf72fb9a9d228e57c375b7ca76efdbf599ede0 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Tue, 14 May 2024 22:35:07 +0200 Subject: [PATCH 6/7] Add a missed hint in _yielded_function --- helpscout/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helpscout/client.py b/helpscout/client.py index 6ebc2d6..29ff384 100644 --- a/helpscout/client.py +++ b/helpscout/client.py @@ -394,7 +394,7 @@ def __getitem__(self, resource_id:int|str) -> HelpScoutEndpointRequester: 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. @@ -405,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 ------- From eb8b537f1ac71c757cde74d2ad67af625008516e Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Mon, 2 Dec 2024 15:43:51 +0100 Subject: [PATCH 7/7] Add py.typed See https://typing.readthedocs.io/en/latest/spec/distributing.html#packaging-typed-libraries --- helpscout/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 helpscout/py.typed diff --git a/helpscout/py.typed b/helpscout/py.typed new file mode 100644 index 0000000..e69de29