diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..6a4c611c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Versions (please complete the following information):** + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..7804bfbe --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,69 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["main"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + schedule: + - cron: "28 1 * * 4" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["python"] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..5daff198 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,38 @@ +name: coverage + +on: + push: + branches: [main, alpha, beta] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + poetry-version: ["1.3"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run image + uses: abatilo/actions-poetry@v2.0.0 + with: + poetry-version: ${{ matrix.poetry-version }} + - name: Install dependencies + run: poetry install + - name: Run tests with coverage + run: | + poetry run coverage run + poetry run coverage xml + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + with: + verbose: true + files: coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: true diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 00000000..14bc47fe --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,27 @@ +name: pre-commit + +on: + pull_request: + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + poetry-version: ["1.3"] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run image + uses: abatilo/actions-poetry@v2.0.0 + with: + poetry-version: ${{ matrix.poetry-version }} + - name: Install dependencies + run: poetry install + - run: poetry run pre-commit run --show-diff-on-failure --color=always --all-files + shell: bash diff --git a/.github/workflows/pylama.yml b/.github/workflows/pylama.yml new file mode 100644 index 00000000..da7839a3 --- /dev/null +++ b/.github/workflows/pylama.yml @@ -0,0 +1,27 @@ +name: lint-pylama + +on: + pull_request: + +jobs: + pylama: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + poetry-version: ["1.3"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run image + uses: abatilo/actions-poetry@v2.0.0 + with: + poetry-version: ${{ matrix.poetry-version }} + - name: Install dependencies + run: poetry install + - name: Analysing the code with pylint + run: | + poetry run pylama diff --git a/.github/workflows/semver_build_publish.yml b/.github/workflows/semver_build_publish.yml new file mode 100644 index 00000000..7250347c --- /dev/null +++ b/.github/workflows/semver_build_publish.yml @@ -0,0 +1,69 @@ +name: Semver, Build, Publish + +on: + push: + branches: [main, alpha, beta] + +jobs: + semver: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Semantic Release - prepare + run: | + cat < package.json + { + "name": "semver", + "private": true + } + EOF + - name: Semantic Release + uses: cycjimmy/semantic-release-action@v3 + with: + semantic_version: 16 + branches: | + [ + 'main', + { + name: 'alpha', + prerelease: true + }, + { + name: 'beta', + prerelease: true + } + ] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + id: semantic + outputs: + version: ${{ steps.semantic.outputs.new_release_version }} + new_release_published: ${{ steps.semantic.outputs.new_release_published }} + new_release_channel: ${{ steps.semantic.outputs.new_release_channel }} + + + build_and_publish: + runs-on: ubuntu-latest + needs: [semver] + if: needs.semver.outputs.new_release_published == 'true' + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: install poetry + uses: abatilo/actions-poetry@v2.2.0 + + - name: Build + run: | + poetry version ${{ needs.semver.outputs.version }} + poetry build + + - name: Publish distribution đŸ“Ļ to PyPI + run: | + poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} + poetry publish diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..9526cd47 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: tests-unittest + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + poetry-version: ["1.3"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run image + uses: abatilo/actions-poetry@v2.0.0 + with: + poetry-version: ${{ matrix.poetry-version }} + - name: Install dependencies + run: poetry install + - name: Run tests with coverage (100% coverage threshold) + run: | + poetry run coverage run + poetry run coverage report -m diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d7c7d21f --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +### Python ### + +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +media +ca.pem +client.crt +client.key +current.py +local.py + +# Unit test / coverage reports +htmlcov +.coverage* +coverage.xml +junit-report.xml +tmp/ + +# Sphinx documentation +docs/_build/ +public + +# pyenv +.python-version + +# Environments +.env +.venv + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode + +# Celery TMP files +celerybeat-schedule* + +# idea +tmp/* +.idea/* +dist/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..a075b671 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,93 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_stages: [commit] +default_language_version: + node: "16.16.0" + python: "python3.9" +repos: + - repo: https://github.com/MarcoGorelli/absolufy-imports + rev: "v0.3.1" + hooks: + - id: absolufy-imports + + - repo: https://github.com/ambv/black + rev: "22.6.0" + hooks: + - id: black + language_version: python3.9 + + - repo: https://github.com/gvanderest/pylama-pre-commit + rev: 0.1.2 + hooks: + - id: pylama + additional_dependencies: ["pylama[toml]"] + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["-m=VERTICAL_HANGING_INDENT", "--combine-as", "--profile=black"] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v4.3.0" + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-ast + - id: check-added-large-files + - id: check-merge-conflict + - id: pretty-format-json + args: + - "--autofix" + - "--no-sort-keys" + - "--no-ensure-ascii" + + - repo: https://github.com/Lucas-C/pre-commit-hooks-safety + rev: "v1.3.0" + hooks: + - id: python-safety-dependencies-check + files: pyproject.toml + args: [--disable-audit-and-monitor] + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: "v1.9.0" + hooks: + - id: python-no-log-warn + - id: python-check-mock-methods + - id: python-no-eval + + - repo: https://github.com/PyCQA/bandit + rev: "1.7.4" + hooks: + - id: bandit + args: ["-c", "pyproject.toml"] + additional_dependencies: ["bandit[toml]"] + + - repo: https://github.com/codespell-project/codespell + rev: "v2.1.0" + hooks: + - id: codespell + args: ["-w"] + additional_dependencies: + - tomli + + - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.0.0 + hooks: + - id: commitlint + stages: [commit-msg] + + - repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + args: ["--py38-plus"] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.0.0 + hooks: + - id: mypy + args: ["--ignore-missing-imports", "novu"] + pass_filenames: false + additional_dependencies: + - types-requests diff --git a/README.md b/README.md index 84c0571f..49632b0b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,61 @@ -# novu-python -This projet aims to provide a wrapper for the Novu API +# Novu Client (Python) + +[![PyPI](https://img.shields.io/pypi/v/novu-python?color=blue)](https://pypi.org/project/novu-python/) +![Tests Status](https://github.com/ryshu/novu-python/actions/workflows/.github/workflows/tests.yml/badge.svg) +[![codecov](https://codecov.io/gh/ryshu/novu-python/branch/main/graph/badge.svg?token=RON7F8QTZX)](https://codecov.io/gh/ryshu/novu-python) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/novu-python) +![PyPI - License](https://img.shields.io/pypi/l/novu-python) +[![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release) + +--- + +This project aims to provide a python wrapper for the Novu API. + +## Install + +To install this package + +```shell +# Via pip +pip install novu + +# Via poetry +poetry add novu +``` + +## Quick start + +This package is a wrapper of all the resources offered by Novu, we will just start by triggering an event on Novu. + +To do this, you will need: + +1. Follow Novu's procedure on how to set up your first template and keep in mind the identifier to trigger the template: https://docs.novu.co/overview/quick-start#create-a-notification-template +2. Retrieve your API key from the platform directly in the settings section: https://web.novu.co/settings +3. Play the following script: + +```python +from novu.api import EventApi + +event_api = EventApi("https://api.novu.co/api/", "") +event_api.trigger( + name="", + recipients="", + payload={}, # Your Novu payload goes here +) +``` + +If all is ok, this should have trigger a notification in Novu. + +## Development + +```bash +# install deps +poetry install + +# pre-commit +poetry run pre-commit install --install-hook +poetry run pre-commit install --install-hooks --hook-type commit-msg +``` diff --git a/novu/__init__.py b/novu/__init__.py new file mode 100644 index 00000000..b798114a --- /dev/null +++ b/novu/__init__.py @@ -0,0 +1,24 @@ +""" +Novu SDK +======== + +Provides + 1. Wrapper to interact with Novu API for each resource + 2. DTO dataclasses to parse Novu resources. + 3. Enumerations from Novu. + +Available subpackages +--------------------- + +api + Wrapper to interact with Novu API for each resource +dto + DTO dataclasses to parse Novu resources. +enums + Enumerations from Novu. +""" + +from novu import api, dto, enums +from novu.config import NovuConfig + +__all__ = ["api", "dto", "enums", "NovuConfig"] diff --git a/novu/api/__init__.py b/novu/api/__init__.py new file mode 100644 index 00000000..75d7943a --- /dev/null +++ b/novu/api/__init__.py @@ -0,0 +1,17 @@ +"""This module is used to gather all python wrapper using to describe resources in Novu API. + +In this SDK, we choose to split the Novu API by business resource to simplify its complexity. +""" +from novu.api.event import EventApi +from novu.api.integration import IntegrationApi +from novu.api.layout import LayoutApi +from novu.api.subscriber import SubscriberApi +from novu.api.topic import TopicApi + +__all__ = [ + "EventApi", + "IntegrationApi", + "LayoutApi", + "SubscriberApi", + "TopicApi", +] diff --git a/novu/api/base.py b/novu/api/base.py new file mode 100644 index 00000000..f47793fa --- /dev/null +++ b/novu/api/base.py @@ -0,0 +1,76 @@ +"""This module is used to defined an abstract class for all reusable methods to communicate with the Novu API""" +import copy +import logging +from json.decoder import JSONDecodeError +from typing import Optional + +import requests +import sentry_sdk + +from novu.config import NovuConfig + +LOGGER = logging.getLogger(__name__) + + +class Api: # pylint: disable=R0903 + """Base class for all API in the Novu client""" + + def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None) -> None: + config = NovuConfig() + + url = url or config.url + api_key = api_key or config.api_key + + self._url = url + self._headers = {"Authorization": f"ApiKey {api_key}"} + + def handle_request( + self, + method: str, + url: str, + json: Optional[dict] = None, + payload: Optional[dict] = None, + headers: Optional[dict] = None, + **kwargs, + ) -> dict: + """Handle a request to the API. + + This method can handle all cases of request and is used to authenticate the request and raise + an error on bad status. + + Args: + method: The HTTP method used during the request (e.g. "POST") + url: The URL to reach during the request + json: The body to send, in json format. Defaults to None. + payload: Params to send, in json format. Defaults to None. + headers: Headers to send, in json format. Defaults to None. + + Returns: + Return parsed response. + """ + if headers: + _headers = copy.deepcopy(self._headers) + _headers.update(copy.deepcopy(headers)) + else: + _headers = self._headers + + res: requests.Response = requests.request( + method=method, + url=url, + headers=_headers, + json=json, + params=payload, + timeout=5, + **kwargs, + ) + + if not res.ok: + try: + detail = res.json() + # FIXME: For some reason, coverage doesn't see this line as covered. + sentry_sdk.set_extra("error_details", detail) # pragma: no cover + except JSONDecodeError: + pass + res.raise_for_status() + + return res.json() diff --git a/novu/api/event.py b/novu/api/event.py new file mode 100644 index 00000000..ac14a22c --- /dev/null +++ b/novu/api/event.py @@ -0,0 +1,163 @@ +""" +This module is used to define the ``EventApi``, a python wrapper to interact with ``Events`` in Novu. +""" +from collections.abc import Iterable +from typing import Iterable as _Iterable, List, Optional, Union + +from novu.api.base import Api +from novu.constants import EVENTS_ENDPOINT +from novu.dto.event import EventDto +from novu.dto.topic import TriggerTopicDto + + +class EventApi(Api): + """This class aims to handle all API methods around events in Novu""" + + def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None) -> None: + super().__init__(url, api_key) + + self._event_url = f"{self._url}{EVENTS_ENDPOINT}" + + def trigger( + self, + name: str, + recipients: Union[str, List[str]], + payload: dict, + overrides: Optional[dict] = None, + transaction_id: Optional[str] = None, + actor: Optional[str] = None, + ) -> EventDto: + """Trigger event is the main way to send notification to subscribers. + + The trigger ID is used to match the particular template associated whit it. + + .. note:: If you want to use topic, please use + :meth:`~novu.api.event.EventApi.trigger_topic` instead + + .. warning:: For the "recipients" attribute, the part allowing the dynamic creation of + subscribers in the Novu AP was deliberately removed from the wrapper + to force you to pre-create them. It is for us a good practice that we must + begin to use for long-term programming with the platform (reuse of actor + during the sending, subscription, management of preferences ...). + + Args: + name: The name of the template trigger to activate. + recipients: A subscriber ID (or a list of subscriber ID) to reach with this trigger + payload: + A JSON serializable python dict to pass additional custom information that could be used to render the + template, or perform routing rules based on it. This data will also be available when fetching the + notifications feed from the API to display certain parts of the UI. + + overrides: + A JSON serializable python dict used to override provider specific configurations. Defaults to None. + + transaction_id: A unique ID for this transaction, we will generated a UUID if not provided. + + actor: + It is used to display the Avatar of the provided actor's subscriber id. Defaults to None. + + Returns: + Create Event definition in Novu + """ + payload = {"name": name, "to": recipients, "payload": payload} + if overrides: + payload["overrides"] = overrides + if actor: + payload["actor"] = actor + if transaction_id: + payload["transactionId"] = transaction_id + + return EventDto.from_camel_case(self.handle_request("POST", self._event_url, payload)["data"]) + + def trigger_topic( + self, + name: str, + topics: Union[TriggerTopicDto, _Iterable[TriggerTopicDto]], + payload: dict, + overrides: Optional[dict] = None, + transaction_id: Optional[str] = None, + actor: Optional[str] = None, + ) -> EventDto: + """Trigger event is the main way to send notification to topic's subscribers. + + The trigger ID is used to match the particular template associated whit it. + + .. note:: If you want to use subscriber ID, please use :meth:`~novu.api.event.EventApi.trigger` instead + + Args: + name: The name of the template trigger to activate. + topics: A TriggerTopicDto (or a list of topic) to reach with this trigger + payload: + A JSON serializable python dict to pass additional custom information that could be used to render the + template, or perform routing rules based on it. This data will also be available when fetching the + notifications feed from the API to display certain parts of the UI. + + overrides: + A JSON serializable python dict used to override provider specific configurations. Defaults to None. + + transaction_id: A unique ID for this transaction, we will generated a UUID if not provided. + + actor: + It is used to display the Avatar of the provided actor's subscriber id. Defaults to None. + + Returns: + Create Event definition in Novu + """ + _recipients = topics if isinstance(topics, Iterable) else [topics] + + payload = {"name": name, "to": [r.to_camel_case() for r in _recipients], "payload": payload} + if overrides: + payload["overrides"] = overrides + if actor: + payload["actor"] = actor + if transaction_id: + payload["transactionId"] = transaction_id + + return EventDto.from_camel_case(self.handle_request("POST", self._event_url, payload)["data"]) + + def broadcast( + self, + name: str, + payload: dict, + overrides: Optional[dict] = None, + transaction_id: Optional[str] = None, + actor: Optional[str] = None, + ): + """Trigger a broadcast event to all existing subscribers, could be used to send announcements, etc. + + Args: + name: The name of the template trigger to activate. + payload: + A JSON serializable python dict to pass additional custom information that could be used to render the + template, or perform routing rules based on it. This data will also be available when fetching the + notifications feed from the API to display certain parts of the UI. + + overrides: + A JSON serializable python dict used to override provider specific configurations. Defaults to None. + + transaction_id: A unique ID for this transaction, we will generated a UUID if not provided. + + actor: + It is used to display the Avatar of the provided actor's subscriber id. Defaults to None. + + Returns: + Create Event definition in Novu + """ + payload = {"name": name, "payload": payload} + if overrides: + payload["overrides"] = overrides + if actor: + payload["actor"] = actor + if transaction_id: + payload["transactionId"] = transaction_id + + return EventDto.from_camel_case(self.handle_request("POST", f"{self._event_url}/broadcast", payload)["data"]) + + def delete(self, transaction_id: str): + """Using a previously generated transaction ID during the event trigger, will cancel any active or pending + workflows. This is useful to cancel active digests, delays, etc... + + Args: + transaction_id: The transaction ID to cancel workflows + """ + self.handle_request("DELETE", f"{self._event_url}/{transaction_id}") diff --git a/novu/api/integration.py b/novu/api/integration.py new file mode 100644 index 00000000..0d818a9b --- /dev/null +++ b/novu/api/integration.py @@ -0,0 +1,103 @@ +""" +This module is used to define the ``IntregrationApi``, a python wrapper to interact with ``Intregrations`` in Novu. +""" +from typing import Iterator, Optional + +from novu.api.base import Api +from novu.constants import INTEGRATIONS_ENDPOINT +from novu.dto.integration import IntegrationChannelUsageDto, IntegrationDto +from novu.enums import Channel, ProviderIdEnum + + +class IntegrationApi(Api): + """This class aims to handle all API methods around integrations in API""" + + def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None) -> None: + super().__init__(url, api_key) + + self._integration_url = f"{self._url}{INTEGRATIONS_ENDPOINT}" + + def list(self, only_active: bool = False) -> Iterator[IntegrationDto]: + """List configured integrations + + Args: + only_active: Allow to retrieve only active integrations. Defaults to False. + + Yields: + Instance of integrations + """ + url = self._integration_url + if only_active: + url += "/active" + + results = self.handle_request("GET", url)["data"] + for result in results: + yield IntegrationDto.from_camel_case(result) + + def create(self, integration: IntegrationDto, check: bool = True) -> IntegrationDto: + """Create a provider integration configuration + + Args: + integration: Integration instance that you want to create. + check: + If you want the Novu server to check his connection using given integration configuration. + Defaults to True. + + Returns: + The instance of the created integration + """ + payload = integration.to_camel_case() + payload["check"] = check if check is not None else True + + return IntegrationDto.from_camel_case(self.handle_request("POST", f"{self._integration_url}", payload)["data"]) + + def status(self, provider_id: ProviderIdEnum) -> bool: + """Get webhook support status for a given provider + + Args: + provider_id: The provider ID + + Returns: + If the provider support webhook + """ + return self.handle_request("GET", f"{self._integration_url}/webhooks/provider/{provider_id}/status")["data"] + + def update(self, integration: IntegrationDto, check: bool = True) -> IntegrationDto: + """Update an integration configuration + + Args: + integration: The instance of the integration to update + check: + If you want the Novu server to check his connection using given integration configuration. + Defaults to True. + + Returns: + The instance of the updated integration + """ + payload = integration.to_camel_case() + payload["check"] = check if check is not None else True + + return IntegrationDto.from_camel_case( + self.handle_request("PUT", f"{self._integration_url}/{integration._id}", payload)["data"] + ) + + def delete(self, integration_id: str) -> None: + """Delete an integration definition + + Args: + integration_id: The integration ID + """ + self.handle_request("DELETE", f"{self._integration_url}/{integration_id}") + + def limit(self, channel: Channel) -> IntegrationChannelUsageDto: + """Get limit of the given channel (and usage) + + Args: + channel: The channel to retrieve limits + + Returns: + Channel usage definition (including usage and limit) + """ + return IntegrationChannelUsageDto.from_camel_case( + self.handle_request("GET", f"{self._integration_url}/{channel}/limit")["data"] + ) diff --git a/novu/api/layout.py b/novu/api/layout.py new file mode 100644 index 00000000..c3733481 --- /dev/null +++ b/novu/api/layout.py @@ -0,0 +1,88 @@ +""" +This module is used to define the ``LayoutApi``, a python wrapper to interact with ``Layouts`` in Novu. +""" +from typing import Optional + +from novu.api.base import Api +from novu.constants import LAYOUTS_ENDPOINT +from novu.dto.layout import LayoutDto, PaginatedLayoutDto + + +class LayoutApi(Api): + """This class aims to handle all API methods around layout in API""" + + def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None) -> None: + super().__init__(url, api_key) + + self._layout_url = f"{self._url}{LAYOUTS_ENDPOINT}" + + def list(self, page: Optional[int] = None, limit: Optional[int] = None) -> PaginatedLayoutDto: + """List existing layouts + + Args: + page: Page in pagination. Defaults to None. + limit: Size of the page in pagination. Defaults to None. + + Returns: + Paginated list of layout + """ + payload = {} + if page: + payload["page"] = page + if limit: + payload["pageSize"] = limit + + return PaginatedLayoutDto.from_camel_case(self.handle_request("GET", f"{self._layout_url}", payload=payload)) + + def create(self, layout: LayoutDto) -> LayoutDto: + """Create a layout and return his identifier + + Args: + layout: The instance of the layout to create + + Returns: + The created layout identifier + """ + return LayoutDto.from_camel_case( + self.handle_request("POST", f"{self._layout_url}", layout.to_camel_case())["data"] + ) + + def get(self, layout_id: str) -> LayoutDto: + """Get a layout using ID + + Args: + layout_id: The layout ID + + Returns: + The layout instance + """ + return LayoutDto.from_camel_case(self.handle_request("GET", f"{self._layout_url}/{layout_id}")["data"]) + + def patch(self, layout: LayoutDto) -> LayoutDto: + """Update a layout + + Args: + layout: The instance of the layout to patch + + Returns: + Updated layout instance + """ + return LayoutDto.from_camel_case( + self.handle_request("PATCH", f"{self._layout_url}/{layout._id}", layout.to_camel_case())["data"] + ) + + def delete(self, layout_id: str) -> None: + """Remove a layout + + Args: + layout_id: The ID of the layout to remove + """ + self.handle_request("DELETE", f"{self._layout_url}/{layout_id}") + + def set_default(self, layout_id: str) -> None: + """Set a layout as the default layout to use + + Args: + layout_id: The layout ID + """ + self.handle_request("POST", f"{self._layout_url}/{layout_id}/default") diff --git a/novu/api/subscriber.py b/novu/api/subscriber.py new file mode 100644 index 00000000..1d139c5d --- /dev/null +++ b/novu/api/subscriber.py @@ -0,0 +1,196 @@ +""" +This module is used to define the ``SubscriberApi``, a python wrapper to interact with ``Subscribers`` in Novu. +""" +from typing import Dict, Iterator, List, Optional, Union + +from novu.api.base import Api +from novu.constants import SUBSCRIBERS_ENDPOINT +from novu.dto.subscriber import ( + PaginatedSubscriberDto, + SubscriberDto, + SubscriberPreferenceDto, +) +from novu.enums import Channel + + +class SubscriberApi(Api): + """This class aims to handle all API methods around subscribers in API""" + + def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None) -> None: + super().__init__(url, api_key) + + self._subscriber_url = f"{self._url}{SUBSCRIBERS_ENDPOINT}" + + def list(self, page: Optional[int] = None) -> PaginatedSubscriberDto: + """Method to list subscriber + + Args: + page: The page number. Defaults to 0. + + Returns: + Paginated subscriber + """ + payload: Dict[str, int] = {} + if page: + payload["page"] = page + + return PaginatedSubscriberDto.from_camel_case(self.handle_request("GET", self._subscriber_url, payload=payload)) + + def create(self, subscriber: SubscriberDto) -> SubscriberDto: + """Method to push a given subscriber instance to Novu + + Args: + subscriber: The subscriber instance to push to Novu + + Returns: + The instance retrieved from Novu (populated after creation) + """ + return SubscriberDto.from_camel_case( + self.handle_request("POST", self._subscriber_url, subscriber.to_camel_case()).get("data", {}) + ) + + def get(self, subscriber_id: str) -> SubscriberDto: + """Method to get a subscriber using his identifier + + Args: + subscriber_id: The subscriber identifier + + Returns: + The subscriber instance + """ + return SubscriberDto.from_camel_case( + self.handle_request("GET", f"{self._subscriber_url}/{subscriber_id}").get("data", {}) + ) + + def put(self, subscriber: SubscriberDto) -> SubscriberDto: + """Method to update a subscriber using his instance + + Args: + subscriber: The subscriber instance to push + + Returns: + Updated subscriber instance from Novu API + """ + return SubscriberDto.from_camel_case( + self.handle_request( + "PUT", f"{self._subscriber_url}/{subscriber.subscriber_id}", subscriber.to_camel_case() + ).get("data", {}) + ) + + def delete(self, subscriber_id: str) -> None: + """Method used to delete a subscriber using his identifier + + Args: + subscriber_id: The subscriber identifier + """ + self.handle_request("DELETE", f"{self._subscriber_url}/{subscriber_id}") + + def credentials( + self, + subscriber_id: str, + provider_id: str, + webhook_url: Optional[str] = None, + device_tokens: Optional[List[str]] = None, + ) -> SubscriberDto: + """Update subscriber credentials associated to the delivery methods such as slack and push tokens + + Args: + subscriber_id: The subscriber identifier + provider_id: The provider name (e.g: slack) + webhook_url: The webhook URL to set in the provider credentials. Defaults to None. + device_tokens: A list of device tokens to set in the provider credentials. Defaults to None. + + Returns: + Updated subscriber + """ + credentials: Dict[str, Union[str, List[str]]] = {} + payload = {"providerId": provider_id, "credentials": credentials} + + if webhook_url: + credentials["webhookUrl"] = webhook_url + if device_tokens: + credentials["deviceTokens"] = device_tokens + + return SubscriberDto.from_camel_case( + self.handle_request("PUT", f"{self._subscriber_url}/{subscriber_id}/credentials", payload).get("data", {}) + ) + + def online_status(self, subscriber_id: str, status: bool) -> SubscriberDto: + """Used to update the subscriber is_online flag + + Args: + subscriber_id: The subscriber identifier. + status: The subscriber is_online flag value. + + Returns: + Updated subscriber + """ + return SubscriberDto.from_camel_case( + self.handle_request( + "PATCH", f"{self._subscriber_url}/{subscriber_id}/online-status", {"isOnline": status} + ).get("data", {}) + ) + + def preferences(self, subscriber_id: str) -> Iterator[SubscriberPreferenceDto]: + """Get the subscriber preferences + + Args: + subscriber_id: The subscriber identifier + + Yield: + Iterator of subscriber preference + """ + results = self.handle_request("GET", f"{self._subscriber_url}/{subscriber_id}/preferences").get("data", []) + for result in results: + yield SubscriberPreferenceDto.from_camel_case(result) + + def change_channel_preference( + self, subscriber_id: str, template_id: str, channel: Channel, channel_enabled: bool + ) -> SubscriberPreferenceDto: + """Change the subscriber preference for a targeted channel of given template + + Args: + subscriber_id: The subscriber on which we want to change preference + template_id: The template on which we want to change preference + channel: The channel on which we want to change preference + channel_enabled: The new state of activation of the channel + + Returns: + Updated subscriber preference of the targeted template + """ + return SubscriberPreferenceDto.from_camel_case( + self.handle_request( + "PATCH", + f"{self._subscriber_url}/{subscriber_id}/preferences/{template_id}", + {"channel": {"type": channel, "enabled": channel_enabled}}, + ).get("data", {}) + ) + + def change_preference_state(self, subscriber_id: str, template_id: str, state: bool) -> SubscriberPreferenceDto: + """Change the subscriber preference state (enabled or disabled) for a given template + + Args: + subscriber_id: The subscriber identifier + template_id: The template identifier + state: The state of the subscriber preference for given template + + Returns: + Updated subscriber preference of the targeted template + """ + return SubscriberPreferenceDto.from_camel_case( + self.handle_request( + "PATCH", f"{self._subscriber_url}/{subscriber_id}/preferences/{template_id}", {"enabled": state} + ).get("data", {}) + ) + + def unseen_notifications(self, subscriber_id: str) -> int: + """Retrieve the number of unseen notification for subscribers feed + + Args: + subscriber_id: The subscriber identifier + + Returns: + The number of unseen notification + """ + res = self.handle_request("GET", f"{self._subscriber_url}/{subscriber_id}/notifications/unseen") + return res.get("data", {}).get("count", 0) diff --git a/novu/api/topic.py b/novu/api/topic.py new file mode 100644 index 00000000..bc10d711 --- /dev/null +++ b/novu/api/topic.py @@ -0,0 +1,109 @@ +""" +This module is used to define the ``TopicApi``, a python wrapper to interact with ``Topics`` in Novu. +""" +from typing import Dict, List, Optional, Tuple, Union + +from novu.api.base import Api +from novu.constants import TOPICS_ENDPOINT +from novu.dto.topic import PaginatedTopicDto, TopicDto + + +class TopicApi(Api): + """This class aims to handle all API methods around topics in API""" + + def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None) -> None: + super().__init__(url, api_key) + + self._topic_url = f"{self._url}{TOPICS_ENDPOINT}" + + def list( + self, page: Optional[int] = None, limit: Optional[int] = None, key: Optional[str] = None + ) -> PaginatedTopicDto: + """List existing topics + + Args: + page: Page to retrieve. Defaults to None. + limit: Size of the page to retrieve. Defaults to None. + key: Filter list by a topic key. Defaults to None. + + Returns: + Paginated list of topic + """ + payload: Dict[str, Union[int, str]] = {} + if page: + payload["page"] = page + if limit: + payload["limit"] = limit + if key: + payload["key"] = key + + return PaginatedTopicDto.from_camel_case(self.handle_request("GET", f"{self._topic_url}", payload=payload)) + + def create(self, key: str, name: str) -> TopicDto: + """Create a topic + + Args: + key: The topic key to create + name: The topic name to create + + Returns: + Created topic (without name ?) + """ + return TopicDto.from_camel_case( + self.handle_request("POST", f"{self._topic_url}", TopicDto(key, name).to_camel_case())["data"] + ) + + def get(self, key: str) -> TopicDto: + """Retrieve a Topic using his key + + .. note:: This method is the easiest way to get the list of subscribers. + + Args: + key: The topic key (ID in Novu) + + Returns: + Topic + """ + return TopicDto.from_camel_case(self.handle_request("GET", f"{self._topic_url}/{key}")["data"]) + + # FIXME: In documentation, doesn't return anything. But in real life ? + def subscribe(self, key: str, subscribers: Union[List[str], str]) -> Tuple[List[str], Dict[str, List[str]]]: + """Subscribe a list of subscribers to a topic + + Args: + key: The key of the topic to subscribe + subscribers: The list of subscribers to subscribe + + Returns: + First element returned is a list of succeeded subscriptions. + + Second element returned is a dict of failed subscriptions + (key the reason and value contains a list of reference which fail for the reason). + """ + payload = {"subscribers": subscribers if isinstance(subscribers, list) else [subscribers]} + result = self.handle_request("POST", f"{self._topic_url}/{key}/subscribers", payload) + return result["data"].get("succeeded", []), result["data"].get("failed", {}) + + def unsubscribe(self, key: str, subscribers: Union[List[str], str]) -> None: + """Unsubscribe a list of subscribers form a topic + + Args: + key: The key of the topic to unsubscribe + subscribers: The list of subscribers to unsubscribe + """ + payload = {"subscribers": subscribers if isinstance(subscribers, list) else [subscribers]} + self.handle_request("POST", f"{self._topic_url}/{key}/subscribers/removal", payload) + + def rename(self, key: str, name: str) -> TopicDto: + """Rename a topic + + Args: + key: The key of the topic to rename + name: The new name of the topic + + Returns: + Renamed topic definition + """ + return TopicDto.from_camel_case( + self.handle_request("PATCH", f"{self._topic_url}/{key}", {"name": name})["data"] + ) diff --git a/novu/config.py b/novu/config.py new file mode 100644 index 00000000..a1c343a5 --- /dev/null +++ b/novu/config.py @@ -0,0 +1,25 @@ +"""This module is used to define a configuration ``NovuConfig`` reusable through the package.""" +from novu.helpers import Singleton + + +class NovuConfig(metaclass=Singleton): # pylint: disable=R0903 + """Singleton used to configure globally URL and API_KEY for the Novu Client + + Attributes: + url: The URL of the Novu API to reach. + api_key: The API Key required to auth on the Novu API. + """ + + url: str + api_key: str + + @classmethod + def configure(cls, url: str, api_key: str): + """Class method provided to initialise the singleton data. + + Args: + url: The URL of the Novu API to reach. + api_key: The API Key required to auth on the Novu API. + """ + cls().url = url + cls().api_key = api_key diff --git a/novu/constants.py b/novu/constants.py new file mode 100644 index 00000000..a8e80d65 --- /dev/null +++ b/novu/constants.py @@ -0,0 +1,9 @@ +"""This module is used to gather all constants and magic number used in the Novu client.""" + +LAYOUTS_ENDPOINT = "/v1/layouts" +INTEGRATIONS_ENDPOINT = "/v1/integrations" +EVENTS_ENDPOINT = "/v1/events/trigger" +SUBSCRIBERS_ENDPOINT = "/v1/subscribers" +TOPICS_ENDPOINT = "/v1/topics" + +DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" diff --git a/novu/dto/__init__.py b/novu/dto/__init__.py new file mode 100644 index 00000000..688e2335 --- /dev/null +++ b/novu/dto/__init__.py @@ -0,0 +1,36 @@ +"""This module is used to gather all Data Transfer Object definitions in the Novu SDK. + +All definitions of format returned by the Novu API are here, which help us to instantiate and document them +for developer purpose (instead of getting raw dict without any hint about what is in it). +""" + +from novu.dto.event import EventDto +from novu.dto.integration import IntegrationChannelUsageDto, IntegrationDto +from novu.dto.layout import LayoutDto, LayoutVariableDto, PaginatedLayoutDto +from novu.dto.subscriber import ( + PaginatedSubscriberDto, + SubscriberDto, + SubscriberPreferenceChannelDto, + SubscriberPreferenceDto, + SubscriberPreferencePreferenceDto, + SubscriberPreferenceTemplateDto, +) +from novu.dto.topic import PaginatedTopicDto, TopicDto, TriggerTopicDto + +__all__ = [ + "EventDto", + "IntegrationDto", + "IntegrationChannelUsageDto", + "LayoutDto", + "LayoutVariableDto", + "PaginatedLayoutDto", + "PaginatedSubscriberDto", + "SubscriberDto", + "SubscriberPreferenceChannelDto", + "SubscriberPreferenceDto", + "SubscriberPreferencePreferenceDto", + "SubscriberPreferenceTemplateDto", + "PaginatedTopicDto", + "TopicDto", + "TriggerTopicDto", +] diff --git a/novu/dto/base.py b/novu/dto/base.py new file mode 100644 index 00000000..432c3320 --- /dev/null +++ b/novu/dto/base.py @@ -0,0 +1,148 @@ +"""This module is used to defined all helpers to parse and send well-formatted data to the Novu API""" +import dataclasses +import re +from typing import ClassVar, Generic, Iterable, List, Optional, Type, TypeVar, Union + +CAMELIZE_PATTERN = re.compile(r"(?:^|_)(.)") +UNDERSCORE_PATTERN_1 = re.compile(r"([A-Z]+)([A-Z][a-z])") +UNDERSCORE_PATTERN_2 = re.compile(r"([a-z\d])([A-Z])") + + +def camelize(string: str, uppercase_first_letter: bool = False) -> str: + """Convert strings to CamelCase. + + Examples: + >>> camelize("device_type") + 'DeviceType' + >>> camelize("device_type", False) + 'deviceType' + + Args: + string: The string to camelize + uppercase_first_letter: + If set to `True` :func:`camelize` converts strings to UpperCamelCase. + If set to `False` :func:`camelize` produces lowerCamelCase. + Defaults to `False`. + """ + if uppercase_first_letter: + return re.sub(CAMELIZE_PATTERN, lambda m: m.group(1).upper(), string) + + return string[0].lower() + camelize(string, True)[1:] + + +def underscore(word: str) -> str: + """Make an underscored, lowercase form from the expression in the string. + + Example: + >>> underscore("DeviceType") + 'device_type' + """ + word = re.sub(UNDERSCORE_PATTERN_1, r"\1_\2", word) + word = re.sub(UNDERSCORE_PATTERN_2, r"\1_\2", word) + return word.replace("-", "_").lower() + + +_T = TypeVar("_T") + + +@dataclasses.dataclass +class CamelCaseDto(Generic[_T]): + """A generic dataclass that allows to convert data from Novu API written in + camel case to python (in snake_case) and back.""" + + camelize_ignored: ClassVar[List[str]] = [] + """List of fields which we don't want to camelize.""" + + camel_case_fields: ClassVar[Optional[List[str]]] = None + """List of fields to define in the dict during :meth:`to_camel_case`. + + If this list is not defined, we have taken all the fields from the data class.""" + + @classmethod + def from_camel_case(cls: Type[_T], data: dict) -> _T: + """Helper to parse a camel case dict""" + fields = {f.name: f.type for f in dataclasses.fields(cls)} + kwargs = {} + for key, val in data.items(): + _key = underscore(key) + if _key in fields.keys(): + kwargs[_key] = fields[_key].from_camel_case(val) if hasattr(fields[_key], "from_camel_case") else val + + return cls(**kwargs) + + def to_camel_case(self) -> dict: + """Helper to build a camel case dict""" + if self.camel_case_fields: + # pylint: disable=E1135 + return { + camelize(k) if k not in self.camelize_ignored else k: v + for k, v in dataclasses.asdict(self).items() + if k in self.camel_case_fields + } + return {camelize(k) if k not in self.camelize_ignored else k: v for k, v in dataclasses.asdict(self).items()} + + +_C_co = TypeVar("_C_co", bound=CamelCaseDto, covariant=True) + + +class DtoDescriptor(Generic[_C_co]): + """The Dto descriptor is used on :func:`dataclasses.dataclass` to help them parse sub-struct defined using + the :class:`~novu.dto.base.CamelCaseDto` class during :meth:`~novu.dto.base.CamelCaseDto.from_camel_case` calls + on Novu API response. + + Example: + >> @dataclasses.dataclass + .. class SubscriberPreferenceDto(CamelCaseDto["SubscriberPreferenceDto"]): + .. template: DtoDescriptor[SubscriberPreferenceTemplateDto] = ( + .. DtoDescriptor[SubscriberPreferenceTemplateDto](item_cls=SubscriberPreferenceTemplateDto) + .. ) + """ + + def __init__(self, item_cls: Type[CamelCaseDto]): + self._name: Optional[str] = None + self._item_cls = item_cls + + def __set_name__(self, _, name: str): + self._name = f"_{name}" + + def __get__(self, obj, _) -> Optional[_C_co]: + if obj is None: + return None + + return getattr(obj, self._name, None) if self._name else None + + def __set__(self, obj, value: Union[_C_co, dict]) -> None: + if self._name: # pragma: no branch (ignore because should never append as descriptor, branch is just for mypy) + setattr(obj, self._name, self._item_cls.from_camel_case(value) if isinstance(value, dict) else value) + + +class DtoIterableDescriptor(Generic[_C_co]): + """The Dto iterable descriptor is used on :func:`dataclasses.dataclass` to help them parse iterable sub-struct + defined using the :class:`~novu.dto.base.CamelCaseDto` class during + :meth:`~novu.dto.base.CamelCaseDto.from_camel_case` calls on Novu API response. + + Example: + >> @dataclasses.dataclass + .. class PaginatedTopicDto(CamelCaseDto["PaginatedTopicDto"]): + .. data: DtoIterableDescriptor[TopicDto] = DtoIterableDescriptor[TopicDto]( + .. default_factory=list, item_cls=TopicDto + .. ) + """ + + def __init__(self, default_factory, item_cls: Type[_C_co]): + self.default_factory = default_factory + self._name: Optional[str] = None + self._item_cls = item_cls + + def __set_name__(self, _, name): + self._name = f"_{name}" + + def __get__(self, obj, _) -> Optional[Iterable[_C_co]]: + if obj is None: + return None + + return getattr(obj, self._name, None) if self._name else None + + def __set__(self, obj, value: Union[Iterable[_C_co], Iterable[dict]]) -> None: + if self._name: # pragma: no branch (ignore because should never append as descriptor, branch is just for mypy) + setattr(obj, self._name, [self._item_cls.from_camel_case(v) if isinstance(v, dict) else v for v in value]) diff --git a/novu/dto/event.py b/novu/dto/event.py new file mode 100644 index 00000000..e7ce311a --- /dev/null +++ b/novu/dto/event.py @@ -0,0 +1,19 @@ +"""This module is used to gather all DTO definitions related to the Event resource in Novu""" +import dataclasses + +from novu.dto.base import CamelCaseDto +from novu.enums import EventStatus + + +@dataclasses.dataclass +class EventDto(CamelCaseDto["EventDto"]): + """Definition of an event""" + + acknowledged: bool + """If trigger was acknowledged or not.""" + + status: EventStatus + """Status for trigger.""" + + transaction_id: str + """Transaction id for trigger.""" diff --git a/novu/dto/integration.py b/novu/dto/integration.py new file mode 100644 index 00000000..3a8d5c79 --- /dev/null +++ b/novu/dto/integration.py @@ -0,0 +1,61 @@ +"""This module is used to gather all DTO definitions related to the Integration resource in Novu""" +import dataclasses +from typing import Dict, Optional, Union + +from novu.dto.base import CamelCaseDto +from novu.enums import Channel, CredentialsKeyEnum, ProviderIdEnum + + +@dataclasses.dataclass +class IntegrationDto(CamelCaseDto["IntegrationDto"]): # pylint: disable=R0902 + """Definition of an integration""" + + camel_case_fields = ["provider_id", "channel", "credentials", "active"] + # Actually, only these fields are editable in Novu, so prevent any activity on others + + provider_id: ProviderIdEnum + """Provider ID, which is one of predefined available enum""" + + channel: Channel + """Integration channel""" + + credentials: Dict[CredentialsKeyEnum, Union[str, bool]] + """Credentials of the provider""" + + active: bool + """If the provider is active""" + + _id: Optional[str] = None + """Integration ID in Novu internal storage system""" + + _environment_id: Optional[str] = None + """Environment ID in Novu internal storage system""" + + _organization_id: Optional[str] = None + """Organization ID in Novu internal storage system""" + + created_at: Optional[str] = None + """Date-time of the integration initial configuration""" + + updated_at: Optional[str] = None + """Date-time of the last update of the integration""" + + deleted_at: Optional[str] = None + """Date-time of the removal of the integration""" + + deleted_by: Optional[str] = None + """User how ask for the removal of the integration""" + + deleted: Optional[bool] = None + """If the integration is deleted""" + + +@dataclasses.dataclass +class IntegrationChannelUsageDto(CamelCaseDto["IntegrationChannelUsageDto"]): + """Definition of an integration usage (scoped to a channel)""" + + count: Optional[int] + """Usage count of the channel""" + + limit: Optional[int] + """Usage limit of the channel""" diff --git a/novu/dto/layout.py b/novu/dto/layout.py new file mode 100644 index 00000000..28cac474 --- /dev/null +++ b/novu/dto/layout.py @@ -0,0 +1,88 @@ +"""This module is used to gather all DTO definitions related to the Layout resource in Novu""" +import dataclasses +from typing import Optional + +from novu.dto.base import CamelCaseDto, DtoIterableDescriptor +from novu.enums import Channel, TemplateVariableTypeEnum + + +@dataclasses.dataclass +class LayoutVariableDto(CamelCaseDto["LayoutVariableDto"]): + """Layout variable definition""" + + name: str + """Layout variable name""" + + type: TemplateVariableTypeEnum + """Layout variable type""" + + _id: Optional[str] = None + """Layout variable ID in the Novu storage system""" + + required: bool = False + """If this variable is required in layout""" + + default_value: Optional[str] = None + """The variable default value in layout""" + + +@dataclasses.dataclass +class LayoutDto(CamelCaseDto["LayoutDto"]): # pylint: disable=R0902 + """Layout definition""" + + camel_case_fields = ["name", "description", "content", "variables", "is_default"] + # Actually, only these fields are editable in Novu, so prevent any activity on others + + name: str + """Layout name""" + + description: str + """Layout description""" + + content: str + """Layout content, must contains at least {{{body}}}}""" + + is_default: bool + """Layout is the default layout in your environment""" + + variables: DtoIterableDescriptor[LayoutVariableDto] = DtoIterableDescriptor[LayoutVariableDto]( + default_factory=list, item_cls=LayoutVariableDto + ) + """List of variables used in the layout""" + + _id: Optional[str] = None + """Layout ID in Novu internal storage system""" + + _environment_id: Optional[str] = None + """Environment ID in Novu internal storage system""" + + _organization_id: Optional[str] = None + """Organization ID in Novu internal storage system""" + + _creator_id: Optional[str] = None + """Creator ID in Novu internal storage system""" + + content_type: Optional[str] = None + """Content type of the layout""" + + channel: Optional[Channel] = None + """Channel of the layout""" + + created_at: Optional[str] = None + """Creation date-time of the layout""" + + updated_at: Optional[str] = None + """Last date-time when the layout was updated""" + + is_deleted: Optional[bool] = None + """If the layout is deleted""" + + +@dataclasses.dataclass +class PaginatedLayoutDto(CamelCaseDto["PaginatedLayoutDto"]): + """Paginated layout definition""" + + page: int = 0 + total_count: int = 0 + page_size: int = 0 + data: DtoIterableDescriptor[LayoutDto] = DtoIterableDescriptor[LayoutDto](default_factory=list, item_cls=LayoutDto) diff --git a/novu/dto/subscriber.py b/novu/dto/subscriber.py new file mode 100644 index 00000000..4802efaa --- /dev/null +++ b/novu/dto/subscriber.py @@ -0,0 +1,105 @@ +"""This module is used to gather all DTO definitions related to the Subscriber resource in Novu""" +import dataclasses +from typing import Optional + +from novu.dto.base import CamelCaseDto, DtoDescriptor, DtoIterableDescriptor + + +@dataclasses.dataclass +class SubscriberPreferenceChannelDto(CamelCaseDto["SubscriberPreferenceChannelDto"]): + """Definition of a channel activation state in subscriber's preference""" + + email: Optional[bool] = True + sms: Optional[bool] = True + in_app: Optional[bool] = True # FIXME: Find a way to not camelize this field + chat: Optional[bool] = True + push: Optional[bool] = True + + +@dataclasses.dataclass +class SubscriberPreferenceTemplateDto(CamelCaseDto["SubscriberPreferenceTemplateDto"]): + """Definition of a template in subscriber's preference""" + + _id: Optional[str] = None + name: Optional[str] = None + + critical: Optional[bool] = None + """Defines if the user's preferences will be ignored or not by Novu for the given template. + + By defining the template as critical, Novu considers that all steps should be executed ignoring user preferences. + """ + + +@dataclasses.dataclass +class SubscriberPreferencePreferenceDto(CamelCaseDto["SubscriberPreferencePreferenceDto"]): + """Definition of subscriber's preference sub-struct""" + + enabled: bool + channels: DtoDescriptor[SubscriberPreferenceChannelDto] = DtoDescriptor[SubscriberPreferenceChannelDto]( + item_cls=SubscriberPreferenceChannelDto + ) + """The activation states of the different channels""" + + +@dataclasses.dataclass +class SubscriberPreferenceDto(CamelCaseDto["SubscriberPreferenceDto"]): + """Definition of subscriber's preference""" + + preference: DtoDescriptor[SubscriberPreferencePreferenceDto] = DtoDescriptor[SubscriberPreferencePreferenceDto]( + item_cls=SubscriberPreferencePreferenceDto + ) + """Sub-struct in Novu for preference / global activation state""" + + template: DtoDescriptor[SubscriberPreferenceTemplateDto] = DtoDescriptor[SubscriberPreferenceTemplateDto]( + item_cls=SubscriberPreferenceTemplateDto + ) + """The identifiers of the template linked to the preferences and its criticality""" + + +@dataclasses.dataclass +class SubscriberDto(CamelCaseDto["SubscriberDto"]): # pylint: disable=R0902 + """Definition of subscriber""" + + camel_case_fields = ["subscriber_id", "email", "first_name", "last_name", "phone", "avatar", "locale"] + # Actually, only these fields are editable in Novu, so prevent any activity on others + + subscriber_id: str + email: str + + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + + avatar: Optional[str] = None + """profile picture (must be a public url to access the avatar)""" + + locale: Optional[str] = None + """language code (we recommend the use of ISO 639 to define them)""" + + _id: Optional[str] = None + """Subscriber ID in Novu internal storage system""" + + _environment_id: Optional[str] = None + """Environment ID in Novu internal storage system""" + + _organization_id: Optional[str] = None + """Organization ID in Novu internal storage system""" + + # TODO: add channels + deleted: Optional[bool] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + is_online: Optional[bool] = None + last_online_at: Optional[str] = None + + +@dataclasses.dataclass +class PaginatedSubscriberDto(CamelCaseDto["PaginatedSubscriberDto"]): + """Definition of paginated subscribers""" + + page: int = 0 + total_count: int = 0 + page_size: int = 0 + data: DtoIterableDescriptor[SubscriberDto] = DtoIterableDescriptor[SubscriberDto]( + default_factory=list, item_cls=SubscriberDto + ) diff --git a/novu/dto/topic.py b/novu/dto/topic.py new file mode 100644 index 00000000..6b18dca2 --- /dev/null +++ b/novu/dto/topic.py @@ -0,0 +1,47 @@ +"""This module is used to gather all DTO definitions related to the Topic resource in Novu""" +import dataclasses +from typing import List, Optional + +from novu.dto.base import CamelCaseDto, DtoIterableDescriptor + + +@dataclasses.dataclass +class TriggerTopicDto(CamelCaseDto["TriggerTopicDto"]): + """Topic definition for trigger""" + + topic_key: str + type: str + + +@dataclasses.dataclass +class TopicDto(CamelCaseDto["TopicDto"]): + """Topic definition""" + + camel_case_fields = ["key", "name"] + # Actually, only these fields are editable in Novu, so prevent any activity on others + + key: str + name: Optional[str] = None + """Name, required during creation""" + + _id: Optional[str] = None + """Topic ID in Novu internal system""" + + _organization_id: Optional[str] = None + """Organization ID in Novu internal storage system""" + + _environment_id: Optional[str] = None + """Environment ID in Novu internal storage system""" + + subscribers: Optional[List[str]] = None + """List of subscribers in the topic.""" + + +@dataclasses.dataclass +class PaginatedTopicDto(CamelCaseDto["PaginatedTopicDto"]): + """Paginated topic definition""" + + page: int = 0 + total_count: int = 0 + page_size: int = 0 + data: DtoIterableDescriptor[TopicDto] = DtoIterableDescriptor[TopicDto](default_factory=list, item_cls=TopicDto) diff --git a/novu/enums/__init__.py b/novu/enums/__init__.py new file mode 100644 index 00000000..7cedbb3b --- /dev/null +++ b/novu/enums/__init__.py @@ -0,0 +1,27 @@ +"""This module is used to gather all enumerations defined by Novu in Python format to be reused by developers.""" + +from novu.enums.channel import Channel +from novu.enums.event import EventStatus +from novu.enums.provider import ( + ChatProviderIdEnum, + CredentialsKeyEnum, + EmailProviderIdEnum, + InAppProviderIdEnum, + ProviderIdEnum, + PushProviderIdEnum, + SmsProviderIdEnum, +) +from novu.enums.template import TemplateVariableTypeEnum + +__all__ = [ + "Channel", + "EventStatus", + "ChatProviderIdEnum", + "CredentialsKeyEnum", + "EmailProviderIdEnum", + "TemplateVariableTypeEnum", + "InAppProviderIdEnum", + "PushProviderIdEnum", + "SmsProviderIdEnum", + "ProviderIdEnum", +] diff --git a/novu/enums/channel.py b/novu/enums/channel.py new file mode 100644 index 00000000..185a7a99 --- /dev/null +++ b/novu/enums/channel.py @@ -0,0 +1,22 @@ +"""This module is used to gather enumerations related to the Channel resource in Novu""" +from enum import Enum + + +class Channel(Enum): + """This enumeration define all available channel in Novu""" + + EMAIL = "email" + """Email channel (Custom SMTP, EmailJS, MailerSend, Mailjet, Outlook 365, + Postmark, SendGrid, Sendinblue, Amazon SES, ...)""" + + SMS = "sms" + """SMS channel (SMS77, AWS SNS, Telnyx, Twilio SMS, ...)""" + + IN_APP = "in_app" + """In APP channel""" + + CHAT = "chat" + """Chat channel (discord, MS Teams, Slack, ...)""" + + PUSH = "push" + """Push channel (Expo, Firebase Cloud Messaging, ...)""" diff --git a/novu/enums/event.py b/novu/enums/event.py new file mode 100644 index 00000000..3ca8a8ac --- /dev/null +++ b/novu/enums/event.py @@ -0,0 +1,18 @@ +"""This module is used to gather enumerations related to the Event resource in Novu""" +from enum import Enum + + +class EventStatus(Enum): + """This enumeration define possible status of an event""" + + PROCESSED = "processed" + """The event have been processed""" + + TRIGGER_NOT_ACTIVE = "trigger_not_active" + """The trigger you gave is not active""" + + TEMPLATE_NOT_FOUND = "template_not_found" + """The template was not found""" + + SUBSCRIBER_ID_MISSING = "subscriber_id_missing" + """The subscriber ID you gave was not found""" diff --git a/novu/enums/provider.py b/novu/enums/provider.py new file mode 100644 index 00000000..019e11e5 --- /dev/null +++ b/novu/enums/provider.py @@ -0,0 +1,92 @@ +"""This module is used to gather enumerations related to the Provider resource in Novu""" +from enum import Enum +from typing import Union + + +class CredentialsKeyEnum(Enum): + """This enumeration define possible key in a Novu credentials dict""" + + API_KEY = "apiKey" + USER = "user" + SECRET_KEY = "secretKey" # nosec: B105 + DOMAIN = "domain" + PASSWORD = "password" # nosec: B105 + HOST = "host" + PORT = "port" + SECURE = "secure" + REGION = "region" + ACCOUNT_SID = "accountSid" + MESSAGE_PROFILE_ID = "messageProfileId" + TOKEN = "token" # nosec: B105 + FROM = "from" + SENDER_NAME = "senderName" + APPLICATION_ID = "applicationId" + CLIENT_ID = "clientId" + PROJECT_NAME = "projectName" + SERVICE_ACCOUNT = "serviceAccount" + BASE_URL = "baseUrl" + WEBHOOK_URL = "webhookUrl" + + +class EmailProviderIdEnum(Enum): + """This enumeration define possible email provider ID""" + + EMAILJS = "emailjs" + MAILGUN = "mailgun" + MAILJET = "mailjet" + MANDRILL = "mandrill" + CUSTOM_SMTP = "nodemailer" + POSTMARK = "postmark" + SENDGRID = "sendgrid" + SENDINBLUE = "sendinblue" + SES = "ses" + NETCORE = "netcore" + INFOBIP = "infobip-email" + MAILERSEND = "mailersend" + CLICKATELL = "clickatell" + OUTLOOK365 = "outlook365" + NOVU = "novu-email" + + +class SmsProviderIdEnum(Enum): + """This enumeration define possible sms provider ID""" + + NEXMO = "nexmo" + PLIVO = "plivo" + SMS77 = "sms77" + SNS = "sns" + TELNYX = "telnyx" + TWILIO = "twilio" + GUPSHUP = "gupshup" + FIRETEXT = "firetext" + INFOBIP = "infobip-sms" + BURST_SMS = "burst-sms" + CLICKATELL = "clickatell" + + +class ChatProviderIdEnum(Enum): + """This enumeration define possible chat provider ID""" + + SLACK = "slack" + DISCORD = "discord" + MS_TEAMS = "msteams" + + +class PushProviderIdEnum(Enum): + """This enumeration define possible push provider ID""" + + FCM = "fcm" + APNS = "apns" + EXPO = "expo" + + +class InAppProviderIdEnum(Enum): + """This enumeration define possible in_app provider ID""" + + NOVU = "novu" + + +ProviderIdEnum = Union[ + SmsProviderIdEnum, PushProviderIdEnum, ChatProviderIdEnum, EmailProviderIdEnum, InAppProviderIdEnum +] +"""Type to define the notion of provider ID, which is one of sms, push, chat, email, or in_app provider ID.""" diff --git a/novu/enums/template.py b/novu/enums/template.py new file mode 100644 index 00000000..fb786f64 --- /dev/null +++ b/novu/enums/template.py @@ -0,0 +1,10 @@ +"""This module is used to gather enumerations related to the Template resource in Novu""" +from enum import Enum + + +class TemplateVariableTypeEnum(Enum): + """This enumeration define possible type for a variable in a template""" + + STRING = "String" + LIST = "Array" + BOOL = "Boolean" diff --git a/novu/helpers.py b/novu/helpers.py new file mode 100644 index 00000000..395a94f9 --- /dev/null +++ b/novu/helpers.py @@ -0,0 +1,20 @@ +"""This module is used to gather helpers reused through the package.""" +from typing import Dict + + +class Singleton(type): + """Metaclass to use if you need a singleton on your class + + Example: + >>> from intranet.lib.utils import Singleton + ... + >>> class MySingleton(metaclass=Singleton) + ... pass + """ + + _instances: Dict[type, type] = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/novu/py.typed b/novu/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..a62a0083 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1159 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "astroid" +version = "2.14.1" +description = "An abstract syntax tree for Python with inference support." +category = "dev" +optional = false +python-versions = ">=3.7.2" +files = [ + {file = "astroid-2.14.1-py3-none-any.whl", hash = "sha256:23c718921acab5f08cbbbe9293967f1f8fec40c336d19cd75dc12a9ea31d2eb2"}, + {file = "astroid-2.14.1.tar.gz", hash = "sha256:bd1aa4f9915c98e8aaebcd4e71930154d4e8c9aaf05d35ac0a63d1956091ae3f"}, +] + +[package.dependencies] +lazy-object-proxy = ">=1.4.0" +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} +wrapt = [ + {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, + {version = ">=1.14,<2", markers = "python_version >= \"3.11\""}, +] + +[[package]] +name = "attrs" +version = "22.2.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, + {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] +tests = ["attrs[tests-no-zope]", "zope.interface"] +tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] + +[[package]] +name = "bandit" +version = "1.7.4" +description = "Security oriented static analyser for python code." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "bandit-1.7.4-py3-none-any.whl", hash = "sha256:412d3f259dab4077d0e7f0c11f50f650cc7d10db905d98f6520a95a18049658a"}, + {file = "bandit-1.7.4.tar.gz", hash = "sha256:2d63a8c573417bae338962d4b9b06fbc6080f74ecd955a092849e1e65c717bd2"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +GitPython = ">=1.0.1" +PyYAML = ">=5.3.1" +stevedore = ">=1.20.0" + +[package.extras] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"] +toml = ["toml"] +yaml = ["PyYAML"] + +[[package]] +name = "black" +version = "22.12.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, + {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, + {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, + {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, + {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, + {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, + {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, + {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, + {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, + {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, + {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, + {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2022.12.7" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, + {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, +] + +[[package]] +name = "cfgv" +version = "3.3.1" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.0.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, + {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, +] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.1.0" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "coverage-7.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b946bbcd5a8231383450b195cfb58cb01cbe7f8949f5758566b881df4b33baf"}, + {file = "coverage-7.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec8e767f13be637d056f7e07e61d089e555f719b387a7070154ad80a0ff31801"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a5a5879a939cb84959d86869132b00176197ca561c664fc21478c1eee60d75"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b643cb30821e7570c0aaf54feaf0bfb630b79059f85741843e9dc23f33aaca2c"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32df215215f3af2c1617a55dbdfb403b772d463d54d219985ac7cd3bf124cada"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:33d1ae9d4079e05ac4cc1ef9e20c648f5afabf1a92adfaf2ccf509c50b85717f"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:29571503c37f2ef2138a306d23e7270687c0efb9cab4bd8038d609b5c2393a3a"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:63ffd21aa133ff48c4dff7adcc46b7ec8b565491bfc371212122dd999812ea1c"}, + {file = "coverage-7.1.0-cp310-cp310-win32.whl", hash = "sha256:4b14d5e09c656de5038a3f9bfe5228f53439282abcab87317c9f7f1acb280352"}, + {file = "coverage-7.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:8361be1c2c073919500b6601220a6f2f98ea0b6d2fec5014c1d9cfa23dd07038"}, + {file = "coverage-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:da9b41d4539eefd408c46725fb76ecba3a50a3367cafb7dea5f250d0653c1040"}, + {file = "coverage-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5b15ed7644ae4bee0ecf74fee95808dcc34ba6ace87e8dfbf5cb0dc20eab45a"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d12d076582507ea460ea2a89a8c85cb558f83406c8a41dd641d7be9a32e1274f"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2617759031dae1bf183c16cef8fcfb3de7617f394c813fa5e8e46e9b82d4222"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4e4881fa9e9667afcc742f0c244d9364d197490fbc91d12ac3b5de0bf2df146"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9d58885215094ab4a86a6aef044e42994a2bd76a446dc59b352622655ba6621b"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ffeeb38ee4a80a30a6877c5c4c359e5498eec095878f1581453202bfacc8fbc2"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3baf5f126f30781b5e93dbefcc8271cb2491647f8283f20ac54d12161dff080e"}, + {file = "coverage-7.1.0-cp311-cp311-win32.whl", hash = "sha256:ded59300d6330be27bc6cf0b74b89ada58069ced87c48eaf9344e5e84b0072f7"}, + {file = "coverage-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a43c7823cd7427b4ed763aa7fb63901ca8288591323b58c9cd6ec31ad910f3c"}, + {file = "coverage-7.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a726d742816cb3a8973c8c9a97539c734b3a309345236cd533c4883dda05b8d"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc7c85a150501286f8b56bd8ed3aa4093f4b88fb68c0843d21ff9656f0009d6a"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b4198d85a3755d27e64c52f8c95d6333119e49fd001ae5798dac872c95e0f8"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddb726cb861c3117a553f940372a495fe1078249ff5f8a5478c0576c7be12050"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:51b236e764840a6df0661b67e50697aaa0e7d4124ca95e5058fa3d7cbc240b7c"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7ee5c9bb51695f80878faaa5598040dd6c9e172ddcf490382e8aedb8ec3fec8d"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c31b75ae466c053a98bf26843563b3b3517b8f37da4d47b1c582fdc703112bc3"}, + {file = "coverage-7.1.0-cp37-cp37m-win32.whl", hash = "sha256:3b155caf3760408d1cb903b21e6a97ad4e2bdad43cbc265e3ce0afb8e0057e73"}, + {file = "coverage-7.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2a60d6513781e87047c3e630b33b4d1e89f39836dac6e069ffee28c4786715f5"}, + {file = "coverage-7.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f2cba5c6db29ce991029b5e4ac51eb36774458f0a3b8d3137241b32d1bb91f06"}, + {file = "coverage-7.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beeb129cacea34490ffd4d6153af70509aa3cda20fdda2ea1a2be870dfec8d52"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c45948f613d5d18c9ec5eaa203ce06a653334cf1bd47c783a12d0dd4fd9c851"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef382417db92ba23dfb5864a3fc9be27ea4894e86620d342a116b243ade5d35d"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c7c0d0827e853315c9bbd43c1162c006dd808dbbe297db7ae66cd17b07830f0"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e5cdbb5cafcedea04924568d990e20ce7f1945a1dd54b560f879ee2d57226912"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9817733f0d3ea91bea80de0f79ef971ae94f81ca52f9b66500c6a2fea8e4b4f8"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:218fe982371ac7387304153ecd51205f14e9d731b34fb0568181abaf7b443ba0"}, + {file = "coverage-7.1.0-cp38-cp38-win32.whl", hash = "sha256:04481245ef966fbd24ae9b9e537ce899ae584d521dfbe78f89cad003c38ca2ab"}, + {file = "coverage-7.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8ae125d1134bf236acba8b83e74c603d1b30e207266121e76484562bc816344c"}, + {file = "coverage-7.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2bf1d5f2084c3932b56b962a683074a3692bce7cabd3aa023c987a2a8e7612f6"}, + {file = "coverage-7.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:98b85dd86514d889a2e3dd22ab3c18c9d0019e696478391d86708b805f4ea0fa"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38da2db80cc505a611938d8624801158e409928b136c8916cd2e203970dde4dc"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3164d31078fa9efe406e198aecd2a02d32a62fecbdef74f76dad6a46c7e48311"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db61a79c07331e88b9a9974815c075fbd812bc9dbc4dc44b366b5368a2936063"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ccb092c9ede70b2517a57382a601619d20981f56f440eae7e4d7eaafd1d1d09"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:33ff26d0f6cc3ca8de13d14fde1ff8efe1456b53e3f0273e63cc8b3c84a063d8"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d47dd659a4ee952e90dc56c97d78132573dc5c7b09d61b416a9deef4ebe01a0c"}, + {file = "coverage-7.1.0-cp39-cp39-win32.whl", hash = "sha256:d248cd4a92065a4d4543b8331660121b31c4148dd00a691bfb7a5cdc7483cfa4"}, + {file = "coverage-7.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7ed681b0f8e8bcbbffa58ba26fcf5dbc8f79e7997595bf071ed5430d8c08d6f3"}, + {file = "coverage-7.1.0-pp37.pp38.pp39-none-any.whl", hash = "sha256:755e89e32376c850f826c425ece2c35a4fc266c081490eb0a841e7c1cb0d3bda"}, + {file = "coverage-7.1.0.tar.gz", hash = "sha256:10188fe543560ec4874f974b5305cd1a8bdcfa885ee00ea3a03733464c4ca265"}, +] + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "dill" +version = "0.3.6" +description = "serialize all of python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "dill-0.3.6-py3-none-any.whl", hash = "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0"}, + {file = "dill-0.3.6.tar.gz", hash = "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] + +[[package]] +name = "distlib" +version = "0.3.6" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, + {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.1.0" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, + {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.9.0" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"}, + {file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"}, +] + +[package.extras] +docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] +testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] + +[[package]] +name = "gitdb" +version = "4.0.10" +description = "Git Object Database" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, + {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, +] + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.30" +description = "GitPython is a python library used to interact with Git repositories" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "GitPython-3.1.30-py3-none-any.whl", hash = "sha256:cd455b0000615c60e286208ba540271af9fe531fa6a87cc590a7298785ab2882"}, + {file = "GitPython-3.1.30.tar.gz", hash = "sha256:769c2d83e13f5d938b7688479da374c4e3d49f71549aaf462b646db9602ea6f8"}, +] + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[[package]] +name = "identify" +version = "2.5.17" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"}, + {file = "identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isort" +version = "5.12.0" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, + {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, +] + +[package.extras] +colors = ["colorama (>=0.4.3)"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "lazy-object-proxy" +version = "1.9.0" +description = "A fast and thorough lazy object proxy." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "lazy-object-proxy-1.9.0.tar.gz", hash = "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8c6cfb338b133fbdbc5cfaa10fe3c6aeea827db80c978dbd13bc9dd8526b7d4"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:721532711daa7db0d8b779b0bb0318fa87af1c10d7fe5e52ef30f8eff254d0cd"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:66a3de4a3ec06cd8af3f61b8e1ec67614fbb7c995d02fa224813cb7afefee701"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1aa3de4088c89a1b69f8ec0dcc169aa725b0ff017899ac568fe44ddc1396df46"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-win32.whl", hash = "sha256:f0705c376533ed2a9e5e97aacdbfe04cecd71e0aa84c7c0595d02ef93b6e4455"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea806fd4c37bf7e7ad82537b0757999264d5f70c45468447bb2b91afdbe73a6e"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:946d27deaff6cf8452ed0dba83ba38839a87f4f7a9732e8f9fd4107b21e6ff07"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79a31b086e7e68b24b99b23d57723ef7e2c6d81ed21007b6281ebcd1688acb0a"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfb38f9ffb53b942f2b5954e0f610f1e721ccebe9cce9025a38c8ccf4a5183a4"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:189bbd5d41ae7a498397287c408617fe5c48633e7755287b21d741f7db2706a9"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-win32.whl", hash = "sha256:81fc4d08b062b535d95c9ea70dbe8a335c45c04029878e62d744bdced5141586"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9e25ef10a39e8afe59a5c348a4dbf29b4868ab76269f81ce1674494e2565a6e"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbf9b082426036e19c6924a9ce90c740a9861e2bdc27a4834fd0a910742ac1e8"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f5fa4a61ce2438267163891961cfd5e32ec97a2c444e5b842d574251ade27d2"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8fa02eaab317b1e9e03f69aab1f91e120e7899b392c4fc19807a8278a07a97e8"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e7c21c95cae3c05c14aafffe2865bbd5e377cfc1348c4f7751d9dc9a48ca4bda"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-win32.whl", hash = "sha256:f12ad7126ae0c98d601a7ee504c1122bcef553d1d5e0c3bfa77b16b3968d2734"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:edd20c5a55acb67c7ed471fa2b5fb66cb17f61430b7a6b9c3b4a1e40293b1671"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0daa332786cf3bb49e10dc6a17a52f6a8f9601b4cf5c295a4f85854d61de63"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cd077f3d04a58e83d04b20e334f678c2b0ff9879b9375ed107d5d07ff160171"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:660c94ea760b3ce47d1855a30984c78327500493d396eac4dfd8bd82041b22be"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:212774e4dfa851e74d393a2370871e174d7ff0ebc980907723bb67d25c8a7c30"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f0117049dd1d5635bbff65444496c90e0baa48ea405125c088e93d9cf4525b11"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-win32.whl", hash = "sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:9990d8e71b9f6488e91ad25f322898c136b008d87bf852ff65391b004da5e17b"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9e7551208b2aded9c1447453ee366f1c4070602b3d932ace044715d89666899b"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f83ac4d83ef0ab017683d715ed356e30dd48a93746309c8f3517e1287523ef4"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7322c3d6f1766d4ef1e51a465f47955f1e8123caee67dd641e67d539a534d006"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:18b78ec83edbbeb69efdc0e9c1cb41a3b1b1ed11ddd8ded602464c3fc6020494"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-win32.whl", hash = "sha256:9090d8e53235aa280fc9239a86ae3ea8ac58eff66a705fa6aa2ec4968b95c821"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy" +version = "1.0.0" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mypy-1.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0626db16705ab9f7fa6c249c017c887baf20738ce7f9129da162bb3075fc1af"}, + {file = "mypy-1.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ace23f6bb4aec4604b86c4843276e8fa548d667dbbd0cb83a3ae14b18b2db6c"}, + {file = "mypy-1.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87edfaf344c9401942883fad030909116aa77b0fa7e6e8e1c5407e14549afe9a"}, + {file = "mypy-1.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0ab090d9240d6b4e99e1fa998c2d0aa5b29fc0fb06bd30e7ad6183c95fa07593"}, + {file = "mypy-1.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:7cc2c01dfc5a3cbddfa6c13f530ef3b95292f926329929001d45e124342cd6b7"}, + {file = "mypy-1.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14d776869a3e6c89c17eb943100f7868f677703c8a4e00b3803918f86aafbc52"}, + {file = "mypy-1.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb2782a036d9eb6b5a6efcdda0986774bf798beef86a62da86cb73e2a10b423d"}, + {file = "mypy-1.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cfca124f0ac6707747544c127880893ad72a656e136adc935c8600740b21ff5"}, + {file = "mypy-1.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8845125d0b7c57838a10fd8925b0f5f709d0e08568ce587cc862aacce453e3dd"}, + {file = "mypy-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b1b9e1ed40544ef486fa8ac022232ccc57109f379611633ede8e71630d07d2"}, + {file = "mypy-1.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c7cf862aef988b5fbaa17764ad1d21b4831436701c7d2b653156a9497d92c83c"}, + {file = "mypy-1.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd187d92b6939617f1168a4fe68f68add749902c010e66fe574c165c742ed88"}, + {file = "mypy-1.0.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4e5175026618c178dfba6188228b845b64131034ab3ba52acaffa8f6c361f805"}, + {file = "mypy-1.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2f6ac8c87e046dc18c7d1d7f6653a66787a4555085b056fe2d599f1f1a2a2d21"}, + {file = "mypy-1.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7306edca1c6f1b5fa0bc9aa645e6ac8393014fa82d0fa180d0ebc990ebe15964"}, + {file = "mypy-1.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3cfad08f16a9c6611e6143485a93de0e1e13f48cfb90bcad7d5fde1c0cec3d36"}, + {file = "mypy-1.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67cced7f15654710386e5c10b96608f1ee3d5c94ca1da5a2aad5889793a824c1"}, + {file = "mypy-1.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a86b794e8a56ada65c573183756eac8ac5b8d3d59daf9d5ebd72ecdbb7867a43"}, + {file = "mypy-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:50979d5efff8d4135d9db293c6cb2c42260e70fb010cbc697b1311a4d7a39ddb"}, + {file = "mypy-1.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ae4c7a99e5153496243146a3baf33b9beff714464ca386b5f62daad601d87af"}, + {file = "mypy-1.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e398652d005a198a7f3c132426b33c6b85d98aa7dc852137a2a3be8890c4072"}, + {file = "mypy-1.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be78077064d016bc1b639c2cbcc5be945b47b4261a4f4b7d8923f6c69c5c9457"}, + {file = "mypy-1.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92024447a339400ea00ac228369cd242e988dd775640755fa4ac0c126e49bb74"}, + {file = "mypy-1.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:fe523fcbd52c05040c7bee370d66fee8373c5972171e4fbc323153433198592d"}, + {file = "mypy-1.0.0-py3-none-any.whl", hash = "sha256:2efa963bdddb27cb4a0d42545cd137a8d2b883bd181bbc4525b568ef6eca258f"}, + {file = "mypy-1.0.0.tar.gz", hash = "sha256:f34495079c8d9da05b183f9f7daec2878280c2ad7cc81da686ef0b484cea2ecf"}, +] + +[package.dependencies] +mypy-extensions = ">=0.4.3" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.7.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, + {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "packaging" +version = "23.0" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, + {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, +] + +[[package]] +name = "pathspec" +version = "0.11.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"}, + {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"}, +] + +[[package]] +name = "pbr" +version = "5.11.1" +description = "Python Build Reasonableness" +category = "dev" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-5.11.1-py2.py3-none-any.whl", hash = "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b"}, + {file = "pbr-5.11.1.tar.gz", hash = "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3"}, +] + +[[package]] +name = "platformdirs" +version = "2.6.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"}, + {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"}, +] + +[package.extras] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "2.21.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, + {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycodestyle" +version = "2.10.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, + {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, +] + +[[package]] +name = "pydocstyle" +version = "6.3.0" +description = "Python docstring style checker" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, + {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, +] + +[package.dependencies] +snowballstemmer = ">=2.2.0" + +[package.extras] +toml = ["tomli (>=1.2.3)"] + +[[package]] +name = "pyflakes" +version = "3.0.1" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, + {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, +] + +[[package]] +name = "pylama" +version = "8.4.1" +description = "Code audit tool for python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pylama-8.4.1-py3-none-any.whl", hash = "sha256:5bbdbf5b620aba7206d688ed9fc917ecd3d73e15ec1a89647037a09fa3a86e60"}, + {file = "pylama-8.4.1.tar.gz", hash = "sha256:2d4f7aecfb5b7466216d48610c7d6bad1c3990c29cdd392ad08259b161e486f6"}, +] + +[package.dependencies] +mccabe = ">=0.7.0" +pycodestyle = ">=2.9.1" +pydocstyle = ">=6.1.1" +pyflakes = ">=2.5.0" + +[package.extras] +all = ["eradicate", "mypy", "pylint", "radon", "vulture"] +eradicate = ["eradicate"] +mypy = ["mypy"] +pylint = ["pylint"] +radon = ["radon"] +tests = ["eradicate (>=2.0.0)", "mypy", "pylama-quotes", "pylint (>=2.11.1)", "pytest (>=7.1.2)", "pytest-mypy", "radon (>=5.1.0)", "toml", "types-setuptools", "types-toml", "vulture"] +toml = ["toml (>=0.10.2)"] +vulture = ["vulture"] + +[[package]] +name = "pylint" +version = "2.16.1" +description = "python code static checker" +category = "dev" +optional = false +python-versions = ">=3.7.2" +files = [ + {file = "pylint-2.16.1-py3-none-any.whl", hash = "sha256:bad9d7c36037f6043a1e848a43004dfd5ea5ceb05815d713ba56ca4503a9fe37"}, + {file = "pylint-2.16.1.tar.gz", hash = "sha256:ffe7fa536bb38ba35006a7c8a6d2efbfdd3d95bbf21199cad31f76b1c50aaf30"}, +] + +[package.dependencies] +astroid = ">=2.14.1,<=2.16.0-dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = [ + {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, +] +isort = ">=4.2.5,<6" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tomlkit = ">=0.10.1" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + +[[package]] +name = "pytest" +version = "7.2.1" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, + {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, +] + +[package.dependencies] +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] + +[[package]] +name = "requests" +version = "2.28.2" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" +files = [ + {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, + {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +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 = "sentry-sdk" +version = "1.14.0" +description = "Python client for Sentry (https://sentry.io)" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "sentry-sdk-1.14.0.tar.gz", hash = "sha256:273fe05adf052b40fd19f6d4b9a5556316807246bd817e5e3482930730726bb0"}, + {file = "sentry_sdk-1.14.0-py2.py3-none-any.whl", hash = "sha256:72c00322217d813cf493fe76590b23a757e063ff62fec59299f4af7201dd4448"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +chalice = ["chalice (>=1.16.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)"] +httpx = ["httpx (>=0.16.0)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +pure-eval = ["asttokens", "executing", "pure-eval"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "setuptools" +version = "67.1.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "setuptools-67.1.0-py3-none-any.whl", hash = "sha256:a7687c12b444eaac951ea87a9627c4f904ac757e7abdc5aac32833234af90378"}, + {file = "setuptools-67.1.0.tar.gz", hash = "sha256:e261cdf010c11a41cb5cb5f1bf3338a7433832029f559a6a7614bd42a967c300"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "smmap" +version = "5.0.0" +description = "A pure Python implementation of a sliding window memory map manager" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, + {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, +] + +[[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 = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "stevedore" +version = "4.1.1" +description = "Manage dynamic plugins for Python applications" +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-4.1.1-py3-none-any.whl", hash = "sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e"}, + {file = "stevedore-4.1.1.tar.gz", hash = "sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "tomlkit" +version = "0.11.6" +description = "Style preserving TOML library" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "tomlkit-0.11.6-py3-none-any.whl", hash = "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b"}, + {file = "tomlkit-0.11.6.tar.gz", hash = "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73"}, +] + +[[package]] +name = "types-requests" +version = "2.28.11.12" +description = "Typing stubs for requests" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "types-requests-2.28.11.12.tar.gz", hash = "sha256:fd530aab3fc4f05ee36406af168f0836e6f00f1ee51a0b96b7311f82cb675230"}, + {file = "types_requests-2.28.11.12-py3-none-any.whl", hash = "sha256:dbc2933635860e553ffc59f5e264264981358baffe6342b925e3eb8261f866ee"}, +] + +[package.dependencies] +types-urllib3 = "<1.27" + +[[package]] +name = "types-urllib3" +version = "1.26.25.5" +description = "Typing stubs for urllib3" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "types-urllib3-1.26.25.5.tar.gz", hash = "sha256:5630e578246d170d91ebe3901788cd28d53c4e044dc2e2488e3b0d55fb6895d8"}, + {file = "types_urllib3-1.26.25.5-py3-none-any.whl", hash = "sha256:e8f25c8bb85cde658c72ee931e56e7abd28803c26032441eea9ff4a4df2b0c31"}, +] + +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] + +[[package]] +name = "urllib3" +version = "1.26.14" +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.*" +files = [ + {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"}, + {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "virtualenv" +version = "20.17.1" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"}, + {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, +] + +[package.dependencies] +distlib = ">=0.3.6,<1" +filelock = ">=3.4.1,<4" +platformdirs = ">=2.4,<3" + +[package.extras] +docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"] +testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] + +[[package]] +name = "wrapt" +version = "1.14.1" +description = "Module for decorators, wrappers and monkey patching." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, + {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, + {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"}, + {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"}, + {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"}, + {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"}, + {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"}, + {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"}, + {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"}, + {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"}, + {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"}, + {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"}, + {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, + {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, + {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, + {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "5d4b853e979ed51915b4f0cbc3a567253e78dd06e8921a882d5bce4079c25db0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a817f1a0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,96 @@ +[tool.poetry] +description = "This project aims to provide a wrapper for the Novu API." +name = "novu" +repository = "https://github.com/ryshu/novu-python" +version = "0.1.0" + +authors = ["oscar.marie-taillefer "] +maintainers = ["oscar.marie-taillefer "] + +license = "BSD-2-Clause" +packages = [{include = "novu"}] +readme = "README.md" + +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development", + "Typing :: Typed", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: MacOS", +] + +[tool.poetry.dependencies] +python = "^3.8" + +requests = "^2.28.2" +sentry-sdk = "^1.14.0" + +[tool.poetry.group.dev.dependencies] +bandit = "^1.7.4" +black = "^22.12.0" +coverage = "^7.0.4" +mypy = "^1.0.0" +pre-commit = "^2.21.0" +pylama = "^8.4.1" +pylint = "^2.15.9" +pytest = "^7.2.0" +toml = "^0.10.2" +types-requests = "^2.28.11.12" + +[tool.bandit] +targets = ["novu"] + +[tool.black] +exclude = ''' +( + /( + \.git + |\.tox + |migrations + )/ +) +''' +include = '\.pyi?$' +line-length = 120 + +[tool.mypy] +modules = "novu" +python_version = "3.8" + +[tool.pylama] +format = "pycodestyle" +linters = "pycodestyle,pyflakes,pylint" +max_line_length = 120 +skip = ".pytest_cache,.venv/*,tests/*,*/tests/*,docs/*" + +[tool.pylama.linter.pycodestyle] +max_line_length = 120 + +[tool.pylama.linter.pylint] +disable = "W0212,W0511,R0913" +# Ignored rules: +# - W0212: access-protected-member +# - W0511: fixme, todo +# - R0913: too-many-arguments + +[tool.coverage.run] +branch = true +command_line = "-m pytest" +omit = ["tests/*"] +relative_files = true +source = ["novu"] + +[tool.coverage.report] +fail_under = 100 + +[tool.pytest.ini_options] +testpaths = "tests/" +# addopts = "--doctest-modules -ra -l --tb=short --show-capture=all --color=yes" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/test_base.py b/tests/api/test_base.py new file mode 100644 index 00000000..9b11acc8 --- /dev/null +++ b/tests/api/test_base.py @@ -0,0 +1,64 @@ +from unittest import TestCase, mock + +from requests.exceptions import HTTPError + +from novu.api.base import Api +from novu.config import NovuConfig +from tests.factories import MockResponse + + +class ApiTests(TestCase): + @classmethod + def setUpClass(cls) -> None: + NovuConfig.configure("sample.novu.com", "api-key") + cls.api = Api() + + @mock.patch("requests.request") + def test_handle_request_with_header_override(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, {}) + + res = self.api.handle_request("GET", self.api._url, headers={"MyHeader": "value"}) + self.assertEqual(res, {}) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com", + headers={"Authorization": "ApiKey api-key", "MyHeader": "value"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_handle_request_raise_with_details(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(500, {"details": "my-detail"}) + + self.assertRaises( + HTTPError, lambda: self.api.handle_request("GET", self.api._url, headers={"MyHeader": "value"}) + ) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com", + headers={"Authorization": "ApiKey api-key", "MyHeader": "value"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_handle_request_raise_with_details(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(500, raise_on_call=True) + + self.assertRaises( + HTTPError, lambda: self.api.handle_request("GET", self.api._url, headers={"MyHeader": "value"}) + ) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com", + headers={"Authorization": "ApiKey api-key", "MyHeader": "value"}, + json=None, + params=None, + timeout=5, + ) diff --git a/tests/api/test_event.py b/tests/api/test_event.py new file mode 100644 index 00000000..7ef33bf0 --- /dev/null +++ b/tests/api/test_event.py @@ -0,0 +1,340 @@ +from unittest import TestCase, mock + +from novu.api import EventApi +from novu.config import NovuConfig +from novu.dto.event import EventDto +from novu.dto.topic import TriggerTopicDto +from novu.enums import EventStatus +from tests.factories import MockResponse + + +class EventApiTests(TestCase): + @classmethod + def setUpClass(cls) -> None: + NovuConfig.configure("sample.novu.com", "api-key") + cls.api = EventApi() + + @mock.patch("requests.request") + def test_trigger_with_single_recipient(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + result = self.api.trigger("test-template", "sample-recipient", {}) + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={"name": "test-template", "to": "sample-recipient", "payload": {}}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_with_multiple_recipients(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + result = self.api.trigger("test-template", ["sample-recipient-1", "sample-recipient-2"], {}) + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={"name": "test-template", "to": ["sample-recipient-1", "sample-recipient-2"], "payload": {}}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_with_overrides(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + result = self.api.trigger("test-template", "sample-recipient", {}, {"an": "override"}) + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={"name": "test-template", "to": "sample-recipient", "payload": {}, "overrides": {"an": "override"}}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_with_actor(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + result = self.api.trigger("test-template", "sample-recipient", {}, actor="actor-id") + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={"name": "test-template", "to": "sample-recipient", "payload": {}, "actor": "actor-id"}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_with_transaction_id(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + result = self.api.trigger("test-template", "sample-recipient", {}, transaction_id="sample-test") + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={"name": "test-template", "to": "sample-recipient", "payload": {}, "transactionId": "sample-test"}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_topic_with_single_topic(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + topic = TriggerTopicDto("topic-key", "type") + result = self.api.trigger_topic("test-template", topic, {}) + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={"name": "test-template", "to": [{"topicKey": "topic-key", "type": "type"}], "payload": {}}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_topic_with_multiple_topics(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + topic_1 = TriggerTopicDto("topic-key-1", "type") + topic_2 = TriggerTopicDto("topic-key-2", "type") + result = self.api.trigger_topic("test-template", [topic_1, topic_2], {}) + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "test-template", + "to": [{"topicKey": "topic-key-1", "type": "type"}, {"topicKey": "topic-key-2", "type": "type"}], + "payload": {}, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_topic_with_overrides(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + topic = TriggerTopicDto("topic-key", "type") + result = self.api.trigger_topic("test-template", topic, {}, {"an": "override"}) + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "test-template", + "to": [{"topicKey": "topic-key", "type": "type"}], + "payload": {}, + "overrides": {"an": "override"}, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_topic_with_actor(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + topic = TriggerTopicDto("topic-key", "type") + result = self.api.trigger_topic("test-template", topic, {}, actor="actor-id") + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "test-template", + "to": [{"topicKey": "topic-key", "type": "type"}], + "payload": {}, + "actor": "actor-id", + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_trigger_topic_with_transaction_id(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + topic = TriggerTopicDto("topic-key", "type") + result = self.api.trigger_topic("test-template", topic, {}, transaction_id="sample-test") + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "test-template", + "to": [{"topicKey": "topic-key", "type": "type"}], + "payload": {}, + "transactionId": "sample-test", + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_broadcast_with_overrides(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + result = self.api.broadcast("test-template", {}, {"an": "override"}) + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger/broadcast", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "test-template", + "payload": {}, + "overrides": {"an": "override"}, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_broadcast_with_actor(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + result = self.api.broadcast("test-template", {}, actor="actor-id") + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger/broadcast", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "test-template", + "payload": {}, + "actor": "actor-id", + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_broadcast_with_transaction_id(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, {"data": {"acknowledged": True, "status": EventStatus.PROCESSED.value, "transactionId": "sample-test"}} + ) + + result = self.api.broadcast("test-template", {}, transaction_id="sample-test") + + self.assertIsInstance(result, EventDto) + self.assertTrue(result.acknowledged) + self.assertEqual(result.status, EventStatus.PROCESSED.value) + self.assertEqual(result.transaction_id, "sample-test") + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/events/trigger/broadcast", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "test-template", + "payload": {}, + "transactionId": "sample-test", + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_delete(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200) + + self.assertIsNone(self.api.delete("sample-test")) + + mock_request.assert_called_once_with( + method="DELETE", + url="sample.novu.com/v1/events/trigger/sample-test", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) diff --git a/tests/api/test_integration.py b/tests/api/test_integration.py new file mode 100644 index 00000000..037b9584 --- /dev/null +++ b/tests/api/test_integration.py @@ -0,0 +1,351 @@ +import types +from unittest import TestCase, mock + +from novu.api import IntegrationApi +from novu.config import NovuConfig +from novu.dto.integration import IntegrationChannelUsageDto, IntegrationDto +from tests.factories import MockResponse + + +class IntegrationApiTests(TestCase): + @classmethod + def setUpClass(cls) -> None: + NovuConfig.configure("sample.novu.com", "api-key") + cls.api = IntegrationApi() + cls.integration_json = { + "_id": "63dfe50ecac5cff328ca5d24", + "_environmentId": "63dafed97779f59258e38445", + "_organizationId": "63dafed97779f59258e3843f", + "providerId": "nodemailer", + "channel": "email", + "credentials": { + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + "active": False, + "deleted": False, + "createdAt": "2023-02-05T17:19:10.826Z", + "updatedAt": "2023-02-05T17:19:10.826Z", + "__v": 0, + } + cls.response_list = {"data": [cls.integration_json]} + cls.response_get = {"data": cls.integration_json} + cls.expected_dto = IntegrationDto( + provider_id="nodemailer", + channel="email", + credentials={ + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + active=False, + _id="63dfe50ecac5cff328ca5d24", + _environment_id="63dafed97779f59258e38445", + _organization_id="63dafed97779f59258e3843f", + created_at="2023-02-05T17:19:10.826Z", + updated_at="2023-02-05T17:19:10.826Z", + deleted_at=None, + deleted_by=None, + deleted=False, + ) + + @mock.patch("requests.request") + def test_list_no_result(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, {"data": []}) + + result = self.api.list() + self.assertIsInstance(result, types.GeneratorType) + self.assertEqual(list(result), []) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/integrations", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_list_with_results(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_list) + + result = self.api.list() + self.assertIsInstance(result, types.GeneratorType) + self.assertEqual(list(result), [self.expected_dto]) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/integrations", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_list_only_active_no_result(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, {"data": []}) + + result = self.api.list(only_active=True) + self.assertIsInstance(result, types.GeneratorType) + self.assertEqual(list(result), []) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/integrations/active", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_list_with_results(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_list) + + result = self.api.list(only_active=True) + self.assertIsInstance(result, types.GeneratorType) + self.assertEqual(list(result), [self.expected_dto]) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/integrations/active", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_create_with_check_by_default(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + result = self.api.create( + IntegrationDto( + provider_id="nodemailer", + channel="email", + credentials={ + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + active=False, + ) + ) + self.assertEqual(result, self.expected_dto) + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/integrations", + headers={"Authorization": "ApiKey api-key"}, + json={ + "providerId": "nodemailer", + "channel": "email", + "credentials": { + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + "active": False, + "check": True, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_create_without_check(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + result = self.api.create( + IntegrationDto( + provider_id="nodemailer", + channel="email", + credentials={ + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + active=False, + ), + check=False, + ) + self.assertEqual(result, self.expected_dto) + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/integrations", + headers={"Authorization": "ApiKey api-key"}, + json={ + "providerId": "nodemailer", + "channel": "email", + "credentials": { + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + "active": False, + "check": False, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_get_provider_status(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, {"data": True}) + + result = self.api.status("something") + self.assertEqual(result, True) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/integrations/webhooks/provider/something/status", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_update_with_check_by_default(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + result = self.api.update( + IntegrationDto( + provider_id="nodemailer", + channel="email", + credentials={ + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + active=False, + _id="identifier", + ) + ) + self.assertEqual(result, self.expected_dto) + + mock_request.assert_called_once_with( + method="PUT", + url="sample.novu.com/v1/integrations/identifier", + headers={"Authorization": "ApiKey api-key"}, + json={ + "providerId": "nodemailer", + "channel": "email", + "credentials": { + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + "active": False, + "check": True, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_update_without_check(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + result = self.api.update( + IntegrationDto( + provider_id="nodemailer", + channel="email", + credentials={ + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + active=False, + _id="identifier", + ), + check=False, + ) + self.assertEqual(result, self.expected_dto) + + mock_request.assert_called_once_with( + method="PUT", + url="sample.novu.com/v1/integrations/identifier", + headers={"Authorization": "ApiKey api-key"}, + json={ + "providerId": "nodemailer", + "channel": "email", + "credentials": { + "user": "test", + "password": "test", + "host": "test.com", + "port": "587", + "from": "from@sample.com", + "senderName": "sample", + }, + "active": False, + "check": False, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_delete(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200) + + result = self.api.delete("identifier") + self.assertIsNone(result) + + mock_request.assert_called_once_with( + method="DELETE", + url="sample.novu.com/v1/integrations/identifier", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_limit(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, {"data": {"limit": 300, "count": 3}}) + + result = self.api.limit("email") + self.assertIsInstance(result, IntegrationChannelUsageDto) + self.assertEqual(result.limit, 300) + self.assertEqual(result.count, 3) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/integrations/email/limit", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) diff --git a/tests/api/test_layout.py b/tests/api/test_layout.py new file mode 100644 index 00000000..cf681621 --- /dev/null +++ b/tests/api/test_layout.py @@ -0,0 +1,314 @@ +from unittest import TestCase, mock + +from novu.api import LayoutApi +from novu.config import NovuConfig +from novu.dto import LayoutDto, LayoutVariableDto, PaginatedLayoutDto +from tests.factories import MockResponse + + +class LayoutApiTests(TestCase): + @classmethod + def setUpClass(cls) -> None: + NovuConfig.configure("sample.novu.com", "api-key") + cls.api = LayoutApi() + cls.layout_json = { + "_id": "63dafeda7779f59258e38450", + "_environmentId": "63dafed97779f59258e38445", + "_organizationId": "63dafed97779f59258e3843f", + "_creatorId": "63dafed4117f8c850991ec4a", + "name": "Default Layout", + "description": "The default layout created by Novu", + "variables": [ + { + "name": "branding.logo", + "type": "String", + "required": False, + "defaultValue": "", + "_id": "63dafeda7779f59258e38451", + }, + { + "name": "preheader", + "type": "Boolean", + "required": False, + "defaultValue": True, + "_id": "63dafeda7779f59258e38452", + }, + { + "name": "preheader", + "type": "String", + "required": False, + "defaultValue": "", + "_id": "63dafeda7779f59258e38453", + }, + { + "name": "branding.color", + "type": "Boolean", + "required": False, + "defaultValue": True, + "_id": "63dafeda7779f59258e38454", + }, + { + "name": "branding.color", + "type": "String", + "required": False, + "defaultValue": "", + "_id": "63dafeda7779f59258e38455", + }, + ], + "content": "", + "contentType": "customHtml", + "isDefault": True, + "channel": "email", + "deleted": False, + "createdAt": "2023-02-02T00:07:54.022Z", + "updatedAt": "2023-02-02T00:07:54.022Z", + "__v": 0, + "isDeleted": False, + } + cls.response_list = {"page": 0, "totalCount": 1, "pageSize": 10, "data": [cls.layout_json]} + cls.response_get = {"data": cls.layout_json} + cls.expected_dto = LayoutDto( + name="Default Layout", + description="The default layout created by Novu", + content="", + is_default=True, + variables=[ + LayoutVariableDto( + name="branding.logo", + type="String", + _id="63dafeda7779f59258e38451", + required=False, + default_value="", + ), + LayoutVariableDto( + name="preheader", type="Boolean", _id="63dafeda7779f59258e38452", required=False, default_value=True + ), + LayoutVariableDto( + name="preheader", type="String", _id="63dafeda7779f59258e38453", required=False, default_value="" + ), + LayoutVariableDto( + name="branding.color", + type="Boolean", + _id="63dafeda7779f59258e38454", + required=False, + default_value=True, + ), + LayoutVariableDto( + name="branding.color", + type="String", + _id="63dafeda7779f59258e38455", + required=False, + default_value="", + ), + ], + _id="63dafeda7779f59258e38450", + _environment_id="63dafed97779f59258e38445", + _organization_id="63dafed97779f59258e3843f", + _creator_id="63dafed4117f8c850991ec4a", + content_type="customHtml", + channel="email", + created_at="2023-02-02T00:07:54.022Z", + updated_at="2023-02-02T00:07:54.022Z", + is_deleted=False, + ) + + @mock.patch("requests.request") + def test_list_layout(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_list) + + result = self.api.list() + self.assertIsInstance(result, PaginatedLayoutDto) + self.assertEqual(list(result.data), [self.expected_dto]) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/layouts", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params={}, + timeout=5, + ) + + @mock.patch("requests.request") + def test_list_layout_with_filters(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_list) + + result = self.api.list(page=1, limit=20) + self.assertIsInstance(result, PaginatedLayoutDto) + self.assertEqual(list(result.data), [self.expected_dto]) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/layouts", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params={"page": 1, "pageSize": 20}, + timeout=5, + ) + + @mock.patch("requests.request") + def test_create_layout(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.create(self.expected_dto) + self.assertIsInstance(res, LayoutDto) + self.assertEqual(res, self.expected_dto) + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/layouts", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "Default Layout", + "description": "The default layout created by Novu", + "content": "", + "isDefault": True, + "variables": [ + { + "name": "branding.logo", + "type": "String", + "_id": "63dafeda7779f59258e38451", + "required": False, + "default_value": "", + }, + { + "name": "preheader", + "type": "Boolean", + "_id": "63dafeda7779f59258e38452", + "required": False, + "default_value": True, + }, + { + "name": "preheader", + "type": "String", + "_id": "63dafeda7779f59258e38453", + "required": False, + "default_value": "", + }, + { + "name": "branding.color", + "type": "Boolean", + "_id": "63dafeda7779f59258e38454", + "required": False, + "default_value": True, + }, + { + "name": "branding.color", + "type": "String", + "_id": "63dafeda7779f59258e38455", + "required": False, + "default_value": "", + }, + ], + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_get_layout(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.get("63dafeda7779f59258e38450") + self.assertIsInstance(res, LayoutDto) + self.assertEqual(res, self.expected_dto) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/layouts/63dafeda7779f59258e38450", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_patch_layout(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.patch(self.expected_dto) + self.assertIsInstance(res, LayoutDto) + self.assertEqual(res, self.expected_dto) + + mock_request.assert_called_once_with( + method="PATCH", + url="sample.novu.com/v1/layouts/63dafeda7779f59258e38450", + headers={"Authorization": "ApiKey api-key"}, + json={ + "name": "Default Layout", + "description": "The default layout created by Novu", + "content": "", + "isDefault": True, + "variables": [ + { + "name": "branding.logo", + "type": "String", + "_id": "63dafeda7779f59258e38451", + "required": False, + "default_value": "", + }, + { + "name": "preheader", + "type": "Boolean", + "_id": "63dafeda7779f59258e38452", + "required": False, + "default_value": True, + }, + { + "name": "preheader", + "type": "String", + "_id": "63dafeda7779f59258e38453", + "required": False, + "default_value": "", + }, + { + "name": "branding.color", + "type": "Boolean", + "_id": "63dafeda7779f59258e38454", + "required": False, + "default_value": True, + }, + { + "name": "branding.color", + "type": "String", + "_id": "63dafeda7779f59258e38455", + "required": False, + "default_value": "", + }, + ], + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_delete_layout(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(204) + + res = self.api.delete("63dafeda7779f59258e38450") + self.assertIsNone(res) + + mock_request.assert_called_once_with( + method="DELETE", + url="sample.novu.com/v1/layouts/63dafeda7779f59258e38450", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_set_default_layout(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200) + + res = self.api.set_default("63dafeda7779f59258e38450") + self.assertIsNone(res) + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/layouts/63dafeda7779f59258e38450/default", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) diff --git a/tests/api/test_subscriber.py b/tests/api/test_subscriber.py new file mode 100644 index 00000000..713f621d --- /dev/null +++ b/tests/api/test_subscriber.py @@ -0,0 +1,367 @@ +from collections.abc import Generator +from unittest import TestCase, mock + +from novu.api import SubscriberApi +from novu.config import NovuConfig +from novu.dto.subscriber import ( + PaginatedSubscriberDto, + SubscriberDto, + SubscriberPreferenceChannelDto, + SubscriberPreferenceDto, + SubscriberPreferencePreferenceDto, + SubscriberPreferenceTemplateDto, +) +from tests.factories import MockResponse + + +class SubscriberApiTests(TestCase): + @classmethod + def setUpClass(cls) -> None: + NovuConfig.configure("sample.novu.com", "api-key") + cls.api = SubscriberApi() + cls.subscriber_json = { + "_id": "63dafedbc037e013fd82d37a", + "_organizationId": "63dafed97779f59258e3843f", + "_environmentId": "63dafed97779f59258e38445", + "subscriberId": "63dafed4117f8c850991ec4a", + "channels": [], + "deleted": False, + "createdAt": "2023-02-02T00:07:55.459Z", + "updatedAt": "2023-02-06T23:03:22.645Z", + "__v": 0, + "isOnline": False, + "email": "oscar.marie-taillefer@spikeelabs.fr", + "lastOnlineAt": "2023-02-06T23:03:22.645Z", + } + cls.response_list = {"page": 0, "totalCount": 1, "pageSize": 10, "data": [cls.subscriber_json]} + cls.response_get = {"data": cls.subscriber_json} + cls.expected_dto = SubscriberDto( + subscriber_id="63dafed4117f8c850991ec4a", + email="oscar.marie-taillefer@spikeelabs.fr", + first_name=None, + last_name=None, + phone=None, + avatar=None, + locale=None, + _id="63dafedbc037e013fd82d37a", + _environment_id="63dafed97779f59258e38445", + _organization_id="63dafed97779f59258e3843f", + deleted=False, + created_at="2023-02-02T00:07:55.459Z", + updated_at="2023-02-06T23:03:22.645Z", + is_online=False, + last_online_at="2023-02-06T23:03:22.645Z", + ) + + @mock.patch("requests.request") + def test_list_subscriber(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_list) + + result = self.api.list() + self.assertIsInstance(result, PaginatedSubscriberDto) + self.assertEqual(list(result.data), [self.expected_dto]) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/subscribers", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params={}, + timeout=5, + ) + + @mock.patch("requests.request") + def test_list_subscriber_using_pagination(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_list) + + result = self.api.list(1) + self.assertIsInstance(result, PaginatedSubscriberDto) + self.assertEqual(list(result.data), [self.expected_dto]) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/subscribers", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params={"page": 1}, + timeout=5, + ) + + @mock.patch("requests.request") + def test_create_subscriber(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 201, + { + "data": { + "_organizationId": "63dafed97779f59258e3843f", + "_environmentId": "63dafed97779f59258e38445", + "firstName": None, + "lastName": None, + "phone": None, + "subscriberId": "subscriber-id", + "email": "subscriber@sample.com", + "avatar": None, + "locale": None, + "channels": [], + "_id": "63e2cc7151af34c4b2f2b5d1", + "deleted": False, + "createdAt": "2023-02-07T22:10:57.433Z", + "updatedAt": "2023-02-07T22:10:57.433Z", + "__v": 0, + "id": "63e2cc7151af34c4b2f2b5d1", + } + }, + ) + + res = self.api.create(SubscriberDto("subscriber-id", "subscriber@sample.com")) + self.assertIsInstance(res, SubscriberDto) + self.assertEqual(res.subscriber_id, "subscriber-id") + self.assertEqual(res.email, "subscriber@sample.com") + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/subscribers", + headers={"Authorization": "ApiKey api-key"}, + json={ + "subscriberId": "subscriber-id", + "email": "subscriber@sample.com", + "firstName": None, + "lastName": None, + "phone": None, + "avatar": None, + "locale": None, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_get_subscriber(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.get("subscriber-id") + self.assertIsInstance(res, SubscriberDto) + self.assertEqual(res, self.expected_dto) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/subscribers/subscriber-id", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_update_subscriber(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.put(SubscriberDto("subscriber-id", "subscriber@sample.com", "John", "Doe", "+33612345678")) + self.assertIsInstance(res, SubscriberDto) + self.assertEqual(res, self.expected_dto) + + mock_request.assert_called_once_with( + method="PUT", + url="sample.novu.com/v1/subscribers/subscriber-id", + headers={"Authorization": "ApiKey api-key"}, + json={ + "subscriberId": "subscriber-id", + "email": "subscriber@sample.com", + "firstName": "John", + "lastName": "Doe", + "phone": "+33612345678", + "avatar": None, + "locale": None, + }, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_delete(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, {"data": {"acknowledged": True, "status": "deleted"}}) + + self.api.delete("subscriber-id") + + mock_request.assert_called_once_with( + method="DELETE", + url="sample.novu.com/v1/subscribers/subscriber-id", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_credentials_update_webhook_url(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.credentials("subscriber-id", "slack", webhook_url="TEST") + self.assertIsInstance(res, SubscriberDto) + self.assertEqual(res, self.expected_dto) + + mock_request.assert_called_once_with( + method="PUT", + url="sample.novu.com/v1/subscribers/subscriber-id/credentials", + headers={"Authorization": "ApiKey api-key"}, + json={"providerId": "slack", "credentials": {"webhookUrl": "TEST"}}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_credentials_update_device_tokens(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.credentials("subscriber-id", "slack", device_tokens=["TEST"]) + self.assertIsInstance(res, SubscriberDto) + self.assertEqual(res, self.expected_dto) + + mock_request.assert_called_once_with( + method="PUT", + url="sample.novu.com/v1/subscribers/subscriber-id/credentials", + headers={"Authorization": "ApiKey api-key"}, + json={"providerId": "slack", "credentials": {"deviceTokens": ["TEST"]}}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_online_status(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.online_status("subscriber-id", True) + self.assertIsInstance(res, SubscriberDto) + self.assertEqual(res, self.expected_dto) + + mock_request.assert_called_once_with( + method="PATCH", + url="sample.novu.com/v1/subscribers/subscriber-id/online-status", + headers={"Authorization": "ApiKey api-key"}, + json={"isOnline": True}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_preferences(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 200, + { + "data": [ + { + "template": {"_id": "63daff36c037e013fd82da05", "name": "Absences", "critical": False}, + "preference": {"enabled": True, "channels": {"email": True, "in_app": True}}, + } + ] + }, + ) + + res = self.api.preferences("subscriber-id") + self.assertIsInstance(res, Generator) + self.assertEqual( + list(res), + [ + SubscriberPreferenceDto( + preference=SubscriberPreferencePreferenceDto( + enabled=True, channels=SubscriberPreferenceChannelDto(email=True, in_app=True) + ), + template=SubscriberPreferenceTemplateDto( + _id="63daff36c037e013fd82da05", name="Absences", critical=False + ), + ) + ], + ) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/subscribers/subscriber-id/preferences", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_change_channel_preference(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 200, + { + "data": { + "template": {"_id": "63daff36c037e013fd82da05", "name": "Absences", "critical": False}, + "preference": {"enabled": True, "channels": {"email": True, "in_app": True}}, + } + }, + ) + + res = self.api.change_channel_preference("subscriber-id", "63daff36c037e013fd82da05", "in_app", True) + self.assertEqual( + res, + SubscriberPreferenceDto( + preference=SubscriberPreferencePreferenceDto( + enabled=True, channels=SubscriberPreferenceChannelDto(email=True, in_app=True) + ), + template=SubscriberPreferenceTemplateDto( + _id="63daff36c037e013fd82da05", name="Absences", critical=False + ), + ), + ) + + mock_request.assert_called_once_with( + method="PATCH", + url="sample.novu.com/v1/subscribers/subscriber-id/preferences/63daff36c037e013fd82da05", + headers={"Authorization": "ApiKey api-key"}, + json={"channel": {"type": "in_app", "enabled": True}}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_change_preference_state(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 200, + { + "data": { + "template": {"_id": "63daff36c037e013fd82da05", "name": "Absences", "critical": False}, + "preference": {"enabled": False, "channels": {"email": True, "in_app": True}}, + } + }, + ) + + res = self.api.change_preference_state("subscriber-id", "63daff36c037e013fd82da05", False) + self.assertEqual( + res, + SubscriberPreferenceDto( + preference=SubscriberPreferencePreferenceDto( + enabled=False, channels=SubscriberPreferenceChannelDto(email=True, in_app=True) + ), + template=SubscriberPreferenceTemplateDto( + _id="63daff36c037e013fd82da05", name="Absences", critical=False + ), + ), + ) + + mock_request.assert_called_once_with( + method="PATCH", + url="sample.novu.com/v1/subscribers/subscriber-id/preferences/63daff36c037e013fd82da05", + headers={"Authorization": "ApiKey api-key"}, + json={"enabled": False}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_unseen_notifications(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, {"data": {"count": 0}}) + + res = self.api.unseen_notifications("subscriber-id") + self.assertEqual(res, 0) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/subscribers/subscriber-id/notifications/unseen", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) diff --git a/tests/api/test_topic.py b/tests/api/test_topic.py new file mode 100644 index 00000000..d6d5d36a --- /dev/null +++ b/tests/api/test_topic.py @@ -0,0 +1,187 @@ +from unittest import TestCase, mock + +from novu.api import TopicApi +from novu.config import NovuConfig +from novu.dto.topic import PaginatedTopicDto, TopicDto +from tests.factories import MockResponse + + +class TopicApiTests(TestCase): + @classmethod + def setUpClass(cls) -> None: + NovuConfig.configure("sample.novu.com", "api-key") + cls.api = TopicApi() + cls.topic_json = { + "_id": "63e17e5b33a4f299199329b5", + "_environmentId": "63dafed97779f59258e38445", + "_organizationId": "63dafed97779f59258e3843f", + "key": "my-topic", + "name": "My Topic", + "subscribers": [], + } + cls.response_list = {"page": 0, "totalCount": 1, "pageSize": 10, "data": [cls.topic_json]} + cls.response_get = {"data": cls.topic_json} + cls.expected_dto = TopicDto( + _id="63e17e5b33a4f299199329b5", + _environment_id="63dafed97779f59258e38445", + _organization_id="63dafed97779f59258e3843f", + key="my-topic", + name="My Topic", + subscribers=[], + ) + + @mock.patch("requests.request") + def test_list_topic(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_list) + + result = self.api.list() + self.assertIsInstance(result, PaginatedTopicDto) + self.assertEqual(list(result.data), [self.expected_dto]) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/topics", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params={}, + timeout=5, + ) + + @mock.patch("requests.request") + def test_list_topic_using_pagination(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_list) + + result = self.api.list(1, 10, "my-topic") + self.assertIsInstance(result, PaginatedTopicDto) + self.assertEqual(list(result.data), [self.expected_dto]) + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/topics", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params={"page": 1, "limit": 10, "key": "my-topic"}, + timeout=5, + ) + + @mock.patch("requests.request") + def test_create_topic(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(201, {"data": {"_id": "63e17e5b33a4f299199329b5", "key": "my-topic"}}) + + res = self.api.create("my-topic", "My Topic") + self.assertIsInstance(res, TopicDto) + self.assertEqual(res._id, "63e17e5b33a4f299199329b5") + self.assertEqual(res.key, "my-topic") + self.assertIsNone(res.name) + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/topics", + headers={"Authorization": "ApiKey api-key"}, + json={"key": "my-topic", "name": "My Topic"}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_get_topic(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + res = self.api.get("my-topic") + self.assertIsInstance(res, TopicDto) + self.assertEqual(res._id, "63e17e5b33a4f299199329b5") + self.assertEqual(res.key, "my-topic") + self.assertEqual(res.name, "My Topic") + + mock_request.assert_called_once_with( + method="GET", + url="sample.novu.com/v1/topics/my-topic", + headers={"Authorization": "ApiKey api-key"}, + json=None, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_subscribe_ok(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, {"data": {"succeeded": ["63dafed4117f8c850991ec4a"]}}) + + succeed, failed = self.api.subscribe("my-topic", "63dafed4117f8c850991ec4a") + self.assertEqual(succeed, ["63dafed4117f8c850991ec4a"]) + self.assertEqual(failed, {}) + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/topics/my-topic/subscribers", + headers={"Authorization": "ApiKey api-key"}, + json={"subscribers": ["63dafed4117f8c850991ec4a"]}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_subscribe_with_ok_and_failed(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse( + 200, {"data": {"succeeded": ["63dafed4117f8c850991ec4a"], "failed": {"notFound": ["not-defined"]}}} + ) + + succeed, failed = self.api.subscribe("my-topic", ["63dafed4117f8c850991ec4a", "not-defined"]) + self.assertEqual(succeed, ["63dafed4117f8c850991ec4a"]) + self.assertEqual(failed, {"notFound": ["not-defined"]}) + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/topics/my-topic/subscribers", + headers={"Authorization": "ApiKey api-key"}, + json={"subscribers": ["63dafed4117f8c850991ec4a", "not-defined"]}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_unsubscribe_single_subscriber(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(204) + + self.api.unsubscribe("my-topic", "not-defined") + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/topics/my-topic/subscribers/removal", + headers={"Authorization": "ApiKey api-key"}, + json={"subscribers": ["not-defined"]}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_unsubscribe_multiple_subscribers(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(204) + + self.api.unsubscribe("my-topic", ["63dafed4117f8c850991ec4a", "not-defined"]) + + mock_request.assert_called_once_with( + method="POST", + url="sample.novu.com/v1/topics/my-topic/subscribers/removal", + headers={"Authorization": "ApiKey api-key"}, + json={"subscribers": ["63dafed4117f8c850991ec4a", "not-defined"]}, + params=None, + timeout=5, + ) + + @mock.patch("requests.request") + def test_rename_200(self, mock_request: mock.MagicMock) -> None: + mock_request.return_value = MockResponse(200, self.response_get) + + topic = self.api.rename("my-topic", "My Topic") + self.assertIsInstance(topic, TopicDto) + self.assertEqual(topic._id, "63e17e5b33a4f299199329b5") + self.assertEqual(topic.name, "My Topic") + + mock_request.assert_called_once_with( + method="PATCH", + url="sample.novu.com/v1/topics/my-topic", + headers={"Authorization": "ApiKey api-key"}, + json={"name": "My Topic"}, + params=None, + timeout=5, + ) diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 00000000..1dcf3c99 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,39 @@ +from json.decoder import JSONDecodeError + +from requests.models import HTTPError + + +class MockResponse: + def __init__(self, status, data=None, headers=None, raise_on_call=False): + self.status_code = status + self.data = data + self.headers = headers + self.raise_on_call = raise_on_call + + if not self.data: + self.data = {} + + def json(self) -> dict: + if self.raise_on_call: + raise JSONDecodeError("test", "", 0) + return self.data + + def raise_for_status(self): + if self.status_code > 399: + raise HTTPError(response=self) + + def close(self): + pass + + @property + def ok(self): # pylint: disable=C0103 + return self.status_code < 399 + + def iter_content(self, chunk_size): + i = 0 + while i < len(self.data): + if i + chunk_size < len(self.data): + yield self.data[i : i + chunk_size] # noqa: E203 + else: + yield self.data[i : len(self.data)] # noqa: E203 + i += chunk_size