Make a SpeciesRecord dataclass for species_map records - #1899
Make a SpeciesRecord dataclass for species_map records#1899adityasingh2400 wants to merge 3 commits into
Conversation
Replaces the loose list of 4-tuples with a frozen `SpeciesRecord` dataclass, as requested in dandigh-1867. The dataclass validates in `__post_init__` the invariants that until now only `test_species_map` checked: common names and prefix lower-cased, URI an NCBITaxon PURL, and name formatted as "{scientific name} - {GenBank common name}". A malformed entry now fails at import time rather than only under pytest. Matching logic moves onto the record as `matches_name` and `matches_common_name`, so `extract_species` reads as the two-pass lookup it already was. `name.partition(" - ")` is replaced by the `scientific_name` and `genbank_common_name` properties: the separator element that `partition` returned could never match a stripped input, so behavior is unchanged. Closes dandi#1867
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1899 +/- ##
==========================================
- Coverage 76.96% 76.91% -0.05%
==========================================
Files 88 88
Lines 12882 12927 +45
==========================================
+ Hits 9914 9943 +29
- Misses 2968 2984 +16
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
THANK YOU for the PR @adityasingh2400 ! Overall looks good and worth pursuing. With claude we seems have identified a number of concerns which I will post now. See (with your claude ;) ) the about to be posted review. |
yarikoptic-gitmate
left a comment
There was a problem hiding this comment.
Nice refactor — this is the right shape for #1867, and moving the matching onto the record makes extract_species read as the two-pass lookup it always was. I checked it out and verified the behavior-preservation claim rather than taking it on faith:
pytest dandi/tests/test_metadata.py→ 143 passed, 1 skipped, 8 xpassedflake8clean;black --checkunchangedmypy: 5 errors, all missing-dateutil-stub errors indandi/utils.py,dandi/tests/fixtures.py, anddandi/tests/test_metadata.py:31— identical set onmaster, so nothing introduced- Matching equivalence: I reimplemented the old
partition-based predicate and diffed it againstmatches_name/matches_common_nameover every name, name-half, common name and prefix in the map, plus edge inputs (""," ","-"," - ", case and whitespace variants) → 0 mismatches
Your reasoning about partition's separator element is right, and for a second reason worth stating: partition(" - ")[1] is always " - ", and lower_value is .strip()ed, so it can never match regardless of the data.
Requesting changes on one class of issue: __post_init__ is now the sole guardian of these invariants, but it accepts several malformed entries that are worse than the ones it rejects. Since the stated goal is "a malformed entry now fails at import rather than only under pytest," it's worth closing those before this lands.
Must fix
1. prefix="" and common_names=("",) are accepted, and either one breaks the whole table
SpeciesRecord(("mouse",), "", uri, "Mus musculus - House mouse") # ACCEPTED
SpeciesRecord(("",), "mus", uri, "Mus musculus - House mouse") # ACCEPTED"" == "".lower() passes the lower-case check, and value.startswith("") is True for every input — so that record matches everything, and every other lookup starts failing with "Got multiple (N) species matched … Should not happen." Verified: an empty-prefix record returns True for matches_name("zebrafish").
This is a bigger hole than anything __post_init__ currently catches. Please reject empty prefix and empty entries in common_names.
2. common_names type isn't enforced, and the likely mistake is silent
Five of the thirteen entries are single-element tuples, so the realistic error is a dropped trailing comma:
SpeciesRecord(("mouse"), "mus", ...) # common_names == "mouse", a strThat constructs, passes __post_init__ (iterating a lowercase string yields lowercase chars), and is hashable — no loud failure anywhere. I patched it in and ran it:
'm' -> Mus musculus - House mouse
'e' -> Mus musculus - House mouse
'mouse' -> ERROR: Cannot interpret species field: mouse
Single letters resolve to mouse; the real common name stops resolving entirely. And test_species_map passes anyway, because chain(...) iterates the string's characters and every one of them now "matches."
The one thing that caught it was assert isinstance(record.common_names, tuple) in your new test_species_map_entries_are_records — so that assertion is genuinely load-bearing, please keep it. But it should be a guarantee rather than a spot-check: either validate the type in __post_init__, or normalize with object.__setattr__(self, "common_names", tuple(self.common_names)). Worth a case in test_species_record_rejects_malformed_entry too, which currently has no bad-common_names-type case.
3. The URI check is prefix-only
if not self.uri.startswith(NCBITAXON_URI_TEMPLATE.format("")):All of these are accepted: NCBITaxon_, NCBITaxon_abc, NCBITaxon_9606/junk, NCBITaxon_9606extra. That matters because extract_species matches incoming URIs with NCBITaxon_([0-9]+), so a non-numeric entry constructs fine and is then silently unreachable via the URI path.
Suggest validating the taxon id is numeric — but please don't do it with a second hardcoded copy of the URL, which would defeat the point of NCBITAXON_URI_TEMPLATE. Cleanest is probably to store the numeric id on the record and derive uri from the template.
4. The comment on the hash assertion is inaccurate
# frozen dataclasses are hashable, which `extract_species` relies on
# indirectly when de-duplicating matches
assert hash(record) == hash(record)extract_species de-duplicates list(set(value_matches)) where the elements are (uri, name) string tuples — records are never hashed, so the stated dependency doesn't exist.
On the assertion itself: it isn't reading a cached value (the generated __hash__ rebuilds and rehashes the field tuple on every call), but with all fields being str/tuple[str, ...]/None it can only ever fail by raising, which a bare hash(record) detects identically. And the hash isn't reproducible across runs — PYTHONHASHSEED randomizes str hashing, and nothing here pins it or needs it to be stable.
The eq/hash contract is worth locking in, so rather than dropping the line, suggest strengthening it to two distinct-but-equal instances:
copy = dataclasses.replace(record)
assert copy is not record
assert copy == record and hash(copy) == hash(record)That version additionally catches eq=False or a hand-rolled identity __hash__, which the current form passes silently.
Should fix
5. matches_name / matches_common_name are public with an unenforced case contract
species_map[0].matches_name("Mus musculus") # False
species_map[0].matches_common_name("Mouse") # FalseThe refactor promoted an inline predicate that always received pre-normalized input into public API that doesn't normalize, with only docstring prose to warn callers. Either normalize inside the methods, or make them _matches_name/_matches_common_name.
6. No direct tests of the two methods the refactor exists to create
The new tests cover the name-half properties and the four rejection paths, but matches_name's prefix branch is exercised only transitively through extract_species. A direct test would also usefully document the surprising bit — matches_name("mushroom") is True for Mus musculus.
7. __post_init__ accepts degenerate names
" - Human" and "Human - " both pass. The first yields scientific_name == "", which makes matches_name("") return True — and extract_species guards value_orig != "" but not whitespace-only, so {"species": " "} reaches it. Not live with the current data, but the check is "separator present," not "both halves non-empty."
8. Docstring nit
matches_name's "or its prefix" reads as equality; it's startswith. The class docstring gets this right — worth matching. Also worth a one-line note on genbank_common_name that it relies on __post_init__ having validated the separator, since it would IndexError otherwise.
Optional
- Cross-record invariants are the ones that actually reach users. Nothing checks for duplicate
uris or aprefixthat matches another record. Addingprefix="ma"to Macaca mulatta, for example, breaks radiata and nemestrina (mulatta itself keeps resolving, since the conditions OR within one record). No collisions exist today — this is hardening, not a live bug — but a module-level check or an extra test would cover a class of mistake that per-record lower-casing doesn't. species_mapis still a mutablelistof frozen records;species_map: tuple[SpeciesRecord, ...]would match the intent.- In
test_species_map,assert key.lower() == keyand the separator assertion can no longer fail —__post_init__guarantees them at import. Worse, the first also passes for the bare-string case in #2, so it's misleading about what it protects. matches_common_namecould just bevalue in self.common_names.
None of this changes the verdict — the refactor is behavior-preserving and the validation is a real improvement over what only test_species_map checked before. Mostly it's that __post_init__ should reject the entries that would do the most damage.
Generated by Claude Code
Reject an empty prefix and empty common names: an empty prefix starts every value, so such a record matched every lookup and turned every other one into a multiple-species error. Reject a non-tuple common_names rather than coercing it. A dropped trailing comma leaves a str, which iterates as lower-cased characters and so passed every other check while making each letter match the species. tuple() would have produced exactly that character tuple, so this has to be a type check. Store the numeric taxon id and derive uri from NCBITAXON_URI_TEMPLATE. The old prefix-only check accepted NCBITaxon_abc and NCBITaxon_9606/junk, which extract_species can never match back, and validating the full URI would have meant a second hardcoded copy of the URL. Normalize input in matches_name and matches_common_name so the case contract is enforced rather than documented, and correct the hash assertion: extract_species de-duplicates string tuples, not records, so the stated dependency did not exist. Compare two distinct but equal instances instead.
|
Thank you for this, particularly for reimplementing the old predicate and diffing it rather than taking the equivalence claim on faith. All six points are addressed in 9b06d87. 1. Empty 2. 3. URI check. Taken your suggestion rather than patching the prefix check. The record now stores 4. Hash assertion. You are right that the comment described a dependency that does not exist, 5. Case contract. Both methods normalize their input now, so the contract is enforced rather than described. I chose that over renaming to 6. Direct tests. Added, including On verification, one caveat worth stating plainly. |
yarikoptic-gitmate
left a comment
There was a problem hiding this comment.
All six are genuinely addressed in 9b06d87 — I re-verified each against the new code rather than reading the diff. And you're right about object.__setattr__(..., tuple(self.common_names)): tuple("mouse") is ('m','o','u','s','e'), which is precisely the character tuple that causes the bug, so coercion would have laundered the mistake instead of catching it. The type check was the only correct fix; my suggestion was wrong on that branch.
Since you couldn't run the suite locally, here are the results from an environment that has dandischema/hdmf:
pytest dandi/tests/test_metadata.py 148 passed, 1 skipped, 8 xpassed
black --check unchanged
flake8 clean
mypy 5 errors, all pre-existing missing-dateutil-stub
errors on untouched lines; identical on master
Re-verified directly:
- Equivalence still holds. The methods normalize now, so I re-ran the old
partition-based predicate against the newmatches_nameacross every name, half, common name, prefix and the whitespace/case edge cases — 0 mismatches.extract_speciesalready passedvalue_orig.lower().strip(), so the added.strip().lower()is idempotent there and nothing shifts. - All five holes closed, with the messages the tests expect: empty
prefix, empty common name,taxon_id="abc",taxon_id="", andcommon_names="mouse"(the last asTypeError: ... must be a tuple, got str). uriderivation produces a well-formed PURL for all 13 entries. Moving it to a property also means eq/hash now key ontaxon_id, which is equivalent and keepsdataclasses.replaceworking — the new two-instance assertion passes.
One thing that will fail CI
import dataclasses landed in the third-party block rather than the stdlib group, and isort runs in the lint pre-commit job:
--- dandi/tests/test_metadata.py:before
+++ dandi/tests/test_metadata.py:after
@@ -1,5 +1,6 @@
from __future__ import annotations
+import dataclasses
from datetime import datetime, timedelta
from itertools import chain
@@ -33,8 +34,6 @@
from pynwb import NWBHDF5IO, NWBFile, TimeSeries
-import dataclasses
-
import pytest
black and flake8 don't catch import ordering, which is why it looked clean — isort --check-only dandi/tests/test_metadata.py, or just pre-commit run --all-files, reproduces it.
Optional nit
taxon_id.isdigit() is a hair looser than the [0-9]+ it's standing in for — "²".isdigit() and "٢٥".isdigit() are both True, and neither would match NCBITaxon_([0-9]+) on the way back in. isdecimal() doesn't close it either ("٢٥".isdecimal() is True); taxon_id.isascii() and taxon_id.isdigit(), or re.fullmatch(r"[0-9]+", taxon_id), is exact. Purely theoretical for a hand-maintained table — mentioning it only because the stated rationale was matching what extract_species actually depends on.
Everything I raised is resolved. Fix the import order and this looks good to me; I'll leave the formal approval to @yarikoptic.
Generated by Claude Code
isort runs in the lint pre-commit job and force_sort_within_sections places it before datetime, not in the third-party block where it landed.
|
Fixed in 321479c, and thank you for running the suite and posting the numbers. That fills exactly the gap I flagged.
On the coercion point, thank you for saying so directly. It would have been easy to apply the suggestion as written, and the result would have looked correct while quietly turning the exact bug we were fixing into the accepted state. Two things from your notes I want to acknowledge rather than let pass silently: The Your point that moving Codecov flags 14 uncovered lines on the patch. Most of those are the new rejection branches, which the parametrized cases do exercise, so I suspect the report predates 9b06d87. Happy to look properly once CI has run against this head if it still shows the same. |
|
Ran Your reading of the config was right: On codecov — your suspicion is right, it's stale. The comment names its head explicitly: "Comparing base ( One caveat so you don't take a wrong number from me: I can't give you a trustworthy local patch-coverage figure to compare against. No further comments from me; the substance was settled in 9b06d87. Generated by Claude Code |
|
The Codecov comment above is stale and I do not think it should hold this up. It reports Measured against the current head, all 58 lines this PR adds to None of the 27 remaining misses fall inside this PR's diff. They are all pre-existing lines outside it. Worth flagging one measurement trap, since it is what made me check twice. Running this through Separately, |
|
Reproduced, independently, on I also checked the stronger claim mechanically rather than by eye, intersecting the 27 uncovered lines against the set of lines the diff actually adds to So every line this PR adds is covered, and the remaining misses are all pre-existing. Your read of the measurement artifact matches mine — the giveaway really is lines 1–21 showing as missing, which can only mean the module was already imported when coverage attached. On That closes the last open question from my side. Nothing outstanding. Generated by Claude Code |
Fixes #1867
species_mapwas a list of loose 4-tuples, which is what @CodyCBakerPhD flagged in the #1866 review. That prerequisite landed on 2026-06-01, so this is now unblocked.Each entry becomes a frozen
SpeciesRecorddataclass withcommon_names,prefix,uri, andname.__post_init__enforces the invariants that until now onlytest_species_mapchecked: common names and prefix lower-cased, URI an NCBITaxon PURL, and name formatted as{scientific name} - {GenBank common name}. A malformed entry now fails at import rather than only under pytest.The matching logic moves onto the record as
matches_nameandmatches_common_name, soextract_speciesreads as the two-pass lookup it already was. Thename.partition(" - ")calls are replaced byscientific_nameandgenbank_common_nameproperties. The separator element thatpartitionreturned could never equal a stripped input, so behavior is unchanged.Tested with 3 new tests (6 cases with parametrization), all carrying
@pytest.mark.ai_generatedper CLAUDE.md, plustest_species_mapreworked to take records. Fulldandi/tests/test_metadata.pyrun gives 142 passed, 1 xfailed, 8 xpassed.Since this is a refactor, an import failure alone would be weak evidence, so the behavior change was checked directly against the base ref. On
mastera malformed entry is accepted silently. On this branch it is rejected withCommon name 'Mouse' of http://example.com/not-ncbitaxon must be lower-cased.AI assistance disclosure: this change was written with the help of Claude Code, and the added tests are marked
ai_generatedas CLAUDE.md asks. I reviewed and tested everything before submitting.