diff --git a/AGENTS.md b/AGENTS.md index 93991e3a3..269569cc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,10 @@ also holds a PR to: constraint or the reason, and end with an issue or PR URL when a reader needs the backstory. A block of three or more `#` lines fails once the PR adds or edits a line in it, so shorten an old block when you touch it. +- No silent `except` (ruff BLE001, S110, S112): catch the exception you expect, or + log it with its traceback, or re-raise. A site that must catch everything says + what happens to the failure in `# noqa: BLE001 - ` + (`tests/test_noqa_reasons.py` fails a bare code). - User outcomes (`tests/outcomes/*.json`, checked by `pytest --outcomes`): a test marked `@pytest.mark.outcome(...)` is evidence for a promise in the docs; weaken or drop it only together with the doc and the contract. The same diff --git a/dokimi/changelog.d/20261112_230000_napetrov_blind-except-gate.md b/dokimi/changelog.d/20261112_230000_napetrov_blind-except-gate.md new file mode 100644 index 000000000..0caeb5815 --- /dev/null +++ b/dokimi/changelog.d/20261112_230000_napetrov_blind-except-gate.md @@ -0,0 +1,6 @@ +### Changed + +- A library id that cannot be resolved for an arm now logs a warning before the arm + falls back to the product name, and a failed MCP tool listing or GitHub issue fetch + is logged instead of dropped silently. JSON extraction from LLM replies catches only + parse errors, so a bug in it is no longer read as "no JSON here". diff --git a/dokimi/dokimi/cli.py b/dokimi/dokimi/cli.py index ea5f96f20..cd32cdf10 100755 --- a/dokimi/dokimi/cli.py +++ b/dokimi/dokimi/cli.py @@ -347,7 +347,7 @@ def main() -> None: # 2 is the one code for bad input: 1 is the verdict of `eval judge validate` and of the # --strict gates, and a malformed file must not read as a failed gate. sys.exit(2) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - printed with its traceback, exit 5 # A crash is an infra failure, exit 5 for every command (§2.6): it never reads as a failed verdict. traceback.print_exc() if _emits_cli_output(args): # a --json leaf still prints its one document on a crash diff --git a/dokimi/dokimi/commands/benchmark.py b/dokimi/dokimi/commands/benchmark.py index b872b50ed..a0bbbe8f9 100644 --- a/dokimi/dokimi/commands/benchmark.py +++ b/dokimi/dokimi/commands/benchmark.py @@ -205,7 +205,7 @@ def _check_questions_from(questions_from: str, key: str) -> tuple[Path, list, st return qf, [], str(exc) try: return qf, load_questions_payload(qf), None - except Exception as exc: + except Exception as exc: # noqa: BLE001 - any load failure is reported as an invalid source return qf, [], f"questions source invalid: {qf} ({exc})" @@ -230,7 +230,7 @@ def _check_golden_set(questions_root: str, key: str, refuse: Callable[[str], Non bad = next((i for i, q in enumerate(cast(List[Any], golden_questions)) if not isinstance(q, dict)), None) if bad is not None: refuse(f"golden set item {bad} is not a dict: {golden}") - except Exception as exc: + except Exception as exc: # noqa: BLE001 - any load failure refuses the run, with its message refuse(f"golden set invalid: {golden} ({exc})") @@ -254,7 +254,7 @@ def _check_doc_source(plan: dict, entry: Any, refuse: Callable[[str], None], err warnings.append("context7 id not pinned; client will resolve by target name") except ValueError as exc: refuse(f"doc source invalid: {plan['doc_source']} ({exc})") - except Exception as exc: # the source cannot be reached or built: a failed check + except Exception as exc: # noqa: BLE001 - the source cannot be reached or built: a failed check errors.append(f"doc source check failed: {plan['doc_source']} ({exc})") @@ -442,7 +442,7 @@ def cmd_benchmark_batch(args: argparse.Namespace) -> None: registry=registry, ) results.append(r) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - one failed library is recorded; --fail-fast stops msg = f"FAILED: {entry.key} — {exc}" print(f"\n❌ {msg}", file=sys.stderr) failed.append({"library": entry.key, "name": entry.name, "status": "failed", "error": str(exc)}) diff --git a/dokimi/dokimi/commands/eval_graph.py b/dokimi/dokimi/commands/eval_graph.py index 8fee20b8f..b7ee87821 100644 --- a/dokimi/dokimi/commands/eval_graph.py +++ b/dokimi/dokimi/commands/eval_graph.py @@ -125,7 +125,7 @@ def _author(name: str, description: str, args: argparse.Namespace, raise # The call is paid for and its reply is the graph, as measure.py keeps such a reply. return _graph_from_unmetered(name, description, reply), True - except Exception as exc: # an unreachable or unauthenticated model: not "an empty graph" + except Exception as exc: # noqa: BLE001 - an unreachable model exits non-zero, not "an empty graph" print(f"Error: the graph-author call failed: {exc}; nothing was written", file=sys.stderr) sys.exit(EXIT_FAILURE) diff --git a/dokimi/dokimi/commands/evaluate.py b/dokimi/dokimi/commands/evaluate.py index 55dcebcaf..bfa73f3dd 100644 --- a/dokimi/dokimi/commands/evaluate.py +++ b/dokimi/dokimi/commands/evaluate.py @@ -72,7 +72,7 @@ def _run_ragas_eval(answers, output_path, args) -> None: raise # a bad input record is a refusal, not a RAGAS failure: cli.main reports it (exit 2) except ImportError as exc: sys.exit(f"RAGAS meta-evaluation was requested but cannot run: {exc}") - except Exception as exc: + except Exception as exc: # noqa: BLE001 - RAGAS is an optional extra pass; its failure is printed print(f"\n⚠️ RAGAS evaluation failed: {exc}") diff --git a/dokimi/dokimi/commands/orchestrate.py b/dokimi/dokimi/commands/orchestrate.py index 7a5ecadc7..a3ae425d9 100644 --- a/dokimi/dokimi/commands/orchestrate.py +++ b/dokimi/dokimi/commands/orchestrate.py @@ -106,7 +106,7 @@ def cmd_evaluate(args: argparse.Namespace) -> None: print(f"\n📊 View full report: cat {results['steps']['report']['path']}") - except Exception as e: + except Exception as e: # noqa: BLE001 - top-level: prints the traceback and exits 1 print(f"\n❌ Pipeline failed: {e}", file=sys.stderr) import traceback traceback.print_exc() diff --git a/dokimi/dokimi/commands/subjects.py b/dokimi/dokimi/commands/subjects.py index d51ad6e98..2d7286ba2 100644 --- a/dokimi/dokimi/commands/subjects.py +++ b/dokimi/dokimi/commands/subjects.py @@ -42,7 +42,7 @@ def cmd_subjects_list(args: argparse.Namespace) -> None: descriptor = load_subject(path) print(f"{descriptor.id}\t{descriptor.subject.kind}\t{path}") - except Exception as exc: + except Exception as exc: # noqa: BLE001 - one bad descriptor is listed as ERROR, the rest still print print(f"{path}\tERROR\t{exc}") diff --git a/dokimi/dokimi/dashboard/aggregator.py b/dokimi/dokimi/dashboard/aggregator.py index 59850bf9a..c4c876d51 100644 --- a/dokimi/dokimi/dashboard/aggregator.py +++ b/dokimi/dokimi/dashboard/aggregator.py @@ -122,7 +122,7 @@ def aggregate(self) -> DashboardData: def _load_snapshot(self, path: Path) -> Optional[ProductSnapshot]: try: data = json.loads(path.read_text(encoding="utf-8")) - except Exception as e: + except Exception as e: # noqa: BLE001 - an unreadable snapshot is logged and skipped logger.warning(f"Cannot read {path}: {e}") return None diff --git a/dokimi/dokimi/eval/answerer.py b/dokimi/dokimi/eval/answerer.py index 5db85fad4..17c824d4c 100644 --- a/dokimi/dokimi/eval/answerer.py +++ b/dokimi/dokimi/eval/answerer.py @@ -111,7 +111,7 @@ def __init__( try: self.reranker = SentenceTransformerReranker(threshold=rerank_threshold) logger.info(f"Semantic reranker initialized with threshold={rerank_threshold:.2f}") - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; the lexical reranker is the documented fallback logger.warning(f"Semantic reranker failed ({e}); fallback to lexical reranker") self.reranker = SimpleReranker(threshold=rerank_threshold) else: @@ -164,7 +164,7 @@ def _process(idx: int, q: Dict) -> None: question=q, max_tokens=max_tokens_per_question ) - except Exception as e: + except Exception as e: # noqa: BLE001 - the failure is stored on the pair and reported failure = e pair = { "question_id": q_id, diff --git a/dokimi/dokimi/eval/arms_run.py b/dokimi/dokimi/eval/arms_run.py index dd373ce88..0624ff1a1 100644 --- a/dokimi/dokimi/eval/arms_run.py +++ b/dokimi/dokimi/eval/arms_run.py @@ -14,6 +14,7 @@ from __future__ import annotations import functools +import logging from collections.abc import Callable from dataclasses import dataclass, replace from typing import TYPE_CHECKING @@ -35,6 +36,8 @@ from dokimi.eval import Judge, JudgePanel from dokimi.eval.arm_runner import ArmRunner +logger = logging.getLogger(__name__) + class _DefaultJudgeProvider(str): """The judge provider left out: equal to the default name, told apart from a named one by identity.""" @@ -222,6 +225,8 @@ def resolve(arm): except ValueError: raise except Exception: + logger.warning("library id for %r not resolved; using the product name", product, + exc_info=True) arm.library_id = product return arm.library_id diff --git a/dokimi/dokimi/eval/cross_judge.py b/dokimi/dokimi/eval/cross_judge.py index 47847c8e9..3c1718730 100644 --- a/dokimi/dokimi/eval/cross_judge.py +++ b/dokimi/dokimi/eval/cross_judge.py @@ -360,7 +360,7 @@ def one(job): return (qid, arm), float( scorer(library, question, answer, (ground_truth or {}).get(qid), context) ), None - except Exception as exc: # an unscored answer drops out of the pairing, it does not kill the run + except Exception as exc: # noqa: BLE001 - an unscored answer drops out of the pairing, it does not kill the run return (qid, arm), None, _reason(exc) if not stored_flags: diff --git a/dokimi/dokimi/eval/pairwise.py b/dokimi/dokimi/eval/pairwise.py index 8231ce933..5f84ca817 100644 --- a/dokimi/dokimi/eval/pairwise.py +++ b/dokimi/dokimi/eval/pairwise.py @@ -296,7 +296,7 @@ def one(job): arm, qid, question, answer, base = job try: return arm, qid, judge.compare(library, question, answer, base) - except Exception as exc: # one failed question must not lose the run + except Exception as exc: # noqa: BLE001 - one failed question must not lose the run return arm, qid, exc report = ArmPairwiseReport(judge=label, baseline_arm=baseline_arm) diff --git a/dokimi/dokimi/eval/panel.py b/dokimi/dokimi/eval/panel.py index e9d6741e3..208266c10 100644 --- a/dokimi/dokimi/eval/panel.py +++ b/dokimi/dokimi/eval/panel.py @@ -360,7 +360,7 @@ def send(text: str, *, draw: int, cache_if: Callable[[str], bool]) -> str: caps=capped.get("caps"), unread_claims=capped.get("unread_claims"), numeric_claims=claims if isinstance(claims := data.get("numeric_claims"), list) else None, ) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged; the vote carries the error and drops out logger.warning(f"Judge '{config.role}' ({config.model}) failed: {exc}") return JudgeVote( role=config.role, model=config.model, provider=config.provider, diff --git a/dokimi/dokimi/eval/ragas_eval.py b/dokimi/dokimi/eval/ragas_eval.py index 9354879e1..0364647e8 100644 --- a/dokimi/dokimi/eval/ragas_eval.py +++ b/dokimi/dokimi/eval/ragas_eval.py @@ -255,7 +255,7 @@ def _score(self, Dataset, ragas_evaluate, rows: List[Dict[str, Any]], metrics: l } summary = {n: round(sum(vals) / len(vals), 4) for n in names if (vals := df[n].dropna().tolist())} - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged; RAGAS is optional, the run keeps its own scores logger.error("RAGAS %s evaluation failed: %s", arm, exc) return {}, {} return scores, summary diff --git a/dokimi/dokimi/eval/reranker.py b/dokimi/dokimi/eval/reranker.py index 1c6dfdc93..0f77c864e 100644 --- a/dokimi/dokimi/eval/reranker.py +++ b/dokimi/dokimi/eval/reranker.py @@ -12,7 +12,7 @@ try: from sentence_transformers import SentenceTransformer, util SENTENCE_TRANSFORMERS_AVAILABLE = True -except Exception: +except Exception: # noqa: BLE001 - importing torch can raise more than ImportError SENTENCE_TRANSFORMERS_AVAILABLE = False diff --git a/dokimi/dokimi/eval/validity_scoring.py b/dokimi/dokimi/eval/validity_scoring.py index bdcabac8e..d30463089 100644 --- a/dokimi/dokimi/eval/validity_scoring.py +++ b/dokimi/dokimi/eval/validity_scoring.py @@ -137,7 +137,7 @@ def intact(pair: Dict[str, Any]) -> Tuple[tuple, Any]: for _ in range(INTACT_ATTEMPTS): try: return key, _call(scorer, pair, pair["intact"]) - except Exception as exc: # one bad pair must not lose the whole run + except Exception as exc: # noqa: BLE001 - one bad pair must not lose the whole run failure = exc return key, failure @@ -152,7 +152,7 @@ def degraded(pair: Dict[str, Any]) -> ValidityRow: if pair_scorer is not None: try: row.intact_score, row.degraded_score = pair_scorer(pair) - except Exception as exc: # one bad pair must not lose the whole run + except Exception as exc: # noqa: BLE001 - one bad pair must not lose the whole run row.error = f"{type(exc).__name__}: {exc}"[:200] return row base = intact_cache[(pair["base_id"], pair.get("arm", ""))] @@ -161,7 +161,7 @@ def degraded(pair: Dict[str, Any]) -> ValidityRow: raise base row.intact_score = base row.degraded_score = _call(scorer, pair, pair["degraded"]) - except Exception as exc: # one bad pair must not lose the whole run + except Exception as exc: # noqa: BLE001 - one bad pair must not lose the whole run row.error = f"{type(exc).__name__}: {exc}"[:200] return row diff --git a/dokimi/dokimi/harnesses/egress_proxy.py b/dokimi/dokimi/harnesses/egress_proxy.py index 145dfd931..5f48282c0 100644 --- a/dokimi/dokimi/harnesses/egress_proxy.py +++ b/dokimi/dokimi/harnesses/egress_proxy.py @@ -260,7 +260,7 @@ def _handle(self, client: socket.socket) -> None: client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") state["established"] = True _relay(client, remote) - except Exception as exc: # one bad tunnel must not take the proxy down + except Exception as exc: # noqa: BLE001 - one bad tunnel must not take the proxy down self._record(state["host"], state["port"], "error", f"{type(exc).__name__}: {exc}") if not state["established"]: # the client learns the route failed, not just a closed socket try: diff --git a/dokimi/dokimi/judged.py b/dokimi/dokimi/judged.py index 01fb7a9ef..27baf6ef5 100644 --- a/dokimi/dokimi/judged.py +++ b/dokimi/dokimi/judged.py @@ -29,7 +29,7 @@ def _accepts(parse: Callable[[str], Any]) -> Callable[[str], bool]: def parses(raw: str) -> bool: try: return parse(raw) is not None - except Exception: + except Exception: # noqa: BLE001 - a cache predicate: any parse failure means do not cache return False return parses diff --git a/dokimi/dokimi/llm.py b/dokimi/dokimi/llm.py index f0f9c5f68..202521829 100644 --- a/dokimi/dokimi/llm.py +++ b/dokimi/dokimi/llm.py @@ -228,7 +228,7 @@ def unsupported_sampling_params( return [n for n in requested if n not in cli.supports] try: supported = _litellm().get_supported_openai_params(model=resolve_model(model, provider)) or [] - except Exception: + except Exception: # noqa: BLE001 - litellm raises many types for an unknown model; [] drops nothing return [] if not supported: return [] @@ -352,7 +352,7 @@ def _safe_completion_cost(resp: Any, model: str, litellm_model: Optional[str] = if cost == 0.0 and not _has_price_entry(litellm_model or model, model): raise ValueError("model is not in litellm's price map") return cost - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged once per model; cost is null, never a fake $0 if model not in _COST_UNKNOWN_LOGGED: _COST_UNKNOWN_LOGGED.add(model) logger.info("No litellm pricing for model %r; cost_usd will be null (%s)", model, exc) @@ -891,7 +891,7 @@ def ask_parsed(ask: Ask, prompt: str, parse: Callable[[str], Optional[T]], *, def parses(raw: str) -> bool: try: return parse(raw) is not None - except Exception: + except Exception: # noqa: BLE001 - a cache predicate: any parse failure means do not cache return False raw, error = "", "" @@ -900,7 +900,7 @@ def parses(raw: str) -> bool: raw = ask(asked, draw=draw * attempts + attempt, cache_if=parses) try: value = parse(raw) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - the parse error is kept and re-asked, then raised error = str(exc) else: if value is not None: diff --git a/dokimi/dokimi/llm_json.py b/dokimi/dokimi/llm_json.py index 8ce439cfc..582fffee5 100644 --- a/dokimi/dokimi/llm_json.py +++ b/dokimi/dokimi/llm_json.py @@ -25,7 +25,7 @@ def extract_json_object(text: str) -> dict: result = json.loads(text) if isinstance(result, dict): return result - except Exception: + except (ValueError, RecursionError): # not JSON here: try the next form pass # Markdown fence fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) @@ -34,7 +34,7 @@ def extract_json_object(text: str) -> dict: result = json.loads(fence.group(1).strip()) if isinstance(result, dict): return result - except Exception: + except (ValueError, RecursionError): pass # First { ... } block m = re.search(r"\{[\s\S]*\}", text) @@ -58,7 +58,7 @@ def extract_json_array(text: str) -> list: result = json.loads(text) if isinstance(result, list): return result - except Exception: + except (ValueError, RecursionError): # not JSON here: try the next form pass # Markdown fence fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) @@ -67,7 +67,7 @@ def extract_json_array(text: str) -> list: result = json.loads(fence.group(1).strip()) if isinstance(result, list): return result - except Exception: + except (ValueError, RecursionError): pass # First [ ... ] block m = re.search(r"\[[\s\S]*\]", text) diff --git a/dokimi/dokimi/llm_pricing.py b/dokimi/dokimi/llm_pricing.py index ea1424acd..2ba3c750e 100644 --- a/dokimi/dokimi/llm_pricing.py +++ b/dokimi/dokimi/llm_pricing.py @@ -20,7 +20,7 @@ def list_cost_usd(model: str, provider: str, prompt_tokens: int, try: prompt, completion = cost_per_token(model=litellm_model, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens) - except Exception: + except Exception: # noqa: BLE001 - litellm raises many types for an unpriced model; None = unknown return None if prompt + completion == 0.0 and not _has_price_entry(litellm_model, model): return None diff --git a/dokimi/dokimi/mcp/context7.py b/dokimi/dokimi/mcp/context7.py index 8539ffbef..808ceff50 100644 --- a/dokimi/dokimi/mcp/context7.py +++ b/dokimi/dokimi/mcp/context7.py @@ -238,7 +238,7 @@ def check_connection(self, library_id: str, query: str = "overview") -> bool: # max_tokens=500 ) return len(docs) > 0 and len(docs[0]["content"]) > 0 - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; a connection check answers False logger.error(f"Context7 connection check failed: {e}") return False diff --git a/dokimi/dokimi/mcp/local_markdown.py b/dokimi/dokimi/mcp/local_markdown.py index de99946ad..3a57882c0 100644 --- a/dokimi/dokimi/mcp/local_markdown.py +++ b/dokimi/dokimi/mcp/local_markdown.py @@ -88,7 +88,7 @@ def get_library_docs( for fp in files: try: content = self._read_file(fp, char_limit) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged at debug; one unreadable file is skipped logger.debug(f"Skipping {fp}: {exc}") continue diff --git a/dokimi/dokimi/mcp/mcp_protocol.py b/dokimi/dokimi/mcp/mcp_protocol.py index 977fc62b7..3869f5314 100644 --- a/dokimi/dokimi/mcp/mcp_protocol.py +++ b/dokimi/dokimi/mcp/mcp_protocol.py @@ -81,6 +81,7 @@ def resolve_library_id(self, library_name: str) -> str: tools = self._list_tool_names() resolve = self._pick_tool(tools, self.resolve_tool, _RESOLVE_TOOL_HINTS) except Exception: + logger.exception("listing MCP tools failed; resolving by name") resolve = None if not resolve: return library_name @@ -127,7 +128,7 @@ def check_connection(self) -> bool: try: self._list_tool_names() return True - except Exception: + except Exception: # noqa: BLE001 - a connection check answers False on any failure return False # ── MCP session plumbing ──────────────────────────────────────────── diff --git a/dokimi/dokimi/mcp/okf_client.py b/dokimi/dokimi/mcp/okf_client.py index eafff9e8a..8cce37fb8 100644 --- a/dokimi/dokimi/mcp/okf_client.py +++ b/dokimi/dokimi/mcp/okf_client.py @@ -143,7 +143,7 @@ def get_library_docs( for fp in files: try: raw = fp.read_text(encoding=self.encoding) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged at debug; one unreadable file is skipped logger.debug("OKFClient: skipping %s: %s", fp, exc) continue diff --git a/dokimi/dokimi/mcp/url_client.py b/dokimi/dokimi/mcp/url_client.py index 00f767283..a26e6e4d7 100644 --- a/dokimi/dokimi/mcp/url_client.py +++ b/dokimi/dokimi/mcp/url_client.py @@ -125,7 +125,7 @@ def check_connection(self) -> bool: try: resp = httpx.head(self.url, timeout=self.timeout, follow_redirects=True) return 200 <= resp.status_code < 300 - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged at debug; a connection check answers False logger.debug(f"URLClient.check_connection failed: {exc}") return False diff --git a/dokimi/dokimi/metrics/example_runner.py b/dokimi/dokimi/metrics/example_runner.py index 16e0d00f5..56b725a1f 100644 --- a/dokimi/dokimi/metrics/example_runner.py +++ b/dokimi/dokimi/metrics/example_runner.py @@ -237,7 +237,7 @@ def _run_docker(lang: str, code: str, policy: ExecutionPolicy, result = runner(cmd, timeout=policy.timeout + 30, env=process.Env.inherit()) except FileNotFoundError: return False, "docker backend selected but docker is not installed" - except Exception as exc: # noqa: BLE001 + except Exception as exc: # noqa: BLE001 - reported as the run's execution error return False, f"Execution error: {type(exc).__name__}: {exc}" if result.timed_out: diff --git a/dokimi/dokimi/novelty/atoms.py b/dokimi/dokimi/novelty/atoms.py index 240593780..c8bc46c46 100644 --- a/dokimi/dokimi/novelty/atoms.py +++ b/dokimi/dokimi/novelty/atoms.py @@ -311,7 +311,7 @@ def _unread_at_cap(stop: SpendCapReached, batch: list[Span], parse: Callable[[st kept: list[Atom] = [] try: # all or nothing: a reply that fails part-way keeps no atoms, and its batch is unread _keep_atoms(parse(stop.reply), valid_ids, set(seen), kept) - except Exception: # not usable, as after any failed call + except Exception: # noqa: BLE001 - not usable, as after any failed call return batch atoms.extend(kept) seen.update(atom.id for atom in kept) @@ -435,7 +435,7 @@ def parse(reply: str) -> list: _stop_at_cap(spans, _unread_at_cap(exc, batch, parse, valid_ids, seen, atoms), pending, report, exc) break - except Exception as exc: + except Exception as exc: # noqa: BLE001 - re-asked in halves or recorded as the batch's error cut = isinstance(exc, OutputTruncated) if (unparseable or cut) and len(batch) > 1: # Unparseable, most often truncated: re-ask the two halves, in place, @@ -575,7 +575,7 @@ def verify_atoms( _VERIFY_PROMPT.format(statement=atom.statement, source=source), usable=_names_a_faithfulness_verdict, ).get("verdict", "")).strip() - except Exception: + except Exception: # noqa: BLE001 - an unverified atom is counted as unchecked verdict = "" atom.faithfulness = verdict if verdict in tally and verdict != "unchecked" else "unchecked" tally[atom.faithfulness] += 1 diff --git a/dokimi/dokimi/novelty/batching.py b/dokimi/dokimi/novelty/batching.py index 0169deef0..1bde07428 100644 --- a/dokimi/dokimi/novelty/batching.py +++ b/dokimi/dokimi/novelty/batching.py @@ -127,7 +127,7 @@ def read(chunk: list[T], reply: str) -> Any: raise ValueError("the model returned an empty reply") replied = True result.answered.append((chunk, read(chunk, reply))) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - re-asked in halves or recorded as the chunk's error half = len(chunk) // 2 # A reply cut at the ceiling is re-asked smaller, like one that will not parse. if (replied or isinstance(exc, OutputTruncated)) and half >= max(min_size, 1): diff --git a/dokimi/dokimi/novelty/client.py b/dokimi/dokimi/novelty/client.py index 0aa65c2d6..e625d0b5a 100644 --- a/dokimi/dokimi/novelty/client.py +++ b/dokimi/dokimi/novelty/client.py @@ -297,7 +297,7 @@ def accepting(parse: Callable[[str], Any]) -> Accept: def accept(reply: str) -> bool: try: parse(reply) - except Exception: + except Exception: # noqa: BLE001 - an accept predicate: any parse failure rejects the reply return False return True diff --git a/dokimi/dokimi/novelty/measure.py b/dokimi/dokimi/novelty/measure.py index 7dbf5ef16..8ef84a50b 100644 --- a/dokimi/dokimi/novelty/measure.py +++ b/dokimi/dokimi/novelty/measure.py @@ -377,7 +377,7 @@ def one(job: tuple[Probe, str]) -> ProbeRun: ) except SpendCapReached as exc: # a call with no price: its paid reply is kept, ungraded return _unpriced_answer_run(probe, arm, exc, stops, ungraded) - except Exception as exc: # a provider failure is a missing datum, not a wrong answer + except Exception as exc: # noqa: BLE001 - a provider failure is a missing datum, not a wrong answer response, confidence = f"", None verdict = ERRORED answered = False @@ -530,7 +530,7 @@ def _grade(probe: Probe, response: str, judge: LLMClient) -> str: ) try: raw = judge.json_object(prompt, usable=_names_a_verdict).get("verdict") - except Exception: + except Exception: # noqa: BLE001 - a failed judge call is the ERRORED verdict, not a wrong one return ERRORED verdict = raw.strip() if isinstance(raw, str) else "" if not verdict: @@ -548,7 +548,7 @@ def _reply_verdict(reply: str | None) -> str | None: return None try: value = parse_json_object(reply) - except Exception: + except ValueError: # an unparseable reply names no verdict return None return value["verdict"] if _names_a_verdict(value) else None diff --git a/dokimi/dokimi/novelty/probes.py b/dokimi/dokimi/novelty/probes.py index 828c3df8a..b7275f929 100644 --- a/dokimi/dokimi/novelty/probes.py +++ b/dokimi/dokimi/novelty/probes.py @@ -492,7 +492,7 @@ def _generate(client: LLMClient, template: str, lines: list[str]) -> list[str]: key = "questions" if "{questions}" in template else "statements" try: rows = client.json_array(template.format(**{key: numbered})) - except Exception: + except Exception: # noqa: BLE001 - a failed rewording call leaves the lines unworded rows = [] return _aligned(rows, len(lines)) diff --git a/dokimi/dokimi/novelty/retention.py b/dokimi/dokimi/novelty/retention.py index 7c6f331e2..967190b52 100644 --- a/dokimi/dokimi/novelty/retention.py +++ b/dokimi/dokimi/novelty/retention.py @@ -169,7 +169,7 @@ def _adjudicate(self, atom: Atom, text: str) -> str | None: _ADJUDICATE_PROMPT.format(statement=atom.statement, text=text[:12000]), accept=lambda raw: parse_verdict(raw) is not None, ) - except Exception: + except Exception: # noqa: BLE001 - a failed adjudication is no verdict (None), not a wrong one return None return parse_verdict(reply) diff --git a/dokimi/dokimi/personas/analyzer.py b/dokimi/dokimi/personas/analyzer.py index d5cee8f12..0aa0cf318 100644 --- a/dokimi/dokimi/personas/analyzer.py +++ b/dokimi/dokimi/personas/analyzer.py @@ -126,7 +126,7 @@ def _analyze_with_pygithub(self, client: "Github", repo_name: str) -> Dict[str, return analysis - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; falls back to the gh CLI logger.error(f"PyGithub analysis error: {e}, falling back to gh CLI") return self._analyze_with_gh_cli(repo_name) @@ -165,7 +165,7 @@ def _analyze_with_gh_cli(self, repo_name: str) -> Dict[str, Any]: "topics": [] } - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; an empty analysis logger.error(f"gh CLI analysis failed: {e}") return self._empty_analysis() @@ -217,7 +217,7 @@ def _get_readme(self, repo) -> str: try: readme = repo.get_readme() return readme.decoded_content.decode('utf-8') - except Exception: + except Exception: # noqa: BLE001 - a repo without a readable README has no README text return "" def _extract_use_cases(self, repo) -> List[str]: @@ -272,6 +272,7 @@ def _analyze_issues(self, repo, limit: int = 50) -> Dict[str, Any]: issues = repo.get_issues(state='all', labels=[label]) issues_to_analyze.extend(list(issues[:limit // len(question_labels)])) except Exception: + logger.warning("Issues for label %r not fetched", label, exc_info=True) continue # Extract patterns @@ -302,7 +303,7 @@ def _analyze_issues(self, repo, limit: int = 50) -> Dict[str, Any]: "sample_issues": sample_issues[:10] } - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; an empty issue sample logger.error(f"Issue analysis error: {e}") return {"common_questions": [], "common_labels": [], "sample_issues": []} diff --git a/dokimi/dokimi/questions/chunk_gen.py b/dokimi/dokimi/questions/chunk_gen.py index 082e665e4..296b003b5 100644 --- a/dokimi/dokimi/questions/chunk_gen.py +++ b/dokimi/dokimi/questions/chunk_gen.py @@ -182,7 +182,7 @@ def generate( logger.info(f"Fetching docs for chunk-based generation: {doc_url}") try: raw = _fetch_url(doc_url) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged; an empty result for that URL logger.error(f"Failed to fetch {doc_url}: {exc}") return ChunkQuestionResult(source_url=doc_url) @@ -220,7 +220,7 @@ def generate( qs = self._gen_from_chunk(library_name, chunk, doc_url, idx, count) all_questions.extend(qs) remaining -= len(qs) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged; one failed chunk does not lose the rest logger.warning(f"Chunk {idx} failed: {exc}") return ChunkQuestionResult( diff --git a/dokimi/dokimi/questions/llm_gen.py b/dokimi/dokimi/questions/llm_gen.py index b66cc37a7..bb0dfe340 100644 --- a/dokimi/dokimi/questions/llm_gen.py +++ b/dokimi/dokimi/questions/llm_gen.py @@ -271,7 +271,7 @@ def _generate_for_persona( } }) - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; one failed persona x topic does not lose the rest logger.error( f"Failed to generate questions for {persona['name']} × {topic}: {e}" ) diff --git a/dokimi/dokimi/questions/panel_reviewer.py b/dokimi/dokimi/questions/panel_reviewer.py index 29e1c89bc..643fdb461 100644 --- a/dokimi/dokimi/questions/panel_reviewer.py +++ b/dokimi/dokimi/questions/panel_reviewer.py @@ -205,7 +205,7 @@ def _call_reviewer(self, reviewer: str, question: str, library_name: str) -> Rev flags=flags, primary_score=primary, ) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged; the vote carries the error logger.warning(f"Reviewer '{reviewer}' failed on '{question[:50]}': {exc}") return ReviewerVote(reviewer=reviewer, scores={}, reasoning="", flags=[], error=str(exc)) diff --git a/dokimi/dokimi/questions/quality_analyzer.py b/dokimi/dokimi/questions/quality_analyzer.py index 2a7cfaa7d..bf5fab7bb 100644 --- a/dokimi/dokimi/questions/quality_analyzer.py +++ b/dokimi/dokimi/questions/quality_analyzer.py @@ -92,7 +92,7 @@ def classify_question(self, question: str, library_name: str) -> QuestionClassif raw = llm_call(prompt, model=self.model, provider=self.provider) from dokimi.llm import extract_json_object return _classification(question, extract_json_object(raw)) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged; a default classification logger.warning(f"Failed to classify question: {exc}") return QuestionClassification( question=question, diff --git a/dokimi/dokimi/questions/ragas_seed.py b/dokimi/dokimi/questions/ragas_seed.py index fb632c9ee..92fd9b443 100644 --- a/dokimi/dokimi/questions/ragas_seed.py +++ b/dokimi/dokimi/questions/ragas_seed.py @@ -145,7 +145,7 @@ def _fetch_docs( max_tokens=max_tokens, ) return "\n\n".join(c["content"] for c in chunks) - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; no seed docs logger.error("Failed to fetch docs for %s: %s", library_id, e) return "" @@ -174,7 +174,7 @@ def _extract_via_llm( # Clean, drop blank and non-string entries (a null is not the topic "None"), deduplicate topics = list(dict.fromkeys(t.strip() for t in topics if isinstance(t, str) and t.strip())) return topics[:max_topics] - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; falls back to the heuristic extractor logger.error("LLM topic extraction failed: %s — falling back to heuristic", e) return self._extract_heuristic(docs, library_name, max_topics) diff --git a/dokimi/dokimi/questions/refiner.py b/dokimi/dokimi/questions/refiner.py index cd1062946..eb05d7af0 100644 --- a/dokimi/dokimi/questions/refiner.py +++ b/dokimi/dokimi/questions/refiner.py @@ -301,7 +301,7 @@ def fill( }) filled[level] = len(texts[:count]) logger.info(f" Generated {filled[level]}/{count} for {level}") - except Exception as exc: + except Exception as exc: # noqa: BLE001 - logged; that level is filled with 0 logger.error(f"Gap fill failed for {level}: {exc}") filled[level] = 0 diff --git a/dokimi/dokimi/questions/validator.py b/dokimi/dokimi/questions/validator.py index f3c51d1ff..93800666d 100644 --- a/dokimi/dokimi/questions/validator.py +++ b/dokimi/dokimi/questions/validator.py @@ -331,7 +331,7 @@ def _validate_question(self, library_name: str, question_text: str) -> Optional[ return score - except Exception as e: + except Exception as e: # noqa: BLE001 - logged; the score is marked unscored, not graded logger.error(f"Validation failed for question: {question_text[:50]}... — {e}") return {"relevance": 100, "answerability": 100, "specificity": 100, "aggregate": 100, "unscored": True, diff --git a/dokimi/dokimi/repos/hits_judge.py b/dokimi/dokimi/repos/hits_judge.py index 764d10a9f..3e085e9ea 100644 --- a/dokimi/dokimi/repos/hits_judge.py +++ b/dokimi/dokimi/repos/hits_judge.py @@ -381,7 +381,7 @@ def judge_pair(ask: Ask, pair: Pair) -> dict[str, Any]: return {**out, "error": f"no reply parsed: {exc}"} except BudgetSpent: raise - except Exception as exc: # a 5xx or 429 past the retries, an auth error: this pair is unjudged, the run goes on + except Exception as exc: # noqa: BLE001 - a 5xx or 429 past the retries, an auth error: this pair is unjudged, the run goes on return {**out, "error": f"{CALL_FAILED}: {type(exc).__name__}: {exc}"} return {**out, "label": label, "reason": reason} diff --git a/dokimi/dokimi/repos/l0/branches.py b/dokimi/dokimi/repos/l0/branches.py index 760e5fc2b..10f798747 100644 --- a/dokimi/dokimi/repos/l0/branches.py +++ b/dokimi/dokimi/repos/l0/branches.py @@ -108,7 +108,7 @@ def _load(scan: Scan, path: str) -> Any: text = scan.text(path) try: return yaml.load(text, Loader=_Loader) # nosec B506 - a SafeLoader subclass - except Exception: # PyYAML's constructors raise more than YAMLError: a bad date, tag value, depth + except Exception: # noqa: BLE001 - PyYAML's constructors raise more than YAMLError: a bad date, tag value, depth return None diff --git a/dokimi/dokimi/repos/l0/versions.py b/dokimi/dokimi/repos/l0/versions.py index 181591cbb..bd62c743d 100644 --- a/dokimi/dokimi/repos/l0/versions.py +++ b/dokimi/dokimi/repos/l0/versions.py @@ -213,7 +213,7 @@ def workflow_versions(scan: Scan, path: str, unknown: list[str], seen: dict[str, text = scan.text(path) # outside the try: a failed git read (SourceError is a ValueError) is no YAML error try: doc = yaml.safe_load(text) - except Exception: # PyYAML's constructors raise more than YAMLError: a bad date, tag value, depth + except Exception: # noqa: BLE001 - PyYAML's constructors raise more than YAMLError: a bad date, tag value, depth unknown.append(f"{path} is not valid YAML") return set() jobs = doc.get("jobs") if isinstance(doc, dict) else None diff --git a/dokimi/dokimi/repos/l0/workflows.py b/dokimi/dokimi/repos/l0/workflows.py index b56a414c5..2a5f66458 100644 --- a/dokimi/dokimi/repos/l0/workflows.py +++ b/dokimi/dokimi/repos/l0/workflows.py @@ -63,7 +63,7 @@ def _action_users(scan: Scan) -> tuple[dict[str, str], dict[str, str]]: for path in paths: try: doc = yaml.safe_load(scan.text(path)) - except Exception: # PyYAML's constructors raise more than YAMLError: a bad date, tag value, depth + except Exception: # noqa: BLE001, S112 - PyYAML's constructors raise more than YAMLError: a bad date, tag value, depth continue for value in _uses(doc, path in flows): # `.github/workflows/action.yml` is a workflow name, at, ref = value.strip().partition("@") diff --git a/dokimi/dokimi/tool_preference.py b/dokimi/dokimi/tool_preference.py index ff766184b..e0b1e4a30 100644 --- a/dokimi/dokimi/tool_preference.py +++ b/dokimi/dokimi/tool_preference.py @@ -331,7 +331,7 @@ def one(_: int) -> None: res.update(choice=classify(item, out.get("called") or []), text_preview=(out.get("text") or "")[:200], truncated=bool(out.get("truncated"))) - except Exception as exc: # recorded, not raised + except Exception as exc: # noqa: BLE001 - recorded, not raised res["error"] = f"{type(exc).__name__}: {exc}"[:300] if hasattr(exc, "usage"): billed, usage = True, exc.usage diff --git a/dokimi/dokimi/treatments/arms.py b/dokimi/dokimi/treatments/arms.py index 103e784c8..f6e5a7d7f 100644 --- a/dokimi/dokimi/treatments/arms.py +++ b/dokimi/dokimi/treatments/arms.py @@ -67,7 +67,7 @@ def _default_reranker(threshold: float): if SENTENCE_TRANSFORMERS_AVAILABLE: try: return SentenceTransformerReranker(threshold=threshold) - except Exception as exc: # pragma: no cover - env dependent + except Exception as exc: # pragma: no cover # noqa: BLE001 - env dependent logger.warning("Semantic reranker unavailable (%s); using lexical", exc) return SimpleReranker(threshold=threshold) diff --git a/dokimi/pyproject.toml b/dokimi/pyproject.toml index 7dc5535ca..f460bd45e 100644 --- a/dokimi/pyproject.toml +++ b/dokimi/pyproject.toml @@ -173,32 +173,8 @@ extend-exclude = ["benchmarks", "data", "tests/fixtures/task_formats"] # The built-in review graders run on the task image's python3 too, 3.8 or newer (contract §4.5). "dokimi/repos/graders/*.py" = "py38" -# A correctness gate: rules that flag code that misbehaves (a swallowed -# KeyboardInterrupt, a naive datetime, a file handle never closed, an assert -# that never runs), not style. A family is taken whole where its rules are of -# that kind and its few style rules are cheap or ignored below; elsewhere the -# rules are listed one by one, so a ruff upgrade cannot add style rules to it. -# A whole family does gain rules with ruff releases (0.16.0 stabilized LOG004 -# and PLE0304, preview-only in 0.12), so ruff is pinned to one version here, in -# requirements-ci.txt (the tools lock CI installs) and in .pre-commit-config.yaml, -# and Renovate bumps all three in one PR that runs the new rules. -# -# Not enabled yet, with their violation counts at ruff 0.17.0. These three are -# autofixes across most files: they land in a PR of their own while few PRs -# are open, and per-file-target-version keeps UP from rewriting the py310 -# files above for 3.11: -# UP (pyupgrade) 1399, of which PEP 585/604 annotations 1185, imports 162 -# I (isort) 156 -# Hand fixes deferred because an open PR rewrites the same lines (#29, one -# process runner for the spawn sites): -# PLW1509 (preexec_fn with threads) 1, PLW1510 (subprocess.run without -# check=) 15 -# Bug-prone, each site needs a look: -# PLW2901 (loop variable reassigned) 20, RUF043 (pytest.raises match= with -# unescaped regex characters) 24, ISC004 (implicit concatenation in a -# collection, i.e. a missing comma) 67, BLE001 (blind except) 64, -# PT011 (pytest.raises too broad) 53, RUF012 (mutable class attribute) 8: -# constants and test stubs, but Tool.parameters is also set per instance +# A correctness gate, not style: ruff is pinned in requirements-ci.txt and .pre-commit-config.yaml. +# Not enabled (counts at ruff 0.17.0): UP 1399, I 156, PLW1509 1, PLW1510 15, PLW2901 20, RUF043 24, ISC004 67, PT011 53, RUF012 8. [tool.ruff.lint] select = [ "F", # pyflakes @@ -207,6 +183,7 @@ select = [ "E711", "E712", "E713", "E714", "E721", "E722", # None/bool/type compares, bare except "A", # shadowed builtins and stdlib modules "ASYNC", # blocking calls in async code + "BLE001", "S110", "S112", # blind except, except-pass/continue: each kept site says why in its noqa "DTZ", # naive datetimes "EXE", # shebang vs executable bit "G", # logging call arguments @@ -228,7 +205,7 @@ select = [ "RUF068", "RUF071", "RUF100", "RUF101", "RUF102", "RUF103", "RUF104", "RUF200", ] # noqa codes of rules not selected here; RUF100 leaves them alone. -external = ["ARG001", "BLE001", "E402", "E731", "N802", "N818", "S310"] +external = ["ARG001", "E402", "E731", "N802", "N818", "S310"] ignore = [ "A002", # argument named like a builtin: keyword APIs mirror subprocess's input= "G004", # f-string in a logging call: style, 108 sites diff --git a/dokimi/scripts/ci_local.py b/dokimi/scripts/ci_local.py index 1ef46845a..1dc4d6bd7 100755 --- a/dokimi/scripts/ci_local.py +++ b/dokimi/scripts/ci_local.py @@ -244,7 +244,7 @@ def network_ok() -> bool: try: urllib.request.urlopen("https://pypi.org/simple/pip/", timeout=8) # nosec B310 return True - except Exception: + except Exception: # noqa: BLE001 - a reachability probe answers False on any failure return False diff --git a/dokimi/tests/test_arms_run_config.py b/dokimi/tests/test_arms_run_config.py index 6d4301ffb..e77ba735e 100644 --- a/dokimi/tests/test_arms_run_config.py +++ b/dokimi/tests/test_arms_run_config.py @@ -152,3 +152,18 @@ def test_check_matrix_names_the_first_cell_with_an_unsupported_harness(): with pytest.raises(ValueError, match="^matrix cell 'odd': matrix cells only support"): check_matrix(ArmsRunConfig(product="demo"), cells) + + +def test_an_unresolved_doc_library_id_is_logged_and_the_name_is_used(monkeypatch, caplog): + from dokimi.eval import arms_run + from dokimi.mcp import factory + from dokimi.treatments.arms import DocTreatment + + def down(*args, **kwargs): + raise RuntimeError("server down") + + monkeypatch.setattr(factory, "resolve_library_id_for_product", down) + with caplog.at_level("WARNING", logger="dokimi.eval.arms_run"): + lib_id = arms_run.resolve_doc_library_id([DocTreatment(object(), name="docs", top_k=3)], "react") + assert lib_id == "react" + assert [r.exc_info is not None for r in caplog.records if "not resolved" in r.getMessage()] == [True] diff --git a/dokimi/tests/test_fuzz_targets.py b/dokimi/tests/test_fuzz_targets.py index e4637f5c8..a1e1e3626 100644 --- a/dokimi/tests/test_fuzz_targets.py +++ b/dokimi/tests/test_fuzz_targets.py @@ -74,6 +74,16 @@ def test_deep_nesting_is_a_parse_error_not_a_crash() -> None: assert isinstance(got, kind) +@pytest.mark.parametrize("text", ["[" * 100000, "```json\n" + "[" * 100000 + "\n```"]) +@pytest.mark.parametrize("extract_name", ["extract_json_object", "extract_json_array"]) +def test_a_bare_deep_reply_is_a_parse_error_not_a_crash(extract_name: str, text: str) -> None: + # No prose before the nesting, bare or fenced: the direct and fence parses are the ones that recurse. + from dokimi import llm_json + + with pytest.raises(ValueError): + getattr(llm_json, extract_name)(text) + + def test_a_date_that_is_not_real_is_invalid_frontmatter() -> None: # PyYAML raises a plain ValueError for 2026-02-30: the lenient reader reads no metadata, # and the strict one raises the YAML error its docstring names. diff --git a/dokimi/tests/test_gate_golden.py b/dokimi/tests/test_gate_golden.py index 041dadad6..fbfec9451 100644 --- a/dokimi/tests/test_gate_golden.py +++ b/dokimi/tests/test_gate_golden.py @@ -70,7 +70,7 @@ def _strict(root: Path, future: dict, strict: bool, extra: dict | None = None) - code: object = 0 except SystemExit as exc: code = exc.code - except Exception as exc: # a bad spec surfaces as the named input error, not a traceback + except Exception as exc: # noqa: BLE001 - a bad spec surfaces as the named input error, not a traceback code = f"{type(exc).__name__}: {exc}" finally: for p in patches: diff --git a/dokimi/tests/test_mcp_protocol.py b/dokimi/tests/test_mcp_protocol.py index 8a3ff3566..eb11a4bdb 100644 --- a/dokimi/tests/test_mcp_protocol.py +++ b/dokimi/tests/test_mcp_protocol.py @@ -165,3 +165,16 @@ def test_http_transport_works_across_mcp_versions(monkeypatch, http_attrs, strea _fake_mcp(monkeypatch, http_attrs, streams) client = MCPProtocolClient(transport="http", url="http://x/mcp") assert client._list_tool_names() == ["get-library-docs"] + + +def test_a_failed_tool_list_is_logged_and_the_name_is_used(caplog): + client = MCPProtocolClient(transport="http", url="http://127.0.0.1:9") + + def down(): + raise RuntimeError("server down") + + client._list_tool_names = down + with caplog.at_level("ERROR", logger="dokimi.mcp.mcp_protocol"): + assert client.resolve_library_id("react") == "react" + assert [r.exc_info is not None for r in caplog.records + if "listing MCP tools failed" in r.getMessage()] == [True] diff --git a/dokimi/tests/test_noqa_reasons.py b/dokimi/tests/test_noqa_reasons.py new file mode 100644 index 000000000..fa54bd4a1 --- /dev/null +++ b/dokimi/tests/test_noqa_reasons.py @@ -0,0 +1,123 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Every kept blind ``except`` says why in its ``noqa``. + +ruff's BLE001, S110 and S112 fail an ``except Exception`` that neither re-raises nor +logs the traceback, and an ``except`` whose body is only ``pass`` or ``continue``. +A site that must catch everything keeps a ``# noqa: BLE001 - `` naming what +happens to the failure (logged, recorded on the row, re-asked, answered False). ruff +accepts a bare ``# noqa: BLE001``; this test does not, so the gate cannot be passed +by adding the code alone. +""" + +from __future__ import annotations + +import io +import re +import subprocess +import tokenize +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CODES = {"BLE001", "S110", "S112"} +# ruff's extend-exclude in pyproject.toml, plus the virtualenvs a checkout may hold. +SKIP = ("benchmarks/", "data/", "tests/fixtures/task_formats/", ".venv/", "venv/", "build/", ".tox/", "dist/") +_NOQA = re.compile(r"#\s*(?:(?:ruff|flake8)\s*:\s*)?noqa:\s*(?P[A-Z]+[0-9]+(?:[\s,]+[A-Z]+[0-9]+)*)(?P.*)$", re.I) +_REASON = re.compile(r"^\s*(?:[-—:(]+\s*)?(?P.*?)\)?\s*$") +# coverage.py's default exclude pattern: the pragma must start with a `#` of its own. +_COVERAGE_PRAGMA = re.compile(r"#\s*(pragma|PRAGMA)[:\s]?\s*(no|NO)\s*(cover|COVER)") + + +def unexplained(source: str) -> list[int]: + """Lines of *source* whose ``noqa`` names a blind-except code but gives no reason.""" + lines = [] + for tok in tokenize.generate_tokens(io.StringIO(source).readline): + if tok.type != tokenize.COMMENT: + continue + m = _NOQA.search(tok.string) + if not m or not CODES & set(re.split(r"[\s,]+", m.group("codes"))): + continue + reason = _REASON.match(_COVERAGE_PRAGMA.sub("", m.group("rest"))) + if not reason or len(reason.group("text").split()) < 2: + lines.append(tok.start[0]) + return lines + + +def _python_files() -> list[Path]: + # The files ruff checks: what git does not hide (.gitignore, so mutants/ is out too). + listed = subprocess.run( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "*.py"], + cwd=ROOT, capture_output=True, text=True, check=True).stdout + return [ROOT / rel for rel in sorted(listed.split("\0")) + if rel and not rel.startswith(SKIP) and (ROOT / rel).is_file()] + + +def test_python_files_follow_gitignore_and_skip_tox(tmp_path: Path, monkeypatch) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / ".gitignore").write_text("mutants/\n", encoding="utf-8") + for rel in ("a.py", "mutants/scratch.py", ".tox/py311/x.py", "pkg/b.py"): + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text("x = 1\n", encoding="utf-8") + (tmp_path / "gone.py").write_text("x = 1\n", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "gone.py"], check=True) + (tmp_path / "gone.py").unlink() # staged, then deleted in the working tree + monkeypatch.setitem(globals(), "ROOT", tmp_path) + assert [p.relative_to(tmp_path).as_posix() for p in _python_files()] == ["a.py", "pkg/b.py"] + + +def test_every_blind_except_noqa_has_a_reason() -> None: + bad = [f"{p.relative_to(ROOT).as_posix()}:{line}" + for p in _python_files() + for line in unexplained(p.read_text(encoding="utf-8"))] + assert not bad, "a # noqa for BLE001/S110/S112 needs a reason after it: " + ", ".join(bad) + + +def test_a_bare_code_fails() -> None: + src = "try:\n x()\nexcept Exception: # noqa: BLE001\n pass\n" + assert unexplained(src) == [3] + + +def test_a_code_with_one_word_fails() -> None: + assert unexplained("try:\n x()\nexcept Exception: # noqa: BLE001 - fine\n pass\n") == [3] + + +def test_a_reason_passes_in_each_separator_style() -> None: + for comment in ("# noqa: BLE001 - one bad cell must not end the sweep", + "# noqa: BLE001 — a bad frontmatter keeps its dir name", + "# noqa: BLE001, S110 (logged and skipped)"): + assert unexplained(f"try:\n x()\nexcept Exception: {comment}\n pass\n") == [] + + +def test_other_codes_are_not_checked() -> None: + assert unexplained("import os # noqa: F401\n") == [] + + +def test_upper_case_and_doubled_dashes_are_still_checked() -> None: + assert unexplained("try:\n x()\nexcept Exception: # NOQA: BLE001 -- ok\n pass\n") == [3] + + +def test_a_code_after_a_space_separated_one_is_still_checked() -> None: + assert unexplained("try:\n x()\nexcept: # noqa: E722 BLE001 S110\n pass\n") == [3] + assert unexplained("try:\n x()\nexcept: # noqa: E722 BLE001 S110 - logged by the caller\n pass\n") == [] + + +def test_a_coverage_pragma_is_not_a_reason() -> None: + src = "try:\n x()\nexcept Exception: # noqa: BLE001, S110 # pragma: no cover\n pass\n" + assert unexplained(src) == [3] + assert unexplained(src.replace("# pragma: no cover", "# pragma: no cover - logged upstream")) == [] + + +def test_a_file_level_noqa_needs_a_reason_too() -> None: + src = "try:\n x()\nexcept Exception:\n pass\n" + assert unexplained("# ruff: noqa: BLE001, S110\n" + src) == [1] + assert unexplained("# flake8: noqa: BLE001\n" + src) == [1] + assert unexplained("# ruff: noqa: BLE001 - every probe failure is logged upstream\n" + src) == [] + + +def test_a_reason_does_not_hide_a_coverage_pragma() -> None: + bad = [f"{p.relative_to(ROOT).as_posix()}:{n}" + for p in _python_files() if p.resolve() != Path(__file__).resolve() + for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1) + if "noqa" in line and "pragma: no cover" in line and not _COVERAGE_PRAGMA.search(line)] + assert not bad, "a noqa reason swallowed a coverage pragma: " + ", ".join(bad) diff --git a/dokimi/tests/test_novelty_answer_key_record.py b/dokimi/tests/test_novelty_answer_key_record.py index 5e1214a0e..2056b4eba 100644 --- a/dokimi/tests/test_novelty_answer_key_record.py +++ b/dokimi/tests/test_novelty_answer_key_record.py @@ -141,7 +141,7 @@ def test_a_copied_or_pickled_record_keeps_the_absent_marker_and_its_verdict(tmp_ def outcome(record): try: return ("ok", entry_point_only(record, [], [])) - except Exception as exc: # the verdict, not the type, must not depend on a copy + except Exception as exc: # noqa: BLE001 - the verdict, not the type, must not depend on a copy return (type(exc).__name__, str(exc)) assert outcome(copy.deepcopy(key)) == outcome(key) diff --git a/dokimi/tests/test_personas_analyzer.py b/dokimi/tests/test_personas_analyzer.py index 5114d09d6..23fb3e9a5 100644 --- a/dokimi/tests/test_personas_analyzer.py +++ b/dokimi/tests/test_personas_analyzer.py @@ -309,3 +309,13 @@ def test_create_minimal_analysis(): assert analysis["issues_analysis"] == {"common_questions": [], "common_labels": [], "sample_issues": []} assert analysis["api_patterns"] == [] assert analysis["topics"] == [] + + +def test_a_failed_label_fetch_is_logged_and_skipped(caplog): + analyzer = PersonaAnalyzer() + repo = Mock() + repo.get_issues.side_effect = RuntimeError("rate limited") + with caplog.at_level("WARNING", logger="dokimi.personas.analyzer"): + analyzer._analyze_issues(repo) + assert [r.exc_info is not None for r in caplog.records + if "not fetched" in r.getMessage()] == [True] * 4