diff --git a/.travis.yml b/.travis.yml index 1ab1f86d..718c72ea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,13 @@ sudo: false language: python python: - - 2.7 - - 3.4 - - 3.5 - - 3.6 -install: + - 3.7 + - 3.8 + - 3.9 + - 3.10 +install: - pip install -r requirements.txt - pip install -r dev-requirements.txt script: - nosetests --with-coverag tests/unit -after_success: - coveralls --verbose +after_success: coveralls --verbose diff --git a/README.rst b/README.rst index c6722ec8..f1e9fde0 100644 --- a/README.rst +++ b/README.rst @@ -472,6 +472,7 @@ or the more specific error subclass: BadRequestError RateLimitExceeded MultipleMatchingUsersError + MultipleMatchingContactsError HttpError UnexpectedError diff --git a/dev-requirements.txt b/dev-requirements.txt index 7c56d80b..98574555 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,9 +1,26 @@ -# -# Development dependencies. -# -nose==1.3.4 -mock==1.0.1 -coveralls==0.5 -coverage==3.7.1 -sphinx==1.4.8 -sphinx-rtd-theme==0.1.9 +alabaster==0.7.12; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +babel==2.10.3; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +colorama==0.4.5; python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6") or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.5.0" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6") +coverage==6.5.0; python_version >= "3.7" +coveralls==3.3.1; python_version >= "3.5" +docopt==0.6.2; python_version >= "3.5" +docutils==0.17.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" +imagesize==1.4.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +importlib-metadata==4.12.0; python_version < "3.10" and python_version >= "3.7" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6") +jinja2==3.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7" +markupsafe==2.1.1; python_version >= "3.7" +mock==4.0.3; python_version >= "3.6" +nose==1.3.7 +packaging==21.3; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +pygments==2.13.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +pyparsing==3.0.9; python_full_version >= "3.6.8" and python_version >= "3.6" +snowballstemmer==2.2.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +sphinx-rtd-theme==1.0.0; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0") +sphinx==5.2.2; python_version >= "3.6" +sphinxcontrib-applehelp==1.0.2; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +sphinxcontrib-devhelp==1.0.2; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +sphinxcontrib-htmlhelp==2.0.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +sphinxcontrib-jsmath==1.0.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +sphinxcontrib-qthelp==1.0.3; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +sphinxcontrib-serializinghtml==1.1.5; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" +zipp==3.8.1; python_version < "3.10" and python_version >= "3.7" diff --git a/docs/conf.py b/docs/conf.py index aeeaea68..985a9c99 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,8 +11,10 @@ # All configuration values have a default; values that are commented out # serve to show the default. +import os +import sys + import sphinx_rtd_theme -import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -44,10 +46,11 @@ master_doc = 'index' # General information about the project. -project = u'python-intercom' +project = 'python-intercom' from datetime import datetime + now = datetime.now() -copyright = u'%s, John Keyes' % (now.year) +copyright = f"{now.year}, John Keyes" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -56,6 +59,7 @@ # The short X.Y version. import re + with open(os.path.join(path_dir, 'intercom', '__init__.py')) as init: source = init.read() m = re.search("__version__ = '(.*)'", source, re.M) @@ -200,8 +204,8 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'python-intercom.tex', u'python-intercom Documentation', - u'John Keyes', 'manual'), + ('index', 'python-intercom.tex', 'python-intercom Documentation', + 'John Keyes', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -230,8 +234,8 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'python-intercom', u'python-intercom Documentation', - [u'John Keyes'], 1) + ('index', 'python-intercom', 'python-intercom Documentation', + ['John Keyes'], 1) ] # If true, show URL addresses after external links. @@ -244,8 +248,8 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'python-intercom', u'python-intercom Documentation', - u'John Keyes', 'python-intercom', 'One line description of project.', + ('index', 'python-intercom', 'python-intercom Documentation', + 'John Keyes', 'python-intercom', 'One line description of project.', 'Miscellaneous'), ] diff --git a/intercom/__init__.py b/intercom/__init__.py index 23824dc6..068468a7 100644 --- a/intercom/__init__.py +++ b/intercom/__init__.py @@ -1,19 +1,21 @@ # -*- coding: utf-8 -*- # from datetime import datetime -from .errors import (ArgumentError, AuthenticationError, # noqa - BadGatewayError, BadRequestError, HttpError, IntercomError, - MultipleMatchingUsersError, RateLimitExceeded, ResourceNotFound, - ServerError, ServiceUnavailableError, UnexpectedError, TokenUnauthorizedError) +from .errors import (ArgumentError, AuthenticationError, BadGatewayError, + BadRequestError, HttpError, IntercomError, + MultipleMatchingContactsError, MultipleMatchingUsersError, + RateLimitExceeded, ResourceNotFound, ServerError, + ServiceUnavailableError, TokenUnauthorizedError, + UnexpectedError) -__version__ = '3.1.0' +__version__ = '4.0.0' RELATED_DOCS_TEXT = "See https://github.com/jkeyes/python-intercom \ for usage examples." -COMPATIBILITY_WARNING_TEXT = "It looks like you are upgrading from \ +COMPATIBILITY_WARNING_TEXT = f"It looks like you are upgrading from \ an older version of python-intercom. Please note that this new version \ -(%s) is not backwards compatible." % (__version__) +({__version__}) is not backwards compatible." COMPATIBILITY_WORKAROUND_TEXT = "To get rid of this error please set \ Intercom.app_api_key and don't set Intercom.api_key." CONFIGURATION_REQUIRED_TEXT = "You must set both Intercom.app_id and \ diff --git a/intercom/api_operations/all.py b/intercom/api_operations/all.py index ec571385..b89284e4 100644 --- a/intercom/api_operations/all.py +++ b/intercom/api_operations/all.py @@ -12,6 +12,6 @@ def all(self): """Return a CollectionProxy for the resource.""" collection = utils.resource_class_to_collection_name( self.collection_class) - finder_url = "/%s" % (collection) + finder_url = f"/{collection}" return CollectionProxy( self.client, self.collection_class, collection, finder_url) diff --git a/intercom/api_operations/bulk.py b/intercom/api_operations/bulk.py index 7681ceb8..bbd7a932 100644 --- a/intercom/api_operations/bulk.py +++ b/intercom/api_operations/bulk.py @@ -38,7 +38,7 @@ def submit_bulk_job(self, create_items=[], delete_items=[], job_id=None): if job_id: bulk_request['job'] = {'id': job_id} - response = self.client.post('/bulk/%s' % (collection_name), bulk_request) + response = self.client.post(f"/bulk/{collection_name}", bulk_request) if not response: raise HttpError('HTTP Error - No response entity returned.') return Job().from_response(response) @@ -51,7 +51,7 @@ def errors(self, id): """Return errors for the Bulk API job specified.""" from intercom.errors import HttpError from intercom.job import Job - response = self.client.get("/jobs/%s/error" % (id), {}) + response = self.client.get(f"/jobs/{id}/error", {}) if not response: raise HttpError('Http Error - No response entity returned.') return Job.from_api(response) diff --git a/intercom/api_operations/delete.py b/intercom/api_operations/delete.py index f9ea71dc..cde5fa49 100644 --- a/intercom/api_operations/delete.py +++ b/intercom/api_operations/delete.py @@ -11,5 +11,5 @@ def delete(self, obj): """Delete the specified instance of this resource.""" collection = utils.resource_class_to_collection_name( self.collection_class) - self.client.delete("/%s/%s" % (collection, obj.id), {}) + self.client.delete(f"/{collection}/{obj.id}", {}) return obj diff --git a/intercom/api_operations/find.py b/intercom/api_operations/find.py index 013665b9..347deea0 100644 --- a/intercom/api_operations/find.py +++ b/intercom/api_operations/find.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- """Operation to find an instance of a particular resource.""" -from intercom import HttpError -from intercom import utils +from intercom import HttpError, utils class Find(object): @@ -13,10 +12,9 @@ def find(self, **params): collection = utils.resource_class_to_collection_name( self.collection_class) if 'id' in params: - response = self.client.get( - "/%s/%s" % (collection, params['id']), {}) + response = self.client.get(f"/{collection}/{params['id']}", {}) else: - response = self.client.get("/%s" % (collection), params) + response = self.client.get(f"/{collection}", params) if response is None: raise HttpError('Http Error - No response entity returned') diff --git a/intercom/api_operations/find_all.py b/intercom/api_operations/find_all.py index 8538f57a..b2dc284c 100644 --- a/intercom/api_operations/find_all.py +++ b/intercom/api_operations/find_all.py @@ -15,9 +15,9 @@ def find_all(self, **params): collection = utils.resource_class_to_collection_name( self.collection_class) if 'id' in params and 'type' not in params: - finder_url = "/%s/%s" % (collection, params['id']) + finder_url = f"/{collection}/{params['id']}" else: - finder_url = "/%s" % (collection) + finder_url = f"/{collection}" finder_params = params return self.proxy_class( self.client, self.collection_class, collection, diff --git a/intercom/api_operations/load.py b/intercom/api_operations/load.py index 82aca451..cc6ae96f 100644 --- a/intercom/api_operations/load.py +++ b/intercom/api_operations/load.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- """Operation to load an instance of a particular resource.""" -from intercom import HttpError -from intercom import utils +from intercom import HttpError, utils class Load(object): @@ -13,11 +12,11 @@ def load(self, resource): collection = utils.resource_class_to_collection_name( self.collection_class) if hasattr(resource, 'id'): - response = self.client.get("/%s/%s" % (collection, resource.id), {}) # noqa + response = self.client.get(f"/{collection}/{resource.id}", {}) # noqa else: raise Exception( - "Cannot load %s as it does not have a valid id." % ( - self.collection_class)) + f"Cannot load {self.collection_class} as it does not have a valid id." + ) if response is None: raise HttpError('Http Error - No response entity returned') diff --git a/intercom/api_operations/save.py b/intercom/api_operations/save.py index ada32fbd..01a24d03 100644 --- a/intercom/api_operations/save.py +++ b/intercom/api_operations/save.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- """Operation to create or save an instance of a particular resource.""" +from tkinter import E + from intercom import utils @@ -11,23 +13,26 @@ def create(self, **params): """Create an instance of the resource from the supplied parameters.""" collection = utils.resource_class_to_collection_name( self.collection_class) - response = self.client.post("/%s/" % (collection), params) + response = self.client.post(f"/{collection}/", params) if response: # may be empty if we received a 202 return self.collection_class(**response) def save(self, obj): """Save the instance of the resource.""" collection = utils.resource_class_to_collection_name( - obj.__class__) + obj.__class__ + ) params = obj.attributes if self.id_present(obj) and not self.posted_updates(obj): # update - response = self.client.put('/%s/%s' % (collection, obj.id), params) + response = self.client.put(f"/{collection}/{obj.id}", params) else: # create params.update(self.identity_hash(obj)) - response = self.client.post('/%s' % (collection), params) - if response: + response = self.client.post(f"/{collection}", params) + if obj.__class__ == response.__class__: + return response + else: return obj.from_response(response) def id_present(self, obj): diff --git a/intercom/client.py b/intercom/client.py index ce37617b..0085cd8d 100644 --- a/intercom/client.py +++ b/intercom/client.py @@ -25,6 +25,11 @@ def companies(self): from intercom.service import company return company.Company(self) + @property + def contacts(self): + from intercom.service import contact + return contact.Contact(self) + @property def conversations(self): from intercom.service import conversation diff --git a/intercom/contact.py b/intercom/contact.py new file mode 100644 index 00000000..7ff5f98b --- /dev/null +++ b/intercom/contact.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +from intercom.traits.api_resource import Resource +from intercom.traits.incrementable_attributes import IncrementableAttributes + + +class Contact(Resource, IncrementableAttributes): + + collection_name = 'contacts' + update_verb = 'put' + identity_vars = ['id', 'email', 'workspace_id', 'external_id'] + + @property + def flat_store_attributes(self): + return ['custom_attributes'] diff --git a/intercom/errors.py b/intercom/errors.py index 3e2f4ba3..9a28a134 100644 --- a/intercom/errors.py +++ b/intercom/errors.py @@ -53,6 +53,10 @@ class MultipleMatchingUsersError(IntercomError): pass +class MultipleMatchingContactsError(IntercomError): + pass + + class UnexpectedError(IntercomError): pass @@ -81,7 +85,7 @@ class TokenNotFoundError(IntercomError): 'rate_limit_exceeded': RateLimitExceeded, 'service_unavailable': ServiceUnavailableError, 'server_error': ServiceUnavailableError, - 'conflict': MultipleMatchingUsersError, + 'conflict': MultipleMatchingContactsError, 'unique_user_constraint': MultipleMatchingUsersError, 'token_unauthorized': TokenUnauthorizedError, 'token_not_found': TokenNotFoundError, diff --git a/intercom/extended_api_operations/tags.py b/intercom/extended_api_operations/tags.py index 6243efe1..453eb5df 100644 --- a/intercom/extended_api_operations/tags.py +++ b/intercom/extended_api_operations/tags.py @@ -12,6 +12,6 @@ def by_tag(self, _id): """Return a CollectionProxy to all the tagged resources.""" collection = utils.resource_class_to_collection_name( self.collection_class) - finder_url = "/%s?tag_id=%s" % (collection, _id) + finder_url = f"/{collection}?tag_id={_id}" return CollectionProxy( self.client, self.collection_class, collection, finder_url) diff --git a/intercom/extended_api_operations/users.py b/intercom/extended_api_operations/users.py index c43cd00b..bfbb695f 100644 --- a/intercom/extended_api_operations/users.py +++ b/intercom/extended_api_operations/users.py @@ -1,17 +1,26 @@ # -*- coding: utf-8 -*- """Operation to return all users for a particular Company.""" -from intercom import utils, user +from deprecated import deprecated + +from intercom import user, utils from intercom.collection_proxy import CollectionProxy +@deprecated( + """Users is no longer available as a resource. + In order to see information and take action on users, + you should use the Contacts API.""" +) class Users(object): """A mixin that provides `users` functionality to Company.""" def users(self, id): """Return a CollectionProxy to all the users for the specified Company.""" collection = utils.resource_class_to_collection_name( - self.collection_class) - finder_url = "/%s/%s/users" % (collection, id) + self.collection_class + ) + finder_url = f"/{collection}/{id}/users" return CollectionProxy( - self.client, user.User, "users", finder_url) + self.client, user.User, "users", finder_url + ) diff --git a/intercom/request.py b/intercom/request.py index 820f8599..3a45904a 100644 --- a/intercom/request.py +++ b/intercom/request.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- -from . import errors -from datetime import datetime -from pytz import utc - -import certifi import json import logging import os +from datetime import datetime + +import certifi import requests +from pytz import utc + +from . import errors logger = logging.getLogger('intercom.request') diff --git a/intercom/service/contact.py b/intercom/service/contact.py new file mode 100644 index 00000000..3c2cbf42 --- /dev/null +++ b/intercom/service/contact.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from intercom import contact +from intercom.api_operations.all import All +from intercom.api_operations.bulk import Submit +from intercom.api_operations.delete import Delete +from intercom.api_operations.find import Find +from intercom.api_operations.find_all import FindAll +from intercom.api_operations.load import Load +from intercom.api_operations.save import Save +from intercom.api_operations.scroll import Scroll +from intercom.extended_api_operations.tags import Tags +from intercom.service.base_service import BaseService + + +class Contact(BaseService, All, Find, FindAll, Delete, Save, Load, Submit, Tags, Scroll): + + @property + def collection_class(self): + return contact.Contact diff --git a/intercom/service/conversation.py b/intercom/service/conversation.py index 61f143a7..d52bf9f4 100644 --- a/intercom/service/conversation.py +++ b/intercom/service/conversation.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- """Service module for Conversations.""" -from intercom import conversation -from intercom import utils +from intercom import conversation, utils from intercom.api_operations.find import Find from intercom.api_operations.find_all import FindAll from intercom.api_operations.load import Load @@ -25,7 +24,7 @@ def collection_class(self): def resource_url(self, _id): """Return the URL for the specified resource in this collection.""" - return "/%s/%s/reply" % (self.collection, _id) + return f"/{self.collection}/{_id}/reply" def reply(self, **reply_data): """Reply to a message.""" diff --git a/intercom/service/user.py b/intercom/service/user.py index 38375d83..4124df40 100644 --- a/intercom/service/user.py +++ b/intercom/service/user.py @@ -1,18 +1,24 @@ # -*- coding: utf-8 -*- +from deprecated import deprecated from intercom import user from intercom.api_operations.all import All from intercom.api_operations.bulk import Submit +from intercom.api_operations.delete import Delete from intercom.api_operations.find import Find from intercom.api_operations.find_all import FindAll -from intercom.api_operations.delete import Delete -from intercom.api_operations.save import Save from intercom.api_operations.load import Load +from intercom.api_operations.save import Save from intercom.api_operations.scroll import Scroll from intercom.extended_api_operations.tags import Tags from intercom.service.base_service import BaseService +@deprecated( + """Users is no longer available as a resource. + In order to see information and take action on users, + you should use the Contacts API.""" +) class User(BaseService, All, Find, FindAll, Delete, Save, Load, Submit, Tags, Scroll): @property diff --git a/intercom/user.py b/intercom/user.py index a5629238..4918b31a 100644 --- a/intercom/user.py +++ b/intercom/user.py @@ -1,9 +1,15 @@ # -*- coding: utf-8 -*- +from deprecated import deprecated from intercom.traits.api_resource import Resource from intercom.traits.incrementable_attributes import IncrementableAttributes +@deprecated( + """Users is no longer available as a resource. + In order to see information and take action on users, + you should use the Contacts API.""" +) class User(Resource, IncrementableAttributes): update_verb = 'post' diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..7074c2b9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,906 @@ +[[package]] +name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "appnope" +version = "0.1.3" +description = "Disable App Nap on macOS >= 10.9" +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "asttokens" +version = "2.0.8" +description = "Annotate AST trees with source code positions" +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +six = "*" + +[package.extras] +test = ["astroid (<=2.5.3)", "pytest"] + +[[package]] +name = "babel" +version = "2.10.3" +description = "Internationalization utilities" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "certifi" +version = "2022.9.24" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "6.5.0" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "coveralls" +version = "3.3.1" +description = "Show coverage stats online via coveralls.io" +category = "dev" +optional = false +python-versions = ">= 3.5" + +[package.dependencies] +coverage = ">=4.1,<6.0.0 || >6.1,<6.1.1 || >6.1.1,<7.0" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +category = "dev" +optional = true +python-versions = ">=3.5" + +[[package]] +name = "deprecated" +version = "1.2.13" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"] + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "docutils" +version = "0.17.1" +description = "Docutils -- Python Documentation Utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "executing" +version = "1.1.0" +description = "Get the currently executing AST node of a frame, and other information" +category = "dev" +optional = true +python-versions = "*" + +[package.extras] +tests = ["rich", "littleutils", "pytest", "asttokens"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "importlib-metadata" +version = "4.12.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] + +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "ipdb" +version = "0.13.9" +description = "IPython-enabled pdb" +category = "dev" +optional = true +python-versions = ">=2.7" + +[package.dependencies] +decorator = {version = "*", markers = "python_version > \"3.6\""} +ipython = {version = ">=7.17.0", markers = "python_version > \"3.6\""} +toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} + +[[package]] +name = "ipython" +version = "8.5.0" +description = "IPython: Productive Interactive Computing" +category = "dev" +optional = true +python-versions = ">=3.8" + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">3.0.1,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" + +[package.extras] +all = ["black", "Sphinx (>=1.3)", "ipykernel", "nbconvert", "nbformat", "ipywidgets", "notebook", "ipyparallel", "qtconsole", "pytest (<7.1)", "pytest-asyncio", "testpath", "curio", "matplotlib (!=3.2.0)", "numpy (>=1.19)", "pandas", "trio"] +black = ["black"] +doc = ["Sphinx (>=1.3)"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] +test_extra = ["pytest (<7.1)", "pytest-asyncio", "testpath", "curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.19)", "pandas", "trio"] + +[[package]] +name = "jedi" +version = "0.18.1" +description = "An autocompletion tool for Python that can be used for text editors." +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +parso = ">=0.8.0,<0.9.0" + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +category = "dev" +optional = true +python-versions = ">=3.5" + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mock" +version = "4.0.3" +description = "Rolling backport of unittest.mock for all Pythons" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +build = ["twine", "wheel", "blurb"] +docs = ["sphinx"] +test = ["pytest (<5.4)", "pytest-cov"] + +[[package]] +name = "nose" +version = "1.3.7" +description = "nose extends unittest to make testing easier" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "prompt-toolkit" +version = "3.0.31" +description = "Library for building powerful interactive command lines in Python" +category = "dev" +optional = true +python-versions = ">=3.6.2" + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +category = "dev" +optional = true +python-versions = "*" + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pygments" +version = "2.13.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytz" +version = "2022.2.1" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "sphinx" +version = "5.2.2" +description = "Python documentation generator" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +alabaster = ">=0.7,<0.8" +babel = ">=2.9" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.14,<0.20" +imagesize = ">=1.3" +importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} +Jinja2 = ">=3.0" +packaging = ">=21.0" +Pygments = ">=2.12" +requests = ">=2.5.0" +snowballstemmer = ">=2.0" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.5" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=3.5.0)", "flake8-comprehensions", "flake8-bugbear", "flake8-simplify", "isort", "mypy (>=0.981)", "sphinx-lint", "docutils-stubs", "types-typed-ast", "types-requests"] +test = ["pytest (>=4.6)", "html5lib", "typed-ast", "cython"] + +[[package]] +name = "sphinx-rtd-theme" +version = "1.0.0" +description = "Read the Docs theme for Sphinx" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[package.dependencies] +docutils = "<0.18" +sphinx = ">=1.6" + +[package.extras] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +test = ["html5lib", "pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["mypy", "flake8", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "stack-data" +version = "0.5.1" +description = "Extract data from python stack frames and tracebacks for informative displays" +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +asttokens = "*" +executing = "*" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "typeguard", "pytest"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = true +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "traitlets" +version = "5.4.0" +description = "" +category = "dev" +optional = true +python-versions = ">=3.7" + +[package.extras] +test = ["pre-commit", "pytest"] + +[[package]] +name = "urllib3" +version = "1.26.12" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "wrapt" +version = "1.14.1" +description = "Module for decorators, wrappers and monkey patching." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "zipp" +version = "3.8.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "3.8.12" +content-hash = "0e7c1ae9e524813a7905edba3fe513ebb7fbb9fd30995968c62c7c722528a691" + +[metadata.files] +alabaster = [ + {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, + {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, +] +appnope = [ + {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, + {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, +] +asttokens = [ + {file = "asttokens-2.0.8-py2.py3-none-any.whl", hash = "sha256:e3305297c744ae53ffa032c45dc347286165e4ffce6875dc662b205db0623d86"}, + {file = "asttokens-2.0.8.tar.gz", hash = "sha256:c61e16246ecfb2cde2958406b4c8ebc043c9e6d73aaa83c941673b35e5d3a76b"}, +] +babel = [ + {file = "Babel-2.10.3-py3-none-any.whl", hash = "sha256:ff56f4892c1c4bf0d814575ea23471c230d544203c7748e8c68f0089478d48eb"}, + {file = "Babel-2.10.3.tar.gz", hash = "sha256:7614553711ee97490f732126dc077f8d0ae084ebc6a96e23db1482afabdb2c51"}, +] +backcall = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] +certifi = [ + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +] +colorama = [ + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, +] +coverage = [ + {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, + {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, + {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, + {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, + {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, + {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, + {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, + {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, + {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, + {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, + {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, + {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, + {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, + {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, + {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, +] +coveralls = [ + {file = "coveralls-3.3.1-py2.py3-none-any.whl", hash = "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026"}, + {file = "coveralls-3.3.1.tar.gz", hash = "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea"}, +] +decorator = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] +deprecated = [] +docopt = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] +docutils = [ + {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, + {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, +] +executing = [ + {file = "executing-1.1.0-py2.py3-none-any.whl", hash = "sha256:4a6d96ba89eb3dcc11483471061b42b9006d8c9f81c584dd04246944cd022530"}, + {file = "executing-1.1.0.tar.gz", hash = "sha256:2c2c07d1ec4b2d8f9676b25170f1d8445c0ee2eb78901afb075a4b8d83608c6a"}, +] +idna = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] +imagesize = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] +importlib-metadata = [] +inflection = [] +ipdb = [] +ipython = [ + {file = "ipython-8.5.0-py3-none-any.whl", hash = "sha256:6f090e29ab8ef8643e521763a4f1f39dc3914db643122b1e9d3328ff2e43ada2"}, + {file = "ipython-8.5.0.tar.gz", hash = "sha256:097bdf5cd87576fd066179c9f7f208004f7a6864ee1b20f37d346c0bcb099f84"}, +] +jedi = [] +jinja2 = [] +markupsafe = [ + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, +] +matplotlib-inline = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] +mock = [ + {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, + {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, +] +nose = [ + {file = "nose-1.3.7-py2-none-any.whl", hash = "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a"}, + {file = "nose-1.3.7-py3-none-any.whl", hash = "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac"}, + {file = "nose-1.3.7.tar.gz", hash = "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"}, +] +packaging = [] +parso = [] +pexpect = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] +pickleshare = [] +prompt-toolkit = [ + {file = "prompt_toolkit-3.0.31-py3-none-any.whl", hash = "sha256:9696f386133df0fc8ca5af4895afe5d78f5fcfe5258111c2a79a1c3e41ffa96d"}, + {file = "prompt_toolkit-3.0.31.tar.gz", hash = "sha256:9ada952c9d1787f52ff6d5f3484d0b4df8952787c087edf6a1f7c2cb1ea88148"}, +] +ptyprocess = [] +pure-eval = [] +pygments = [ + {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, + {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytz = [ + {file = "pytz-2022.2.1-py2.py3-none-any.whl", hash = "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197"}, + {file = "pytz-2022.2.1.tar.gz", hash = "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5"}, +] +requests = [] +six = [] +snowballstemmer = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] +sphinx = [ + {file = "Sphinx-5.2.2.tar.gz", hash = "sha256:7225c104dc06169eb73b061582c4bc84a9594042acae6c1582564de274b7df2f"}, + {file = "sphinx-5.2.2-py3-none-any.whl", hash = "sha256:9150a8ed2e98d70e778624373f183c5498bf429dd605cf7b63e80e2a166c35a5"}, +] +sphinx-rtd-theme = [ + {file = "sphinx_rtd_theme-1.0.0-py2.py3-none-any.whl", hash = "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8"}, + {file = "sphinx_rtd_theme-1.0.0.tar.gz", hash = "sha256:eec6d497e4c2195fa0e8b2016b337532b8a699a68bcb22a512870e16925c6a5c"}, +] +sphinxcontrib-applehelp = [ + {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, + {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, +] +sphinxcontrib-devhelp = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] +sphinxcontrib-htmlhelp = [ + {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, + {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, +] +sphinxcontrib-jsmath = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] +sphinxcontrib-qthelp = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] +sphinxcontrib-serializinghtml = [ + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, +] +stack-data = [ + {file = "stack_data-0.5.1-py3-none-any.whl", hash = "sha256:5120731a18ba4c82cefcf84a945f6f3e62319ef413bfc210e32aca3a69310ba2"}, + {file = "stack_data-0.5.1.tar.gz", hash = "sha256:95eb784942e861a3d80efd549ff9af6cf847d88343a12eb681d7157cfcb6e32b"}, +] +toml = [] +traitlets = [ + {file = "traitlets-5.4.0-py3-none-any.whl", hash = "sha256:93663cc8236093d48150e2af5e2ed30fc7904a11a6195e21bab0408af4e6d6c8"}, + {file = "traitlets-5.4.0.tar.gz", hash = "sha256:3f2c4e435e271592fe4390f1746ea56836e3a080f84e7833f0f801d9613fec39"}, +] +urllib3 = [ + {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, + {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +wrapt = [] +zipp = [ + {file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"}, + {file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..31782e27 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[tool.poetry] +name = "intercom" +version = "4.0.0" +description = "Intercom API wrapper" +authors = ["Dimosthenis Schizas "] +license = "MIT License" + +[tool.poetry.dependencies] +python = "^3.8" +certifi = "^2022.9.24" +inflection = "^0.5.1" +pytz = "^2022.2.1" +requests = "^2.28.1" +urllib3 = "^1.26.12" +six = "^1.16.0" +Deprecated = "^1.2.13" + +[tool.poetry.dev-dependencies] +nose = "^1.3.7" +mock = "^4.0.3" +coveralls = "^3.3.1" +coverage = "^6.5.0" +Sphinx = "^5.2.2" +sphinx-rtd-theme = "^1.0.0" +ipdb = {version = "^0.13.9", optional = true} +ipython = {version = "^8.5.0", optional = true} + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt index 30b0f4cc..e19dc653 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,11 @@ # # Runtime dependencies. # -certifi -inflection==0.3.0 -pytz==2016.7 -requests==2.20.1 -urllib3==1.24.2 -six==1.9.0 +certifi==2022.9.24; python_version >= "3.6" +charset-normalizer==2.1.1; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.0" +idna==2.7; python_version >= "3.7" and python_version < "4" +inflection==0.5.1; python_version >= "3.5" +pytz==2022.2.1 +requests==2.28.1; python_version >= "3.7" and python_version < "4" +six==1.16.0; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.3.0") +urllib3==1.26.12; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0" and python_version < "4") diff --git a/setup.py b/setup.py index 3b5bcde8..2e4496b5 100644 --- a/setup.py +++ b/setup.py @@ -7,8 +7,7 @@ import os import re -from setuptools import find_packages -from setuptools import setup +from setuptools import find_packages, setup with open(os.path.join('intercom', '__init__.py')) as init: source = init.read() @@ -29,10 +28,11 @@ url="http://github.com/jkeyes/python-intercom", keywords='Intercom crm python', classifiers=[ - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', ], packages=find_packages(), include_package_data=True, diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 8db6f1aa..096f3e5b 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import time - from datetime import datetime + # from intercom import Company from intercom import ResourceNotFound + # from intercom import User @@ -15,7 +16,7 @@ def get_timestamp(): def get_or_create_user(client, timestamp): # get user - email = '%s@example.com' % (timestamp) + email = f"{timestamp}@example.com" try: user = client.users.find(email=email) except ResourceNotFound: @@ -23,13 +24,13 @@ def get_or_create_user(client, timestamp): user = client.users.create( email=email, user_id=timestamp, - name="Ada %s" % (timestamp)) + name=f"Ada {timestamp}" time.sleep(5) return user def get_or_create_company(client, timestamp): - name = 'Company %s' % (timestamp) + name = f"Company {timestamp}" # get company try: diff --git a/tests/integration/test_company.py b/tests/integration/test_company.py index dd6bbd2b..4af48ed0 100644 --- a/tests/integration/test_company.py +++ b/tests/integration/test_company.py @@ -2,12 +2,11 @@ import os import unittest + from intercom.client import Client -from . import delete_company -from . import delete_user -from . import get_or_create_user -from . import get_or_create_company -from . import get_timestamp + +from . import (delete_company, delete_user, get_or_create_company, + get_or_create_user, get_timestamp) intercom = Client( os.environ.get('INTERCOM_PERSONAL_ACCESS_TOKEN')) @@ -78,7 +77,7 @@ def test_update(self): company = intercom.companies.find(id=self.company.id) # Update a company now = get_timestamp() - updated_name = 'Company %s' % (now) + updated_name = f"Company {now}" company.name = updated_name intercom.companies.save(company) company = intercom.companies.find(id=self.company.id) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index a85c92f6..5330e5db 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -136,6 +136,96 @@ def get_user(email="bob@example.com", name="Joe Schmoe"): } } +def get_contact(email="bob@example.com", name="Joe Schmoe"): + return { + "type": "contact", + "id": "aaaaaaaaaaaaaaaaaaaaaaaa", + "external_id": 'id-from-customers-app', + "email": email, + "name": name, + "avatar": { + "type": "avatar", + "image_url": "https://graph.facebook.com/1/picture?width=24&height=24" + }, + "app_id": "the-app-id", + "created_at": 1323422442, + "custom_attributes": {"a": "b", "b": 2}, + "companies": { + "type": "company.list", + "companies": [ + { + "type": "company", + "company_id": "123", + "id": "bbbbbbbbbbbbbbbbbbbbbbbb", + "app_id": "the-app-id", + "name": "Company 1", + "remote_created_at": 1390936440, + "created_at": 1401970114, + "updated_at": 1401970114, + "last_request_at": 1401970113, + "monthly_spend": 0, + "session_count": 0, + "user_count": 1, + "tag_ids": [], + "custom_attributes": { + "category": "Tech" + } + } + ] + }, + "session_count": 123, + "unsubscribed_from_emails": True, + "last_request_at": 1401970113, + "created_at": 1401970114, + "remote_created_at": 1393613864, + "updated_at": 1401970114, + "user_agent_data": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11", + "social_profiles": { + "type": "social_profile.list", + "social_profiles": [ + { + "type": "social_profile", + "name": "twitter", + "url": "http://twitter.com/abc", + "username": "abc", + "id": None + }, + { + "type": "social_profile", + "name": "twitter", + "username": "abc2", + "url": "http://twitter.com/abc2", + "id": None + }, + { + "type": "social_profile", + "name": "facebook", + "url": "http://facebook.com/abc", + "username": "abc", + "id": "1234242" + }, + { + "type": "social_profile", + "name": "quora", + "url": "http://facebook.com/abc", + "username": "abc", + "id": "1234242" + } + ] + }, + "location_data": { + "type": "location_data", + "city_name": 'Dublin', + "continent_code": 'EU', + "country_name": 'Ireland', + "latitude": '90', + "longitude": '10', + "postal_code": 'IE', + "region_name": 'Europe', + "timezone": '+1000', + "country_code": "IRL" + } + } def get_company(name): return { @@ -196,6 +286,27 @@ def page_of_users(include_next_link=False): return page +def page_of_contacts(include_next_link=False): + page = { + "type": "contact.list", + "pages": { + "type": "pages", + "page": 1, + "next": None, + "per_page": 50, + "total_pages": 7 + }, + "contacts": [ + get_contact("user1@example.com"), + get_contact("user2@example.com"), + get_contact("user3@example.com")], + "total_count": 314 + } + if include_next_link: + page["pages"]["next"] = "https://api.intercom.io/contacts?per_page=50&page=2" + return page + + def users_scroll(include_users=False): # noqa # a "page" of results from the Scroll API if include_users: diff --git a/tests/unit/test_collection_proxy.py b/tests/unit/test_collection_proxy.py index ba0cde09..34bfab78 100644 --- a/tests/unit/test_collection_proxy.py +++ b/tests/unit/test_collection_proxy.py @@ -2,12 +2,11 @@ import unittest +from mock import call, patch +from nose.tools import eq_, istest + from intercom.client import Client -from mock import call -from mock import patch -from nose.tools import eq_ -from nose.tools import istest -from tests.unit import page_of_users +from tests.unit import page_of_contacts class CollectionProxyTest(unittest.TestCase): @@ -17,34 +16,34 @@ def setUp(self): @istest def it_stops_iterating_if_no_next_link(self): - body = page_of_users(include_next_link=False) + body = page_of_contacts(include_next_link=False) with patch.object(Client, 'get', return_value=body) as mock_method: - emails = [user.email for user in self.client.users.all()] - mock_method.assert_called_once_with('/users', {}) + emails = [contact.email for contact in self.client.contacts.all()] + mock_method.assert_called_once_with('/contacts', {}) eq_(emails, ['user1@example.com', 'user2@example.com', 'user3@example.com']) # noqa @istest def it_keeps_iterating_if_next_link(self): - page1 = page_of_users(include_next_link=True) - page2 = page_of_users(include_next_link=False) + page1 = page_of_contacts(include_next_link=True) + page2 = page_of_contacts(include_next_link=False) side_effect = [page1, page2] with patch.object(Client, 'get', side_effect=side_effect) as mock_method: # noqa - emails = [user.email for user in self.client.users.all()] - eq_([call('/users', {}), call('/users?per_page=50&page=2', {})], # noqa + emails = [contact.email for contact in self.client.contacts.all()] + eq_([call('/contacts', {}), call('/contacts?per_page=50&page=2', {})], # noqa mock_method.mock_calls) eq_(emails, ['user1@example.com', 'user2@example.com', 'user3@example.com'] * 2) # noqa @istest def it_supports_indexed_array_access(self): - body = page_of_users(include_next_link=False) + body = page_of_contacts(include_next_link=False) with patch.object(Client, 'get', return_value=body) as mock_method: - eq_(self.client.users.all()[0].email, 'user1@example.com') - mock_method.assert_called_once_with('/users', {}) + eq_(self.client.contacts.all()[0].email, 'user1@example.com') + mock_method.assert_called_once_with('/contacts', {}) @istest def it_supports_querying(self): - body = page_of_users(include_next_link=False) + body = page_of_contacts(include_next_link=False) with patch.object(Client, 'get', return_value=body) as mock_method: - emails = [user.email for user in self.client.users.find_all(tag_name='Taggart J')] # noqa + emails = [contact.email for contact in self.client.contacts.find_all(tag_name='Taggart J')] # noqa eq_(emails, ['user1@example.com', 'user2@example.com', 'user3@example.com']) # noqa - mock_method.assert_called_once_with('/users', {'tag_name': 'Taggart J'}) # noqa + mock_method.assert_called_once_with('/contacts', {'tag_name': 'Taggart J'}) # noqa diff --git a/tests/unit/test_contact.py b/tests/unit/test_contact.py new file mode 100644 index 00000000..6b823365 --- /dev/null +++ b/tests/unit/test_contact.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- + +import calendar +import json +import time +import unittest +from datetime import datetime + +import mock +from mock import patch +from nose.tools import assert_raises, eq_, istest, ok_ + +from intercom import MultipleMatchingContactsError +from intercom.client import Client +from intercom.collection_proxy import CollectionProxy +from intercom.contact import Contact +from intercom.lib.flat_store import FlatStore +from intercom.utils import define_lightweight_class +from tests.unit import get_contact, mock_response, page_of_contacts + + +class ContactTest(unittest.TestCase): + + def setUp(self): + self.client = Client() + + @istest + def it_to_dict_itself(self): + created_at = datetime.utcnow() + contact = Contact( + email="jim@example.com", external_id="12345", + created_at=created_at, name="Jim Bob") + as_dict = contact.to_dict() + eq_(as_dict["email"], "jim@example.com") + eq_(as_dict["external_id"], "12345") + eq_(as_dict["created_at"], calendar.timegm(created_at.utctimetuple())) + eq_(as_dict["name"], "Jim Bob") + + @istest + def it_presents_created_at_and_last_impression_at_as_datetime(self): + now = datetime.utcnow() + now_ts = calendar.timegm(now.utctimetuple()) + contact = Contact.from_api( + {'created_at': now_ts, 'last_impression_at': now_ts}) + self.assertIsInstance(contact.created_at, datetime) + eq_(now.strftime('%c'), contact.created_at.strftime('%c')) + self.assertIsInstance(contact.last_impression_at, datetime) + eq_(now.strftime('%c'), contact.last_impression_at.strftime('%c')) + + @istest + def it_throws_an_attribute_error_on_trying_to_access_an_attribute_that_has_not_been_set(self): # noqa + with assert_raises(AttributeError): + contact = Contact() + contact.foo_property + + @istest + def it_presents_a_complete_contact_record_correctly(self): + contact = Contact.from_api(get_contact()) + eq_('id-from-customers-app', contact.external_id) + eq_('bob@example.com', contact.email) + eq_('Joe Schmoe', contact.name) + eq_('the-app-id', contact.app_id) + eq_(123, contact.session_count) + eq_(1401970114, calendar.timegm(contact.created_at.utctimetuple())) + eq_(1393613864, calendar.timegm(contact.remote_created_at.utctimetuple())) + eq_(1401970114, calendar.timegm(contact.updated_at.utctimetuple())) + + Avatar = define_lightweight_class('avatar', 'Avatar') # noqa + Company = define_lightweight_class('company', 'Company') # noqa + SocialProfile = define_lightweight_class('social_profile', 'SocialProfile') # noqa + LocationData = define_lightweight_class('locaion_data', 'LocationData') # noqa + self.assertIsInstance(contact.avatar.__class__, Avatar.__class__) + img_url = 'https://graph.facebook.com/1/picture?width=24&height=24' + eq_(img_url, contact.avatar.image_url) + + self.assertIsInstance(contact.companies, list) + eq_(1, len(contact.companies)) + self.assertIsInstance(contact.companies[0].__class__, Company.__class__) + eq_('123', contact.companies[0].company_id) + eq_('bbbbbbbbbbbbbbbbbbbbbbbb', contact.companies[0].id) + eq_('the-app-id', contact.companies[0].app_id) + eq_('Company 1', contact.companies[0].name) + eq_(1390936440, calendar.timegm( + contact.companies[0].remote_created_at.utctimetuple())) + eq_(1401970114, calendar.timegm( + contact.companies[0].created_at.utctimetuple())) + eq_(1401970114, calendar.timegm( + contact.companies[0].updated_at.utctimetuple())) + eq_(1401970113, calendar.timegm( + contact.companies[0].last_request_at.utctimetuple())) + eq_(0, contact.companies[0].monthly_spend) + eq_(0, contact.companies[0].session_count) + eq_(1, contact.companies[0].user_count) + eq_([], contact.companies[0].tag_ids) + + self.assertIsInstance(contact.custom_attributes, FlatStore) + eq_('b', contact.custom_attributes["a"]) + eq_(2, contact.custom_attributes["b"]) + + eq_(4, len(contact.social_profiles)) + twitter_account = contact.social_profiles[0] + self.assertIsInstance(twitter_account.__class__, SocialProfile.__class__) + eq_('twitter', twitter_account.name) + eq_('abc', twitter_account.username) + eq_('http://twitter.com/abc', twitter_account.url) + + self.assertIsInstance(contact.location_data.__class__, LocationData.__class__) + eq_('Dublin', contact.location_data.city_name) + eq_('EU', contact.location_data.continent_code) + eq_('Ireland', contact.location_data.country_name) + eq_('90', contact.location_data.latitude) + eq_('10', contact.location_data.longitude) + eq_('IRL', contact.location_data.country_code) + + ok_(contact.unsubscribed_from_emails) + eq_("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11", contact.user_agent_data) # noqa + + @istest + def it_allows_easy_setting_of_custom_data(self): + now = datetime.utcnow() + now_ts = calendar.timegm(now.utctimetuple()) + + contact = Contact() + contact.custom_attributes["mad"] = 123 + contact.custom_attributes["other"] = now_ts + contact.custom_attributes["thing"] = "yay" + attrs = {"mad": 123, "other": now_ts, "thing": "yay"} + eq_(contact.to_dict()["custom_attributes"], attrs) + + @istest + def it_allows_easy_setting_of_multiple_companies(self): + contact = Contact() + companies = [ + {"name": "Intercom", "company_id": "6"}, + {"name": "Test", "company_id": "9"}, + ] + contact.companies = companies + eq_(contact.to_dict()["companies"], companies) + + @istest + def it_rejects_nested_data_structures_in_custom_attributes(self): + contact = Contact() + with assert_raises(ValueError): + contact.custom_attributes["thing"] = [1] + + with assert_raises(ValueError): + contact.custom_attributes["thing"] = {1: 2} + + with assert_raises(ValueError): + contact.custom_attributes = {1: {2: 3}} + + contact = Contact.from_api(get_contact()) + with assert_raises(ValueError): + contact.custom_attributes["thing"] = [1] + + @istest + def it_fetches_a_contact(self): + with patch.object(Client, 'get', return_value=get_contact()) as mock_method: # noqa + contact = self.client.contacts.find(email='somebody@example.com') + eq_(contact.email, 'bob@example.com') + eq_(contact.name, 'Joe Schmoe') + mock_method.assert_called_once_with( + '/contacts', {'email': 'somebody@example.com'}) # noqa + + @istest + def it_gets_contacts_by_tag(self): + with patch.object(Client, 'get', return_value=page_of_contacts(False)): + contacts = self.client.contacts.by_tag(124) + for contact in contacts: + ok_(hasattr(contact, 'avatar')) + + @istest + def it_saves_a_contact_always_sends_custom_attributes(self): + + body = { + 'email': 'jo@example.com', + 'external_id': 'i-1224242', + 'custom_attributes': {} + } + + with patch.object(Client, 'post', return_value=body) as mock_method: + contact = Contact(email="jo@example.com", external_id="i-1224242") + self.client.contacts.save(contact) + eq_(contact.email, 'jo@example.com') + eq_(contact.custom_attributes, {}) + mock_method.assert_called_once_with( + '/contacts', + {'email': "jo@example.com", 'external_id': "i-1224242", + 'custom_attributes': {}}) + + @istest + def it_saves_a_contact_with_a_company(self): + body = { + 'email': 'jo@example.com', + 'external_id': 'i-1224242', + 'companies': [{ + 'company_id': 6, + 'name': 'Intercom' + }] + } + with patch.object(Client, 'post', return_value=body) as mock_method: + contact = Contact( + email="jo@example.com", external_id="i-1224242", + company={'company_id': 6, 'name': 'Intercom'}) + self.client.contacts.save(contact) + eq_(contact.email, 'jo@example.com') + eq_(len(contact.companies), 1) + mock_method.assert_called_once_with( + '/contacts', + { + 'email': "jo@example.com", + 'external_id': "i-1224242", + 'company': {'company_id': 6, 'name': 'Intercom'}, + 'custom_attributes': {} + } + ) + + @istest + def it_saves_a_contact_with_companies(self): + body = { + 'email': 'jo@example.com', + 'external_id': 'i-1224242', + 'companies': [{ + 'company_id': 6, + 'name': 'Intercom' + }] + } + with patch.object(Client, 'post', return_value=body) as mock_method: + contact = Contact( + email="jo@example.com", external_id="i-1224242", + companies=[{'company_id': 6, 'name': 'Intercom'}]) + self.client.contacts.save(contact) + eq_(contact.email, 'jo@example.com') + eq_(len(contact.companies), 1) + mock_method.assert_called_once_with( + '/contacts', + {'email': "jo@example.com", 'external_id': "i-1224242", + 'companies': [{'company_id': 6, 'name': 'Intercom'}], + 'custom_attributes': {}}) + + @istest + def it_can_save_a_contact_with_a_none_email(self): + contact = Contact( + email=None, external_id="i-1224242", + companies=[{'company_id': 6, 'name': 'Intercom'}]) + body = { + 'custom_attributes': {}, + 'email': None, + 'external_id': 'i-1224242', + 'companies': [{ + 'company_id': 6, + 'name': 'Intercom' + }] + } + with patch.object(Client, 'post', return_value=body) as mock_method: + self.client.contacts.save(contact) + ok_(contact.email is None) + eq_(contact.external_id, 'i-1224242') + mock_method.assert_called_once_with( + '/contacts', + {'email': None, 'external_id': "i-1224242", + 'companies': [{'company_id': 6, 'name': 'Intercom'}], + 'custom_attributes': {}}) + + @istest + def it_deletes_a_contact(self): + contact = Contact(id="1") + with patch.object(Client, 'delete', return_value={}) as mock_method: + contact = self.client.contacts.delete(contact) + eq_(contact.id, "1") + mock_method.assert_called_once_with('/contacts/1', {}) + + @istest + def it_can_use_contact_create_for_convenience(self): + payload = { + 'email': 'jo@example.com', + 'external_id': 'i-1224242', + 'custom_attributes': {} + } + with patch.object(Client, 'post', return_value=payload) as mock_method: # noqa + contact = self.client.contacts.create(email="jo@example.com", external_id="i-1224242") # noqa + eq_(payload, contact.to_dict()) + mock_method.assert_called_once_with( + '/contacts/', {'email': "jo@example.com", 'external_id': "i-1224242"}) # noqa + + @istest + def it_updates_the_contact_with_attributes_set_by_the_server(self): + payload = { + 'email': 'jo@example.com', + 'external_id': 'i-1224242', + 'custom_attributes': {}, + 'session_count': 4 + } + with patch.object(Client, 'post', return_value=payload) as mock_method: # noqa + contact = self.client.contacts.create(email="jo@example.com", external_id="i-1224242") # noqa + eq_(payload, contact.to_dict()) + mock_method.assert_called_once_with( + '/contacts/', + {'email': "jo@example.com", 'external_id': "i-1224242"}) # noqa + + @istest + def it_allows_setting_dates_to_none_without_converting_them_to_0(self): + payload = { + 'email': 'jo@example.com', + 'custom_attributes': {}, + 'remote_created_at': None + } + with patch.object(Client, 'post', return_value=payload) as mock_method: + contact = self.client.contacts.create(email="jo@example.com", remote_created_at=None) # noqa + ok_(contact.remote_created_at is None) + mock_method.assert_called_once_with('/contacts/', {'email': "jo@example.com", 'remote_created_at': None}) # noqa + + @istest + def it_gets_sets_rw_keys(self): + created_at = datetime.utcnow() + payload = { + 'email': 'me@example.com', + 'external_id': 'abc123', + 'name': 'Bob Smith', + 'last_seen_ip': '1.2.3.4', + 'last_seen_contact_agent': 'ie6', + 'created_at': calendar.timegm(created_at.utctimetuple()) + } + contact = Contact(**payload) + expected_keys = ['custom_attributes'] + expected_keys.extend(list(payload.keys())) + eq_(sorted(expected_keys), sorted(contact.to_dict().keys())) + for key in list(payload.keys()): + eq_(payload[key], contact.to_dict()[key]) + + @istest + def it_will_allow_extra_attributes_in_response_from_api(self): + contact = Contact.from_api({'new_param': 'some value'}) + eq_('some value', contact.new_param) + + @istest + def it_returns_a_collectionproxy_for_all_without_making_any_requests(self): + with mock.patch('intercom.request.Request.send_request_to_path', new_callable=mock.NonCallableMock): # noqa + res = self.client.contacts.all() + self.assertIsInstance(res, CollectionProxy) + + @istest + def it_raises_a_multiple_matching_contacts_error_when_receiving_a_conflict(self): # noqa + payload = { + 'type': 'error.list', + 'errors': [ + { + 'code': 'conflict', + 'message': 'Multiple existing contacts match this email address - must be more specific using external_id' # noqa + } + ] + } + # create bytes content + content = json.dumps(payload).encode('utf-8') + # create mock response + resp = mock_response(content) + with patch('requests.sessions.Session.request') as mock_method: + mock_method.return_value = resp + with assert_raises(MultipleMatchingContactsError): + self.client.get('/contacts', {}) + + @istest + def it_handles_accented_characters(self): + # create a contact dict with a name that contains accented characters + payload = get_contact(name='Jóe Schmö') + # create bytes content + content = json.dumps(payload).encode('utf-8') + # create mock response + resp = mock_response(content) + with patch('requests.sessions.Session.request') as mock_method: + mock_method.return_value = resp + contact = self.client.contacts.find(email='bob@example.com') + eq_('Jóe Schmö', contact.name) + + +class DescribeIncrementingCustomAttributeFields(unittest.TestCase): + + def setUp(self): # noqa + self.client = Client() + + created_at = datetime.utcnow() + params = { + 'email': 'jo@example.com', + 'external_id': 'i-1224242', + 'custom_attributes': { + 'mad': 123, + 'another': 432, + 'other': time.mktime(created_at.timetuple()), + 'thing': 'yay', + 'logins': None, + } + } + self.contact = Contact(**params) + + @istest + def it_increments_up_by_1_with_no_args(self): + self.contact.increment('mad') + eq_(self.contact.to_dict()['custom_attributes']['mad'], 124) + + @istest + def it_increments_up_by_given_value(self): + self.contact.increment('mad', 4) + eq_(self.contact.to_dict()['custom_attributes']['mad'], 127) + + @istest + def it_increments_down_by_given_value(self): + self.contact.increment('mad', -1) + eq_(self.contact.to_dict()['custom_attributes']['mad'], 122) + + @istest + def it_can_increment_new_custom_data_fields(self): + self.contact.increment('new_field', 3) + eq_(self.contact.to_dict()['custom_attributes']['new_field'], 3) + + @istest + def it_can_increment_none_values(self): + self.contact.increment('logins') + eq_(self.contact.to_dict()['custom_attributes']['logins'], 1) + + @istest + def it_can_call_increment_on_the_same_key_twice_and_increment_by_2(self): # noqa + self.contact.increment('mad') + self.contact.increment('mad') + eq_(self.contact.to_dict()['custom_attributes']['mad'], 125) + + @istest + def it_can_save_after_increment(self): # noqa + contact = Contact( + email=None, external_id="i-1224242", + companies=[{'company_id': 6, 'name': 'Intercom'}]) + body = { + 'custom_attributes': {}, + 'email': "", + 'external_id': 'i-1224242', + 'companies': [{ + 'company_id': 6, + 'name': 'Intercom' + }] + } + with patch.object(Client, 'post', return_value=body) as mock_method: # noqa + contact.increment('mad') + eq_(contact.to_dict()['custom_attributes']['mad'], 1) + self.client.contacts.save(contact) + + +class DescribeBulkOperations(unittest.TestCase): # noqa + + def setUp(self): # noqa + self.client = Client() + + self.job = { + "app_id": "app_id", + "id": "super_awesome_job", + "created_at": 1446033421, + "completed_at": 1446048736, + "closing_at": 1446034321, + "updated_at": 1446048736, + "name": "api_bulk_job", + "state": "completed", + "links": { + "error": "https://api.intercom.io/jobs/super_awesome_job/error", + "self": "https://api.intercom.io/jobs/super_awesome_job" + }, + "tasks": [ + { + "id": "super_awesome_task", + "item_count": 2, + "created_at": 1446033421, + "started_at": 1446033709, + "completed_at": 1446033709, + "state": "completed" + } + ] + } + + self.bulk_request = { + "items": [ + { + "method": "post", + "data_type": "contact", + "data": { + "external_id": 25, + "email": "alice@example.com" + } + }, + { + "method": "delete", + "data_type": "contact", + "data": { + "external_id": 26, + "email": "bob@example.com" + } + } + ] + } + + self.contacts_to_create = [ + { + "external_id": 25, + "email": "alice@example.com" + } + ] + + self.contacts_to_delete = [ + { + "external_id": 26, + "email": "bob@example.com" + } + ] + + created_at = datetime.utcnow() + params = { + 'email': 'jo@example.com', + 'external_id': 'i-1224242', + 'custom_attributes': { + 'mad': 123, + 'another': 432, + 'other': time.mktime(created_at.timetuple()), + 'thing': 'yay' + } + } + self.contact = Contact(**params) + + @istest + def it_submits_a_bulk_job(self): # noqa + with patch.object(Client, 'post', return_value=self.job) as mock_method: # noqa + self.client.contacts.submit_bulk_job( + create_items=self.contacts_to_create, delete_items=self.contacts_to_delete) + mock_method.assert_called_once_with('/bulk/contacts', self.bulk_request) + + @istest + def it_adds_contacts_to_an_existing_bulk_job(self): # noqa + self.bulk_request['job'] = {'id': 'super_awesome_job'} + with patch.object(Client, 'post', return_value=self.job) as mock_method: # noqa + self.client.contacts.submit_bulk_job( + create_items=self.contacts_to_create, delete_items=self.contacts_to_delete, + job_id='super_awesome_job') + mock_method.assert_called_once_with('/bulk/contacts', self.bulk_request) diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index fdfa7794..f590552f 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -1,17 +1,15 @@ # -*- coding: utf-8 -*- -import intercom import json import unittest +from mock import patch +from nose.tools import assert_raises, eq_, istest, ok_ + +import intercom +from intercom import UnexpectedError from intercom.client import Client from intercom.request import Request -from intercom import UnexpectedError -from mock import patch -from nose.tools import assert_raises -from nose.tools import eq_ -from nose.tools import ok_ -from nose.tools import istest from tests.unit import mock_response @@ -230,7 +228,7 @@ def it_raises_a_multiple_matching_users_error(self): resp = mock_response(content) with patch('requests.sessions.Session.request') as mock_method: mock_method.return_value = resp - with assert_raises(intercom.MultipleMatchingUsersError): + with assert_raises(intercom.MultipleMatchingContactsError): self.client.get('/users', {}) @istest @@ -342,6 +340,7 @@ def it_allows_the_timeout_to_be_changed(self): @istest def it_allows_the_timeout_to_be_configured(self): import os + from intercom.request import configure_timeout # check the default diff --git a/tests/unit/test_scroll_collection_proxy.py b/tests/unit/test_scroll_collection_proxy.py index a2405858..0eda7d0c 100644 --- a/tests/unit/test_scroll_collection_proxy.py +++ b/tests/unit/test_scroll_collection_proxy.py @@ -2,13 +2,11 @@ """Test module for Scroll Collection Proxy.""" import unittest +from mock import call, patch +from nose.tools import assert_raises, eq_, istest + from intercom import HttpError from intercom.client import Client -from mock import call -from mock import patch -from nose.tools import assert_raises -from nose.tools import eq_ -from nose.tools import istest from tests.unit import users_scroll @@ -22,7 +20,7 @@ def it_stops_iterating_if_no_users_returned(self): # noqa body = users_scroll(include_users=False) with patch.object(Client, 'get', return_value=body) as mock_method: emails = [user.email for user in self.client.users.scroll()] - mock_method.assert_called('/users/scroll', {}) + mock_method.assert_called_with('/users/scroll', {}) eq_(emails, []) # noqa @istest diff --git a/tests/unit/test_user.py b/tests/unit/test_user.py index f3f3594f..f83137a1 100644 --- a/tests/unit/test_user.py +++ b/tests/unit/test_user.py @@ -2,25 +2,21 @@ import calendar import json -import mock import time import unittest - from datetime import datetime + +import mock +from mock import patch +from nose.tools import assert_raises, eq_, istest, ok_ + +from intercom import MultipleMatchingContactsError +from intercom.client import Client from intercom.collection_proxy import CollectionProxy from intercom.lib.flat_store import FlatStore -from intercom.client import Client from intercom.user import User -from intercom import MultipleMatchingUsersError from intercom.utils import define_lightweight_class -from mock import patch -from nose.tools import assert_raises -from nose.tools import eq_ -from nose.tools import ok_ -from nose.tools import istest -from tests.unit import get_user -from tests.unit import mock_response -from tests.unit import page_of_users +from tests.unit import get_user, mock_response, page_of_users class UserTest(unittest.TestCase): @@ -370,7 +366,7 @@ def it_raises_a_multiple_matching_users_error_when_receiving_a_conflict(self): resp = mock_response(content) with patch('requests.sessions.Session.request') as mock_method: mock_method.return_value = resp - with assert_raises(MultipleMatchingUsersError): + with assert_raises(MultipleMatchingContactsError): self.client.get('/users', {}) @istest