Developer guide

Timesheets and entries

The Entry class is the base of Taxi. An entry is the record of an activity for a certain period of time. It consists of an activity, a time span, and a description:

>>> from taxi.timesheet import Entry
>>> my_entry = Entry('_internal', 1, 'Play ping-pong')

Entries duration can be expressed either as a fixed duration, in hours (eg. 0.5 for half an hour), or as time spans. Time span notation works with 2-items tuples, like so: (start_time, end_time). In the following example, the entry starts at 9:30 and ends at 10, thus having a duration of half an hour:

>>> from datetime import time
>>> my_entry = Entry('_internal', (time(9, 30), time(10)), 'Play ping-ping')
>>> my_entry.hours
0.5

end_time can be left blank, in that case the entry will be considered as being “in progress”. This is useful in certain situations, for example the start command uses this feature to start an entry which can then be “stopped” with the stop commands (which detects the last in-progress activity and sets its end time).

>>> my_entry = Entry('_internal', (time(9), None), 'Play ping-pong')
>>> my_entry.hours
0
>>> my_entry.in_progress
True

Now we know how to create entries, we can put them together in timesheets, which is a collection of entries and dates. You might have noticed entries don’t have an associated date: that’s because the link between dates and entries is in the timesheet itself. Let’s create a timesheet:

>>> from datetime import date
>>> from taxi.timesheet import Timesheet
>>> timesheet = Timesheet()
>>> timesheet.entries
{}

Now we have a timesheet, we can start adding entries to it:

>>> timesheet.entries.add(date(2017, 6, 7), my_entry)
>>> timesheet.entries
{datetime.date(2017, 6, 7): [<Entry: "_internal 0 Play ping-pong">]}

You can dump the timesheet contents by casting it to a string:

>>> str(timesheet)
'07.06.2017\n\n_internal 09:00-? Play ping-pong'

Entries also have flags: pushed and ignored. Ignored and pushed entries will be excluded from the commit process:

>>> my_entry = Entry('_internal', 1, 'Play ping-pong')
>>> my_entry.ignored = True
>>> timesheet = Timesheet()
>>> timesheet.entries.add(date(2017, 6, 7), my_entry)
>>> str(timesheet)
'07.06.2017\n\n? _internal 1 Play ping-pong'

Loading and saving timesheets

Use the load method to create a timesheet from a file:

>>> timesheet = Timesheet.load('/tmp/timesheet.tks')
>>> timesheet.entries.add(date(2017, 6, 7), Entry('_internal', 1, 'Play ping-pong'))
>>> timesheet.save()

You can also save the timesheet to a different file from the file it was loaded from:

>>> timesheet.save('/tmp/new_timesheet.tks')

Timesheet collections

Dealing with multiple timesheets is achieved through the taxi.timesheet.TimesheetCollection class. This is useful if you want to run operations on multiple timesheets in a single command. The TimesheetCollection class proxies all calls to the associated timesheets and aggregates the results. The following example illustrates how the entries attribute from a timesheet collection can be used to transparently access entries from all associated timesheets:

>>> from taxi.timesheet import TimesheetCollection
>>> timesheets = [Timesheet(), Timesheet()]
>>> timesheets[0].entries.add(date(2017, 6, 8), Entry('_internal', 1, 'Play ping-pong'))
>>> timesheets[1].entries.add(date(2017, 7, 8), Entry('_internal', 1, 'Play ping-pong'))
>>> timesheet_collection = TimesheetCollection(timesheets)
>>> timesheet_collection.entries
{datetime.date(2017, 6, 8): [<Entry: ...>], datetime.date(2017, 7, 8): [<Entry: ...>]}
>>> timesheet_collection.get_hours()
2

Creating a backend

A backend is a Python package that can be installed independently of Taxi and that persists the entries transmitted by the commit command. To create a backend, you’ll need to create a new Python package, which is hopefully quite easy to do.

As an example, we’ll build a simple backend that sends the timesheets it receives by mail. We’ll call it taxi_mail.

Registering the backend

A backend provides functionality but should not contain harcoded configuration such as usernames or passwords. Think about other people who will want to use your backend, they’ll probably don’t have the same credentials as you.

A backend is defined and configured by a URI that allows you to configure it. The full syntax is:

[backends]
default = <backend_name>://<user>:<password>@<host>:<port><path><options>

Your backend obviously doesn’t have to use all the parts of the URI. For example an unauthenticated backend won’t need any user or password, and the user is allowed to leave them blank in the configuration file.

Let’s start to write our backend. The first thing you’ll want to do is define a setup.py file. Here’s an example:

#!/usr/bin/env python
from setuptools import find_packages, setup

setup(
    name='taxi_mail',
    version='1.0',
    packages=find_packages(),
    description='Mail backend for Taxi',
    author='Me',
    author_email='me@example.com',
    url='https://github.com/me/taxi-mail',
    license='wtfpl',
    entry_points={
        'taxi.backends': 'smtp = taxi_mail.backend:MailBackend'
    }
)

The important part is the entry_points. This is what will tell Taxi the class to use for the backend. The key smtp is the name of the backend. This is what the user will put in <backend_name> in the configuration file. The part taxi_mail.backend:MailBackend is the path to our backend class. This basically means from taxi_mail.backend import MailBackend.

Let’s create the backend class:

# file: taxi_mail/backend.py

from taxi.backends import BaseBackend

class MailBackend(BaseBackend):
    pass

The first thing our backend will need to do is store the information we want from the URI so that we can use it later. The BaseBackend already defines an __init__ method that stores all the parts of the backend URI so there isn’t much to do. Let’s think about how the user will configure our backend. The following syntax would probably make sense:

[backends]
mail = smtp://user:password@smtp.gmail.com/me@example.com

We decided to use the <path> part for the e-mail address of the recipient. There’s one detail though: the path here is /me@example.com, so we need to get rid of that initial slash. Let’s do it:

class MailBackend(BaseBackend):
    def __init__(self, **kwargs):
        super(MailBackend, self).__init__(**kwargs)
        self.path = self.path.lstrip('/')

Pushing entries

We now have all the information we need to send mails. For the actual sending, we could implement the push_entry method. However this will fire for every entry, which means we would get one mail per entry. Obviously this is not what we want, but hopefully you can implement the post_push_entries method, which is called once after all entries have been committed. This method also gives you a chance to raise an exception for failing entries.

So let’s buffer the entries to put in the mail in the push_entry method and send them all in the post_push_entries method. The code could look like that:

from collections import defaultdict
import smtplib

from taxi.backends import BaseBackend

class MailBackend(BaseBackend):
    def __init__(self, **kwargs):
        super(MailBackend, self).__init__(**kwargs)
        self.path = self.path.lstrip('/')
        self.entries = defaultdict(list)

    def push_entry(self, date, entry):
        self.entries[date].append(entry)

    def post_push_entries(self):
        timesheet = []

        for date, entries in self.entries.items():
            timesheet.append(date.strftime('%d %m %Y'))

            for entry in entries:
                timesheet.append(str(entry))

        smtp = smtplib.SMTP_SSL(self.hostname)
        smtp.login(self.username, self.password)
        smtp.sendmail('taxi@example.com', self.path, '\n'.join(timesheet))
        smtp.quit()

Note that for the sake of brevity, we didn’t catch any exception at all in this example. It’s of course a good idea to do it, so that the user knows why the entries couldn’t be pushed. If your backends raises an exception, all entries will be considered to have failed and will be reported as such. If you want to report only certain entries as failed in post_push_entries, raise a PushEntriesFailed exception, with a parameter entries that will be a entry: error dictionary.

We now have a fully working backend that can be used to push entries!

Creating custom commands

Taxi will load any module defined in the taxi.commands entry point. Let’s create a current command that displays the path to the current timesheet. First, let’s create the command (in taxi_current/commands.py):

import click

from taxi.commands.base import cli

@cli.command()
@click.pass_context
def current(ctx):
    timesheet_path = ctx.obj['settings'].get_entries_file_path(expand_date=True)
    click.echo("Current timesheet path is " + timesheet_path)

The cli.command part allows us to create a Taxi subcommand. For more information on how to use Click, refer to the official Click documentation. Also feel free to check the source code of the existing commands that can give a good base to start from.

As with custom backend creation, your package should also have a setup.py file. The commands module should be registered in the taxi.commands entry point (in the setup.py file):

#!/usr/bin/env python
from setuptools import find_packages, setup

setup(
    name='taxi_current',
    version='1.0',
    packages=find_packages(),
    description='Show current timesheet',
    author='Me',
    author_email='me@example.com',
    url='https://github.com/me/taxi-current',
    license='wtfpl',
    entry_points={
        'taxi.commands': 'current = taxi_current.commands'
    }
)

That’s it! If you install your custom plugin (eg. with ./setup.py install or by using ./setup.py develop as explained in the Getting a development environment section, you will now be able to type taxi current!

Getting a development environment

Start by cloning Taxi (you’ll probably want to use your fork URL instead of the public URL):

git clone https://github.com/sephii/taxi

Then create a virtual environment with mkvirtualenv:

mkvirtualenv taxi

Now run the setup script to create the development environment:

./setup.py develop

Now every time you’ll want to work on taxi, start by running workon taxi first, so that you’re using the version you checked out instead of the system-wide one.

Running tests

Setup a virtual environment as explained in the previous section, then install the test requirements in it:

pip install -r requirements_test.txt

To run the tests, run the following command:

pytest

When developing it’s useful to only run certain tests, for this, use the following command:

pytest tests/commands/test_alias.py::AliasCommandTestCase::test_alias_list

You can also leave out ::test_alias_list to run all tests in the AliasCommandTestCase, or leave out ::AliasCommandTestCase as well if you have multiple test classes and you want to run them all.