├── tox.ini ├── .gitignore ├── runtests ├── .travis.yml ├── setup.py ├── README.md ├── periodical.py └── test.py /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py33,py32,py27 3 | 4 | [testenv] 5 | commands=python ./test.py 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .tox 3 | .coverage 4 | htmlcov 5 | __pycache__/ 6 | *.egg-info/ 7 | env/ 8 | dist/ 9 | -------------------------------------------------------------------------------- /runtests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | export PYTHONPATH=. 6 | 7 | coverage run --source=periodical test.py $@ 8 | flake8 periodical.py --ignore=E128,E501 9 | echo 10 | coverage report 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.3" 5 | - "3.2" 6 | - "2.7" 7 | 8 | install: 9 | - pip install coverage 10 | - pip install coveralls 11 | 12 | script: coverage run --source periodical test.py 13 | 14 | after_success: 15 | coveralls 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | import re 6 | import os 7 | import sys 8 | 9 | 10 | def get_version(): 11 | """ 12 | Return package version as listed in `__version__` in `init.py`. 13 | """ 14 | module = open('periodical.py').read() 15 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", module).group(1) 16 | 17 | 18 | version = get_version() 19 | 20 | 21 | if sys.argv[-1] == 'publish': 22 | os.system("python setup.py sdist upload") 23 | print("You probably want to also tag the version now:") 24 | print(" git tag -a %s -m 'version %s'" % (version, version)) 25 | print(" git push --tags") 26 | sys.exit() 27 | 28 | 29 | setup( 30 | name='periodical', 31 | version=version, 32 | url='https://github.com/dabapps/periodical', 33 | license='BSD', 34 | description='A library for working with time and date series.', 35 | author='Tom Christie', 36 | author_email='tom@tomchristie.com', 37 | py_modules=['periodical'], 38 | classifiers=[ 39 | 'Development Status :: 3 - Alpha', 40 | 'Environment :: Web Environment', 41 | 'Intended Audience :: Developers', 42 | 'License :: OSI Approved :: BSD License', 43 | 'Operating System :: OS Independent', 44 | 'Programming Language :: Python', 45 | 'Topic :: Internet :: WWW/HTTP', 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Periodical 2 | 3 | **A library for working with time and date series in Python.** 4 | 5 | [![Build Status](https://travis-ci.org/dabapps/periodical.png?branch=master)](https://travis-ci.org/dabapps/periodical) 6 | [![Coverage Status](https://coveralls.io/repos/dabapps/periodical/badge.png?branch=master)](https://coveralls.io/r/dabapps/periodical?branch=master) 7 | [![PyPI version](https://badge.fury.io/py/periodical.png)](http://badge.fury.io/py/periodical) 8 | 9 | The `periodical` Python module provides a convienient way of dealing with time and date series. 10 | 11 | These are particular useful for aggregating events at differing time granualities, for example when generating graphs or reports covering a given time span. 12 | 13 | ### Requirements 14 | 15 | `periodical` currently supports Python 2.7, 3.2 and 3.3. 16 | 17 | ### Installation 18 | 19 | You can install the `periodical` module using pip: 20 | 21 | pip install periodical 22 | 23 | ### An example of using periodical 24 | 25 | In this example we have a service which is logging the response times from a web application. We'd like to generate the average response time for each hourly period over the previous 24 hours. 26 | 27 | First we'll get the sequence of the last 24 hour periods. 28 | 29 | >>> import periodical 30 | >>> hour_periods = periodical.time_periods_descending(span='hour', num_periods=24) 31 | >>> hour_periods 32 | [ 33 | , 34 | , 35 | , 36 | ... 37 | ] 38 | 39 | Let's assume we have a list of requests in the `request_log` variable. Let's also assume that each of the request objects has an asociated `started` property, which is a `datetime` representing the time the request was recieved, and a `duration` property, which is a float representing the number of seconds it took to generate and send a response. 40 | 41 | In order to work with this data in periodical we need to first transform our objects into a list of two-tuple data points, of the form `(datetime, value)`, like so: 42 | 43 | >>> data_points = [(request.started, request.duration) for request in request_log] 44 | >>> data_points 45 | [ 46 | (datetime.datetime(2014, 4, 28, 15, 23, 35, 682504, tzinfo=), 0.24), 47 | (datetime.datetime(2014, 4, 28, 15, 22, 12, 659191, tzinfo=), 0.22), 48 | (datetime.datetime(2014, 4, 28, 15, 21, 45, 728530, tzinfo=), 0.30), 49 | ... 50 | ] 51 | 52 | Now that we have our data points we can get the average response time within each hour time period. We use the `periodical.average()` function, which returns an ordered dictionary mapping each time period onto the average value of data points within that period. 53 | 54 | >>> average_response_times = periodical.average(hour_periods, data_points) 55 | >>> average_response_times 56 | { 57 | : 0.26, 58 | : 0.24, 59 | : 0.35, 60 | ... 61 | } 62 | 63 | --- 64 | 65 | ## TimePeriod and DatePeriod objects 66 | 67 | The two basic building blocks of periodical are the `TimePeriod` and `DatePeriod` classes. 68 | 69 | The `TimePeriod` class is used to represent an interval between two datetimes. 70 | 71 | The `DatePeriod` class is used to represent an interval of dates. 72 | 73 | ### Creating period instances 74 | 75 | You can instantiate a `TimePeriod` or `DatePeriod` object by specifying a time span. By default this will return a period that covers the current time or day in UTC timezone. 76 | 77 | For `DatePeriod` this may be one of `'day'`, `'week'`, `'month'`, `'quarter'` or `'year'`. 78 | 79 | >>> import periodical 80 | >>> period = periodical.DatePeriod(span='week') 81 | >>> period 82 | 83 | 84 | For `TimePeriod` this may be any of the date spans, or may also be one of `'hour'`, `'minute'`, or `'second'`. 85 | 86 | >>> period = periodical.TimePeriod(span='hour') 87 | >>> period 88 | 89 | 90 | You can also explicitly provide a date or time that you wish the period to cover. 91 | 92 | >>> date = datetime.date(2015, 1, 25) 93 | >>> period = periodical.DatePeriod(date=date, span='week') 94 | >>> period 95 | 96 | 97 | ### A note on timezones 98 | 99 | The default implementations for `DatePeriod` and `TimePeriod` return periods coverring the current date or time *in the UTC timezone*. To work with local time you'll need to pass the local date or time explicitly. 100 | 101 | For example to get the current week period, using local time to determine the current date instead of using UTC time, we would do the following: 102 | 103 | >>> today = datetime.date.today() 104 | >>> period = periodical.DatePeriod(date=today, span='week') 105 | 106 | #### Timezone awareness and TimePeriod objects 107 | 108 | When passing a `datetime` instance to `TimePeriod`, the resulting period instance will use the same timezone info as the provided argument, or be timezone-naive if no timezone info is included. 109 | 110 | Instantiating a `TimePeriod` with no timezone information: 111 | 112 | >>> time = datetime.datetime(2015, 1, 25, 4) 113 | >>> period = periodical.TimePeriod(time=time, span='hour') 114 | >>> period 115 | 116 | 117 | Instantiating a `TimePeriod` with an explicit UTC timezone: 118 | 119 | >>> time = datetime.datetime(2015, 1, 25, 4, tzinfo=periodical.UTC()) 120 | >>> period = periodical.TimePeriod(time=time, span='hour') 121 | >>> period 122 | 123 | 124 | If not specified, the default time is set using `periodical.utcnow()` which returns the current time with a UTC timezone. 125 | 126 | You can determine the timezone information in use by examining the suffix of the `TimePeriod` representation. 127 | 128 | # 25th Jan 2015, 04:00 Timezone naive 129 | # 25th Jan 2015, 04:00 UTC 130 | # 25th Jan 2015, 04:00 EST 131 | 132 | ### Start and end dates 133 | 134 | Both objects provide `start` and `end` properties. For `DatePeriod` objects these return an instance of `date`. 135 | 136 | >>> period = periodical.DatePeriod(span='week') 137 | >>> period.start 138 | datetime.date(2014, 1, 6) 139 | >>> period.end 140 | datetime.date(2014, 1, 12) 141 | 142 | For `TimePeriod` objects these properties return `datetime` instances. 143 | 144 | >>> period = periodical.TimePeriod(span='month') 145 | >>> period.start 146 | datetime.datetime(2014, 1, 1, 0, 0, tzinfo=) 147 | >>> period.end 148 | datetime.datetime(2014, 2, 1, 0, 0, tzinfo=) 149 | 150 | Period objects also provide a `contains()` method that takes a date or time object and returns `True` if the date is contained by the given period. 151 | 152 | >>> period = periodical.DatePeriod(span='month') 153 | >>> period.contains(datetime.date(2014, 3, 20)) 154 | True 155 | >>> period.contains(datetime.date(2014, 4, 20)) 156 | False 157 | 158 | ### Differences between time and date periods 159 | 160 | When considering the end point of a period there is an important distinction to be made between `DatePeriod` and `TimePeriod` objects, due to the fact that dates and times represent fundamentally different concepts. 161 | 162 | * A `date` represents a discreet entity. The `end` property of a `DatePeriod` will be the last date included in that period. 163 | * A `datetime` represents a point in time. The `end` property of a `TimePeriod` will not be included in that period. 164 | 165 | For example, the date and time periods for the month of November 2014 may be represented like so: 166 | 167 | DatePeriod: start date <= period <= end date 168 | 169 | 2014-11-01 2014-11-30 170 | | | 171 | V V 172 | +---------+---------+-- --+----------+----------+ 173 | | 1 Nov. | 2 Nov. | | 29 Nov. | 30 Nov. | 174 | | 2014 | 2014 | ... | 2015 | 2015 | 175 | +---------+---------+-- --+----------+----------+ 176 | ^ ^ 177 | | | 178 | 2014-11-01 00:00:00 2014-12-01 00:00:00 179 | 180 | TimePeriod: start time <= period < end time 181 | 182 | 183 | ### Iterating through periods 184 | 185 | To return a new `TimePeriod` or `DatePeriod` object that occurs immediately before or after the existing period, you can call the `.next()` and `.previous()` methods. 186 | 187 | >>> period = periodical.DatePeriod(date=datetime.date(2014, 01, 05), span='week') 188 | >>> period.next() 189 | 190 | >>> period.previous() 191 | 192 | 193 | ### String representations 194 | 195 | DatePeriod objects use a unique representation that follows ISO 8601 with the following exceptions: 196 | 197 | * Only the relevant portion of the period will be included in the representation. 198 | * Quarterley intervals use a 'Q' prefix to the quarter. 199 | * If present, then timezone information is included using a `Z` or `±HH:MM` suffix. 200 | 201 | The following are all valid representations of `DatePeriod` objects: 202 | 203 | # The 2015 year. 204 | # The second quarter of 2013. 205 | # March 2014. 206 | # The 24th week of 2013. (Numbering by ISO 8601 weeks) 207 | # The 29th of April 2014. 208 | 209 | The following are all valid representations of `TimePeriod` objects: 210 | 211 | # The 2015 year, UTC. 212 | # The second quarter of 2013, UTC. 213 | # March 2014, timezone-naive. 214 | # The 24th week of 2013, UTC. (Numbering by ISO 8601 weeks) 215 | # The 29th of April 2014, EST. 216 | # 15:00:00-16:00:00 UTC, 29th of April 2014. 217 | # 15:34:00-15:35:00 timezone-naive, 29th of April 2014. 218 | # 15:34:24-15:34:25 EST, 29th of April 2014. 219 | 220 | You can also instantiate a `TimePeriod` or `DatePeriod` object using it's unique representation. 221 | 222 | >>> period = periodical.DatePeriod('2014-Q1') 223 | >>> period.start 224 | datetime.date(2014, 1, 1) 225 | >>> period.end 226 | datetime.date(2014, 3, 31) 227 | 228 | The `isoformat()` method returns a valid ISO 8601 formatted time representing the start of the range. Note that quarterly representations cannot be expressed in ISO 8601, so will simply return the monthly representation of the start date. 229 | 230 | '2015' # The 2015 year. 231 | '2013-04' # The second quarter of 2013. 232 | '2014-03' # March, 2014. 233 | '2013-W24' # The 24th week of 2013. (Numbering by ISO 8601 weeks) 234 | '2014-04-29' # The 29th of April, 2014. 235 | '2014-04-29T15:00Z' # 15:00 UTC on 29th of April, 2014. 236 | 237 | Note that the strings returned by `isoformat()` are not unique in the same way that the representational strings are. For example, `'2014-04'` may represent either the quarter `2014-Q2` or the month `2014-04`. Similarly, the isoformat string `'2014-04-29T15:00Z'` may represent either a complete hour span or a single minute span. 238 | 239 | --- 240 | 241 | ## Sequences of periods 242 | 243 | The `periodical` module provides functions for returning sequences of time or date periods. These allow you to easily return ranges such as "the last 24 hours", or "all the weeks since the start of the year". 244 | 245 | ### time_periods_ascending(time, span, num_periods) 246 | 247 | ### date_periods_ascending(date, span, num_periods) 248 | 249 | Returns a list of `TimePeriod` or `DatePeriod` objects in chronological order, starting with a given time or date. 250 | 251 | ##### Arguments: 252 | 253 | * `time`/`date` **(Optional)** - The starting time or date. If not provided, this defaults to the current time or day. 254 | * `span` - A string representing the period length. 255 | * `num_periods` - An integer representing the number of `DatePeriod` objects to return. 256 | 257 | Example result from `date_periods_ascending(span='monthly', num_periods=3)` on Nov 25th, 2014. 258 | 259 | Nov 25th 2014 260 | | 261 | V 262 | +--------+--------+--------+ 263 | | Nov. | Dec. | Jan. | 264 | | 2014 | 2014 | 2015 | 265 | +--------+--------+--------+ 266 | [0] ---> [1] ---> [2] 267 | 268 | Example code: 269 | 270 | >>> periodical.date_periods_ascending(span='monthly', num_periods=3) 271 | [, , ] 272 | 273 | ### time_periods_descending(time, span, num_periods) 274 | 275 | ### date_periods_descending(date, span, num_periods) 276 | 277 | Returns a list of `TimePeriod` or `DatePeriod` objects in reverse chronological order, starting with a given time or date. 278 | 279 | ##### Arguments: 280 | 281 | * `time`/`date` **(Optional)** - The starting time or date. If not provided, this defaults to the current time or day. 282 | * `span` - A string representing the period length. 283 | * `num_periods` - An integer representing the number of `DatePeriod` objects to return. 284 | 285 | Example result from `date_periods_descending(span='monthly', num_periods=3)` on Nov 25th, 2014. 286 | 287 | Nov 25th 2014 288 | | 289 | V 290 | +--------+--------+--------+ 291 | | Sept. | Oct. | Nov. | 292 | | 2014 | 2014 | 2014 | 293 | +--------+--------+--------+ 294 | [2] <--- [1] <--- [0] 295 | 296 | 297 | Example code: 298 | 299 | >>> periodical.date_periods_descending(span='monthly', num_periods=3) 300 | [, , ] 301 | 302 | ### time_periods_between(time_from, time_until, period) 303 | 304 | ### date_periods_between(date_from, date_until, period) 305 | 306 | Returns a list of `TimePeriod` or `DatePeriod` objects in *either* chronological *or* reverse chronological order, starting and ending with a pair of given datetimes or dates. 307 | 308 | ##### Arguments: 309 | 310 | * `time_from`/`date_from` **(Optional)** - The starting time or date. If not provided, this defaults to the current time or day. 311 | * `time_until`/`date_until` **(Optional)** - The ending time or date. If not provided, this defaults to the current time or day. 312 | * `span` - A string representing the period length. 313 | 314 | Example result from `date_periods_between(date_until=datetime.date(2014, 12, 31), span='monthly')` on Sept 23rd, 2014. 315 | 316 | Sept 23rd 2014 Dec 31st 2014 317 | | | 318 | V V 319 | +--------+--------+--------+--------+ 320 | | Sept. | Oct. | Nov. | Dec. | 321 | | 2014 | 2014 | 2014 | 2014 | 322 | +--------+--------+--------+--------+ 323 | [0] ---> [1] ---> [2] ---> [3] 324 | 325 | Example code: 326 | 327 | >>> periodical.date_periods_between(date_until=datetime.date(2014, 12, 31), span='monthly') 328 | [, , , ] 329 | 330 | --- 331 | 332 | ## Aggregation of values 333 | 334 | For the following documentation we're going to need a set of data points that we're interested in aggregating, in order to demonstate how the different aggregation functions work. 335 | 336 | We'll also need a set of periods that we're interested in aggregating the data against. 337 | 338 | Our initial data looks like this: 339 | 340 | >>> start = date(2014, 09, 01) 341 | >>> periods = periodical.date_periods_ascending(start, num_periods = 4) 342 | >>> data_points = [ 343 | (datetime.date(2014, 9, 1), 20), 344 | (datetime.date(2014, 9, 2), 25), 345 | (datetime.date(2014, 10, 1), 20), 346 | (datetime.date(2014, 10, 1), 20), 347 | (datetime.date(2014, 12, 1), 30) 348 | ] 349 | 350 | ### map(periods, data_points, transform=None) 351 | 352 | Given a sequence of time periods and a set of events, maps each event into it's containing period. 353 | 354 | * `periods`: A list of DatePeriod or TimePeriod instances. 355 | * `times_value_pairs`: A list of two-tuples of the form `(date or datetime, value)`. 356 | * `transform`: If provided, this should be a function that takes a single argument. The function will be applied to the list of values contained in each period in order to generate the output for that period. 357 | 358 | Returns an ordered dictionary that maps each period to a list of the contained values. 359 | 360 | >>> periodical.map(periods, data_points) 361 | OrderedDict([ 362 | (, [20, 25]), 363 | (, [20, 20]), 364 | (, []), 365 | (, [30]) 366 | ]) 367 | 368 | ### summation(periods, data_points, zero=0) 369 | 370 | Given a sequence of time periods and a set of data points, produces the sum of data points within each period. 371 | 372 | **Arguments**: 373 | 374 | * `periods`: A list of DatePeriod or TimePeriod instances. 375 | * `times_value_pairs`: A list of two-tuples of the form `(date or datetime, value)`. 376 | * `zero`: The initial value to use for summations. If using non-integer type you may wish to set this to ensure that zero values in the return result have the same type as non-zero values. For example, you might set the zero argument to `0.0` or `Decimal('0')`. **(Optional)** 377 | 378 | Returns an ordered dictionary that sums the values of the data points contained in each period. 379 | 380 | >>> periodical.summation(periods, data_points) 381 | OrderedDict([ 382 | (, 45), 383 | (, 40), 384 | (, 0), 385 | (, 30) 386 | ]) 387 | 388 | ### average(periods, data_points) 389 | 390 | Given a sequence of time periods and a set of data points, produces the average of data points within each period. 391 | 392 | **Arguments**: 393 | 394 | * `periods`: A list of DatePeriod or TimePeriod instances. 395 | * `times_value_pairs`: A list of two-tuples of the form `(date or datetime, value)`. 396 | 397 | Returns an ordered dictionary that sums the values of the data points contained in each period. Periods which do not contain any data points will be mapped to `None`. 398 | 399 | >>> periodical.average(periods, data_points) 400 | OrderedDict([ 401 | (, 22.5), 402 | (, 20.0), 403 | (, None), 404 | (, 30.0) 405 | ]) 406 | 407 | ### count(periods, times) 408 | 409 | Counts the number of occurances of an event within each period. 410 | 411 | **Arguments**: 412 | 413 | * `periods`: A list of DatePeriod or TimePeriod instances. 414 | * `times`: A list of date or datetime instances. 415 | 416 | Returns an ordered dictionary that maps each period to the corresponding count of the number of date or time instances that it contained. 417 | 418 | >>> times = [date for (date, value) in data_points] 419 | >>> periodical.count(periods, times) 420 | OrderedDict([ 421 | (, 2), 422 | (, 2), 423 | (, 0), 424 | (, 1) 425 | ]) 426 | 427 | ## Timezone utilities 428 | 429 | The periodical library includes a few utility classes to make it easier to work with properly timezone-aware datetime objects. 430 | 431 | ### UTC 432 | 433 | A `tzinfo` class for representing the UTC timezone. 434 | 435 | >>> time = datetime.datetime(2014, 01, 01, tzinfo=periodical.UTC()) 436 | >>> time 437 | datetime.datetime(2014, 1, 1, 0, 0, tzinfo=) 438 | 439 | ### Offset 440 | 441 | A `tzinfo` class for representing the timezone with the given offset. The offset string must be specified in the form `+HH:MM` or `-HH:MM`. 442 | 443 | >>> time = datetime.datetime(2014, 01, 01, tzinfo=periodical.Offset('-05:00')) 444 | >>> time 445 | datetime.datetime(2014, 1, 1, 0, 0, tzinfo=) 446 | 447 | ### utcnow() 448 | 449 | Returns a `datetime` instance representing the current time in UTC, with an attached `UTC` timzone instance. 450 | 451 | >>> now = periodical.utcnow() 452 | >>> now 453 | datetime.datetime(2014, 1, 30, 13, 39, 13, 515377, tzinfo=) 454 | 455 | ### utctoday() 456 | 457 | Returns a `datetime` instance representing the current date within the UTC timezone. 458 | 459 | >>> today = periodical.utctoday() 460 | >>> today 461 | datetime.date(2014, 1, 30) 462 | 463 | ### utc_datetime(\*args, \*\*kwargs) 464 | 465 | Returns a new `datetime` instance representing the given time, with an attached `UTC` timzone instance. 466 | 467 | >>> time = periodical.utc_datetime(2014, 01, 01, 14, 30) 468 | >>> time 469 | datetime.datetime(2014, 1, 1, 14, 30, tzinfo=) 470 | 471 | --- 472 | 473 | ## License 474 | 475 | Copyright © 2014 Tom Christie & DabApps. 476 | 477 | All rights reserved. 478 | 479 | Redistribution and use in source and binary forms, with or without 480 | modification, are permitted provided that the following conditions are met: 481 | 482 | Redistributions of source code must retain the above copyright notice, this 483 | list of conditions and the following disclaimer. 484 | Redistributions in binary form must reproduce the above copyright notice, this 485 | list of conditions and the following disclaimer in the documentation and/or 486 | other materials provided with the distribution. 487 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 488 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 489 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 490 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 491 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 492 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 493 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 494 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 495 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 496 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 497 | 498 | ## Code of conduct 499 | 500 | For guidelines regarding the code of conduct when contributing to this repository please review [https://www.dabapps.com/open-source/code-of-conduct/](https://www.dabapps.com/open-source/code-of-conduct/) 501 | -------------------------------------------------------------------------------- /periodical.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import calendar 4 | import collections 5 | import datetime 6 | import re 7 | 8 | __version__ = '1.0.2' 9 | 10 | 11 | yearly_re = re.compile('(?P[0-9]+)$') 12 | quarterly_re = re.compile('(?P[0-9]+)[-/][Qq](?P[0-9]+)$') 13 | monthly_re = re.compile('(?P[0-9]+)[-/](?P[0-9]+)$') 14 | weekly_re = re.compile('(?P[0-9]+)[-/][Ww](?P[0-9]+)$') 15 | daily_re = re.compile('(?P[0-9]+)[-/](?P[0-9]+)[-/](?P[0-9]+)$') 16 | hour_re = re.compile('(?P[0-9]+)[-/](?P[0-9]+)[-/](?P[0-9]+)[T ](?P[0-9]+)$') 17 | minute_re = re.compile('(?P[0-9]+)[-/](?P[0-9]+)[-/](?P[0-9]+)[T ](?P[0-9]+):(?P[0-9]+)$') 18 | second_re = re.compile('(?P[0-9]+)[-/](?P[0-9]+)[-/](?P[0-9]+)[T ](?P[0-9]+):(?P[0-9]+):(?P[0-9]+)$') 19 | 20 | timezone_re = re.compile('(?P[+-])(?P[0-9][0-9]):(?P[0-9][0-9])') 21 | 22 | 23 | class UTC(datetime.tzinfo): 24 | """ 25 | UTC timezone class. 26 | """ 27 | def utcoffset(self, dt): 28 | return datetime.timedelta(0) 29 | 30 | def tzname(self, dt): 31 | return "UTC" 32 | 33 | def dst(self, dt): 34 | return datetime.timedelta(0) 35 | 36 | def __repr__(self): 37 | return '' 38 | 39 | 40 | class Offset(datetime.tzinfo): 41 | """ 42 | UTC timezone class. 43 | """ 44 | def __init__(self, offset_repr): 45 | result = timezone_re.match(offset_repr) 46 | assert result, "Invalid timezone offset string, '%s'" % offset_repr 47 | sign, hours, minutes = result.groups() 48 | offset = datetime.timedelta(hours=int(hours), minutes=int(minutes)) 49 | self._offset_repr = offset_repr 50 | self._offset = -offset if sign == '-' else offset 51 | 52 | def utcoffset(self, dt): 53 | return self._offset 54 | 55 | def tzname(self, dt): 56 | return self._offset_repr 57 | 58 | def dst(self, dt): 59 | return self._offset 60 | 61 | def __repr__(self): 62 | return "" % self._offset_repr 63 | 64 | 65 | def utctoday(): 66 | """ 67 | Returns the current date in the UTC timezone. 68 | """ 69 | return datetime.datetime.utcnow().date() 70 | 71 | 72 | def utcnow(): 73 | """ 74 | As `datetime.datetime.utcnow()`, but returns a timezone aware datetime in UTC. 75 | """ 76 | return datetime.datetime.utcnow().replace(tzinfo=UTC()) 77 | 78 | 79 | def utc_datetime(*args, **kwargs): 80 | """ 81 | As `datetime.datetime()`, but returns a timezone aware datetime in UTC. 82 | """ 83 | kwargs['tzinfo'] = UTC() 84 | return datetime.datetime(*args, **kwargs) 85 | 86 | 87 | def _strip_hhmmss(time): 88 | return time.replace(hour=0, minute=0, second=0, microsecond=0) 89 | 90 | 91 | def _incr_month(month, amount=1): 92 | return ((month + amount) % 12) or 12 93 | 94 | 95 | def _decr_month(month, amount=1): 96 | return ((month - amount) % 12) or 12 97 | 98 | 99 | def _repr_to_date_and_span(string_repr): 100 | """ 101 | Given a date period representation, return a two-tuple of the 102 | corresponding start date and string time span. 103 | 104 | eg. '2001-04' -> (date(2001, 04, 01), 'daily') 105 | """ 106 | yearly_result = yearly_re.match(string_repr) 107 | quarerly_result = quarterly_re.match(string_repr) 108 | monthly_result = monthly_re.match(string_repr) 109 | weekly_result = weekly_re.match(string_repr) 110 | daily_result = daily_re.match(string_repr) 111 | 112 | if yearly_result: 113 | (year,) = yearly_result.groups() 114 | date = datetime.date(int(year), 1, 1) 115 | span = 'yearly' 116 | elif quarerly_result: 117 | (year, quarter) = quarerly_result.groups() 118 | date = datetime.date(int(year), (int(quarter) * 3) - 2, 1) 119 | span = 'quarterly' 120 | elif monthly_result: 121 | (year, month) = monthly_result.groups() 122 | date = datetime.date(int(year), int(month), 1) 123 | span = 'monthly' 124 | elif weekly_result: 125 | # ISO 8601 dates always include 4th Jan in the first week. 126 | # We populate a date that will be in the correct week period. 127 | (year, week) = weekly_result.groups() 128 | date = ( 129 | datetime.date(int(year), 1, 4) + 130 | datetime.timedelta(days=(int(week) * 7) - 7) 131 | ) 132 | span = 'weekly' 133 | elif daily_result: 134 | (year, month, day) = daily_result.groups() 135 | date = datetime.date(int(year), int(month), int(day)) 136 | span = 'daily' 137 | else: 138 | raise ValueError('Unknown date representation') 139 | 140 | return (date, span) 141 | 142 | 143 | def _repr_to_time_and_span(string_repr): 144 | """ 145 | Given a time period representation, return a two-tuple of the 146 | corresponding start date and string time span. 147 | 148 | eg. '2001-04' -> (datetime(2001, 04, 01), 'daily') 149 | """ 150 | if string_repr.endswith('Z'): 151 | tzinfo = UTC() 152 | string_repr = string_repr[:-1] 153 | elif string_repr.endswith('+00:00') or string_repr.endswith('-00:00'): 154 | tzinfo = UTC() 155 | string_repr = string_repr[:-6] 156 | elif timezone_re.match(string_repr[-6:]): 157 | tzinfo = Offset(string_repr[-6:]) 158 | string_repr = string_repr[:-6] 159 | else: 160 | tzinfo = None 161 | 162 | yearly_result = yearly_re.match(string_repr) 163 | quarerly_result = quarterly_re.match(string_repr) 164 | monthly_result = monthly_re.match(string_repr) 165 | weekly_result = weekly_re.match(string_repr) 166 | daily_result = daily_re.match(string_repr) 167 | hour_result = hour_re.match(string_repr) 168 | minute_result = minute_re.match(string_repr) 169 | second_result = second_re.match(string_repr) 170 | 171 | if yearly_result: 172 | (year,) = yearly_result.groups() 173 | time = datetime.datetime(int(year), 1, 1) 174 | span = 'yearly' 175 | elif quarerly_result: 176 | (year, quarter) = quarerly_result.groups() 177 | time = datetime.datetime(int(year), (int(quarter) * 3) - 2, 1) 178 | span = 'quarterly' 179 | elif monthly_result: 180 | (year, month) = monthly_result.groups() 181 | time = datetime.datetime(int(year), int(month), 1) 182 | span = 'monthly' 183 | elif weekly_result: 184 | # ISO 8601 dates always include 4th Jan in the first week. 185 | # We populate a date that will be in the correct week period. 186 | (year, week) = weekly_result.groups() 187 | time = ( 188 | datetime.datetime(int(year), 1, 4) + 189 | datetime.timedelta(days=(int(week) * 7) - 7) 190 | ) 191 | span = 'weekly' 192 | elif daily_result: 193 | (year, month, day) = daily_result.groups() 194 | time = datetime.datetime(int(year), int(month), int(day)) 195 | span = 'daily' 196 | elif hour_result: 197 | (year, month, day, hour) = hour_result.groups() 198 | time = datetime.datetime(int(year), int(month), int(day), int(hour)) 199 | span = 'hour' 200 | elif minute_result: 201 | (year, month, day, hour, minute) = minute_result.groups() 202 | time = datetime.datetime(int(year), int(month), int(day), int(hour), int(minute)) 203 | span = 'minute' 204 | elif second_result: 205 | (year, month, day, hour, minute, second) = second_result.groups() 206 | time = datetime.datetime(int(year), int(month), int(day), int(hour), int(minute), int(second)) 207 | span = 'second' 208 | else: 209 | raise ValueError('Unknown datetime representation') 210 | 211 | if tzinfo: 212 | time = time.replace(tzinfo=tzinfo) 213 | 214 | return (time, span) 215 | 216 | 217 | class DatePeriod(object): 218 | """ 219 | An immuntable object that represents a calendering period, 220 | which may be one of: daily, weekly, monthly, quarterly, yearly. 221 | """ 222 | def __init__(self, string_repr=None, date=None, span=None, _start=None, _end=None, _today_func=None): 223 | self.today_func = utctoday if _today_func is None else _today_func 224 | 225 | if _start is not None and _end is not None: 226 | # Created a new DatePeriod with explicit start and end dates, 227 | # as a result of calling `.previous()` or `.next()`. 228 | self._span = span 229 | self._start = _start 230 | self._end = _end 231 | return 232 | 233 | if string_repr: 234 | # Create a DatePeriod by supplying a string representation. 235 | assert date is None, 'Cannot supply both `string_repr` and `date`' 236 | assert span is None, 'Cannot supply both `string_repr` and `span`' 237 | date, span = _repr_to_date_and_span(string_repr) 238 | 239 | # Create a DatePeriod by supplying a date and span. 240 | assert span is not None, '`span` argument not supplied.' 241 | 242 | if date is None: 243 | date = self.today_func() 244 | 245 | try: 246 | self._span = { 247 | 'day': 'daily', 248 | 'dai': 'daily', 249 | 'wee': 'weekly', 250 | 'mon': 'monthly', 251 | 'qua': 'quarterly', 252 | 'yea': 'yearly' 253 | }[span.lower()[:3]] 254 | except KeyError: 255 | raise ValueError("Invalid value for `span` argument '%s'" % span) 256 | 257 | if self._span == 'daily': 258 | self._start = date 259 | self._end = date 260 | elif self._span == 'weekly': 261 | weekday = date.weekday() # 0..6 262 | self._start = date - datetime.timedelta(days=weekday) 263 | self._end = date + datetime.timedelta(days=(6 - weekday)) 264 | elif self._span == 'monthly': 265 | month_end_day = calendar.monthrange(date.year, date.month)[1] 266 | self._start = datetime.date(date.year, date.month, 1) 267 | self._end = datetime.date(date.year, date.month, month_end_day) 268 | elif self._span == 'quarterly': 269 | current_quarter = int((date.month - 1) / 3) # In the range 0..3 270 | st_month = (current_quarter * 3) + 1 # (1, 4, 7, 10) 271 | en_month = (current_quarter * 3) + 3 # (3, 6, 9, 12) 272 | month_end_day = calendar.monthrange(date.year, en_month)[1] 273 | self._start = datetime.date(date.year, st_month, 1) 274 | self._end = datetime.date(date.year, en_month, month_end_day) 275 | else: # self._span == 'yearly': 276 | self._start = datetime.date(date.year, 1, 1) 277 | self._end = datetime.date(date.year, 12, 31) 278 | 279 | def previous(self): 280 | """ 281 | Return a new DatePeriod representing the period 282 | immediately prior to this one. 283 | """ 284 | if self.span == 'daily': 285 | start = self.start - datetime.timedelta(days=1) 286 | end = self.end - datetime.timedelta(days=1) 287 | elif self.span == 'weekly': 288 | start = self.start - datetime.timedelta(days=7) 289 | end = self.end - datetime.timedelta(days=7) 290 | elif self.span == 'monthly': 291 | year = self.start.year - 1 if self.start.month == 1 else self.start.year 292 | start_month = _decr_month(self.start.month) 293 | end_month = _decr_month(self.end.month) 294 | end_day = calendar.monthrange(year, end_month)[1] 295 | start = self.start.replace(month=start_month, year=year) 296 | end = self.end.replace(day=end_day, month=end_month, year=year) 297 | elif self._span == 'quarterly': 298 | year = self._start.year 299 | if self._start.month == 1: 300 | year -= 1 301 | start_month = _decr_month(self.start.month, 3) 302 | end_month = _decr_month(self.end.month, 3) 303 | end_day = calendar.monthrange(year, end_month)[1] 304 | start = self.start.replace(month=start_month, year=year) 305 | end = self.end.replace(day=end_day, month=end_month, year=year) 306 | else: # self._span == 'yearly' 307 | year = self.start.year - 1 308 | start = self.start.replace(year) 309 | end = self.end.replace(year) 310 | 311 | return DatePeriod(span=self.span, _start=start, _end=end) 312 | 313 | def next(self): 314 | """ 315 | Return a new DatePeriod representing the period 316 | immediately following this one. 317 | """ 318 | if self.span == 'daily': 319 | start = self.start + datetime.timedelta(days=1) 320 | end = self.end + datetime.timedelta(days=1) 321 | elif self.span == 'weekly': 322 | start = self.start + datetime.timedelta(days=7) 323 | end = self.end + datetime.timedelta(days=7) 324 | elif self.span == 'monthly': 325 | year = self.start.year + 1 if self.start.month == 12 else self.start.year 326 | start_month = _incr_month(self.start.month) 327 | end_month = _incr_month(self.end.month) 328 | end_day = calendar.monthrange(year, end_month)[1] 329 | start = self.start.replace(month=start_month, year=year) 330 | end = self.end.replace(day=end_day, month=end_month, year=year) 331 | elif self._span == 'quarterly': 332 | year = self.start.year + 1 if self.start.month >= 10 else self.start.year 333 | start_month = _incr_month(self.start.month, 3) 334 | end_month = _incr_month(self.end.month, 3) 335 | end_day = calendar.monthrange(year, end_month)[1] 336 | start = self.start.replace(month=start_month, year=year) 337 | end = self.end.replace(day=end_day, month=end_month, year=year) 338 | else: # self._span == 'yearly' 339 | year = self.start.year + 1 340 | start = self.start.replace(year) 341 | end = self.end.replace(year) 342 | 343 | return DatePeriod(span=self.span, _start=start, _end=end) 344 | 345 | def isoformat(self): 346 | """ 347 | Return an ISO8601 formatted string representing the period. 348 | """ 349 | if self.span == 'daily': 350 | # YYYY-MM-DD 351 | return self.start.isoformat() 352 | elif self.span == 'weekly': 353 | # YYYY-W## 354 | iso_year, iso_week, iso_day = self.start.isocalendar() 355 | return '%d-W%02d' % (iso_year, iso_week) 356 | elif self.span in ('monthly', 'quarterly'): 357 | # YYYY-MM 358 | return self.start.isoformat()[:7] 359 | else: 360 | # YYYY 361 | return str(self.start.year) 362 | 363 | def contains(self, date): 364 | """ 365 | Returns `True` if the given date is contained by this period. 366 | """ 367 | return date >= self.start and date <= self.end 368 | 369 | def __repr__(self): 370 | """ 371 | Returns a representation that uniquely identifies the date period. 372 | """ 373 | return "<%s '%s'>" % (self.__class__.__name__, self) 374 | 375 | def __str__(self): 376 | """ 377 | Returns a representation that uniquely identifies the date period. 378 | """ 379 | if self.span == 'quarterly': 380 | return "%04d-Q%01d" % (self.start.year, (self.end.month / 3)) 381 | return self.isoformat() 382 | 383 | def __hash__(self): 384 | return hash(str(self)) 385 | 386 | @property 387 | def start(self): 388 | return self._start 389 | 390 | @property 391 | def end(self): 392 | return self._end 393 | 394 | @property 395 | def span(self): 396 | return self._span 397 | 398 | def __gt__(self, other): 399 | return self.start > other.end 400 | 401 | def __eq__(self, other): 402 | if not isinstance(other, DatePeriod): 403 | return False 404 | return self.start == other.start and self.end == other.end 405 | 406 | 407 | class TimePeriod(object): 408 | """ 409 | An immuntable object that represents a calendering period, 410 | which may be one of: daily, weekly, monthly, quarterly, yearly. 411 | """ 412 | def __init__(self, string_repr=None, time=None, span=None, _start=None, _end=None, _now_func=None): 413 | self.now_func = utcnow if _now_func is None else _now_func 414 | 415 | if _start is not None and _end is not None: 416 | # Created a new DatePeriod with explicit start and end dates, 417 | # as a result of calling `.previous()` or `.next()`. 418 | self._span = span 419 | self._start = _start 420 | self._end = _end 421 | return 422 | 423 | if string_repr: 424 | # Create a DatePeriod by supplying a string representation. 425 | assert time is None, 'Cannot supply both `string_repr` and `time`' 426 | assert span is None, 'Cannot supply both `string_repr` and `span`' 427 | time, span = _repr_to_time_and_span(string_repr) 428 | 429 | # Create a DatePeriod by supplying a date and span. 430 | assert span is not None, '`span` argument not supplied.' 431 | 432 | if time is None: 433 | time = self.now_func() 434 | 435 | try: 436 | self._span = { 437 | 'sec': 'seconds', 438 | 'min': 'minutes', 439 | 'hou': 'hours', 440 | 'day': 'daily', 441 | 'dai': 'daily', 442 | 'wee': 'weekly', 443 | 'mon': 'monthly', 444 | 'qua': 'quarterly', 445 | 'yea': 'yearly' 446 | }[span.lower()[:3]] 447 | except KeyError: 448 | raise ValueError("Invalid value for `span` argument '%s'" % span) 449 | 450 | if self._span == 'seconds': 451 | self._start = time.replace(microsecond=0) 452 | self._end = time + datetime.timedelta(seconds=1) 453 | elif self._span == 'minutes': 454 | self._start = time.replace(second=0, microsecond=0) 455 | self._end = time.replace(second=0) + datetime.timedelta(minutes=1) 456 | elif self._span == 'hours': 457 | self._start = time.replace(minute=0, second=0, microsecond=0) 458 | self._end = time.replace(minute=0, second=0) + datetime.timedelta(hours=1) 459 | elif self._span == 'daily': 460 | self._start = _strip_hhmmss(time) 461 | self._end = _strip_hhmmss(time) + datetime.timedelta(days=1) 462 | elif self._span == 'weekly': 463 | weekday = time.weekday() # 0..6 464 | self._start = _strip_hhmmss(time) - datetime.timedelta(days=weekday) 465 | self._end = _strip_hhmmss(time) + datetime.timedelta(days=(7 - weekday)) 466 | elif self._span == 'monthly': 467 | en_month = _incr_month(time.month) 468 | en_year = time.year + 1 if time.month == 12 else time.year 469 | self._start = _strip_hhmmss(time).replace(day=1) 470 | self._end = _strip_hhmmss(time).replace(year=en_year, month=en_month, day=1) 471 | elif self._span == 'quarterly': 472 | current_quarter = int((time.month - 1) / 3) # In the range 0..3 473 | st_month = (current_quarter * 3) + 1 # (1, 4, 7, 10) 474 | en_month = _incr_month((current_quarter * 3), 4) # (4, 7, 10, 1) 475 | en_year = time.year + 1 if current_quarter == 3 else time.year 476 | self._start = _strip_hhmmss(time).replace(month=st_month, day=1) 477 | self._end = _strip_hhmmss(time).replace(year=en_year, month=en_month, day=1) 478 | else: # self._span == 'yearly': 479 | self._start = _strip_hhmmss(time).replace(month=1, day=1) 480 | self._end = _strip_hhmmss(time).replace(year=time.year + 1, month=1, day=1) 481 | 482 | def previous(self): 483 | """ 484 | Return a new TimePeriod representing the period 485 | immediately prior to this one. 486 | """ 487 | if self.span == 'seconds': 488 | start = self.start - datetime.timedelta(seconds=1) 489 | end = self.end - datetime.timedelta(seconds=1) 490 | elif self.span == 'minutes': 491 | start = self.start - datetime.timedelta(minutes=1) 492 | end = self.end - datetime.timedelta(minutes=1) 493 | elif self.span == 'hours': 494 | start = self.start - datetime.timedelta(hours=1) 495 | end = self.end - datetime.timedelta(hours=1) 496 | elif self.span == 'daily': 497 | start = self.start - datetime.timedelta(days=1) 498 | end = self.end - datetime.timedelta(days=1) 499 | elif self.span == 'weekly': 500 | start = self.start - datetime.timedelta(days=7) 501 | end = self.end - datetime.timedelta(days=7) 502 | elif self.span == 'monthly': 503 | start_year = self.start.year - 1 if self.start.month == 1 else self.start.year 504 | end_year = self.end.year - 1 if self.end.month == 1 else self.end.year 505 | start_month = _decr_month(self.start.month) 506 | end_month = _decr_month(self.end.month) 507 | start = self._start.replace(month=start_month, year=start_year) 508 | end = self._end.replace(month=end_month, year=end_year) 509 | elif self.span == 'quarterly': 510 | start_year = self.start.year - 1 if self.start.month == 1 else self.start.year 511 | end_year = self.end.year - 1 if self.end.month == 1 else self.end.year 512 | start_month = _decr_month(self.start.month, 3) 513 | end_month = _decr_month(self.end.month, 3) 514 | start = self._start.replace(month=start_month, year=start_year) 515 | end = self._end.replace(month=end_month, year=end_year) 516 | else: # self._span == 'yearly' 517 | start = self.start.replace(self.start.year - 1) 518 | end = self.end.replace(self.end.year - 1) 519 | 520 | return TimePeriod(span=self.span, _start=start, _end=end) 521 | 522 | def next(self): 523 | """ 524 | Return a new TimePeriod representing the period 525 | immediately following this one. 526 | """ 527 | if self._span == 'seconds': 528 | start = self.start + datetime.timedelta(seconds=1) 529 | end = self.end + datetime.timedelta(seconds=1) 530 | elif self._span == 'minutes': 531 | start = self.start + datetime.timedelta(minutes=1) 532 | end = self.end + datetime.timedelta(minutes=1) 533 | elif self._span == 'hours': 534 | start = self.start + datetime.timedelta(hours=1) 535 | end = self.end + datetime.timedelta(hours=1) 536 | elif self._span == 'daily': 537 | start = self.start + datetime.timedelta(days=1) 538 | end = self.end + datetime.timedelta(days=1) 539 | elif self._span == 'weekly': 540 | start = self.start + datetime.timedelta(days=7) 541 | end = self.end + datetime.timedelta(days=7) 542 | elif self._span == 'monthly': 543 | start_year = self.start.year + 1 if self.start.month == 12 else self.start.year 544 | end_year = self.end.year + 1 if self.end.month == 12 else self.end.year 545 | start_month = _incr_month(self.start.month) 546 | end_month = _incr_month(self.end.month) 547 | start = self.start.replace(year=start_year, month=start_month) 548 | end = self.end.replace(year=end_year, month=end_month) 549 | elif self._span == 'quarterly': 550 | start_year = self.start.year + 1 if self.start.month >= 10 else self.start.year 551 | end_year = self.end.year + 1 if self.end.month >= 10 else self.end.year 552 | start_month = _incr_month(self._start.month, 3) 553 | end_month = _incr_month(self._end.month, 3) 554 | start = self.start.replace(year=start_year, month=start_month) 555 | end = self.end.replace(year=end_year, month=end_month) 556 | else: # self._span == 'yearly' 557 | start = self.start.replace(self.start.year + 1) 558 | end = self.end.replace(self.end.year + 1) 559 | 560 | return TimePeriod(span=self.span, _start=start, _end=end) 561 | 562 | def isoformat(self): 563 | """ 564 | Return an ISO8601 formatted string representing the period. 565 | """ 566 | if self.span == 'seconds': 567 | # YYYY-MM-DDTHH:MM:SS 568 | ret = self.start.isoformat()[:19] 569 | elif self.span in ('hours', 'minutes'): 570 | # YYYY-MM-DDTHH:MM 571 | ret = self.start.isoformat()[:16] 572 | elif self.span == 'daily': 573 | # YYYY-MM-DD 574 | ret = self.start.isoformat()[:10] 575 | elif self.span == 'weekly': 576 | # YYYY-W## 577 | iso_year, iso_week, iso_day = self.start.isocalendar() 578 | ret = '%d-W%02d' % (iso_year, iso_week) 579 | elif self.span in ('monthly', 'quarterly'): 580 | # YYYY-MM 581 | ret = self.start.isoformat()[:7] 582 | else: 583 | # YYYY 584 | ret = str(self.start.year) 585 | 586 | if self.start.tzinfo is not None: 587 | if self.start.utcoffset().seconds: 588 | ret += self.start.isoformat()[-6:] 589 | else: 590 | ret += 'Z' 591 | 592 | return ret 593 | 594 | def contains(self, time): 595 | """ 596 | Returns `True` if the given datetime is contained by this period. 597 | """ 598 | return time >= self.start and time < self.end 599 | 600 | def __repr__(self): 601 | """ 602 | Returns a representation that uniquely identifies the time period. 603 | """ 604 | return "<%s '%s'>" % (self.__class__.__name__, self) 605 | 606 | def __str__(self): 607 | """ 608 | Returns a string that uniquely identifies the time period. 609 | """ 610 | if self.span == 'quarterly': 611 | return "%04d-Q%01d" % (self.start.year, (self.end.month / 3)) 612 | return self.isoformat() 613 | 614 | def __hash__(self): 615 | return hash(str(self)) 616 | 617 | @property 618 | def start(self): 619 | return self._start 620 | 621 | @property 622 | def end(self): 623 | return self._end 624 | 625 | @property 626 | def span(self): 627 | return self._span 628 | 629 | def __gt__(self, other): 630 | return self.start >= other.end 631 | 632 | def __eq__(self, other): 633 | if not isinstance(other, TimePeriod): 634 | return False 635 | return self.start == other.start and self.end == other.end 636 | 637 | 638 | # Series functions 639 | 640 | def date_periods_descending(date=None, span=None, num_periods=None): 641 | """ 642 | Returns a list of DatePeriod instances, starting with a period that 643 | covers the given date and iterating through the preceeding periods. 644 | """ 645 | assert num_periods is not None, '`num_periods` argument not supplied.' 646 | 647 | ret = [] 648 | period = DatePeriod(date=date, span=span) 649 | for idx in range(num_periods): 650 | ret.append(period) 651 | period = period.previous() 652 | return ret 653 | 654 | 655 | def date_periods_ascending(date=None, span=None, num_periods=None): 656 | """ 657 | Returns a list of DatePeriod instances, starting with a period that 658 | covers the given date and iterating through the following periods. 659 | """ 660 | assert num_periods is not None, '`num_periods` argument not supplied.' 661 | 662 | ret = [] 663 | period = DatePeriod(date=date, span=span) 664 | for idx in range(num_periods): 665 | ret.append(period) 666 | period = period.next() 667 | return ret 668 | 669 | 670 | def date_periods_between(date_from=None, date_until=None, span=None): 671 | """ 672 | Returns a list of DatePeriod instances, starting and ending with 673 | periods that cover the given start and end dates. 674 | """ 675 | period = DatePeriod(date=date_from, span=span) 676 | until = DatePeriod(date=date_until, span=span) 677 | ascending = until > period 678 | 679 | ret = [] 680 | while not period == until: 681 | ret.append(period) 682 | period = period.next() if ascending else period.previous() 683 | ret.append(period) 684 | return ret 685 | 686 | 687 | def time_periods_descending(time=None, span=None, num_periods=None): 688 | """ 689 | Returns a list of TimePeriod instances, starting with a period that 690 | covers the given time and iterating through the preceeding periods. 691 | """ 692 | assert num_periods is not None, '`num_periods` argument not supplied.' 693 | 694 | ret = [] 695 | period = TimePeriod(time=time, span=span) 696 | for idx in range(num_periods): 697 | ret.append(period) 698 | period = period.previous() 699 | return ret 700 | 701 | 702 | def time_periods_ascending(time=None, span=None, num_periods=None): 703 | """ 704 | Returns a list of TimePeriod instances, starting with a period that 705 | covers the given time and iterating through the following periods. 706 | """ 707 | assert num_periods is not None, '`num_periods` argument not supplied.' 708 | 709 | ret = [] 710 | period = TimePeriod(time=time, span=span) 711 | for idx in range(num_periods): 712 | ret.append(period) 713 | period = period.next() 714 | return ret 715 | 716 | 717 | def time_periods_between(time_from=None, time_until=None, span=None): 718 | """ 719 | Returns a list of TimePeriod instances, starting and ending with 720 | periods that cover the given start and end times. 721 | """ 722 | period = TimePeriod(time=time_from, span=span) 723 | until = TimePeriod(time=time_until, span=span) 724 | ascending = until > period 725 | 726 | ret = [] 727 | while not period == until: 728 | ret.append(period) 729 | period = period.next() if ascending else period.previous() 730 | ret.append(period) 731 | return ret 732 | 733 | 734 | # Aggregation functions 735 | 736 | def _next_pair_or_none(iterator): 737 | """ 738 | Returns the next pair in an iterator of pairs, or a pair of None. 739 | """ 740 | try: 741 | return next(iterator) 742 | except StopIteration: 743 | return (None, None) 744 | 745 | 746 | def map(periods, data_points, transform=None): 747 | """ 748 | Given a sequence of dates periods, and a list of date/value pairs, 749 | map each value to the period containing it's date. 750 | """ 751 | is_descending = periods and (periods[0] > periods[-1]) 752 | sort_by_date = lambda date_value_pair: date_value_pair[0] 753 | date_value_iter = iter(sorted(data_points, key=sort_by_date, reverse=is_descending)) 754 | 755 | ret = collections.OrderedDict() 756 | date, value = _next_pair_or_none(date_value_iter) 757 | for period in periods: 758 | this_mapping = [] 759 | while date is not None and period.contains(date): 760 | this_mapping.append(value) 761 | date, value = _next_pair_or_none(date_value_iter) 762 | 763 | if transform is not None: 764 | this_mapping = transform(this_mapping) 765 | 766 | ret[period] = this_mapping 767 | 768 | return ret 769 | 770 | 771 | def summation(periods, data_points, zero=0): 772 | sum_from_zero = lambda values: sum(values, zero) 773 | return map(periods, data_points, transform=sum_from_zero) 774 | 775 | 776 | def average(periods, data_points): 777 | avg = lambda values: float(sum(values)) / len(values) if values else None 778 | return map(periods, data_points, transform=avg) 779 | 780 | 781 | def count(periods, dates): 782 | date_value_pairs = [(date, None) for date in dates] 783 | return map(periods, date_value_pairs, transform=len) 784 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import collections 4 | import datetime 5 | import periodical 6 | import unittest 7 | 8 | 9 | class TestDatePeriods(unittest.TestCase): 10 | 11 | # Date/period initialization tests 12 | def test_daily(self): 13 | date = datetime.date(2000, 1, 1) 14 | period = periodical.DatePeriod(date=date, span='daily') 15 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 16 | self.assertEqual(period.end, datetime.date(2000, 1, 1)) 17 | 18 | def test_weekly(self): 19 | date = datetime.date(2000, 1, 1) 20 | period = periodical.DatePeriod(date=date, span='weekly') 21 | self.assertEqual(period.start, datetime.date(1999, 12, 27)) 22 | self.assertEqual(period.end, datetime.date(2000, 1, 2)) 23 | 24 | def test_monthly(self): 25 | date = datetime.date(2000, 1, 1) 26 | period = periodical.DatePeriod(date=date, span='monthly') 27 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 28 | self.assertEqual(period.end, datetime.date(2000, 1, 31)) 29 | 30 | def test_quarterly(self): 31 | date = datetime.date(2000, 1, 1) 32 | period = periodical.DatePeriod(date=date, span='quarterly') 33 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 34 | self.assertEqual(period.end, datetime.date(2000, 3, 31)) 35 | 36 | def test_yearly(self): 37 | date = datetime.date(2000, 1, 1) 38 | period = periodical.DatePeriod(date=date, span='yearly') 39 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 40 | self.assertEqual(period.end, datetime.date(2000, 12, 31)) 41 | 42 | # Representation initialization tests 43 | def test_daily_repr(self): 44 | period = periodical.DatePeriod('2000-01-01') 45 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 46 | self.assertEqual(period.end, datetime.date(2000, 1, 1)) 47 | 48 | def test_weekly_repr(self): 49 | period = periodical.DatePeriod('2000-W1') 50 | self.assertEqual(period.start, datetime.date(2000, 1, 3)) 51 | self.assertEqual(period.end, datetime.date(2000, 1, 9)) 52 | 53 | def test_monthly_repr(self): 54 | period = periodical.DatePeriod('2000-01') 55 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 56 | self.assertEqual(period.end, datetime.date(2000, 1, 31)) 57 | 58 | def test_quarterly_repr(self): 59 | period = periodical.DatePeriod('2000-Q1') 60 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 61 | self.assertEqual(period.end, datetime.date(2000, 3, 31)) 62 | 63 | def test_yearly_repr(self): 64 | period = periodical.DatePeriod('2000') 65 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 66 | self.assertEqual(period.end, datetime.date(2000, 12, 31)) 67 | 68 | # Tests for `.previous()` 69 | def test_daily_previous(self): 70 | date = datetime.date(2000, 1, 1) 71 | period = periodical.DatePeriod(date=date, span='daily').previous() 72 | self.assertEqual(period.start, datetime.date(1999, 12, 31)) 73 | self.assertEqual(period.end, datetime.date(1999, 12, 31)) 74 | 75 | def test_weekly_previous(self): 76 | date = datetime.date(2000, 1, 1) 77 | period = periodical.DatePeriod(date=date, span='weekly').previous() 78 | self.assertEqual(period.start, datetime.date(1999, 12, 20)) 79 | self.assertEqual(period.end, datetime.date(1999, 12, 26)) 80 | 81 | def test_monthly_previous(self): 82 | date = datetime.date(2000, 1, 1) 83 | period = periodical.DatePeriod(date=date, span='monthly').previous() 84 | self.assertEqual(period.start, datetime.date(1999, 12, 1)) 85 | self.assertEqual(period.end, datetime.date(1999, 12, 31)) 86 | 87 | date = datetime.date(2000, 3, 1) 88 | period = periodical.DatePeriod(date=date, span='monthly').previous() 89 | self.assertEqual(period.start, datetime.date(2000, 2, 1)) 90 | self.assertEqual(period.end, datetime.date(2000, 2, 29)) 91 | 92 | def test_quarterly_previous(self): 93 | date = datetime.date(2000, 1, 1) 94 | period = periodical.DatePeriod(date=date, span='quarterly').previous() 95 | self.assertEqual(period.start, datetime.date(1999, 10, 1)) 96 | self.assertEqual(period.end, datetime.date(1999, 12, 31)) 97 | 98 | date = datetime.date(2000, 4, 1) 99 | period = periodical.DatePeriod(date=date, span='quarterly') 100 | period = periodical.DatePeriod(date=date, span='quarterly').previous() 101 | self.assertEqual(period.start, datetime.date(2000, 1, 1)) 102 | self.assertEqual(period.end, datetime.date(2000, 3, 31)) 103 | 104 | def test_yearly_previous(self): 105 | date = datetime.date(2000, 1, 1) 106 | period = periodical.DatePeriod(date=date, span='yearly').previous() 107 | self.assertEqual(period.start, datetime.date(1999, 1, 1)) 108 | self.assertEqual(period.end, datetime.date(1999, 12, 31)) 109 | 110 | # Tests for `.next()` 111 | def test_daily_next(self): 112 | date = datetime.date(2000, 1, 31) 113 | period = periodical.DatePeriod(date=date, span='daily').next() 114 | self.assertEqual(period.start, datetime.date(2000, 2, 1)) 115 | self.assertEqual(period.end, datetime.date(2000, 2, 1)) 116 | 117 | def test_weekly_next(self): 118 | date = datetime.date(2000, 1, 1) 119 | period = periodical.DatePeriod(date=date, span='weekly').next() 120 | self.assertEqual(period.start, datetime.date(2000, 1, 3)) 121 | self.assertEqual(period.end, datetime.date(2000, 1, 9)) 122 | 123 | def test_monthly_next(self): 124 | date = datetime.date(2000, 1, 1) 125 | period = periodical.DatePeriod(date=date, span='monthly').next() 126 | self.assertEqual(period.start, datetime.date(2000, 2, 1)) 127 | self.assertEqual(period.end, datetime.date(2000, 2, 29)) 128 | 129 | date = datetime.date(2000, 12, 1) 130 | period = periodical.DatePeriod(date=date, span='monthly').next() 131 | self.assertEqual(period.start, datetime.date(2001, 1, 1)) 132 | self.assertEqual(period.end, datetime.date(2001, 1, 31)) 133 | 134 | def test_quarterly_next(self): 135 | date = datetime.date(2000, 1, 1) 136 | period = periodical.DatePeriod(date=date, span='quarterly').next() 137 | self.assertEqual(period.start, datetime.date(2000, 4, 1)) 138 | self.assertEqual(period.end, datetime.date(2000, 6, 30)) 139 | 140 | date = datetime.date(2000, 10, 1) 141 | period = periodical.DatePeriod(date=date, span='quarterly') 142 | period = periodical.DatePeriod(date=date, span='quarterly').next() 143 | self.assertEqual(period.start, datetime.date(2001, 1, 1)) 144 | self.assertEqual(period.end, datetime.date(2001, 3, 31)) 145 | 146 | def test_yearly_next(self): 147 | date = datetime.date(2000, 1, 1) 148 | period = periodical.DatePeriod(date=date, span='yearly').next() 149 | self.assertEqual(period.start, datetime.date(2001, 1, 1)) 150 | self.assertEqual(period.end, datetime.date(2001, 12, 31)) 151 | 152 | # Tests for date_periods_descending(), and isoformat representations 153 | def test_daily_series_descending(self): 154 | date = datetime.date(2000, 1, 1) 155 | periods = periodical.date_periods_descending(date, 'daily', 3) 156 | iso = [period.isoformat() for period in periods] 157 | self.assertEqual(['2000-01-01', '1999-12-31', '1999-12-30'], iso) 158 | 159 | def test_weekly_series_descending(self): 160 | date = datetime.date(2000, 1, 1) 161 | periods = periodical.date_periods_descending(date, 'weekly', 3) 162 | iso = [period.isoformat() for period in periods] 163 | self.assertEqual(['1999-W52', '1999-W51', '1999-W50'], iso) 164 | 165 | def test_monthly_series_descending(self): 166 | date = datetime.date(2000, 1, 1) 167 | periods = periodical.date_periods_descending(date, 'monthly', 3) 168 | iso = [period.isoformat() for period in periods] 169 | self.assertEqual(['2000-01', '1999-12', '1999-11'], iso) 170 | 171 | def test_quarterly_series_descending(self): 172 | date = datetime.date(2000, 1, 1) 173 | periods = periodical.date_periods_descending(date, 'quarterly', 3) 174 | iso = [period.isoformat() for period in periods] 175 | self.assertEqual(['2000-01', '1999-10', '1999-07'], iso) 176 | 177 | def test_yearly_series_descending(self): 178 | date = datetime.date(2000, 1, 1) 179 | periods = periodical.date_periods_descending(date, 'yearly', 3) 180 | iso = [period.isoformat() for period in periods] 181 | self.assertEqual(['2000', '1999', '1998'], iso) 182 | 183 | # Tests for date_periods_ascending(), and string representations 184 | def test_daily_series_ascending(self): 185 | date = datetime.date(2000, 1, 1) 186 | periods = periodical.date_periods_ascending(date, 'daily', 3) 187 | reprs = [str(period) for period in periods] 188 | self.assertEqual(['2000-01-01', '2000-01-02', '2000-01-03'], reprs) 189 | 190 | def test_weekly_series_ascending(self): 191 | date = datetime.date(2000, 1, 1) 192 | periods = periodical.date_periods_ascending(date, 'weekly', 3) 193 | reprs = [str(period) for period in periods] 194 | self.assertEqual(['1999-W52', '2000-W01', '2000-W02'], reprs) 195 | 196 | def test_monthly_series_ascending(self): 197 | date = datetime.date(2000, 1, 1) 198 | periods = periodical.date_periods_ascending(date, 'monthly', 3) 199 | reprs = [str(period) for period in periods] 200 | self.assertEqual(['2000-01', '2000-02', '2000-03'], reprs) 201 | 202 | def test_quarterly_series_ascending(self): 203 | date = datetime.date(2000, 1, 1) 204 | periods = periodical.date_periods_ascending(date, 'quarterly', 3) 205 | reprs = [str(period) for period in periods] 206 | self.assertEqual(['2000-Q1', '2000-Q2', '2000-Q3'], reprs) 207 | 208 | def test_yearly_series_ascending(self): 209 | date = datetime.date(2000, 1, 1) 210 | periods = periodical.date_periods_ascending(date, 'yearly', 3) 211 | reprs = [str(period) for period in periods] 212 | self.assertEqual(['2000', '2001', '2002'], reprs) 213 | 214 | # Tests for date_periods_between 215 | def test_yearly_series_between_dates(self): 216 | date_from = datetime.date(2000, 1, 1) 217 | date_until = datetime.date(2002, 1, 1) 218 | 219 | periods = periodical.date_periods_between(date_from, date_until, 'yearly') 220 | reprs = [str(period) for period in periods] 221 | self.assertEqual(['2000', '2001', '2002'], reprs) 222 | 223 | periods = periodical.date_periods_between(date_until, date_from, 'yearly') 224 | reprs = [str(period) for period in periods] 225 | self.assertEqual(['2002', '2001', '2000'], reprs) 226 | 227 | periods = periodical.date_periods_between(date_from, date_from, 'yearly') 228 | reprs = [str(period) for period in periods] 229 | self.assertEqual(['2000'], reprs) 230 | 231 | # Test using the current day instead of explicitly specifying 232 | def test_today_func(self): 233 | def today(): 234 | return datetime.date(2000, 1, 1) 235 | cal = periodical.DatePeriod(span='monthly', _today_func=today) 236 | self.assertEqual(cal.start, datetime.date(2000, 1, 1)) 237 | self.assertEqual(cal.end, datetime.date(2000, 1, 31)) 238 | 239 | def test_default_today(self): 240 | # Simply exersize the default `today` implementation 241 | period = periodical.DatePeriod(span='yearly') 242 | period_repr = str(period) 243 | self.assertEqual(len(period_repr), 4) 244 | 245 | def test_repr(self): 246 | date = datetime.date(2000, 1, 1) 247 | cal = periodical.DatePeriod(date=date, span='monthly') 248 | self.assertEqual(repr(cal), "") 249 | 250 | # Tests for bad values 251 | def test_invalid_period(self): 252 | date = datetime.date(2000, 1, 1) 253 | with self.assertRaises(ValueError): 254 | periodical.DatePeriod(date=date, span='blibble') 255 | 256 | def test_invalid_date_period_comparison(self): 257 | date = datetime.date(2000, 1, 1) 258 | period = periodical.DatePeriod(date=date, span='day') 259 | self.assertFalse(period == 5) 260 | 261 | def test_invalid_time_period_comparison(self): 262 | time = datetime.datetime(2000, 1, 1, 0, 0, 0) 263 | period = periodical.TimePeriod(time=time, span='day') 264 | self.assertFalse(period == 5) 265 | 266 | def test_unknown_representation(self): 267 | with self.assertRaises(ValueError): 268 | periodical.DatePeriod('199x') 269 | 270 | def test_map(self): 271 | date = datetime.date(2014, 9, 1) 272 | periods = periodical.date_periods_ascending(date=date, span='monthly', num_periods=4) 273 | date_value_pairs = [ 274 | (datetime.date(2014, 9, 1), 20), 275 | (datetime.date(2014, 9, 2), 25), 276 | (datetime.date(2014, 10, 1), 20), 277 | (datetime.date(2014, 10, 1), 20), 278 | (datetime.date(2014, 12, 1), 30), 279 | ] 280 | mapped = periodical.map(periods, date_value_pairs) 281 | expected = collections.OrderedDict([ 282 | (periodical.DatePeriod('2014-09'), [20, 25]), 283 | (periodical.DatePeriod('2014-10'), [20, 20]), 284 | (periodical.DatePeriod('2014-11'), []), 285 | (periodical.DatePeriod('2014-12'), [30]), 286 | ]) 287 | self.assertEqual(mapped, expected) 288 | 289 | def test_summation(self): 290 | date = datetime.date(2014, 9, 1) 291 | periods = periodical.date_periods_ascending(date=date, span='monthly', num_periods=4) 292 | date_value_pairs = [ 293 | (datetime.date(2014, 9, 1), 20), 294 | (datetime.date(2014, 9, 2), 25), 295 | (datetime.date(2014, 10, 1), 20), 296 | (datetime.date(2014, 10, 1), 20), 297 | (datetime.date(2014, 12, 1), 30), 298 | ] 299 | summed = periodical.summation(periods, date_value_pairs) 300 | expected = collections.OrderedDict([ 301 | (periodical.DatePeriod('2014-09'), 45), 302 | (periodical.DatePeriod('2014-10'), 40), 303 | (periodical.DatePeriod('2014-11'), 0), 304 | (periodical.DatePeriod('2014-12'), 30), 305 | ]) 306 | self.assertEqual(summed, expected) 307 | 308 | def test_average(self): 309 | date = datetime.date(2014, 9, 1) 310 | periods = periodical.date_periods_ascending(date=date, span='monthly', num_periods=4) 311 | date_value_pairs = [ 312 | (datetime.date(2014, 9, 1), 20), 313 | (datetime.date(2014, 9, 2), 25), 314 | (datetime.date(2014, 10, 1), 20), 315 | (datetime.date(2014, 10, 1), 20), 316 | (datetime.date(2014, 12, 1), 30), 317 | ] 318 | averages = periodical.average(periods, date_value_pairs) 319 | expected = collections.OrderedDict([ 320 | (periodical.DatePeriod('2014-09'), 22.5), 321 | (periodical.DatePeriod('2014-10'), 20.0), 322 | (periodical.DatePeriod('2014-11'), None), 323 | (periodical.DatePeriod('2014-12'), 30.0), 324 | ]) 325 | self.assertEqual(averages, expected) 326 | 327 | def test_count(self): 328 | date = datetime.date(2014, 9, 1) 329 | periods = periodical.date_periods_ascending(date=date, span='monthly', num_periods=4) 330 | dates = [ 331 | datetime.date(2014, 9, 1), 332 | datetime.date(2014, 9, 2), 333 | datetime.date(2014, 10, 1), 334 | datetime.date(2014, 10, 1), 335 | datetime.date(2014, 12, 1), 336 | ] 337 | counts = periodical.count(periods, dates) 338 | expected = collections.OrderedDict([ 339 | (periodical.DatePeriod('2014-09'), 2), 340 | (periodical.DatePeriod('2014-10'), 2), 341 | (periodical.DatePeriod('2014-11'), 0), 342 | (periodical.DatePeriod('2014-12'), 1), 343 | ]) 344 | self.assertEqual(counts, expected) 345 | 346 | 347 | class TestTimePeriods(unittest.TestCase): 348 | # Date/period initialization tests 349 | def test_daily(self): 350 | time = datetime.datetime(2000, 1, 1, 23, 59) 351 | period = periodical.TimePeriod(time=time, span='daily') 352 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 353 | self.assertEqual(period.end, datetime.datetime(2000, 1, 2)) 354 | 355 | def test_weekly(self): 356 | time = datetime.datetime(2000, 1, 1, 23, 59) 357 | period = periodical.TimePeriod(time=time, span='weekly') 358 | self.assertEqual(period.start, datetime.datetime(1999, 12, 27)) 359 | self.assertEqual(period.end, datetime.datetime(2000, 1, 3)) 360 | 361 | def test_monthly(self): 362 | time = datetime.datetime(2000, 1, 1, 23, 59) 363 | period = periodical.TimePeriod(time=time, span='monthly') 364 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 365 | self.assertEqual(period.end, datetime.datetime(2000, 2, 1)) 366 | 367 | def test_quarterly(self): 368 | time = datetime.datetime(2000, 1, 1, 23, 59) 369 | period = periodical.TimePeriod(time=time, span='quarterly') 370 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 371 | self.assertEqual(period.end, datetime.datetime(2000, 4, 1)) 372 | 373 | def test_yearly(self): 374 | time = datetime.datetime(2000, 1, 1, 23, 59) 375 | period = periodical.TimePeriod(time=time, span='yearly') 376 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 377 | self.assertEqual(period.end, datetime.datetime(2001, 1, 1)) 378 | 379 | # Representation initialization tests 380 | def test_second_repr(self): 381 | period = periodical.TimePeriod('2000-01-01T23:59:59') 382 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1, 23, 59, 59)) 383 | self.assertEqual(period.end, datetime.datetime(2000, 1, 2, 0, 0, 0)) 384 | 385 | def test_minute_repr(self): 386 | period = periodical.TimePeriod('2000-01-01T23:59') 387 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1, 23, 59, 0)) 388 | self.assertEqual(period.end, datetime.datetime(2000, 1, 2, 0, 0, 0)) 389 | 390 | def test_hour_repr(self): 391 | period = periodical.TimePeriod('2000-01-01T23') 392 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1, 23, 0, 0)) 393 | self.assertEqual(period.end, datetime.datetime(2000, 1, 2, 0, 0, 0)) 394 | 395 | def test_daily_repr(self): 396 | period = periodical.TimePeriod('2000-01-01') 397 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 398 | self.assertEqual(period.end, datetime.datetime(2000, 1, 2)) 399 | 400 | def test_weekly_repr(self): 401 | period = periodical.TimePeriod('2000-W1') 402 | self.assertEqual(period.start, datetime.datetime(2000, 1, 3)) 403 | self.assertEqual(period.end, datetime.datetime(2000, 1, 10)) 404 | 405 | def test_monthly_repr(self): 406 | period = periodical.TimePeriod('2000-01') 407 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 408 | self.assertEqual(period.end, datetime.datetime(2000, 2, 1)) 409 | 410 | def test_quarterly_repr(self): 411 | period = periodical.TimePeriod('2000-Q1') 412 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 413 | self.assertEqual(period.end, datetime.datetime(2000, 4, 1)) 414 | 415 | def test_yearly_repr(self): 416 | period = periodical.TimePeriod('2000') 417 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 418 | self.assertEqual(period.end, datetime.datetime(2001, 1, 1)) 419 | 420 | def test_utc_repr(self): 421 | period = periodical.TimePeriod('2000-01Z') 422 | self.assertEqual(period.start, periodical.utc_datetime(2000, 1, 1)) 423 | self.assertEqual(period.end, periodical.utc_datetime(2000, 2, 1)) 424 | 425 | def test_utc_offset_repr(self): 426 | period = periodical.TimePeriod('2000-01+00:00') 427 | self.assertEqual(period.start, periodical.utc_datetime(2000, 1, 1)) 428 | self.assertEqual(period.end, periodical.utc_datetime(2000, 2, 1)) 429 | 430 | def test_fixed_offset_repr(self): 431 | period = periodical.TimePeriod('2000-01+01:00') 432 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1, tzinfo=periodical.Offset('+01:00'))) 433 | self.assertEqual(period.end, datetime.datetime(2000, 2, 1, tzinfo=periodical.Offset('+01:00'))) 434 | self.assertTrue(period.contains(periodical.utc_datetime(2000, 1, 1))) 435 | self.assertFalse(period.contains(periodical.utc_datetime(2000, 2, 1))) 436 | 437 | # Tests for `.previous()` 438 | def test_seconds_previous(self): 439 | time = datetime.datetime(2000, 1, 31, 23, 59, 59) 440 | period = periodical.TimePeriod(time=time, span='seconds').previous() 441 | self.assertEqual(period.start, datetime.datetime(2000, 1, 31, 23, 59, 58)) 442 | self.assertEqual(period.end, datetime.datetime(2000, 1, 31, 23, 59, 59)) 443 | 444 | def test_minutes_previous(self): 445 | time = datetime.datetime(2000, 1, 31, 23, 59) 446 | period = periodical.TimePeriod(time=time, span='minutes').previous() 447 | self.assertEqual(period.start, datetime.datetime(2000, 1, 31, 23, 58, 0)) 448 | self.assertEqual(period.end, datetime.datetime(2000, 1, 31, 23, 59, 0)) 449 | 450 | def test_hourly_previous(self): 451 | time = datetime.datetime(2000, 1, 31, 23, 00) 452 | period = periodical.TimePeriod(time=time, span='hour').previous() 453 | self.assertEqual(period.start, datetime.datetime(2000, 1, 31, 22, 0, 0)) 454 | self.assertEqual(period.end, datetime.datetime(2000, 1, 31, 23, 0, 0)) 455 | 456 | def test_daily_previous(self): 457 | time = datetime.datetime(2000, 1, 1) 458 | period = periodical.TimePeriod(time=time, span='daily').previous() 459 | self.assertEqual(period.start, datetime.datetime(1999, 12, 31)) 460 | self.assertEqual(period.end, datetime.datetime(2000, 1, 1)) 461 | 462 | def test_weekly_previous(self): 463 | time = datetime.datetime(2000, 1, 1) 464 | period = periodical.TimePeriod(time=time, span='weekly').previous() 465 | self.assertEqual(period.start, datetime.datetime(1999, 12, 20)) 466 | self.assertEqual(period.end, datetime.datetime(1999, 12, 27)) 467 | 468 | def test_monthly_previous(self): 469 | time = datetime.datetime(2000, 1, 1) 470 | period = periodical.TimePeriod(time=time, span='monthly').previous() 471 | self.assertEqual(period.start, datetime.datetime(1999, 12, 1)) 472 | self.assertEqual(period.end, datetime.datetime(2000, 1, 1)) 473 | 474 | time = datetime.datetime(2000, 3, 1) 475 | period = periodical.TimePeriod(time=time, span='monthly').previous() 476 | self.assertEqual(period.start, datetime.datetime(2000, 2, 1)) 477 | self.assertEqual(period.end, datetime.datetime(2000, 3, 1)) 478 | 479 | def test_quarterly_previous(self): 480 | time = datetime.datetime(2000, 1, 1) 481 | period = periodical.TimePeriod(time=time, span='quarterly').previous() 482 | self.assertEqual(period.start, datetime.datetime(1999, 10, 1)) 483 | self.assertEqual(period.end, datetime.datetime(2000, 1, 1)) 484 | 485 | time = datetime.datetime(2000, 4, 1) 486 | period = periodical.TimePeriod(time=time, span='quarterly') 487 | period = periodical.TimePeriod(time=time, span='quarterly').previous() 488 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 489 | self.assertEqual(period.end, datetime.datetime(2000, 4, 1)) 490 | 491 | def test_yearly_previous(self): 492 | time = datetime.datetime(2000, 1, 1) 493 | period = periodical.TimePeriod(time=time, span='yearly').previous() 494 | self.assertEqual(period.start, datetime.datetime(1999, 1, 1)) 495 | self.assertEqual(period.end, datetime.datetime(2000, 1, 1)) 496 | 497 | # Tests for `.next()` 498 | def test_seconds_next(self): 499 | time = datetime.datetime(2000, 1, 31, 23, 59, 59) 500 | period = periodical.TimePeriod(time=time, span='seconds').next() 501 | self.assertEqual(period.start, datetime.datetime(2000, 2, 1, 0, 0, 0)) 502 | self.assertEqual(period.end, datetime.datetime(2000, 2, 1, 0, 0, 1)) 503 | 504 | def test_minutes_next(self): 505 | time = datetime.datetime(2000, 1, 31, 23, 59) 506 | period = periodical.TimePeriod(time=time, span='minutes').next() 507 | self.assertEqual(period.start, datetime.datetime(2000, 2, 1, 0, 0)) 508 | self.assertEqual(period.end, datetime.datetime(2000, 2, 1, 0, 1)) 509 | 510 | def test_hourly_next(self): 511 | time = datetime.datetime(2000, 1, 31, 23, 00) 512 | period = periodical.TimePeriod(time=time, span='hour').next() 513 | self.assertEqual(period.start, datetime.datetime(2000, 2, 1, 0)) 514 | self.assertEqual(period.end, datetime.datetime(2000, 2, 1, 1)) 515 | 516 | def test_daily_next(self): 517 | time = datetime.datetime(2000, 1, 31) 518 | period = periodical.TimePeriod(time=time, span='daily').next() 519 | self.assertEqual(period.start, datetime.datetime(2000, 2, 1)) 520 | self.assertEqual(period.end, datetime.datetime(2000, 2, 2)) 521 | 522 | def test_weekly_next(self): 523 | time = datetime.datetime(2000, 1, 1) 524 | period = periodical.TimePeriod(time=time, span='weekly').next() 525 | self.assertEqual(period.start, datetime.datetime(2000, 1, 3)) 526 | self.assertEqual(period.end, datetime.datetime(2000, 1, 10)) 527 | 528 | def test_monthly_next(self): 529 | time = datetime.datetime(2000, 1, 1) 530 | period = periodical.TimePeriod(time=time, span='monthly').next() 531 | self.assertEqual(period.start, datetime.datetime(2000, 2, 1)) 532 | self.assertEqual(period.end, datetime.datetime(2000, 3, 1)) 533 | 534 | time = datetime.datetime(2000, 12, 1) 535 | period = periodical.TimePeriod(time=time, span='monthly').next() 536 | self.assertEqual(period.start, datetime.datetime(2001, 1, 1)) 537 | self.assertEqual(period.end, datetime.datetime(2001, 2, 1)) 538 | 539 | def test_quarterly_next(self): 540 | time = datetime.datetime(2000, 1, 1) 541 | period = periodical.TimePeriod(time=time, span='quarterly').next() 542 | self.assertEqual(period.start, datetime.datetime(2000, 4, 1)) 543 | self.assertEqual(period.end, datetime.datetime(2000, 7, 1)) 544 | 545 | time = datetime.datetime(2000, 10, 1) 546 | period = periodical.TimePeriod(time=time, span='quarterly') 547 | period = periodical.TimePeriod(time=time, span='quarterly').next() 548 | self.assertEqual(period.start, datetime.datetime(2001, 1, 1)) 549 | self.assertEqual(period.end, datetime.datetime(2001, 4, 1)) 550 | 551 | def test_yearly_next(self): 552 | time = datetime.datetime(2000, 1, 1) 553 | period = periodical.TimePeriod(time=time, span='yearly').next() 554 | self.assertEqual(period.start, datetime.datetime(2001, 1, 1)) 555 | self.assertEqual(period.end, datetime.datetime(2002, 1, 1)) 556 | 557 | # Tests for date_periods_descending(), and isoformat representations 558 | def test_offset_second_series_descending(self): 559 | time = datetime.datetime(2000, 1, 1, 23, 00, 00, tzinfo=periodical.Offset('+01:30')) 560 | periods = periodical.time_periods_descending(time, 'seconds', 3) 561 | iso = [period.isoformat() for period in periods] 562 | self.assertEqual(['2000-01-01T23:00:00+01:30', '2000-01-01T22:59:59+01:30', '2000-01-01T22:59:58+01:30'], iso) 563 | 564 | def test_offset_minute_series_descending(self): 565 | time = datetime.datetime(2000, 1, 1, 23, 00, tzinfo=periodical.Offset('-01:30')) 566 | periods = periodical.time_periods_descending(time, 'minutes', 3) 567 | iso = [period.isoformat() for period in periods] 568 | self.assertEqual(['2000-01-01T23:00-01:30', '2000-01-01T22:59-01:30', '2000-01-01T22:58-01:30'], iso) 569 | 570 | def test_utc_hourly_series_descending(self): 571 | time = periodical.utc_datetime(2000, 1, 1, 1) 572 | periods = periodical.time_periods_descending(time, 'hourly', 3) 573 | iso = [period.isoformat() for period in periods] 574 | self.assertEqual(['2000-01-01T01:00Z', '2000-01-01T00:00Z', '1999-12-31T23:00Z'], iso) 575 | 576 | def test_daily_series_descending(self): 577 | time = datetime.datetime(2000, 1, 1) 578 | periods = periodical.time_periods_descending(time, 'daily', 3) 579 | iso = [period.isoformat() for period in periods] 580 | self.assertEqual(['2000-01-01', '1999-12-31', '1999-12-30'], iso) 581 | 582 | def test_weekly_series_descending(self): 583 | time = datetime.datetime(2000, 1, 1) 584 | periods = periodical.date_periods_descending(time, 'weekly', 3) 585 | iso = [period.isoformat() for period in periods] 586 | self.assertEqual(['1999-W52', '1999-W51', '1999-W50'], iso) 587 | 588 | def test_monthly_series_descending(self): 589 | time = datetime.datetime(2000, 1, 1) 590 | periods = periodical.date_periods_descending(time, 'monthly', 3) 591 | iso = [period.isoformat() for period in periods] 592 | self.assertEqual(['2000-01', '1999-12', '1999-11'], iso) 593 | 594 | def test_quarterly_series_descending(self): 595 | time = datetime.datetime(2000, 1, 1) 596 | periods = periodical.date_periods_descending(time, 'quarterly', 3) 597 | iso = [period.isoformat() for period in periods] 598 | self.assertEqual(['2000-01', '1999-10', '1999-07'], iso) 599 | 600 | def test_yearly_series_descending(self): 601 | time = datetime.datetime(2000, 1, 1) 602 | periods = periodical.date_periods_descending(time, 'yearly', 3) 603 | iso = [period.isoformat() for period in periods] 604 | self.assertEqual(['2000', '1999', '1998'], iso) 605 | 606 | # Tests for date_periods_ascending(), and string representations 607 | def test_daily_series_ascending(self): 608 | time = datetime.datetime(2000, 1, 1) 609 | periods = periodical.time_periods_ascending(time, 'daily', 3) 610 | reprs = [str(period) for period in periods] 611 | self.assertEqual(['2000-01-01', '2000-01-02', '2000-01-03'], reprs) 612 | 613 | def test_weekly_series_ascending(self): 614 | time = datetime.datetime(2000, 1, 1) 615 | periods = periodical.time_periods_ascending(time, 'weekly', 3) 616 | reprs = [str(period) for period in periods] 617 | self.assertEqual(['1999-W52', '2000-W01', '2000-W02'], reprs) 618 | 619 | def test_monthly_series_ascending(self): 620 | time = datetime.datetime(2000, 1, 1) 621 | periods = periodical.time_periods_ascending(time, 'monthly', 3) 622 | reprs = [str(period) for period in periods] 623 | self.assertEqual(['2000-01', '2000-02', '2000-03'], reprs) 624 | 625 | def test_quarterly_series_ascending(self): 626 | time = datetime.datetime(2000, 1, 1) 627 | periods = periodical.time_periods_ascending(time, 'quarterly', 3) 628 | reprs = [str(period) for period in periods] 629 | self.assertEqual(['2000-Q1', '2000-Q2', '2000-Q3'], reprs) 630 | 631 | def test_yearly_series_ascending(self): 632 | time = datetime.datetime(2000, 1, 1) 633 | periods = periodical.time_periods_ascending(time, 'yearly', 3) 634 | reprs = [str(period) for period in periods] 635 | self.assertEqual(['2000', '2001', '2002'], reprs) 636 | 637 | # Tests for date_periods_between 638 | def test_yearly_series_between_dates(self): 639 | time_from = datetime.datetime(2000, 1, 1) 640 | time_until = datetime.datetime(2002, 1, 1) 641 | 642 | periods = periodical.time_periods_between(time_from, time_until, 'yearly') 643 | reprs = [str(period) for period in periods] 644 | self.assertEqual(['2000', '2001', '2002'], reprs) 645 | 646 | periods = periodical.time_periods_between(time_until, time_from, 'yearly') 647 | reprs = [str(period) for period in periods] 648 | self.assertEqual(['2002', '2001', '2000'], reprs) 649 | 650 | periods = periodical.time_periods_between(time_from, time_from, 'yearly') 651 | reprs = [str(period) for period in periods] 652 | self.assertEqual(['2000'], reprs) 653 | 654 | # Test using the current time instead of explicitly specifying 655 | def test_now_func(self): 656 | def now(): 657 | return datetime.datetime(2000, 1, 1) 658 | period = periodical.TimePeriod(span='monthly', _now_func=now) 659 | self.assertEqual(period.start, datetime.datetime(2000, 1, 1)) 660 | self.assertEqual(period.end, datetime.datetime(2000, 2, 1)) 661 | 662 | def test_default_now_func_is_timezone_aware(self): 663 | period = periodical.TimePeriod(span='yearly') 664 | period_repr = str(period) 665 | self.assertEqual(len(period_repr), 5) 666 | self.assertEqual(period_repr[-1], 'Z') 667 | 668 | def test_repr(self): 669 | time = datetime.datetime(2000, 1, 1) 670 | cal = periodical.TimePeriod(time=time, span='monthly') 671 | self.assertEqual(repr(cal), "") 672 | 673 | # Tests for bad values 674 | def test_invalid_period(self): 675 | time = datetime.datetime(2000, 1, 1) 676 | with self.assertRaises(ValueError): 677 | periodical.TimePeriod(time=time, span='blibble') 678 | 679 | def test_unknown_representation(self): 680 | with self.assertRaises(ValueError): 681 | periodical.TimePeriod('199x') 682 | 683 | # def test_map(self): 684 | # date = datetime.date(2014, 9, 1) 685 | # periods = periodical.date_periods_ascending(date=date, span='monthly', num_periods=4) 686 | # date_value_pairs = [ 687 | # (datetime.date(2014, 9, 1), 20), 688 | # (datetime.date(2014, 9, 2), 25), 689 | # (datetime.date(2014, 10, 1), 20), 690 | # (datetime.date(2014, 10, 1), 20), 691 | # (datetime.date(2014, 12, 1), 30), 692 | # ] 693 | # mapped = periodical.map(periods, date_value_pairs) 694 | # expected = collections.OrderedDict([ 695 | # (periodical.DatePeriod('2014-09'), [20, 25]), 696 | # (periodical.DatePeriod('2014-10'), [20, 20]), 697 | # (periodical.DatePeriod('2014-11'), []), 698 | # (periodical.DatePeriod('2014-12'), [30]), 699 | # ]) 700 | # self.assertEqual(mapped, expected) 701 | 702 | # def test_summation(self): 703 | # date = datetime.date(2014, 9, 1) 704 | # periods = periodical.date_periods_ascending(date=date, span='monthly', num_periods=4) 705 | # date_value_pairs = [ 706 | # (datetime.date(2014, 9, 1), 20), 707 | # (datetime.date(2014, 9, 2), 25), 708 | # (datetime.date(2014, 10, 1), 20), 709 | # (datetime.date(2014, 10, 1), 20), 710 | # (datetime.date(2014, 12, 1), 30), 711 | # ] 712 | # summed = periodical.summation(periods, date_value_pairs) 713 | # expected = collections.OrderedDict([ 714 | # (periodical.DatePeriod('2014-09'), 45), 715 | # (periodical.DatePeriod('2014-10'), 40), 716 | # (periodical.DatePeriod('2014-11'), 0), 717 | # (periodical.DatePeriod('2014-12'), 30), 718 | # ]) 719 | # self.assertEqual(summed, expected) 720 | 721 | # def test_average(self): 722 | # date = datetime.date(2014, 9, 1) 723 | # periods = periodical.date_periods_ascending(date=date, span='monthly', num_periods=4) 724 | # date_value_pairs = [ 725 | # (datetime.date(2014, 9, 1), 20), 726 | # (datetime.date(2014, 9, 2), 25), 727 | # (datetime.date(2014, 10, 1), 20), 728 | # (datetime.date(2014, 10, 1), 20), 729 | # (datetime.date(2014, 12, 1), 30), 730 | # ] 731 | # averages = periodical.average(periods, date_value_pairs) 732 | # expected = collections.OrderedDict([ 733 | # (periodical.DatePeriod('2014-09'), 22.5), 734 | # (periodical.DatePeriod('2014-10'), 20.0), 735 | # (periodical.DatePeriod('2014-11'), None), 736 | # (periodical.DatePeriod('2014-12'), 30.0), 737 | # ]) 738 | # self.assertEqual(averages, expected) 739 | 740 | def test_count(self): 741 | time = periodical.utc_datetime(2014, 9, 1) 742 | periods = periodical.time_periods_ascending(time=time, span='monthly', num_periods=4) 743 | dates = [ 744 | periodical.utc_datetime(2014, 9, 1), 745 | periodical.utc_datetime(2014, 9, 2), 746 | periodical.utc_datetime(2014, 10, 1), 747 | periodical.utc_datetime(2014, 10, 1), 748 | periodical.utc_datetime(2014, 12, 1), 749 | ] 750 | counts = periodical.count(periods, dates) 751 | expected = collections.OrderedDict([ 752 | (periodical.TimePeriod('2014-09Z'), 2), 753 | (periodical.TimePeriod('2014-10Z'), 2), 754 | (periodical.TimePeriod('2014-11Z'), 0), 755 | (periodical.TimePeriod('2014-12Z'), 1), 756 | ]) 757 | self.assertEqual(counts, expected) 758 | 759 | if __name__ == '__main__': 760 | unittest.main() 761 | --------------------------------------------------------------------------------