Skip to content

Burn down the coord, protobuf and forwarding-mismatch diagnostics - #821

Merged
d-chambers merged 9 commits into
devfrom
ty-burn-final
Aug 5, 2026
Merged

Burn down the coord, protobuf and forwarding-mismatch diagnostics#821
d-chambers merged 9 commits into
devfrom
ty-burn-final

Conversation

@d-chambers

@d-chambers d-chambers commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Description

Continues the ty burn-down after #816, combining the four remaining planned pieces. Across the three still-ignored rules the count goes 156 → 65:

rule before after
invalid-argument-type 86 31
invalid-return-type 40 30
invalid-method-override 30 4

Coords say what they actually return

The coord methods which canonicalize declared -> Self, but a coord routinely comes back as a different class — empty() always gives a CoordPartial, index()/snap()/sort() give a CoordRange from an array coord, and select() does too once the selection turns out to be evenly sampled. Those six say BaseCoord now; the type variable stays where the class really is preserved, such as convert_units.

The base also disagreed with every one of its own implementations about two parameter names — arg against args, unit against units — so the base moved, which is the side nothing calls (all 70 in-repo convert_units calls are positional).

A bug fell out of that. CoordPartial aliased update_limits and set_units to update, whose only parameter is **kwargs, which made set_units unusable:

patch.set_units(time="s")   # where that dim's coord holds no values
# TypeError: CoordPartial.update() takes 1 positional argument but 2 were given

Both are spelled out now, each keeping the signature its base declares. Two tests cover it and fail on the old code.

The sintela protobuf block

23 diagnostics on a single line, and the same root cause as #807 and #812: attrs collected in a plain dict and splatted into the model, so every field was offered the dict's value union. Building the model directly and applying the family's extras with new() leaves each field its own type. Two helpers there also under-declared — a packet with no header time contributes None, and the record parser only iterates.

Things that were narrower than what they forward to

Patch.__init__'s coords was narrower than the CoordManagerInput it hands to get_coord_manager, and get_coord_manager took only a tuple of dims while its callers have a Sequence — which also meant a list of dims never compared equal to a CoordManager's tuple, so a rename was silently skipped. get_quantity has always accepted a bare number as dimensionless and a pint Unit as itself, but three of its neighbours declared narrower subsets of the same idea; they share one quantity_like alias now. unbyte only decodes bytes and hands everything else back untouched, which bytes | str could not say.

Three Path(...) calls on a value that may be a UPath now go through the existing coerce_to_local_path, which is also more correct: bare Path("file:///tmp/x") keeps the scheme as a literal path segment, and that helper exists precisely to strip it.

Capstone

Ruff and ty badges in the readme.

What is not done

The rules stay ignored: 31 / 30 / 4 remain. They are one-offs now, not clusters — no two share a root cause — and several turn on whether a value can really be None at that point, which is a question about behaviour rather than annotation. I stopped rather than guess at those; the review of #816 was a good reminder of what rushing one produces.

Changelog

none

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

Summary by CodeRabbit

  • Enhancements

    • Expanded support for path-like inputs, iterable records, coordinate collections, and quantity values.
    • Improved coordinate operations, including unit assignment and limit updates for valueless coordinates.
    • Improved handling of missing metadata and timestamps during data import.
    • Preserved non-byte values when decoding utility inputs.
    • Improved unit handling in spectrogram visualizations.
  • Documentation

    • Added Ruff and ty status badges to the README.
  • Tests

    • Added coverage for coordinate units and updates without coordinate values.

The coord methods which canonicalize declared they return Self, but a
coord routinely comes back as a different class: empty() always gives a
CoordPartial, index() and snap() and sort() give a CoordRange from an
array coord, and select() does too once the selection turns out to be
evenly sampled. Those six say BaseCoord now. The type variable stays
where the class really is preserved, such as convert_units.

The base also disagreed with every one of its implementations about two
parameter names -- arg against args, unit against units -- so the base
moved, which is the side nothing calls.

CoordPartial aliased update_limits and set_units to update, whose only
parameter is **kwargs. That made set_units unusable: patch.set_units on
any dimension whose coord holds no values raised TypeError rather than
recording the units. They are spelled out now, each keeping the
signature its base declares.
The protobuf attrs were collected in a plain dict and splatted into the
model, so every field was offered the dict's value union; building the
model directly and applying the family's extras with new() leaves each
field its own type. Two helpers there also under-declared: a packet with
no header time contributes None, and the record parser only iterates.

Patch's coords parameter was narrower than the CoordManagerInput it
forwards to, and get_coord_manager took only a tuple of dims while its
callers have a Sequence -- which also meant a list of dims never
compared equal to a CoordManager's tuple.
get_quantity has always taken a bare number as dimensionless and a
pint Unit as itself, but three of its neighbours declared narrower
subsets of the same idea, so passing a value from one to another was an
error. They share one alias now. unbyte only decodes bytes and hands
everything else back untouched, which its bytes | str signature could
not say.
The repo lints with ruff and type-checks with ty in pre-commit, so say
so where everything else is said.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@d-chambers d-chambers added the ready_for_review PR is ready for review label Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@d-chambers, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a113c31-2653-47f8-b694-97e21fd3e185

📥 Commits

Reviewing files that changed from the base of the PR and between c33f7ff and 3160136.

📒 Files selected for processing (16)
  • dascore/core/attrs.py
  • dascore/core/coordmanager.py
  • dascore/core/coords.py
  • dascore/core/patch.py
  • dascore/io/core.py
  • dascore/io/index/indexer.py
  • dascore/io/index/planned.py
  • dascore/io/sintela/protobuf_utils.py
  • dascore/units.py
  • dascore/utils/misc.py
  • dascore/utils/models.py
  • pyproject.toml
  • tests/test_core/test_coordmanager.py
  • tests/test_core/test_coords.py
  • tests/test_io/test_indexer.py
  • tests/test_utils/test_misc.py
📝 Walkthrough

Walkthrough

Changes

The pull request updates typing and input normalization across coordinate, quantity, IO, and utility APIs. It adds explicit CoordPartial metadata methods, broadens path and record support, and adds related tests and project status badges.

API and input contract updates

Layer / File(s) Summary
Coordinate method contracts and partial-coordinate updates
dascore/core/coords.py, tests/test_core/test_coords.py, tests/test_core/test_coordmanager.py
Coordinate methods now return BaseCoord where concrete types may vary. CoordPartial explicitly implements update_limits and set_units.
Coordinate construction and attribute normalization
dascore/core/attrs.py, dascore/core/coordmanager.py, dascore/core/patch.py, dascore/core/summary.py
Coordinate and dimension inputs accept broader collection types. Dimension sequences normalize to tuples. Patch attributes require mappings, and Patch uses CoordManagerInput.
Quantity and transformed-coordinate contracts
dascore/units.py, dascore/viz/spectrogram.py, pyproject.toml, readme.md
quantity_like centralizes accepted quantity inputs. Spectrogram conversion uses get_quantity. Ty counts and README badges are updated.
IO path and record handling
dascore/io/core.py, dascore/io/index/indexer.py, dascore/io/sintela/protobuf_utils.py, dascore/io/terra15/utils.py, dascore/utils/downloader.py, dascore/utils/misc.py
IO paths are coerced to local paths. Protobuf parsing accepts iterables and missing timestamps. unbyte preserves non-byte values, and source metadata defaults to empty strings.

Possibly related PRs

Suggested labels: patch, IO

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: reducing type-checking diagnostics across coordinate, protobuf, and forwarding mismatches.
Description check ✅ Passed The description explains the problem, summarizes the changes, reports remaining work, and completes the applicable checklist items.
Docstring Coverage ✅ Passed Docstring coverage is 96.67% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ty-burn-final

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added IO Work for reading/writing different formats patch related to Patch class labels Aug 5, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
dascore/core/attrs.py (1)

141-146: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use an explicit exception instead of assert for input validation.

assert isinstance(out, Mapping) enforces from_dict's type contract. Python removes assert statements when the interpreter runs with the -O flag. If that flag is used and a caller passes a value that violates the Mapping | PatchAttrs | None type hint, the assertion is skipped and dict(out) receives an unexpected value, producing a less clear error or unintended behavior.

Raise a TypeError (or another explicit exception) instead of using assert, so the contract holds regardless of the optimization flag.

♻️ Proposed fix
-        # Anything not already a mapping came from model_dump, which
-        # returns one, so this only restates the contract for the checker.
-        assert isinstance(out, Mapping), "attr_map must resolve to a mapping"
+        # Anything not already a mapping came from model_dump, which
+        # returns one, so this only restates the contract for the checker.
+        if not isinstance(out, Mapping):
+            msg = f"attr_map must resolve to a mapping, got {type(out)}."
+            raise TypeError(msg)
         out = dict(out)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/core/attrs.py` around lines 141 - 146, Replace the assert in the
from_dict flow with an explicit TypeError when out is not a Mapping. Preserve
the existing validation message and continue converting valid mappings with
dict(out), removing dims, and constructing the class unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dascore/core/coords.py`:
- Around line 1243-1252: Update CoordPartial.update_limits to translate min to
start and max to stop before forwarding the limits, rather than passing min/max
unchanged through self.update. Preserve step and kwargs forwarding, ensuring
update_limits(min=...) produces a partial with the requested start value instead
of start=np.nan.

In `@dascore/io/index/indexer.py`:
- Line 139: Remove the eager non-UPath-to-Path conversion before the
local-directory validation in the indexer initializer. Preserve the original
URI-capable input through the requires_local_directory check, then pass it to
coerce_to_local_path and assign the resulting absolute path to self.path,
keeping Path inputs supported without bypassing URI handling.

---

Nitpick comments:
In `@dascore/core/attrs.py`:
- Around line 141-146: Replace the assert in the from_dict flow with an explicit
TypeError when out is not a Mapping. Preserve the existing validation message
and continue converting valid mappings with dict(out), removing dims, and
constructing the class unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e1710a82-6835-4c07-878d-676d6c2562b0

📥 Commits

Reviewing files that changed from the base of the PR and between d556a7f and c33f7ff.

📒 Files selected for processing (17)
  • dascore/core/attrs.py
  • dascore/core/coordmanager.py
  • dascore/core/coords.py
  • dascore/core/patch.py
  • dascore/core/summary.py
  • dascore/io/core.py
  • dascore/io/index/indexer.py
  • dascore/io/sintela/protobuf_utils.py
  • dascore/io/terra15/utils.py
  • dascore/units.py
  • dascore/utils/downloader.py
  • dascore/utils/misc.py
  • dascore/viz/spectrogram.py
  • pyproject.toml
  • readme.md
  • tests/test_core/test_coordmanager.py
  • tests/test_core/test_coords.py

Comment thread dascore/core/coords.py Outdated
Comment on lines +1243 to +1252
# Other operations that normally modify data do not in this case;
# they are spelled out rather than aliased so each keeps the
# signature its base declares.
def update_limits(self, min=None, max=None, step=None, **kwargs) -> BaseCoord:
"""No values to change, so only the metadata in kwargs is applied."""
return self.update(min=min, max=max, step=step, **kwargs)

def set_units(self, units) -> Self:
"""No values to change, so this only records the new units."""
return self.update(units=units)

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check get_coord's shape-handling branch when both shape and min/max are supplied.
rg -n -A 20 'if segments is not None' dascore/core/coords.py
rg -n 'shape' dascore/core/coords.py | rg -n 'get_coord'

Repository: DASDAE/dascore

Length of output: 1440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== get_coord signature and alias handling =="
rg -n -A 60 '^def get_coord' dascore/core/coords.py
rg -n -B 20 -A 80 'min=.*start|min.*start|max.*stop|start=.*min|stop=.*max|min=None|max=None|start=None|stop=None' dascore/core/coords.py | head -220

echo "== CoordPartial update/update_limits =="
sed -n '1200,1270p' dascore/core/coords.py

echo "== tests for CoordPartial update_limits =="
rg -n -A 40 'update_limits|test_update_limits' tests/test_core/test_coords.py

Repository: DASDAE/dascore

Length of output: 11391


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== full get_coord alias resolution and body =="
sed -n '2832,3015p' dascore/core/coords.py

echo "== focused search for alias branches =="
rg -n -A 12 -B 8 'min is not None|start if min|if stop is None|stop = max|max is not None' dascore/core/coords.py

echo "== existing update_limits tests =="
rg -n -A 50 -B 10 'update_limits|test_update_limits|set_units|CoordPartial' tests/test_core/test_coords.py | sed -n '1,240p'

Repository: DASDAE/dascore

Length of output: 24479


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate existing update_limits tests =="
rg -n 'def test_.*[Uu]pdate_[Ll]imits|update_limits' tests/test_core/test_coords.py tests -g '*.py'

echo "== search for min/max partial tests around coordination =="
rg -n -A 25 -B 10 'CoordPartial\(shape=.*start|get_coord\(shape=.*start|start=.*stop=.*step=.*units|test_.*partial' tests/test_core/test_coords.py

Repository: DASDAE/dascore

Length of output: 19791


Fix the update_limits forwarding logic for CoordPartial.

get_coord(min=...) maps min to start, but CoordPartial.update_limits(min=...) forwards min through get_coord unchanged, which creates a partial with start=np.nan even though the documented intent is to set the start. Drop min/max by merging them into start/stop before forwarding, or avoid passing them through self.update(...).

🧰 Tools
🪛 Ruff (0.16.1)

[error] 1246-1246: Function argument min is shadowing a Python builtin

(A002)


[error] 1246-1246: Function argument max is shadowing a Python builtin

(A002)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/core/coords.py` around lines 1243 - 1252, Update
CoordPartial.update_limits to translate min to start and max to stop before
forwarding the limits, rather than passing min/max unchanged through
self.update. Preserve step and kwargs forwarding, ensuring
update_limits(min=...) produces a partial with the requested start value instead
of start=np.nan.

Comment thread dascore/io/index/indexer.py Outdated
path = UPath(path).absolute() if isinstance(path, UPath) else Path(path)
requires_local_directory(path, label="DBDirectoryIndexer")
self.path = Path(path).absolute()
self.path = coerce_to_local_path(path).absolute()

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve URI inputs until coerce_to_local_path runs.

Line 137 converts every non-UPath input to Path before Line 139 calls coerce_to_local_path. For file:///tmp/data or local:///tmp/data, the helper receives a Path and returns it unchanged. self.path then points to a literal file:/... path instead of the intended local path. A remote-scheme string can also bypass requires_local_directory after this conversion.

Remove the eager Path conversion and apply coerce_to_local_path after the local-directory check. dascore/utils/paths.py Lines 80-93 define this Path fast path.

Proposed fix
-        path = UPath(path).absolute() if isinstance(path, UPath) else Path(path)
         requires_local_directory(path, label="DBDirectoryIndexer")
         self.path = coerce_to_local_path(path).absolute()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/io/index/indexer.py` at line 139, Remove the eager non-UPath-to-Path
conversion before the local-directory validation in the indexer initializer.
Preserve the original URI-capable input through the requires_local_directory
check, then pass it to coerce_to_local_path and assign the resulting absolute
path to self.path, keeping Path inputs supported without bypassing URI handling.

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (d556a7f) to head (3160136).
⚠️ Report is 2 commits behind head on dev.

Additional details and impacted files
@@            Coverage Diff            @@
##               dev      #821   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          164       164           
  Lines        17906     17914    +8     
=========================================
+ Hits         17906     17914    +8     
Flag Coverage Δ
network 48.49% <71.42%> (-0.01%) ⬇️
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Two of the changes were regressions and three of the annotations were
false.

CoordPartial.update_limits forwarded min, max and step whether or not
the caller passed them, and a None reaching the nullish validator
overwrites the stored scalar with nan -- so stacking along a dimension
whose coord holds no values quietly lost its step, and with it the
coord's fingerprint. It forwards only what it was given. The test that
claimed to cover this used a coord with nothing to lose; it now uses one
with a step, and fails without the fix.

PatchAttrs.from_dict grew an assert whose comment was wrong: the branch
it replaced took arbitrary caller input, so a pandas Series of attrs
stopped working, the error escaped the TypeError handler in the scan
path, and python -O removed the check entirely. The guard is back.

drop_coords takes bare names -- the body makes a set of its varargs, so
a collection is either unhashable or silently ignored -- and Patch takes
every mapping get_coord_manager does, including the {name: list} form
its own tests pass. Both said otherwise. quantity_like left out bytes
and Ellipsis, which get_quantity opens by handling, while promising
get_quantity_str a numpy time it stringifies into a date.

Self was correct for change_length and for a segmented coord's snap, so
they keep it, and CoordPartial's set_units override goes away: the base
rebuilds the same class and already takes its argument positionally.

Three of the changes turned out to do nothing at runtime and are
reverted rather than left as noise.
The four remaining ones were each a real disagreement, not noise.

BaseCoord's shape validator was named for a job it does not do, and the
name collided with CoordPartial's start/stop/step validator -- pydantic
lets the subclass replace it, so a partial coord silently lost the int
to tuple coercion every other coord has. Renaming it to what it does
restores that and removes the collision.

PlanResolver.live_entries promised a Mapping where the registry it
returns is popped from. CoordManager.new named three fields where its
base takes any. sensible_model_equals declared an other it cannot
require, and now returns NotImplemented for anything that cannot carry
the same fields, which is what __eq__ is supposed to do.

With those gone the rule holds at zero, so it comes out of the ignore
list.
@d-chambers
d-chambers merged commit acb25ca into dev Aug 5, 2026
27 checks passed
@d-chambers
d-chambers deleted the ty-burn-final branch August 5, 2026 07:02
@coderabbitai coderabbitai Bot mentioned this pull request Aug 7, 2026
4 tasks
@d-chambers d-chambers removed the ready_for_review PR is ready for review label Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

IO Work for reading/writing different formats patch related to Patch class

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant