]> git.madduck.net Git - etc/taskwarrior.git/commitdiff

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

Merge branch 'release/0.7.0'
authorRob Golding <rob@robgolding.com>
Sun, 11 Jan 2015 19:03:16 +0000 (19:03 +0000)
committerRob Golding <rob@robgolding.com>
Sun, 11 Jan 2015 19:03:16 +0000 (19:03 +0000)
.travis.yml
README.rst
docs/conf.py
docs/index.rst
setup.py
tasklib/task.py
tasklib/tests.py

index 7c1a6291234b96a760b8988e033cfcdf07211a63..6a1936e8bab30f2bacf7bda68a0efbb3f26b7ecf 100644 (file)
@@ -4,7 +4,8 @@ env:
   - TASK_VERSION=v2.1.2
   - TASK_VERSION=v2.2.0
   - TASK_VERSION=v2.3.0
-  - TASK_VERSION=2.4.0
+  - TASK_VERSION=v2.4.0
+  - TASK_VERSION=2.4.1
 python:
   - "2.6"
   - "2.7"
@@ -13,7 +14,11 @@ python:
   - "3.4"
 install:
   - pip install -e .
-  - sudo apt-get install -qq build-essential cmake uuid-dev
+  - pip install coveralls
+  - sudo add-apt-repository ppa:ubuntu-toolchain-r/test -y
+  - sudo apt-get update -qq
+  - sudo apt-get install -qq build-essential cmake uuid-dev g++-4.8
+  - sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-4.8 50
   - git clone https://git.tasktools.org/scm/tm/task.git
   - cd task
   - git checkout $TASK_VERSION
@@ -25,4 +30,6 @@ install:
 before_script:
   - cd $TRAVIS_BUILD_DIR
 script:
-  - python setup.py test
+  - coverage run --source=tasklib setup.py test
+after_success:
+  - coveralls
index b0a3e3453971b7fdb147b26db04097dd28848232..ff25287dc08ad5ab280aa93eade6013f0ef6fc45 100644 (file)
@@ -4,6 +4,9 @@ tasklib
 .. image:: https://travis-ci.org/robgolding63/tasklib.png
     :target: http://travis-ci.org/robgolding63/tasklib
 
+.. image:: https://coveralls.io/repos/robgolding63/tasklib/badge.png?branch=develop
+    :target: https://coveralls.io/r/robgolding63/tasklib?branch=develop
+
 tasklib is a Python library for interacting with taskwarrior_ databases, using
 a queryset API similar to that of Django's ORM.
 
index 3e43ce3d14fe62c5e5552e23794808478d257929..650efc95ec4b88f07569121a37df03f6a35b660c 100644 (file)
@@ -44,16 +44,16 @@ master_doc = 'index'
 
 # General information about the project.
 project = u'tasklib'
-copyright = u'2013, Rob Golding'
+copyright = u'2014, Rob Golding'
 
 # 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 = '0.1'
+version = '0.7.0'
 # The full version, including alpha/beta/rc tags.
-release = '0.1'
+release = '0.7.0'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
index 0338bd191bfa8840003fc33c8c786fa825194be3..44ef46949b7dc7698800aada024e18745c715371 100644 (file)
@@ -36,6 +36,113 @@ The default location is the same as taskwarrior's::
 
     >>> tw = TaskWarrior(data_location='~/.task', create=True)
 
+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(2013, 12, 5, 0, 0)
+    >>> 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.
+
+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']
+
+
 Retrieving Tasks
 ----------------
 
@@ -43,13 +150,14 @@ 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
@@ -62,6 +170,22 @@ Tasks can also be filtered using raw commands, like so::
     >>> 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')
@@ -72,61 +196,163 @@ There are built-in functions for retrieving pending & completed tasks::
 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::
+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)
+    >>> 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
+
+Setting custom configuration values
+-----------------------------------
+
+By default, TaskWarrior does not use any of configuration values stored in
+your .taskrc. To see what configuration values are passed to each executed
+task command, have a peek into ``config`` attribute of ``TaskWarrior`` object::
+
+    >>> tw.config
+    {'confirmation': 'no', 'data.location': '/home/tbabej/.task'}
+
+To pass your own configuration, you just need to update this dictionary::
+
+    >>> tw.config.update({'hooks': 'off'})  # tasklib will not trigger hooks
+
+Working with UDAs
+-----------------
+
+Since TaskWarrior does not read your .taskrc, you need to define any UDAs
+in the TaskWarrior's config dictionary, as described above.
+
+Let us demonstrate this on the same example as in the TaskWarrior's docs::
+
+    >>> tw = TaskWarrior()
+    >>> tw.config.update({'uda.estimate.type': 'numeric'})
+
+Now we can filter and create tasks using the estimate UDA::
+
+    >>> 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, ...}
+
+As long as ``TaskWarrior``'s config is updated, we can approach UDAs as built in attributes::
+
+    >>> tw.tasks.filter(estimate=1000)
+    Long task
+
+Syncing
+-------
+
+Syncing is not directly supported by tasklib, but it can be made to work in a similiar way
+as the UDAs. First we need to update the ``config`` dictionary by the values required for
+sync to work, 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
index 088747a3ffe210ea3cf49d50e8491a8c90bccc2e..92f8e85a7d752a0f86aa27cdecec54330b7fa391 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,6 @@
 from setuptools import setup, find_packages
 
-version = '0.6.0'
+version = '0.7.0'
 
 setup(
     name='tasklib',
index bb3fa537438250a7153f3aa214138bbba65819dc..6392b9e96e7b39f1c569cdb22089d232c3ba2fae 100644 (file)
@@ -24,24 +24,126 @@ class TaskWarriorException(Exception):
     pass
 
 
-class TaskResource(object):
+class SerializingObject(object):
+    """
+    Common ancestor for TaskResource & TaskFilter, since they both
+    need to serialize arguments.
+    """
+
+    def _deserialize(self, key, value):
+        hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
+                               lambda x: x if x != '' else None)
+        return hydrate_func(value)
+
+    def _serialize(self, key, value):
+        dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
+                                 lambda x: x if x is not None else '')
+        return dehydrate_func(value)
+
+    def timestamp_serializer(self, date):
+        if not date:
+            return None
+        return date.strftime(DATE_FORMAT)
+
+    def timestamp_deserializer(self, date_str):
+        if not date_str:
+            return None
+        return datetime.datetime.strptime(date_str, DATE_FORMAT)
+
+    def serialize_entry(self, value):
+        return self.timestamp_serializer(value)
+
+    def deserialize_entry(self, value):
+        return self.timestamp_deserializer(value)
+
+    def serialize_modified(self, value):
+        return self.timestamp_serializer(value)
+
+    def deserialize_modified(self, value):
+        return self.timestamp_deserializer(value)
+
+    def serialize_due(self, value):
+        return self.timestamp_serializer(value)
+
+    def deserialize_due(self, value):
+        return self.timestamp_deserializer(value)
+
+    def serialize_scheduled(self, value):
+        return self.timestamp_serializer(value)
+
+    def deserialize_scheduled(self, value):
+        return self.timestamp_deserializer(value)
+
+    def serialize_until(self, value):
+        return self.timestamp_serializer(value)
+
+    def deserialize_until(self, value):
+        return self.timestamp_deserializer(value)
+
+    def serialize_wait(self, value):
+        return self.timestamp_serializer(value)
+
+    def deserialize_wait(self, value):
+        return self.timestamp_deserializer(value)
+
+    def deserialize_annotations(self, data):
+        return [TaskAnnotation(self, d) for d in data] if data else []
+
+    def serialize_tags(self, tags):
+        return ','.join(tags) if tags else ''
+
+    def deserialize_tags(self, tags):
+        if isinstance(tags, basestring):
+            return tags.split(',') if tags else []
+        return tags
+
+    def serialize_depends(self, cur_dependencies):
+        # Return the list of uuids
+        return ','.join(task['uuid'] for task in cur_dependencies)
+
+    def deserialize_depends(self, raw_uuids):
+        raw_uuids = raw_uuids or ''  # Convert None to empty string
+        uuids = raw_uuids.split(',')
+        return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
+
+
+class TaskResource(SerializingObject):
     read_only_fields = []
 
     def _load_data(self, data):
         self._data = data
+        # We need to use a copy for original data, so that changes
+        # are not propagated. Shallow copy is alright, since data dict uses only
+        # primitive data types
+        self._original_data = data.copy()
+
+    def _update_data(self, data, update_original=False):
+        """
+        Low level update of the internal _data dict. Data which are coming as
+        updates should already be serialized. If update_original is True, the
+        original_data dict is updated as well.
+        """
+
+        self._data.update(data)
+
+        if update_original:
+            self._original_data.update(data)
 
     def __getitem__(self, key):
-        hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
-                               lambda x: x)
-        return hydrate_func(self._data.get(key))
+        # This is a workaround to make TaskResource non-iterable
+        # over simple index-based iteration
+        try:
+            int(key)
+            raise StopIteration
+        except ValueError:
+            pass
+
+        return self._deserialize(key, self._data.get(key))
 
     def __setitem__(self, key, value):
         if key in self.read_only_fields:
             raise RuntimeError('Field \'%s\' is read-only' % key)
-        dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
-                                 lambda x: x)
-        self._data[key] = dehydrate_func(value)
-        self._modified_fields.add(key)
+        self._data[key] = self._serialize(key, value)
 
     def __str__(self):
         s = six.text_type(self.__unicode__())
@@ -60,12 +162,6 @@ class TaskAnnotation(TaskResource):
         self.task = task
         self._load_data(data)
 
-    def deserialize_entry(self, data):
-        return datetime.datetime.strptime(data, DATE_FORMAT) if data else None
-
-    def serialize_entry(self, date):
-        return date.strftime(DATE_FORMAT) if date else ''
-
     def remove(self):
         self.task.remove_annotation(self)
 
@@ -76,7 +172,7 @@ class TaskAnnotation(TaskResource):
 
 
 class Task(TaskResource):
-    read_only_fields = ['id', 'entry', 'urgency', 'uuid']
+    read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
 
     class DoesNotExist(Exception):
         pass
@@ -100,18 +196,49 @@ class Task(TaskResource):
         """
         pass
 
-    def __init__(self, warrior, data={}, **kwargs):
+    def __init__(self, warrior, **kwargs):
         self.warrior = warrior
 
-        # We keep data for backwards compatibility
-        kwargs.update(data)
+        # Check that user is not able to set read-only value in __init__
+        for key in kwargs.keys():
+            if key in self.read_only_fields:
+                raise RuntimeError('Field \'%s\' is read-only' % key)
 
-        self._load_data(kwargs)
-        self._modified_fields = set()
+        # We serialize the data in kwargs so that users of the library
+        # do not have to pass different data formats via __setitem__ and
+        # __init__ methods, that would be confusing
+
+        # Rather unfortunate syntax due to python2.6 comaptiblity
+        self._load_data(dict((key, self._serialize(key, value))
+                        for (key, value) in six.iteritems(kwargs)))
 
     def __unicode__(self):
         return self['description']
 
+    def __eq__(self, other):
+        if self['uuid'] and other['uuid']:
+            # For saved Tasks, just define equality by equality of uuids
+            return self['uuid'] == other['uuid']
+        else:
+            # If the tasks are not saved, compare the actual instances
+            return id(self) == id(other)
+
+
+    def __hash__(self):
+        if self['uuid']:
+            # For saved Tasks, just define equality by equality of uuids
+            return self['uuid'].__hash__()
+        else:
+            # If the tasks are not saved, return hash of instance id
+            return id(self).__hash__()
+
+    @property
+    def _modified_fields(self):
+        writable_fields = set(self._data.keys()) - set(self.read_only_fields)
+        for key in writable_fields:
+            if self._data.get(key) != self._original_data.get(key):
+                yield key
+
     @property
     def completed(self):
         return self['status'] == six.text_type('completed')
@@ -132,24 +259,42 @@ class Task(TaskResource):
     def saved(self):
         return self['uuid'] is not None or self['id'] is not None
 
-    def serialize_due(self, date):
-        return date.strftime(DATE_FORMAT)
+    def serialize_depends(self, cur_dependencies):
+        # Check that all the tasks are saved
+        for task in cur_dependencies:
+            if not task.saved:
+                raise Task.NotSaved('Task \'%s\' needs to be saved before '
+                                    'it can be set as dependency.' % task)
 
-    def deserialize_due(self, date_str):
-        if not date_str:
-            return None
-        return datetime.datetime.strptime(date_str, DATE_FORMAT)
+        return super(Task, self).serialize_depends(cur_dependencies)
 
-    def deserialize_annotations(self, data):
-        return [TaskAnnotation(self, d) for d in data] if data else []
+    def format_depends(self):
+        # We need to generate added and removed dependencies list,
+        # since Taskwarrior does not accept redefining dependencies.
 
-    def deserialize_tags(self, tags):
-        if isinstance(tags, basestring):
-            return tags.split(',') if tags else []
-        return tags
+        # This cannot be part of serialize_depends, since we need
+        # to keep a list of all depedencies in the _data dictionary,
+        # not just currently added/removed ones
 
-    def serialize_tags(self, tags):
-        return ','.join(tags) if tags else ''
+        old_dependencies_raw = self._original_data.get('depends','')
+        old_dependencies = self.deserialize_depends(old_dependencies_raw)
+
+        added = self['depends'] - old_dependencies
+        removed = old_dependencies - self['depends']
+
+        # Removed dependencies need to be prefixed with '-'
+        return 'depends:' + ','.join(
+                [t['uuid'] for t in added] +
+                ['-' + t['uuid'] for t in removed]
+            )
+
+    def format_description(self):
+        # Task version older than 2.4.0 ignores first word of the
+        # task description if description: prefix is used
+        if self.warrior.version < VERSION_2_4_0:
+            return self._data['description']
+        else:
+            return "description:'{0}'".format(self._data['description'] or '')
 
     def delete(self):
         if not self.saved:
@@ -161,9 +306,7 @@ class Task(TaskResource):
         if self.deleted:
             raise Task.DeletedTask("Task was already deleted")
 
-        self.warrior.execute_command([self['uuid'], 'delete'], config_override={
-            'confirmation': 'no',
-        })
+        self.warrior.execute_command([self['uuid'], 'delete'])
 
         # Refresh the status again, so that we have updated info stored
         self.refresh(only_fields=['status'])
@@ -204,7 +347,6 @@ class Task(TaskResource):
             # Circumvent the ID storage, since ID is considered read-only
             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
 
-        self._modified_fields.clear()
         self.refresh()
 
     def add_annotation(self, annotation):
@@ -217,7 +359,7 @@ class Task(TaskResource):
 
     def remove_annotation(self, annotation):
         if not self.saved:
-            raise Task.NotSaved("Task needs to be saved to add annotation")
+            raise Task.NotSaved("Task needs to be saved to remove annotation")
 
         if isinstance(annotation, TaskAnnotation):
             annotation = annotation['description']
@@ -229,12 +371,12 @@ class Task(TaskResource):
         args = []
 
         def add_field(field):
-            # Task version older than 2.4.0 ignores first word of the
-            # task description if description: prefix is used
-            if self.warrior.version < VERSION_2_4_0 and field == 'description':
-                args.append(self._data[field])
-            else:
-                args.append('{0}:{1}'.format(field, self._data[field]))
+            # Add the output of format_field method to args list (defaults to
+            # field:value)
+            format_default = lambda k: "{0}:'{1}'".format(k, self._data[k] or '')
+            format_func = getattr(self, 'format_{0}'.format(field),
+                                  lambda: format_default(field))
+            args.append(format_func())
 
         # If we're modifying saved task, simply pass on all modified fields
         if self.saved:
@@ -262,12 +404,12 @@ class Task(TaskResource):
         if only_fields:
             to_update = dict(
                 [(k, new_data.get(k)) for k in only_fields])
-            self._data.update(to_update)
+            self._update_data(to_update, update_original=True)
         else:
-            self._data = new_data
+            self._load_data(new_data)
 
 
-class TaskFilter(object):
+class TaskFilter(SerializingObject):
     """
     A set of parameters to filter the task list with.
     """
@@ -283,14 +425,23 @@ class TaskFilter(object):
 
         # Replace the value with empty string, since that is the
         # convention in TW for empty values
-        value = value if value is not None else ''
+        attribute_key = key.split('.')[0]
+        value = self._serialize(attribute_key, value)
 
         # If we are filtering by uuid:, do not use uuid keyword
         # due to TW-1452 bug
         if key == 'uuid':
             self.filter_params.insert(0, value)
         else:
-            self.filter_params.append('{0}:{1}'.format(key, value))
+            # Surround value with aphostrophes unless it's a empty string
+            value = "'%s'" % value if value else ''
+
+            # We enforce equality match by using 'is' (or 'none') modifier
+            # Without using this syntax, filter fails due to TW-1479
+            modifier = '.is' if value else '.none'
+            key = key + modifier if '.' not in key else key
+
+            self.filter_params.append("{0}:{1}".format(key, value))
 
     def get_filter_params(self):
         return [f for f in self.filter_params if f]
@@ -418,6 +569,8 @@ class TaskWarrior(object):
             os.makedirs(data_location)
         self.config = {
             'data.location': os.path.expanduser(data_location),
+            'confirmation': 'no',
+            'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
         }
         self.tasks = TaskQuerySet(self)
         self.version = self._get_version()
@@ -461,7 +614,9 @@ class TaskWarrior(object):
             if line:
                 data = line.strip(',')
                 try:
-                    tasks.append(Task(self, json.loads(data)))
+                    filtered_task = Task(self)
+                    filtered_task._load_data(json.loads(data))
+                    tasks.append(filtered_task)
                 except ValueError:
                     raise TaskWarriorException('Invalid JSON: %s' % data)
         return tasks
@@ -473,6 +628,4 @@ class TaskWarrior(object):
         })
 
     def undo(self):
-        self.execute_command(['undo'], config_override={
-            'confirmation': 'no',
-        })
+        self.execute_command(['undo'])
index 2103c0625d19c3bd6abf69c0847c79230a3fecc5..6d67a12d4fb7ae0098080bf5762bee7d1e54c701 100644 (file)
@@ -1,5 +1,6 @@
 # coding=utf-8
 
+import datetime
 import shutil
 import tempfile
 import unittest
@@ -69,6 +70,34 @@ class TaskFilterTest(TasklibTest):
         no_priority_task = self.tw.tasks.get(priority=None)
         self.assertEqual(no_priority_task['description'], "no priority task")
 
+    def test_filter_for_task_with_space_in_descripition(self):
+        task = Task(self.tw, description="test task")
+        task.save()
+
+        filtered_task = self.tw.tasks.get(description="test task")
+        self.assertEqual(filtered_task['description'], "test task")
+
+    def test_filter_for_task_without_space_in_descripition(self):
+        task = Task(self.tw, description="test")
+        task.save()
+
+        filtered_task = self.tw.tasks.get(description="test")
+        self.assertEqual(filtered_task['description'], "test")
+
+    def test_filter_for_task_with_space_in_project(self):
+        task = Task(self.tw, description="test", project="random project")
+        task.save()
+
+        filtered_task = self.tw.tasks.get(project="random project")
+        self.assertEqual(filtered_task['project'], "random project")
+
+    def test_filter_for_task_without_space_in_project(self):
+        task = Task(self.tw, description="test", project="random")
+        task.save()
+
+        filtered_task = self.tw.tasks.get(project="random")
+        self.assertEqual(filtered_task['project'], "random")
+
 
 class TaskTest(TasklibTest):
 
@@ -113,6 +142,277 @@ class TaskTest(TasklibTest):
 
         self.assertRaises(Task.DeletedTask, t.done)
 
+    def test_modify_simple_attribute_without_space(self):
+        t = Task(self.tw, description="test")
+        t.save()
+
+        self.assertEquals(t['description'], "test")
+
+        t['description'] = "test-modified"
+        t.save()
+
+        self.assertEquals(t['description'], "test-modified")
+
+    def test_modify_simple_attribute_with_space(self):
+        # Space can pose problems with parsing
+        t = Task(self.tw, description="test task")
+        t.save()
+
+        self.assertEquals(t['description'], "test task")
+
+        t['description'] = "test task modified"
+        t.save()
+
+        self.assertEquals(t['description'], "test task modified")
+
+    def test_empty_dependency_set_of_unsaved_task(self):
+        t = Task(self.tw, description="test task")
+        self.assertEqual(t['depends'], set())
+
+    def test_empty_dependency_set_of_saved_task(self):
+        t = Task(self.tw, description="test task")
+        t.save()
+        self.assertEqual(t['depends'], set())
+
+    def test_set_unsaved_task_as_dependency(self):
+        # Adds only one dependency to task with no dependencies
+        t = Task(self.tw, description="test task")
+        dependency = Task(self.tw, description="needs to be done first")
+
+        # We only save the parent task, dependency task is unsaved
+        t.save()
+
+        self.assertRaises(Task.NotSaved,
+                          t.__setitem__, 'depends', set([dependency]))
+
+    def test_set_simple_dependency_set(self):
+        # Adds only one dependency to task with no dependencies
+        t = Task(self.tw, description="test task")
+        dependency = Task(self.tw, description="needs to be done first")
+
+        t.save()
+        dependency.save()
+
+        t['depends'] = set([dependency])
+
+        self.assertEqual(t['depends'], set([dependency]))
+
+    def test_set_complex_dependency_set(self):
+        # Adds two dependencies to task with no dependencies
+        t = Task(self.tw, description="test task")
+        dependency1 = Task(self.tw, description="needs to be done first")
+        dependency2 = Task(self.tw, description="needs to be done second")
+
+        t.save()
+        dependency1.save()
+        dependency2.save()
+
+        t['depends'] = set([dependency1, dependency2])
+
+        self.assertEqual(t['depends'], set([dependency1, dependency2]))
+
+    def test_remove_from_dependency_set(self):
+        # Removes dependency from task with two dependencies
+        t = Task(self.tw, description="test task")
+        dependency1 = Task(self.tw, description="needs to be done first")
+        dependency2 = Task(self.tw, description="needs to be done second")
+
+        dependency1.save()
+        dependency2.save()
+
+        t['depends'] = set([dependency1, dependency2])
+        t.save()
+
+        t['depends'] = t['depends'] - set([dependency2])
+        t.save()
+
+        self.assertEqual(t['depends'], set([dependency1]))
+
+    def test_add_to_dependency_set(self):
+        # Adds dependency to task with one dependencies
+        t = Task(self.tw, description="test task")
+        dependency1 = Task(self.tw, description="needs to be done first")
+        dependency2 = Task(self.tw, description="needs to be done second")
+
+        dependency1.save()
+        dependency2.save()
+
+        t['depends'] = set([dependency1])
+        t.save()
+
+        t['depends'] = t['depends'] | set([dependency2])
+        t.save()
+
+        self.assertEqual(t['depends'], set([dependency1, dependency2]))
+
+    def test_simple_dependency_set_save_repeatedly(self):
+        # Adds only one dependency to task with no dependencies
+        t = Task(self.tw, description="test task")
+        dependency = Task(self.tw, description="needs to be done first")
+        dependency.save()
+
+        t['depends'] = set([dependency])
+        t.save()
+
+        # We taint the task, but keep depends intact
+        t['description'] = "test task modified"
+        t.save()
+
+        self.assertEqual(t['depends'], set([dependency]))
+
+        # We taint the task, but assign the same set to the depends
+        t['depends'] = set([dependency])
+        t['description'] = "test task modified again"
+        t.save()
+
+        self.assertEqual(t['depends'], set([dependency]))
+
+    def test_compare_different_tasks(self):
+        # Negative: compare two different tasks
+        t1 = Task(self.tw, description="test task")
+        t2 = Task(self.tw, description="test task")
+
+        t1.save()
+        t2.save()
+
+        self.assertEqual(t1 == t2, False)
+
+    def test_compare_same_task_object(self):
+        # Compare Task object wit itself
+        t = Task(self.tw, description="test task")
+        t.save()
+
+        self.assertEqual(t == t, True)
+
+    def test_compare_same_task(self):
+        # Compare the same task using two different objects
+        t1 = Task(self.tw, description="test task")
+        t1.save()
+
+        t2 = self.tw.tasks.get(uuid=t1['uuid'])
+        self.assertEqual(t1 == t2, True)
+
+    def test_compare_unsaved_tasks(self):
+        # t1 and t2 are unsaved tasks, considered to be unequal
+        # despite the content of data
+        t1 = Task(self.tw, description="test task")
+        t2 = Task(self.tw, description="test task")
+
+        self.assertEqual(t1 == t2, False)
+
+    def test_hash_unsaved_tasks(self):
+        # Considered equal, it's the same object
+        t1 = Task(self.tw, description="test task")
+        t2 = t1
+        self.assertEqual(hash(t1) == hash(t2), True)
+
+    def test_hash_same_task(self):
+        # Compare the hash of the task using two different objects
+        t1 = Task(self.tw, description="test task")
+        t1.save()
+
+        t2 = self.tw.tasks.get(uuid=t1['uuid'])
+        self.assertEqual(t1.__hash__(), t2.__hash__())
+
+    def test_adding_task_with_priority(self):
+        t = Task(self.tw, description="test task", priority="M")
+        t.save()
+
+    def test_removing_priority_with_none(self):
+        t = Task(self.tw, description="test task", priority="L")
+        t.save()
+
+        # Remove the priority mark
+        t['priority'] = None
+        t.save()
+
+        # Assert that priority is not there after saving
+        self.assertEqual(t['priority'], None)
+
+    def test_adding_task_with_due_time(self):
+        t = Task(self.tw, description="test task", due=datetime.datetime.now())
+        t.save()
+
+    def test_removing_due_time_with_none(self):
+        t = Task(self.tw, description="test task", due=datetime.datetime.now())
+        t.save()
+
+        # Remove the due timestamp
+        t['due'] = None
+        t.save()
+
+        # Assert that due timestamp is no longer there
+        self.assertEqual(t['due'], None)
+
+    def test_modified_fields_new_task(self):
+        t = Task(self.tw)
+
+        # This should be empty with new task
+        self.assertEqual(set(t._modified_fields), set())
+
+        # Modify the task
+        t['description'] = "test task"
+        self.assertEqual(set(t._modified_fields), set(['description']))
+
+        t['due'] = datetime.datetime(2014, 2, 14, 14, 14, 14)  # <3
+        self.assertEqual(set(t._modified_fields), set(['description', 'due']))
+
+        t['project'] = "test project"
+        self.assertEqual(set(t._modified_fields),
+                         set(['description', 'due', 'project']))
+
+        # List of modified fields should clear out when saved
+        t.save()
+        self.assertEqual(set(t._modified_fields), set())
+
+        # Reassigning the fields with the same values now should not produce
+        # modified fields
+        t['description'] = "test task"
+        t['due'] = datetime.datetime(2014, 2, 14, 14, 14, 14)  # <3
+        t['project'] = "test project"
+        self.assertEqual(set(t._modified_fields), set())
+
+    def test_modified_fields_loaded_task(self):
+        t = Task(self.tw)
+
+        # Modify the task
+        t['description'] = "test task"
+        t['due'] = datetime.datetime(2014, 2, 14, 14, 14, 14)  # <3
+        t['project'] = "test project"
+
+        dependency = Task(self.tw, description="dependency")
+        dependency.save()
+        t['depends'] = set([dependency])
+
+        # List of modified fields should clear out when saved
+        t.save()
+        self.assertEqual(set(t._modified_fields), set())
+
+        # Get the task by using a filter by UUID
+        t2 = self.tw.tasks.get(uuid=t['uuid'])
+
+        # Reassigning the fields with the same values now should not produce
+        # modified fields
+        t['description'] = "test task"
+        t['due'] = datetime.datetime(2014, 2, 14, 14, 14, 14)  # <3
+        t['project'] = "test project"
+        t['depends'] = set([dependency])
+        self.assertEqual(set(t._modified_fields), set())
+
+    def test_setting_read_only_attrs_through_init(self):
+        # Test that we are unable to set readonly attrs through __init__
+        for readonly_key in Task.read_only_fields:
+            kwargs = {'description': 'test task', readonly_key: 'value'}
+            self.assertRaises(RuntimeError,
+                              lambda: Task(self.tw, **kwargs))
+
+    def test_setting_read_only_attrs_through_setitem(self):
+        # Test that we are unable to set readonly attrs through __init__
+        for readonly_key in Task.read_only_fields:
+            t = Task(self.tw, description='test task')
+            self.assertRaises(RuntimeError,
+                              lambda: t.__setitem__(readonly_key, 'value'))
+
 
 class AnnotationTest(TasklibTest):