diff --git a/duties.py b/duties.py
new file mode 100644
index 0000000..34885fd
--- /dev/null
+++ b/duties.py
@@ -0,0 +1,377 @@
+from __future__ import annotations
+
+import importlib
+import os
+import pathlib
+import re
+import shutil
+import sys
+from io import StringIO
+from typing import List, Optional, Pattern
+from urllib.request import urlopen
+
+from duty import duty
+
+PACKAGE_NAME = "csops"
+REPO_URL = "https://github.com/dtomlinson91/csops"
+
+
+@duty(post=["export"])
+def update_deps(ctx, dry: bool = False):
+ """
+ Update the dependencies using Poetry.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ dry (bool, optional) = If True will update the `poetry.lock` without updating the
+ dependencies themselves. Defaults to False.
+
+ Example:
+ `duty update_deps dry=False`
+ """
+ dry_run = "--dry-run" if dry else ""
+ ctx.run(
+ ["poetry", "update", dry_run],
+ title=f"Updating poetry deps {dry_run}",
+ )
+
+
+@duty
+def test(ctx):
+ """
+ Run tests using pytest.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ """
+ pytest_results = ctx.run(["pytest", "-v"], pty=True)
+ print(pytest_results)
+
+
+@duty
+def coverage(ctx):
+ """
+ Generate a coverage report and save to XML and HTML.
+
+ Args:
+ ctx: The context instance (passed automatically).
+
+ Example:
+ `duty coverage`
+ """
+ ctx.run(["coverage", "run", "--source", PACKAGE_NAME, "-m", "pytest"])
+ res = ctx.run(["coverage", "report"], pty=True)
+ print(res)
+ ctx.run(["coverage", "html"])
+ ctx.run(["coverage", "xml"])
+
+
+@duty
+def bump(ctx, version: str = "patch"):
+ """
+ Bump the version using Poetry and update _version.py.
+
+ This duty is ran as part of `duty release`.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ version (str, optional) = poetry version flag. Available options are:
+ patch, minor, major. Defaults to patch.
+
+ Example:
+ `duty bump version=major`
+ """
+
+ # bump with poetry
+ result = ctx.run(["poetry", "version", version])
+ new_version = re.search(r"(?:.*)(?:\s)(\d+\.\d+\.\d+)$", result)
+ print(new_version.group(0))
+
+ # update _version.py
+ version_file = pathlib.Path(PACKAGE_NAME) / "_version.py"
+ with version_file.open("w", encoding="utf-8") as version_file:
+ version_file.write(
+ f'"""Module containing the version of {PACKAGE_NAME}."""\n\n' + f'__version__ = "{new_version.group(1)}"\n'
+ )
+ print(f"Bumped _version.py to {new_version.group(1)}")
+
+
+@duty
+def build(ctx):
+ """
+ Build with poetry and extract the setup.py and copy to project root.
+
+ Args:
+ ctx: The context instance (passed automatically).
+
+ Example:
+ `duty build`
+ """
+
+ repo_root = pathlib.Path(".")
+
+ # build with poetry
+ result = ctx.run(["poetry", "build"])
+ print(result)
+
+ # extract the setup.py from the tar
+ extracted_tar = re.search(r"(?:.*)(?:Built\s)(.*)", result)
+ tar_file = pathlib.Path(f"./dist/{extracted_tar.group(1)}")
+ shutil.unpack_archive(tar_file, tar_file.parents[0])
+
+ # copy setup.py to repo root
+ extracted_path = tar_file.parents[0] / os.path.splitext(tar_file.stem)[0]
+ setup_py = extracted_path / "setup.py"
+ shutil.copyfile(setup_py, (repo_root / "setup.py"))
+
+ # cleanup
+ shutil.rmtree(extracted_path)
+
+
+@duty
+def release(ctx, version: str = "patch") -> None:
+ """
+ Prepare package for a new release.
+
+ Will run bump, build, export. Manual running of publish is required afterwards.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ version (str): poetry version flag. Available options are: patch, minor, major.
+ """
+ print(ctx.run(["duty", "bump", f"version={version}"]))
+ ctx.run(["duty", "build"])
+ ctx.run(["duty", "export"])
+ print(
+ "✔ Check generated files. Run `duty changelog planned_release= previous_release=` and `duty publish password=`"
+ " when ready to publish."
+ )
+
+
+@duty
+def export(ctx):
+ """
+ Export the dependencies to a requirements.txt file.
+
+ Args:
+ ctx: The context instance (passed automatically).
+
+ Example:
+ `duty export`
+ """
+ requirements_content = ctx.run(
+ [
+ "poetry",
+ "export",
+ "-f",
+ "requirements.txt",
+ "--without-hashes",
+ ]
+ )
+ requirements_dev_content = ctx.run(
+ [
+ "poetry",
+ "export",
+ "-f",
+ "requirements.txt",
+ "--without-hashes",
+ "--dev",
+ ]
+ )
+
+ requirements = pathlib.Path(".") / "requirements.txt"
+ requirements_dev = pathlib.Path(".") / "requirements_dev.txt"
+
+ with requirements.open("w", encoding="utf-8") as req:
+ req.write(requirements_content)
+
+ with requirements_dev.open("w", encoding="utf-8") as req:
+ req.write(requirements_dev_content)
+
+
+@duty
+def publish(ctx, password: str):
+ """
+ Publish the package to pypi.org.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ password (str): pypi.org password.
+
+ Example:
+ `duty publish password=$my_password`
+ """
+ dist_dir = pathlib.Path(".") / "dist"
+ rm_result = rm_tree(dist_dir)
+ print(rm_result)
+
+ publish_result = ctx.run(["poetry", "publish", "-u", "dtomlinson", "-p", password, "--build"])
+ print(publish_result)
+
+
+@duty(silent=True)
+def clean(ctx):
+ """
+ Delete temporary files.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ """
+ ctx.run("rm -rf .mypy_cache")
+ ctx.run("rm -rf .pytest_cache")
+ ctx.run("rm -rf tests/.pytest_cache")
+ ctx.run("rm -rf build")
+ ctx.run("rm -rf dist")
+ ctx.run("rm -rf pip-wheel-metadata")
+ ctx.run("rm -rf site")
+ ctx.run("rm -rf coverage.xml")
+ ctx.run("rm -rf pytest.xml")
+ ctx.run("rm -rf htmlcov")
+ ctx.run("find . -iname '.coverage*' -not -name .coveragerc | xargs rm -rf")
+ ctx.run("find . -type d -name __pycache__ | xargs rm -rf")
+ ctx.run("find . -name '*.rej' -delete")
+
+
+@duty
+def format(ctx):
+ """
+ Format code using Black and isort.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ """
+ res = ctx.run(["black", "--line-length=99", PACKAGE_NAME], pty=True, title="Running Black")
+ print(res)
+
+ res = ctx.run(["isort", PACKAGE_NAME])
+ print(res)
+
+
+@duty(pre=["check_code_quality", "check_types", "check_docs", "check_dependencies"])
+def check(ctx):
+ """
+ Check the code quality, check types, check documentation builds and check dependencies for vulnerabilities.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ """
+
+
+@duty
+def check_code_quality(ctx):
+ """
+ Check the code quality using prospector.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ """
+ ctx.run(["prospector", PACKAGE_NAME], pty=True, title="Checking code quality with prospector")
+
+
+@duty
+def check_types(ctx):
+ """
+ Check the types using mypy.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ """
+ ctx.run(["mypy", PACKAGE_NAME], pty=True, title="Checking types with MyPy")
+
+
+@duty
+def check_docs(ctx):
+ """
+ Check the documentation builds successfully.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ """
+ ctx.run(["mkdocs", "build"], title="Building documentation")
+
+
+@duty
+def check_dependencies(ctx):
+ """
+ Check dependencies with safety for vulnerabilities.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ """
+ for module in sys.modules:
+ if module.startswith("safety.") or module == "safety":
+ del sys.modules[module]
+
+ importlib.invalidate_caches()
+
+ from safety import safety
+ from safety.formatter import report
+ from safety.util import read_requirements
+
+ requirements = ctx.run(
+ "poetry export --dev --without-hashes",
+ title="Exporting dependencies as requirements",
+ allow_overrides=False,
+ )
+
+ def check_vulns():
+ packages = list(read_requirements(StringIO(requirements)))
+ vulns = safety.check(packages=packages, ignore_ids="41002", key="", db_mirror="", cached=False, proxy={})
+ output_report = report(vulns=vulns, full=True, checked_packages=len(packages))
+ print(vulns)
+ if vulns:
+ print(output_report)
+
+ ctx.run(
+ check_vulns,
+ stdin=requirements,
+ title="Checking dependencies",
+ pty=True,
+ )
+
+
+@duty
+def changelog(ctx, planned_release: Optional[str] = None, previous_release: Optional[str] = None):
+ """
+ Generate a changelog with git-cliff.
+
+ Args:
+ ctx: The context instance (passed automatically).
+ planned_release (str, optional): The planned release version. Example: v1.0.2
+ previous_release (str, optional): The previous release version. Example: v1.0.1
+ """
+ generated_changelog: str = ctx.run(["git", "cliff", "-u", "-t", planned_release, "-s", "header"])[:-1]
+ if previous_release is not None:
+ generated_changelog: list = generated_changelog.splitlines()
+ generated_changelog.insert(
+ 1,
+ f"[Compare with {previous_release}]({REPO_URL}/compare/{previous_release}..{planned_release})",
+ )
+ generated_changelog: str = "\n".join([line for line in generated_changelog]) + "\n"
+ new_changelog = []
+
+ changelog_file = pathlib.Path(".") / "CHANGELOG.md"
+ with changelog_file.open("r", encoding="utf-8") as changelog_contents:
+ all_lines = changelog_contents.readlines()
+ for line_string in all_lines:
+ regex_string = re.search(r"()", line_string)
+ new_changelog.append(line_string)
+ if isinstance(regex_string, re.Match):
+ new_changelog.append(generated_changelog)
+ with changelog_file.open("w", encoding="utf-8") as changelog_contents:
+ changelog_contents.writelines(new_changelog)
+
+
+def rm_tree(directory: pathlib.Path):
+ """
+ Recursively delete a directory and all its contents.
+
+ Args:
+ directory (pathlib.Path): The directory to delete.
+ """
+ for child in directory.glob("*"):
+ if child.is_file():
+ child.unlink()
+ else:
+ rm_tree(child)
+ directory.rmdir()
diff --git a/poetry.lock b/poetry.lock
index ce9ec3e..5cd87c8 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,8 +1,191 @@
-package = []
+[[package]]
+name = "ansimarkup"
+version = "1.5.0"
+description = "Produce colored terminal text with an xml-like markup"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+colorama = "*"
+
+[package.extras]
+devel = ["bumpversion (>=0.5.2)", "check-manifest (>=0.35)", "readme-renderer (>=16.0)", "flake8", "pep8-naming"]
+tests = ["tox (>=2.6.0)", "pytest (>=3.0.3)", "pytest-cov (>=2.3.1)"]
+
+[[package]]
+name = "cached-property"
+version = "1.5.2"
+description = "A decorator for caching properties in classes."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "colorama"
+version = "0.4.4"
+description = "Cross-platform colored terminal text."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "duty"
+version = "0.7.0"
+description = "A simple task runner."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+cached-property = {version = ">=1.5,<2.0", markers = "python_version < \"3.8\""}
+failprint = ">=0.8,<1.0"
+
+[[package]]
+name = "failprint"
+version = "0.8.0"
+description = "Run a command, print its output only if it fails."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+ansimarkup = ">=1.4,<2.0"
+jinja2 = ">=2.11,<4"
+ptyprocess = {version = ">=0.6,<1.0", markers = "sys_platform != \"win32\""}
+
+[[package]]
+name = "jinja2"
+version = "3.0.3"
+description = "A very fast and expressive template engine."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "markupsafe"
+version = "2.0.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+description = "Run a subprocess in a pseudo terminal"
+category = "dev"
+optional = false
+python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
-content-hash = "669741988c507fb04697bdb0c9077fa1b2342c356df6ae6c96baa3119a96a9ea"
+content-hash = "25c39083344a50ca67bfcb0dc9f537b2514800593d520f88c6251d0d6e1d6555"
[metadata.files]
+ansimarkup = [
+ {file = "ansimarkup-1.5.0-py2.py3-none-any.whl", hash = "sha256:3146ca74af5f69e48a9c3d41b31085c0d6378f803edeb364856d37c11a684acf"},
+ {file = "ansimarkup-1.5.0.tar.gz", hash = "sha256:96c65d75bbed07d3dcbda8dbede8c2252c984f90d0ca07434b88a6bbf345fad3"},
+]
+cached-property = [
+ {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"},
+ {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"},
+]
+colorama = [
+ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
+]
+duty = [
+ {file = "duty-0.7.0-py3-none-any.whl", hash = "sha256:45068baf1639f16464aa40e9d8f698f0ae09408368fe53a34e9bfe6993dfd743"},
+ {file = "duty-0.7.0.tar.gz", hash = "sha256:5ebfd4640ab41e3058f1d8433f74228d60c9a808def1784e65319ef1899a9d15"},
+]
+failprint = [
+ {file = "failprint-0.8.0-py3-none-any.whl", hash = "sha256:a8215a7aca5ce687116b995cd3a9667180f222ab88c4328a5007d2fa0b5c0f78"},
+ {file = "failprint-0.8.0.tar.gz", hash = "sha256:4633b52f9395bf042ad996c96cd7819a94b2021833030dd1eb692ebbd86b89a1"},
+]
+jinja2 = [
+ {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"},
+ {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"},
+]
+markupsafe = [
+ {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
+ {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
+]
+ptyprocess = [
+ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
+ {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
+]
diff --git a/pyproject.toml b/pyproject.toml
index 72c59d0..7d37075 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,6 +8,7 @@ authors = ["Daniel Tomlinson "]
python = "^3.7"
[tool.poetry.dev-dependencies]
+duty = "^0.7.0"
[build-system]
requires = ["poetry-core>=1.0.0"]