Type-hint imperatively-mapped RepositoryMetadata columns

RepositoryMetadata stays imperatively mapped because a declaratively
mapped class cannot have a `metadata` column attribute (it collides with
DeclarativeBase.metadata, see PR #12064). The downside has been that
mypy can't see any of the column attributes, forcing every caller to
either ignore [attr-defined] or work around it with setattr.

Add Mapped[X] annotations on the class — they're inert at runtime (the
mapper still installs InstrumentedAttribute descriptors via
map_imperatively) and only feed the type checker. JSON columns
(`metadata`, `tool_versions`) are typed as Mapped[Any] rather than
Optional, matching existing caller assumptions that they're populated
when accessed.

Drops three now-obsolete `# type: ignore[has-type]` /
`# type: ignore[attr-defined]` comments. mypy error count on the
affected toolshed files goes from 62 to 53 with no new failures.
This commit is contained in:
mvdbeek
2026-05-08 21:39:16 +02:00
parent 1751723877
commit a1445bfbaf
3 changed files with 28 additions and 13 deletions
@@ -1087,7 +1087,7 @@ def get_repository_metadata(session, repository_id):
stmt = (
select(RepositoryMetadata)
.where(RepositoryMetadata.repository_id == repository_id)
.order_by(RepositoryMetadata.changeset_revision, RepositoryMetadata.update_time.desc()) # type: ignore[attr-defined] # mapped attribute
.order_by(RepositoryMetadata.changeset_revision, RepositoryMetadata.update_time.desc())
)
return session.scalars(stmt)
+23 -6
View File
@@ -387,13 +387,14 @@ class Repository(Base, Dictifiable):
user = relationship("User", back_populates="active_repositories")
downloadable_revisions = relationship(
"RepositoryMetadata",
primaryjoin=lambda: (Repository.id == RepositoryMetadata.repository_id) & (RepositoryMetadata.downloadable == true()), # type: ignore[has-type]
primaryjoin=lambda: (Repository.id == RepositoryMetadata.repository_id)
& (RepositoryMetadata.downloadable == true()),
viewonly=True,
order_by=lambda: desc(RepositoryMetadata.update_time), # type: ignore[attr-defined]
order_by=lambda: desc(RepositoryMetadata.update_time),
)
metadata_revisions = relationship(
"RepositoryMetadata",
order_by=lambda: desc(RepositoryMetadata.update_time), # type: ignore[attr-defined]
order_by=lambda: desc(RepositoryMetadata.update_time),
back_populates="repository",
)
roles = relationship("RepositoryRoleAssociation", back_populates="repository")
@@ -702,10 +703,26 @@ class Tag(Base):
class RepositoryMetadata(Dictifiable):
repository: "Repository"
# Annotations only — runtime attributes are installed by
# mapper_registry.map_imperatively below.
id: Mapped[Optional[int]]
create_time: Mapped[Optional[datetime]]
update_time: Mapped[Optional[datetime]]
repository_id: Mapped[Optional[int]]
changeset_revision: Mapped[Optional[str]]
numeric_revision: Mapped[Optional[int]]
metadata: Mapped[Any]
tool_versions: Mapped[Any]
malicious: Mapped[Optional[bool]]
downloadable: Mapped[Optional[bool]]
missing_test_components: Mapped[Optional[bool]]
has_repository_dependencies: Mapped[Optional[bool]]
includes_datatypes: Mapped[Optional[bool]]
includes_tools: Mapped[Optional[bool]]
includes_tool_dependencies: Mapped[Optional[bool]]
includes_workflows: Mapped[Optional[bool]]
repository: Mapped["Repository"]
# Once the class has been mapped, all Column items in this table will be available
# as instrumented class attributes on RepositoryMetadata.
table = Table(
"repository_metadata",
mapper_registry.metadata,
+4 -6
View File
@@ -91,12 +91,10 @@ def test_get_repositories_for_indexing_orders_by_last_revision_create_time(shed_
base = datetime(2026, 1, 1, 12, 0, 0)
rm_a = RepositoryMetadata(repository_id=repo_a.id, changeset_revision="a", downloadable=True)
rm_b = RepositoryMetadata(repository_id=repo_b.id, changeset_revision="b", downloadable=True)
# setattr — RepositoryMetadata is imperatively mapped, so create_time /
# update_time aren't visible to the static type checker.
setattr(rm_a, "create_time", base)
setattr(rm_a, "update_time", base)
setattr(rm_b, "create_time", base + timedelta(hours=1))
setattr(rm_b, "update_time", base + timedelta(hours=1))
rm_a.create_time = base
rm_a.update_time = base
rm_b.create_time = base + timedelta(hours=1)
rm_b.update_time = base + timedelta(hours=1)
session.add_all([rm_a, rm_b])
# Make repo_a's row look "recently updated" — under the old ORDER BY this
# surfaced repo_a first even though repo_b has the newer downloadable