summaryrefslogtreecommitdiff
path: root/bvggrabber/api/__init__.py
blob: 333b0e505df96a5d01a02ab0d3e3c1be71881152 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# -*- coding: utf-8 -*-
import datetime
import re

from functools import total_ordering
from math import floor

from dateutil.parser import parse

from bvggrabber.utils.format import timeformat
from bvggrabber.utils.json import ObjectJSONEncoder


def compute_remaining(start, end):
    """Compute the number of seconds between ``start`` and ``end`` and return
    the number of seconds rounded down to entire minutes. That means:

    * [0, 59] => 0
    * [60, 119] => 60
    * [120, 179] => 120
    * [-59, -1] => -60
    * [-119, -60] => -120

    :param datetime.datetime start: The start to compute the remaining number
        of minutes.
    :param datetime.datetime end: The end of the interval
    :raises: TypeError if either of ``start`` or ``end`` is not a
        ``datetime.datetime``.
    :return: The number of remaining seconds rounded to entire minutes
    :rtype: int
    """
    if not isinstance(start, datetime.datetime):
        raise TypeError("start needs to be a datetime.datetime")
    if not isinstance(end, datetime.datetime):
        raise TypeError("start needs to be a datetime.datetime")
    seconds = (end - start).total_seconds()
    return datetime.timedelta(minutes=floor(seconds / 60)).total_seconds()


class QueryApi(object):

    def __init__(self):
        pass

    def call(self):
        """Needs to be implemented by inheriting classes!"""
        raise NotImplementedError("The inheriting class needs to implement "
                                  "the call() method!")


class Response(object):

    def __init__(self, state, station=None, departures=None, error=None):
        """Creates a new response. Returned by :meth:`QueryApi.call`
        
        :param bool state: ``True`` iff the request and parsing was successful,
            ``False`` otherwise.
        :param station: If a ``list``, the station name is ambiguous. If a
            string the full qualified name of the station.
        :param departures: A list of :class:`Departure`` objects
        :param Exception error: In case an unexpected error occurred, this
            contains the original exception.

        If ``state`` is ``True``, ``station`` must be a ``str`` and
        ``departures`` must be a list of :class:`Departure`` objects. If
        ``state`` is ``False`` there must be several reasons for that:

            1. The provided station name during the :meth:`QueryApi.call`
               returned multiple possible departing stations. You have to
               specify the name in an unambiguous way.
            2. The station does not exist at all.
            3. An exception occurred during the :meth:`QueryApi.call`

        .. deprecated:: 0.1b3
           The ``state`` argument will be removed in the future and will be
           computed automatically based on ``station``, ``departures`` and
           ``error``.
        """
        self._state = state
        self._departures = [(station, departures)]
        self._error = error
        if self._error is None:
            if isinstance(station, list):
                self._state = False
                msg = ', '.join(station)
                self._error = Exception("Station is ambiguous: %s" % msg)
            elif station is None:
                self._state = False
                self._error = Exception("Station does not exist")
        elif isinstance(self._error, str):
            self._state = False
            self._error = Exception(self._error)

    def merge(self, other):
        """Checks that ``other`` is a :class:`Response` and extends
        :attr:`departures` by the departures given in ``other`` iff neither
        response object has a invalid state.
        """
        if isinstance(other, Response):
            if not other.state:
                raise ValueError("The response contains errors: " +
                                 str(other.error))
            elif not self.state:
                raise ValueError("The response contains errors: " +
                                 str(self.error))
            else:
                self.departures.extend(other.departures)
        else:
            raise TypeError("The given object is not a response object")

    @property
    def to_json(self):
        """.. deprecated:: 0.0.1
           Use :attr:`json` instead.
        """
        return ObjectJSONEncoder(ensure_ascii=False).encode(self.departures)

    @property
    def departures(self):
        """A list of 2-tuple in the form (:class:`str`, :class:`Departure`).
        The first element in the tuple defines the departing station where the
        second element holds a list of departure objects.
        """
        if self.state:
            return self._departures
        return str(self.error)

    @property
    def error(self):
        """The error that occurred during creation or ``None``"""
        return self._error

    @property
    def json(self):
        """Uses :class:`~bvggrabber.utils.json.ObjectJSONEncoder` to encode
        the :attr:`departures` to a JSON format.
        """
        if self.state:
            return ObjectJSONEncoder(ensure_ascii=False).encode(self.departures)
        return ObjectJSONEncoder(ensure_ascii=False).encode(str(self.error))

    @property
    def state(self):
        """``True`` iff the request and parsing was successful, ``False``
        otherwise.
        """
        return self._state


@total_ordering
class Departure(object):

    def __init__(self, start, end, when, line, since=None, no_add_day=False):
        """
        :param str start: The start station
        :param str end: The end station
        :param when: The leaving time of the public transport at the given
            ``start`` station. Might be an :class:`int` (timestamp), a
            :class:`datetime.datetime` instance or a :class:`str` accepted by
            ``dateutil.parse()``. If ``when`` is smaller than ``since`` and the
            difference between both times is larger than 12 hours (43200sec),
            the API will add another day unless ``no_add_day`` is ``True``.
        :param str line: The line of the public transport
        :param since: Either ``None`` or :class:`datetime.datetime`. Defines
            the temporal start for searching. ``None`` will internally be
            resolved as :meth:`datetime.datetime.now`.
        :param bool no_add_day: If true, there no additional day will be added
            if ``when`` is smaller than ``since``. Default ``False``.
        :raises: :exc:`TypeError` if ``when`` is invalid or cannot be parsed.
        """
        if since is None:
            self.now = datetime.datetime.now()
        else:
            self.now = since

        self.start = start
        self.end = end
        self.line = line
        if isinstance(when, (int, float)):
            # We assume to get a UNIX / POSIX timestamp
            self.when = datetime.datetime.fromtimestamp(when)
        elif isinstance(when, str):
            # We need to parse a string. But we need to remove trailing
            # whitespaces and *
            self.when = parse(re.sub('[\s*]$', '', when))
        elif isinstance(when, datetime.datetime):
            # Everything's fine, we can just take the parameter as is
            self.when = when
        else:
            raise TypeError("when must be a valid datetime, timestamp or "
                            "string!")
        diff = abs((self.when - self.now).total_seconds())
        if not no_add_day and self.when < self.now and diff > 43200:
            # 43200 are 12 hours in seconds So we accept a offset of 12 hours
            # that is still counted as "time gone" for the current day.
            self.when = self.when + datetime.timedelta(days=1)

    @property
    def remaining(self):
        """.. seealso:: bvggrabber.api.compute_remaining"""
        return int(compute_remaining(self.now, self.when))

    def __eq__(self, other):
        """Two departures are assumed to be equal iff their remaining time
        and their destination are equal.

        Right now we do **not** considering the start or line, since that would
        require some kind of geo location in order to define a *total order*.
        """
        return ((self.remaining, self.end.lower()) ==
                (other.remaining, other.end.lower()))

    def __lt__(self, other):
        """A departure is assumed to be less than another iff its remaining
        time is less than the remaining time of the other departure.

        Right now we do **not** considering the start, end or line, since that
        would require some kind of geo location in order to define a *total
        order*.
        """
        return (self.remaining < other.remaining)

    def __str__(self):
        return "Start: %s, End: %s, when: %s, now: %s, line: %s" % (
            self.start, self.end, timeformat(self.when),
            timeformat(self.now), self.line)

    def __repr__(self):
        return self.__str__()