diff --git a/taxonomy/applicator/generic.py b/taxonomy/applicator/generic.py index ebb4770..ab11c36 100644 --- a/taxonomy/applicator/generic.py +++ b/taxonomy/applicator/generic.py @@ -1317,6 +1317,10 @@ def build_plan( f"change {index}: field {field_name!r} is not an " "ADT tag field" ) + # Work around https://github.com/JelleZijlstra/pycroscope/issues/520. + field = cast( # type: ignore[redundant-cast] + ADTField[Any], field + ) if field_key in planned_values: raise RecommendationError( f"change {index}: remove_raw must be the first change " diff --git a/taxonomy/applicator/location.py b/taxonomy/applicator/location.py index 4dc59aa..f7f6c00 100644 --- a/taxonomy/applicator/location.py +++ b/taxonomy/applicator/location.py @@ -1481,7 +1481,7 @@ def add_virtual_models(plan: RecommendationPlan, builder: ProposalBuilder) -> No change.field, change.new_value, expected_label=change.new_label ), ) - tags = tuple(proposal.tags or ()) + tags = tuple(proposal.tags) tags = tuple(tag for tag in tags if tag not in row.remove_tags) tags = (*tags, *(tag for tag in row.add_tags if tag not in tags)) proposal.tags = tuple(sorted(set(tags))) # type: ignore[assignment] diff --git a/taxonomy/applicator/taxon.py b/taxonomy/applicator/taxon.py index feb7dbd..db6a22a 100644 --- a/taxonomy/applicator/taxon.py +++ b/taxonomy/applicator/taxon.py @@ -1,9 +1,10 @@ """Atomic Taxon/base-Name recommendations for manifest-created classifications.""" +import enum from collections import Counter from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, TypeVar from taxonomy.applicator import generic from taxonomy.applicator.proposals import ProposalBuilder @@ -72,7 +73,10 @@ def _string(data: Mapping[str, Any], key: str, line: int) -> str: return value -def _enum(data: Mapping[str, Any], key: str, cls: type[Any], line: int) -> Any: +EnumT = TypeVar("EnumT", bound=enum.Enum) + + +def _enum(data: Mapping[str, Any], key: str, cls: type[EnumT], line: int) -> EnumT: value = _string(data, key, line) try: return cls[value] diff --git a/taxonomy/applicator/test_article.py b/taxonomy/applicator/test_article.py index 560ec7c..8020c0e 100644 --- a/taxonomy/applicator/test_article.py +++ b/taxonomy/applicator/test_article.py @@ -1,6 +1,7 @@ import copy import hashlib from collections.abc import Mapping +from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -14,6 +15,12 @@ from taxonomy.db.models.person import VirtualPerson +@dataclass(frozen=True) +class _Options: + new_path: Path + library_path: Path + + def _pdf_bytes() -> bytes: return b"%PDF-1.4\nminimal test fixture\n%%EOF\n" @@ -138,7 +145,7 @@ def _build_volume_and_chapter(tmp_path: Path) -> recommendations.RecommendationP ) return recommendations.build_plan( rows, - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, articles_with_doi=lambda _doi: (), is_catalog_folder=lambda _path: True, @@ -157,7 +164,7 @@ def _build(tmp_path: Path) -> recommendations.RecommendationPlan: row = recommendations.parse_recommendation(_row(pdf), 1) return recommendations.build_plan( (row,), - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, articles_with_doi=lambda _doi: (), is_catalog_folder=lambda _path: True, @@ -216,7 +223,7 @@ def test_build_plan_canonicalizes_tags_and_infers_jstor(tmp_path: Path) -> None: plan = recommendations.build_plan( (row,), - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, articles_with_doi=lambda _doi: (), is_catalog_folder=lambda _path: True, @@ -280,7 +287,7 @@ def test_existing_person_author_is_guarded_and_reused_virtually(tmp_path: Path) ) plan = recommendations.build_plan( (recommendations.parse_recommendation(row_data, 1),), - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, get_person_by_id=lambda _id: person, articles_with_doi=lambda _doi: (), @@ -328,7 +335,7 @@ def test_existing_person_author_rejects_changed_family_name(tmp_path: Path) -> N ): recommendations.build_plan( (recommendations.parse_recommendation(row_data, 1),), - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, get_person_by_id=lambda _id: person, articles_with_doi=lambda _doi: (), @@ -350,7 +357,7 @@ def test_build_plan_rejects_changed_staged_file(tmp_path: Path) -> None: with pytest.raises(recommendations.RecommendationError, match="size changed"): recommendations.build_plan( (row,), - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, articles_with_doi=lambda _doi: (), is_catalog_folder=lambda _path: True, @@ -424,7 +431,7 @@ def test_inline_citation_group_is_planned_and_created(tmp_path: Path) -> None: recommendations.parse_recommendation(row_data, 1), recommendations.parse_recommendation(second_row_data, 2), ), - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, articles_with_doi=lambda _doi: (), is_catalog_folder=lambda _path: True, @@ -494,7 +501,7 @@ def test_completed_exact_state_is_idempotent(tmp_path: Path) -> None: plan = recommendations.build_plan( (recommendations.parse_recommendation(_row(pdf), 1),), - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: existing, articles_with_doi=lambda _doi: (existing,), is_catalog_folder=lambda _path: True, @@ -578,7 +585,7 @@ def test_build_plan_rejects_forward_planned_parent_reference(tmp_path: Path) -> with pytest.raises(recommendations.RecommendationError, match="must be an earlier"): recommendations.build_plan( rows, - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, articles_with_doi=lambda _doi: (), is_catalog_folder=lambda _path: True, @@ -610,7 +617,7 @@ def test_build_plan_orders_forward_typed_parent_reference(tmp_path: Path) -> Non plan = recommendations.build_plan( rows, - options=SimpleNamespace(new_path=new_path, library_path=library_path), + options=_Options(new_path=new_path, library_path=library_path), get_article=lambda _name: None, articles_with_doi=lambda _doi: (), is_catalog_folder=lambda _path: True, diff --git a/taxonomy/applicator/test_generic.py b/taxonomy/applicator/test_generic.py index 4f5ffba..7933aa3 100644 --- a/taxonomy/applicator/test_generic.py +++ b/taxonomy/applicator/test_generic.py @@ -74,6 +74,7 @@ def create_object( model: type[BaseModel], values: Mapping[str, object] ) -> BaseModel: obj = model.virtual(**values) + assert isinstance(obj, Location) created.append(obj) return obj @@ -118,6 +119,7 @@ def create_object( model: type[BaseModel], values: Mapping[str, object] ) -> BaseModel: obj = model.virtual(**values) + assert isinstance(obj, Location) created.append(obj) return obj diff --git a/taxonomy/db/models/base.py b/taxonomy/db/models/base.py index a54bb1c..4483af5 100644 --- a/taxonomy/db/models/base.py +++ b/taxonomy/db/models/base.py @@ -1530,13 +1530,17 @@ def serialize(self, value: Sequence[ADTT]) -> str | None: def prepare_virtual( self, value: Sequence[ADTT] ) -> tuple[str | None, Sequence[ADTT]]: - raw_value = self.serialize(value) + # Work around https://github.com/JelleZijlstra/pycroscope/issues/521. + raw_value = self.serialize(value) # static analysis: ignore[incompatible_call] if isinstance(value, str): return raw_value, self.deserialize(raw_value) return raw_value, tuple(value or ()) def validate_persistent(self, value: Sequence[ADTT]) -> None: - super().validate_persistent(value) + # Work around https://github.com/JelleZijlstra/pycroscope/issues/521. + super().validate_persistent( + value # static analysis: ignore[incompatible_argument] + ) def iter_models(item: Any) -> Iterable[Model]: if isinstance(item, Model): diff --git a/taxonomy/db/models/classification_entry/lint.py b/taxonomy/db/models/classification_entry/lint.py index ba4a229..9e38047 100644 --- a/taxonomy/db/models/classification_entry/lint.py +++ b/taxonomy/db/models/classification_entry/lint.py @@ -94,13 +94,13 @@ def check_tags(ce: ClassificationEntry, cfg: LintConfig) -> Iterable[LintResult] yield "unnecessary CorrectedName tag" if counts[ClassificationEntryTag.ReferencedUsage] > 1: yield "multiple ReferencedUsage tags" - if counts[ClassificationEntryTag.AuxiliaryName] > 1: + if counts[type(ClassificationEntryTag.AuxiliaryName)] > 1: yield "multiple AuxiliaryName tags" if counts[ClassificationEntryTag.VerbatimParent] > 1: yield "multiple VerbatimParent tags" if counts[ClassificationEntryTag.Materialize] > 1: yield "multiple Materialize tags" - if counts[ClassificationEntryTag.OriginalCitation] > 1: + if counts[type(ClassificationEntryTag.OriginalCitation)] > 1: yield "multiple OriginalCitation tags" base_name_author_tags = list( ce.get_tags(ce.tags, ClassificationEntryTag.MaterializeBaseNameAuthor) @@ -2146,6 +2146,7 @@ def check_page(ce: ClassificationEntry, cfg: LintConfig) -> Iterable[LintResult] new_page = yield from models.name.page.check_page( ce.page, get_raw_page_regex=ce.article.get_raw_page_regex ) + assert new_page is not None if new_page != ce.page: fixes = [field_fix(ce, "page", new_page)] for tag in ce.get_tags(ce.tags, ClassificationEntryTag.PageLink): diff --git a/taxonomy/db/models/classification_entry/test_lint.py b/taxonomy/db/models/classification_entry/test_lint.py index d482d55..a40fd5f 100644 --- a/taxonomy/db/models/classification_entry/test_lint.py +++ b/taxonomy/db/models/classification_entry/test_lint.py @@ -765,6 +765,21 @@ def test_mapped_entry_removes_orphaned_base_name_instructions() -> None: assert ce.tags == (retained,) +def test_check_tags_counts_parameterless_tags() -> None: + ce = _make_ce(parent=None, auxiliary=False) + + for tag, expected in ( + (ClassificationEntryTag.AuxiliaryName, "multiple AuxiliaryName tags"), + (ClassificationEntryTag.OriginalCitation, "multiple OriginalCitation tags"), + ): + ce.tags = (tag, tag) # type: ignore[assignment] + + assert any( + expected in str(issue) + for issue in check_tags(ce, LintConfig(autofix=False, interactive=False)) + ) + + def test_etymology_detail_is_transferred_to_mapped_name() -> None: taxon = Taxon.virtual(valid_name="Endodonta", rank=Rank.genus, age=AgeClass.extant) name = Name.virtual( diff --git a/taxonomy/db/models/name/lint.py b/taxonomy/db/models/name/lint.py index f0e1239..c7b38ba 100644 --- a/taxonomy/db/models/name/lint.py +++ b/taxonomy/db/models/name/lint.py @@ -4092,6 +4092,8 @@ def check_required_fields(nam: Name, cfg: LintConfig) -> Iterable[LintResult]: if nam.verbatim_citation and not nam.citation_group: yield "has verbatim citation but no citation group" if has_accessible_original_citation(nam): + citation = nam.original_citation + assert citation is not None if ( nam.page_described is None and not ( @@ -4108,7 +4110,6 @@ def check_required_fields(nam: Name, cfg: LintConfig) -> Iterable[LintResult]: yield "has original citation but no original_rank" if nam.author_tags is None: message = "has original citation but no author_tags" - citation = nam.original_citation if citation.issupplement() and citation.parent is not None: authors = citation.parent.author_tags else: @@ -4119,7 +4120,7 @@ def check_required_fields(nam: Name, cfg: LintConfig) -> Iterable[LintResult]: yield field_issue(message, nam, "author_tags", authors) if nam.year is None: message = "has original citation but no year" - yield field_issue(message, nam, "year", nam.original_citation.year) + yield field_issue(message, nam, "year", citation.year) if ( nam.name_complex is None and nam.group is Group.genus @@ -5508,7 +5509,6 @@ def check_structured_verbatim_citation_fields( yield replace_tag_issue( msg, nam, tag, new_tag, field="type_tags" ) - tag = new_tag end_page = candidate except ValueError: pass diff --git a/taxonomy/db/models/name/test_lint.py b/taxonomy/db/models/name/test_lint.py index 38aa47f..a6c9218 100644 --- a/taxonomy/db/models/name/test_lint.py +++ b/taxonomy/db/models/name/test_lint.py @@ -8,6 +8,7 @@ from taxonomy.db import coordinate_lint, models from taxonomy.db.constants import ( AgeClass, + ArticleType, Group, NamingConvention, NomenclatureStatus, @@ -20,6 +21,7 @@ ) from taxonomy.db.models.base import LintConfig from taxonomy.db.models.location import Location +from taxonomy.db.models.person import AuthorTag from .lint import ( _create_name_variant_issue, @@ -62,8 +64,13 @@ def test_redirect_name_issue_changes_only_redirect_fields() -> None: def test_take_over_name_issue_uses_explicit_fields_and_tag_removal() -> None: - citation = SimpleNamespace( - parent=None, author_tags=("Author",), year="1900", issupplement=lambda: False + author = AuthorTag.Author(person=models.Person.virtual(family_name="Author")) + citation = models.Article.virtual( + name="citation", + parent=None, + author_tags=(author,), + year="1900", + type=ArticleType.JOURNAL, ) ce = SimpleNamespace(article=citation, page="12", name="Original name") page_link = TypeTag.AuthorityPageLink( @@ -91,7 +98,7 @@ def test_take_over_name_issue_uses_explicit_fields_and_tag_removal() -> None: assert name.original_citation is citation assert name.page_described == "12" assert name.original_name == "Original name" - assert name.author_tags == ("Author",) + assert name.author_tags == (author,) assert name.year == "1900" assert name.type_tags == () diff --git a/taxonomy/db/models/test_base.py b/taxonomy/db/models/test_base.py index b117ed3..3893365 100644 --- a/taxonomy/db/models/test_base.py +++ b/taxonomy/db/models/test_base.py @@ -1,5 +1,5 @@ import sqlite3 -from collections.abc import Iterator +from collections.abc import Generator from contextlib import contextmanager from types import SimpleNamespace @@ -59,7 +59,7 @@ def test_lint_all_uses_read_only_context_without_autofix( events: list[str] = [] @contextmanager - def readonly() -> Iterator[None]: + def readonly() -> Generator[None]: events.append("enter") try: yield diff --git a/taxonomy/db/models/test_lint_registry.py b/taxonomy/db/models/test_lint_registry.py index 74aa1cb..596e28a 100644 --- a/taxonomy/db/models/test_lint_registry.py +++ b/taxonomy/db/models/test_lint_registry.py @@ -412,7 +412,7 @@ def test_duplicate_finder_can_return_structured_fix() -> None: def duplicate_key(_obj: FakeObject) -> str: return "same" - issues = list(duplicate_key.linter(second, LintConfig(autofix=False))) + issues = list(duplicate_key.linter(cast(Any, second), LintConfig(autofix=False))) assert len(issues) == 1 assert isinstance(issues[0], LintIssue) @@ -475,7 +475,7 @@ def test_legacy_autofix_branches_are_explicitly_allowlisted() -> None: parent, (ast.FunctionDef, ast.AsyncFunctionDef) ): parent = parents.get(parent) - assert parent is not None + assert isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)) branch = (str(path.relative_to(models_dir)), parent.name) if branch in { ("base.py", "_process_lint_results"), diff --git a/taxonomy/db/nomenclature_book.py b/taxonomy/db/nomenclature_book.py index 4edb3b8..da3d7ca 100644 --- a/taxonomy/db/nomenclature_book.py +++ b/taxonomy/db/nomenclature_book.py @@ -394,7 +394,7 @@ def get_type_specimen_text(name: Name) -> tuple[str, list[str]]: def get_row(taxon: Taxon, name: Name, taxon_to_ces: TaxonToCEs) -> Row: - todos = [] + todos: list[str] = [] order = taxon.get_derived_field("order") family = taxon.get_derived_field("family") interpreted_tl = name.get_type_tag(TypeTag.InterpretedTypeLocality) @@ -411,7 +411,10 @@ def get_row(taxon: Taxon, name: Name, taxon_to_ces: TaxonToCEs) -> Row: todos.append( f"Base name is not valid (status: {name.nomenclature_status.name})" ) - todos += check_full_expected_base_name.linter(taxon, LintConfig()) + todos.extend( + str(issue) + for issue in check_full_expected_base_name.linter(taxon, LintConfig()) + ) nomenclature_text = "" if nomen_novum_for := name.get_tag_target(NameTag.NomenNovumFor):