Skip to content

Add inline type annotations and a typing CI check - #1647

Draft
pganssle wants to merge 5 commits into
dateutil:masterfrom
pganssle:add-inline-type-hints
Draft

pganssle wants to merge 5 commits into
dateutil:masterfrom
pganssle:add-inline-type-hints

Conversation

@pganssle

@pganssle pganssle commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Summary of changes

Add inline type annotations and a py.typed marker so downstream users can use the library's annotations. The signatures use structural protocols and overloads to preserve duck typing, with tox -e typing checking the implementation and type expectations in CI.

Annotation-only imports and overload declarations are guarded by TYPE_CHECKING. Casts that remain use a shared no-op fallback at runtime.

Related to #383. Earlier approaches are #754's generated stubs and #768's annotations for utils.py.

Comparison with typeshed

These annotations were compared with the typeshed stubs distributed in
types-python-dateutil==2.9.0.20261021. The implementation is the source of
truth where they differ:

  • utils.default_tzinfo accepts objects with tzinfo and an appropriate
    replace method, including datetime.time, preserves their type, and
    accepts None as the replacement zone.
  • utils.within_delta accepts compatible
    subtraction and comparison operations, including numbers and dates, rather
    than requiring datetimes and timedeltas.
  • parser.parse accepts bytearrays and streams whose read method requires
    a size argument. Parser configuration and time zone lookups use structural
    interfaces. Time zone mappings may return strings or None, as well as
    integer offsets and tzinfo instances.
  • ISO parsing accepts bytearrays and streams returning them. Typeshed's annotation
    permits a bytes separator for isoparser, but the implementation requires
    a string when a separator is specified. parse_tzstr returns a tzutc or
    tzoffset instance.
  • Relative units in relativedelta accept floats, and weekday arguments may
    provide weekday and n attributes without inheriting from dateutil's
    weekday class. Scaling a relativedelta accepts strings, bytes,
    bytearrays and objects providing __float__ or __index__, because it
    converts the operand with float.
  • Arithmetic with a relativedelta does not promise to preserve an arbitrary
    date subclass: adding a time component can promote a date to a datetime.
  • Recurrence slicing returns a sequence of datetimes, rather than the single
    datetime specified in typeshed. count exhausts the recurrence and returns
    an integer. replace constructs an rrule, rather than preserving an
    arbitrary subclass.
  • Weekday iterables can mix integers and structural weekday objects.
    Recurrence sets accept any iterable of datetimes as a rule.
    Frequencies accept integer variables, while invalid values remain runtime
    errors. Nonzero integer until values are not supported.
  • tzfile accepts read-only binary streams without seeking, exposes its
    key, and accepts the constructor's key argument. Its ambiguity method
    does not accept the old idx argument. These differences also reflect
    changes since the version covered by the stubs.
  • Fixed offsets and ranges accept objects providing total_seconds.
    Range transitions accept objects supporting addition to a datetime, and
    fixed ranges return None from transitions. Fixed-offset names may be
    None; fixed-zone offset methods always return a timedelta.
  • tzstr.instance takes a TZ string and posix_offset, and returns a
    tzstr. The typeshed signature instead describes tzoffset.instance.
  • tz.enfold accepts objects supporting replace(fold=...) and preserves
    their type.
  • zoneinfo.rebuild ignores its arguments, so its annotations
    do not impose the restrictions of its former implementation.
Import benchmarks against master and before cast removal

These measurements compare master (27ecba297e3e33d2e46c0f34f06452d2284bf029), the rebased PR (8bf5f7ac), and the annotation revision before cast removal (7cf76f2e). All three source snapshots are on the same filesystem and use the same _version.py generated by the current Hatchling build. The comparison with the pre-removal revision therefore isolates source changes under the current version-file format; it does not reproduce its former packaging environment. This replaces the earlier table, which compared snapshots on different filesystems.

Each measurement uses a fresh process on CPython 3.11.17 or 3.15.0, with 60 samples and two warm-up rounds. Revision and module order are randomized, the process is pinned to one CPU, and bytecode caches are warm. Processes use -S and PYTHONHASHSEED=0. The timer surrounds importlib.import_module, including dependencies but excluding interpreter startup. Times below are medians in milliseconds; positive changes mean slower imports.

Python 3.11

Module Master Before cast removal PR PR − master PR − before
dateutil 0.224 0.231 0.229 +0.004 (+1.9%) -0.002 (-0.9%)
dateutil._common 0.271 5.048 0.276 +0.005 (+1.7%) -4.772 (-94.5%)
dateutil.easter 0.858 0.874 0.866 +0.008 (+0.9%) -0.008 (-1.0%)
dateutil.parser 14.355 14.496 14.531 +0.176 (+1.2%) +0.035 (+0.2%)
dateutil.parser.isoparser 14.363 14.546 14.538 +0.174 (+1.2%) -0.009 (-0.1%)
dateutil.relativedelta 4.646 6.365 4.756 +0.109 (+2.3%) -1.610 (-25.3%)
dateutil.rrule 4.936 6.642 4.988 +0.051 (+1.0%) -1.655 (-24.9%)
dateutil.tz 12.827 13.105 12.934 +0.107 (+0.8%) -0.171 (-1.3%)
dateutil.utils 0.858 0.873 0.863 +0.006 (+0.7%) -0.010 (-1.2%)
dateutil.zoneinfo 14.322 14.368 14.362 +0.040 (+0.3%) -0.006 (-0.0%)

Python 3.15

Module Master Before cast removal PR PR − master PR − before
dateutil 0.188 0.189 0.192 +0.004 (+2.0%) +0.003 (+1.5%)
dateutil._common 0.236 3.417 0.247 +0.011 (+4.5%) -3.171 (-92.8%)
dateutil.easter 0.284 0.289 0.302 +0.017 (+6.0%) +0.013 (+4.3%)
dateutil.parser 19.143 19.501 18.958 -0.186 (-1.0%) -0.544 (-2.8%)
dateutil.parser.isoparser 18.741 19.252 18.972 +0.230 (+1.2%) -0.281 (-1.5%)
dateutil.relativedelta 4.165 5.558 4.172 +0.007 (+0.2%) -1.385 (-24.9%)
dateutil.rrule 5.533 6.699 5.510 -0.023 (-0.4%) -1.189 (-17.7%)
dateutil.tz 17.428 17.423 17.623 +0.195 (+1.1%) +0.200 (+1.1%)
dateutil.utils 0.280 0.293 0.301 +0.021 (+7.6%) +0.008 (+2.8%)
dateutil.zoneinfo 18.607 18.975 18.977 +0.370 (+2.0%) +0.002 (+0.0%)

The earlier comparison against 64914d9b used the old setuptools-generated version file. There, importing __future__ accounted for most of the root-package overhead. Master's new Hatchling build generates a _version.py that already imports __future__, so that cost is shared by the baseline and PR in this comparison. Preloading __future__ before starting the timer gives the following root-package measurements:

Python Master PR Difference (ms)
3.11 0.166 0.169 +0.003
3.15 0.125 0.124 -0.001

Larger modules also load more bytecode and construct annotation metadata and additional helper definitions. An isolated experiment stripping signature annotations from compiled code reduced some of that overhead, but doesn't establish an exact breakdown. Small differences vary between runs; the percentages for the root package should be read alongside their absolute cost.

Remaining casts

The library now contains 13 typing casts, down from 80 in 6ccb96cb and 133 in the initial annotation commit. These use the shared runtime fallback in dateutil._common. The separate ctypes.cast in the Windows module performs a pointer conversion and is not a typing cast.

Location Calls Reason retained
parser.__deprecate_private_class 2 The base class is chosen at runtime. Python's type system cannot express inheriting from a type variable or infer that the resulting subclass preserves that variable.
tz._factories 2 The receiver is both a metaclass instance and the class of the constructed object. Pyrefly does not preserve that relationship when binding the generic instance method.
rrule weekday normalization 2 A weekday is selected by its n attribute, including objects that are also iterable. The checker cannot narrow both branches of that structural dispatch.
_ymd.append 2 The existing dispatch uses __len__, followed by string-like operations, or numeric comparison otherwise. Replacing it with an isinstance(str) check would change duck typing. The string branch now uses a protocol rather than pretending the value is necessarily a str.
parser timezone lookup 1 Membership can establish that a None key is valid even for a lookup otherwise typed with string keys. The checker cannot express that relationship between __contains__ and get.
ISO stream normalization 1 getattr(input, "read", identity)() selects the reader's result or the original text/bytes. This also preserves reader methods on string subclasses.
Offset normalization 1 A numeric subclass can provide total_seconds, and the existing code falls back to the original number when the method raises AttributeError or TypeError. The cast describes that fallback, shared by all three callers.
Windows registry decoding 1 The Windows timezone registry schema specifies the types of named values; the generic registry reader returns a mapping of all possible registry value types. The assumption is confined to one helper.
Legacy zoneinfo archive reader 1 The read-only archive protocol suffices for this operation, but the standard-library stub also requires operations used by writable archive modes.

Other casts were removed through narrower signatures, constrained type variables, a stream capability guard, explicit state invariants, and control flow that carries narrowed values to their uses. No new type-checker suppressions were added.

Remaining typing diagnostics

The tzical parser initializes component state and variables together, then uses that state to guard later access. Pyrefly does not establish that relationship across loop iterations, so seven uses have local unbound-name suppressions. A reduced example also produces a possibly-unbound diagnostic in pyright, while mypy accepts it.

One other suppression preserves an existing component-indexing error in the fallback for dates before the onset of an all-DST zone. This PR does not change that behavior.

Pull Request Checklist

  • Changes have tests
  • Authors have been added to AUTHORS.md
  • News fragment added in changelog.d. See CONTRIBUTING.md for details

Comment thread src/dateutil/parser/__init__.py Outdated
Use structural protocols and overloads to preserve the library's permissive
inputs, with type-only imports guarded to limit import overhead. Package a
py.typed marker and check the annotations with pyrefly in CI.

Add pytest cases that pair inferred-type expectations with runtime checks
for duck-typed inputs. Existing unittest tests remain unannotated.
Use narrower internal types and control flow to remove unnecessary casts.
The remaining casts use a shared no-op fallback from _common, with the
real typing.cast imported only for type checking.

Guard overload declarations with TYPE_CHECKING and separate missing
transition information from ordinary offset records so these annotations
do not require runtime imports from typing.
Describe the fromutc decorator's input and output relationship, include
arithmetic fallback overloads, and preserve structural inputs when
narrowing internal state. Use the existing stream capability checks as a
type guard and carry narrowed recurrence values through their consumers.

Keep the remaining casts at dynamic dispatch and external data boundaries,
including timezone lookups with a None key. Add contract checks for these
lookups, datetime subclasses, arithmetic fallbacks and offset precedence.
@pganssle
pganssle force-pushed the add-inline-type-hints branch from 28c4ab8 to 8bf5f7a Compare October 25, 2026 20:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement tests types Static typing, mypy, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant