├── .coveragerc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── AUTHORS.md ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── images ├── image_config.PNG ├── image_header.PNG ├── image_kanban.PNG ├── image_kanban_change.PNG ├── image_kanban_configure.PNG ├── image_kanban_init.PNG ├── image_kanban_report.PNG ├── image_kanban_report_document.PNG ├── image_scan_table.PNG ├── image_scan_view.PNG └── image_task_example.PNG ├── pyproject.toml ├── src └── kanban_python │ ├── __init__.py │ ├── app.py │ ├── cli_parser.py │ ├── config.py │ ├── constants.py │ ├── controls.py │ ├── interface.py │ └── utils.py ├── tests ├── conftest.py ├── test_config.py ├── test_interface.py └── test_utils.py └── uv.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | source = kanban_python 5 | # omit = bad_file.py 6 | 7 | [paths] 8 | source = 9 | src/ 10 | */site-packages/ 11 | 12 | [report] 13 | # Regexes for lines to exclude from consideration 14 | exclude_lines = 15 | # Have to re-enable the standard pragma 16 | pragma: no cover 17 | 18 | # Don't complain about missing debug-only code: 19 | def __repr__ 20 | if self\.debug 21 | 22 | # Don't complain if tests don't hit defensive assertion code: 23 | raise AssertionError 24 | raise NotImplementedError 25 | 26 | # Don't complain if non-runnable code isn't run: 27 | if 0: 28 | if __name__ == .__main__.: 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | # Avoid using all the resources/limits available by checking only 6 | # relevant branches and tags. Other branches can be checked via PRs. 7 | # branches: [main] 8 | tags: ['v[0-9]*', '[0-9]+.[0-9]+*'] # Match tags that resemble a version 9 | pull_request: # Run in every PR 10 | workflow_dispatch: # Allow manually triggering the workflow 11 | 12 | permissions: 13 | contents: read 14 | 15 | 16 | jobs: 17 | tests: 18 | strategy: 19 | matrix: 20 | python-version: 21 | - "3.9" 22 | - "3.13" 23 | platform: 24 | - ubuntu-latest 25 | - macos-latest 26 | - windows-latest 27 | runs-on: ${{matrix.platform}} 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Install uv 33 | uses: astral-sh/setup-uv@v3 34 | with: 35 | enable-cache: true 36 | 37 | - name: "Set up Python" 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{matrix.python-version}} 41 | - name: Install the project 42 | run: uv sync --all-extras --dev 43 | 44 | - name: Run tests 45 | # For example, using `pytest` 46 | run: uv run -p ${{matrix.python-version}} pytest tests 47 | 48 | # Generate Coverage 49 | - name: Generate coverage report 50 | run: uv run coverage lcov -o coverage.lcov 51 | - name: Upload partial coverage report 52 | uses: coverallsapp/github-action@v2 53 | with: 54 | path-to-lcov: coverage.lcov 55 | github-token: ${{ secrets.GITHUB_TOKEN }} 56 | flag-name: ${{ matrix.platform }} - py${{ matrix.python }} 57 | parallel: true 58 | 59 | finalize_coverage: 60 | needs: tests 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: Finalize coverage report 64 | uses: coverallsapp/github-action@v2 65 | with: 66 | github-token: ${{ secrets.GITHUB_TOKEN }} 67 | parallel-finished: true 68 | 69 | publish: 70 | needs: finalize_coverage 71 | if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }} 72 | runs-on: ubuntu-latest 73 | environment: 'deploy' 74 | permissions: 75 | # IMPORTANT: this permission is mandatory for trusted publishing 76 | # id-token: write 77 | contents: write 78 | steps: 79 | - uses: actions/checkout@v4 80 | 81 | - name: Install uv 82 | uses: astral-sh/setup-uv@v3 83 | with: 84 | enable-cache: true 85 | 86 | - name: "Set up Python" 87 | uses: actions/setup-python@v5 88 | with: 89 | python-version-file: ".python-version" 90 | 91 | - name: "Build Package" 92 | run: uv build 93 | 94 | - name: "Publish Package to PyPi" 95 | env: 96 | # UV_PUBLISH_USERNAME: __token__ 97 | UV_PUBLISH_TOKEN: ${{secrets.PYPI_TOKEN}} 98 | 99 | run: uv publish 100 | # - name: Publish package distributions to PyPI 101 | # uses: pypa/gh-action-pypi-publish@release/v1 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary and binary files 2 | *~ 3 | *.py[cod] 4 | *.so 5 | *.cfg 6 | !.isort.cfg 7 | !setup.cfg 8 | *.orig 9 | *.json 10 | *.log 11 | *.pot 12 | __pycache__/* 13 | .cache/* 14 | .*.swp 15 | */.ipynb_checkpoints/* 16 | .DS_Store 17 | 18 | # Project files 19 | .ropeproject 20 | .project 21 | .pydevproject 22 | .settings 23 | .idea 24 | .vscode 25 | tags 26 | 27 | # Package files 28 | *.egg 29 | *.eggs/ 30 | .installed.cfg 31 | *.egg-info 32 | 33 | # Unittest and coverage 34 | htmlcov/* 35 | .coverage 36 | .coverage.* 37 | .tox 38 | junit*.xml 39 | coverage.xml 40 | .pytest_cache/ 41 | 42 | # Build and docs folder/files 43 | build/* 44 | dist/* 45 | sdist/* 46 | docs/api/* 47 | docs/_rst/* 48 | docs/_build/* 49 | cover/* 50 | MANIFEST 51 | 52 | # Per-project virtualenvs 53 | .venv*/ 54 | .conda*/ 55 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | args: ["--maxkb=600"] 12 | 13 | 14 | - repo: https://github.com/astral-sh/ruff-pre-commit 15 | # Ruff version. 16 | rev: v0.11.0 17 | hooks: 18 | # Run the linter. 19 | - id: ruff 20 | # Run the formatter. 21 | - id: ruff-format 22 | 23 | #- repo: local 24 | # hooks: 25 | # - id: run tests 26 | # name: Run Tests 27 | # language: system 28 | # entry: pytest 29 | # pass_filenames: false 30 | # always_run: true 31 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | * Zaloog [gramslars@gmail.com](mailto:gramslars@gmail.com) 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 0.6 4 | - Added proper console clear, to prevent multiple board versions being in view 5 | 6 | ## Version 0.5.2 7 | - Fix `kanban report` path error if no `-P`-flag provided, with default path 8 | - Fix `kanban scan` path error if no `-P`-flag provided, with default path 9 | 10 | ## Version 0.5.1 11 | - Fix deploymet issue caused by missing version file 12 | 13 | ## Version 0.5.0 14 | - Move Project to uv structure and cleanup repository gh-17 15 | - improve argparser to support new funtionality and give better command overview 16 | - add `-l`/`--local` flag to `kanban init` command as a shortcut to create a board named `local`, which creates a local board. 17 | Local boards are stored in the current working directory and not under `user_data_dir`. 18 | - add `path` argument to `kanban scan` command to declare which path to scan for tasks 19 | - add `path` argument to `kanban report` command to declare path where the report should be created 20 | 21 | ## Version 0.4.1 22 | - Update minimum python version in ci test 23 | 24 | ## Version 0.4.0 25 | - Update Docs with pipx link 26 | 27 | ## Version 0.3.10 28 | - Fix datetime for test for dict creation 29 | - Amount of completed task to tasks of current year in report 30 | 31 | ## Version 0.3.9 32 | - Add `from __future__ import annotations` to controls.py to not error on Union TypeHint if python version is <=3.9 33 | 34 | ## Version 0.3.8 35 | - Add `Due_Date` to Task Structure 36 | - App now asks for `Due_Date` on task creation and task update 37 | - Show `days left` on board for task with `Due_Date` 38 | - Show `days left` of most overdue or urgent task for each board when changing boards 39 | - Add `freezegun` as test requirement 40 | 41 | ## Version 0.3.7 42 | - Bug Fix, `Complete_Time`-Key was wrong in Task Creation, when task was created with `kanban scan` 43 | - Add new function `kanban report` to show a github contribution like visual + creating a `.md` report 44 | - The report is created in a `kanban_report` folder next to the `kanban_boards` folder 45 | - Updated Readme/Docs accordingly 46 | 47 | ## Version 0.3.6 48 | - Add an `Overview` of Task amounts when changing boards, to get an overview over all Boards 49 | - Behind the boardname you now have something like `Ready: 05 | Doing: 02 | Done: 10` 50 | - only tasks amount of columns set as visible are displayed 51 | 52 | ## Version 0.3.5 53 | - Bug Fix: Datatype `Welcome Task` Duration: str -> int 54 | 55 | ## Version 0.3.4 56 | - Bug fix: default separator for `settings.scanner` Pattern setting was space separated not comma separated 57 | - Fix Image for kanban configure to show right Pattern 58 | 59 | ## Version 0.3.3 60 | - Push lower bound Version of `platformdirs` dependency to be 3 or higher to include `ensure_exists` argument 61 | in `user_data_dir` and `user_config_dir`. 62 | - Update User Action Options with new option `Show Task Details` 63 | - Change coloring and order of User Actions 64 | - Added another Menu to configure settings when using `[6] Show Current Settings` or `kanban configure` 65 | - Update DOCS/README and Images 66 | - Bugfix for data type of min col width setter 67 | 68 | ## Version 0.3.2 69 | - Add `^D` besides `^C` as option to close app (on windows pwsh its `^Z`). 70 | - App closes now on `KeyboardInterrupt` and `EOFError` 71 | 72 | ## Version 0.3.1 73 | - Bug fix: On first use the kanban_boards folder was not created. And therefore Board creation failed 74 | 75 | ## Version 0.3.0 76 | - Move to XDG Path convention, 77 | utilize `platformdirs` to write the config file to `user_config_dir` and the task files 78 | to `user_data_dir`. 79 | - added constants.py file for constants like the above mentioned Paths 80 | - added more Tests 81 | - added `platformdirs` <4 dependency 82 | - Updated the docs 83 | 84 | ## Version 0.2.2 85 | - BUGFIX settings.scanner keys not capitalized 86 | - New Image for Readme 87 | 88 | ## Version 0.2.1 89 | - New `kanban scan` option to scan for `# TODO` entries or other patterns. 90 | Check Docs for example and Usage. 91 | - Bug Fix: Prevent ValueError, if active board is not in board_list (couldve happened 92 | if active board was deleted.) Now gives you option to change to other board. 93 | - Add config options for `kanban scan` functionality 94 | - Updated Readme/Docs accordingly 95 | 96 | ## Version 0.2.0 97 | - Moved the board specific `pykanban.json` files into a dedicated `kanban_boards` directory 98 | in the `.kanban-python` directory under `/pykanban.json`. 99 | This allows centrally stored tasks and doesnt scatter multiple 100 | `pykanban.json` files over your projects. 101 | - Adjusted functions/tests accordingly to new structure 102 | - limiting Namespace of new boardnames to alpha-numeric + `-_ ` due to folder creation 103 | - added default option (active board selection) for board change 104 | - updated docs/readme 105 | 106 | ## Version 0.1.2 107 | - Instead of `pykanban.ini` configfile in Home Directory 108 | Creates a `.kanban-python` Folder for the respective configfile 109 | - Improved Dialog on first use when config is created 110 | - Documentation update 111 | 112 | ## Version 0.1.1 113 | - Documentation update 114 | 115 | ## Version 0.1.0 116 | - published on PyPi 117 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Zaloog 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 11 | 12 | [![Project generated with PyScaffold](https://img.shields.io/badge/-PyScaffold-005CA0?logo=pyscaffold)](https://pyscaffold.org/) 13 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 14 | [![PyPI-Server](https://img.shields.io/pypi/v/kanban-python.svg)](https://pypi.org/project/kanban-python/) 15 | [![Pyversions](https://img.shields.io/pypi/pyversions/kanban-python.svg)](https://pypi.python.org/pypi/kanban-python) 16 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 17 | [![Downloads](https://static.pepy.tech/badge/kanban-python)](https://pepy.tech/project/kanban-python) 18 | [![Coverage Status](https://coveralls.io/repos/github/Zaloog/kanban-python/badge.svg?branch=main)](https://coveralls.io/github/Zaloog/kanban-python?branch=main) 19 | # kanban-python 20 | 21 | > A Terminal Kanban Application written in Python to boost your productivity :rocket: 22 | 23 | ## Introduction 24 | Welcome to **kanban-python**, your Terminal Kanban-Board Manager. 25 | 26 | ![header](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_header.PNG) 27 | The [clikan] Kanban App inspired me to write 28 | my own Terminal Kanban Application since I preferred a more simple and guided workflow. 29 | 30 | **kanban-python** also comes with more features, like custom column creation, 31 | automatic scanning and customizable config file to support you being productive. 32 | 33 | This package was initially developed with [pyscaffold], which provides awesome project templates 34 | and takes over much of the boilerplate for python packaging. 35 | It was a great help for developing my first package and I can highly recommend it. 36 | With version `0.5.X` the repository structure was changed to use [uv]. 37 | 38 | ## Features 39 |
Colorful and Interactive 40 | 41 | - kanban-python uses [rich] under the hood to process user input 42 | and display nice looking kanban-boards to the terminal. 43 | - Each task has a unique `ID` per board and also has an optional `Tag` and `Due Date` associated with it, 44 | which are displayed alongside its `Title` 45 | 46 |
47 | 48 | 49 |
Following the XDG basedir convention 50 | 51 | - kanban-python utilizes [platformdirs] `user_config_dir` to save the config file and `user_data_dir` for 52 | the board specific task files. After creating your first board, you can use `kanban configure` to show the current settings table. 53 | The config path in the table caption and the path for the task files can be found in the kanban_boards section. 54 | 55 |
56 | 57 | 58 |
Scanning of Files for automatic Task Creation 59 | 60 | - kanban-python can scan files of defined types for specific patterns at start of line. 61 | Check [Automatic Task Creation](#automatic-task-creation) for more Infos. 62 | 63 |
64 | 65 | 66 |
Customizable Configfile 67 | 68 | - A `pykanban.ini` file gets created on first initialization in a `kanban-python` folder in your `user_config_dir`-Directory. 69 | This can be edited manually or within the kanban-python application. It tracks the location for all your created boards. \ 70 | ![configfile](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_config.PNG) 71 | * `Active_Board`: current board that is shown when using `kanban`-command 72 | * `Done_Limit`: If the amount of tasks exceed this number in the Done column, 73 | the first task of that column gets its status updated to Archived and is moved into that column. (default: `10`) 74 | * `Column_Min_Width`: Sets the minimum width of columns. (default: `40`) 75 | * `Show_Footer`: Shows the table footer with package name and version. (default: `True`) 76 | * `Files`: Space seperated filetypes to search for patterns to create tasks. (default: `.py .md`) 77 | * `Patterns`: Comma seperated patterns to search for start of line to create tasks.
(default: `# TODO,#TODO,# BUG`) 78 | 79 |
80 | 81 | 82 |
Task Storage File for each Board 83 | 84 | - Each created board comes with its own name and `pykanban.json` file, 85 | which stores all tasks for that board. The files are stored in board specific folders under `$USER_DATA_DIR/kanban-python/kanban_boards/`. 86 | When changing Boards you also get an overview over tasks in visible columns for each board and the most urgent or overdue task on that board. 87 | ![change_view](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_kanban_change.PNG) 88 | 89 |
90 | 91 | 92 |
Customizable Columns 93 | 94 | - kanban-python comes with 5 pre-defined colored columns: [Ready, Doing, Done, Archived, Deleted] 95 | More column can be added manually in the `pykanban.ini`, the visibility can be configured in the settings 96 | with `kanban configure`. 97 | 98 |
99 | 100 | 101 |
Time Tracking of Task duration in Doing 102 | 103 | - For each task it is tracked, how long it was in the 104 | Doing column, based on the moments when you update the task status. 105 | The initial Task structure on creation looks as follows: 106 | ![task](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_task_example.PNG) 107 | 108 |
109 | 110 | 111 |
Report Creation for completed Tasks 112 | 113 | - When you use [kanban report](#create-report) a github-like contribution map is displayed for the current year, 114 | Also a markdown file is created with all tasks comleted based on the moment, when the tasks were moved to Done Column. 115 | ![task](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_kanban_report_document.PNG) 116 | 117 |
118 | 119 | ## Installation 120 | You can install kanban-python with: 121 | ```bash 122 | python -m pip install kanban-python 123 | ``` 124 | 125 | or using [pipx] / [uv] / [rye]: 126 | ```bash 127 | pipx install kanban-python # using pipx 128 | uv tool install kanban-python # using uv 129 | rye install kanban-python # using rye 130 | ``` 131 | I recommend using pipx, rye or uv to install CLI Tools into an isolated environment. 132 | 133 | ## Usage 134 | After Installation of kanban-python, there are 5 commands available: 135 | 136 | ### Create new Boards 137 | ```bash 138 | kanban init 139 | ``` 140 | Is used to create a new kanban board i.e. it asks for a name and then creates a `pykanban.json` file with a Welcome Task. 141 | On first use of any command, the `pykanban.ini` configfile and the `kanban-python` folder will be created automatically. 142 | ![init_file](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_kanban_init.PNG) 143 | 144 | You can create local boards in the current working directory by using the name `local` or the flags `-l` or `--local` when 145 | using `kanban init`. kanban-python checks for local boards and updates the config file accordingly. 146 | Local boards can only be accessed when using `kanban` in the same folder. 147 | 148 | ### Interact with Tasks/Boards 149 | ```bash 150 | kanban 151 | ``` 152 | This is your main command to interact with your boards and tasks. It also gives the option to show the current settings and adjust them. 153 | Adjusting the settings can also be done directly by using the command `kanban configure`: 154 | ![kanban](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_kanban.PNG) 155 | 156 | Use `Ctrl-C` or `Ctrl-D` to exit the application at any time. :warning: If you exit in the middle of creating/updating a task, 157 | or changing settings, your progress wont be saved. 158 | 159 | ### Automatic Task Creation 160 | ```bash 161 | kanban scan 162 | ``` 163 | After executing this command, kanban-python scans your current directory recursively for the defined filetypes and searches for lines 164 | that start with the pattern provided. 165 | ![scan_view](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_scan_view.PNG) 166 | 167 | After confirmation to add the found tasks to table they will be added to the board. The alphanumeric Part of the Pattern will be used as tag. 168 | The filepath were the task was found will be added as description of the task. 169 | ![scan_table](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_scan_table.PNG) 170 | 171 | You can also define a different path to scan with the `-p` or `--path` argument. 172 | 173 | ### Create Report 174 | ```bash 175 | kanban report 176 | ``` 177 | Goes over all your Boards and creates a single markdown file by checking the `Completion Dates` of your tasks. 178 | Also shows a nice github-like contribution table for the current year. 179 | ![report](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_kanban_report.PNG) 180 | 181 | You can define a different output path of the `pykanban.md` report file with the `-p` or `--path` argument. 182 | 183 | ### Change Settings 184 | ```bash 185 | kanban configure 186 | ``` 187 | ![settings](https://raw.githubusercontent.com/Zaloog/kanban-python/main/images/image_kanban_configure.PNG) 188 | 189 | To create a new custom Column, you have to edit the `pykanban.ini` manually and add a new column name + visibility status 190 | under the `settings.columns.visible` section. The other options are all customizable now via the new settings menu. 191 | 192 | 193 | ## Feedback and Issues 194 | Feel free to reach out and share your feedback, or open an Issue, if something doesnt work as expected. 195 | Also check the [Changelog](https://github.com/Zaloog/kanban-python/blob/main/CHANGELOG.md) for new updates. 196 | 197 | :warning: 198 | With release v0.3.0 kanban-python switched to the [XDG] Basedir Spec. So some file migrations and config edits might be 199 | needed to continue working with your already created boards if you update from `v0.2.X` to `v0.3.X` 200 | 201 | 202 | 203 | [XDG]: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 204 | [platformdirs]: https://platformdirs.readthedocs.io/en/latest/ 205 | [clikan]: https://github.com/kitplummer/clikan 206 | [pyscaffold]: https://pyscaffold.org/ 207 | [rich]: https://github.com/Textualize/rich 208 | [pipx]: https://github.com/pypa/pipx 209 | [uv]: https://docs.astral.sh/uv/ 210 | [rye]: https://rye.astral.sh 211 | -------------------------------------------------------------------------------- /images/image_config.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_config.PNG -------------------------------------------------------------------------------- /images/image_header.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_header.PNG -------------------------------------------------------------------------------- /images/image_kanban.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_kanban.PNG -------------------------------------------------------------------------------- /images/image_kanban_change.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_kanban_change.PNG -------------------------------------------------------------------------------- /images/image_kanban_configure.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_kanban_configure.PNG -------------------------------------------------------------------------------- /images/image_kanban_init.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_kanban_init.PNG -------------------------------------------------------------------------------- /images/image_kanban_report.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_kanban_report.PNG -------------------------------------------------------------------------------- /images/image_kanban_report_document.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_kanban_report_document.PNG -------------------------------------------------------------------------------- /images/image_scan_table.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_scan_table.PNG -------------------------------------------------------------------------------- /images/image_scan_view.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_scan_view.PNG -------------------------------------------------------------------------------- /images/image_task_example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zaloog/kanban-python/8b20f599e64b3c56d2bd6d54b9c5e1917ab5a292/images/image_task_example.PNG -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "kanban-python" 3 | version = "0.6.0" 4 | description = "Terminal Kanban App written in Python" 5 | readme = "README.md" 6 | authors = [ 7 | { name = "Zaloog", email = "gramslars@gmail.com" } 8 | ] 9 | requires-python = ">=3.9" 10 | license = { file = "LICENSE.txt" } 11 | dependencies = [ 12 | "platformdirs>=4.3.6", 13 | "rich>=13.9.4", 14 | ] 15 | 16 | classifiers = [ 17 | "Development Status :: 3 - Alpha", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3 :: Only", 27 | ] 28 | 29 | [project.urls] 30 | Repository = "https://github.com/Zaloog/kanban-python" 31 | Changelog = "https://github.com/Zaloog/kanban-python/blob/main/CHANGELOG.md" 32 | 33 | [project.scripts] 34 | kanban = "kanban_python.app:run" 35 | 36 | [build-system] 37 | requires = ["hatchling"] 38 | build-backend = "hatchling.build" 39 | 40 | [tool.pytest.ini_options] 41 | addopts = "--cov src/kanban_python --cov-report term-missing --verbose --color=yes" 42 | testpaths = ["tests"] 43 | 44 | [tool.uv] 45 | dev-dependencies = [ 46 | "freezegun>=1.5.1", 47 | "pre-commit>=4.0.1", 48 | "pytest>=8.3.3", 49 | "pytest-cov>=6.0.0", 50 | ] 51 | 52 | [tool.hatch.metadata] 53 | allow-direct-references = true 54 | 55 | [tool.hatch.build.targets.wheel] 56 | packages = ["src/kanban_python"] 57 | -------------------------------------------------------------------------------- /src/kanban_python/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info[:2] >= (3, 8): 4 | # Import directly (no need for conditional) when `python_requires = >= 3.8` 5 | from importlib.metadata import PackageNotFoundError, version # pragma: no cover 6 | else: 7 | from importlib_metadata import PackageNotFoundError, version # pragma: no cover 8 | 9 | try: 10 | # Change here if project is renamed and does not equal the package name 11 | dist_name = "kanban-python" 12 | __version__ = version(dist_name) 13 | except PackageNotFoundError: # pragma: no cover 14 | __version__ = "unknown" 15 | finally: 16 | del version, PackageNotFoundError 17 | -------------------------------------------------------------------------------- /src/kanban_python/app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | from kanban_python import cli_parser, config, controls, utils, constants 5 | 6 | __author__ = "Zaloog" 7 | __copyright__ = "Zaloog" 8 | __license__ = "MIT" 9 | 10 | 11 | def main(args): 12 | args = cli_parser.parse_args(args) 13 | 14 | if not config.check_config_exists(): 15 | config.create_init_config() 16 | return 17 | 18 | # Delete Local Entry if no local file present 19 | if (not Path("pykanban.json").exists()) and ("local" in config.cfg.kanban_boards): 20 | config.delete_board_from_config(board_name="local") 21 | 22 | # New database creation 23 | if args.command == "init": 24 | utils.console.print("Starting new [blue]Kanban Board[/]:mechanical_arm:") 25 | controls.create_new_db(local=args.local) 26 | 27 | if args.command == "configure": 28 | controls.change_settings() 29 | utils.console.clear() 30 | 31 | if args.command == "scan": 32 | controls.add_todos_to_board(path=Path(args.path) or Path.cwd()) 33 | 34 | if args.command == "report": 35 | controls.create_report( 36 | output_path=Path(args.path) or constants.REPORT_FILE_PATH 37 | ) 38 | return 39 | 40 | while True: 41 | controls.show() 42 | user_input = controls.get_user_action() 43 | 44 | if user_input == 1: 45 | controls.add_new_task_to_db() 46 | utils.console.clear() 47 | elif user_input == 2: 48 | controls.update_task_from_db() 49 | utils.console.clear() 50 | elif user_input == 3: 51 | controls.change_kanban_board() 52 | utils.console.clear() 53 | elif user_input == 4: 54 | controls.show_tasks() 55 | elif user_input == 5: 56 | controls.delete_kanban_board() 57 | utils.console.clear() 58 | elif user_input == 6: 59 | controls.change_settings() 60 | utils.console.clear() 61 | 62 | 63 | def run(): 64 | """Calls :func:`main` passing the CLI arguments extracted from :obj:`sys.argv` 65 | 66 | This function can be used as entry point to create console scripts with setuptools. 67 | """ 68 | try: 69 | main(sys.argv[1:]) 70 | except (KeyboardInterrupt, EOFError): 71 | utils.console.print(utils.get_motivational_quote()) 72 | 73 | 74 | if __name__ == "__main__": 75 | run() 76 | -------------------------------------------------------------------------------- /src/kanban_python/cli_parser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from kanban_python import __version__ 4 | from kanban_python.constants import REPORT_FILE_PATH 5 | 6 | 7 | def parse_args(args): 8 | """Parse command line parameters 9 | 10 | Args: 11 | args (List[str]): command line parameters as list of strings 12 | (for example ``["--help"]``). 13 | 14 | Returns: 15 | :obj:`argparse.Namespace`: command line parameters namespace 16 | """ 17 | parser = argparse.ArgumentParser(description="Usage Options") 18 | parser.add_argument( 19 | "-v", 20 | "--version", 21 | action="version", 22 | version=f"kanban-python {__version__}", 23 | ) 24 | 25 | command_parser = parser.add_subparsers( 26 | title="commands", dest="command", description="available commands" 27 | ) 28 | 29 | # Init 30 | init_parser = command_parser.add_parser( 31 | "init", 32 | help="initialize a new board, use `--local`-flag to create in current working directory", 33 | ) 34 | init_parser.add_argument( 35 | "-l", 36 | "--local", 37 | help="create a local board in the current working directory, default:False", 38 | action="store_true", 39 | ) 40 | 41 | # Configure 42 | command_parser.add_parser("configure", help="configure settings") 43 | 44 | # Scan 45 | scan_parser = command_parser.add_parser( 46 | "scan", help="scan path for TODOs in files (default: `.`)" 47 | ) 48 | scan_parser.add_argument( 49 | "-p", "--path", required=False, help="path to scan (default: `.`)", default="." 50 | ) 51 | 52 | # Report 53 | report_parser = command_parser.add_parser( 54 | "report", help=f"create report in output path (default: {REPORT_FILE_PATH})" 55 | ) 56 | report_parser.add_argument( 57 | "-p", 58 | "--path", 59 | required=False, 60 | help=f"path to save output to (default: {REPORT_FILE_PATH})", 61 | default=REPORT_FILE_PATH, 62 | ) 63 | 64 | return parser.parse_args(args) 65 | -------------------------------------------------------------------------------- /src/kanban_python/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from pathlib import Path 3 | 4 | from .constants import ( 5 | CONFIG_FILE_NAME, 6 | CONFIG_FILE_PATH, 7 | CONFIG_PATH, 8 | DATA_PATH, 9 | KANBAN_BOARDS_FOLDER_NAME, 10 | KANBAN_BOARDS_PATH, 11 | TASK_FILE_NAME, 12 | ) 13 | from .utils import console 14 | 15 | 16 | class KanbanConfig: 17 | def __init__(self, path=CONFIG_FILE_PATH) -> None: 18 | self.configpath = path 19 | self._config = configparser.ConfigParser(default_section=None) 20 | self._config.optionxform = str 21 | self._config.read(path) 22 | 23 | def __repr__(self) -> str: 24 | output = "" 25 | for sec in self.config: 26 | if sec: 27 | output += 15 * "-" 28 | output += f"Section: {sec}" 29 | output += 15 * "-" + "\n" 30 | for key, val in self.config[sec].items(): 31 | output += f"{key}: {val}\n" 32 | return output 33 | 34 | def save(self): 35 | with open(self.configpath, "w") as configfile: 36 | self.config.write(configfile) 37 | 38 | @property 39 | def config(self) -> configparser.ConfigParser: 40 | return self._config 41 | 42 | @property 43 | def active_board(self) -> str: 44 | return self._config["settings.general"]["Active_Board"] 45 | 46 | @active_board.setter 47 | def active_board(self, new_board): 48 | self.config["settings.general"]["Active_Board"] = new_board 49 | self.save() 50 | 51 | @property 52 | def kanban_boards(self) -> list: 53 | return [board for board in self.config["kanban_boards"]] 54 | 55 | @property 56 | def kanban_boards_dict(self) -> dict: 57 | return self.config["kanban_boards"] 58 | 59 | @kanban_boards_dict.setter 60 | def kanban_boards_dict(self, board_name: str) -> dict: 61 | self.config["kanban_boards"][board_name] = get_json_path(board_name) 62 | self.save() 63 | 64 | @property 65 | def active_board_path(self) -> str: 66 | return self.config["kanban_boards"][self.active_board] 67 | 68 | @property 69 | def show_footer(self): 70 | return self.config["settings.general"]["Show_Footer"] 71 | 72 | @show_footer.setter 73 | def show_footer(self, visible): 74 | self.config["settings.general"]["Show_Footer"] = visible 75 | self.save() 76 | 77 | @property 78 | def col_min_width(self) -> int: 79 | return int(self.config["settings.general"]["Column_Min_Width"]) 80 | 81 | @col_min_width.setter 82 | def col_min_width(self, new_width: int) -> None: 83 | self.config["settings.general"]["Column_Min_Width"] = str(new_width) 84 | self.save() 85 | 86 | @property 87 | def kanban_columns_dict(self) -> dict: 88 | return self.config["settings.columns.visible"] 89 | 90 | @kanban_columns_dict.setter 91 | def kanban_columns_dict(self, updated_dict) -> dict: 92 | self.config["settings.columns.visible"] = updated_dict 93 | self.save() 94 | 95 | @property 96 | def vis_cols(self) -> list: 97 | return [c for c, v in self.kanban_columns_dict.items() if v == "True"] 98 | 99 | @property 100 | def done_limit(self) -> int: 101 | return int(self.config["settings.general"]["Done_Limit"]) 102 | 103 | @done_limit.setter 104 | def done_limit(self, new_limit: int) -> None: 105 | self.config["settings.general"]["Done_Limit"] = new_limit 106 | self.save() 107 | 108 | @property 109 | def scanned_files(self) -> list: 110 | return self.config["settings.scanner"]["Files"].split(" ") 111 | 112 | @scanned_files.setter 113 | def scanned_files(self, new_files_to_scan: str) -> None: 114 | self.config["settings.scanner"]["Files"] = new_files_to_scan 115 | self.save() 116 | 117 | @property 118 | def scanned_patterns(self) -> list: 119 | return self.config["settings.scanner"]["Patterns"].split(",") 120 | 121 | @scanned_patterns.setter 122 | def scanned_patterns(self, new_patterns_to_scan: str) -> None: 123 | self.config["settings.scanner"]["Patterns"] = new_patterns_to_scan 124 | self.save() 125 | 126 | 127 | cfg = KanbanConfig(path=CONFIG_FILE_PATH) 128 | 129 | 130 | def create_init_config(conf_path=CONFIG_PATH, data_path=DATA_PATH): 131 | config = configparser.ConfigParser(default_section=None) 132 | config.optionxform = str 133 | config["settings.general"] = { 134 | "Active_Board": "", 135 | "Column_Min_Width": 35, 136 | "Done_Limit": 10, 137 | "Show_Footer": "True", 138 | } 139 | config["settings.columns.visible"] = { 140 | "Ready": True, 141 | "Doing": True, 142 | "Done": True, 143 | "Deleted": False, 144 | "Archived": False, 145 | } 146 | config["settings.scanner"] = { 147 | "Files": ".py .md", 148 | "Patterns": "# TODO,#TODO,# BUG", 149 | } 150 | config["kanban_boards"] = {} 151 | 152 | if not (data_path / KANBAN_BOARDS_FOLDER_NAME).exists(): 153 | data_path.mkdir(exist_ok=True) 154 | (data_path / KANBAN_BOARDS_FOLDER_NAME).mkdir(exist_ok=True) 155 | 156 | with open(conf_path / CONFIG_FILE_NAME, "w") as configfile: 157 | config.write(configfile) 158 | console.print( 159 | f"Welcome, I Created a new [orange3]{CONFIG_FILE_NAME}[/] file " 160 | + f"located in [orange3]{conf_path}[/]" 161 | ) 162 | console.print("Now use 'kanban init' to create kanban boards") 163 | 164 | 165 | def delete_current_folder_board_from_config( 166 | cfg=cfg, curr_path: str = str(Path.cwd()) 167 | ) -> None: 168 | for b_name, b_path in cfg.kanban_boards_dict.items(): 169 | if b_path == curr_path: 170 | cfg.config["kanban_boards"].pop(b_name) 171 | cfg.save() 172 | 173 | 174 | def check_if_board_name_exists_in_config(boardname: str, cfg=cfg) -> bool: 175 | return boardname in cfg.kanban_boards 176 | 177 | 178 | def check_if_current_active_board_in_board_list(cfg=cfg) -> bool: 179 | return check_if_board_name_exists_in_config(cfg=cfg, boardname=cfg.active_board) 180 | 181 | 182 | def delete_board_from_config(board_name, cfg=cfg) -> None: 183 | cfg.config["kanban_boards"].pop(board_name) 184 | cfg.save() 185 | 186 | 187 | def check_config_exists(path=CONFIG_FILE_PATH) -> bool: 188 | return path.exists() 189 | 190 | 191 | def get_json_path(boardname: str): 192 | if boardname == "local": 193 | return (Path().cwd() / TASK_FILE_NAME).as_posix() 194 | return (KANBAN_BOARDS_PATH / boardname / TASK_FILE_NAME).as_posix() 195 | -------------------------------------------------------------------------------- /src/kanban_python/constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from platformdirs import user_config_dir, user_data_dir 4 | 5 | from kanban_python import __version__ 6 | 7 | # For Config Stuff 8 | TASK_FILE_NAME = "pykanban.json" 9 | CONFIG_FILE_NAME = "pykanban.ini" 10 | REPORT_FILE_NAME = "pykanban.md" 11 | KANBAN_BOARDS_FOLDER_NAME = "kanban_boards" 12 | REPORTS_FOLDER_NAME = "kanban_report" 13 | 14 | CONFIG_PATH = Path( 15 | user_config_dir(appname="kanban-python", appauthor=False, ensure_exists=True) 16 | ) 17 | DATA_PATH = Path( 18 | user_data_dir(appname="kanban-python", appauthor=False, ensure_exists=True) 19 | ) 20 | KANBAN_BOARDS_PATH = DATA_PATH / KANBAN_BOARDS_FOLDER_NAME 21 | CONFIG_FILE_PATH = CONFIG_PATH / CONFIG_FILE_NAME 22 | REPORT_FILE_PATH = DATA_PATH / REPORTS_FOLDER_NAME 23 | 24 | 25 | QUOTES = [ 26 | "\n:wave:Stay Hard:wave:", 27 | "\n:wave:See you later:wave:", 28 | "\n:wave:Lets get started:wave:", 29 | "\n:wave:Lets work on those tasks:wave:", 30 | ] 31 | 32 | BOARD_CAPTION_STRING = "Tasks have the following Structure:\ 33 | [[cyan]ID[/]] ([orange3]TAG[/]) [white]Task Title[/] |[red]Days Left[/]|" 34 | 35 | COLOR_DICT = { 36 | "Ready": "[red]Ready[/]", 37 | "Doing": "[yellow]Doing[/]", 38 | "Done": "[green]Done[/]", 39 | "Deleted": "[deep_pink4]Deleted[/]", 40 | "Archived": "[dark_goldenrod]Archived[/]", 41 | } 42 | 43 | DUMMY_TASK = { 44 | "Title": "Welcome Task", 45 | "Description": "Welcome to kanban-python, I hope this helps your productivity", 46 | "Tag": "HI", 47 | "Status": "Ready", 48 | "Begin_Time": "", 49 | "Complete_Time": "", 50 | "Duration": 0, 51 | "Creation_Date": "", 52 | "Due_Date": "", 53 | } 54 | DUMMY_DB = {1: DUMMY_TASK} 55 | 56 | FOOTER_LINK = "[link=https://github.com/Zaloog/kanban-python][blue]kanban-python[/]" 57 | FOOTER_AUTHOR = "[/link][grey35] (by Zaloog)[/]" 58 | FOOTER_FIRST = FOOTER_LINK + FOOTER_AUTHOR 59 | 60 | FOOTER_LAST = f"version [blue]{__version__}[/]" 61 | FOOTER = [FOOTER_FIRST, FOOTER_LAST] 62 | 63 | # found here: https://github.com/orgs/community/discussions/7078 64 | REPORT_COLORS = ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"] 65 | -------------------------------------------------------------------------------- /src/kanban_python/controls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from json import dump, load 4 | from pathlib import Path 5 | 6 | from rich.pretty import pprint 7 | from kanban_python.config import ( 8 | cfg, 9 | check_if_board_name_exists_in_config, 10 | check_if_current_active_board_in_board_list, 11 | delete_board_from_config, 12 | get_json_path, 13 | ) 14 | from kanban_python.constants import ( 15 | DUMMY_DB, 16 | KANBAN_BOARDS_PATH, 17 | REPORT_FILE_NAME, 18 | REPORT_FILE_PATH, 19 | TASK_FILE_NAME, 20 | ) 21 | from kanban_python.interface import ( 22 | create_config_table, 23 | create_github_like_report_table, 24 | create_table, 25 | input_ask_for_action, 26 | input_ask_for_action_settings, 27 | input_ask_for_change_board, 28 | input_ask_for_delete_board, 29 | input_ask_for_new_board_name, 30 | input_ask_which_task_to_update, 31 | input_ask_which_tasks_to_show, 32 | input_change_column_settings, 33 | input_change_done_limit_settings, 34 | input_change_files_to_scan_settings, 35 | input_change_footer_settings, 36 | input_change_min_col_width_settings, 37 | input_change_patterns_to_scan_settings, 38 | input_confirm_add_todos_to_board, 39 | input_confirm_delete_board, 40 | input_confirm_set_board_active, 41 | input_create_new_task, 42 | input_update_task, 43 | ) 44 | from kanban_python.utils import ( 45 | check_board_name_valid, 46 | check_if_done_col_leq_X, 47 | check_if_there_are_visible_tasks_in_board, 48 | check_scanner_files_valid, 49 | check_scanner_patterns_valid, 50 | console, 51 | create_report_document, 52 | current_time_to_str, 53 | delete_json_file, 54 | get_tag_id_choices, 55 | move_first_done_task_to_archive, 56 | scan_files, 57 | scan_for_todos, 58 | split_todo_in_tag_and_title, 59 | ) 60 | 61 | 62 | # DB Controls 63 | ##################################################################################### 64 | def create_new_db(local: bool = False) -> None: 65 | while True: 66 | while True: 67 | new_board_name = "local" if local else input_ask_for_new_board_name() 68 | if check_board_name_valid(new_board_name): 69 | break 70 | console.print(f":warning: '{new_board_name}' is [red]not[/] a valid Name.") 71 | 72 | if new_board_name == "local": 73 | break 74 | if not check_if_board_name_exists_in_config(new_board_name): 75 | break 76 | console.print( 77 | f":warning: Board '{new_board_name}' already exists, choose another Name." 78 | ) 79 | 80 | # Options: 81 | # 1. ~/.kanban-python/.json 82 | # 2. ~/.kanban-python/kanban_boards/.json 83 | # 3. ~/.kanban-python/kanban_boards//pykanban.json <- THIS 84 | # 4. ~/.kanban-python/kanban_boards//.json 85 | if local or (new_board_name == "local"): 86 | new_db_path = Path().cwd() 87 | cfg.active_board = new_board_name 88 | else: 89 | new_db_path = KANBAN_BOARDS_PATH / new_board_name 90 | if input_confirm_set_board_active(name=new_board_name): 91 | cfg.active_board = new_board_name 92 | 93 | cfg.kanban_boards_dict = new_board_name 94 | if not new_db_path.exists(): 95 | new_db_path.mkdir() 96 | 97 | with open(get_json_path(new_board_name), "w", encoding="utf-8") as f: 98 | dump(DUMMY_DB, f, ensure_ascii=False, indent=4) 99 | 100 | console.print( 101 | f"Created new [orange3]{TASK_FILE_NAME}[/] file at " 102 | + f"[orange3]{new_db_path}[/] to save tasks." 103 | ) 104 | 105 | 106 | def save_db(data): 107 | path = cfg.active_board_path 108 | with open(path, "w", encoding="utf-8") as f: 109 | dump(data, f, ensure_ascii=False, indent=4) 110 | 111 | 112 | def add_tasks_to_db(tasks: dict | list[dict]) -> None: 113 | db_data = read_db() 114 | if isinstance(tasks, dict): 115 | new_id = str(max(int(i) for i in db_data.keys()) + 1) 116 | db_data[new_id] = tasks 117 | else: 118 | for task in tasks: 119 | new_id = str(max(int(i) for i in db_data.keys()) + 1) 120 | db_data[new_id] = task 121 | 122 | save_db(data=db_data) 123 | 124 | 125 | def read_db(path: str = None) -> dict: 126 | if not path: 127 | path = cfg.active_board_path 128 | 129 | if path == "all": 130 | board_dict = { 131 | b: read_single_board(b_path) 132 | for b, b_path in cfg.kanban_boards_dict.items() 133 | if Path(b_path).exists() 134 | } 135 | 136 | return board_dict 137 | 138 | try: 139 | data = read_single_board(path) 140 | return data 141 | except FileNotFoundError: 142 | console.print(f":warning: No [orange3]{TASK_FILE_NAME}[/] file here anymore.") 143 | console.print("Please change to another board.") 144 | change_kanban_board() 145 | 146 | return read_db() 147 | 148 | 149 | def read_single_board(path): 150 | with open(path, "r") as file: 151 | data = load(file) 152 | return data 153 | 154 | 155 | # User Action Controls 156 | ##################################################################################### 157 | # Get User Action 158 | def get_user_action(): 159 | return input_ask_for_action() 160 | 161 | 162 | # Action 1 163 | def add_new_task_to_db(): 164 | new_task = input_create_new_task() 165 | add_tasks_to_db(tasks=new_task) 166 | 167 | 168 | # Action 2 169 | def update_task_from_db(): 170 | db_data = read_db() 171 | if not check_if_there_are_visible_tasks_in_board(db_data, cfg.vis_cols): 172 | console.print(":cross_mark:[red]No Tasks available on this Kanban board[/]") 173 | return 174 | selected_id = input_ask_which_task_to_update(db_data) 175 | updated_task = input_update_task(current_task=db_data[selected_id]) 176 | db_data[selected_id] = updated_task 177 | 178 | while not check_if_done_col_leq_X(cfg=cfg, data=db_data): 179 | first_task_id, archive_task = move_first_done_task_to_archive(data=db_data) 180 | db_data[first_task_id] = archive_task 181 | save_db(data=db_data) 182 | 183 | 184 | # Action 3 185 | def change_kanban_board(): 186 | boards_dict = read_db(path="all") 187 | new_active_board = input_ask_for_change_board(boards_dict) 188 | cfg.active_board = new_active_board 189 | 190 | 191 | # Action 4 192 | def show_tasks(): 193 | db_data = read_db() 194 | choices = get_tag_id_choices(db_data, cfg.vis_cols) 195 | selection_criteria = input_ask_which_tasks_to_show(choices) 196 | console.clear() 197 | for i, task in db_data.items(): 198 | if selection_criteria in [i, task["Tag"]]: 199 | console.print( 200 | 20 * "[bold blue]#[/]" + f" Task {i} " + 20 * "[bold blue]#[/]" 201 | ) 202 | pprint( 203 | { 204 | key: val 205 | for key, val in task.items() 206 | if key in ["Title", "Description", "Tag", "Status", "Due_Date"] 207 | }, 208 | console=console, 209 | expand_all=True, 210 | ) 211 | 212 | 213 | # Action 5 214 | def delete_kanban_board(): 215 | board_to_delete = input_ask_for_delete_board() 216 | if input_confirm_delete_board(board_to_delete): 217 | board_to_delete_path = cfg.kanban_boards_dict[board_to_delete] 218 | 219 | delete_json_file(board_to_delete_path) 220 | delete_board_from_config(board_to_delete) 221 | 222 | 223 | def show(): 224 | if (Path().cwd() / "pykanban.json").exists(): 225 | cfg.kanban_boards_dict = "local" 226 | 227 | if not cfg.kanban_boards: 228 | console.print(":warning: [red]No Boards created yet[/]:warning:") 229 | console.print("Use 'kanban init' to create a new kanban board.") 230 | raise KeyboardInterrupt 231 | 232 | if not check_if_current_active_board_in_board_list(): 233 | console.print( 234 | "[yellow]Hmm, Something went wrong.[/] " 235 | + f"The active board '{cfg.active_board}' is not in the list of boards." 236 | ) 237 | change_kanban_board() 238 | show() 239 | return 240 | db_data = read_db() 241 | table = create_table(data=db_data) 242 | console.print(table) 243 | 244 | 245 | # Scan Functionality 246 | ##################################################################################### 247 | def add_todos_to_board(path: Path) -> None: 248 | files = scan_files(path=path, endings=cfg.scanned_files) 249 | todos = scan_for_todos( 250 | file_paths=files, rel_path=path, patterns=cfg.scanned_patterns 251 | ) 252 | if not todos: 253 | console.print( 254 | ":cross_mark: [red]Nothing found that " 255 | + "matches any of your provided patterns.[/]" 256 | ) 257 | return 258 | # TODO Write Docs for kanban scan functionality 259 | # BUG This pattern also works 260 | if input_confirm_add_todos_to_board(todos=todos): 261 | todo_task_list = [] 262 | for task, file in todos: 263 | tag, title = split_todo_in_tag_and_title(task, cfg.scanned_patterns) 264 | new_task = { 265 | "Title": title, 266 | "Description": f"from {file}", 267 | "Status": "Ready", 268 | "Tag": tag, 269 | "Creation_Date": current_time_to_str(), 270 | "Begin_Time": "", 271 | "Complete_Time": "", 272 | "Duration": 0, 273 | } 274 | 275 | todo_task_list.append(new_task) 276 | add_tasks_to_db(tasks=todo_task_list) 277 | 278 | 279 | # Config Settings 280 | ##################################################################################### 281 | def change_settings(): 282 | while True: 283 | console.clear() 284 | show_settings() 285 | settings_selection = input_ask_for_action_settings() 286 | 287 | if settings_selection == 1: 288 | change_kanban_board() 289 | 290 | new_min_col_widths = input_change_min_col_width_settings() 291 | cfg.col_min_width = new_min_col_widths 292 | 293 | done_limit = input_change_done_limit_settings() 294 | cfg.done_limit = done_limit 295 | 296 | footer_visible = input_change_footer_settings() 297 | cfg.show_footer = "True" if footer_visible else "False" 298 | 299 | if settings_selection == 2: 300 | updated_col_config = input_change_column_settings() 301 | cfg.kanban_columns_dict = updated_col_config 302 | 303 | if settings_selection == 3: 304 | while True: 305 | new_files_to_scan = input_change_files_to_scan_settings() 306 | if check_scanner_files_valid(new_files_to_scan): 307 | cfg.scanned_files = new_files_to_scan 308 | break 309 | console.print( 310 | f":warning: '{new_files_to_scan}' is [red]not[/] a valid." 311 | ) 312 | 313 | while True: 314 | new_patterns_to_scan = input_change_patterns_to_scan_settings() 315 | if check_scanner_patterns_valid(new_patterns_to_scan): 316 | cfg.scanned_patterns = new_patterns_to_scan 317 | break 318 | console.print( 319 | f":warning: '{new_patterns_to_scan}' is [red]not[/] a valid." 320 | ) 321 | 322 | if settings_selection == 4: 323 | break 324 | 325 | 326 | def show_settings(): 327 | settings_table = create_config_table() 328 | console.print(settings_table) 329 | 330 | 331 | # Report Creation 332 | ##################################################################################### 333 | def create_report(output_path: Path = REPORT_FILE_PATH): 334 | boards_dict = read_db("all") 335 | gh_table = create_github_like_report_table(boards_dict) 336 | console.print(gh_table) 337 | if not output_path.exists(): 338 | output_path.mkdir(exist_ok=True) 339 | create_report_document(path=output_path, boards_dict=boards_dict) 340 | console.print( 341 | "\n[bright_black]You can find your markdown report under:" 342 | + f"\n[bold green]{output_path / REPORT_FILE_NAME}" 343 | ) 344 | -------------------------------------------------------------------------------- /src/kanban_python/interface.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from datetime import datetime 3 | from itertools import zip_longest 4 | 5 | from rich.prompt import Confirm, IntPrompt, Prompt 6 | from rich.table import Table 7 | 8 | from .config import cfg 9 | from .constants import ( 10 | BOARD_CAPTION_STRING, 11 | COLOR_DICT, 12 | CONFIG_FILE_PATH, 13 | FOOTER, 14 | REPORT_COLORS, 15 | ) 16 | from .utils import ( 17 | calculate_days_left_till_due, 18 | calculate_time_delta_str, 19 | check_due_date_format, 20 | console, 21 | create_color_mapping, 22 | create_dict_for_report_view, 23 | create_status_dict_for_rows, 24 | current_time_to_str, 25 | due_date_date_to_datetime, 26 | due_date_datetime_to_date, 27 | ) 28 | 29 | 30 | # Board 31 | ##################################################################################### 32 | def create_table(data: dict) -> Table: 33 | status_dict = create_status_dict_for_rows(data=data, vis_cols=cfg.vis_cols) 34 | 35 | table_name = cfg.active_board 36 | table = Table( 37 | title=f"[blue]Active Board: {table_name}[/]", 38 | highlight=True, 39 | show_header=True, 40 | show_footer=True if cfg.show_footer == "True" else False, 41 | caption=BOARD_CAPTION_STRING, 42 | ) 43 | 44 | for i, category in enumerate([COLOR_DICT.get(col, col) for col in cfg.vis_cols]): 45 | table.add_column( 46 | header=category + f"\t({len(status_dict[cfg.vis_cols[i]])} Task/s)", 47 | header_style="bold", 48 | justify="left", 49 | overflow="fold", 50 | footer=FOOTER[0] 51 | if i == 0 52 | else FOOTER[1] 53 | if i == len(cfg.vis_cols) - 1 54 | else "", 55 | min_width=cfg.col_min_width, 56 | ) 57 | 58 | for row_tasks in zip_longest(*status_dict.values()): 59 | table.add_row(*row_tasks) 60 | 61 | return table 62 | 63 | 64 | # Board Action selection 65 | def input_ask_for_action(): 66 | console.print( 67 | "[yellow]Whats up!?[/], how can I help you being productive today :rocket:?" 68 | ) 69 | console.print( 70 | "\t[1] :clipboard: [green]Create new Task[/]" 71 | + 2 * "\t" 72 | + "[2] :clockwise_vertical_arrows: [bold cornflower_blue]Update/Check Task[/]" 73 | ) 74 | console.print( 75 | "\t[3] :bookmark_tabs: [bold yellow]Change Kanban Board[/]" 76 | + "\t" 77 | + "[4] :magnifying_glass_tilted_left: [bold blue]Show Task Details[/]" 78 | ) 79 | console.print( 80 | "\t[5] :cross_mark: [red]Delete Kanban Board[/]" 81 | + "\t" 82 | + "[6] :hammer_and_wrench: [grey69]Show Current Settings[/]" 83 | ) 84 | action = IntPrompt.ask( 85 | prompt="Choose wisely :books:", 86 | choices=[ 87 | "1", 88 | "2", 89 | "3", 90 | "4", 91 | "5", 92 | "6", 93 | ], 94 | show_choices=False, 95 | ) 96 | return action 97 | 98 | 99 | # Action 1: New Task 100 | def input_create_new_task() -> dict: 101 | title = Prompt.ask( 102 | prompt="[1/5] Add Task Title", 103 | ) 104 | 105 | description = Prompt.ask( 106 | prompt="[2/5] Add Task Description", 107 | show_default=True, 108 | default="", 109 | ) 110 | 111 | tag = Prompt.ask( 112 | prompt="[3/5] Add a Tag", 113 | show_default=True, 114 | default="ETC", 115 | ) 116 | 117 | while True: 118 | due_date = Prompt.ask( 119 | prompt="[4/5] Add a Due Date (YYYY-MM-DD)", 120 | show_default=True, 121 | default="", 122 | ) 123 | if not due_date or check_due_date_format(date_str=due_date): 124 | break 125 | else: 126 | console.print( 127 | f":warning: '{due_date}' has [red]not[/] " 128 | + "the right format YYYY-MM-DD" 129 | ) 130 | 131 | console.print(f"\t[1] {COLOR_DICT['Ready']}") 132 | console.print(f"\t[2] {COLOR_DICT['Doing']}") 133 | 134 | status = IntPrompt.ask( 135 | prompt="[5/5] Status of Task", 136 | show_choices=False, 137 | choices=["1", "2"], 138 | show_default=True, 139 | default="1", 140 | ) 141 | 142 | new_task = { 143 | "Title": title, 144 | "Description": description, 145 | "Status": "Ready" if str(status) == "1" else "Doing", 146 | "Tag": tag.upper(), 147 | "Creation_Date": current_time_to_str(), 148 | "Due_Date": due_date_date_to_datetime(due_date), 149 | "Begin_Time": current_time_to_str() if str(status) == "2" else "", 150 | "Complete_Time": "", 151 | "Duration": 0, 152 | } 153 | return new_task 154 | 155 | 156 | # Action 2: Update Task 157 | def input_ask_which_task_to_update(data: dict) -> str: 158 | choice_task_ids = [ 159 | id for id, task in data.items() if task["Status"] in cfg.vis_cols 160 | ] 161 | task_id_to_update = IntPrompt.ask( 162 | prompt="Which Task to update? Select an [[cyan]Id[/]]", 163 | choices=choice_task_ids, 164 | show_choices=False, 165 | ) 166 | return str(task_id_to_update) 167 | 168 | 169 | def input_update_task_title(current_title) -> str: 170 | return Prompt.ask( 171 | prompt="[1/5] Update Task Title", 172 | show_default=True, 173 | default=current_title, 174 | ) 175 | 176 | 177 | def input_update_task_description(current_desc) -> str: 178 | return Prompt.ask( 179 | prompt="[2/5] Update Task Description", 180 | show_default=True, 181 | default=current_desc, 182 | ) 183 | 184 | 185 | def input_update_task_tag(current_tag) -> str: 186 | return Prompt.ask( 187 | prompt="[3/5] Update Tag", 188 | show_default=True, 189 | default=current_tag, 190 | ) 191 | 192 | 193 | def input_update_due_date(current_due) -> str: 194 | while True: 195 | due_date_str = Prompt.ask( 196 | prompt="[4/5] Update Due Date (YYYY-MM-DD or ` `)", 197 | show_default=True, 198 | # fix default view 199 | default=due_date_datetime_to_date(date_datetime=current_due), 200 | ) 201 | 202 | if not due_date_str or check_due_date_format(date_str=due_date_str): 203 | break 204 | else: 205 | console.print( 206 | f":warning: '{due_date_str}' has [red]not[/] " 207 | + "the right format YYYY-MM-DD" 208 | ) 209 | 210 | return due_date_date_to_datetime(due_date_str) 211 | 212 | 213 | def input_ask_to_what_status_to_move(task_title): 214 | possible_status = [cat for cat in cfg.kanban_columns_dict] 215 | 216 | console.print(f'Updating Status of Task "[white]{task_title}[/]"') 217 | for idx, status in enumerate(possible_status, start=1): 218 | console.print(f"\t[{idx}] {COLOR_DICT.get(status, status)}") 219 | 220 | new_status = IntPrompt.ask( 221 | prompt="[5/5] New Status of Task?", 222 | show_choices=False, 223 | choices=[f"{i}" for i, _ in enumerate(possible_status, start=1)], 224 | ) 225 | return possible_status[int(new_status) - 1] 226 | 227 | 228 | def input_update_task(current_task: dict) -> dict: 229 | title = input_update_task_title(current_task["Title"]) 230 | description = input_update_task_description(current_task["Description"]) 231 | tag = input_update_task_tag(current_task["Tag"]) 232 | due_date = input_update_due_date(current_task.get("Due_Date", "")) 233 | status = input_ask_to_what_status_to_move(current_task["Title"]) 234 | 235 | if (status == "Doing") and (current_task["Status"] != "Doing"): 236 | start_doing = current_time_to_str() 237 | stop_doing = current_task.get("Complete_Time", "") 238 | duration = current_task.get("Duration", 0) 239 | elif (status != "Doing") and (current_task["Status"] == "Doing"): 240 | start_doing = current_task.get("Begin_Time", "") 241 | stop_doing = current_time_to_str() 242 | duration = calculate_time_delta_str( 243 | start_time_str=current_task.get("Begin_Time", ""), end_time_str=stop_doing 244 | ) + current_task.get("Duration", 0) 245 | else: 246 | start_doing = current_task.get("Begin_Time", "") 247 | stop_doing = current_task.get("Complete_Time", "") 248 | duration = current_task.get("Duration", 0) 249 | 250 | if status == "Done": 251 | stop_doing = current_time_to_str() 252 | console.print( 253 | f":sparkle: Congrats, you just completed '{title}'" 254 | + f" after {duration} minutes :muscle:" 255 | ) 256 | 257 | updated_task = { 258 | "Title": title, 259 | "Description": description, 260 | "Status": status, 261 | "Tag": tag.upper(), 262 | "Due_Date": due_date, 263 | "Begin_Time": start_doing, 264 | "Complete_Time": stop_doing, 265 | "Duration": duration, 266 | } 267 | current_task.update(updated_task) 268 | return current_task 269 | 270 | 271 | def input_confirm_set_board_active(name) -> bool: 272 | return Confirm.ask( 273 | f"Do you want to set the Board '{name}' as active:question_mark:" 274 | ) 275 | 276 | 277 | def input_ask_for_new_board_name() -> str: 278 | return Prompt.ask( 279 | prompt="A new folder will be created for your board\n" 280 | + ":warning: [yellow]Only[/] use alpha-numeric characters or" 281 | + " [green]'-', '_', ' '[/] for new board names.\n" 282 | + "To create a local board at the current location, choose the name [green]'local'[/].\n" 283 | + "What should the new board be called?" 284 | ) 285 | 286 | 287 | # Action 3: Change Boards 288 | def input_ask_for_change_board(boards_dict: dict) -> str: 289 | boards = cfg.kanban_boards 290 | max_board_len = max([len(b) for b in cfg.kanban_boards]) 291 | 292 | # if active Board is not in Board List dont show default 293 | try: 294 | active_board_idx = boards.index(cfg.active_board) + 1 295 | except ValueError: 296 | active_board_idx = None 297 | 298 | for idx, (board, board_data) in enumerate(boards_dict.items(), start=1): 299 | status_dict = create_status_dict_for_rows(board_data, cfg.vis_cols) 300 | days_left_list = [ 301 | calculate_days_left_till_due(val["Due_Date"]) 302 | for val in board_data.values() 303 | if (val.get("Due_Date") and (val["Status"] in ["Ready", "Doing"])) 304 | ] 305 | # Use -9999 to as placeholder for no tasks to make comparison later 306 | days_left = min(days_left_list) if days_left_list else -9999 307 | console.print( 308 | f"[{idx}] {board}" 309 | + " " * (max_board_len - len(board) + 1) 310 | + " | ".join( 311 | [ 312 | f"{COLOR_DICT[col]}: {len(status_dict[col]):02d}" 313 | for col in cfg.vis_cols 314 | ] 315 | ) 316 | + ( 317 | f"\t next due in {days_left} day/s" 318 | if days_left > 0 319 | else f"[red]\t task {-days_left} day/s overdue[/]" 320 | if days_left != -9999 321 | else "\t no dues present here" 322 | ) 323 | ) 324 | 325 | answer = IntPrompt.ask( 326 | prompt="Which board to activate", 327 | choices=[f"{i}" for i, _ in enumerate(boards, start=1)], 328 | show_choices=False, 329 | default=active_board_idx, 330 | show_default=True, 331 | ) 332 | return boards[int(answer) - 1] 333 | 334 | 335 | # Action 4: Show Tasks 336 | def input_ask_which_tasks_to_show(choices): 337 | return Prompt.ask( 338 | prompt="What Task/s to show? Select an [[cyan]Id[/]] or ([orange3]Tag[/])?", 339 | default=False, 340 | show_default=False, 341 | choices=choices, 342 | show_choices=False, 343 | ) 344 | 345 | 346 | # Action 5 Delete Boards 347 | def input_ask_for_delete_board() -> str: 348 | boards = [b for b in cfg.kanban_boards] 349 | for idx, board in enumerate(boards, start=1): 350 | console.print(f"[{idx}] {board}") 351 | 352 | answer = IntPrompt.ask( 353 | prompt="Which board to delete", 354 | choices=[f"{i}" for i, _ in enumerate(boards, start=1)], 355 | show_choices=False, 356 | ) 357 | return boards[int(answer) - 1] 358 | 359 | 360 | def input_confirm_delete_board(name) -> bool: 361 | return Confirm.ask( 362 | f"Are you sure you want to delete the Board '{name}':question_mark:" 363 | ) 364 | 365 | 366 | # Scanner Options 367 | def input_confirm_show_all_todos() -> bool: 368 | return Confirm.ask( 369 | prompt="Do you want to list all of them?", 370 | default=True, 371 | show_default=True, 372 | ) 373 | 374 | 375 | def print_all_todos(todos: list) -> None: 376 | pattern_dict = {pat: f"[orange3]{pat}[/]" for pat in cfg.scanned_patterns} 377 | 378 | for i, (todo, path) in enumerate(todos, start=1): 379 | todo_string = f"[cyan]{i}[/]) " if i > 9 else f"[cyan]0{i}[/]) " 380 | for pat, col_pat in pattern_dict.items(): 381 | todo = todo.replace(pat, col_pat) 382 | todo_string += f"{todo:<90} " 383 | todo_string += f"[blue]{str(path):>10}[/] " 384 | console.print(todo_string) 385 | 386 | 387 | def input_confirm_add_todos_to_board(todos: list) -> bool: 388 | # Question Also print tasks already in Board? 389 | console.print(f"Found [blue]{len(todos)}[/] TODOs.") 390 | if len(todos) > 10: 391 | if input_confirm_show_all_todos(): 392 | print_all_todos(todos) 393 | else: 394 | print_all_todos(todos) 395 | 396 | return Confirm.ask( 397 | prompt="Add found Tasks to active board?", default=False, show_default=True 398 | ) 399 | 400 | 401 | # Report Options 402 | def create_github_like_report_table(boards_dict: dict): 403 | done_tasks = [] 404 | for _, task_dict in boards_dict.items(): 405 | done_tasks += [task for _, task in task_dict.items() if task["Complete_Time"]] 406 | 407 | max_val, report_dict = create_dict_for_report_view(done_tasks) 408 | current_year = datetime.now().year 409 | done_tasks_this_year = [ 410 | task 411 | for task in done_tasks 412 | if datetime.strptime(task["Complete_Time"], "%Y-%m-%d %H:%M:%S").year 413 | == current_year 414 | ] 415 | 416 | gh_table = Table( 417 | title=f"[{REPORT_COLORS[4]}]{len(done_tasks_this_year)}[/] Tasks completed" 418 | + f" in [{REPORT_COLORS[4]}]{current_year}[/]", 419 | title_justify="left", 420 | highlight=True, 421 | padding=False, 422 | show_header=True, 423 | box=None, 424 | caption="\nless" 425 | + " ".join([f"[{scale} on {scale}] [/] " for scale in REPORT_COLORS]) 426 | + " more", 427 | caption_justify="right", 428 | ) 429 | for work_week in range(0, 53): 430 | gh_table.add_column( 431 | header="" if (work_week % 5 or work_week == 0) else f"{work_week}", 432 | header_style="bold", 433 | justify="left", 434 | overflow="fold", 435 | ) 436 | 437 | for day in range(1, 8): 438 | day_name = calendar.day_abbr[day - 1] if day % 2 else "" 439 | day_row_vals = [report_dict[day].get(week, 0) for week in range(1, 53)] 440 | mapped_day_row_vals = create_color_mapping(day_row_vals, max_val=max_val) 441 | 442 | gh_table.add_row( 443 | day_name, 444 | *[ 445 | f"[{REPORT_COLORS[i]} on {REPORT_COLORS[i]}] [/]" 446 | for i in mapped_day_row_vals 447 | ], 448 | ) 449 | 450 | return gh_table 451 | 452 | 453 | # Config Settings 454 | ##################################################################################### 455 | 456 | 457 | # Ask for Actions 458 | def input_ask_for_action_settings() -> int: 459 | console.print( 460 | "[yellow]Not happy with current settings!?[/]," 461 | + "which [blue]Section[/] do you want to change :hammer_and_wrench:?" 462 | ) 463 | console.print( 464 | "\t[1] :clipboard: [blue]settings.general[/]" 465 | + 2 * "\t" 466 | + "[2] :eye: [blue]settings.columns.visibility[/]" 467 | ) 468 | console.print( 469 | "\t[3] :magnifying_glass_tilted_left: [blue]settings.scanner[/]" 470 | + 2 * "\t" 471 | + "[4] :cross_mark: [red]Go back to Kanban Board[/]" 472 | ) 473 | action = IntPrompt.ask( 474 | prompt="Choose [blue]Section[/], where you want to change the Current Value", 475 | choices=[ 476 | "1", 477 | "2", 478 | "3", 479 | "4", 480 | ], 481 | show_choices=False, 482 | ) 483 | return action 484 | 485 | 486 | # Show current Config Table 487 | def create_config_table(): 488 | settings_table = Table( 489 | title=":hammer_and_wrench: [grey69]Settings Overview[/]:hammer_and_wrench:", 490 | highlight=True, 491 | show_header=True, 492 | caption=f"Your config file is located under [light_green]{CONFIG_FILE_PATH}[/]", 493 | ) 494 | for col in ["Option", "Current Value"]: 495 | settings_table.add_column( 496 | header=col, 497 | header_style="bold", 498 | justify="left", 499 | overflow="fold", 500 | min_width=30, 501 | ) 502 | for section in cfg.config: 503 | if section: 504 | settings_table.add_section() 505 | settings_table.add_row(f"[blue]{section}[/]", "") 506 | for key, val in cfg.config[section].items(): 507 | settings_table.add_row(key, val) 508 | 509 | return settings_table 510 | 511 | 512 | # Change settings.general 513 | def input_change_footer_settings(): 514 | footer_visible = Confirm.ask( 515 | prompt="Should Footer be visible?", 516 | default=True if cfg.show_footer == "True" else False, 517 | show_default=True, 518 | ) 519 | 520 | return footer_visible 521 | 522 | 523 | def input_change_done_limit_settings() -> int: 524 | done_limit = IntPrompt.ask( 525 | prompt=f"What should the Limit of Tasks in {COLOR_DICT.get('Done','Done')} " 526 | + f"Column be, before moving to {COLOR_DICT.get('Archived','Archived')}?", 527 | default=cfg.done_limit, 528 | show_default=True, 529 | ) 530 | 531 | return str(done_limit) 532 | 533 | 534 | def input_change_min_col_width_settings(): 535 | new_min_col_width = IntPrompt.ask( 536 | prompt="What should the minimum Column Width be?", 537 | default=cfg.col_min_width, 538 | show_default=True, 539 | ) 540 | 541 | return new_min_col_width 542 | 543 | 544 | # Change settings.columns.visible 545 | def input_change_column_settings(): 546 | updated_column_dict = {} 547 | for col, vis in cfg.kanban_columns_dict.items(): 548 | new_visible = Confirm.ask( 549 | prompt=f"Should Column {COLOR_DICT.get(col,col)} be visible?", 550 | default=True if vis == "True" else False, 551 | show_default=True, 552 | ) 553 | updated_column_dict[col] = "True" if new_visible else "False" 554 | 555 | return updated_column_dict 556 | 557 | 558 | # Change settings.scanner 559 | def input_change_files_to_scan_settings(): 560 | files_to_scan = Prompt.ask( 561 | prompt="Which Files to scan? Enter [green]' '[/] separated File Endings", 562 | default=" ".join(cfg.scanned_files), 563 | show_default=True, 564 | ) 565 | 566 | return files_to_scan 567 | 568 | 569 | def input_change_patterns_to_scan_settings(): 570 | files_to_scan = Prompt.ask( 571 | prompt="Which Patterns to scan? Enter [green]','[/] separated Patterns", 572 | default=",".join(cfg.scanned_patterns), 573 | show_default=True, 574 | ) 575 | 576 | return files_to_scan 577 | -------------------------------------------------------------------------------- /src/kanban_python/utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import os 3 | from collections import defaultdict 4 | from datetime import datetime 5 | from pathlib import Path 6 | from random import choice 7 | 8 | from rich.console import Console 9 | from rich.progress import MofNCompleteColumn, Progress 10 | 11 | from .constants import QUOTES, REPORT_FILE_NAME 12 | 13 | console = Console() 14 | 15 | 16 | def get_motivational_quote() -> str: 17 | return choice(QUOTES) 18 | 19 | 20 | def current_time_to_str() -> str: 21 | return datetime.now().strftime("%Y-%m-%d %H:%M:%S") 22 | 23 | 24 | def calculate_time_delta_str(start_time_str: str, end_time_str: str) -> float: 25 | date_format = "%Y-%m-%d %H:%M:%S" 26 | start_time = datetime.strptime(start_time_str, date_format) 27 | end_time = datetime.strptime(end_time_str, date_format) 28 | 29 | delta = end_time - start_time 30 | delta_minutes = delta.total_seconds() / 60 31 | 32 | return round(delta_minutes, 2) 33 | 34 | 35 | def create_status_dict_for_rows(data: dict, vis_cols: list) -> dict: 36 | status_dict = {col: [] for col in vis_cols} 37 | 38 | for id, task in data.items(): 39 | if task["Status"] not in vis_cols: 40 | continue 41 | task_str = f"[[cyan]{id}[/]]" if int(id) > 9 else f"[[cyan]0{id}[/]]" 42 | task_str += f'([orange3]{task.get("Tag")}[/])' 43 | task_str += f' [white]{task["Title"]}[/]' 44 | # Add days left 45 | if all((task["Status"] in ["Ready", "Doing"], task.get("Due_Date", False))): 46 | days_left = calculate_days_left_till_due(task["Due_Date"]) 47 | task_str += f" |[red]{days_left:02d}[/]|" 48 | status_dict[task["Status"]].append(task_str) 49 | 50 | return status_dict 51 | 52 | 53 | def check_if_done_col_leq_X(cfg, data: dict) -> bool: 54 | done_col_idxs = [idx for idx, t in data.items() if t["Status"] == "Done"] 55 | return len(done_col_idxs) <= cfg.done_limit 56 | 57 | 58 | def check_if_there_are_visible_tasks_in_board(data: dict, vis_cols: list) -> bool: 59 | for task in data.values(): 60 | if task["Status"] in vis_cols: 61 | return True 62 | return False 63 | 64 | 65 | def move_first_done_task_to_archive(data: dict): 66 | first_task_id = [idx for idx, t in data.items() if t["Status"] == "Done"][0] 67 | updated_task = data[first_task_id] 68 | updated_task["Status"] = "Archived" 69 | 70 | return first_task_id, updated_task 71 | 72 | 73 | def delete_json_file(db_path: str) -> None: 74 | path = Path(db_path) 75 | try: 76 | path.unlink() 77 | if path != (Path().cwd() / "pykanban.json"): 78 | path.parent.rmdir() 79 | console.print(f"File under {path.parent} was now removed") 80 | except FileNotFoundError: 81 | console.print("File already deleted") 82 | 83 | 84 | def check_board_name_valid(boardname: str): 85 | if not boardname: 86 | return False 87 | checker = "".join(x for x in boardname if (x.isalnum() or x in "_- ")) 88 | return True if (checker == boardname) else False 89 | 90 | 91 | def scan_files(path: Path, endings: list = [".py"]): 92 | def recursive_search(path, file_list: list, progress): 93 | for entry in os.scandir(path): 94 | try: 95 | if entry.is_dir(follow_symlinks=False) and not entry.name.startswith( 96 | "." 97 | ): 98 | recursive_search( 99 | path=entry.path, file_list=file_list, progress=progress 100 | ) 101 | 102 | elif entry.is_file(follow_symlinks=False): 103 | if any(entry.path.endswith(ending) for ending in endings): 104 | file_list.append(entry.path) 105 | prog.update(task_id=task, advance=1) 106 | except PermissionError: 107 | continue 108 | 109 | file_list = [] 110 | with Progress(transient=True) as prog: 111 | task = prog.add_task("[blue]Collecting files...", total=None) 112 | recursive_search(path=path, file_list=file_list, progress=prog) 113 | 114 | return file_list 115 | 116 | 117 | def scan_for_todos( 118 | file_paths: list, rel_path: Path, patterns: list = ["#TODO", "# TODO"] 119 | ) -> list: 120 | todos = [] 121 | with Progress(MofNCompleteColumn(), *Progress.get_default_columns()) as prog: 122 | task = prog.add_task("Files searched for TODOs...", total=len(file_paths)) 123 | 124 | for file_path in file_paths: 125 | prog.update(task_id=task, advance=1) 126 | with open(file_path, "r") as file: 127 | try: 128 | todos += [ 129 | (line.strip(), str(Path(file_path).relative_to(rel_path))) 130 | for line in file.readlines() 131 | if any(line.strip().startswith(pattern) for pattern in patterns) 132 | ] 133 | except UnicodeDecodeError: 134 | continue 135 | 136 | return todos 137 | 138 | 139 | def split_todo_in_tag_and_title(todo: str, patterns: list): 140 | for pattern in patterns: 141 | if pattern in todo: 142 | tag = "".join(c for c in pattern if c.isalnum()) 143 | if not todo.split(pattern)[0]: 144 | title = todo.split(pattern)[1].strip() 145 | title = title[1:].strip() if title.startswith(":") else title 146 | 147 | return tag.upper(), title 148 | 149 | 150 | def get_tag_id_choices(data_dict: dict, vis_cols: list) -> list: 151 | valid_ids = [i for i, task in data_dict.items() if task["Status"] in vis_cols] 152 | valid_tags = [ 153 | task["Tag"] for task in data_dict.values() if task["Status"] in vis_cols 154 | ] 155 | 156 | valid_choices = list(set(valid_ids + valid_tags)) 157 | return valid_choices 158 | 159 | 160 | def check_scanner_files_valid(files: str) -> bool: 161 | for file in files.split(" "): 162 | if not file.startswith("."): 163 | return False 164 | if not all(char.isalpha() for char in file[1:]): 165 | return False 166 | return True 167 | 168 | 169 | def check_scanner_patterns_valid(patterns: str) -> bool: 170 | for pattern in patterns.split(","): 171 | if not pattern.startswith("#"): 172 | return False 173 | return True 174 | 175 | 176 | def get_iso_calender_info(date_str: str): 177 | year, week, weekday = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S").isocalendar() 178 | return year, week, weekday 179 | 180 | 181 | def create_dict_for_report_view(completed_tasks: list): 182 | report_dict = defaultdict(lambda: defaultdict(int)) 183 | max_val = 0 184 | current_year = datetime.now().year 185 | for task in completed_tasks: 186 | year, week, day = get_iso_calender_info(task["Complete_Time"]) 187 | if year != current_year: 188 | continue 189 | report_dict[day][week] += 1 190 | max_val = max(max_val, report_dict[day][week]) 191 | 192 | return max_val, report_dict 193 | 194 | 195 | def create_color_mapping(amount_list: list, max_val: int): 196 | mapped_list = [] 197 | for val in amount_list: 198 | if val == 0: 199 | mapped_list.append(0) 200 | elif (val / max_val) <= 0.25: 201 | mapped_list.append(1) 202 | elif (val / max_val) <= 0.5: 203 | mapped_list.append(2) 204 | elif (val / max_val) <= 0.75: 205 | mapped_list.append(3) 206 | elif (val / max_val) <= 1: 207 | mapped_list.append(4) 208 | else: 209 | continue 210 | 211 | return mapped_list 212 | 213 | 214 | def create_report_document(path: Path, boards_dict: dict): 215 | date_dict = defaultdict(list) 216 | for _, task_dict in boards_dict.items(): 217 | for _, task in task_dict.items(): 218 | if not task["Complete_Time"]: 219 | continue 220 | completion_date = datetime.strptime( 221 | task["Complete_Time"], "%Y-%m-%d %H:%M:%S" 222 | ).date() 223 | date_dict[completion_date].append(f"- {task['Tag']} {task['Title']}\n") 224 | 225 | with open(path / REPORT_FILE_NAME, "w") as report_file: 226 | last_year = "" 227 | last_month = "" 228 | last_day = "" 229 | for date, completed in sorted(date_dict.items()): 230 | if date.year != last_year: 231 | last_year = date.year 232 | report_file.write(f"# Tasks Completed in {date.year}\n") 233 | 234 | if date.month != last_month: 235 | last_month = date.month 236 | report_file.write(f"## {calendar.month_name[date.month]}\n") 237 | 238 | if date.day != last_day: 239 | last_day = date.day 240 | report_file.write(f"### {date}\n") 241 | 242 | report_file.write("".join(completed)) 243 | 244 | return date_dict 245 | 246 | 247 | def check_due_date_format(date_str: str) -> bool: 248 | try: 249 | datetime.strptime(date_str, "%Y-%m-%d") 250 | return True 251 | except ValueError: 252 | return False 253 | 254 | 255 | def due_date_datetime_to_date(date_datetime: str) -> str: 256 | if date_datetime: 257 | date_str = str(datetime.strptime(date_datetime, "%Y-%m-%d %H:%M:%S").date()) 258 | return date_str 259 | return date_datetime 260 | 261 | 262 | def due_date_date_to_datetime(date_str: str) -> str: 263 | if date_str: 264 | date_datetime = str( 265 | datetime.strptime(f"{date_str} 23:59:59", "%Y-%m-%d %H:%M:%S") 266 | ) 267 | return date_datetime 268 | return date_str 269 | 270 | 271 | def calculate_days_left_till_due(due_date: str): 272 | time_now = datetime.now() 273 | time_due = datetime.strptime(due_date, "%Y-%m-%d %H:%M:%S") 274 | 275 | delta_days = (time_due - time_now).days 276 | return delta_days 277 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dummy conftest.py for kanban_python. 3 | 4 | If you don't know what this is for, just leave it empty. 5 | Read more about conftest.py under: 6 | - https://docs.pytest.org/en/stable/fixture.html 7 | - https://docs.pytest.org/en/stable/writing_plugins.html 8 | """ 9 | 10 | import pytest 11 | 12 | from kanban_python import config, constants 13 | 14 | 15 | @pytest.fixture 16 | def start_time_str(): 17 | return "2023-11-01 10:00:00" 18 | 19 | 20 | @pytest.fixture 21 | def end_time_str(): 22 | return "2023-11-01 10:02:30" 23 | 24 | 25 | @pytest.fixture 26 | def test_task(): 27 | return { 28 | "Title": "Welcome Task", 29 | "Description": "Welcome to kanban-python, I hope this helps your productivity", 30 | "Tag": "HI", 31 | "Status": "Ready", 32 | "Begin_Time": "", 33 | "Complete_Time": "", 34 | "Duration": "0", 35 | "Creation_Date": "", 36 | "Due_Date": "", 37 | } 38 | 39 | 40 | @pytest.fixture 41 | def test_config_path(tmp_path): 42 | return tmp_path 43 | 44 | 45 | @pytest.fixture 46 | def test_config_file_path(test_config_path): 47 | return test_config_path / constants.CONFIG_FILE_NAME 48 | 49 | 50 | @pytest.fixture 51 | def test_kanban_board_path(tmp_path): 52 | return tmp_path / constants.KANBAN_BOARDS_FOLDER_NAME 53 | 54 | 55 | @pytest.fixture 56 | def test_config(test_config_path, test_config_file_path, test_kanban_board_path): 57 | config.create_init_config(test_config_path, test_kanban_board_path) 58 | 59 | cfg = config.KanbanConfig(path=test_config_file_path) 60 | return cfg 61 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from kanban_python import config 4 | 5 | 6 | def test_active_board_path(test_config): 7 | cfg = test_config 8 | board_name = "Testboard" 9 | cfg.kanban_boards_dict = board_name 10 | cfg.active_board = board_name 11 | 12 | expected_path = ( 13 | config.KANBAN_BOARDS_PATH / board_name / config.TASK_FILE_NAME 14 | ).as_posix() 15 | assert cfg.active_board_path == expected_path 16 | 17 | 18 | def test_show_footer_setter(test_config): 19 | cfg = test_config 20 | cfg.show_footer = "False" 21 | assert cfg.show_footer == "False" 22 | 23 | 24 | def test_col_min_width(test_config): 25 | cfg = test_config 26 | assert cfg.col_min_width == 35 27 | 28 | 29 | def test_kanban_columns_dict_setter(test_config): 30 | cfg = test_config 31 | cfg.kanban_columns_dict = {"Archived": True} 32 | assert cfg.kanban_columns_dict["Archived"] == "True" 33 | 34 | 35 | def test_vis_cols(test_config): 36 | cfg = test_config 37 | assert cfg.vis_cols == ["Ready", "Doing", "Done"] 38 | 39 | 40 | def test_done_limit_setter(test_config): 41 | cfg = test_config 42 | cfg.done_limit = "11" 43 | assert cfg.done_limit == 11 44 | 45 | 46 | def test_create_init_config( 47 | test_config_path, test_config_file_path, test_kanban_board_path 48 | ): 49 | config.create_init_config(test_config_path, test_kanban_board_path) 50 | cfg = config.KanbanConfig(path=test_config_file_path) 51 | 52 | assert ("settings.general" in cfg.config.sections()) is True 53 | assert ("settings.scanner" in cfg.config.sections()) is True 54 | assert ("settings.columns.visible" in cfg.config.sections()) is True 55 | assert ("kanban_boards" in cfg.config.sections()) is True 56 | 57 | assert cfg.config["settings.columns.visible"]["Ready"] == "True" 58 | assert cfg.config["settings.columns.visible"]["Doing"] == "True" 59 | assert cfg.config["settings.columns.visible"]["Done"] == "True" 60 | assert cfg.config["settings.columns.visible"]["Deleted"] == "False" 61 | assert cfg.config["settings.columns.visible"]["Archived"] == "False" 62 | 63 | assert cfg.config["settings.scanner"]["Files"] == ".py .md" 64 | assert cfg.config["settings.scanner"]["Patterns"] == "# TODO,#TODO,# BUG" 65 | 66 | assert cfg.kanban_boards is not True 67 | 68 | 69 | def test_delete_current_folder_board_from_config(test_config): 70 | cfg = test_config 71 | cfg.config["kanban_boards"]["testboard1"] = "path1" 72 | cfg.config["kanban_boards"]["testboard2"] = "path1" 73 | cfg.config["kanban_boards"]["testboard3"] = "path3" 74 | 75 | config.delete_current_folder_board_from_config(cfg=cfg, curr_path="path1") 76 | 77 | assert ("path1" in cfg.kanban_boards_dict.values()) is False 78 | assert ("path3" in cfg.kanban_boards_dict.values()) is True 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "board, in_list", 83 | [("testboard1", True), ("testboard2", False)], 84 | ) 85 | def test_check_if_board_name_exists_in_config(test_config, board, in_list): 86 | cfg = test_config 87 | cfg.config["kanban_boards"]["testboard1"] = "" 88 | 89 | assert ( 90 | config.check_if_board_name_exists_in_config( 91 | board, 92 | cfg=cfg, 93 | ) 94 | == in_list 95 | ) 96 | 97 | 98 | @pytest.mark.parametrize( 99 | "act_board, in_list", 100 | [("testboard1", True), ("testboard2", False)], 101 | ) 102 | def test_check_if_current_active_board_in_board_list(test_config, act_board, in_list): 103 | cfg = test_config 104 | cfg.active_board = act_board 105 | cfg.config["kanban_boards"]["testboard1"] = "" 106 | 107 | assert config.check_if_current_active_board_in_board_list(cfg=cfg) == in_list 108 | 109 | 110 | @pytest.mark.parametrize( 111 | "to_delete_board, board_left", 112 | [("testboard1", "testboard2"), ("testboard2", "testboard1")], 113 | ) 114 | def test_delete_board_from_config(test_config, to_delete_board, board_left): 115 | cfg = test_config 116 | cfg.config["kanban_boards"]["testboard1"] = "" 117 | cfg.config["kanban_boards"]["testboard2"] = "" 118 | 119 | config.delete_board_from_config(cfg=cfg, board_name=to_delete_board) 120 | 121 | assert board_left in cfg.kanban_boards 122 | 123 | 124 | def test_check_config_exists(test_config_file_path, test_config): 125 | assert config.check_config_exists(path=test_config_file_path) is True 126 | 127 | 128 | def test_get_json_path(): 129 | board_name = "Testboard" 130 | result = config.get_json_path(board_name) 131 | 132 | assert ( 133 | result 134 | == (config.KANBAN_BOARDS_PATH / board_name / config.TASK_FILE_NAME).as_posix() 135 | ) 136 | -------------------------------------------------------------------------------- /tests/test_interface.py: -------------------------------------------------------------------------------- 1 | from rich.prompt import Confirm, IntPrompt, Prompt 2 | 3 | from kanban_python import interface 4 | 5 | 6 | def test_input_ask_for_action(monkeypatch): 7 | monkeypatch.setattr(IntPrompt, "ask", lambda *args, **kwargs: 1) 8 | result = interface.input_ask_for_action() 9 | assert result == 1 10 | 11 | 12 | def test_input_confirm_set_board_active(monkeypatch) -> bool: 13 | monkeypatch.setattr(Confirm, "ask", lambda *args, **kwargs: False) 14 | result = interface.input_confirm_set_board_active("Testboard") 15 | assert result is False 16 | 17 | 18 | def test_input_ask_for_new_board_name(monkeypatch): 19 | monkeypatch.setattr(Prompt, "ask", lambda *args, **kwargs: "Test") 20 | result = interface.input_ask_for_new_board_name() 21 | assert result == "Test" 22 | 23 | 24 | def test_input_confirm_delete_board(monkeypatch): 25 | monkeypatch.setattr(Confirm, "ask", lambda *args, **kwargs: False) 26 | result = interface.input_confirm_delete_board("Testboard") 27 | assert result is False 28 | 29 | monkeypatch.setattr(Confirm, "ask", lambda *args, **kwargs: True) 30 | result = interface.input_confirm_delete_board("Testboard") 31 | assert result is True 32 | 33 | 34 | def test_input_confirm_show_all_todos(monkeypatch): 35 | monkeypatch.setattr(Confirm, "ask", lambda *args, **kwargs: False) 36 | result = interface.input_confirm_show_all_todos() 37 | assert result is False 38 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from freezegun import freeze_time 5 | 6 | from kanban_python import utils 7 | 8 | 9 | def test_get_motivational_quote(): 10 | assert utils.get_motivational_quote() in utils.QUOTES 11 | 12 | 13 | def test_current_time_to_str(): 14 | current_time = datetime.datetime.now() 15 | result = utils.current_time_to_str() 16 | expected_format = current_time.strftime("%Y-%m-%d %H:%M:%S") 17 | 18 | assert result == expected_format 19 | 20 | 21 | def test_calculate_time_delta_str(start_time_str, end_time_str): 22 | delta = utils.calculate_time_delta_str(start_time_str, end_time_str) 23 | 24 | assert delta == 2.50 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "num_tasks, expected_result", 29 | [ 30 | (1, True), 31 | (0, True), 32 | (11, False), 33 | ], 34 | ) 35 | def test_check_if_done_col_leq_x(test_config, num_tasks, expected_result): 36 | cfg = test_config 37 | test_data = {i: {"Status": "Done"} for i in range(num_tasks)} 38 | result = utils.check_if_done_col_leq_X(cfg=cfg, data=test_data) 39 | 40 | assert result is expected_result 41 | 42 | 43 | @pytest.mark.parametrize("first_task_id", [1, 2, 3]) 44 | def test_move_first_done_task_to_archive(first_task_id): 45 | test_data = {i: {"Status": "Done"} for i in range(first_task_id, 15)} 46 | idx, task = utils.move_first_done_task_to_archive(data=test_data) 47 | 48 | assert first_task_id == idx 49 | assert task["Status"] == "Archived" 50 | 51 | 52 | @pytest.mark.parametrize( 53 | "vis_col, expected_result", 54 | [ 55 | ("Ready", {"Ready": ["[[cyan]01[/]]([orange3]HI[/]) [white]Welcome Task[/]"]}), 56 | ("Doing", {"Doing": []}), 57 | ("Done", {"Done": []}), 58 | ], 59 | ) 60 | def test_create_status_dict_for_rows(test_task, vis_col, expected_result): 61 | test_db = {"1": test_task} 62 | result_dict = utils.create_status_dict_for_rows(test_db, [vis_col]) 63 | assert isinstance(result_dict, dict) 64 | 65 | assert result_dict == expected_result 66 | 67 | 68 | @pytest.mark.parametrize( 69 | "vis_col, expected_result", [("Ready", True), ("Doing", False), ("Done", False)] 70 | ) 71 | def test_check_if_there_are_visible_tasks_in_board(test_task, vis_col, expected_result): 72 | test_db = {"1": test_task} 73 | result = utils.check_if_there_are_visible_tasks_in_board(test_db, [vis_col]) 74 | assert result == expected_result 75 | 76 | 77 | @pytest.mark.parametrize( 78 | "file_there, output", [(True, "removed"), (False, "File already deleted")] 79 | ) 80 | def test_delete_json_file(tmp_path, capsys, file_there, output): 81 | db_path = tmp_path / "boardname" 82 | db_file_path = db_path / "pykanban.json" 83 | if file_there: 84 | db_path.mkdir() 85 | db_file_path.touch() 86 | 87 | utils.delete_json_file(db_file_path) 88 | 89 | captured = capsys.readouterr() 90 | assert output in captured.out 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "name, expected_result", 95 | [("Test_123", True), ("Test123/", False), (".test_", False)], 96 | ) 97 | def test_check_board_name_valid(name, expected_result): 98 | result = utils.check_board_name_valid(name) 99 | 100 | assert result is expected_result 101 | 102 | 103 | def test_scan_files(tmp_path, test_config): 104 | cfg = test_config 105 | 106 | folderlv1 = tmp_path / "folder1" 107 | folderlv2 = folderlv1 / ".folder2" 108 | 109 | folderlv1.mkdir() 110 | folderlv2.mkdir() 111 | 112 | py_filelv0 = tmp_path / "file.py" 113 | txt_filelv1 = folderlv1 / "file.txt" 114 | md_filelv1 = folderlv1 / "file.md" 115 | md_filelv2 = folderlv2 / "file.md" 116 | 117 | py_filelv0.touch() 118 | txt_filelv1.touch() 119 | md_filelv1.touch() 120 | md_filelv2.touch() 121 | 122 | result = utils.scan_files(path=tmp_path, endings=cfg.scanned_files) 123 | 124 | assert sorted(result) == sorted( 125 | [ 126 | str(tmp_path / "file.py"), 127 | str(tmp_path / "folder1" / "file.md"), 128 | ] 129 | ) 130 | 131 | 132 | def test_scan_todos(test_config, tmp_path): 133 | cfg = test_config 134 | file_path = tmp_path / "file.py" 135 | file_path.touch() 136 | with open(file_path, "w") as file: 137 | file.write("# TODO: Pytest is cool") 138 | 139 | list_input = [file_path] 140 | 141 | result = utils.scan_for_todos( 142 | rel_path=tmp_path, file_paths=list_input, patterns=cfg.scanned_patterns 143 | ) 144 | 145 | assert result == [("# TODO: Pytest is cool", "file.py")] 146 | pass 147 | 148 | 149 | @pytest.mark.parametrize( 150 | "todo, pattern, expected_result", 151 | [ 152 | ("#TODO Test this", ["#TODO"], ("TODO", "Test this")), 153 | ("# BUG : Test this", ["# TODO", "# BUG"], ("BUG", "Test this")), 154 | ], 155 | ) 156 | def test_split_todo_in_tag_and_title(todo, pattern, expected_result): 157 | tag, title = utils.split_todo_in_tag_and_title(todo=todo, patterns=pattern) 158 | 159 | assert tag == expected_result[0] 160 | assert title == expected_result[1] 161 | 162 | 163 | @pytest.mark.parametrize( 164 | "files, expected_result", 165 | [(".md .py", True), (".py md", False), (".py .d3", False)], 166 | ) 167 | def test_check_scanner_files_valid(files, expected_result): 168 | result = utils.check_scanner_files_valid(files) 169 | 170 | assert result is expected_result 171 | 172 | 173 | @pytest.mark.parametrize( 174 | "patterns, expected_result", 175 | [("# TODO,#TODO", True), ("TODO,# BUG", False), ("TODO BUG", False)], 176 | ) 177 | def test_check_scanner_patterns_valid(patterns, expected_result): 178 | result = utils.check_scanner_patterns_valid(patterns) 179 | 180 | assert result is expected_result 181 | 182 | 183 | @pytest.mark.parametrize( 184 | "vis_cols, expected_result", 185 | [(["Ready"], ["1", "3", "HI"]), (["Done"], [])], 186 | ) 187 | def test_get_tag_id_choices(test_task, vis_cols, expected_result): 188 | data_dict = {"1": test_task, "3": test_task} 189 | 190 | result = utils.get_tag_id_choices(data_dict=data_dict, vis_cols=vis_cols) 191 | assert sorted(result) == sorted(expected_result) 192 | 193 | 194 | def test_get_iso_calender_info(): 195 | date = "2023-12-05 23:41:41" 196 | year, week, weekday = utils.get_iso_calender_info(date) 197 | assert year == 2023 198 | assert week == 49 199 | assert weekday == 2 200 | 201 | 202 | def test_create_dict_for_report_view(): 203 | tasks = [ 204 | {"Complete_Time": "2022-12-05 23:41:41"}, 205 | {"Complete_Time": "2023-12-05 23:41:41"}, 206 | ] 207 | # freeze to year 2023 208 | fake_now = datetime.datetime(2023, 12, 10, 0, 0, 0) 209 | 210 | result_max = 1 211 | result_dict = {2: {49: 1}} 212 | 213 | with freeze_time(fake_now): 214 | max_val, report_dict = utils.create_dict_for_report_view(tasks) 215 | 216 | assert result_max == max_val 217 | assert result_dict == report_dict 218 | 219 | 220 | def test_create_color_mapping(): 221 | task_amount = [1, 3, 4, 0, 5, 8, 12, 16, 25] 222 | max_val = 16 223 | result = utils.create_color_mapping(amount_list=task_amount, max_val=max_val) 224 | 225 | assert result == [1, 1, 1, 0, 2, 2, 3, 4] 226 | 227 | 228 | @pytest.mark.parametrize( 229 | "date, expected_result", 230 | [ 231 | ("2023-12-24", True), 232 | ("2023-17-3", False), 233 | ("30.05.2023", False), 234 | ("30.05.223", False), 235 | ], 236 | ) 237 | def test_check_due_date_format(date, expected_result): 238 | result = utils.check_due_date_format(date) 239 | 240 | assert result is expected_result 241 | 242 | 243 | @pytest.mark.parametrize( 244 | "datetime, expected_result", 245 | [ 246 | ("2023-12-24 00:00:00", "2023-12-24"), 247 | ("", ""), 248 | ], 249 | ) 250 | def test_due_date_datetime_to_date(datetime, expected_result): 251 | result = utils.due_date_datetime_to_date(datetime) 252 | 253 | assert result == expected_result 254 | 255 | 256 | @pytest.mark.parametrize( 257 | "datetime, expected_result", 258 | [ 259 | ("2023-12-24", "2023-12-24 23:59:59"), 260 | ("", ""), 261 | ], 262 | ) 263 | def test_due_date_date_to_datetime(datetime, expected_result): 264 | result = utils.due_date_date_to_datetime(datetime) 265 | 266 | assert result == expected_result 267 | 268 | 269 | def test_calculate_days_left_till_due(): 270 | fake_now = datetime.datetime(2023, 12, 10, 0, 0, 0) 271 | test_time = "2023-12-24 23:59:59" 272 | delta_days = 14 273 | 274 | with freeze_time(fake_now): 275 | result = utils.calculate_days_left_till_due(test_time) 276 | 277 | assert result == delta_days 278 | 279 | 280 | # def test_main(capsys): 281 | # """CLI Tests""" 282 | # # capsys is a pytest fixture that allows asserts against stdout/stderr 283 | # # https://docs.pytest.org/en/stable/capture.html 284 | # main(["7"]) 285 | # captured = capsys.readouterr() 286 | # assert "The 7-th Fibonacci number is 13" in captured.out 287 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.9" 4 | 5 | [[package]] 6 | name = "cfgv" 7 | version = "3.4.0" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, 12 | ] 13 | 14 | [[package]] 15 | name = "colorama" 16 | version = "0.4.6" 17 | source = { registry = "https://pypi.org/simple" } 18 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 19 | wheels = [ 20 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 21 | ] 22 | 23 | [[package]] 24 | name = "coverage" 25 | version = "7.7.0" 26 | source = { registry = "https://pypi.org/simple" } 27 | sdist = { url = "https://files.pythonhosted.org/packages/02/36/465f5492443265e1278f9a82ffe6aeed3f1db779da0d6e7d4611a5cfb6af/coverage-7.7.0.tar.gz", hash = "sha256:cd879d4646055a573775a1cec863d00c9ff8c55860f8b17f6d8eee9140c06166", size = 809969 } 28 | wheels = [ 29 | { url = "https://files.pythonhosted.org/packages/10/f5/2b801fe88f199707cf9ec66dcee036e7073b5a208a4a161b64371b3f1e35/coverage-7.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a538a23119d1e2e2ce077e902d02ea3d8e0641786ef6e0faf11ce82324743944", size = 210608 }, 30 | { url = "https://files.pythonhosted.org/packages/07/44/bcc030cf977d1069a28742c0a67284c6e831ef172f914055b3d45da83f89/coverage-7.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1586ad158523f4133499a4f322b230e2cfef9cc724820dbd58595a5a236186f4", size = 211042 }, 31 | { url = "https://files.pythonhosted.org/packages/2c/3f/b427f17e1bcf3e1f5ac42fc0b6cb623616f2aedcfc7fde17a058afb62568/coverage-7.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b6c96d69928a3a6767fab8dc1ce8a02cf0156836ccb1e820c7f45a423570d98", size = 240168 }, 32 | { url = "https://files.pythonhosted.org/packages/58/92/6e8d71c5e651f152ffc518ec4cd7add87035533e88af29e233635c0f0dfb/coverage-7.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f18d47641282664276977c604b5a261e51fefc2980f5271d547d706b06a837f", size = 238079 }, 33 | { url = "https://files.pythonhosted.org/packages/40/33/1c25ae35c16972dc379c24cd7dde20359d076dee00687825c92a53e43b02/coverage-7.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a1e18a85bd066c7c556d85277a7adf4651f259b2579113844835ba1a74aafd", size = 239216 }, 34 | { url = "https://files.pythonhosted.org/packages/4d/3d/adf40bdd07a49e1880632c1bc6b31f42d32cf0bfe6b4d294a8f706d70078/coverage-7.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:70f0925c4e2bfc965369f417e7cc72538fd1ba91639cf1e4ef4b1a6b50439b3b", size = 239126 }, 35 | { url = "https://files.pythonhosted.org/packages/72/a5/51e39811cd0ec0569a25fe8e6bac0a00efa222a3e49d51d64f5ba0dce24a/coverage-7.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b0fac2088ec4aaeb5468b814bd3ff5e5978364bfbce5e567c44c9e2854469f6c", size = 237842 }, 36 | { url = "https://files.pythonhosted.org/packages/ab/b7/c5796906cd9eed6d258138f1fddc8d6af01b6d07b3c183bac4a9731ac383/coverage-7.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3e212a894d8ae07fde2ca8b43d666a6d49bbbddb10da0f6a74ca7bd31f20054", size = 238136 }, 37 | { url = "https://files.pythonhosted.org/packages/d7/8a/bd34ea3c602b3ef323a001d375f9b1d663e901079bb26b5f9b8f96fae32b/coverage-7.7.0-cp310-cp310-win32.whl", hash = "sha256:f32b165bf6dfea0846a9c9c38b7e1d68f313956d60a15cde5d1709fddcaf3bee", size = 213320 }, 38 | { url = "https://files.pythonhosted.org/packages/94/60/6e7efe849e305a233623a80aaeba7ebb02809fa63ab8a1e49c4323b8083b/coverage-7.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:a2454b12a3f12cc4698f3508912e6225ec63682e2ca5a96f80a2b93cef9e63f3", size = 214219 }, 39 | { url = "https://files.pythonhosted.org/packages/e8/ec/9e0c9358a3bd56b1ddbf266b889ea9d51ee29e58fb72712d5600663fa806/coverage-7.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a0a207c87a9f743c8072d059b4711f8d13c456eb42dac778a7d2e5d4f3c253a7", size = 210722 }, 40 | { url = "https://files.pythonhosted.org/packages/be/bd/7b47a4302423a13960ee30682900d7ca20cee15c978b1d9ea9594d59d352/coverage-7.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d673e3add00048215c2cc507f1228a7523fd8bf34f279ac98334c9b07bd2656", size = 211154 }, 41 | { url = "https://files.pythonhosted.org/packages/c6/7c/ae54d9022440196bf9f3fad535388678a3db186980ff58a4956ddeb849a2/coverage-7.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f81fe93dc1b8e5673f33443c0786c14b77e36f1025973b85e07c70353e46882b", size = 243787 }, 42 | { url = "https://files.pythonhosted.org/packages/2d/21/913a2a2d89a2221f4410fbea4ff84e64ddf4367a4b9eb2c328bd01a1a401/coverage-7.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8c7524779003d59948c51b4fcbf1ca4e27c26a7d75984f63488f3625c328b9b", size = 241473 }, 43 | { url = "https://files.pythonhosted.org/packages/40/f1/5ae36fffd542fb86ab3b2d5e012af0840265f3dd001ad0ffabe9e4dbdcf6/coverage-7.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c124025430249118d018dcedc8b7426f39373527c845093132196f2a483b6dd", size = 243259 }, 44 | { url = "https://files.pythonhosted.org/packages/47/1b/abc87bad7f606a4df321bd8300413fe13700099a163e7d63453c7c70c1b2/coverage-7.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7f559c36d5cdc448ee13e7e56ed7b6b5d44a40a511d584d388a0f5d940977ba", size = 242904 }, 45 | { url = "https://files.pythonhosted.org/packages/e0/b3/ff0cf15f5709996727dda2fa00af6f4da92ea3e16168400346f2f742341a/coverage-7.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:37cbc7b0d93dfd133e33c7ec01123fbb90401dce174c3b6661d8d36fb1e30608", size = 241079 }, 46 | { url = "https://files.pythonhosted.org/packages/05/c9/fcad82aad05b1eb8040e6c25ae7a1303716cc05718d4dd326e0fab31aa14/coverage-7.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7d2a65876274acf544703e943c010b60bd79404e3623a1e5d52b64a6e2728de5", size = 241617 }, 47 | { url = "https://files.pythonhosted.org/packages/59/9f/d1efe149afa5c3a459c08bf04f7e6917ef4ee8e3440df5c3e87d6b972870/coverage-7.7.0-cp311-cp311-win32.whl", hash = "sha256:f5a2f71d6a91238e7628f23538c26aa464d390cbdedf12ee2a7a0fb92a24482a", size = 213372 }, 48 | { url = "https://files.pythonhosted.org/packages/88/d2/4b58f03e399185b01fb3168d4b870882de9c7a10e273f99c8f25ec690302/coverage-7.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae8006772c6b0fa53c33747913473e064985dac4d65f77fd2fdc6474e7cd54e4", size = 214285 }, 49 | { url = "https://files.pythonhosted.org/packages/b7/47/f7b870caa26082ff8033be074ac61dc175a6b0c965adf7b910f92a6d7cfe/coverage-7.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:056d3017ed67e7ddf266e6f57378ece543755a4c9231e997789ab3bd11392c94", size = 210907 }, 50 | { url = "https://files.pythonhosted.org/packages/ea/eb/40b39bdc6c1da403257f0fcb2c1b2fd81ff9f66c13abbe3862f42780e1c1/coverage-7.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33c1394d8407e2771547583b66a85d07ed441ff8fae5a4adb4237ad39ece60db", size = 211162 }, 51 | { url = "https://files.pythonhosted.org/packages/53/08/42a2db41b4646d6261122773e222dd7105e2306526f2d7846de6fee808ec/coverage-7.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fbb7a0c3c21908520149d7751cf5b74eb9b38b54d62997b1e9b3ac19a8ee2fe", size = 245223 }, 52 | { url = "https://files.pythonhosted.org/packages/78/2a/0ceb328a7e67e8639d5c7800b8161d4b5f489073ac8d5ac33b11eadee218/coverage-7.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb356e7ae7c2da13f404bf8f75be90f743c6df8d4607022e759f5d7d89fe83f8", size = 242114 }, 53 | { url = "https://files.pythonhosted.org/packages/ba/68/42b13b849d40af1581830ff06c60f4ec84649764f4a58d5c6e20ae11cbd4/coverage-7.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce730d484038e97f27ea2dbe5d392ec5c2261f28c319a3bb266f6b213650135", size = 244371 }, 54 | { url = "https://files.pythonhosted.org/packages/68/66/ab7c3b9fdbeb8bdd322f5b67b1886463834dba2014a534caba60fb0075ea/coverage-7.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa4dff57fc21a575672176d5ab0ef15a927199e775c5e8a3d75162ab2b0c7705", size = 244134 }, 55 | { url = "https://files.pythonhosted.org/packages/01/74/b833d299a479681957d6b238e16a0725586e1d56ec1e43658f3184550bb0/coverage-7.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b667b91f4f714b17af2a18e220015c941d1cf8b07c17f2160033dbe1e64149f0", size = 242353 }, 56 | { url = "https://files.pythonhosted.org/packages/f9/c5/0ed656d65da39bbab8e8fc367dc3d465a7501fea0f2b1caccfb4f6361c9f/coverage-7.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:693d921621a0c8043bfdc61f7d4df5ea6d22165fe8b807cac21eb80dd94e4bbd", size = 243543 }, 57 | { url = "https://files.pythonhosted.org/packages/87/b5/142bcff3828e4cce5d4c9ddc9222de1664464263acca09638e4eb0dbda7c/coverage-7.7.0-cp312-cp312-win32.whl", hash = "sha256:52fc89602cde411a4196c8c6894afb384f2125f34c031774f82a4f2608c59d7d", size = 213543 }, 58 | { url = "https://files.pythonhosted.org/packages/29/74/99d226985def03284bad6a9aff27a1079a8881ec7523b5980b00a5260527/coverage-7.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ce8cf59e09d31a4915ff4c3b94c6514af4c84b22c4cc8ad7c3c546a86150a92", size = 214344 }, 59 | { url = "https://files.pythonhosted.org/packages/45/2f/df6235ec963b9eb6b6b2f3c24f70448f1ffa13b9a481c155a6caff176395/coverage-7.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4545485fef7a8a2d8f30e6f79ce719eb154aab7e44217eb444c1d38239af2072", size = 210934 }, 60 | { url = "https://files.pythonhosted.org/packages/f3/85/ff19510bf642e334845318ddb73a550d2b17082831fa9ae053ce72288be7/coverage-7.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1393e5aa9441dafb0162c36c8506c648b89aea9565b31f6bfa351e66c11bcd82", size = 211212 }, 61 | { url = "https://files.pythonhosted.org/packages/2d/6a/af6582a419550d35eacc3e1bf9f4a936dda0ae559632a0bc4e3aef694ac8/coverage-7.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:316f29cc3392fa3912493ee4c83afa4a0e2db04ff69600711f8c03997c39baaa", size = 244727 }, 62 | { url = "https://files.pythonhosted.org/packages/55/62/7c49526111c91f3d7d27e111c22c8d08722f5b661c3f031b625b4d7bc4d9/coverage-7.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1ffde1d6bc2a92f9c9207d1ad808550873748ac2d4d923c815b866baa343b3f", size = 241768 }, 63 | { url = "https://files.pythonhosted.org/packages/62/4b/2dc27700782be9795cbbbe98394dd19ef74815d78d5027ed894972cd1b4a/coverage-7.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:416e2a8845eaff288f97eaf76ab40367deafb9073ffc47bf2a583f26b05e5265", size = 243790 }, 64 | { url = "https://files.pythonhosted.org/packages/d3/11/9cc1ae56d3015edca69437f3121c2b44de309f6828980b29e4cc9b13246d/coverage-7.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5efdeff5f353ed3352c04e6b318ab05c6ce9249c25ed3c2090c6e9cadda1e3b2", size = 243861 }, 65 | { url = "https://files.pythonhosted.org/packages/db/e4/2398ed93edcf42ff43002d91c37be11514d825cec382606654fd44f4b8fa/coverage-7.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:57f3bd0d29bf2bd9325c0ff9cc532a175110c4bf8f412c05b2405fd35745266d", size = 241942 }, 66 | { url = "https://files.pythonhosted.org/packages/ec/fe/b6bd35b17a2b8d26bdb21d5ea4351a837ec01edf552655e833629af05b90/coverage-7.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ab7090f04b12dc6469882ce81244572779d3a4b67eea1c96fb9ecc8c607ef39", size = 243228 }, 67 | { url = "https://files.pythonhosted.org/packages/6d/06/d8701bae1e5d865edeb00a6c2a71bd7659ca6af349789271c6fd16a57909/coverage-7.7.0-cp313-cp313-win32.whl", hash = "sha256:180e3fc68ee4dc5af8b33b6ca4e3bb8aa1abe25eedcb958ba5cff7123071af68", size = 213572 }, 68 | { url = "https://files.pythonhosted.org/packages/d7/c1/7e67780bfcaed6bed20100c9e1b2645e3414577b4bdad169578325249045/coverage-7.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:55143aa13c49491f5606f05b49ed88663446dce3a4d3c5d77baa4e36a16d3573", size = 214372 }, 69 | { url = "https://files.pythonhosted.org/packages/ed/25/50b0447442a415ad3da33093c589d9ef945dd6933225f1ce0ac97476397e/coverage-7.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc41374d2f27d81d6558f8a24e5c114580ffefc197fd43eabd7058182f743322", size = 211774 }, 70 | { url = "https://files.pythonhosted.org/packages/13/cc/3daddc707e934d3c0aafaa4a9b217f53fcf4133d4e40cc6ae63aa51243b8/coverage-7.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:89078312f06237417adda7c021c33f80f7a6d2db8572a5f6c330d89b080061ce", size = 211995 }, 71 | { url = "https://files.pythonhosted.org/packages/98/99/c92f43355d3d67f6bf8c946a350f2174e18f9ea7c8a1e36c9eb84ab7d20b/coverage-7.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b2f144444879363ea8834cd7b6869d79ac796cb8f864b0cfdde50296cd95816", size = 256226 }, 72 | { url = "https://files.pythonhosted.org/packages/25/62/65f0f33c08e0a1632f1e487b9c2d252e8bad6a77a942836043972b0ba6d2/coverage-7.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60e6347d1ed882b1159ffea172cb8466ee46c665af4ca397edbf10ff53e9ffaf", size = 251937 }, 73 | { url = "https://files.pythonhosted.org/packages/b2/10/99a9565aaeb159aade178c6509c8324a9c9e825b01f02242a37c2a8869f8/coverage-7.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb203c0afffaf1a8f5b9659a013f8f16a1b2cad3a80a8733ceedc968c0cf4c57", size = 254276 }, 74 | { url = "https://files.pythonhosted.org/packages/a7/12/206196edbf0b82250b11bf5c252fe25ebaa2b7c8d66edb0c194e7b3403fe/coverage-7.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ad0edaa97cb983d9f2ff48cadddc3e1fb09f24aa558abeb4dc9a0dbacd12cbb4", size = 255366 }, 75 | { url = "https://files.pythonhosted.org/packages/a5/82/a2abb8d4cdd99c6a443ab6682c0eee5797490a2113a45ffaa8b6b31c5dcc/coverage-7.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c5f8a5364fc37b2f172c26a038bc7ec4885f429de4a05fc10fdcb53fb5834c5c", size = 253536 }, 76 | { url = "https://files.pythonhosted.org/packages/4d/7d/3747e000e60ad5dd8157bd978f99979967d56cb35c55235980c85305db86/coverage-7.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4e09534037933bf6eb31d804e72c52ec23219b32c1730f9152feabbd7499463", size = 254344 }, 77 | { url = "https://files.pythonhosted.org/packages/45/56/7c33f8a6de1b3b079374d2ae490ccf76fb7c094a23f72d10f071989fc3ef/coverage-7.7.0-cp313-cp313t-win32.whl", hash = "sha256:1b336d06af14f8da5b1f391e8dec03634daf54dfcb4d1c4fb6d04c09d83cef90", size = 214284 }, 78 | { url = "https://files.pythonhosted.org/packages/95/ab/657bfa6171800a67bd1c005402f06d6b78610820ef1364ea4f85b04bbb5b/coverage-7.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b54a1ee4c6f1905a436cbaa04b26626d27925a41cbc3a337e2d3ff7038187f07", size = 215445 }, 79 | { url = "https://files.pythonhosted.org/packages/d1/42/0e77be6f2fafe7f3de88ddf9f8d9a0d8e9a75f9517081d261d31439908c7/coverage-7.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1c8fbce80b2b8bf135d105aa8f5b36eae0c57d702a1cc3ebdea2a6f03f6cdde5", size = 210604 }, 80 | { url = "https://files.pythonhosted.org/packages/0e/62/a82adc7818545fca3987367c6b20f239645678438f7da5827a4960bcbe7f/coverage-7.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d9710521f07f526de30ccdead67e6b236fe996d214e1a7fba8b36e2ba2cd8261", size = 211031 }, 81 | { url = "https://files.pythonhosted.org/packages/a6/50/a98b418fcaf531b2829b2a06f47f8c5cbc0dcce4a9aa63c5f30bf47d1a92/coverage-7.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7789e700f33f2b133adae582c9f437523cd5db8de845774988a58c360fc88253", size = 239791 }, 82 | { url = "https://files.pythonhosted.org/packages/58/f7/0a8f891fce6f389b1062a520aff130fa6974433efeb549dd19cbdccc76b3/coverage-7.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c36093aca722db73633cf2359026ed7782a239eb1c6db2abcff876012dc4cf", size = 237718 }, 83 | { url = "https://files.pythonhosted.org/packages/a9/8f/362c91661e6c43ff86b65b15bbb60ad1ad4924e9d1e35a0d5f08eb3337c4/coverage-7.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c075d167a6ec99b798c1fdf6e391a1d5a2d054caffe9593ba0f97e3df2c04f0e", size = 238820 }, 84 | { url = "https://files.pythonhosted.org/packages/dd/4b/56520dba6f38ad59e96cdeb8c7eafa47781576d2baabdfa10f8c1813b37b/coverage-7.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d013c07061751ae81861cae6ec3a4fe04e84781b11fd4b6b4201590234b25c7b", size = 238595 }, 85 | { url = "https://files.pythonhosted.org/packages/4d/e6/acfae468bd1f9b691b29d42f93bfd7080c05021103f03580934c066a3844/coverage-7.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:104bf640f408f4e115b85110047c7f27377e1a8b7ba86f7db4fa47aa49dc9a8e", size = 236820 }, 86 | { url = "https://files.pythonhosted.org/packages/22/4f/9b65332326b0c5b7de197a52e766e2bd547beec6948e1d5c4063289e3281/coverage-7.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:39abcacd1ed54e2c33c54bdc488b310e8ef6705833f7148b6eb9a547199d375d", size = 237800 }, 87 | { url = "https://files.pythonhosted.org/packages/bb/99/1c2214678731517d91774b75ed5c0f72feefee3270c232c286b314518d7d/coverage-7.7.0-cp39-cp39-win32.whl", hash = "sha256:8e336b56301774ace6be0017ff85c3566c556d938359b61b840796a0202f805c", size = 213341 }, 88 | { url = "https://files.pythonhosted.org/packages/21/30/4d9ae5544f839da30e42e03850d1dfe4ab184d6307ed971e70178760a68d/coverage-7.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:8c938c6ae59be67ac19a7204e079efc94b38222cd7d0269f96e45e18cddeaa59", size = 214227 }, 89 | { url = "https://files.pythonhosted.org/packages/cb/69/6a5eac32d2e8721274ef75df1b9fd6a8f7e8231e41ff7bc5501f19835f25/coverage-7.7.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:3b0e6e54591ae0d7427def8a4d40fca99df6b899d10354bab73cd5609807261c", size = 202813 }, 90 | { url = "https://files.pythonhosted.org/packages/2a/ac/60f409a448e5b0e9b8539716f683568aa5848c1be903cdbbc805a552cdf8/coverage-7.7.0-py3-none-any.whl", hash = "sha256:708f0a1105ef2b11c79ed54ed31f17e6325ac936501fc373f24be3e6a578146a", size = 202803 }, 91 | ] 92 | 93 | [package.optional-dependencies] 94 | toml = [ 95 | { name = "tomli", marker = "python_full_version <= '3.11'" }, 96 | ] 97 | 98 | [[package]] 99 | name = "distlib" 100 | version = "0.3.9" 101 | source = { registry = "https://pypi.org/simple" } 102 | sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } 103 | wheels = [ 104 | { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, 105 | ] 106 | 107 | [[package]] 108 | name = "exceptiongroup" 109 | version = "1.2.2" 110 | source = { registry = "https://pypi.org/simple" } 111 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 112 | wheels = [ 113 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 114 | ] 115 | 116 | [[package]] 117 | name = "filelock" 118 | version = "3.18.0" 119 | source = { registry = "https://pypi.org/simple" } 120 | sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } 121 | wheels = [ 122 | { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, 123 | ] 124 | 125 | [[package]] 126 | name = "freezegun" 127 | version = "1.5.1" 128 | source = { registry = "https://pypi.org/simple" } 129 | dependencies = [ 130 | { name = "python-dateutil" }, 131 | ] 132 | sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 } 133 | wheels = [ 134 | { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 }, 135 | ] 136 | 137 | [[package]] 138 | name = "identify" 139 | version = "2.6.9" 140 | source = { registry = "https://pypi.org/simple" } 141 | sdist = { url = "https://files.pythonhosted.org/packages/9b/98/a71ab060daec766acc30fb47dfca219d03de34a70d616a79a38c6066c5bf/identify-2.6.9.tar.gz", hash = "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf", size = 99249 } 142 | wheels = [ 143 | { url = "https://files.pythonhosted.org/packages/07/ce/0845144ed1f0e25db5e7a79c2354c1da4b5ce392b8966449d5db8dca18f1/identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150", size = 99101 }, 144 | ] 145 | 146 | [[package]] 147 | name = "iniconfig" 148 | version = "2.0.0" 149 | source = { registry = "https://pypi.org/simple" } 150 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 151 | wheels = [ 152 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 153 | ] 154 | 155 | [[package]] 156 | name = "kanban-python" 157 | version = "0.6.0" 158 | source = { editable = "." } 159 | dependencies = [ 160 | { name = "platformdirs" }, 161 | { name = "rich" }, 162 | ] 163 | 164 | [package.dev-dependencies] 165 | dev = [ 166 | { name = "freezegun" }, 167 | { name = "pre-commit" }, 168 | { name = "pytest" }, 169 | { name = "pytest-cov" }, 170 | ] 171 | 172 | [package.metadata] 173 | requires-dist = [ 174 | { name = "platformdirs", specifier = ">=4.3.6" }, 175 | { name = "rich", specifier = ">=13.9.4" }, 176 | ] 177 | 178 | [package.metadata.requires-dev] 179 | dev = [ 180 | { name = "freezegun", specifier = ">=1.5.1" }, 181 | { name = "pre-commit", specifier = ">=4.0.1" }, 182 | { name = "pytest", specifier = ">=8.3.3" }, 183 | { name = "pytest-cov", specifier = ">=6.0.0" }, 184 | ] 185 | 186 | [[package]] 187 | name = "markdown-it-py" 188 | version = "3.0.0" 189 | source = { registry = "https://pypi.org/simple" } 190 | dependencies = [ 191 | { name = "mdurl" }, 192 | ] 193 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 194 | wheels = [ 195 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 196 | ] 197 | 198 | [[package]] 199 | name = "mdurl" 200 | version = "0.1.2" 201 | source = { registry = "https://pypi.org/simple" } 202 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 203 | wheels = [ 204 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 205 | ] 206 | 207 | [[package]] 208 | name = "nodeenv" 209 | version = "1.9.1" 210 | source = { registry = "https://pypi.org/simple" } 211 | sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } 212 | wheels = [ 213 | { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, 214 | ] 215 | 216 | [[package]] 217 | name = "packaging" 218 | version = "24.2" 219 | source = { registry = "https://pypi.org/simple" } 220 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 221 | wheels = [ 222 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 223 | ] 224 | 225 | [[package]] 226 | name = "platformdirs" 227 | version = "4.3.6" 228 | source = { registry = "https://pypi.org/simple" } 229 | sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } 230 | wheels = [ 231 | { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, 232 | ] 233 | 234 | [[package]] 235 | name = "pluggy" 236 | version = "1.5.0" 237 | source = { registry = "https://pypi.org/simple" } 238 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 239 | wheels = [ 240 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 241 | ] 242 | 243 | [[package]] 244 | name = "pre-commit" 245 | version = "4.2.0" 246 | source = { registry = "https://pypi.org/simple" } 247 | dependencies = [ 248 | { name = "cfgv" }, 249 | { name = "identify" }, 250 | { name = "nodeenv" }, 251 | { name = "pyyaml" }, 252 | { name = "virtualenv" }, 253 | ] 254 | sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } 255 | wheels = [ 256 | { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, 257 | ] 258 | 259 | [[package]] 260 | name = "pygments" 261 | version = "2.19.1" 262 | source = { registry = "https://pypi.org/simple" } 263 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } 264 | wheels = [ 265 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, 266 | ] 267 | 268 | [[package]] 269 | name = "pytest" 270 | version = "8.3.5" 271 | source = { registry = "https://pypi.org/simple" } 272 | dependencies = [ 273 | { name = "colorama", marker = "sys_platform == 'win32'" }, 274 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 275 | { name = "iniconfig" }, 276 | { name = "packaging" }, 277 | { name = "pluggy" }, 278 | { name = "tomli", marker = "python_full_version < '3.11'" }, 279 | ] 280 | sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } 281 | wheels = [ 282 | { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, 283 | ] 284 | 285 | [[package]] 286 | name = "pytest-cov" 287 | version = "6.0.0" 288 | source = { registry = "https://pypi.org/simple" } 289 | dependencies = [ 290 | { name = "coverage", extra = ["toml"] }, 291 | { name = "pytest" }, 292 | ] 293 | sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } 294 | wheels = [ 295 | { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, 296 | ] 297 | 298 | [[package]] 299 | name = "python-dateutil" 300 | version = "2.9.0.post0" 301 | source = { registry = "https://pypi.org/simple" } 302 | dependencies = [ 303 | { name = "six" }, 304 | ] 305 | sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } 306 | wheels = [ 307 | { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, 308 | ] 309 | 310 | [[package]] 311 | name = "pyyaml" 312 | version = "6.0.2" 313 | source = { registry = "https://pypi.org/simple" } 314 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 315 | wheels = [ 316 | { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, 317 | { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, 318 | { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, 319 | { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, 320 | { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, 321 | { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, 322 | { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, 323 | { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, 324 | { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, 325 | { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, 326 | { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, 327 | { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, 328 | { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, 329 | { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, 330 | { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, 331 | { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, 332 | { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, 333 | { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, 334 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, 335 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, 336 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, 337 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, 338 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, 339 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, 340 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, 341 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, 342 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, 343 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, 344 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, 345 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, 346 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, 347 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, 348 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, 349 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, 350 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, 351 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, 352 | { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, 353 | { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, 354 | { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, 355 | { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, 356 | { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, 357 | { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, 358 | { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, 359 | { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, 360 | { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, 361 | ] 362 | 363 | [[package]] 364 | name = "rich" 365 | version = "13.9.4" 366 | source = { registry = "https://pypi.org/simple" } 367 | dependencies = [ 368 | { name = "markdown-it-py" }, 369 | { name = "pygments" }, 370 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 371 | ] 372 | sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } 373 | wheels = [ 374 | { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, 375 | ] 376 | 377 | [[package]] 378 | name = "six" 379 | version = "1.17.0" 380 | source = { registry = "https://pypi.org/simple" } 381 | sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } 382 | wheels = [ 383 | { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, 384 | ] 385 | 386 | [[package]] 387 | name = "tomli" 388 | version = "2.2.1" 389 | source = { registry = "https://pypi.org/simple" } 390 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } 391 | wheels = [ 392 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, 393 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, 394 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, 395 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, 396 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, 397 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, 398 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, 399 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, 400 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, 401 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, 402 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, 403 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, 404 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, 405 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, 406 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, 407 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, 408 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, 409 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, 410 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, 411 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, 412 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, 413 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, 414 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, 415 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, 416 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, 417 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, 418 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, 419 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, 420 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, 421 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, 422 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, 423 | ] 424 | 425 | [[package]] 426 | name = "typing-extensions" 427 | version = "4.12.2" 428 | source = { registry = "https://pypi.org/simple" } 429 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 430 | wheels = [ 431 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 432 | ] 433 | 434 | [[package]] 435 | name = "virtualenv" 436 | version = "20.29.3" 437 | source = { registry = "https://pypi.org/simple" } 438 | dependencies = [ 439 | { name = "distlib" }, 440 | { name = "filelock" }, 441 | { name = "platformdirs" }, 442 | ] 443 | sdist = { url = "https://files.pythonhosted.org/packages/c7/9c/57d19fa093bcf5ac61a48087dd44d00655f85421d1aa9722f8befbf3f40a/virtualenv-20.29.3.tar.gz", hash = "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac", size = 4320280 } 444 | wheels = [ 445 | { url = "https://files.pythonhosted.org/packages/c2/eb/c6db6e3001d58c6a9e67c74bb7b4206767caa3ccc28c6b9eaf4c23fb4e34/virtualenv-20.29.3-py3-none-any.whl", hash = "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170", size = 4301458 }, 446 | ] 447 | --------------------------------------------------------------------------------