diff --git a/backend/data_quality/candles.py b/backend/data_quality/candles.py index 350717d..8acb9a6 100644 --- a/backend/data_quality/candles.py +++ b/backend/data_quality/candles.py @@ -374,7 +374,10 @@ def _is_finite(value: object) -> bool: letting the exception escape. """ try: - return bool(pd.notna(value) and math.isfinite(float(cast(Any, value)))) + # cast(Any, ...) both times: pandas-stubs' notna overloads take concrete + # scalar/array types, but this boundary helper deliberately accepts any + # cell value and treats "cannot even check" as not-finite. + return bool(pd.notna(cast(Any, value)) and math.isfinite(float(cast(Any, value)))) except (TypeError, ValueError): return False diff --git a/backend/dhan_client.py b/backend/dhan_client.py index 435cb9c..f780ce4 100644 --- a/backend/dhan_client.py +++ b/backend/dhan_client.py @@ -11,7 +11,7 @@ import threading from collections.abc import Callable from datetime import date, datetime -from typing import Any +from typing import Any, Literal import pandas as pd @@ -22,8 +22,13 @@ class DhanRateLimitError(RuntimeError): """Raised when Dhan asks the app to slow down history requests.""" -def infer_epoch_unit(values: pd.Series) -> str: - """Infer whether numeric timestamps are seconds, milliseconds, or microseconds.""" +def infer_epoch_unit(values: pd.Series) -> Literal["s", "ms", "us"]: + """Infer whether numeric timestamps are seconds, milliseconds, or microseconds. + + The return type is the exact literal set ``pd.to_datetime``'s ``unit=`` + parameter accepts (QUAL-006), so callers can pass the result straight + through without a cast. + """ nums = pd.to_numeric(values, errors="coerce").dropna() if nums.empty: return "s" diff --git a/backend/indicators.py b/backend/indicators.py index d904275..42abbba 100644 --- a/backend/indicators.py +++ b/backend/indicators.py @@ -15,6 +15,8 @@ from __future__ import annotations import logging +from collections.abc import Hashable +from typing import cast import numpy as np import pandas as pd @@ -476,7 +478,9 @@ def resample_to_weekly(frame: pd.DataFrame) -> pd.DataFrame: work["timestamp"] = pd.to_datetime(work["timestamp"], errors="coerce") work = work.dropna(subset=["timestamp"]).sort_values("timestamp").set_index("timestamp") - aggregation = {"open": "first", "high": "max", "low": "min", "close": "last"} + # Keyed by Hashable (not str) because pandas-stubs' ``agg`` mapping + # overload is invariant in its key type (QUAL-006). + aggregation: dict[Hashable, str] = {"open": "first", "high": "max", "low": "min", "close": "last"} if "volume" in work.columns: aggregation["volume"] = "sum" @@ -902,7 +906,9 @@ def bullish_knoxville_divergence( if (len(enriched) - 1 - latest_index) > int(recency): return None - latest = enriched.loc[latest_index] + # ``.loc`` with a scalar label types as Series | DataFrame; the label here + # is always one integer row, so narrow to the Series it returns at runtime. + latest = cast(pd.Series, enriched.loc[latest_index]) if float(latest["rsi"]) > float(oversold): return None @@ -958,7 +964,8 @@ def bullish_knoxville_divergences( bars_back = max(1, int(bars_back)) for latest_index in pivot_rows.index[1:]: latest_index = int(latest_index) - latest = enriched.loc[latest_index] + # Scalar-label .loc narrows to a Series at runtime (see above). + latest = cast(pd.Series, enriched.loc[latest_index]) if float(latest["rsi"]) > float(oversold): continue diff --git a/backend/scanning/service.py b/backend/scanning/service.py index f3279e7..5f8b318 100644 --- a/backend/scanning/service.py +++ b/backend/scanning/service.py @@ -750,7 +750,9 @@ def _result_rows( # persistence records, never the DataFrame owned by the caller. rows: list[dict[str, Any]] = [] skipped = 0 - for index, row in enumerate(results.to_dict("records")): + # to_dict("records") types its keys as Hashable; screener columns are + # strings (the contract normalizer enforces exactly that downstream). + for index, row in enumerate(cast(list[dict[str, Any]], results.to_dict("records"))): try: rows.append( normalize_screener_row( diff --git a/backend/scoring/components.py b/backend/scoring/components.py index b007ee0..61d0689 100644 --- a/backend/scoring/components.py +++ b/backend/scoring/components.py @@ -15,7 +15,7 @@ import math from collections.abc import Mapping -from typing import Any +from typing import Any, cast import numpy as np import pandas as pd @@ -181,7 +181,9 @@ def risk_score_absolute( if len(close) < window_size or (close <= 0).any(): return None - returns = np.log(close / close.shift(1)).dropna() + # numpy's stubs type np.log(Series) as an ndarray, but a ufunc on a Series + # returns a Series at runtime — narrow so .dropna() stays typed (QUAL-006). + returns = cast(pd.Series, np.log(close / close.shift(1))).dropna() if returns.empty: return None sigma = float(returns.std(ddof=0)) diff --git a/backend/scoring/model.py b/backend/scoring/model.py index e069259..56c640c 100644 --- a/backend/scoring/model.py +++ b/backend/scoring/model.py @@ -21,7 +21,7 @@ import math from collections.abc import Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, cast import pandas as pd @@ -95,7 +95,10 @@ def score_candidates( # parquet cache twice for a single symbol. # Materialize the row dicts once and reuse them for every component below; a # large shortlist would otherwise be converted to records three times. - records = ranked.to_dict("records") + # ``to_dict("records")`` types its keys as Hashable; these frames come from + # screeners whose columns are always strings, so narrow once for every + # Mapping[str, ...] consumer below (QUAL-006). + records = cast(list[dict[str, Any]], ranked.to_dict("records")) symbol_to_security_id = _security_id_lookup(context.universe_df) cached_candles = [ _read_cached_candles(row, symbol_to_security_id, context.data_loader) diff --git a/backend/technical/technical_agent.py b/backend/technical/technical_agent.py index 929fb39..005c5fb 100644 --- a/backend/technical/technical_agent.py +++ b/backend/technical/technical_agent.py @@ -38,7 +38,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime -from typing import Any, Literal +from typing import Any, Literal, cast import pandas as pd from pydantic import Field, ValidationError, field_validator @@ -115,7 +115,8 @@ def _technical_context_hash( """ window = candles.tail(_OHLC_WINDOW_BARS).copy() if not candles.empty else candles candle_records: list[dict[str, Any]] = [] - for row in window.to_dict("records"): + # to_dict("records") types its keys as Hashable; candle columns are strings. + for row in cast(list[dict[str, Any]], window.to_dict("records")): candle_records.append( { key: (value.isoformat() if hasattr(value, "isoformat") else value) @@ -143,9 +144,11 @@ def _technical_ohlc_csv( for row in recent.itertuples(index=False): timestamp = getattr(row, "timestamp", "") date_str = str(timestamp)[:10] if timestamp is not None else "" + # itertuples() fields type as a broad scalar union; OHLC columns are + # numeric by the loader's boundary coercion, so narrow for float(). lines.append( - f"{date_str},{float(row.open):.2f},{float(row.high):.2f}," - f"{float(row.low):.2f},{float(row.close):.2f}" + f"{date_str},{float(cast(float, row.open)):.2f},{float(cast(float, row.high)):.2f}," + f"{float(cast(float, row.low)):.2f},{float(cast(float, row.close)):.2f}" ) return "\n".join(lines) diff --git a/constraints.txt b/constraints.txt index 3409c1c..5cc3f3c 100644 --- a/constraints.txt +++ b/constraints.txt @@ -37,5 +37,9 @@ pip-audit==2.10.0 mypy==1.19.1 types-requests==2.33.0.20260518 types-PyYAML==6.0.12.20260518 +# pandas-stubs tracks the pinned pandas 2.3.3 series (QUAL-006); types-pytz is +# its declared dependency. +pandas-stubs==2.3.3.260113 +types-pytz==2026.2.0.20260518 pytest-cov==7.1.0 pre-commit==4.6.0 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 5601ace..81d2a90 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -77,7 +77,7 @@ testing · extension points. - **[rank-002-handoff.md](rank-002-handoff.md)** — RANK-002 implemented build brief for the `backend/scoring/` scorer (pure components + config + the `run_scan` call + UI sort/components + tests). - **[auth-003-role-model.md](auth-003-role-model.md)** — AUTH-003 role model: hierarchical viewer/analyst/admin, the capability→min-role map, the database-driven `user_roles` store with an `ADMIN_EMAILS` bootstrap floor, resolution precedence, defense-in-depth enforcement, and denial logging/audit. - **[auth-003-handoff.md](auth-003-handoff.md)** — AUTH-003 build brief for the `backend/auth/roles.py` policy + `user_roles` table/migration + repository + `require_capability` enforcement + the admin Roles page + tests. -- **[audit-2026-06.md](audit-2026-06.md)** — June 2026 codebase audit & hardening register (QUAL-001/002/003, REF-001, PERF-001, DOC-001): what was found, fixed, and deferred. +- **[audit-2026-06.md](audit-2026-06.md)** — June–July 2026 codebase audit and hardening register through PR #107: what was found, fixed, rejected, and deferred across both review waves. ## Conventions diff --git a/docs/architecture/audit-2026-06.md b/docs/architecture/audit-2026-06.md index c3192bd..287e44e 100644 --- a/docs/architecture/audit-2026-06.md +++ b/docs/architecture/audit-2026-06.md @@ -198,3 +198,80 @@ removal of one redundant flush, and the stronger regression guard. | Fundamentals eligibility tooltip UX | Users can't tell why "Check Fundamentals" mode differs by symbol; small UI improvement, needs product wording. | | `_parse_company_page(session=None)` + a page with an HTMX peers URL would crash in the unmocked path | Production-unreachable (`fetch_company_data` always builds a real Session); the test seam relies on mocking `_fetch_html`. Documented at the call site; tidy when the scraper next changes. | | Postgres-backed deployment guide with a worked example | docs/operations.md covers the switch; a full worked deploy (host, service file, reverse proxy) is its own doc when a real deployment exists. | + +## July 2026 — whole-app review, two waves (appended 2026-07-11) + +Two independent review passes ran this month and their outputs were reconciled +against main @ `0c87b65` before the second wave started, so nothing below was +built twice. **Wave 1** (another Claude session, PRs +[#88](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/88)–[#97](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/97)) +and **wave 2** (this session, PRs +[#98](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/98)–[#107](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/107), +one ticket per PR) together close most of the June deferral list; the status of +every June item is updated in the table at the end of this section. + +### Wave 1 (PRs #88–#97, other session) + +DOC-002 AGENTS.md IPO map (#88) · TEST-004 golden coverage for the five +untested deterministic screeners (#89) · UI-001 chart-cache +`screener_version` key, SEC-001 role-email shape check, QUAL-005 coverage +floor raised to 87 (#90–#92) · AI-005 shared sync bridge, landed as +REFACTOR-003 with ADR (#93) · TEST-005 screener.in HTML snapshot tests (#94) +· UI-002 fundamentals freshness caption + eligibility captions (#95) · +QUAL-004 mypy on tests/ phase 1 (#96) · REF-002 app.py extraction of +`ui/scan_view.py` + `ui/fundamentals_panel.py` (#97). + +### Wave 2 (PRs #98–#107, this session) + +| PR | Ticket | What landed | +|---|---|---| +| [#98](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/98) | TEST-007 | Render-path tests for the #97 extractions; locks the AUTH-003 export gate, OBS-003 export audit, symbol-stable chart selection after row reordering, secret-safe cached/agent failures, and force-refresh reruns. | +| [#99](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/99) | REF-003 | app.py 1,127→822 lines: status panel + parameter controls → `ui/`, 100% tested; `cache_summary` default no longer bound at import time. | +| [#100](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/100) | AI-006 | One shared verdict-JSON extractor in `backend/ai_runtime.py`; rejects non-finite constants and parser failures before validation/cache signing, with `allow_inf_nan=False` as model-level defense in depth. | +| [#101](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/101) | IPO-006 | One `canonical_sebi_url` behind both SSRF gates; rejects encoded traversal/separators, preserves PDF-path policy across redirects, resolves wrappers from the final URL, and normalizes malformed URL/stream failures into safe domain errors. | +| [#102](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/102) | TEST-006 | IPO pipeline end-to-end scenario (ingest→download→extract→ratios→verdict) with a provenance-digest chain across every stage hand-off. | +| [#103](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/103) | PERF-002 | Parquet footer-statistics cache decisions (see the sidecar supersession note below); benchmark-gated at ~12×. | +| [#104](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/104) | DEPLOY-004 | Password-safe env-file/prompt Postgres workflow, URL-encoding guidance, SQLite→Postgres migration recipe, Alembic encoded-password safety, and the correct `audit_logs` table; `pool_pre_ping` remains server-only. | +| [#105](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/105) | QUAL-006 | pandas-stubs adopted (see the estimate correction below). The wave's one sanctioned constraints/pyproject change. | +| [#106](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/106) | QUAL-007 | mypy checks the whole test tree by default; 29 modules of pre-typing debt are enumerated in a mechanically enforced shrink-only override. | +| (this PR) | DOC-003 | This register section. | + +### Findings verified FALSE in this review (do not re-flag) + +1. **"`cpr_yearly` computes with floats, violating the Decimal-for-money + rule."** False as a violation: float math is the framework-wide convention + for *indicator* calculations across every screener and `backend/indicators.py`; + the Decimal rule governs money at persistence/contract boundaries. Changing + one screener would create inconsistency, not correctness. +2. **"`cpr_yearly` is untested."** False — four dedicated tests plus a golden + snapshot existed at review time. +3. **"The technical agent lacks a prompt-injection quarantine."** Not a gap: + its structured-candle-only posture (no untrusted-text tool) is the locked + TEST-003 decision above, with regression tests pinning it. + +### Considered and REJECTED in wave 1 (recorded so they are not re-proposed) + +- **Session-state registry abstraction** for the UI — indirection without a + demonstrated bug class. +- **Indicators decorator dedup** — the repetition is shallow; a decorator + would obscure the per-indicator math it wraps. +- **`sectors` iterrows micro-optimization** — not a measured hotspot. +- **DNS-rebinding hardening for the fixed SEBI listing URLs** — the listing + URLs are hardcoded constants, and the byte-fetching surface (the PDF + downloader) already resolves the host and rejects non-public answers. +- **`run_scan` length refactor** — long but linear and heavily commented; + splitting it would scatter one coherent lifecycle. + +### June deferral list — status after July + +| June item | July status | +|---|---| +| Agent SDK boilerplate dedup | **Landed in two steps**: the sync bridge (REFACTOR-003, #93) and the verdict-JSON extractor (AI-006, #100). Options-construction/retry/error-taxonomy blocks stay per-agent **by ADR decision** — that is the recorded end state, not remaining debt. | +| mypy on tests/ | **Landed in two phases**: whitelist (#96), then checked-by-default with a 29-module shrink-only debt list (#106). | +| pandas-stubs | **Landed** (#105). Correction for future estimators: June predicted "hundreds of errors"; with stubs version-matched to the pinned pandas minor (2.3.3), the real count was **22**, all mechanical. | +| Parquet metadata sidecar | **Superseded, honoring the June objection.** PERF-002 (#103) needs **no second cache format**: Parquet footers already carry row-group min/max statistics (health.py had read them since OBS-002). Benchmark-gated per the objection (~12× on the bounds question). Review hardening on the PR: footer bounds are treated as an advisory index — the prefetch still validates the actual frame before a "fresh" verdict, because a valid footer can coexist with corrupt data pages. | +| screener.in HTML snapshots | **Landed** (#94). | +| Fundamentals eligibility tooltip | **Landed** (#95). | +| Postgres worked deployment guide | **Landed** (#104), including the SQLite→Postgres data-migration recipe and pool guidance. | +| Redaction single-pass regex | Still deferred — remains a micro-optimization. | +| `_parse_company_page(session=None)` crash path | Still deferred — production-unreachable; tidy when the scraper next changes. | diff --git a/pyproject.toml b/pyproject.toml index c6b8419..afc0f46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,18 +38,17 @@ external = ["BLE"] # CI runs 3.11; local development may run newer. Pinning the target version # keeps local runs honest about what CI will accept. python_version = "3.11" -# tests/ adopts mypy module-by-module (QUAL-004): the shared fixtures and the -# two policy/guard tests go first because they encode repo invariants. Extend -# this list as further test modules come clean; do not remove entries. +# QUAL-004 started tests/ adoption module-by-module (conftest + the two +# policy/guard tests); QUAL-007 finished it — the whole test tree is checked, +# so a fixture or fake that drifts from the typed application API fails here +# before it fails at runtime. files = [ "app.py", "backend", "screeners", "ui", "Dependencies", - "tests/conftest.py", - "tests/test_supply_chain_policy.py", - "tests/test_repository_layer_boundary.py", + "tests", ] # First-adoption strictness: check function bodies everywhere (including # untyped ones) without yet *requiring* annotations on every def. Stricter @@ -62,14 +61,56 @@ warn_redundant_casts = true # import/assignment lines also suppress the environment-dependent unused-ignore. warn_unused_ignores = true +[[tool.mypy.overrides]] +# QUAL-007's remaining debt: these test modules still carry pre-typing idioms +# (fake clients passed where the real client class is annotated, records-dict +# rows, unannotated accumulators) — ~385 mechanical errors at adoption time. +# Every OTHER test module is fully checked, and a NEW test file is checked by +# default. Shrink this list as modules come clean; NEVER add to it — a new +# entry means new untyped debt, which is exactly what this gate exists to +# prevent. +# NOTE: tests/ is not a package (no __init__.py), so mypy sees these as +# top-level modules — no "tests." prefix. +module = [ + "test_app_comparison_page", + "test_app_validation_page", + "test_auth_session", + "test_daily_data_loader", + "test_daily_scan_job", + "test_dhan_client", + "test_forward_return_service", + "test_indicators", + "test_ipo_document_downloader", + "test_ipo_models", + "test_ipo_ratio_engine", + "test_ipo_repository", + "test_ipo_scorecard", + "test_notifications_channels", + "test_notifications_report", + "test_notifications_service", + "test_pdf_reader", + "test_real_screeners", + "test_result_contract", + "test_scan_run_integration", + "test_scan_service", + "test_scan_storage_repository", + "test_scanner_base", + "test_scoring_model", + "test_screener_in_client", + "test_screener_registry", + "test_sixty_seven_agent", + "test_sixty_seven_search_client", + "test_technical_analysis_agent", +] +ignore_errors = true + [[tool.mypy.overrides]] # Third-party packages that ship no type stubs (and optional accelerators -# that are not installed on CI). pandas is deliberately untyped here: -# pandas-stubs would surface hundreds of new errors in this pandas-heavy -# codebase and is its own future migration. +# that are not installed on CI). pandas graduated OUT of this list in +# QUAL-006: pandas-stubs (pinned to the pandas 2.3.3 series) now types every +# DataFrame/Series call site in the checked application code. module = [ "dhanhq.*", - "pandas.*", "pandas_ta.*", "pyarrow.*", "pypdf.*", diff --git a/requirements-dev.txt b/requirements-dev.txt index e45744f..c85ff53 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,6 +11,10 @@ pip-audit mypy types-requests types-PyYAML +# pandas typing (QUAL-006). pandas-stubs tracks the pinned pandas minor +# series; types-pytz is its declared dependency and is pinned alongside it. +pandas-stubs +types-pytz # Coverage measurement for the CI gate (QUAL-003). pytest-cov # Local commit-time checks mirroring CI's non-rewriting hooks. diff --git a/screeners/cpr_yearly.py b/screeners/cpr_yearly.py index 3b89f0e..499c930 100644 --- a/screeners/cpr_yearly.py +++ b/screeners/cpr_yearly.py @@ -25,6 +25,7 @@ from typing import ClassVar +import numpy as np import pandas as pd from backend.charts import add_cpr_overlay, candlestick_with_volume @@ -101,7 +102,11 @@ def compute_signal(self, symbol: str, candles: pd.DataFrame, params: dict) -> di # Rule 2: a weekly close recently up-crossed the previous-year high, and the # latest weekly close is still at/above it (the reclaim is holding). weekly = resample_to_weekly(daily) - weekly_closes = weekly["close"].astype(float).to_numpy() if not weekly.empty else [] + # Annotated because the empty branch would otherwise leave mypy with an + # untyped union of ndarray and list[Never] (QUAL-006). + weekly_closes: np.ndarray | list[float] = ( + weekly["close"].astype(float).to_numpy() if not weekly.empty else [] + ) if len(weekly_closes) < 2 or float(weekly_closes[-1]) < prev_year_high: return None diff --git a/screeners/envelope_knoxville_buy.py b/screeners/envelope_knoxville_buy.py index 83b3e98..986146b 100644 --- a/screeners/envelope_knoxville_buy.py +++ b/screeners/envelope_knoxville_buy.py @@ -29,7 +29,7 @@ from __future__ import annotations import math -from typing import ClassVar +from typing import ClassVar, cast import pandas as pd @@ -145,7 +145,8 @@ def compute_signal(self, symbol: str, candles: pd.DataFrame, params: dict) -> di for candidate in reversed(all_divergences): # `candidate.name` is the row number inside `frame`. Comparing it to # the last row tells us how many candles ago the divergence occurred. - if len(frame) - 1 - int(candidate.name) <= signal_recency: + # (.name types as Hashable; these pivots carry the integer index.) + if len(frame) - 1 - int(cast(int, candidate.name)) <= signal_recency: recent_divergence = candidate break @@ -175,7 +176,7 @@ def compute_signal(self, symbol: str, candles: pd.DataFrame, params: dict) -> di return None selected_divergence_price = float(divergence["low"]) - selected_bars_ago = len(frame) - 1 - int(divergence.name) + selected_bars_ago = len(frame) - 1 - int(cast(int, divergence.name)) rsi_value = float(divergence["rsi"]) momentum_value = float(divergence["momentum"]) if entry_trigger == "recent_envelope_kd": diff --git a/screeners/heikin_ashi_supertrend.py b/screeners/heikin_ashi_supertrend.py index 92f0bab..d66847a 100644 --- a/screeners/heikin_ashi_supertrend.py +++ b/screeners/heikin_ashi_supertrend.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import ClassVar +from typing import ClassVar, cast import pandas as pd @@ -99,7 +99,8 @@ def compute_signal(self, symbol: str, candles: pd.DataFrame, params: dict) -> di latest = valid.iloc[-1] # `valid` preserves the original integer index, so this points back to the # matching row in the Heikin Ashi DataFrame for output fields like ha_open. - latest_index = int(latest.name) + # (.name types as Hashable; these frames use the default integer index.) + latest_index = int(cast(int, latest.name)) latest_ha = ha.iloc[latest_index] previous_ha_close = float(previous["close"]) diff --git a/tests/test_admin_roles_service.py b/tests/test_admin_roles_service.py index 899c4d9..923d7cd 100644 --- a/tests/test_admin_roles_service.py +++ b/tests/test_admin_roles_service.py @@ -22,9 +22,11 @@ def _audit_events(file_session_factory) -> list[str]: def _role_changed_rows(file_session_factory) -> list[dict]: with file_session_factory() as session: return [ + # The None filter narrows the Optional ORM column; role_changed + # rows always carry metadata, so it filters nothing at runtime. row.metadata_json for row in get_recent_audit_logs(session) - if row.event == "role_changed" + if row.event == "role_changed" and row.metadata_json is not None ] diff --git a/tests/test_app_audit_page.py b/tests/test_app_audit_page.py index 5f261d7..4d446e8 100644 --- a/tests/test_app_audit_page.py +++ b/tests/test_app_audit_page.py @@ -9,12 +9,14 @@ import datetime as dt from contextlib import contextmanager from types import SimpleNamespace +from typing import cast from sqlalchemy.exc import OperationalError from backend.auth.roles import Role from backend.auth.session import AuthenticatedUser from backend.security import MASK +from backend.storage import AuditLog from ui import audit_page @@ -84,7 +86,9 @@ def test_audit_row_renders_system_for_missing_user(): user_email=None, metadata_json=None, ) - row = audit_page._audit_row(entry) + # The formatter only reads the four attributes the namespace fakes, so the + # cast documents an intentional duck-typed AuditLog stand-in. + row = audit_page._audit_row(cast(AuditLog, entry)) assert row["Event"] == "data_refresh_started" assert row["User"] == "system" assert row["Details"] == "" diff --git a/tests/test_app_health_page.py b/tests/test_app_health_page.py index d75268e..9ca52e2 100644 --- a/tests/test_app_health_page.py +++ b/tests/test_app_health_page.py @@ -4,6 +4,8 @@ import datetime as dt +import pandas as pd + import app from backend.auth.roles import Role from backend.auth.session import AuthenticatedUser @@ -41,7 +43,8 @@ def __init__(self): self.warnings: list[str] = [] self.captions: list[str] = [] self.metrics: list[tuple[str, object]] = [] - self.dataframes: list[object] = [] + # DataFrame-typed so assertions may call .to_dict on captured tables. + self.dataframes: list[pd.DataFrame] = [] def subheader(self, *_args, **_kwargs): pass diff --git a/tests/test_app_history_page.py b/tests/test_app_history_page.py index aa3077b..558e08f 100644 --- a/tests/test_app_history_page.py +++ b/tests/test_app_history_page.py @@ -458,10 +458,14 @@ def fake_session_scope(): lambda *, client: loader if client is None else None, raising=False, ) + def _record_chart_render(**kwargs: object) -> object: + rendered.append(kwargs) + return kwargs["chart_symbol"] + monkeypatch.setattr( history_page, "_render_cached_symbol_chart", - lambda **kwargs: rendered.append(kwargs) or kwargs["chart_symbol"], + _record_chart_render, raising=False, ) diff --git a/tests/test_app_ipo_manual_page.py b/tests/test_app_ipo_manual_page.py index afb5642..913d176 100644 --- a/tests/test_app_ipo_manual_page.py +++ b/tests/test_app_ipo_manual_page.py @@ -12,6 +12,7 @@ import datetime as dt from decimal import Decimal +import pandas as pd import pytest from backend.auth.roles import Role @@ -252,7 +253,7 @@ def NumberColumn(self, *_args, **_kwargs) -> None: def __init__(self) -> None: """Prepare the capture slot and the column-config factory.""" - self.captured_frame: object | None = None + self.captured_frame: pd.DataFrame | None = None self.column_config = self._ColumnConfig() def markdown(self, *_args, **_kwargs) -> None: diff --git a/tests/test_app_orchestration.py b/tests/test_app_orchestration.py index ff64a1c..04e0236 100644 --- a/tests/test_app_orchestration.py +++ b/tests/test_app_orchestration.py @@ -10,6 +10,7 @@ import inspect import os import time +from collections.abc import Callable from datetime import date as real_date from pathlib import Path from types import SimpleNamespace @@ -88,7 +89,7 @@ def __init__(self, _client): def test_capability_flags_are_required_at_every_render_boundary(): """A forgotten call-site argument must fail closed instead of enabling actions.""" - boundaries = [ + boundaries: list[tuple[Callable[..., object], str]] = [ (app._render_sidebar, "can_run"), (app._render_scan_output, "can_export"), (app._render_history_page, "can_export"), @@ -122,10 +123,14 @@ def test_shared_cached_chart_renderer_uses_mapped_security_id(monkeypatch): payload = SimpleNamespace(html="