diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 00a7ba6a4..1af6ec361 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -1,18 +1,33 @@ import re import time +from collections.abc import Sequence from pathlib import Path -from typing import Any, List, Optional, cast +from typing import Any, List, Optional, Self, cast -from sqlalchemy import JSON, Boolean, Index, Integer, String, delete, false, func, or_, select, update +from sqlalchemy import ( + JSON, + Boolean, + ColumnElement, + Index, + Integer, + Row, + String, + delete, + false, + func, + or_, + select, + update, +) from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Mapped, Session, mapped_column +from sqlalchemy.orm import InstrumentedAttribute, Mapped, Session, mapped_column from app.db.base import Base, execute_dml, get_id_column from app.db.models._constraints import media_identity_constraint from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType -def _text_like(column, pattern: str, wildcard: bool = False): +def _text_like(column: InstrumentedAttribute[Optional[str]], pattern: str, wildcard: bool = False) -> ColumnElement[bool]: """构造跨数据库大小写不敏感的文本匹配条件。""" if wildcard: return column.ilike(pattern, escape='\\') @@ -119,7 +134,7 @@ class TransferHistory(Base): @classmethod def list_by_title(cls, db: Session, title: str, page: int = 1, count: int = 30, - status: Optional[bool] = None, wildcard: bool = False): + status: Optional[bool] = None, wildcard: bool = False) -> list[Self]: """按标题或路径分页查询整理历史。""" if wildcard: text_filter = or_( @@ -146,7 +161,7 @@ class TransferHistory(Base): @classmethod async def async_list_by_title(cls, db: AsyncSession, title: str, page: int = 1, count: int = 30, - status: Optional[bool] = None, wildcard: bool = False): + status: Optional[bool] = None, wildcard: bool = False) -> list[Self]: """异步按标题或路径分页查询整理历史。""" if wildcard: text_filter = or_( @@ -173,7 +188,7 @@ class TransferHistory(Base): return list(result.scalars().all()) @classmethod - def list_by_page(cls, db: Session, page: int = 1, count: int = 30, status: Optional[bool] = None): + def list_by_page(cls, db: Session, page: int = 1, count: int = 30, status: Optional[bool] = None) -> list[Self]: """按时间倒序分页读取整理历史。""" statement = select(cls) if status is not None: @@ -188,7 +203,7 @@ class TransferHistory(Base): @classmethod async def async_list_by_page(cls, db: AsyncSession, page: int = 1, count: int = 30, - status: Optional[bool] = None): + status: Optional[bool] = None) -> list[Self]: """异步按时间倒序分页读取整理历史。""" if status is not None: query = select(cls).filter( @@ -213,11 +228,12 @@ class TransferHistory(Base): cls, db: Session, download_hash: str, - ): + ) -> Optional[Self]: """在调用方 Session 中按下载哈希查询最新记录。""" - return db.execute( + history: Optional[Self] = db.execute( select(cls).where(cls.download_hash == download_hash) ).scalars().first() + return history @classmethod def get_by_src( @@ -235,7 +251,8 @@ class TransferHistory(Base): statement = select(cls).where(cls.src == src) if storage: statement = statement.where(cls.src_storage == storage) - return db.execute(statement.order_by(cls.id.desc())).scalars().first() + history: Optional[TransferHistory] = db.execute(statement.order_by(cls.id.desc())).scalars().first() + return history @classmethod def get_by_transfer_task_id( @@ -350,7 +367,8 @@ class TransferHistory(Base): statement = select(cls).where(cls.src == src, cls.status.is_(True)) if storage: statement = statement.where(cls.src_storage == storage) - return db.execute(statement.order_by(cls.id.desc())).scalars().first() + history: Optional[TransferHistory] = db.execute(statement.order_by(cls.id.desc())).scalars().first() + return history @classmethod def get_by_dest( @@ -368,7 +386,8 @@ class TransferHistory(Base): statement = select(cls).where(cls.dest == dest) if storage: statement = statement.where(cls.dest_storage == storage) - return db.execute(statement.order_by(cls.id.desc())).scalars().first() + history: Optional[TransferHistory] = db.execute(statement.order_by(cls.id.desc())).scalars().first() + return history @classmethod def list_success_by_src( @@ -452,14 +471,14 @@ class TransferHistory(Base): return list(db.execute(statement).scalars().all()) @classmethod - def list_by_hash(cls, db: Session, download_hash: str): + def list_by_hash(cls, db: Session, download_hash: str) -> list[Self]: """查询同一下载任务的全部整理历史。""" return list(db.execute( select(cls).where(cls.download_hash == download_hash) ).scalars().all()) @classmethod - def statistic(cls, db: Session, days: int = 7): + def statistic(cls, db: Session, days: int = 7) -> list[Row[tuple[str, int]]]: """ 统计最近days天的下载历史数量,按日期分组返回每日数量 """ @@ -475,7 +494,7 @@ class TransferHistory(Base): ).all()) @classmethod - def monthly_media_statistics(cls, db: Session): + def monthly_media_statistics(cls, db: Session) -> tuple[int, int, int, int]: """ 统计当月成功整理的电影、电视剧、剧集和音乐数量。 @@ -514,7 +533,7 @@ class TransferHistory(Base): return len(movie_identities), len(tv_identities), episode_count, len(music_identities) @staticmethod - def _music_history_identity(history: "TransferHistory") -> tuple: + def _music_history_identity(history: "TransferHistory") -> tuple[str | int, ...]: """构造曲目级历史身份,避免整专内全部曲目被同一专辑 ID 合并。""" source = str(history.media_source or "").strip().casefold() media_id = str(history.media_id or "").strip() @@ -540,7 +559,7 @@ class TransferHistory(Base): return 1 @classmethod - async def async_statistic(cls, db: AsyncSession, days: int = 7): + async def async_statistic(cls, db: AsyncSession, days: int = 7) -> Sequence[Row[tuple[str, int]]]: """ 统计最近days天的下载历史数量,按日期分组返回每日数量 """ @@ -551,18 +570,20 @@ class TransferHistory(Base): result = await db.execute( select(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date) ) - return result.all() + rows: Sequence[Row[tuple[str, int]]] = result.all() + return rows @classmethod - def count(cls, db: Session, status: Optional[bool] = None): + def count(cls, db: Session, status: Optional[bool] = None) -> int: """统计指定状态的整理历史数量。""" statement = select(func.count(cls.id)) if status is not None: statement = statement.where(cls.status == status) - return db.execute(statement).scalar() + count: int = db.execute(statement).scalar() + return count @classmethod - async def async_count(cls, db: AsyncSession, status: Optional[bool] = None): + async def async_count(cls, db: AsyncSession, status: Optional[bool] = None) -> int: """异步统计指定状态的整理历史数量。""" if status is not None: result = await db.execute( @@ -572,10 +593,11 @@ class TransferHistory(Base): result = await db.execute( select(func.count(cls.id)) ) - return result.scalar() + count: int = result.scalar() + return count @classmethod - def count_by_title(cls, db: Session, title: str, status: Optional[bool] = None, wildcard: bool = False): + def count_by_title(cls, db: Session, title: str, status: Optional[bool] = None, wildcard: bool = False) -> int: """统计与标题或路径匹配的整理历史数量。""" if wildcard: text_filter = or_( @@ -592,10 +614,11 @@ class TransferHistory(Base): statement = select(func.count(cls.id)).where(text_filter) if status is not None: statement = statement.where(cls.status == status) - return db.execute(statement).scalar() + count: int = db.execute(statement).scalar() + return count @classmethod - async def async_count_by_title(cls, db: AsyncSession, title: str, status: Optional[bool] = None, wildcard: bool = False): + async def async_count_by_title(cls, db: AsyncSession, title: str, status: Optional[bool] = None, wildcard: bool = False) -> int: """异步统计与标题或路径匹配的整理历史数量。""" if wildcard: text_filter = or_( @@ -613,7 +636,8 @@ class TransferHistory(Base): if status is not None: stmt = stmt.filter(cls.status == status) result = await db.execute(stmt) - return result.scalar() + count: int = result.scalar() + return count @classmethod def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, @@ -621,7 +645,7 @@ class TransferHistory(Base): episode: Optional[str] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - dest: Optional[str] = None, *, match_all_versions: bool = False): + dest: Optional[str] = None, *, match_all_versions: bool = False) -> list[Self]: """ 按媒体身份、季集或标题年份查询整理记录。 @@ -662,16 +686,17 @@ class TransferHistory(Base): def get_by_media_identity( cls, db: Session, media_source: MediaSource, media_id: str, mtype: Optional[str] = None, - ): + ) -> Optional[Self]: """按规范媒体身份和类型查询整理记录。""" - return db.execute(select(cls).where( + history: Optional[Self] = db.execute(select(cls).where( cls.media_source == str(media_source), cls.media_id == str(media_id), cls.type == mtype, )).scalars().first() + return history @classmethod - def update_download_hash(cls, db: Session, historyid: Optional[int] = None, download_hash: Optional[str] = None): + def update_download_hash(cls, db: Session, historyid: Optional[int] = None, download_hash: Optional[str] = None) -> None: """在调用方事务中暂存下载任务哈希更新。""" db.execute( update(cls).where(cls.id == historyid).values(download_hash=download_hash) @@ -727,7 +752,7 @@ class TransferHistory(Base): ) @classmethod - def replace_by_src(cls, db: Session, **kwargs) -> "TransferHistory": + def replace_by_src(cls, db: Session, **kwargs: object) -> "TransferHistory": """ 用同源存储的新记录原子替换旧整理历史。 @@ -764,7 +789,7 @@ class TransferHistory(Base): return history @classmethod - def list_by_date(cls, db: Session, date: str): + def list_by_date(cls, db: Session, date: str) -> list[Self]: """ 查询某时间之后的转移历史 """ diff --git a/docs/code-quality.md b/docs/code-quality.md index bf5ff3276..5362087d0 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -75,7 +75,7 @@ uv run --locked --no-sync python scripts/architecture/complexity.py 回调和返回值类型;全仓错误计数为零并不保证单独纳入 strict frontier 后也为零。 2026-10-16 开始逐文件清理类型债务,`app/domain/title.py` 通过局部可空类型声明保留 -既有搜索解析行为,并纳入严格清单;当前清单共 132 个文件(131 个生产文件和 1 个脚本)。 +既有搜索解析行为,并纳入严格清单;当前清单共 133 个文件(132 个生产文件和 1 个脚本)。 每个治理提交聚焦一个生产文件及其必要测试、门禁配置、真实下降基线和指标文档。 验收同时检查完整严格清单与全宿主 Linux 分析,避免 `follow_imports = skip` 隐藏依赖 签名冲突;不通过忽略诊断或放宽类型合同接受回退。最新文件清单以 `mypy.ini` 为准, diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index b168faab1..488c00978 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -106,9 +106,9 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | | Module Contract | 215 specs / 211 methods / 263 calls | 动态方法名为 0;分类事实富化通过显式合同进入插件调度,旧 YAML 分类方法已移除;download 新增 custom_words 可选参数,旧 provider 签名通过显式合同兼容 | | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | -| Python 源码量 | 372,638 行 | 本次重新扫描物理行数,排除 `app/plugins/**`;74 个文件超过 1,000 行,12 个超过 2,000 行;源码尺寸仅作观察 | +| Python 源码量 | 372,664 行 | 本次重新扫描物理行数,排除 `app/plugins/**`;74 个文件超过 1,000 行,12 个超过 2,000 行;源码尺寸仅作观察 | | 长方法 | 309 个超过 80 行 | 本次 AST 扫描排除 `app/plugins/**`;61 个超过 150 行,17 个超过 250 行;新增函数仍受 C901/嵌套深度门禁约束 | -| 全量 mypy 历史债务 | 8,627 / 423 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项,逐文件逐错误码无增长 | +| 全量 mypy 历史债务 | 8,601 / 422 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项,逐文件逐错误码无增长 | | Ruff 历史诊断 | 98 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/mypy.ini b/mypy.ini index 4d8af5fc9..4fcca0690 100644 --- a/mypy.ini +++ b/mypy.ini @@ -121,6 +121,7 @@ files = app/db/models/siteuserdata.py, app/db/models/subscribehistory.py, app/db/models/systemconfig.py, + app/db/models/transferhistory.py, app/db/models/user.py, app/db/models/userconfig.py, app/db/models/workflow.py, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 3cda9505d..960a43f6b 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -981,11 +981,6 @@ "arg-type": 8, "no-untyped-def": 18 }, - "app/db/models/transferhistory.py": { - "no-any-return": 3, - "no-untyped-def": 20, - "type-arg": 1 - }, "app/db/oper/downloadhistory.py": { "no-any-return": 1, "no-untyped-def": 9, @@ -1014,7 +1009,6 @@ "union-attr": 3 }, "app/db/oper/transferhistory.py": { - "no-any-return": 2, "no-untyped-def": 6, "union-attr": 2 },