diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7bd328c..d03944d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,32 +1,13 @@ -name: Python Testing +name: Lint and Test on: pull_request: - push: + branches: + - main + - master jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - python-version: [3.8, 3.11] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Test with pytest - run: | - pip install -r requirements-dev.txt - pytest + test-python-poetry: + uses: radiorabe/actions/.github/workflows/test-python-poetry.yaml@v0.20.8 + pre-commit: + uses: radiorabe/actions/.github/workflows/test-pre-commit.yaml@v0.20.8 diff --git a/.gitignore b/.gitignore index bee8a64..bff2ad4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ __pycache__ +.venv/ +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..08de282 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.3.2' + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + exclude: ^src/api/client.js$ + - id: end-of-file-fixer + exclude: ^src/api/client.js$ + - id: check-symlinks + - id: check-merge-conflict + - id: check-case-conflict + - id: detect-aws-credentials + args: + - --allow-missing-credentials + - id: detect-private-key diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..c68af84 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,308 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +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 = "configargparse" +version = "1.7" +description = "A drop-in replacement for argparse that allows options to also be set via config files and/or environment variables." +optional = false +python-versions = ">=3.5" +files = [ + {file = "ConfigArgParse-1.7-py3-none-any.whl", hash = "sha256:d249da6591465c6c26df64a9f73d2536e743be2f244eb3ebe61114af2f94f86b"}, + {file = "ConfigArgParse-1.7.tar.gz", hash = "sha256:e7067471884de5478c58a511e529f0f9bd1c66bfef1dea90935438d6c23306d1"}, +] + +[package.extras] +test = ["PyYAML", "mock", "pytest"] +yaml = ["PyYAML"] + +[[package]] +name = "coverage" +version = "7.4.4" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, + {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, + {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, + {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, + {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, + {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, + {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, + {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, + {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, + {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, + {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, + {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, + {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, +] + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "freezegun" +version = "1.4.0" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.7" +files = [ + {file = "freezegun-1.4.0-py3-none-any.whl", hash = "sha256:55e0fc3c84ebf0a96a5aa23ff8b53d70246479e9a68863f1fcac5a3e52f19dd6"}, + {file = "freezegun-1.4.0.tar.gz", hash = "sha256:10939b0ba0ff5adaecf3b06a5c2f73071d9678e507c5eaedb23c761d56ac774b"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +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 = "kanboard" +version = "1.1.5" +description = "Client library for Kanboard" +optional = false +python-versions = "*" +files = [ + {file = "kanboard-1.1.5-py3-none-any.whl", hash = "sha256:2eafd47dc4638a1226d9516dd10cea3621da4c058d28bcd7b232040213a9f5d3"}, + {file = "kanboard-1.1.5.tar.gz", hash = "sha256:5482c50cb1d83ea531f834e4dfe8c41774baacb2f465303b4920d72371361440"}, +] + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pluggy" +version = "1.4.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.1.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.4,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-freezegun" +version = "0.4.2" +description = "Wrap tests with fixtures in freeze_time" +optional = false +python-versions = "*" +files = [ + {file = "pytest-freezegun-0.4.2.zip", hash = "sha256:19c82d5633751bf3ec92caa481fb5cffaac1787bd485f0df6436fd6242176949"}, + {file = "pytest_freezegun-0.4.2-py2.py3-none-any.whl", hash = "sha256:5318a6bfb8ba4b709c8471c94d0033113877b3ee02da5bfcd917c1889cde99a7"}, +] + +[package.dependencies] +freezegun = ">0.3" +pytest = ">=3.0.0" + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pytest-random-order" +version = "1.1.1" +description = "Randomise the order in which pytest tests are run with some control over the randomness" +optional = false +python-versions = ">=3.5.0" +files = [ + {file = "pytest-random-order-1.1.1.tar.gz", hash = "sha256:4472d7d34f1f1c5f3a359c4ffc5c13ed065232f31eca19c8844c1ab406e79080"}, + {file = "pytest_random_order-1.1.1-py3-none-any.whl", hash = "sha256:882727a8b597ecd06ede28654ffeb8a6d511a1e4abe1054cca7982f2e42008cd"}, +] + +[package.dependencies] +pytest = ">=3.0.0" + +[[package]] +name = "pytest-ruff" +version = "0.3.1" +description = "pytest plugin to check ruff requirements." +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "pytest_ruff-0.3.1-py3-none-any.whl", hash = "sha256:008556576fb1bda93a432ad381432bfd5575cc94627d22bfdece46561b8e4f7f"}, + {file = "pytest_ruff-0.3.1.tar.gz", hash = "sha256:c9f7392a3384af73a6a72741a4035a605480a7a8e7a4bd8da05a98e6664cffb5"}, +] + +[package.dependencies] +pytest = ">=5" +ruff = ">=0.0.242" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "ruff" +version = "0.3.5" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.3.5-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:aef5bd3b89e657007e1be6b16553c8813b221ff6d92c7526b7e0227450981eac"}, + {file = "ruff-0.3.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:89b1e92b3bd9fca249153a97d23f29bed3992cff414b222fcd361d763fc53f12"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e55771559c89272c3ebab23326dc23e7f813e492052391fe7950c1a5a139d89"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dabc62195bf54b8a7876add6e789caae0268f34582333cda340497c886111c39"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a05f3793ba25f194f395578579c546ca5d83e0195f992edc32e5907d142bfa3"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dfd3504e881082959b4160ab02f7a205f0fadc0a9619cc481982b6837b2fd4c0"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87258e0d4b04046cf1d6cc1c56fadbf7a880cc3de1f7294938e923234cf9e498"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:712e71283fc7d9f95047ed5f793bc019b0b0a29849b14664a60fd66c23b96da1"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a532a90b4a18d3f722c124c513ffb5e5eaff0cc4f6d3aa4bda38e691b8600c9f"}, + {file = "ruff-0.3.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:122de171a147c76ada00f76df533b54676f6e321e61bd8656ae54be326c10296"}, + {file = "ruff-0.3.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d80a6b18a6c3b6ed25b71b05eba183f37d9bc8b16ace9e3d700997f00b74660b"}, + {file = "ruff-0.3.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a7b6e63194c68bca8e71f81de30cfa6f58ff70393cf45aab4c20f158227d5936"}, + {file = "ruff-0.3.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a759d33a20c72f2dfa54dae6e85e1225b8e302e8ac655773aff22e542a300985"}, + {file = "ruff-0.3.5-py3-none-win32.whl", hash = "sha256:9d8605aa990045517c911726d21293ef4baa64f87265896e491a05461cae078d"}, + {file = "ruff-0.3.5-py3-none-win_amd64.whl", hash = "sha256:dc56bb16a63c1303bd47563c60482a1512721053d93231cf7e9e1c6954395a0e"}, + {file = "ruff-0.3.5-py3-none-win_arm64.whl", hash = "sha256:faeeae9905446b975dcf6d4499dc93439b131f1443ee264055c5716dd947af55"}, + {file = "ruff-0.3.5.tar.gz", hash = "sha256:a067daaeb1dc2baf9b82a32dae67d154d95212080c80435eb052d95da647763d"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "f7c702731a9098393da4ff7e80035384801d8f526c4207a31decefdb10431b21" diff --git a/py.typed b/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1aae1d6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[tool.poetry] +name = "kanboard_tasks_from_email" +version = "0.0.0" # 0.0.0 placeholder is replaced on release +description = "Script to create kanboard tasks from email" +repository = "https://github.com/radiorabe/kanboard-tasks-from-email" +authors = ["IT Reaktion "] +license = "AGPL-3" +readme = "README.md" +packages = [{include = "src/tasks_from_email.py" }] + +[tool.poetry.scripts] +kanboard-tasks-from-email = 'tasks_from_email:main' + +[tool.poetry.dependencies] +python = "^3.11" +kanboard = "^1.1.5" +ConfigArgParse = "^1.7" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.1.1" +pytest-cov = "^5.0.0" +pytest-freezegun = "^0.4.2" +pytest-mock = "^3.14.0" +pytest-random-order = "^1.1.1" +pytest-ruff = "^0.3.1" +ruff = "^0.3.5" + +[tool.pytest.ini_options] +minversion = "8.1" +addopts = "-ra -q --random-order --doctest-glob='*.md' --doctest-modules --cov=src --cov-fail-under=100 --cov-report=html --ruff --mypy --ignore docs/" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index e9b10ff..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -pytest==8.1.1 -pytest-cov==5.0.0 -pytest-freezegun==0.4.2 -pytest-mock==3.14.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index deb7351..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -ConfigArgParse==1.7 -kanboard==1.1.5 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..966cc7f --- /dev/null +++ b/ruff.toml @@ -0,0 +1,78 @@ +# [ruff](https://docs.astral.sh/ruff/) config +# +# templated with https://github.com/radiorabe/backstage-software-templates + +[lint] +select = [ + "F", # pyflakes + "E", # pycodestyle errors + "I", # isort + "C90", # mccabe + "N", # pep8-naming + "D", # pydocstyle + "UP", # pyupgrade + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "S", # flake8-bandit + "BLE", # flake8-blind-exception + "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T10", # flake8-debugger + "EM", # flake8-errmsg + "EXE", # flake8-executable + "FA", # flake8-future-annotations + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + "PYI", # flake8-pyi + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SLF", # flake8-self + "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "TCH", # flake8-type-checking + "INT", # flake8-gettext + "ARG", # flake8-unused-arguments + "PTH", # flake8-use-pathlib + "TD", # flake8-todos + "ERA", # eradicate + "PGH", # pygrep-hooks + "PL", # Pylint + "TRY", # tryceratops + "PERF", # Perflint + "RUF", # ruff specific rules +] +ignore = [ + "D203", # we prefer blank-line-before-class (D211) for black compat + "D213", # we prefer multi-line-summary-first-line (D212) + "COM812", # ignore due to conflict with formatter + "ISC001", # ignore due to conflict with formatter +] + +[lint.per-file-ignores] +"tests/**/*.py" = [ + "D", # pydocstyle is optional for tests + "ANN", # flake8-annotations are optional for tests + "S101", # assert is allow in tests + "S105", # tests may have hardcoded secrets + "S106", # tests may have hardcoded passwords + "S108", # /tmp is allowed in tests since it's expected to be mocked + "DTZ00", # tests often run in UTC + "INP001", # tests do not need a dunder init +] +"**/__init__.py" = [ + "D104", # dunder init does not need a docstring because it might be empty +] +"docs/gen_ref_pages.py" = [ + "INP001", # mkdocs does not need a dunder init +] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tasks_from_email.py b/src/tasks_from_email.py index c4aaaa5..da47138 100755 --- a/src/tasks_from_email.py +++ b/src/tasks_from_email.py @@ -35,247 +35,417 @@ # -""" Import libraries and config file """ -import os, sys, imaplib, email, datetime, mailbox, kanboard, ssl, re, time, base64 -from os.path import basename, expanduser +"""Import libraries and config file.""" -from configargparse import ArgumentParser +from __future__ import annotations +import base64 +import datetime +import email +import imaplib +import re +import time +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING, Any, NoReturn + +import kanboard # type: ignore[import-untyped] +from configargparse import ArgumentParser # type: ignore[import-untyped] + +if TYPE_CHECKING: # pragma: no cover + from argparse import Namespace + + +def get_arguments(parser: ArgumentParser) -> Namespace: + """Configure argument parsing. -def get_arguments(parser): - """Setup the provided ArgumentParser (a configargparse.ArgumentParser object) with arguments - and return arguments Arguments: + --------- parser: the parser to add arguments + Returns: + ------- args: the parsed args from the parser - """ + """ for arg in [ # mail server - ('--imaps-server', {'dest':'IMAPS_SERVER', 'help':'fqdn of mail sever', 'required':True}), - ('--imaps-user', {'dest':'IMAPS_USERNAME', 'env_var':'IMAPS_USERNAME', 'help':'imap user name', 'required':True}), - ('--imaps-password', {'dest':'IMAPS_PASSWORD', 'env_var':'IMAPS_PASSWORD', 'help':'imap user password', 'required':True}), + ( + "--imaps-server", + {"dest": "IMAPS_SERVER", "help": "fqdn of mail sever", "required": True}, + ), + ( + "--imaps-user", + { + "dest": "IMAPS_USERNAME", + "env_var": "IMAPS_USERNAME", + "help": "imap user name", + "required": True, + }, + ), + ( + "--imaps-password", + { + "dest": "IMAPS_PASSWORD", + "env_var": "IMAPS_PASSWORD", + "help": "imap user password", + "required": True, + }, + ), # kanboard - ('--kanboard-connect-url', {'dest':'KANBOARD_CONNECT_URL', 'help':'url for API requests', 'required':True}), - ('--kanboard-api-token', {'dest':'KANBOARD_API_TOKEN', 'help':'API token from a user that is allowed to create tasks', 'required':True}), - ('--kanboard-project-name', {'dest':'KANBOARD_PROJECT_NAME', 'help':'Name of the kanboard project where new tasks are going to be created.', 'default':'Support'}), - ('--kanboard-due-offset-hours', {'dest':'KANBOARD_TASK_DUE_OFFSET_IN_HOURS', 'help':'Number of hours the task is due after mail received', 'default':48}), - ('--kanboard-group-id', {'dest':'KANBOARD_GROUP_ID', 'help':"ID of group new users shall be added to. If set to 0 (default), the new user won't be added to a group.", 'default':0}), + ( + "--kanboard-connect-url", + { + "dest": "KANBOARD_CONNECT_URL", + "help": "url for API requests", + "required": True, + }, + ), + ( + "--kanboard-api-token", + { + "dest": "KANBOARD_API_TOKEN", + "help": "API token from a user that is allowed to create tasks", + "required": True, + }, + ), + ( + "--kanboard-project-name", + { + "dest": "KANBOARD_PROJECT_NAME", + "help": "Name of the kanboard project where tasks are to be created.", + "default": "Support", + }, + ), + ( + "--kanboard-due-offset-hours", + { + "dest": "KANBOARD_TASK_DUE_OFFSET_IN_HOURS", + "help": "Number of hours the task is due after mail received", + "default": 48, + }, + ), + ( + "--kanboard-group-id", + { + "dest": "KANBOARD_GROUP_ID", + "help": ( + "ID of group new users shall be added to. " + "If set to 0 (default), the new user won't be added to a group." + ), + "default": 0, + }, + ), # various - ('--well-known-email-addresses', {'dest':'WELL_KNOWN_EMAIL_ADDRESSES', 'help':'well-known mail addresses from where emails could be forwarded because they were sent to the wrong address', 'default':[]}), + ( + "--well-known-email-addresses", + { + "dest": "WELL_KNOWN_EMAIL_ADDRESSES", + "help": ( + "well-known mail addresses from where emails could be forwarded" + "because they were sent to the wrong address" + ), + "default": [], + }, + ), ]: name, params = arg - params['env_var'] = params['dest'] + params["env_var"] = params["dest"] parser.add_argument(name, **params) return parser.parse_args() -def convert_to_kb_date(date_str, increment_by_hours=0): - """convert a date into a kanboard compatible date +def convert_to_kb_date(date_str: str, increment_by_hours: int = 0) -> str: + """Convert date into a kanboard compatible date. + + Arguments: + --------- + date_str: String containing a date from an email (tested with emails only) + increment_by_hours: Number of hours offset to date_str - Parameters - ---------- - date_str: str, mandatory - String containing a date from an email (tested with emails only) - increment_by_hours: int, optional - Number of hours offset to date_str - - Returns + Returns: ------- - string the date in kanboard compatible format %d.%m.%Y %H:%M + """ local_kb_date = None date_tuple = email.utils.parsedate(date_str) - """ add 12 hours if the passed date string is in 12-hours format and ends with 'PM' """ - if date_str[-2:] == 'PM': + # add 12 hours if the passed date string is in 12-hours format + # and it ends with 'PM' + if date_str[-2:] == "PM": increment_by_hours += 12 if date_tuple: - local_timezone = datetime.datetime.now(datetime.timezone(datetime.timedelta(0))).astimezone().tzinfo - local_date = datetime.datetime.fromtimestamp(time.mktime(date_tuple), local_timezone) + local_timezone = ( + datetime.datetime.now(datetime.timezone(datetime.timedelta(0))) + .astimezone() + .tzinfo + ) + local_date = datetime.datetime.fromtimestamp( + time.mktime(date_tuple), local_timezone + ) if increment_by_hours > 0: local_date = local_date + datetime.timedelta(hours=increment_by_hours) - local_kb_date = "%s" %(str(local_date.strftime('%d.%m.%Y %H:%M'))) + local_kb_date = "%s" % (str(local_date.strftime("%d.%m.%Y %H:%M"))) return local_kb_date -def imap_connect(server, user, password): - """ connect and authenticate against mailserver """ +def imap_connect(server: str, user: str, password: str) -> imaplib.IMAP4_SSL: + """Connect and authenticate against mailserver.""" imap_connection = imaplib.IMAP4_SSL(server) imap_connection.login(user, password) return imap_connection -def imap_close(imap_connection): - """ close mailserver connection """ +def imap_close(imap_connection: imaplib.IMAP4_SSL) -> NoReturn: + """Close mailserver connection.""" imap_connection.close() imap_connection.logout() -def imap_search_unseen(imap_connection): - """ get unread mails """ - imap_connection.select('INBOX') - return imap_connection.search(None, 'unseen') +def imap_search_unseen(imap_connection: imaplib.IMAP4_SSL) -> imaplib._CommandResults: + """Get unread mails.""" + imap_connection.select("INBOX") + return imap_connection.search(None, "unseen") -def walk_message_parts(email_message): - """ - Grab the body and all named attachments from a given email.message.EmailMessage. - """ +def walk_message_parts(email_message: email.message.EmailMessage) -> tuple[str, dict]: + """Grab the body and all named attachments from a message.""" body = None kb_attachments = {} for part in email_message.walk(): """ get plain text body details """ - if part.get_content_maintype() == 'multipart': + if part.get_content_maintype() == "multipart": continue - if part.get('Content-Disposition') is None: - if part.get_content_type() == 'text/plain': - body = re.sub('\r\n', '\r\n\r\n', part.get_payload(decode=True).decode('utf-8')) + if part.get("Content-Disposition") is None: + if part.get_content_type() == "text/plain": + body = re.sub( + "\r\n", "\r\n\r\n", part.get_payload(decode=True).decode("utf-8") + ) continue - fileName = email.header.make_header(email.header.decode_header(part.get_filename())) - if bool(fileName): - kb_attachments[str(fileName)] = base64.b64encode(part.get_payload(decode=True)) - body = '%s\n\n<< Attachment: %s >>' %(body, fileName) + file_name = email.header.make_header( + email.header.decode_header(part.get_filename()) + ) + if bool(file_name): + kb_attachments[str(file_name)] = base64.b64encode( + part.get_payload(decode=True) + ) + body = f"{body}\n\n<< Attachment: {file_name} >>" return (body, kb_attachments) -def handle_well_known_forwarders(well_known, offset, body, email_address, local_task_start_date_ISO8601, local_task_due_date_ISO8601): - """ if the email has been forwarded from specified addresses use sender - email address and timestamp from message body """ - fwd_email_addresses=re.findall('(From:.*\S+@\S+|To:.*\S+@\S+)', '%s' % body) +def handle_well_known_forwarders( # noqa: PLR0913 + well_known: list[str], + offset: int, + body: str, + email_address: str, + local_task_start_date_iso8601: str, + local_task_due_date_iso8601: str, +) -> tuple: + """Handle known addresses. + + If the email has been forwarded from specified addresses use sender + email address and timestamp from message body. + """ + fwd_email_addresses = re.findall("(From:.*\S+@\S+|To:.*\S+@\S+)", "%s" % body) if fwd_email_addresses: - fwd_to_email_address=re.sub('[<>]', '', re.findall('\S+@\S+', fwd_email_addresses[1])[-1]) + fwd_to_email_address = re.sub( + "[<>]", "", re.findall("\S+@\S+", fwd_email_addresses[1])[-1] + ) if fwd_to_email_address in well_known: - email_address = re.sub('[<>]', '', re.findall('\S+@\S+', fwd_email_addresses[0])[-1]) - local_task_start_date_ISO8601 = convert_to_kb_date(re.sub('Date:\s*', - '', - re.search('Date:[\S ]+', - '%s' % body, - re.MULTILINE).group(0))) - local_task_due_date_ISO8601 = convert_to_kb_date(re.sub('Date:\s*', - '', - re.search('Date:[\S ]+', - '%s' % body, - re.MULTILINE).group(0)), - offset) - return email_address, local_task_start_date_ISO8601, local_task_due_date_ISO8601 - -def create_user_for_sender(kb, email_address): - """ create user for sender email if it doesn't exist """ + email_address = re.sub( + "[<>]", "", re.findall("\S+@\S+", fwd_email_addresses[0])[-1] + ) + local_task_start_date_iso8601 = convert_to_kb_date( + re.sub( + "Date:\s*", + "", + re.search("Date:[\S ]+", "%s" % body, re.MULTILINE).group(0), + ) + ) + local_task_due_date_iso8601 = convert_to_kb_date( + re.sub( + "Date:\s*", + "", + re.search("Date:[\S ]+", "%s" % body, re.MULTILINE).group(0), + ), + offset, + ) + return email_address, local_task_start_date_iso8601, local_task_due_date_iso8601 + + +def create_user_for_sender( + kb: kanboard.Client, + email_address: str, +) -> int: + """Create user for sender email if it doesn't exist.""" kb_user_id = None kb_users = kb.get_all_users() for kb_user in kb_users: - if kb_user['email'] == email_address: - kb_user_id = kb_user['id'] - if kb_user_id == None: - kb_user_id = kb.create_user(username=email_address, password=email_address, email=email_address) + if kb_user["email"] == email_address: + kb_user_id = kb_user["id"] + if kb_user_id is None: + kb_user_id = kb.create_user( + username=email_address, password=email_address, email=email_address + ) return kb_user_id -def get_task_if_subject_matches(kb, subject): - """ search for link to already existing task """ + +def get_task_if_subject_matches( + kb: kanboard.Client, + subject: str, +) -> tuple[int, Any]: + """Search for link to already existing task.""" kb_task_id = False kb_task = None - kb_task_search_result = re.findall('\[KB#\d+', '%s' % subject) + kb_task_search_result = re.findall("\[KB#\d+", "%s" % subject) if kb_task_search_result: - kb_task_id = re.sub('\[KB#', '', kb_task_search_result[-1]) + kb_task_id = re.sub("\[KB#", "", kb_task_search_result[-1]) """ test if task already exists """ kb_task = kb.get_task(task_id=kb_task_id) return kb_task_id, kb_task -def reopen_and_update(kb, kb_task, kb_task_id, kb_user_id, kb_text, local_task_due_date_ISO8601): - """ reopen task, update due date and add email as comment """ - if kb_task['is_active'] == 0: + +def reopen_and_update( # noqa: PLR0913 + kb: kanboard.Client, + kb_task: dict, + kb_task_id: int, + kb_user_id: int, + kb_text: str, + local_task_due_date_iso8601: str, +) -> NoReturn: + """Reopen task, update due date and add email as comment.""" + if kb_task["is_active"] == 0: kb.open_task(task_id=kb_task_id) """ add email as comment """ kb.create_comment(task_id=kb_task_id, user_id=kb_user_id, content=kb_text) - kb.update_task(id=kb_task_id, date_due=local_task_due_date_ISO8601) - -def main(): - """main function""" - default_config_file = 'tasks_from_email.conf' - # TODO: use basename once modularized - # default_config_file = basename(__file__).replace('.py', '.conf') - # config file in /etc gets overriden by the one in /etc/tasks_from_email which gets overridden by the one in - # $HOME which gets overriden by the one in the current directory + kb.update_task(id=kb_task_id, date_due=local_task_due_date_iso8601) + + +def main() -> NoReturn: + """Run Application.""" + default_config_file = Path(__file__).name.replace(".py", ".conf") + # config file in /etc gets overriden by the one in /etc/tasks_from_email which gets + # overridden by the one in $HOME which gets overriden by the one in the current + # directory default_config_files = [ - '/etc/' + default_config_file, - '/etc/' + default_config_file.replace('.conf', '') + '/' + default_config_file, - expanduser('~') + '/' + default_config_file, - default_config_file + "/etc/" + default_config_file, + "/etc/" + default_config_file.replace(".conf", "") + "/" + default_config_file, + Path("~").expanduser() / default_config_file, + default_config_file, ] parser = ArgumentParser( - default_config_files=default_config_files, - description='Kanboard Tasks from Email.') + default_config_files=default_config_files, + description="Kanboard Tasks from Email.", + ) args = get_arguments(parser) - imap_connection = imap_connect(args.IMAPS_SERVER, args.IMAPS_USERNAME, args.IMAPS_PASSWORD) + imap_connection = imap_connect( + args.IMAPS_SERVER, args.IMAPS_USERNAME, args.IMAPS_PASSWORD + ) typ, data = imap_search_unseen(imap_connection) for num in data[0].split(): """ for each unread mail do """ - typ, data = imap_connection.fetch(num, '(RFC822)') + typ, data = imap_connection.fetch(num, "(RFC822)") raw_email = data[0][1] email_message = email.message_from_bytes(raw_email) - local_task_start_date_ISO8601 = convert_to_kb_date(email_message['Date']) - local_task_due_date_ISO8601 = convert_to_kb_date(email_message['Date'], - args.KANBOARD_TASK_DUE_OFFSET_IN_HOURS) - email_from = email_message['From'] + local_task_start_date_iso8601 = convert_to_kb_date(email_message["Date"]) + local_task_due_date_iso8601 = convert_to_kb_date( + email_message["Date"], args.KANBOARD_TASK_DUE_OFFSET_IN_HOURS + ) + email_from = email_message["From"] """ extract email address if specified as 'name ' """ - email_address=re.sub('[<>]', '', re.findall('\S+@\S+', email_from)[-1]) - email_to = email_message['To'] - subject = email.header.make_header(email.header.decode_header(email_message['Subject'])) + email_address = re.sub("[<>]", "", re.findall("\S+@\S+", email_from)[-1]) + email_to = email_message["To"] + subject = email.header.make_header( + email.header.decode_header(email_message["Subject"]) + ) body, kb_attachments = walk_message_parts(email_message) - email_address, local_task_start_date_ISO8601, local_task_due_date_ISO8601 = handle_well_known_forwarders(args.WELL_KNOWN_EMAIL_ADDRESSES, args.KANBOARD_TASK_DUE_OFFSET_IN_HOURS, body, email_address, local_task_start_date_ISO8601, local_task_due_date_ISO8601) + email_address, local_task_start_date_iso8601, local_task_due_date_iso8601 = ( + handle_well_known_forwarders( + args.WELL_KNOWN_EMAIL_ADDRESSES, + args.KANBOARD_TASK_DUE_OFFSET_IN_HOURS, + body, + email_address, + local_task_start_date_iso8601, + local_task_due_date_iso8601, + ) + ) + + kb_text = dedent(f""" + From: {email_from} + + To: {email_to} + + Date: {local_task_start_date_iso8601} + + Subject: {subject} - kb_text = 'From: %s\n\nTo: %s\n\nDate: %s\n\nSubject: %s\n\n%s' % (email_from, - email_to, - local_task_start_date_ISO8601, - subject, - body) + {body}""").lstrip() """ connect to kanboard api """ - kb = kanboard.Client(args.KANBOARD_CONNECT_URL+'/jsonrpc.php', 'jsonrpc', args.KANBOARD_API_TOKEN) + kb = kanboard.Client( + args.KANBOARD_CONNECT_URL + "/jsonrpc.php", + "jsonrpc", + args.KANBOARD_API_TOKEN, + ) kb_user_id = create_user_for_sender(kb, email_address) """ add user to group """ - if args.KANBOARD_GROUP_ID > 0: # pragma: no cover - will get tested once config is refactored + if ( + args.KANBOARD_GROUP_ID > 0 + ): # pragma: no cover - will get tested once config is refactored kb.add_group_member(group_id=args.KANBOARD_GROUP_ID, user_id=kb_user_id) """ get id from project specified """ - kb_project_id = kb.get_project_by_name(name=str(args.KANBOARD_PROJECT_NAME))['id'] + kb_project_id = kb.get_project_by_name(name=str(args.KANBOARD_PROJECT_NAME))[ + "id" + ] kb_task_id, kb_task = get_task_if_subject_matches(kb, subject) if kb_task: - reopen_and_update(kb, kb_task, kb_task_id, kb_user_id, kb_text, local_task_due_date_ISO8601) + reopen_and_update( + kb, + kb_task, + kb_task_id, + kb_user_id, + kb_text, + local_task_due_date_iso8601, + ) else: """ create task in project specified """ - kb_task_id = kb.create_task(project_id=str(kb_project_id), - title=str(subject), - creator_id=kb_user_id, - date_started=local_task_start_date_ISO8601, - date_due=local_task_due_date_ISO8601, - description=kb_text) - - """ add the email as an attachment to the task in case it's not properly displayed - in the description or comment """ - if kb_task_id != False: - kb_attachments['%s.mbox' % re.sub('[^\w_.)( -]', '_', str(subject))] = base64.b64encode(raw_email) + kb_task_id = kb.create_task( + project_id=str(kb_project_id), + title=str(subject), + creator_id=kb_user_id, + date_started=local_task_start_date_iso8601, + date_due=local_task_due_date_iso8601, + description=kb_text, + ) + + """ add the email as an attachment to the task in case it's not properly + displayed in the description or comment """ + if kb_task_id: + kb_attachments["%s.mbox" % re.sub("[^\w_.)( -]", "_", str(subject))] = ( + base64.b64encode(raw_email) + ) for i in kb_attachments: - kb.create_task_file(project_id=str(kb_project_id), - task_id=str(kb_task_id), - filename=i, - blob=kb_attachments[i].decode('utf-8')) + kb.create_task_file( + project_id=str(kb_project_id), + task_id=str(kb_task_id), + filename=i, + blob=kb_attachments[i].decode("utf-8"), + ) imap_close(imap_connection) -if __name__ == "__main__": # pragma: no cover +if __name__ == "__main__": # pragma: no cover main() diff --git a/conftest.py b/tests/conftest.py similarity index 52% rename from conftest.py rename to tests/conftest.py index a2ad354..071dce0 100644 --- a/conftest.py +++ b/tests/conftest.py @@ -1,24 +1,26 @@ -"""Contains global fixtures for pytest""" +"""Contains global fixtures for pytest.""" + +from email.message import EmailMessage from os import environ -from unittest.mock import Mock, MagicMock +from unittest.mock import MagicMock, Mock -import pytest import kanboard -from email.message import EmailMessage +import pytest -environ['IMAPS_SERVER'] = 'imap.example.org' -environ['IMAPS_USERNAME'] = 'imaps-user' -environ['IMAPS_PASSWORD'] = 'imaps-pass' +environ["IMAPS_SERVER"] = "imap.example.org" +environ["IMAPS_USERNAME"] = "imaps-user" +environ["IMAPS_PASSWORD"] = "imaps-pass" -environ['KANBOARD_CONNECT_URL'] = 'https://kanboard.example.org' -environ['KANBOARD_API_TOKEN'] = 'l33tT0k3n' +environ["KANBOARD_CONNECT_URL"] = "https://kanboard.example.org" +environ["KANBOARD_API_TOKEN"] = "l33tT0k3n" -@pytest.fixture -def kb(): - """ - A mock of the kanboard client. - This mock is used by various tests and can be extended with more methods should they be needed. +@pytest.fixture() +def kb() -> kanboard.Client: + """Mock the kanboard client. + + This mock is used by various tests and can be extended with more methods should they + be needed. """ kb = Mock(kanboard.Client) kb.add_group_member = Mock() @@ -33,11 +35,10 @@ def kb(): kb.update_task = Mock() return kb -@pytest.fixture -def email_message(): - """ - Mock an email.message.EmailMessage object - """ + +@pytest.fixture() +def email_message() -> EmailMessage: + """Mock an email.message.EmailMessage object.""" mail = MagicMock(EmailMessage) mail.walk.return_value = [] return mail diff --git a/src/test_convert_to_kb_date.py b/tests/test_convert_to_kb_date.py similarity index 89% rename from src/test_convert_to_kb_date.py rename to tests/test_convert_to_kb_date.py index 49bde18..b1f6dc1 100644 --- a/src/test_convert_to_kb_date.py +++ b/tests/test_convert_to_kb_date.py @@ -1,12 +1,12 @@ import pytest from freezegun import freeze_time -from tasks_from_email import convert_to_kb_date +from src.tasks_from_email import convert_to_kb_date class TestConvertToKbDate: @pytest.mark.parametrize( - "date_str,expected", + ("date_str", "expected"), [ ("Mon, 20 Nov 1995 19:12:08 -0500", "20.11.1995 19:12"), ("20 Nov 1995 7:12 PM", "20.11.1995 19:12"), @@ -19,7 +19,7 @@ def test_date_str_calls(self, date_str, expected): assert result == expected @pytest.mark.parametrize( - "increment,expected", + ("increment", "expected"), [ (0, "20.11.1995 19:12"), (1, "20.11.1995 20:12"), diff --git a/src/test_create_user_for_sender.py b/tests/test_create_user_for_sender.py similarity index 82% rename from src/test_create_user_for_sender.py rename to tests/test_create_user_for_sender.py index 2870e8c..73225d5 100644 --- a/src/test_create_user_for_sender.py +++ b/tests/test_create_user_for_sender.py @@ -1,6 +1,4 @@ -import pytest - -from tasks_from_email import create_user_for_sender +from src.tasks_from_email import create_user_for_sender class TestCreateUserForSender: @@ -9,7 +7,7 @@ class TestCreateUserForSender: def test_call_with_no_users(self, kb): kb.get_all_users.return_value = [] - result = create_user_for_sender(kb, self._EMAIL) + create_user_for_sender(kb, self._EMAIL) kb.create_user.assert_called_with( username=self._EMAIL, password=self._EMAIL, email=self._EMAIL ) @@ -21,7 +19,7 @@ def test_call_with_no_matching_users(self, kb): kb.create_user.return_value = 956 result = create_user_for_sender(kb, self._EMAIL) - assert result == 956 + assert result == 956 # noqa: PLR2004 kb.create_user.assert_called_with( username=self._EMAIL, password=self._EMAIL, email=self._EMAIL ) @@ -32,5 +30,5 @@ def test_call_with_matching_user(self, kb): ] result = create_user_for_sender(kb, self._EMAIL) - assert result == 956 + assert result == 956 # noqa: PLR2004 assert not kb.create_user.called diff --git a/src/test_get_task_if_subject_matches.py b/tests/test_get_task_if_subject_matches.py similarity index 88% rename from src/test_get_task_if_subject_matches.py rename to tests/test_get_task_if_subject_matches.py index c63c1ea..a8a04d7 100644 --- a/src/test_get_task_if_subject_matches.py +++ b/tests/test_get_task_if_subject_matches.py @@ -1,6 +1,6 @@ import pytest -from tasks_from_email import get_task_if_subject_matches +from src.tasks_from_email import get_task_if_subject_matches class TestGetTaskIfSubjectMatches: @@ -9,7 +9,7 @@ def test_call_with_unknown_subject(self, kb): assert result == (False, None) @pytest.mark.parametrize( - "subject,expected", + ("subject", "expected"), [ ("[KB#956]", ("956", {})), ("[KB#956] as prefix", ("956", {})), @@ -17,7 +17,7 @@ def test_call_with_unknown_subject(self, kb): ("as [KB#956] affix", ("956", {})), ("[KB#12345678901234567890] ridiculous", ("12345678901234567890", {})), ("[KB#000] ridiculous", ("000", {})), - ], + ], ) def test_call_with_known_subjects(self, kb, subject, expected): kb.get_task.return_value = expected[1] diff --git a/src/test_handle_well_known_forwarders.py b/tests/test_handle_well_known_forwarders.py similarity index 82% rename from src/test_handle_well_known_forwarders.py rename to tests/test_handle_well_known_forwarders.py index 5426303..b531216 100644 --- a/src/test_handle_well_known_forwarders.py +++ b/tests/test_handle_well_known_forwarders.py @@ -1,7 +1,7 @@ import pytest from freezegun import freeze_time -from tasks_from_email import handle_well_known_forwarders +from src.tasks_from_email import handle_well_known_forwarders _EMAIL_FROM_FORWARDER = """ From: Forwarder @@ -25,7 +25,11 @@ class TestHandleWellKnownForwarders: def test_call_no_change(self): - (email_address, start_date, due_date,) = handle_well_known_forwarders( + ( + email_address, + start_date, + due_date, + ) = handle_well_known_forwarders( [], 0, "", "origin@example.org", "21.10.1995 21:17", "21.10.1995 21:17" ) @@ -34,7 +38,7 @@ def test_call_no_change(self): assert due_date == "21.10.1995 21:17" @pytest.mark.parametrize( - "body,email,expected", + ("body", "email", "expected"), [ (_EMAIL_TO_FORWARDER, "user@example.org", "staff@example.org"), (_EMAIL_TO_FORWARDER, "anyone@example.org", "staff@example.org"), @@ -50,13 +54,17 @@ def test_call_no_change(self): def test_email_forward_from_body(self, body, email, expected): well_known = ["forwarder@example.org"] - (email_address, start_date, due_date,) = handle_well_known_forwarders( + ( + email_address, + start_date, + due_date, + ) = handle_well_known_forwarders( well_known, 0, body, email, "21.10.1995 21:17", "21.10.1995 21:17" ) assert email_address == expected @pytest.mark.parametrize( - "offset,start_expected,due_expected", + ("offset", "start_expected", "due_expected"), [ (0, "21.11.1995 19:12", "21.11.1995 19:12"), (1, "21.11.1995 19:12", "21.11.1995 20:12"), @@ -68,7 +76,11 @@ def test_date_from_forward(self, offset, start_expected, due_expected): email = "staff@example.org" well_known = ["forwarder@example.org"] - (email_address, start_date, due_date,) = handle_well_known_forwarders( + ( + email_address, + start_date, + due_date, + ) = handle_well_known_forwarders( well_known, offset, _EMAIL_TO_FORWARDER, diff --git a/src/test_imap.py b/tests/test_imap.py similarity index 52% rename from src/test_imap.py rename to tests/test_imap.py index ab6196b..c2a5c68 100644 --- a/src/test_imap.py +++ b/tests/test_imap.py @@ -1,20 +1,19 @@ -import pytest +import imaplib from unittest.mock import Mock -import imaplib +from src.tasks_from_email import imap_close, imap_connect, imap_search_unseen -from tasks_from_email import imap_connect, imap_close, imap_search_unseen class TestImapFunctions: def test_imap_connect(self, mocker): - mocker.patch('imaplib.IMAP4_SSL') + mocker.patch("imaplib.IMAP4_SSL") connection = Mock() imaplib.IMAP4_SSL.return_value = connection - imap_connection = imap_connect('servername', 'username', 'password') + imap_connection = imap_connect("servername", "username", "password") - imaplib.IMAP4_SSL.assert_called_once_with('servername') + imaplib.IMAP4_SSL.assert_called_once_with("servername") assert imap_connection == connection def test_imap_close(self): @@ -27,11 +26,11 @@ def test_imap_close(self): def test_image_search_unseen(self): imap_connection = Mock() - imap_connection.search.return_value = ('typ', 'data') + imap_connection.search.return_value = ("typ", "data") typ, data = imap_search_unseen(imap_connection) - imap_connection.select.assert_called_once_with('INBOX') - imap_connection.search.assert_called_once_with(None, 'unseen') - assert typ == 'typ' - assert data == 'data' + imap_connection.select.assert_called_once_with("INBOX") + imap_connection.search.assert_called_once_with(None, "unseen") + assert typ == "typ" + assert data == "data" diff --git a/src/test_main.py b/tests/test_main.py similarity index 52% rename from src/test_main.py rename to tests/test_main.py index 1acaf6a..359ae92 100644 --- a/src/test_main.py +++ b/tests/test_main.py @@ -1,43 +1,43 @@ -import pytest +import email from unittest.mock import Mock, call -import email import kanboard +import pytest -import tasks_from_email +import src class TestMain: def test_call_no_results(self, mocker): - mocker.patch("tasks_from_email.imap_connect") - mocker.patch("tasks_from_email.imap_search_unseen") - mocker.patch("tasks_from_email.imap_close") + mocker.patch("src.tasks_from_email.imap_connect") + mocker.patch("src.tasks_from_email.imap_search_unseen") + mocker.patch("src.tasks_from_email.imap_close") - tasks_from_email.imap_search_unseen.return_value = ("typ", [""]) + src.tasks_from_email.imap_search_unseen.return_value = ("typ", [""]) - tasks_from_email.main() + src.tasks_from_email.main() - tasks_from_email.imap_connect.assert_called_once() - tasks_from_email.imap_search_unseen.assert_called_once() - tasks_from_email.imap_close.assert_called_once() + src.tasks_from_email.imap_connect.assert_called_once() + src.tasks_from_email.imap_search_unseen.assert_called_once() + src.tasks_from_email.imap_close.assert_called_once() @pytest.mark.parametrize("create", [(True), (False)]) def test_call_with_mocks(self, mocker, kb, email_message, create): - mocker.patch("tasks_from_email.imap_connect") - mocker.patch("tasks_from_email.imap_search_unseen") - mocker.patch("tasks_from_email.imap_close") - mocker.patch("tasks_from_email.convert_to_kb_date") - mocker.patch("tasks_from_email.create_user_for_sender") - mocker.patch("tasks_from_email.get_task_if_subject_matches") - mocker.patch("tasks_from_email.reopen_and_update") + mocker.patch("src.tasks_from_email.imap_connect") + mocker.patch("src.tasks_from_email.imap_search_unseen") + mocker.patch("src.tasks_from_email.imap_close") + mocker.patch("src.tasks_from_email.convert_to_kb_date") + mocker.patch("src.tasks_from_email.create_user_for_sender") + mocker.patch("src.tasks_from_email.get_task_if_subject_matches") + mocker.patch("src.tasks_from_email.reopen_and_update") mocker.patch("email.message_from_bytes") mocker.patch("email.header.make_header") mocker.patch("kanboard.Client") imap_connection = Mock() imap_connection.fetch.return_value = ("typ", [(None, b"raw")]) - tasks_from_email.imap_connect.return_value = imap_connection - tasks_from_email.imap_search_unseen.return_value = ("typ", ["a"]) + src.tasks_from_email.imap_connect.return_value = imap_connection + src.tasks_from_email.imap_search_unseen.return_value = ("typ", ["a"]) email.message_from_bytes.return_value = email_message mock_data = { "Date": "rfcdate", @@ -50,28 +50,31 @@ def getitem(name): return mock_data[name] email_message.__getitem__.side_effect = getitem - tasks_from_email.convert_to_kb_date.return_value = "converted-date" - tasks_from_email.create_user_for_sender.return_value = 2 + src.tasks_from_email.convert_to_kb_date.return_value = "converted-date" + src.tasks_from_email.create_user_for_sender.return_value = 2 kanboard.Client.return_value = kb kb.get_project_by_name.return_value = {"id": 1} if create: - tasks_from_email.get_task_if_subject_matches.return_value = (None, None) + src.tasks_from_email.get_task_if_subject_matches.return_value = (None, None) else: - tasks_from_email.get_task_if_subject_matches.return_value = ( + src.tasks_from_email.get_task_if_subject_matches.return_value = ( 1, {"is_active": True}, ) email.header.make_header.return_value = "ExampleHeader" kb.create_task.return_value = 1 - tasks_from_email.main() + src.tasks_from_email.main() - tasks_from_email.imap_connect.assert_called_once() - tasks_from_email.imap_search_unseen.assert_called_once() + src.tasks_from_email.imap_connect.assert_called_once() + src.tasks_from_email.imap_search_unseen.assert_called_once() imap_connection.fetch.assert_called_once_with("a", "(RFC822)") email.message_from_bytes.assert_called_once_with(b"raw") - tasks_from_email.convert_to_kb_date.assert_has_calls( - [call("rfcdate"), call("rfcdate", 48)] + src.tasks_from_email.convert_to_kb_date.assert_has_calls( + [ + mocker.call("rfcdate"), + mocker.call("rfcdate", 48), + ] ) assert email_message.__getitem__.call_args_list == [ call("Date"), @@ -81,11 +84,15 @@ def getitem(name): call("Subject"), ] kanboard.Client.assert_called_once() - tasks_from_email.create_user_for_sender.called_once_with(kb, "from@example.org") + src.tasks_from_email.create_user_for_sender.called_once_with( + kb, + "from@example.org", + ) kb.add_group_member.assert_not_called() kb.get_project_by_name.assert_called_once_with(name="Support") - tasks_from_email.get_task_if_subject_matches.assert_called_once_with( - kb, "ExampleHeader" + src.tasks_from_email.get_task_if_subject_matches.assert_called_once_with( + kb, + "ExampleHeader", ) if create: kb.create_task.assert_called_once_with( @@ -94,12 +101,12 @@ def getitem(name): creator_id=2, date_started="converted-date", date_due="converted-date", - description="From: from@example.org\n\nTo: to@example.org\n\nDate: converted-date\n\nSubject: ExampleHeader\n\nNone", + description="From: from@example.org\n\nTo: to@example.org\n\nDate: converted-date\n\nSubject: ExampleHeader\n\nNone", # noqa: E501 ) - tasks_from_email.reopen_and_update.assert_not_called() + src.tasks_from_email.reopen_and_update.assert_not_called() else: kb.create_task.assert_not_called() - tasks_from_email.reopen_and_update.called_once_with( + src.tasks_from_email.reopen_and_update.called_once_with( kb, {"is_active": True}, 1, 2, "None", "converted-date" ) kb.create_task_file.assert_called_once_with( @@ -109,4 +116,4 @@ def getitem(name): blob="cmF3", # None ) - tasks_from_email.imap_close.assert_called_once() + src.tasks_from_email.imap_close.assert_called_once() diff --git a/src/test_reopen_and_update.py b/tests/test_reopen_and_update.py similarity index 80% rename from src/test_reopen_and_update.py rename to tests/test_reopen_and_update.py index ba0cace..04f9d0c 100644 --- a/src/test_reopen_and_update.py +++ b/tests/test_reopen_and_update.py @@ -1,11 +1,15 @@ import pytest -from tasks_from_email import reopen_and_update +from src.tasks_from_email import reopen_and_update class TestReopenAndUpdate: @pytest.mark.parametrize( - "kb_task,open_called", [({"is_active": 1}, False), ({"is_active": 0}, True),] + ("kb_task", "open_called"), + [ + ({"is_active": 1}, False), + ({"is_active": 0}, True), + ], ) def test_call_open_task(self, kb, kb_task, open_called): reopen_and_update(kb, kb_task, 956, None, None, None) diff --git a/src/test_walk_message_parts.py b/tests/test_walk_message_parts.py similarity index 53% rename from src/test_walk_message_parts.py rename to tests/test_walk_message_parts.py index 2c37f7c..d5cce49 100644 --- a/src/test_walk_message_parts.py +++ b/tests/test_walk_message_parts.py @@ -1,8 +1,6 @@ -import pytest - from email import header -from tasks_from_email import walk_message_parts +from src.tasks_from_email import walk_message_parts class TestWalkMessageParts: @@ -10,59 +8,56 @@ def test_empty_message(self, email_message): body, attachments = walk_message_parts(email_message) email_message.walk.assert_called_once() - assert body == None + assert body is None assert attachments == {} def test_call_with_multipart_maintype(self, email_message): email_message.walk.return_value = [email_message] - email_message.get_content_maintype.return_value = 'multipart' + email_message.get_content_maintype.return_value = "multipart" body, _ = walk_message_parts(email_message) email_message.walk.assert_called_once() email_message.get_content_maintype.assert_called_once() - assert body == None + assert body is None def test_call_with_text_plain(self, email_message): email_message.walk.return_value = [email_message] - email_message.get_content_maintype.return_value = '' + email_message.get_content_maintype.return_value = "" email_message.get.return_value = None - email_message.get_content_type.return_value = 'text/plain' - email_message.get_payload.return_value = b'example-body' + email_message.get_content_type.return_value = "text/plain" + email_message.get_payload.return_value = b"example-body" body, _ = walk_message_parts(email_message) email_message.walk.assert_called_once() email_message.get_content_maintype.assert_called_once() - email_message.get.assert_called_once_with('Content-Disposition') + email_message.get.assert_called_once_with("Content-Disposition") email_message.get_content_type.assert_called_once() email_message.get_payload.assert_called_once_with(decode=True) - assert body == 'example-body' + assert body == "example-body" def test_call_multipart_email(self, mocker, email_message): - mocker.patch('email.header.decode_header') - mocker.patch('email.header.make_header') + mocker.patch("email.header.decode_header") + mocker.patch("email.header.make_header") - main_part = email_message email_message.walk.return_value = [email_message] - email_message.get_content_maintype.return_value = '' - email_message.get.return_value = 'not None' + email_message.get_content_maintype.return_value = "" + email_message.get.return_value = "not None" + + email_message.get_filename.return_value = b"filename.txt" + email_message.get_payload.return_value = b"example-body" - email_message.get_filename.return_value = b'filename.txt' - email_message.get_payload.return_value = b'example-body' + header.decode_header.return_value = "decoded-header-filename" - header.decode_header.return_value = 'decoded-header-filename' - - header.make_header.return_value = 'real-filename.txt' + header.make_header.return_value = "real-filename.txt" body, attachments = walk_message_parts(email_message) email_message.walk.assert_called_once() email_message.get_content_maintype.assert_called_once() - email_message.get.assert_called_once_with('Content-Disposition') + email_message.get.assert_called_once_with("Content-Disposition") email_message.get_filename.assert_called_once() email_message.get_payload.assert_called_once_with(decode=True) - assert body == 'None\n\n<< Attachment: real-filename.txt >>' - assert attachments == { - 'real-filename.txt': b'ZXhhbXBsZS1ib2R5' - } + assert body == "None\n\n<< Attachment: real-filename.txt >>" + assert attachments == {"real-filename.txt": b"ZXhhbXBsZS1ib2R5"}