pax_global_header00006660000000000000000000000064133713221770014520gustar00rootroot0000000000000052 comment=f2a006b2b057a33c6d06cafb7e4ab9ba7c61d8c2 todoman-3.5.0/000077500000000000000000000000001337132217700131665ustar00rootroot00000000000000todoman-3.5.0/.gitignore000066400000000000000000000002111337132217700151500ustar00rootroot00000000000000*.sw? *.pyc __pycache__ env build dist todoman.egg-info/ .eggs .cache .tox docs/_build/ .coverage .hypothesis *.tmp todoman/version.py todoman-3.5.0/.travis.yml000066400000000000000000000024171337132217700153030ustar00rootroot00000000000000language: python cache: pip python: - 3.6 - 3.4 - 3.5 - pypy3.3-5.2-alpha1 env: - TOXENV=py addons: apt: packages: - language-pack-de stages: - test - name: deploy if: tag IS present install: - pip install tox codecov script: - tox after_script: - codecov jobs: include: - python: 3.7 dist: xenial sudo: true - env: TOXENV=flake8 python: 3.6 - env: TOXENV=docs - env: TOXENV=repl - env: TOXENV=pyicu - os: osx language: generic env: TOXENV=py before_install: - brew update - brew link --overwrite gcc - brew upgrade python - pip3 install -U virtualenv # Travis' images rename pip to pip3. - virtualenv env -p python3 - source env/bin/activate - stage: deploy script: - pip install wheel twine - python setup.py sdist bdist_wheel - twine upload -u $PYPI_USERNAME -p $PYPI_PASSWORD dist/* - stage: deploy script: - pip install wheel - python setup.py sdist bdist_wheel - gem install dpl - dpl --api-key=$RELEASES_TOKEN --provider=releases --file=dist/* --file_glob=true --skip_cleanup=true matrix: allow_failures: - python: pypy3.3-5.2-alpha1 - os: osx todoman-3.5.0/AUTHORS.rst000066400000000000000000000020021337132217700150370ustar00rootroot00000000000000Authors ======= 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 * Benjamin Frank * Christian Geier * Doron Behar * Guilhem Saurel * Hugo Osvaldo Barrera * José Ribeiro * Mansimar Kaur * Markus Unterwaditzer * Pascal Wichmann * Paweł Fertyk * Rimsha Khan * Sakshi Saraswat * Stephan Weller * Swati Garg * Thomas Glanzmann todoman-3.5.0/CHANGELOG.rst000066400000000000000000000117701337132217700152150ustar00rootroot00000000000000Changelog ========= This file contains a brief summary of new features and dependency changes or releases, in reverse chronological order. 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-3.5.0/CODE_OF_CONDUCT.rst000066400000000000000000000000631337132217700161740ustar00rootroot00000000000000See `the pimutils CoC `_. todoman-3.5.0/LICENCE000066400000000000000000000013721337132217700141560ustar00rootroot00000000000000Copyright (c) 2015-2017, 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-3.5.0/README.rst000066400000000000000000000043211337132217700146550ustar00rootroot00000000000000Todoman ======= .. image:: https://travis-ci.org/pimutils/todoman.svg?branch=master :target: https://travis-ci.org/pimutils/todoman :alt: Travis CI build status .. image:: https://codecov.io/gh/pimutils/todoman/branch/master/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/master/LICENCE :alt: licence .. image:: https://img.shields.io/badge/Say%20Thanks!-%F0%9F%A6%89-1EAEDB.svg :target: https://saythanks.io/to/hobarrera :alt: Say Thanks! 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. 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) for now. * 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-3.5.0/bin/000077500000000000000000000000001337132217700137365ustar00rootroot00000000000000todoman-3.5.0/bin/todo000077500000000000000000000001301337132217700146230ustar00rootroot00000000000000#!/usr/bin/env python from todoman.cli import cli if __name__ == "__main__": cli() todoman-3.5.0/codecov.yml000066400000000000000000000000151337132217700153270ustar00rootroot00000000000000comment: off todoman-3.5.0/contrib/000077500000000000000000000000001337132217700146265ustar00rootroot00000000000000todoman-3.5.0/contrib/completion/000077500000000000000000000000001337132217700167775ustar00rootroot00000000000000todoman-3.5.0/contrib/completion/bash/000077500000000000000000000000001337132217700177145ustar00rootroot00000000000000todoman-3.5.0/contrib/completion/bash/_todo000066400000000000000000000046501337132217700207500ustar00rootroot00000000000000_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-3.5.0/contrib/completion/zsh/000077500000000000000000000000001337132217700176035ustar00rootroot00000000000000todoman-3.5.0/contrib/completion/zsh/_todo000066400000000000000000000223741337132217700206420ustar00rootroot00000000000000#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-3.5.0/docs/000077500000000000000000000000001337132217700141165ustar00rootroot00000000000000todoman-3.5.0/docs/Makefile000066400000000000000000000163761337132217700155730ustar00rootroot00000000000000# 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-3.5.0/docs/source/000077500000000000000000000000001337132217700154165ustar00rootroot00000000000000todoman-3.5.0/docs/source/changelog.rst000066400000000000000000000000421337132217700200730ustar00rootroot00000000000000.. include:: ../../CHANGELOG.rst todoman-3.5.0/docs/source/conf.py000066400000000000000000000306601337132217700167220ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Todoman documentation build configuration file, created by # sphinx-quickstart on Tue Dec 15 22:10:30 2015. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import validate from configobj import ConfigObj import todoman # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # sys.path.insert(0, os.path.abspath('.')) # -- Generate configspec.rst ---------------------------------------------- specpath = '../../todoman/confspec.ini' config = ConfigObj( None, configspec=specpath, stringify=False, list_values=False, ) validator = validate.Validator() config.validate(validator) spec = config.configspec def write_section(section, secname, key, comment, file_): fun_name, fun_args, fun_kwargs, default = validator._parse_check(section) file_.write('\n.. _{}-{}:'.format(secname, key)) file_.write('\n') file_.write('\n.. object:: {}\n'.format(key)) file_.write('\n') file_.write(' ' + '\n '.join([line.strip('# ') for line in comment])) file_.write('\n') if fun_name == 'option': fun_args = ['*{}*'.format(arg) for arg in fun_args] fun_args = fun_args[:-2] + [fun_args[-2] + ' and ' + fun_args[-1]] fun_name += ', allowed values are {}'.format(', '.join(fun_args)) fun_args = [] if fun_name == 'integer' and len(fun_args) == 2: fun_name += ', allowed values are between {} and {}'.format( fun_args[0], fun_args[1], ) fun_args = [] file_.write('\n') if fun_name in ['expand_db_path', 'expand_path']: fun_name = 'string' elif fun_name in ['force_list']: fun_name = 'list' if isinstance(default, list): default = ['space' if one == ' ' else one for one in default] default = ', '.join(default) file_.write(' :type: {}'.format(fun_name)) file_.write('\n') if fun_args != []: file_.write(' :args: {}'.format(fun_args)) file_.write('\n') file_.write(' :default: {}'.format(default)) file_.write('\n') with open('confspec.tmp', 'w') as file_: for secname in sorted(spec): file_.write('\n') heading = 'The [{}] section'.format(secname) file_.write('{}\n{}'.format(heading, len(heading) * '~')) file_.write('\n') comment = spec.comments[secname] file_.write('\n'.join([line[2:] for line in comment])) file_.write('\n') for key, comment in sorted(spec[secname].comments.items()): if key == '__many__': comment = spec[secname].comments[key] file_.write('\n'.join([line[2:] for line in comment])) file_.write('\n') comments = spec[secname]['__many__'].comments for key, comment in sorted(comments.items()): write_section( spec[secname]['__many__'][key], secname, key, comment, file_, ) else: write_section(spec[secname][key], secname, key, comment, file_) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx_autorun', 'sphinx.ext.todo', 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'Todoman' copyright = '2015-2017, Hugo Osvaldo Barrera' author = 'Hugo Osvaldo Barrera' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = todoman.__version__ # The full version, including alpha/beta/rc tags. release = todoman.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['build'] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { 'github_user': 'pimutils', 'github_repo': 'todoman', 'travis_button': 'true', 'github_banner': 'true', 'github_button': 'false', } # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. html_sidebars = { '**': [ 'about.html', 'navigation.html', 'relations.html', 'searchbox.html', 'donate.html', ] } # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'Todomandoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', # Latex figure (float) alignment # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ( master_doc, 'Todoman.tex', 'Todoman Documentation', 'Hugo Osvaldo Barrera', 'manual', ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [( master_doc, 'todoman', 'Todoman Documentation', [author], 1, )] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, 'Todoman', 'Todoman Documentation', author, 'Todoman', 'One line description of project.', 'Miscellaneous', ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False todoman-3.5.0/docs/source/configure.rst000066400000000000000000000021441337132217700201320ustar00rootroot00000000000000Configuring =========== 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/todoman.conf``. ``$XDG_CONFIG_DIR`` defaults to ``~/.config`` is most situations, so this will generally be ``~/.config/todoman/todoman.conf``. .. 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/``, use the ISO-8601 date format, and set the due date for new todos in 48hs. .. literalinclude:: ../../todoman.conf.sample :language: ini Color and displayname --------------------- - You can set a color for each task list by creating a ``color`` file containing a colorcode in the format ``#RRGGBB``. - A file named ``displayname`` decides how the task list should be named. The default is the directory name. See also `this discussion about metadata for collections in vdirsyncer `_. todoman-3.5.0/docs/source/contributing.rst000066400000000000000000000075171337132217700206710ustar00rootroot00000000000000Contributing ============ 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 strictly follow the `Style Guide for Python Code`_, which I strongly recommend you read, though you may simply run ``flake8`` to verify that your code is compliant. 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. .. _Style Guide for Python Code: http://python.org/dev/peps/pep-0008/ .. _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 we operate with. 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/ Patch review checklist ~~~~~~~~~~~~~~~~~~~~~~ Please follow this checklist when submitting new PRs (or reviewing PRs by others): #. Do all tests pass? #. Does the documentation build? #. Does the coding style conform to our guidelines? Are there any flake8 errors? #. Are user-facing changes documented? #. Is there an entry for new features or dependencies in ``CHANGELOG.rst``? #. Are you the patch author? Are you listed in ``AUTHORS.rst``? *Hint: To quickly verify the first three items run* ``tox``. Authorship ---------- While authors must add themselves to ``AUTHORS.rst``, all copyright is retained by them. Contributions are accepted under the :doc:`ISC licence `. todoman-3.5.0/docs/source/index.rst000066400000000000000000000030431337132217700172570ustar00rootroot00000000000000Todoman ======= 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 .. _GitLab.com: https://gitlab.com/hobarrera/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) for now. * 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 nor reflect nor allow editing for values for ``percent > 0 ^ percent < 100``. Table of Contents ================= .. toctree:: :maxdepth: 2 install configure usage contributing changelog licence Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` todoman-3.5.0/docs/source/install.rst000066400000000000000000000066601337132217700176260ustar00rootroot00000000000000Installing ========== 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_, an 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.4 or later. Installation of required libraries can be done via pip, or your OS's package manager. Todoman will not work with python 2. However, keep in mind that python 2 and python 3 can coexist (and most distributions actually ship both). 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-3.5.0/docs/source/licence.rst000066400000000000000000000015441337132217700175560ustar00rootroot00000000000000Licence ======= Todoman is licensed under the ISC licence:: Copyright (c) 2015-2017, 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-3.5.0/docs/source/usage.rst000066400000000000000000000054201337132217700172550ustar00rootroot00000000000000Usage ===== Todoman usage is `CLI`_ based (thought there are some TUI bits, and the intentions is to also provide a fully `TUI`_-based interface). First of all, the classic usage output: .. runblock:: console $ todo --help 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 (0%) 2 [ ] ! Send minipimer back for warranty replacement (0%) 3 [X] 2015-03-29 Buy soy milk (100%) 4 [ ] !! Fix the iPad's screen (0%) 5 [ ] !! Fix the Touchad battery (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 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 needs 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-3.5.0/requirements-dev.txt000066400000000000000000000001201337132217700172170ustar00rootroot00000000000000flake8 flake8-import-order freezegun hypothesis pytest pytest-cov pytest-runner todoman-3.5.0/requirements-docs.txt000066400000000000000000000000171337132217700173760ustar00rootroot00000000000000sphinx_autorun todoman-3.5.0/setup.cfg000066400000000000000000000004151337132217700150070ustar00rootroot00000000000000[aliases] test=pytest [tool:pytest] testpaths = tests addopts = --cov=todoman --cov-report=term-missing [yapf] coalesce_brackets = true dedent_closing_brackets = true space_between_ending_comma_and_closing_bracket = false split_arguments_when_comma_terminated = true todoman-3.5.0/setup.py000066400000000000000000000032331337132217700147010ustar00rootroot00000000000000#!/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>=6.0', 'click-log>=0.2.1', 'configobj', 'humanize', 'icalendar>=4.0.3', 'parsedatetime', 'python-dateutil', 'pyxdg', 'tabulate', '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(), }, classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', 'Environment :: Console :: Curses', 'License :: OSI Approved :: ISC License (ISCL)', 'Operating System :: POSIX', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Office/Business :: Scheduling', 'Topic :: Utilities', ] ) todoman-3.5.0/tests/000077500000000000000000000000001337132217700143305ustar00rootroot00000000000000todoman-3.5.0/tests/conftest.py000066400000000000000000000106251337132217700165330ustar00rootroot00000000000000import 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, settings, Verbosity from todoman import model from todoman.formatters import DefaultFormatter, 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): path = tmpdir.join('config') path.write( '[main]\n' 'path = {}/*\n' 'date_format = %Y-%m-%d\n' 'time_format = \n' 'cache_path = {}\n' .format(str(tmpdir), str(tmpdir.join('cache.sqlite3'))) ) return 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\n' 'BEGIN:VTODO\n' + content + 'END:VTODO\n' 'END: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( 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-3.5.0/tests/helpers.py000066400000000000000000000016551337132217700163530ustar00rootroot00000000000000__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-3.5.0/tests/test_backend.py000066400000000000000000000064551337132217700173420ustar00rootroot00000000000000from datetime import date, datetime import icalendar import pytest import pytz from dateutil.tz import tzlocal from freezegun import freeze_time from todoman.model import Todo, VtodoWritter 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 = VtodoWritter(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 = VtodoWritter(todo).serialize() assert vtodo.get('dtstart') is not None def test_serializer_raises(todo_factory): todo = todo_factory() writter = VtodoWritter(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(VtodoWritter.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), priority=7, status='IN-PROCESS', summary='Some tea', rrule='FREQ=MONTHLY', ) writer = VtodoWritter(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 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 = VtodoWritter(None) assert ( writter.normalize_datetime(date(2017, 6, 17)) == datetime( 2017, 6, 17, tzinfo=tzlocal() ) ) 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-3.5.0/tests/test_cli.py000066400000000000000000000671241337132217700165220ustar00rootroot00000000000000import datetime import sys from os.path import exists, isdir from unittest import mock 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, pyicu_sensitive from todoman.cli import cli, exceptions from todoman.model import Database, 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\n' 'PERCENT-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\n' 'DESCRIPTION: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): """Test the default_list config parameter""" result = runner.invoke(cli, ['new', 'test default list']) assert result.exception path = tmpdir.join('config') path.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): """Test setting the due date using the default_due config parameter""" if default_due is not None: path = tmpdir.join('config') path.write('default_due = {}\n'.format(default_due), '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): cfg = tmpdir.join('config') cfg.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 = 'harhar{}'.format(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\n' 'DUE;VALUE=DATE-TIME;TZID=ART:20160102T000000\n' ) create( 'test2.ics', 'SUMMARY:bbb\n' 'DUE;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\n' 'PRIORITY:9\n') create( 'test2.ics', 'SUMMARY:bbb\n' 'DUE;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\n' 'STATUS:IN-PROCESS\n' 'DUE;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: assert result.output == \ '1 [ ] {} aaa @default\x1b[0m\n'.format(due_str) else: assert result.output == \ '1 [ ] \x1b[31m{}\x1b[0m aaa @default\x1b[0m\n' \ .format(due_str) 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[31m2007-03-22\x1b[0m YARR! @default\x1b[0m' ) result = runner.invoke(cli, color=True) assert ( result.output.strip() == '1 [ ] \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 isdir(str(item)): 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\n' 'LOCATION: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\n' 'DUE;VALUE=DATE-TIME;TZID=CET:20170304T180000\n' # 1700 UTC ) create( 'test2.ics', 'SUMMARY:second\n' 'DUE;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\n' 'END:VTODO\n' 'BEGIN:VTODO\n' 'SUMMARY: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): 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 path = tmpdir.join('config') path.write('startable = yes\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: --start option requires 2 arguments' ) result = runner.invoke(cli, ['list', '--start', 'before']) assert result.exception assert ( result.output.strip() == 'Error: --start option 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)) 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): path = tmpdir.join('config') path.write('default_command = flush\n', 'a') flush = mock.MagicMock() with patch.dict(cli.commands, values=dict(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): path = tmpdir.join('config') path.write('default_command = DoTheRobot\n', 'a') result = runner.invoke(cli, catch_exceptions=False) assert result.exception assert ( 'Error: Invalid setting for [main][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 is 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 todoman-3.5.0/tests/test_config.py000066400000000000000000000114311337132217700172060ustar00rootroot00000000000000from unittest.mock import patch import pytest from click.testing import CliRunner from todoman.cli import cli from todoman.configuration import ConfigurationException, 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', []): 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('todoman.conf').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( '[main]\n' 'color = auto\n' 'date_format = %Y-%m-%d\n' 'path = /\n' 'cache_path = {}\n'.format(tmpdir.join('cache.sqlite')) ) result = runner.invoke(cli) assert not result.exception def test_invalid_color(config, runner): config.write('[main]\n' 'color = 12\n' 'path = "/"\n') result = runner.invoke(cli, ['list']) assert result.exception assert 'Error: Bad color setting, the value "12" is unacceptable.' \ in result.output def test_invalid_color_arg(config, runner): config.write('[main]\n' '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('[main]\n' 'color = auto\n') result = runner.invoke(cli, ['list']) assert result.exception assert ( "Error: path is missing from the ['main'] section of the " "configuration file" ) in result.output @pytest.mark.xfail(reason="Not implemented") def test_extra_entry(config, runner): config.write( '[main]\n' 'color = auto\n' 'date_format = %Y-%m-%d\n' 'path = /\n' 'blah = false\n' ) result = runner.invoke(cli, ['list']) assert result.exception assert "Invalid configuration entry" in result.output @pytest.mark.xfail(reason="Not implemented") def test_extra_section(config, runner): config.write( '[main]\n' 'date_format = %Y-%m-%d\n' 'path = /\n' '[extra]\n' 'color = 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') path = tmpdir.join('config') path.write('cache_path = {}\n'.format(cache_file), 'a') path.write( '[main]\n' 'path = {}/*\n' 'cache_path = {}\n'.format(str(tmpdir), cache_file) ) 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('[main]\n' 'path = "/"\n' 'time_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('[main]\n' 'path = "/"\n' 'date_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['main']['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['main']['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-3.5.0/tests/test_filtering.py000066400000000000000000000177461337132217700177430ustar00rootroot00000000000000from datetime import datetime, timedelta from todoman.cli import cli from todoman.model import Database, 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\n' 'PRIORITY:4\n') create('two.ics', 'SUMMARY:hoho\n' 'PRIORITY:9\n') create('three.ics', 'SUMMARY:hehe\n' 'PRIORITY: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\n' 'LOCATION: The Pool\n') create('two.ics', 'SUMMARY:hoho\n' 'LOCATION: 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\n' 'CATEGORIES:work,trip\n') create('two.ics', 'CATEGORIES:trip\n' 'SUMMARY: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\n' 'DESCRIPTION: Have fun!\n', ) create( 'two.ics', 'SUMMARY:work\n' 'DESCRIPTION: The stuff for work\n', ) create( 'three.ics', 'SUMMARY:buy sandwiches\n' 'DESCRIPTION: This is for the Duke\n', ) create( 'four.ics', 'SUMMARY:puppies\n' 'DESCRIPTION: Feed the puppies\n', ) create( 'five.ics', 'SUMMARY:research\n' 'DESCRIPTION: 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']) result = runner.invoke(cli, ['new', 'list']) assert len(result.output.splitlines()) == 3 result = runner.invoke(cli, ['list', 'list_two']) assert not result.exception assert len(result.output.splitlines()) == 1 assert 'todo 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 = '{}'.format(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( 'test_{}.ics'.format(i), 'SUMMARY:{}\n' 'DUE;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-3.5.0/tests/test_formatter.py000066400000000000000000000112121337132217700177410ustar00rootroot00000000000000from datetime import date, datetime, time, 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_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!\n' 'DUE;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 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\n' 'This includes multiline descriptions\n' 'Blah!', location='Over the hills, and far away', ) # TODO:use formatter instead of runner? result = runner.invoke(cli, ['show', '1']) expected = ( '1 [ ] YARR! @default\n\n' 'Description Test detailed formatting\n' ' This includes multiline descriptions\n' ' Blah!\n' 'Location Over the hills, and far away' ) assert not result.exception assert result.output.strip() == 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 == datetime(2017, 3, 5).replace(tzinfo=tz) 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' todoman-3.5.0/tests/test_main.py000066400000000000000000000010021337132217700166560ustar00rootroot00000000000000import os import sys from subprocess import PIPE, 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-3.5.0/tests/test_model.py000066400000000000000000000267061337132217700170540ustar00rootroot00000000000000from datetime import datetime, 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 cached_property, Database, List, Todo def test_querying(create, tmpdir): for list in 'abc': for i, location in enumerate('abc'): create( 'test{}.ics'.format(i), ('SUMMARY:test_querying\r\n' 'LOCATION:{}\r\n').format(location), list_name=list ) db = Database([str(tmpdir.ensure_dir(l)) for l 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\n' 'DUE;VALUE=DATE-TIME;TZID=HST:20160102T000000\n' ) create( 'de.ics', 'SUMMARY:blah.de\n' 'DUE;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\n' 'DUE;VALUE=DATE:20170617\n') todos = list(todos()) assert len(todos) == 1 assert todos[0].due == datetime(2017, 6, 17, tzinfo=tzlocal()) def test_change_paths(tmpdir, create): old_todos = set('abcdefghijk') for x in old_todos: create('{}.ics'.format(x), 'SUMMARY:{}\n'.format(x), 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_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('test{}.ics'.format(i), 'PRIORITY:{}\n'.format(i)) create('test_none.ics'.format(i), '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\n' 'SUMMARY:RAWR\n' 'X-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', # TZ-aware UNTIL '20990315T020000', # TZ-naive UNTIL ] ) @pytest.mark.parametrize( 'tz', [ pytz.UTC, # TZ-aware todos None, # 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 = 'FREQ=DAILY;UNTIL={}'.format(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_todo_filename_absolute_path(): Todo(filename='test.ics') with pytest.raises(ValueError): Todo(filename='/test.ics') def test_list_equality(tmpdir): list1 = List(path=str(tmpdir), name='test list') list2 = List(path=str(tmpdir), name='test list') list3 = List(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\n' 'UID:NOTABC\n') len(list(todos())) == 1 todo = Todo(new=False) todo.uid = 'ABC' todo.list = next(default_database.lists()) default_database.save(todo) 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 todoman-3.5.0/tests/test_porcelain.py000066400000000000000000000114001337132217700177110ustar00rootroot00000000000000import 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' '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, '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_nodue(tmpdir, runner, create): create( 'test.ics', 'SUMMARY:Do stuff\n' 'PERCENT-COMPLETE:12\n' 'PRIORITY:4\n' ) result = runner.invoke(cli, ['--porcelain', 'list']) expected = [{ 'completed': False, '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\n' 'PRIORITY:4\n') create('two.ics', 'SUMMARY:hoho\n' 'PRIORITY:9\n') create('three.ics', 'SUMMARY:hehe\n' 'PRIORITY: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\n' 'DESCRIPTION:Lots of text. Yum!\n' 'PRIORITY:5\n' ) result = runner.invoke(cli, ['--porcelain', 'show', '1']) expected = { 'completed': False, '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, "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-3.5.0/tests/test_ui.py000066400000000000000000000113661337132217700163650ustar00rootroot00000000000000from 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 is 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-3.5.0/tests/test_widgets.py000066400000000000000000000144121337132217700174110ustar00rootroot00000000000000from unittest import mock from todoman.widgets import ExtendedEdit, 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-3.5.0/todoman.conf.sample000066400000000000000000000002711337132217700167560ustar00rootroot00000000000000[main] # 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-3.5.0/todoman/000077500000000000000000000000001337132217700146275ustar00rootroot00000000000000todoman-3.5.0/todoman/__init__.py000066400000000000000000000001651337132217700167420ustar00rootroot00000000000000from todoman import version __version__ = version.version __documentation__ = 'https://todoman.rtfd.org/en/latest/' todoman-3.5.0/todoman/__main__.py000066400000000000000000000001021337132217700167120ustar00rootroot00000000000000from todoman.cli import cli if __name__ == '__main__': cli() todoman-3.5.0/todoman/cli.py000066400000000000000000000377201337132217700157610ustar00rootroot00000000000000import functools import glob import locale import sys from contextlib import contextmanager from datetime import timedelta from os.path import expanduser, isdir import click import click_log from todoman import exceptions, formatters from todoman.configuration import ConfigurationException, load_config from todoman.interactive import TodoEditor from todoman.model import cached_property, Database, Todo 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=l) for l in lists] def _validate_list_param(ctx, param=None, name=None): ctx = ctx.find_object(AppContext) if name is None: if ctx.config['main']['default_list']: name = ctx.config['main']['default_list'] else: raise click.BadParameter( 'You must set "default_list" or use -l.'.format(name) ) 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['main']['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('Unknown field "{}"'.format(field)) return fields def validate_status(ctx=None, param=None, val=None): # The default command doesn't run callbacks as expected, so it needs to # specify the callback'd type. When `list` is called explicitly, this # callback *IS* run, so we need to handle that edge case: if not isinstance(val, str): return val statuses = val.upper().split(',') if 'ANY' in statuses: return 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 statuses def _todo_property_options(command): click.option( '--priority', default='', callback=_validate_priority_param, help=('The priority for this todo') )(command) click.option( '--location', help=('The location where ' 'this todo takes place.') )(command) click.option( '--due', '-d', default='', callback=_validate_date_param, help=( 'The due date of the task, in the format specified in the ' 'configuration file.' ) )(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['main']['date_format'], self.config['main']['time_format'], self.config['main']['dt_separator'] ) @cached_property def formatter(self): return self.formatter_class( self.config['main']['date_format'], self.config['main']['time_format'], self.config['main']['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.pass_context @click.version_option(prog_name='todoman') @catch_errors def cli(click_ctx, colour, porcelain, humanize): ctx = click_ctx.ensure_object(AppContext) try: ctx.config = load_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['main']['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['main']['color'] if colour == 'always': click_ctx.color = True elif colour == 'never': click_ctx.color = False paths = [ path for path in glob.iglob(expanduser(ctx.config["main"]["path"])) if isdir(path) ] if len(paths) == 0: raise exceptions.NoListsFound(ctx.config["main"]["path"]) ctx.db = Database(paths, ctx.config['main']['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['main']['default_command'], ) def invoke_command(click_ctx, command): name, *args = command.split(' ') if name not in cli.commands: raise click.ClickException( 'Invalid setting for [main][default_command]' ) click_ctx.invoke(cli.commands[command], args) 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='The list to create the task in.' ) @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['main']['default_due'] if default_due: todo.due = todo.created_at + timedelta(hours=default_due) 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: 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, 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. 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: """ todos = ctx.db.todos(**kwargs) click.echo(ctx.formatter.compact_multiple(todos)) todoman-3.5.0/todoman/configuration.py000066400000000000000000000052441337132217700200550ustar00rootroot00000000000000import os from os.path import exists, join import xdg.BaseDirectory from configobj import ConfigObj, flatten_errors from validate import Validator, VdtValueError from todoman import __documentation__ 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 expand_path(path): """expands `~` as well as variable names""" return os.path.expanduser(os.path.expandvars(path)) def validate_cache_path(path): if path: return expand_path(path) else: return os.path.join( xdg.BaseDirectory.xdg_cache_home, 'todoman/cache.sqlite3', ) 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 find_config(): custom_path = os.environ.get('TODOMAN_CONFIG') if custom_path: if not exists(custom_path): raise ConfigurationException( "Configuration file {} does not exist".format(custom_path) ) return custom_path for d in xdg.BaseDirectory.xdg_config_dirs: path = join(d, 'todoman', 'todoman.conf') if exists(path): return path raise ConfigurationException("No configuration file found.\n\n") def load_config(): path = find_config() specpath = os.path.join(os.path.dirname(__file__), 'confspec.ini') validator = Validator({ 'expand_path': expand_path, 'cache_path': validate_cache_path, 'date_format': validate_date_format, 'time_format': validate_time_format, }) config = ConfigObj(path, configspec=specpath, file_error=True) validation = config.validate(validator, preserve_errors=True) for section, key, error in flatten_errors(config, validation): if not error: raise ConfigurationException(( '{} is missing from the {} section of the configuration ' + 'file' ).format(key, section)) if isinstance(error, VdtValueError): raise ConfigurationException( 'Bad {} setting, {}'.format(key, error.args[0]) ) return config todoman-3.5.0/todoman/confspec.ini000066400000000000000000000043611337132217700171340ustar00rootroot00000000000000[main] # 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. path = expand_path() # 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. color = option('always', 'auto', 'never', default='auto') # The date format used both for displaying dates, and parsing input dates. If # this option is not specified the system locale's is used. date_format = date_format(default='%x') # The date format used both for displaying times, and parsing input times. time_format = time_format(default='%X') # The string used to separate date and time when displaying and parsing. dt_separator = string(default=' ') # If set to true, datetimes will be printed in human friendly formats like # "tomorrow", "in on hour", "3 weeks ago", etc. # # If false, datetimes fill be formatted using ``date_format`` and #``time_format``. humanize = boolean(default=False) # 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. default_list = string(default=None) # 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. default_due = integer(default=24) # 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, the database will be place in the # ``XDG_CACHE_HOME`` directory, generally, ``~/.cache``. cache_path = cache_path(default='') # 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. startable = boolean(default=False) # When running ``todo`` with no commands, run this command. default_command = string(default='list') todoman-3.5.0/todoman/exceptions.py000066400000000000000000000020301337132217700173550ustar00rootroot00000000000000class TodomanException(Exception): """ Base class for all our exceptions. Should not be raised directly. """ pass class NoSuchTodo(TodomanException): EXIT_CODE = 20 def __str__(self): return 'No todo with id {}.'.format(self.args[0]) class ReadOnlyTodo(TodomanException): EXIT_CODE = 21 def __str__(self): return ( 'Todo is in read-only mode because there are multiple todos in {}.' .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-3.5.0/todoman/formatters.py000066400000000000000000000166011337132217700173730ustar00rootroot00000000000000import datetime import json from time import mktime import click import humanize import parsedatetime import pytz from dateutil.tz import tzlocal from tabulate import tabulate def rgb_to_ansi(colour): """ Convert a string containing an RGB colour to ANSI escapes """ if not colour or not colour.startswith('#'): return r, g, b = colour[1:3], colour[3:5], colour[5:7] if not len(r) == len(g) == len(b) == 2: return return '\33[38;2;{!s};{!s};{!s}m'.format( int(r, 16), int(g, 16), int(b, 16) ) 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.datetime.now().replace(tzinfo=self.tz) self._parsedatetime_calendar = parsedatetime.Calendar( version=parsedatetime.VERSION_CONTEXT_STYLE, ) def simple_action(self, action, todo): return '{} "{}"'.format(action, todo.summary) def compact(self, todo): return self.compact_multiple([todo]) def compact_multiple(self, todos): table = [] for todo in todos: completed = "X" if todo.is_completed else " " percent = todo.percent_complete or '' if percent: percent = " ({}%)".format(percent) priority = self.format_priority_compact(todo.priority) due = self.format_datetime(todo.due) if todo.due and todo.due <= self.now and not todo.is_completed: due = click.style(due, fg='red') recurring = '⟳' if todo.is_recurring else '' table.append([ todo.id, "[{}]".format(completed), priority, '{} {}'.format(due, recurring), "{} {}{}".format( todo.summary, self.format_database(todo.list), percent, ), ]) return tabulate(table, tablefmt='plain') def _columnize_text(self, label, text): """Display text, split text by line-endings, on multiple colums,""" """do nothing if text is empty or None""" lines = text.splitlines() if text else None return self._columnize_list(label, lines) def _columnize_list(self, label, lst): """Display list on multiple columns,""" """do nothing if list is empty or None""" rows = [] if lst: rows.append([label, lst[0]]) for line in lst[1:]: rows.append([None, line]) return rows def detailed(self, todo): """ Returns a detailed representation of a task. :param Todo todo: The todo component. """ extra_rows = [] extra_rows += self._columnize_text('Description', todo.description) extra_rows += self._columnize_text('Location', todo.location) if extra_rows: return '{}\n\n{}'.format( self.compact(todo), tabulate(extra_rows, tablefmt='plain') ) return self.compact(todo) def format_datetime(self, dt): if not dt: return '' elif isinstance(dt, datetime.datetime): return dt.strftime(self.datetime_format) elif isinstance(dt, datetime.date): return dt.strftime(self.date_format) def parse_priority(self, priority): if priority is None or priority is '': 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): 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): if not priority: return '' elif 1 <= priority <= 4: return "!!!" elif priority == 5: return "!!" elif 6 <= priority <= 9: return "!" def parse_datetime(self, dt): if not dt: return None rv = self._parse_datetime_naive(dt) return rv.replace(tzinfo=self.tz) def _parse_datetime_naive(self, dt): try: return datetime.datetime.strptime(dt, self.datetime_format) except ValueError: pass try: return datetime.datetime.strptime(dt, self.date_format) except ValueError: pass try: return datetime.datetime.combine( self.now.date(), datetime.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('Time description not recognized: {}'.format(dt)) return datetime.datetime.fromtimestamp(mktime(rv)) def format_database(self, database): return '{}@{}'.format( rgb_to_ansi(database.colour) or '', click.style(database.name) ) class HumanizedFormatter(DefaultFormatter): def format_datetime(self, dt): if not dt: return '' rv = humanize.naturaltime(self.now - dt) if ' from now' in rv: rv = 'in {}'.format(rv[:-9]) return rv class PorcelainFormatter(DefaultFormatter): def _todo_as_dict(self, todo): return dict( 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, ) def compact(self, todo): return json.dumps(self._todo_as_dict(todo), indent=4, sort_keys=True) def compact_multiple(self, todos): data = [self._todo_as_dict(todo) for todo in todos] return json.dumps(data, indent=4, sort_keys=True) def simple_action(self, action, todo): return self.compact(todo) def parse_priority(self, priority): 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(e) def detailed(self, todo): return self.compact(todo) def format_datetime(self, date): if date: return int(date.timestamp()) else: return None def parse_datetime(self, value): if value: return datetime.datetime.fromtimestamp(value, tz=pytz.UTC) else: return None todoman-3.5.0/todoman/interactive.py000066400000000000000000000142441337132217700175230ustar00rootroot00000000000000import 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( ' {}: {}'.format(k, v) for k, v in widgets.ExtendedEdit.HELP ) + '\n\n' 'In Priority Selector:\n' + '\n'.join( ' {}: {}'.format(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-3.5.0/todoman/model.py000066400000000000000000000733561337132217700163170ustar00rootroot00000000000000import logging import os import socket import sqlite3 from datetime import date, datetime, timedelta from os.path import normpath, split 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: # noqa '''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. """ def __init__(self, filename=None, mtime=None, new=False, list=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 List list: The list to which this Todo belongs. """ self.list = list now = datetime.now(LOCAL_TIMEZONE) self.uid = '{}@{}'.format(uuid4().hex, socket.gethostname()) self.list = list 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 "{}.ics".format(self.uid) self.related = [] if os.path.basename(self.filename) != self.filename: raise ValueError( 'Must not be an absolute path: {}'.format(self.filename) ) self.mtime = mtime or datetime.now() def clone(self): """ 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, 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), ( "Got {0} for {1} where str was expected" .format(type(value), name)) if name in Todo.STRING_FIELDS: if value is None: v = '' else: assert isinstance(value, str), ( "Got {0} for {1} where str was expected" .format(type(value), name)) if name in Todo.INT_FIELDS: if value is None: v = 0 else: assert isinstance(value, int), ( "Got {0} for {1} where int was expected" .format(type(value), name)) if name in Todo.LIST_FIELDS: if value is None: v = [] else: assert isinstance(value, list), ( "Got {0} for {1} where list was expected" .format(type(value), name)) return object.__setattr__(self, name, v) @property def is_completed(self): return ( bool(self.completed_at) or self.status in ('CANCELLED', 'COMPLETED') ) @property def is_recurring(self): return bool(self.rrule) def _apply_recurrence_to_dt(self, dt): if not dt: return None recurrence = rrulestr(self.rrule, dtstart=dt) 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): """ 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): return os.path.join(self.list.path, self.filename) def cancel(self): self.status = 'CANCELLED' class VtodoWritter: """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): self.todo = todo def normalize_datetime(self, dt): ''' 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): dt = datetime(dt.year, dt.month, dt.day) if not dt.tzinfo: dt = dt.replace(tzinfo=LOCAL_TIMEZONE) return dt.astimezone(pytz.UTC) def serialize_field(self, name, 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('Unknown field {} serialized.'.format(name)) def set_field(self, name, 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, overwrite=True).open() as f: f.write(cal.to_ical().decode("UTF-8")) def _write_new(self, path): vtodo = self.serialize() c = icalendar.Calendar() c.add_component(vtodo) with AtomicWriter(path).open() as f: c.add('prodid', 'io.barrera.todoman') c.add('version', '2.0') f.write(c.to_ical().decode("UTF-8")) 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 = 5 def __init__(self, path): 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): 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, 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, "start" 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, path, colour): """ 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) VALUES (?, ?, ?)", ( name, path, colour, ), ) except sqlite3.IntegrityError as e: raise exceptions.AlreadyExists('list', name) from e return self.add_list(name, path, colour) def add_file(self, list_name, path, mtime): 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, field): dt = todo.decoded(field, None) if not dt: return None if isinstance(dt, date) and not isinstance(dt, datetime): dt = datetime(dt.year, dt.month, dt.day) if not dt.tzinfo: dt = dt.replace(tzinfo=LOCAL_TIMEZONE) return dt.timestamp() def _serialize_rrule(self, todo, field): rrule = todo.get(field) if not rrule: return None return rrule.to_ical().decode() def _serialize_categories(self, todo, field): categories = todo.get(field, []) if not categories: return '' return ','.join([str(category) for category in categories.cats]) def add_vtodo(self, todo, file_path, id=None): """ 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, start, priority, created_at, completed_at, percent_complete, dtstamp, status, description, location, categories, sequence, last_modified, rrule ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''' due = self._serialize_datetime(todo, 'due') start = 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, start, todo.get('priority', 0) or None, self._serialize_datetime(todo, 'created'), self._serialize_datetime(todo, 'completed'), todo.get('percent-complete', None), self._serialize_datetime(todo, 'dtstamp'), 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'), 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', ) ): """ 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 = [] if 'ANY' not in status: extra_where.append( 'AND status IN ({})'.format(', '.join(['?'] * len(status))) ) params.extend(s.upper() for s in status) if lists: lists = [l.name if isinstance(l, List) else l for l in lists] q = ', '.join(['?'] * len(lists)) extra_where.append('AND files.list_name IN ({})'.format(q)) params.extend(lists) if priority: extra_where.append('AND PRIORITY > 0 AND PRIORITY <= ?') params.append('{}'.format(priority)) if location: extra_where.append('AND location LIKE ?') params.append('%{}%'.format(location)) if category: extra_where.append('AND categories LIKE ?') params.append('%{}%'.format(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('%{}%'.format(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 = [] for s in sort: if s.startswith('-'): order.append(' {} ASC'.format(s[1:])) else: order.append(' {} DESC'.format(s)) order = ','.join(order) 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 _dt_from_db(self, dt): if dt: return datetime.fromtimestamp(dt, LOCAL_TIMEZONE) return None def _todo_from_db(self, row): todo = Todo() todo.id = row['id'] todo.uid = row['uid'] todo.summary = row['summary'] todo.due = self._dt_from_db(row['due']) todo.start = self._dt_from_db(row['start']) todo.priority = row['priority'] todo.created_at = self._dt_from_db(row['created_at']) todo.completed_at = self._dt_from_db(row['completed_at']) todo.dtstamp = self._dt_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): result = self._conn.execute("SELECT * FROM lists") for row in result: yield List( name=row['name'], path=row['path'], colour=row['colour'], ) @cached_property def lists_map(self): return {l.name: l for l in self.lists()} def expire_lists(self, paths): results = self._conn.execute("SELECT path, name from lists") for result in results: if result['path'] not in paths: self.delete_list(result['name']) def delete_list(self, name): self._conn.execute("DELETE FROM lists WHERE lists.name = ?", (name,)) def todo(self, id, read_only=False): # 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): """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): self._conn.execute("DELETE FROM files WHERE path = ?", (path,)) class List: def __init__(self, name, path, colour=None): self.path = path self.name = name self.colour = colour @staticmethod def colour_for_path(path): try: with open(os.path.join(path, 'color')) as f: return f.read().strip() except (OSError, IOError): logger.debug('No colour for list %s', path) @staticmethod def name_for_path(path): try: with open(os.path.join(path, 'displayname')) as f: return f.read().strip() except (OSError, IOError): return split(normpath(path))[1] def __eq__(self, other): if isinstance(other, List): return self.name == other.name return object.__eq__(self, other) def __str__(self): 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): self.cache.expire_lists(self.paths) paths_to_mtime = {} paths_to_list_name = {} for path in self.paths: list_name = self.cache.add_list( List.name_for_path(path), path, List.colour_for_path(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 = f.read() cal = icalendar.Calendar.from_ical(cal) 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): return self.cache.todos(**kwargs) def todo(self, id, **kwargs): return self.cache.todo(id, **kwargs) def lists(self): return self.cache.lists() def move(self, todo, new_list, from_list=None): from_list = from_list or todo.list 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): path = os.path.join(todo.list.path, todo.filename) os.remove(path) def flush(self): 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): for related in todo.related: self.save(related) todo.sequence += 1 todo.last_modified = datetime.now(LOCAL_TIMEZONE) vtodo = VtodoWritter(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): stat = os.stat(path) return getattr(stat, 'st_mtime_ns', stat.st_mtime) todoman-3.5.0/todoman/widgets.py000066400000000000000000000125041337132217700166510ustar00rootroot00000000000000# Copyright (c) 2016-2017 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-3.5.0/tox.ini000066400000000000000000000015261337132217700145050ustar00rootroot00000000000000[tox] envlist = py33, py34, py35, py36, flake8, docs skip_missing_interpreters = True [testenv] deps = -rrequirements-dev.txt commands = py.test --cov todoman usedevelop = True passenv = CI [testenv:repl] deps = {[testenv]deps} click_repl [testenv:pyicu] deps = {[testenv]deps} pyicu [testenv:flake8] basepython = python3 skip_install = True deps = flake8 flake8-bugbear flake8-import-order commands = flake8 [testenv:yapf] basepython = python3 skip_install = True deps = yapf commands = yapf --recursive --diff -p todoman tests docs setup.py [testenv:docs] basepython = python3 whitelist_externals = make # These two steps imitate RTD as best as possible. commands = pip install -rrequirements-docs.txt make -C docs html [flake8] exclude=.tox,build,.eggs application-import-names=todoman,tests import-order-style=smarkets