From 01f1ec60eb9de4f59963827b86b0fe6e87967044 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Apr 2024 10:54:38 -0700 Subject: [PATCH] 13925 port fromisoformat from python 3.11 --- netbox/extras/models/customfields.py | 134 ++++++++++++++++++++++++--- 1 file changed, 123 insertions(+), 11 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 464518571..eb179a73f 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,6 +1,6 @@ import decimal import re -from datetime import datetime, date +from datetime import datetime, date, timezone import django_filters from django import forms @@ -599,7 +599,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): return filter_instance - def _parse_hh_mm_ss_ff(tstr): + def _parse_hh_mm_ss_ff(self, tstr): # Parses things of the form HH[:?MM[:?SS[{.,}fff[fff]]]] # TODO: Remove when drop python 3.10 len_str = len(tstr) @@ -641,12 +641,48 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): time_comps[3] = int(tstr[pos:(pos + to_parse)]) if to_parse < 6: + _FRACTION_CORRECTION = [100000, 10000, 1000, 100, 10] time_comps[3] *= _FRACTION_CORRECTION[to_parse - 1] if (len_remainder > to_parse and not all(map(_is_ascii_digit, tstr[(pos + to_parse):]))): raise ValueError(_("Non-digit values in unparsed fraction")) return time_comps + def _parse_isoformat_date(self, dtstr): + # It is assumed that this is an ASCII-only string of lengths 7, 8 or 10, + # see the comment on Modules/_datetimemodule.c:_find_isoformat_datetime_separator + assert len(dtstr) in (7, 8, 10) + year = int(dtstr[0:4]) + has_sep = dtstr[4] == '-' + + pos = 4 + has_sep + if dtstr[pos:pos + 1] == "W": + # YYYY-?Www-?D? + pos += 1 + weekno = int(dtstr[pos:pos + 2]) + pos += 2 + + dayno = 1 + if len(dtstr) > pos: + if (dtstr[pos:pos + 1] == '-') != has_sep: + raise ValueError("Inconsistent use of dash separator") + + pos += has_sep + + dayno = int(dtstr[pos:pos + 1]) + + return list(datetime._isoweek_to_gregorian(year, weekno, dayno)) + else: + month = int(dtstr[pos:pos + 2]) + pos += 2 + if (dtstr[pos:pos + 1] == "-") != has_sep: + raise ValueError("Inconsistent use of dash separator") + + pos += has_sep + day = int(dtstr[pos:pos + 2]) + + return [year, month, day] + def _parse_isoformat_time(self, tstr): # Format supported is HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]] # TODO: Remove when drop python 3.10 @@ -689,25 +725,101 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): hours=tz_comps[0], minutes=tz_comps[1], seconds=tz_comps[2], microseconds=tz_comps[3]) - tzi = datetime.timezone(tzsign * td) + tzi = timezone(tzsign * td) time_comps.append(tzi) return time_comps + # Helpers for parsing the result of isoformat() + def _is_ascii_digit(self, c): + return c in "0123456789" + + def _find_isoformat_datetime_separator(self, dtstr): + # See the comment in _datetimemodule.c:_find_isoformat_datetime_separator + len_dtstr = len(dtstr) + if len_dtstr == 7: + return 7 + + assert len_dtstr > 7 + date_separator = "-" + week_indicator = "W" + + if dtstr[4] == date_separator: + if dtstr[5] == week_indicator: + if len_dtstr < 8: + raise ValueError("Invalid ISO string") + if len_dtstr > 8 and dtstr[8] == date_separator: + if len_dtstr == 9: + raise ValueError("Invalid ISO string") + if len_dtstr > 10 and self._is_ascii_digit(dtstr[10]): + # This is as far as we need to resolve the ambiguity for + # the moment - if we have YYYY-Www-##, the separator is + # either a hyphen at 8 or a number at 10. + # + # We'll assume it's a hyphen at 8 because it's way more + # likely that someone will use a hyphen as a separator than + # a number, but at this point it's really best effort + # because this is an extension of the spec anyway. + # TODO(pganssle): Document this + return 8 + return 10 + else: + # YYYY-Www (8) + return 8 + else: + # YYYY-MM-DD (10) + return 10 + else: + if dtstr[4] == week_indicator: + # YYYYWww (7) or YYYYWwwd (8) + idx = 7 + while idx < len_dtstr: + if not self._is_ascii_digit(dtstr[idx]): + break + idx += 1 + + if idx < 9: + return idx + + if idx % 2 == 0: + # If the index of the last number is even, it's YYYYWwwd + return 7 + else: + return 8 + else: + # YYYYMMDD (8) + return 8 + def fromisoformat(self, date_string): - """Construct a date from a string in ISO 8601 format.""" - # TODO: Remove when drop python 3.10 + """Construct a datetime from a string in one of the ISO 8601 formats.""" if not isinstance(date_string, str): - raise TypeError(_('fromisoformat: argument must be str')) + raise TypeError('fromisoformat: argument must be str') - if len(date_string) not in (7, 8, 10): - raise ValueError(_('Invalid isoformat string')) + if len(date_string) < 7: + raise ValueError(f'Invalid isoformat string: {date_string!r}') + # Split this at the separator try: - return self._parse_isoformat_date(date_string) - except Exception: - raise ValueError(_('Invalid isoformat string')) + separator_location = self._find_isoformat_datetime_separator(date_string) + dstr = date_string[0:separator_location] + tstr = date_string[(separator_location + 1):] + + date_components = self._parse_isoformat_date(dstr) + except ValueError: + raise ValueError( + f'Invalid isoformat string: {date_string!r}') from None + + if tstr: + try: + time_components = self._parse_isoformat_time(tstr) + except ValueError: + raise ValueError( + f'Invalid isoformat string: {date_string!r}') from None + else: + time_components = [0, 0, 0, 0, None] + + return (date_components + time_components) def validate(self, value): """