diff options
author | Gerhard Weis <gweis@gmx.at> | 2009-02-09 10:25:14 +1000 |
---|---|---|
committer | Gerhard Weis <gweis@gmx.at> | 2009-02-09 10:25:14 +1000 |
commit | 40a99905da4df23a4d68753258c0354abd8ab932 (patch) | |
tree | a826fd5e96346c962319cffb9fd8caa40a6cb353 | |
parent | 44c432ba38078eff35b1ab7cde07fc205e1564d3 (diff) | |
download | isodate-40a99905da4df23a4d68753258c0354abd8ab932.tar.gz |
* added ISO 8601 formating methods
* still problems with negative durations
* refactored Duration class to separate module
* allow access to wrapped timedelta attributes directly on Duration.
* refactored tzinfo parsing and formating into separate module
* updated test cases for new functionality
-rw-r--r-- | .hgignore | 4 | ||||
-rw-r--r-- | CHANGES.txt | 8 | ||||
-rw-r--r-- | README.txt | 37 | ||||
-rw-r--r-- | TODO.txt | 8 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | src/isodate/__init__.py | 30 | ||||
-rw-r--r-- | src/isodate/duration.py | 244 | ||||
-rw-r--r-- | src/isodate/isodates.py | 32 | ||||
-rw-r--r-- | src/isodate/isodatetime.py | 12 | ||||
-rw-r--r-- | src/isodate/isoduration.py | 225 | ||||
-rw-r--r-- | src/isodate/isostrf.py | 196 | ||||
-rw-r--r-- | src/isodate/isotime.py | 41 | ||||
-rw-r--r-- | src/isodate/isotzinfo.py | 108 | ||||
-rw-r--r-- | src/isodate/tzinfo.py | 2 | ||||
-rw-r--r-- | src/tests/test_date.py | 78 | ||||
-rw-r--r-- | src/tests/test_datetime.py | 42 | ||||
-rw-r--r-- | src/tests/test_duration.py | 95 | ||||
-rw-r--r-- | src/tests/test_time.py | 79 |
18 files changed, 888 insertions, 355 deletions
diff --git a/.hgignore b/.hgignore new file mode 100644 index 0000000..97bd802 --- /dev/null +++ b/.hgignore @@ -0,0 +1,4 @@ +syntax: glob +*.pyc +isodate.egg-info +build/ diff --git a/CHANGES.txt b/CHANGES.txt index 949e95e..6b32ae1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,9 +1,17 @@ + CHANGES ======= +0.4.0 (unreleased) +------------------ + +- added method to parse ISO 8601 time zone strings +- added methods to create ISO 8601 conforming strings + 0.3.0 (2009-1-05) ------------------ - Initial release + @@ -11,8 +11,8 @@ 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. +Another example is, when no time zone information is given for a time, +then it should be interpreted as 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 @@ -31,18 +31,46 @@ Currently there are four parsing methods available. 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. + object. + * parse_tzinfo: + parses the time zone info part of an ISO 8601 string into a + *tzinfo* 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. +There are also ISO formating methods for all supported data types. Each +*xxx_isoformat* method accepts a format parameter. The default format is +always the ISO 8601 expanded format. This is the same format used by +*datetime.isoformat*: + * time_isoformat: + Intended to create ISO time strings with default format + *hh:mm:ssZ*. + * date_isoformat: + Intended to create ISO date strings with default format + *yyyy-mm-dd*. + * datetime_isoformat: + Intended to create ISO date-time strings with default format + *yyyy-mm-ddThh:mm:ssZ*. + * duration_isoformat: + Intended to create ISO duration strings with default format + *PnnYnnMnnDTnnHnnMnnS*. + * tz_isoformat: + Intended to create ISO time zone strings with default format + *hh:mm*. + * strftime: + A re-implementation mostly compatible with Python's *strftime*, but + supports only those format strings, which can also be used for dates + prior 1900. This method also understands how to format *datetime* and + *Duration* instances. + Installation: ------------- This module can easily be installed with Python standard installation methods. -Just use *setuptools* or *easy_instal* as usual. +Just use *setuptools* or *easy_install* as usual. Limitations: ------------ @@ -55,6 +83,7 @@ Limitations: 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. + 3. negative *Duration* and *timedelta* value are not fully supported yet. Further information: -------------------- @@ -1,4 +1,5 @@ + TODOs ===== @@ -9,8 +10,7 @@ not complete. Missing features: ----------------- - * methods to format *date*, *time*, *datetime*, *timedelta* and *Duration* - objects to various ISO strings. + * time formating does not allow to create fractional representations. * parser for ISO intervals. Documentation: @@ -34,5 +34,7 @@ Documentation: - 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?) + - when is a Duration negative? + - normalize Durations. months [00-12] and years ]-inf,+inf[ + - @@ -34,7 +34,7 @@ def read(*rnames): return open(os.path.join(os.path.dirname(__file__), *rnames)).read() setup(name = 'isodate', - version = '0.3.0', + version = '0.4.0', packages = find_packages('src', exclude=["tests"]), package_dir={'': 'src'}, diff --git a/src/isodate/__init__.py b/src/isodate/__init__.py index 4e119dd..091af0a 100644 --- a/src/isodate/__init__.py +++ b/src/isodate/__init__.py @@ -26,12 +26,30 @@ ############################################################################## ''' Import all essential functions and constants to re-export them here for easy -access. -''' +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 +This module contains also various pre-defined ISO 8601 format strings. +''' +from isodate.isodates import parse_date, date_isoformat +from isodate.isotime import parse_time, time_isoformat +from isodate.isodatetime import parse_datetime, datetime_isoformat +from isodate.isoduration import parse_duration, duration_isoformat, Duration from isodate.isoerror import ISO8601Error +from isodate.isotzinfo import parse_tzinfo, tz_isoformat from isodate.tzinfo import UTC, FixedOffset, LOCAL +from isodate.duration import Duration +from isodate.isostrf import strftime +from isodate.isostrf import DATE_BAS_COMPLETE, DATE_BAS_ORD_COMPLETE +from isodate.isostrf import DATE_BAS_WEEK, DATE_BAS_WEEK_COMPLETE +from isodate.isostrf import DATE_CENTURY, DATE_EXT_COMPLETE +from isodate.isostrf import DATE_EXT_ORD_COMPLETE, DATE_EXT_WEEK +from isodate.isostrf import DATE_EXT_WEEK_COMPLETE, DATE_MONTH, DATE_YEAR +from isodate.isostrf import TIME_BAS_COMPLETE, TIME_BAS_MINUTE +from isodate.isostrf import TIME_EXT_COMPLETE, TIME_EXT_MINUTE +from isodate.isostrf import TIME_HOUR +from isodate.isostrf import TZ_BAS, TZ_EXT, TZ_HOUR +from isodate.isostrf import DT_BAS_COMPLETE, DT_EXT_COMPLETE +from isodate.isostrf import DT_BAS_ORD_COMPLETE, DT_EXT_ORD_COMPLETE +from isodate.isostrf import DT_BAS_WEEK_COMPLETE, DT_EXT_WEEK_COMPLETE +from isodate.isostrf import D_DEFAULT, D_WEEK, D_ALT_EXT, D_ALT_BAS +from isodate.isostrf import D_ALT_BAS_ORD, D_ALT_EXT_ORD diff --git a/src/isodate/duration.py b/src/isodate/duration.py new file mode 100644 index 0000000..25d94af --- /dev/null +++ b/src/isodate/duration.py @@ -0,0 +1,244 @@ +############################################################################## +# 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 Duration class. + +The class Duration allows to define durations in years and months and can be +used as limited replacement for timedelta objects. +''' +from datetime import date, datetime, timedelta + +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 __getattr__(self, name): + ''' + Provide direct access to attributes of included timedelta instance. + ''' + return getattr(self.tdelta, name) + + 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/isodates.py b/src/isodate/isodates.py index 136757b..8bafa20 100644 --- a/src/isodate/isodates.py +++ b/src/isodate/isodates.py @@ -35,6 +35,7 @@ implementation, which does not support dates before 0001-01-01. import re from datetime import date, timedelta +from isodate.isostrf import strftime, DATE_EXT_COMPLETE from isodate.isoerror import ISO8601Error DATE_REGEX_CACHE = {} @@ -161,26 +162,22 @@ def parse_date(datestring, yeardigits=4, expanded=False): 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: + # FIXME: negative dates not possible with python standard types + sign = (groups['sign'] == '-' and -1) or 1 + if 'century' in groups: 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: + if 'week' in groups: 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) + # if first week in year, do weeks-1 + return ret + timedelta(weeks=int(groups['week']) - + (((isotuple[1] == 1) and 1) or 0), + days = -isotuple[2] + days) elif 'day' in groups: # ordinal date return ret + timedelta(days=int(groups['day'])-1) else: # year date @@ -190,6 +187,15 @@ def parse_date(datestring, yeardigits=4, expanded=False): day = 1 else: day = int(groups['day']) - return date(sign*int(groups['year']), + return date(sign * int(groups['year']), int(groups['month']) or 1, day) raise ISO8601Error('Unrecognised ISO 8601 date format: %r' % datestring) + +def date_isoformat(tdate, format=DATE_EXT_COMPLETE, yeardigits=4): + ''' + Format date strings. + + This method is just a wrapper around isodate.isostrf.strftime and uses + Date-Extended-Complete as default format. + ''' + return strftime(tdate, format, yeardigits) diff --git a/src/isodate/isodatetime.py b/src/isodate/isodatetime.py index 4cc6fd4..7e4d570 100644 --- a/src/isodate/isodatetime.py +++ b/src/isodate/isodatetime.py @@ -32,6 +32,8 @@ and time module. ''' from datetime import datetime +from isodate.isostrf import strftime +from isodate.isostrf import DATE_EXT_COMPLETE, TIME_EXT_COMPLETE, TZ_EXT from isodate.isodates import parse_date from isodate.isotime import parse_time @@ -47,3 +49,13 @@ def parse_datetime(datetimestring): tmpdate = parse_date(datestring) tmptime = parse_time(timestring) return datetime.combine(tmpdate, tmptime) + +def datetime_isoformat(tdt, format=DATE_EXT_COMPLETE + 'T' + + TIME_EXT_COMPLETE + TZ_EXT): + ''' + Format datetime strings. + + This method is just a wrapper around isodate.isostrf.strftime and uses + Extended-Complete as default format. + ''' + return strftime(tdt, format) diff --git a/src/isodate/isoduration.py b/src/isodate/isoduration.py index ebbd7eb..5002e51 100644 --- a/src/isodate/isoduration.py +++ b/src/isodate/isoduration.py @@ -27,14 +27,16 @@ ''' This module provides an ISO 8601:2004 duration parser. -It also defines a class Duration, which allows to define -durations in years and months. +It also provides a wrapper to strftime. This wrapper makes it easier to +format timedelta or Duration instances as ISO conforming strings. ''' -from datetime import date, datetime, timedelta +from datetime import timedelta import re +from isodate.duration import Duration from isodate.isoerror import ISO8601Error from isodate.isodatetime import parse_datetime +from isodate.isostrf import strftime, D_DEFAULT ISO8601_PERIOD_REGEX = re.compile(r"^(?P<sign>[+-])?" r"P(?P<years>[0-9]+([,.][0-9]+)?Y)?" @@ -117,208 +119,21 @@ def parse_duration(datestring): 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. +def duration_isoformat(tduration, format=D_DEFAULT): ''' - 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. + Format duration strings. - The algorithm to add a duration to a date is defined at - http://www.w3.org/TR/xmlschema-2/#adding-durations-to-dateTimes + This method is just a wrapper around isodate.isostrf.strftime and uses + P%P (D_DEFAULT) as default format. ''' - - 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) + # TODO: implement better decision for negative Durations. + # should be done in Duration class in consistent way with timedelta. + if ((isinstance(tduration, Duration) and (tduration.years < 0 or + tduration.months < 0 or + tduration.tdelta < timedelta(0))) + or (isinstance(tduration, timedelta) and (tduration < timedelta(0)))): + ret = '-' + else: + ret = '' + ret += strftime(tduration, format) + return ret diff --git a/src/isodate/isostrf.py b/src/isodate/isostrf.py new file mode 100644 index 0000000..aeda09f --- /dev/null +++ b/src/isodate/isostrf.py @@ -0,0 +1,196 @@ +############################################################################## +# 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 alternative strftime method. + +The strftime method in this module allows only a subset of Python's strftime +format codes, plus a few additional. It supports the full range of date values +possible with standard Python date/time objects. Furthermore there are several +pr-defined format strings in this module to make ease producing of ISO 8601 +conforming strings. +''' +import re +from datetime import date, timedelta + +from isodate.duration import Duration +from isodate.isotzinfo import tz_isoformat + +# Date specific format strings +DATE_BAS_COMPLETE = '%Y%m%d' +DATE_EXT_COMPLETE = '%Y-%m-%d' +DATE_BAS_WEEK_COMPLETE = '%YW%W%w' +DATE_EXT_WEEK_COMPLETE = '%Y-W%W-%w' +DATE_BAS_ORD_COMPLETE = '%Y%j' +DATE_EXT_ORD_COMPLETE = '%Y-%j' +DATE_BAS_WEEK = '%YW%W' +DATE_EXT_WEEK = '%Y-W%W' +DATE_MONTH = '%Y-%m' +DATE_YEAR = '%Y' +DATE_CENTURY = '%C' + +# Time specific format strings +TIME_BAS_COMPLETE = '%H%M%S' +TIME_EXT_COMPLETE = '%H:%M:%S' +TIME_BAS_MINUTE = '%H%M' +TIME_EXT_MINUTE = '%H:%M' +TIME_HOUR = '%H' + +# Time zone formats +TZ_BAS = '%z' +TZ_EXT = '%Z' +TZ_HOUR = '%h' + +# DateTime formats +DT_EXT_COMPLETE = DATE_EXT_COMPLETE + 'T' + TIME_EXT_COMPLETE + TZ_EXT +DT_BAS_COMPLETE = DATE_BAS_COMPLETE + 'T' + TIME_BAS_COMPLETE + TZ_BAS +DT_EXT_ORD_COMPLETE = DATE_EXT_ORD_COMPLETE + 'T' + TIME_EXT_COMPLETE + TZ_EXT +DT_BAS_ORD_COMPLETE = DATE_BAS_ORD_COMPLETE + 'T' + TIME_BAS_COMPLETE + TZ_BAS +DT_EXT_WEEK_COMPLETE = DATE_EXT_WEEK_COMPLETE + 'T' + TIME_EXT_COMPLETE +\ + TZ_EXT +DT_BAS_WEEK_COMPLETE = DATE_BAS_WEEK_COMPLETE + 'T' + TIME_BAS_COMPLETE +\ + TZ_BAS + +# Duration formts +D_DEFAULT = 'P%P' +D_WEEK = 'P%p' +D_ALT_EXT = 'P' + DATE_EXT_COMPLETE + 'T' + TIME_EXT_COMPLETE +D_ALT_BAS = 'P' + DATE_BAS_COMPLETE + 'T' + TIME_BAS_COMPLETE +D_ALT_EXT_ORD = 'P' + DATE_EXT_ORD_COMPLETE + 'T' + TIME_EXT_COMPLETE +D_ALT_BAS_ORD = 'P' + DATE_BAS_ORD_COMPLETE + 'T' + TIME_BAS_COMPLETE + +STRF_DT_MAP = {'%d': lambda tdt, yds: '%02d' % tdt.day, + '%f': lambda tdt, yds: '%d' % tdt.microseconds, + '%H': lambda tdt, yds: '%02d' % tdt.hour, + '%j': lambda tdt, yds: '%03d' % (tdt.toordinal() - + date(tdt.year, 1, 1).toordinal() + + 1), + '%m': lambda tdt, yds: '%02d' % tdt.month, + '%M': lambda tdt, yds: '%02d' % tdt.minute, + '%S': lambda tdt, yds: '%02d' % tdt.second, + '%w': lambda tdt, yds: '%1d' % tdt.isoweekday(), + '%W': lambda tdt, yds: '%02d' % tdt.isocalendar()[1], + '%Y': lambda tdt, yds: (((yds != 4) and '+') or '') +\ + (('%%0%dd' % yds) % tdt.year), + '%C': lambda tdt, yds: (((yds != 4) and '+') or '') +\ + (('%%0%dd' % (yds - 2)) % (tdt.year / 100)), + '%h': lambda tdt, yds: tz_isoformat(tdt.tzinfo, '%h'), + '%Z': lambda tdt, yds: tz_isoformat(tdt.tzinfo, '%Z'), + '%z': lambda tdt, yds: tz_isoformat(tdt.tzinfo, '%z'), + '%%': lambda tdt, yds: '%'} + +STRF_D_MAP = {'%d': lambda tdt, yds: '%02d' % tdt.days, + '%f': lambda tdt, yds: '%d' % tdt.microseconds, + '%H': lambda tdt, yds: '%02d' % (tdt.seconds / 60 / 60), + '%m': lambda tdt, yds: '%02d' % tdt.months, + '%M': lambda tdt, yds: '%02d' % ((tdt.seconds / 60) % 60), + '%S': lambda tdt, yds: '%02d' % (tdt.seconds % 60), + '%W': lambda tdt, yds: '%02d' % (abs (tdt.days / 7)), + '%Y': lambda tdt, yds: (((yds != 4) and '+') or '') +\ + (('%%0%dd' % yds) % tdt.years), + '%C': lambda tdt, yds: (((yds != 4) and '+') or '') +\ + (('%%0%dd' % (yds - 2)) % (tdt.years / 100)), + '%%': lambda tdt, yds: '%'} + +def _strfduration(tdt, format, yeardigits=4): + ''' + this is the work method for timedelta and Duration instances. + + see strftime for more details. + ''' + def repl(match): + ''' + lookup format command and return corresponding replacement. + ''' + if match.group(0) in STRF_D_MAP: + return STRF_D_MAP[match.group(0)](tdt, yeardigits) + elif match.group(0) == '%P': + ret = '' + if isinstance(tdt, Duration): + if tdt.years: + ret += str(abs(tdt.years)) + 'Y' + if tdt.months: + ret += str(abs(tdt.months)) + 'M' + seconds = abs(tdt.days * 24 * 60 * 60 + tdt.seconds) + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + if days: + ret += str(days) + 'D' + if hours or minutes or seconds: + ret += 'T' + if hours: + ret += str(hours) + 'H' + if minutes: + ret += str(minutes) + 'M' + if seconds: + ret += str(seconds) + 'S' + return ret or '0D' # at least one component has to be there. + elif match.group(0) == '%p': + return str(abs(tdt.days / 7)) + 'W' + return match.group(0) + return re.sub('%d|%f|%H|%m|%M|%S|%W|%Y|%C|%%|%P|%p', repl, + format) + +def _strfdt(tdt, format, yeardigits=4): + ''' + this is the work method for time and date instances. + + see strftime for more details. + ''' + def repl(match): + ''' + lookup format command and return corresponding replacement. + ''' + if match.group(0) in STRF_DT_MAP: + return STRF_DT_MAP[match.group(0)](tdt, yeardigits) + return match.group(0) + return re.sub('%d|%f|%H|%j|%m|%M|%S|%w|%W|%Y|%C|%z|%Z|%h|%%', repl, + format) + +def strftime(tdt, format, yeardigits=4): + ''' + Directive Meaning Notes + %d Day of the month as a decimal number [01,31]. + %f Microsecond as a decimal number [0,999999], zero-padded on the left (1) + %H Hour (24-hour clock) as a decimal number [00,23]. + %j Day of the year as a decimal number [001,366]. + %m Month as a decimal number [01,12]. + %M Minute as a decimal number [00,59]. + %S Second as a decimal number [00,61]. (3) + %w Weekday as a decimal number [0(Monday),6]. + %W Week number of the year (Monday as the first day of the week) as a decimal number [00,53]. All days in a new year preceding the first Monday are considered to be in week 0. (4) + %Y Year with century as a decimal number. [0000,9999] + %C Century as a decimal number. [00,99] + %z UTC offset in the form +HHMM or -HHMM (empty string if the the object is naive). (5) + %Z Time zone name (empty string if the object is naive). + %P ISO8601 duration format. + %p ISO8601 duration format in weeks. + %% A literal '%' character. + ''' + if isinstance(tdt, (timedelta, Duration)): + return _strfduration(tdt, format, yeardigits) + return _strfdt(tdt, format, yeardigits) diff --git a/src/isodate/isotime.py b/src/isodate/isotime.py index 563d668..c677f74 100644 --- a/src/isodate/isotime.py +++ b/src/isodate/isotime.py @@ -35,8 +35,9 @@ import re import math from datetime import time +from isodate.isostrf import strftime, TIME_EXT_COMPLETE, TZ_EXT from isodate.isoerror import ISO8601Error -from isodate.tzinfo import UTC, FixedOffset +from isodate.isotzinfo import TZ_REGEX, build_tzinfo TIME_REGEX_CACHE = [] # used to cache regular expressions to parse ISO time strings. @@ -64,30 +65,30 @@ def build_time_regexps(): # +-hh:mm # +-hhmm # +-hh => - tz_regex = r"(?P<tz>Z|(?P<tzh>[+-][0-9]{2})(:?(?P<tzm>[0-9]{2})?))?" + # isotzinfo.TZ_REGEX # 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)) + + 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)) + + 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)) + + 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)) + + TZ_REGEX)) # hh.hh ... basic format TIME_REGEX_CACHE.append(re.compile(r"T?(?P<hour>[0-9]{2}([,.][0-9]+)?)" - + tz_regex)) + + TZ_REGEX)) return TIME_REGEX_CACHE def parse_time(timestring): @@ -116,20 +117,9 @@ def parse_time(timestring): 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 + tzinfo = build_tzinfo(groups['tzname'], groups['tzsign'], + int(groups['tzhour'] or 0), + int(groups['tzmin'] or 0)) if 'second' in groups: frac, second = math.modf(float(groups['second'])) microsecond = frac * 1e6 @@ -150,3 +140,12 @@ def parse_time(timestring): return time(int(hour), int(minute), int(second), int(microsecond), tzinfo) raise ISO8601Error('Unrecognised ISO 8601 time format: %r' % timestring) + +def time_isoformat(ttime, format=TIME_EXT_COMPLETE + TZ_EXT): + ''' + Format time strings. + + This method is just a wrapper around isodate.isostrf.strftime and uses + Time-Extended-Complete with extended time zone as default format. + ''' + return strftime(ttime, format) diff --git a/src/isodate/isotzinfo.py b/src/isodate/isotzinfo.py new file mode 100644 index 0000000..79797fa --- /dev/null +++ b/src/isodate/isotzinfo.py @@ -0,0 +1,108 @@ +############################################################################## +# 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 time zone info parser. + +It offers a function to parse the time zone offset as specified by ISO 8601. +''' +import re + +from isodate.isoerror import ISO8601Error +from isodate.tzinfo import UTC, FixedOffset, ZERO + +TZ_REGEX = r"(?P<tzname>(Z|(?P<tzsign>[+-])"\ + r"(?P<tzhour>[0-9]{2})(:(?P<tzmin>[0-9]{2}))?)?)" + +TZ_RE = re.compile(TZ_REGEX) + +def build_tzinfo(tzname, tzsign='+', tzhour=0, tzmin=0): + ''' + create a tzinfo instance according to given parameters. + + tzname: + 'Z' ... return UTC + '' | None ... return None + other ... return FixedOffset + ''' + if tzname is None or tzname == '': + return None + if tzname == 'Z': + return UTC + tzsign = ((tzsign == '-') and -1) or 1 + return FixedOffset(tzsign * tzhour, tzsign * tzmin, tzname) + +def parse_tzinfo(tzstring): + ''' + Parses ISO 8601 time zone designators to tzinfo objecs. + + A time zone designator 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 + ''' + match = TZ_RE.match(tzstring) + if match: + groups = match.groupdict() + return build_tzinfo(groups['tzname'], groups['tzsign'], + int(groups['tzhour'] or 0), + int(groups['tzmin'] or 0)) + raise ISO8601Error('%s not a valid time zone info' % tzstring) + +def tz_isoformat(tzinfo, format='%Z'): + ''' + return time zone offset ISO 8601 formatted. + The various ISO formats can be chosen with the format parameter. + + if tzinfo is None returns '' + if tzinfo is UTC returns 'Z' + else the offset is rendered to the given format. + format: + %h ... +-HH + %z ... +-HHMM + %Z ... +-HH:MM + ''' + if (tzinfo is None) or (tzinfo.utcoffset(None) is None): + return '' + if tzinfo.utcoffset(None) == ZERO and tzinfo.dst(None) == ZERO: + return 'Z' + tdelta = tzinfo.utcoffset(None) + seconds = tdelta.days * 24 * 60 * 60 + tdelta.seconds + sign = ((seconds < 0) and '-') or '+' + seconds = abs(seconds) + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + if hours > 99: + raise OverflowError('can not handle differences > 99 hours') + if format == '%Z': + return '%s%02d:%02d' % (sign, hours, minutes) + elif format == '%z': + return '%s%02d%02d' % (sign, hours, minutes) + elif format == '%h': + return '%s%02d' % (sign, hours) + raise AttributeError('unknown format string "%s"' % format) diff --git a/src/isodate/tzinfo.py b/src/isodate/tzinfo.py index 03b3a83..776d905 100644 --- a/src/isodate/tzinfo.py +++ b/src/isodate/tzinfo.py @@ -7,7 +7,7 @@ from datetime import timedelta, tzinfo import time ZERO = timedelta(0) -# constant for zero time offset. +# constant for zero time offset. class Utc(tzinfo): '''UTC diff --git a/src/tests/test_date.py b/src/tests/test_date.py index 238f8fc..fb6ba8c 100644 --- a/src/tests/test_date.py +++ b/src/tests/test_date.py @@ -29,39 +29,44 @@ Test cases for the isodate module. ''' import unittest from datetime import date -from isodate import parse_date, ISO8601Error +from isodate import parse_date, ISO8601Error, date_isoformat +from isodate import DATE_CENTURY, DATE_YEAR, DATE_MONTH +from isodate import DATE_EXT_COMPLETE, DATE_BAS_COMPLETE +from isodate import DATE_BAS_ORD_COMPLETE, DATE_EXT_ORD_COMPLETE +from isodate import DATE_BAS_WEEK, DATE_BAS_WEEK_COMPLETE +from isodate import DATE_EXT_WEEK, DATE_EXT_WEEK_COMPLETE # 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))]} +TEST_CASES = {4: [('19', date(1901, 1, 1), DATE_CENTURY), + ('1985', date(1985, 1, 1), DATE_YEAR), + ('1985-04', date(1985, 4, 1), DATE_MONTH), + ('1985-04-12', date(1985, 4, 12), DATE_EXT_COMPLETE), + ('19850412', date(1985, 4, 12), DATE_BAS_COMPLETE), + ('1985102', date(1985, 4, 12), DATE_BAS_ORD_COMPLETE), + ('1985-102', date(1985, 4, 12), DATE_EXT_ORD_COMPLETE), + ('1985W155', date(1985, 4, 12), DATE_BAS_WEEK_COMPLETE), + ('1985-W15-5', date(1985, 4, 12), DATE_EXT_WEEK_COMPLETE), + ('1985W15', date(1985, 4, 8), DATE_BAS_WEEK), + ('1985-W15', date(1985, 4, 8), DATE_EXT_WEEK), + ('1989-W15', date(1989, 4, 10), DATE_EXT_WEEK), + ('1989-W15-5', date(1989, 4, 14), DATE_EXT_WEEK_COMPLETE), + ('1-W1-1', None, DATE_BAS_WEEK_COMPLETE)], + 6: [('+0019', date(1901, 1, 1), DATE_CENTURY), + ('+001985', date(1985, 1, 1), DATE_YEAR), + ('+001985-04', date(1985, 4, 1), DATE_MONTH), + ('+001985-04-12', date(1985, 4, 12), DATE_EXT_COMPLETE), + ('+0019850412', date(1985, 4, 12), DATE_BAS_COMPLETE), + ('+001985102', date(1985, 4, 12), DATE_BAS_ORD_COMPLETE), + ('+001985-102', date(1985, 4, 12), DATE_EXT_ORD_COMPLETE), + ('+001985W155', date(1985, 4, 12), DATE_BAS_WEEK_COMPLETE), + ('+001985-W15-5', date(1985, 4, 12), DATE_EXT_WEEK_COMPLETE), + ('+001985W15', date(1985, 4, 8), DATE_BAS_WEEK), + ('+001985-W15', date(1985, 4, 8), DATE_EXT_WEEK)]} -def create_testcase(datestring, yeardigits, expectation): +def create_testcase(yeardigits, datestring, expectation, format): ''' Create a TestCase class for a specific test. @@ -85,6 +90,20 @@ def create_testcase(datestring, yeardigits, expectation): else: result = parse_date(datestring, yeardigits) self.assertEqual(result, expectation) + + def test_format(self): + ''' + Take date object and create ISO string from it. + This is the reverse test to test_parse. + ''' + if expectation is None: + self.assertRaises(AttributeError, + date_isoformat, expectation, format, + yeardigits) + else: + self.assertEqual(date_isoformat(expectation, format, + yeardigits), + datestring) return unittest.TestLoader().loadTestsFromTestCase(TestDate) @@ -94,8 +113,9 @@ def test_suite(): ''' suite = unittest.TestSuite() for yeardigits, tests in TEST_CASES.items(): - for datestring, expectation in tests: - suite.addTest(create_testcase(datestring, yeardigits, expectation)) + for datestring, expectation, format in tests: + suite.addTest(create_testcase(yeardigits, datestring, + expectation, format)) return suite if __name__ == '__main__': diff --git a/src/tests/test_datetime.py b/src/tests/test_datetime.py index 98daaba..842f312 100644 --- a/src/tests/test_datetime.py +++ b/src/tests/test_datetime.py @@ -30,23 +30,33 @@ Test cases for the isodatetime module. import unittest from datetime import datetime -from isodate import parse_datetime, UTC, FixedOffset +from isodate import parse_datetime, UTC, FixedOffset, datetime_isoformat +from isodate import DATE_BAS_COMPLETE, TIME_BAS_MINUTE, TZ_BAS +from isodate import DATE_EXT_COMPLETE, TIME_EXT_MINUTE, TZ_EXT, TZ_HOUR +from isodate import DATE_BAS_ORD_COMPLETE, DATE_EXT_ORD_COMPLETE +from isodate import DATE_BAS_WEEK_COMPLETE, DATE_EXT_WEEK_COMPLETE # 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)), +TEST_CASES = [('19850412T1015', datetime(1985, 4, 12, 10, 15), + DATE_BAS_COMPLETE + 'T' + TIME_BAS_MINUTE), + ('1985-04-12T10:15', datetime(1985, 4, 12, 10, 15), + DATE_EXT_COMPLETE + 'T' + TIME_EXT_MINUTE), + ('1985102T1015Z', datetime(1985, 4, 12, 10, 15, tzinfo=UTC), + DATE_BAS_ORD_COMPLETE + 'T' + TIME_BAS_MINUTE + TZ_BAS), + ('1985-102T10:15Z', datetime(1985, 4, 12, 10, 15, tzinfo=UTC), + DATE_EXT_ORD_COMPLETE + 'T' + TIME_EXT_MINUTE + TZ_EXT), ('1985W155T1015+0400', datetime(1985, 4, 12, 10, 15, tzinfo=FixedOffset(4, 0, - '+0400'))), + '+0400')), + DATE_BAS_WEEK_COMPLETE + 'T' + TIME_BAS_MINUTE + TZ_BAS), ('1985-W15-5T10:15+04', datetime(1985, 4, 12, 10, 15, tzinfo=FixedOffset(4, 0, - '+0400')))] + '+0400')), + DATE_EXT_WEEK_COMPLETE + 'T' + TIME_EXT_MINUTE + TZ_HOUR)] -def create_testcase(datetimestring, expectation): +def create_testcase(datetimestring, expectation, format): ''' Create a TestCase class for a specific test. @@ -67,6 +77,18 @@ def create_testcase(datetimestring, expectation): result = parse_datetime(datetimestring) self.assertEqual(result, expectation) + def test_format(self): + ''' + Take datetime object and create ISO string from it. + This is the reverse test to test_parse. + ''' + if expectation is None: + self.assertRaises(AttributeError, + datetime_isoformat, expectation, format) + else: + self.assertEqual(datetime_isoformat(expectation, format), + datetimestring) + return unittest.TestLoader().loadTestsFromTestCase(TestDateTime) def test_suite(): @@ -74,8 +96,8 @@ 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)) + for datetimestring, expectation, format in TEST_CASES: + suite.addTest(create_testcase(datetimestring, expectation, format)) return suite if __name__ == '__main__': diff --git a/src/tests/test_duration.py b/src/tests/test_duration.py index e95008e..d8244b5 100644 --- a/src/tests/test_duration.py +++ b/src/tests/test_duration.py @@ -32,41 +32,49 @@ import operator from datetime import timedelta, date, datetime from isodate import Duration, parse_duration, ISO8601Error +from isodate import D_DEFAULT, D_WEEK, D_ALT_EXT, duration_isoformat # 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), +PARSE_TEST_CASES = {'P18Y9M4DT11H9M8S': (Duration(4, 8, 0, 0, 9, 11, 0, 9, 18), + D_DEFAULT, None), + 'P2W': (timedelta(weeks = 2), D_WEEK, None), + 'P3Y6M4DT12H30M5S': (Duration(4, 5, 0, 0, 30, 12, 0, 6, 3), + D_DEFAULT, None), + 'P23DT23H': (timedelta(hours=23, days=23), + D_DEFAULT, None), + 'P4Y': (Duration(years=4), D_DEFAULT, None), + 'P1M': (Duration(months=1), D_DEFAULT, None), + 'PT1M': (timedelta(minutes=1), D_DEFAULT, None), + 'P0.5Y': (Duration(years=0.5), D_DEFAULT, None), + 'PT36H': (timedelta(hours=36), D_DEFAULT, 'P1DT12H'), + 'P1DT12H': (timedelta(days=1, hours=12), D_DEFAULT, None), + '+P11D': (timedelta(days=11), D_DEFAULT, 'P11D'), + '-P2W': (timedelta(weeks=-2), D_WEEK, None), + '-P2.2W': (timedelta(weeks=-2.2), D_DEFAULT, '-P15DT9H36M'), + 'P1DT2H3M4S': (timedelta(days=1, hours=2, minutes=3, + seconds=4), D_DEFAULT, None), + 'P1DT2H3M': (timedelta(days=1, hours=2, minutes=3), + D_DEFAULT, None), + 'P1DT2H': (timedelta(days=1, hours=2), D_DEFAULT, None), + 'PT2H': (timedelta(hours=2), D_DEFAULT, None), + 'PT2.3H': (timedelta(hours=2.3), D_DEFAULT, 'PT2H18M'), + 'PT2H3M4S': (timedelta(hours=2, minutes=3, seconds=4), + D_DEFAULT, None), + 'PT3M4S': (timedelta(minutes=3, seconds=4), D_DEFAULT, + None), + 'PT22S': (timedelta(seconds=22), D_DEFAULT, None), + 'PT22.22S': (timedelta(seconds=22.22), 'PT%S.%fS', + 'PT22.220000S'), + '-P2Y': (Duration(years=-2), D_DEFAULT, None), + '-P3Y6M4DT12H30M5S': (Duration(-4, -5, 0, 0, -30, -12, 0, + -6, -3), D_DEFAULT, None), + '-P1DT2H3M4S': ( timedelta(days=-1, hours=-2, minutes=-3, + seconds=-4), D_DEFAULT, None), # alternative format - 'P0018-09-04T11:09:08': Duration(4, 8, 0, 0, 9, 11, 0, 9, - 18), + 'P0018-09-04T11:09:08': (Duration(4, 8, 0, 0, 9, 11, 0, 9, + 18), D_ALT_EXT, None), #'PT000022.22': timedelta(seconds=22.22), } @@ -242,9 +250,15 @@ class DurationTest(unittest.TestCase): ''' 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,' + self.assertEqual('isodate.duration.Duration(10, 10, 0,' ' years=10, months=10)', repr(dur)) + def test_format(self): + ''' + Test various other strftime combinations. + ''' + self.assertEqual(duration_isoformat(Duration(0)), 'P0D') + def test_equal(self): ''' Test __eq__ and __ne__ methods. @@ -264,7 +278,7 @@ class DurationTest(unittest.TestCase): self.assertTrue(Duration(years=1, months=1) != Duration(months=14)) self.assertTrue(Duration(years=1) != timedelta(days=365)) -def create_parsetestcase(durationstring, expectation): +def create_parsetestcase(durationstring, expectation, format, altstr): ''' Create a TestCase class for a specific test. @@ -285,6 +299,18 @@ def create_parsetestcase(durationstring, expectation): result = parse_duration(durationstring) self.assertEqual(result, expectation) + def test_format(self): + ''' + Take duration/timedelta object and create ISO string from it. + This is the reverse test to test_parse. + ''' + if altstr: + self.assertEqual(duration_isoformat(expectation, format), + altstr) + else: + self.assertEqual(duration_isoformat(expectation, format), + durationstring) + return unittest.TestLoader().loadTestsFromTestCase(TestParseDuration) def create_mathtestcase(dur1, dur2, resadd, ressub, resge): @@ -394,8 +420,9 @@ 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 durationstring, (expectation, format, altstr) in PARSE_TEST_CASES.items(): + suite.addTest(create_parsetestcase(durationstring, expectation, + format, altstr)) for testdata in MATH_TEST_CASES: suite.addTest(create_mathtestcase(*testdata)) for testdata in DATE_TEST_CASES: diff --git a/src/tests/test_time.py b/src/tests/test_time.py index a1f8f4e..062995b 100644 --- a/src/tests/test_time.py +++ b/src/tests/test_time.py @@ -29,44 +29,55 @@ Test cases for the isotime module. ''' import unittest from datetime import time -from isodate import parse_time, UTC, FixedOffset, ISO8601Error + +from isodate import parse_time, UTC, FixedOffset, ISO8601Error, time_isoformat +from isodate import TIME_BAS_COMPLETE, TIME_BAS_MINUTE +from isodate import TIME_EXT_COMPLETE, TIME_EXT_MINUTE +from isodate import TIME_HOUR +from isodate import TZ_BAS, TZ_EXT, TZ_HOUR # 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)), +TEST_CASES = [('232050', time(23, 20, 50), TIME_BAS_COMPLETE + TZ_BAS), + ('23:20:50', time(23, 20, 50), TIME_EXT_COMPLETE + TZ_EXT), + ('2320', time(23, 20), TIME_BAS_MINUTE), + ('23:20', time(23, 20), TIME_EXT_MINUTE), + ('23', time(23), TIME_HOUR), + ('232050,5', time(23, 20, 50, 500000), None), + ('23:20:50.5', time(23, 20, 50, 500000), None), + ('2320,8', time(23, 20, 48), None), + ('23:20,8', time(23, 20, 48), None), + ('23,3', time(23, 18), None), + ('232030Z', time(23, 20, 30, tzinfo=UTC), TIME_BAS_COMPLETE + TZ_BAS), + ('2320Z', time(23, 20, tzinfo=UTC), TIME_BAS_MINUTE + TZ_BAS), + ('23Z', time(23, tzinfo=UTC), TIME_HOUR + TZ_BAS), + ('23:20:30Z', time(23, 20, 30, tzinfo=UTC), TIME_EXT_COMPLETE + TZ_EXT), + ('23:20Z', time(23, 20, tzinfo=UTC), TIME_EXT_MINUTE + TZ_EXT), + ('152746+0100', time(15, 27, 46, tzinfo=FixedOffset(1, 0, '+0100')), TIME_BAS_COMPLETE + TZ_BAS), + ('152746-0500', time(15, 27, 46, tzinfo=FixedOffset(-5, 0, '-0500')), TIME_BAS_COMPLETE + TZ_BAS), ('152746+01', time(15, 27, 46, - tzinfo=FixedOffset(1, 0, '+01:00'))), + tzinfo=FixedOffset(1, 0, '+01:00')), + TIME_BAS_COMPLETE + TZ_HOUR), ('152746-05', time(15, 27, 46, - tzinfo=FixedOffset(-5, -0, '-05:00'))), + tzinfo=FixedOffset(-5, -0, '-05:00')), + TIME_BAS_COMPLETE + TZ_HOUR), ('15:27:46+01:00', time(15, 27, 46, - tzinfo=FixedOffset(1, 0, '+01:00'))), + tzinfo=FixedOffset(1, 0, '+01:00')), + TIME_EXT_COMPLETE + TZ_EXT), ('15:27:46-05:00', time(15, 27, 46, - tzinfo=FixedOffset(-5, -0, '-05:00'))), + tzinfo=FixedOffset(-5, -0, '-05:00')), + TIME_EXT_COMPLETE + TZ_EXT), ('15:27:46+01', time(15, 27, 46, - tzinfo=FixedOffset(1, 0, '+01:00'))), + tzinfo=FixedOffset(1, 0, '+01:00')), + TIME_EXT_COMPLETE + TZ_HOUR), ('15:27:46-05', time(15, 27, 46, - tzinfo=FixedOffset(-5, -0, '-05:00'))), - ('1:17:30', None)] + tzinfo=FixedOffset(-5, -0, '-05:00')), + TIME_EXT_COMPLETE + TZ_HOUR), + ('1:17:30', None, TIME_EXT_COMPLETE)] -def create_testcase(timestring, expectation): +def create_testcase(timestring, expectation, format): ''' Create a TestCase class for a specific test. @@ -89,6 +100,18 @@ def create_testcase(timestring, expectation): else: result = parse_time(timestring) self.assertEqual(result, expectation) + + def test_format(self): + ''' + Take time object and create ISO string from it. + This is the reverse test to test_parse. + ''' + if expectation is None: + self.assertRaises(AttributeError, + time_isoformat, expectation, format) + elif format is not None: + self.assertEqual(time_isoformat(expectation, format), + timestring) return unittest.TestLoader().loadTestsFromTestCase(TestTime) @@ -97,8 +120,8 @@ 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)) + for timestring, expectation, format in TEST_CASES: + suite.addTest(create_testcase(timestring, expectation, format)) return suite if __name__ == '__main__': |