From aa3a84d6e5b3cf4ecca2d0a2422766735336366d Mon Sep 17 00:00:00 2001 From: Gerhard Weis Date: Wed, 9 Oct 2024 11:16:22 +1000 Subject: [PATCH] merge #PR83 update typing drop supprot for Py < 3.9 --- CHANGES.txt | 3 +- MANIFEST.in | 3 + pyproject.toml | 4 +- src/isodate/duration.py | 140 +++++++++------------ src/isodate/isodates.py | 75 +++++------ src/isodate/isodatetime.py | 22 ++-- src/isodate/isoduration.py | 51 ++++---- src/isodate/isoerror.py | 4 +- src/isodate/isostrf.py | 87 ++++++------- src/isodate/isotime.py | 37 +++--- src/isodate/isotzinfo.py | 32 ++--- src/isodate/py.typed | 0 src/isodate/tzinfo.py | 99 ++++++--------- src/py.typed | 0 tests/test_date.py | 29 +++-- tests/test_datetime.py | 21 ++-- tests/test_duration.py | 246 ++++++++++++++++++++----------------- tests/test_strf.py | 12 +- tests/test_time.py | 21 ++-- tox.ini | 2 +- 20 files changed, 424 insertions(+), 464 deletions(-) create mode 100644 MANIFEST.in create mode 100644 src/isodate/py.typed create mode 100644 src/py.typed diff --git a/CHANGES.txt b/CHANGES.txt index 84d61cd..91557ce 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -5,7 +5,8 @@ CHANGES 0.7.3 (unreleased) ------------------ -- no changes yet +- Drop support for Python < 3.9 +- add type hints 0.7.2 (2024-10-08) diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..468a427 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include CHANGES.txt +include TODO.txt +include src/isodate/py.typed diff --git a/pyproject.toml b/pyproject.toml index b18858d..ef323a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -24,7 +22,7 @@ classifiers = [ "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", ] -requires-python = ">=3.7" +requires-python = ">=3.9" dynamic = ["version", "readme"] [project.urls] diff --git a/src/isodate/duration.py b/src/isodate/duration.py index bc8a5cb..0e5af4f 100644 --- a/src/isodate/duration.py +++ b/src/isodate/duration.py @@ -1,19 +1,17 @@ -""" -This module defines a Duration class. +"""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 timedelta +from __future__ import annotations + +from datetime import date, datetime, timedelta from decimal import ROUND_FLOOR, Decimal -def fquotmod(val, low, high): - """ - A divmod function with boundaries. - - """ +def fquotmod(val: Decimal, low: int, high: int) -> tuple[int, Decimal]: + """A divmod function with boundaries.""" # assumes that all the maths is done with Decimals. # divmod for Decimal uses truncate instead of floor as builtin # divmod, so we have to do it manually here. @@ -26,10 +24,8 @@ def fquotmod(val, low, high): 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 max_days_in_month(year: int, month: int) -> int: + """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): @@ -40,8 +36,7 @@ def max_days_in_month(year, month): class Duration: - """ - A class which represents a duration. + """A class which represents a duration. The difference to datetime.timedelta is, that this class handles also differences given in years and months. @@ -64,28 +59,24 @@ class Duration: def __init__( self, - days=0, - seconds=0, - microseconds=0, - milliseconds=0, - minutes=0, - hours=0, - weeks=0, - months=0, - years=0, + days: float = 0, + seconds: float = 0, + microseconds: float = 0, + milliseconds: float = 0, + minutes: float = 0, + hours: float = 0, + weeks: float = 0, + months: float | Decimal = 0, + years: float | Decimal = 0, ): - """ - Initialise this Duration instance with the given parameters. - """ + """Initialise this Duration instance with the given parameters.""" if not isinstance(months, Decimal): months = Decimal(str(months)) if not isinstance(years, Decimal): years = Decimal(str(years)) self.months = months self.years = years - self.tdelta = timedelta( - days, seconds, microseconds, milliseconds, minutes, hours, weeks - ) + self.tdelta = timedelta(days, seconds, microseconds, milliseconds, minutes, hours, weeks) def __getstate__(self): return self.__dict__ @@ -93,17 +84,13 @@ def __getstate__(self): def __setstate__(self, state): self.__dict__.update(state) - def __getattr__(self, name): - """ - Provide direct access to attributes of included timedelta instance. - """ + def __getattr__(self, name: str): + """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 = [] + """Return a string representation of this duration similar to timedelta.""" + params: list[str] = [] if self.years: params.append("%d years" % self.years) if self.months: @@ -115,9 +102,7 @@ def __str__(self): return ", ".join(params) def __repr__(self): - """ - Return a string suitable for repr(x) calls. - """ + """Return a string suitable for repr(x) calls.""" return "%s.%s(%d, %d, %d, years=%d, months=%d)" % ( self.__class__.__module__, self.__class__.__name__, @@ -129,15 +114,14 @@ def __repr__(self): ) def __hash__(self): - """ - Return a hash of this instance so that it can be used in, for - example, dicts and sets. + """Return a hash of this instance. + + So that it can be used in, for example, dicts and sets. """ return hash((self.tdelta, self.months, self.years)) def __neg__(self): - """ - A simple unary minus. + """A simple unary minus. Returns a new Duration instance with all it's negated. """ @@ -145,10 +129,10 @@ def __neg__(self): negduration.tdelta = -self.tdelta return negduration - def __add__(self, other): - """ - Durations can be added with Duration, timedelta, date and datetime - objects. + def __add__(self, other: Duration | timedelta | date | datetime) -> Duration | date | datetime: + """+ operator for Durations. + + Durations can be added with Duration, timedelta, date and datetime objects. """ if isinstance(other, Duration): newduration = Duration( @@ -156,7 +140,7 @@ def __add__(self, other): ) newduration.tdelta = self.tdelta + other.tdelta return newduration - try: + elif isinstance(other, (date, datetime)): # try anything that looks like a date or datetime # 'other' has attributes year, month, day # and relies on 'timedelta + other' being implemented @@ -167,34 +151,26 @@ def __add__(self, other): newmonth = other.month + self.months carry, newmonth = fquotmod(newmonth, 1, 13) newyear = other.year + self.years + carry - maxdays = max_days_in_month(newyear, newmonth) + maxdays = max_days_in_month(int(newyear), int(newmonth)) if other.day > maxdays: newday = maxdays else: newday = other.day - newdt = other.replace( - year=int(newyear), month=int(newmonth), day=int(newday) - ) + newdt = other.replace(year=int(newyear), month=int(newmonth), day=int(newday)) # does a timedelta + date/datetime return self.tdelta + newdt - except AttributeError: - # other probably was not a date/datetime compatible object - pass - try: + elif isinstance(other, timedelta): # try if other is a timedelta # relies on timedelta + timedelta supported newduration = Duration(years=self.years, months=self.months) newduration.tdelta = self.tdelta + other return newduration - except AttributeError: - # ignore ... other probably was not a timedelta compatible object - pass # we have tried everything .... return a NotImplemented return NotImplemented __radd__ = __add__ - def __mul__(self, other): + def __mul__(self, other: int) -> Duration: if isinstance(other, int): newduration = Duration(years=self.years * other, months=self.months * other) newduration.tdelta = self.tdelta * other @@ -203,8 +179,9 @@ def __mul__(self, other): __rmul__ = __mul__ - def __sub__(self, other): - """ + def __sub__(self, other: Duration | timedelta) -> Duration: + """- operator for Durations. + It is possible to subtract Duration and timedelta objects from Duration objects. """ @@ -224,8 +201,9 @@ def __sub__(self, other): pass return NotImplemented - def __rsub__(self, other): - """ + def __rsub__(self, other: Duration | date | datetime | timedelta): + """- operator for Durations. + It is possible to subtract Duration objects from date, datetime and timedelta objects. @@ -252,22 +230,21 @@ def __rsub__(self, other): newmonth = other.month - self.months carry, newmonth = fquotmod(newmonth, 1, 13) newyear = other.year - self.years + carry - maxdays = max_days_in_month(newyear, newmonth) + maxdays = max_days_in_month(int(newyear), int(newmonth)) if other.day > maxdays: newday = maxdays else: newday = other.day - newdt = other.replace( - year=int(newyear), month=int(newmonth), day=int(newday) - ) + newdt = other.replace(year=int(newyear), month=int(newmonth), day=int(newday)) return newdt - self.tdelta except AttributeError: # other probably was not compatible with data/datetime pass return NotImplemented - def __eq__(self, other): - """ + def __eq__(self, other: object) -> bool: + """== operator. + If the years, month part and the timedelta part are both equal, then the two Durations are considered equal. """ @@ -283,8 +260,9 @@ def __eq__(self, other): return self.tdelta == other return False - def __ne__(self, other): - """ + def __ne__(self, other: object) -> bool: + """!= operator. + If the years, month part or the timedelta part is not equal, then the two Durations are considered not equal. """ @@ -300,9 +278,10 @@ def __ne__(self, other): return self.tdelta != other return True - def totimedelta(self, start=None, end=None): - """ - Convert this duration into a timedelta object. + def totimedelta( + self, start: date | datetime | None = None, end: date | datetime | None = None + ) -> timedelta: + """Convert this duration into a timedelta object. This method requires a start datetime or end datetimem, but raises an exception if both are given. @@ -312,5 +291,8 @@ def totimedelta(self, start=None, end=None): 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: ignore type error ... false positive in mypy or wrong type annotation in + # __rsub__ ? + return (start + self) - start # type: ignore [operator, return-value] + # ignore typ error ... false positive in mypy + return end - (end - self) # type: ignore [operator] diff --git a/src/isodate/isodates.py b/src/isodate/isodates.py index d32fe25..aa432c5 100644 --- a/src/isodate/isodates.py +++ b/src/isodate/isodates.py @@ -1,5 +1,4 @@ -""" -This modules provides a method to parse an ISO 8601:2004 date string to a +"""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 @@ -8,22 +7,24 @@ """ import re -from datetime import date, timedelta +from datetime import date, time, timedelta +from typing import Union +from isodate.duration import Duration from isodate.isoerror import ISO8601Error from isodate.isostrf import DATE_EXT_COMPLETE, strftime -DATE_REGEX_CACHE = {} +DATE_REGEX_CACHE: dict[tuple[int, bool], list[re.Pattern[str]]] = {} # 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. +def build_date_regexps(yeardigits: int = 4, expanded: bool = False) -> list[re.Pattern[str]]: + """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. @@ -35,7 +36,7 @@ def build_date_regexps(yeardigits=4, expanded=False): if yeardigits != 4: expanded = True if (yeardigits, expanded) not in DATE_REGEX_CACHE: - cache_entry = [] + cache_entry: list[re.Pattern[str]] = [] # ISO 8601 expanded DATE formats allow an arbitrary number of year # digits with a leading +/- sign. if expanded: @@ -43,7 +44,7 @@ def build_date_regexps(yeardigits=4, expanded=False): else: sign = 0 - def add_re(regex_text): + def add_re(regex_text: str) -> None: cache_entry.append(re.compile(r"\A" + regex_text + r"\Z")) # 1. complete dates: @@ -70,41 +71,27 @@ def add_re(regex_text): ) # 3. ordinal dates: # YYYY-DDD or +-YYYYYY-DDD ... extended format - add_re( - r"(?P[+-]){%d}(?P[0-9]{%d})" - r"-(?P[0-9]{3})" % (sign, yeardigits) - ) + add_re(r"(?P[+-]){%d}(?P[0-9]{%d})" r"-(?P[0-9]{3})" % (sign, yeardigits)) # YYYYDDD or +-YYYYYYDDD ... basic format - add_re( - r"(?P[+-]){%d}(?P[0-9]{%d})" - r"(?P[0-9]{3})" % (sign, yeardigits) - ) + add_re(r"(?P[+-]){%d}(?P[0-9]{%d})" r"(?P[0-9]{3})" % (sign, yeardigits)) # 4. week dates: # YYYY-Www or +-YYYYYY-Www ... extended reduced accuracy week date # 4. week dates: # YYYY-Www or +-YYYYYY-Www ... extended reduced accuracy week date add_re( - r"(?P[+-]){%d}(?P[0-9]{%d})" - r"-W(?P[0-9]{2})" % (sign, yeardigits) + r"(?P[+-]){%d}(?P[0-9]{%d})" r"-W(?P[0-9]{2})" % (sign, yeardigits) ) # YYYYWww or +-YYYYYYWww ... basic reduced accuracy week date - add_re( - r"(?P[+-]){%d}(?P[0-9]{%d})W" - r"(?P[0-9]{2})" % (sign, yeardigits) - ) + add_re(r"(?P[+-]){%d}(?P[0-9]{%d})W" r"(?P[0-9]{2})" % (sign, yeardigits)) # 5. month dates: # YYY-MM or +-YYYYYY-MM ... reduced accuracy specific month # 5. month dates: # YYY-MM or +-YYYYYY-MM ... reduced accuracy specific month add_re( - r"(?P[+-]){%d}(?P[0-9]{%d})" - r"-(?P[0-9]{2})" % (sign, yeardigits) + r"(?P[+-]){%d}(?P[0-9]{%d})" r"-(?P[0-9]{2})" % (sign, yeardigits) ) # YYYMM or +-YYYYYYMM ... basic incomplete month date format - add_re( - r"(?P[+-]){%d}(?P[0-9]{%d})" - r"(?P[0-9]{2})" % (sign, yeardigits) - ) + add_re(r"(?P[+-]){%d}(?P[0-9]{%d})" r"(?P[0-9]{2})" % (sign, yeardigits)) # 6. year dates: # YYYY or +-YYYYYY ... reduced accuracy specific year add_re(r"(?P[+-]){%d}(?P[0-9]{%d})" % (sign, yeardigits)) @@ -116,9 +103,14 @@ def add_re(regex_text): return DATE_REGEX_CACHE[(yeardigits, expanded)] -def parse_date(datestring, yeardigits=4, expanded=False, defaultmonth=1, defaultday=1): - """ - Parse an ISO 8601 date string into a datetime.date object. +def parse_date( + datestring: str, + yeardigits: int = 4, + expanded: bool = False, + defaultmonth: int = 1, + defaultday: int = 1, +) -> date: + """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 @@ -162,9 +154,7 @@ def parse_date(datestring, yeardigits=4, expanded=False, defaultmonth=1, default # 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), defaultmonth, defaultday - ) + return date(sign * (int(groups["century"]) * 100 + 1), defaultmonth, defaultday) if "month" not in groups: # weekdate or ordinal date ret = date(sign * int(groups["year"]), 1, 1) if "week" in groups: @@ -187,15 +177,16 @@ def parse_date(datestring, yeardigits=4, expanded=False, defaultmonth=1, default day = defaultday else: day = int(groups["day"]) - return date( - sign * int(groups["year"]), int(groups["month"]) or defaultmonth, day - ) + return date(sign * int(groups["year"]), int(groups["month"]) or defaultmonth, day) raise ISO8601Error("Unrecognised ISO 8601 date format: %r" % datestring) -def date_isoformat(tdate, format=DATE_EXT_COMPLETE, yeardigits=4): - """ - Format date strings. +def date_isoformat( + tdate: Union[timedelta, Duration, time, date], + format: str = DATE_EXT_COMPLETE, + yeardigits: int = 4, +) -> str: + """Format date strings. This method is just a wrapper around isodate.isostrf.strftime and uses Date-Extended-Complete as default format. diff --git a/src/isodate/isodatetime.py b/src/isodate/isodatetime.py index 1b20805..bafd708 100644 --- a/src/isodate/isodatetime.py +++ b/src/isodate/isodatetime.py @@ -1,21 +1,23 @@ -""" -This module defines a method to parse an ISO 8601:2004 date time string. +"""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 __future__ import annotations + +from datetime import date, datetime, time, timedelta +from typing import Union +import isodate from isodate.isodates import parse_date from isodate.isoerror import ISO8601Error from isodate.isostrf import DATE_EXT_COMPLETE, TIME_EXT_COMPLETE, TZ_EXT, strftime from isodate.isotime import parse_time -def parse_datetime(datetimestring): - """ - Parses ISO 8601 date-times into datetime.datetime objects. +def parse_datetime(datetimestring: str) -> datetime: + """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 @@ -34,10 +36,10 @@ def parse_datetime(datetimestring): def datetime_isoformat( - tdt, format=DATE_EXT_COMPLETE + "T" + TIME_EXT_COMPLETE + TZ_EXT -): - """ - Format datetime strings. + tdt: Union[timedelta, isodate.isoduration.Duration, time, date], + format: str = DATE_EXT_COMPLETE + "T" + TIME_EXT_COMPLETE + TZ_EXT, +) -> str: + """Format datetime strings. This method is just a wrapper around isodate.isostrf.strftime and uses Extended-Complete as default format. diff --git a/src/isodate/isoduration.py b/src/isodate/isoduration.py index 4f1755b..6b396b4 100644 --- a/src/isodate/isoduration.py +++ b/src/isodate/isoduration.py @@ -1,13 +1,13 @@ -""" -This module provides an ISO 8601:2004 duration parser. +"""This module provides an ISO 8601:2004 duration parser. It also provides a wrapper to strftime. This wrapper makes it easier to format timedelta or Duration instances as ISO conforming strings. """ import re -from datetime import timedelta +from datetime import date, time, timedelta from decimal import Decimal +from typing import Union, Optional from isodate.duration import Duration from isodate.isodatetime import parse_datetime @@ -28,9 +28,10 @@ # regular expression to parse ISO duration strings. -def parse_duration(datestring, as_timedelta_if_possible=True): - """ - Parses an ISO 8601 durations into datetime.timedelta or Duration objects. +def parse_duration( + datestring: str, as_timedelta_if_possible: bool = True +) -> Union[timedelta, Duration]: + """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. @@ -56,6 +57,7 @@ def parse_duration(datestring, as_timedelta_if_possible=True): The alternative format does not support durations with years, months or days set to 0. """ + ret: Optional[Union[timedelta, Duration]] = None if not isinstance(datestring, str): raise TypeError("Expecting a string %r" % datestring) match = ISO8601_PERIOD_REGEX.match(datestring) @@ -100,32 +102,33 @@ def parse_duration(datestring, as_timedelta_if_possible=True): groups[key] = float(groups[key][:-1].replace(",", ".")) if as_timedelta_if_possible and groups["years"] == 0 and groups["months"] == 0: ret = timedelta( - days=groups["days"], - hours=groups["hours"], - minutes=groups["minutes"], - seconds=groups["seconds"], - weeks=groups["weeks"], + days=int(groups["days"]), + hours=int(groups["hours"]), + minutes=int(groups["minutes"]), + seconds=int(groups["seconds"]), + weeks=int(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"], + years=int(groups["years"]), + months=int(groups["months"]), + days=int(groups["days"]), + hours=int(groups["hours"]), + minutes=int(groups["minutes"]), + seconds=int(groups["seconds"]), + weeks=int(groups["weeks"]), ) if groups["sign"] == "-": ret = Duration(0) - ret return ret -def duration_isoformat(tduration, format=D_DEFAULT): - """ - Format duration strings. +def duration_isoformat( + tduration: Union[timedelta, Duration, time, date], format: str = D_DEFAULT +) -> str: + """Format duration strings. This method is just a wrapper around isodate.isostrf.strftime and uses P%P (D_DEFAULT) as default format. @@ -134,11 +137,7 @@ def duration_isoformat(tduration, format=D_DEFAULT): # 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) - ) + and (tduration.years < 0 or tduration.months < 0 or tduration.tdelta < timedelta(0)) ) or (isinstance(tduration, timedelta) and (tduration < timedelta(0))): ret = "-" else: diff --git a/src/isodate/isoerror.py b/src/isodate/isoerror.py index 068429f..d024587 100644 --- a/src/isodate/isoerror.py +++ b/src/isodate/isoerror.py @@ -1,6 +1,4 @@ -""" -This module defines all exception classes in the whole package. -""" +"""This module defines all exception classes in the whole package.""" class ISO8601Error(ValueError): diff --git a/src/isodate/isostrf.py b/src/isodate/isostrf.py index 455ce97..e77fec9 100644 --- a/src/isodate/isostrf.py +++ b/src/isodate/isostrf.py @@ -1,5 +1,4 @@ -""" -This module provides an alternative strftime method. +"""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 @@ -9,7 +8,8 @@ """ import re -from datetime import date, timedelta +from datetime import date, time, timedelta +from typing import Callable, Union from isodate.duration import Duration from isodate.isotzinfo import tz_isoformat @@ -56,65 +56,58 @@ 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: "%06d" % tdt.microsecond, - "%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, "%h"), - "%Z": lambda tdt, yds: tz_isoformat(tdt, "%Z"), - "%z": lambda tdt, yds: tz_isoformat(tdt, "%z"), +STRF_DT_MAP: dict[str, Callable[[Union[time, date], int], str]] = { + "%d": lambda tdt, yds: "%02d" % tdt.day, # type: ignore [union-attr] + "%f": lambda tdt, yds: "%06d" % tdt.microsecond, # type: ignore [union-attr] + "%H": lambda tdt, yds: "%02d" % tdt.hour, # type: ignore [union-attr] + "%j": lambda tdt, yds: "%03d" % (tdt.toordinal() - date(tdt.year, 1, 1).toordinal() + 1), # type: ignore [union-attr, operator] + "%m": lambda tdt, yds: "%02d" % tdt.month, # type: ignore [union-attr] + "%M": lambda tdt, yds: "%02d" % tdt.minute, # type: ignore [union-attr] + "%S": lambda tdt, yds: "%02d" % tdt.second, # type: ignore [union-attr] + "%w": lambda tdt, yds: "%1d" % tdt.isoweekday(), # type: ignore [union-attr] + "%W": lambda tdt, yds: "%02d" % tdt.isocalendar()[1], # type: ignore [union-attr] + "%Y": lambda tdt, yds: (((yds != 4) and "+") or "") + (("%%0%dd" % yds) % tdt.year), # type: ignore [union-attr] + "%C": lambda tdt, yds: (((yds != 4) and "+") or "") # type: ignore [union-attr] + + (("%%0%dd" % (yds - 2)) % (tdt.year / 100)), # type: ignore [union-attr] + "%h": lambda tdt, yds: tz_isoformat(tdt, "%h"), # type: ignore [arg-type] + "%Z": lambda tdt, yds: tz_isoformat(tdt, "%Z"), # type: ignore [arg-type] + "%z": lambda tdt, yds: tz_isoformat(tdt, "%z"), # type: ignore [arg-type] "%%": lambda tdt, yds: "%", } -STRF_D_MAP = { +STRF_D_MAP: dict[str, Callable[[Union[timedelta, Duration], int], str]] = { "%d": lambda tdt, yds: "%02d" % tdt.days, "%f": lambda tdt, yds: "%06d" % tdt.microseconds, "%H": lambda tdt, yds: "%02d" % (tdt.seconds / 60 / 60), - "%m": lambda tdt, yds: "%02d" % tdt.months, + "%m": lambda tdt, yds: "%02d" % tdt.months, # type: ignore [union-attr] "%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), + "%Y": lambda tdt, yds: (((yds != 4) and "+") or "") + (("%%0%dd" % yds) % tdt.years), # type: ignore [union-attr] "%C": lambda tdt, yds: (((yds != 4) and "+") or "") - + (("%%0%dd" % (yds - 2)) % (tdt.years / 100)), + + (("%%0%dd" % (yds - 2)) % (tdt.years / 100)), # type: ignore [union-attr] "%%": lambda tdt, yds: "%", } -def _strfduration(tdt, format, yeardigits=4): - """ - this is the work method for timedelta and Duration instances. +def _strfduration(tdt: Union[timedelta, Duration], format: str, yeardigits: int = 4) -> str: + """This is the work method for timedelta and Duration instances. - see strftime for more details. + See strftime for more details. """ - def repl(match): - """ - lookup format command and return corresponding replacement. - """ + def repl(match: re.Match[str]) -> str: + """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 = [] + ret: list[str] = [] if isinstance(tdt, Duration): if tdt.years: ret.append("%sY" % abs(tdt.years)) if tdt.months: ret.append("%sM" % abs(tdt.months)) - usecs = abs( - (tdt.days * 24 * 60 * 60 + tdt.seconds) * 1000000 + tdt.microseconds - ) + usecs = abs((tdt.days * 24 * 60 * 60 + tdt.seconds) * 1000000 + tdt.microseconds) seconds, usecs = divmod(usecs, 1000000) minutes, seconds = divmod(seconds, 60) hours, minutes = divmod(minutes, 60) @@ -134,7 +127,7 @@ def repl(match): ret.append("%d" % seconds) ret.append("S") # at least one component has to be there. - return ret and "".join(ret) or "0D" + return "".join(ret) if ret else "0D" elif match.group(0) == "%p": return str(abs(tdt.days // 7)) + "W" return match.group(0) @@ -142,17 +135,14 @@ def repl(match): 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. +def _strfdt(tdt: Union[time, date], format: str, yeardigits: int = 4) -> str: + """This is the work method for time and date instances. - see strftime for more details. + See strftime for more details. """ - def repl(match): - """ - lookup format command and return corresponding replacement. - """ + def repl(match: re.Match[str]) -> str: + """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) @@ -160,8 +150,9 @@ def repl(match): 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 +def strftime(tdt: Union[timedelta, Duration, time, date], format: str, yeardigits: int = 4) -> str: + """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) diff --git a/src/isodate/isotime.py b/src/isodate/isotime.py index f74ef5d..78b4de2 100644 --- a/src/isodate/isotime.py +++ b/src/isodate/isotime.py @@ -1,5 +1,4 @@ -""" -This modules provides a method to parse an ISO 8601:2004 time string to a +"""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 @@ -7,20 +6,21 @@ """ import re -from datetime import time +from datetime import date, time, timedelta from decimal import ROUND_FLOOR, Decimal +from typing import Union +from isodate.duration import Duration from isodate.isoerror import ISO8601Error from isodate.isostrf import TIME_EXT_COMPLETE, TZ_EXT, strftime from isodate.isotzinfo import TZ_REGEX, build_tzinfo -TIME_REGEX_CACHE = [] +TIME_REGEX_CACHE: list[re.Pattern[str]] = [] # used to cache regular expressions to parse ISO time strings. -def build_time_regexps(): - """ - Build regular expressions to parse ISO time string. +def build_time_regexps() -> list[re.Pattern[str]]: + """Build regular expressions to parse ISO time string. The regular expressions are compiled and stored in TIME_REGEX_CACHE for later reuse. @@ -42,7 +42,7 @@ def build_time_regexps(): # +-hhmm # +-hh => # isotzinfo.TZ_REGEX - def add_re(regex_text): + def add_re(regex_text: str) -> None: TIME_REGEX_CACHE.append(re.compile(r"\A" + regex_text + TZ_REGEX + r"\Z")) # 1. complete time: @@ -55,10 +55,7 @@ def add_re(regex_text): ) # hhmmss.ss ... basic format add_re( - r"T?(?P[0-9]{2})" - r"(?P[0-9]{2})" - r"(?P[0-9]{2}" - r"([,.][0-9]+)?)" + r"T?(?P[0-9]{2})" r"(?P[0-9]{2})" r"(?P[0-9]{2}" r"([,.][0-9]+)?)" ) # 2. reduced accuracy: # hh:mm.mm ... extended format @@ -70,9 +67,8 @@ def add_re(regex_text): return TIME_REGEX_CACHE -def parse_time(timestring): - """ - Parses ISO 8601 times into datetime.time objects. +def parse_time(timestring: str) -> time: + """Parses ISO 8601 times into datetime.time objects. Following ISO 8601 formats are supported: (as decimal separator a ',' or a '.' is allowed) @@ -130,10 +126,10 @@ def parse_time(timestring): tzinfo, ) else: - microsecond, second, minute = 0, 0, 0 + microsecond, second, minute = Decimal(0), Decimal(0), Decimal(0) hour = Decimal(groups["hour"]) minute = (hour - int(hour)) * 60 - second = (minute - int(minute)) * 60 + second = Decimal((minute - int(minute)) * 60) microsecond = (second - int(second)) * int(1e6) return time( int(hour), @@ -145,9 +141,10 @@ def parse_time(timestring): raise ISO8601Error("Unrecognised ISO 8601 time format: %r" % timestring) -def time_isoformat(ttime, format=TIME_EXT_COMPLETE + TZ_EXT): - """ - Format time strings. +def time_isoformat( + ttime: Union[timedelta, Duration, time, date], format: str = TIME_EXT_COMPLETE + TZ_EXT +) -> str: + """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. diff --git a/src/isodate/isotzinfo.py b/src/isodate/isotzinfo.py index 54f36de..f7efb86 100644 --- a/src/isodate/isotzinfo.py +++ b/src/isodate/isotzinfo.py @@ -1,22 +1,23 @@ -""" -This module provides an ISO 8601:2004 time zone info parser. +"""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 datetime import datetime, tzinfo +from typing import Union from isodate.isoerror import ISO8601Error -from isodate.tzinfo import UTC, ZERO, FixedOffset +from isodate.tzinfo import UTC, ZERO, FixedOffset, Utc -TZ_REGEX = ( - r"(?P(Z|(?P[+-])" r"(?P[0-9]{2})(:?(?P[0-9]{2}))?)?)" -) +TZ_REGEX = r"(?P(Z|(?P[+-])" r"(?P[0-9]{2})(:?(?P[0-9]{2}))?)?)" TZ_RE = re.compile(TZ_REGEX) -def build_tzinfo(tzname, tzsign="+", tzhour=0, tzmin=0): +def build_tzinfo( + tzname: Union[str, None], tzsign: str = "+", tzhour: float = 0, tzmin: float = 0 +) -> Union[FixedOffset, Utc, None]: """ create a tzinfo instance according to given parameters. @@ -29,13 +30,12 @@ def build_tzinfo(tzname, tzsign="+", tzhour=0, tzmin=0): return None if tzname == "Z": return UTC - tzsign = ((tzsign == "-") and -1) or 1 - return FixedOffset(tzsign * tzhour, tzsign * tzmin, tzname) + tzsignum = ((tzsign == "-") and -1) or 1 + return FixedOffset(tzsignum * tzhour, tzsignum * tzmin, tzname) -def parse_tzinfo(tzstring): - """ - Parses ISO 8601 time zone designators to tzinfo objects. +def parse_tzinfo(tzstring: str) -> Union[tzinfo, None]: + """Parses ISO 8601 time zone designators to tzinfo objects. A time zone designator can be in the following format: no designator indicates local time zone @@ -56,9 +56,9 @@ def parse_tzinfo(tzstring): raise ISO8601Error("%s not a valid time zone info" % tzstring) -def tz_isoformat(dt, format="%Z"): - """ - return time zone offset ISO 8601 formatted. +def tz_isoformat(dt: datetime, format: str = "%Z") -> str: + """Return time zone offset ISO 8601 formatted. + The various ISO formats can be chosen with the format parameter. if tzinfo is None returns '' @@ -75,6 +75,8 @@ def tz_isoformat(dt, format="%Z"): if tzinfo.utcoffset(dt) == ZERO and tzinfo.dst(dt) == ZERO: return "Z" tdelta = tzinfo.utcoffset(dt) + if tdelta is None: + return "" seconds = tdelta.days * 24 * 60 * 60 + tdelta.seconds sign = ((seconds < 0) and "-") or "+" seconds = abs(seconds) diff --git a/src/isodate/py.typed b/src/isodate/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/isodate/tzinfo.py b/src/isodate/tzinfo.py index 726c54a..6306146 100644 --- a/src/isodate/tzinfo.py +++ b/src/isodate/tzinfo.py @@ -1,11 +1,11 @@ -""" -This module provides some datetime.tzinfo implementations. +"""This module provides some datetime.tzinfo implementations. All those classes are taken from the Python documentation. """ import time -from datetime import timedelta, tzinfo +from datetime import datetime, timedelta, tzinfo +from typing import Literal, Optional ZERO = timedelta(0) # constant for zero time offset. @@ -17,30 +17,24 @@ class Utc(tzinfo): Universal time coordinated time zone. """ - def utcoffset(self, dt): - """ - Return offset from UTC in minutes east of UTC, which is ZERO for UTC. - """ + def utcoffset(self, dt: Optional[datetime]) -> timedelta: + """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, + def tzname(self, dt: Optional[datetime]) -> Literal["UTC"]: + """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 + def dst(self, dt: Optional[datetime]) -> timedelta: + """Return the daylight saving time (DST) adjustment, in minutes east of UTC. """ return ZERO def __reduce__(self): - """ - When unpickling a Utc object, return the default instance below, UTC. - """ + """When unpickling a Utc object, return the default instance below, UTC.""" return _Utc, () @@ -48,54 +42,47 @@ def __reduce__(self): # the default instance for UTC. -def _Utc(): - """ - Helper function for unpickling a Utc object. - """ +def _Utc() -> Utc: + """Helper function for unpickling a Utc object.""" return UTC class FixedOffset(tzinfo): - """ - A class building tzinfo objects for fixed-offset time zones. + """A class building tzinfo objects for fixed-offset time zones. Note that FixedOffset(0, 0, "UTC") or FixedOffset() is a different way to build a UTC tzinfo object. """ - def __init__(self, offset_hours=0, offset_minutes=0, name="UTC"): - """ - Initialise an instance with time offset and name. + def __init__( + self, offset_hours: float = 0, offset_minutes: float = 0, name: str = "UTC" + ) -> None: + """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. - """ + def utcoffset(self, dt: Optional[datetime]) -> timedelta: + """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 + def tzname(self, dt: Optional[datetime]) -> str: + """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 + def dst(self, dt: Optional[datetime]) -> timedelta: + """Return the daylight saving time (DST) adjustment, in minutes east of UTC. """ return ZERO - def __repr__(self): - """ - Return nicely formatted repr string. - """ + def __repr__(self) -> str: + """Return nicely formatted repr string.""" return "" % self.__name @@ -103,49 +90,39 @@ def __repr__(self): # locale time zone offset # calculate local daylight saving offset if any. -if time.daylight: - DSTOFFSET = timedelta(seconds=-time.altzone) -else: - DSTOFFSET = STDOFFSET +DSTOFFSET = timedelta(seconds=-time.altzone) if time.daylight else 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. - """ + """A class capturing the platform's idea of local time.""" - def utcoffset(self, dt): - """ - Return offset from UTC in minutes of UTC. - """ + def utcoffset(self, dt: Optional[datetime]) -> timedelta: + """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. - """ + def dst(self, dt: Optional[datetime]) -> timedelta: + """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 + def tzname(self, dt: Optional[datetime]) -> str: + """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. - """ + def _isdst(self, dt: Optional[datetime]) -> bool: + """Returns true if DST is active for given datetime object dt.""" + if dt is None: + raise Exception("datetime object dt was None!") tt = ( dt.year, dt.month, diff --git a/src/py.typed b/src/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_date.py b/tests/test_date.py index 36b6fde..e4d391d 100644 --- a/tests/test_date.py +++ b/tests/test_date.py @@ -1,8 +1,7 @@ -""" -Test cases for the isodate module. -""" +"""Test cases for the isodate module.""" from datetime import date +from typing import Optional import pytest @@ -26,9 +25,9 @@ # 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 = { +# is expected. The test cases are grouped into dates with 4 digit years and +# 6 digit years. +TEST_CASES: list[tuple[int, str, Optional[date], str]] = [ # yeardigits = 4 (4, "19", date(1901, 1, 1), DATE_CENTURY), (4, "1985", date(1985, 1, 1), DATE_YEAR), @@ -57,11 +56,12 @@ (6, "+001985-W15-5", date(1985, 4, 12), DATE_EXT_WEEK_COMPLETE), (6, "+001985W15", date(1985, 4, 8), DATE_BAS_WEEK), (6, "+001985-W15", date(1985, 4, 8), DATE_EXT_WEEK), -} +] -@pytest.mark.parametrize("yeardigits,datestring,expected,_", TEST_CASES) -def test_parse(yeardigits, datestring, expected, _): +@pytest.mark.parametrize("yeardigits, datestring, expected, _", TEST_CASES) +def test_parse(yeardigits: int, datestring: str, expected: Optional[date], _): + """Parse dates and verify result.""" if expected is None: with pytest.raises(ISO8601Error): parse_date(datestring, yeardigits) @@ -71,13 +71,16 @@ def test_parse(yeardigits, datestring, expected, _): @pytest.mark.parametrize("yeardigits, datestring, expected, format", TEST_CASES) -def test_format(yeardigits, datestring, expected, format): - """ - Take date object and create ISO string from it. +def test_format(yeardigits: int, datestring: str, expected: Optional[date], format: str): + """Format date objects to ISO strings. + This is the reverse test to test_parse. """ if expected is None: + # TODO: a bit of assymetry here, if parse raises an error, + # then format raises an AttributeError + # with typing this also raises a type error with pytest.raises(AttributeError): - date_isoformat(expected, format, yeardigits) + date_isoformat(expected, format, yeardigits) # type: ignore [arg-type] else: assert date_isoformat(expected, format, yeardigits) == datestring diff --git a/tests/test_datetime.py b/tests/test_datetime.py index d86d84d..d230694 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -1,8 +1,7 @@ -""" -Test cases for the isodatetime module. -""" +"""Test cases for the isodatetime module.""" from datetime import datetime +from typing import Optional import pytest @@ -30,7 +29,7 @@ # 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 = [ +TEST_CASES: list[tuple[str, Optional[datetime], str, str]] = [ ( "19850412T1015", datetime(1985, 4, 12, 10, 15), @@ -140,10 +139,8 @@ @pytest.mark.parametrize("datetimestring, expected, format, output", TEST_CASES) -def test_parse(datetimestring, expected, format, output): - """ - Parse an ISO datetime string and compare it to the expected value. - """ +def test_parse(datetimestring: str, expected: Optional[datetime], format: str, output: str): + """Parse an ISO datetime string and compare it to the expected value.""" if expected is None: with pytest.raises(ISO8601Error): parse_datetime(datetimestring) @@ -152,13 +149,13 @@ def test_parse(datetimestring, expected, format, output): @pytest.mark.parametrize("datetimestring, expected, format, output", TEST_CASES) -def test_format(datetimestring, expected, format, output): - """ - Take datetime object and create ISO string from it. +def test_format(datetimestring: str, expected: Optional[datetime], format: str, output: str): + """Take datetime object and create ISO string from it. + This is the reverse test to test_parse. """ if expected is None: with pytest.raises(AttributeError): - datetime_isoformat(expected, format) + datetime_isoformat(expected, format) # type: ignore [arg-type] else: assert datetime_isoformat(expected, format) == output diff --git a/tests/test_duration.py b/tests/test_duration.py index 9bf26b4..072d38e 100644 --- a/tests/test_duration.py +++ b/tests/test_duration.py @@ -1,6 +1,7 @@ """Test cases for the isoduration module.""" from datetime import date, datetime, timedelta +from typing import Optional, Union import pytest @@ -17,7 +18,7 @@ # 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 = ( +PARSE_TEST_CASES: list[tuple[str, Union[Duration, timedelta], str, Optional[str]]] = [ ("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), @@ -51,7 +52,35 @@ # alternative format ("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), +] + + +@pytest.mark.parametrize( + "durationstring, expectation, format, altstr", + PARSE_TEST_CASES, +) +def test_parse(durationstring, expectation, format, altstr): + """Parse an ISO duration string and compare it to the expected value.""" + result = parse_duration(durationstring) + assert result == expectation + + +@pytest.mark.parametrize( + "durationstring, expectation, format, altstr", + PARSE_TEST_CASES, ) +def test_format_parse(durationstring, expectation, format, altstr): + """Take duration/timedelta object and create ISO string from it. + + This is the reverse test to test_parse. + """ + if altstr: + assert duration_isoformat(expectation, format) == altstr + else: + # if durationstring == '-P2W': + # import pdb; pdb.set_trace() + assert duration_isoformat(expectation, format) == durationstring + # d1 d2 '+', '-', '>' # A list of test cases to test addition and subtraction between datetime and @@ -59,7 +88,7 @@ # 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 = ( +MATH_TEST_CASES: list[tuple[str, str, str, str, Optional[bool]]] = [ ( "P5Y7M1DT9H45M16.72S", "PT27M24.68S", @@ -90,13 +119,58 @@ "-P1331DT23H54M58.38S", False, ), -) +] + + +@pytest.mark.parametrize("dur1, dur2, resadd, ressub, resge", MATH_TEST_CASES) +def test_add(dur1: str, dur2: str, resadd: str, ressub: str, resge: Optional[bool]): + """Test operator - (__add__, __radd__).""" + duration1 = parse_duration(dur1) + duration2 = parse_duration(dur2) + result_add = parse_duration(resadd) + assert duration1 + duration2 == result_add + + +@pytest.mark.parametrize("dur1, dur2, resadd, ressub, resge", MATH_TEST_CASES) +def test_sub(dur1: str, dur2: str, resadd: str, ressub: str, resge: Optional[bool]): + """Test operator - (__sub__, __rsub__).""" + duration1 = parse_duration(dur1) + duration2 = parse_duration(dur2) + result_sub = parse_duration(ressub) + assert duration1 - duration2 == result_sub + + +@pytest.mark.parametrize("dur1, dur2, resadd, ressub, resge", MATH_TEST_CASES) +def test_ge(dur1: str, dur2: str, resadd: str, ressub: str, resge: Optional[bool]): + """Test operator > and <.""" + duration1 = parse_duration(dur1) + duration2 = parse_duration(dur2) + + def dogetest(d1: Union[timedelta, Duration], d2: Union[timedelta, Duration]): + """Test greater than.""" + # ignore type assertion as we are testing the error + return d1 > d2 # type: ignore [operator] + + def doletest(d1: Union[timedelta, Duration], d2: Union[timedelta, Duration]): + """Test less than.""" + # ignore type assertion as we are testing the error + return d1 < d2 # type: ignore [operator] + + if resge is None: + with pytest.raises(TypeError): + dogetest(duration1, duration2) + with pytest.raises(TypeError): + doletest(duration1, duration2) + else: + # resge says if greater so testing comparison result directly against config value. + assert dogetest(duration1, duration2) is resge + assert doletest(duration1, duration2) is not resge # 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_TEST_CASES: list[tuple[Union[date, datetime], Union[timedelta, Duration], Duration]] = [ ( date(2008, 2, 29), timedelta(days=10, hours=12, minutes=20), @@ -138,11 +212,32 @@ Duration(years=1, months=1, days=10, hours=12, minutes=20), Duration(months=13, days=10, hours=12, minutes=20), ), -) +] + + +@pytest.mark.parametrize("start, tdelta, duration", DATE_TEST_CASES) +def test_add_date( + start: Union[date, datetime], tdelta: Union[timedelta, Duration], duration: Duration +): + assert start + tdelta == start + duration + + +@pytest.mark.parametrize("start, tdelta, duration", DATE_TEST_CASES) +def test_sub_date( + start: Union[date, datetime], tdelta: Union[timedelta, Duration], duration: Duration +): + assert start - tdelta == start - duration + # A list of test cases of addition of date/datetime and Duration. The results # are compared against a given expected result. -DATE_CALC_TEST_CASES = ( +DATE_CALC_TEST_CASES: list[ + tuple[ + Union[timedelta, date, datetime, Duration], + Union[Duration, date, datetime, timedelta], + Optional[Union[date, datetime, Duration]], + ] +] = [ (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)), @@ -202,11 +297,26 @@ # (date(2000, 1, 1), # Duration(years=1, months=1.5), # date(2001, 2, 14)), -) +] + + +@pytest.mark.parametrize("start, duration, expectation", DATE_CALC_TEST_CASES) +def test_calc_date( + start: Union[timedelta, date, datetime, Duration], + duration: Union[Duration, datetime, timedelta], + expectation: Optional[Union[date, datetime, Duration]], +): + """Test operator +.""" + if expectation is None: + with pytest.raises(ValueError): + start + duration # type: ignore [operator] + else: + assert start + duration == expectation # type: ignore [operator] + # A list of test cases of multiplications of durations # are compared against a given expected result. -DATE_MUL_TEST_CASES = ( +DATE_MUL_TEST_CASES: list[tuple[Union[Duration, int], Union[Duration, int], Duration]] = [ (Duration(years=1, months=1), 3, Duration(years=3, months=3)), (Duration(years=1, months=1), -3, Duration(years=-3, months=-3)), (3, Duration(years=1, months=1), Duration(years=3, months=3)), @@ -214,7 +324,15 @@ (5, Duration(years=2, minutes=40), Duration(years=10, hours=3, minutes=20)), (-5, Duration(years=2, minutes=40), Duration(years=-10, hours=-3, minutes=-20)), (7, Duration(years=1, months=2, weeks=40), Duration(years=8, months=2, weeks=280)), -) +] + + +@pytest.mark.parametrize("operand1, operand2, expectation", DATE_MUL_TEST_CASES) +def test_mul_date( + operand1: Union[Duration, int], operand2: Union[Duration, int], expectation: Duration +): + """Test operator *.""" + assert operand1 * operand2 == expectation # type: ignore [operator] def test_associative(): @@ -230,23 +348,23 @@ def test_associative(): def test_typeerror(): """Test if TypError is raised with certain parameters.""" with pytest.raises(TypeError): - parse_duration(date(2000, 1, 1)) + parse_duration(date(2000, 1, 1)) # type: ignore [arg-type] with pytest.raises(TypeError): - Duration(years=1) - date(2000, 1, 1) + Duration(years=1) - date(2000, 1, 1) # type: ignore [operator] with pytest.raises(TypeError): - "raise exc" - Duration(years=1) + "raise exc" - Duration(years=1) # type: ignore [operator] with pytest.raises(TypeError): - Duration(years=1, months=1, weeks=5) + "raise exception" + Duration(years=1, months=1, weeks=5) + "raise exception" # type: ignore [operator] with pytest.raises(TypeError): - "raise exception" + Duration(years=1, months=1, weeks=5) + "raise exception" + Duration(years=1, months=1, weeks=5) # type: ignore [operator] with pytest.raises(TypeError): Duration(years=1, months=1, weeks=5) * "raise exception" with pytest.raises(TypeError): "raise exception" * Duration(years=1, months=1, weeks=5) with pytest.raises(TypeError): - Duration(years=1, months=1, weeks=5) * 3.14 + Duration(years=1, months=1, weeks=5) * 3.14 # type: ignore [operator] with pytest.raises(TypeError): - 3.14 * Duration(years=1, months=1, weeks=5) + 3.14 * Duration(years=1, months=1, weeks=5) # type: ignore [operator] def test_parseerror(): @@ -338,99 +456,3 @@ def test_totimedelta(): assert dur.totimedelta(datetime(2000, 2, 25)) == timedelta(60) assert dur.totimedelta(datetime(2001, 2, 25)) == timedelta(59) assert dur.totimedelta(datetime(2001, 3, 25)) == timedelta(61) - - -@pytest.mark.parametrize( - "durationstring, expectation, format, altstr", - PARSE_TEST_CASES, -) -def test_parse(durationstring, expectation, format, altstr): - """Parse an ISO duration string and compare it to the expected value.""" - result = parse_duration(durationstring) - assert result == expectation - - -@pytest.mark.parametrize( - "durationstring, expectation, format, altstr", - PARSE_TEST_CASES, -) -def test_format_parse(durationstring, expectation, format, altstr): - """Take duration/timedelta object and create ISO string from it. - - This is the reverse test to test_parse. - """ - if altstr: - assert duration_isoformat(expectation, format) == altstr - else: - # if durationstring == '-P2W': - # import pdb; pdb.set_trace() - assert duration_isoformat(expectation, format) == durationstring - - -@pytest.mark.parametrize("dur1, dur2, resadd, ressub, resge", MATH_TEST_CASES) -def test_add(dur1, dur2, resadd, ressub, resge): - dur1 = parse_duration(dur1) - dur2 = parse_duration(dur2) - resadd = parse_duration(resadd) - assert dur1 + dur2 == resadd - - -@pytest.mark.parametrize("dur1, dur2, resadd, ressub, resge", MATH_TEST_CASES) -def test_sub(dur1, dur2, resadd, ressub, resge): - """ - Test operator - (__sub__, __rsub__) - """ - dur1 = parse_duration(dur1) - dur2 = parse_duration(dur2) - ressub = parse_duration(ressub) - assert dur1 - dur2 == ressub - - -@pytest.mark.parametrize("dur1, dur2, resadd, ressub, resge", MATH_TEST_CASES) -def test_ge(dur1, dur2, resadd, ressub, resge): - """Test operator > and <.""" - dur1 = parse_duration(dur1) - dur2 = parse_duration(dur2) - - def dogetest(): - """Test greater than.""" - return dur1 > dur2 - - def doletest(): - """Test less than.""" - return dur1 < dur2 - - if resge is None: - with pytest.raises(TypeError): - dogetest() - with pytest.raises(TypeError): - doletest() - else: - assert dogetest() is resge - assert doletest() is not resge - - -@pytest.mark.parametrize("start, tdelta, duration", DATE_TEST_CASES) -def test_add_date(start, tdelta, duration): - assert start + tdelta == start + duration - - -@pytest.mark.parametrize("start, tdelta, duration", DATE_TEST_CASES) -def test_sub_date(start, tdelta, duration): - assert start - tdelta == start - duration - - -@pytest.mark.parametrize("start, duration, expectation", DATE_CALC_TEST_CASES) -def test_calc_date(start, duration, expectation): - """Test operator +.""" - if expectation is None: - with pytest.raises(ValueError): - start + duration - else: - assert start + duration == expectation - - -@pytest.mark.parametrize("operand1, operand2, expectation", DATE_MUL_TEST_CASES) -def test_mul_date(operand1, operand2, expectation): - """Test operator *.""" - assert operand1 * operand2 == expectation diff --git a/tests/test_strf.py b/tests/test_strf.py index 7249c89..43af617 100644 --- a/tests/test_strf.py +++ b/tests/test_strf.py @@ -7,7 +7,7 @@ from isodate import DT_EXT_COMPLETE, LOCAL, strftime, tzinfo -TEST_CASES = ( +TEST_CASES: list[tuple[datetime, str, str]] = [ ( datetime(2012, 12, 25, 13, 30, 0, 0, LOCAL), DT_EXT_COMPLETE, @@ -30,7 +30,7 @@ "%Y-%m-%dT%H:%M:%S.%f", "2012-10-12T08:29:46.691780", ), -) +] @pytest.fixture @@ -38,7 +38,7 @@ def tz_patch(monkeypatch): # local time zone mock function localtime_orig = time.localtime - def localtime_mock(secs): + def localtime_mock(secs: int): """Mock time to fixed date. Mock time.localtime so that it always returns a time_struct with tm_dst=1 @@ -49,7 +49,7 @@ def localtime_mock(secs): dst = 1 else: dst = 0 - tt = ( + new_tt = ( tt.tm_year, tt.tm_mon, tt.tm_mday, @@ -60,7 +60,7 @@ def localtime_mock(secs): tt.tm_yday, dst, ) - return time.struct_time(tt) + return time.struct_time(new_tt) monkeypatch.setattr(time, "localtime", localtime_mock) # assume LOC = +10:00 @@ -71,7 +71,7 @@ def localtime_mock(secs): @pytest.mark.parametrize("dt, format, expectation", TEST_CASES) -def test_format(tz_patch, dt, format, expectation): +def test_format(tz_patch, dt: datetime, format: str, expectation: str): """Take date object and create ISO string from it. This is the reverse test to test_parse. diff --git a/tests/test_time.py b/tests/test_time.py index b605b93..b2d9395 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,8 +1,7 @@ -""" -Test cases for the isotime module. -""" +"""Test cases for the isotime module.""" from datetime import time +from typing import Optional import pytest @@ -25,7 +24,7 @@ # 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 = [ +TEST_CASES: list[tuple[str, Optional[time], Optional[str]]] = [ ("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), @@ -105,10 +104,8 @@ @pytest.mark.parametrize("timestring, expectation, format", TEST_CASES) -def test_parse(timestring, expectation, format): - """ - Parse an ISO time string and compare it to the expected value. - """ +def test_parse(timestring: str, expectation: Optional[time], format: Optional[str]): + """Parse an ISO time string and compare it to the expected value.""" if expectation is None: with pytest.raises(ISO8601Error): parse_time(timestring) @@ -117,13 +114,13 @@ def test_parse(timestring, expectation, format): @pytest.mark.parametrize("timestring, expectation, format", TEST_CASES) -def test_format(timestring, expectation, format): - """ - Take time object and create ISO string from it. +def test_format(timestring: str, expectation: Optional[time], format: Optional[str]): + """Take time object and create ISO string from it. + This is the reverse test to test_parse. """ if expectation is None: with pytest.raises(AttributeError): - time_isoformat(expectation, format) + time_isoformat(expectation, format) # type: ignore [arg-type] elif format is not None: assert time_isoformat(expectation, format) == timestring diff --git a/tox.ini b/tox.ini index 779a90f..cc32bf7 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ isolated_build = True envlist = lint - py{37, 38, 39, 310, 311, 312, py39} + py{39, 310, 311, 312, py39} [testenv] deps =