pax_global_header00006660000000000000000000000064141555204540014517gustar00rootroot0000000000000052 comment=0293da20bd6d60444c3a64114e62fc808b31ab73 todoman-4.1.0/000077500000000000000000000000001415552045400131625ustar00rootroot00000000000000todoman-4.1.0/.builds/000077500000000000000000000000001415552045400145225ustar00rootroot00000000000000todoman-4.1.0/.builds/archlinux.yaml000066400000000000000000000014241415552045400174040ustar00rootroot00000000000000image: archlinux packages: - python-pip - python-tox - python-wheel - python-pre-commit - twine sources: - https://github.com/pimutils/todoman secrets: - 9a8d4d44-96f9-4365-beaa-aaa759c4f1c4 environment: CODECOV_TOKEN: a4471483-7f55-411a-bf2f-f65a91013dc4 CI: true tasks: - setup: | sudo pip install codecov - test: | # Test without pyicu installed: cd todoman tox -e py codecov - test-pyicu: | # Test with pyicu installed: cd todoman TOXENV=pyicu tox codecov - test-repl: | # Test repl repl: cd todoman TOXENV=repl tox codecov git describe --exact-match --tags || complete-build - publish: | cd todoman python setup.py sdist bdist_wheel twine upload dist/* todoman-4.1.0/.builds/py38.yaml000066400000000000000000000010721415552045400162110ustar00rootroot00000000000000image: alpine/3.13 packages: - py-pip - py-tox - icu-dev - alpine-sdk - python3-dev sources: - https://github.com/pimutils/todoman environment: CODECOV_TOKEN: a4471483-7f55-411a-bf2f-f65a91013dc4 CI: true tasks: - setup: | sudo pip install codecov - test: | # Test without pyicu installed: cd todoman tox -e py codecov - test-pyicu: | # Test with pyicu installed: cd todoman TOXENV=pyicu tox codecov - test-repl: | # Test repl repl: cd todoman TOXENV=repl tox codecov todoman-4.1.0/.gitignore000066400000000000000000000002111415552045400151440ustar00rootroot00000000000000*.sw? *.pyc __pycache__ env build dist todoman.egg-info/ .eggs .cache .tox docs/_build/ .coverage .hypothesis *.tmp todoman/version.py todoman-4.1.0/.kodiak.toml000066400000000000000000000001211415552045400153710ustar00rootroot00000000000000# .kodiak.toml # Minimal config. version is the only required field. version = 1 todoman-4.1.0/.pre-commit-config.yaml000066400000000000000000000021251415552045400174430ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: check-toml - id: check-added-large-files - id: debug-statements - repo: https://github.com/pycqa/isort rev: 5.10.1 hooks: - id: isort name: isort (python) - repo: https://github.com/psf/black rev: "21.12b0" hooks: - id: black - repo: https://github.com/PyCQA/flake8 rev: "4.0.1" # pick a git hash / tag to point to hooks: - id: flake8 additional_dependencies: - flake8-comprehensions - flake8-bugbear - repo: https://github.com/asottile/pyupgrade rev: v2.29.1 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/pre-commit/mirrors-mypy rev: "v0.910-1" hooks: - id: mypy additional_dependencies: - types-atomicwrites - types-tabulate - types-freezegun - types-pytz - types-python-dateutil todoman-4.1.0/AUTHORS.rst000066400000000000000000000022721415552045400150440ustar00rootroot00000000000000Authors ======= All contributors retain copyright to their contributions. Below is a list of authors who've either contributed code to the project, or who wrote code elsewhere that we reused here. We manually track this since commit author *does not* equal code author (eg: when copying code from elsewhere). Authors are listed in alphabetical order. * Anubha Agrawal * Ben Boeckel * Ben Moran * Benjamin Frank * Christian Geier * Doron Behar * Guilhem Saurel * Hugo Osvaldo Barrera * Joachim Desroches * José Ribeiro * Mansimar Kaur * Markus Unterwaditzer * Nicolas Évrard * Pascal Wichmann * Paweł Fertyk * Rimsha Khan * Sakshi Saraswat * Stephan Weller * Swati Garg * Thomas Glanzmann * https://github.com/Pikrass todoman-4.1.0/CHANGELOG.rst000066400000000000000000000201611415552045400152030ustar00rootroot00000000000000Changelog ========= This file contains a brief summary of new features and dependency changes or releases, in reverse chronological order. v4.1.0 ------ * The "table" layout has been dropped in favour of a simpler, fluid layout. As such, ``tabulate`` is not longer a required dependency. * Added support for python 3.10. v4.0.1 ------ * Fix issue passing arguments to ``default_command``. * Various documentation improvements. v4.0.0 ------ Breaking changes in the configuration format ******************************************** The configuration format is changing with release 4.0.0. We currently depend on an unmaintained library for configuration. It's not currently in a working state, and while some distributions are patching it, setting up a clean environment is a bit non-trivial, and the situation will only degrade in future. The changes in format are be subtle, and also come with an intention to add further extensibility in future. Configuration files will be plain python. If you don't know Python don't worry, you don't _need_ to know Python. I'll take my own config as a reference. The pre-4.0.0 format is: ```dosini [main] path = ~/.local/share/calendars/* time_format = '%H:%M' default_list = todo humanize = true startable = true ``` The 4.0.0 version would look like this: ```python path = "~/.local/share/calendars/*" time_format = "%H:%M" default_list = "todo" humanize = True startable = True ``` Key differences: - The `[main]` header is no longer needed. - All strings must be quoted (this was previously optional). - True and False start with uppercase. - Using `yes` or `on` is no longer valid; only `True` and `False` are valid. That's basically it. This lets up drop the problematic dependency, and we don't actually need anything to read the config: it's just python code like the rest of `todoman`! For those users who _are_ python developers, you'll note this gives some interesting flexibility: you CAN add any custom python code into the config file. For example, you can defined the `path` programatically: .. code-block:: python def get_path() -> str: ... path = get_path Dropped support *************** * Dropped support older Python versions. Only 3.8 and 3.9 are now supported. Minor changes ************* * Added support for python 3.9. * The dependency `configobj` is no longer required. * Click 8.0 is now supported. * Fix crash when ``default_command`` has arguments. v3.9.0 ------ * The man page has been improved. ``sphinx-click`` is now required to build the documentation. v3.8.0 ------ * Don't display list if there's only one list (of one list has been specified). * Fixed several issues when using dates with no times. * Dropped support for Python 3.4. v3.7.0 ------ * Properly support iCal files with dates (instead of datetimes). v3.6.0 ------ * Allow passing a custom configuration file with the ``--config/-c`` option. * Cached list metadata is now invalidated when it has changed on-disk. * Support for click < 6.0 has been dropped (it wasn't actually working perfectly any more anyway). Click 7.x is the only currently supported version. * ``click-repl`` is now listed as an optional dependency. It is required for the ``todo repl`` command. * Add the ``default_priority`` config setting. * Drop support for Python 3.4. v3.5.0 ------ * Fix crashes due to API changes in icalendar 4.0.3. * Dropped compatibility for icalendar < 4.0.3. v3.4.1 ------ * Support Python 3.7. * Support click>=7.0. * Properly parse RBGA colours. v3.4.0 ------ * Add ``-r`` option to ``new`` to read description from ``stdin``. * Add a dedicated zsh completion function. * Lists matching is now case insensitive, unless there are multiple lists with colliding names, in which case those will be treated case-sensitive. * Fix some tests that failed due to test dependency changes. v3.3.0 ------ * New runtime dependency: ``click-log``. * Drop support for Python 3.3, which has reached its end of life cycle. * Add `--raw` flag to `edit`. This allows editing the raw icalendar file, but **only use this if you really know what you're doing**. There's a big risk of data loss, and this is considered a developer / expert feature! v3.2.4 ------ * Deploy new versions to PyPI using ``twine``. Travis doesn't seem to be working. v3.2.3 ------ * Tests should no longer fail with ``pyicu`` installed. * Improved documentation regarding how to test locally. v3.2.2 ------ * Initial support for (bash) autocompletion. * The location field is not printed as part of ``--porcelain``. v3.2.1 ------ * Fix start-up crash caused by click_log interface change. * Dropped runtime dependency: ``click_log``. v3.2.0 ------ * Completing recurring todos now works as expected and does not make if disappear forever. v3.1.0 ------ * Last-modified fields of todos are now updated upon edition. * Sequence numbers are now properly increased upon edition. * Add new command ``todo cancel`` to cancel an existing todo without deleting it. * Add a new setting ``default_command``. * Replace ``--all`` and ``--done-only`` with ``--status``, which allows fine-grained status filtering. Use ``--status ANY`` or ``--status COMPLETED`` to obtain the same results as the previous flags. * Rename ``--today`` flag to ``--startable``. * Illegal start dates (eg: start dates that are not before the due date) are ignored and are removed when saving an edited todo. v3.0.1 ------ * Fix a crash for users upgrading from pre-v3.0.0, caused due to the cache's schema not being updated. v3.0.0 ------ New features ************ * Add a ``today`` setting and flag to exclude todos that start in the future. * Add the ``--humanize`` to show friendlier date times (eg: ``in 3 hours``). * Drop ``--urgent`` and introduced ``--priority``, which allows fine-filtering by priority. * Add support for times in due dates, new ``time_format`` setting. * Use the system's date format as a default. * Add list selector to the interactive editor. * Add ``--start=[before|after] [DATE]`` option for ``list`` to only show todos starting before/after given date. * Add flag "--done-only" to todo list. Displays only completed tasks. * Make the output of move, delete, copy and flush consistent. * Porcelain now outputs proper JSON, rather than one-JSON-per-line. * Increment sequence number upon edits. * Print a descriptive message when no lists are found. * Add full support for locations. Packaging changes ***************** * New runtime dependency: ``tabulate``. * New runtime dependency: ``humanize``. * New supported python version: ``pypy3``. * Include an alternative [much faster] entry point (aka "bin") which we recommend all downstream packagers use. Please see the :ref:`Notes for Packagers ` documentation for further details. v2.1.0 ------ * The global ``--verbosity`` option has been introduced. It doesn't do much for now though, because we do not have many debug logging statements. * New PyPI dependency ``click-log``. * The ``--no-human-time`` flag is gone. Integrations/scripts might want to look at ``--porcelain`` as an alternative. * Fix crash when running ``todo new``. * Fixes some issues when filtering todos from different timezones. * Attempt to create the cache file's directory if it does not exist. * Fix crash when running ``--porcelain show``. * Show ``id`` for todos everywhere (eg: including new, etc). * Add the ``ctrl-s`` shortcut for saving in the interactive editor. v2.0.2 ------ * Fix a crash after editing or completing a todo. v2.0.1 ------ * Fix a packaging error. v2.0.0 ------ New features ~~~~~~~~~~~~ * New flag ``--porcelain`` for programmatic integrations to use. See the ``integrations`` section :doc:`here ` for details. * Implement a new :doc:`configuration option `: ``default_due``. * The configuration file is now pre-emptively validated. Users will be warned of any inconsistencies. * The ``list`` command has a new ``--due`` flag to filter tasks due soon. * Todo ids are now persisted in a cache. They can be manually purged using ``flush``. Packaging changes ~~~~~~~~~~~~~~~~~ * New runtime dependency: configobj * New runtime dependency: python-dateutil * New test dependency: flake8-import-order. todoman-4.1.0/CODE_OF_CONDUCT.rst000066400000000000000000000000631415552045400161700ustar00rootroot00000000000000See `the pimutils CoC `_. todoman-4.1.0/LICENCE000066400000000000000000000013721415552045400141520ustar00rootroot00000000000000Copyright (c) 2015-2020, Hugo Osvaldo Barrera Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. todoman-4.1.0/README.rst000066400000000000000000000043541415552045400146570ustar00rootroot00000000000000Todoman ======= .. image:: https://builds.sr.ht/~whynothugo/todoman.svg :target: https://builds.sr.ht/~whynothugo/todoman :alt: CI status .. image:: https://codecov.io/gh/pimutils/todoman/branch/main/graph/badge.svg :target: https://codecov.io/gh/pimutils/todoman :alt: Codecov coverage report .. image:: https://readthedocs.org/projects/todoman/badge/ :target: https://todoman.rtfd.org/ :alt: documentation .. image:: https://img.shields.io/pypi/v/todoman.svg :target: https://pypi.python.org/pypi/todoman :alt: version on pypi .. image:: https://img.shields.io/pypi/l/todoman.svg :target: https://github.com/pimutils/todoman/blob/main/LICENCE :alt: licence .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://pypi.org/project/black/ :alt: code style: black Todoman is a simple, standards-based, cli todo (aka: task) manager. Todos are stored into `icalendar `_ files, which means you can sync them via `CalDAV `_ using, for example, `vdirsyncer `_. Todoman is now part of the ``pimutils`` project, and is hosted at `GitHub `_. Todoman should run fine on any Unix-like OS. It's been tested on GNU/Linux, BSD, and macOS. We do not support windows, and very basic testing seems to indicate it does not work. Feel free to join the IRC channel: #pimutils on irc.libera.chat. Features -------- * Listing, editing and creating todos. * Todos are read from individual ics files from the configured directory. This matches the `vdir `_ specification. * There's support for the most common TODO features for now (summary, description, location, due date and priority). * Todoman should run on any major operating system (except Windows). * Unsupported fields may not be shown but are *never* deleted or altered. Documentation ------------- For detailed usage, configuration and contributing documentation, please consult the latest version of the manual at readthedocs_. .. _readthedocs: https://todoman.readthedocs.org/ LICENCE ------- Todoman is licensed under the ISC licence. See LICENCE for details. todoman-4.1.0/bin/000077500000000000000000000000001415552045400137325ustar00rootroot00000000000000todoman-4.1.0/bin/todo000077500000000000000000000001301415552045400146170ustar00rootroot00000000000000#!/usr/bin/env python from todoman.cli import cli if __name__ == "__main__": cli() todoman-4.1.0/codecov.yml000066400000000000000000000000151415552045400153230ustar00rootroot00000000000000comment: off todoman-4.1.0/config.py.sample000066400000000000000000000002721415552045400162620ustar00rootroot00000000000000# A glob expression which matches all directories relevant. path = "~/.local/share/calendars/*" date_format = "%Y-%m-%d" time_format = "%H:%M" default_list = "Personal" default_due = 48 todoman-4.1.0/contrib/000077500000000000000000000000001415552045400146225ustar00rootroot00000000000000todoman-4.1.0/contrib/completion/000077500000000000000000000000001415552045400167735ustar00rootroot00000000000000todoman-4.1.0/contrib/completion/bash/000077500000000000000000000000001415552045400177105ustar00rootroot00000000000000todoman-4.1.0/contrib/completion/bash/_todo000066400000000000000000000046501415552045400207440ustar00rootroot00000000000000_todo_get_ids() { local pattern='^\s*"id": \([0-9]\+\),' todo --porcelain list | grep -o "${pattern}" | sed -e "s/${pattern}/\1/" | sort } _todo_get_lists() { local pattern='^\s*"list": "\(.*\)",' todo --porcelain list | grep -o "${pattern}" | sed -e "s/${pattern}/\1/" | sort | uniq } _todo_get_locations() { local pattern='^\s*"location": \(.*\)\+,' todo --porcelain list | grep -o "${pattern}" | sed -e "s/${pattern}/\1/" | sort | uniq } _todo_single_dash_options() { local prev_word=$1 case $prev_word in copy) echo "-l" ;; edit) echo "-s -d -i" ;; list) echo "-s" ;; move) echo "-l" ;; new) echo "-l -s -d -i" ;; *) echo "-v -h" ;; esac } _todo_double_dash_options() { local prev_word=$1 case $prev_word in copy) echo "--list" ;; delete) echo " --yes" ;; edit) echo " --start --due --location --interactive" ;; flush) echo " --yes" ;; list) echo " --location --category --grep --sort --reverse --no-reverse --due --priority --start --startable --status" ;; move) echo " --list" ;; new) echo " --list --start --due --location --priority --interactive" ;; show) ;; *) echo " --verbosity --color --colour --porcelain --humanize --version" ;; esac echo " --help" } _todo_complete() { local cur_word prev_word arg_list cur_word="${COMP_WORDS[COMP_CWORD]}" prev_word="${COMP_WORDS[COMP_CWORD-1]}" arg_list="" if [[ ${cur_word} == --* ]] ; then arg_list="$(_todo_double_dash_options ${prev_word})" elif [[ ${cur_word} == -* ]] ; then arg_list="$(_todo_single_dash_options ${prev_word}) $(_todo_double_dash_options ${prev_word})" else case ${prev_word} in -v|--verbosity) arg_list="CRITICAL ERROR WARNING INFO DEBUG" ;; --color|--colour) arg_list="always auto never" ;; --list) arg_list="$(_todo_get_lists)" ;; --location) arg_list="$(_todo_get_locations)" ;; --priority) arg_list="none low medium high" ;; -s|--start|-d|--due) arg_list="" ;; --help) arg_list="" ;; cancel|copy|delete|done|edit|move|show) arg_list="$(_todo_get_ids)" ;; flush) arg_list="" ;; list) arg_list="$(_todo_get_lists)" ;; new) arg_list="" ;; *) arg_list="cancel copy delete done edit flush list move new show" ;; esac fi COMPREPLY=( $(compgen -W "${arg_list}" -- ${cur_word}) ) return 0 } complete -F _todo_complete todo todoman-4.1.0/contrib/completion/zsh/000077500000000000000000000000001415552045400175775ustar00rootroot00000000000000todoman-4.1.0/contrib/completion/zsh/_todo000066400000000000000000000223741415552045400206360ustar00rootroot00000000000000#compdef todo # {{{ sub commands common options variables local common_options_help=( '(- :)--help[Show a help message and exit]' ) local common_options_start=( {-s,--start=}'[When the task starts]:DATE:__todo_date' ) local common_options_due=( {-d,--due=}'[When the task is due]:DATE:__todo_date' ) local common_options_priority=( '--priority=[The priority for this todo]:PRIORITY:("low" "medium" "high")' ) local common_options_interactive=( {-i,--interactive}'[Go into interactive mode before saving the task]' ) local common_options_location=( '--location=[The location where this todo takes place]:LOCATION:' ) # }}} # {{{ option helper: color mode __color_mode(){ local modes=( "always:enable regardless of stdout" "auto:enable only when not on tty (default)" "never:disable colored output entirely" ) _describe "mode" modes } # }}} # {{{ general helper: set variable of path to configuration file __todo_set_conf(){ todoman_configuration_file=${XDG_CONFIG_DIR:-${HOME}/.config}/todoman/todoman.conf if [[ -f $todoman_configuration_file ]]; then return 0 else return 1 fi } # }}} # {{{ general helper: set variable main.path from configuration file __todo_set_conf_path(){ if __todo_set_conf; then tasks_lists_path="$(sed -n -e 's/^[^#]\s*path\s*=\s*\(.*\)$/\1/p' $todoman_configuration_file 2>/dev/null)" # the eval echo is needed since the path may contain ~ which should be evalueated to $HOME tasks_lists_dir="$(eval echo ${tasks_lists_path%/\**})" if [[ -z "${tasks_lists_path}" || ! -d "${tasks_lists_dir}" ]]; then return 1 else return 0 fi else return 1 fi } # }}} # {{{ general helper: set variables related to date and time formats for __todo_date __todo_set_conf_dt(){ if __todo_set_conf; then date_format="$(eval echo $(sed -n -e 's/^[^#]\s*date_format\s*=\s*\(.*\)$/\1/p' $todoman_configuration_file 2>/dev/null))" dt_separator="$(eval echo $(sed -n -e 's/^[^#]\s*dt_separator\s*=\s*\(.*\)$/\1/p' $todoman_configuration_file 2>/dev/null))" time_format="$(eval echo $(sed -n -e 's/^[^#]\s*time_format\s*=\s*\(.*\)$/\1/p' $todoman_configuration_file 2>/dev/null))" # default value according to documentation: https://todoman.readthedocs.io/en/stable/configure.html if [[ -z "${date_format}" ]]; then date_format="%x" fi if [[ -z "${dt_separator}" ]]; then dt_separator="" fi if [[ -z "${time_format}" ]]; then time_format="%x" fi return 0 else return 1 fi } # }}} # {{{ option helper: due and start date __todo_date(){ if __todo_set_conf_dt; then _message "date in format ${date_format//\%/%%}${dt_separator//\%/%%}${time_format//\%/%%}" else _message "date format (couldn't read configuration file and extract date and time formats)" fi } # }}} # {{{ argument helper: sub-command choice __todo_command(){ local commands=( 'cancel:Cancel one or more tasks' 'copy:Copy tasks to another list' 'delete:Delete tasks' 'done:Mark one or more tasks as done' 'edit:Edit the task with id ID' 'flush:Delete done tasks' 'list:List tasks' 'move:Move tasks to another list' 'new:Create a new task with SUMMARY' 'show:Show details about a task' ) _describe "command" commands } # }}} # {{{ argument helper: available tasks choice __todo_tasks(){ # checking if the command jq exists and it's version # credit: http://stackoverflow.com/a/592649/4935114 jq_version=$(jq --version 2>/dev/null) if [ ${${jq_version#jq\-}//./} -lt 15 ]; then _message "we can't complete tasks unless you'll install the latest version of jq: https://stedolan.github.io/jq/" return fi # $1 is a comma-seperated list of statuses to show when trying to complete this local status_search_query="$1" local -a tasks IFS=$'\n' for task_and_description in $(todo --porcelain list --status "${status_search_query}" | jq --raw-output '.[] | .id,":\"@",.list," ",.summary,"\"\\0"' | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n//g' -e 's/\\0/\n/g'); do tasks+="$(eval echo ${task_and_description})" done _describe tasks tasks } # }}} # {{{ todo available lists cache policy __todo_lists_cache_policy(){ # the number of seconds since 1970-01-01 the directory local tasks_lists_dir_last_date_modified="$(date -r ${tasks_lists_dir} +%s 2>/dev/null)" # the number of seconds since 1970-01-01 the cache file was modified local cache_last_date_modified="$(date -r $1 +%s 2>/dev/null)" if [[ ! -z ${cache_last_date_modified} && ! -z ${tasks_lists_dir_last_date_modified} ]]; then # if the manifest file is newer then the cache: if [ ${tasks_lists_dir_last_date_modified} -ge ${cache_last_date_modified} ]; then (( 1 )) else (( 0 )) fi else (( 1 )) fi } # }}} # {{{ option helper: available lists __todo_lists(){ if __todo_set_conf_path; then local update_policy zstyle -s ":completion:${curcontext}:" cache-policy update_policy if [[ -z "$update_policy" ]]; then zstyle ":completion:${curcontext}:" cache-policy __todo_lists_cache_policy fi local -a tasks_lists if _cache_invalid todoman_lists; then if [[ ${tasks_lists_path} =~ '/*$' ]]; then for dir in $(eval echo ${tasks_lists_path}); do if grep "VTODO" -q -R "${dir}"; then list_name="${dir##*/}" tasks_lists+=("${list_name}") fi done fi _store_cache todoman_lists tasks_lists else _retrieve_cache todoman_lists fi if [[ "${#tasks_lists[@]}" == 1 ]]; then _message "only one list was detected: (\"${tasks_lists[1]}\")" return else _describe "available lists" tasks_lists return fi else _message -e "no 'path = ' string was found in todoman's default configuration file ($todoman_configuration_file)" return fi } # }}} # {{{ command `cancel` _todo_cancel(){ _arguments \ "${common_options_help[@]}" \ '*: :{__todo_tasks "IN-PROCESS,NEEDS-ACTION"}' } # }}} # {{{ command `copy` local _command_copy_options=( "${common_options_help[@]}" {-l,--list=}'[The list to copy the tasks to]:TEXT:__todo_lists' ) _todo_copy(){ _arguments \ "${_command_copy_options[@]}" \ '*: :{__todo_tasks "IN-PROCESS,NEEDS-ACTION"}' } # }}} # {{{ command `delete` local _command_delete_options=( "${common_options_help[@]}" "--yes[Don't ask for permission before deleting]" ) _todo_delete(){ _arguments \ "${_command_delete_options[@]}" \ '*: :{__todo_tasks "IN-PROCESS,NEEDS-ACTION"}' } # }}} # {{{ command `done` local _command_done_options=( "${common_options_help[@]}" ) _todo_done(){ _arguments \ "${_command_done_options[@]}" \ '*: :{__todo_tasks "IN-PROCESS,NEEDS-ACTION"}' } # }}} # {{{ command `edit` local _command_edit_options=( "${common_options_help[@]}" "${common_options_start[@]}" "${common_options_due[@]}" "${common_options_priority[@]}" "${common_options_location[@]}" "${common_options_interactive[@]}" ) _todo_edit(){ _arguments \ "${_command_edit_options[@]}" \ '*: :{__todo_tasks "IN-PROCESS,NEEDS-ACTION"}' } # }}} # {{{ command `flush` _todo_flush(){ } # }}} # {{{ command `list` _command_list_options=( "${common_options_location[@]}" '--category=[Only show tasks with category containg TEXT]:TEXT:__todo_existing_categories' '--grep=[Only show tasks with message containg TEXT]:TEXT:' '--sort=[Sort tasks using these fields]:TEXT:(description location status summary uid rrule percent_complete priority sequence categories completed_at created_at dtstamp start due last_modified)' '(--reverse --no-reverse)'{--reverse,--no-reverse}'[sort tasks in reverse order (see --sort)]' "${common_options_start[@]}" "${common_options_due[@]}" '--priority[Only show tasks with priority at least as high as TEXT]:TEXT:("low", "medium", "high")' '--startable[Show only todos which should can be started today]' {-s,--status=}'[Show only todos with the provided comma-separated statuses]:STATUS:{_values -s , "status" "NEEDS-ACTION" "CANCELLED" "COMPLETED" "IN-PROCESS" "ANY"}' "${common_options_help[@]}" ) _todo_list(){ _arguments \ "${_command_list_options[@]}" \ '1: :__todo_lists' \ } # }}} # {{{ command `move` _todo_move(){ _todo_copy } # }}} # {{{ command `new` local _command_new_options=( "${common_options_start[@]}" "${common_options_due[@]}" "${common_options_help[@]}" {-l,--list=}'[The list to move the tasks to]:TEXT:__todo_lists' '--location[The location where this todo takes place.]:TEXT:__todo_existing_locations' "${common_options_priority[@]}" "${common_options_interactive[@]}" ) _todo_new(){ _arguments \ "${_command_new_options[@]}" \ '*: :{_message "summary"}' } # }}} # {{{ command `show` _todo_show(){ _todo_done } # }}} # The real thing _arguments -C -A "-*" \ {-v,--verbosity=}'[Set verbosity to the given level]:MODE(CRITICAL ERROR WARNING INFO DEBUG)' \ '--color=[Set colored output mode]:MODE:__color_mode' \ '--porcelain[Use a JSON format that will remain stable regadless of configuration or version]' \ {-h,--humanize}'[Format all dates and times in a human friendly way]' \ '(- :)--version[Show the version and exit]' \ "${common_options_help[@]}" \ '1: :__todo_command' \ '*::arg:->args' case $state in (args) curcontext="${curcontext%:*:*}:todo_$words[1]:" case "${words[1]}" in cancel) _todo_cancel ;; copy) _todo_copy ;; delete) _todo_delete ;; done) _todo_done ;; edit) _todo_edit ;; flush) _todo_flush ;; list) _todo_list ;; move) _todo_move ;; new) _todo_new ;; show) _todo_show ;; esac ;; esac todoman-4.1.0/docs/000077500000000000000000000000001415552045400141125ustar00rootroot00000000000000todoman-4.1.0/docs/Makefile000066400000000000000000000163761415552045400155670ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Todoman.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Todoman.qhc" applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Todoman" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Todoman" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." todoman-4.1.0/docs/pull_request_template.md000066400000000000000000000006261415552045400210570ustar00rootroot00000000000000 todoman-4.1.0/docs/source/000077500000000000000000000000001415552045400154125ustar00rootroot00000000000000todoman-4.1.0/docs/source/changelog.rst000066400000000000000000000000411415552045400200660ustar00rootroot00000000000000.. include:: ../../CHANGELOG.rst todoman-4.1.0/docs/source/conf.py000066400000000000000000000043521415552045400167150ustar00rootroot00000000000000#!/usr/bin/env python3 import todoman from todoman.configuration import CONFIG_SPEC from todoman.configuration import NO_DEFAULT # -- Generate confspec.rst ---------------------------------------------- def confspec_rst(): """Generator that returns lines for the confspec doc page.""" for name, type_, default, description, _validation in sorted(CONFIG_SPEC): if default == NO_DEFAULT: formatted_default = "None, this field is mandatory." elif isinstance(default, str): formatted_default = f'``"{default}"``' else: formatted_default = f"``{default}``" yield f"\n.. _main-{name}:" yield f"\n\n.. object:: {name}\n" yield " " + "\n ".join(line for line in description.splitlines()) yield "\n\n" if isinstance(type_, tuple): yield f" :type: {type_[0].__name__}" else: yield f" :type: {type_.__name__}" yield f"\n :default: {formatted_default}\n" with open("confspec.tmp", "w") as file_: file_.writelines(confspec_rst()) # -- General configuration ------------------------------------------------ extensions = [ "sphinx_click.ext", "sphinx.ext.autodoc", "sphinx_autorun", "sphinx.ext.todo", "sphinx.ext.viewcode", "sphinx_rtd_theme", ] source_suffix = ".rst" master_doc = "index" project = "Todoman" copyright = "2015-2020, Hugo Osvaldo Barrera" author = "Hugo Osvaldo Barrera , et al" # The short X.Y version. version = todoman.__version__ # The full version, including alpha/beta/rc tags. release = todoman.__version__ # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True # -- Options for HTML output ---------------------------------------------- html_theme = "sphinx_rtd_theme" # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ( "man", "todo", "a simple, standards-based, cli todo manager", [author], 1, ) ] todoman-4.1.0/docs/source/configure.rst000066400000000000000000000027441415552045400201340ustar00rootroot00000000000000Configuring =========== You'll need to configure Todoman before the first usage, using its simple ini-like configuration file. Configuration File ------------------ The configuration file should be placed in ``$XDG_CONFIG_DIR/todoman/config.py``. ``$XDG_CONFIG_DIR`` defaults to ``~/.config`` is most situations, so this will generally be ``~/.config/todoman/config.py``. .. include:: confspec.tmp Sample configuration -------------------- The below example should serve as a reference. It will read ics files from any directory inside ``~/.local/share/calendars/``, uses the ISO-8601 date format, and set the due date for new todos in 48hs. .. literalinclude:: ../../config.py.sample :language: python Color and displayname --------------------- - You can set a color for each task list by creating a ``color`` file containing a color code in the hex format: ``#RRGGBB``. - A file named ``displayname`` indicates how the task list should be named and is needed when there are multiple directories sharing a name, e.g.: when using multiple $CloudInstances. The default is the directory name. See also `relevant documentation for the vdir format `_. Timezone -------- Todoman will use the system-wide configured timezone. If this doesn't work for you, you _may_ override the timezone by specifying the ``TZ`` environment variable. For instruction on changing your system's timezone, consult your distribution's documentation. todoman-4.1.0/docs/source/contributing.rst000066400000000000000000000116621415552045400206610ustar00rootroot00000000000000Contributing ============ Bug reports and code and documentation patches are greatly appreciated. You can also help by using the development version of todoman and reporting any bugs you might encounter `here `_. All participants must follow the pimutils `Code of Conduct `_. Before working on a new feature or a bug, please browse existing issues to see whether it has been previously discussed. If the change in question is a bigger one, it's always good to open a new issue to discuss it before your starting working on it. Hacking ~~~~~~~ Runtime dependencies are listed in ``setup.py``. We recommend that you use virtualenv to make sure that no additional dependencies are required without them being properly documented. Run ``pip install -e .`` to install todoman and its dependencies into a virtualenv. We use ``pre-commit`` to run style and convention checks. Run ``pre-commit install``` to install our git-hooks. These will check code style and inform you of any issues when attempting to commit. This will also run ``black`` to reformat code that may have any inconsistencies. Commits should follow `Git Commit Guidelines`_ whenever possible, including rewriting branch histories to remove any noise, and using a 50-message imperative present tense for commit summary messages. All commits should pass all tests to facilitate bisecting in future. .. _Git Commit Guidelines: https://www.git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines An overview of the Todo lifecycle --------------------------------- This is a brief overview of the life cycles of todos (from the apps point of view) as they are read from disk, displayed, and or saved again. When the app starts, it will read all todos from disk, and initialize from the cache any further display (either ``list``, ``show``, ``edit``, etc) is then done reading from the cache, which only contains the fields with which we operate. This stage also assigns the id numbers to each todo. When a Todo is edited, the entire cycle is: * File is read from disk and cached (if not already cached). * A Todo object is created by reading the cache. * If edition is interactive, show the UI now. * No matter how the edition occurs, apply changes to the Todo object. * Start saving process: * Read file from disk (as a VTodo object). * Apply changes from fields to the VTodo object. * Write to disk. The main goal of this is to simplify how many conversions we have. If we read from disk to the editor, we'd need an extra VTodo->Todo conversion code that skips the cache. Running and testing locally --------------------------- The easiest way to run tests, it to install ``tox``, and then simply run ``tox``. By default, several python versions and environments are tested. If you want to run a specific one use ``tox -e ENV``, where ``ENV`` should be one of the environments listed by ``tox -l``. See the `tox`_ documentation for further details. To run your modified copy of ``todoman`` without installing it, it's recommended you set up a virtualenv, and run ``pip install -e .`` to install your checked-out copy into it (this'll make ``todo`` run your local copy while the virtualenv is active). .. _tox: http://tox.readthedocs.io/en/latest/ Authorship ---------- Authors may add themselves to ``AUTHORS.rst``, and all copyright is retained by them. Contributions are accepted under the :doc:`ISC licence `. Patch review checklist ~~~~~~~~~~~~~~~~~~~~~~ Please follow this checklist when submitting new PRs (or reviewing PRs by others): CI will automatically check these for us: #. Do all tests pass? #. Does the documentation build? #. Do all linting and style checks pass? Please keep an eye open for these other items: #. If any features have changed, make sure the docs reflect this. #. If there are any user-facing changes, make sure the :doc:`changelog` reflects this. #. If there are any dependency changes, make sure the :doc:`changelog` reflects this. #. If not already present, please add yourself to ``AUTHORS.rst``. Packaging ~~~~~~~~~ We appreciate distributions packaging todoman. Packaging should be relatively straightforward following usual Python package guidelines. We recommend that you git-clone tags, and build from source, since these tags are GPG signed. Dependencies are listed in ``setup.py``. Please also try to include the extras dependencies as optional dependencies (or what maps best for your distribution). You'll need to run ``python setup.py build`` to generate the ``todoman/version.py`` file which is necessary at runtime. We recommend that you include the :doc:`man` in distribution packages. You can build this by running:: sphinx-build -b man docs/source docs/build/man The man page will be saved as `docs/build/man/todo.1`. Generating the man pages requires that todoman and its doc dependencies (see ``requirements-docs.txt``) are either installed, or in the current ``PYTHONPATH``. todoman-4.1.0/docs/source/index.rst000066400000000000000000000027531415552045400172620ustar00rootroot00000000000000Todoman ======= Todoman is a simple, standards-based, cli todo (aka: task) manager. Todos are stored into icalendar_ files, which means you can sync them via CalDAV_ using, for example, vdirsyncer_. Todoman is now part of the ``pimutils`` project, and is hosted at GitHub_. .. _icalendar: https://tools.ietf.org/html/rfc5545 .. _CalDav: http://en.wikipedia.org/wiki/CalDAV .. _vdirsyncer: https://vdirsyncer.readthedocs.org/ .. _GitHub: https://github.com/pimutils/todoman Features -------- * Listing, editing and creating todos. * Todos are read from individual ics files from the configured directory. This matches the `vdir `_ specification. * There's support for the most common TODO features for now (summary, description, location, due date and priority). * Runs on any Unix-like OS. It's been tested on GNU/Linux, BSD and macOS. * Unsupported fields may not be shown but are *never* deleted or altered. Contributing ------------ See :doc:`contributing` for details on contributing. Caveats ------- Support for the ``percent-completed`` attribute is incomplete. Todoman can only mark todos as completed (100%), and will not reflect nor allow editing for values for ``percent > 0 ^ percent < 100``. Table of Contents ================= .. toctree:: :maxdepth: 2 install configure usage man contributing changelog licence Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` todoman-4.1.0/docs/source/install.rst000066400000000000000000000064411415552045400176170ustar00rootroot00000000000000Installing ========== Distribution packages --------------------- If todoman is packaged for your OS/distribution, using your system's standard package manager is probably the easiest way to install todoman. ArchLinux ~~~~~~~~~ todoman is packaged in the community_ repository, and can be installed using:: pacman -S todoman .. _community: https://www.archlinux.org/packages/community/any/todoman/ macOS ~~~~~ todoman is packaged in homebrew_, and can be installed using:: brew install todoman .. _homebrew: https://formulae.brew.sh/formula/todoman Installation via PIP -------------------- Since *todoman* is written in python, you can use python's package managers, *pip* by executing:: pip install todoman or the latest development version by executing:: pip install git+git://github.com/pimutils/todoman.git This should also take care of installing all required dependencies. Manual installation ------------------- If pip is not available either (this is most unlikely), you'll need to download the source tarball and install via setup.py, though this is not a recommended installation method:: python3 setup.py install bash autocompletion (optional) ------------------------------ There is an autocompletion function for bash provided in the ``contrib`` directory. If you want to enable autocompletion for todoman in bash, copy the file ``contrib/autocompletion/bash/_todo`` to any directory you want. Typically ``/etc/bash_completion.d`` is used for system-wide installations or ``~/.bash_completion.d`` for local installations. In the former case, the file is automatically sourced in most distributions, in the latter case, you will most likely need to add:: source ~/.bash_completion.d/_todo to your ``~/.bashrc``. zsh autocompletion (optional) ----------------------------- There is an autocompletion function for zsh provided in the ``contrib`` directory. If you want to enable autocompletion for todoman in zsh, copy the file ``contrib/autocompletion/zsh/_todo`` to any directory in your ``$fpath``. Typically ``/usr/local/share/zsh/site-functions/`` is used for system-wide installations. Requirements ------------ Todoman requires python 3.8 or later. Installation of required libraries can be done via pip, or your OS's package manager. Recent versions also have experimental support for pypy3. .. _notes-for-packagers: Notes for Packagers ------------------- All of todoman's dependencies are listed in the requirements.txt_ file. New dependencies will be clearly announced in the ``CHANGELOG.rst`` file for each release. Patch releases (eg: those where the third digit of the version is incremented) **will not** introduce new dependencies. If your packages are generated by running ``setup.py install`` or some similar mechanism, you'll end up with a very slow entry point (that's the file ``/usr/bin/todo`` file). Package managers should use the file included in this repository under ``bin/todo`` and replace the above one. The root cause of the issue is really how python's setuptools generates these and outside of the scope of this project. If your packages are generated using python wheels, this should not be an issue (much like it won't be an issue for users installing via ``pip``). .. _requirements.txt: https://github.com/pimutils/todoman/blob/master/requirements.txt todoman-4.1.0/docs/source/licence.rst000066400000000000000000000001451415552045400175460ustar00rootroot00000000000000Licence ======= Todoman is licensed under the ISC licence: .. include:: ../../LICENCE :literal: todoman-4.1.0/docs/source/man.rst000066400000000000000000000014501415552045400167170ustar00rootroot00000000000000Man page ======== .. click:: todoman.cli:cli :prog: todo :show-nested: Description ----------- Todoman is a simple, standards-based, cli todo (aka: task) manager. Todos are stored into *icalendar* files, which means you can sync them via *CalDAV* using, for example, *vdirsyncer*. Usage ----- .. include:: usage.rst Configuring ----------- .. include:: configure.rst Caveats ------- Support for the ``percent-completed`` attribute is incomplete. Todoman can only mark todos as completed (100%), and will not reflect nor allow editing for values for ``percent > 0 ^ percent < 100``. Contributing ------------ For information on contributing, see: https://todoman.readthedocs.io/en/stable/contributing.html LICENCE ------- Todoman is licensed under the ISC licence. See LICENCE for details. todoman-4.1.0/docs/source/usage.rst000066400000000000000000000055541415552045400172610ustar00rootroot00000000000000Usage ===== Todoman usage is `CLI`_ based (thought there are some TUI bits, and the intentions is to also provide a fully `TUI`_-based interface). The default action is ``list``, which outputs all tasks for all calendars, each with a semi-permanent unique id:: 1 [ ] !!! 2015-04-30 Close bank account @work (0%) 2 [ ] ! Send minipimer back for warranty replacement @home (0%) 3 [X] 2015-03-29 Buy soy milk @home (100%) 4 [ ] !! Fix the iPad's screen @home (0%) 5 [ ] !! Fix the Touchpad battery @work (0%) The columns, in order, are: * An id. * Whether the task has been completed or not. * An ``!!!`` indicating high priority, ``!!`` indicating medium priority, ``!`` indicating low priority tasks. * The due date. * The task summary. * The list the todo is from; it will be hidden when filtering by one list, or if the database only contains a single list. * The completed percentage. The id is retained by ``todoman`` until the next time you run the ``flush`` command. To operate on a todo, the id is what's used to reference it. For example, to edit the `Buy soy milk` task from the example above, the proper command is ``todo edit 3``, or ``todo undo 3`` to un-mark the task as done. Editing tasks can only be done via the TUI interface for now, and cannot be done via the command line yet. .. _cli: https://en.wikipedia.org/wiki/Command-line_interface .. _tui: https://en.wikipedia.org/wiki/Text-based_user_interface Synchronization --------------- If you want to synchronize your tasks, you'll need something that syncs via CalDAV. `vdirsyncer`_ is the recommended tool for this. .. _vdirsyncer: https://vdirsyncer.readthedocs.org/en/stable/ Interactive shell ----------------- If you install `click-repl `_, todoman gets a new command called ``repl``, which launches an interactive shell with tab-completion. Integrations ------------ When attempting to integrate ``todoman`` into other systems or parse its output, you're advised to use the ``--porcelain`` flag, which will print all output in a pre-defined format that will remain stable regardless of user configuration or version. The format is JSON, with a single array containing each todo as a single entry (object). Fields will always be present; if a todo does not have a value for a given field, it will be printed as ``null``. Fields MAY be added in future, but will never be removed. Sorting ------- The tasks can be sorted with the ``--sort`` argument. Sorting may be done according to the following fields: - ``description`` - ``location`` - ``status`` - ``summary`` - ``uid`` - ``rrule`` - ``percent_complete`` - ``priority`` - ``sequence`` - ``categories`` - ``completed_at`` - ``created_at`` - ``dtstamp`` - ``start`` - ``due`` - ``last_modified`` todoman-4.1.0/requirements-dev.txt000066400000000000000000000001201415552045400172130ustar00rootroot00000000000000flake8 flake8-import-order freezegun hypothesis pytest pytest-cov pytest-runner todoman-4.1.0/requirements-docs.txt000066400000000000000000000000551415552045400173740ustar00rootroot00000000000000sphinx_autorun sphinx-click sphinx_rtd_theme todoman-4.1.0/setup.cfg000066400000000000000000000006601415552045400150050ustar00rootroot00000000000000[flake8] exclude=.tox,build,.eggs extend-ignore = E203, # Black-incompatible colon spacing. W503, # Line jump before binary operator. max-line-length = 88 [aliases] test=pytest [tool:pytest] testpaths = tests addopts = --cov=todoman --cov-report=term-missing --color=yes --verbose [isort] force_single_line=true [mypy] ignore_missing_imports = True # See https://github.com/python/mypy/issues/7511: warn_no_return = False todoman-4.1.0/setup.py000066400000000000000000000031251415552045400146750ustar00rootroot00000000000000#!/usr/bin/env python3 from setuptools import setup setup( name="todoman", description="A simple CalDav-based todo manager.", author="Hugo Osvaldo Barrera", author_email="hugo@barrera.io", url="https://github.com/pimutils/todoman", license="ISC", packages=["todoman"], include_package_data=True, entry_points={"console_scripts": ["todo = todoman.cli:cli"]}, install_requires=[ "atomicwrites", "click>=7.1,<9.0", "click-log>=0.2.1", "humanize", "icalendar>=4.0.3", "parsedatetime", "python-dateutil", "pyxdg", "urwid", ], long_description=open("README.rst").read(), use_scm_version={ "version_scheme": "post-release", "write_to": "todoman/version.py", }, setup_requires=["setuptools_scm"], tests_require=open("requirements-dev.txt").readlines(), extras_require={ "docs": open("requirements-docs.txt").readlines(), "repl": ["click-repl>=0.1.6"], }, classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Environment :: Console :: Curses", "License :: OSI Approved :: ISC License (ISCL)", "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Office/Business :: Scheduling", "Topic :: Utilities", ], ) todoman-4.1.0/tests/000077500000000000000000000000001415552045400143245ustar00rootroot00000000000000todoman-4.1.0/tests/conftest.py000066400000000000000000000107321415552045400165260ustar00rootroot00000000000000import os import time from datetime import datetime from uuid import uuid4 import pytest import pytz from click.testing import CliRunner from dateutil.tz import tzlocal from hypothesis import HealthCheck from hypothesis import Verbosity from hypothesis import settings from todoman import model from todoman.formatters import DefaultFormatter from todoman.formatters import HumanizedFormatter @pytest.fixture def default_database(tmpdir): return model.Database( [tmpdir.mkdir("default")], tmpdir.mkdir(uuid4().hex).join("cache.sqlite3"), ) @pytest.fixture def config(tmpdir, default_database): config_path = tmpdir.join("config.py") config_path.write( f'path = "{tmpdir}/*"\n' 'date_format = "%Y-%m-%d"\n' 'time_format = ""\n' f'cache_path = "{str(tmpdir.join("cache.sqlite3"))}"\n' ) return config_path @pytest.fixture def runner(config, sleep): class SleepyCliRunner(CliRunner): """ Sleeps before invoking to make sure cache entries have expired. """ def invoke(self, *args, **kwargs): sleep() return super().invoke(*args, **kwargs) return SleepyCliRunner(env={"TODOMAN_CONFIG": str(config)}) @pytest.fixture def create(tmpdir): def inner(name, content, list_name="default"): path = tmpdir.ensure_dir(list_name).join(name) path.write( "BEGIN:VCALENDAR\nBEGIN:VTODO\n" + content + "END:VTODO\nEND:VCALENDAR" ) return path return inner @pytest.fixture def now_for_tz(): def inner(tz="CET"): """ Provides the current time cast to a given timezone. This helper should be used in place of datetime.now() when the date will be compared to some pre-computed value that assumes a determined timezone. """ return datetime.now().replace(tzinfo=tzlocal()).astimezone(pytz.timezone(tz)) return inner @pytest.fixture def todo_factory(default_database): def inner(**attributes): todo = model.Todo(new=True) todo.list = list(default_database.lists())[0] attributes.setdefault("summary", "YARR!") for name, value in attributes.items(): setattr(todo, name, value) default_database.save(todo) return todo return inner @pytest.fixture def default_formatter(): formatter = DefaultFormatter(tz_override=pytz.timezone("CET")) return formatter @pytest.fixture def humanized_formatter(): formatter = HumanizedFormatter(tz_override=pytz.timezone("CET")) return formatter @pytest.fixture(scope="session") def sleep(tmpdir_factory): """ Sleeps as long as needed for the filesystem's mtime to pick up differences Measures how long we need to sleep for the filesystem's mtime precision to pick up differences and returns a function that sleeps that amount of time. This keeps test fast on systems with high precisions, but makes them pass on those that don't (I'm looking at you, macOS). """ tmpfile = tmpdir_factory.mktemp("sleep").join("touch_me") def touch_and_mtime(): tmpfile.open("w").close() stat = os.stat(str(tmpfile)) return getattr(stat, "st_mtime_ns", stat.st_mtime) def inner(): time.sleep(i) i = 0.00001 while i < 100: # Measure three times to avoid things like 12::18:11.9994 [mis]passing first = touch_and_mtime() time.sleep(i) second = touch_and_mtime() time.sleep(i) third = touch_and_mtime() if first != second != third: i *= 1.1 return inner i = i * 10 # This should never happen, but oh, well: raise Exception( "Filesystem does not seem to save modified times of files. \n" "Cannot run tests that depend on this." ) @pytest.fixture def todos(default_database, sleep): def inner(**filters): sleep() default_database.update_cache() return default_database.todos(**filters) return inner settings.register_profile( "ci", settings( deadline=None, max_examples=1000, verbosity=Verbosity.verbose, suppress_health_check=[HealthCheck.too_slow], ), ) settings.register_profile( "deterministic", settings( derandomize=True, ), ) if os.getenv("DETERMINISTIC_TESTS", "false").lower() == "true": settings.load_profile("deterministic") elif os.getenv("CI", "false").lower() == "true": settings.load_profile("ci") todoman-4.1.0/tests/helpers.py000066400000000000000000000015351415552045400163440ustar00rootroot00000000000000__all__ = [ "pyicu_sensitive", ] import os from tempfile import TemporaryDirectory import pytest def is_fs_case_sensitive(): with TemporaryDirectory() as tmpdir: os.mkdir(os.path.join(tmpdir, "casesensitivetest")) try: os.mkdir(os.path.join(tmpdir, "casesensitiveTEST")) return True except FileExistsError: return False def is_pyicu_installed(): try: import icu # noqa: F401: This is an import to tests if it's installed. except ImportError: return False else: return True fs_case_sensitive = pytest.mark.skipif( not is_fs_case_sensitive(), reason="This test cannot be run when the fs is not case sensitive.", ) pyicu_sensitive = pytest.mark.skipif( is_pyicu_installed(), reason="This test cannot be run with pyicu installed.", ) todoman-4.1.0/tests/test_backend.py000066400000000000000000000064211415552045400173270ustar00rootroot00000000000000from datetime import date from datetime import datetime import icalendar import pytest import pytz from dateutil.tz import tzlocal from freezegun import freeze_time from todoman.model import Todo from todoman.model import VtodoWriter def test_datetime_serialization(todo_factory, tmpdir): now = datetime(2017, 8, 31, 23, 49, 53, tzinfo=pytz.UTC) todo = todo_factory(created_at=now) filename = tmpdir.join("default").join(todo.filename) with open(str(filename)) as f: assert "CREATED;VALUE=DATE-TIME:20170831T234953Z\n" in f.readlines() def test_serialize_created_at(todo_factory): now = datetime.now(tz=pytz.UTC) todo = todo_factory(created_at=now) vtodo = VtodoWriter(todo).serialize() assert vtodo.get("created") is not None def test_serialize_dtstart(todo_factory): now = datetime.now(tz=pytz.UTC) todo = todo_factory(start=now) vtodo = VtodoWriter(todo).serialize() assert vtodo.get("dtstart") is not None def test_serializer_raises(todo_factory): todo = todo_factory() writter = VtodoWriter(todo) with pytest.raises(Exception): writter.serialize_field("nonexistant", 7) def test_supported_fields_are_serializeable(): supported_fields = set(Todo.ALL_SUPPORTED_FIELDS) serialized_fields = set(VtodoWriter.FIELD_MAP.keys()) assert supported_fields == serialized_fields def test_vtodo_serialization(todo_factory): """Test VTODO serialization: one field of each type.""" description = "A tea would be nice, thanks." todo = todo_factory( categories=["tea", "drinking", "hot"], description=description, due=datetime(3000, 3, 21), start=date(3000, 3, 21), priority=7, status="IN-PROCESS", summary="Some tea", rrule="FREQ=MONTHLY", ) writer = VtodoWriter(todo) vtodo = writer.serialize() assert [str(c) for c in vtodo.get("categories").cats] == ["tea", "drinking", "hot"] assert str(vtodo.get("description")) == description assert vtodo.get("priority") == 7 assert vtodo.decoded("due") == datetime(3000, 3, 21, tzinfo=tzlocal()) assert vtodo.decoded("dtstart") == date(3000, 3, 21) assert str(vtodo.get("status")) == "IN-PROCESS" assert vtodo.get("rrule") == icalendar.vRecur.from_ical("FREQ=MONTHLY") @freeze_time("2017-04-04 20:11:57") def test_update_last_modified(todo_factory, todos, tmpdir): todo = todo_factory() assert todo.last_modified == datetime.now(tzlocal()) def test_sequence_increment(default_database, todo_factory, todos): todo = todo_factory() assert todo.sequence == 1 default_database.save(todo) assert todo.sequence == 2 # Relaod (and check the caching flow for the sequence) todo = next(todos()) assert todo.sequence == 2 def test_normalize_datetime(): writter = VtodoWriter(None) assert writter.normalize_datetime(date(2017, 6, 17)) == date(2017, 6, 17) assert writter.normalize_datetime(datetime(2017, 6, 17)) == datetime( 2017, 6, 17, tzinfo=tzlocal() ) assert writter.normalize_datetime(datetime(2017, 6, 17, 12, 19)) == datetime( 2017, 6, 17, 12, 19, tzinfo=tzlocal() ) assert writter.normalize_datetime( datetime(2017, 6, 17, 12, tzinfo=tzlocal()) ) == datetime(2017, 6, 17, 12, tzinfo=tzlocal()) todoman-4.1.0/tests/test_cli.py000066400000000000000000000722041415552045400165110ustar00rootroot00000000000000import datetime import sys from os.path import exists from unittest import mock from unittest.mock import call from unittest.mock import patch import click import hypothesis.strategies as st import pytest from dateutil.tz import tzlocal from freezegun import freeze_time from hypothesis import given from tests.helpers import fs_case_sensitive from tests.helpers import pyicu_sensitive from todoman.cli import cli from todoman.cli import exceptions from todoman.model import Database from todoman.model import Todo # TODO: test --grep def test_list(tmpdir, runner, create): result = runner.invoke(cli, ["list"], catch_exceptions=False) assert not result.exception assert not result.output.strip() create("test.ics", "SUMMARY:harhar\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert "harhar" in result.output def test_no_default_list(runner): result = runner.invoke(cli, ["new", "Configure a default list"]) assert result.exception assert ( "Error: Invalid value for '--list' / '-l': You must set " "`default_list` or use -l." in result.output ) def test_no_extra_whitespace(tmpdir, runner, create): """ Test that we don't output extra whitespace Test that we don't output a lot of extra whitespace when there are no tasks, or when there are tasks (eg: both scenarios). Note: Other tests should be set up so that comparisons don't care much about whitespace, so that if this changes, only this test should fail. """ result = runner.invoke(cli, ["list"], catch_exceptions=False) assert not result.exception assert result.output == "\n" create("test.ics", "SUMMARY:harhar\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert len(result.output.splitlines()) == 1 def test_percent(tmpdir, runner, create): create("test.ics", "SUMMARY:harhar\nPERCENT-COMPLETE:78\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert "78%" in result.output @fs_case_sensitive @pytest.mark.parametrize("list_name", ["default", "DEfault", "deFAUlT"]) def test_list_case_insensitive(tmpdir, runner, create, list_name): result = runner.invoke(cli, ["list", list_name]) assert not result.exception @fs_case_sensitive def test_list_case_insensitive_collision(tmpdir, runner, create): """ Test that the case-insensitive list name matching is not used if colliding list names exist. """ tmpdir.mkdir("DEFaUlT") result = runner.invoke(cli, ["list", "deFaulT"]) assert result.exception result = runner.invoke(cli, ["list", "default"]) assert not result.exception result = runner.invoke(cli, ["list", "DEFaUlT"]) assert not result.exception @fs_case_sensitive def test_list_case_insensitive_other_collision(tmpdir, runner, create): """ Test that the case-insensitive list name matching is used if a collision exists that does not affect the queried list. """ tmpdir.mkdir("coLLiding") tmpdir.mkdir("COLLiDING") result = runner.invoke(cli, ["list", "cOlliDInG"]) assert result.exception result = runner.invoke(cli, ["list", "DEfAult"]) assert not result.exception def test_list_inexistant(tmpdir, runner, create): result = runner.invoke(cli, ["list", "nonexistant"]) assert result.exception assert "Error: Invalid value for '[LISTS]...': nonexistant" in result.output result = runner.invoke(cli, ["list", "NONexistant"]) assert result.exception assert "Error: Invalid value for '[LISTS]...': NONexistant" in result.output def test_show_existing(tmpdir, runner, create): create("test.ics", "SUMMARY:harhar\nDESCRIPTION:Lots of text. Yum!\n") result = runner.invoke(cli, ["list"]) result = runner.invoke(cli, ["show", "1"]) assert not result.exception assert "harhar" in result.output assert "Lots of text. Yum!" in result.output def test_show_inexistant(tmpdir, runner, create): create("test.ics", "SUMMARY:harhar\n") result = runner.invoke(cli, ["list"]) result = runner.invoke(cli, ["show", "2"]) assert result.exit_code == 20 assert result.output == "No todo with id 2.\n" def test_human(runner): result = runner.invoke( cli, ["new", "-l", "default", "-d", "tomorrow", "hail belzebub"] ) assert not result.exception assert "belzebub" in result.output result = runner.invoke(cli, ["list"]) assert not result.exception assert "belzebub" in result.output @pytest.mark.xfail(reason="issue#9") def test_two_events(tmpdir, runner): tmpdir.join("default/test.ics").write( "BEGIN:VCALENDAR\n" "BEGIN:VTODO\n" "SUMMARY:task one\n" "END:VTODO\n" "BEGIN:VTODO\n" "SUMMARY:task two\n" "END:VTODO\n" "END:VCALENDAR" ) result = runner.invoke(cli, ["list"]) assert not result.exception assert len(result.output.splitlines()) == 2 assert "task one" in result.output assert "task two" in result.output def test_default_command(tmpdir, runner, create): create("test.ics", "SUMMARY:harhar\n") result = runner.invoke(cli) assert not result.exception assert "harhar" in result.output def test_delete(runner, create): create("test.ics", "SUMMARY:harhar\n") result = runner.invoke(cli, ["list"]) assert not result.exception result = runner.invoke(cli, ["delete", "1", "--yes"]) assert not result.exception result = runner.invoke(cli, ["list"]) assert not result.exception assert not result.output.strip() def test_delete_prompt(todo_factory, runner, todos): todo_factory() result = runner.invoke(cli, ["delete", "1"], input="yes") assert not result.exception assert '[y/N]: yes\nDeleting "YARR!"' in result.output assert len(list(todos())) == 0 def test_copy(tmpdir, runner, create): tmpdir.mkdir("other_list") create("test.ics", "SUMMARY:test_copy\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert "test_copy" in result.output assert "default" in result.output assert "other_list" not in result.output result = runner.invoke(cli, ["copy", "-l", "other_list", "1"]) assert not result.exception result = runner.invoke(cli, ["list"]) assert not result.exception assert "test_copy" in result.output assert "default" in result.output assert "other_list" in result.output def test_move(tmpdir, runner, create): tmpdir.mkdir("other_list") create("test.ics", "SUMMARY:test_move\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert "test_move" in result.output assert "default" in result.output assert "other_list" not in result.output result = runner.invoke(cli, ["move", "-l", "other_list", "1"]) assert not result.exception result = runner.invoke(cli, ["list"]) assert not result.exception assert "test_move" in result.output assert "default" not in result.output assert "other_list" in result.output @pyicu_sensitive @freeze_time("2017-03-17 20:22:19") def test_dtstamp(tmpdir, runner, create): """Test that we add the DTSTAMP to new entries as per RFC5545.""" result = runner.invoke(cli, ["new", "-l", "default", "test event"]) assert not result.exception db = Database([tmpdir.join("default")], tmpdir.join("/dtstamp_cache")) todo = list(db.todos())[0] assert todo.dtstamp is not None assert todo.dtstamp == datetime.datetime.now(tz=tzlocal()) def test_default_list(tmpdir, runner, create, config): """Test the default_list config parameter""" result = runner.invoke(cli, ["new", "test default list"]) assert result.exception config.write('default_list = "default"\n', "a") result = runner.invoke(cli, ["new", "test default list"]) assert not result.exception db = Database([tmpdir.join("default")], tmpdir.join("/default_list")) todo = list(db.todos())[0] assert todo.summary == "test default list" @pytest.mark.parametrize( "default_due, expected_due_hours", [(None, 24), (1, 1), (0, None)], ids=["not specified", "greater than 0", "0"], ) def test_default_due(tmpdir, runner, create, default_due, expected_due_hours, config): """Test setting the due date using the default_due config parameter""" if default_due is not None: config.write(f"default_due = {default_due}\n", "a") runner.invoke(cli, ["new", "-l", "default", "aaa"]) db = Database([tmpdir.join("default")], tmpdir.join("/default_list")) todo = list(db.todos())[0] if expected_due_hours is None: assert todo.due is None else: assert (todo.due - todo.created_at) == datetime.timedelta( hours=expected_due_hours ) @pyicu_sensitive @freeze_time(datetime.datetime.now()) def test_default_due2(tmpdir, runner, create, todos, config): config.write("default_due = 24\n", "a") r = runner.invoke(cli, ["new", "-ldefault", "-dtomorrow", "aaa"]) assert not r.exception r = runner.invoke(cli, ["new", "-ldefault", "bbb"]) assert not r.exception r = runner.invoke(cli, ["new", "-ldefault", "-d", "one hour", "ccc"]) assert not r.exception todos = {t.summary: t for t in todos(status="ANY")} assert todos["aaa"].due.date() == todos["bbb"].due.date() assert todos["ccc"].due == todos["bbb"].due - datetime.timedelta(hours=23) def test_sorting_fields(tmpdir, runner, default_database): tasks = [] for i in range(1, 10): days = datetime.timedelta(days=i) todo = Todo(new=True) todo.list = next(default_database.lists()) todo.due = datetime.datetime.now() + days todo.created_at = datetime.datetime.now() - days todo.summary = f"harhar{i}" tasks.append(todo) default_database.save(todo) fields = ( "id", "uid", "summary", "due", "priority", "created_at", "completed_at", "dtstamp", "status", "description", "location", "categories", ) @given( sort_key=st.lists( st.sampled_from(fields + tuple("-" + x for x in fields)), unique=True ) ) def run_test(sort_key): sort_key = ",".join(sort_key) result = runner.invoke(cli, ["list", "--sort", sort_key]) assert not result.exception assert result.exit_code == 0 assert len(result.output.strip().splitlines()) == len(tasks) run_test() def test_sorting_output(tmpdir, runner, create): create("test.ics", "SUMMARY:aaa\nDUE;VALUE=DATE-TIME;TZID=ART:20160102T000000\n") create("test2.ics", "SUMMARY:bbb\nDUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n") examples = [("-summary", ["aaa", "bbb"]), ("due", ["aaa", "bbb"])] # Normal sorting, reversed by default all_examples = [(["--sort", key], order) for key, order in examples] # Testing --reverse, same exact output all_examples.extend( (["--reverse", "--sort", key], order) for key, order in examples ) # Testing --no-reverse all_examples.extend( (["--no-reverse", "--sort", key], reversed(order)) for key, order in examples ) for args, order in all_examples: result = runner.invoke(cli, ["list"] + args) assert not result.exception lines = result.output.splitlines() for i, task in enumerate(order): assert task in lines[i] def test_sorting_null_values(tmpdir, runner, create): create("test.ics", "SUMMARY:aaa\nPRIORITY:9\n") create("test2.ics", "SUMMARY:bbb\nDUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n") result = runner.invoke(cli) assert not result.exception assert "bbb" in result.output.splitlines()[0] assert "aaa" in result.output.splitlines()[1] def test_sort_invalid_fields(runner): result = runner.invoke(cli, ["list", "--sort", "hats"]) assert result.exception assert "Invalid value for '--sort': Unknown field 'hats'" in result.output @pytest.mark.parametrize("hours", [72, -72]) def test_color_due_dates(tmpdir, runner, create, hours): due = datetime.datetime.now() + datetime.timedelta(hours=hours) create( "test.ics", "SUMMARY:aaa\nSTATUS:IN-PROCESS\nDUE;VALUE=DATE-TIME;TZID=ART:{}\n".format( due.strftime("%Y%m%dT%H%M%S") ), ) result = runner.invoke(cli, ["--color", "always"]) assert not result.exception due_str = due.strftime("%Y-%m-%d") if hours == 72: expected = ( f"[ ] 1 \x1b[35m\x1b[0m \x1b[37m{due_str}\x1b[0m aaa @default\x1b[0m\n" ) else: expected = ( f"[ ] 1 \x1b[35m\x1b[0m \x1b[31m{due_str}\x1b[0m aaa @default\x1b[0m\n" ) assert result.output == expected def test_color_flag(runner, todo_factory): todo_factory(due=datetime.datetime(2007, 3, 22)) result = runner.invoke(cli, ["--color", "always"], color=True) assert ( result.output.strip() == "[ ] 1 \x1b[35m\x1b[0m \x1b[31m2007-03-22\x1b[0m YARR! @default\x1b[0m" ) result = runner.invoke(cli, color=True) assert ( result.output.strip() == "[ ] 1 \x1b[35m\x1b[0m \x1b[31m2007-03-22\x1b[0m YARR! @default\x1b[0m" ) result = runner.invoke(cli, ["--color", "never"], color=True) assert result.output.strip() == "[ ] 1 2007-03-22 YARR! @default" def test_flush(tmpdir, runner, create, todo_factory, todos): todo_factory(summary="aaa", status="COMPLETED") todo_factory(summary="bbb") all_todos = list(todos(status="ANY")) assert len(all_todos) == 2 result = runner.invoke(cli, ["flush"], input="y\n", catch_exceptions=False) assert not result.exception all_todos = list(todos(status="ANY")) assert len(all_todos) == 1 assert all_todos[0].summary == "bbb" def test_edit(runner, default_database, todos): todo = Todo(new=True) todo.list = next(default_database.lists()) todo.summary = "Eat paint" todo.due = datetime.datetime(2016, 10, 3) default_database.save(todo) result = runner.invoke(cli, ["edit", "1", "--due", "2017-02-01"]) assert not result.exception assert "2017-02-01" in result.output todo = next(todos(status="ANY")) assert todo.due == datetime.datetime(2017, 2, 1, tzinfo=tzlocal()) assert todo.summary == "Eat paint" def test_edit_move(runner, todo_factory, default_database, tmpdir, todos): """ Test that editing the list in the UI edits the todo as expected The goal of this test is not to test the editor itself, but rather the `edit` command and its slightly-complex moving logic. """ tmpdir.mkdir("another_list") default_database.paths = [ str(tmpdir.join("default")), str(tmpdir.join("another_list")), ] default_database.update_cache() todo_factory(summary="Eat some headphones") lists = list(default_database.lists()) another_list = next(filter(lambda x: x.name == "another_list", lists)) def moving_edit(self): self.current_list = another_list self._save_inner() with patch("todoman.interactive.TodoEditor.edit", moving_edit): result = runner.invoke(cli, ["edit", "1"]) assert not result.exception todos = list(todos()) assert len(todos) == 1 assert todos[0].list.name == "another_list" def test_edit_retains_id(runner, todos, todo_factory): """Tests that we retain a todo's ID after editing.""" original_id = todo_factory().id result = runner.invoke(cli, ["edit", "1", "--due", "2017-04-01"]) assert not result.exception todo = next(todos()) assert todo.due == datetime.datetime(2017, 4, 1, tzinfo=tzlocal()) assert todo.id == original_id def test_edit_inexistant(runner): """Tests that we show the right output and exit code for inexistant ids.""" result = runner.invoke(cli, ["edit", "1", "--due", "2017-04-01"]) assert result.exception assert result.exit_code == exceptions.NoSuchTodo.EXIT_CODE assert result.output.strip() == "No todo with id 1." def test_empty_list(tmpdir, runner, create): for item in tmpdir.listdir(): if item.isdir(): item.remove() result = runner.invoke(cli) expected = ( "No lists found matching {}/*, create a directory for a new list" ).format(tmpdir) assert expected in result.output def test_show_location(tmpdir, runner, create): create("test.ics", "SUMMARY:harhar\nLOCATION:Boston\n") result = runner.invoke(cli, ["show", "1"]) assert "Boston" in result.output def test_location(runner): result = runner.invoke( cli, ["new", "-l", "default", "--location", "Chembur", "Event Test"] ) assert "Chembur" in result.output def test_sort_mixed_timezones(runner, create): """ Test sorting mixed timezones. The times on this tests are carefully chosen so that a TZ-naive comparison gives the opposite results. """ create( "test.ics", "SUMMARY:first\nDUE;VALUE=DATE-TIME;TZID=CET:20170304T180000\n", # 1700 UTC ) create( "test2.ics", "SUMMARY:second\nDUE;VALUE=DATE-TIME;TZID=HST:20170304T080000\n", # 1800 UTC ) result = runner.invoke(cli, ["list", "--status", "ANY"]) assert not result.exception output = result.output.strip() assert len(output.splitlines()) == 2 assert "second" in result.output.splitlines()[0] assert "first" in result.output.splitlines()[1] def test_humanize_interactive(runner): result = runner.invoke(cli, ["--humanize", "--porcelain", "list"]) assert result.exception assert ( result.output.strip() == "Error: --porcelain and --humanize cannot be used at the same time." ) def test_due_bad_date(runner): result = runner.invoke(cli, ["new", "--due", "Not a date", "Blargh!"]) assert result.exception assert ( "Error: Invalid value for '--due' / '-d': Time description not " "recognized: Not a date" == result.output.strip().splitlines()[-1] ) def test_multiple_todos_in_file(runner, create): path = create("test.ics", "SUMMARY:a\nEND:VTODO\nBEGIN:VTODO\nSUMMARY:b\n") for _ in range(2): with patch("todoman.model.logger", spec=True) as mocked_logger: result = runner.invoke(cli, ["list"]) assert " a " in result.output assert " b " in result.output assert mocked_logger.warning.call_count == 1 assert mocked_logger.warning.call_args == mock.call( "Todo is in read-only mode because there are multiple todos in %s", path, ) result = runner.invoke(cli, ["done", "1"]) assert result.exception assert "Todo is in read-only mode because there are multiple todos" in result.output result = runner.invoke(cli, ["show", "1"]) assert not result.exception result = runner.invoke(cli, ["show", "2"]) assert not result.exception def test_todo_new(runner, default_database): # This isn't a very thurough test, but at least catches obvious regressions # like startup crashes or typos. with patch("urwid.MainLoop"): result = runner.invoke(cli, ["new", "-l", "default"]) # No SUMMARY error after UI runs assert isinstance(result.exception, SystemExit) assert result.exception.args == (2,) assert "Error: No SUMMARY specified" in result.output def test_todo_edit(runner, default_database, todo_factory): # This isn't a very thurough test, but at least catches obvious regressions # like startup crashes or typos. todo_factory() with patch("urwid.MainLoop"): result = runner.invoke(cli, ["edit", "1"]) assert not result.exception assert "YARR!" in result.output @pyicu_sensitive @freeze_time("2017, 3, 20") def test_list_startable(tmpdir, runner, todo_factory, config): todo_factory(summary="started", start=datetime.datetime(2017, 3, 15)) todo_factory(summary="nostart") todo_factory(summary="unstarted", start=datetime.datetime(2017, 3, 24)) result = runner.invoke( cli, ["list", "--startable"], catch_exceptions=False, ) assert not result.exception assert "started" in result.output assert "nostart" in result.output assert "unstarted" not in result.output result = runner.invoke( cli, ["list"], catch_exceptions=False, ) assert not result.exception assert "started" in result.output assert "nostart" in result.output assert "unstarted" in result.output config.write("startable = True\n", "a") result = runner.invoke(cli, ["list"], catch_exceptions=False) assert not result.exception assert "started" in result.output assert "nostart" in result.output assert "unstarted" not in result.output def test_bad_start_date(runner): result = runner.invoke(cli, ["list", "--start"]) assert result.exception assert result.output.strip() == "Error: Option '--start' requires 2 arguments." result = runner.invoke(cli, ["list", "--start", "before"]) assert result.exception assert result.output.strip() == "Error: Option '--start' requires 2 arguments." result = runner.invoke(cli, ["list", "--start", "before", "not_a_date"]) assert result.exception assert ( "Invalid value for '--start': Time description not recognized: not_a_date" in result.output ) result = runner.invoke(cli, ["list", "--start", "godzilla", "2017-03-22"]) assert result.exception assert "Format should be '[before|after] [DATE]'" in result.output def test_done(runner, todo_factory, todos): todo = todo_factory() result = runner.invoke(cli, ["done", "1"]) assert not result.exception todo = next(todos(status="ANY")) assert todo.percent_complete == 100 assert todo.is_completed is True result = runner.invoke(cli, ["done", "17"]) assert result.exception assert result.output.strip() == "No todo with id 17." def test_done_recurring(runner, todo_factory, todos): rrule = "FREQ=DAILY;UNTIL=20990315T020000Z" todo = todo_factory(rrule=rrule) result = runner.invoke(cli, ["done", "1"]) assert not result.exception todos = todos(status="ANY") todo = next(todos) assert todo.percent_complete == 100 assert todo.is_completed is True assert not todo.rrule todo = next(todos) assert todo.percent_complete == 0 assert todo.is_completed is False assert todo.rrule == rrule def test_cancel(runner, todo_factory, todos): todo = todo_factory() result = runner.invoke(cli, ["cancel", "1"]) assert not result.exception todo = next(todos(status="ANY")) assert todo.status == "CANCELLED" def test_id_printed_for_new(runner): result = runner.invoke(cli, ["new", "-l", "default", "show me an id"]) assert not result.exception assert result.output.strip().startswith("[ ] 1") def test_repl(runner): """Test that repl registers properly.""" if "click_repl" not in sys.modules: pytest.skip('Optional dependency "click_repl" is not installed') result = runner.invoke(cli, ["--help"]) assert not result.exception assert "repl Start an interactive shell." in result.output assert "shell Start an interactive shell." in result.output def test_no_repl(runner): """Test that we work fine without click_repl installed.""" modules = sys.modules if "click_repl" in modules: pytest.skip("Test can't be run with click_repl installed") result = runner.invoke(cli, ["--help"]) assert not result.exception assert "repl" not in result.output assert "shell" not in result.output assert "Start an interactive shell." not in result.output def test_status_validation(): from todoman import cli @given( statuses=st.lists( st.sampled_from(Todo.VALID_STATUSES + ("ANY",)), min_size=1, max_size=5, unique=True, ) ) def run_test(statuses): validated = cli.validate_status(val=",".join(statuses)).split(",") if "ANY" in statuses: assert len(validated) == 4 else: assert len(validated) == len(statuses) for status in validated: assert status in Todo.VALID_STATUSES run_test() def test_bad_status_validation(): from todoman import cli with pytest.raises(click.BadParameter): cli.validate_status(val="INVALID") with pytest.raises(click.BadParameter): cli.validate_status(val="IN-PROGRESS") def test_status_filtering(runner, todo_factory): todo_factory(summary="one", status="CANCELLED") todo_factory(summary="two") result = runner.invoke(cli, ["list", "--status", "cancelled"]) assert not result.exception assert len(result.output.splitlines()) == 1 assert "one " in result.output result = runner.invoke(cli, ["list", "--status", "NEEDS-action"]) assert not result.exception assert len(result.output.splitlines()) == 1 assert "two" in result.output def test_invoke_command(runner, tmpdir, config): config.write('default_command = "flush"\n', "a") flush = mock.MagicMock() parser = mock.MagicMock() flush.make_parser.return_value = parser parser.parse_args.return_value = {}, [], [] with patch.dict(cli.commands, values={"flush": flush}): result = runner.invoke(cli, catch_exceptions=False) assert not result.exception assert not result.output.strip() assert flush.call_count == 1 def test_invoke_invalid_command(runner, tmpdir, config): config.write('default_command = "DoTheRobot"\n', "a") result = runner.invoke(cli, catch_exceptions=False) assert result.exception assert "Error: Invalid setting for [default_command]" in result.output def test_show_priority(runner, todo_factory, todos): todo_factory(summary="harhar\n", priority=1) result = runner.invoke(cli, ["show", "1"]) assert "!!!" in result.output def test_priority(runner): result = runner.invoke( cli, ["new", "-l", "default", "--priority", "high", "Priority Test"] ) assert "!!!" in result.output def test_porcelain_precedence(runner, tmpdir): """Test that --humanize flag takes precedence over `porcelain` config""" path = tmpdir.join("config") path.write("humanize = true\n", "a") with patch("todoman.formatters.PorcelainFormatter") as mocked_formatter: runner.invoke(cli, ["--porcelain", "list"]) assert mocked_formatter.call_count == 1 def test_duplicate_list(tmpdir, runner): tmpdir.join("personal1").mkdir() with tmpdir.join("personal1").join("displayname").open("w") as f: f.write("personal") tmpdir.join("personal2").mkdir() with tmpdir.join("personal2").join("displayname").open("w") as f: f.write("personal") result = runner.invoke(cli, ["list"]) assert result.exception assert result.exit_code == exceptions.AlreadyExists.EXIT_CODE assert ( result.output.strip() == "More than one list has the same identity: personal." ) def test_edit_raw(todo_factory, runner): todo = todo_factory() assert exists(todo.path) with patch("click.edit", spec=True) as mocked_edit: result = runner.invoke(cli, ["edit", "--raw", "1"], catch_exceptions=False) assert mocked_edit.call_count == 1 assert mocked_edit.call_args == mock.call(filename=todo.path) assert not result.exception assert not result.output def test_new_description_from_stdin(runner, todos): result = runner.invoke( cli, ["new", "-l", "default", "-r", "hello"], input="world\n" ) assert not result.exception (todo,) = todos() assert "world" in todo.description assert "hello" in todo.summary def test_default_priority(tmpdir, runner, create, config): """Test setting the due date using the default_due config parameter""" config.write("default_priority = 3\n", "a") runner.invoke(cli, ["new", "-l", "default", "aaa"]) db = Database([tmpdir.join("default")], tmpdir.join("/default_list")) todo = list(db.todos())[0] assert todo.priority == 3 def test_no_default_priority(tmpdir, runner, create): """Test setting the due date using the default_due config parameter""" runner.invoke(cli, ["new", "-l", "default", "aaa"]) db = Database([tmpdir.join("default")], tmpdir.join("/default_list")) todo = list(db.todos())[0] assert todo.priority == 0 todo_file = tmpdir.join("default").join(todo.filename) todo_ics = todo_file.read_text("utf-8") assert "PRIORITY" not in todo_ics def test_invalid_default_priority(config, runner, create): """Test setting the due date using the default_due config parameter""" config.write("default_priority = 13\n", "a") result = runner.invoke(cli, ["new", "-l", "default", "aaa"]) assert result.exception assert "Error: Invalid `default_priority` settings." in result.output def test_default_command_args(config, runner): config.write( 'default_command = "list --sort=due --due 168 ' '--priority low --no-reverse"', "a", ) with patch("todoman.model.Database.todos", spec=True) as todos: result = runner.invoke(cli) assert not result.exception assert todos.call_args == call( sort=["due"], due=168, priority=9, reverse=False, lists=[], location=None, category=None, grep=None, start=None, startable=None, status="NEEDS-ACTION,IN-PROCESS", ) todoman-4.1.0/tests/test_config.py000066400000000000000000000110251415552045400172010ustar00rootroot00000000000000from unittest.mock import patch import pytest from click.testing import CliRunner from todoman.cli import cli from todoman.configuration import ConfigurationException from todoman.configuration import load_config def test_explicit_nonexistant(runner): result = CliRunner().invoke( cli, env={"TODOMAN_CONFIG": "/nonexistant"}, catch_exceptions=True, ) assert result.exception assert "Configuration file /nonexistant does not exist" in result.output def test_xdg_nonexistant(runner): with patch("xdg.BaseDirectory.xdg_config_dirs", ["/does-not-exist"]): result = CliRunner().invoke( cli, catch_exceptions=True, ) assert result.exception assert "No configuration file found" in result.output def test_xdg_existant(runner, tmpdir, config): with tmpdir.mkdir("todoman").join("config.py").open("w") as f: with config.open() as c: f.write(c.read()) with patch("xdg.BaseDirectory.xdg_config_dirs", [str(tmpdir)]): result = CliRunner().invoke( cli, catch_exceptions=True, ) assert not result.exception assert not result.output.strip() def test_sane_config(config, runner, tmpdir): config.write( 'color = "auto"\n' 'date_format = "%Y-%m-%d"\n' f'path = "{tmpdir}"\n' f'cache_path = "{tmpdir.join("cache.sqlite")}"\n' ) result = runner.invoke(cli) # This is handy for debugging breakage: if result.exception: print(result.output) raise result.exception assert not result.exception def test_invalid_color(config, runner): config.write('color = 12\npath = "/"\n') result = runner.invoke(cli, ["list"]) assert result.exception assert ( "Error: Bad color setting. Invalid type (expected str, got int)." in result.output ) def test_invalid_color_arg(config, runner): config.write('path = "/"\n') result = runner.invoke(cli, ["--color", "12", "list"]) assert result.exception assert "Usage:" in result.output def test_missing_path(config, runner): config.write('color = "auto"\n') result = runner.invoke(cli, ["list"]) assert result.exception assert "Error: Missing 'path' setting." in result.output @pytest.mark.xfail(reason="Not implemented") def test_extra_entry(config, runner): config.write("color = auto\ndate_format = %Y-%m-%d\npath = /\nblah = false\n") result = runner.invoke(cli, ["list"]) assert result.exception assert "Error: Invalid configuration entry" in result.output @pytest.mark.xfail(reason="Not implemented") def test_extra_section(config, runner): config.write("date_format = %Y-%m-%d\npath = /\n[extra]\ncolor = auto\n") result = runner.invoke(cli, ["list"]) assert result.exception assert "Invalid configuration section" in result.output def test_missing_cache_dir(config, runner, tmpdir): cache_dir = tmpdir.join("does").join("not").join("exist") cache_file = cache_dir.join("cache.sqlite") config.write(f'path = "{tmpdir}/*"\ncache_path = "{cache_file}"\n') result = runner.invoke(cli) assert not result.exception assert cache_dir.isdir() assert cache_file.isfile() def test_date_field_in_time_format(config, runner, tmpdir): config.write('path = "/"\ntime_format = "%Y-%m-%d"\n') result = runner.invoke(cli) assert result.exception assert ( "Found date component in `time_format`, please use `date_format` for that." in result.output ) def test_date_field_in_time(config, runner, tmpdir): config.write('path = "/"\ndate_format = "%Y-%d-:%M"\n') result = runner.invoke(cli) assert result.exception assert ( "Found time component in `date_format`, please use `time_format` for that." in result.output ) def test_colour_validation_auto(config): with patch( "todoman.configuration.find_config", return_value=(str(config)), ): cfg = load_config() assert cfg["color"] == "auto" def test_colour_validation_always(config): config.write("color = 'always'\n", "a") with patch( "todoman.configuration.find_config", return_value=(str(config)), ): cfg = load_config() assert cfg["color"] == "always" def test_colour_validation_invalid(config): config.write("color = 'on_weekends_only'\n", "a") with patch( "todoman.configuration.find_config", return_value=(str(config)), ), pytest.raises(ConfigurationException): load_config() todoman-4.1.0/tests/test_filtering.py000066400000000000000000000211161415552045400177210ustar00rootroot00000000000000from datetime import datetime from datetime import timedelta from todoman.cli import cli from todoman.model import Database from todoman.model import Todo def test_priority(tmpdir, runner, create): result = runner.invoke(cli, ["list"], catch_exceptions=False) assert not result.exception assert not result.output.strip() create("one.ics", "SUMMARY:haha\nPRIORITY:4\n") create("two.ics", "SUMMARY:hoho\nPRIORITY:9\n") create("three.ics", "SUMMARY:hehe\nPRIORITY:5\n") create("four.ics", "SUMMARY:huhu\n") result_high = runner.invoke(cli, ["list", "--priority=high"]) assert not result_high.exception assert "haha" in result_high.output assert "hoho" not in result_high.output assert "huhu" not in result_high.output assert "hehe" not in result_high.output result_medium = runner.invoke(cli, ["list", "--priority=medium"]) assert not result_medium.exception assert "haha" in result_medium.output assert "hehe" in result_medium.output assert "hoho" not in result_medium.output assert "huhu" not in result_medium.output result_low = runner.invoke(cli, ["list", "--priority=low"]) assert not result_low.exception assert "haha" in result_low.output assert "hehe" in result_low.output assert "hoho" in result_low.output assert "huhu" not in result_low.output result_none = runner.invoke(cli, ["list", "--priority=none"]) assert not result_none.exception assert "haha" in result_none.output assert "hehe" in result_none.output assert "hoho" in result_none.output assert "huhu" in result_none.output result_error = runner.invoke(cli, ["list", "--priority=blah"]) assert result_error.exception def test_location(tmpdir, runner, create): result = runner.invoke(cli, ["list"], catch_exceptions=False) assert not result.exception assert not result.output.strip() create("one.ics", "SUMMARY:haha\nLOCATION: The Pool\n") create("two.ics", "SUMMARY:hoho\nLOCATION: The Dungeon\n") create("two.ics", "SUMMARY:harhar\n") result = runner.invoke(cli, ["list", "--location", "Pool"]) assert not result.exception assert "haha" in result.output assert "hoho" not in result.output assert "harhar" not in result.output def test_category(tmpdir, runner, create): result = runner.invoke(cli, ["list"], catch_exceptions=False) assert not result.exception assert not result.output.strip() create("one.ics", "SUMMARY:haha\nCATEGORIES:work,trip\n") create("two.ics", "CATEGORIES:trip\nSUMMARY:hoho\n") create("three.ics", "SUMMARY:harhar\n") result = runner.invoke(cli, ["list", "--category", "work"]) assert not result.exception assert "haha" in result.output assert "hoho" not in result.output assert "harhar" not in result.output def test_grep(tmpdir, runner, create): result = runner.invoke(cli, ["list"], catch_exceptions=False) assert not result.exception assert not result.output.strip() create( "one.ics", "SUMMARY:fun\nDESCRIPTION: Have fun!\n", ) create( "two.ics", "SUMMARY:work\nDESCRIPTION: The stuff for work\n", ) create( "three.ics", "SUMMARY:buy sandwiches\nDESCRIPTION: This is for the Duke\n", ) create( "four.ics", "SUMMARY:puppies\nDESCRIPTION: Feed the puppies\n", ) create( "five.ics", "SUMMARY:research\nDESCRIPTION: Cure cancer\n", ) create("six.ics", "SUMMARY:hoho\n") result = runner.invoke(cli, ["list", "--grep", "fun"]) assert not result.exception assert "fun" in result.output assert "work" not in result.output assert "sandwiches" not in result.output assert "puppies" not in result.output assert "research" not in result.output assert "hoho" not in result.output def test_filtering_lists(tmpdir, runner, create): tmpdir.mkdir("list_one") tmpdir.mkdir("list_two") tmpdir.mkdir("list_three") runner.invoke(cli, ["new", "-l", "list_one", "todo one"]) runner.invoke(cli, ["new", "-l", "list_two", "todo two"]) runner.invoke(cli, ["new", "-l", "list_three", "todo three"]) # No filter result = runner.invoke(cli, ["list"]) assert not result.exception assert len(result.output.splitlines()) == 3 assert "todo one" in result.output assert "@list_one" in result.output assert "todo two" in result.output assert "@list_two" in result.output assert "todo three" in result.output assert "@list_three" in result.output # One filter result = runner.invoke(cli, ["list", "list_two"]) assert not result.exception assert len(result.output.splitlines()) == 1 assert "todo two" in result.output assert "@list_two" not in result.output # Several filters result = runner.invoke(cli, ["list", "list_one", "list_two"]) assert not result.exception assert len(result.output.splitlines()) == 2 assert "todo one" in result.output assert "todo two" in result.output assert "@list_one" in result.output assert "@list_two" in result.output def test_due_aware(tmpdir, runner, create, now_for_tz): db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite")) list_ = next(db.lists()) for tz in ["CET", "HST"]: for i in [1, 23, 25, 48]: todo = Todo(new=True) todo.due = now_for_tz(tz) + timedelta(hours=i) todo.summary = f"{i}" todo.list = list_ db.save(todo) todos = list(db.todos(due=24)) assert len(todos) == 4 assert todos[0].summary == "23" assert todos[1].summary == "23" assert todos[2].summary == "1" assert todos[3].summary == "1" def test_due_naive(tmpdir, runner, create): now = datetime.now() for i in [1, 23, 25, 48]: due = now + timedelta(hours=i) create( f"test_{i}.ics", "SUMMARY:{}\nDUE;VALUE=DATE-TIME:{}\n".format( i, due.strftime("%Y%m%dT%H%M%S"), ), ) db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite")) todos = list(db.todos(due=24)) assert len(todos) == 2 assert todos[0].summary == "23" assert todos[1].summary == "1" def test_filtering_start(tmpdir, runner, todo_factory): today = datetime.now() now = today.strftime("%Y-%m-%d") tomorrow = (today + timedelta(days=1)).strftime("%Y-%m-%d") yesterday = (today + timedelta(days=-1)).strftime("%Y-%m-%d") result = runner.invoke(cli, ["list", "--start", "before", now]) assert not result.exception assert not result.output.strip() result = runner.invoke(cli, ["list", "--start", "after", now]) assert not result.exception assert not result.output.strip() todo_factory(summary="haha", start=today) todo_factory(summary="hoho", start=today) todo_factory(summary="hihi", start=today - timedelta(days=2)) todo_factory(summary="huhu") result = runner.invoke(cli, ["list", "--start", "after", yesterday]) assert not result.exception assert "haha" in result.output assert "hoho" in result.output assert "hihi" not in result.output assert "huhu" not in result.output result = runner.invoke(cli, ["list", "--start", "before", yesterday]) assert not result.exception assert "haha" not in result.output assert "hoho" not in result.output assert "hihi" in result.output assert "huhu" not in result.output result = runner.invoke(cli, ["list", "--start", "after", tomorrow]) assert not result.exception assert "haha" not in result.output assert "hoho" not in result.output assert "hihi" not in result.output assert "huhu" not in result.output def test_statuses(todo_factory, todos): cancelled = todo_factory(status="CANCELLED").uid completed = todo_factory(status="COMPLETED").uid in_process = todo_factory(status="IN-PROCESS").uid needs_action = todo_factory(status="NEEDS-ACTION").uid no_status = todo_factory(status="NEEDS-ACTION").uid all_todos = set(todos(status="ANY")) cancelled_todos = set(todos(status="CANCELLED")) completed_todos = set(todos(status="COMPLETED")) in_process_todos = set(todos(status="IN-PROCESS")) needs_action_todos = set(todos(status="NEEDS-ACTION")) assert {t.uid for t in all_todos} == { cancelled, completed, in_process, needs_action, no_status, } assert {t.uid for t in cancelled_todos} == {cancelled} assert {t.uid for t in completed_todos} == {completed} assert {t.uid for t in in_process_todos} == {in_process} assert {t.uid for t in needs_action_todos} == {needs_action, no_status} todoman-4.1.0/tests/test_formatter.py000066400000000000000000000134271415552045400177470ustar00rootroot00000000000000from datetime import date from datetime import datetime from datetime import time from datetime import timedelta import pytest import pytz from freezegun import freeze_time from tests.helpers import pyicu_sensitive from todoman.cli import cli from todoman.formatters import rgb_to_ansi @pyicu_sensitive @pytest.mark.parametrize("interval", [(65, "in a minute"), (-10800, "3 hours ago")]) @pytest.mark.parametrize("tz", ["CET", "HST"]) @freeze_time("2017-03-25") def test_humanized_datetime(runner, create, interval, now_for_tz, tz): seconds, expected = interval due = now_for_tz(tz) + timedelta(seconds=seconds) create( "test.ics", "SUMMARY:Hi human!\nDUE;VALUE=DATE-TIME;TZID={}:{}\n".format( tz, due.strftime("%Y%m%dT%H%M%S") ), ) result = runner.invoke(cli, ["--humanize", "list", "--status", "ANY"]) assert not result.exception assert expected in result.output @pyicu_sensitive @pytest.mark.parametrize("interval", [(65, "today"), (-10800, "today")]) @pytest.mark.parametrize("tz", ["CET", "HST"]) @freeze_time("2017-03-25 18:00:00") def test_humanized_date(runner, create, interval, now_for_tz, tz): seconds, expected = interval due = now_for_tz(tz) + timedelta(seconds=seconds) create( "test.ics", "SUMMARY:Hi human!\nDUE;VALUE=DATE;TZID={}:{}\n".format( tz, due.strftime("%Y%m%d") ), ) result = runner.invoke(cli, ["--humanize", "list", "--status", "ANY"]) assert not result.exception assert expected in result.output def test_format_priority(default_formatter): assert default_formatter.format_priority(None) == "none" assert default_formatter.format_priority(0) == "none" assert default_formatter.format_priority(5) == "medium" for i in range(1, 5): assert default_formatter.format_priority(i) == "high" for i in range(6, 10): assert default_formatter.format_priority(i) == "low" def test_format_priority_compact(default_formatter): assert default_formatter.format_priority_compact(None) == "" assert default_formatter.format_priority_compact(0) == "" assert default_formatter.format_priority_compact(5) == "!!" for i in range(1, 5): assert default_formatter.format_priority_compact(i) == "!!!" for i in range(6, 10): assert default_formatter.format_priority_compact(i) == "!" def test_format_date(default_formatter): assert default_formatter.format_datetime(date(2017, 3, 4)) == "2017-03-04" def test_format_datetime(default_formatter): assert ( default_formatter.format_datetime(datetime(2017, 3, 4, 17, 00)) == "2017-03-04 17:00" ) def test_detailed_format(runner, todo_factory): todo_factory( description=( "Test detailed formatting\nThis includes multiline descriptions\nBlah!" ), location="Over the hills, and far away", ) # TODO:use formatter instead of runner? result = runner.invoke(cli, ["show", "1"]) expected = [ "[ ] 1 (no due date) YARR! @default", "", "Description:", "Test detailed formatting", "This includes multiline descriptions", "Blah!", "", "Location: Over the hills, and far away", ] assert not result.exception assert result.output.strip().splitlines() == expected def test_parse_time(default_formatter): tz = pytz.timezone("CET") parsed = default_formatter.parse_datetime("12:00") expected = datetime.combine( date.today(), time(hour=12, minute=0), ).replace(tzinfo=tz) assert parsed == expected def test_parse_datetime(default_formatter): tz = pytz.timezone("CET") parsed = default_formatter.parse_datetime("2017-03-05") assert parsed == date(2017, 3, 5) parsed = default_formatter.parse_datetime("2017-03-05 12:00") assert parsed == datetime(2017, 3, 5, 12).replace(tzinfo=tz) # Notes. will round to the NEXT matching date, so we need to freeze time # for this one: with freeze_time("2017-03-04"): parsed = default_formatter.parse_datetime("Mon Mar 6 22:50:52 -03 2017") assert parsed == datetime(2017, 3, 6, 20, 17).replace(tzinfo=tz) assert default_formatter.parse_datetime("") is None assert default_formatter.parse_datetime(None) is None def test_humanized_parse_datetime(humanized_formatter): tz = pytz.timezone("CET") humanized_formatter.now = datetime(2017, 3, 6, 22, 17).replace(tzinfo=tz) dt = datetime(2017, 3, 6, 20, 17).replace(tzinfo=tz) assert humanized_formatter.format_datetime(dt) == "2 hours ago" assert humanized_formatter.format_datetime(None) == "" def test_simple_action(default_formatter, todo_factory): todo = todo_factory() assert default_formatter.simple_action("Delete", todo) == 'Delete "YARR!"' def test_formatting_parsing_consitency(default_formatter): tz = pytz.timezone("CET") dt = datetime(2017, 3, 8, 21, 6).replace(tzinfo=tz) formatted = default_formatter.format_datetime(dt) assert default_formatter.parse_datetime(formatted) == dt def test_rgb_to_ansi(): assert rgb_to_ansi(None) is None assert rgb_to_ansi("#8ab6d") is None assert rgb_to_ansi("#8ab6d2f") == "\x1b[38;2;138;182;210m" assert rgb_to_ansi("red") is None assert rgb_to_ansi("#8ab6d2") == "\x1b[38;2;138;182;210m" def test_format_multiple_with_list(default_formatter, todo_factory): todo = todo_factory() assert todo.list assert ( default_formatter.compact_multiple([todo]) == "[ ] 1 \x1b[35m\x1b[0m \x1b[37m(no due date)\x1b[0m YARR! @default\x1b[0m" ) def test_format_multiple_without_list(default_formatter, todo_factory): todo = todo_factory() todo.list = None assert not todo.list with pytest.raises(ValueError): default_formatter.compact_multiple([todo]) todoman-4.1.0/tests/test_main.py000066400000000000000000000010301415552045400166530ustar00rootroot00000000000000import os import sys from subprocess import PIPE from subprocess import Popen from todoman.cli import cli def test_main(tmpdir, runner): root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") env = os.environ.copy() env["PYTHONPATH"] = root cli_result = runner.invoke(cli, ["--version"]) pipe = Popen( [sys.executable, "-m", "todoman", "--version"], stdout=PIPE, env=env, ) main_output = pipe.communicate()[0] assert cli_result.output == main_output.decode() todoman-4.1.0/tests/test_model.py000066400000000000000000000321561415552045400170440ustar00rootroot00000000000000from datetime import date from datetime import datetime from datetime import timedelta from unittest.mock import patch import pytest import pytz from dateutil.tz import tzlocal from dateutil.tz.tz import tzoffset from freezegun import freeze_time from todoman.exceptions import AlreadyExists from todoman.model import Database from todoman.model import Todo from todoman.model import TodoList from todoman.model import cached_property def test_querying(create, tmpdir): for list in "abc": for i, location in enumerate("abc"): create( f"test{i}.ics", ("SUMMARY:test_querying\r\nLOCATION:{}\r\n").format(location), list_name=list, ) db = Database( [str(tmpdir.ensure_dir(list_)) for list_ in "abc"], str(tmpdir.join("cache")) ) assert len(set(db.todos())) == 9 assert len(set(db.todos(lists="ab"))) == 6 assert len(set(db.todos(lists="ab", location="a"))) == 2 def test_retain_tz(tmpdir, create, todos): create("ar.ics", "SUMMARY:blah.ar\nDUE;VALUE=DATE-TIME;TZID=HST:20160102T000000\n") create("de.ics", "SUMMARY:blah.de\nDUE;VALUE=DATE-TIME;TZID=CET:20160102T000000\n") todos = list(todos()) assert len(todos) == 2 assert todos[0].due == datetime(2016, 1, 2, 0, 0, tzinfo=tzoffset(None, -36000)) assert todos[1].due == datetime(2016, 1, 2, 0, 0, tzinfo=tzoffset(None, 3600)) def test_due_date(tmpdir, create, todos): create("ar.ics", "SUMMARY:blah.ar\nDUE;VALUE=DATE:20170617\n") todos = list(todos()) assert len(todos) == 1 assert todos[0].due == date(2017, 6, 17) def test_change_paths(tmpdir, create): old_todos = set("abcdefghijk") for x in old_todos: create(f"{x}.ics", f"SUMMARY:{x}\n", x) tmpdir.mkdir("3") db = Database([tmpdir.join(x) for x in old_todos], tmpdir.join("cache.sqlite")) assert {t.summary for t in db.todos()} == old_todos db.paths = [str(tmpdir.join("3"))] db.update_cache() assert len(list(db.lists())) == 1 assert not list(db.todos()) def test_list_displayname(tmpdir): tmpdir.join("default").mkdir() with tmpdir.join("default").join("displayname").open("w") as f: f.write("personal") db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3")) list_ = next(db.lists()) assert list_.name == "personal" assert str(list_) == "personal" def test_list_colour(tmpdir): tmpdir.join("default").mkdir() with tmpdir.join("default").join("color").open("w") as f: f.write("#8ab6d2") db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3")) list_ = next(db.lists()) assert list_.colour == "#8ab6d2" def test_list_colour_cache_invalidation(tmpdir, sleep): tmpdir.join("default").mkdir() with tmpdir.join("default").join("color").open("w") as f: f.write("#8ab6d2") db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3")) list_ = next(db.lists()) assert list_.colour == "#8ab6d2" sleep() with tmpdir.join("default").join("color").open("w") as f: f.write("#f874fd") db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3")) list_ = next(db.lists()) assert list_.colour == "#f874fd" def test_list_no_colour(tmpdir): tmpdir.join("default").mkdir() db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3")) list_ = next(db.lists()) assert list_.colour is None def test_database_priority_sorting(create, todos): for i in [1, 5, 9, 0]: create(f"test{i}.ics", f"PRIORITY:{i}\n") create("test_none.ics", "SUMMARY:No priority (eg: None)\n") todos = list(todos()) assert todos[0].priority == 0 assert todos[1].priority == 0 assert todos[2].priority == 9 assert todos[3].priority == 5 assert todos[4].priority == 1 def test_retain_unknown_fields(tmpdir, create, default_database): """ Test that we retain unknown fields after a load/save cycle. """ create("test.ics", "UID:AVERYUNIQUEID\nSUMMARY:RAWR\nX-RAWR-TYPE:Reptar\n") db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite")) todo = db.todo(1, read_only=False) todo.description = 'Rawr means "I love you" in dinosaur.' default_database.save(todo) path = tmpdir.join("default").join("test.ics") with path.open() as f: vtodo = f.read() lines = vtodo.splitlines() assert "SUMMARY:RAWR" in lines assert 'DESCRIPTION:Rawr means "I love you" in dinosaur.' in lines assert "X-RAWR-TYPE:Reptar" in lines def test_todo_setters(todo_factory): todo = todo_factory() todo.description = "A tea would be nice, thanks." assert todo.description == "A tea would be nice, thanks." todo.priority = 7 assert todo.priority == 7 now = datetime.now() todo.due = now assert todo.due == now todo.description = None assert todo.description == "" todo.priority = None assert todo.priority == 0 todo.categories = None assert todo.categories == [] todo.due = None assert todo.due is None @freeze_time("2017-03-19-15") def test_is_completed(): completed_at = datetime(2017, 3, 19, 14, tzinfo=pytz.UTC) todo = Todo() assert todo.is_completed is False todo.completed_at = completed_at assert todo.is_completed is True todo.percent_complete = 20 todo.complete() assert todo.is_completed is True assert todo.completed_at == datetime.now(pytz.UTC) assert todo.percent_complete == 100 assert todo.status == "COMPLETED" @pytest.mark.parametrize( "until", ["20990315T020000Z", "20990315T020000"], # TZ-aware UNTIL # TZ-naive UNTIL ) @pytest.mark.parametrize("tz", [pytz.UTC, None]) # TZ-aware todos # TZ-naive todos @pytest.mark.parametrize("due", [True, False]) def test_complete_recurring(default_database, due, todo_factory, tz, until): # We'll lose the milis when casting, so: now = datetime.now(tz).replace(microsecond=0) if bool(tz) != bool(until.endswith("Z")): pytest.skip("These combinations are invalid, as per the spec.") original_start = now if due: original_due = now + timedelta(hours=12) else: due = original_due = None rrule = f"FREQ=DAILY;UNTIL={until}" todo = todo_factory(rrule=rrule, due=original_due, start=original_start) todo.complete() related = todo.related[0] if due: assert todo.due == original_due else: assert todo.due is None assert todo.start == original_start assert todo.is_completed assert not todo.rrule if due: assert related.due == original_due + timedelta(days=1) else: assert related.due is None assert related.start == original_start + timedelta(days=1) # check due/start tz assert not related.is_completed assert related.rrule == rrule def test_save_recurring_related(default_database, todo_factory, todos): now = datetime.now(pytz.UTC) original_due = now + timedelta(hours=12) rrule = "FREQ=DAILY;UNTIL=20990315T020000Z" todo = todo_factory(rrule=rrule, due=original_due) todo.complete() default_database.save(todo) todos = todos(status="ANY") todo = next(todos) assert todo.percent_complete == 100 assert todo.is_completed is True assert not todo.rrule todo = next(todos) assert todo.percent_complete == 0 assert todo.is_completed is False assert todo.rrule == rrule def test_save_recurring_related_with_date(default_database, todo_factory, todos): now = date.today() original_due = now + timedelta(days=1) rrule = "FREQ=DAILY;UNTIL=20990315" todo = todo_factory(rrule=rrule, due=original_due) todo.complete() default_database.save(todo) todos = todos(status="ANY") todo = next(todos) assert todo.percent_complete == 100 assert todo.is_completed is True assert not todo.rrule todo = next(todos) assert todo.percent_complete == 0 assert todo.is_completed is False assert todo.rrule == rrule def test_todo_filename_absolute_path(): Todo(filename="test.ics") with pytest.raises(ValueError): Todo(filename="/test.ics") def test_list_equality(tmpdir): list1 = TodoList(path=str(tmpdir), name="test list") list2 = TodoList(path=str(tmpdir), name="test list") list3 = TodoList(path=str(tmpdir), name="yet another test list") assert list1 == list2 assert list1 != list3 assert list1 != "test list" def test_clone(): now = datetime.now(tz=tzlocal()) todo = Todo(new=True) todo.summary = "Organize a party" todo.location = "Home" todo.due = now todo.uid = "123" todo.id = "123" todo.filename = "123.ics" clone = todo.clone() assert todo.summary == clone.summary assert todo.location == clone.location assert todo.due == clone.due assert todo.uid != clone.uid assert len(clone.uid) > 32 assert clone.id is None assert todo.filename != clone.filename assert clone.uid in clone.filename @freeze_time("2017, 3, 20") def test_todos_startable(tmpdir, runner, todo_factory, todos): todo_factory(summary="started", start=datetime(2017, 3, 15)) todo_factory(summary="nostart") todo_factory(summary="unstarted", start=datetime(2017, 3, 24)) todos = list(todos(startable=True)) assert len(todos) == 2 for todo in todos: assert "unstarted" not in todo.summary def test_filename_uid_colision(create, default_database, runner, todos): create("ABC.ics", "SUMMARY:My UID is not ABC\nUID:NOTABC\n") assert len(list(todos())) == 1 todo = Todo(new=False) todo.uid = "ABC" todo.list = next(default_database.lists()) default_database.save(todo) assert len(list(todos())) == 2 def test_hide_cancelled(todos, todo_factory): todo_factory(status="CANCELLED") assert len(list(todos())) == 0 assert len(list(todos(status="ANY"))) == 1 def test_illegal_start_suppression(create, default_database, todos): create( "test.ics", "SUMMARY:Start doing stuff\n" "DUE;VALUE=DATE-TIME;TZID=CET:20170331T120000\n" "DTSTART;VALUE=DATE-TIME;TZID=CET:20170331T140000\n", ) todo = next(todos()) assert todo.start is None assert todo.due == datetime(2017, 3, 31, 12, tzinfo=tzoffset(None, 7200)) def test_default_status(create, todos): create("test.ics", "SUMMARY:Finish all these status tests\n") todo = next(todos()) assert todo.status == "NEEDS-ACTION" def test_nullify_field(default_database, todo_factory, todos): todo_factory(due=datetime.now()) todo = next(todos(status="ANY")) assert todo.due is not None todo.due = None default_database.save(todo) todo = next(todos(status="ANY")) assert todo.due is None def test_duplicate_list(tmpdir): tmpdir.join("personal1").mkdir() with tmpdir.join("personal1").join("displayname").open("w") as f: f.write("personal") tmpdir.join("personal2").mkdir() with tmpdir.join("personal2").join("displayname").open("w") as f: f.write("personal") with pytest.raises(AlreadyExists): Database( [tmpdir.join("personal1"), tmpdir.join("personal2")], tmpdir.join("cache.sqlite3"), ) def test_unreadable_ics(todo_factory, todos, tmpdir): """ Test that we properly handle an unreadable ICS file In this case, it's a directory, which will fail even if you run the tests as root (you shouldn't!!), but the same codepath is followed for readonly files, etc. """ tmpdir.join("default").join("fake.ics").mkdir() todo_factory() with patch("logging.Logger.exception") as mocked_exception: todos = list(todos()) assert len(todos) == 1 assert mocked_exception.call_count == 1 def test_cached_property_caching(): class TestClass: i = 0 @cached_property def a(self): TestClass.i += 1 return TestClass.i obj = TestClass() assert obj.a == 1 assert obj.a == 1 assert obj.a == 1 def test_cached_property_overwriting(): class TestClass: i = 0 @cached_property def a(self): TestClass.i += 1 return TestClass.i obj = TestClass() # Overriting will overwrite the cached_property: obj.a = 12 assert obj.a == 12 assert obj.a == 12 obj.a += 1 assert obj.a == 13 def test_cached_property_property(): class TestClass: @cached_property def a(self): return 0 assert TestClass.a.__class__ == cached_property def test_deleting_todo_without_list_fails(tmpdir, default_database): db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3")) todo = Todo() with pytest.raises(ValueError, match="Cannot delete Todo without a list."): db.delete(todo) def test_saving_todo_without_list_fails(tmpdir, default_database): db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3")) todo = Todo() with pytest.raises(ValueError, match="Cannot save Todo without a list."): db.save(todo) def test_todo_path_without_list(tmpdir): todo = Todo() with pytest.raises(ValueError, match="A todo without a list does not have a path."): todo.path todoman-4.1.0/tests/test_porcelain.py000066400000000000000000000134551415552045400177210ustar00rootroot00000000000000import json from datetime import datetime import pytz from todoman.cli import cli from todoman.formatters import PorcelainFormatter def test_list_all(tmpdir, runner, create): create( "test.ics", "SUMMARY:Do stuff\n" "STATUS:COMPLETED\n" "COMPLETED:20181225T191234Z\n" "DUE;VALUE=DATE-TIME;TZID=CET:20160102T000000\n" "PERCENT-COMPLETE:26\n" "LOCATION:Wherever\n", ) result = runner.invoke(cli, ["--porcelain", "list", "--status", "ANY"]) expected = [ { "completed": True, "completed_at": 1545765154, "description": "", "due": 1451689200, "id": 1, "list": "default", "location": "Wherever", "percent": 26, "priority": 0, "summary": "Do stuff", } ] assert not result.exception assert result.output.strip() == json.dumps(expected, indent=4, sort_keys=True) def test_list_due_date(tmpdir, runner, create): create( "test.ics", "SUMMARY:Do stuff\n" "STATUS:COMPLETED\n" "DUE;VALUE=DATE:20160102\n" "PERCENT-COMPLETE:26\n" "LOCATION:Wherever\n", ) result = runner.invoke(cli, ["--porcelain", "list", "--status", "ANY"]) expected = [ { "completed": True, "completed_at": None, "description": "", "due": 1451692800, "id": 1, "list": "default", "location": "Wherever", "percent": 26, "priority": 0, "summary": "Do stuff", } ] assert not result.exception assert result.output.strip() == json.dumps(expected, indent=4, sort_keys=True) def test_list_nodue(tmpdir, runner, create): create("test.ics", "SUMMARY:Do stuff\nPERCENT-COMPLETE:12\nPRIORITY:4\n") result = runner.invoke(cli, ["--porcelain", "list"]) expected = [ { "completed": False, "completed_at": None, "description": "", "due": None, "id": 1, "list": "default", "location": "", "percent": 12, "priority": 4, "summary": "Do stuff", } ] assert not result.exception assert result.output.strip() == json.dumps(expected, indent=4, sort_keys=True) def test_list_priority(tmpdir, runner, create): result = runner.invoke(cli, ["--porcelain", "list"], catch_exceptions=False) assert not result.exception assert result.output.strip() == "[]" create("one.ics", "SUMMARY:haha\nPRIORITY:4\n") create("two.ics", "SUMMARY:hoho\nPRIORITY:9\n") create("three.ics", "SUMMARY:hehe\nPRIORITY:5\n") create("four.ics", "SUMMARY:huhu\n") result_high = runner.invoke(cli, ["--porcelain", "list", "--priority=4"]) assert not result_high.exception assert "haha" in result_high.output assert "hoho" not in result_high.output assert "huhu" not in result_high.output assert "hehe" not in result_high.output result_medium = runner.invoke(cli, ["--porcelain", "list", "--priority=5"]) assert not result_medium.exception assert "haha" in result_medium.output assert "hehe" in result_medium.output assert "hoho" not in result_medium.output assert "huhu" not in result_medium.output result_low = runner.invoke(cli, ["--porcelain", "list", "--priority=9"]) assert not result_low.exception assert "haha" in result_low.output assert "hehe" in result_low.output assert "hoho" in result_low.output assert "huhu" not in result_low.output result_none = runner.invoke(cli, ["--porcelain", "list", "--priority=0"]) assert not result_none.exception assert "haha" in result_none.output assert "hehe" in result_none.output assert "hoho" in result_none.output assert "huhu" in result_none.output result_error = runner.invoke(cli, ["--porcelain", "list", "--priority=18"]) assert result_error.exception def test_show(tmpdir, runner, create): create("test.ics", "SUMMARY:harhar\nDESCRIPTION:Lots of text. Yum!\nPRIORITY:5\n") result = runner.invoke(cli, ["--porcelain", "show", "1"]) expected = { "completed": False, "completed_at": None, "description": "Lots of text. Yum!", "due": None, "id": 1, "list": "default", "location": "", "percent": 0, "priority": 5, "summary": "harhar", } assert not result.exception assert result.output.strip() == json.dumps(expected, indent=4, sort_keys=True) def test_simple_action(todo_factory): formatter = PorcelainFormatter() todo = todo_factory(id=7, location="Downtown") expected = { "completed": False, "completed_at": None, "description": "", "due": None, "id": 7, "list": "default", "location": "Downtown", "percent": 0, "priority": 0, "summary": "YARR!", } assert formatter.simple_action("Delete", todo) == json.dumps( expected, indent=4, sort_keys=True ) def test_format_datetime(): formatter = PorcelainFormatter() dt = datetime(2017, 3, 8, 0, 0, 17, 457955, tzinfo=pytz.UTC) t = 1488931217 assert formatter.format_datetime(dt) == t def test_parse_datetime(): formatter = PorcelainFormatter() expected = datetime(2017, 3, 6, 23, 22, 21, 610429, tzinfo=pytz.UTC) assert formatter.parse_datetime(1488842541.610429) == expected assert formatter.parse_datetime(None) is None assert formatter.parse_datetime("") is None def test_formatting_parsing_consitency(): tz = pytz.timezone("CET") dt = datetime(2017, 3, 8, 21, 6, 19).replace(tzinfo=tz) formatter = PorcelainFormatter(tz_override=tz) assert formatter.parse_datetime(formatter.format_datetime(dt)) == dt todoman-4.1.0/tests/test_ui.py000066400000000000000000000113261415552045400163550ustar00rootroot00000000000000from datetime import datetime from unittest import mock import pytest import pytz from freezegun import freeze_time from urwid import ExitMainLoop from todoman.interactive import TodoEditor def test_todo_editor_priority(default_database, todo_factory, default_formatter): todo = todo_factory(priority=1) lists = list(default_database.lists()) editor = TodoEditor(todo, lists, default_formatter) assert editor._priority.label == "high" editor._priority.keypress(10, "right") with pytest.raises(ExitMainLoop): # Look at editor._msg_text if this fails editor._keypress("ctrl s") assert todo.priority == 0 def test_todo_editor_list(default_database, todo_factory, default_formatter, tmpdir): tmpdir.mkdir("another_list") default_database.paths = [ str(tmpdir.join("default")), str(tmpdir.join("another_list")), ] default_database.update_cache() todo = todo_factory() lists = list(default_database.lists()) editor = TodoEditor(todo, lists, default_formatter) default_list = next(filter(lambda x: x.label == "default", editor.list_selector)) another_list = next( filter(lambda x: x.label == "another_list", editor.list_selector) ) assert editor.current_list == todo.list assert default_list.label == todo.list.name another_list.set_state(True) editor._save_inner() assert editor.current_list == todo.list assert another_list.label == todo.list.name def test_todo_editor_summary(default_database, todo_factory, default_formatter): todo = todo_factory() lists = list(default_database.lists()) editor = TodoEditor(todo, lists, default_formatter) assert editor._summary.edit_text == "YARR!" editor._summary.edit_text = "Goodbye" with pytest.raises(ExitMainLoop): # Look at editor._msg_text if this fails editor._keypress("ctrl s") assert todo.summary == "Goodbye" @freeze_time("2017-03-04 14:00:00", tz_offset=4) def test_todo_editor_due(default_database, todo_factory, default_formatter): tz = pytz.timezone("CET") todo = todo_factory(due=datetime(2017, 3, 4, 14)) lists = list(default_database.lists()) default_formatter.tz = tz editor = TodoEditor(todo, lists, default_formatter) assert editor._due.edit_text == "2017-03-04 14:00" editor._due.edit_text = "2017-03-10 12:00" with pytest.raises(ExitMainLoop): # Look at editor._msg_text if this fails editor._keypress("ctrl s") assert todo.due == datetime(2017, 3, 10, 12, tzinfo=tz) def test_toggle_help(default_database, default_formatter, todo_factory): todo = todo_factory() lists = list(default_database.lists()) editor = TodoEditor(todo, lists, default_formatter) editor._loop = mock.MagicMock() assert editor._help_text not in editor.left_column.body.contents editor._keypress("f1") # Help text is made visible assert editor._help_text in editor.left_column.body.contents # Called event_loop.draw_screen assert editor._loop.draw_screen.call_count == 1 assert editor._loop.draw_screen.call_args == mock.call() editor._keypress("f1") # Help text is made visible assert editor._help_text not in editor.left_column.body.contents # Called event_loop.draw_screen assert editor._loop.draw_screen.call_count == 2 assert editor._loop.draw_screen.call_args == mock.call() def test_show_save_errors(default_database, default_formatter, todo_factory): todo = todo_factory() lists = list(default_database.lists()) editor = TodoEditor(todo, lists, default_formatter) # editor._loop = mock.MagicMock() editor._due.set_edit_text("not a date") editor._keypress("ctrl s") assert ( editor.left_column.body.contents[2].get_text()[0] == "Time description not recognized: not a date" ) @pytest.mark.parametrize("completed", [True, False]) @pytest.mark.parametrize("check", [True, False]) def test_save_completed(check, completed, default_formatter, todo_factory): todo = todo_factory() if completed: todo.complete() editor = TodoEditor(todo, [todo.list], default_formatter) editor._completed.state = check with pytest.raises(ExitMainLoop): editor._keypress("ctrl s") assert todo.is_completed is check def test_ctrl_c_clears(default_formatter, todo_factory): todo = todo_factory() editor = TodoEditor(todo, [todo.list], default_formatter) # Simulate that ctrl+c gets pressed, since we can't *really* do that # trivially inside unit tests. with mock.patch( "urwid.main_loop.MainLoop.run", side_effect=KeyboardInterrupt ), mock.patch( "urwid.main_loop.MainLoop.stop", ) as mocked_stop: editor.edit() assert mocked_stop.call_count == 1 todoman-4.1.0/tests/test_widgets.py000066400000000000000000000144451415552045400174130ustar00rootroot00000000000000from unittest import mock from todoman.widgets import ExtendedEdit from todoman.widgets import PrioritySelector # We ignore `size` when testing keypresses, because it's not used anywhere. # Just pass any number when writing tests, unless we start using the value. BASE_STRING = "The lazy fox bla bla\n@ät" def test_extended_edit_delete_word(): extended_edit = ExtendedEdit(None) extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 9 extended_edit.keypress(10, "ctrl w") assert extended_edit.edit_pos == 4 assert extended_edit.get_edit_text() == "The fox bla bla\n@ät" extended_edit.set_edit_text("The-lazy-fox-bla-bla\n@ät") extended_edit.edit_pos = 8 extended_edit.keypress(10, "ctrl w") assert extended_edit.edit_pos == 4 assert extended_edit.get_edit_text() == "The--fox-bla-bla\n@ät" extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 6 extended_edit.keypress(10, "ctrl w") assert extended_edit.edit_pos == 4 assert extended_edit.get_edit_text() == "The zy fox bla bla\n@ät" extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 0 extended_edit.keypress(10, "ctrl w") assert extended_edit.edit_pos == 0 assert extended_edit.get_edit_text() == BASE_STRING def test_extended_edit_delete_sol(): extended_edit = ExtendedEdit(None) extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 9 extended_edit.keypress(10, "ctrl u") assert extended_edit.edit_pos == 0 assert extended_edit.get_edit_text() == "fox bla bla\n@ät" extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 0 extended_edit.keypress(10, "ctrl u") assert extended_edit.edit_pos == 0 assert extended_edit.get_edit_text() == BASE_STRING def test_extended_edit_delete_eol(): extended_edit = ExtendedEdit(None) extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 9 extended_edit.keypress(10, "ctrl k") assert extended_edit.edit_pos == 9 assert extended_edit.get_edit_text() == "The lazy \n@ät" extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 20 extended_edit.keypress(10, "ctrl k") assert extended_edit.edit_pos == 20 assert extended_edit.get_edit_text() == BASE_STRING extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 24 extended_edit.keypress(10, "ctrl k") assert extended_edit.edit_pos == 24 assert extended_edit.get_edit_text() == BASE_STRING def test_extended_edit_goto_sol(): extended_edit = ExtendedEdit(None) extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 9 extended_edit.keypress(10, "ctrl a") assert extended_edit.edit_pos == 0 assert extended_edit.get_edit_text() == BASE_STRING extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 23 extended_edit.keypress(10, "ctrl a") assert extended_edit.edit_pos == 21 assert extended_edit.get_edit_text() == BASE_STRING def test_extended_edit_goto_eol(): extended_edit = ExtendedEdit(None) extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 9 extended_edit.keypress(10, "ctrl e") assert extended_edit.edit_pos == 20 assert extended_edit.get_edit_text() == BASE_STRING extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 22 extended_edit.keypress(10, "ctrl e") assert extended_edit.edit_pos == 24 assert extended_edit.get_edit_text() == BASE_STRING def test_extended_edit_delete_next_char(): extended_edit = ExtendedEdit(None) extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 9 extended_edit.keypress(10, "ctrl d") assert extended_edit.edit_pos == 9 assert extended_edit.get_edit_text() == "The lazy ox bla bla\n@ät" extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 0 extended_edit.keypress(10, "ctrl d") assert extended_edit.edit_pos == 0 assert extended_edit.get_edit_text() == "he lazy fox bla bla\n@ät" extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 24 extended_edit.keypress(10, "ctrl d") assert extended_edit.edit_pos == 24 assert extended_edit.get_edit_text() == "The lazy fox bla bla\n@ät" extended_edit.set_edit_text(BASE_STRING) extended_edit.edit_pos = 24 extended_edit.keypress(10, "ctrl d") assert extended_edit.edit_pos == 24 assert extended_edit.get_edit_text() == "The lazy fox bla bla\n@ät" def test_extended_edit_input(): """ Very basic test to make sure we don't break basic editing We don't need to do more testing because that's done upstream. We basically want to test that we properly forward unhandled keypresses. """ extended_edit = ExtendedEdit(mock.MagicMock()) extended_edit.keypress((10,), "h") extended_edit.keypress((10,), "i") assert extended_edit.get_edit_text() == "hi" def test_extended_edit_editor(): extended_edit = ExtendedEdit(mock.MagicMock()) extended_edit.set_edit_text(BASE_STRING) with mock.patch("click.edit", return_value="Sheep!") as edit: extended_edit.keypress(10, "ctrl o") assert edit.call_count == 1 assert edit.call_args == mock.call(BASE_STRING) assert extended_edit.get_edit_text() == "Sheep!" def test_priority_selector(default_formatter): selector = PrioritySelector(None, 5, default_formatter.format_priority) assert selector.label == "medium" assert selector.priority == 5 selector.keypress(10, "right") assert selector.label == "high" assert selector.priority == 1 selector.keypress(10, "left") selector.keypress(10, "left") assert selector.label == "low" assert selector.priority == 9 selector.keypress(10, "right") assert selector.label == "medium" assert selector.priority == 5 # Spin the whoel way around: for _ in PrioritySelector.RANGES: selector.keypress(10, "right") assert selector.label == "medium" assert selector.priority == 5 # Now the other way for _ in PrioritySelector.RANGES: selector.keypress(10, "left") assert selector.label == "medium" assert selector.priority == 5 # Should do nothing: selector.keypress(10, "d") selector.keypress(10, "9") assert selector.label == "medium" assert selector.priority == 5 todoman-4.1.0/todoman/000077500000000000000000000000001415552045400146235ustar00rootroot00000000000000todoman-4.1.0/todoman/__init__.py000066400000000000000000000002051415552045400167310ustar00rootroot00000000000000from todoman import version # type: ignore __version__ = version.version __documentation__ = "https://todoman.rtfd.org/en/latest/" todoman-4.1.0/todoman/__main__.py000066400000000000000000000001361415552045400167150ustar00rootroot00000000000000from todoman.cli import cli if __name__ == "__main__": cli(auto_envvar_prefix="TODOMAN") todoman-4.1.0/todoman/cli.py000066400000000000000000000403501415552045400157460ustar00rootroot00000000000000import functools import glob import locale import sys from contextlib import contextmanager from datetime import timedelta from os.path import isdir import click import click_log from todoman import exceptions from todoman import formatters from todoman.configuration import ConfigurationException from todoman.configuration import load_config from todoman.interactive import TodoEditor from todoman.model import Database from todoman.model import Todo from todoman.model import cached_property click_log.basic_config() @contextmanager def handle_error(): try: yield except exceptions.TodomanException as e: click.echo(e) sys.exit(e.EXIT_CODE) def catch_errors(f): @functools.wraps(f) def wrapper(*a, **kw): with handle_error(): return f(*a, **kw) return wrapper TODO_ID_MIN = 1 with_id_arg = click.argument("id", type=click.IntRange(min=TODO_ID_MIN)) def _validate_lists_param(ctx, param=None, lists=()): return [_validate_list_param(ctx, name=list_) for list_ in lists] def _validate_list_param(ctx, param=None, name=None): ctx = ctx.find_object(AppContext) if name is None: if ctx.config["default_list"]: name = ctx.config["default_list"] else: raise click.BadParameter("You must set `default_list` or use -l.") lists = {list_.name: list_ for list_ in ctx.db.lists()} fuzzy_matches = [ list_ for list_ in lists.values() if list_.name.lower() == name.lower() ] if len(fuzzy_matches) == 1: return fuzzy_matches[0] # case-insensitive matching collides or does not find a result, # use exact matching if name in lists: return lists[name] raise click.BadParameter( "{}. Available lists are: {}".format( name, ", ".join(list_.name for list_ in lists.values()) ) ) def _validate_date_param(ctx, param, val): ctx = ctx.find_object(AppContext) try: return ctx.formatter.parse_datetime(val) except ValueError as e: raise click.BadParameter(e) def _validate_priority_param(ctx, param, val): ctx = ctx.find_object(AppContext) try: return ctx.formatter.parse_priority(val) except ValueError as e: raise click.BadParameter(e) def _validate_start_date_param(ctx, param, val): ctx = ctx.find_object(AppContext) if not val: return val if len(val) != 2 or val[0] not in ["before", "after"]: raise click.BadParameter("Format should be '[before|after] [DATE]'") is_before = val[0] == "before" try: dt = ctx.formatter.parse_datetime(val[1]) return is_before, dt except ValueError as e: raise click.BadParameter(e) def _validate_startable_param(ctx, param, val): ctx = ctx.find_object(AppContext) return val or ctx.config["startable"] def _validate_todos(ctx, param, val): ctx = ctx.find_object(AppContext) with handle_error(): return [ctx.db.todo(int(id)) for id in val] def _sort_callback(ctx, param, val): fields = val.split(",") if val else [] for field in fields: if field.startswith("-"): field = field[1:] if field not in Todo.ALL_SUPPORTED_FIELDS and field != "id": raise click.BadParameter(f"Unknown field '{field}'") return fields def validate_status(ctx=None, param=None, val=None) -> str: statuses = val.upper().split(",") if "ANY" in statuses: return ",".join(Todo.VALID_STATUSES) for status in statuses: if status not in Todo.VALID_STATUSES: raise click.BadParameter( 'Invalid status, "{}", statuses must be one of "{}", or "ANY"'.format( status, ", ".join(Todo.VALID_STATUSES) ) ) return val def _todo_property_options(command): click.option( "--priority", default="", callback=_validate_priority_param, help="Priority for this task", )(command) click.option("--location", help="The location where this todo takes place.")( command ) click.option( "--due", "-d", default="", callback=_validate_date_param, help=("Due date of the task, in the format specified in the configuration."), )(command) click.option( "--start", "-s", default="", callback=_validate_date_param, help="When the task starts.", )(command) @functools.wraps(command) def command_wrap(*a, **kw): kw["todo_properties"] = { key: kw.pop(key) for key in ("due", "start", "location", "priority") } return command(*a, **kw) return command_wrap class AppContext: def __init__(self): self.config = None self.db = None self.formatter_class = None @cached_property def ui_formatter(self): return formatters.DefaultFormatter( self.config["date_format"], self.config["time_format"], self.config["dt_separator"], ) @cached_property def formatter(self): return self.formatter_class( self.config["date_format"], self.config["time_format"], self.config["dt_separator"], ) pass_ctx = click.make_pass_decorator(AppContext) _interactive_option = click.option( "--interactive", "-i", is_flag=True, default=None, help="Go into interactive mode before saving the task.", ) @click.group(invoke_without_command=True) @click_log.simple_verbosity_option() @click.option( "--colour", "--color", "colour", default=None, type=click.Choice(["always", "auto", "never"]), help=( "By default todoman will disable colored output if stdout " "is not a TTY (value `auto`). Set to `never` to disable " "colored output entirely, or `always` to enable it " "regardless." ), ) @click.option( "--porcelain", is_flag=True, help=( "Use a JSON format that will " "remain stable regardless of configuration or version." ), ) @click.option( "--humanize", "-h", default=None, is_flag=True, help="Format all dates and times in a human friendly way", ) @click.option( "--config", "-c", default=None, help="The config file to use.", envvar="TODOMAN_CONFIG", metavar="PATH", ) @click.pass_context @click.version_option(prog_name="todoman") @catch_errors def cli(click_ctx, colour, porcelain, humanize, config): ctx = click_ctx.ensure_object(AppContext) try: ctx.config = load_config(config) except ConfigurationException as e: raise click.ClickException(e.args[0]) if porcelain and humanize: raise click.ClickException( "--porcelain and --humanize cannot be used at the same time." ) if humanize is None: # False means explicitly disabled humanize = ctx.config["humanize"] if porcelain: ctx.formatter_class = formatters.PorcelainFormatter elif humanize: ctx.formatter_class = formatters.HumanizedFormatter else: ctx.formatter_class = formatters.DefaultFormatter colour = colour or ctx.config["color"] if colour == "always": click_ctx.color = True elif colour == "never": click_ctx.color = False paths = [ path for path in glob.iglob(ctx.config["path"]) if isdir(path) and not path.endswith("__pycache__") ] if len(paths) == 0: raise exceptions.NoListsFound(ctx.config["path"]) ctx.db = Database(paths, ctx.config["cache_path"]) # Make python actually use LC_TIME, or the user's locale settings locale.setlocale(locale.LC_TIME, "") if not click_ctx.invoked_subcommand: invoke_command( click_ctx, ctx.config["default_command"], ) def invoke_command(click_ctx, command): name, *raw_args = command.split(" ") if name not in cli.commands: raise click.ClickException("Invalid setting for [default_command]") parser = cli.commands[name].make_parser(click_ctx) opts, args, param_order = parser.parse_args(raw_args) for param in param_order: opts[param.name] = param.handle_parse_result(click_ctx, opts, args)[0] click_ctx.invoke(cli.commands[name], *args, **opts) try: # pragma: no cover import click_repl click_repl.register_repl(cli) click_repl.register_repl(cli, name="shell") except ImportError: pass @cli.command() @click.argument("summary", nargs=-1) @click.option( "--list", "-l", callback=_validate_list_param, help="List in which the task will be saved.", ) @click.option( "--read-description", "-r", is_flag=True, default=False, help="Read task description from stdin.", ) @_todo_property_options @_interactive_option @pass_ctx @catch_errors def new(ctx, summary, list, todo_properties, read_description, interactive): """ Create a new task with SUMMARY. """ todo = Todo(new=True, list=list) default_due = ctx.config["default_due"] if default_due: todo.due = todo.created_at + timedelta(hours=default_due) default_priority = ctx.config["default_priority"] if default_priority is not None: todo.priority = default_priority for key, value in todo_properties.items(): if value: setattr(todo, key, value) todo.summary = " ".join(summary) if read_description: todo.description = "\n".join(sys.stdin) if interactive or (not summary and interactive is None): ui = TodoEditor(todo, ctx.db.lists(), ctx.ui_formatter) ui.edit() click.echo() # work around lines going missing after urwid if not todo.summary: raise click.UsageError("No SUMMARY specified") ctx.db.save(todo) click.echo(ctx.formatter.detailed(todo)) @cli.command() @pass_ctx @click.option( "--raw", is_flag=True, help=( "Open the raw file for editing in $EDITOR.\n" "Only use this if you REALLY know what you're doing!" ), ) @_todo_property_options @_interactive_option @with_id_arg @catch_errors def edit(ctx, id, todo_properties, interactive, raw): """ Edit the task with id ID. """ todo = ctx.db.todo(id) if raw: click.edit(filename=todo.path) return old_list = todo.list changes = False for key, value in todo_properties.items(): if value is not None: changes = True setattr(todo, key, value) if interactive or (not changes and interactive is None): ui = TodoEditor(todo, ctx.db.lists(), ctx.ui_formatter) ui.edit() # This little dance avoids duplicates when changing the list: new_list = todo.list todo.list = old_list ctx.db.save(todo) if old_list != new_list: ctx.db.move(todo, new_list=new_list, from_list=old_list) click.echo(ctx.formatter.detailed(todo)) @cli.command() @pass_ctx @with_id_arg @catch_errors def show(ctx, id): """ Show details about a task. """ todo = ctx.db.todo(id, read_only=True) click.echo(ctx.formatter.detailed(todo)) @cli.command() @pass_ctx @click.argument( "todos", nargs=-1, required=True, type=click.IntRange(0), callback=_validate_todos, ) @catch_errors def done(ctx, todos): """Mark one or more tasks as done.""" for todo in todos: todo.complete() ctx.db.save(todo) click.echo(ctx.formatter.detailed(todo)) @cli.command() @pass_ctx @click.argument( "todos", nargs=-1, required=True, type=click.IntRange(0), callback=_validate_todos, ) @catch_errors def cancel(ctx, todos): """Cancel one or more tasks.""" for todo in todos: todo.cancel() ctx.db.save(todo) click.echo(ctx.formatter.detailed(todo)) @cli.command() @pass_ctx @click.confirmation_option(prompt="Are you sure you want to delete all done tasks?") @catch_errors def flush(ctx): """ Delete done tasks. This will also clear the cache to reset task IDs. """ database = ctx.db for todo in database.flush(): click.echo(ctx.formatter.simple_action("Flushing", todo)) @cli.command() @pass_ctx @click.argument("ids", nargs=-1, required=True, type=click.IntRange(0)) @click.option("--yes", is_flag=True, default=False) @catch_errors def delete(ctx, ids, yes): """ Delete tasks. Permanently deletes one or more task. It is recommended that you use the `cancel` command if you wish to remove this from the pending list, but keep the actual task around. """ todos = [] for i in ids: todo = ctx.db.todo(i) click.echo(ctx.formatter.compact(todo)) todos.append(todo) if not yes: click.confirm("Do you want to delete those tasks?", abort=True) for todo in todos: click.echo(ctx.formatter.simple_action("Deleting", todo)) ctx.db.delete(todo) @cli.command() @pass_ctx @click.option( "--list", "-l", callback=_validate_list_param, help="The list to copy the tasks to." ) @click.argument("ids", nargs=-1, required=True, type=click.IntRange(0)) @catch_errors def copy(ctx, list, ids): """Copy tasks to another list.""" for id in ids: original = ctx.db.todo(id) todo = original.clone() todo.list = list click.echo(ctx.formatter.compact(todo)) ctx.db.save(todo) @cli.command() @pass_ctx @click.option( "--list", "-l", callback=_validate_list_param, help="The list to move the tasks to." ) @click.argument("ids", nargs=-1, required=True, type=click.IntRange(0)) @catch_errors def move(ctx, list, ids): """Move tasks to another list.""" for id in ids: todo = ctx.db.todo(id) click.echo(ctx.formatter.compact(todo)) ctx.db.move(todo, new_list=list, from_list=todo.list) @cli.command() @pass_ctx @click.argument("lists", nargs=-1, callback=_validate_lists_param) @click.option("--location", help="Only show tasks with location containg TEXT") @click.option("--category", help="Only show tasks with category containg TEXT") @click.option("--grep", help="Only show tasks with message containg TEXT") @click.option( "--sort", help=( "Sort tasks using fields like : " '"start", "due", "priority", "created_at", "percent_complete" etc.' "\nFor all fields please refer to: " " " ), callback=_sort_callback, ) @click.option( "--reverse/--no-reverse", default=True, help="Sort tasks in reverse order (see --sort). Defaults to true.", ) @click.option( "--due", default=None, help="Only show tasks due in INTEGER hours", type=int ) @click.option( "--priority", default=None, help=( "Only show tasks with priority at least as high as TEXT (low, medium or high)." ), type=str, callback=_validate_priority_param, ) @click.option( "--start", default=None, callback=_validate_start_date_param, nargs=2, help="Only shows tasks before/after given DATE", ) @click.option( "--startable", default=None, is_flag=True, callback=_validate_startable_param, help=( "Show only todos which " "should can be started today (i.e.: start time is not in the " "future)." ), ) @click.option( "--status", "-s", default="NEEDS-ACTION,IN-PROCESS", callback=validate_status, help=( "Show only todos with the " "provided comma-separated statuses. Valid statuses are " '"NEEDS-ACTION", "CANCELLED", "COMPLETED", "IN-PROCESS" or "ANY"' ), ) @catch_errors def list(ctx, *args, **kwargs): """ List tasks (default). Filters any completed or cancelled tasks by default. If no arguments are provided, all lists will be displayed, and only incomplete tasks are show. Otherwise, only todos for the specified list will be displayed. eg: \b - `todo list' shows all unfinished tasks from all lists. - `todo list work' shows all unfinished tasks from the list `work`. This is the default action when running `todo'. The following commands can further filter shown todos, or include those omited by default: """ hide_list = (len([_ for _ in ctx.db.lists()]) == 1) or ( # noqa: C416 len(kwargs["lists"]) == 1 ) todos = ctx.db.todos(**kwargs) click.echo(ctx.formatter.compact_multiple(todos, hide_list)) todoman-4.1.0/todoman/configuration.py000066400000000000000000000153401415552045400200470ustar00rootroot00000000000000import importlib import os from os.path import exists from os.path import join from typing import Any from typing import Callable from typing import List from typing import NamedTuple from typing import Optional from typing import Tuple from typing import Type from typing import Union import xdg.BaseDirectory from todoman import __documentation__ def expand_path(path): """expands `~` as well as variable names""" return os.path.expanduser(os.path.expandvars(path)) def validate_cache_path(path): path = path.replace("$XDG_CACHE_HOME", xdg.BaseDirectory.xdg_cache_home) return expand_path(path) def validate_date_format(fmt): if any(x in fmt for x in ("%H", "%M", "%S", "%X")): raise ConfigurationException( "Found time component in `date_format`, please use `time_format` for that." ) return fmt def validate_time_format(fmt): if any(x in fmt for x in ("%Y", "%y", "%m", "%d", "%x")): raise ConfigurationException( "Found date component in `time_format`, please use `date_format` for that." ) return fmt def validate_color_config(value: str): if value not in ["always", "auto", "never"]: raise ConfigurationException("Invalid `color` settings.") return value def validate_default_priority(value: int): if value and not (0 <= value <= 9): raise ConfigurationException("Invalid `default_priority` settings.") return value class ConfigEntry(NamedTuple): name: str type: Union[Type, Tuple[Type]] default: Any description: str validation: Optional[Callable] NO_DEFAULT = object() # A list of tuples (name, type, default, description, validation) CONFIG_SPEC: List[ConfigEntry] = [ ConfigEntry( "path", str, NO_DEFAULT, """ A glob pattern matching the directories where your todos are located. This pattern will be expanded, and each matching directory (with any icalendar files) will be treated as a list.""", expand_path, ), ConfigEntry( "color", str, "auto", """ By default todoman will disable colored output if stdout is not a TTY (value ``auto``). Set to ``never`` to disable colored output entirely, or ``always`` to enable it regardless. This can be overridden with the ``--color`` option. """, validate_color_config, ), ConfigEntry( "date_format", str, "%x", """ The date format used both for displaying dates, and parsing input dates. If this option is not specified the system locale's is used. """, validate_date_format, ), ConfigEntry( "time_format", str, "%X", """ The date format used both for displaying times, and parsing input times. """, validate_time_format, ), ConfigEntry( "dt_separator", str, " ", """ The string used to separate date and time when displaying and parsing. """, None, ), ConfigEntry( "humanize", bool, False, """ If set to true, datetimes will be printed in human friendly formats like "tomorrow", "in one hour", "3 weeks ago", etc. If false, datetimes will be formatted using ``date_format`` and ``time_format``. """, None, ), ConfigEntry( "default_list", (str, None.__class__), # type: ignore None, """ The default list for adding a todo. If you do not specify this option, you must use the ``--list`` / ``-l`` option every time you add a todo. """, None, ), ConfigEntry( "default_due", int, 24, """ The default difference (in hours) between new todo’s due date and creation date. If not specified, the value is 24. If set to 0, the due date for new todos will not be set. """, None, ), ConfigEntry( "cache_path", str, "$XDG_CACHE_HOME/todoman/cache.sqlite3", """ The location of the cache file (an sqlite database). This file is used to store todo data and speed up execution/startup, and also contains the IDs for todos. If the value is not specified, a path relative to ``$XDG_CACHE_HOME`` will be used. ``$XDG_CACHE_HOME`` generally resolves to ``~/.cache/``. """, validate_cache_path, ), ConfigEntry( "startable", bool, False, """ If set to true, only show todos which are currently startable; these are todos which have a start date today, or some day in the past. Todos with no start date are always considered current. Incomplete todos (eg: partially-complete) are also included. """, None, ), ConfigEntry( "default_command", str, "list", """ When running ``todo`` with no commands, run this command. """, None, ), ConfigEntry( "default_priority", (int, None.__class__), # type: ignore None, """ The default priority of a task on creation. Highest priority is 1, lowest priority is 10, and 0 means no priority at all. """, validate_default_priority, ), ] class ConfigurationException(Exception): def __init__(self, msg): super().__init__( ( "{}\nFor details on the configuration format and a sample file, " "see\n{}configure.html" ).format(msg, __documentation__) ) def find_config(config_path=None): if not config_path: for d in xdg.BaseDirectory.xdg_config_dirs: path = join(d, "todoman", "config.py") if exists(path): config_path = path break if not config_path: raise ConfigurationException("No configuration file found.\n\n") elif not exists(config_path): raise ConfigurationException( f"Configuration file {config_path} does not exist.\n" ) else: return config_path def load_config(custom_path=None): path = find_config(custom_path) spec = importlib.util.spec_from_file_location("config", path) config_source = importlib.util.module_from_spec(spec) spec.loader.exec_module(config_source) # TODO: Handle SyntaxError config = {} for name, type_, default, _description, validation in CONFIG_SPEC: value = getattr(config_source, name, default) if value == NO_DEFAULT: raise ConfigurationException(f"Missing '{name}' setting.") if not isinstance(value, type_): expected = type_.__name__ actual = value.__class__.__name__ raise ConfigurationException( f"Bad {name} setting. Invalid type " f"(expected {expected}, got {actual})." ) if validation: value = validation(value) config[name] = value return config todoman-4.1.0/todoman/exceptions.py000066400000000000000000000020221415552045400173520ustar00rootroot00000000000000class TodomanException(Exception): """ Base class for all our exceptions. Should not be raised directly. """ pass class NoSuchTodo(TodomanException): EXIT_CODE = 20 def __str__(self): return f"No todo with id {self.args[0]}." class ReadOnlyTodo(TodomanException): EXIT_CODE = 21 def __str__(self): return ( "Todo is in read-only mode because there are multiple todosin {}.".format( self.args[0] ) ) class NoListsFound(TodomanException): EXIT_CODE = 22 def __str__(self): return "No lists found matching {}, create a directory for a new list.".format( self.args[0] ) class AlreadyExists(TodomanException): """ Raised when two objects have a same identity. This can ocurrs when two lists have the same name, or when two Todos have the same path. """ EXIT_CODE = 23 def __str__(self): return "More than one {} has the same identity: {}.".format(*self.args) todoman-4.1.0/todoman/formatters.py000066400000000000000000000217721415552045400173740ustar00rootroot00000000000000import json from datetime import date from datetime import datetime from datetime import timedelta from time import mktime from typing import Iterable from typing import Optional from typing import Union import click import humanize import parsedatetime import pytz from dateutil.tz import tzlocal from todoman.model import Todo from todoman.model import TodoList def rgb_to_ansi(colour: Optional[str]) -> Optional[str]: """ Convert a string containing an RGB colour to ANSI escapes """ if not colour or not colour.startswith("#"): return None r, g, b = colour[1:3], colour[3:5], colour[5:7] if not len(r) == len(g) == len(b) == 2: return None return f"\33[38;2;{int(r, 16)!s};{int(g, 16)!s};{int(b, 16)!s}m" class DefaultFormatter: def __init__( self, date_format="%Y-%m-%d", time_format="%H:%M", dt_separator=" ", tz_override=None, ): self.date_format = date_format self.time_format = time_format self.dt_separator = dt_separator self.datetime_format = dt_separator.join( filter(bool, (date_format, time_format)) ) self.tz = tz_override or tzlocal() self.now = datetime.now().replace(tzinfo=self.tz) self._parsedatetime_calendar = parsedatetime.Calendar( version=parsedatetime.VERSION_CONTEXT_STYLE, ) def simple_action(self, action: str, todo: Todo) -> str: return f'{action} "{todo.summary}"' def compact(self, todo: Todo) -> str: return self.compact_multiple([todo]) def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: # TODO: format lines fuidly and drop the table # it can end up being more readable when too many columns are empty. # show dates that are in the future in yellow (in 24hs) or grey (future) table = [] for todo in todos: completed = "X" if todo.is_completed else " " percent = todo.percent_complete or "" if percent: percent = f" ({percent}%)" priority = click.style( self.format_priority_compact(todo.priority), fg="magenta", ) due = self.format_datetime(todo.due) or "(no due date)" now = self.now if isinstance(todo.due, datetime) else self.now.date() due_colour = None if todo.due: if todo.due <= now and not todo.is_completed: due_colour = "red" elif todo.due >= now + timedelta(hours=24): due_colour = "white" elif todo.due >= now: due_colour = "yellow" else: due_colour = "white" if due_colour: due = click.style(str(due), fg=due_colour) recurring = "⟳" if todo.is_recurring else "" if hide_list: summary = "{} {}".format( todo.summary, percent, ) else: if not todo.list: raise ValueError("Cannot format todo without a list") summary = "{} {}{}".format( todo.summary, self.format_database(todo.list), percent, ) # TODO: add spaces on the left based on max todos" # FIXME: double space when no priority table.append( f"[{completed}] {todo.id} {priority} {due} {recurring}{summary}" ) return "\n".join(table) def _format_multiline(self, title: str, value: str) -> str: formatted_title = click.style(title, fg="white") if value.strip().count("\n") == 0: return f"\n\n{formatted_title}: {value}" else: return f"\n\n{formatted_title}:\n{value}" def detailed(self, todo: Todo) -> str: """Returns a detailed representation of a task. :param todo: The todo component. """ extra_lines = [] if todo.description: extra_lines.append(self._format_multiline("Description", todo.description)) if todo.location: extra_lines.append(self._format_multiline("Location", todo.location)) return f"{self.compact(todo)}{''.join(extra_lines)}" def format_datetime(self, dt: Optional[date]) -> Union[str, int, None]: if not dt: return "" elif isinstance(dt, datetime): return dt.strftime(self.datetime_format) elif isinstance(dt, date): return dt.strftime(self.date_format) def parse_priority(self, priority: Optional[str]) -> Optional[int]: if priority is None or priority == "": return None if priority == "low": return 9 elif priority == "medium": return 5 elif priority == "high": return 4 elif priority == "none": return 0 else: raise ValueError("Priority has to be one of low, medium, high or none") def format_priority(self, priority: Optional[int]) -> str: if not priority: return "none" elif 1 <= priority <= 4: return "high" elif priority == 5: return "medium" elif 6 <= priority <= 9: return "low" def format_priority_compact(self, priority: Optional[int]) -> str: if not priority: return "" elif 1 <= priority <= 4: return "!!!" elif priority == 5: return "!!" elif 6 <= priority <= 9: return "!" def parse_datetime(self, dt: str) -> Optional[date]: if not dt: return None rv = self._parse_datetime_naive(dt) return rv.replace(tzinfo=self.tz) if isinstance(rv, datetime) else rv def _parse_datetime_naive(self, dt: str) -> date: """Parse dt and returns a naive datetime or a date""" try: return datetime.strptime(dt, self.datetime_format) except ValueError: pass try: return datetime.strptime(dt, self.date_format).date() except ValueError: pass try: return datetime.combine( self.now.date(), datetime.strptime(dt, self.time_format).time() ) except ValueError: pass rv, pd_ctx = self._parsedatetime_calendar.parse(dt) if not pd_ctx.hasDateOrTime: raise ValueError(f"Time description not recognized: {dt}") return datetime.fromtimestamp(mktime(rv)) def format_database(self, database: TodoList): return "{}@{}".format( rgb_to_ansi(database.colour) or "", click.style(database.name) ) class HumanizedFormatter(DefaultFormatter): def format_datetime(self, dt: Optional[date]) -> str: if not dt: return "" if isinstance(dt, datetime): rv = humanize.naturaltime(self.now - dt) if " from now" in rv: rv = f"in {rv[:-9]}" elif isinstance(dt, date): rv = humanize.naturaldate(dt) return rv class PorcelainFormatter(DefaultFormatter): def _todo_as_dict(self, todo): return { "completed": todo.is_completed, "due": self.format_datetime(todo.due), "id": todo.id, "list": todo.list.name, "percent": todo.percent_complete, "summary": todo.summary, "priority": todo.priority, "location": todo.location, "description": todo.description, "completed_at": self.format_datetime(todo.completed_at), } def compact(self, todo: Todo) -> str: return json.dumps(self._todo_as_dict(todo), indent=4, sort_keys=True) def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: data = [self._todo_as_dict(todo) for todo in todos] return json.dumps(data, indent=4, sort_keys=True) def simple_action(self, action: str, todo: Todo) -> str: return self.compact(todo) def parse_priority(self, priority: Optional[str]) -> Optional[int]: if priority is None: return None try: if int(priority) in range(0, 10): return int(priority) else: raise ValueError("Priority has to be in the range 0-9") except ValueError as e: raise click.BadParameter(str(e)) def detailed(self, todo: Todo) -> str: return self.compact(todo) def format_datetime(self, value: Optional[date]) -> Optional[int]: if value: if not isinstance(value, datetime): dt = datetime.fromordinal(value.toordinal()) else: dt = value return int(dt.timestamp()) else: return None def parse_datetime(self, value): if value: return datetime.fromtimestamp(value, tz=pytz.UTC) else: return None todoman-4.1.0/todoman/interactive.py000066400000000000000000000137461415552045400175250ustar00rootroot00000000000000import urwid from todoman import widgets _palette = [("error", "light red", "")] class TodoEditor: """ The UI for a single todo entry. """ def __init__(self, todo, lists, formatter): """ :param model.Todo todo: The todo object which will be edited. """ self.current_list = todo.list self.todo = todo self.lists = list(lists) self.formatter = formatter self._loop = None self._status = urwid.Text("") self._init_basic_fields() self._init_list_selector() self._init_help_text() save_btn = urwid.Button("Save", on_press=self._save) cancel_text = urwid.Text("Hit Ctrl-C to cancel, F1 for help.") buttons = urwid.Columns([(8, save_btn), cancel_text], dividechars=2) pile_items = [] for label, field in [ ("Summary", self._summary), ("Description", self._description), ("Location", self._location), ("Start", self._dtstart), ("Due", self._due), ("Completed", self._completed), ("Priority", self._priority), ]: label = urwid.Text(label + ":", align="right") column = urwid.Columns([(13, label), field], dividechars=1) pile_items.append(("pack", column)) grid = urwid.Pile(pile_items) spacer = urwid.Divider() self.left_column = urwid.ListBox( urwid.SimpleListWalker([grid, spacer, self._status, buttons]) ) right_column = urwid.ListBox( urwid.SimpleListWalker([urwid.Text("List:\n")] + self.list_selector) ) self._ui = urwid.Columns([self.left_column, right_column]) def _init_basic_fields(self): self._summary = widgets.ExtendedEdit( parent=self, edit_text=self.todo.summary, ) self._description = widgets.ExtendedEdit( parent=self, edit_text=self.todo.description, multiline=True, ) self._location = widgets.ExtendedEdit( parent=self, edit_text=self.todo.location, ) self._due = widgets.ExtendedEdit( parent=self, edit_text=self.formatter.format_datetime(self.todo.due), ) self._dtstart = widgets.ExtendedEdit( parent=self, edit_text=self.formatter.format_datetime(self.todo.start), ) self._completed = urwid.CheckBox("", state=self.todo.is_completed) self._priority = widgets.PrioritySelector( parent=self, priority=self.todo.priority, formatter_function=self.formatter.format_priority, ) def _init_list_selector(self): self.list_selector = [] for _list in self.lists: urwid.RadioButton( self.list_selector, _list.name, state=_list == self.current_list, on_state_change=self._change_current_list, user_data=_list, ) def _init_help_text(self): self._help_text = urwid.Text( "\n\n" "Global:\n" " F1: Toggle help\n" " Ctrl-C: Cancel\n" " Ctrl-S: Save (only works if not a shell shortcut already)\n" "\n" "In Textfields:\n" + "\n".join(f" {k}: {v}" for k, v in widgets.ExtendedEdit.HELP) + "\n\nIn Priority Selector:\n" + "\n".join(f" {k}: {v}" for k, v in widgets.PrioritySelector.HELP) ) def _change_current_list(self, radio_button, new_state, new_list): if new_state: self.current_list = new_list def _toggle_help(self): if self.left_column.body.contents[-1] is self._help_text: self.left_column.body.contents.pop() else: self.left_column.body.contents.append(self._help_text) self._loop.draw_screen() def set_status(self, text): self._status.set_text(text) def edit(self): """Shows the UI for editing a given todo.""" self._loop = urwid.MainLoop( self._ui, palette=_palette, unhandled_input=self._keypress, handle_mouse=False, ) try: self._loop.run() except KeyboardInterrupt: self._loop.stop() # Try to leave terminal in usable state self._loop = None def _save(self, btn=None): try: self._save_inner() except Exception as e: self.set_status(("error", str(e))) else: raise urwid.ExitMainLoop() def _save_inner(self): self.todo.list = self.current_list self.todo.summary = self.summary self.todo.description = self.description self.todo.location = self.location self.todo.due = self.formatter.parse_datetime(self.due) self.todo.start = self.formatter.parse_datetime(self.dtstart) if not self.todo.is_completed and self._completed.get_state(): self.todo.complete() elif self.todo.is_completed and not self._completed.get_state(): self.todo.status = "NEEDS-ACTION" self.todo.completed_at = None self.todo.priority = self.priority # TODO: categories # TODO: comment # https://tools.ietf.org/html/rfc5545#section-3.8 # geo (lat, lon) # RESOURCE: the main room def _keypress(self, key): if key.lower() == "f1": self._toggle_help() elif key == "ctrl s": self._save() @property def summary(self): return self._summary.edit_text @property def description(self): return self._description.edit_text @property def location(self): return self._location.edit_text @property def due(self): return self._due.edit_text @property def dtstart(self): return self._dtstart.edit_text @property def priority(self): return self._priority.priority todoman-4.1.0/todoman/model.py000066400000000000000000001017551415552045400163060ustar00rootroot00000000000000from __future__ import annotations import logging import os import socket import sqlite3 from datetime import date from datetime import datetime from datetime import time from datetime import timedelta from os.path import normpath from os.path import split from typing import Iterable from uuid import uuid4 import icalendar import pytz from atomicwrites import AtomicWriter from dateutil.rrule import rrulestr from dateutil.tz import tzlocal from todoman import exceptions logger = logging.getLogger(name=__name__) # Initialize this only once # We were doing this all over the place (even if unused!), so at least only do # it once. LOCAL_TIMEZONE = tzlocal() class cached_property: """A read-only @property that is only evaluated once. Only usable on class instances' methods. """ def __init__(self, fget, doc=None): self.__name__ = fget.__name__ self.__module__ = fget.__module__ self.__doc__ = doc or fget.__doc__ self.fget = fget def __get__(self, obj, cls): if obj is None: return self obj.__dict__[self.__name__] = result = self.fget(obj) return result class Todo: """ Represents a task/todo, and wrapps around icalendar.Todo. All text attributes are always treated as text, and "" will be returned if they are not defined. Date attributes are treated as datetime objects, and None will be returned if they are not defined. All datetime objects have tzinfo, either the one defined in the file, or the local system's one. """ categories: list[str] completed_at: datetime | None created_at: datetime | None due: date | None dtstamp: datetime | None last_modified: datetime | None related: list[Todo] rrule: str | None start: date | None def __init__( self, filename: str = None, mtime: int = None, new: bool = False, list: TodoList = None, ): """ Creates a new todo using `todo` as a source. :param str filename: The name of the file for this todo. Defaults to the .ics :param mtime int: The last modified time for the file backing this Todo. :param bool new: Indicate that a new Todo is being created and should be populated with default values. :param TodoList list: The list to which this Todo belongs. """ self.list = list now = datetime.now(LOCAL_TIMEZONE) self.uid = f"{uuid4().hex}@{socket.gethostname()}" if new: self.created_at = now else: self.created_at = None # Default values for supported fields self.categories = [] self.completed_at = None self.description = "" self.dtstamp = now self.due = None self.id = None self.last_modified = None self.location = "" self.percent_complete = 0 self.priority = 0 self.rrule = "" self.sequence = 0 self.start = None self.status = "NEEDS-ACTION" self.summary = "" self.filename = filename or f"{self.uid}.ics" self.related = [] if os.path.basename(self.filename) != self.filename: raise ValueError(f"Must not be an absolute path: {self.filename}") self.mtime = mtime or datetime.now() def clone(self) -> Todo: """ Returns a clone of this todo Returns a copy of this todo, which is almost identical, except that is has a different UUID and filename. """ todo = Todo(new=True, list=self.list) fields = ( Todo.STRING_FIELDS + Todo.INT_FIELDS + Todo.LIST_FIELDS + Todo.DATETIME_FIELDS ) fields.remove("uid") for field in fields: setattr(todo, field, getattr(self, field)) return todo STRING_FIELDS = [ "description", "location", "status", "summary", "uid", "rrule", ] INT_FIELDS = [ "percent_complete", "priority", "sequence", ] LIST_FIELDS = [ "categories", ] DATETIME_FIELDS = [ "completed_at", "created_at", "dtstamp", "start", "due", "last_modified", ] RRULE_FIELDS = [ "rrule", ] ALL_SUPPORTED_FIELDS = ( DATETIME_FIELDS + INT_FIELDS + LIST_FIELDS + RRULE_FIELDS + STRING_FIELDS ) VALID_STATUSES = ( "CANCELLED", "COMPLETED", "IN-PROCESS", "NEEDS-ACTION", ) def __setattr__(self, name: str, value): """Check type and avoid setting fields to None""" """when that is not a valid attribue.""" v = value if name in Todo.RRULE_FIELDS: if value is None: v = "" else: assert isinstance( value, str ), f"Got {type(value)} for {name} where str was expected" if name in Todo.STRING_FIELDS: if value is None: v = "" else: assert isinstance( value, str ), f"Got {type(value)} for {name} where str was expected" if name in Todo.INT_FIELDS: if value is None: v = 0 else: assert isinstance( value, int ), f"Got {type(value)} for {name} where int was expected" if name in Todo.LIST_FIELDS: if value is None: v = [] else: assert isinstance( value, list ), f"Got {type(value)} for {name} where list was expected" return object.__setattr__(self, name, v) @property def is_completed(self) -> bool: return bool(self.completed_at) or self.status in ("CANCELLED", "COMPLETED") @property def is_recurring(self) -> bool: return bool(self.rrule) def _apply_recurrence_to_dt(self, dt) -> datetime | None: if not dt: return None recurrence = rrulestr(self.rrule, dtstart=dt) if isinstance(dt, date) and not isinstance(dt, datetime): dt = datetime.combine(dt, time.min) return recurrence.after(dt) def _create_next_instance(self): copy = self.clone() copy.due = self._apply_recurrence_to_dt(self.due) copy.start = self._apply_recurrence_to_dt(self.start) assert copy.uid != self.uid # TODO: Push copy's alarms. return copy def complete(self) -> None: """ Immediately completes this todo Immediately marks this todo as completed, sets the percentage to 100% and the completed_at datetime to now. If this todo belongs to a series, newly created todo are added to the ``related`` list. """ if self.is_recurring: related = self._create_next_instance() if related: self.rrule = None self.related.append(related) self.completed_at = datetime.now(tz=LOCAL_TIMEZONE) self.percent_complete = 100 self.status = "COMPLETED" @cached_property def path(self) -> str: if not self.list: raise ValueError("A todo without a list does not have a path.") return os.path.join(self.list.path, self.filename) def cancel(self) -> None: self.status = "CANCELLED" class VtodoWriter: """Writes a Todo as a VTODO file.""" """Maps Todo field names to VTODO field names""" FIELD_MAP = { "summary": "summary", "priority": "priority", "sequence": "sequence", "uid": "uid", "categories": "categories", "completed_at": "completed", "description": "description", "dtstamp": "dtstamp", "start": "dtstart", "due": "due", "location": "location", "percent_complete": "percent-complete", "priority": "priority", "status": "status", "created_at": "created", "last_modified": "last-modified", "rrule": "rrule", } def __init__(self, todo: Todo): self.todo = todo def normalize_datetime(self, dt: date) -> date: """ Eliminate several differences between dates, times and datetimes which are hindering comparison: - Convert everything to datetime - Add missing timezones - Cast to UTC Datetimes are cast to UTC because icalendar doesn't include the VTIMEZONE information upon serialization, and some clients have issues dealing with that. """ if isinstance(dt, date) and not isinstance(dt, datetime): return dt if not dt.tzinfo: dt = dt.replace(tzinfo=LOCAL_TIMEZONE) return dt.astimezone(pytz.UTC) def serialize_field(self, name: str, value): if name in Todo.RRULE_FIELDS: return icalendar.vRecur.from_ical(value) if name in Todo.DATETIME_FIELDS: return self.normalize_datetime(value) if name in Todo.LIST_FIELDS: return value if name in Todo.INT_FIELDS: return int(value) if name in Todo.STRING_FIELDS: return value raise Exception(f"Unknown field {name} serialized.") def set_field(self, name: str, value): # If serialized value is None: self.vtodo.pop(name) if value: logger.debug("Setting field %s to %s.", name, value) self.vtodo.add(name, value) def serialize(self, original=None): """Serialize a Todo into a VTODO.""" if not original: original = icalendar.Todo() self.vtodo = original for source, target in self.FIELD_MAP.items(): self.vtodo.pop(target) if getattr(self.todo, source): self.set_field( target, self.serialize_field(source, getattr(self.todo, source)), ) return self.vtodo def _read(self, path): with open(path, "rb") as f: cal = f.read() cal = icalendar.Calendar.from_ical(cal) for component in cal.walk("VTODO"): return component def write(self): if os.path.exists(self.todo.path): self._write_existing(self.todo.path) else: self._write_new(self.todo.path) return self.vtodo def _write_existing(self, path): original = self._read(path) vtodo = self.serialize(original) with open(path, "rb") as f: cal = icalendar.Calendar.from_ical(f.read()) for index, component in enumerate(cal.subcomponents): if component.get("uid", None) == self.todo.uid: cal.subcomponents[index] = vtodo with AtomicWriter(path, "wb", overwrite=True).open() as f: f.write(cal.to_ical()) def _write_new(self, path): vtodo = self.serialize() c = icalendar.Calendar() c.add_component(vtodo) with AtomicWriter(path, "wb").open() as f: c.add("prodid", "io.barrera.todoman") c.add("version", "2.0") f.write(c.to_ical()) return vtodo class Cache: """ Caches Todos for faster read and simpler querying interface The Cache class persists relevant[1] fields into an SQL database, which is only updated if the actual file has been modified. This greatly increases load times, but, more importantly, provides a simpler interface for filtering/querying/sorting. [1]: Relevant fields are those we show when listing todos, or those which may be used for filtering/sorting. """ SCHEMA_VERSION = 7 def __init__(self, path: str): self.cache_path = str(path) os.makedirs(os.path.dirname(self.cache_path), exist_ok=True) self._conn = sqlite3.connect(self.cache_path) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA foreign_keys = ON") self.create_tables() def save_to_disk(self) -> None: self._conn.commit() def is_latest_version(self): """Checks if the cache DB schema is the latest version.""" try: return self._conn.execute( "SELECT version FROM meta WHERE version = ?", (Cache.SCHEMA_VERSION,), ).fetchone() except sqlite3.OperationalError: return False def create_tables(self): if self.is_latest_version(): return self._conn.executescript( """ DROP TABLE IF EXISTS lists; DROP TABLE IF EXISTS files; DROP TABLE IF EXISTS todos; """ ) self._conn.execute('CREATE TABLE IF NOT EXISTS meta ("version" INT)') self._conn.execute( "INSERT INTO meta (version) VALUES (?)", (Cache.SCHEMA_VERSION,), ) self._conn.execute( """ CREATE TABLE IF NOT EXISTS lists ( "name" TEXT PRIMARY KEY, "path" TEXT, "colour" TEXT, "mtime" INTEGER, CONSTRAINT path_unique UNIQUE (path) ); """ ) self._conn.execute( """ CREATE TABLE IF NOT EXISTS files ( "path" TEXT PRIMARY KEY, "list_name" TEXT, "mtime" INTEGER, CONSTRAINT path_unique UNIQUE (path), FOREIGN KEY(list_name) REFERENCES lists(name) ON DELETE CASCADE ); """ ) self._conn.execute( """ CREATE TABLE IF NOT EXISTS todos ( "file_path" TEXT, "id" INTEGER PRIMARY KEY, "uid" TEXT, "summary" TEXT, "due" INTEGER, "due_dt" INTEGER, "start" INTEGER, "start_dt" INTEGER, "priority" INTEGER, "created_at" INTEGER, "completed_at" INTEGER, "percent_complete" INTEGER, "dtstamp" INTEGER, "status" TEXT, "description" TEXT, "location" TEXT, "categories" TEXT, "sequence" INTEGER, "last_modified" INTEGER, "rrule" TEXT, FOREIGN KEY(file_path) REFERENCES files(path) ON DELETE CASCADE ); """ ) def clear(self): self._conn.close() os.remove(self.cache_path) self._conn = None def add_list(self, name: str, path: str, colour: str, mtime: int): """ Inserts a new list into the cache. Returns the id of the newly inserted list. """ result = self._conn.execute( "SELECT name FROM lists WHERE path = ?", (path,), ).fetchone() if result: return result["name"] try: self._conn.execute( """ INSERT INTO lists ( name, path, colour, mtime ) VALUES (?, ?, ?, ?) """, ( name, path, colour, mtime, ), ) except sqlite3.IntegrityError as e: raise exceptions.AlreadyExists("list", name) from e return self.add_list(name, path, colour, mtime) def add_file(self, list_name: str, path: str, mtime: int): try: self._conn.execute( """ INSERT INTO files ( list_name, path, mtime ) VALUES (?, ?, ?); """, ( list_name, path, mtime, ), ) except sqlite3.IntegrityError as e: raise exceptions.AlreadyExists("file", list_name) from e def _serialize_datetime( self, todo: icalendar.Todo, field: str, ) -> tuple[int | None, bool | None]: """ Serialize a todo field in two value, the first one is the corresponding timestamp, the second one is a boolean indicating if the serialized value is a date or a datetime. :param icalendar.Todo todo: An icalendar component object :param str field: The name of the field to serialize """ dt = todo.decoded(field, None) if not dt: return None, None is_date = isinstance(dt, date) and not isinstance(dt, datetime) if is_date: dt = datetime(dt.year, dt.month, dt.day) if not dt.tzinfo: dt = dt.replace(tzinfo=LOCAL_TIMEZONE) return dt.timestamp(), is_date def _serialize_rrule(self, todo, field) -> str | None: rrule = todo.get(field) if not rrule: return None return rrule.to_ical().decode() def _serialize_categories(self, todo, field) -> str: categories = todo.get(field, []) if not categories: return "" return ",".join([str(category) for category in categories.cats]) def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: """ Adds a todo into the cache. :param icalendar.Todo todo: The icalendar component object on which """ sql = """ INSERT INTO todos ( {} file_path, uid, summary, due, due_dt, start, start_dt, priority, created_at, completed_at, percent_complete, dtstamp, status, description, location, categories, sequence, last_modified, rrule ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ due, due_dt = self._serialize_datetime(todo, "due") start, start_dt = self._serialize_datetime(todo, "dtstart") if start and due: start = None if start >= due else start params = [ file_path, todo.get("uid"), todo.get("summary"), due, due_dt, start, start_dt, todo.get("priority", 0) or None, self._serialize_datetime(todo, "created")[0], self._serialize_datetime(todo, "completed")[0], todo.get("percent-complete", None), self._serialize_datetime(todo, "dtstamp")[0], todo.get("status", "NEEDS-ACTION"), todo.get("description", None), todo.get("location", None), self._serialize_categories(todo, "categories"), todo.get("sequence", 1), self._serialize_datetime(todo, "last-modified")[0], self._serialize_rrule(todo, "rrule"), ] if id: params = [id] + params sql = sql.format("id,\n", "?, ") else: sql = sql.format("", "") cursor = self._conn.cursor() try: cursor.execute(sql, params) rv = cursor.lastrowid finally: cursor.close() return rv def todos( self, lists=(), priority=None, location="", category="", grep="", sort=(), reverse=True, due=None, start=None, startable=False, status="NEEDS-ACTION,IN-PROCESS", ) -> Iterable[Todo]: """ Returns filtered cached todos, in a specified order. If no order is specified, todos are sorted by the following fields:: completed_at -priority due -created_at :param list lists: Only return todos for these lists. :param str location: Only return todos with a location containing this string. :param str category: Only return todos with a category containing this string. :param str grep: Filter common fields with this substring. :param list sort: Order returned todos by these fields. Field names with a ``-`` prepended will be used to sort in reverse order. :param bool reverse: Reverse the order of the todos after sorting. :param int due: Return only todos due within ``due`` hours. :param str priority: Only return todos with priority at least as high as specified. :param tuple(bool, datetime) start: Return only todos before/after ``start`` date :param list(str) status: Return only todos with any of the given statuses. :return: A sorted, filtered list of todos. :rtype: generator """ extra_where = [] params: list = [] if "ANY" not in status: statuses = status.split(",") extra_where.append( "AND (status IN ({}) OR status IS NULL)".format( ", ".join(["?"] * len(statuses)) ) ) params.extend(s.upper() for s in statuses) if lists: lists = [ list_.name if isinstance(list_, TodoList) else list_ for list_ in lists ] q = ", ".join(["?"] * len(lists)) extra_where.append(f"AND files.list_name IN ({q})") params.extend(lists) if priority: extra_where.append("AND PRIORITY > 0 AND PRIORITY <= ?") params.append(f"{priority}") if location: extra_where.append("AND location LIKE ?") params.append(f"%{location}%") if category: extra_where.append("AND categories LIKE ?") params.append(f"%{category}%") if grep: # # requires sqlite with pcre, which won't be available everywhere: # extra_where.append('AND summary REGEXP ?') # params.append(grep) extra_where.append("AND summary LIKE ?") params.append(f"%{grep}%") if due: max_due = (datetime.now() + timedelta(hours=due)).timestamp() extra_where.append("AND due IS NOT NULL AND due < ?") params.append(max_due) if start: is_before, dt = start dt = dt.timestamp() if is_before: extra_where.append("AND start <= ?") params.append(dt) else: extra_where.append("AND start >= ?") params.append(dt) if startable: extra_where.append("AND (start IS NULL OR start <= ?)") params.append(datetime.now().timestamp()) if sort: order_items = [] for s in sort: if s.startswith("-"): order_items.append(f" {s[1:]} ASC") else: order_items.append(f" {s} DESC") order = ",".join(order_items) else: order = """ completed_at DESC, priority IS NOT NULL, priority DESC, due IS NOT NULL, due DESC, created_at ASC """ if not reverse: # Note the change in case to avoid swapping all of them. sqlite # doesn't care about casing anyway. order = order.replace(" DESC", " asc").replace(" ASC", " desc") query = """ SELECT todos.*, files.list_name, files.path FROM todos, files WHERE todos.file_path = files.path {} ORDER BY {} """.format( " ".join(extra_where), order, ) logger.debug(query) logger.debug(params) result = self._conn.execute(query, params) seen_paths = set() warned_paths = set() for row in result: todo = self._todo_from_db(row) path = row["path"] if path in seen_paths and path not in warned_paths: logger.warning( "Todo is in read-only mode because there are multiple todos in %s", path, ) warned_paths.add(path) seen_paths.add(path) yield todo def _datetime_from_db(self, dt) -> datetime | None: if dt: return datetime.fromtimestamp(dt, LOCAL_TIMEZONE) return None def _date_from_db(self, dt, is_date=False) -> date | None: """Deserialise a date (possible datetime).""" if not dt: return dt if is_date: return datetime.fromtimestamp(dt, LOCAL_TIMEZONE).date() else: return datetime.fromtimestamp(dt, LOCAL_TIMEZONE) def _todo_from_db(self, row: dict) -> Todo: todo = Todo() todo.id = row["id"] todo.uid = row["uid"] todo.summary = row["summary"] todo.due = self._date_from_db(row["due"], row["due_dt"]) todo.start = self._date_from_db(row["start"], row["start_dt"]) todo.priority = row["priority"] todo.created_at = self._datetime_from_db(row["created_at"]) todo.completed_at = self._datetime_from_db(row["completed_at"]) todo.dtstamp = self._datetime_from_db(row["dtstamp"]) todo.percent_complete = row["percent_complete"] todo.status = row["status"] todo.description = row["description"] todo.location = row["location"] todo.sequence = row["sequence"] todo.last_modified = row["last_modified"] todo.list = self.lists_map[row["list_name"]] todo.filename = os.path.basename(row["path"]) todo.rrule = row["rrule"] return todo def lists(self) -> Iterable[TodoList]: result = self._conn.execute("SELECT * FROM lists") for row in result: yield TodoList( name=row["name"], path=row["path"], colour=row["colour"], ) @cached_property def lists_map(self) -> dict[str, TodoList]: return {list_.name: list_ for list_ in self.lists()} def expire_lists(self, paths: dict[str, int]) -> None: results = self._conn.execute("SELECT path, name, mtime from lists") for result in results: if result["path"] not in paths: self.delete_list(result["name"]) else: mtime = paths.get(result["path"]) if mtime and mtime > result["mtime"]: self.delete_list(result["name"]) def delete_list(self, name: str) -> None: self._conn.execute("DELETE FROM lists WHERE lists.name = ?", (name,)) def todo(self, id: int, read_only=False) -> Todo: # XXX: DON'T USE READ_ONLY result = self._conn.execute( """ SELECT todos.*, files.list_name, files.path FROM todos, files WHERE files.path = todos.file_path AND todos.id = ? """, (id,), ).fetchone() if not result: raise exceptions.NoSuchTodo(id) if not read_only: count = self._conn.execute( """ SELECT count(id) AS c FROM files, todos WHERE todos.file_path = files.path AND path=? """, (result["path"],), ).fetchone() if count["c"] > 1: raise exceptions.ReadOnlyTodo(result["path"]) return self._todo_from_db(result) def expire_files(self, paths_to_mtime: dict[str, int]) -> None: """Remove stale cache entries based on the given fresh data.""" result = self._conn.execute("SELECT path, mtime FROM files") for row in result: path, mtime = row["path"], row["mtime"] if paths_to_mtime.get(path, None) != mtime: self.expire_file(path) def expire_file(self, path: str) -> None: self._conn.execute("DELETE FROM files WHERE path = ?", (path,)) class TodoList: def __init__(self, name: str, path: str, colour: str = None): self.path = path self.name = name self.colour = colour @staticmethod def colour_for_path(path: str) -> str | None: try: with open(os.path.join(path, "color")) as f: return f.read().strip() except OSError: logger.debug("No colour for list %s", path) @staticmethod def name_for_path(path: str) -> str: try: with open(os.path.join(path, "displayname")) as f: return f.read().strip() except OSError: return split(normpath(path))[1] @staticmethod def mtime_for_path(path: str) -> int: colour_file = os.path.join(path, "color") display_file = os.path.join(path, "displayname") mtimes = [] if os.path.exists(colour_file): mtimes.append(_getmtime(colour_file)) if os.path.exists(display_file): mtimes.append(_getmtime(display_file)) if mtimes: return max(mtimes) else: return 0 def __eq__(self, other) -> bool: if isinstance(other, TodoList): return self.name == other.name return object.__eq__(self, other) def __str__(self) -> str: return self.name class Database: """ This class is essentially a wrapper around all the lists (which in turn, contain all the todos). Caching in abstracted inside this class, and is transparent to outside classes. """ def __init__(self, paths, cache_path): self.cache = Cache(cache_path) self.paths = [str(path) for path in paths] self.update_cache() def update_cache(self) -> None: paths = {path: TodoList.mtime_for_path(path) for path in self.paths} self.cache.expire_lists(paths) paths_to_mtime = {} paths_to_list_name = {} for path in self.paths: list_name = self.cache.add_list( TodoList.name_for_path(path), path, TodoList.colour_for_path(path), paths[path], ) for entry in os.listdir(path): if not entry.endswith(".ics"): continue entry_path = os.path.join(path, entry) mtime = _getmtime(entry_path) paths_to_mtime[entry_path] = mtime paths_to_list_name[entry_path] = list_name self.cache.expire_files(paths_to_mtime) for entry_path, mtime in paths_to_mtime.items(): list_name = paths_to_list_name[entry_path] try: self.cache.add_file(list_name, entry_path, mtime) except exceptions.AlreadyExists: logger.debug("File already in cache: %s", entry_path) continue try: with open(entry_path, "rb") as f: cal = icalendar.Calendar.from_ical(f.read()) for component in cal.walk("VTODO"): self.cache.add_vtodo(component, entry_path) except Exception: logger.exception("Failed to read entry %s.", entry_path) self.cache.save_to_disk() def todos(self, **kwargs) -> Iterable[Todo]: return self.cache.todos(**kwargs) def todo(self, id: int, **kwargs) -> Todo: return self.cache.todo(id, **kwargs) def lists(self) -> Iterable[TodoList]: return self.cache.lists() def move(self, todo: Todo, new_list: TodoList, from_list: TodoList) -> None: orig_path = os.path.join(from_list.path, todo.filename) dest_path = os.path.join(new_list.path, todo.filename) os.rename(orig_path, dest_path) def delete(self, todo: Todo) -> None: if not todo.list: raise ValueError("Cannot delete Todo without a list.") path = os.path.join(todo.list.path, todo.filename) os.remove(path) def flush(self) -> Iterable[Todo]: for todo in self.todos(status=["ANY"]): if todo.is_completed: yield todo self.delete(todo) self.cache.clear() self.cache = None def save(self, todo: Todo) -> None: if not todo.list: raise ValueError("Cannot save Todo without a list.") for related in todo.related: self.save(related) todo.sequence += 1 todo.last_modified = datetime.now(LOCAL_TIMEZONE) vtodo = VtodoWriter(todo).write() self.cache.expire_file(todo.path) mtime = _getmtime(todo.path) self.cache.add_file(todo.list.name, todo.path, mtime) todo.id = self.cache.add_vtodo(vtodo, todo.path, todo.id) self.cache.save_to_disk() def _getmtime(path: str) -> int: stat = os.stat(path) return getattr(stat, "st_mtime_ns", stat.st_mtime) todoman-4.1.0/todoman/widgets.py000066400000000000000000000125111415552045400166430ustar00rootroot00000000000000# Copyright (c) 2016-2020 Hugo Osvaldo Barrera # Copyright (c) 2013-2016 Christian Geier et al. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import re import click import urwid class ExtendedEdit(urwid.Edit): """A text editing widget supporting some more editing commands""" HELP = [ ("Ctrl-W", "Delete word"), ("Ctrl-U", "Delete until beginning of line"), ("Ctrl-K", "Delete until end of line"), ("Ctrl-A", "Go to beginning of line"), ("Ctrl-E", "Go to end of line"), ("Ctrl-D", "Delete forward letter"), ("Ctrl-O", "Edit in $EDITOR"), ] def __init__(self, parent, *a, **kw): self._parent = parent super().__init__(*a, **kw) def keypress(self, size, key): if key == "ctrl w": self._delete_word() elif key == "ctrl u": self._delete_till_beginning_of_line() elif key == "ctrl k": self._delete_till_end_of_line() elif key == "ctrl a": self._goto_beginning_of_line() elif key == "ctrl e": self._goto_end_of_line() elif key == "ctrl d": self._delete_forward_letter() elif key == "ctrl o": # Allow editing in $EDITOR self._editor() # TODO: alt b, alt f else: return super().keypress(size, key) def _delete_forward_letter(self): text = self.get_edit_text() pos = self.edit_pos text = text[:pos] + text[pos + 1 :] self.set_edit_text(text) def _delete_word(self): """delete word before cursor""" text = self.get_edit_text() t = text[: self.edit_pos].rstrip() words = re.findall(r"[\w]+|[^\w\s]", t, re.UNICODE) if t == "": f_text = t else: f_text = t[: len(t) - len(words[-1])] self.set_edit_text(f_text + text[self.edit_pos :]) self.set_edit_pos(len(f_text)) def _delete_till_beginning_of_line(self): """delete till start of line before cursor""" text = self.get_edit_text() sol = text.rfind("\n", 0, self.edit_pos) + 1 before_line = text[:sol] self.set_edit_text(before_line + text[self.edit_pos :]) self.set_edit_pos(sol) def _delete_till_end_of_line(self): """delete till end of line before cursor""" text = self.get_edit_text() eol = text.find("\n", self.edit_pos) if eol == -1: after_eol = "" else: after_eol = text[eol:] self.set_edit_text(text[: self.edit_pos] + after_eol) def _goto_beginning_of_line(self): text = self.get_edit_text() sol = text.rfind("\n", 0, self.edit_pos) + 1 self.set_edit_pos(sol) def _goto_end_of_line(self): text = self.get_edit_text() eol = text.find("\n", self.edit_pos) if eol == -1: eol = len(text) self.set_edit_pos(eol) def _editor(self): self._parent._loop.screen.clear() new_text = click.edit(self.get_edit_text()) if new_text is not None: self.set_edit_text(new_text.strip()) class PrioritySelector(urwid.Button): HELP = [ ("left", "Lower Priority"), ("right", "Higher Priority"), ] RANGES = [ [0], [9, 8, 7, 6], [5], [1, 2, 3, 4], ] def __init__(self, parent, priority, formatter_function): self._parent = parent self._label = urwid.SelectableIcon("", 0) urwid.WidgetWrap.__init__(self, self._label) self._priority = priority self._formatter = formatter_function self._set_label() def _set_label(self): self.set_label(self._formatter(self._priority)) def _update_label(self, delta=0): for i, r in enumerate(PrioritySelector.RANGES): if self._priority in r: self._priority = PrioritySelector.RANGES[ (i + delta) % len(PrioritySelector.RANGES) ][0] self._set_label() return def keypress(self, size, key): if key in ["right", "enter"]: self._update_label(1) return if key == "left": self._update_label(-1) return return super().keypress(size, key) @property def priority(self): return self._priority todoman-4.1.0/tox.ini000066400000000000000000000004721415552045400145000ustar00rootroot00000000000000[tox] envlist = py38, py39, py310, docs skip_missing_interpreters = True [testenv] deps = -rrequirements-dev.txt commands = py.test --cov todoman {posargs} usedevelop = True passenv = CI setenv = TZ = UTC [testenv:repl] deps = {[testenv]deps} click_repl [testenv:pyicu] deps = {[testenv]deps} pyicu