From 48951cb0e3142fffa2b97119c9dcfdf0796f4dd0 Mon Sep 17 00:00:00 2001 From: Illya Laifu Date: Fri, 30 Aug 2024 21:10:08 +0300 Subject: [PATCH] feat: initial commit --- .github/workflows/CI.yml | 138 +++++++++++++ .gitignore | 180 +++++++++++++++++ Cargo.toml | 19 ++ README.md | 10 + mypy.ini | 8 + poetry.lock | 219 +++++++++++++++++++++ pyproject.toml | 30 +++ src/dependency_map.rs | 20 ++ src/lib.rs | 29 +++ src/operation.rs | 59 ++++++ src/replica.rs | 148 ++++++++++++++ src/storage.rs | 38 ++++ src/task/annotation.rs | 36 ++++ src/task/data.rs | 46 +++++ src/task/mod.rs | 11 ++ src/task/status.rs | 37 ++++ src/task/tag.rs | 28 +++ src/task/task.rs | 394 ++++++++++++++++++++++++++++++++++++++ src/working_set.rs | 36 ++++ taskchampion.pyi | 136 +++++++++++++ tests/__init__.py | 0 tests/test_annotation.py | 18 ++ tests/test_operation.py | 5 + tests/test_replica.py | 141 ++++++++++++++ tests/test_tag.py | 22 +++ tests/test_task.py | 131 +++++++++++++ tests/test_working_set.py | 52 +++++ 27 files changed, 1991 insertions(+) create mode 100644 .github/workflows/CI.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 mypy.ini create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 src/dependency_map.rs create mode 100644 src/lib.rs create mode 100644 src/operation.rs create mode 100644 src/replica.rs create mode 100644 src/storage.rs create mode 100644 src/task/annotation.rs create mode 100644 src/task/data.rs create mode 100644 src/task/mod.rs create mode 100644 src/task/status.rs create mode 100644 src/task/tag.rs create mode 100644 src/task/task.rs create mode 100644 src/working_set.rs create mode 100644 taskchampion.pyi create mode 100644 tests/__init__.py create mode 100644 tests/test_annotation.py create mode 100644 tests/test_operation.py create mode 100644 tests/test_replica.py create mode 100644 tests/test_tag.py create mode 100644 tests/test_task.py create mode 100644 tests/test_working_set.py diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..f884711 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,138 @@ +# This file is autogenerated by maturin v1.5.1 +# To update, run +# +# maturin generate-ci github +# +name: CI + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + - runner: ubuntu-latest + target: s390x + - runner: ubuntu-latest + target: ppc64le + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-latest + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00a1af8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,180 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,direnv +# Edit at https://www.toptal.com/developers/gitignore?templates=python,direnv + +### direnv ### +.direnv +.envrc + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python,direnv diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ab7b819 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "taskchampion_python" +version = "0.1.0" +edition = "2021" + +[package.metadata.maturin] +name = "taskchampion" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "taskchampion" +crate-type = ["cdylib"] +doc = false +[dependencies] +chrono.workspace = true +pyo3 = { version = "0.22.0", features = ["anyhow"] } + +taskchampion = { path = "../taskchampion", version = "0.7.0" } +anyhow = "*" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0589e6 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# Python Taskchampion Bindings + +This submodule contains bindings to the Taskchampion + +# TODO + +- There is no good way to describe functions that accept interface (e.g. `Replica::new` accepts any of the storage implementations, but Python bindings lack such mechanisms), currently, `Replica::new` just constructs the SqliteStorage from the params passed into the constructor. +- Currently Task class is just a reflection of the rust's `Task` struct, but constructing the standalone `TaskMut` is impossible, as Pyo3 bindings do not allow lifetimes (python has no alternatives to them). Would be nice to expand the `Task` class to include the methods from `TaskMut` and convert into the mutable state and back when they are called. +- It is possible to convert `WorkingSet` into a python iterator (you can iterate over it via `for item in :` or `next()`), but that needs a way to store the current state. + diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..1d270f3 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,8 @@ +[mypy] +warn_return_any = True +warn_unused_configs = True +no_implicit_optional = True +check_untyped_defs = True +warn_unused_ignores = True +show_error_codes = True +disable_error_code = assignment diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..e27a25d --- /dev/null +++ b/poetry.lock @@ -0,0 +1,219 @@ +# This file is automatically @generated by Poetry 1.8.3 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 = "exceptiongroup" +version = "1.2.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[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 = "maturin" +version = "1.5.1" +description = "Build and publish crates with pyo3, rust-cpython and cffi bindings as well as rust binaries as python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "maturin-1.5.1-py3-none-linux_armv6l.whl", hash = "sha256:589e9b7024007e130b136ba6f1c2c8393a87e42cf968d12852913ab1e3c69ed3"}, + {file = "maturin-1.5.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a1abda07093b3c8ef897626166c02ed64e3e446c48460b28efb51833abf89cbb"}, + {file = "maturin-1.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:48a1fbbdc2514525f27d6d339ab97b098ede28759f8593d110c89cc07bbe40ed"}, + {file = "maturin-1.5.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:96d96b1fa3a165db9ca539f764d31da8ebc92e31ca3a1dd6ccd50008d222bd96"}, + {file = "maturin-1.5.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:786bf36a98c4e27cbebb1dc8e432c1bcbbb59e7a9719592cbb89e46a0ccd5bcc"}, + {file = "maturin-1.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d821b37da759884ad09cfee4cd9deac10f4132744cc66e4d9190a1972233bc83"}, + {file = "maturin-1.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:62133bf690555bbc8cc6b1c18a0c57b0ab2b4d68d3fcd320eb16df941563fe06"}, + {file = "maturin-1.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:6bff165252b1fcc887679ddf7b71b5cc024327ba96ea893133be38c0ed38f163"}, + {file = "maturin-1.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c42a95466ffc3de0a3940cd20c57cf0c44fe5ea679375d73422afbb00236c64"}, + {file = "maturin-1.5.1-py3-none-win32.whl", hash = "sha256:d09538b4aa0da4b59fd47cb429003b45bfd5d801714adf1db2511bf8bdea532f"}, + {file = "maturin-1.5.1-py3-none-win_amd64.whl", hash = "sha256:a3db9054222ac79275e082b21cfd234b8e036714a4ff227a0a28f6a3ffa3744d"}, + {file = "maturin-1.5.1-py3-none-win_arm64.whl", hash = "sha256:acf528e51413f6ae489473d64116d8c83f140386349004949d29137c16a82193"}, + {file = "maturin-1.5.1.tar.gz", hash = "sha256:3dd834ece80edb866af18cbd4635e0ecac40139c726428d5f1849ae154b26dca"}, +] + +[package.dependencies] +patchelf = {version = "*", optional = true, markers = "extra == \"patchelf\""} +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[package.extras] +patchelf = ["patchelf"] +zig = ["ziglang (>=0.10.0,<0.11.0)"] + +[[package]] +name = "mypy" +version = "1.10.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, + {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, + {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, + {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, + {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, + {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, + {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, + {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, + {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, + {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, + {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, + {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, + {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, + {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, + {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, + {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, + {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, + {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, + {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +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 = "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 = "patchelf" +version = "0.17.2.1" +description = "A small utility to modify the dynamic linker and RPATH of ELF executables." +optional = false +python-versions = "*" +files = [ + {file = "patchelf-0.17.2.1-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:fc329da0e8f628bd836dfb8eaf523547e342351fa8f739bf2b3fe4a6db5a297c"}, + {file = "patchelf-0.17.2.1-py2.py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:ccb266a94edf016efe80151172c26cff8c2ec120a57a1665d257b0442784195d"}, + {file = "patchelf-0.17.2.1-py2.py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:f47b5bdd6885cfb20abdd14c707d26eb6f499a7f52e911865548d4aa43385502"}, + {file = "patchelf-0.17.2.1-py2.py3-none-manylinux_2_17_s390x.manylinux2014_s390x.musllinux_1_1_s390x.whl", hash = "sha256:a9e6ebb0874a11f7ed56d2380bfaa95f00612b23b15f896583da30c2059fcfa8"}, + {file = "patchelf-0.17.2.1-py2.py3-none-manylinux_2_5_i686.manylinux1_i686.musllinux_1_1_i686.whl", hash = "sha256:3c8d58f0e4c1929b1c7c45ba8da5a84a8f1aa6a82a46e1cfb2e44a4d40f350e5"}, + {file = "patchelf-0.17.2.1-py2.py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:d1a9bc0d4fd80c038523ebdc451a1cce75237cfcc52dbd1aca224578001d5927"}, + {file = "patchelf-0.17.2.1.tar.gz", hash = "sha256:a6eb0dd452ce4127d0d5e1eb26515e39186fa609364274bc1b0b77539cfa7031"}, +] + +[package.extras] +test = ["importlib-metadata", "pytest"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.2.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"}, + {file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +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 = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.8" +content-hash = "efe06dc2a8ee89e602f4a79386f48534d2c6f624288360a7e8dd558ff296f6f2" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a9cdf45 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["maturin>=1.5,<2.0"] +build-backend = "maturin" + +[project] +name = "taskchampion" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] + +[tool.poetry] +name = "taskchampion" +version = "0.1.0" +authors = ["illyalaifu "] +description = "" + +[tool.poetry.dependencies] +python = ">=3.8" +maturin = {extras = ["patchelf"], version = "^1.5.1"} + +[tool.poetry.group.test.dependencies] +pytest = "^8.2.0" +mypy = "^1.10.0" diff --git a/src/dependency_map.rs b/src/dependency_map.rs new file mode 100644 index 0000000..07d8ca0 --- /dev/null +++ b/src/dependency_map.rs @@ -0,0 +1,20 @@ +use pyo3::prelude::*; +use taskchampion::{DependencyMap as TCDependencyMap, Uuid}; + +#[pyclass] +pub struct DependencyMap(pub(crate) TCDependencyMap); + +#[pymethods] +impl DependencyMap { + // TODO: possibly optimize this later, if possible + pub fn dependencies(&self, dep_of: String) -> Vec { + let uuid = Uuid::parse_str(&dep_of).unwrap(); + self.0.dependencies(uuid).map(|uuid| uuid.into()).collect() + } + + pub fn dependents(&self, dep_on: String) -> Vec { + let uuid = Uuid::parse_str(&dep_on).unwrap(); + + self.0.dependents(uuid).map(|uuid| uuid.into()).collect() + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9e8c176 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,29 @@ +use pyo3::prelude::*; +pub mod replica; +use replica::*; +pub mod working_set; +use working_set::*; +pub mod storage; +use storage::*; +pub mod dependency_map; +use dependency_map::*; +pub mod operation; +use operation::*; +mod task; +use task::{Annotation, Status, Tag, Task}; + +#[pymodule] +fn taskchampion(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + Ok(()) +} diff --git a/src/operation.rs b/src/operation.rs new file mode 100644 index 0000000..ea08401 --- /dev/null +++ b/src/operation.rs @@ -0,0 +1,59 @@ +use chrono::DateTime; +use pyo3::prelude::*; + +use std::collections::HashMap; +use taskchampion::{Operation as TCOperation, Uuid}; + +#[pyclass] +#[derive(PartialEq, Eq, Clone, Debug)] +pub struct Operation(pub(crate) TCOperation); + +#[pymethods] +impl Operation { + #[allow(non_snake_case)] + #[staticmethod] + pub fn Create(uuid: String) -> anyhow::Result { + Ok(Operation(TCOperation::Create { + uuid: Uuid::parse_str(&uuid)?, + })) + } + + #[allow(non_snake_case)] + #[staticmethod] + pub fn Delete(uuid: String, old_task: HashMap) -> anyhow::Result { + Ok(Operation(TCOperation::Delete { + uuid: Uuid::parse_str(&uuid)?, + old_task, + })) + } + + #[allow(non_snake_case)] + #[staticmethod] + #[pyo3(signature = (uuid, property, timestamp, old_value=None, value=None))] + pub fn Update( + uuid: String, + property: String, + timestamp: String, + old_value: Option, + value: Option, + ) -> anyhow::Result { + Ok(Operation(TCOperation::Update { + uuid: Uuid::parse_str(&uuid)?, + property, + old_value, + value, + timestamp: DateTime::parse_from_rfc3339(×tamp).unwrap().into(), + })) + } + + #[allow(non_snake_case)] + #[staticmethod] + pub fn UndoPoint() -> Operation { + Operation(TCOperation::UndoPoint) + } + pub fn is_undo_point(&self) -> bool { + self.0.is_undo_point() + } +} + +pub type Operations = Vec; diff --git a/src/replica.rs b/src/replica.rs new file mode 100644 index 0000000..83fbf74 --- /dev/null +++ b/src/replica.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; +use std::rc::Rc; + +use crate::task::TaskData; +use crate::{DependencyMap, Operation, Task, WorkingSet}; +use pyo3::prelude::*; +use taskchampion::storage::{InMemoryStorage, SqliteStorage}; +use taskchampion::{Operations as TCOperations, Replica as TCReplica, Uuid}; + +#[pyclass] +/// A replica represents an instance of a user's task data, providing an easy interface +/// for querying and modifying that data. +pub struct Replica(TCReplica); + +unsafe impl Send for Replica {} +#[pymethods] +impl Replica { + #[new] + /// Instantiates the Replica + /// + /// Args: + /// path (str): path to the directory with the database + /// create_if_missing (bool): create the database if it does not exist + /// Raises: + /// RuntimeError: if database does not exist, and create_if_missing is false + pub fn new(path: String, create_if_missing: bool) -> anyhow::Result { + let storage = SqliteStorage::new(path, create_if_missing)?; + + Ok(Replica(TCReplica::new(Box::new(storage)))) + } + + #[staticmethod] + pub fn new_inmemory() -> Self { + let storage = InMemoryStorage::new(); + + Replica(TCReplica::new(Box::new(storage))) + } + /// Create a new task + /// The task must not already exist. + + pub fn create_task(&mut self, uuid: String) -> anyhow::Result<(Task, Operation)> { + let mut ops = TCOperations::new(); + let task = self + .0 + .create_task(Uuid::parse_str(&uuid)?, &mut ops) + .map(|t| Task(t))?; + Ok(( + task, + Operation(ops.get(0).expect("Missing Operation").clone()), + )) + } + + /// Get a list of all tasks in the replica. + pub fn all_tasks(&mut self) -> anyhow::Result> { + Ok(self + .0 + .all_tasks()? + .into_iter() + .map(|(key, value)| (key.to_string(), Task(value))) + .collect()) + } + + pub fn all_task_data(&mut self) -> anyhow::Result> { + Ok(self + .0 + .all_task_data()? + .into_iter() + .map(|(key, value)| (key.to_string(), TaskData(value))) + .collect()) + } + /// Get a list of all uuids for tasks in the replica. + pub fn all_task_uuids(&mut self) -> anyhow::Result> { + Ok(self + .0 + .all_task_uuids() + .map(|v| v.iter().map(|item| item.to_string()).collect())?) + } + + pub fn working_set(&mut self) -> anyhow::Result { + Ok(self.0.working_set().map(|ws| WorkingSet(ws))?) + } + + pub fn dependency_map(&mut self, force: bool) -> anyhow::Result { + // TODO: kinda spaghetti here, it will do for now + let s = self + .0 + .dependency_map(force) + .map(|rc| { + // TODO: better error handling here + Rc::into_inner(rc).unwrap() + }) + .map(|dm| DependencyMap(dm))?; + + Ok(s) + } + + pub fn get_task(&mut self, uuid: String) -> anyhow::Result> { + Ok(self + .0 + .get_task(Uuid::parse_str(&uuid).unwrap()) + .map(|opt| opt.map(|t| Task(t)))?) + } + + pub fn get_task_data(&mut self, uuid: String) -> anyhow::Result> { + Ok(self + .0 + .get_task_data(Uuid::parse_str(&uuid)?) + .map(|opt| opt.map(|td| TaskData(td)))?) + } + + pub fn sync(&self, _avoid_snapshots: bool) { + todo!() + } + pub fn commit_operations(&mut self, operations: Vec) -> anyhow::Result<()> { + let ops = operations.iter().map(|op| op.0.clone()).collect(); + Ok(self.0.commit_operations(ops)?) + } + pub fn rebuild_working_set(&mut self, renumber: bool) -> anyhow::Result<()> { + Ok(self.0.rebuild_working_set(renumber)?) + } + pub fn num_local_operations(&mut self) -> anyhow::Result { + Ok(self.0.num_local_operations()?) + } + + pub fn num_undo_points(&mut self) -> anyhow::Result { + Ok(self.0.num_local_operations()?) + } + + pub fn get_undo_operations(&mut self) -> anyhow::Result> { + Ok(self + .0 + .get_undo_operations() + .map(|ops| ops.iter().map(|op| Operation(op.clone())).collect())?) + } + + pub fn commit_reversed_operations( + &mut self, + operations: Vec, + ) -> anyhow::Result { + let ops = operations.iter().map(|op| op.0.clone()).collect(); + + Ok(self.0.commit_reversed_operations(ops)?) + } + + pub fn expire_tasks(&mut self) -> anyhow::Result<()> { + Ok(self.0.expire_tasks()?) + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..b7c1ae9 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,38 @@ +use pyo3::prelude::*; +use taskchampion::storage::{ + InMemoryStorage as TCInMemoryStorage, SqliteStorage as TCSqliteStorage, +}; +// TODO: actually make the storage usable and extensible, rn it just exists /shrug +pub trait Storage {} + +#[allow(dead_code)] +#[pyclass] +pub struct InMemoryStorage(TCInMemoryStorage); + +#[pymethods] +impl InMemoryStorage { + #[new] + pub fn new() -> InMemoryStorage { + InMemoryStorage(TCInMemoryStorage::new()) + } +} + +impl Storage for InMemoryStorage {} + +#[allow(dead_code)] +#[pyclass] +pub struct SqliteStorage(TCSqliteStorage); + +#[pymethods] +impl SqliteStorage { + #[new] + pub fn new(path: String, create_if_missing: bool) -> anyhow::Result { + // TODO: kinda ugly, prettify; + Ok(SqliteStorage(TCSqliteStorage::new( + path, + create_if_missing, + )?)) + } +} + +impl Storage for SqliteStorage {} diff --git a/src/task/annotation.rs b/src/task/annotation.rs new file mode 100644 index 0000000..13ff320 --- /dev/null +++ b/src/task/annotation.rs @@ -0,0 +1,36 @@ +use chrono::DateTime; +use pyo3::prelude::*; +use taskchampion::Annotation as TCAnnotation; +#[pyclass] +/// An annotation for the task +pub struct Annotation(pub(crate) TCAnnotation); + +#[pymethods] +impl Annotation { + #[new] + pub fn new() -> Self { + Annotation(TCAnnotation { + entry: DateTime::default(), + description: String::new(), + }) + } + #[getter] + pub fn entry(&self) -> String { + self.0.entry.to_rfc3339() + } + + #[setter] + pub fn set_entry(&mut self, time: String) { + self.0.entry = DateTime::parse_from_rfc3339(&time).unwrap().into() + } + + #[getter] + pub fn description(&self) -> String { + self.0.description.clone() + } + + #[setter] + pub fn set_description(&mut self, description: String) { + self.0.description = description + } +} diff --git a/src/task/data.rs b/src/task/data.rs new file mode 100644 index 0000000..5ea401e --- /dev/null +++ b/src/task/data.rs @@ -0,0 +1,46 @@ +use crate::Operation; +use pyo3::prelude::*; +use taskchampion::{Operation as TCOperation, TaskData as TCTaskData, Uuid}; + +#[pyclass] +pub struct TaskData(pub(crate) TCTaskData); + +#[pymethods] +impl TaskData { + #[staticmethod] + pub fn create(uuid: String) -> (Self, Operation) { + let u = Uuid::parse_str(&uuid).expect("invalid UUID"); + + let mut ops: Vec = vec![TCOperation::Create { uuid: u }]; + + let td = TaskData(TCTaskData::create(u, &mut ops)); + (td, Operation(ops.get(0).expect("").clone())) + } + + pub fn get_uuid(&self) -> String { + self.0.get_uuid().into() + } + + pub fn get(&self, value: String) -> Option { + self.0.get(value).map(|r| r.to_owned()) + } + + pub fn has(&self, value: String) -> bool { + self.0.has(value) + } + + #[pyo3(signature=(property, value=None))] + pub fn update(&mut self, property: String, value: Option) -> Operation { + let mut ops: Vec = Vec::new(); + + self.0.update(property, value, &mut ops); + ops.get(0).map(|op| Operation(op.clone())).expect("") + } + + pub fn delete(&mut self) -> Operation { + let mut ops: Vec = Vec::new(); + self.0.delete(&mut ops); + + ops.get(0).map(|op| Operation(op.clone())).expect("") + } +} diff --git a/src/task/mod.rs b/src/task/mod.rs new file mode 100644 index 0000000..f28e9ec --- /dev/null +++ b/src/task/mod.rs @@ -0,0 +1,11 @@ +mod annotation; +mod data; +mod status; +mod tag; +mod task; + +pub use annotation::Annotation; +pub use data::TaskData; +pub use status::Status; +pub use tag::Tag; +pub use task::Task; diff --git a/src/task/status.rs b/src/task/status.rs new file mode 100644 index 0000000..77dae47 --- /dev/null +++ b/src/task/status.rs @@ -0,0 +1,37 @@ +use pyo3::prelude::*; +pub use taskchampion::Status as TCStatus; + +#[pyclass(eq, eq_int)] +#[derive(Clone, Copy, PartialEq)] +pub enum Status { + Pending, + Completed, + Deleted, + Recurring, + /// IMPORTANT: #[pyclass] only supports unit variants + Unknown, +} + +impl From for Status { + fn from(status: TCStatus) -> Self { + return match status { + TCStatus::Pending => Status::Pending, + TCStatus::Completed => Status::Completed, + TCStatus::Deleted => Status::Deleted, + TCStatus::Recurring => Status::Recurring, + _ => Status::Unknown, + }; + } +} + +impl From for TCStatus { + fn from(status: Status) -> Self { + return match status { + Status::Pending => TCStatus::Pending, + Status::Completed => TCStatus::Completed, + Status::Deleted => TCStatus::Deleted, + Status::Recurring => TCStatus::Recurring, + Status::Unknown => TCStatus::Unknown("unknown status".to_string()), + }; + } +} diff --git a/src/task/tag.rs b/src/task/tag.rs new file mode 100644 index 0000000..7f3a03a --- /dev/null +++ b/src/task/tag.rs @@ -0,0 +1,28 @@ +use pyo3::prelude::*; +use taskchampion::Tag as TCTag; + +/// TODO: following the api there currently is no way to construct the task by hand, not sure if this is +/// correct +#[pyclass] +pub struct Tag(pub(crate) TCTag); + +#[pymethods] +impl Tag { + #[new] + pub fn new(tag: String) -> anyhow::Result { + Ok(Tag(tag.parse()?)) + } + pub fn is_synthetic(&self) -> bool { + self.0.is_synthetic() + } + + pub fn is_user(&self) -> bool { + self.0.is_user() + } +} + +impl From for TCTag { + fn from(value: Tag) -> Self { + value.0 + } +} diff --git a/src/task/task.rs b/src/task/task.rs new file mode 100644 index 0000000..49307c5 --- /dev/null +++ b/src/task/task.rs @@ -0,0 +1,394 @@ +use crate::task::{Annotation, Status, Tag, TaskData}; +use crate::Operation; +use chrono::DateTime; +use pyo3::prelude::*; +use taskchampion::{Operation as TCOperation, Task as TCTask, Uuid}; +// TODO: actually create a front-facing user class, instead of this data blob +#[pyclass] +pub struct Task(pub(crate) TCTask); + +unsafe impl Send for Task {} + +#[pymethods] +impl Task { + pub fn into_task_data(&self) -> TaskData { + TaskData(self.0.clone().into_task_data()) + } + /// Get a tasks UUID + /// + /// Returns: + /// str: UUID of a task + // TODO: possibly determine if it's possible to turn this from/into python's UUID instead + pub fn get_uuid(&self) -> String { + self.0.get_uuid().to_string() + } + /// Get a task's status + /// Returns: + /// Status: Status subtype + pub fn get_status(&self) -> Status { + self.0.get_status().into() + } + + pub fn get_description(&self) -> String { + self.0.get_description().to_string() + } + + /// Get the entry timestamp for a task + /// + /// Returns: + /// str: RFC3339 timestamp + /// None: No timestamp + // Attempt to convert this into a python datetime later on + pub fn get_entry(&self) -> Option { + self.0.get_entry().map(|timestamp| timestamp.to_rfc3339()) + } + + /// Get the task's priority + /// + /// Returns: + /// str: Task's priority + pub fn get_priority(&self) -> String { + self.0.get_priority().to_string() + } + + /// Get the wait timestamp of the task + /// + /// Returns: + /// str: RFC3339 timestamp + /// None: No timesamp + pub fn get_wait(&self) -> Option { + self.0.get_wait().map(|timestamp| timestamp.to_rfc3339()) + } + /// Check if the task is waiting + /// + /// Returns: + /// bool: if the task is waiting + pub fn is_waiting(&self) -> bool { + self.0.is_waiting() + } + + /// Check if the task is active + /// + /// Returns: + /// bool: if the task is active + pub fn is_active(&self) -> bool { + self.0.is_active() + } + /// Check if the task is blocked + /// + /// Returns: + /// bool: if the task is blocked + pub fn is_blocked(&self) -> bool { + self.0.is_blocked() + } + /// Check if the task is blocking + /// + /// Returns: + /// bool: if the task is blocking + pub fn is_blocking(&self) -> bool { + self.0.is_blocking() + } + /// Check if the task has a tag + /// + /// Returns: + /// bool: if the task has a given tag + // TODO: Not very user friendly; User has to construct a Tag object and then pass is into here. + // Should probably use a string + pub fn has_tag(&self, tag: &Tag) -> bool { + self.0.has_tag(&tag.0) + } + + /// Get task tags + /// + /// Returns: + /// list[str]: list of tags + pub fn get_tags(&self) -> Vec { + self.0.get_tags().into_iter().map(|v| Tag(v)).collect() + } + /// Get task annotations + /// + /// Returns: + /// list[Annotation]: list of task annotations + pub fn get_annotations(&self) -> Vec { + self.0 + .get_annotations() + .into_iter() + .map(|annotation| Annotation(annotation)) + .collect() + } + + /// Get a task UDA + /// + /// Arguments: + /// namespace (str): argument namespace + /// key (str): argument key + /// + /// Returns: + /// str: UDA value + /// None: Not found + pub fn get_uda(&self, namespace: &str, key: &str) -> Option<&str> { + self.0.get_uda(namespace, key) + } + + // TODO: this signature is ugly and confising, possibly replace this with a struct in the + // actual code + /// get all the task's UDAs + /// + /// Returns: + /// Uh oh, ew? + pub fn get_udas(&self) -> Vec<((&str, &str), &str)> { + self.0.get_udas().collect() + } + /// Get the task modified time + /// + /// Returns: + /// str: RFC3339 modified time + /// None: Not applicable + pub fn get_modified(&self) -> Option { + self.0 + .get_modified() + .map(|timestamp| timestamp.to_rfc3339()) + } + + /// Get the task's due date + /// + /// Returns: + /// str: RFC3339 due date + /// None: No such value + pub fn get_due(&self) -> Option { + self.0.get_due().map(|timestamp| timestamp.to_rfc3339()) + } + /// Get a list of tasks dependencies + /// + /// Returns: + /// list[str]: List of UUIDs of the task depends on + pub fn get_dependencies(&self) -> Vec { + self.0 + .get_dependencies() + .into_iter() + .map(|uuid| uuid.to_string()) + .collect() + } + /// Get the task's property value + /// + /// Returns: + /// str: property value + /// None: no such value + pub fn get_value(&self, property: String) -> Option<&str> { + self.0.get_value(property) + } + + pub fn set_status(&mut self, status: Status) -> anyhow::Result> { + let mut ops: Vec = Vec::new(); + + self.0.set_status(status.into(), &mut ops).expect(""); + + Ok(ops.iter().map(|op| Operation(op.clone())).collect()) + } + + pub fn set_description(&mut self, description: String) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.set_description(description, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn set_priority(&mut self, priority: String) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.set_priority(priority, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + #[pyo3(signature=(entry=None))] + pub fn set_entry(&mut self, entry: Option) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + let timestamp = entry.map(|time| { + DateTime::parse_from_rfc3339(&time) + .unwrap() + .with_timezone(&chrono::Utc) + }); + + self.0.set_entry(timestamp, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + #[pyo3(signature=(wait=None))] + pub fn set_wait(&mut self, wait: Option) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + let timestamp = wait.map(|time| { + DateTime::parse_from_rfc3339(&time) + .unwrap() + .with_timezone(&chrono::Utc) + }); + + self.0.set_wait(timestamp, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + #[pyo3(signature=(modified=None))] + pub fn set_modified(&mut self, modified: Option) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + let timestamp = modified.map(|time| { + DateTime::parse_from_rfc3339(&time) + .unwrap() + .with_timezone(&chrono::Utc) + }); + + self.0.set_wait(timestamp, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + #[pyo3(signature=(property, value=None))] + pub fn set_value( + &mut self, + property: String, + value: Option, + ) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + self.0.set_value(property, value, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn start(&mut self) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.start(&mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn stop(&mut self) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.stop(&mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn done(&mut self) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.done(&mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn add_tag(&mut self, tag: &Tag) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.add_tag(&tag.0, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn remove_tag(&mut self, tag: &Tag) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.remove_tag(&tag.0, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn add_annotation(&mut self, ann: &Annotation) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + let mut annotation = Annotation::new(); + + annotation.set_entry(ann.entry()); + annotation.set_description(ann.description()); + + self.0.add_annotation(annotation.0, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn remove_annotation(&mut self, timestamp: String) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + let time = DateTime::parse_from_rfc3339(×tamp) + .unwrap() + .with_timezone(&chrono::Utc); + self.0.remove_annotation(time, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + #[pyo3(signature=(due=None))] + pub fn set_due(&mut self, due: Option) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + let timestamp = due.map(|s| { + DateTime::parse_from_rfc3339(s.as_ref()) + .unwrap() + .with_timezone(&chrono::Utc) + }); + + self.0.set_due(timestamp, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn set_uda( + &mut self, + namespace: String, + key: String, + value: String, + ) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.set_uda(namespace, key, value, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn remove_uda(&mut self, namespace: String, key: String) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.remove_uda(namespace, key, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn set_legacy_uda(&mut self, key: String, value: String) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.set_legacy_uda(key, value, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn remove_legacy_uda(&mut self, key: String) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + + self.0.remove_legacy_uda(key, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn add_dependency(&mut self, dep: String) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + let dep_uuid = Uuid::parse_str(&dep).expect("couldn't parse UUID"); + + self.0.add_dependency(dep_uuid, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } + + pub fn remove_dependency(&mut self, dep: String) -> anyhow::Result { + let mut ops: Vec = Vec::new(); + let dep_uuid = Uuid::parse_str(&dep).expect("couldn't parse UUID"); + + self.0.remove_dependency(dep_uuid, &mut ops).expect(""); + + Ok(ops.get(0).map(|op| Operation(op.clone())).unwrap()) + } +} diff --git a/src/working_set.rs b/src/working_set.rs new file mode 100644 index 0000000..720be0e --- /dev/null +++ b/src/working_set.rs @@ -0,0 +1,36 @@ +use pyo3::prelude::*; +use taskchampion::Uuid; +use taskchampion::WorkingSet as TCWorkingSet; +// TODO: convert working set into python's iterable type +#[pyclass] +pub struct WorkingSet(pub(crate) TCWorkingSet); + +#[pymethods] +impl WorkingSet { + pub fn __len__(&self) -> usize { + self.0.len() + } + + pub fn largest_index(&self) -> usize { + self.0.largest_index() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn by_index(&self, index: usize) -> Option { + self.0.by_index(index).map(|uuid| uuid.into()) + } + + pub fn by_uuid(&self, uuid: String) -> Option { + // TODO I don't like the conversion, should use try-expect or something else as an input + self.0.by_uuid(Uuid::parse_str(&uuid).unwrap()) + } + + fn __iter__(_slf: PyRef<'_, Self>) -> PyResult> { + todo!("Figure way to propertly implement iterator for python") + // Usability-wise we want it to hold the reference to the iterator, so that + // with each iteration the state persists. + } +} diff --git a/taskchampion.pyi b/taskchampion.pyi new file mode 100644 index 0000000..0189a06 --- /dev/null +++ b/taskchampion.pyi @@ -0,0 +1,136 @@ +from typing import Optional +from enum import Enum + + +class Replica: + def __init__(self, path: str, create_if_missing: bool): ... + @staticmethod + def new_inmemory(): ... + def create_task(self, uuid: str) -> tuple["Task", "Operation"]: ... + def all_task_uuids(self) -> list[str]: ... + def all_tasks(self) -> dict[str, "Task"]: ... + + def update_task( + self, uuid: str, property: str, value: Optional[str] + ) -> dict[str, str]: ... + def working_set(self) -> "WorkingSet": ... + def dependency_map(self, force: bool) -> "DependencyMap": ... + def get_task(self, uuid: str) -> Optional["Task"]: ... + def import_task_with_uuid(self, uuid: str) -> "Task": ... + def sync(self): ... + def rebuild_working_set(self, renumber: bool): ... + def add_undo_point(self, force: bool) -> None: ... + def num_local_operations(self) -> int: ... + def num_undo_points(self) -> int: ... + def commit_operations(self, operations: list["Operation"]) -> None: ... + + +class Operation: + @staticmethod + def Create(uuid: str) -> "Operation": ... + @staticmethod + def Delete(uuid: str, old_task: dict[str, str]): ... + + @staticmethod + def Update( + uuid: str, + property: str, + timestamp: str, + old_value: Optional[str], + value: Optional[str], + ) -> "Operation": ... + @staticmethod + def UndoPoint() -> "Operation": ... + + +class Status(Enum): + Pending = 1 + Completed = 2 + Deleted = 3 + Recurring = 4 + Unknown = 5 + + +class Task: + def get_uuid(self) -> str: ... + def get_status(self) -> "Status": ... + def get_taskmap(self) -> dict[str, str]: ... + def get_entry(self) -> Optional[str]: ... + def get_priority(self) -> str: ... + def get_wait(self) -> Optional[str]: ... + def is_waiting(self) -> bool: ... + def is_active(self) -> bool: ... + def is_blocked(self) -> bool: ... + def is_blocking(self) -> bool: ... + def has_tag(self, tag: "Tag") -> bool: ... + def get_tags(self) -> list["Tag"]: ... + def get_annotations(self) -> list["Annotation"]: ... + def get_uda(self, namespace: str, key: str) -> Optional[str]: ... + def get_udas(self) -> list[tuple[tuple[str, str], str]]: ... + def get_modified(self) -> Optional[str]: ... + def get_due(self) -> Optional[str]: ... + def get_dependencies(self) -> list[str]: ... + def get_value(self, property: str) -> Optional[str]: ... + def set_status(self, status: "Status") -> list["Operation"]: ... + def set_description(self, description: str) -> Optional["Operation"]: ... + def set_priority(self, priority: str) -> Optional["Operation"]: ... + def set_entry(self, entry: Optional[str]) -> Optional["Operation"]: ... + def set_wait(self, wait: Optional[str]) -> Optional["Operation"]: ... + + def set_modified( + self, modified: Optional[str]) -> Optional["Operation"]: ... + + def set_value( + self, property: str, value: Optional[str] + ) -> Optional["Operation"]: ... + def start(self) -> Optional["Operation"]: ... + def stop(self) -> Optional["Operation"]: ... + def done(self) -> Optional["Operation"]: ... + def add_tag(self, tag: "Tag") -> Optional["Operation"]: ... + def remove_tag(self, tag: "Tag") -> Optional["Operation"]: ... + + def add_annotation( + self, annotation: "Annotation") -> Optional["Operation"]: ... + def remove_annotation( + self, annotation: "Annotation") -> Optional["Operation"]: ... + + def set_due(self, due: Optional[str]) -> Optional["Operation"]: ... + + def set_uda( + self, namespace: str, key: str, value: str + ) -> Optional["Operation"]: ... + + def remove_uda(self, namespace: str, + key: str) -> Optional["Operation"]: ... + def set_legacy_uda( + self, key: str, value: str) -> Optional["Operation"]: ... + + def remove_legacy_uda(self, key: str) -> Optional["Operation"]: ... + def add_dependency(self, uuid: str) -> Optional["Operation"]: ... + def remove_dependency(self, uuid: str) -> Optional["Operation"]: ... + + +class WorkingSet: + def __len__(self) -> int: ... + def largest_index(self) -> int: ... + def is_empty(self) -> bool: ... + def by_index(self, index: int) -> Optional[str]: ... + def by_uuid(self, uuid: str) -> Optional[int]: ... + + +class Annotation: + entry: str + description: str + + def __init__(self) -> None: ... + + +class DependencyMap: + def dependencies(self, dep_of: str) -> list[str]: ... + def dependents(self, dep_on: str) -> list[str]: ... + + +class Tag: + def __init__(self, tag: str): ... + def is_synthetic(self) -> bool: ... + def is_user(self) -> bool: ... diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_annotation.py b/tests/test_annotation.py new file mode 100644 index 0000000..019f90d --- /dev/null +++ b/tests/test_annotation.py @@ -0,0 +1,18 @@ +from taskchampion import Annotation + + +# IDK if this is a good idea, but it seems overkill to have another test to +# test the getter ... while using it for testing +def test_get_set_entry(): + a = Annotation() + a.entry = "2024-05-07T01:35:57+03:00" + + assert a.entry == "2024-05-06T22:35:57+00:00" + + +def test_get_set_description(): + a = Annotation() + + a.description = "This is a basic description" + + assert a.description == "This is a basic description" diff --git a/tests/test_operation.py b/tests/test_operation.py new file mode 100644 index 0000000..f446388 --- /dev/null +++ b/tests/test_operation.py @@ -0,0 +1,5 @@ +from taskchampion import Operation + + +def test_new_create(): + o = Operation.Create("10c52749-aec7-4ec9-b390-f371883b9605") diff --git a/tests/test_replica.py b/tests/test_replica.py new file mode 100644 index 0000000..dc41b6d --- /dev/null +++ b/tests/test_replica.py @@ -0,0 +1,141 @@ +import uuid +from pathlib import Path + +import pytest +from taskchampion import Replica + +# TODO: instantiate the in-memory replica, this will do for now + + +@pytest.fixture +def empty_replica(tmp_path: Path) -> Replica: + return Replica(str(tmp_path), True) + + +@pytest.fixture +def replica_with_tasks(empty_replica: Replica): + ops = [] + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + empty_replica.commit_operations(ops) + + return empty_replica + + +def test_constructor(tmp_path: Path): + r = Replica(str(tmp_path), True) + + assert r is not None + + +def test_constructor_throws_error_with_missing_database(tmp_path: Path): + with pytest.raises(RuntimeError): + Replica(str(tmp_path), False) + + +def test_create_task(empty_replica: Replica): + u = uuid.uuid4() + + _, op = empty_replica.create_task(str(u)) + empty_replica.commit_operations([op]) + + tasks = empty_replica.all_task_uuids() + + assert len(tasks) == 1 + + +def test_all_task_uuids(empty_replica: Replica): + ops = [] + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + empty_replica.commit_operations(ops) + tasks = empty_replica.all_task_uuids() + assert len(tasks) == 3 + + +def test_all_tasks(empty_replica: Replica): + ops = [] + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + _, op = empty_replica.create_task(str(uuid.uuid4())) + ops.append(op) + + empty_replica.commit_operations(ops) + + tasks = empty_replica.all_tasks() + + assert len(tasks) == 3 + keys = tasks.keys() + + for key in keys: + assert tasks[key] != 0 + + +def test_working_set(replica_with_tasks: Replica): + ws = replica_with_tasks.working_set() + + assert ws is not None + + +# TODO: create testable and inspectable WorkingSet + + +def test_get_task(replica_with_tasks: Replica): + uuid = replica_with_tasks.all_task_uuids()[0] + + task = replica_with_tasks.get_task(uuid) + + assert task is not None + + +@pytest.mark.skip() +def test_rebuild_working_set(replica_with_tasks: Replica): + # TODO actually test this + replica_with_tasks.rebuild_working_set(False) + + +@pytest.mark.skip() +def test_add_undo_point(replica_with_tasks: Replica): + replica_with_tasks.add_undo_point(False) + + +def test_num_local_operations(replica_with_tasks: Replica): + assert replica_with_tasks.num_local_operations() == 3 + + _, op = replica_with_tasks.create_task(str(uuid.uuid4())) + + replica_with_tasks.commit_operations([op]) + assert replica_with_tasks.num_local_operations() == 4 + + +def test_num_undo_points(replica_with_tasks: Replica): + assert replica_with_tasks.num_undo_points() == 3 + + _, op = replica_with_tasks.create_task(str(uuid.uuid4())) + + replica_with_tasks.commit_operations([op]) + + assert replica_with_tasks.num_undo_points() == 4 + + +@pytest.mark.skip("Skipping as gotta actually polish it") +def test_dependency_map(replica_with_tasks: Replica): + assert replica_with_tasks.dependency_map(False) is not None diff --git a/tests/test_tag.py b/tests/test_tag.py new file mode 100644 index 0000000..e0ec831 --- /dev/null +++ b/tests/test_tag.py @@ -0,0 +1,22 @@ +import pytest +from taskchampion import Tag + + +@pytest.fixture +def user_tag(): + return Tag("user_tag") + + +@pytest.fixture +def synthetic_tag(): + return Tag("UNBLOCKED") + + +def test_user_tag(user_tag: Tag): + assert user_tag.is_user() + assert not user_tag.is_synthetic() + + +def test_synthetic_tag(synthetic_tag: Tag): + assert synthetic_tag.is_synthetic() + assert not synthetic_tag.is_user() diff --git a/tests/test_task.py b/tests/test_task.py new file mode 100644 index 0000000..04626f9 --- /dev/null +++ b/tests/test_task.py @@ -0,0 +1,131 @@ +from taskchampion import Task, Replica, Status, Tag +import pytest +import uuid + + +@pytest.fixture +def new_task(tmp_path): + r = Replica(str(tmp_path), True) + result = r.create_task(str(uuid.uuid4())) + + assert result is not None + task, _ = result + + return task + + +@pytest.fixture +def waiting_task(tmp_path): + r = Replica(str(tmp_path), True) + result = r.create_task(str(uuid.uuid4())) + + assert result is not None + task, _ = result + + task.set_wait("2038-01-19T03:14:07+00:00") + task.set_priority("10") + task.add_tag(Tag("example_tag")) + + return task + + +@pytest.fixture +def started_task(tmp_path): + r = Replica(str(tmp_path), True) + + result = r.create_task(str(uuid.uuid4())) + assert result is not None + task, _ = result + + task.start() + return task + + +@pytest.fixture +def blocked_task(tmp_path): + r = Replica(str(tmp_path), True) + result = r.create_task(str(uuid.uuid4())) + + assert result is not None + + task, _ = result + + # Fragile test, but I cannot mock Rust's Chrono, so this will do. + # Need to refresh the tag, the one that's in memory is stale + return task + + +@pytest.fixture +def due_task(tmp_path): + r = Replica(str(tmp_path), True) + result = r.create_task(str(uuid.uuid4())) + assert result is not None + task, _ = result + + task.set_due("2006-05-13T01:27:27+00:00") + # Need to refresh the tag, the one that's in memory is stale + + return task + + +def test_get_uuid(new_task: Task): + task_uuid = new_task.get_uuid() + assert uuid is not None + + # This tests that the UUID is valid, it raises exception if not + uuid.UUID(task_uuid) + + +@pytest.mark.skip("This could be a bug") +def test_get_status(new_task: Task): + status = new_task.get_status() + + # for whatever reason these are not equivalent + # TODO: research if this is a bug + assert status is Status.Pending + + +def test_get_priority(waiting_task: Task): + priority = waiting_task.get_priority() + assert priority == "10" + + +def test_get_wait(waiting_task: Task): + wait = waiting_task.get_wait() + assert wait == "2038-01-19T03:14:07+00:00" + + +def test_is_waiting(waiting_task: Task): + assert waiting_task.is_waiting() + + +def test_is_active(started_task: Task): + assert started_task.is_active() + + +@pytest.mark.skip() +def test_is_blocked(started_task: Task): + assert started_task.is_blocked() + + +@pytest.mark.skip() +def test_is_blocking(started_task: Task): + assert started_task.is_blocking() + + +@pytest.mark.skip("Enable this when able to add tags to the tasks") +def test_has_tag(waiting_task: Task): + assert waiting_task.has_tag(Tag("sample_tag")) + + +@pytest.mark.skip("Enable this when able to add tags to the tasks") +def test_get_tags(waiting_task: Task): + assert waiting_task.get_tags() + + +def test_get_modified(waiting_task: Task): + assert waiting_task.get_modified() is not None + + +def test_get_due(due_task: Task): + assert due_task.get_due() == "2006-05-13T01:27:27+00:00" diff --git a/tests/test_working_set.py b/tests/test_working_set.py new file mode 100644 index 0000000..1837357 --- /dev/null +++ b/tests/test_working_set.py @@ -0,0 +1,52 @@ +from taskchampion import Replica, WorkingSet, Status, Operation +from pathlib import Path +import pytest +import uuid + + +@pytest.fixture +def working_set(tmp_path: Path): + r = Replica(str(tmp_path), True) + + ops = [] + task, op = r.create_task(str(uuid.uuid4())) + ops.append(op) + ops.extend(task.set_status(Status.Pending)) + + task, op = r.create_task(str(uuid.uuid4())) + ops.append(op) + ops.extend(task.set_status(Status.Pending)) + + ops.append(task.start()) + r.commit_operations(ops) + + return r.working_set() + + +def test_len(working_set: WorkingSet): + assert len(working_set) == 2 + + +def test_largest_index(working_set: WorkingSet): + assert working_set.largest_index() == 2 + + +def test_is_empty(working_set: WorkingSet): + assert not working_set.is_empty() + + +def test_by_index(working_set: WorkingSet): + assert working_set.by_index(1) is not None + + +@pytest.mark.skip() +def test_iter(working_set: WorkingSet): + assert iter(working_set) + + +@pytest.mark.skip() +def test_next(working_set: WorkingSet): + assert next(working_set)[0] == 1 + assert next(working_set)[0] == 2 + with pytest.raises(OSError): + next(working_set)