diff options
author | Gerhard Weis <gweis@gmx.at> | 2009-01-05 05:32:56 +1000 |
---|---|---|
committer | Gerhard Weis <gweis@gmx.at> | 2009-01-05 05:32:56 +1000 |
commit | b95749437bdb961a98b79a10114567eb31547e9d (patch) | |
tree | fdaa9e7e9b74d0e4de6b61990fc9c2a4a26160ce | |
download | isodate-b95749437bdb961a98b79a10114567eb31547e9d.tar.gz |
* initial commit (version 0.3.0)
-rw-r--r-- | .project | 17 | ||||
-rw-r--r-- | CHANGES.txt | 9 | ||||
-rw-r--r-- | MANIFEST.in | 2 | ||||
-rw-r--r-- | README.txt | 69 | ||||
-rw-r--r-- | TODO.txt | 37 | ||||
-rw-r--r-- | buildout.cfg | 32 | ||||
-rw-r--r-- | setup.py | 66 | ||||
-rw-r--r-- | src/isodate/__init__.py | 37 | ||||
-rw-r--r-- | src/isodate/isodates.py | 195 | ||||
-rw-r--r-- | src/isodate/isodatetime.py | 49 | ||||
-rw-r--r-- | src/isodate/isoduration.py | 324 | ||||
-rw-r--r-- | src/isodate/isoerror.py | 32 | ||||
-rw-r--r-- | src/isodate/isotime.py | 152 | ||||
-rw-r--r-- | src/isodate/tzinfo.py | 137 | ||||
-rw-r--r-- | src/tests/__init__.py | 45 | ||||
-rw-r--r-- | src/tests/test_date.py | 102 | ||||
-rw-r--r-- | src/tests/test_datetime.py | 82 | ||||
-rw-r--r-- | src/tests/test_duration.py | 409 | ||||
-rw-r--r-- | src/tests/test_time.py | 105 |
19 files changed, 1901 insertions, 0 deletions
diff --git a/.project b/.project new file mode 100644 index 0000000..b4db36c --- /dev/null +++ b/.project @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>isodate</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.python.pydev.PyDevBuilder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.python.pydev.pythonNature</nature> + </natures> +</projectDescription> diff --git a/CHANGES.txt b/CHANGES.txt new file mode 100644 index 0000000..949e95e --- /dev/null +++ b/CHANGES.txt @@ -0,0 +1,9 @@ + +CHANGES +======= + +0.3.0 (2009-1-05) +------------------ + +- Initial release + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..426ffc6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include CHANGES.txt +include TODO.txt
\ No newline at end of file diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..8cbeb63 --- /dev/null +++ b/README.txt @@ -0,0 +1,69 @@ + +ISO 8601 date/time parser +========================= + +This module implements ISO 8601 date, time and duration parsing. +The implementation follows ISO8601:2004 standard, and implements only +date/time representations mentioned in the standard. If something is not +mentioned there, then it is treated as non existent, and not as an allowed +option. + +For instance, ISO8601:2004 never mentions 2 digit years. So, it is not +intended by this module to support 2 digit years. (while it may still +be valid as ISO date, because it is not explicitly forbidden.) +Another example is, that if no time zone designation is given for a time, +the it is a local time, and not UTC. + +As this module maps ISO 8601 dates/times to standard Python data types, like +*date*, *time*, *datetime* and *timedelta*, it is not possible to convert +all possible ISO 8601 dates/times. For instance, dates before 0001-01-01 are +not allowed by the Python *date* and *datetime* classes. + +Documentation +------------- + +Currently there are four parsing methods available. + * parse_time: + parses an ISO 8601 time string into a *time* object + * parse_date: + parses an ISO 8601 date string into a *date* object + * parse_datetime: + parses an ISO 8601 date-time string into a *datetime* object + * parse_duration: + parses an ISO 8601 duration string into a *timedelta* or *Duration* + object. + +As ISO 8601 allows to define durations in years and months, and *timedelta* +does not handle years and months, this module provides a *Duration* class, +which can be used almost like a *timedelta* object (with some limitations). +However, a *Duration* object can be converted into a *timedelta* object. + +Installation: +------------- + +This module can easily be installed with Python standard installation methods. +Just use *setuptools* or *easy_instal* as usual. + +Limitations: +------------ + + * The parser accepts several date/time representation which should be invalid + according to ISO 8601 standard. + + 1. for date and time together, this parser accepts a mixture of basic and extended format. + e.g. the date could be in basic format, while the time is accepted in extended format. + It also allows short dates and times in date-time strings. + 2. For incomplete dates, the first day is chosen. e.g. 19th century results in a date of + 1901-01-01. + +Further information: +-------------------- + +The doc strings and unit tests should provide rather detailed information about +the methods and their limitations. + +The source release provides a *setup.py* script and a *buildout.cfg*. Both can +be used to run the unit tests included. + +Source code is available at `<http://hg.proclos.com/isodate>`_. + diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..cc3a07a --- /dev/null +++ b/TODO.txt @@ -0,0 +1,37 @@ + +TODOs +===== + +This to do list contains some thoughts and ideas about missing features, and +parts to think about, whether to implement them or not. This list is probably +not complete. + +Missing features: +----------------- + + * methods to format *date*, *time*, *datetime*, *timedelta* and *Duration* + objects to various ISO strings. + * parser for ISO intervals. + +Documentation: +-------------- + + * parse_datetime: + - complete documentation to show what this function allows, but ISO forbids. + and vice verse. + - support other separators between date and time than 'T' + + * parse_date: + - yeardigits should be always greater than 4 + - dates before 0001-01-01 are not supported + + * parse_duration: + - alternative formats are not fully supported due to parse_date restrictions + - standard duration format is fully supported but not very restrictive. + + * Duration: + - support fractional years and month in calculations + - implement w3c order relation? (`<http://www.w3.org/TR/xmlschema-2/#duration-order>`_) + - refactor to have duration mathematics only at one place. + - localize __str__ method (does timedelta do this?) +
\ No newline at end of file diff --git a/buildout.cfg b/buildout.cfg new file mode 100644 index 0000000..dd34859 --- /dev/null +++ b/buildout.cfg @@ -0,0 +1,32 @@ +[buildout] +develop = . +parts = iso8601date importchecker test pydev coverage coverage-report + +[iso8601date] +recipe = zc.recipe.egg +eggs = iso8601date +interpreter = python + +[pydev] +recipe = pb.recipes.pydev +eggs = ${iso8601date:eggs} + +[test] +recipe = zc.recipe.testrunner +eggs = iso8601date + +[coverage] +recipe = zc.recipe.testrunner +eggs = iso8601date +defaults = ['--coverage', '.'] + +[coverage-report] +recipe = zc.recipe.egg +eggs = z3c.coverage +scripts = coverage=coverage-report +arguments = ('parts/coverage', 'parts/coverage-report') + +[importchecker] +recipe = zc.recipe.egg +eggs = importchecker +arguments = "${buildout:directory}/src" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f7b478c --- /dev/null +++ b/setup.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +import os +from setuptools import setup +from setuptools import find_packages +# for setuptools see: http://peak.telecommunity.com/DevCenter/setuptools + +def read(*rnames): + return open(os.path.join(os.path.dirname(__file__), *rnames)).read() + +setup(name = 'iso8601date', + version = '0.3.0', + packages = find_packages('src', exclude=["tests"]), + package_dir={'': 'src'}, + + # dependencies: + # install_requires = [], + + # PyPI metadata + author='Gerhard Weis', + author_email='gerhard.weis@proclos.com', + description='An ISO 8601 date/time/duration parser and formater', + license = 'BSD', + #keywords = '', + url='http://cheeseshop.python.org/pypi/isodate', + + long_description=read('README.txt') + + read('CHANGES.txt') + + read('TODO.txt'), + + classifiers = ['Development Status :: 4 - Beta', + # 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Internet', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], + test_suite = "tests.test_suite" + ) diff --git a/src/isodate/__init__.py b/src/isodate/__init__.py new file mode 100644 index 0000000..4e119dd --- /dev/null +++ b/src/isodate/__init__.py @@ -0,0 +1,37 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +Import all essential functions and constants to re-export them here for easy +access. +''' + +from isodate.isodates import parse_date +from isodate.isotime import parse_time +from isodate.isodatetime import parse_datetime +from isodate.isoduration import parse_duration, Duration +from isodate.isoerror import ISO8601Error +from isodate.tzinfo import UTC, FixedOffset, LOCAL diff --git a/src/isodate/isodates.py b/src/isodate/isodates.py new file mode 100644 index 0000000..136757b --- /dev/null +++ b/src/isodate/isodates.py @@ -0,0 +1,195 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +This modules provides a method to parse an ISO 8601:2004 date string to a +python datetime.date instance. + +It supports all basic, extended and expanded formats as described in the ISO +standard. The only limitations it has, are given by the Python datetime.date +implementation, which does not support dates before 0001-01-01. +''' +import re +from datetime import date, timedelta + +from isodate.isoerror import ISO8601Error + +DATE_REGEX_CACHE = {} +# A dictionary to cache pre-compiled regular expressions. +# A set of regular expressions is identified, by number of year digits allowed +# and whether a plus/minus sign is required or not. (This option is changeable +# only for 4 digit years). + +def build_date_regexps(yeardigits=4, expanded=False): + ''' + Compile set of regular expressions to parse ISO dates. The expressions will + be created only if they are not already in REGEX_CACHE. + + It is necessary to fix the number of year digits, else it is not possible + to automatically distinguish between various ISO date formats. + + ISO 8601 allows more than 4 digit years, on prior agreement, but then a +/- + sign is required (expanded format). To support +/- sign for 4 digit years, + the expanded parameter needs to be set to True. + ''' + if yeardigits != 4: + expanded = True + if (yeardigits, expanded) not in DATE_REGEX_CACHE: + cache_entry = [] + # ISO 8601 expanded DATE formats allow an arbitrary number of year + # digits with a leading +/- sign. + if expanded: + sign = 1 + else: + sign = 0 + # 1. complete dates: + # YYYY-MM-DD or +- YYYYYY-MM-DD... extended date format + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})" + r"-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})" + % (sign, yeardigits))) + # YYYYMMDD or +- YYYYYYMMDD... basic date format + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})" + r"(?P<month>[0-9]{2})(?P<day>[0-9]{2})" + % (sign, yeardigits))) + # 2. complete week dates: + # YYYY-Www-D or +-YYYYYY-Www-D ... extended week date + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})" + r"-W(?P<week>[0-9]{2})-(?P<day>[0-9]{1})" + % (sign, yeardigits))) + # YYYYWwwD or +-YYYYYYWwwD ... basic week date + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})W" + r"(?P<week>[0-9]{2})(?P<day>[0-9]{1})" + % (sign, yeardigits))) + # 3. ordinal dates: + # YYYY-DDD or +-YYYYYY-DDD ... extended format + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})" + r"-(?P<day>[0-9]{3})" + % (sign, yeardigits))) + # YYYYDDD or +-YYYYYYDDD ... basic format + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})" + r"(?P<day>[0-9]{3})" + % (sign, yeardigits))) + # 4. week dates: + # YYYY-Www or +-YYYYYY-Www ... extended reduced accuracy week date + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})" + r"-W(?P<week>[0-9]{2})" + % (sign, yeardigits))) + # YYYYWww or +-YYYYYYWww ... basic reduced accuracy week date + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})W" + r"(?P<week>[0-9]{2})" + % (sign, yeardigits))) + # 5. month dates: + # YYY-MM or +-YYYYYY-MM ... reduced accuracy specific month + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})" + r"-(?P<month>[0-9]{2})" + % (sign, yeardigits))) + # 6. year dates: + # YYYY or +-YYYYYY ... reduced accuracy specific year + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}(?P<year>[0-9]{%d})" + % (sign, yeardigits))) + # 7. century dates: + # YY or +-YYYY ... reduced accuracy specific century + cache_entry.append(re.compile(r"(?P<sign>[+-]){%d}" + r"(?P<century>[0-9]{%d})" + % (sign, yeardigits - 2))) + + DATE_REGEX_CACHE[(yeardigits, expanded)] = cache_entry + return DATE_REGEX_CACHE[(yeardigits, expanded)] + +def parse_date(datestring, yeardigits=4, expanded=False): + ''' + Parse an ISO 8601 date string into a datetime.date object. + + As the datetime.date implementation is limited to dates starting from + 0001-01-01, negative dates (BC) and year 0 can not be parsed by this + method. + + For incomplete dates, this method chooses the first day for it. For + instance if only a century is given, this method returns the 1st of + January in year 1 of this century. + + supported formats: (expanded formats are shown with 6 digits for year) + YYYYMMDD +-YYYYYYMMDD basic complete date + YYYY-MM-DD +-YYYYYY-MM-DD extended complete date + YYYYWwwD +-YYYYYYWwwD basic complete week date + YYYY-Www-D +-YYYYYY-Www-D extended complete week date + YYYYDDD +-YYYYYYDDD basic ordinal date + YYYY-DDD +-YYYYYY-DDD extended ordinal date + YYYYWww +-YYYYYYWww basic incomplete week date + YYYY-Www +-YYYYYY-Www extended incomplete week date + YYY-MM +-YYYYYY-MM incomplete month date + YYYY +-YYYYYY incomplete year date + YY +-YYYY incomplete century date + + @param datestring: the ISO date string to parse + @param yeardigits: how many digits are used to represent a year + @param expanded: if True then +/- signs are allowed. This parameter + is forced to True, if yeardigits != 4 + + @return: a datetime.date instance represented by datestring + @raise ISO8601Error: if this function can not parse the datestring + @raise ValueError: if datestring can not be represented by datetime.date + ''' + if yeardigits != 4: + expanded = True + isodates = build_date_regexps(yeardigits, expanded) + for pattern in isodates: + match = pattern.match(datestring) + if match: + groups = match.groupdict() + # sign, century, year, month, week, day, + if groups['sign'] == '-': # FIXME: not possible with datetime, date + sign = -1 + else: + sign = 1 + if 'century' in groups and groups['century'] is not None: + return date(sign * (int(groups['century']) * 100 + 1), 1, 1) + if not 'month' in groups: # weekdate or ordinal date + ret = date(sign * int(groups['year']), 1, 1) + if 'week' in groups and groups['week'] is not None: + isotuple = ret.isocalendar() + if 'day' in groups: + days = int(groups['day'] or 1) + else: + days = 1 + if isotuple[1] == 1: # this is the first week in the year + return ret + timedelta(weeks=int(groups['week'])-1, + days=-isotuple[2]+days) + else: + return ret + timedelta(weeks=int(groups['week']), + days=-isotuple[2]+days) + elif 'day' in groups: # ordinal date + return ret + timedelta(days=int(groups['day'])-1) + else: # year date + return ret + # year-, month-, or complete date + if 'day' not in groups or groups['day'] is None: + day = 1 + else: + day = int(groups['day']) + return date(sign*int(groups['year']), + int(groups['month']) or 1, day) + raise ISO8601Error('Unrecognised ISO 8601 date format: %r' % datestring) diff --git a/src/isodate/isodatetime.py b/src/isodate/isodatetime.py new file mode 100644 index 0000000..4cc6fd4 --- /dev/null +++ b/src/isodate/isodatetime.py @@ -0,0 +1,49 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +This module defines a method to parse an ISO 8601:2004 date time string. + +For this job it uses the parse_date and parse_time methods defined in date +and time module. +''' +from datetime import datetime + +from isodate.isodates import parse_date +from isodate.isotime import parse_time + +def parse_datetime(datetimestring): + ''' + Parses ISO 8601 date-times into datetime.datetime objects. + + This function uses parse_date and parse_time to do the job, so it allows + more combinations of date and time representations, than the actual + ISO 8601:2004 standard allows. + ''' + datestring, timestring = datetimestring.split('T') + tmpdate = parse_date(datestring) + tmptime = parse_time(timestring) + return datetime.combine(tmpdate, tmptime) diff --git a/src/isodate/isoduration.py b/src/isodate/isoduration.py new file mode 100644 index 0000000..ebbd7eb --- /dev/null +++ b/src/isodate/isoduration.py @@ -0,0 +1,324 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +This module provides an ISO 8601:2004 duration parser. + +It also defines a class Duration, which allows to define +durations in years and months. +''' +from datetime import date, datetime, timedelta +import re + +from isodate.isoerror import ISO8601Error +from isodate.isodatetime import parse_datetime + +ISO8601_PERIOD_REGEX = re.compile(r"^(?P<sign>[+-])?" + r"P(?P<years>[0-9]+([,.][0-9]+)?Y)?" + r"(?P<months>[0-9]+([,.][0-9]+)?M)?" + r"(?P<weeks>[0-9]+([,.][0-9]+)?W)?" + r"(?P<days>[0-9]+([,.][0-9]+)?D)?" + r"((?P<separator>T)(?P<hours>[0-9]+([,.][0-9]+)?H)?" + r"(?P<minutes>[0-9]+([,.][0-9]+)?M)?" + r"(?P<seconds>[0-9]+([,.][0-9]+)?S)?)?$") +# regular expression to parse ISO duartion strings. + +def parse_duration(datestring): + """ + Parses an ISO 8601 durations into datetime.timedelta or Duration objects. + + If the ISO date string does not contain years or months, a timedelta instance + is returned, else a Duration instance is returned. + + The following duration formats are supported: + -PnnW duration in weeks + -PnnYnnMnnDTnnHnnMnnS complete duration specification + -PYYYYMMDDThhmmss basic alternative complete date format + -PYYYY-MM-DDThh:mm:ss extended alternative complete date format + -PYYYYDDDThhmmss basic alternative ordinal date format + -PYYYY-DDDThh:mm:ss extended alternative ordinal date format + + The '-' is optional. + + Limitations: + ISO standard defines some restrictions about where to use fractional numbers + and which component and format combinations are allowed. This parser + implementation ignores all those restrictions and returns something when it is + able to find all necessary components. + In detail: + it does not check, whether only the last component has fractions. + it allows weeks specified with all other combinations + + The alternative format does not support durations with years, months or days + set to 0. + """ + if not isinstance(datestring, basestring): + raise TypeError("Expecting a string %r" % datestring) + match = ISO8601_PERIOD_REGEX.match(datestring) + if not match: + # try alternative format: + if datestring.startswith("P"): + durdt = parse_datetime(datestring[1:]) + if durdt.year != 0 or durdt.month != 0: + # create Duration + ret = Duration(days=durdt.day, seconds=durdt.second, + microseconds=durdt.microsecond, + minutes=durdt.minute, hours=durdt.hour, + months=durdt.month, years=durdt.year) + else: # FIXME: currently not possible in alternative format + # create timedelta + ret = timedelta(days=durdt.day, seconds=durdt.second, + microseconds=durdt.microsecond, + minutes=durdt.minute, hours=durdt.hour) + return ret + raise ISO8601Error("Unable to parse duration string %r" % datestring) + groups = match.groupdict() + for key, val in groups.items(): + if key not in ('separator', 'sign'): + if val is None: + groups[key] = "0n" + #print groups[key] + groups[key] = float(groups[key][:-1].replace(',', '.')) + if groups["years"] == 0 and groups["months"] == 0: + ret = timedelta(days=groups["days"], hours=groups["hours"], + minutes=groups["minutes"], seconds=groups["seconds"], + weeks=groups["weeks"]) + if groups["sign"] == '-': + ret = timedelta(0) - ret + else: + ret = Duration(years=groups["years"], months=groups["months"], + days=groups["days"], hours=groups["hours"], + minutes=groups["minutes"], seconds=groups["seconds"], + weeks=groups["weeks"]) + if groups["sign"] == '-': + ret = Duration(0) - ret + return ret + + +def fquotmod(val, low, high): + ''' + A divmod function with boundaries. + ''' + div, mod = divmod(val - low, high - low) + mod += low + return int(div), mod + +def max_days_in_month(year, month): + ''' + Determines the number of days of a specific month in a specific year. + ''' + if month in (1, 3, 5, 7, 8, 10, 12): + return 31 + if month in (4, 6, 9, 11): + return 30 + if ((year % 400) == 0) or ((year % 100) != 0) and ((year % 4) == 0): + return 29 + return 28 + +class Duration(object): + ''' + A class which represents a duration. + + The difference to datetime.timedelta is, that this class handles also + differences given in years and months. + A Duration treats differences given in year, months separately from all + other components. + + A Duration can be used almost like any timedelta object, however there + are some restrictions: + * It is not really possible to compare Durations, because it is unclear, + whether a duration of 1 year is bigger than 365 days or not. + * Equality is only tested between the two (year, month vs. timedelta) + basic components. + + A Duration can also be converted into a datetime object, but this requires + a start date or an end date. + + The algorithm to add a duration to a date is defined at + http://www.w3.org/TR/xmlschema-2/#adding-durations-to-dateTimes + ''' + + def __init__(self, days=0, seconds=0, microseconds=0, milliseconds=0, + minutes=0, hours=0, weeks=0, months=0, years=0): + ''' + Initialise this Duration instance with the given parameters. + ''' + self.months = months + self.years = years + self.tdelta = timedelta(days, seconds, microseconds, milliseconds, + minutes, hours, weeks) + + def __str__(self): + ''' + Return a string representation of this duration similar to timedelta. + ''' + params = [] + if self.years: + params.append('%d years' % self.years) + if self.months: + params.append('%d months' % self.months) + params.append(str(self.tdelta)) + return ', '.join(params) + + def __repr__(self): + ''' + Return a string suitable for repr(x) calls. + ''' + return "%s.%s(%d, %d, %d, years=%d, months=%d)" % ( + self.__class__.__module__, self.__class__.__name__, + self.tdelta.days, self.tdelta.seconds, + self.tdelta.microseconds, self.years, self.months) + + def __add__(self, other): + ''' + Durations can be added with Duration, timedelta, date and datetime + objects. + ''' + if isinstance(other, timedelta): + newduration = Duration(years=self.years, months=self.months) + newduration.tdelta = self.tdelta + other + return newduration + if isinstance(other, Duration): + newduration = Duration(years=self.years+other.years, + months=self.months+other.months) + newduration.tdelta = self.tdelta + other.tdelta + return newduration + if isinstance(other, (date, datetime)): + newmonth = other.month + self.months + carry, newmonth = fquotmod(newmonth, 1, 13) + newyear = other.year + self.years + carry + maxdays = max_days_in_month(newyear, newmonth) + if other.day > maxdays: + newday = maxdays + else: + newday = other.day + newdt = other.replace(year=newyear, month=newmonth, day=newday) + return self.tdelta + newdt + raise TypeError('unsupported operand type(s) for +: %s and %s' % + (self.__class__, other.__class__)) + + + def __radd__(self, other): + ''' + Add durations to timedelta, date and datetime objects. + ''' + if isinstance(other, timedelta): + newduration = Duration(years=self.years, months=self.months) + newduration.tdelta = self.tdelta + other + return newduration + if isinstance(other, (date, datetime)): + newmonth = other.month + self.months + carry, newmonth = fquotmod(newmonth, 1, 13) + newyear = other.year + self.years + carry + maxdays = max_days_in_month(newyear, newmonth) + if other.day > maxdays: + newday = maxdays + else: + newday = other.day + newdt = other.replace(year=newyear, month=newmonth, day=newday) + return newdt + self.tdelta + raise TypeError('unsupported operand type(s) for +: %s and %s' % + (other.__class__, self.__class__)) + + def __sub__(self, other): + ''' + It is possible to subtract Duration and timedelta objects from Duration + objects. + ''' + if isinstance(other, Duration): + newduration = Duration(years=self.years-other.years, + months=self.months-other.months) + newduration.tdelta = self.tdelta - other.tdelta + return newduration + if isinstance(other, timedelta): + newduration = Duration(years=self.years, months=self.months) + newduration.tdelta = self.tdelta - other + return newduration + raise TypeError('unsupported operand type(s) for -: %s and %s' % + (self.__class__, other.__class__)) + + def __rsub__(self, other): + ''' + It is possible to subtract Duration objecs from date, datetime and + timedelta objects. + ''' + #print '__rsub__:', self, other + if isinstance(other, (date, datetime)): + newmonth = other.month - self.months + carry, newmonth = fquotmod(newmonth, 1, 13) + newyear = other.year - self.years + carry + maxdays = max_days_in_month(newyear, newmonth) + if other.day > maxdays: + newday = maxdays + else: + newday = other.day + newdt = other.replace(year=newyear, month=newmonth, day=newday) + return newdt - self.tdelta + if isinstance(other, timedelta): + tmpdur = Duration() + tmpdur.tdelta = other + return tmpdur - self + raise TypeError('unsupported operand type(s) for -: %s and %s' % + (other.__class__, self.__class__)) + + def __eq__(self, other): + ''' + If the years, month part and the timedelta part are both equal, then + the two Durations are considered equal. + ''' + if not isinstance(other, Duration): + return NotImplemented + if ((self.years * 12 + self.months) == + (other.years * 12 + other.months) and self.tdelta == other.tdelta): + return True + return False + + def __ne__(self, other): + ''' + If the years, month part or the timedelta part is not equal, then + the two Durations are considered not equal. + ''' + if not isinstance(other, Duration): + return NotImplemented + if ((self.years * 12 + self.months) != + (other.years * 12 + other.months) or self.tdelta != other.tdelta): + return True + return False + + def todatetime(self, start=None, end=None): + ''' + Convert this duration into a timedelta object. + + This method requires a start datetime or end datetimem, but raises + an exception if both are given. + ''' + if start is None and end is None: + raise ValueError("start or end required") + if start is not None and end is not None: + raise ValueError("only start or end allowed") + if start is not None: + return (start + self) - start + return end - (end - self) diff --git a/src/isodate/isoerror.py b/src/isodate/isoerror.py new file mode 100644 index 0000000..b690bfe --- /dev/null +++ b/src/isodate/isoerror.py @@ -0,0 +1,32 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +This module defines all exception classes in the whole package. +''' + +class ISO8601Error(Exception): + '''Raised when the given ISO string can not be parsed.''' diff --git a/src/isodate/isotime.py b/src/isodate/isotime.py new file mode 100644 index 0000000..563d668 --- /dev/null +++ b/src/isodate/isotime.py @@ -0,0 +1,152 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +This modules provides a method to parse an ISO 8601:2004 time string to a +Python datetime.time instance. + +It supports all basic and extended formats including time zone specifications +as described in the ISO standard. +''' +import re +import math +from datetime import time + +from isodate.isoerror import ISO8601Error +from isodate.tzinfo import UTC, FixedOffset + +TIME_REGEX_CACHE = [] +# used to cache regular expressions to parse ISO time strings. + +def build_time_regexps(): + ''' + Build regular expressions to parse ISO time string. + + The regular expressions are compiled and stored in TIME_REGEX_CACHE + for later reuse. + ''' + if not TIME_REGEX_CACHE: + # ISO 8601 time representations allow decimal fractions on least + # significant time component. Command and Full Stop are both valid + # fraction separators. + # The letter 'T' is allowed as time designator in front of a time + # expression. + # Immediately after a time expression, a time zone definition is + # allowed. + # a TZ may be missing (local time), be a 'Z' for UTC or a string of + # +-hh:mm where the ':mm' part can be skipped. + # TZ information patterns: + # '' + # Z + # +-hh:mm + # +-hhmm + # +-hh => + tz_regex = r"(?P<tz>Z|(?P<tzh>[+-][0-9]{2})(:?(?P<tzm>[0-9]{2})?))?" + # 1. complete time: + # hh:mm:ss.ss ... extended format + TIME_REGEX_CACHE.append(re.compile(r"T?(?P<hour>[0-9]{2}):" + r"(?P<minute>[0-9]{2}):" + r"(?P<second>[0-9]{2}([,.][0-9]+)?)" + + tz_regex)) + # hhmmss.ss ... basic format + TIME_REGEX_CACHE.append(re.compile(r"T?(?P<hour>[0-9]{2})" + r"(?P<minute>[0-9]{2})" + r"(?P<second>[0-9]{2}([,.][0-9]+)?)" + + tz_regex)) + # 2. reduced accuracy: + # hh:mm.mm ... extended format + TIME_REGEX_CACHE.append(re.compile(r"T?(?P<hour>[0-9]{2}):" + r"(?P<minute>[0-9]{2}([,.][0-9]+)?)" + + tz_regex)) + # hhmm.mm ... basic format + TIME_REGEX_CACHE.append(re.compile(r"T?(?P<hour>[0-9]{2})" + r"(?P<minute>[0-9]{2}([,.][0-9]+)?)" + + tz_regex)) + # hh.hh ... basic format + TIME_REGEX_CACHE.append(re.compile(r"T?(?P<hour>[0-9]{2}([,.][0-9]+)?)" + + tz_regex)) + return TIME_REGEX_CACHE + +def parse_time(timestring): + ''' + Parses ISO 8601 times into datetime.time objects. + + Following ISO 8601 formats are supported: + (as decimal separator a ',' or a '.' is allowed) + hhmmss.ssTZD basic complete time + hh:mm:ss.ssTZD extended compelte time + hhmm.mmTZD basic reduced accuracy time + hh:mm.mmTZD extended reduced accuracy time + hh.hhTZD basic reduced accuracy time + TZD is the time zone designator which can be in the following format: + no designator indicates local time zone + Z UTC + +-hhmm basic hours and minutes + +-hh:mm extended hours and minutes + +-hh hours + ''' + isotimes = build_time_regexps() + for pattern in isotimes: + match = pattern.match(timestring) + if match: + groups = match.groupdict() + for key, value in groups.items(): + if value is not None: + groups[key] = value.replace(',', '.') + if groups['tz'] is not None: + if groups['tz'] == 'Z': + tzinfo = UTC + else: + if groups['tzh'].startswith('-'): + tzinfo = FixedOffset(int(groups['tzh']), + -int(groups['tzm'] or 0), + groups['tz']) + else: + tzinfo = FixedOffset(int(groups['tzh']), + int(groups['tzm'] or 0), + groups['tz']) + else: + tzinfo = None + if 'second' in groups: + frac, second = math.modf(float(groups['second'])) + microsecond = frac * 1e6 + return time(int(groups['hour']), int(groups['minute']), + int(second), int(microsecond), tzinfo) + if 'minute' in groups: + frac, minute = math.modf(float(groups['minute'])) + frac, second = math.modf(frac * 60.0) + microsecond = frac * 1e6 + return time(int(groups['hour']), int(minute), int(second), + int(microsecond), tzinfo) + else: + microsecond, second, minute = 0, 0, 0 + frac, hour = math.modf(float(groups['hour'])) + frac, minute = math.modf(frac * 60.0) + frac, second = math.modf(frac * 60.0) + microsecond = frac * 1e6 + return time(int(hour), int(minute), int(second), int(microsecond), + tzinfo) + raise ISO8601Error('Unrecognised ISO 8601 time format: %r' % timestring) diff --git a/src/isodate/tzinfo.py b/src/isodate/tzinfo.py new file mode 100644 index 0000000..03b3a83 --- /dev/null +++ b/src/isodate/tzinfo.py @@ -0,0 +1,137 @@ +''' +This module provides some datetime.tzinfo implementations. + +All those classes are taken from the Python documentation. +''' +from datetime import timedelta, tzinfo +import time + +ZERO = timedelta(0) +# constant for zero time offset. + +class Utc(tzinfo): + '''UTC + + Universal time coordinated time zone. + ''' + + def utcoffset(self, dt): + ''' + Return offset from UTC in minutes east of UTC, which is ZERO for UTC. + ''' + return ZERO + + def tzname(self, dt): + ''' + Return the time zone name corresponding to the datetime object dt, as a string. + ''' + return "UTC" + + def dst(self, dt): + ''' + Return the daylight saving time (DST) adjustment, in minutes east of UTC. + ''' + return ZERO + +UTC = Utc() +# the default instance for UTC. + +class FixedOffset(tzinfo): + ''' + A class building tzinfo objects for fixed-offset time zones. + + Note that FixedOffset(0, "UTC") is a different way to build a + UTC tzinfo object. + ''' + + def __init__(self, offset_hours, offset_minutes, name): + ''' + Initialise an instance with time offset and name. + The time offset should be positive for time zones east of UTC + and negate for time zones west of UTC. + ''' + self.__offset = timedelta(hours=offset_hours, minutes=offset_minutes) + self.__name = name + + def utcoffset(self, dt): + ''' + Return offset from UTC in minutes of UTC. + ''' + return self.__offset + + def tzname(self, dt): + ''' + Return the time zone name corresponding to the datetime object dt, as a + string. + ''' + return self.__name + + def dst(self, dt): + ''' + Return the daylight saving time (DST) adjustment, in minutes east of + UTC. + ''' + return ZERO + + def __repr__(self): + ''' + Return nicely formatted repr string. + ''' + return "<FixedOffset %r>" % self.__name + + +STDOFFSET = timedelta(seconds = -time.timezone) +# locale time zone offset + +# calculate local daylight saving offset if any. +if time.daylight: + DSTOFFSET = timedelta(seconds = -time.altzone) +else: + DSTOFFSET = STDOFFSET + +DSTDIFF = DSTOFFSET - STDOFFSET +# difference between local time zone and local DST time zone + +class LocalTimezone(tzinfo): + ''' + A class capturing the platform's idea of local time. + ''' + + def utcoffset(self, dt): + ''' + Return offset from UTC in minutes of UTC. + ''' + if self._isdst(dt): + return DSTOFFSET + else: + return STDOFFSET + + def dst(self, dt): + ''' + Return daylight saving offset. + ''' + if self._isdst(dt): + return DSTDIFF + else: + return ZERO + + def tzname(self, dt): + ''' + Return the time zone name corresponding to the datetime object dt, as a + string. + ''' + return time.tzname[self._isdst(dt)] + + def _isdst(self, dt): + ''' + Returns true if DST is active for given datetime object dt. + ''' + tt = (dt.year, dt.month, dt.day, + dt.hour, dt.minute, dt.second, + dt.weekday(), 0, -1) + stamp = time.mktime(tt) + tt = time.localtime(stamp) + return tt.tm_isdst > 0 + +LOCAL = LocalTimezone() +# the default instance for local time zone. diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..b9783f8 --- /dev/null +++ b/src/tests/__init__.py @@ -0,0 +1,45 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +Collect all test suites into one TestSuite instance. +''' + +import unittest +from tests import test_date, test_time, test_datetime, test_duration + +def test_suite(): + ''' + Return a new TestSuite instance consisting of all available TestSuites. + ''' + return unittest.TestSuite([ + test_date.test_suite(), + test_time.test_suite(), + test_datetime.test_suite(), + test_duration.test_suite()]) + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite') diff --git a/src/tests/test_date.py b/src/tests/test_date.py new file mode 100644 index 0000000..238f8fc --- /dev/null +++ b/src/tests/test_date.py @@ -0,0 +1,102 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +Test cases for the isodate module. +''' +import unittest +from datetime import date +from isodate import parse_date, ISO8601Error + +# the following list contains tuples of ISO date strings and the expected +# result from the parse_date method. A result of None means an ISO8601Error +# is expected. The test cases are grouped into dates with 4 digit years +# and 6 digit years. +TEST_CASES = {4: [('19', date(1901, 1, 1)), + ('1985', date(1985, 1, 1)), + ('1985-04', date(1985, 4, 1)), + ('1985-04-12', date(1985, 4, 12)), + ('19850412', date(1985, 4, 12)), + ('1985102', date(1985, 4, 12)), + ('1985-102', date(1985, 4, 12)), + ('1985W155', date(1985, 4, 12)), + ('1985-W15-5', date(1985, 4, 12)), + ('1985W15', date(1985, 4, 8)), + ('1985-W15', date(1985, 4, 8)), + ('1989-W15', date(1989, 4, 10)), + ('1989-W15-5', date(1989, 4, 14)), + ('1-W1-1', None)], + 6: [('+0019', date(1901, 1, 1)), + ('+001985', date(1985, 1, 1)), + ('+001985-04', date(1985, 4, 1)), + ('+001985-04-12', date(1985, 4, 12)), + ('+0019850412', date(1985, 4, 12)), + ('+001985102', date(1985, 4, 12)), + ('+001985-102', date(1985, 4, 12)), + ('+001985W155', date(1985, 4, 12)), + ('+001985-W15-5', date(1985, 4, 12)), + ('+001985W15', date(1985, 4, 8)), + ('+001985-W15', date(1985, 4, 8))]} + +def create_testcase(datestring, yeardigits, expectation): + ''' + Create a TestCase class for a specific test. + + This allows having a separate TestCase for each test tuple from the + TEST_CASES list, so that a failed test won't stop other tests. + ''' + + class TestDate(unittest.TestCase): + ''' + A test case template to parse an ISO date string into a date + object. + ''' + + def test_parse(self): + ''' + Parse an ISO date string and compare it to the expected value. + ''' + if expectation is None: + self.assertRaises(ISO8601Error, parse_date, datestring, + yeardigits) + else: + result = parse_date(datestring, yeardigits) + self.assertEqual(result, expectation) + + return unittest.TestLoader().loadTestsFromTestCase(TestDate) + +def test_suite(): + ''' + Construct a TestSuite instance for all test cases. + ''' + suite = unittest.TestSuite() + for yeardigits, tests in TEST_CASES.items(): + for datestring, expectation in tests: + suite.addTest(create_testcase(datestring, yeardigits, expectation)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite') diff --git a/src/tests/test_datetime.py b/src/tests/test_datetime.py new file mode 100644 index 0000000..98daaba --- /dev/null +++ b/src/tests/test_datetime.py @@ -0,0 +1,82 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +Test cases for the isodatetime module. +''' +import unittest +from datetime import datetime + +from isodate import parse_datetime, UTC, FixedOffset + +# the following list contains tuples of ISO datetime strings and the expected +# result from the parse_datetime method. A result of None means an ISO8601Error +# is expected. +TEST_CASES = [('19850412T1015', datetime(1985, 4, 12, 10, 15)), + ('1985-04-12T10:15', datetime(1985, 4, 12, 10, 15)), + ('1985102T1015Z', datetime(1985, 4, 12, 10, 15, tzinfo=UTC)), + ('1985-102T10:15Z', datetime(1985, 4, 12, 10, 15, tzinfo=UTC)), + ('1985W155T1015+0400', datetime(1985, 4, 12, 10, 15, + tzinfo=FixedOffset(4, 0, + '+0400'))), + ('1985-W15-5T10:15+04', datetime(1985, 4, 12, 10, 15, + tzinfo=FixedOffset(4, 0, + '+0400')))] + +def create_testcase(datetimestring, expectation): + ''' + Create a TestCase class for a specific test. + + This allows having a separate TestCase for each test tuple from the + TEST_CASES list, so that a failed test won't stop other tests. + ''' + + class TestDateTime(unittest.TestCase): + ''' + A test case template to parse an ISO datetime string into a + datetime object. + ''' + + def test_parse(self): + ''' + Parse an ISO datetime string and compare it to the expected value. + ''' + result = parse_datetime(datetimestring) + self.assertEqual(result, expectation) + + return unittest.TestLoader().loadTestsFromTestCase(TestDateTime) + +def test_suite(): + ''' + Construct a TestSuite instance for all test cases. + ''' + suite = unittest.TestSuite() + for datetimestring, expectation in TEST_CASES: + suite.addTest(create_testcase(datetimestring, expectation)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite') diff --git a/src/tests/test_duration.py b/src/tests/test_duration.py new file mode 100644 index 0000000..e95008e --- /dev/null +++ b/src/tests/test_duration.py @@ -0,0 +1,409 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +Test cases for the isoduration module. +''' +import unittest +import operator +from datetime import timedelta, date, datetime + +from isodate import Duration, parse_duration, ISO8601Error + +# the following list contains tuples of ISO duration strings and the expected +# result from the parse_duration method. A result of None means an ISO8601Error +# is expected. +PARSE_TEST_CASES = {'P18Y9M4DT11H9M8S': Duration(4, 8, 0, 0, 9, 11, 0, 9, 18), + 'P2W': timedelta(weeks = 2), + 'P3Y6M4DT12H30M5S':Duration(4, 5, 0, 0, 30, 12, 0, 6, 3), + 'P23DT23H': timedelta(hours=23, days=23), + 'P4Y': Duration(years=4), + 'P1M': Duration(months=1), + 'PT1M': timedelta(minutes=1), + 'P0.5Y': Duration(years=0.5), # ????????? + 'PT36H': timedelta(hours=36), + 'P1DT12H': timedelta(days=1, hours=12), + '+P11D': timedelta(days=11), + '-P2W': timedelta(weeks=-2), + '-P2.2W': timedelta(weeks=-2.2), + 'P1DT2H3M4S': timedelta(days=1, hours=2, minutes=3, + seconds=4), + 'P1DT2H3M': timedelta(days=1, hours=2, minutes=3), + 'P1DT2H': timedelta(days=1, hours=2), + 'PT2H': timedelta(hours=2), + 'PT2.3H': timedelta(hours=2.3), + 'PT2H3M4S': timedelta(hours=2, minutes=3, seconds=4), + 'PT3M4S': timedelta(minutes=3, seconds=4), + 'PT22S': timedelta(seconds=22), + 'PT22.22S': timedelta(seconds=22.22), + '-P2Y': Duration(years=-2), + '-P3Y6M4DT12H30M5S': Duration(-4, -5, 0, 0, -30, -12, 0, + -6, -3), + '-P1DT2H3M4S': timedelta(days=-1, hours=-2, minutes=-3, + seconds=-4), + # alternative format + 'P0018-09-04T11:09:08': Duration(4, 8, 0, 0, 9, 11, 0, 9, + 18), + #'PT000022.22': timedelta(seconds=22.22), + } + +# d1 d2 '+', '-', '>' +# A list of test cases to test addition and subtraction between datetime and +# Duration objects. +# each tuple contains 2 duration strings, and a result string for addition and +# one for subtraction. The last value says, if the first duration is greater +# than the second. +MATH_TEST_CASES = (('P5Y7M1DT9H45M16.72S', 'PT27M24.68S', + 'P5Y7M1DT10H12M41.4S', 'P5Y7M1DT9H17M52.04S', None), + ('PT28M12.73S', 'PT56M29.92S', + 'PT1H24M42.65S', '-PT28M17.19S', False), + ('P3Y7M23DT5H25M0.33S', 'PT1H1.95S', + 'P3Y7M23DT6H25M2.28S', 'P3Y7M23DT4H24M58.38S', None), + ('PT1H1.95S', 'P3Y7M23DT5H25M0.33S', + 'P3Y7M23DT6H25M2.28S', '-P3Y7M23DT4H24M58.38S', None), + ('P1332DT55M0.33S', 'PT1H1.95S', + 'P1332DT1H55M2.28S', 'P1331DT23H54M58.38S', True), + ('PT1H1.95S', 'P1332DT55M0.33S', + 'P1332DT1H55M2.28S', '-P1331DT23H54M58.38S', False)) + +# A list of test cases to test addition and subtraction of date/datetime +# and Duration objects. They are tested against the results of an +# equal long timedelta duration. +DATE_TEST_CASES = ( (date(2008, 2, 29), + timedelta(days=10, hours=12, minutes=20), + Duration(days=10, hours=12, minutes=20)), + (date(2008, 1, 31), + timedelta(days=10, hours=12, minutes=20), + Duration(days=10, hours=12, minutes=20)), + (datetime(2008, 2, 29), + timedelta(days=10, hours=12, minutes=20), + Duration(days=10, hours=12, minutes=20)), + (datetime(2008, 1, 31), + timedelta(days=10, hours=12, minutes=20), + Duration(days=10, hours=12, minutes=20)), + (datetime(2008, 4, 21), + timedelta(days=10, hours=12, minutes=20), + Duration(days=10, hours=12, minutes=20)), + (datetime(2008, 5, 5), + timedelta(days=10, hours=12, minutes=20), + Duration(days=10, hours=12, minutes=20)), + (datetime(2000, 1, 1), + timedelta(hours=-33), + Duration(hours=-33)), + (datetime(2008, 5, 5), + Duration(years=1, months=1, days=10, hours=12, + minutes=20), + Duration(months=13, days=10, hours=12, minutes=20)), + (datetime(2000, 3, 30), + Duration(years=1, months=1, days=10, hours=12, + minutes=20), + Duration(months=13, days=10, hours=12, minutes=20)), + ) + +# A list of test cases of additon of date/datetime and Duration. The results +# are compared against a given expected result. +DATE_CALC_TEST_CASES = ( + (date(2000, 2, 1), + Duration(years=1, months=1), + date(2001, 3, 1)), + (date(2000, 2, 29), + Duration(years=1, months=1), + date(2001, 3, 29)), + (date(2000, 2, 29), + Duration(years=1), + date(2001, 2, 28)), + (date(1996, 2, 29), + Duration(years=4), + date(2000, 2, 29)), + (date(2096, 2, 29), + Duration(years=4), + date(2100, 2, 28)), + (date(2000, 2, 1), + Duration(years=-1, months=-1), + date(1999, 1, 1)), + (date(2000, 2, 29), + Duration(years=-1, months=-1), + date(1999, 1, 29)), + (date(2000, 2, 1), + Duration(years=1, months=1, days=1), + date(2001, 3, 2)), + (date(2000, 2, 29), + Duration(years=1, months=1, days=1), + date(2001, 3, 30)), + (date(2000, 2, 29), + Duration(years=1, days=1), + date(2001, 3, 1)), + (date(1996, 2, 29), + Duration(years=4, days=1), + date(2000, 3, 1)), + (date(2096, 2, 29), + Duration(years=4, days=1), + date(2100, 3, 1)), + (date(2000, 2, 1), + Duration(years=-1, months=-1, days=-1), + date(1998, 12, 31)), + (date(2000, 2, 29), + Duration(years=-1, months=-1, days=-1), + date(1999, 1, 28)), + (date(2001, 4, 1), + Duration(years=-1, months=-1, days=-1), + date(2000, 2, 29)), + (date(2000, 4, 1), + Duration(years=-1, months=-1, days=-1), + date(1999, 2, 28)), + (Duration(years=1, months=2), + Duration(years=0, months=0, days=1), + Duration(years=1, months=2, days=1)), + (Duration(years=-1, months=-1, days=-1), + date(2000, 4, 1), + date(1999, 2, 28)), + (Duration(years=1, months=1, weeks=5), + date(2000, 1, 30), + date(2001, 4, 4)), + (Duration(years=1, months=1, weeks=5), + 'raise exception', + None), + ('raise exception', + Duration(years=1, months=1, weeks=5), + None), + (Duration(years=1, months=2), + timedelta(days=1), + Duration(years=1, months=2, days=1)), + (timedelta(days=1), + Duration(years=1, months=2), + Duration(years=1, months=2, days=1)), + #(date(2000, 1, 1), + # Duration(years=1.5), + # date(2001, 6, 1)), + #(date(2000, 1, 1), + # Duration(years=1, months=1.5), + # date(2001, 2, 14)), + ) + +class DurationTest(unittest.TestCase): + ''' + This class tests various other aspects of the isoduration module, + which are not covered with the test cases listed above. + ''' + + def test_associative(self): + ''' + Adding 2 durations to a date is not associative. + ''' + days1 = Duration(days=1) + months1 = Duration(months=1) + start = date(2000, 3, 30) + res1 = start + days1 + months1 + res2 = start + months1 + days1 + self.assertNotEqual(res1, res2) + + def test_typeerror(self): + ''' + Test if TypError is raised with certain parameters. + ''' + self.assertRaises(TypeError, parse_duration, date(2000, 1, 1)) + self.assertRaises(TypeError, operator.sub, Duration(years=1), + date(2000, 1, 1)) + self.assertRaises(TypeError, operator.sub, 'raise exc', + Duration(years=1)) + + def test_parseerror(self): + ''' + Test for unparseable duration string. + ''' + self.assertRaises(ISO8601Error, parse_duration, 'T10:10:10') + + def test_repr(self): + ''' + Test __repr__ and __str__ for Duration obects. + ''' + dur = Duration(10, 10, years=10, months=10) + self.assertEqual('10 years, 10 months, 10 days, 0:00:10', str(dur)) + self.assertEqual('isodate.isoduration.Duration(10, 10, 0,' + ' years=10, months=10)', repr(dur)) + + def test_equal(self): + ''' + Test __eq__ and __ne__ methods. + ''' + self.assertEqual(Duration(years=1, months=1), + Duration(years=1, months=1)) + self.assertEqual(Duration(years=1, months=1), Duration(months=13)) + self.assertNotEqual(Duration(years=1, months=2), + Duration(years=1, months=1)) + self.assertNotEqual(Duration(years=1, months=1), Duration(months=14)) + self.assertNotEqual(Duration(years=1), timedelta(days=365)) + self.assertFalse(Duration(years=1, months=1) != + Duration(years=1, months=1)) + self.assertFalse(Duration(years=1, months=1) != Duration(months=13)) + self.assertTrue(Duration(years=1, months=2) != + Duration(years=1, months=1)) + self.assertTrue(Duration(years=1, months=1) != Duration(months=14)) + self.assertTrue(Duration(years=1) != timedelta(days=365)) + +def create_parsetestcase(durationstring, expectation): + ''' + Create a TestCase class for a specific test. + + This allows having a separate TestCase for each test tuple from the + PARSE_TEST_CASES list, so that a failed test won't stop other tests. + ''' + + class TestParseDuration(unittest.TestCase): + ''' + A test case template to parse an ISO duration string into a + timedelta or Duration object. + ''' + + def test_parse(self): + ''' + Parse an ISO duration string and compare it to the expected value. + ''' + result = parse_duration(durationstring) + self.assertEqual(result, expectation) + + return unittest.TestLoader().loadTestsFromTestCase(TestParseDuration) + +def create_mathtestcase(dur1, dur2, resadd, ressub, resge): + ''' + Create a TestCase class for a specific test. + + This allows having a separate TestCase for each test tuple from the + MATH_TEST_CASES list, so that a failed test won't stop other tests. + ''' + + dur1 = parse_duration(dur1) + dur2 = parse_duration(dur2) + resadd = parse_duration(resadd) + ressub = parse_duration(ressub) + + class TestMathDuration(unittest.TestCase): + ''' + A test case template test addition, subtraction and > + operators for Duration objects. + ''' + + def test_add(self): + ''' + Test operator + (__add__, __radd__) + ''' + self.assertEqual(dur1 + dur2, resadd) + + def test_sub(self): + ''' + Test operator - (__sub__, __rsub__) + ''' + self.assertEqual(dur1 - dur2, ressub) + + def test_ge(self): + ''' + Test operator > and < + ''' + def dogetest(): + ''' Test greater than.''' + return dur1 > dur2 + def doletest(): + ''' Test less than.''' + return dur1 < dur2 + if resge is None: + self.assertRaises(TypeError, dogetest) + self.assertRaises(TypeError, doletest) + else: + self.assertEqual(dogetest(), resge) + self.assertEqual(doletest(), not resge) + + return unittest.TestLoader().loadTestsFromTestCase(TestMathDuration) + +def create_datetestcase(start, tdelta, duration): + ''' + Create a TestCase class for a specific test. + + This allows having a separate TestCase for each test tuple from the + DATE_TEST_CASES list, so that a failed test won't stop other tests. + ''' + + class TestDateCalc(unittest.TestCase): + ''' + A test case template test addition, subtraction + operators for Duration objects. + ''' + + def test_add(self): + ''' + Test operator +. + ''' + self.assertEqual(start + tdelta, start + duration) + + def test_sub(self): + ''' + Test operator -. + ''' + self.assertEqual(start - tdelta, start - duration) + + return unittest.TestLoader().loadTestsFromTestCase(TestDateCalc) + +def create_datecalctestcase(start, duration, expectation): + ''' + Create a TestCase class for a specific test. + + This allows having a separate TestCase for each test tuple from the + DATE_CALC_TEST_CASES list, so that a failed test won't stop other tests. + ''' + + class TestDateCalc(unittest.TestCase): + ''' + A test case template test addition operators for Duration objects. + ''' + + def test_calc(self): + ''' + Test operator +. + ''' + if expectation is None: + self.assertRaises(TypeError, operator.add, start, duration) + else: + self.assertEqual(start + duration, expectation) + + return unittest.TestLoader().loadTestsFromTestCase(TestDateCalc) + +def test_suite(): + ''' + Return a test suite containing all test defined above. + ''' + suite = unittest.TestSuite() + for durationstring, expectation in PARSE_TEST_CASES.items(): + suite.addTest(create_parsetestcase(durationstring, expectation)) + for testdata in MATH_TEST_CASES: + suite.addTest(create_mathtestcase(*testdata)) + for testdata in DATE_TEST_CASES: + suite.addTest(create_datetestcase(*testdata)) + for testdata in DATE_CALC_TEST_CASES: + suite.addTest(create_datecalctestcase(*testdata)) + suite.addTest(unittest.TestLoader().loadTestsFromTestCase(DurationTest)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite') diff --git a/src/tests/test_time.py b/src/tests/test_time.py new file mode 100644 index 0000000..a1f8f4e --- /dev/null +++ b/src/tests/test_time.py @@ -0,0 +1,105 @@ +############################################################################## +# Copyright 2009, Gerhard Weis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT +############################################################################## +''' +Test cases for the isotime module. +''' +import unittest +from datetime import time +from isodate import parse_time, UTC, FixedOffset, ISO8601Error + +# the following list contains tuples of ISO time strings and the expected +# result from the parse_time method. A result of None means an ISO8601Error +# is expected. +TEST_CASES = [('232050', time(23, 20, 50)), + ('23:20:50', time(23, 20, 50)), + ('2320', time(23, 20)), + ('23:20', time(23, 20)), + ('23', time(23)), + ('232050,5', time(23, 20, 50, 500000)), + ('23:20:50.5', time(23, 20, 50, 500000)), + ('2320,8', time(23, 20, 48)), + ('23:20,8', time(23, 20, 48)), + ('23,3', time(23, 18)), + ('232030Z', time(23, 20, 30, tzinfo=UTC)), + ('2320Z', time(23, 20, tzinfo=UTC)), + ('23Z', time(23, tzinfo=UTC)), + ('23:20:30Z', time(23, 20, 30, tzinfo=UTC)), + ('23:20Z', time(23, 20, tzinfo=UTC)), + ('152746+0100', time(14, 27, 46, tzinfo=UTC)), + ('152746-0500', time(20, 27, 46, tzinfo=UTC)), + ('152746+01', time(15, 27, 46, + tzinfo=FixedOffset(1, 0, '+01:00'))), + ('152746-05', time(15, 27, 46, + tzinfo=FixedOffset(-5, -0, '-05:00'))), + ('15:27:46+01:00', time(15, 27, 46, + tzinfo=FixedOffset(1, 0, '+01:00'))), + ('15:27:46-05:00', time(15, 27, 46, + tzinfo=FixedOffset(-5, -0, '-05:00'))), + ('15:27:46+01', time(15, 27, 46, + tzinfo=FixedOffset(1, 0, '+01:00'))), + ('15:27:46-05', time(15, 27, 46, + tzinfo=FixedOffset(-5, -0, '-05:00'))), + ('1:17:30', None)] + + +def create_testcase(timestring, expectation): + ''' + Create a TestCase class for a specific test. + + This allows having a separate TestCase for each test tuple from the + TEST_CASES list, so that a failed test won't stop other tests. + ''' + + class TestTime(unittest.TestCase): + ''' + A test case template to parse an ISO time string into a time + object. + ''' + + def test_parse(self): + ''' + Parse an ISO time string and compare it to the expected value. + ''' + if expectation is None: + self.assertRaises(ISO8601Error, parse_time, timestring) + else: + result = parse_time(timestring) + self.assertEqual(result, expectation) + + return unittest.TestLoader().loadTestsFromTestCase(TestTime) + +def test_suite(): + ''' + Construct a TestSuite instance for all test cases. + ''' + suite = unittest.TestSuite() + for timestring, expectation in TEST_CASES: + suite.addTest(create_testcase(timestring, expectation)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite') |