#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-aniso8601. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds and every measurement below is string work over inputs under 40 bytes, so the start is the entire cost and taking it once is the difference between a half-minute suite and an hour. THREE RULES THE VALUES OBEY. The runner parses its input LINE BY LINE, so no value may contain a newline. Every value here is a scalar or a comma-joined list of scalars. No value may be ENCODING-DEPENDENT. The guest runs under the C locale, so whether CPython coerces stdout to UTF-8 is a property of the platform rather than of this package. Every fixture is ASCII, and the two places this library could produce something else -- a `datetime.time' carrying a `UTCOffset' and a `timedelta' repr -- are reported through `isoformat()' and through the INTEGER FIELDS respectively, never through `repr'. And no value may be a REPR THAT CARRIES AN ADDRESS. `UTCOffset' defines `__repr__' without one, but a generator does not, so a repeating interval is reported as its `type(...).__name__' and as the isoformat of the values drawn from it -- never as the object. WHY A DURATION IS REPORTED FIELD BY FIELD RATHER THAN AS A repr. `datetime.timedelta' normalises into (days, seconds, microseconds) and its repr suppresses whichever of the three is zero, so `P1Y' and `P365D' have the same repr and a release that changed DAYS_PER_YEAR could not be told from one that changed nothing. Each duration below emits its three fields separately, which is also what makes the years-and-months conversion assertable at all. WHY THIS FILE IS NOT NAMED aniso8601.py. It sits in ${libdir}/brintos-tests/data and the interpreter puts a script's own directory FIRST on sys.path, so a probe named after the module would shadow the module it is measuring. `module_file' below is the case that would catch it if that ever stopped being true. """ import sys emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def emit_call(key, fn, *a, **kw): """Emit what `fn' returned, or the TYPE of the exception it raised. A raise is a first-class expected value here -- the whole `err_*' group below asserts ABOUT raising -- so it is reported as `' rather than killing the probe and leaving every case after it reading got:''. """ try: emit(key, fn(*a, **kw)) except Exception as exc: # noqa: BLE001 emit(key, '<%s>' % type(exc).__name__) def raises(fn, *a, **kw): """The name of the exception type `fn' raised, or `'.""" try: fn(*a, **kw) except Exception as exc: # noqa: BLE001 return type(exc).__name__ return '' # THE sys.modules BASELINE, TAKEN BEFORE THE IMPORT. `unexpected_modules' at # the foot of this file reports the DELTA across the import, not the contents of # sys.modules -- an interpreter arrives with `encodings', `site' and whatever a # sitecustomize dragged in already loaded, and a set measured after the fact is # a statement about the platform rather than about this package. Measured: on # an Ubuntu control the absolute set carries 39 names this package never # mentions (apport_python_hook, zope, zipfile); the delta carries 21, all of # them stdlib and all of them reachable from the source. _BASELINE = {m.split('.')[0] for m in sys.modules} try: import aniso8601 except Exception as exc: # pragma: no cover print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) # ...and the set again IMMEDIATELY after it. It has to be taken here rather # than at the foot of the file: everything this probe does afterwards -- # `importlib.metadata' most of all, which reaches email, zipfile and csv -- # would otherwise land in a delta that claims to be about aniso8601. Measured: # 21 names taken here against 60 taken at the end. _AFTER_IMPORT = {m.split('.')[0] for m in sys.modules} from aniso8601 import exceptions as ex # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Every behavioural case below is a statement about the # tree named here; without this one they would be statements about whatever was # first on sys.path. emit('module_file', getattr(aniso8601, '__file__', '')) # ...and it is a PACKAGE, not a module. setup.py uses `find_packages()', so a # DIRECTORY lands in site-packages -- the opposite shape from python3-parse's # single file. A release that flattened to one module would still import and # still pass every behavioural case below. emit('is_package', 'yes' if hasattr(aniso8601, '__path__') else 'no') emit('version_attr', getattr(aniso8601, '__version__', '')) try: from importlib.metadata import version as _dist_version emit('dist_version', _dist_version('aniso8601')) except Exception as exc: # noqa: BLE001 emit('dist_version', '<%s>' % type(exc).__name__) # THE PUBLIC SURFACE. `__init__.py' re-exports ten names and declares no # `__all__' at all, so `from aniso8601 import *' takes the submodules with it. # The ten are pinned by name because they are the whole documented API and a # release that moved one behind a submodule breaks a caller who never touched # the submodule. for _n in ('parse_date', 'parse_time', 'parse_datetime', 'parse_duration', 'parse_interval', 'parse_repeating_interval', 'get_date_resolution', 'get_time_resolution', 'get_datetime_resolution', 'get_duration_resolution', 'get_interval_resolution', 'get_repeating_interval_resolution'): emit('has_' + _n, 'yes' if getattr(aniso8601, _n, None) is not None else 'no') emit('has_all_attr', 'yes' if hasattr(aniso8601, '__all__') else 'no') # THE SUBMODULES, which are what a caller reaches for `parse_timezone' and for # the exception classes -- neither is re-exported at package level. emit('submodules', ','.join(sorted( n for n in ('builders', 'compat', 'date', 'decimalfraction', 'duration', 'exceptions', 'interval', 'resolution', 'time', 'timezone', 'utcoffset') if getattr(aniso8601, n, None) is not None))) # ------------------------------------------------------- the exception tree --- # THE SHAPE OF THE ERROR HIERARCHY, WHICH IS NOT THE SHAPE THE NAME SUGGESTS. # See the suite header: `ISOFormatError' has NO subclasses, and the nine # range errors descend from `RangeCheckError' instead. The two meet only at # ValueError. Each half is emitted separately so a red says WHICH belief broke. emit('isoformat_is_valueerror', 'yes' if issubclass(ex.ISOFormatError, ValueError) else 'no') emit('rangecheck_is_valueerror', 'yes' if issubclass(ex.RangeCheckError, ValueError) else 'no') emit('isoformat_subclasses', ','.join(sorted(c.__name__ for c in ex.ISOFormatError.__subclasses__())) or '') emit('rangecheck_subclasses', ','.join(sorted(c.__name__ for c in ex.RangeCheckError.__subclasses__()))) emit('range_under_isoformat', 'yes' if issubclass(ex.YearOutOfBoundsError, ex.ISOFormatError) else 'no') # ------------------------------------------------------------- parse_date --- # THE CALENDAR FORMS, extended and basic, and the reduced precisions. A # component the string omits defaults to 1 -- `1984-05' is the first of May and # `1984' is New Year's Day -- which is a documented behaviour rather than an # error, and is the one a caller who wanted "just the year" has to know about. emit_call('date_extended', lambda: aniso8601.parse_date('1984-05-07').isoformat()) emit_call('date_basic', lambda: aniso8601.parse_date('19840507').isoformat()) emit_call('date_month_only', lambda: aniso8601.parse_date('1984-05').isoformat()) emit_call('date_year_only', lambda: aniso8601.parse_date('1984').isoformat()) # A TRUNCATED YEAR IS LEFT-JUSTIFIED WITH ZEROS, not centred on a century: # `19' is 1900, not 2019 and not 1919. `year_range_check' pads the string on # the RIGHT (`valuestr.ljust(4, "0")'), which is the surprising half. emit_call('date_trunc_year', lambda: aniso8601.parse_date('19').isoformat()) # THE ORDINAL FORM, YYYY-DDD -- day-of-year, which the calendar forms cannot # express and which is the first of the three the ticket names. emit_call('date_ordinal', lambda: aniso8601.parse_date('1981-095').isoformat()) emit_call('date_ordinal_basic', lambda: aniso8601.parse_date('1981095').isoformat()) # Day 366 is a statement about the year, not about the format: it parses in a # leap year and is a DayOutOfBoundsError in any other. Both directions. emit_call('date_ordinal_leap', lambda: aniso8601.parse_date('2000-366').isoformat()) emit('date_ordinal_nonleap', raises(aniso8601.parse_date, '1999-366')) # THE WEEK FORMS, and the reason they are here at all: THE WEEK-NUMBERING YEAR # IS NOT THE CALENDAR YEAR. Each of the three below names a year in the string # and lands in a DIFFERENT year on the calendar, in both directions across a # New Year boundary. A caller who reads `YYYY' as the calendar year is wrong # by up to three days, silently, and only on some years. emit_call('date_week_start', lambda: aniso8601.parse_date('2004-W53').isoformat()) emit_call('date_weekday', lambda: aniso8601.parse_date('2004-W53-6').isoformat()) emit_call('date_weekday_basic', lambda: aniso8601.parse_date('2004W536').isoformat()) # 2004-W01-1 is in 2003. 2009-W53-7 is in 2010. 2010-W01-1 is in 2010 but is # the 4th, not the 1st. emit_call('date_week_back', lambda: aniso8601.parse_date('2004-W01-1').isoformat()) emit_call('date_week_fwd', lambda: aniso8601.parse_date('2009-W53-7').isoformat()) emit_call('date_week_2010', lambda: aniso8601.parse_date('2010-W01-1').isoformat()) # ...and the library's answer agrees with the interpreter's own ISO calendar, # which is the strongest available statement that the week arithmetic is right # rather than merely stable. Computed here rather than written as a literal: # the point is the AGREEMENT, and a literal would assert only one side of it. try: _wk = aniso8601.parse_date('2004-W53-6').isocalendar() emit('date_week_isocalendar', '%d-W%02d-%d' % (_wk[0], _wk[1], _wk[2])) except Exception as exc: # noqa: BLE001 emit('date_week_isocalendar', '<%s>' % type(exc).__name__) # THE RESOLUTION ENUM, which is how a caller learns WHICH form it was handed. # The values are small ints and the names are what a caller writes, so both are # emitted: an enum whose members were reordered would keep every parse_date # case above green. emit_call('res_date_year', lambda: aniso8601.get_date_resolution('1984')) emit_call('res_date_month', lambda: aniso8601.get_date_resolution('1984-05')) emit_call('res_date_week', lambda: aniso8601.get_date_resolution('2004-W53')) emit_call('res_date_weekday', lambda: aniso8601.get_date_resolution('2004-W53-6')) emit_call('res_date_day', lambda: aniso8601.get_date_resolution('1984-05-07')) emit_call('res_date_ordinal', lambda: aniso8601.get_date_resolution('1981-095')) from aniso8601.resolution import DateResolution as _DR emit('res_date_names', ','.join('%s=%d' % (n, getattr(_DR, n)) for n in ('Year', 'Month', 'Week', 'Weekday', 'Day', 'Ordinal'))) # ------------------------------------------------------------- parse_time --- # THE FOUR PRECISIONS, extended and basic. `hh' alone is a legal time and # yields minute and second zero. emit_call('time_hms', lambda: aniso8601.parse_time('11:31:14').isoformat()) emit_call('time_hms_basic', lambda: aniso8601.parse_time('113114').isoformat()) emit_call('time_hm', lambda: aniso8601.parse_time('11:31').isoformat()) emit_call('time_h', lambda: aniso8601.parse_time('11').isoformat()) # THE FRACTIONAL COMPONENT, which may appear on the LOWEST-ORDER element only # and is carried DOWNWARD into the fields below it. A fractional hour becomes # minutes, a fractional minute becomes seconds -- `datetime.time' has no # fractional hour to put it in. Three separate cases because they exercise # three different arms of `_distribute_microseconds'. emit_call('time_frac_sec', lambda: aniso8601.parse_time('11:31:14.5').isoformat()) emit_call('time_frac_min', lambda: aniso8601.parse_time('11:31.5').isoformat()) emit_call('time_frac_hour', lambda: aniso8601.parse_time('11.5').isoformat()) # THE COMMA IS THE OTHER DECIMAL SEPARATOR, and ISO 8601 prefers it. # `decimalfraction.normalize' rewrites it, so the two spellings must agree. emit_call('time_comma_frac', lambda: aniso8601.parse_time('11:31:14,5').isoformat()) # THE UTC OFFSETS, in all four spellings the module accepts. Reported through # `isoformat()', which renders the offset itself, so each case is a statement # about the tzinfo rather than only about the wall-clock fields. emit_call('time_utc_z', lambda: aniso8601.parse_time('11:31:14Z').isoformat()) emit_call('time_off_ext', lambda: aniso8601.parse_time('11:31:14+04:00').isoformat()) emit_call('time_off_basic', lambda: aniso8601.parse_time('11:31:14-0530').isoformat()) emit_call('time_off_hour', lambda: aniso8601.parse_time('11:31:14+04').isoformat()) # The tzinfo is this library's OWN `UTCOffset', not `datetime.timezone', and # `Z' is given the name "UTC" rather than the string that produced it. emit_call('time_tz_class', lambda: type(aniso8601.parse_time('11:31:14Z').tzinfo).__name__) emit_call('time_tz_name', lambda: aniso8601.parse_time('11:31:14Z').tzinfo.tzname(None)) emit_call('time_tz_offset_s', lambda: int(aniso8601.parse_time('11:31:14+04:00').utcoffset().total_seconds())) # `dst()' is always zero by design -- ISO 8601 has offsets, not daylight rules. emit_call('time_tz_dst_s', lambda: int(aniso8601.parse_time('11:31:14+04:00').tzinfo.dst(None).total_seconds())) # HOUR 24 IS MIDNIGHT AND IS MOVED TO 0, so `24:00:00' does not round-trip as # the string it came from. Anything else at hour 24 is a MidnightBoundsError. emit_call('time_midnight', lambda: aniso8601.parse_time('24:00:00').isoformat()) emit('time_midnight_bad', raises(aniso8601.parse_time, '24:00:01')) # A LEAP SECOND IS REFUSED. The hour/minute/second range check admits second # 60 -- the limit says so -- and the builder then raises anyway, so this is a # deliberate refusal rather than a range that stops at 59. emit('time_leap_second', raises(aniso8601.parse_time, '23:59:60')) emit_call('res_time_sec', lambda: aniso8601.get_time_resolution('11:31:14')) emit_call('res_time_min', lambda: aniso8601.get_time_resolution('11:31')) emit_call('res_time_hour', lambda: aniso8601.get_time_resolution('11')) # --------------------------------------------------------- parse_datetime --- emit_call('dt_plain', lambda: aniso8601.parse_datetime('1981-04-05T23:21:28.512400').isoformat()) emit_call('dt_utc', lambda: aniso8601.parse_datetime('1981-04-05T23:21:28.512400Z').isoformat()) emit_call('dt_offset', lambda: aniso8601.parse_datetime('1981-04-05T23:21:28.512400+11:15').isoformat()) # THE DELIMITER IS A PARAMETER, not a constant: a space is the other spelling # in the wild, and it is accepted only when asked for. emit_call('dt_space', lambda: aniso8601.parse_datetime('1981-04-05 23:21:28', delimiter=' ').isoformat()) emit('dt_space_default', raises(aniso8601.parse_datetime, '1981-04-05 23:21:28')) # THE DATE HALF TAKES ANY DATE FORM, so the week and ordinal forms compose with # a time. This is the case that says parse_datetime delegates rather than # carrying its own date parser. emit_call('dt_ordinal', lambda: aniso8601.parse_datetime('1981-095T23:21:28').isoformat()) emit_call('dt_week', lambda: aniso8601.parse_datetime('2004-W53-6T23:21:28').isoformat()) emit_call('res_datetime', lambda: aniso8601.get_datetime_resolution('1981-04-05T23:21:28')) # --------------------------------------------------------- parse_duration --- # A timedelta reported FIELD BY FIELD -- see the header for why never as a repr. def dur(key, s): try: d = aniso8601.parse_duration(s) except Exception as exc: # noqa: BLE001 emit(key + '_days', '<%s>' % type(exc).__name__) emit(key + '_seconds', '<%s>' % type(exc).__name__) emit(key + '_micro', '<%s>' % type(exc).__name__) return emit(key + '_days', d.days) emit(key + '_seconds', d.seconds) emit(key + '_micro', d.microseconds) # THE DESIGNATOR FORM, the ticket's own fixture. 3 years + 6 months + 4 days # is 1279 days only because of the two constants below; every other reading of # "3 years" gives a different number. dur('dur_full', 'P3Y6M4DT12H30M5S') # THE COMBINED DATE-AND-TIME FORM, PT