lint(ruff): select the checks no other gate covers, and pin the linter - #398
lint(ruff): select the checks no other gate covers, and pin the linter#398thodson-usgs wants to merge 9 commits into
Conversation
4d18214 to
1e992e9
Compare
nodohs
left a comment
There was a problem hiding this comment.
please address my suggestions
`get_ratings(..., file_path=...)` wrote each rating with `open(..., "w")` and no encoding, so the bytes on disk depended on the writing machine's locale rather than on what the service sent. On a non-UTF-8 locale an unrepresentable character raises `UnicodeEncodeError`, which is a `ValueError`, which `_download_all`'s per-feature handler downgrades to a `SkippedRatingWarning` -- so the rating disappears from the returned dict rather than failing loudly, which is the opposite of what the function's own docstring promises. `newline=""` disables the translation that would otherwise rewrite the RDB's line endings on Windows. The regression test asserts the saved file equals the response body byte for byte, using a character absent from cp1252, so it fails on the Windows leg without the fix and cannot fail on Linux or macOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6MVcko4gh68LieGWxUnrR
The same `open()` was a blocking call inside `async def _fetch_rating`, which runs dozens-concurrent under a fan-out drive -- so every rating write stalled every other in-flight download for its duration. The write is now a three-line `_write_rating` dispatched through `anyio.to_thread.run_sync`. Nothing in the suite would have caught this: the tests await mocked transports, so a stalled loop still passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6MVcko4gh68LieGWxUnrR
`format_response` set the index with `inplace=True` on the frame it was handed while already returning a new object -- and already left the caller's frame untouched on the `peaks` and GeoDataFrame paths, so the in-place branch was the odd one out. Callers using the return value, which is the only documented use, see no difference. `pandas` is also phasing `inplace=` out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6MVcko4gh68LieGWxUnrR
c6e8664 to
56ea946
Compare
`get_record`'s `wide_format`, `datetime_index` and `state` have read nothing
since `qwdata`, `gwlevels` and `water_use` were retired -- their only readers
went with those branches -- but all three stayed in the signature and in the
published docstring. `get_record(sites=..., state="OH")` therefore reads as a
state filter while doing nothing at all.
Verified against the tree before `491eb5c3` ("remove usage of qwdata"), where
`get_record` passed `wide_format` and `datetime_index` to `get_qwdata`,
`datetime_index` to `get_gwlevels`, and `state` to `get_water_use` -- the four
readers (`if wide_format:`, two `if datetime_index is True:`, and
`if state is not None:`) that went with those functions. Nothing in the
package reads any of the three today.
They now advise through `_deprecation.warn_deprecated`, naming a live
replacement each, and are still accepted and still ignored. Naming one at its
declared default is silent: the caller is asking for exactly what the dead
default already gave them, so only a value the option cannot honour is worth
a warning. A test pins that, and with it the agreement between the table's
"unset" value and `get_record`'s declared default that the distinction rests
on.
They are not raised on and not deleted:
- A defunct *service* cannot return the data asked for; `get_record(
service="dv", wide_format=False)` returns exactly the right data, and only
the knob is dead. Raising would retire a documented parameter of a
Production/Stable getter ~20 months before `REMOVALS["nwis"]` with no
warned release in between.
- Deleting `state` from the signature would let it fall through `**kwargs` to
`query_waterservices` and onto the wire as a parameter NWIS does not
define. Confirmed against the live code path: an unknown *string* kwarg
reaches the query string, while an unknown *bool* is dropped, because
`to_str` returns `None` for a non-iterable scalar. So the argument for
keeping the parameter holds for `state` alone.
A call naming all three emits four `DeprecationWarning`s: one from
`@_deprecated` plus one per option. They are not duplicates -- each names a
distinct subject and a distinct replacement -- but `_warn_defunct_record_options`
raises through `warn_deprecated` directly and so does not pass through
`_deprecated`'s re-entrancy guard. The count is pinned by a test, as is the
attribution the hand-counted `stacklevel=4` encodes.
The replacement tripwire is now derived from the deprecation tables
themselves rather than from a hand-kept list, and checks the keywords a
message names as well as the function.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6MVcko4gh68LieGWxUnrR
Everything the newly selected rules reported that is not a bug in its own right, kept apart from the config change so the config commit is only config: - `D100`: `dataretrieval.codes` and `dataretrieval.waterdata.types` are rendered by `automodule ... :members:`, so a missing module docstring ships to the docs site as a bare signature. `setup.py` and `docs/source/conf.py` get one-liners. - `ARG001`: `_display_api_key` and `_display_progress` never read `adapter`; it is there because the display registry calls every renderer with the same signature, so the name is prefixed rather than removed. - `RUF100`: four `# noqa: BLE001` directives sat on handlers that re-raise and never needed one. Four more carried their reason after a dash or in parentheses -- ruff reads the rest of the line as the directive's description and would delete a co-located `# pylint: disable=` with it -- so they are respelled with a second `#`. - `PLW1514`: the test helpers that read fixture files now name an encoding. - `PD002`: `_parse_parameter_record` returns the renamed frame rather than renaming in place; the frame is local, so nothing outside changes. - `PD003`: `isnull` -> `isna`. - `DTZ007`: `ogc/dates.py`'s `_parse_datetime` is correct by design -- `_DATETIME_FORMATS` deliberately carries both the `%z` and the bare forms, and the docstring promises tz-awareness only for an input that carried an offset. It takes a directive naming the reason rather than a "fix". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6MVcko4gh68LieGWxUnrR
Config only; the findings are fixed in the commits before this one. ## The rules - `PLW1514` -- mypy does not model encodings, and a locale-dependent write misbehaves only on the Windows leg, so a green Linux run proves nothing. Found the silent rating loss. - `ARG001` -- notices when retiring a legacy getter strands a documented keyword. Found the three inert `get_record` options. - `ASYNC` -- the suite awaits mocked transports, so a stalled event loop still passes. Found the blocking ratings write. - `D100`-`D104` -- `automodule ... :members:` renders a docstring-less public symbol as a bare signature. - `RUF100` -- nothing was watching the suppressions. Six `# noqa: BLE001` directives sat in the tree suppressing a rule nobody had selected. - `BLE001` -- a blind `except Exception` skips ADR 0004's transient-versus- fatal judgement. Already half-adopted, via those six directives. - `PD`, `DTZ` -- `inplace=` is on pandas' way out, in a pandas library; a naive `datetime` in a library about time series. - `C4` `LOG` `G` `T20` `FA` `NPY` -- free ratchets at today's setting. The suppression budget goes down, not up: four directives removed, one added. ## The flow - The version was pinned in three places and honoured in none: the pre-commit rev, CI and the `test` extra all said 0.16.1, while the machine that wrote this had 0.16.5 on `$PATH` and 0.15.12 in its venv -- `ruff format --check` disagreed by 28 files between them. It now lives in two: a `[lint]` extra that CI installs the way the `[metrics]` job already installs its own, and the `ruff-pre-commit` rev. The `test` extra reuses `dataretrieval[lint]`, as it already reuses `dataretrieval[type-check]`. - Pinned at 0.16.5, the current release. - `preview = true` enabled 52 rules to get one. `explicit-preview-rules` keeps the rest off: that count was 50 at 0.16.1 and is 52 at 0.16.5, four patch releases apart, and each would have arrived as a CI failure nobody selected. - `E501` was selected redundantly (already inside `E`) under a comment naming a `line-length` setting that did not exist. The value is now set explicitly, where it visibly governs both the checker and the formatter. Unrelated but found the same way: the mypy `anyio` override is dropped. Its rationale was a 3.9 target that a `match` statement would fail to parse, and `python_version` has been 3.10 since 1.2.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6MVcko4gh68LieGWxUnrR
The `nwis.get_record` option deprecations and the `format_response` change are not called out: `nwis` is deprecated itself, and the entry covers interface changes and bug fixes to active modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6MVcko4gh68LieGWxUnrR
56ea946 to
a9c7d8c
Compare
|
@ehinman , I updated the linter config and this PR fixes bugs and linting errors that resulted from that change. No new features. |
Dropping `inplace=` from `format_response`'s `set_index` stopped the function
from mutating its argument, but it also made the function copy the data.
pandas resolves a non-inplace `set_index` to `self.copy(deep=None)`, and
`BlockManager.copy` turns `deep=None` into a full deep copy whenever
copy-on-write is off -- the default for all of pandas 2.x, which is what a
Python 3.10 install gets, since pandas 3.0 requires 3.11.
A shallow copy carries the same guarantee: the index lands on our frame and
the columns stay shared. `set_index` then runs inplace on that copy, which is
what the `noqa` records.
The rest of the gap is `_localize_datetime_index`. `DataFrame.tz_localize`
relabels one axis by duplicating every column, a copy main paid too.
Retagging the index alone is equivalent -- asserted frame-equal against the
old call on both the multi-index and single-index branches -- and costs
nothing.
Peak RSS for one call, measured in a fresh process on pandas 2.2.3:
main inplace dropped here
2M rows, 1 site 108.0 123.3 46.2 MiB
2M rows, 8 sites 250.0 394.6 318.3 MiB
1M rows, 20 cols, 8 sites 389.2 456.6 288.7 MiB
The narrow multi-site frame stays above main. Building a two-level MultiIndex
while the source columns stay materialized is the price of leaving the
caller's frame intact; only mutating the argument avoids it. The other two
shapes fall below main because the tz_localize copy is gone.
CPU improves alongside: 355 -> 319 ms on pandas 2.2.3 and 227 -> 215 ms on
3.0.5, for 2M rows across 8 sites. pandas 3.0.5 memory is unchanged
throughout -- copy-on-write already made both copies shallow, which is why
the suite cannot see any of this: CI runs 3.13 and 3.14.
So the test asserts the property that does hold everywhere -- the argument
keeps its columns and its index -- which fails against the in-place version
on both branches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVhHGSDkVxWzeUMmHn6oAT
A review of the previous commit found that the invariant its test states is broader than what the code delivers. `preformat_peaks_response` derives `datetime` from `peak_dt` with a plain assignment, so `format_response(df, service="peaks")` still added a column to the frame it was handed: the shallow copy the previous commit introduced sits downstream of that call. `preformat_peaks_response` is public in its own right, so a caller reaches the mutation without going through `format_response` at all. It now derives the column on its own frame, and the test class covers the peaks arm alongside the plain ones. That also settles a claim in e773171, which said the function already left the caller's frame untouched on the peaks and GeoDataFrame paths. It held for GeoDataFrame only. It holds for both now. `_localize_datetime_index` loses its frame wrapper. Its one caller hands it a frame it has just shallow-copied, so the second copy -- and the identity guard that existed to avoid it -- protected a caller that no longer exists. As `_localized_datetime_index` it takes an index and returns one, and the caller assigns. Frame-equal to the previous form on pandas 2.2.3 and 3.0.5 across single-site, multi-site, already-aware and no-datetime frames; peak RSS is unchanged at 46.2 / 317.3 / 288.6 MiB on the three shapes the previous commit measured. The `datetime_index` entry told a caller to reach for `waterdata.get_continuous` or `get_daily` "which index by datetime". Neither does: no getter under `waterdata/` sets an index, and the suite pins `time` as an ordinary column. The recommendation is right, the reason given for it was not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVhHGSDkVxWzeUMmHn6oAT
|
Note for whoever merges this, from a separate project rather than a review point on the diff.
That reason expires when #398 and #399 land. Worth revisiting the guardrails then rather than leaving a dated justification in place indefinitely — until they are retired, a lint lane there can only report drift and never act on it. No change requested here. |
Selects the ruff checks that catch something nothing else in the stack can see, fixes what they found, and pins the linter that grades them. Three live bugs; the suppression budget goes down, not up.
This started as a pylint adoption and turned into a measurement that pointed the other way — most of pylint's yield was ruff rules that simply were not selected. #399 covers what was left over after that.
The rules
PLW1514ARG001D100–D104automodule ... :members:renders a docstring-less public symbol as a bare signatureBLE001RUF100# noqa: BLE001directives sat in the tree suppressing a rule nobody had selectedASYNCPDinplace=is on pandas' way out; this is a pandas libraryDTZdatetimein a library about time seriesC4LOGGT20FANPYBLE001is the interesting one. Ruff reported all six pre-existing directives asUnused noqa directive (non-enabled: BLE001). Four were on handlers that re-raise and never needed one; those are deleted, and the two onprogress.py's best-effort cosmetics now do something. Four directives removed, one added (aDTZ007that is correct-by-design, see below).What they found
get_ratings(file_path=...)wrote each rating withopen(..., "w")and no encoding. On a non-UTF-8 locale an unrepresentable character raisesUnicodeEncodeError→ aValueError→ caught by_download_all's per-feature handler and downgraded to aSkippedRatingWarning, so the rating silently disappears from the returned dict. That function's own docstring promises the opposite. It had survived ruff,mypy --strict, 995 tests and a 98.9% coverage floor.open()insideasync def _fetch_rating, which runs dozens-concurrent under a fan-out drive — so every rating write stalled every other in-flight download for its duration. Now_write_ratingon a worker thread viaanyio.to_thread.run_sync.get_record'swide_format,datetime_indexandstatehave read nothing sinceqwdata,gwlevelsandwater_usewere retired, but the docstring still described the behavior they used to have — soget_record(sites=..., state="OH")reads as a state filter while doing nothing.format_responseset the index on the caller's frame in place while already returning a new object — and already left the caller's frame untouched on thepeaksand GeoDataFrame paths, so the in-place branch was the odd one out.The flow, not just the rules
The rule list is only half the gate. Four flow defects, none of which selecting rules addresses:
testextra all said 0.16.1; the machine that wrote this had 0.16.5 on$PATHand 0.15.12 in its venv, andruff format --checkdisagreed by 28 files between them. The pin now lives in two places instead of three: a[lint]extra that CI installs the way the[metrics]job already installs its own, and theruff-pre-commitrev. Thetestextra reusesdataretrieval[lint], exactly as it already reusesdataretrieval[type-check], so a bump is a two-line edit. (Arequired-versionguard turned a mismatch into a hard exit 2; review pointed out it bought that at the cost of a fourth place to keep in step, and it is out.)preview = trueenabled 52 rules to get one.PLW1514is the only preview rule this config wants; withoutexplicit-preview-rulesevery other preview rule under the selected prefixes is live too — 41 fromEalone — and each ruff bump adds more. Not hypothetically: that count was 50 at 0.16.1 and is 52 at 0.16.5, four patch releases later. Those two would have arrived as CI failures nobody selected.RUF100, above.E501was selected redundantly (already insideE) under a comment naming aline-lengthsetting that did not exist. The value is now set explicitly, where it visibly governs both the checker and the formatter.A note for authors, now in the config comment: RUF100's fix reads the rest of the line as the directive's description, so a co-located suppression for another tool is deleted with it. Write
# noqa: X # pylint: disable=..., never# noqa: X, pylint: .... The four directives that carried a reason after a dash or in parens are respelled accordingly.Two judgement calls worth your eye
get_recordwarns rather than raises. My first version raisedNameError, matching howget_recordalready treats a defunct service. Review overturned it, correctly: a defunct service cannot return the data asked for, butget_record(service="dv", wide_format=False)returns exactly the right data — only the knob is dead. Raising would retire the parameters ~20 months beforeREMOVALS["nwis"]with no warned release in between. They now warn through_deprecation.warn_deprecatedand are still ignored.They are not deleted from the signature:
statewould then fall through**kwargstoquery_waterservicesand onto the wire as a parameter NWIS does not define. (wide_format/datetime_indexwould not —to_strdrops non-iterable scalars — so that argument holds forstatealone.)ADR 0005 says a deprecated surface should "warn once per user call" — and a call naming all three options now emits four
DeprecationWarnings: one from@_deprecatedplus one per option. They are not duplicates (each names a distinct subject and a distinct replacement, which the same ADR sentence asks for), but_warn_defunct_record_optionsraises throughwarn_deprecateddirectly and so does not pass through_deprecated's re-entrancy guard, which is the module's cadence mechanism. The count is now pinned by a test, as is the attribution the hand-countedstacklevel=4encodes.Two ways to resolve, your call: clarify the ADR's cadence wording to scope the guarantee per deprecated surface (my preference — the code is doing what the Decision explicitly asks), or collapse the three advisories into one warning appended to the function-level message, which costs the per-argument subject/replacement pairing that makes each individually actionable.
One open question
The ratings write is still text mode (
encoding="utf-8",newline="") writingresponse.text. httpx'sTextDecoderuseserrors="replace", so a body that is not valid UTF-8 round-trips through U+FFFD — which is normalization, not the byte-for-byte fidelity the test name claims.open(..., "wb")+response.contentwould be literally byte-for-byte and needs neither keyword. Practical impact today is nil (USGS RDB is ASCII, and httpx defaults toutf-8when the asset carries no charset), and going binary would take the line out ofPLW1514's scope, leaving that rule at zero findings in the package. Either is defensible; the write is now isolated in a three-line_write_rating, so it is a one-line change. Flagging rather than deciding.Cost
Measured at ruff 0.16.1 (the pin at the time), interleaved A/B, n=25, whole tree: 25.9 ms → 25.6 ms, i.e. −0.3 ms, within noise. For scale,
python -c passis 121 ms on the same machine, and pre-commit's own per-hook orchestration is ~380 ms against the ~21 ms of ruff it wraps. The rules cost less than nothing measurable.What was rejected, and why it matters
Around 3,500 findings across
A,SLF,RET,DOC,FIX/TD, most of pydocstyle and 19 of 20PLcodes — none a defect. Several would actively damage the codebase: pydocstyle's section rules ship a safe autofix that rewrites eight flagship getters' docstrings into broken ones (they read the indentedExamples:inside a parameter description as a section header);DOC502wants thirteen public getters to delete theChunkInterruptedentry that is the resume contract;PLR0913fires 40 times on the OGC queryable signaturesCONTEXT.mdnames as the public contract.PLC0414must never be selected —mypy --strictimpliesno_implicit_reexport, so the 44X as Xaliases inconfiguration.pyare required, and removing them produces 29[attr-defined]errors.Also rejected in the second pass:
N818/N801would rename exportedNWIS_Metadata,RateLimited,ServiceUnavailable— a breaking change buying nothing.Sis 1665 findings (1656assertin tests; the two package hits are correct non-crypto retry jitter).ANNis 1722 andmypy --strictalready covers the package.PYI019had one finding but wantstyping_extensions.Self— a new runtime dependency for one annotation on 3.10.Caveats
PLW1514is the only selected rule ruff has not stabilized; it works viapreview = trueand is named explicitly underexplicit-preview-rules, so a version bump has to re-check it. Re-checked at 0.16.5: still preview-only, still fires.DTZ007inogc/dates.py:_parse_datetimeis correct-by-design —_DATETIME_FORMATSdeliberately carries both the%zand the bare forms, and the docstring promises tz-awareness only for an input that carried an offset. It takes a directive naming the reason rather than a "fix".tests/*ignore coversARG001and the fiveD1codes — the largest admission here. pytest matches fixtures and hook arguments by name (renamingpytest_collection_modifyitems'sconfigfails collection with aPluginValidationError), and test docstrings are not published API. Codes are named rather than writtenD1, so D105–D107 are not pre-exempted. The rationale lives in this description and the commit message rather than in the config, which review asked to keep to the constraint.anyiooverride is unrelated to ruff but was found the same way: its rationale was a 3.9 target that amatchstatement would fail to parse, andpython_versionhas been 3.10 since 1.2.0.mypy --strictpasses without it, in the isolated pre-commit hook env as well.Verification
All 11 CI checks pass, including the full 6-leg matrix (ubuntu + windows × 3.10/3.13/3.14).
ruff check+ruff format --checkclean at the pinned 0.16.5 ·mypy --strict60 files clean · 1146 tests pass · coverage 98.93% against the 98.9 floor · xenon, complexipy, import-linter (8/8 contracts) all pass.Regression tests added for the fixes. The ratings test asserts the saved file equals the response body byte for byte using a character absent from cp1252, so it fails on the Windows leg without the fix and cannot fail on Linux or macOS.
Changes after review
ruff-pre-commitrev together.ruff checkandruff format --checkare clean on the tree at that version, and bothPLW1514andARG001still fire.required-versionremoved and thetestextra folded intodataretrieval[lint]— two places to keep in step, down from three onmainand four on the first pass of this branch. The cost is that a contributor on a mismatched local ruff grades differently and quietly, as onmaintoday, instead of exiting 2.per-file-ignoresnote and the CI step's comment go entirely. TheRUF100author note about ruff's fix swallowing a co-located# pylint: disable=stays — that one is a trap you hit while editing the file, not history.waterdata.get_ratings(file_path=...)bug fixes. Thenwis.get_recordoption deprecations and theformat_responsechange are no longer called out,nwisbeing deprecated itself.AGENTS.mdnow points at the[lint]extra rather than at the CI job for the pin.Still open and unchanged: the ADR 0005 cadence question and the text-versus-binary ratings write below.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T6MVcko4gh68LieGWxUnrR