#!/usr/bin/env python3 """One interpreter start for every measurement 99-python3-isodate makes. WHY ONE PROCESS. A python3 start on wasm32-hwjs is tens of seconds before the first line of a program runs, and this suite has ~110 things to say about isodate. Run as separate `python3 -c` invocations that would be well over an hour of interpreter starts and nothing else; run here it is ONE start and the work inside is a few hundred `re` matches over strings under 40 bytes. THE OUTPUT CONTRACT. One `key=value` line per measurement, keys emitted in file order, `probe_done=yes` last and `emitted_count=N` beside it. The suite asserts both, so a probe that died two thirds of the way through is distinguishable from a library that returned the wrong answers -- without them the early cases stay green and the later ones read got:'' , which looks like a partial library. EVERY VALUE GOES THROUGH repr() OR AN EXPLICIT TYPE NAME. isodate's whole subject is that a value and its rendering are different things, and its parsers return FOUR different types for inputs that print alike: a `date`, a `time`, a `timedelta` and isodate's own `Duration`. `str(timedelta(days=365))` and `str(Duration(years=1))` are not the same text, but `str()` of a parse result is still the wrong instrument for a suite whose failure mode is a type change -- `repr` names the class, so `datetime.timedelta(days=730)` and `isodate.duration.Duration(730, 0, 0, years=0, months=0)` cannot collapse. NOTHING HERE READS THE CLOCK OR THE ZONE. Every fixture is a literal and every comparison is against a literal. `LOCAL` is exercised only through what it IS (a tzinfo whose class is LocalTimezone), never through what offset it reports, because that offset is the host's -- and a suite asserting it would red on any image whose TZ moved rather than on a broken isodate. Measured against the upstream tests, which are themselves TZ-independent: 278 pass under TZ unset, UTC, America/New_York and Europe/Moscow alike. """ import sys import traceback from datetime import date, datetime, time, timedelta COUNT = 0 def emit(key, value): global COUNT COUNT += 1 sys.stdout.write("%s=%s\n" % (key, value)) def val(key, fn): """Emit fn()'s repr, or `raised:` -- never a traceback. A case that expects a value and gets an exception name still reds, and the class name is what says which. The traceback goes to stderr, which bst_probe keeps at .err, so nothing is lost by not putting it on a key line the runner would parse as N stray results. """ try: emit(key, repr(fn())) except Exception: traceback.print_exc() emit(key, "raised:%s" % sys.exc_info()[0].__name__) def zval(key, fn): """Emit a zoned time/datetime WITHOUT its tzinfo's repr. `repr(datetime.time(15, 4, 5, tzinfo=))` carries the object's ADDRESS, which is different on every run -- so a case asserting it would red at random. The naive part is compared as itself and the zone by its class name and offset, which is the whole of what the parse decided. """ try: v = fn() z = v.tzinfo emit(key, "%r|%s|%r" % (v.replace(tzinfo=None), type(z).__name__ if z is not None else "None", z.utcoffset(None) if z is not None else None)) except Exception: traceback.print_exc() emit(key, "raised:%s" % sys.exc_info()[0].__name__) def raises(key, exc, fn): """Emit `yes` iff fn() raises exc -- the failure paths, stated positively. `no` when it returned, and the class name when it raised something else: three outcomes, one key, and the red says which of the two wrong ones happened. """ try: fn() except exc: emit(key, "yes") return except Exception: emit(key, "raised:%s" % sys.exc_info()[0].__name__) return emit(key, "no") import isodate # noqa: E402 from isodate import Duration, ISO8601Error, UTC, FixedOffset, LOCAL # noqa: E402 def _is_iso(fn): """True iff fn() raises isodate's OWN exception rather than a plain one. ISO8601Error derives from ValueError, so `except ValueError` cannot tell the two apart -- and the difference is exactly what a caller needs: one says the STRING was not ISO, the other says it was ISO and named a value `datetime` will not hold. """ try: fn() except ISO8601Error: return True except Exception: return False return False # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Every behavioural key below is a statement about the # module named here, which is what ties them to the payload cases the suite # makes against the dpkg file list. emit("module_file", isodate.__file__) # ...and it is a PACKAGE, not a module. isodate ships nine submodules under one # directory; the opposite shape from python3-docopt's single file, and asserted # for the same reason -- a release that flattened it would be a different # import surface, and nothing behavioural would notice. emit("is_package", "yes" if hasattr(isodate, "__path__") else "no") emit("submodule_count", len([m for m in sys.modules if m.startswith("isodate.")])) # THE VERSION HAS EXACTLY ONE SOURCE HERE, and that is worth stating rather than # discovering. isodate defines NO `__version__` attribute: the installed # dist-info is the only statement of which release this is, so there is no # second reading to cross-check it against and no way for the two to disagree. emit("has_dunder_version", "yes" if hasattr(isodate, "__version__") else "no") try: import importlib.metadata as _md emit("dist_version", _md.version("isodate")) except Exception: emit("dist_version", "raised:%s" % sys.exc_info()[0].__name__) # THE PUBLISHED SURFACE. `__all__` is 48 names, and it is mostly CONSTANTS: ten # functions and one class against thirty-odd format strings. That ratio is the # package's design -- the formats are the API as much as the parsers are -- and # it is why the isoformat cases below assert against the module's own constants # rather than against hand-written patterns. emit("all_count", len(isodate.__all__)) emit("all_sorted_head", ",".join(sorted(isodate.__all__)[:5])) for _n in ("parse_date", "parse_time", "parse_datetime", "parse_duration", "parse_tzinfo", "date_isoformat", "time_isoformat", "datetime_isoformat", "duration_isoformat", "tz_isoformat", "strftime", "Duration", "ISO8601Error", "UTC", "FixedOffset", "LOCAL"): emit("has_%s" % _n, "yes" if _n in isodate.__all__ else "no") # THE DEPENDENCY THAT IS NOT PYTHON'S. isoduration.py does `from six import # string_types`, which is why the recipe's RDEPENDS names python3-six and why # this port would fail at IMPORT -- not at the first parse -- on an image # without it. Stated as a fact about the module, so that an import failure has # a case naming its cause rather than eighty reds naming symptoms. emit("six_imported", "yes" if "six" in sys.modules else "no") # ...AND THE ONE python3-numbers SUPPLIES. Both parse_time and parse_duration # do their fractional arithmetic in `Decimal`, so `decimal` -- and with it # `python3-numbers` -- is a hard runtime requirement of the parsers rather than # of the import. This is what the recipe's second RDEPENDS is for. import decimal # noqa: E402 emit("decimal_available", "yes") emit("decimal_module_file", decimal.__file__) # ON THIS IMAGE THE ACCELERATOR IS EXPECTED TO BE THE C ONE, COMPILED IN. # python3 here is one static binary: configure picks MODULE_BUILDTYPE=static # from host_cpu=wasm32, so every extension module -- `_decimal` among them -- is # linked into the interpreter instead of landing in the empty lib-dynload. So # `decimal` should resolve to _decimal's C implementation and NOT fall back to # the pure-Python _pydecimal, even though python3-numbers ships both. # PREDICTED, NOT MEASURED: this is derived from the recipe, and the batch verify # is the first time it runs on target. A red here is a statement about the # interpreter's static extension set, not about isodate -- which works either # way, _pydecimal being a complete implementation. emit("decimal_accelerated", "yes" if hasattr(decimal, "__libmpdec_version__") else "no") # ------------------------------------------------------------- parse_date --- # THE SIX FORMS ISO 8601 GIVES A CALENDAR DATE, all naming the SAME DAY -- # 2024-05-17, a Friday in week 20 of a leap year, day 138. Asserting them # against one day is what makes the set a statement about the parser rather # than six independent statements about six fixtures: a week-date parser that # was off by one, or an ordinal parser that forgot the leap day, lands on a # different date than its five neighbours and says so here. val("date_ext", lambda: isodate.parse_date("2024-05-17")) val("date_bas", lambda: isodate.parse_date("20240517")) val("date_ord_ext", lambda: isodate.parse_date("2024-138")) val("date_ord_bas", lambda: isodate.parse_date("2024138")) val("date_week_ext", lambda: isodate.parse_date("2024-W20-5")) val("date_week_bas", lambda: isodate.parse_date("2024W205")) # THE REDUCED-ACCURACY FORMS, where the parser must INVENT the components the # string does not carry. It chooses the first: the 1st of the month, January # for a bare year, and -- for a century -- the 1st of January of year 1 OF that # century, which is 2001 and not 2000. That last one is the surprise, and it is # the reason a century case is here at all. val("date_week_only", lambda: isodate.parse_date("2024-W20")) val("date_month_ext", lambda: isodate.parse_date("2024-05")) val("date_year", lambda: isodate.parse_date("2024")) val("date_century", lambda: isodate.parse_date("20")) # ...and the defaults are OVERRIDABLE, which is the half a caller reads the # signature for. `defaultmonth`/`defaultday` reach the year and month forms. val("date_year_defaults", lambda: isodate.parse_date("2024", defaultmonth=6, defaultday=15)) # THE EXPANDED FORM -- more than four year digits, with a MANDATORY sign. It is # the one call shape where `yeardigits` is not 4, and passing it flips # `expanded` on by itself. val("date_expanded", lambda: isodate.parse_date("+002024-05-17", 6)) # A four-digit year takes a sign only when asked, and this is the pair that # says so: the same string is a parse or an error depending on `expanded`. val("date_signed_ok", lambda: isodate.parse_date("+2024-05-17", 4, True)) raises("date_signed_refused", ISO8601Error, lambda: isodate.parse_date("+2024-05-17")) # --------------------------------------------------------- date_isoformat --- # EVERY DATE FORMAT CONSTANT THE MODULE PUBLISHES, against the same day. These # assert against `isodate.DATE_*` rather than against a hand-written pattern: # the constants ARE the API, so a suite writing '%Y-%m-%d' itself would keep # passing after a release changed what DATE_EXT_COMPLETE means. _D = date(2024, 5, 17) for _c in ("DATE_BAS_COMPLETE", "DATE_EXT_COMPLETE", "DATE_BAS_ORD_COMPLETE", "DATE_EXT_ORD_COMPLETE", "DATE_BAS_WEEK_COMPLETE", "DATE_EXT_WEEK_COMPLETE", "DATE_BAS_WEEK", "DATE_EXT_WEEK", "DATE_BAS_MONTH", "DATE_EXT_MONTH", "DATE_YEAR", "DATE_CENTURY"): val("fmt_%s" % _c.lower(), lambda c=_c: isodate.date_isoformat(_D, getattr(isodate, c))) # The constants' own text, so that a red above says whether the FORMAT moved or # the RENDERER did. Two keys, one for each half of the same statement. emit("const_date_ext_complete", isodate.DATE_EXT_COMPLETE) emit("const_date_ext_week_complete", isodate.DATE_EXT_WEEK_COMPLETE) # `yeardigits` other than 4 forces a SIGN into the output -- the rendering side # of the expanded form, and the only reason date_isoformat takes a third # argument. val("fmt_date_expanded", lambda: isodate.date_isoformat(_D, isodate.DATE_EXT_COMPLETE, 6)) # ------------------------------------------------- the date round trip ----- # BOTH DIRECTIONS, which is the property the ticket is written around and the # one neither half proves alone. # # Forward: a string parsed and rendered back through the constant matching its # own form returns the string. A parser and a renderer that were wrong in the # same direction would pass this. # Backward: a value rendered and re-parsed returns the value. That is what # catches the pair being wrong together -- the round trip closes on the DATE, # which is constructed here rather than parsed. for _n, _s, _c in (("ext", "2024-05-17", "DATE_EXT_COMPLETE"), ("bas", "20240517", "DATE_BAS_COMPLETE"), ("ord", "2024-138", "DATE_EXT_ORD_COMPLETE"), ("week", "2024-W20-5", "DATE_EXT_WEEK_COMPLETE")): val("rt_date_str_%s" % _n, lambda s=_s, c=_c: isodate.date_isoformat(isodate.parse_date(s), getattr(isodate, c))) val("rt_date_val_%s" % _n, lambda c=_c: isodate.parse_date( isodate.date_isoformat(_D, getattr(isodate, c)))) # ------------------------------------------------------------- parse_time --- # COMPLETE TIMES, both spellings, and the fractional second -- which is where # the Decimal arithmetic above earns its dependency. `.25` of a second is # 250000 microseconds exactly; a float path would land near it. val("time_ext", lambda: isodate.parse_time("15:04:05")) val("time_bas", lambda: isodate.parse_time("150405")) val("time_frac", lambda: isodate.parse_time("15:04:05.250000")) # A COMMA IS A DECIMAL SEPARATOR TOO. ISO 8601 says so and the module honours # it; a caller reading only the docstring's examples would never try it. val("time_frac_comma", lambda: isodate.parse_time("15:04:05,25")) # REDUCED ACCURACY, where the fraction belongs to the LAST component present -- # so `.5` of a minute is thirty seconds and `.5` of an hour is thirty minutes. # The parser carries the fraction down; these are the cases that say so. val("time_minute", lambda: isodate.parse_time("15:04")) val("time_minute_frac", lambda: isodate.parse_time("15:04.5")) val("time_hour_frac", lambda: isodate.parse_time("15.5")) # THE `T` DESIGNATOR IS ACCEPTED ON A BARE TIME, which the ISO grammar allows # and which is what lets parse_datetime hand its right-hand half over unchanged. val("time_t_prefix", lambda: isodate.parse_time("T15:04:05")) # THE ZONE, attached to a time: absent (naive), Z, and an offset in each of the # three spellings the standard gives it. zval("time_zulu", lambda: isodate.parse_time("15:04:05Z")) zval("time_off_ext", lambda: isodate.parse_time("15:04:05+05:30")) zval("time_off_bas", lambda: isodate.parse_time("15:04:05-0800")) zval("time_off_hour", lambda: isodate.parse_time("15:04:05+02")) val("time_naive_tz", lambda: isodate.parse_time("15:04:05").tzinfo) # --------------------------------------------------------- time_isoformat --- _T = time(15, 4, 5, 250000, UTC) for _c in ("TIME_BAS_COMPLETE", "TIME_EXT_COMPLETE", "TIME_BAS_MINUTE", "TIME_EXT_MINUTE", "TIME_HOUR"): val("fmt_%s" % _c.lower(), lambda c=_c: isodate.time_isoformat(_T, getattr(isodate, c))) # THE DEFAULT FORMAT CARRIES THE ZONE, which the constants above do not: it is # TIME_EXT_COMPLETE + TZ_EXT, so a zoned time rendered with no format argument # keeps its offset and a naive one renders nothing extra. Both halves. val("fmt_time_default_utc", lambda: isodate.time_isoformat(_T)) val("fmt_time_default_naive", lambda: isodate.time_isoformat(time(15, 4, 5))) val("fmt_time_default_off", lambda: isodate.time_isoformat(time(15, 4, 5, 0, FixedOffset(5, 30, "+05:30")))) # ------------------------------------------------- the time round trip ----- for _n, _s in (("ext", "15:04:05"), ("frac", "15:04:05.250000"), ("zulu", "15:04:05Z"), ("off", "15:04:05+05:30")): val("rt_time_str_%s" % _n, lambda s=_s: isodate.time_isoformat(isodate.parse_time(s))) zval("rt_time_val", lambda: isodate.parse_time(isodate.time_isoformat(_T))) # --------------------------------------------------------- parse_datetime --- # THE `T` IS LOAD-BEARING: parse_datetime splits on it and delegates the halves # to parse_date and parse_time, which is why it accepts any COMBINATION of # their forms -- an ordinal date against an extended time, a week date against # a basic one -- and why a missing `T` is its own error rather than a date # error. Every mixed form below is a real parse and is asserted as one. val("dt_ext", lambda: isodate.parse_datetime("2024-05-17T15:04:05")) val("dt_bas", lambda: isodate.parse_datetime("20240517T150405")) val("dt_ord", lambda: isodate.parse_datetime("2024-138T15:04:05")) val("dt_week", lambda: isodate.parse_datetime("2024-W20-5T15:04:05")) val("dt_frac", lambda: isodate.parse_datetime("2024-05-17T15:04:05.250000")) zval("dt_zulu", lambda: isodate.parse_datetime("2024-05-17T15:04:05Z")) zval("dt_off", lambda: isodate.parse_datetime("2024-05-17T15:04:05+05:30")) zval("dt_off_west", lambda: isodate.parse_datetime("2024-05-17T15:04:05-08:00")) val("dt_mixed", lambda: isodate.parse_datetime("20240517T15:04:05")) # ...and the ZONE SURVIVES the split-and-recombine, which is the one thing # `datetime.combine` could silently drop. val("dt_tzname", lambda: isodate.parse_datetime("2024-05-17T15:04:05+05:30").tzinfo.tzname(None)) val("dt_utcoffset", lambda: isodate.parse_datetime("2024-05-17T15:04:05+05:30").utcoffset()) # ----------------------------------------------------- datetime_isoformat --- _DT = datetime(2024, 5, 17, 15, 4, 5, 0, UTC) for _c in ("DT_BAS_COMPLETE", "DT_EXT_COMPLETE", "DT_BAS_ORD_COMPLETE", "DT_EXT_ORD_COMPLETE", "DT_BAS_WEEK_COMPLETE", "DT_EXT_WEEK_COMPLETE"): val("fmt_%s" % _c.lower(), lambda c=_c: isodate.datetime_isoformat(_DT, getattr(isodate, c))) val("fmt_dt_default", lambda: isodate.datetime_isoformat(_DT)) # THE DT_* CONSTANTS ARE COMPOSED, not written out -- DATE + 'T' + TIME + TZ -- # and this is the case that says so, so that a red in a `fmt_dt_*` above can be # read against which of its three parts moved. emit("const_dt_ext_complete", isodate.DT_EXT_COMPLETE) emit("dt_ext_is_composed", "yes" if isodate.DT_EXT_COMPLETE == (isodate.DATE_EXT_COMPLETE + "T" + isodate.TIME_EXT_COMPLETE + isodate.TZ_EXT) else "no") # --------------------------------------------- the datetime round trip ----- for _n, _s in (("ext", "2024-05-17T15:04:05Z"), ("off", "2024-05-17T15:04:05+05:30")): val("rt_dt_str_%s" % _n, lambda s=_s: isodate.datetime_isoformat(isodate.parse_datetime(s))) zval("rt_dt_val", lambda: isodate.parse_datetime(isodate.datetime_isoformat(_DT))) # --------------------------------------------------------- parse_duration --- # THE TYPE SWITCH IS THE WHOLE OF THIS SECTION. parse_duration returns a # `datetime.timedelta` when the string names neither years nor months, and # isodate's own `Duration` when it names either -- because a year is not a # fixed number of days and a timedelta cannot say "one year". A caller that # assumed one type gets the other from a string it did not choose, so the TYPE # is asserted for every case here, not just the value. for _n, _s in (("day", "P1D"), ("week", "P2W"), ("time", "PT1H30M"), ("full_nomonth", "P3DT4H5M6S"), ("frac_sec", "PT0.5S"), ("frac_comma", "PT0,5S"), ("neg", "-P1D"), ("zero", "P0D")): val("dur_%s" % _n, lambda s=_s: isodate.parse_duration(s)) emit("durtype_%s" % _n, type(isodate.parse_duration(_s)).__name__) # ...and the ones that name a year or a month, which come back as `Duration`. for _n, _s in (("year", "P1Y"), ("month", "P3M"), ("ym", "P1Y6M"), ("mixed", "P1Y2M3DT4H5M6S"), ("neg_year", "-P1Y")): val("dur_%s" % _n, lambda s=_s: isodate.parse_duration(s)) emit("durtype_%s" % _n, type(isodate.parse_duration(_s)).__name__) # THE ALTERNATIVE FORMAT -- a duration written as if it were a date-time. It # takes the SECOND branch of the parser, the one reached only when the period # regex fails, and it is the shape most callers never learn exists. val("dur_alt", lambda: isodate.parse_duration("P0001-02-03T04:05:06")) emit("durtype_alt", type(isodate.parse_duration("P0001-02-03T04:05:06")).__name__) # A WEEK IS SEVEN DAYS AND NOT A COMPONENT: `P2W` comes back as a timedelta of # 14 days, with nothing left that remembers it was written in weeks. That is # why `D_WEEK` below is a rendering choice rather than a round trip. val("dur_week_days", lambda: isodate.parse_duration("P2W").days) # ------------------------------------------------------ Duration semantics --- # WHY THE SECOND TYPE EXISTS, asserted against a FIXED DATE -- which is the # only way a year or a month has a length at all. Adding `P1Y2M` to 2024-05-17 # is a calendar operation, not an arithmetic one. _ANCHOR = date(2024, 5, 17) val("dur_add_year", lambda: _ANCHOR + isodate.parse_duration("P1Y")) val("dur_add_ym", lambda: _ANCHOR + isodate.parse_duration("P1Y2M")) val("dur_add_mixed", lambda: datetime(2024, 5, 17, 0, 0, 0) + isodate.parse_duration("P1Y2M3DT4H5M6S")) val("dur_sub_year", lambda: _ANCHOR - isodate.parse_duration("P1Y")) # THE MONTH-END CLAMP, which is the rule a caller cannot guess: adding one # month to the 31st of a 31-day month lands on the LAST day of the next, not on # the 1st of the one after. w3.org's adding-durations algorithm, and the single # most surprising thing Duration does. val("dur_clamp_month", lambda: date(2024, 1, 31) + isodate.parse_duration("P1M")) # ...and the leap-year edge of the same rule. val("dur_clamp_leap", lambda: date(2024, 2, 29) + isodate.parse_duration("P1Y")) # ONE YEAR IS NOT 365 DAYS UNTIL A DATE SAYS IT IS, which is what totimedelta # is for -- and 2024 being a leap year is what makes the number 366 here. val("dur_totimedelta", lambda: isodate.parse_duration("P1Y").totimedelta(start=_ANCHOR)) # EQUALITY IS BETWEEN THE TWO HALVES SEPARATELY -- years-and-months against # years-and-months, timedelta against timedelta -- so a Duration carrying no # years or months compares EQUAL to a plain timedelta, and one that does never # can. Both halves, because that asymmetry is the whole contract. emit("dur_eq_timedelta", repr(Duration(days=1) == timedelta(days=1))) emit("dur_ne_year", repr(Duration(years=1) == timedelta(days=365))) emit("dur_eq_months", repr(Duration(years=1) == Duration(months=12))) # The arithmetic Duration supports beyond dates: negation, integer # multiplication, and addition to another Duration. val("dur_negate", lambda: -isodate.parse_duration("P1Y2M")) val("dur_mul", lambda: isodate.parse_duration("P1Y") * 3) val("dur_add_dur", lambda: isodate.parse_duration("P1Y") + isodate.parse_duration("P6M")) # `Duration.__getattr__` DELEGATES to the timedelta it holds, so `.days` on a # Duration is the timedelta's days and says nothing about its years -- a # caller reading `.days` off a `P1Y` gets 0, not 365. emit("dur_year_days", repr(isodate.parse_duration("P1Y").days)) emit("dur_year_years", repr(int(isodate.parse_duration("P1Y").years))) # ----------------------------------------------------- duration_isoformat --- # THE RENDERING SIDE, against the module's own format constants. `P%P` is the # default and emits only the components that are non-zero -- so a zero duration # renders `P0D` rather than an empty `P`, which is the case that pins the # "at least one component has to be there" branch. for _n, _s in (("day", "P1D"), ("week", "P2W"), ("time", "PT1H30M"), ("year", "P1Y"), ("ym", "P1Y6M"), ("mixed", "P1Y2M3DT4H5M6S"), ("frac", "PT0.5S"), ("zero", "P0D"), ("neg", "-P1D")): val("dfmt_default_%s" % _n, lambda s=_s: isodate.duration_isoformat(isodate.parse_duration(s))) # D_WEEK renders whatever the duration's days DIVIDE into weeks -- it is a # choice made at render time, not a property the parse kept. val("dfmt_week_2w", lambda: isodate.duration_isoformat( isodate.parse_duration("P2W"), isodate.D_WEEK)) val("dfmt_week_10d", lambda: isodate.duration_isoformat( isodate.parse_duration("P10D"), isodate.D_WEEK)) # The alternative formats, which render a duration in the shape of a date-time. for _c in ("D_ALT_EXT", "D_ALT_BAS", "D_ALT_EXT_ORD", "D_ALT_BAS_ORD"): val("dfmt_%s" % _c.lower(), lambda c=_c: isodate.duration_isoformat( isodate.parse_duration("P1Y2M3DT4H5M6S"), getattr(isodate, c))) emit("const_d_default", isodate.D_DEFAULT) emit("const_d_week", isodate.D_WEEK) # ------------------------------------------- the duration round trip ------- # The forward direction for every duration form, and the backward direction # from a CONSTRUCTED Duration -- the same two directions the date and time # sections take, on the type that has two of them. for _n, _s in (("day", "P1D"), ("time", "PT1H30M"), ("year", "P1Y"), ("mixed", "P1Y2M3DT4H5M6S")): val("rt_dur_str_%s" % _n, lambda s=_s: isodate.duration_isoformat(isodate.parse_duration(s))) val("rt_dur_val_duration", lambda: isodate.parse_duration(isodate.duration_isoformat( Duration(years=1, months=2, days=3)))) val("rt_dur_val_timedelta", lambda: isodate.parse_duration(isodate.duration_isoformat( timedelta(days=3, hours=4)))) # ----------------------------------------------------------- parse_tzinfo --- # THE THREE ANSWERS: the UTC singleton for `Z`, a FixedOffset for an offset, # and None for the empty designator -- which means "local time", i.e. the # absence of a zone rather than the presence of the host's. emit("tz_z_is_utc", "yes" if isodate.parse_tzinfo("Z") is UTC else "no") val("tz_z_offset", lambda: isodate.parse_tzinfo("Z").utcoffset(None)) val("tz_ext", lambda: isodate.parse_tzinfo("+05:30").utcoffset(None)) val("tz_bas", lambda: isodate.parse_tzinfo("-0800").utcoffset(None)) val("tz_hour", lambda: isodate.parse_tzinfo("+02").utcoffset(None)) emit("tz_empty_none", "yes" if isodate.parse_tzinfo("") is None else "no") emit("tz_ext_class", type(isodate.parse_tzinfo("+05:30")).__name__) # The NAME a FixedOffset carries is the designator it was built from, which is # what makes a re-rendered offset readable rather than a bare number. val("tz_ext_name", lambda: isodate.parse_tzinfo("+05:30").tzname(None)) # THE LOCAL ZONE, asserted by WHAT IT IS and never by what it reports. `LOCAL` # is the module's tzinfo for the host's own zone; its offset is the image's TZ # and would red on any image whose zone moved, so the class is the assertion # and the offset is deliberately not one. emit("tz_local_class", type(LOCAL).__name__) emit("tz_local_is_tzinfo", "yes" if isinstance(LOCAL.utcoffset(datetime(2024, 5, 17)), timedelta) else "no") # tz_isoformat's three formats over the same offset -- %h, %z and %Z -- and the # two ends: UTC renders `Z`, and a naive value renders the empty string rather # than raising. _Z = datetime(2024, 5, 17, 15, 4, 5, 0, FixedOffset(5, 30, "+05:30")) val("tzfmt_hour", lambda: isodate.tz_isoformat(_Z, "%h")) val("tzfmt_bas", lambda: isodate.tz_isoformat(_Z, "%z")) val("tzfmt_ext", lambda: isodate.tz_isoformat(_Z, "%Z")) val("tzfmt_utc", lambda: isodate.tz_isoformat(_DT, "%Z")) val("tzfmt_naive", lambda: isodate.tz_isoformat(datetime(2024, 5, 17), "%Z")) # -------------------------------------------------------------- strftime --- # THE RENDERER UNDERNEATH ALL FOUR isoformat WRAPPERS, called directly. It # takes a SUBSET of Python's strftime codes plus a few of its own, and it is # not datetime.strftime: `%W` here is the ISO week number from isocalendar(), # where the stdlib's is a different week numbering. That divergence is the # reason this section exists rather than trusting the wrappers. val("strf_date", lambda: isodate.strftime(_D, "%Y-%m-%d")) val("strf_week", lambda: isodate.strftime(_D, "%W")) val("strf_weekday", lambda: isodate.strftime(_D, "%w")) val("strf_ordinal", lambda: isodate.strftime(_D, "%j")) val("strf_century", lambda: isodate.strftime(_D, "%C")) val("strf_percent", lambda: isodate.strftime(_D, "100%%")) # A NON-FOUR `yeardigits` PREFIXES A SIGN, on the renderer as on the parser. val("strf_expanded", lambda: isodate.strftime(_D, "%Y", 6)) # ...and over a duration, where the SAME code means something else entirely: # `%W` on a Duration is whole weeks of its timedelta, not a week-of-year. One # format string, two meanings, chosen by the type of the first argument. val("strf_dur_week", lambda: isodate.strftime(isodate.parse_duration("P14D"), "%W")) val("strf_dur_days", lambda: isodate.strftime(isodate.parse_duration("P14D"), "%d")) # ------------------------------------------------------- the failure path --- # WHAT A CALLER WRAPS IN try. ISO8601Error derives from ValueError, so an # existing `except ValueError` around a parse already catches it -- that is the # fact worth pinning, because a release that gave it a different base would # silently stop being caught by code that never mentioned isodate's own class. emit("err_base", ISO8601Error.__bases__[0].__name__) emit("err_is_valueerror", "yes" if issubclass(ISO8601Error, ValueError) else "no") raises("err_date_garbage", ISO8601Error, lambda: isodate.parse_date("not-a-date")) raises("err_time_garbage", ISO8601Error, lambda: isodate.parse_time("nope")) # ...and the OTHER way a time fails, which is not this module's exception at # all: `99:99:99` MATCHES the extended-complete pattern, so the parser accepts # it and `datetime.time` refuses the values. A caller catching only ISO8601Error # lets this one through. raises("err_time_range", ValueError, lambda: isodate.parse_time("99:99:99")) emit("err_time_range_not_iso", "no" if not _is_iso(lambda: isodate.parse_time("99:99:99")) else "yes") raises("err_datetime_no_t", ISO8601Error, lambda: isodate.parse_datetime("2024-05-17 15:04:05")) raises("err_duration_garbage", ISO8601Error, lambda: isodate.parse_duration("P")) raises("err_duration_nop", ISO8601Error, lambda: isodate.parse_duration("1Y")) # `parse_tzinfo` HAS NO FAILURE PATH IN PRACTICE, which its own docstring's # `@raise` would not tell you. Every group in TZ_REGEX is optional, so the # regex matches the empty prefix of ANYTHING -- `+`, a word, a whole sentence -- # and `build_tzinfo` turns that empty match into `None`, the "local time" # answer. The ISO8601Error branch below it is unreachable from a str argument. # So a caller validating a designator by catching an exception validates # nothing; this pair is what says so. raises("err_tz_plus_raises", ISO8601Error, lambda: isodate.parse_tzinfo("+")) emit("err_tz_plus_value", repr(isodate.parse_tzinfo("+"))) emit("err_tz_word_value", repr(isodate.parse_tzinfo("banana"))) # A NON-STRING IS A DIFFERENT FAULT AND A DIFFERENT EXCEPTION: parse_duration # type-checks its argument through six.string_types and raises TypeError, which # an `except ISO8601Error` does NOT catch. The one place in this API where the # error a caller gets is not the module's own. raises("err_duration_type", TypeError, lambda: isodate.parse_duration(1)) # A VALID ISO STRING THE PYTHON TYPE CANNOT HOLD raises ValueError from # `datetime.date` itself, not ISO8601Error -- the parser recognised the format # and the constructor refused the value. Two different failures, and the # distinction is what tells a caller whether their INPUT or their EXPECTATION # was wrong. raises("err_date_month13", ValueError, lambda: isodate.parse_date("2024-13-01")) emit("err_month13_not_iso", "no" if not _is_iso( lambda: isodate.parse_date("2024-13-01")) else "yes") # --------------------------------------------- match(), NOT fullmatch() --- # THE TRAP THAT IS NOT IN THE DOCSTRING, and the most useful single thing this # suite measured. Every parser here matches its regexes with `pattern.match`, # which anchors at the START only -- so a string whose PREFIX is a valid ISO # form parses, and everything after the prefix is silently discarded. # # `2024-5-17` is the one a caller actually writes: a single-digit month is not # ISO, and it does not raise. `2024` matches the bare-year pattern, the rest of # the string is dropped, and the answer is the 1st of January -- four months and # sixteen days from what was meant, with no error anywhere. The same shape # reaches the time and datetime parsers. # # `parse_duration` is the exception, and deliberately so: its regex is the only # one in the package anchored at BOTH ends (`^...$`), so it alone rejects a # trailing tail. That asymmetry is asserted rather than described. val("loose_date_short_month", lambda: isodate.parse_date("2024-5-17")) val("loose_date_trailing", lambda: isodate.parse_date("2024-05-17garbage")) val("loose_time_trailing", lambda: isodate.parse_time("15:04:05xyz")) raises("strict_duration_trailing", ISO8601Error, lambda: isodate.parse_duration("P1Dxyz")) emit("duration_regex_anchored", "yes" if isodate.isoduration.ISO8601_PERIOD_REGEX.pattern.endswith("$") else "no") # ------------------------------------------- UPSTREAM'S OWN TEST SUITE ----- # THE PACKAGE SHIPS ITS TESTS, and this image can run them. setup.py names # `isodate.tests` in `packages`, so test_date, test_time, test_datetime, # test_duration, test_strf and test_pickle land in site-packages beside the # library -- and python3-unittest is on this image, so the whole of upstream's # 278-case suite executes here for the cost of the interpreter start this probe # has already paid. # # That is worth far more than the ~110 cases above could be on their own: those # are this port's statements about the API a caller reaches, chosen and # measured by hand; these are the AUTHOR's statements about the implementation, # including the pickle round trip and the fraction-carrying edges no # hand-written table would cover. # # It is counted, not just run. `upstream_ran` is the case that catches a suite # that collected nothing -- which is exactly what `python3 -m unittest # isodate.tests` does, since the suites are built by `test_suite()` callables # rather than discovered. # # NOTE the RDEPENDS gap this exercises: the recipe does not name # python3-unittest, so the tests it ships are runnable here only because the # image installs unittest for other reasons. That is a statement about the # recipe, and `upstream_ran` is what would go quiet if the image stopped. try: import unittest import isodate.tests as _it _res = unittest.TextTestRunner(verbosity=0, stream=open("/dev/null", "w")).run(_it.test_suite()) emit("upstream_ran", str(_res.testsRun)) emit("upstream_failures", str(len(_res.failures))) emit("upstream_errors", str(len(_res.errors))) for _f in _res.failures + _res.errors: sys.stderr.write("upstream: %s\n%s\n" % (_f[0], _f[1])) except Exception: traceback.print_exc() emit("upstream_ran", "raised:%s" % sys.exc_info()[0].__name__) emit("upstream_failures", "unmeasured") emit("upstream_errors", "unmeasured") # ------------------------------------------------------------- the trailer --- # Emitted last, and the suite asserts both: `probe_done` says the probe reached # the end, `emitted_count` says nothing between the first key and this line was # skipped. Together they make every got:'' above readable as a missing KEY # rather than as a missing RUN. emit("emitted_count", COUNT + 1) sys.stdout.write("probe_done=yes\n")