Skip to content

DM-55980: Bring mypy coverage to 100% and gate on it - #196

Merged
mfisherlevine merged 12 commits into
mainfrom
tickets/DM-55980
Sep 3, 2026
Merged

DM-55980: Bring mypy coverage to 100% and gate on it#196
mfisherlevine merged 12 commits into
mainfrom
tickets/DM-55980

Conversation

@mfisherlevine

Copy link
Copy Markdown
Contributor

No description provided.

mfisherlevine and others added 7 commits August 28, 2026 14:09
The tests were never actually type-checked: the [mypy-tests.*] ignore_errors
section never matched, because the tests/ dir is not a package so mypy names
the modules test_<name> (top-level), not tests.<name>. They were therefore
checked under the base config, which neither required annotations nor looked
inside unannotated function bodies, so nothing surfaced.

Enable it properly by setting disallow_untyped_defs = True in the base [mypy]
section; the existing [mypy-lsst.summit.utils.*] section already turns it off
for the library source, so only the (top-level) test modules are newly
required to be annotated. The dead [mypy-tests.*] sections are removed.

This commit does the purely mechanical part: annotating every test function
(mostly -> None) and declaring the class-level attributes that the unittest
TestCases populate in setUpClass/setUp (so mypy knows self.foo exists). The
EFD client and TMAEventMaker._data come from untyped libraries, so those
attributes are typed Any. Real type errors that these newly-checked bodies
reveal are fixed in the following commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the test suite now type-checked, mypy sees inside the previously-skipped
function bodies. These are the fixes that are genuine and self-contained:

- Guard values that are statically optional before use: assert-not-None for
  a visit's start seqNum and a dataId seq_num, for getBlockInfo results (as
  the truth-value writer already does), and for request bodies before
  json.loads (requests types .body as bytes | str | None).
- Annotate variables that are deliberately rebound to a different type across
  a test (a dayObs as str then int; a dataId as a DataCoordinate then a dict;
  ra/dec lists then their np.deg2rad arrays).
- type: ignore the handful of lines mypy cannot know about: intentionally
  malformed obs_id tuples and a bad dayObs passed to assert they are
  rejected, dafButler.Butler(...) which the stubs mark abstract, and the
  dynamically-added PipelineTaskConnections descriptors.
- Initialise expTime before the loop that may not run, and stop discarding
  the None return of _ipython_display_ / the unused assertNoLogs watcher.

Three remaining errors are not mechanical at all - they point at a vacuous
assertion and two annotations that are simply wrong - so each is fixed, with
its rationale, in its own following commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
self.assertTrue(all(isinstance(n, str)) for n in blockNums) puts the "for"
outside the all() call, so the argument to assertTrue is a generator object
(always truthy) that is never iterated: all(isinstance(...)) is never even
evaluated, and the check silently passed regardless of the block/seqNum
types. Moving the closing paren makes all() consume the generator, so the
assertion actually tests that every blockNum is a str and every seqNum an
int, matching the correctly-parenthesised assertion just below it.

This is EFD-gated so it does not run off-summit; the change is a pure
parenthesisation fix that mypy now accepts (all() gets an iterable, not a
lone bool).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blockInfos field was annotated as list, but its own docstring says "or
None", it is compared against None in __repr__ and in getEvent, and the EFD
tests construct a TMAEvent with blockInfos=None. Widen the annotation to
list | None to match reality (the default_factory=list default is unchanged,
so nothing that relies on the empty-list default is affected).

Widening exposed one place, TMAEvent.associatedWith, that iterated
self.blockInfos with no None guard, unlike the guarded access in __repr__;
iterate over `self.blockInfos or []` there so a None is treated the same as
no block info. The test that checks an empty blockInfos is narrowed through a
local so mypy is satisfied it is non-None at the len() call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quickSmooth was annotated NDArray[np.float64] on both the parameter and the
return, but it is a thin wrapper over scipy.ndimage.gaussian_filter, which
preserves the input dtype and happily accepts float32. Real callers already
pass float32 (e.g. ImageExaminer on an afw image.array), and the unit test
deliberately exercises the float32 interface. Widen both to
NDArray[np.floating[Any]] so the annotation matches what the function
actually accepts and returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requests types HTTPError.response as Response | None, so mypy rejects
e.response.json(). raise_for_status() always attaches the response it was
called on, so read the body from that response directly instead.
offset_time_aware() called utc.localize() on a zoneinfo.ZoneInfo, which
has no such method - that is pytz's API. Any naive tick label reaching
this branch would have raised AttributeError rather than being converted
to Chilean time.
@mfisherlevine
mfisherlevine force-pushed the tickets/DM-55980 branch 3 times, most recently from 94412da to cd8aa58 Compare August 28, 2026 14:29
Fills in every remaining unannotated and partially annotated definition
the mypy-coverage report listed, so the whole package is body-checked by
mypy rather than silently skipped.

GuiderData.__getitem__ gets @overload signatures: a single return union
of Stamps | ndarray | list[ndarray] would have forced narrowing at every
gd[det, i] call site across plotting, detection and tracking, whereas the
overloads give each key form its real type. black collapses the overload
stub bodies onto the def line, so E704 joins the other black-disagrees-
with-flake8 codes already ignored in setup.cfg.
Three holes let the typing work on this branch regress silently:

The shared mypy workflow defaults to checking python/ only, so the test
suite mypy.ini names in files= was never checked in CI at all - the
[mypy-tests.*] ignore_errors section it used to rely on had never matched
anything either, since tests/ is not a package. Checking tests/ turns up
one config gap: [mypy-lsst.obs.base] matched only the exact module, not
the makeRawVisitInfoViaObsInfo submodule test_utils imports, so that is
folded into the [mypy-lsst.obs.base.*] section which already existed.

CI installed no requests stubs, so mypy.ini's per-module
ignore_missing_imports degraded every requests object to Any. That is why
the HTTPError.response and request.body errors fixed earlier on this
branch passed CI while failing locally. types.txt, which the shared
workflow installs when present, now pins the stubs plus the pytest and
responses imports the test modules need.

The mypy-coverage workflow ran without a threshold and could never fail.
It now gates fully-typed at 100%, which pins body-checked to 100% as
well: body-checked counts the partially annotated definitions that
fully-typed excludes, so leaving no gap in the latter leaves none in the
former.

"call-workflow / mypy" was already a required status check on main, so it
gated considerably less than it looked like it did. "Annotate coverage
gaps" has now been added to that list alongside it.
__str__ is a dunder: Python calls it with no arguments on every print(),
str() and f-string, so its expid parameter could only ever be filled by
an explicit self.__str__(expid) call - which is what summary() did, and
the only such call there was. That forced a default, and any default is
a lie: "" and None both render a blank exposure id line, and 0 renders a
fictitious one.

A DriftResult does not carry an exposure id, so __str__ no longer claims
to know one and summary() takes a plain required int, printing the
header itself. Output from summary(expid) is byte-identical; print() on
a DriftResult now omits the vestigial blank "Exposure summary: " line.

@fred3m fred3m left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. My only suggestion is that the LLM generated commit descriptions are (as expected) verbose. It's getting better but as long as for this package you're still using the premise that only AI will actually look at them, it's fine. If you expect a person to read them then they should be simplified a bit.

mfisherlevine and others added 2 commits September 3, 2026 13:26
mypy sees @deprecated from deprecated.sphinx as untyped here: the
Deprecated package ships no py.typed, and mypy.ini silences the
resulting import-untyped warning. An untyped decorator erases the type
of everything it decorates, so makeDefaultLatissButler() returned Any,
self.butler was Any, and the whole of ButlerUtilsTestCase went
unchecked. Environments that do have type information for Deprecated
(USDF's shared stack, for one) see the real signature and report 16
errors that CI never did. types.txt now pins types-Deprecated so every
environment agrees.

All 16 were real. Three annotations were narrower than their own bodies:
updateDataIdOrDataCord and fillDataId both coerce with _assureDict(),
which handles DimensionRecord, so both now accept one.
getDatasetRefForDataId went the other way - it mutates the dataId with
.update(), so passing it the DataCoordinate the test passes would have
raised AttributeError had the expid been absent; it now coerces with
_assureDict() first rather than widen a promise it cannot keep.

In the tests, the butler is pinned to DirectButler where DirectButler
-only APIs are used, the heterogeneous dataId lists are annotated so
they do not join to object, and the materialised list of dimension
records gets its own name rather than being assigned back over the
query result it came from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An untyped decorator gives mypy an Any-returning callable, which erases
the signature of the function it wraps: the definition still reads as
fully annotated, and mypy still checks its body, but every caller of it
goes unchecked. That is how an untyped @deprecated on
makeDefaultLatissButler hid the whole of ButlerUtilsTestCase, and why
mypy-coverage reported 100% while a test class went unexamined.

Scoped to lsst.summit.utils rather than set globally: with the
types-Deprecated stub the library is already clean, whereas the tests
still carry 32 untyped @vcr.use_cassette() decorators. DM-55972 replaces
those with @pytest.mark.vcr, after which this can move to [mypy].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

mypy-coverage report

✅ All 796 definitions are fully annotated.

  • Root: /home/runner/work/summit_utils/summit_utils
  • Config: mypy.ini
  • Files scanned: 60
  • Files excluded: 0

Summary

metric value
✅ body-checked by mypy 100.0%
✅ fully annotated 100.0%
annotated 796
partial 0
unannotated 0

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 58.69565% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 23.38%. Comparing base (6e79a37) to head (9132165).

Files with missing lines Patch % Lines
python/lsst/summit/utils/butlerUtils.py 28.57% 5 Missing ⚠️
python/lsst/summit/utils/simonyi/mountAnalysis.py 0.00% 3 Missing ⚠️
python/lsst/summit/utils/simonyi/mountData.py 0.00% 3 Missing ⚠️
python/lsst/summit/utils/tmaUtils.py 40.00% 3 Missing ⚠️
python/lsst/summit/utils/guiders/transformation.py 84.61% 2 Missing ⚠️
python/lsst/summit/utils/plotRadialAnalysis.py 0.00% 2 Missing ⚠️
python/lsst/summit/utils/guiders/plotting.py 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #196   +/-   ##
=======================================
  Coverage   23.38%   23.38%           
=======================================
  Files          38       38           
  Lines        7070     7077    +7     
=======================================
+ Hits         1653     1655    +2     
- Misses       5417     5422    +5     
Files with missing lines Coverage Δ
python/lsst/summit/utils/consdbClient.py 73.33% <100.00%> (ø)
python/lsst/summit/utils/guiders/reading.py 30.80% <100.00%> (+0.16%) ⬆️
python/lsst/summit/utils/utils.py 43.33% <100.00%> (ø)
python/lsst/summit/utils/guiders/plotting.py 17.13% <0.00%> (ø)
python/lsst/summit/utils/guiders/transformation.py 21.03% <84.61%> (+0.21%) ⬆️
python/lsst/summit/utils/plotRadialAnalysis.py 0.00% <0.00%> (ø)
python/lsst/summit/utils/simonyi/mountAnalysis.py 0.00% <0.00%> (ø)
python/lsst/summit/utils/simonyi/mountData.py 0.00% <0.00%> (ø)
python/lsst/summit/utils/tmaUtils.py 25.06% <40.00%> (ø)
python/lsst/summit/utils/butlerUtils.py 21.98% <28.57%> (-0.08%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mfisherlevine
mfisherlevine merged commit f50eea2 into main Sep 3, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants