jenkins-bot has submitted this change. (
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1176834?usp=email )
Change subject: [IMPR] Refactor WbTime
......................................................................
[IMPR] Refactor WbTime
- refactor Initializer
- add re.compiled _timestr_re class attribute for fromTimestr
- refactor fromTimestamp
- force keyword arguments for initializer, fromTimestr, fromTimestamp
- update docstrings
- tests added for normalize handlers
- update other TestWbTime tests
Change-Id: I2757d10b0bed2e604b4fb7c29fd0f3e393ea3fe1
---
M pywikibot/_wbtypes.py
M tests/wbtypes_tests.py
2 files changed, 250 insertions(+), 150 deletions(-)
Approvals:
jenkins-bot: Verified
Xqt: Looks good to me, approved
diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py
index 9efaf58..eb15038 100644
--- a/pywikibot/_wbtypes.py
+++ b/pywikibot/_wbtypes.py
@@ -414,19 +414,26 @@
12: 334, # Nov -> Dec: 30 days, plus 304 days in Jan -> Nov
}
- def __init__(self,
- year: int | None = None,
- month: int | None = None,
- day: int | None = None,
- hour: int | None = None,
- minute: int | None = None,
- second: int | None = None,
- precision: int | str | None = None,
- before: int = 0,
- after: int = 0,
- timezone: int = 0,
- calendarmodel: str | None = None,
- site: DataSite | None = None) -> None:
+ _timestr_re = re.compile(
+ r'([-+]?\d{1,16})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z')
+
+ @deprecate_positionals(since='10.4.0')
+ def __init__(
+ self,
+ year: int,
+ month: int | None = None,
+ day: int | None = None,
+ hour: int | None = None,
+ minute: int | None = None,
+ second: int | None = None,
+ *,
+ precision: int | str | None = None,
+ before: int = 0,
+ after: int = 0,
+ timezone: int = 0,
+ calendarmodel: str | None = None,
+ site: DataSite | None = None
+ ) -> None:
"""Create a new WbTime object.
The precision can be set by the Wikibase int value (0-14) or by
@@ -455,6 +462,11 @@
*precision* value 'millenia' is deprecated; 'millennium' must
be used instead.
+ .. versionchanged:: 10.4
+ The parameters except timestamp values are now keyword-only.
+ A TypeError is raised if *year* is not an int. Previously, a
+ ValueError was raised if *year* was None.
+
:param year: The year as a signed integer of between 1 and 16
digits.
:param month: Month of the timestamp, if it exists.
@@ -476,56 +488,55 @@
:param site: The Wikibase site. If not provided, retrieves the
data repository from the default site from user-config.py.
Only used if calendarmodel is not given.
+ :raises TypeError: Invalid *year* type.
+ :raises ValueError: Invalid *precision* or *site* or default
+ site has no data repository.
"""
- if year is None:
- raise ValueError('no year given')
- self.precision = self.PRECISION['year']
- if month is not None:
- self.precision = self.PRECISION['month']
- else:
- month = 1
- if day is not None:
- self.precision = self.PRECISION['day']
- else:
- day = 1
- if hour is not None:
- self.precision = self.PRECISION['hour']
- else:
- hour = 0
- if minute is not None:
- self.precision = self.PRECISION['minute']
- else:
- minute = 0
- if second is not None:
- self.precision = self.PRECISION['second']
- else:
- second = 0
+ if not isinstance(year, int):
+ raise TypeError(f'year must be an int, not {type(year).__name__}')
+
+ units = [
+ ('month', month, 1),
+ ('day', day, 1),
+ ('hour', hour, 0),
+ ('minute', minute, 0),
+ ('second', second, 0),
+ ]
+
+ # set unit attribute values
self.year = year
- self.month = month
- self.day = day
- self.hour = hour
- self.minute = minute
- self.second = second
+ for unit, value, default in units:
+ setattr(self, unit, value if value is not None else default)
+
+ if precision is None:
+ # Autodetection of precision based on the passed time values
+ prec = self.PRECISION['year']
+
+ for unit, value, _ in units:
+ if value is not None:
+ prec = self.PRECISION[unit]
+ else:
+ # explicit precision is given
+ if (isinstance(precision, int)
+ and precision in self.PRECISION.values()):
+ prec = precision
+ elif precision in self.PRECISION:
+ prec = self.PRECISION[precision]
+ else:
+ raise ValueError(f'Invalid precision: "{precision}"')
+
+ self.precision = prec
self.after = after
self.before = before
self.timezone = timezone
if calendarmodel is None:
+ site = site or pywikibot.Site().data_repository()
if site is None:
- site = pywikibot.Site().data_repository()
- if site is None:
- raise ValueError(
- f'Site {pywikibot.Site()} has no data repository')
+ raise ValueError(
+ f'Site {pywikibot.Site()} has no data repository')
calendarmodel = site.calendarmodel()
+
self.calendarmodel = calendarmodel
- # if precision is given it overwrites the autodetection above
- if precision is not None:
- if (isinstance(precision, int)
- and precision in self.PRECISION.values()):
- self.precision = precision
- elif precision in self.PRECISION:
- self.precision = self.PRECISION[precision]
- else:
- raise ValueError(f'Invalid precision: "{precision}"')
def _getSecondsAdjusted(self) -> int:
"""Return an internal representation of the time object as seconds.
@@ -621,60 +632,78 @@
return self._getSecondsAdjusted() == other._getSecondsAdjusted()
@classmethod
- def fromTimestr(cls,
- datetimestr: str,
- precision: int | str = 14,
- before: int = 0,
- after: int = 0,
- timezone: int = 0,
- calendarmodel: str | None = None,
- site: DataSite | None = None) -> WbTime:
+ @deprecate_positionals(since='10.4.0')
+ def fromTimestr(
+ cls,
+ datetimestr: str,
+ *,
+ precision: int | str = 14,
+ before: int = 0,
+ after: int = 0,
+ timezone: int = 0,
+ calendarmodel: str | None = None,
+ site: DataSite | None = None
+ ) -> WbTime:
"""Create a new WbTime object from a UTC date/time string.
- The timestamp differs from ISO 8601 in that:
+ The timestamp format must match a string resembling ISO 8601
+ with the following constraints:
- * The year is always signed and having between 1 and 16 digits;
- * The month, day and time are zero if they are unknown;
- * The Z is discarded since time zone is determined from the timezone
- param.
+ - Year is signed and can have between 1 and 16 digits.
+ - Month, day, hour, minute and second are always two digits.
+ They may be zero.
+ - Time is always in UTC and ends with ``Z``.
+ - Example: ``+0000000000123456-01-01T00:00:00Z``.
- :param datetimestr: Timestamp in a format resembling ISO 8601,
- e.g. +2013-01-01T00:00:00Z
- :param precision: The unit of the precision of the time. Defaults to
- 14 (second).
- :param before: Number of units after the given time it could be, if
- uncertain. The unit is given by the precision.
- :param after: Number of units before the given time it could be, if
- uncertain. The unit is given by the precision.
- :param timezone: Timezone information in minutes.
+ .. versionchanged:: 10.4
+ The parameters except *datetimestr* are now keyword-only.
+
+ :param datetimestr: Timestamp string to parse
+ :param precision: The unit of the precision of the time. Defaults
+ to 14 (second).
+ :param before: Number of units after the given time it could be,
+ if uncertain. The unit is given by the precision.
+ :param after: Number of units before the given time it could be,
+ if uncertain. The unit is given by the precision.
+ :param timezone: Timezone offset in minutes.
:param calendarmodel: URI identifying the calendar model.
- :param site: The Wikibase site. If not provided, retrieves the data
- repository from the default site from user-config.py.
+ :param site: The Wikibase site. If not provided, retrieves the
+ data repository from the default site from user-config.py.
Only used if calendarmodel is not given.
+ :raises ValueError: If the string does not match the expected
+ format.
"""
- match = re.match(r'([-+]?\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)Z',
- datetimestr)
+ match = cls._timestr_re.match(datetimestr)
if not match:
raise ValueError(f"Invalid format: '{datetimestr}'")
+
t = match.groups()
return cls(int(t[0]), int(t[1]), int(t[2]),
int(t[3]), int(t[4]), int(t[5]),
- precision, before, after, timezone, calendarmodel, site)
+ precision=precision, before=before, after=after,
+ timezone=timezone, calendarmodel=calendarmodel, site=site)
@classmethod
- def fromTimestamp(cls,
- timestamp: Timestamp,
- precision: int | str = 14,
- before: int = 0,
- after: int = 0,
- timezone: int = 0,
- calendarmodel: str | None = None,
- site: DataSite | None = None,
- copy_timezone: bool = False) -> WbTime:
+ @deprecate_positionals(since='10.4.0')
+ def fromTimestamp(
+ cls,
+ timestamp: Timestamp,
+ *,
+ precision: int | str = 14,
+ before: int = 0,
+ after: int = 0,
+ timezone: int = 0,
+ calendarmodel: str | None = None,
+ site: DataSite | None = None,
+ copy_timezone: bool = False
+ ) -> WbTime:
"""Create a new WbTime object from a pywikibot.Timestamp.
.. versionchanged:: 8.0
Added *copy_timezone* parameter.
+ .. versionchanged:: 10.4
+ The parameters except *timestamp* are now keyword-only.
+
:param timestamp: Timestamp
:param precision: The unit of the precision of the time.
@@ -699,6 +728,89 @@
before=before, after=after, timezone=timezone,
calendarmodel=calendarmodel, site=site)
+ @staticmethod
+ def _normalize_millennium(year: int) -> int:
+ """Round the given year to the start of its millennium.
+
+ The rounding is performed towards positive infinity for positive
+ years and towards negative infinity for negative years.
+
+ .. versionadded:: 10.4
+
+ :param year: The year as an integer.
+ :return: The first year of the millennium containing the given
+ year.
+ """
+ # For negative years, floor rounds away from zero to correctly handle
+ # BCE dates. For positive years, ceil rounds up to the next
+ # millennium/century.
+ year_float = year / 1000
+ if year_float < 0:
+ year = math.floor(year_float)
+ else:
+ year = math.ceil(year_float)
+ return year * 1000
+
+ @staticmethod
+ def _normalize_century(year: int) -> int:
+ """Round the given year to the start of its century.
+
+ The rounding is performed towards positive infinity for positive
+ years and towards negative infinity for negative years.
+
+ .. versionadded:: 10.4
+
+ :param year: The year as an integer.
+ :return: The first year of the century containing the given year.
+ """
+ # For century, -1301 is the same century as -1400 but not -1401.
+ # Similar for 1901 and 2000 vs 2001.
+ year_float = year / 100
+ if year_float < 0:
+ year = math.floor(year_float)
+ else:
+ year = math.ceil(year_float)
+ return year * 100
+
+ @staticmethod
+ def _normalize_decade(year: int) -> int:
+ """Round the given year down to the start of its decade.
+
+ Unlike millennium or century normalization, this always
+ truncates towards zero.
+
+ .. versionadded:: 10.4
+
+ :param year: The year as an integer.
+ :return: The first year of the decade containing the given year.
+ """
+ # For decade, -1340 is the same decade as -1349 but not -1350.
+ # Similar for 2010 and 2019 vs 2020
+ year_float = year / 10
+ year = math.trunc(year_float)
+ return year * 10
+
+ @staticmethod
+ def _normalize_power_of_ten(year: int, precision: int) -> int:
+ """Round the year to the given power-of-ten precision.
+
+ This is used for very coarse historical precision levels, where
+ the time unit represents a power-of-ten number of years.
+
+ .. versionadded:: 10.4
+
+ :param year: The year as an integer.
+ :param precision: The precision level (Wikibase int value).
+ :return: The normalized year rounded to the nearest matching
+ power-of-ten boundary.
+ """
+ # Wikidata rounds the number based on the first non-decimal digit.
+ # Python's round function will round -15.5 to -16, and +15.5 to +16
+ # so we don't need to do anything complicated like the other
+ # examples.
+ power_of_10 = 10 ** (9 - precision)
+ return round(year / power_of_10) * power_of_10
+
def normalize(self) -> WbTime:
"""Normalizes the WbTime object to account for precision.
@@ -712,45 +824,24 @@
Normalization will delete timezone information if the precision
is less than or equal to DAY.
- Note: Normalized WbTime objects can only be compared to other
- normalized WbTime objects of the same precision. Normalization
- might make a WbTime object that was less than another WbTime object
- before normalization, greater than it after normalization, or vice
- versa.
+ .. note:: Normalized WbTime objects can only be compared to
+ other normalized WbTime objects of the same precision.
+ Normalization might make a WbTime object that was less than
+ another WbTime object before normalization, greater than it
+ after normalization, or vice versa.
"""
year = self.year
- # This is going to get messy.
- if self.PRECISION['1000000000'] <= self.precision <=
self.PRECISION['10000']: # noqa: E501
- # 1000000000 == 10^9
- power_of_10 = 10 ** (9 - self.precision)
- # Wikidata rounds the number based on the first non-decimal digit.
- # Python's round function will round -15.5 to -16, and +15.5 to +16
- # so we don't need to do anything complicated like the other
- # examples.
- year = round(year / power_of_10) * power_of_10
- elif self.precision == self.PRECISION['millennium']:
- # Similar situation with centuries
- year_float = year / 1000
- if year_float < 0:
- year = math.floor(year_float)
- else:
- year = math.ceil(year_float)
- year *= 1000
- elif self.precision == self.PRECISION['century']:
- # For century, -1301 is the same century as -1400 but not -1401.
- # Similar for 1901 and 2000 vs 2001.
- year_float = year / 100
- if year_float < 0:
- year = math.floor(year_float)
- else:
- year = math.ceil(year_float)
- year *= 100
- elif self.precision == self.PRECISION['decade']:
- # For decade, -1340 is the same decade as -1349 but not -1350.
- # Similar for 2010 and 2019 vs 2020
- year_float = year / 10
- year = math.trunc(year_float)
- year *= 10
+ for prec in 'millennium', 'century', 'decade':
+ if self.precision == self.PRECISION[prec]:
+ handler = getattr(self, '_normalize_' + prec)
+ year = handler(year)
+ break
+ else:
+ lower = self.PRECISION['1000000000']
+ upper = self.PRECISION['10000']
+ if lower <= self.precision <= upper:
+ year = self._normalize_power_of_ten(year, self.precision)
+
kwargs = {
'precision': self.precision,
'before': self.before,
@@ -758,18 +849,14 @@
'calendarmodel': self.calendarmodel,
'year': year
}
- if self.precision >= self.PRECISION['month']:
- kwargs['month'] = self.month
- if self.precision >= self.PRECISION['day']:
- kwargs['day'] = self.day
- if self.precision >= self.PRECISION['hour']:
- # See T326693
- kwargs['timezone'] = self.timezone
- kwargs['hour'] = self.hour
- if self.precision >= self.PRECISION['minute']:
- kwargs['minute'] = self.minute
- if self.precision >= self.PRECISION['second']:
- kwargs['second'] = self.second
+
+ for prec in 'month', 'day', 'hour', 'minute', 'second':
+ if self.precision >= self.PRECISION[prec]:
+ kwargs[prec] = getattr(self, prec)
+ if prec == 'hour':
+ # Add timezone, see T326693
+ kwargs['timezone'] = self.timezone
+
return type(self)(**kwargs)
@remove_last_args(['normalize']) # since 8.2.0
diff --git a/tests/wbtypes_tests.py b/tests/wbtypes_tests.py
index a321f92..a136ba6 100755
--- a/tests/wbtypes_tests.py
+++ b/tests/wbtypes_tests.py
@@ -335,12 +335,21 @@
self.assertNotEqual(t11, t12)
self.assertEqual(t11_normalized, t12_normalized)
self.assertEqual(t13.normalize().timezone, -300)
+ # test _normalize handler functions
+ self.assertEqual(pywikibot.WbTime._normalize_millennium(1301), 2000)
+ self.assertEqual(pywikibot.WbTime._normalize_millennium(-1301), -2000)
+ self.assertEqual(pywikibot.WbTime._normalize_century(1301), 1400)
+ self.assertEqual(pywikibot.WbTime._normalize_century(-1301), -1400)
+ self.assertEqual(pywikibot.WbTime._normalize_decade(1301), 1300)
+ self.assertEqual(pywikibot.WbTime._normalize_decade(-1301), -1300)
+ self.assertEqual(
+ pywikibot.WbTime._normalize_power_of_ten(123456, 7), 123500)
+ self.assertEqual(
+ pywikibot.WbTime._normalize_power_of_ten(-987654, 3), -1000000)
def test_WbTime_normalization_very_low_precision(self) -> None:
"""Test WbTime normalization with very low precision."""
repo = self.get_repo()
- # flake8 is being annoying, so to reduce line length, I'll make
- # some aliases here
year_10000 = pywikibot.WbTime.PRECISION['10000']
year_100000 = pywikibot.WbTime.PRECISION['100000']
year_1000000 = pywikibot.WbTime.PRECISION['1000000']
@@ -423,15 +432,18 @@
def test_WbTime_errors(self) -> None:
"""Test WbTime precision errors."""
repo = self.get_repo()
- regex = r'^no year given$'
- with self.assertRaisesRegex(ValueError, regex):
+ regex = '^year must be an int, not NoneType$'
+ with self.assertRaisesRegex(TypeError, regex):
+ pywikibot.WbTime(None, site=repo, precision=15)
+ regex = "missing 1 required positional argument: 'year'"
+ with self.assertRaisesRegex(TypeError, regex):
pywikibot.WbTime(site=repo, precision=15)
- with self.assertRaisesRegex(ValueError, regex):
+ with self.assertRaisesRegex(TypeError, regex):
pywikibot.WbTime(site=repo, precision='invalid_precision')
- regex = r'^Invalid precision: "15"$'
+ regex = '^Invalid precision: "15"$'
with self.assertRaisesRegex(ValueError, regex):
pywikibot.WbTime(site=repo, year=2020, precision=15)
- regex = r'^Invalid precision: "invalid_precision"$'
+ regex = '^Invalid precision: "invalid_precision"$'
with self.assertRaisesRegex(ValueError, regex):
pywikibot.WbTime(site=repo, year=2020,
precision='invalid_precision')
@@ -460,10 +472,11 @@
self.assertEqual(t2.second, 0)
self.assertEqual(t1.toTimestr(), '+00000002010-01-01T12:43:00Z')
self.assertEqual(t2.toTimestr(), '-00000002005-01-01T16:45:00Z')
- self.assertRaises(ValueError, pywikibot.WbTime, site=repo,
- precision=15)
- self.assertRaises(ValueError, pywikibot.WbTime, site=repo,
- precision='invalid_precision')
+ with self.assertRaisesRegex(ValueError, 'Invalid precision: "15"'):
+ pywikibot.WbTime(0, site=repo, precision=15)
+ with self.assertRaisesRegex(ValueError,
+ 'Invalid precision: "invalid_precision"'):
+ pywikibot.WbTime(0, site=repo, precision='invalid_precision')
self.assertIsInstance(t1.toTimestamp(), pywikibot.Timestamp)
self.assertRaises(ValueError, t2.toTimestamp)
--
To view, visit
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1176834?usp=email
To unsubscribe, or for help writing mail filters, visit
https://gerrit.wikimedia.org/r/settings?usp=email
Gerrit-MessageType: merged
Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: I2757d10b0bed2e604b4fb7c29fd0f3e393ea3fe1
Gerrit-Change-Number: 1176834
Gerrit-PatchSet: 6
Gerrit-Owner: Xqt <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
Gerrit-CC: Matěj Suchánek <[email protected]>
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]