tasklib is a Python library for interacting with taskwarrior_ databases, using
a queryset API similar to that of Django's ORM.
-Supports Python 2.6, 2.7, 3.2 and 3.3 with taskwarrior 2.1.x and 2.2.x.
+Supports Python 2.6, 2.7, 3.2, 3.3 and 3.4 with taskwarrior 2.1.x and above.
Older versions of taskwarrior are untested and may not work.
Requirements
------------
-* taskwarrior_ v2.1.x or v2.2.x
+* taskwarrior_ v2.1.x or above, although newest minor release is recommended.
Installation
------------
>>> tw = TaskWarrior(data_location='~/.task', create=True)
+The ``TaskWarrior`` instance will also use your .taskrc configuration (so that
+it recognizes the same UDAs as your task binary, uses the same configuration,
+etc.). To override the location of the .taskrc, use
+``taskrc_location=~/some/different/path``.
+
+Creating Tasks
+--------------
+
+To create a task, simply create a new ``Task`` object::
+
+ >>> new_task = Task(tw, description="throw out the trash")
+
+This task is not yet saved to TaskWarrior (same as in Django), not until
+you call ``.save()`` method::
+
+ >>> new_task.save()
+
+You can set any attribute as a keyword argument to the Task object::
+
+ >>> complex_task = Task(tw, description="finally fix the shower", due=datetime(2015,2,14,8,0,0), priority='H')
+
+or by setting the attributes one by one::
+
+ >>> complex_task = Task(tw)
+ >>> complex_task['description'] = "finally fix the shower"
+ >>> complex_task['due'] = datetime(2015,2,14,8,0,0)
+ >>> complex_task['priority'] = 'H'
+
+Modifying Task
+--------------
+
+To modify a created or retrieved ``Task`` object, use dictionary-like access::
+
+ >>> homework = tw.tasks.get(tags=['chores'])
+ >>> homework['project'] = 'Home'
+
+The change is not propagated to the TaskWarrior until you run the ``save()`` method::
+
+ >>> homework.save()
+
+Attributes, which map to native Python objects are converted. See Task Attributes section.
+
+Task Attributes
+---------------
+
+Attributes of task objects are accessible through indices, like so::
+
+ >>> task = tw.tasks.pending().get(tags__contain='work') # There is only one pending task with 'work' tag
+ >>> task['description']
+ 'Upgrade Ubuntu Server'
+ >>> task['id']
+ 15
+ >>> task['due']
+ datetime.datetime(2015, 2, 5, 0, 0, tzinfo=<DstTzInfo 'Europe/Berlin' CET+1:00:00 STD>)
+ >>> task['tags']
+ ['work', 'servers']
+
+The following fields are deserialized into Python objects:
+
+* ``due``, ``wait``, ``scheduled``, ``until``, ``entry``: deserialized to a ``datetime`` object
+* ``annotations``: deserialized to a list of ``TaskAnnotation`` objects
+* ``tags``: deserialized to a list of strings
+* ``depends``: deserialized to a set of ``Task`` objects
+
+Attributes should be set using the correct Python representation, which will be
+serialized into the correct format when the task is saved.
+
+Task properties
+---------------
+
+Tasklib defines several properties upon ``Task`` object, for convenience::
+
+ >>> t.save()
+ >>> t.saved
+ True
+ >>> t.pending
+ True
+ >>> t.active
+ False
+ >>> t.start()
+ >>> t.active
+ True
+ >>> t.done()
+ >>> t.completed
+ True
+ >>> t.pending
+ False
+ >>> t.delete()
+ >>> t.deleted
+ True
+
+Operations on Tasks
+-------------------
+
+After modifying one or more attributes, simple call ``save()`` to write those
+changes to the database::
+
+ >>> task = tw.tasks.pending().get(tags__contain='work')
+ >>> task['due'] = datetime(year=2014, month=1, day=5)
+ >>> task.save()
+
+To mark a task as complete, use ``done()``::
+
+ >>> task = tw.tasks.pending().get(tags__contain='work')
+ >>> task.done()
+ >>> len(tw.tasks.pending().filter(tags__contain='work'))
+ 0
+
+To delete a task, use ``delete()``::
+
+ >>> task = tw.tasks.get(description="task added by mistake")
+ >>> task.delete()
+
+To update a task object with values from TaskWarrior database, use ``refresh()``. Example::
+
+ >>> task = Task(tw, description="learn to cook")
+ >>> task.save()
+ >>> task['id']
+ 5
+ >>> task['tags']
+ []
+
+Now, suppose the we modify the task using the TaskWarrior interface in another terminal::
+
+ $ task 5 modify +someday
+ Task 5 modified.
+
+Switching back to the open python process::
+
+ >>> task['tags']
+ []
+ >>> task.refresh()
+ >>> task['tags']
+ ['someday']
+
+Tasks can also be started and stopped. Use ``start()`` and ``stop()``
+respectively::
+
+ >>> task.start()
+ >>> task['start']
+ datetime.datetime(2015, 7, 16, 18, 48, 28, tzinfo=<DstTzInfo 'Europe/Prague' CEST+2:00:00 DST>)
+ >>> task.stop()
+ >>> task['start']
+ >>> task.done()
+ >>> task['end']
+ datetime.datetime(2015, 7, 16, 18, 49, 2, tzinfo=<DstTzInfo 'Europe/Prague' CEST+2:00:00 DST>)
+
+
Retrieving Tasks
----------------
API. To get all tasks (including completed ones)::
>>> tw.tasks.all()
+ ['First task', 'Completed task', 'Deleted task', ...]
Filtering
---------
Filter tasks using the same familiar syntax::
- >>> tw.tasks.filter(status='pending', tags__contain='work')
+ >>> tw.tasks.filter(status='pending', tags__contains=['work'])
['Upgrade Ubuntu Server']
Filter arguments are passed to the ``task`` command (``__`` is replaced by
>>> tw.tasks.filter('status:pending +work')
['Upgrade Ubuntu Server']
+Although this practice is discouraged, as by using raw commands you may lose
+some of the portablility of your commands over different TaskWarrior versions.
+
+However, you can mix raw commands with keyword filters, as in the given example::
+
+ >>> tw.tasks.filter('+BLOCKING', project='Home') # Gets all blocking tasks in project Home
+ ['Fix the toilette']
+
+This can be a neat way how to use syntax not yet supported by tasklib. The above
+is excellent example, since virtual tags do not work the same way as the ordinary ones, that is::
+
+ >>> tw.tasks.filter(tags=['BLOCKING'])
+ >>> []
+
+will not work.
+
There are built-in functions for retrieving pending & completed tasks::
>>> tw.tasks.pending().filter(tags__contain='work')
Use ``get()`` to return the only task in a ``TaskQuerySet``, or raise an
exception::
- >>> tw.tasks.filter(status='pending', tags__contain='work').get()
- 'Upgrade Ubuntu Server'
- >>> tw.tasks.filter(status='pending', tags__contain='work').get(status='completed')
+ >>> tw.tasks.get(tags__contain='work')['status']
+ 'pending'
+ >>> tw.tasks.get(status='completed', tags__contains='work') # Status of only task with the work tag is pending, so this should fail
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "tasklib/task.py", line 224, in get
'Lookup parameters were {0}'.format(kwargs))
- tasklib.task.DoesNotExist: Task matching query does not exist. Lookup parameters were {'status': 'completed'}
- >>> tw.tasks.filter(status='pending', tags__contain='home').get()
+ tasklib.task.DoesNotExist: Task matching query does not exist. Lookup parameters were {'status': 'completed', 'tags__contains': ['work']}
+ >>> tw.tasks.get(status='pending')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "tasklib/task.py", line 227, in get
'Lookup parameters were {1}'.format(num, kwargs))
- ValueError: get() returned more than one Task -- it returned 2! Lookup parameters were {}
+ ValueError: get() returned more than one Task -- it returned 23! Lookup parameters were {'status': 'pending'}
-Task Attributes
----------------
+Additionally, since filters return ``TaskQuerySets`` you can stack filters on top of each other::
-Attributes of task objects are accessible through indices, like so::
+ >>> home_tasks = tw.tasks.filter(project='Wife')
+ >>> home_tasks.filter(due__before=datetime(2015,2,14,14,14,14)) # What I have to do until Valentine's day
+ ['Prepare surprise birthday party']
- >>> task = tw.tasks.pending().filter(tags__contain='work').get()
- >>> task['description']
- 'Upgrade Ubuntu Server'
- >>> task['id']
- 15
- >>> task['due']
- datetime.datetime(2013, 12, 5, 0, 0)
- >>> task['tags']
- ['work', 'servers']
+Equality of Task objects
+------------------------
-The following fields are deserialized into Python objects:
+Two Tasks are considered equal if they have the same UUIDs::
-* ``due``: deserialized to a ``datetime`` object
-* ``annotations``: deserialized to a list of dictionaries, where the ``entry``
- field is a ``datetime`` object
-* ``tags``: deserialized to a list
+ >>> task1 = Task(tw, description="Pet the dog")
+ >>> task1.save()
+ >>> task2 = tw.tasks.get(description="Pet the dog")
+ >>> task1 == task2
+ True
-Attributes should be set using the correct Python representation, which will be
-serialized into the correct format when the task is saved.
+If you compare the two unsaved tasks, they are considered equal only if it's the
+same Python object::
-Saving Tasks
-------------
+ >>> task1 = Task(tw, description="Pet the cat")
+ >>> task2 = Task(tw, description="Pet the cat")
+ >>> task1 == task2
+ False
+ >>> task3 = task1
+ >>> task3 == task1
+ True
-After modifying one or more attributes, simple call ``save()`` to write those
-changes to the database::
+Accessing original values
+-------------------------
+
+To access the saved state of the Task, use dict-like access using the
+``original`` attribute:
+
+ >>> t = Task(tw, description="tidy up")
+ >>> t.save()
+ >>> t['description'] = "tidy up the kitchen and bathroom"
+ >>> t['description']
+ "tidy up the kitchen and bathroom"
+ >>> t.original['description']
+ "tidy up"
+
+When you save the task, original values are refreshed to reflect the
+saved state of the task:
+
+ >>> t.save()
+ >>> t.original['description']
+ "tidy up the kitchen and bathroom"
+
+Dealing with dates and time
+---------------------------
+
+Any timestamp-like attributes of the tasks are converted to timezone-aware
+datetime objects. To achieve this, Tasklib leverages ``pytz`` Python module,
+which brings the Olsen timezone databaze to Python.
+
+This shields you from annoying details of Daylight Saving Time shifts
+or conversion between different timezones. For example, to list all the
+tasks which are due midnight if you're currently in Berlin:
+
+ >>> myzone = pytz.timezone('Europe/Berlin')
+ >>> midnight = myzone.localize(datetime(2015,2,2,0,0,0))
+ >>> tw.tasks.filter(due__before=midnight)
+
+However, this is still a little bit tedious. That's why TaskWarrior object
+is capable of automatic timezone detection, using the ``tzlocal`` Python
+module. If your system timezone is set to 'Europe/Berlin', following example
+will work the same way as the previous one:
+
+ >>> tw.tasks.filter(due__before=datetime(2015,2,2,0,0,0))
+
+You can also use simple dates when filtering:
+
+ >>> tw.tasks.filter(due__before=date(2015,2,2))
+
+In such case, a 00:00:00 is used as the time component.
+
+Of course, you can use datetime naive objects when initializing Task object
+or assigning values to datetime atrributes:
+
+ >>> t = Task(tw, description="Buy new shoes", due=date(2015,2,5))
+ >>> t['due']
+ datetime.datetime(2015, 2, 5, 0, 0, tzinfo=<DstTzInfo 'Europe/Berlin' CET+1:00:00 STD>)
+ >>> t['due'] = date(2015,2,6,15,15,15)
+ >>> t['due']
+ datetime.datetime(2015, 2, 6, 15, 15, 15, tzinfo=<DstTzInfo 'Europe/Berlin' CET+1:00:00 STD>)
+
+However, since timezone-aware and timezone-naive datetimes are not comparable
+in Python, this can cause some unexpected behaviour:
+
+ >>> from datetime import datetime
+ >>> now = datetime.now()
+ >>> t = Task(tw, description="take out the trash now")
+ >>> t['due'] = now
+ >>> now
+ datetime.datetime(2015, 2, 1, 19, 44, 4, 770001)
+ >>> t['due']
+ datetime.datetime(2015, 2, 1, 19, 44, 4, 770001, tzinfo=<DstTzInfo 'Europe/Berlin' CET+1:00:00 STD>)
+ >>> t['due'] == now
+ Traceback (most recent call last):
+ File "<stdin>", line 1, in <module>
+ TypeError: can't compare offset-naive and offset-aware datetimes
+
+If you want to compare datetime aware value with datetime naive value, you need
+to localize the naive value first:
+
+ >>> from datetime import datetime
+ >>> from tasklib.task import local_zone
+ >>> now = local_zone.localize(datetime.now())
+ >>> t['due'] = now
+ >>> now
+ datetime.datetime(2015, 2, 1, 19, 44, 4, 770001, tzinfo=<DstTzInfo 'Europe/Berlin' CET+1:00:00 STD>)
+ >>> t['due'] == now
+ True
+
+Also, note that it does not matter whether the timezone aware datetime objects
+are set in the same timezone:
+
+ >>> import pytz
+ >>> t['due']
+ datetime.datetime(2015, 2, 1, 19, 44, 4, 770001, tzinfo=<DstTzInfo 'Europe/Berlin' CET+1:00:00 STD>)
+ >>> now.astimezone(pytz.utc)
+ datetime.datetime(2015, 2, 1, 18, 44, 4, 770001, tzinfo=<UTC>)
+ >>> t['due'] == now.astimezone(pytz.utc)
+ True
+
+*Note*: Following behaviour is available only for TaskWarrior >= 2.4.0.
+
+There is a third approach to setting up date time values, which leverages
+the 'task calc' command. You can simply set any datetime attribute to
+any string that contains an acceptable TaskWarrior-formatted time expression::
+
+ $ task calc now + 1d
+ 2015-07-17T21:17:54
+
+This syntax can be leveraged in the python interpreter as follows::
+
+ >>> t['due'] = "now + 1d"
+ >>> t['due']
+ datetime.datetime(2015, 7, 17, 21, 19, 31, tzinfo=<DstTzInfo 'Europe/Berlin' CEST+2:00:00 DST>)
+
+It can be easily seen that the string with TaskWarrior-formatted time expression
+is automatically converted to native datetime in the local time zone.
+
+For the list of acceptable formats and keywords, please consult:
+
+* http://taskwarrior.org/docs/dates.html
+* http://taskwarrior.org/docs/named_dates.html
+
+However, as each such assigment involves call to 'task calc' for conversion,
+it might cause some performance issues when assigning strings to datetime
+attributes repeatedly, in a automated manner.
+
+Working with annotations
+------------------------
+
+Annotations of the tasks are represented in tasklib by ``TaskAnnotation`` objects. These
+are much like ``Task`` objects, albeit very simplified.
+
+ >>> annotated_task = tw.tasks.get(description='Annotated task')
+ >>> annotated_task['annotations']
+ [Yeah, I am annotated!]
+
+Annotations have only defined ``entry`` and ``description`` values::
+
+ >>> annotation = annotated_task['annotations'][0]
+ >>> annotation['entry']
+ datetime.datetime(2015, 1, 3, 21, 13, 55, tzinfo=<DstTzInfo 'Europe/Berlin' CET+1:00:00 STD>)
+ >>> annotation['description']
+ u'Yeah, I am annotated!'
+
+To add a annotation to a Task, use ``add_annotation()``::
+
+ >>> task = Task(tw, description="new task")
+ >>> task.add_annotation("we can annotate any task")
+ Traceback (most recent call last):
+ File "<stdin>", line 1, in <module>
+ File "build/bdist.linux-x86_64/egg/tasklib/task.py", line 355, in add_annotation
+ tasklib.task.NotSaved: Task needs to be saved to add annotation
+
+However, Task needs to be saved before you can add a annotation to it::
- >>> task = tw.tasks.pending().filter(tags__contain='work').get()
- >>> task['due'] = datetime(year=2014, month=1, day=5)
>>> task.save()
+ >>> task.add_annotation("we can annotate saved tasks")
+ >>> task['annotations']
+ [we can annotate saved tasks]
-To mark a task as complete, use ``done()``::
+To remove the annotation, pass its description to ``remove_annotation()`` method::
+
+ >>> task.remove_annotation("we can annotate saved tasks")
+
+Alternatively, you can pass the ``TaskAnnotation`` object itself::
+
+ >>> task.remove_annotation(task['annotations'][0])
+
+
+Running custom commands
+-----------------------
+
+To run a custom commands, use ``execute_command()`` method of ``TaskWarrior`` object::
+
+ >>> tw = TaskWarrior()
+ >>> tw.execute_command(['log', 'Finish high school.'])
+ [u'Logged task.']
+
+You can use ``config_override`` keyword argument to specify a dictionary of configuration overrides::
+
+ >>> tw.execute_command(['3', 'done'], config_override={'gc': 'off'}) # Will mark 3 as completed and it will retain its ID
+
+
+Additionally, you can use ``return_all=True`` flag, which returns
+``(stdout, sterr, return_code)`` triplet, and ``allow_failure=False``, which will
+prevent tasklib from raising an exception if the task binary returned non-zero
+return code::
+
+ >>> tw.execute_command(['invalidcommand'], allow_failure=False, return_all=True)
+ ([u''],
+ [u'Using alternate .taskrc file /home/tbabej/.taskrc',
+ u"[task next rc:/home/tbabej/.taskrc rc.recurrence.confirmation=no rc.json.array=off rc.confirmation=no rc.bulk=0 rc.dependency.confirmation=no description ~ 'invalidcommand']",
+ u'Configuration override rc.recurrence.confirmation:no',
+ u'Configuration override rc.json.array:off',
+ u'Configuration override rc.confirmation:no',
+ u'Configuration override rc.bulk:0',
+ u'Configuration override rc.dependency.confirmation:no',
+ u'No matches.',
+ u'There are local changes. Sync required.'],
+ 1)
+
+
+Setting custom configuration values
+-----------------------------------
+
+By default, TaskWarrior uses configuration values stored in your .taskrc.
+To see what configuration value overrides are passed to each executed
+task command, have a peek into ``overrides`` attribute of ``TaskWarrior`` object::
+
+ >>> tw.overrides
+ {'confirmation': 'no', 'data.location': '/home/tbabej/.task'}
+
+To pass your own configuration overrides, you just need to update this dictionary::
+
+ >>> tw.overrides.update({'hooks': 'off'}) # tasklib will not trigger hooks
+
+Creating hook scripts
+---------------------
+
+From version 2.4.0, TaskWarrior has support for hook scripts. Tasklib provides
+some very useful helpers to write those. With tasklib, writing these becomes
+a breeze::
+
+ #!/usr/bin/python
+
+ from tasklib.task import Task
+ task = Task.from_input()
+ # ... <custom logic>
+ print task.export_data()
+
+For example, plugin which would assign the priority "H" to any task containing
+three exclamation marks in the description, would go like this::
+
+ #!/usr/bin/python
+
+ from tasklib.task import Task
+ task = Task.from_input()
+
+ if "!!!" in task['description']:
+ task['priority'] = "H"
+
+ print task.export_data()
+
+Tasklib can automatically detect whether it's running in the ``on-modify`` event,
+which provides more input than ``on-add`` event and reads the data accordingly.
+
+This means the example above works both for ``on-add`` and ``on-modify`` events!
+
+Consenquently, you can create just one hook file for both ``on-add`` and
+``on-modify`` events, and you just need to create a symlink for the other one.
+This removes the need for maintaining two copies of the same code base and/or
+boilerplate code.
+
+In ``on-modify`` events, tasklib loads both the original version and the modified
+version of the task to the returned ``Task`` object. To access the original data
+(in read-only manner), use ``original`` dict-like attribute:
+
+ >>> t = Task.from_input()
+ >>> t['description']
+ "Modified description"
+ >>> t.original['description']
+ "Original description"
+
+Working with UDAs
+-----------------
+
+Since TaskWarrior does read your .taskrc, you need not to define any UDAs
+in the TaskWarrior's config dictionary, as described above. Suppose we have
+a estimate UDA in the .taskrc::
+
+ uda.estimate.type = numeric
+
+We can simply filter and create tasks using the estimate UDA out of the box::
+
+ >>> tw = TaskWarrior()
+ >>> task = Task(tw, description="Long task", estimate=1000)
+ >>> task.save()
+ >>> task['id']
+ 1
+
+This is saved as UDA in the TaskWarrior::
+
+ $ task 1 export
+ {"id":1,"description":"Long task","estimate":1000, ...}
+
+We can also speficy UDAs as arguments in the TaskFilter::
+
+ >>> tw.tasks.filter(estimate=1000)
+ Long task
+
+Syncing
+-------
+
+If you have configurated the needed config variables in your .taskrc, syncing
+is as easy as::
+
+ >>> tw = TaskWarrior()
+ >>> tw.execute_command(['sync'])
+
+If you want to use non-standard server/credentials, you'll need to provide configuration
+overrides to the ``TaskWarrior`` instance. Update the ``config`` dictionary with the
+values you desire to override, and then we can run the sync command using
+the ``execute_command()`` method::
+
+ >>> tw = TaskWarrior()
+ >>> sync_config = {
+ ... 'taskd.certificate': '/home/tbabej/.task/tbabej.cert.pem',
+ ... 'taskd.credentials': 'Public/tbabej/34af54de-3cb2-4d3d-82be-33ddb8fd3e66',
+ ... 'taskd.server': 'task.server.com:53589',
+ ... 'taskd.ca': '/home/tbabej/.task/ca.cert.pem',
+ ... 'taskd.trust': 'ignore hostname'}
+ >>> tw.config.update(sync_config)
+ >>> tw.execute_command(['sync'])
- >>> task = tw.tasks.pending().filter(tags__contain='work').get()
- >>> task.done()
- >>> len(tw.tasks.pending().filter(tags__contain='work'))
- 0
.. _taskwarrior: http://taskwarrior.org