├── .coveragerc ├── .cruft.json ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── install.yml │ ├── tests.yml │ └── update.yml ├── .gitignore ├── .markdownlint.json ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── assets └── config.yaml ├── docs ├── adr │ ├── 001-change-id-from-str-to-int.md │ ├── 002-list-task-tui.md │ ├── 0xx-agenda.md │ ├── 0xx-subtasks-and-steps.md │ └── adr.md ├── areas.md ├── basic_usage.md ├── contributing.md ├── dates.md ├── developing │ └── database_schema.md ├── estimate.md ├── export.md ├── fun.md ├── future_features.md ├── images │ ├── database_schema.jpg │ └── logo.bmp ├── index.md ├── mkdocs.yml ├── objective_value.md ├── priority.md ├── recurrence.md ├── reference.md ├── related.md ├── reports.md ├── requirements.in ├── requirements.txt ├── sorting.md ├── stylesheets │ ├── extra.css │ └── links.css ├── tags.md ├── theme │ └── assets │ │ └── images │ │ ├── amazon.svg │ │ ├── archive.svg │ │ ├── audio.svg │ │ ├── bluebook.2.svg │ │ ├── bluebook.bmp │ │ ├── bluebook.svg │ │ ├── chi-dna.svg │ │ ├── code.svg │ │ ├── csv.svg │ │ ├── deepmind.svg │ │ ├── dropbox.svg │ │ ├── erowid.svg │ │ ├── gitea.svg │ │ ├── github.svg │ │ ├── google-scholar.svg │ │ ├── hn.svg │ │ ├── image.svg │ │ ├── internetarchive.svg │ │ ├── kubernetes.svg │ │ ├── mega.svg │ │ ├── miri.svg │ │ ├── misc.svg │ │ ├── newyorktimes.svg │ │ ├── nlm-ncbi.svg │ │ ├── openai.svg │ │ ├── patreon.svg │ │ ├── plos.svg │ │ ├── pydo.svg │ │ ├── reddit.svg │ │ ├── spreadsheet.svg │ │ ├── stackexchange.svg │ │ ├── theguardian.svg │ │ ├── thenewyorker.svg │ │ ├── twitter.svg │ │ ├── txt.svg │ │ ├── uptontea.svg │ │ ├── video.svg │ │ ├── washingtonpost.svg │ │ ├── wired.svg │ │ └── worddoc.svg ├── update.md └── willpower.md ├── mkdocs.yml ├── mypy.ini ├── pyproject.toml ├── requirements-dev.in ├── requirements-dev.txt ├── requirements.txt ├── setup.py ├── src └── pydo │ ├── __init__.py │ ├── config.py │ ├── entrypoints │ ├── __init__.py │ ├── cli.py │ ├── tui.py │ └── utils.py │ ├── exceptions.py │ ├── model │ ├── __init__.py │ ├── date.py │ ├── task.py │ └── views.py │ ├── py.typed │ ├── services.py │ ├── test_typing.py │ ├── types.py │ ├── version.py │ └── views.py └── tests ├── __init__.py ├── assets └── config.yaml ├── cases.py ├── conftest.py ├── e2e ├── __init__.py └── test_cli.py ├── factories.py └── unit ├── __init__.py ├── entrypoints └── __init__.py ├── model ├── __init__.py ├── test_date.py └── test_task.py ├── test_config.py ├── test_services.py ├── test_task_argument_parser.py ├── test_version.py └── test_views.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit=pydo/migrations/* 3 | -------------------------------------------------------------------------------- /.cruft.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "git@github.com:lyz-code/cookiecutter-python-project.git", 3 | "commit": "f2d6860498002278bdb94df7b16b0b6758219111", 4 | "context": { 5 | "cookiecutter": { 6 | "project_name": "pydo", 7 | "project_slug": "pydo", 8 | "project_description": "CLI task manager built with Python and SQLite.", 9 | "requirements": "click, click-default-group, ruamel.yaml, pypika, tabulate, yoyo-migrations, ulid-py", 10 | "configure_command_line": "True", 11 | "read_configuration_from_yaml": "True", 12 | "github_user": "lyz-code", 13 | "github_token_pass_path": "internet/github.lyz-code.api_token", 14 | "pypi_token_pass_path": "internet/pypi.token", 15 | "test_pypi_token_pass_path": "internet/test.pypi.token", 16 | "author": "Lyz", 17 | "author_email": "lyz-code-security-advisories@riseup.net", 18 | "security_advisories_email": "lyz-code-security-advisories@riseup.net", 19 | "project_underscore_slug": "pydo", 20 | "_template": "git@github.com:lyz-code/cookiecutter-python-project.git" 21 | } 22 | }, 23 | "directory": null, 24 | "checkout": null 25 | } 26 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # ignore = E203, E266, E501, W503, F403, F401 3 | max-line-length = 88 4 | # max-complexity = 18 5 | # select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Create a bug report to help us improve pydo 4 | labels: bug 5 | --- 6 | 7 | ## Description 8 | 9 | 10 | ## Steps to reproduce 11 | 15 | 16 | ## Current behavior 17 | 18 | 19 | ## Desired behavior 20 | 26 | 27 | ## Environment 28 | 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature or change to pydo 4 | labels: feature request 5 | --- 6 | 7 | ## Description 8 | 9 | 10 | ## Possible Solution 11 | 12 | 13 | ## Additional context 14 | 15 | 16 | ## Related Issue 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about how to use pydo 4 | labels: question 5 | --- 6 | 7 | 14 | 15 | ## Question 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## Checklist 8 | 9 | * [ ] Add test cases to all the changes you introduce 10 | * [ ] Update the documentation for the changes 11 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We will endeavour to support: 6 | 7 | * The most recent minor release with bug fixes. 8 | * The latest minor release from the last major version for 6 months after a new 9 | major version is released with critical bug fixes. 10 | * All versions if a security vulnerability is found provided: 11 | * Upgrading to a later version is non-trivial. 12 | * Sufficient people are using that version to make support worthwhile. 13 | 14 | ## Reporting a Vulnerability 15 | 16 | If you find what you think might be a security vulnerability with 17 | pydo, please do not create an issue on github. Instead please 18 | email lyz-code-security-advisories@riseup.net I'll reply to your email promptly 19 | and try to get a patch out ASAP. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | updates: 5 | - package-ecosystem: pip 6 | directory: / 7 | schedule: 8 | interval: weekly 9 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build 3 | 4 | on: # yamllint disable-line rule:truthy 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | PyPI: 11 | name: Build and publish Python distributions to PyPI and TestPyPI 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@master 15 | - name: Set up Python 3.7 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.7 19 | - name: Install dependencies 20 | run: pip install wheel 21 | - name: Build package 22 | run: make build-package 23 | - name: Publish distribution to Test PyPI 24 | uses: pypa/gh-action-pypi-publish@master 25 | with: 26 | password: ${{ secrets.test_pypi_password }} 27 | repository_url: https://test.pypi.org/legacy/ 28 | skip_existing: true 29 | - name: Publish distribution to PyPI 30 | if: startsWith(github.ref, 'refs/tags') 31 | uses: pypa/gh-action-pypi-publish@master 32 | with: 33 | password: ${{ secrets.pypi_password }} 34 | Documentation: 35 | runs-on: ubuntu-18.04 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v2 39 | with: 40 | # Number of commits to fetch. 0 indicates all history. 41 | # Default: 1 42 | fetch-depth: 0 43 | - uses: actions/setup-python@v2 44 | with: 45 | python-version: 3.7 46 | - name: Install dependencies 47 | run: >- 48 | pip install -r requirements.txt 49 | pip install -r ./docs/requirements.txt 50 | pip install -e . 51 | - name: Build the Documentation 52 | run: make build-docs 53 | - name: Deploy 54 | uses: peaceiris/actions-gh-pages@v3 55 | with: 56 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} 57 | publish_dir: ./site 58 | -------------------------------------------------------------------------------- /.github/workflows/install.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Install 3 | on: # yamllint disable-line rule:truthy 4 | schedule: 5 | - cron: 21 08 * * * 6 | 7 | jobs: 8 | Install: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | max-parallel: 3 12 | matrix: 13 | python-version: [3.7, 3.8] 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install the program 21 | run: pip install py-do 22 | - name: Test the program works 23 | run: pydo --version 24 | - name: Test the program can create task 25 | run: pydo add test task 26 | - name: Test the program can remove task 27 | run: pydo rm 1 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: # yamllint disable-line rule:truthy 5 | push: 6 | branches: 7 | - '*' # matches every branch that doesn't contain a '/' 8 | - '*/*' # matches every branch containing a single '/' 9 | - '**' # matches every branch 10 | - '!gh-pages' # excludes gh-pages 11 | pull_request: 12 | types: [opened] 13 | 14 | jobs: 15 | Tests: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | max-parallel: 3 19 | matrix: 20 | python-version: [3.7, 3.8] 21 | steps: 22 | - uses: actions/checkout@v1 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v1 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: make install 29 | - name: Test linters 30 | run: make lint 31 | - name: Test type checkers 32 | run: make mypy 33 | - name: Test security 34 | run: make security 35 | - name: Test with pytest 36 | run: make test 37 | - name: Upload Coverage 38 | run: | 39 | pip3 install coveralls 40 | coveralls --service=github 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | COVERALLS_FLAG_NAME: ${{ matrix.test-name }} 44 | COVERALLS_PARALLEL: true 45 | - name: Test documentation 46 | run: make build-docs 47 | Coveralls: 48 | name: Finish Coveralls 49 | needs: Tests 50 | runs-on: ubuntu-latest 51 | container: python:3-slim 52 | steps: 53 | - name: Finished 54 | run: | 55 | pip3 install coveralls 56 | coveralls --service=github --finish 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | Python_Package: 60 | name: Build the Python package 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@master 64 | - name: Set up Python 3.7 65 | uses: actions/setup-python@v1 66 | with: 67 | python-version: 3.7 68 | - name: Install PEP517 69 | run: >- 70 | python -m 71 | pip install 72 | pep517 73 | --user 74 | - name: Build a binary wheel and a source tarball 75 | run: >- 76 | python -m 77 | pep517.build 78 | --source 79 | --binary 80 | --out-dir dist/ 81 | . 82 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Update 3 | on: # yamllint disable-line rule:truthy 4 | schedule: 5 | - cron: 11 08 * * * 6 | 7 | jobs: 8 | Update: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | with: 13 | persist-credentials: false 14 | fetch-depth: 0 15 | - name: Set up Python 3.7 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.7 19 | - name: Install dependencies 20 | run: pip install pip-tools 21 | - name: Update requirements 22 | run: make update 23 | - name: Install the program 24 | run: make install 25 | - name: Run tests 26 | run: make all 27 | - name: Commit files 28 | run: | 29 | rm -r .git/hooks 30 | git config --local user.email "action@github.com" 31 | git config --local user.name "GitHub Action" 32 | git add requirements.txt docs/requirements.txt requirements-dev.txt 33 | git diff-index --quiet HEAD \ 34 | || git commit -m "chore: update dependency requirements" 35 | - name: Push changes 36 | uses: ad-m/github-push-action@master 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | branch: master 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | pytestdebug.log 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | doc/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # End of https://www.toptal.com/developers/gitignore/api/python 142 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD003": { 4 | "style": "atx" 5 | }, 6 | "MD013": { 7 | "line_length": 180 8 | }, 9 | "MD004": { 10 | "style": "asterisk" 11 | }, 12 | "MD007": { 13 | "indent": 4 14 | }, 15 | "MD025": false, 16 | "MD030": false, 17 | "MD035": { 18 | "style": "---" 19 | }, 20 | "MD041": false, 21 | "MD046": false 22 | } 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v3.1.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: check-added-large-files 8 | - id: check-docstring-first 9 | - id: check-merge-conflict 10 | - id: end-of-file-fixer 11 | - repo: https://github.com/ambv/black 12 | rev: master 13 | hooks: 14 | - id: black 15 | language_version: python3.7 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v0.782 18 | hooks: 19 | - name: Run mypy static analysis tool 20 | id: mypy 21 | - repo: https://github.com/Lucas-C/pre-commit-hooks-safety 22 | rev: v1.1.3 23 | hooks: 24 | - id: python-safety-dependencies-check 25 | - repo: https://github.com/life4/flakehell/ 26 | rev: master 27 | hooks: 28 | - name: Run flakehell static analysis tool 29 | id: flakehell 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.1 (2021-10-08) 2 | 3 | ### Fix 4 | 5 | - create default configuration file and directory if it doesn't exist 6 | 7 | ## 0.1.0 (2021-10-08) 8 | 9 | ### Perf 10 | 11 | - remove the imports from src/pydo/__init__.py 12 | 13 | ### fix 14 | 15 | - add dateutil as a dependency 16 | 17 | ### Feat 18 | 19 | - migrate code structure to domain driven design to improve flexibility and maintainability 20 | - added due parser test for date format with hour 21 | 22 | ### feat 23 | 24 | - migrate persistence code to repository-orm 25 | 26 | ### Fix 27 | 28 | - pip upgrade command 29 | - doc for YYYY-MM-DDTHH:mm fixed 30 | - due parser fixed for dates with ':' 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := test 2 | isort = isort src docs/examples tests setup.py 3 | black = black --target-version py37 src tests setup.py 4 | 5 | .PHONY: install 6 | install: 7 | python -m pip install -U setuptools pip pip-tools 8 | python -m piptools sync requirements.txt requirements-dev.txt docs/requirements.txt 9 | pip install -e . 10 | pre-commit install 11 | 12 | .PHONY: update 13 | update: 14 | @echo "-------------------------" 15 | @echo "- Updating dependencies -" 16 | @echo "-------------------------" 17 | 18 | # Sync your virtualenv with the expected state 19 | python -m piptools sync requirements.txt requirements-dev.txt docs/requirements.txt 20 | 21 | # Remove the requirement files to avoid conflicts between themselves 22 | rm requirements.txt docs/requirements.txt requirements-dev.txt 23 | touch requirements.txt docs/requirements.txt requirements-dev.txt 24 | 25 | # Update the requirements 26 | pip-compile -Ur --allow-unsafe 27 | pip-compile -Ur --allow-unsafe docs/requirements.in --output-file docs/requirements.txt 28 | pip-compile -Ur --allow-unsafe requirements-dev.in --output-file requirements-dev.txt 29 | 30 | # Sync your virtualenv with the new state 31 | python -m piptools sync requirements.txt requirements-dev.txt docs/requirements.txt 32 | 33 | pip install -e . 34 | 35 | @echo "" 36 | 37 | .PHONY: format 38 | format: 39 | @echo "----------------------" 40 | @echo "- Formating the code -" 41 | @echo "----------------------" 42 | 43 | $(isort) 44 | $(black) 45 | 46 | @echo "" 47 | 48 | .PHONY: lint 49 | lint: 50 | @echo "--------------------" 51 | @echo "- Testing the lint -" 52 | @echo "--------------------" 53 | 54 | flakehell lint src/ tests/ setup.py 55 | $(isort) --check-only --df 56 | $(black) --check --diff 57 | 58 | @echo "" 59 | 60 | .PHONY: mypy 61 | mypy: 62 | @echo "----------------" 63 | @echo "- Testing mypy -" 64 | @echo "----------------" 65 | 66 | mypy src tests 67 | 68 | @echo "" 69 | 70 | .PHONY: test 71 | test: test-code 72 | 73 | .PHONY: test-code 74 | test-code: 75 | @echo "----------------" 76 | @echo "- Testing code -" 77 | @echo "----------------" 78 | 79 | pytest --cov-report term-missing --cov src tests ${ARGS} 80 | 81 | @echo "" 82 | 83 | .PHONY: test-examples 84 | test-examples: 85 | @echo "--------------------" 86 | @echo "- Testing examples -" 87 | @echo "--------------------" 88 | 89 | @find docs/examples -type f -name '*.py' | xargs -I'{}' sh -c 'python {} >/dev/null 2>&1 || (echo "{} failed" ; exit 1)' 90 | 91 | @echo "" 92 | 93 | .PHONY: all 94 | all: lint mypy test security 95 | 96 | .PHONY: clean 97 | clean: 98 | @echo "---------------------------" 99 | @echo "- Cleaning unwanted files -" 100 | @echo "---------------------------" 101 | 102 | rm -rf `find . -name __pycache__` 103 | rm -f `find . -type f -name '*.py[co]' ` 104 | rm -f `find . -type f -name '*.rej' ` 105 | rm -rf `find . -type d -name '*.egg-info' ` 106 | rm -f `find . -type f -name '*~' ` 107 | rm -f `find . -type f -name '.*~' ` 108 | rm -rf .cache 109 | rm -rf .pytest_cache 110 | rm -rf .mypy_cache 111 | rm -rf htmlcov 112 | rm -f .coverage 113 | rm -f .coverage.* 114 | rm -rf build 115 | rm -rf dist 116 | rm -f src/*.c pydantic/*.so 117 | python setup.py clean 118 | rm -rf site 119 | rm -rf docs/_build 120 | rm -rf docs/.changelog.md docs/.version.md docs/.tmp_schema_mappings.html 121 | rm -rf codecov.sh 122 | rm -rf coverage.xml 123 | 124 | @echo "" 125 | 126 | .PHONY: docs 127 | docs: test-examples 128 | @echo "-------------------------" 129 | @echo "- Serving documentation -" 130 | @echo "-------------------------" 131 | 132 | mkdocs serve 133 | 134 | @echo "" 135 | 136 | .PHONY: bump 137 | bump: pull-master bump-version build-package upload-pypi clean 138 | 139 | .PHONY: pull-master 140 | pull-master: 141 | @echo "------------------------" 142 | @echo "- Updating repository -" 143 | @echo "------------------------" 144 | 145 | git checkout master 146 | git pull 147 | 148 | @echo "" 149 | 150 | .PHONY: build-package 151 | build-package: clean 152 | @echo "------------------------" 153 | @echo "- Building the package -" 154 | @echo "------------------------" 155 | 156 | python setup.py -q bdist_wheel 157 | python setup.py -q sdist 158 | 159 | @echo "" 160 | 161 | .PHONY: build-docs 162 | build-docs: test-examples 163 | @echo "--------------------------" 164 | @echo "- Building documentation -" 165 | @echo "--------------------------" 166 | 167 | mkdocs build 168 | 169 | @echo "" 170 | 171 | .PHONY: upload-pypi 172 | upload-pypi: 173 | @echo "-----------------------------" 174 | @echo "- Uploading package to pypi -" 175 | @echo "-----------------------------" 176 | 177 | twine upload -r pypi dist/* 178 | 179 | @echo "" 180 | 181 | .PHONY: upload-testing-pypi 182 | upload-testing-pypi: 183 | @echo "-------------------------------------" 184 | @echo "- Uploading package to pypi testing -" 185 | @echo "-------------------------------------" 186 | 187 | twine upload -r testpypi dist/* 188 | 189 | @echo "" 190 | 191 | .PHONY: bump-version 192 | bump-version: 193 | @echo "---------------------------" 194 | @echo "- Bumping program version -" 195 | @echo "---------------------------" 196 | 197 | cz bump --changelog --no-verify 198 | git push 199 | git push --tags 200 | 201 | @echo "" 202 | 203 | .PHONY: security 204 | security: 205 | @echo "--------------------" 206 | @echo "- Testing security -" 207 | @echo "--------------------" 208 | 209 | safety check 210 | @echo "" 211 | bandit -r src 212 | 213 | @echo "" 214 | 215 | .PHONY: version 216 | version: 217 | @python -c "import pydo.version; print(pydo.version.version_info())" 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Actions Status](https://github.com/lyz-code/pydo/workflows/Tests/badge.svg)](https://github.com/lyz-code/pydo/actions) 2 | [![Actions Status](https://github.com/lyz-code/pydo/workflows/Build/badge.svg)](https://github.com/lyz-code/pydo/actions) 3 | [![Coverage Status](https://coveralls.io/repos/github/lyz-code/pydo/badge.svg?branch=master)](https://coveralls.io/github/lyz-code/pydo?branch=master) 4 | 5 | I'm archiving the repo as I'm currently going to use Orgmode instead 6 | 7 | # Pydo 8 | 9 | `pydo` is a free software command line task manager built in 10 | [Python](https://en.wikipedia.org/wiki/Python_%28programming_language%29). 11 | 12 | # Why another CLI Task Manager? 13 | 14 | [Taskwarrior](https://taskwarrior.org/) has been the gold standard for CLI task 15 | managers so far. However, It has the following inconveniences: 16 | 17 | * It uses a plaintext file as data storage. 18 | * It stores the data in a non standard way in different files. 19 | * It's written in C, which I don't speak. 20 | * It's development has come to [code maintenance 21 | only](https://github.com/GothenburgBitFactory/taskwarrior/graphs/code-frequency). 22 | * There are many issues with how it handles 23 | [recurrence](https://taskwarrior.org/docs/design/recurrence.html). 24 | * It doesn't have [friendly task 25 | identifiers](https://lyz-code.github.io/pydo/developing/sulids). 26 | * There is no way of accessing the task time tracking from the python library. 27 | 28 | And lacks the following features: 29 | 30 | * Native Kanban or Scrum support. 31 | * Task estimations. 32 | * Easy report creation. 33 | * Easy way to manage the split of a task in subtasks. 34 | * Freezing of recurrent tasks. 35 | 36 | Most of the above points can be addressed through the [Taskwarrior plugin 37 | system](https://taskwarrior.org/docs/3rd-party.html) or 38 | [udas](https://taskwarrior.org/docs/udas.html), but sometimes it's difficult to 39 | access the data or as the database grows, the performance drops so quick that it 40 | makes them unusable. 41 | 42 | [tasklite](https://tasklite.org) is a promising project that tackles most of the 43 | points above. But as it's written in 44 | [Haskel](https://en.wikipedia.org/wiki/Haskell_%28programming_language%29), 45 | I won't be able to add support for the features I need. 46 | 47 | # A quick demonstration 48 | 49 | Let's see `pydo` in action. We'll first add three tasks to our list. 50 | 51 | ```bash 52 | $: pydo add Buy milk 53 | [+] Added task 0: Buy milk 54 | $: pydo add Buy eggs 55 | [+] Added task 1: Buy eggs 56 | $: pydo add Bake cake 57 | [+] Added task 2: Bake cake 58 | ``` 59 | 60 | Now let's see the list. 61 | 62 | ```bash 63 | $: pydo list 64 | ╷ 65 | ID │ Description 66 | ╺━━━━┿━━━━━━━━━━━━━╸ 67 | 0 │ Buy milk 68 | 1 │ Buy eggs 69 | 2 │ Bake cake 70 | ╵ 71 | ``` 72 | 73 | Suppose we bought our ingredients and wish to mark the first two tasks as done. 74 | 75 | ```bash 76 | $: pydo do 0 1 77 | [+] Closed task 0: Buy milk with state done 78 | [+] Closed task 1: Buy eggs with state done 79 | 80 | $: pydo list 81 | ╷ 82 | ID │ Description 83 | ╺━━━━┿━━━━━━━━━━━━━╸ 84 | 2 │ Bake cake 85 | ╵ 86 | ``` 87 | 88 | Those are the first three features, the `add`, `list` and `done` commands, but 89 | they represent all you need to know, to get started with `pydo`. 90 | 91 | But there are hundreds of other features, so if you learn more, you can do more. 92 | It's entirely up to you to choose how you use `pydo`. Stick to the 93 | three commands above, or learn about sophisticated agile support, custom reports, 94 | user defined metadata and more. 95 | 96 | # Install 97 | 98 | To install pydo, run: 99 | 100 | ```bash 101 | pip install py-do 102 | ``` 103 | 104 | The installation method will create a new pydo database at 105 | `~/.local/share/pydo/database.tinydb`. 106 | 107 | `pydo` reads it's configuration from the yaml file located at 108 | `~/.local/share/pydo/config.yaml`. The [default 109 | template](https://github.com/lyz-code/pydo/blob/master/assets/config.yaml) is 110 | provided at installation time. 111 | 112 | # What's next? 113 | 114 | Probably the most important next step is to start using `pydo`. 115 | Capture your tasks, don't try to remember them. Review your task list to keep it 116 | current. Consult your task list to guide your actions. Develop the habit. 117 | 118 | It doesn't take long until you realize that you might want to change your 119 | workflow. Perhaps you are missing due dates, and need more defined deadlines. 120 | Perhaps you need to make greater use of tags to help you filter tasks 121 | differently. You'll know if your workflow is not helping you as much as it 122 | could. 123 | 124 | This is when you might look closer at the 125 | [docs](https://lyz-code.github.io/pydo) and the recommended Best Practices. 126 | 127 | If you want to contribute to the project follow [this 128 | guidelines](https://lyz-code.github.io/pydo/contributing). 129 | 130 | Welcome to `pydo`. 131 | -------------------------------------------------------------------------------- /assets/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Configuration of the reports. 3 | reports: 4 | # Datetime strftime compatible string to print dates. 5 | date_format: '%Y-%m-%d %H:%M' 6 | 7 | # Equivalence between task attributes and how they are shown in the reports 8 | task_attribute_labels: 9 | id_: ID 10 | description: Description 11 | agile: Agile 12 | body: Body 13 | closed: Closed 14 | created: Created 15 | due: Due 16 | estimate: Est 17 | fun: Fun 18 | parent_id: Parent 19 | area: Area 20 | priority: Pri 21 | state: State 22 | recurrence: Recur 23 | recurrence_type: RecurType 24 | tags: Tags 25 | value: Val 26 | wait: Wait 27 | willpower: WP 28 | 29 | # Definition of reports over a group of tasks: 30 | # 31 | # Each of them has the following properties: 32 | # * report_name: It's the key that identifies the report. 33 | # * columns: Ordered list of task attributes to print. 34 | # * filter: Dictionary of task properties that narrow down the tasks you 35 | # want to print. 36 | task_reports: 37 | # Open: Print active tasks. 38 | open: 39 | filter: 40 | active: true 41 | type: task 42 | columns: 43 | - id_ 44 | - description 45 | - area 46 | - priority 47 | - tags 48 | - due 49 | - parent_id 50 | 51 | # Closed: Print inactive tasks. 52 | closed: 53 | filter: 54 | active: false 55 | type: task 56 | columns: 57 | - id_ 58 | - description 59 | - area 60 | - priority 61 | - tags 62 | - due 63 | - parent_id 64 | 65 | # Recurring: Print repeating and recurring active parent tasks. 66 | recurring: 67 | filter: 68 | active: true 69 | type: recurrent_task 70 | columns: 71 | - id_ 72 | - description 73 | - recurrence 74 | - recurrence_type 75 | - area 76 | - priority 77 | - tags 78 | - due 79 | 80 | # Frozen: Print repeating and recurring inactive parent tasks. 81 | frozen: 82 | filter: 83 | state: frozen 84 | type: recurrent_task 85 | columns: 86 | - id_ 87 | - description 88 | - recurrence 89 | - recurrence_type 90 | - area 91 | - priority 92 | - tags 93 | - due 94 | - parent_id 95 | 96 | # Level of logging verbosity. One of ['info', 'debug', 'warning']. 97 | verbose: info 98 | 99 | # URL specifying the connection to the database. For example: 100 | # * tinydb: tinydb:////home/user/database.tinydb 101 | # * sqlite: sqlite:////home/user/mydb.sqlite 102 | # * mysql: mysql://scott:tiger@localhost/mydatabase 103 | database_url: tinydb://~/.local/share/pydo/database.tinydb 104 | -------------------------------------------------------------------------------- /docs/adr/001-change-id-from-str-to-int.md: -------------------------------------------------------------------------------- 1 | Date: 2021-05-27 2 | 3 | # Status 4 | 6 | Accepted 7 | 8 | # Context 9 | 10 | Older versions of `pydo` use fulid ids to define the entities as it's easy to 11 | create the short ids from them in order to present them to the user through the 12 | cli interface. To do it, at service level it creates the next id with the 13 | `_create_next_id` function. 14 | 15 | The current system adds a lot of complexity for the sake of identifying entities 16 | through the minimum amount of keystrokes chosen from a set of keys defined by 17 | the user. 18 | 19 | Newer versions of the repository-orm library manages automatically id increments 20 | if the `id_` field is of type `int`. This allows the addition of entities 21 | without id, which will be very useful to create children task objects at model level 22 | instead of service level. 23 | 24 | I'm more and more convinced that the ideal everyday user interface is not a command line 25 | program, but an REPL interface. The first one is meant only for bulk operations, 26 | not for operations on one item. That means that being able to tell apart tasks 27 | using short ids has loosing importance as this idea evolves. 28 | 29 | # Proposals 30 | 31 | Change the entities `id_` type from `str` to `Optional[int]`, so pydo can 32 | delegate it's management to the repository. 33 | 34 | When using the command line, we can show the ids as numbers, or optionally do 35 | the translation from numbers to the user chosen keys. But we won't be as optimal 36 | as before, because currently the short ids are defined by the subset of open 37 | tasks, and we'll use all the tasks, so more keystrokes will be needed. But this 38 | is acceptable as most of the times you'll use the REPL interface to interact 39 | with individual tasks, and when bulk editing you'll use task filters instead of 40 | the ids. We can even envision how to effectively do bulk edits through the REPL. 41 | 42 | The REPL interface will not even show the ids, you'll move through the tasks by 43 | vim movement keys or by fuzzy searching. 44 | 45 | # Decision 46 | 47 | 48 | # Consequences 49 | 50 | -------------------------------------------------------------------------------- /docs/adr/002-list-task-tui.md: -------------------------------------------------------------------------------- 1 | Date: 2021-10-16 2 | 3 | # Status 4 | 6 | Draft 7 | 8 | # Context 9 | 10 | 11 | 12 | # Proposals 13 | 14 | 15 | We could use a [scroll 16 | marging](https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/examples/full-screen/simple-demos/margins.py) 17 | if the text is too long. or use a scrollable panel (see below). 18 | 19 | Get inspiration in the [scrollable panel 20 | example](https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/examples/full-screen/scrollable-panes/simple-example.py) 21 | to create the list of tasks. 22 | 23 | To paint the different lines use the `style` argument with `class:row` and 24 | `class:alternate_row` 25 | 26 | Use `FormattedTextControl` to format text? 27 | https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/examples/full-screen/split-screen.py 28 | 29 | # Decision 30 | 31 | 32 | # Consequences 33 | 34 | -------------------------------------------------------------------------------- /docs/adr/0xx-agenda.md: -------------------------------------------------------------------------------- 1 | Date: 2021-09-23 2 | 3 | # Status 4 | 6 | Draft 7 | 8 | # Context 9 | 10 | We want in a user friendly way to: 11 | 12 | * See the tasks that need to be done today, and in this week 13 | * Plan the day, which task needs to be done when 14 | * Get notifications when it's time to do a task (for example attend a meeting) 15 | * Graphical visualization on how much time is left for the active task 16 | 17 | # Proposals 18 | 19 | We can create two TUI interfaces: 20 | 21 | * Landing page: Where the relevant information about the state of the tasks is 22 | shown 23 | * Day planning: Where the user can organize the day. 24 | 25 | ## Landing page 26 | 27 | The user will have the choice to activate or deactivate any of the next 28 | sections. 29 | 30 | ### Day's plan section 31 | 32 | Shows the day's plan, something like: 33 | 34 | ``` 35 | 07:00 - 08:00 Breakfast 36 | 07:30 Review Anki 37 | 08:00 - 13:00 Work 38 | 08:00 Work on task 1 39 | 11:00 - 11:30 Meeting 40 | 13:00 - 14:00 Lunch 41 | ``` 42 | 43 | Where: 44 | 45 | * Active elements are in green. An element is activated if `their start planned 46 | date < actual date` and they are not closed. 47 | * Overdue elements are in red. An element is overdue if `their end planned date 48 | > actual date` and they are not closed. 49 | * Closed elements are in grey. 50 | 51 | If a task doesn't have an end planned date, it means that it can be done 52 | whenever in the day since the start planned date. 53 | 54 | If a task has the `notify` attribute set, an alert will be raised when the 55 | `start` date arrives, so that it's actionable, it'll also raise another alert 56 | when it becomes overdue. If a task has the `start_reminder` or `end_reminder` 57 | attributes set, a notification will be shown that amount of time before the 58 | start or end date. 59 | 60 | By default the cursor will be at the first active task. 61 | 62 | #### Controls 63 | 64 | The user will be able to interact with the TUI through: 65 | 66 | * `jk`: to move between the elements 67 | * `d`: Toggle element state from done to todo. 68 | * `D`: Delete the element 69 | * `h`: Toggle the hiding of completed elements 70 | * `m`: Toggle the moving mode. Moving mode will move the highlighted element with 71 | `jk`. 72 | * `enter`: Enter the Task TUI 73 | * `e`: Edit the highlighted element description. 74 | * `a`: Add an element through the task creation TUI 75 | 76 | ### Close future task section 77 | 78 | Shows the tasks that have a due date in the next X days: 79 | 80 | ``` 81 | ----------- 2021-09-25 -------------- 82 | 11:00 Meeting 83 | Task 2 84 | 85 | ----------- 2021-09-26 -------------- 86 | Task 3 87 | ``` 88 | 89 | #### Controls 90 | 91 | The user will be able to interact with the TUI through: 92 | 93 | * `jk`: to move between the elements 94 | * `d`: Toggle element state from done to todo. 95 | * `D`: Delete the element 96 | * `m`: Toggle the moving mode. Moving mode will move the highlighted element with 97 | `jk`. 98 | * `enter`: Enter the Task TUI 99 | * `e`: Edit the highlighted element description. 100 | * `a`: Add an element through the task creation TUI 101 | 102 | ### Notifications section 103 | 104 | Shows relevant events related to the tasks state: 105 | 106 | * A task has become overdue 107 | * A task has become actionable 108 | 109 | ``` 110 | Overdue: Task 1 111 | Actionable: Task 2 112 | ``` 113 | 114 | #### Controls 115 | 116 | The user will be able to interact with the TUI through: 117 | 118 | * `jk`: to move between the elements 119 | * `d`: Mark the element as seen 120 | * `enter`: Enter the Task TUI 121 | 122 | ### Next tasks section 123 | 124 | An ordered list of the tasks that should be actioned upon next. Returned by the 125 | `Next` report. 126 | 127 | #### Controls 128 | 129 | The user will be able to interact with the TUI through: 130 | 131 | * `jk`: to move between the elements 132 | * `d`: Toggle element state from done to todo. 133 | * `D`: Toggle element state from deleted to todo. 134 | * `m`: Toggle the moving mode. Moving mode will move the highlighted element with 135 | `jk`. 136 | * `enter`: Enter the Task TUI 137 | * `e`: Edit the highlighted element description. 138 | * `a`: Add an element through the task creation TUI 139 | 140 | ### Task section 141 | 142 | Shows the active Task TUI. Defined in [this adr](). 143 | 144 | ## Day planning 145 | 146 | * The tasks for the day need to have a `plan` date. 147 | 148 | # Decision 149 | 150 | 151 | # Consequences 152 | 153 | -------------------------------------------------------------------------------- /docs/adr/adr.md: -------------------------------------------------------------------------------- 1 | [ADR](https://lyz-code.github.io/blue-book/adr/) are short text documents that 2 | captures an important architectural decision made along with its context and 3 | consequences. 4 | 5 | ```mermaid 6 | graph TD 7 | 001[001: Deprecation of Fulids] 8 | 00X[00X: Subtasks and steps] 9 | 00Y[00X: Agenda] 10 | 11 | click 001 "https://lyz-code.github.io/pydo/adr/001-change-id-from-str-to-int" _blank 12 | click 00X "https://lyz-code.github.io/pydo/adr/0xx-subtasks-and-steps" _blank 13 | click 00Y "https://lyz-code.github.io/pydo/adr/0xx-agenda" _blank 14 | 15 | 001:::accepted 16 | 00X:::draft 17 | 00Y:::draft 18 | 19 | classDef draft fill:#CDBFEA; 20 | classDef proposed fill:#B1CCE8; 21 | classDef accepted fill:#B1E8BA; 22 | classDef rejected fill:#E8B1B1; 23 | classDef deprecated fill:#E8B1B1; 24 | classDef superseeded fill:#E8E5B1; 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/areas.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Areas 3 | date: 20200301 4 | author: Lyz 5 | --- 6 | 7 | Once you feel comfortable with the [basic usage](basic_usage.md) of `pydo`, you 8 | may want to explore the different features it has to adapt it to your workflow. 9 | 10 | As the number of tasks starts to increase, it's convenient to group them 11 | together to help us with the prioritization and visualization. 12 | 13 | One way of doing so is using areas. An area is an optional category that 14 | defines the purpose of a task, so a task can *only have one area*. If 15 | you feel that a task might need two areas or if you have hierarchical 16 | problems with your tasks, you may want to use [tags](tags.md) instead. 17 | 18 | For example, you can use `clean` for cleaning tasks, or `task_management` for `pydo` 19 | developing tasks. 20 | 21 | To add a area to a task, use the `area` or `ar` keywords. 22 | 23 | ```bash 24 | pydo add Improve pydo documentation ar:task_management 25 | ``` 26 | 27 | To see all the existing areas, use the `areas` report: 28 | 29 | ```code 30 | $: pydo areas 31 | ╷ 32 | Name │ Open Tasks 33 | ╺━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━╸ 34 | None │ 2 35 | task_management │ 1 36 | ╵ 37 | ``` 38 | 39 | To change a task area use the `mod` command: 40 | 41 | ```bash 42 | pydo mod {{ task_filter }} ar:new_area 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/basic_usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Usage 3 | date: 20200301 4 | author: Lyz 5 | --- 6 | 7 | All you need to know to use `pydo` effectively are these five commands (`add`, 8 | `do`, `rm`, `mod` and `open`). 9 | 10 | # add 11 | 12 | To add a task run: 13 | 14 | ```bash 15 | pydo add Improve the pydo manual 16 | ``` 17 | 18 | It's also possible to add [tags](tags.md) or [areas](areas.md) when creating a task: 19 | 20 | ```bash 21 | pydo add Improve the pydo manual ar:task_management +python 22 | ``` 23 | 24 | # open 25 | 26 | To see the open tasks run: 27 | 28 | ```bash 29 | pydo open 30 | ``` 31 | 32 | By default, `open` is the default command, so you can run `pydo` alone. If you 33 | don't like the order of the tasks, you can [sort them](sorting.md). 34 | 35 | # do 36 | 37 | If you've completed a task, run: 38 | 39 | ```bash 40 | pydo do {{ task_filter }} 41 | ``` 42 | 43 | Where `{{ task_filter }}` can be a task id extracted from the `open` report or 44 | a task expression like `ar:task_management +python`. 45 | 46 | # rm 47 | 48 | If you no longer need a task, run: 49 | 50 | ```bash 51 | pydo del {{ task_filter }} 52 | ``` 53 | 54 | # mod 55 | 56 | To change existent tasks use the following syntax. 57 | 58 | ```bash 59 | pydo mod '{{ task_filter }}' {{ task_attributes }} 60 | ``` 61 | 62 | Notice that the `task_filter` needs to be quoted if the filter contains more 63 | than one word. 64 | 65 | For example, to change the description of the first task, we'd do: 66 | 67 | ```bash 68 | pydo mod 0 Improve the pydo documentation 69 | ``` 70 | 71 | If you are new to `pydo`, it's recommended that you stop here, start managing 72 | your tasks for a while. When you are comfortable with basic `pydo` usage, there 73 | are many other features you can learn about. While you are not expected to learn 74 | all of them, or even find them useful, you might find what you need. 75 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | So you've started using `pydo` and want to show your gratitude to the project, 2 | depending on your programming skills there are different ways to do so. 3 | 4 | # I don't know how to program 5 | 6 | There are several ways you can contribute: 7 | 8 | * [Open an issue](https://github.com/lyz-code/pydo/issues/new) if you encounter 9 | any bug or to let us know if you want a new feature to be implemented. 10 | * Spread the word about the program. 11 | * Review the [documentation](https://lyz-code.github.io/pydo) and try to improve 12 | it. 13 | 14 | # I know how to program in Python 15 | 16 | If you have some python knowledge there are some additional ways to contribute. 17 | We've ordered the [issues](https://github.com/lyz-code/pydo/issues) in 18 | [milestones](https://github.com/lyz-code/pydo/milestones), check the issues in 19 | the smaller one, as it's where we'll be spending most of our efforts. Try the 20 | [good first 21 | issues](https://github.com/lyz-code/pydo/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22), 22 | as they are expected to be easier to get into the project. 23 | 24 | We develop the program with 25 | [TDD](https://en.wikipedia.org/wiki/Test-driven_development), so we expect any 26 | contribution to have it's associated tests. We also try to maintain an updated 27 | [documentation](https://lyz-code.github.io/pydo) of the project, so think if 28 | your contribution needs to update it. 29 | 30 | The [database schema](database_schema.md) is defined with 31 | [SQLAlchemy](https://lyz-code.github.io/blue-book/coding/python/sqlalchemy/) 32 | objects and maintained with 33 | [Alembic](https://lyz-code.github.io/blue-book/coding/python/alembic/). 34 | 35 | To generate the testing data we use 36 | [FactoryBoy](https://lyz-code.github.io/blue-book/coding/python/factoryboy/) 37 | with [Faker](https://lyz-code.github.io/blue-book/coding/python/faker/). 38 | 39 | We know that the expected code quality is above average. Therefore it might 40 | be changeling to get the initial grasp of the project structure, know how to make the 41 | tests, update the documentation or use all the project technology stack. but please 42 | don't let this fact discourage you from contributing: 43 | 44 | * If you want to develop a new feature, explain how you'd like to do it in the related issue. 45 | * If you don't know how to test your code, do the pull request without the tests 46 | and we'll try to do them for you. 47 | 48 | Finally, to ensure a quicker pull request resolution, remember to *[Allow edits from 49 | maintainers](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork)*. 50 | 51 | Check [Developing pydo](developing/database_schema.md) to get better insights of the 52 | internals of the program. 53 | 54 | ## Issues 55 | 56 | Questions, feature requests and bug reports are all welcome as issues. 57 | **To report a security vulnerability, please see our [security 58 | policy](https://github.com/lyz-code/pydo/security/policy) instead.** 59 | 60 | To make it as simple as possible for us to help you, please include the output 61 | of the following call in your issue: 62 | 63 | ```bash 64 | python -c "import pydo.version; print(pydo.version.version_info())" 65 | ``` 66 | 67 | or if you have `make` installed, you can use `make version`. 68 | 69 | Please try to always include the above unless you're unable to install `pydo` or know it's not relevant to your question or 70 | feature request. 71 | 72 | # Pull Requests 73 | 74 | *pydo* is released regularly so you should see your 75 | improvements release in a matter of days or weeks. 76 | 77 | !!! note 78 | Unless your change is trivial (typo, docs tweak etc.), please create an 79 | issue to discuss the change before creating a pull request. 80 | 81 | If you're looking for something to get your teeth into, check out the ["help 82 | wanted"](https://github.com/lyz-code/pydo/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) 83 | label on github. 84 | 85 | # Development facilities 86 | 87 | To make contributing as easy and fast as possible, you'll want to run tests and 88 | linting locally. 89 | 90 | !!! note "" 91 | **tl;dr**: use `make format` to fix formatting, `make` to run tests and linting & `make docs` 92 | to build the docs. 93 | 94 | You'll need to have python 3.6, 3.7, or 3.8, virtualenv, git, and make installed. 95 | 96 | * Clone your fork and go into the repository directory: 97 | 98 | ```bash 99 | git clone git@github.com:/pydo.git 100 | cd pydo 101 | ``` 102 | 103 | * Set up the virtualenv for running tests: 104 | 105 | ```bash 106 | virtualenv -p `which python3.7` env 107 | source env/bin/activate 108 | ``` 109 | 110 | * Install pydo, dependencies and configure the 111 | pre-commits: 112 | 113 | ```bash 114 | make install 115 | ``` 116 | 117 | * Checkout a new branch and make your changes: 118 | 119 | ```bash 120 | git checkout -b my-new-feature-branch 121 | ``` 122 | 123 | * Fix formatting and imports: pydo uses 124 | [black](https://github.com/ambv/black) to enforce formatting and 125 | [isort](https://github.com/timothycrosley/isort) to fix imports. 126 | 127 | ```bash 128 | make format 129 | ``` 130 | 131 | * Run tests and linting: 132 | 133 | ```bash 134 | make 135 | ``` 136 | 137 | There are more sub-commands in Makefile like `test-code`, `test-examples`, 138 | `mypy` or `security` which you might want to use, but generally `make` 139 | should be all you need. 140 | 141 | If you need to pass specific arguments to pytest use the `ARGS` variable, 142 | for example `make test ARGs='-k test_markdownlint_passes'`. 143 | 144 | * Build documentation: If you have changed the documentation, make sure it 145 | builds the static site. Once built it will serve the documentation at 146 | `localhost:8000`: 147 | 148 | ```bash 149 | make docs 150 | ``` 151 | 152 | * Commit, push, and create your pull request. 153 | 154 | * Make a new release: To generate the changelog of the new changes, build the 155 | package, upload to pypi and clean the build files use `make bump`. 156 | 157 | We'd love you to contribute to *pydo*! 158 | -------------------------------------------------------------------------------- /docs/dates.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dates 3 | date: 20200301 4 | author: Lyz 5 | --- 6 | 7 | A task does not require a due date: 8 | 9 | ```pydo 10 | pydo add Send Alice a birthday card 11 | ``` 12 | 13 | However these are the kind of tasks can benefit from having a due date. 14 | 15 | # The `due` date 16 | 17 | Use the `due` task attribute to define the date by which a task 18 | needs to be completed. Using the previous example, you can set the `due` date to 19 | Alice's birthday: 20 | 21 | ```bash 22 | pydo add Send Alice a birthday card due:2016-11-08 23 | ``` 24 | 25 | Now your task has an associated due date, to help you determine when you need to 26 | work on it. 27 | 28 | To change the due date of a task use the `mod` command: 29 | 30 | ```bash 31 | pydo mod {{ task_id }} due:{{ new_due_date }} 32 | ``` 33 | 34 | # Date format 35 | 36 | `pydo` understands different ways of expressing dates. 37 | 38 | * `YYYY-MM-DD`: Enter year, month and day. 39 | * `YYYY-MM-DDTHH:mm`: Enter year, month, day, hour and minute. 40 | * `now`: Current local date and time. 41 | * `tomorrow`: Local date for tomorrow, same as `now + 24h`. 42 | * `yesterday`: Local date for yesterday, same as `now - 24h`. 43 | * `monday`, `tuesday`, ...: Local date for the specified day, after today. There 44 | is also available in the short three lettered version: `mon`, `tue`... 45 | * Combination of the next operators to specify a relative date from `now`: 46 | 47 | * `s`: seconds, 48 | * `m`: minutes. 49 | * `h`: hours, 50 | * `d`: days. 51 | * `w`: weeks. 52 | * `mo`: months. 53 | * `rmo`: relative months. Use this if you want to set the 3rd Friday of the month. 54 | * `y`: years. 55 | 56 | So `1y2mo30s` will set the date to `now + 1 year + 2 months + 30 seconds`. 57 | -------------------------------------------------------------------------------- /docs/developing/database_schema.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Database Schema 3 | date: 20200424 4 | author: Lyz 5 | --- 6 | 7 | The schema is defined in the 8 | [models.py](https://github.com/lyz-code/pydo/blob/master/pydo/models.py) file 9 | through 10 | [SQLAlchemy](https://lyz-code.github.io/blue-book/coding/python/sqlalchemy/) 11 | objects. 12 | 13 | To visualize the schema we've used 14 | [wwwsqldesigner](https://github.com/ondras/wwwsqldesigner/wiki) through their 15 | [hosted instance](https://ondras.zarovi.cz/sql/demo/). We load the 16 | [database_schema.xml](https://github.com/lyz-code/pydo/tree/master/pydo/migrations/sql_schema.xml) 17 | to modify it and save it back as xml in the repo. 18 | 19 | ![](../../images/database_schema.jpg) 20 | -------------------------------------------------------------------------------- /docs/estimate.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Estimate 3 | date: 20200301 4 | author: Lyz 5 | --- 6 | 7 | The `estimate` optional task attribute registers how many hours do you expect to 8 | spend on a task. 9 | 10 | In the [Scrum](https://en.wikipedia.org/wiki/Scrum_%28software_development%29) 11 | methodology, assigning time estimate to tasks is a sin. Instead they use *story 12 | points*, which is an dimensionless quantity to compare tasks between themselves. 13 | The advantages of using *story points* is that you are not commiting to do 14 | a task in a specified amount of time and that it's easier to estimate if you 15 | are going to need more or less time to do a task than an other in relative 16 | terms. Once you complete several 17 | [sprints](https://en.wikipedia.org/wiki/Scrum_%28software_development%29#Sprint), 18 | this estimate method is said to be more accurate. 19 | 20 | I've tried using *story points* in the past, but I find them unintuitive and 21 | useless when trying to improve your estimations. But as the `estimate` keyword 22 | accepts any float, you can use it to store *story points*. 23 | 24 | To set the estimate of a task use the `est` or `estimate` keyword: 25 | 26 | ``` 27 | $: pydo add Task that takes 5 hours to complete est:5 28 | [+] Added task 3: Task that takes 5 hours to complete 29 | ``` 30 | 31 | Right now we only use the `estimate` for filtering or visualization purposes. 32 | But we plan to support reports to do data analysis on the difference between the 33 | estimation and the real time required to complete the task, so as to suggest 34 | estimation improvements. 35 | -------------------------------------------------------------------------------- /docs/export.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Export 3 | date: 20211007 4 | author: Lyz 5 | --- 6 | 7 | All your data lives at the `~/.local/share/pydo/database.tinydb` in json format, 8 | you can use it to migrate the data to other systems. 9 | -------------------------------------------------------------------------------- /docs/fun.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Fun 3 | date: 20200424 4 | author: Lyz 5 | --- 6 | 7 | The `fun` optional task attribute registers how much light-hearted pleasure, 8 | enjoyment, or amusement does the execution of the task gives. 9 | 10 | As with [willpower](willpower.md), if your tasks have this property, it can help 11 | you determine the tasks to be done during the sprint and in what order so as not 12 | to burn yourself out. 13 | 14 | To set the value of a task use the `fun` keyword: 15 | 16 | ``` 17 | $: pydo add Go hiking fun:4 18 | [+] Added task 6: Go hiking 19 | ``` 20 | 21 | As with the [priority](priority.md), I use a range of values from `0` to `5` but 22 | the parameter allows any integer. 23 | -------------------------------------------------------------------------------- /docs/images/database_schema.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/docs/images/database_schema.jpg -------------------------------------------------------------------------------- /docs/images/logo.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/docs/images/logo.bmp -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | [![Actions Status](https://github.com/lyz-code/pydo/workflows/Tests/badge.svg)](https://github.com/lyz-code/pydo/actions) 2 | [![Actions Status](https://github.com/lyz-code/pydo/workflows/Build/badge.svg)](https://github.com/lyz-code/pydo/actions) 3 | [![Coverage Status](https://coveralls.io/repos/github/lyz-code/pydo/badge.svg?branch=master)](https://coveralls.io/github/lyz-code/pydo?branch=master) 4 | 5 | # What is Pydo? 6 | 7 | `pydo` is a free software command line task manager built in 8 | [Python](https://en.wikipedia.org/wiki/Python_%28programming_language%29). 9 | 10 | # Why another CLI Task Manager? 11 | 12 | [Taskwarrior](https://taskwarrior.org/) has been the gold standard for CLI task 13 | managers so far. However, It has the following inconveniences: 14 | 15 | * It uses a plaintext file as data storage. 16 | * It stores the data in a non standard way in different files. 17 | * It's written in C, which I don't speak. 18 | * It's development has come to [code maintenance 19 | only](https://github.com/GothenburgBitFactory/taskwarrior/graphs/code-frequency). 20 | * There are many issues with how it handles 21 | [recurrence](https://taskwarrior.org/docs/design/recurrence.html). 22 | * It doesn't have [friendly task 23 | identifiers](https://lyz-code.github.io/pydo/developing/sulids). 24 | * There is no way of accessing the task time tracking from the python library. 25 | 26 | And lacks the following features: 27 | 28 | * Native Kanban or Scrum support. 29 | * Task estimations. 30 | * Easy report creation. 31 | * Easy way to manage the split of a task in subtasks. 32 | * Freezing of recurrent tasks. 33 | 34 | Most of the above points can be addressed through the [Taskwarrior plugin 35 | system](https://taskwarrior.org/docs/3rd-party.html) or 36 | [udas](https://taskwarrior.org/docs/udas.html), but sometimes it's difficult to 37 | access the data or as the database grows, the performance drops so quick that it 38 | makes them unusable. 39 | 40 | [tasklite](https://tasklite.org) is a promising project that tackles most of the 41 | points above. But as it's written in 42 | [Haskel](https://en.wikipedia.org/wiki/Haskell_%28programming_language%29), 43 | I won't be able to add support for the features I need. 44 | 45 | # A quick demonstration 46 | 47 | Let's see `pydo` in action. We'll first add three tasks to our list. 48 | 49 | ```code 50 | $: pydo add Buy milk 51 | [+] Added task 0: Buy milk 52 | $: pydo add Buy eggs 53 | [+] Added task 1: Buy eggs 54 | $: pydo add Bake cake 55 | [+] Added task 2: Bake cake 56 | ``` 57 | 58 | Now let's see the list. 59 | 60 | ```code 61 | $: pydo list 62 | ╷ 63 | ID │ Description 64 | ╺━━━━┿━━━━━━━━━━━━━╸ 65 | 0 │ Buy milk 66 | 1 │ Buy eggs 67 | 2 │ Bake cake 68 | ╵ 69 | ``` 70 | 71 | Suppose we bought our ingredients and wish to mark the first two tasks as done. 72 | 73 | ```code 74 | $: pydo do 0 1 75 | [+] Closed task 0: Buy milk with state done 76 | [+] Closed task 1: Buy eggs with state done 77 | 78 | $: pydo list 79 | ╷ 80 | ID │ Description 81 | ╺━━━━┿━━━━━━━━━━━━━╸ 82 | 2 │ Bake cake 83 | ╵ 84 | ``` 85 | 86 | Those are the first three features, the `add`, `list` and `done` commands, but 87 | they represent all you need to know, to get started with `pydo`. 88 | 89 | But there are hundreds of other features, so if you learn more, you can do more. 90 | It's entirely up to you to choose how you use `pydo`. Stick to the 91 | three commands above, or learn about sophisticated agile support, custom reports, 92 | user defined metadata and more. 93 | 94 | # Install 95 | 96 | To install pydo, run: 97 | 98 | ```bash 99 | pip install py-do 100 | ``` 101 | 102 | The installation method will create a new pydo database at 103 | `~/.local/share/pydo/database.tinydb`. 104 | 105 | `pydo` reads it's configuration from the yaml file located at 106 | `~/.local/share/pydo/config.yaml`. The [default 107 | template](https://github.com/lyz-code/pydo/blob/master/assets/config.yaml) is 108 | provided at installation time. 109 | 110 | # What's next? 111 | 112 | Probably the most important next step is to start using `pydo`. 113 | Capture your tasks, don't try to remember them. Review your task list to keep it 114 | current. Consult your task list to guide your actions. Develop the habit. 115 | 116 | It doesn't take long until you realize that you might want to change your 117 | workflow. Perhaps you are missing due dates, and need more defined deadlines. 118 | Perhaps you need to make greater use of tags to help you filter tasks 119 | differently. You'll know if your workflow is not helping you as much as it 120 | could. 121 | 122 | This is when you might look closer at the 123 | [docs](https://lyz-code.github.io/pydo) and the recommended Best Practices. 124 | 125 | If you want to contribute to the project follow [this 126 | guidelines](https://lyz-code.github.io/pydo/contributing). 127 | 128 | Welcome to `pydo`. 129 | 130 | # References 131 | 132 | As most open sourced programs, `pydo` is standing on the shoulders of 133 | giants, namely: 134 | 135 | [Pytest](https://docs.pytest.org/en/latest) 136 | : Testing framework, enhanced by the awesome 137 | [pytest-cases](https://smarie.github.io/python-pytest-cases/) library that made 138 | the parametrization of the tests a lovely experience. 139 | 140 | [Mypy](https://mypy.readthedocs.io/en/stable/) 141 | : Python static type checker. 142 | 143 | [Flakehell](https://github.com/life4/flakehell) 144 | : Python linter with [lots of 145 | checks](https://lyz-code.github.io/blue-book/devops/flakehell/#plugins). 146 | 147 | [Black](https://black.readthedocs.io/en/stable/) 148 | : Python formatter to keep a nice style without effort. 149 | 150 | [Autoimport](https://github.com/lyz-code/autoimport) 151 | : Python formatter to automatically fix wrong import statements. 152 | 153 | [isort](https://github.com/timothycrosley/isort) 154 | : Python formatter to order the import statements. 155 | 156 | [Pip-tools](https://github.com/jazzband/pip-tools) 157 | : Command line tool to manage the dependencies. 158 | 159 | [Mkdocs](https://www.mkdocs.org/) 160 | : To build this documentation site, with the 161 | [Material theme](https://squidfunk.github.io/mkdocs-material). 162 | 163 | [Safety](https://github.com/pyupio/safety) 164 | : To check the installed dependencies for known security vulnerabilities. 165 | 166 | [Bandit](https://bandit.readthedocs.io/en/latest/) 167 | : To finds common security issues in Python code. 168 | 169 | [Yamlfix](https://github.com/lyz-code/yamlfix) 170 | : YAML fixer. 171 | 172 | # Contributing 173 | 174 | For guidance on setting up a development environment, and how to make 175 | a contribution to *pydo*, see [Contributing to 176 | pydo](https://lyz-code.github.io/pydo/contributing). 177 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Pydo documentation 2 | site_author: Lyz 3 | site_url: https://lyz-code.github.io/pydo 4 | nav: 5 | - Introduction: 'index.md' 6 | - Basic Usage: 'basic_usage.md' 7 | - Configuration: 'configuration.md' 8 | - Update: 'update.md' 9 | - Advanced Usage: 10 | - Projects: 'advanced_usage/projects.md' 11 | - Tags: 'advanced_usage/tags.md' 12 | - Dates: 'advanced_usage/dates.md' 13 | - Priority: 'advanced_usage/priority.md' 14 | - Recurrence: 'advanced_usage/recurrence.md' 15 | - Estimate: 'advanced_usage/estimate.md' 16 | - Value: 'advanced_usage/objective_value.md' 17 | - Willpower: 'advanced_usage/willpower.md' 18 | - Fun: 'advanced_usage/fun.md' 19 | - Export: 'advanced_usage/export.md' 20 | - Future Features: future_features.md 21 | - Contributing: 'contributing.md' 22 | - Developing: 23 | - Database Schema: 'developing/database_schema.md' 24 | - Friendly IDs: 'developing/fulids.md' 25 | - Related: 'related.md' 26 | 27 | plugins: 28 | - search 29 | - autolinks 30 | - git-revision-date-localized: 31 | type: timeago 32 | - minify: 33 | minify_html: true 34 | 35 | markdown_extensions: 36 | - admonition 37 | - meta 38 | - toc: 39 | permalink: true 40 | baselevel: 2 41 | - pymdownx.arithmatex 42 | - pymdownx.betterem: 43 | smart_enable: all 44 | - pymdownx.caret 45 | - pymdownx.critic 46 | - pymdownx.details 47 | - pymdownx.emoji: 48 | emoji_generator: !!python/name:pymdownx.emoji.to_svg 49 | - pymdownx.inlinehilite 50 | - pymdownx.magiclink 51 | - pymdownx.mark 52 | - pymdownx.smartsymbols 53 | - pymdownx.superfences 54 | - pymdownx.tasklist: 55 | custom_checkbox: true 56 | - pymdownx.tilde 57 | 58 | theme: 59 | name: material 60 | custom_dir: 'theme' 61 | logo: 'images/logo.bmp' 62 | palette: 63 | primary: 'blue grey' 64 | accent: 'light blue' 65 | 66 | extra_css: 67 | - 'stylesheets/extra.css' 68 | - 'stylesheets/links.css' 69 | 70 | repo_name: 'lyz-code/pydo' 71 | repo_url: 'https://github.com/lyz-code/pydo' 72 | -------------------------------------------------------------------------------- /docs/objective_value.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Objective value 3 | date: 20200424 4 | author: Lyz 5 | --- 6 | 7 | The `value` optional task attribute registers how much you feel this task 8 | is going to help you achieve a specific goal. It can be associated with 9 | [Scrum business 10 | value](https://medium.com/the-liberators/what-is-this-thing-called-business-value-3b88b734d5a9). 11 | 12 | Business value is an horrendous capitalist term with a lot of implications, 13 | therefore we've shorten it to `value`. 14 | 15 | If you've categorized your tasks in [projects](projects.md), each one 16 | probably has one or several main objectives. If your tasks have this property, 17 | it can help you priorize which ones need to be done first, or measure the 18 | difference in value between sprints. 19 | 20 | To set the value of a task use the `vl` or `value` keyword: 21 | 22 | ``` 23 | $: pydo add Task with high value value:5 24 | [+] Added task 4: Task with high value 25 | ``` 26 | 27 | As with the [priority](priority.md), I use a range of values from `0` (it 28 | doesn't get me closer to the objective at all) to `5` (it's a critical advance 29 | towards the goal) but the parameter allows any integer. 30 | -------------------------------------------------------------------------------- /docs/priority.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Priority 3 | date: 20200301 4 | author: Lyz 5 | --- 6 | 7 | The `priority` optional task attribute registers how urgent a task is. 8 | 9 | The parameter allows any integer, but I use from `0` (really low priority) to 10 | `5` (really high priority), being `3` the standard medium priority. 11 | 12 | To set the priority of a task use the `pri` or `priority` keyword: 13 | 14 | ```bash 15 | $: pydo add Task with highest priority pri:5 16 | [+] Added task 2: Task with highest priority 17 | ``` 18 | 19 | Right now we only use the 20 | `priority` for filtering or visualization purposes. But we plan to support 21 | reports that sort the tasks by their 22 | [urgency](https://github.com/lyz-code/pydo/issues/17). The `priority` will be 23 | one of the main factors to take into account. 24 | -------------------------------------------------------------------------------- /docs/recurrence.md: -------------------------------------------------------------------------------- 1 | Recurrence is used to create periodical tasks, such as paying the rent or 2 | mowing the lawn. 3 | 4 | There are two types of recurrence: 5 | 6 | * *Recurring*: Task which needs to be done every specified period of time, like 7 | day, week, etc. It doesn't matter when you complete the task, the next one 8 | will be created based on the original due date. 9 | * *Repeating*: When this task gets completed or deleted, a duplicate will be 10 | created with the specified time offset from the closing date. I.e. 11 | subsequent tasks get delayed (e.g. mowing the lawn). 12 | 13 | `pydo` implements recurrence with the creation of two kind of tasks: parent and 14 | children. The first one holds the template for the second. Each time a child 15 | is completed or deleted, the parent attributes are copied and the due date is 16 | set according to the recurrence type. 17 | 18 | `pydo` will only maintain one children per parent, so it won't create new tasks 19 | until the existent is either completed or deleted. Furthermore, it will create 20 | only the next actionable task. So if from the last completed children you've 21 | missed 3 tasks, those won't be created. 22 | 23 | # Create a recurring or repeating task. 24 | 25 | To create a recurrent or repeating task, the recurrence time must be set under 26 | the `rec` or `rep` attribute. That attribute must match the [pytho 27 | date](dates.md) format. 28 | 29 | ```bash 30 | $: pydo add Pay the rent due:2021-11-01 rec:1mo 31 | [+] Added recurring task 0: Pay the rent 32 | [+] Added first child task with id 1 33 | 34 | $: pydo add Mow the lawn due:today rep:20d 35 | [+] Added repeating task 1: Mow the lawn 36 | [+] Added first child task with id 2 37 | ``` 38 | 39 | Once they are created, the children will show in the `open` report, but not the 40 | parent. 41 | ```bash 42 | $: pydo 43 | ╷ ╷ ╷ 44 | ID │ Description │ Due │ Parent 45 | ╺━━━━┿━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━┿━━━━━━━━╸ 46 | 1 │ Pay the rent │ 2021-11-01 00:00 │ 0 47 | 2 │ Mow the lawn │ 2021-10-07 13:38 │ 1 48 | ╵ ╵ ╵ 49 | ``` 50 | 51 | The recurrent and repeating parents can be seen with the `recurring` report. 52 | 53 | ```bash 54 | $: pydo recurring 55 | 56 | ╷ ╷ ╷ ╷ 57 | ID │ Description │ Recur │ RecurType │ Due 58 | ╺━━━━┿━━━━━━━━━━━━━━┿━━━━━━━┿━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━╸ 59 | 0 │ Pay the rent │ 1mo │ Recurring │ 2021-11-01 00:00 60 | 1 │ Mow the lawn │ 20d │ Repeating │ 2021-10-07 13:38 61 | ╵ ╵ ╵ ╵ 62 | ``` 63 | 64 | # Completing or deleting a repeating task 65 | 66 | If you complete or delete the children of a recurrent or repeating task, the 67 | next child will be spawned. But if you wish to delete or complete the parent so 68 | no further children gets created, you can do either by calling `do` or `rm` 69 | with the parent task id, or with the `--parent` flag with the child id. The 70 | advantage of the second method is that you don't need to know the parent id, and 71 | it will close both parent and children. 72 | 73 | ```bash 74 | $: pydo rm --parent 1 75 | [+] Closed child task 1: Pay the rent with state deleted 76 | [+] Closed parent task 0: Pay the rent with state deleted 77 | 78 | $: pydo 79 | ╷ ╷ ╷ 80 | ID │ Description │ Due │ Parent 81 | ╺━━━━┿━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━┿━━━━━━━━╸ 82 | 2 │ Mow the lawn │ 2021-10-07 13:43 │ 1 83 | ╵ ╵ ╵ 84 | ``` 85 | 86 | # Modifying a recurring task 87 | 88 | If changes are made in a child task, those changes wont be propagated to the 89 | following children, if you want to make changes permanent, you need to change 90 | the parent either using the parent task id or using `mod --parent` with the children 91 | id. 92 | 93 | # Freeze a parent task 94 | 95 | If you need to temporary pause the creation of new children you can `freeze` the 96 | parent task either with it's id or with `freeze --parent` using the children id. 97 | Frozen tasks will appear in the `frozen` report. 98 | 99 | To resume the children creation use the `thaw` command. 100 | 101 | ``` 102 | $: pydo freeze 2 --parent 103 | [+] Frozen recurrent task 1: Mow the lawn and deleted it's last child 2 104 | 105 | $: pydo thaw 1 106 | [+] Thawed task 1: Mow the lawn, and created it's next child task with id 3 107 | ``` 108 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | ::: pydo 2 | -------------------------------------------------------------------------------- /docs/related.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Related 3 | date: 20200301 4 | author: Lyz 5 | --- 6 | 7 | If `pydo` isn't your cup of tea, maybe one of the other free task managers 8 | fits the bill: 9 | 10 | - [Buku](https://github.com/jarun/Buku): Store and manage your bookmarks from 11 | the command line. 12 | - [CommitTasks](https://github.com/ZeroX-DG/CommitTasks): Combination between 13 | git commit and todo list. 14 | - [Eureka](https://github.com/simeg/eureka): CLI tool to input and store ideas 15 | without leaving the terminal. 16 | - [Ff](https://github.com/ff-notes/ff): A distributed note taker and task 17 | manager. 18 | - [git-pending](https://github.com/kamranahmedse/git-pending): Git plugin to 19 | list TODO, FIXME, TESTME, DOCME comments in a repository. 20 | - [Org mode](https://orgmode.org): Notes and todo lists powered by an Emacs 21 | based plain-text system. 22 | - [Smos](https://smos.cs-syd.eu): Purely functional semantic tree-based editor 23 | (similar to [Org mode]). 24 | - [Taskbook](https://github.com/klauscfhq/taskbook): Tasks, boards & notes for 25 | the command-line habitat. 26 | - [Taskell](https://taskell.app): Command line Kanban board / task management. 27 | - [Taskwarrior](https://github.com/GothenburgBitFactory/taskwarrior): Command 28 | line task management. 29 | - [Tasklite](https://tasklite.org): Command line tool built with Haskell and 30 | SQLite. 31 | - [Toodles](https://github.com/aviaviavi/toodles): Project management from the 32 | TODO's in your codebase. 33 | - [Tracli](https://github.com/ridvankaradag/tracli-terminal): Command line app 34 | that tracks your time. 35 | - [Ultralist](https://ultralist.io): Open source task management system for the 36 | command line. 37 | - [Unfog](https://github.com/unfog-io/unfog-cli): A simple CLI task and time 38 | manager. 39 | - [Yokadi](https://yokadi.github.io/): Command line oriented, SQLite powered 40 | todo list. 41 | - [Eagle](https://github.com/im-n1/eagle): Minimalistic todo app for command 42 | line. 43 | -------------------------------------------------------------------------------- /docs/reports.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Reports 3 | date: 20211007 4 | author: Lyz 5 | --- 6 | 7 | `pydo` comes with some configured reports, such as `open`, `closed`, 8 | `recurring`, `overdue`, `frozen`. Each of them accepts a task 9 | filter that let's you do more specific queries over the content shown by those 10 | reports. Even so, you may want to create your own reports, or change the 11 | existing ones. 12 | 13 | All report configuration is saved in the config file (by default at 14 | `~/.local/share/pydo/config.yaml`) under the key `task_reports`. Each of them 15 | has the following properties: 16 | 17 | * `report_name`: It's the key that identifies the report. 18 | * `columns`: Ordered list of task attributes to print. 19 | * `filter`: Dictionary of task properties that narrow down the tasks you 20 | want to print. 21 | * `sort`: Ordered list of criteria used to sort the tasks. 22 | 23 | To create a new report that shows the open tasks of the area `health` and 24 | priority `5`, sorted descending by priority, edit your config file as the next 25 | snippet: 26 | 27 | ```yaml 28 | reports: 29 | task_reports: 30 | important_health: 31 | filter: 32 | active: true 33 | type: task 34 | area: health 35 | priority: 5 36 | sort: 37 | - "-priority" 38 | columns: 39 | - id_ 40 | - description 41 | - area 42 | - priority 43 | - tags 44 | - due 45 | - parent_id 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | 3 | mkdocs 4 | mkdocs-git-revision-date-localized-plugin 5 | mkdocs-htmlproofer-plugin 6 | mkdocs-autolinks-plugin 7 | mkdocs-material 8 | mkdocstrings 9 | markdown-include 10 | mkdocs-mermaid2-plugin 11 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.7 3 | # To update, run: 4 | # 5 | # pip-compile --allow-unsafe --output-file=docs/requirements.txt docs/requirements.in 6 | # 7 | astunparse==1.6.3 8 | # via pytkdocs 9 | babel==2.9.1 10 | # via mkdocs-git-revision-date-localized-plugin 11 | beautifulsoup4==4.10.0 12 | # via 13 | # mkdocs-htmlproofer-plugin 14 | # mkdocs-mermaid2-plugin 15 | cached-property==1.5.2 16 | # via pytkdocs 17 | certifi==2021.10.8 18 | # via requests 19 | charset-normalizer==2.0.6 20 | # via requests 21 | click==8.0.3 22 | # via mkdocs 23 | editorconfig==0.12.3 24 | # via jsbeautifier 25 | ghp-import==2.0.2 26 | # via mkdocs 27 | gitdb==4.0.7 28 | # via gitpython 29 | gitpython==3.1.24 30 | # via mkdocs-git-revision-date-localized-plugin 31 | idna==3.2 32 | # via requests 33 | importlib-metadata==4.8.1 34 | # via 35 | # click 36 | # markdown 37 | # mkdocs 38 | jinja2==3.0.2 39 | # via 40 | # mkdocs 41 | # mkdocs-material 42 | # mkdocstrings 43 | jsbeautifier==1.14.0 44 | # via mkdocs-mermaid2-plugin 45 | lxml==4.6.3 46 | # via mkdocs-htmlproofer-plugin 47 | markdown==3.3.4 48 | # via 49 | # markdown-include 50 | # mkdocs 51 | # mkdocs-autorefs 52 | # mkdocs-htmlproofer-plugin 53 | # mkdocs-material 54 | # mkdocstrings 55 | # pymdown-extensions 56 | markdown-include==0.6.0 57 | # via -r docs/requirements.in 58 | markupsafe==2.0.1 59 | # via 60 | # jinja2 61 | # mkdocstrings 62 | mergedeep==1.3.4 63 | # via mkdocs 64 | mkdocs==1.2.2 65 | # via 66 | # -r docs/requirements.in 67 | # mkdocs-autolinks-plugin 68 | # mkdocs-autorefs 69 | # mkdocs-git-revision-date-localized-plugin 70 | # mkdocs-htmlproofer-plugin 71 | # mkdocs-material 72 | # mkdocs-mermaid2-plugin 73 | # mkdocstrings 74 | mkdocs-autolinks-plugin==0.4.0 75 | # via -r docs/requirements.in 76 | mkdocs-autorefs==0.3.0 77 | # via mkdocstrings 78 | mkdocs-git-revision-date-localized-plugin==0.10.0 79 | # via -r docs/requirements.in 80 | mkdocs-htmlproofer-plugin==0.7.0 81 | # via -r docs/requirements.in 82 | mkdocs-material==7.3.2 83 | # via 84 | # -r docs/requirements.in 85 | # mkdocs-mermaid2-plugin 86 | mkdocs-material-extensions==1.0.3 87 | # via mkdocs-material 88 | mkdocs-mermaid2-plugin==0.5.2 89 | # via -r docs/requirements.in 90 | mkdocstrings==0.16.2 91 | # via -r docs/requirements.in 92 | packaging==21.0 93 | # via mkdocs 94 | pygments==2.10.0 95 | # via mkdocs-material 96 | pymdown-extensions==9.0 97 | # via 98 | # mkdocs-material 99 | # mkdocs-mermaid2-plugin 100 | # mkdocstrings 101 | pyparsing==2.4.7 102 | # via packaging 103 | python-dateutil==2.8.2 104 | # via ghp-import 105 | pytkdocs==0.12.0 106 | # via mkdocstrings 107 | pytz==2021.3 108 | # via babel 109 | pyyaml==5.4.1 110 | # via 111 | # mkdocs 112 | # mkdocs-mermaid2-plugin 113 | # pyyaml-env-tag 114 | pyyaml-env-tag==0.1 115 | # via mkdocs 116 | requests==2.26.0 117 | # via 118 | # mkdocs-htmlproofer-plugin 119 | # mkdocs-mermaid2-plugin 120 | six==1.16.0 121 | # via 122 | # astunparse 123 | # jsbeautifier 124 | # python-dateutil 125 | smmap==4.0.0 126 | # via gitdb 127 | soupsieve==2.2.1 128 | # via beautifulsoup4 129 | typing-extensions==3.10.0.2 130 | # via 131 | # gitpython 132 | # importlib-metadata 133 | # pytkdocs 134 | urllib3==1.26.7 135 | # via requests 136 | watchdog==2.1.6 137 | # via mkdocs 138 | wheel==0.37.0 139 | # via astunparse 140 | zipp==3.6.0 141 | # via importlib-metadata 142 | 143 | # The following packages are considered to be unsafe in a requirements file: 144 | setuptools==58.2.0 145 | # via mkdocs-mermaid2-plugin 146 | -------------------------------------------------------------------------------- /docs/sorting.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sorting 3 | date: 20211007 4 | author: Lyz 5 | --- 6 | 7 | `pydo` lets you sort the contents of any report with the `sort:` task filter. By 8 | default, the reports are sorted increasingly by the task id. 9 | 10 | ```bash 11 | $: pydo open 12 | ╷ ╷ 13 | ID │ Description │ Pri 14 | ╺━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━╸ 15 | 0 │ First task with medium priority │ 3 16 | 1 │ Second task with medium priority │ 3 17 | 2 │ Third task with low priority │ 1 18 | ╵ ╵ 19 | ``` 20 | 21 | If you want to sort the tasks increasingly by priority instead, you could use: 22 | 23 | ```bash 24 | $: pydo open sort:+priority 25 | ╷ ╷ 26 | ID │ Description │ Pri 27 | ╺━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━╸ 28 | 2 │ Third task with low priority │ 1 29 | 0 │ First task with medium priority │ 3 30 | 1 │ Second task with medium priority │ 3 31 | ╵ ╵ 32 | ``` 33 | 34 | To sort by more than one criteria, separate them by commas. For example, if you 35 | want to sort increasingly by priority and then decreasingly by id, use: 36 | 37 | ```bash 38 | $: pydo open sort:+priority,-id_ 39 | ╷ ╷ 40 | ID │ Description │ Pri 41 | ╺━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━╸ 42 | 2 │ Third task with low priority │ 1 43 | 1 │ Second task with medium priority │ 3 44 | 0 │ First task with medium priority │ 3 45 | ╵ ╵ 46 | ``` 47 | 48 | !!! warning "To sort by ID you need to use `id_` instead of `id`." 49 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | .md-content a:link { 2 | text-decoration:underline; 3 | } 4 | 5 | .md-typeset a:hover { 6 | color: #abb9c1; 7 | text-decoration:underline; 8 | } 9 | 10 | .md-typeset h1 { 11 | font-size: 32px; 12 | font-family: Content-font, Roboto, sans-serif; 13 | font-weight: 500; 14 | line-height: 1.5; 15 | color: #28292d; 16 | } 17 | 18 | .md-typeset h1::after { 19 | width:93%; 20 | height:2px; 21 | background: #283551; 22 | content:""; 23 | display: block; 24 | margin-top: 1px; 25 | opacity: 0.3; 26 | } 27 | 28 | .md-typeset h2 { 29 | font-size: 24px; 30 | font-family: Content-font, Roboto, sans-serif; 31 | font-weight: 700; 32 | line-height: 1.5; 33 | color: #28292d; 34 | } 35 | 36 | .md-typeset h2::after { 37 | width:100%; 38 | height:1px; 39 | background: #283551; 40 | content:""; 41 | display: block; 42 | margin-top: -5px; 43 | opacity: 0.2; 44 | } 45 | -------------------------------------------------------------------------------- /docs/tags.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tags 3 | date: 20200301 4 | author: Lyz 5 | --- 6 | 7 | Tags are the other way of clustering your tasks, unlike [areas](areas.md), 8 | a task can have many tags. So adding tags is the way to cluster tasks that 9 | share an attribute. For example, you can use `python` for tasks related to 10 | developing programs with that language, or if you don't use 11 | [`willpower`](willpower.md), `light` 12 | could be used to gather easily done tasks. 13 | 14 | To add a tag to a task, we use the `+tag` keyword. 15 | 16 | ```bash 17 | pydo add Fix pydo install process +python 18 | ``` 19 | 20 | To see all the existing tags, use the `tags` report: 21 | 22 | ```code 23 | $: pydo tags 24 | ╷ 25 | Name │ Open Tasks 26 | ╺━━━━━━━━┿━━━━━━━━━━━━╸ 27 | None │ 4 28 | python │ 1 29 | ``` 30 | 31 | To add a tag to an existing task or to remove one, use the `mod` command. 32 | 33 | ```bash 34 | pydo mod {{ task_filter }} +new_tag 35 | pydo mod {{ task_filter }} -existing_tag 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/theme/assets/images/amazon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/archive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/audio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/bluebook.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/docs/theme/assets/images/bluebook.bmp -------------------------------------------------------------------------------- /docs/theme/assets/images/bluebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /docs/theme/assets/images/chi-dna.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/csv.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/deepmind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /docs/theme/assets/images/dropbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/erowid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/gitea.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/theme/assets/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/google-scholar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/hn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/theme/assets/images/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/internetarchive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/kubernetes.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/theme/assets/images/mega.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/miri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/misc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/newyorktimes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/nlm-ncbi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/openai.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/patreon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/plos.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/pydo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/theme/assets/images/reddit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/spreadsheet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/stackexchange.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/theguardian.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/thenewyorker.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/txt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/uptontea.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/video.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/washingtonpost.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/wired.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/theme/assets/images/worddoc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/update.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Update 3 | date: 20200301 4 | author: Lyz 5 | --- 6 | 7 | To update `pydo`, follow the next steps: 8 | 9 | ```bash 10 | pip3 install --upgrade git+git://github.com/lyz-code/pydo 11 | pydo install 12 | ``` 13 | 14 | It will apply all the [alembic](https://lyz-code.github.io/blue-book/coding/python/alembic/) SQL migration 15 | scripts and seed the new configuration parameters. 16 | -------------------------------------------------------------------------------- /docs/willpower.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Willpower 3 | date: 20200424 4 | author: Lyz 5 | --- 6 | 7 | The `willpower` optional task attribute registers how much energy the execution 8 | of the task consumes. Understanding energy as physical or mental energy. For 9 | example, solving a complex programming problem, doing long boring tasks or 10 | running a marathon have high `willpower` value, meanwhile watering the plants, 11 | going for a walk or to the cinema have a low value. 12 | 13 | If your tasks have this property, it can help you determine the tasks to be done 14 | during the sprint and in what order so as not to burn yourself out. Or to 15 | analyze which tasks can be candidates for automation or habit building. 16 | 17 | To set the value of a task use the `wp` or `willpower` keyword: 18 | 19 | ``` 20 | $: pydo add Add recurrence support to pydo willpower:4 21 | [+] Added task 5: Add recurrence support to pydo 22 | ``` 23 | 24 | As with the [priority](priority.md), I use a range of values from `0` to `5` but 25 | the parameter allows any integer. 26 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | site_name: Pydo 3 | site_author: Lyz 4 | site_url: https://lyz-code.github.io/pydo 5 | nav: 6 | - Overview: index.md 7 | - Basic Usage: basic_usage.md 8 | - Advanced Usage: 9 | - Areas: areas.md 10 | - Tags: tags.md 11 | - Dates: dates.md 12 | - Recurrence: recurrence.md 13 | - Priority: priority.md 14 | - Estimate: estimate.md 15 | - Value: objective_value.md 16 | - Willpower: willpower.md 17 | - Fun: fun.md 18 | - Export: export.md 19 | - Customization: 20 | - Sorting: sorting.md 21 | - Reports: reports.md 22 | - Contributing: contributing.md 23 | - Developing: 24 | - Contributing: contributing.md 25 | - Architecture Decision Records: 26 | - adr/adr.md 27 | - '001: Deprecation of Fulids': adr/001-change-id-from-str-to-int 28 | - '00X: Subtasks and steps': adr/0xx-subtasks-and-steps 29 | - '00X: Agenda': adr/0xx-agenda 30 | - Reference: reference.md 31 | - Related: related.md 32 | 33 | plugins: 34 | - search 35 | # - mkdocstrings: 36 | # handlers: 37 | # python: 38 | # rendering: 39 | # show_root_heading: true 40 | # heading_level: 1 41 | # watch: 42 | # - src 43 | - autolinks 44 | - git-revision-date-localized: 45 | type: timeago 46 | fallback_to_build_date: true 47 | - mermaid2: 48 | arguments: 49 | securityLevel: loose 50 | 51 | markdown_extensions: 52 | - abbr 53 | - def_list 54 | - admonition 55 | - markdown_include.include: 56 | base_path: docs 57 | - meta 58 | - toc: 59 | permalink: true 60 | baselevel: 2 61 | - pymdownx.arithmatex 62 | - pymdownx.betterem: 63 | smart_enable: all 64 | - pymdownx.caret 65 | - pymdownx.critic 66 | - pymdownx.details 67 | - pymdownx.emoji: 68 | emoji_generator: '!!python/name:pymdownx.emoji.to_svg' 69 | - pymdownx.inlinehilite 70 | - pymdownx.magiclink 71 | - pymdownx.mark 72 | - pymdownx.smartsymbols 73 | - pymdownx.superfences 74 | - pymdownx.tabbed 75 | - pymdownx.tasklist: 76 | custom_checkbox: true 77 | - pymdownx.tilde 78 | 79 | theme: 80 | name: material 81 | custom_dir: docs/theme 82 | logo: images/logo.bmp 83 | features: 84 | - navigation.instant 85 | palette: 86 | primary: blue grey 87 | accent: light blue 88 | 89 | extra_css: 90 | - stylesheets/extra.css 91 | - stylesheets/links.css 92 | 93 | repo_name: lyz-code/pydo 94 | repo_url: https://github.com/lyz-code/pydo 95 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | show_error_codes = True 3 | follow_imports = silent 4 | ignore_missing_imports = False 5 | strict_optional = True 6 | warn_redundant_casts = True 7 | warn_unused_ignores = True 8 | disallow_any_generics = True 9 | check_untyped_defs = True 10 | no_implicit_reexport = True 11 | warn_unused_configs = True 12 | disallow_subclassing_any = True 13 | disallow_incomplete_defs = True 14 | disallow_untyped_decorators = True 15 | disallow_untyped_calls = True 16 | disallow_untyped_defs = True 17 | 18 | [mypy-tests.*] 19 | # Required to not have error: Untyped decorator makes function on fixtures and 20 | # parametrize decorators 21 | disallow_untyped_decorators = False 22 | 23 | [mypy-yoyo.*] 24 | ignore_missing_imports = True 25 | 26 | [mypy-deepdiff.*] 27 | ignore_missing_imports = True 28 | [mypy-setuptools.*] 29 | ignore_missing_imports = True 30 | 31 | [mypy-click_default_group.*] 32 | ignore_missing_imports = True 33 | 34 | [mypy-factory.*] 35 | ignore_missing_imports = True 36 | 37 | [mypy-faker.*] 38 | ignore_missing_imports = True 39 | 40 | [mypy-pytest.*] 41 | ignore_missing_imports = True 42 | 43 | [mypy-faker_enum.*] 44 | ignore_missing_imports = True 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # --------- Commitizen ------------- 2 | 3 | [tool.commitizen] 4 | name = "cz_conventional_commits" 5 | version = "0.1.1" 6 | tag_format = "$version" 7 | version_files = [ 8 | "src/pydo/version.py", 9 | ] 10 | 11 | # --------- Black ------------- 12 | 13 | [tool.black] 14 | line-length = 88 15 | target-version = ['py36', 'py37', 'py38'] 16 | include = '\.pyi?$' 17 | exclude = ''' 18 | /( 19 | \.eggs 20 | | \.git 21 | | \.hg 22 | | \.mypy_cache 23 | | \.tox 24 | | \.venv 25 | | _build 26 | | buck-out 27 | | build 28 | | dist 29 | # The following are specific to Black, you probably don't want those. 30 | | blib2to3 31 | | tests/data 32 | | profiling 33 | )/ 34 | ''' 35 | 36 | # --------- Pytest ------------- 37 | 38 | [tool.pytest.ini_options] 39 | minversion = "6.0" 40 | addopts = "-vv --tb=short -n auto" 41 | log_level = "info" 42 | python_paths = "." 43 | norecursedirs = [ 44 | ".tox", 45 | ".git", 46 | "*/migrations/*", 47 | "*/static/*", 48 | "docs", 49 | "venv", 50 | ] 51 | markers = [ 52 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 53 | "secondary: mark tests that use functionality tested in the same file (deselect with '-m \"not secondary\"')" 54 | ] 55 | 56 | # --------- Coverage ------------- 57 | 58 | [tool.coverage.report] 59 | exclude_lines = [ 60 | # Have to re-enable the standard pragma 61 | 'pragma: no cover', 62 | 63 | # Ignore the Abstract classes definition 64 | 'raise NotImplementedError', 65 | ] 66 | 67 | # --------- Isort ------------- 68 | 69 | [tool.isort] 70 | profile = "black" 71 | src_paths = ["src", "test"] 72 | 73 | # --------- Flakehell ------------- 74 | 75 | [tool.flakehell] 76 | format = "grouped" 77 | max_line_length = 88 78 | show_source = true 79 | docstring-convention = "google" 80 | extended_default_ignore=[] # Until https://github.com/flakehell/flakehell/issues/10 is solved 81 | 82 | [tool.flakehell.plugins] 83 | flake8-aaa = ["+*"] 84 | flake8-annotations = [ 85 | "+*", 86 | "-ANN101", # There is usually no need to type the self argument of class methods. 87 | "-ANN102", # There is usually no need to type the cls argument of class methods. 88 | ] 89 | flake8-annotations-complexity = ["+*"] 90 | flake8-bugbear = ["+*"] 91 | flake8-comprehensions = ["+*"] 92 | flake8-debugger = ["+*"] 93 | flake8-docstrings = ["+*"] 94 | flake8-eradicate = ["+*"] 95 | flake8-expression-complexity = ["+*"] 96 | flake8-fixme = ["+*"] 97 | flake8-markdown = ["+*"] 98 | flake8-mutable = ["+*"] 99 | flake8-pytest = ["+*"] 100 | flake8-pytest-style = ["+*"] 101 | flake8-simplify = ["+*"] 102 | flake8-use-fstring = [ 103 | "+*", 104 | '-FS003' # f-string missing prefix 105 | ] 106 | flake8-typing-imports = [ 107 | "+*", 108 | "-TYP001", # guard import by `if False: # TYPE_CHECKING`: TYPE_CHECKING (not in 109 | # 3.5.0, 3.5.1). We don't support Python < 3.6 110 | ] 111 | flake8-variables-names = ["+*"] 112 | dlint = ["+*"] 113 | pylint = [ 114 | "+*", 115 | "-C0411", # %s should be placed before %s, 116 | # see https://github.com/PyCQA/pylint/issues/2175 and https://github.com/PyCQA/pylint/issues/1797 117 | "-W1203", # Use %s formatting in logging functions. Deprecated rule in favor of 118 | # f-strings. 119 | "-W1201", # Use lazy % formatting in logging functions. Deprecated rule in favor of 120 | # f-strings. 121 | "-C0301", # Lines too long. Already covered by E501. 122 | ] 123 | mccabe = ["+*"] 124 | pep8-naming = ["+*"] 125 | pycodestyle = [ 126 | "+*", 127 | "-W503", # No longer applies, incompatible with newer version of PEP8 128 | # see https://github.com/PyCQA/pycodestyle/issues/197 129 | # and https://github.com/psf/black/issues/113 130 | ] 131 | pyflakes = ["+*"] 132 | 133 | [tool.flakehell.exceptions."tests/"] 134 | flake8-docstrings = [ 135 | "-D400", # First line should end with a period 136 | "-D205" # 1 blank line required between summary line and description 137 | ] 138 | flake8-annotations = [ 139 | "-ANN001" 140 | ] 141 | pylint = [ 142 | "-R0201", # Method could be a function. Raised because the methods of a test class 143 | # don't use the self object, which is not wrong. 144 | ] 145 | 146 | [tool.flakehell.exceptions."src/pydo/model"] 147 | pylint = [ 148 | "-R0903", # Too few methods warning, but is the way to define factoryboy factories. 149 | ] 150 | 151 | [tool.flakehell.exceptions."tests/factories.py"] 152 | pylint = [ 153 | "-R0903", # Too few methods warning, but is the way to define factoryboy factories. 154 | ] 155 | 156 | [tool.flakehell.exceptions."tests/unit/test_views.py"] 157 | pycodestyle = [ 158 | "-E501", # lines too long. As we are testing the output of the terminal, the test is 159 | # cleaner if we show the actual result without splitting long lines. 160 | ] 161 | 162 | # --------- Pylint ------------- 163 | [tool.pylint.'TYPECHECK'] 164 | generated-members = "sh" 165 | 166 | [tool.pylint.'MESSAGES CONTROL'] 167 | extension-pkg-whitelist = "pydantic" 168 | 169 | # --------- Build-system ------------- 170 | 171 | [build-system] 172 | requires = ["setuptools >= 40.6.0", "wheel"] 173 | build-backend = "setuptools.build_meta" 174 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | -c docs/requirements.in 3 | 4 | # Testing 5 | pytest 6 | pytest-xdist 7 | pytest-cov 8 | pytest-cases 9 | pytest-pythonpath 10 | pytest-freezegun 11 | deepdiff 12 | pip-tools 13 | factory_boy 14 | faker-enum 15 | 16 | # Linting 17 | yamllint 18 | flakehell 19 | flake8-aaa 20 | flake8-annotations 21 | flake8-annotations-complexity 22 | flake8-typing-imports 23 | flake8-bugbear 24 | flake8-debugger 25 | flake8-fixme 26 | flake8-markdown 27 | flake8-mutable 28 | flake8-pytest 29 | flake8-pytest-style 30 | flake8-simplify 31 | flake8-variables-names 32 | flake8-comprehensions 33 | pep8-naming 34 | flake8-expression-complexity 35 | flake8-use-fstring 36 | flake8-eradicate 37 | flake8-docstrings 38 | flake8-markdown 39 | pylint 40 | pre-commit 41 | dlint 42 | 43 | # Security 44 | pip-tools 45 | safety 46 | bandit 47 | 48 | # Type checkers 49 | mypy 50 | types-freezegun 51 | types-python-dateutil 52 | 53 | # Formatters 54 | autoimport 55 | black 56 | isort 57 | yamlfix 58 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.7 3 | # To update, run: 4 | # 5 | # pip-compile --allow-unsafe 6 | # 7 | click==8.0.3 8 | # via 9 | # click-default-group 10 | # py-do (setup.py) 11 | click-default-group==1.2.2 12 | # via py-do (setup.py) 13 | colorama==0.4.4 14 | # via rich 15 | commonmark==0.9.1 16 | # via rich 17 | deepdiff==5.5.0 18 | # via repository-orm 19 | distro==1.6.0 20 | # via ruyaml 21 | importlib-metadata==4.8.1 22 | # via click 23 | ordered-set==4.0.2 24 | # via deepdiff 25 | pydantic==1.8.2 26 | # via repository-orm 27 | pygments==2.10.0 28 | # via rich 29 | pymysql==1.0.2 30 | # via repository-orm 31 | pypika==0.48.8 32 | # via repository-orm 33 | python-dateutil==2.8.2 34 | # via py-do (setup.py) 35 | repository-orm==0.5.5 36 | # via py-do (setup.py) 37 | rich==10.12.0 38 | # via py-do (setup.py) 39 | ruyaml==0.20.0 40 | # via py-do (setup.py) 41 | six==1.16.0 42 | # via python-dateutil 43 | sqlparse==0.4.2 44 | # via yoyo-migrations 45 | tabulate==0.8.9 46 | # via yoyo-migrations 47 | tinydb==4.5.2 48 | # via 49 | # repository-orm 50 | # tinydb-serialization 51 | tinydb-serialization==2.1.0 52 | # via repository-orm 53 | typing-extensions==3.10.0.2 54 | # via 55 | # importlib-metadata 56 | # pydantic 57 | # rich 58 | # tinydb 59 | ulid-py==1.1.0 60 | # via py-do (setup.py) 61 | yoyo-migrations==7.3.2 62 | # via repository-orm 63 | zipp==3.6.0 64 | # via importlib-metadata 65 | 66 | # The following packages are considered to be unsafe in a requirements file: 67 | setuptools==58.2.0 68 | # via ruyaml 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Python package building configuration.""" 2 | 3 | import logging 4 | import re 5 | from glob import glob 6 | from os.path import basename, splitext 7 | 8 | from setuptools import find_packages, setup 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | # Avoid loading the package to extract the version 13 | with open("src/pydo/version.py") as fp: 14 | version_match = re.search(r'__version__ = "(?P.*)"', fp.read()) 15 | if version_match is None: 16 | raise ValueError("The version is not specified in the version.py file.") 17 | version = version_match["version"] 18 | 19 | 20 | with open("README.md", "r") as readme_file: 21 | readme = readme_file.read() 22 | 23 | 24 | setup( 25 | name="py-do", 26 | version=version, 27 | description="Free software command line task manager built in Python.", 28 | author="Lyz", 29 | author_email="lyz-code-security-advisories@riseup.net", 30 | license="GNU General Public License v3", 31 | long_description=readme, 32 | long_description_content_type="text/markdown", 33 | url="https://github.com/lyz-code/pydo", 34 | packages=find_packages("src"), 35 | package_dir={"": "src"}, 36 | package_data={"pydo": ["py.typed"]}, 37 | py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], 38 | python_requires=">=3.7", 39 | classifiers=[ 40 | "Development Status :: 2 - Pre-Alpha", 41 | "Intended Audience :: Developers", 42 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 43 | "Operating System :: Unix", 44 | "Operating System :: POSIX", 45 | "Programming Language :: Python", 46 | "Programming Language :: Python :: 3", 47 | "Programming Language :: Python :: 3.7", 48 | "Programming Language :: Python :: 3.8", 49 | "Topic :: Utilities", 50 | "Natural Language :: English", 51 | ], 52 | entry_points=""" 53 | [console_scripts] 54 | pydo=pydo.entrypoints.cli:cli 55 | """, 56 | install_requires=[ 57 | "click", 58 | "click-default-group", 59 | "repository-orm", 60 | "rich", 61 | "python-dateutil", 62 | "ulid-py", 63 | "ruyaml", 64 | ], 65 | ) 66 | -------------------------------------------------------------------------------- /src/pydo/__init__.py: -------------------------------------------------------------------------------- 1 | """Task manager command line tool.""" 2 | -------------------------------------------------------------------------------- /src/pydo/config.py: -------------------------------------------------------------------------------- 1 | """Define the configuration of the main program.""" 2 | 3 | import logging 4 | import os 5 | from collections import UserDict 6 | from typing import Any, Dict, List, Union 7 | 8 | from ruyaml import YAML # type: ignore 9 | from ruyaml.parser import ParserError 10 | from ruyaml.scanner import ScannerError 11 | 12 | from .exceptions import ConfigError 13 | 14 | # It complains that ruamel.yaml doesn't have the object YAML, but it does. 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | # R0901: UserDict has too many ancestors. Right now I don't feel like switching to 20 | # another base class, as `dict` won't work straight ahead. 21 | # type ignore: I haven't found a way to specify the type of the generic UserDict class. 22 | class Config(UserDict): # type: ignore # noqa: R0901 23 | """Expose the configuration in a friendly way. 24 | 25 | Public methods: 26 | get: Fetch the configuration value of the specified key. 27 | load: Load the configuration from the configuration YAML file. 28 | save: Saves the configuration in the configuration YAML file. 29 | 30 | Attributes and properties: 31 | config_path (str): Path to the configuration file. 32 | data(dict): Program configuration. 33 | """ 34 | 35 | def __init__(self, config_path: str = "~/.local/share/pydo/config.yaml") -> None: 36 | """Configure the attributes and load the configuration.""" 37 | super().__init__() 38 | self.config_path = os.path.expanduser(config_path) 39 | self.load() 40 | 41 | def get( 42 | self, key: str, default: Any = None 43 | ) -> Union[str, int, Dict[str, Any], List[Any]]: 44 | """Fetch the configuration value of the specified key. 45 | 46 | If there are nested dictionaries, a dot notation can be used. 47 | 48 | So if the configuration contents are: 49 | 50 | self.data = { 51 | 'first': { 52 | 'second': 'value' 53 | }, 54 | } 55 | 56 | self.data.get('first.second') == 'value' 57 | """ 58 | original_key = key 59 | config_keys = key.split(".") 60 | value = self.data.copy() 61 | 62 | for config_key in config_keys: 63 | try: 64 | value = value[config_key] 65 | except KeyError as error: 66 | raise ConfigError( 67 | f"Failed to fetch the configuration {config_key} " 68 | f"when searching for {original_key}" 69 | ) from error 70 | 71 | return value 72 | 73 | def set(self, key: str, value: Any) -> None: 74 | """Set the configuration value of the specified key. 75 | 76 | If there are nested dictionaries, a dot notation can be used. 77 | 78 | So if you want to set the configuration: 79 | 80 | self.data = { 81 | 'first': { 82 | 'second': 'value' 83 | }, 84 | } 85 | 86 | self.data.set('first.second', 'value') 87 | """ 88 | config_keys: List[str] = key.split(".") 89 | last_key = config_keys.pop(-1) 90 | 91 | # Initialize the dictionary structure 92 | parent = self.data 93 | for config_key in config_keys: 94 | try: 95 | parent = parent[config_key] 96 | except KeyError: 97 | parent[config_key] = {} 98 | parent = parent[config_key] 99 | 100 | # Set value 101 | parent[last_key] = value 102 | 103 | def load(self) -> None: 104 | """Load the configuration from the configuration YAML file.""" 105 | try: 106 | with open(os.path.expanduser(self.config_path), "r") as file_cursor: 107 | try: 108 | self.data = YAML().load(file_cursor) 109 | except (ParserError, ScannerError) as error: 110 | raise ConfigError(str(error)) from error 111 | except FileNotFoundError as error: 112 | raise FileNotFoundError( 113 | "The configuration file could not be found." 114 | ) from error 115 | 116 | def save(self) -> None: 117 | """Save the configuration in the configuration YAML file.""" 118 | with open(os.path.expanduser(self.config_path), "w+") as file_cursor: 119 | yaml = YAML() 120 | yaml.default_flow_style = False 121 | yaml.dump(self.data, file_cursor) 122 | -------------------------------------------------------------------------------- /src/pydo/entrypoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/src/pydo/entrypoints/__init__.py -------------------------------------------------------------------------------- /src/pydo/entrypoints/tui.py: -------------------------------------------------------------------------------- 1 | """Define the terminal user interface.""" 2 | 3 | from typing import Any, Callable, List, Optional, Union 4 | 5 | from prompt_toolkit import Application 6 | from prompt_toolkit.application import get_app 7 | from prompt_toolkit.key_binding import KeyBindings, KeyBindingsBase 8 | from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous 9 | from prompt_toolkit.key_binding.key_processor import KeyPressEvent 10 | from prompt_toolkit.layout.containers import ( 11 | Container, 12 | HorizontalAlign, 13 | HSplit, 14 | VerticalAlign, 15 | VSplit, 16 | Window, 17 | ) 18 | from prompt_toolkit.layout.controls import FormattedTextControl 19 | from prompt_toolkit.layout.dimension import AnyDimension 20 | from prompt_toolkit.layout.layout import Layout 21 | from prompt_toolkit.output.color_depth import ColorDepth 22 | from prompt_toolkit.styles import Style 23 | 24 | # Helper functions 25 | 26 | 27 | class Table(HSplit): 28 | """Define a table. 29 | 30 | Args: 31 | header 32 | data: Data to print 33 | handler: Called when the row is clicked, no parameters are passed to this 34 | callable. 35 | """ 36 | 37 | def __init__( 38 | self, 39 | data: Any, 40 | window_too_small: Optional[Container] = None, 41 | align: VerticalAlign = VerticalAlign.JUSTIFY, 42 | padding: AnyDimension = 0, 43 | padding_char: Optional[str] = None, 44 | padding_style: str = "", 45 | width: AnyDimension = None, 46 | height: AnyDimension = None, 47 | z_index: Optional[int] = None, 48 | modal: bool = False, 49 | key_bindings: Optional[KeyBindingsBase] = None, 50 | style: Union[str, Callable[[], str]] = "", 51 | ) -> None: 52 | """Initialize the widget.""" 53 | self.data = data 54 | # rows = [MultiColumnRow(row) for row in self.data] 55 | rows = [ 56 | MultiColumnRow(data[0]), 57 | MultiColumnRow(data[1], alternate=True), 58 | MultiColumnRow(data[2]), 59 | MultiColumnRow(data[2], alternate=True), 60 | ] 61 | 62 | super().__init__( 63 | children=rows, 64 | window_too_small=window_too_small, 65 | align=align, 66 | padding=padding, 67 | padding_char=padding_char, 68 | padding_style=padding_style, 69 | width=width, 70 | height=height, 71 | z_index=z_index, 72 | modal=modal, 73 | key_bindings=key_bindings, 74 | style=style, 75 | ) 76 | 77 | 78 | class MultiColumnRow(VSplit): 79 | """Define row. 80 | 81 | Args: 82 | text: text to print 83 | """ 84 | 85 | def __init__( 86 | self, 87 | data: Optional[List[str]] = None, 88 | alternate: bool = False, 89 | window_too_small: Optional[Container] = None, 90 | align: HorizontalAlign = HorizontalAlign.JUSTIFY, 91 | padding: AnyDimension = 2, 92 | padding_char: Optional[str] = " ", 93 | padding_style: str = "", 94 | width: AnyDimension = None, 95 | height: AnyDimension = None, 96 | z_index: Optional[int] = None, 97 | modal: bool = False, 98 | key_bindings: Optional[KeyBindingsBase] = None, 99 | style: Union[str, Callable[[], str]] = "", 100 | ) -> None: 101 | """Initialize the widget.""" 102 | if data is None: 103 | data = [] 104 | self.data = data 105 | 106 | def get_style() -> str: 107 | if get_app().layout.has_focus(self): 108 | return "class:row.focused" 109 | else: 110 | return "class:row" 111 | 112 | def get_style_alternate() -> str: 113 | if get_app().layout.has_focus(self): 114 | return "class:row.alternate.focused" 115 | else: 116 | return "class:row.alternate" 117 | 118 | if alternate: 119 | style = get_style_alternate 120 | else: 121 | style = get_style 122 | 123 | columns = [ 124 | Window( 125 | FormattedTextControl(self.data[0], focusable=True), 126 | style=style, 127 | always_hide_cursor=True, 128 | dont_extend_height=True, 129 | dont_extend_width=True, 130 | wrap_lines=True, 131 | ), 132 | Window( 133 | FormattedTextControl(self.data[1], focusable=False), 134 | style=style, 135 | always_hide_cursor=True, 136 | dont_extend_height=True, 137 | wrap_lines=True, 138 | ), 139 | ] 140 | # self.window.children[1].width = 10 141 | super().__init__( 142 | children=columns, 143 | window_too_small=window_too_small, 144 | align=align, 145 | padding=padding, 146 | padding_char=padding_char, 147 | padding_style=style, 148 | width=width, 149 | height=height, 150 | z_index=z_index, 151 | modal=modal, 152 | key_bindings=key_bindings, 153 | style=style, 154 | ) 155 | 156 | 157 | # Layout 158 | 159 | layout = Table( 160 | [ 161 | ["Test1", "Description"], 162 | [ 163 | "Test2", 164 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaa a", 165 | ], 166 | ["Test3", "Description 2"], 167 | ] 168 | ) 169 | 170 | # Key bindings 171 | 172 | kb = KeyBindings() 173 | 174 | kb.add("j")(focus_next) 175 | kb.add("k")(focus_previous) 176 | 177 | 178 | @kb.add("c-c", eager=True) 179 | @kb.add("q", eager=True) 180 | def exit_(event: KeyPressEvent) -> None: 181 | """Exit the user interface.""" 182 | event.app.exit() 183 | 184 | 185 | # Styles 186 | 187 | style = Style( 188 | [ 189 | ("row", "bg:#002b36 #657b83"), 190 | ("row.focused", "#268bd2"), 191 | ("row.alternate", "bg:#073642 #657b83"), 192 | ("row.alternate.focused", "#268bd2"), 193 | ] 194 | ) 195 | # Application 196 | 197 | # ignore: it asks for the type annotation of app, but I haven't found it 198 | app = Application( # type: ignore 199 | layout=Layout(layout), 200 | full_screen=True, 201 | key_bindings=kb, 202 | style=style, 203 | color_depth=ColorDepth.DEPTH_24_BIT, 204 | ) 205 | 206 | app.run() 207 | -------------------------------------------------------------------------------- /src/pydo/entrypoints/utils.py: -------------------------------------------------------------------------------- 1 | """Define common entrypoint functions.""" 2 | 3 | import logging 4 | import os 5 | import re 6 | import shutil 7 | import sys 8 | from contextlib import suppress 9 | from typing import Any, Iterable, Tuple 10 | 11 | from repository_orm import Repository, load_repository 12 | 13 | from ..config import Config 14 | from ..exceptions import ConfigError, DateParseError 15 | from ..model import RecurrentTask, Task, TaskChanges, TaskSelector, convert_date 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | def load_config(config_path: str) -> Config: 21 | """Load the configuration from the file.""" 22 | log.debug(f"Loading the configuration from file {config_path}") 23 | try: 24 | config = Config(config_path) 25 | except ConfigError as error: 26 | log.error( 27 | f"Error parsing yaml of configuration file {config_path}: {str(error)}" 28 | ) 29 | sys.exit(1) 30 | except FileNotFoundError: 31 | try: 32 | data_directory = os.path.expanduser(os.path.dirname(config_path)) 33 | os.makedirs(data_directory) 34 | log.info(f"Data directory {data_directory} created") 35 | except FileExistsError: 36 | log.info("Data directory already exits") 37 | 38 | config_path = os.path.join(data_directory, "config.yaml") 39 | if not os.path.isfile(config_path) or not os.access(config_path, os.R_OK): 40 | shutil.copyfile("assets/config.yaml", config_path) 41 | log.info("Copied default configuration template") 42 | 43 | config = load_config(config_path) 44 | config.set("database_url", f"tinydb://{data_directory}/database.tinydb") 45 | 46 | return config 47 | 48 | 49 | def get_repo(config: Config) -> Repository: 50 | """Configure the Repository.""" 51 | log.debug("Initializing the repository") 52 | repo = load_repository([Task, RecurrentTask], config["database_url"]) 53 | 54 | return repo 55 | 56 | 57 | # I have no idea how to test this function :(. If you do, please send a PR. 58 | def load_logger(verbose: bool = False) -> None: # pragma no cover 59 | """Configure the Logging logger. 60 | 61 | Args: 62 | verbose: Set the logging level to Debug. 63 | """ 64 | logging.addLevelName(logging.INFO, "[\033[36m+\033[0m]") 65 | logging.addLevelName(logging.ERROR, "[\033[31m+\033[0m]") 66 | logging.addLevelName(logging.DEBUG, "[\033[32m+\033[0m]") 67 | logging.addLevelName(logging.WARNING, "[\033[33m+\033[0m]") 68 | if verbose: 69 | logging.basicConfig( 70 | stream=sys.stderr, level=logging.DEBUG, format=" %(levelname)s %(message)s" 71 | ) 72 | else: 73 | logging.basicConfig( 74 | stream=sys.stderr, level=logging.INFO, format=" %(levelname)s %(message)s" 75 | ) 76 | 77 | 78 | def _parse_task_selector(task_args: Iterable[str]) -> TaskSelector: 79 | """Parse the task ids and task filter from the task cli arguments.""" 80 | selector = TaskSelector() 81 | 82 | for arg in task_args: 83 | attribute_id, attribute_value = _parse_task_argument(arg) 84 | if attribute_id == "unprocessed": 85 | with suppress(ValueError): 86 | selector.task_ids.append(int(arg)) 87 | elif attribute_id == "sort": 88 | selector.sort = attribute_value 89 | elif attribute_id not in ["tag_ids", "tags_rm", "recurring", "repeating"]: 90 | selector.task_filter[attribute_id] = attribute_value 91 | 92 | return selector 93 | 94 | 95 | def _parse_changes(task_args: Iterable[str]) -> TaskChanges: 96 | """Parse the changes to take from a friendly task attributes string. 97 | 98 | Args: 99 | task_args: command line friendly task attributes representation. 100 | 101 | Returns: 102 | Changes on the task attributes. 103 | """ 104 | changes = TaskChanges() 105 | 106 | unprocessed_args = [] 107 | 108 | for task_arg in task_args: 109 | attribute_id, attribute_value = _parse_task_argument(task_arg) 110 | if attribute_id == "tag_ids": 111 | changes.tags_to_add.append(attribute_value) 112 | elif attribute_id == "tags_rm": 113 | changes.tags_to_remove.append(attribute_value) 114 | elif attribute_id == "unprocessed": 115 | unprocessed_args.append(attribute_value) 116 | elif attribute_id in ["recurring", "repeating"]: 117 | changes.task_attributes["recurrence"] = attribute_value 118 | changes.task_attributes["recurrence_type"] = attribute_id 119 | else: 120 | changes.task_attributes[attribute_id] = attribute_value 121 | 122 | if len(unprocessed_args) > 0: 123 | changes.task_attributes["description"] = " ".join(unprocessed_args) 124 | 125 | return changes 126 | 127 | 128 | def _parse_task_argument(task_arg: str) -> Tuple[str, Any]: 129 | """Parse the Task attributes from a friendly task attribute string. 130 | 131 | If the key doesn't match any of the known keys, it will be returned with the key 132 | "unprocessed". 133 | 134 | Returns: 135 | attribute_id: Attribute key. 136 | attributes_value: Attribute value. 137 | """ 138 | attribute_conf = { 139 | "body": {"regexp": re.compile(r"^body:"), "type": "str"}, 140 | "due": {"regexp": re.compile(r"^due:"), "type": "date"}, 141 | "estimate": {"regexp": re.compile(r"^(est|estimate):"), "type": "float"}, 142 | "fun": {"regexp": re.compile(r"^fun:"), "type": "int"}, 143 | "priority": {"regexp": re.compile(r"^(pri|priority):"), "type": "int"}, 144 | "area": {"regexp": re.compile(r"^(ar|area):"), "type": "str"}, 145 | "recurring": {"regexp": re.compile(r"^(rec|recurring):"), "type": "str"}, 146 | "repeating": {"regexp": re.compile(r"^(rep|repeating):"), "type": "str"}, 147 | "sort": {"regexp": re.compile(r"^(sort):"), "type": "sort"}, 148 | "state": {"regexp": re.compile(r"^(st|state):"), "type": "str"}, 149 | "tag_ids": {"regexp": re.compile(r"^\+"), "type": "tag"}, 150 | "tags_rm": {"regexp": re.compile(r"^\-"), "type": "tag"}, 151 | "type": {"regexp": re.compile(r"^type:"), "type": "model"}, 152 | "value": {"regexp": re.compile(r"^(vl|value):"), "type": "int"}, 153 | "willpower": {"regexp": re.compile(r"^(wp|willpower):"), "type": "int"}, 154 | } 155 | attribute_value: Any = "initial_internal_value" 156 | 157 | for attribute_id, attribute in attribute_conf.items(): 158 | if attribute["regexp"].match(task_arg): # type: ignore 159 | if attribute["type"] == "tag": 160 | attribute_value = re.sub(r"^[+-]", "", task_arg) 161 | elif task_arg.split(":")[1] == "": 162 | attribute_value = None 163 | elif attribute["type"] == "str": 164 | attribute_value = task_arg.split(":")[1] 165 | elif attribute["type"] == "int": 166 | attribute_value = int(task_arg.split(":")[1]) 167 | elif attribute["type"] == "float": 168 | attribute_value = float(task_arg.split(":")[1]) 169 | elif attribute["type"] == "date": 170 | try: 171 | attribute_value = convert_date(":".join(task_arg.split(":")[1:])) 172 | except DateParseError as error: 173 | log.error(str(error)) 174 | sys.exit(1) 175 | elif attribute["type"] == "sort": 176 | attribute_value = task_arg.split(":")[1].split(",") 177 | if attribute_value != "initial_internal_value": 178 | return attribute_id, attribute_value 179 | return "unprocessed", task_arg 180 | -------------------------------------------------------------------------------- /src/pydo/exceptions.py: -------------------------------------------------------------------------------- 1 | """Store the pydo exceptions.""" 2 | 3 | 4 | class TaskAttributeError(Exception): 5 | """Catch Task attribute errors.""" 6 | 7 | 8 | class DateParseError(Exception): 9 | """Catch date parsing errors.""" 10 | 11 | 12 | class ConfigError(Exception): 13 | """Catch configuration errors.""" 14 | -------------------------------------------------------------------------------- /src/pydo/model/__init__.py: -------------------------------------------------------------------------------- 1 | """Module to store the common business model of all entities. 2 | 3 | Abstract Classes: 4 | Entity: Gathers common methods and define the interface of the entities. 5 | """ 6 | 7 | from typing import TypeVar 8 | 9 | from repository_orm import Entity 10 | 11 | from .date import convert_date 12 | from .task import ( 13 | RecurrentTask, 14 | Task, 15 | TaskAttrs, 16 | TaskChanges, 17 | TaskSelector, 18 | TaskState, 19 | TaskType, 20 | ) 21 | 22 | EntityType = TypeVar("EntityType", bound=Entity) 23 | 24 | __all__ = [ 25 | "convert_date", 26 | "EntityType", 27 | "RecurrentTask", 28 | "Sulid", 29 | "Task", 30 | "TaskAttrs", 31 | "TaskChanges", 32 | "TaskSelector", 33 | "TaskType", 34 | "TaskState", 35 | ] 36 | -------------------------------------------------------------------------------- /src/pydo/model/date.py: -------------------------------------------------------------------------------- 1 | """Store the operations on dates.""" 2 | 3 | import re 4 | from datetime import datetime 5 | from typing import Optional 6 | 7 | from dateutil._common import weekday 8 | from dateutil.relativedelta import FR, MO, SA, SU, TH, TU, WE, relativedelta 9 | 10 | from ..exceptions import DateParseError 11 | 12 | 13 | def convert_date(human_date: str, starting_date: Optional[datetime] = None) -> datetime: 14 | """Convert a human string into a datetime object. 15 | 16 | Arguments: 17 | human_date (str): Date string to convert 18 | starting_date (datetime): Date to compare. 19 | """ 20 | if starting_date is None: 21 | starting_date = datetime.now() 22 | 23 | date = _convert_weekday(human_date, starting_date) 24 | 25 | if date is not None: 26 | return date 27 | 28 | if re.match( 29 | r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}", 30 | human_date, 31 | ): 32 | return datetime.strptime(human_date, "%Y-%m-%dT%H:%M") 33 | elif re.match(r"[0-9]{4}.[0-9]{2}.[0-9]{2}", human_date): 34 | return datetime.strptime(human_date, "%Y-%m-%d") 35 | elif re.match(r"(now|today)", human_date): 36 | return starting_date 37 | elif re.match(r"tomorrow", human_date): 38 | return starting_date + relativedelta(days=1) 39 | elif re.match(r"yesterday", human_date): 40 | return starting_date + relativedelta(days=-1) 41 | else: 42 | return _str2date(human_date, starting_date) 43 | 44 | 45 | def _convert_weekday(human_date: str, starting_date: datetime) -> Optional[datetime]: 46 | """Convert a weekday human string into a datetime object. 47 | 48 | Arguments: 49 | human_date (str): Date string to convert 50 | starting_date (datetime): Date to compare. 51 | """ 52 | if re.match(r"mon.*", human_date): 53 | return _next_weekday(0, starting_date) 54 | elif re.match(r"tue.*", human_date): 55 | return _next_weekday(1, starting_date) 56 | elif re.match(r"wed.*", human_date): 57 | return _next_weekday(2, starting_date) 58 | elif re.match(r"thu.*", human_date): 59 | return _next_weekday(3, starting_date) 60 | elif re.match(r"fri.*", human_date): 61 | return _next_weekday(4, starting_date) 62 | elif re.match(r"sat.*", human_date): 63 | return _next_weekday(5, starting_date) 64 | elif re.match(r"sun.*", human_date): 65 | return _next_weekday(6, starting_date) 66 | else: 67 | return None 68 | 69 | 70 | def _str2date(modifier: str, starting_date: datetime) -> datetime: 71 | """Convert a string into a date using the supported code. 72 | 73 | Arguments: 74 | modifier (str): Possible inputs are a combination of: 75 | s: seconds, 76 | m: minutes, 77 | h: hours, 78 | d: days, 79 | w: weeks, 80 | mo: months, 81 | rmo: relative months, 82 | y: years. 83 | 84 | For example '5d10h3m10s'. 85 | starting_date (datetime): Date to compare 86 | 87 | Returns: 88 | resulting_date (datetime) 89 | """ 90 | date_delta = relativedelta() 91 | 92 | element_regexp = re.compile("(?P[0-9]+)(?P[a-z]+)") 93 | elements = element_regexp.findall(modifier) 94 | 95 | if len(elements) == 0: 96 | raise DateParseError( 97 | f"Unable to parse the date string {modifier}, please enter a valid one" 98 | ) 99 | for element_match in elements: 100 | value: int = int(element_match[0]) 101 | unit: str = element_match[1] 102 | 103 | if unit == "s": 104 | date_delta += relativedelta(seconds=value) 105 | elif unit == "m": 106 | date_delta += relativedelta(minutes=value) 107 | elif unit == "h": 108 | date_delta += relativedelta(hours=value) 109 | elif unit == "d": 110 | date_delta += relativedelta(days=value) 111 | elif unit == "mo": 112 | date_delta += relativedelta(months=value) 113 | elif unit == "w": 114 | date_delta += relativedelta(weeks=value) 115 | elif unit == "y": 116 | date_delta += relativedelta(years=value) 117 | elif unit == "rmo": 118 | date_delta += _next_monthday(value, starting_date) - starting_date 119 | return starting_date + date_delta 120 | 121 | 122 | def _next_weekday(weekday_number: int, starting_date: datetime) -> datetime: 123 | """Get the next week day of a given date. 124 | 125 | Arguments: 126 | weekday (int): Integer representation of weekday (0 == monday) 127 | starting_date (datetime): Date to compare 128 | """ 129 | if weekday_number == starting_date.weekday(): 130 | starting_date = starting_date + relativedelta(days=1) 131 | 132 | weekday = _int2weekday(weekday_number) 133 | 134 | date_delta: relativedelta = relativedelta( 135 | day=starting_date.day, 136 | weekday=weekday, 137 | ) 138 | return starting_date + date_delta 139 | 140 | 141 | def _next_monthday(months: int, starting_date: datetime) -> datetime: 142 | """Get the next same week day of the month for the specified number of months. 143 | 144 | For example the difference till the next 3rd Wednesday of the month 145 | after the next `months` months. 146 | 147 | Arguments: 148 | months (int): Number of months to skip. 149 | 150 | Returns: 151 | next_week_day () 152 | """ 153 | weekday = _int2weekday(starting_date.weekday()) 154 | 155 | first_month_weekday = starting_date - relativedelta(day=1, weekday=weekday(1)) 156 | month_weekday = (starting_date - first_month_weekday).days // 7 + 1 157 | 158 | date_delta = relativedelta(months=months, day=1, weekday=weekday(month_weekday)) 159 | return starting_date + date_delta 160 | 161 | 162 | def _int2weekday(weekday_number: int) -> weekday: 163 | """Return the weekday of an weekday integer. 164 | 165 | Arguments: 166 | weekday (int): Weekday, Monday == 0 167 | """ 168 | if weekday_number == 0: 169 | weekday = MO 170 | elif weekday_number == 1: 171 | weekday = TU 172 | elif weekday_number == 2: 173 | weekday = WE 174 | elif weekday_number == 3: 175 | weekday = TH 176 | elif weekday_number == 4: 177 | weekday = FR 178 | elif weekday_number == 5: 179 | weekday = SA 180 | elif weekday_number == 6: 181 | weekday = SU 182 | return weekday 183 | -------------------------------------------------------------------------------- /src/pydo/model/views.py: -------------------------------------------------------------------------------- 1 | """Define the object models for the views.""" 2 | 3 | from typing import List 4 | 5 | from pydantic import BaseModel, Field # noqa: E0611 6 | from repository_orm import EntityNotFoundError 7 | from rich import box 8 | from rich.console import Console 9 | from rich.style import Style 10 | from rich.table import Table 11 | 12 | 13 | class Colors(BaseModel): 14 | """Define the program colors.""" 15 | 16 | background_1: str = "#073642" 17 | background_2: str = "#002b36" 18 | foreground_1: str = "#657b83" 19 | foreground_2: str = "#586e75" 20 | yellow: str = "#b58900" 21 | orange: str = "#cb4b16" 22 | red: str = "#dc322f" 23 | magenta: str = "#d33682" 24 | violet: str = "#6c71c4" 25 | blue: str = "#268bd2" 26 | cyan: str = "#2aa198" 27 | green: str = "#859900" 28 | 29 | 30 | class Report(BaseModel): 31 | """Manage the data to print.""" 32 | 33 | labels: List[str] 34 | data: List[List[str]] = Field(default_factory=list) 35 | colors: Colors = Colors() 36 | 37 | def _remove_null_columns(self) -> None: 38 | """Remove the columns that have all None items from the report_data.""" 39 | for column in reversed(range(0, len(self.labels))): 40 | # If all entities have the None attribute value, remove the column from 41 | # the report. 42 | column_used = False 43 | for row in range(0, len(self.data)): 44 | if self.data[row][column] not in [None, ""]: 45 | column_used = True 46 | 47 | if not column_used: 48 | [row.pop(column) for row in self.data] 49 | self.labels.pop(column) 50 | 51 | def add(self, data: List[str]) -> None: 52 | """Add a row of data to the report.""" 53 | self.data.append(data) 54 | 55 | def print(self) -> None: 56 | """Print the report.""" 57 | self._remove_null_columns() 58 | 59 | if len(self.data) == 0: 60 | raise EntityNotFoundError("The report doesn't have any data to print") 61 | 62 | table = Table( 63 | box=box.SIMPLE, 64 | header_style=Style(color=self.colors.violet), 65 | footer_style=Style(color=self.colors.violet), 66 | style=Style(color=self.colors.background_1), 67 | border_style=Style(color=self.colors.background_1), 68 | row_styles=[ 69 | Style(color=self.colors.foreground_1, bgcolor=self.colors.background_1), 70 | Style(color=self.colors.foreground_1, bgcolor=self.colors.background_2), 71 | ], 72 | ) 73 | if len(self.data) > 60: 74 | table.show_footer = True 75 | 76 | for label in self.labels: 77 | table.add_column(label, footer=label) 78 | 79 | for row in self.data: 80 | row = [str(element) for element in row] 81 | table.add_row(*row) 82 | 83 | console = Console() 84 | console.print(table) 85 | -------------------------------------------------------------------------------- /src/pydo/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/src/pydo/py.typed -------------------------------------------------------------------------------- /src/pydo/test_typing.py: -------------------------------------------------------------------------------- 1 | """TEst.""" 2 | 3 | from typing import Dict, Iterable, List, Tuple, TypeVar 4 | 5 | from prompt_toolkit.layout.containers import HSplit 6 | from pydantic import BaseModel # noqa: E0611 7 | 8 | T = TypeVar("T", int, float, complex) 9 | 10 | Vec = Iterable[Tuple[T, T]] 11 | 12 | RowData = TypeVar("RowData", "BaseModel", Dict[str, str], List[str]) 13 | TableData = List[RowData] 14 | 15 | 16 | class TestClass(HSplit): 17 | """test.""" 18 | 19 | def __init__(self, vect: Vec = None, table: TableData = None): 20 | """test.""" 21 | self.vect = vect 22 | self.table = table 23 | -------------------------------------------------------------------------------- /src/pydo/types.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/src/pydo/types.py -------------------------------------------------------------------------------- /src/pydo/version.py: -------------------------------------------------------------------------------- 1 | """Utilities to retrieve the information of the program version.""" 2 | 3 | import platform 4 | import sys 5 | 6 | __version__ = "0.1.1" # Do not edit this line manually, let `make bump` do it. 7 | 8 | 9 | def version_info() -> str: 10 | """Display the version of the program, python and the platform.""" 11 | info = { 12 | "pydo version": __version__, 13 | "python version": sys.version.replace("\n", " "), 14 | "platform": platform.platform(), 15 | } 16 | return "\n".join(f"{k + ':' :>30} {v}" for k, v in info.items()) 17 | -------------------------------------------------------------------------------- /src/pydo/views.py: -------------------------------------------------------------------------------- 1 | """Store the representations of the data.""" 2 | 3 | import re 4 | from contextlib import suppress 5 | from datetime import datetime 6 | from enum import Enum 7 | from operator import attrgetter 8 | from typing import Dict, List, Optional, Tuple, TypeVar 9 | 10 | from repository_orm import Repository 11 | 12 | from . import config 13 | from .exceptions import ConfigError 14 | from .model.task import RecurrentTask, Task, TaskAttrs, TaskSelector 15 | from .model.views import Colors, Report 16 | 17 | EntityType = TypeVar("EntityType", Task, RecurrentTask) 18 | 19 | 20 | def print_task_report( 21 | repo: Repository, 22 | config: config.Config, 23 | report_name: str, 24 | task_selector: Optional[TaskSelector] = None, 25 | ) -> None: 26 | """Gather the common tasks required to print several tasks.""" 27 | if task_selector is None: 28 | task_selector = TaskSelector() 29 | 30 | # Initialize the Report 31 | ( 32 | columns, 33 | labels, 34 | default_task_filter, 35 | sort_criteria, 36 | ) = _get_task_report_configuration(config, report_name) 37 | colors = Colors(**config.data["themes"][config.get("theme")]) 38 | 39 | report = Report(labels=labels, colors=colors) 40 | 41 | # Complete the task_selector with the report task_filter 42 | task_selector.task_filter.update(default_task_filter) 43 | 44 | # Change the sorting of the report with the values of the task selector 45 | if task_selector.sort != []: 46 | sort_criteria = task_selector.sort 47 | 48 | with suppress(KeyError): 49 | if task_selector.task_filter["type"] == "recurrent_task": 50 | task_selector.model = RecurrentTask 51 | task_selector.task_filter.pop("type") 52 | 53 | # Fill up the report with the selected tasks 54 | tasks = repo.search(task_selector.task_filter, [task_selector.model]) 55 | for task in sort_tasks(tasks, sort_criteria): 56 | entity_line = [] 57 | for attribute in columns: 58 | value = getattr(task, attribute) 59 | if isinstance(value, list): 60 | value = ", ".join(value) 61 | elif isinstance(value, datetime): 62 | value = _date2str(config, value) 63 | elif isinstance(value, Enum): 64 | value = value.value.title() 65 | elif value is None: 66 | value = "" 67 | 68 | entity_line.append(value) 69 | report.add(entity_line) 70 | 71 | # Clean up the report and print it 72 | report._remove_null_columns() 73 | report.print() 74 | 75 | 76 | def _get_task_report_configuration( 77 | config: config.Config, 78 | report_name: str, 79 | ) -> Tuple[List[str], List[str], TaskAttrs, List[str]]: 80 | """Retrieve a task report configuration from the config file.""" 81 | columns = config.get(f"reports.task_reports.{report_name}.columns") 82 | available_labels = config.get("reports.task_attribute_labels") 83 | default_task_filter = config.get(f"reports.task_reports.{report_name}.filter") 84 | try: 85 | sort = config.get(f"reports.task_reports.{report_name}.sort") 86 | except ConfigError: 87 | # Sort by id_ by default 88 | sort = ["id_"] 89 | 90 | if not isinstance(columns, list): 91 | raise ValueError( 92 | f"The columns configuration of the {report_name} report is not a list." 93 | ) 94 | if not isinstance(default_task_filter, dict): 95 | raise ValueError( 96 | f"The filter configuration of the {report_name} report is not a dictionary." 97 | ) 98 | 99 | if not isinstance(available_labels, dict): 100 | raise ValueError("The labels configuration of the reports is not a dictionary.") 101 | 102 | if not isinstance(sort, list): 103 | raise ValueError( 104 | f"The sort configuration of the {report_name} report is not a list." 105 | ) 106 | labels = [available_labels[task_attribute] for task_attribute in columns] 107 | 108 | return columns, labels, default_task_filter, sort 109 | 110 | 111 | def sort_tasks(tasks: List[Task], sort_criteria: List[str]) -> List[Task]: 112 | """Sorts the tasks given the criteria. 113 | 114 | Args: 115 | tasks: List of tasks to sort 116 | sort_criteria: An ordered list of task attributes to use for sorting. 117 | If the attribute is prepended with a + it will be sort in increasing value, 118 | if it's prepended with a -, it will be sorted decreasingly. 119 | Returns: 120 | List of ordered tasks 121 | """ 122 | for criteria in reversed(sort_criteria): 123 | if re.match("^-", criteria): 124 | reverse = True 125 | criteria = criteria[1:] 126 | elif re.match(r"^\+", criteria): 127 | reverse = False 128 | criteria = criteria[1:] 129 | else: 130 | reverse = False 131 | 132 | tasks.sort(key=attrgetter(criteria), reverse=reverse) 133 | return tasks 134 | 135 | 136 | def _date2str(config: config.Config, date: datetime) -> str: 137 | """Convert a datetime object into a string. 138 | 139 | Using the format specified in the reports.date_format configuration. 140 | """ 141 | date_format = str(config.get("reports.date_format")) 142 | 143 | return date.strftime(date_format) 144 | 145 | 146 | def areas(repo: Repository) -> None: 147 | """Print the areas information.""" 148 | report = Report(labels=["Name", "Open Tasks"]) 149 | open_tasks = repo.search({"active": True}, [Task]) 150 | 151 | # Gather areas 152 | areas: Dict[str, int] = {} 153 | for task in open_tasks: 154 | if task.area is None: 155 | area = "None" 156 | else: 157 | area = task.area 158 | 159 | areas.setdefault(area, 0) 160 | areas[area] += 1 161 | 162 | for area in sorted(areas.keys()): 163 | report.add([area, str(areas[area])]) 164 | 165 | report.print() 166 | 167 | 168 | def tags(repo: Repository) -> None: 169 | """Print the tags information.""" 170 | report = Report(labels=["Name", "Open Tasks"]) 171 | open_tasks = repo.search({"active": True}, [Task]) 172 | 173 | # Gather tags 174 | tags: Dict[str, int] = {} 175 | for task in open_tasks: 176 | if len(task.tags) == 0: 177 | tags.setdefault("None", 0) 178 | tags["None"] += 1 179 | else: 180 | for tag in task.tags: 181 | tags.setdefault(tag, 0) 182 | tags[tag] += 1 183 | 184 | for tag in sorted(tags.keys()): 185 | report.add([tag, str(tags[tag])]) 186 | 187 | report.print() 188 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/tests/__init__.py -------------------------------------------------------------------------------- /tests/assets/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Configuration of the reports. 3 | reports: 4 | # Datetime strftime compatible string to print dates. 5 | date_format: '%Y-%m-%d %H:%M' 6 | 7 | # Equivalence between task attributes and how they are shown in the reports 8 | task_attribute_labels: 9 | id_: ID 10 | description: Description 11 | agile: Agile 12 | body: Body 13 | closed: Closed 14 | created: Created 15 | due: Due 16 | estimate: Est 17 | fun: Fun 18 | parent_id: Parent 19 | area: Area 20 | priority: Pri 21 | state: State 22 | recurrence: Recur 23 | recurrence_type: RecurType 24 | tags: Tags 25 | value: Val 26 | wait: Wait 27 | willpower: WP 28 | 29 | # Definition of reports over a group of tasks: 30 | # 31 | # Each of them has the following properties: 32 | # * report_name: It's the key that identifies the report. 33 | # * columns: Ordered list of task attributes to print. 34 | # * filter: Dictionary of task properties that narrow down the tasks you 35 | # want to print. 36 | # * sort: Ordered list of criteria used to sort the tasks. 37 | task_reports: 38 | # Open: Print active tasks. 39 | open: 40 | filter: 41 | active: true 42 | type: task 43 | columns: 44 | - id_ 45 | - description 46 | - area 47 | - priority 48 | - tags 49 | - due 50 | - parent_id 51 | 52 | # Closed: Print inactive tasks. 53 | closed: 54 | filter: 55 | active: false 56 | type: task 57 | columns: 58 | - id_ 59 | - description 60 | - area 61 | - priority 62 | - tags 63 | - due 64 | - parent_id 65 | 66 | # Recurring: Print repeating and recurring active parent tasks. 67 | recurring: 68 | filter: 69 | active: true 70 | type: recurrent_task 71 | columns: 72 | - id_ 73 | - description 74 | - recurrence 75 | - recurrence_type 76 | - area 77 | - priority 78 | - tags 79 | - due 80 | 81 | # Frozen: Print repeating and recurring inactive parent tasks. 82 | frozen: 83 | filter: 84 | state: frozen 85 | type: recurrent_task 86 | columns: 87 | - id_ 88 | - description 89 | - recurrence 90 | - recurrence_type 91 | - area 92 | - priority 93 | - tags 94 | - due 95 | - parent_id 96 | 97 | # Level of logging verbosity. One of ['info', 'debug', 'warning']. 98 | verbose: info 99 | 100 | # URL specifying the connection to the database. For example: 101 | # * tinydb: tinydb:////home/user/database.tinydb 102 | # * sqlite: sqlite:////home/user/mydb.sqlite 103 | # * mysql: mysql://scott:tiger@localhost/mydatabase 104 | database_url: fake:///fake.db 105 | 106 | # Colors 107 | theme: solarized_dark 108 | themes: 109 | solarized_dark: 110 | background_1: '#073642' 111 | background_2: '#002b36' 112 | foreground_1: '#657b83' 113 | foreground_2: '#586e75' 114 | yellow: '#b58900' 115 | orange: '#cb4b16' 116 | red: '#dc322f' 117 | magenta: '#d33682' 118 | violet: '#6c71c4' 119 | blue: '#268bd2' 120 | cyan: '#2aa198' 121 | green: '#859900' 122 | solarized: 123 | background_1: '#fdf6e3' 124 | background_2: '#eee8d5' 125 | foreground_1: '#839496' 126 | foreground_2: '#93a1a1' 127 | yellow: '#b58900' 128 | orange: '#cb4b16' 129 | red: '#dc322f' 130 | magenta: '#d33682' 131 | violet: '#6c71c4' 132 | blue: '#268bd2' 133 | cyan: '#2aa198' 134 | green: '#859900' 135 | -------------------------------------------------------------------------------- /tests/cases.py: -------------------------------------------------------------------------------- 1 | """Define all cases for the tests.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Store the classes and fixtures used throughout the tests.""" 2 | 3 | from random import SystemRandom 4 | from shutil import copyfile 5 | from typing import List, Tuple 6 | 7 | import pytest 8 | from _pytest.tmpdir import TempdirFactory 9 | from repository_orm import FakeRepository, Repository, load_repository 10 | from tests import factories 11 | 12 | from pydo.config import Config 13 | from pydo.model.task import RecurrentTask, Task 14 | 15 | 16 | @pytest.fixture(name="config") 17 | def fixture_config(tmpdir_factory: TempdirFactory) -> Config: 18 | """Configure the Config object for the tests.""" 19 | data = tmpdir_factory.mktemp("data") 20 | config_file = str(data.join("config.yaml")) 21 | copyfile("tests/assets/config.yaml", config_file) 22 | config = Config(config_file) 23 | config["database_url"] = f"tinydb://{data}/database.tinydb" 24 | config.save() 25 | 26 | return config 27 | 28 | 29 | @pytest.fixture(scope="session", autouse=True) 30 | def faker_seed() -> int: 31 | """Make faker use a random seed when generating values. 32 | 33 | There is no need to add it anywhere, the faker fixture uses it. 34 | """ 35 | return SystemRandom().randint(0, 999999) 36 | 37 | 38 | @pytest.fixture(name="repo") 39 | def repo_() -> FakeRepository: 40 | """Configure a FakeRepository instance.""" 41 | return FakeRepository([Task, RecurrentTask]) 42 | 43 | 44 | @pytest.fixture(name="task") 45 | def task_(repo: Repository) -> Task: 46 | """Insert a Task in the FakeRepository.""" 47 | task = factories.TaskFactory.create(state="backlog") 48 | repo.add(task) 49 | repo.commit() 50 | 51 | return task 52 | 53 | 54 | @pytest.fixture(name="tasks") 55 | def tasks_(repo: Repository) -> List[Task]: 56 | """Insert three Tasks in the FakeRepository.""" 57 | tasks = sorted(factories.TaskFactory.create_batch(3, state="backlog")) 58 | for task in tasks: 59 | repo.add(task) 60 | repo.commit() 61 | 62 | return tasks 63 | 64 | 65 | @pytest.fixture(name="parent_and_child_tasks") 66 | def parent_and_child_tasks_( 67 | repo: Repository, 68 | ) -> Tuple[RecurrentTask, Task]: 69 | """Insert a RecurrentTask and it's children Task in the FakeRepository.""" 70 | parent_task = factories.RecurrentTaskFactory.create(state="backlog") 71 | child_task = parent_task.breed_children() 72 | 73 | repo.add(parent_task) 74 | repo.add(child_task) 75 | repo.commit() 76 | 77 | return parent_task, child_task 78 | 79 | 80 | @pytest.fixture() 81 | def insert_parent_task( 82 | repo: Repository, 83 | ) -> Tuple[RecurrentTask, Task]: 84 | """Insert a RecurrentTask and it's children Task in the FakeRepository.""" 85 | parent_task = factories.RecurrentTaskFactory.create(state="backlog") 86 | child_task = parent_task.breed_children() 87 | 88 | repo.add(parent_task) 89 | repo.add(child_task) 90 | repo.commit() 91 | 92 | return parent_task, child_task 93 | 94 | 95 | @pytest.fixture() 96 | def insert_parent_tasks( 97 | repo: Repository, 98 | ) -> Tuple[List[RecurrentTask], List[Task]]: 99 | """Insert a RecurrentTask and it's children Task in the FakeRepository.""" 100 | parent_tasks = factories.RecurrentTaskFactory.create_batch(3, state="backlog") 101 | child_tasks = [] 102 | 103 | for parent_task in parent_tasks: 104 | child_task = parent_task.breed_children() 105 | parent_task.children = [child_task] 106 | child_task.parent = parent_task 107 | repo.add(parent_task) 108 | repo.add(child_task) 109 | 110 | child_tasks.append(child_task) 111 | 112 | repo.commit() 113 | 114 | return parent_tasks, child_tasks 115 | 116 | 117 | @pytest.fixture() 118 | def insert_multiple_tasks(repo: Repository) -> List[Task]: 119 | """Insert three Tasks in the repository.""" 120 | tasks = sorted(factories.TaskFactory.create_batch(20, state="backlog")) 121 | [repo.add(task) for task in tasks] 122 | repo.commit() 123 | 124 | return tasks 125 | 126 | 127 | @pytest.fixture() 128 | def repo_e2e(config: Config) -> Repository: 129 | """Configure the end to end repository.""" 130 | return load_repository([Task, RecurrentTask], config["database_url"]) 131 | 132 | 133 | @pytest.fixture() 134 | def insert_task_e2e(repo_e2e: Repository) -> Task: 135 | """Insert a task in the end to end repository.""" 136 | task = factories.TaskFactory.create(state="backlog") 137 | repo_e2e.add(task) 138 | repo_e2e.commit() 139 | return task 140 | 141 | 142 | @pytest.fixture() 143 | def insert_tasks_e2e(repo_e2e: Repository) -> List[Task]: 144 | """Insert many tasks in the end to end repository.""" 145 | tasks = factories.TaskFactory.create_batch(3, priority=3, state="backlog") 146 | different_task = factories.TaskFactory.create(priority=2, state="backlog") 147 | tasks.append(different_task) 148 | 149 | for task in tasks: 150 | repo_e2e.add(task) 151 | repo_e2e.commit() 152 | 153 | return tasks 154 | 155 | 156 | @pytest.fixture() 157 | def insert_parent_task_e2e(repo_e2e: Repository) -> Tuple[RecurrentTask, Task]: 158 | """Insert a RecurrentTask and it's children Task in the repository.""" 159 | parent_task = factories.RecurrentTaskFactory.create(state="backlog") 160 | child_task = parent_task.breed_children() 161 | 162 | repo_e2e.add(parent_task) 163 | repo_e2e.add(child_task) 164 | repo_e2e.commit() 165 | 166 | return parent_task, child_task 167 | 168 | 169 | @pytest.fixture() 170 | def insert_parent_tasks_e2e( 171 | repo_e2e: Repository, 172 | ) -> Tuple[List[RecurrentTask], List[Task]]: 173 | """Insert a RecurrentTask and it's children Task in the repository.""" 174 | parent_tasks = factories.RecurrentTaskFactory.create_batch(3, state="backlog") 175 | child_tasks = [parent_task.breed_children() for parent_task in parent_tasks] 176 | 177 | [repo_e2e.add(parent_task) for parent_task in parent_tasks] 178 | [repo_e2e.add(child_task) for child_task in child_tasks] 179 | repo_e2e.commit() 180 | 181 | return parent_tasks, child_tasks 182 | 183 | 184 | @pytest.fixture() 185 | def insert_frozen_parent_task_e2e(repo_e2e: Repository) -> RecurrentTask: 186 | """Insert a RecurrentTask in frozen state.""" 187 | parent_task = factories.RecurrentTaskFactory.create(state="backlog") 188 | parent_task.freeze() 189 | repo_e2e.add(parent_task) 190 | repo_e2e.commit() 191 | 192 | return parent_task 193 | 194 | 195 | @pytest.fixture() 196 | def insert_frozen_parent_tasks_e2e(repo_e2e: Repository) -> List[RecurrentTask]: 197 | """Insert many RecurrentTask in frozen state.""" 198 | parent_tasks = factories.RecurrentTaskFactory.create_batch(3, state="backlog") 199 | for parent_task in parent_tasks: 200 | parent_task.freeze() 201 | repo_e2e.add(parent_task) 202 | repo_e2e.commit() 203 | 204 | return parent_tasks 205 | -------------------------------------------------------------------------------- /tests/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/tests/e2e/__init__.py -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | """Define the factories for the tests.""" 2 | 3 | from datetime import datetime 4 | from random import SystemRandom 5 | from typing import Optional 6 | 7 | import factory 8 | from faker import Faker 9 | from faker_enum import EnumProvider 10 | 11 | from pydo.model.task import RecurrenceType, RecurrentTask, Task, TaskState 12 | 13 | factory.Faker.add_provider(EnumProvider) 14 | 15 | faker = Faker() # type: ignore 16 | 17 | 18 | class TaskFactory(factory.Factory): # type: ignore 19 | """Generate a fake task.""" 20 | 21 | id_ = factory.Faker("pyint") 22 | state = factory.Faker("enum", enum_cls=TaskState) 23 | subtype = "task" 24 | active = True 25 | area = factory.Faker("word") 26 | priority = factory.Faker("random_number") 27 | description = factory.Faker("sentence") 28 | 29 | # Let half the tasks have a due date 30 | 31 | @factory.lazy_attribute 32 | def due(self) -> Optional[datetime]: 33 | """Generate the due date for half of the tasks.""" 34 | if SystemRandom().random() > 0.5: 35 | return faker.date_time() 36 | return None 37 | 38 | @factory.lazy_attribute 39 | def closed(self) -> factory.Faker: 40 | """Generate the closed date for the tasks with completed or deleted state.""" 41 | if self.state == "completed" or self.state == "deleted": 42 | return faker.date_time() 43 | return None 44 | 45 | class Meta: 46 | """Configure factoryboy class.""" 47 | 48 | model = Task 49 | 50 | 51 | class RecurrentTaskFactory(TaskFactory): 52 | """Generate a fake recurrent task.""" 53 | 54 | recurrence = factory.Faker("word", ext_word_list=["1d", "1rmo", "1y2mo30s"]) 55 | recurrence_type = factory.Faker("enum", enum_cls=RecurrenceType) 56 | due = factory.Faker("date_time") 57 | subtype = "recurrent_task" 58 | 59 | class Meta: 60 | """Configure factoryboy class.""" 61 | 62 | model = RecurrentTask 63 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/entrypoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/tests/unit/entrypoints/__init__.py -------------------------------------------------------------------------------- /tests/unit/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyz-code/pydo/d974eafe8db4adb6dbb1e47d273b7cbbd24dbd7b/tests/unit/model/__init__.py -------------------------------------------------------------------------------- /tests/unit/model/test_date.py: -------------------------------------------------------------------------------- 1 | """Test the date implementation.""" 2 | 3 | from datetime import datetime, timedelta 4 | 5 | import pytest 6 | 7 | from pydo import exceptions 8 | from pydo.model.date import convert_date 9 | 10 | 11 | @pytest.fixture() 12 | def now() -> datetime: 13 | """Return the datetime of now.""" 14 | return datetime.now() 15 | 16 | 17 | @pytest.mark.parametrize( 18 | ("date_string", "starting_date", "expected_date"), 19 | [ 20 | ("monday", datetime(2020, 1, 6), datetime(2020, 1, 13)), 21 | ("mon", datetime(2020, 1, 6), datetime(2020, 1, 13)), 22 | ("tuesday", datetime(2020, 1, 7), datetime(2020, 1, 14)), 23 | ("tue", datetime(2020, 1, 7), datetime(2020, 1, 14)), 24 | ("wednesday", datetime(2020, 1, 8), datetime(2020, 1, 15)), 25 | ("wed", datetime(2020, 1, 8), datetime(2020, 1, 15)), 26 | ("thursdday", datetime(2020, 1, 9), datetime(2020, 1, 16)), 27 | ("thu", datetime(2020, 1, 9), datetime(2020, 1, 16)), 28 | ("friday", datetime(2020, 1, 10), datetime(2020, 1, 17)), 29 | ("fri", datetime(2020, 1, 10), datetime(2020, 1, 17)), 30 | ("saturday", datetime(2020, 1, 11), datetime(2020, 1, 18)), 31 | ("sat", datetime(2020, 1, 11), datetime(2020, 1, 18)), 32 | ("sunday", datetime(2020, 1, 12), datetime(2020, 1, 19)), 33 | ("sun", datetime(2020, 1, 12), datetime(2020, 1, 19)), 34 | ("1d", datetime(2020, 1, 12), datetime(2020, 1, 13)), 35 | ("1mo", datetime(2020, 1, 12), datetime(2020, 2, 12)), 36 | ("1rmo", datetime(2020, 1, 12), datetime(2020, 2, 9)), 37 | ("tomorrow", datetime(2020, 1, 12), datetime(2020, 1, 13)), 38 | ("yesterday", datetime(2020, 1, 12), datetime(2020, 1, 11)), 39 | ("1w", datetime(2020, 1, 12), datetime(2020, 1, 19)), 40 | ("1y", datetime(2020, 1, 12), datetime(2021, 1, 12)), 41 | ("2019-05-05", None, datetime(2019, 5, 5)), 42 | ("2019-05-05T10:00", None, datetime(2019, 5, 5, 10)), 43 | ("1d1mo1y", datetime(2020, 1, 7), datetime(2021, 2, 8)), 44 | ], 45 | ) 46 | @pytest.mark.freeze_time() 47 | def test_convert_date_accepts_human_string( 48 | date_string: str, starting_date: datetime, expected_date: datetime 49 | ) -> None: 50 | """Test common human word translation to actual datetimes.""" 51 | result = convert_date(date_string, starting_date) 52 | 53 | assert result == expected_date 54 | 55 | 56 | @pytest.mark.freeze_time() 57 | def test_convert_date_accepts_now(now: datetime) -> None: 58 | """Test that now works as date input.""" 59 | result = convert_date("now") 60 | 61 | assert result == now 62 | 63 | 64 | @pytest.mark.freeze_time() 65 | def test_convert_date_accepts_today(now: datetime) -> None: 66 | """Test that today works as date input.""" 67 | result = convert_date("today") 68 | 69 | assert result == now 70 | 71 | 72 | def test_next_weekday_if_starting_weekday_is_smaller() -> None: 73 | """ 74 | Given: starting date is Monday, which is weekday 0 75 | When: using convert_date to get the next Tuesday, which is weekday 1 76 | Then: the next Tuesday is returned. 77 | """ 78 | starting_date = datetime(2020, 1, 6) 79 | 80 | result = convert_date("tuesday", starting_date) 81 | 82 | assert result == datetime(2020, 1, 7) 83 | 84 | 85 | def test_next_weekday_if_starting_weekday_is_greater() -> None: 86 | """ 87 | Given: starting date is Wednesday, which is weekday 2 88 | When: using convert_date to get the next Tuesday, which is weekday 1 89 | Then: the next Tuesday is returned. 90 | """ 91 | starting_date = datetime(2020, 1, 8) 92 | 93 | result = convert_date("tuesday", starting_date) 94 | 95 | assert result == datetime(2020, 1, 14) 96 | 97 | 98 | def test_next_weekday_if_weekdays_are_equal() -> None: 99 | """ 100 | Given: starting date is Monday, which is weekday 0 101 | When: using convert_date to get the next Monday, which is weekday 0 102 | Then: the next Monday is returned. 103 | """ 104 | starting_date = datetime(2020, 1, 6) 105 | 106 | result = convert_date("monday", starting_date) 107 | 108 | assert result == datetime(2020, 1, 13) 109 | 110 | 111 | def test_next_relative_month_works_if_start_from_first_day_of_month() -> None: 112 | """ 113 | Given: The first Tuesday of the month as the starting date 114 | When: Asking for the next relative month day. 115 | Then: The 1st Tuesday of the next month is returned 116 | A month is not equal to 30d, it depends on the days of the month, 117 | use this in case you want for example the 3rd Friday of the month 118 | """ 119 | starting_date = datetime(2020, 1, 7) 120 | 121 | result = convert_date("1rmo", starting_date) 122 | 123 | assert result == datetime(2020, 2, 4) 124 | 125 | 126 | def test_next_relative_month_works_if_start_from_second_day_of_month() -> None: 127 | """ 128 | Given: The second Wednesday of the month 129 | When: asking for the next relative month 130 | Then: the second Wednesday of the next month is returned 131 | """ 132 | starting_date = datetime(2020, 1, 8) 133 | 134 | result = convert_date("1rmo", starting_date) 135 | 136 | assert result == datetime(2020, 2, 12) 137 | 138 | 139 | def test_next_relative_month_works_if_start_from_fifth_day_of_month() -> None: 140 | """ 141 | Given: The fifth Monday of the month 142 | When: asking for the next relative month 143 | Then: the first Monday of the following to the next month is returned 144 | """ 145 | starting_date = datetime(2019, 12, 30) 146 | 147 | result = convert_date("1rmo", starting_date) 148 | 149 | assert result == datetime(2020, 2, 3) 150 | 151 | 152 | def test_if_convert_date_accepts_seconds(now: datetime) -> None: 153 | """Test seconds works as date input.""" 154 | result = convert_date("1s", now) 155 | 156 | assert result == now + timedelta(seconds=1) 157 | 158 | 159 | def test_if_convert_date_accepts_minutes(now: datetime) -> None: 160 | """Test minutes works as date input.""" 161 | result = convert_date("1m", now) 162 | 163 | assert result == now + timedelta(minutes=1) 164 | 165 | 166 | def test_if_convert_date_accepts_hours(now: datetime) -> None: 167 | """Test hours works as date input.""" 168 | result = convert_date("1h", now) 169 | 170 | assert result == now + timedelta(hours=1) 171 | 172 | 173 | def test_if_convert_date_accepts_days(now: datetime) -> None: 174 | """Test days works as date input.""" 175 | result = convert_date("1d", now) 176 | 177 | assert result == now + timedelta(days=1) 178 | 179 | 180 | def test_if_convert_date_accepts_months_if_on_31() -> None: 181 | """Test 1mo works if starting date is the 31th of a month.""" 182 | starting_date = datetime(2020, 1, 31) 183 | 184 | result = convert_date("1mo", starting_date) 185 | 186 | assert result == datetime(2020, 2, 29) 187 | 188 | 189 | def test_convert_date_raises_error_if_wrong_format() -> None: 190 | """Test error is returned if the date format is wrong.""" 191 | with pytest.raises(exceptions.DateParseError): 192 | convert_date("wrong date string") 193 | -------------------------------------------------------------------------------- /tests/unit/model/test_task.py: -------------------------------------------------------------------------------- 1 | """Test generic behaviour of all Task objects and subclasses.""" 2 | 3 | from datetime import datetime 4 | from typing import Dict 5 | 6 | import pytest 7 | from faker.proxy import Faker 8 | from pydantic import ValidationError 9 | from tests import factories 10 | from tests.factories import RecurrentTaskFactory 11 | 12 | from pydo.model.task import RecurrentTask, Task 13 | 14 | 15 | @pytest.fixture(name="task_attributes") 16 | def task_attributes_(faker: Faker) -> Dict[str, str]: 17 | """Create the basic attributes of a task.""" 18 | return { 19 | "id_": faker.pyint(), 20 | "description": faker.sentence(), 21 | } 22 | 23 | 24 | @pytest.fixture(name="task") 25 | def task_() -> Task: 26 | """Create a task""" 27 | return factories.TaskFactory.create() 28 | 29 | 30 | @pytest.fixture(name="open_task") 31 | def open_task_() -> Task: 32 | """Create an open task""" 33 | return factories.TaskFactory.create(state="backlog") 34 | 35 | 36 | def test_raise_error_if_add_task_assigns_unvalid_agile_state( 37 | task_attributes: Dict[str, str], faker: Faker 38 | ) -> None: 39 | """ 40 | Given: Nothing 41 | When: A Task is initialized with an invalid agile state 42 | Then: ValueError is raised 43 | """ 44 | task_attributes["state"] = faker.word() 45 | 46 | with pytest.raises( 47 | ValidationError, 48 | match="value is not a valid enumeration member; permitted: 'backlog'", 49 | ): 50 | Task(**task_attributes) 51 | 52 | 53 | @pytest.mark.freeze_time("2017-05-21") 54 | def test_task_closing(open_task: Task) -> None: 55 | """ 56 | Given: An open task 57 | When: The close method is called 58 | Then: The current date is registered and the state is transitioned to closed. 59 | """ 60 | now = datetime.now() 61 | 62 | open_task.close() # act 63 | 64 | assert open_task.state == "done" 65 | assert open_task.active is False 66 | assert open_task.closed == now 67 | 68 | 69 | def test_add_recurrent_task_raises_exception_if_recurrence_type_is_incorrect( 70 | task_attributes: Dict[str, str], 71 | faker: Faker, 72 | ) -> None: 73 | """ 74 | Given: The task attributes of a recurrent task with a wrong recurrence_type 75 | When: A RecurrentTask is initialized. 76 | Then: TaskAttributeError exception is raised. 77 | """ 78 | task_attributes = { 79 | **task_attributes, 80 | "due": faker.date_time(), 81 | "recurrence": "1d", 82 | "recurrence_type": "inexistent_recurrence_type", 83 | } 84 | 85 | with pytest.raises( 86 | ValidationError, 87 | match="value is not a valid enumeration member; permitted: 'recurring'", 88 | ): 89 | RecurrentTask(**task_attributes) 90 | 91 | 92 | @pytest.mark.parametrize("recurrence_type", ["recurring", "repeating"]) 93 | def test_raise_exception_if_recurring_task_doesnt_have_due( 94 | task_attributes: Dict[str, str], 95 | recurrence_type: str, 96 | ) -> None: 97 | """ 98 | Given: The task attributes of a recurrent task without a due date. 99 | When: A RecurrentTask is initialized. 100 | Then: TaskAttributeError exception is raised. 101 | """ 102 | task_attributes = { 103 | **task_attributes, 104 | "recurrence": "1d", 105 | "recurrence_type": recurrence_type, 106 | } 107 | 108 | with pytest.raises( 109 | ValidationError, 110 | match="field required", 111 | ): 112 | RecurrentTask(**task_attributes) 113 | 114 | 115 | @pytest.mark.parametrize("recurrence_type", ["recurring", "repeating"]) 116 | def test_breed_children_removes_unwanted_parent_data( 117 | recurrence_type: str, 118 | ) -> None: 119 | """ 120 | Given: A valid parent task. 121 | When: The breed_children method is called. 122 | Then: The children doesn't have the recurrence and recurrence_type attributes. 123 | """ 124 | parent = RecurrentTaskFactory(recurrence_type=recurrence_type) 125 | 126 | result = parent.breed_children() 127 | 128 | assert "recurrence" not in result.__dict__.keys() 129 | assert "recurrence_type" not in result.__dict__.keys() 130 | 131 | 132 | @pytest.mark.parametrize("recurrence_type", ["recurring", "repeating"]) 133 | @pytest.mark.freeze_time("2017-05-21") 134 | def test_breed_children_new_due_uses_parent_if_no_children( 135 | recurrence_type: str, 136 | ) -> None: 137 | """ 138 | Given: A recurring parent task without children, and it's due is after today. 139 | When: breed_children is called. 140 | Then: The first children's due date will be the parent's. 141 | """ 142 | parent = factories.RecurrentTaskFactory( 143 | recurrence_type=recurrence_type, recurrence="1mo", due=datetime(2020, 8, 2) 144 | ) 145 | 146 | result = parent.breed_children() 147 | 148 | assert result.due == parent.due 149 | 150 | 151 | @pytest.mark.freeze_time("2017-05-21") 152 | def test_breed_children_new_due_follows_recurr_algorithm() -> None: 153 | """ 154 | Given: A recurring parent task. 155 | When: breed_children is called. 156 | Then: The children's due date is calculated using the recurring algorithm. 157 | It will apply `recurrence` to the parent's due date, until we get the next 158 | one in the future. 159 | """ 160 | parent = RecurrentTaskFactory( 161 | recurrence_type="recurring", recurrence="1mo", due=datetime(1800, 8, 2) 162 | ) 163 | first_child = factories.TaskFactory(parent_id=parent.id_) 164 | first_child.close("completed", datetime(1800, 8, 2)) 165 | 166 | result = parent.breed_children(first_child) 167 | 168 | assert result.due == datetime(2017, 6, 2) 169 | 170 | 171 | @pytest.mark.freeze_time("2017-05-21") 172 | def test_breed_children_new_due_follows_repeating_algorithm() -> None: 173 | """ 174 | Given: A repeating parent task, and it's first child. 175 | When: breed_children is called. 176 | Then: The second children's due date is calculated using the repeating algorithm. 177 | It will apply `recurrence` to the last completed or deleted child's 178 | completed date independently of when today is. 179 | """ 180 | parent = RecurrentTaskFactory( 181 | recurrence_type="repeating", recurrence="1mo", due=datetime(1800, 8, 2) 182 | ) 183 | first_child = factories.TaskFactory(parent_id=parent.id_) 184 | first_child.close("completed", datetime(2020, 8, 2)) 185 | 186 | result = parent.breed_children(first_child) 187 | 188 | assert result.due == datetime(2020, 9, 2) 189 | 190 | 191 | @pytest.mark.freeze_time("2017-05-21") 192 | def test_breed_children_new_due_follows_repeating_algorithm_if_no_children() -> None: 193 | """ 194 | Given: A repeating parent task without any child 195 | When: breed_children is called. 196 | Then: The first child is created with a date of today. 197 | """ 198 | parent = RecurrentTaskFactory( 199 | recurrence_type="repeating", recurrence="1mo", due=datetime(1800, 8, 2) 200 | ) 201 | 202 | result = parent.breed_children() 203 | 204 | assert result.due == datetime(2017, 5, 21) 205 | 206 | 207 | @pytest.mark.freeze_time("2017-05-21") 208 | def test_breed_children_repeating_when_last_child_plus_rec_older_than_today() -> None: 209 | """ 210 | Given: A repeating parent task, and the last child due plus the recurrence date is 211 | before today. 212 | When: breed_children is called. 213 | Then: The second children's due date is set to now. 214 | """ 215 | parent = RecurrentTaskFactory( 216 | recurrence_type="repeating", recurrence="1mo", due=datetime(1800, 8, 2) 217 | ) 218 | first_child = factories.TaskFactory(parent_id=parent.id_) 219 | first_child.close("completed", datetime(1800, 8, 3)) 220 | 221 | result = parent.breed_children(first_child) 222 | 223 | assert result.due == datetime(2017, 5, 21) 224 | 225 | 226 | def test_not_frozen_recurrent_task_cant_be_thawed() -> None: 227 | """ 228 | Given: A non frozen recurrent task 229 | When: trying to thaw it 230 | Then: an error is returned 231 | """ 232 | task = RecurrentTaskFactory(state="backlog") 233 | 234 | with pytest.raises( 235 | ValueError, match=rf"Task {task.id_}: {task.description} is not frozen" 236 | ): 237 | task.thaw() 238 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | """Test the configuration of the program.""" 2 | 3 | from unittest.mock import Mock, patch 4 | 5 | import pytest 6 | from ruyaml.scanner import ScannerError 7 | 8 | from pydo.config import Config 9 | from pydo.exceptions import ConfigError 10 | 11 | 12 | # R0903: too few methods. C'est la vie! 13 | class FileMarkMock: # noqa: R0903 14 | """Mock of the ruamel FileMark object.""" 15 | 16 | name: str = "mark" 17 | line: int = 1 18 | column: int = 1 19 | 20 | 21 | def test_config_load(config: Config) -> None: 22 | """Loading the configuration from the yaml file works.""" 23 | config.load() # act 24 | 25 | assert config.data["verbose"] == "info" 26 | 27 | 28 | @patch("pydo.config.YAML") 29 | def test_load_handles_wrong_file_format(yaml_mock: Mock, config: Config) -> None: 30 | """ 31 | Given: A config file with wrong yaml format. 32 | When: configuration is loaded. 33 | Then: A ConfigError is returned. 34 | """ 35 | yaml_mock.return_value.load.side_effect = ScannerError( 36 | "error", 37 | FileMarkMock(), 38 | "problem", 39 | FileMarkMock(), 40 | ) 41 | 42 | with pytest.raises(ConfigError): 43 | config.load() 44 | 45 | 46 | def test_load_handles_file_not_found(config: Config) -> None: 47 | """ 48 | Given: An inexistent config file. 49 | When: configuration is loaded. 50 | Then: A ConfigError is returned. 51 | """ 52 | config.config_path = "inexistent.yaml" 53 | 54 | with pytest.raises(FileNotFoundError): 55 | config.load() 56 | 57 | 58 | def test_save_config(config: Config) -> None: 59 | """Saving the configuration to the yaml file works.""" 60 | config.data = {"a": "b"} 61 | 62 | config.save() # act 63 | 64 | with open(config.config_path, "r") as file_cursor: 65 | assert "a:" in file_cursor.read() 66 | 67 | 68 | def test_get_can_fetch_nested_items_with_dots(config: Config) -> None: 69 | """Fetching values of configuration keys using dot notation works.""" 70 | config.data = { 71 | "first": {"second": "value"}, 72 | } 73 | 74 | result = config.get("first.second") 75 | 76 | assert result == "value" 77 | 78 | 79 | def test_config_can_fetch_nested_items_with_dictionary_notation(config: Config) -> None: 80 | """Fetching values of configuration keys using the dictionary notation works.""" 81 | config.data = { 82 | "first": {"second": "value"}, 83 | } 84 | 85 | result = config["first"]["second"] 86 | 87 | assert result == "value" 88 | 89 | 90 | def test_get_an_inexistent_key_raises_error(config: Config) -> None: 91 | """If the key you're trying to fetch doesn't exist, raise a KeyError exception.""" 92 | config.data = { 93 | "reports": {"second": "value"}, 94 | } 95 | 96 | with pytest.raises(ConfigError): 97 | config.get("reports.inexistent") 98 | 99 | 100 | def test_set_can_set_nested_items_with_dots(config: Config) -> None: 101 | """Setting values of configuration keys using dot notation works.""" 102 | config.set("storage.type", "tinydb") # act 103 | 104 | assert config.data["storage"]["type"] == "tinydb" 105 | -------------------------------------------------------------------------------- /tests/unit/test_task_argument_parser.py: -------------------------------------------------------------------------------- 1 | """Test the task argument parsing from the friendly string.""" 2 | 3 | from datetime import datetime 4 | 5 | import pytest 6 | from faker import Faker 7 | from freezegun.api import FrozenDateTimeFactory 8 | 9 | from pydo.entrypoints.utils import _parse_changes, _parse_task_selector 10 | 11 | 12 | def test_parse_extracts_description_without_quotes(faker: Faker) -> None: 13 | """Test description extraction.""" 14 | description = faker.sentence() 15 | task_arguments = description.split(" ") 16 | 17 | result = _parse_changes(task_arguments) 18 | 19 | assert result.task_attributes == {"description": description} 20 | 21 | 22 | def test_parse_allows_empty_description() -> None: 23 | """Test empty description extraction.""" 24 | result = _parse_changes([""]) 25 | 26 | assert result.task_attributes == {"description": ""} 27 | 28 | 29 | @pytest.mark.parametrize( 30 | ("string", "attribute"), 31 | [ 32 | ("ar", "area"), 33 | ("area", "area"), 34 | ("body", "body"), 35 | ("st", "state"), 36 | ("state", "state"), 37 | ], 38 | ) 39 | def test_parse_extracts_string_properties( 40 | faker: Faker, string: str, attribute: str 41 | ) -> None: 42 | """Test parsing of properties that are strings.""" 43 | description = faker.sentence() 44 | value = faker.word() 45 | task_arguments = [ 46 | description, 47 | f"{string}:{value}", 48 | ] 49 | 50 | result = _parse_changes(task_arguments) 51 | 52 | assert result.task_attributes == {"description": description, attribute: value} 53 | 54 | 55 | @pytest.mark.parametrize( 56 | ("string", "attribute"), 57 | [ 58 | ("pri", "priority"), 59 | ("priority", "priority"), 60 | ("est", "estimate"), 61 | ("estimate", "estimate"), 62 | ("wp", "willpower"), 63 | ("willpower", "willpower"), 64 | ("vl", "value"), 65 | ("value", "value"), 66 | ("fun", "fun"), 67 | ], 68 | ) 69 | def test_parse_extracts_integer_properties( 70 | faker: Faker, string: str, attribute: str 71 | ) -> None: 72 | """Test parsing of properties that are integers.""" 73 | description = faker.sentence() 74 | value = faker.random_number() 75 | task_arguments = [ 76 | description, 77 | f"{string}:{value}", 78 | ] 79 | 80 | result = _parse_changes(task_arguments) 81 | 82 | assert result.task_attributes == {"description": description, attribute: value} 83 | 84 | 85 | def test_parse_extracts_due(faker: Faker, freezer: FrozenDateTimeFactory) -> None: 86 | """Test parsing of due dates.""" 87 | description = faker.sentence() 88 | freezer.move_to("2017-05-20") 89 | due = "1d" 90 | task_arguments = [ 91 | description, 92 | f"due:{due}", 93 | ] 94 | 95 | result = _parse_changes(task_arguments) 96 | 97 | assert result.task_attributes == { 98 | "description": description, 99 | "due": datetime(2017, 5, 21), 100 | } 101 | 102 | 103 | def test_parse_return_none_if_argument_is_empty() -> None: 104 | """Test that the attributes can be set to None. 105 | 106 | One of each type (str, date, float, int) and the description 107 | empty tags are tested separately. 108 | """ 109 | task_arguments = [ 110 | "", 111 | "area:", 112 | "due:", 113 | "estimate:", 114 | "fun:", 115 | ] 116 | 117 | result = _parse_changes(task_arguments) 118 | 119 | assert result.task_attributes == { 120 | "description": "", 121 | "area": None, 122 | "due": None, 123 | "estimate": None, 124 | "fun": None, 125 | } 126 | 127 | 128 | @pytest.mark.parametrize( 129 | ("string", "attribute", "recurrence_type"), 130 | [ 131 | ("recurring", "recurring", "recurring"), 132 | ("rec", "recurring", "recurring"), 133 | ("repeating", "repeating", "repeating"), 134 | ("rep", "repeating", "repeating"), 135 | ], 136 | ) 137 | def test_parse_extracts_recurring_in_long_representation( 138 | faker: Faker, string: str, attribute: str, recurrence_type: str 139 | ) -> None: 140 | """Test parsing of recurrent tasks.""" 141 | description = faker.sentence() 142 | recurring = faker.word() 143 | task_arguments = [ 144 | description, 145 | f"{string}:{recurring}", 146 | ] 147 | 148 | result = _parse_changes(task_arguments) 149 | 150 | assert result.task_attributes == { 151 | "description": description, 152 | "recurrence_type": recurrence_type, 153 | "recurrence": recurring, 154 | } 155 | 156 | 157 | def test_parse_extracts_task_ids(faker: Faker) -> None: 158 | """Test the parsing of task ids.""" 159 | task_arguments = ["1", "235", "29044"] 160 | 161 | result = _parse_task_selector(task_arguments) 162 | 163 | assert result.task_ids == [1, 235, 29044] 164 | 165 | 166 | def test_parse_extracts_tags(faker: Faker) -> None: 167 | """Test the parsing of tags to add.""" 168 | description = faker.sentence() 169 | tags = [faker.word(), faker.word()] 170 | task_arguments = [ 171 | description, 172 | f"+{tags[0]}", 173 | f"+{tags[1]}", 174 | ] 175 | 176 | result = _parse_changes(task_arguments) 177 | 178 | assert result.task_attributes == {"description": description} 179 | assert result.tags_to_add == tags 180 | 181 | 182 | def test_parse_extracts_tags_to_remove(faker: Faker) -> None: 183 | """Test the parsing of tags to remove.""" 184 | description = faker.sentence() 185 | tags = [faker.word(), faker.word()] 186 | task_arguments = [ 187 | description, 188 | f"-{tags[0]}", 189 | f"-{tags[1]}", 190 | ] 191 | 192 | result = _parse_changes(task_arguments) 193 | 194 | assert result.task_attributes == {"description": description} 195 | assert result.tags_to_remove == tags 196 | -------------------------------------------------------------------------------- /tests/unit/test_version.py: -------------------------------------------------------------------------------- 1 | """Test the version message""" 2 | 3 | import platform 4 | import sys 5 | 6 | from pydo.version import __version__, version_info 7 | 8 | 9 | def test_version() -> None: 10 | """ 11 | Given: Nothing 12 | When: version_info is called 13 | Then: the expected output is given 14 | """ 15 | result = version_info() 16 | 17 | assert sys.version.replace("\n", " ") in result 18 | assert platform.platform() in result 19 | assert __version__ in result 20 | --------------------------------------------------------------------------------