Coverage for vibefpmgui/ai_review.py: 89%

303 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""AI code review with a local model served by Ollama (https://ollama.com). 

2 

3Everything runs on this machine: the GUI talks to an ``ollama serve`` 

4process on localhost (starting one if needed), so no code leaves the 

5computer. Models are downloaded once with ``ollama pull`` (the GUI can do 

6that too) and are then available offline. 

7 

8The model is asked to answer with one finding per line:: 

9 

10 LINE 12: bug: the loop never updates i 

11 LINE 30-34: style: use an assumed-shape array instead of (*) 

12 

13followed by ``SUMMARY: ...``. `parse_findings` reads those lines back 

14(tolerating the small variations models produce), so findings can be listed 

15and marked in the editor's gutter. 

16 

17The work happens in a `ReviewWorker` on a background thread; the 

18``ollama`` Python package is optional and imported lazily. 

19""" 

20 

21from __future__ import annotations 

22 

23import contextlib 

24import re 

25import shutil 

26import threading 

27import time 

28from dataclasses import dataclass 

29 

30from qtpy.QtCore import QObject, QThread, Signal, Slot 

31 

32from .ai_edits import END_RE as EDIT_END_RE 

33from .ai_edits import START_RE as EDIT_START_RE 

34 

35DEFAULT_MODEL = "qwen2.5-coder:7b" 

36MAX_ANSWER_TOKENS = 1500 # the default answer length: plenty for a list of findings; stops runaway answers 

37ANSWER_LENGTHS = (1500, 4000, 8000, 16000, 0) # AI → Answer Length (0: no limit) 

38REASONING_TOKENS = 8192 # room for a thinking model's reasoning (AI → Think Before Answering), on top 

39DEFAULT_HOST = None # the ollama library's default: $OLLAMA_HOST or localhost:11434 

40CATEGORIES = ("bug", "warning", "performance", "style", "suggestion") 

41 

42FOCUS = { 

43 "general": "Give a balanced review: correctness first, then clarity, performance and style.", 

44 "bugs": "Concentrate on real bugs: wrong results, out-of-bounds access, uninitialised " 

45 "variables, integer overflow, wrong intent, aliasing, precision loss, race conditions.", 

46 "modern": "Concentrate on modernising the code: implicit none, intent, modules instead of " 

47 "common blocks/include, assumed-shape arrays, allocatable instead of pointers, " 

48 "do concurrent where safe, removing obsolescent features (goto, arithmetic if, " 

49 "fixed form, equivalence).", 

50 "performance": "Concentrate on performance: memory access order (Fortran is column-major), " 

51 "unnecessary temporaries and copies, repeated allocation in loops, " 

52 "vectorisation and OpenMP opportunities.", 

53 "docs": "Concentrate on documentation and readability: missing or misleading comments, " 

54 "unclear names, FORD-style (!>) doc comments for public procedures.", 

55} 

56FOCUS_LABELS = {"general": "General", "bugs": "Bugs", "modern": "Modern Fortran style", 

57 "performance": "Performance", "docs": "Documentation"} 

58 

59SYSTEM_PROMPT = """You are an experienced reviewer of scientific and numerical code, \ 

60expert in modern Fortran (2008/2018), and also C and Python. You review one file at a time. 

61 

62Rules for your answer: 

63- Write one line per finding, in exactly this form: 

64 LINE <n>: <category>: <comment> 

65 or, for a range of lines, 

66 LINE <start>-<end>: <category>: <comment> 

67- <category> is one of: bug, warning, performance, style, suggestion. 

68- Use the line numbers shown at the start of each source line. 

69- Keep each comment to one or two sentences and say how to fix it. 

70- Only report real, specific issues. Do not invent problems; if the code is fine, say so. 

71- After the findings, write a line starting with "SUMMARY:" with a one to three 

72 sentence overall assessment. 

73- Do not repeat the code and do not use Markdown headings or code blocks.""" 

74 

75 

76@dataclass 

77class Finding: 

78 line: int 

79 end_line: int 

80 category: str 

81 comment: str 

82 

83 

84FINDING_RE = re.compile( 

85 r"""^\s*(?:[-*•]\s*|\d+[.)]\s*)? # optional bullet / numbering 

86 \**\s*L(?:INE)?S?\s*\#?\s*(\d+) # LINE 12 / Line 12 / L12 

87 (?:\s*(?:-|–|to)\s*(\d+))? # optional range 

88 \s*\**\s*[:\])-]\s*\**\s* # separator 

89 (?:(bug|error|warning|performance|style|suggestion|info|note)s?\**\s*[:\-]\s*)? 

90 (.+?)\s*$""", 

91 re.IGNORECASE | re.VERBOSE) 

92SUMMARY_RE = re.compile(r"^\s*\**\s*SUMMARY\s*\**\s*:\s*(.*)$", re.IGNORECASE) 

93 

94 

95def parse_findings(text: str, max_line: int | None = None) -> tuple[list[Finding], str]: 

96 """Findings and the summary from a model's answer.""" 

97 findings: list[Finding] = [] 

98 summary_lines: list[str] = [] 

99 in_summary = False 

100 for raw in text.splitlines(): 

101 if in_summary: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 if raw.strip(): 

103 summary_lines.append(raw.strip()) 

104 continue 

105 match = SUMMARY_RE.match(raw) 

106 if match: 

107 in_summary = True 

108 if match.group(1).strip(): 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was always true

109 summary_lines.append(match.group(1).strip()) 

110 continue 

111 match = FINDING_RE.match(raw) 

112 if not match: 

113 # Models often explain a finding on the following line(s). 

114 extra = raw.strip().lstrip("-*\u2022 ").strip() 

115 if extra and findings and not extra.startswith("```"): 

116 last = findings[-1] 

117 joiner = " " if last.comment.rstrip("\x00").endswith((".", "!", "?", ":")) else " — " 

118 last.comment = f"{last.comment}{joiner}{extra}" 

119 elif not extra and findings: 

120 findings[-1].comment += "\x00" # a blank line ends the explanation 

121 continue 

122 start = int(match.group(1)) 

123 end = int(match.group(2)) if match.group(2) else start 

124 if end < start: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 start, end = end, start 

126 if max_line is not None and (start < 1 or start > max_line): 

127 continue # a hallucinated line number 

128 category = (match.group(3) or "suggestion").lower() 

129 category = {"error": "bug", "info": "suggestion", "note": "suggestion"}.get(category, category) 

130 comment = match.group(4).strip().strip("*").strip() 

131 if comment: 131 ↛ 100line 131 didn't jump to line 100 because the condition on line 131 was always true

132 findings.append(Finding(start, min(end, max_line or end), category, comment)) 

133 unique: list[Finding] = [] 

134 seen = set() 

135 for f in findings: 

136 # Text after a blank line wasn't part of the finding. 

137 f.comment = f.comment.split("\x00")[0].strip() 

138 key = (f.line, f.end_line, f.category, " ".join(f.comment.lower().split())) 

139 if key not in seen: # small models sometimes repeat themselves 

140 seen.add(key) 

141 unique.append(f) 

142 return unique, " ".join(summary_lines) 

143 

144 

145class RepeatGuard: 

146 """Spots a model stuck in a loop (writing a line it already wrote). 

147 

148 Text is passed on a line at a time, so a repeated line can be held back: 

149 `feed` returns the text that's safe to show and whether to stop. 

150 

151 Lines in code (``` blocks) and in edits (SEARCH/REPLACE blocks) aren't 

152 looked at: there, repeating is normal (an edit's new text repeats the 

153 unchanged lines of the old one). A loop there still ends at the answer's 

154 length limit. 

155 """ 

156 

157 MIN_LENGTH = 12 # short lines ("", "```", "SUMMARY:") may legitimately repeat 

158 

159 def __init__(self): 

160 self._partial = "" 

161 self._seen: set[str] = set() 

162 self.repeated = False 

163 self._in_code = False # in a ``` block 

164 self._in_edit = False # in a SEARCH/REPLACE block 

165 

166 @staticmethod 

167 def _key(line: str) -> str: 

168 return " ".join(line.lower().split()) 

169 

170 def feed(self, text: str) -> tuple[str, bool]: 

171 self._partial += text 

172 out = [] 

173 while "\n" in self._partial: 

174 line, self._partial = self._partial.split("\n", 1) 

175 key = self._key(line) 

176 if EDIT_START_RE.match(line): 

177 self._in_edit = True 

178 elif EDIT_END_RE.match(line): 

179 self._in_edit = False 

180 elif line.lstrip().startswith("```"): 

181 self._in_code = not self._in_code 

182 elif len(key) >= self.MIN_LENGTH and not (self._in_code or self._in_edit): 

183 if key in self._seen: 

184 self.repeated = True 

185 self._partial = "" 

186 return "".join(out), True 

187 self._seen.add(key) 

188 out.append(line + "\n") 

189 return "".join(out), False 

190 

191 def flush(self) -> str: 

192 """Whatever is left at the end (a last line without a newline).""" 

193 rest, self._partial = self._partial, "" 

194 if rest and len(self._key(rest)) >= self.MIN_LENGTH and self._key(rest) in self._seen \ 194 ↛ 196line 194 didn't jump to line 196 because the condition on line 194 was never true

195 and not (self._in_code or self._in_edit): 

196 self.repeated = True 

197 return "" 

198 return rest 

199 

200 

201def build_prompt(path: str, language: str, code: str, focus: str, 

202 diagnostics: list[tuple[int, str, str]], first_line: int = 1, total_lines: int = 0) -> str: 

203 """The user message: instructions, compiler messages and numbered code. 

204 ``code`` can be part of the file: its lines from ``first_line`` (of 

205 ``total_lines``), numbered as in the file.""" 

206 # Split only at newlines, like the editor: str.splitlines() would also 

207 # split at form feeds, U+2028 etc. and shift every later line number. 

208 lines = code.split("\n") 

209 if len(lines) > 1 and lines[-1] == "": 

210 lines.pop() # a final newline doesn't start another line 

211 last_line = first_line + len(lines) - 1 

212 width = len(str(last_line)) 

213 numbered = "\n".join(f"{i:>{width}}| {line}" for i, line in enumerate(lines, first_line)) 

214 parts = [f"Review this {language} file: {path}", FOCUS.get(focus, FOCUS["general"])] 

215 if total_lines and (first_line > 1 or last_line < total_lines): 

216 parts.append(f"Only lines {first_line}–{last_line} of the file's {total_lines} are shown: review " 

217 "only these. Don't report problems that may come from code that isn't shown " 

218 "(declarations elsewhere in the file, other procedures).") 

219 diagnostics = [d for d in diagnostics if first_line <= d[0] <= last_line] 

220 if diagnostics: 

221 notes = "\n".join(f"line {ln}: {sev}: {msg}" for ln, sev, msg in diagnostics[:40]) 

222 parts.append("The compiler already reported these messages in the last build, and they " 

223 "are shown to the user separately. Do NOT list them again; look for other " 

224 "problems the compiler can't see:\n" + notes) 

225 parts.append("Source (line numbers on the left):\n" + numbered) 

226 return "\n\n".join(parts) 

227 

228 

229EXPLAIN_SYSTEM_PROMPT = """You are an expert in modern Fortran, its compilers (gfortran, \ 

230LFortran, Intel) and linters, helping a programmer understand one message about their code. \ 

231Answer in Markdown, briefly and concretely, for this code (not in general): 

2321. **What it means**: the message in plain words. 

2332. **Why here**: what in the marked line (and around it) causes it. 

2343. **How to fix it**: the change to make, with the corrected lines in a ```fortran block \ 

235(code always in fenced blocks labeled with its language, so it can be highlighted). 

236If the message is harmless or a style preference, say so. Don't invent code that isn't shown.""" 

237 

238EXPLAIN_CONTEXT_LINES = 15 # lines of code shown before and after the problem 

239 

240 

241def build_explain_prompt(path: str, code: str, line: int, kind: str, message: str, 

242 rule: str = "", compiler: str = "") -> str: 

243 """The question for one problem: the message, where, and the code around 

244 it (numbered, the problem's line marked with >>).""" 

245 lines = code.split("\n") 

246 first = max(1, line - EXPLAIN_CONTEXT_LINES) 

247 last = min(len(lines), line + EXPLAIN_CONTEXT_LINES) 

248 width = len(str(last)) 

249 shown = "\n".join(f"{'>>' if n == line else ' '} {n:>{width}}| {lines[n - 1]}" for n in range(first, last + 1)) 

250 source = {"build": f"the compiler ({compiler})" if compiler else "the compiler", 

251 "fortitude": "the fortitude linter", "fortls": "the fortls language server"}.get(kind, kind) 

252 what = f"{rule}: {message}" if rule and rule != kind else message 

253 return (f"In {path}, line {line}, {source} reports:\n\n {what}\n\n" 

254 f"The code around it (line numbers on the left; the line is marked >>):\n\n{shown}\n\n" 

255 "Explain this message for this code, and how to fix it.") 

256 

257 

258CHAT_SYSTEM_PROMPT = """You are a helpful assistant for a programmer working on a Fortran \ 

259project built with fpm (the Fortran Package Manager). You are an expert in modern Fortran \ 

260(2008/2018), its compilers (gfortran, LFortran, Intel), fpm, and numerical methods. \ 

261Answer in Markdown, concisely. Put code in fenced blocks labeled with its language \ 

262(```fortran, ```toml for fpm.toml, ```bash for commands), so it can be highlighted. \ 

263If you are not sure, say so. 

264When the user's file is attached (its lines are numbered on the left) and you point out a \ 

265problem or a change at specific lines, also write each one on a line of its own, exactly as 

266 LINE <n>: <category>: <comment> 

267(or LINE <start>-<end>: ...), where <category> is one of: bug, warning, performance, style, \ 

268suggestion. Those lines are listed for the user and marked in the editor.""" 

269 

270 

271EDIT_SYSTEM_PROMPT = """You change a programmer's Fortran file as they ask. Reply with: 

2721. One or two sentences on what you'll change. 

2732. One SEARCH/REPLACE block per change, in exactly this form: 

274<<<<<<< SEARCH 

275(lines copied exactly from the file) 

276======= 

277(the new lines) 

278>>>>>>> REPLACE 

279Rules: change only what was asked; keep everything else exactly as it is (names, \ 

280comments, formatting, free or fixed form). Copy the SEARCH lines exactly, enough of them \ 

281(usually 2 to 6) to be found in only one place in the file. Never write "..." or \ 

282"rest unchanged" inside a block. To delete lines, leave REPLACE empty. If the request is \ 

283unclear or unsafe, ask instead of editing. Earlier edits in the conversation are marked \ 

284applied, not applied yet, or rejected: answer the latest request, and don't propose a \ 

285rejected edit again.""" 

286 

287EDIT_ANSWER_TOKENS = 4096 # (several blocks, each with the old and new text) 

288MAX_CONTEXT = 32768 # the most asked of Ollama (memory: a 7B model at 32k already needs ~6 GB) 

289 

290 

291def context_for(tokens: int, cap: int = MAX_CONTEXT, answer: int = MAX_ANSWER_TOKENS) -> int: 

292 """A context window for ``tokens`` of conversation plus the answer: a 

293 power of two from 4096, at most ``cap``.""" 

294 size = 4096 

295 while size < tokens + answer and size < cap: 

296 size *= 2 

297 return min(size, cap) 

298 

299 

300def model_context_length(model: str, host: str | None = DEFAULT_HOST) -> int | None: 

301 """The longest context ``model`` was trained for, as Ollama reports it 

302 (None if it can't be asked).""" 

303 try: 

304 info = make_client(host, timeout=5).show(model) 

305 data = getattr(info, "modelinfo", None) 

306 if data is None and isinstance(info, dict): 

307 data = info.get("model_info") or info.get("modelinfo") 

308 for key, value in dict(data or {}).items(): 

309 if str(key).endswith(".context_length"): 

310 return int(value) 

311 except Exception: # noqa: BLE001 - not known, then 

312 return None 

313 return None 

314 

315 

316def ollama_available() -> bool: 

317 try: 

318 import ollama # noqa: F401 

319 except ImportError: 

320 return False 

321 return True 

322 

323 

324def ollama_executable() -> str | None: 

325 return shutil.which("ollama") 

326 

327 

328def make_client(host: str | None = DEFAULT_HOST, timeout: float | None = None): 

329 import ollama 

330 return ollama.Client(host=host, timeout=timeout) 

331 

332 

333def server_running(host: str | None = DEFAULT_HOST) -> bool: 

334 try: 

335 make_client(host, timeout=2).list() 

336 return True 

337 except Exception: # noqa: BLE001 - any failure means "not reachable" 

338 return False 

339 

340 

341def installed_models(host: str | None = DEFAULT_HOST) -> list[str]: 

342 try: 

343 response = make_client(host, timeout=5).list() 

344 except Exception: # noqa: BLE001 

345 return [] 

346 models = getattr(response, "models", None) or response.get("models", []) 

347 names = [] 

348 for m in models: 

349 name = getattr(m, "model", None) or (m.get("model") if isinstance(m, dict) else None) \ 

350 or (m.get("name") if isinstance(m, dict) else None) 

351 if name: 351 ↛ 348line 351 didn't jump to line 348 because the condition on line 351 was always true

352 names.append(name) 

353 return sorted(names) 

354 

355 

356class ReviewWorker(QObject): 

357 """Runs a review (or a model download) on a background thread.""" 

358 

359 chunk = Signal(str) # streamed text of the answer 

360 reasoning = Signal(str) # streamed reasoning of a thinking model 

361 progress = Signal(str, float) # download status, fraction (-1 = unknown) 

362 finished = Signal(str, dict) # full answer, stats 

363 failed = Signal(str) 

364 

365 def __init__(self, host: str | None, model: str, prompt: str = "", pull_only: bool = False, 

366 system: str = SYSTEM_PROMPT, history: list[dict] | None = None, 

367 num_ctx: int | None = None, temperature: float = 0.2, 

368 num_predict: int = MAX_ANSWER_TOKENS, think: bool = False): 

369 """``system``: the model's instructions (a review's, by default; 

370 EXPLAIN_SYSTEM_PROMPT to explain one problem, CHAT_SYSTEM_PROMPT to 

371 chat, EDIT_SYSTEM_PROMPT for edits); ``history``: the conversation so 

372 far, as chat messages (``{"role": "user" | "assistant", "content": 

373 ...}``); ``num_ctx``: the context window (by default, from the 

374 prompt's and answer's lengths); ``num_predict``: the answer's length 

375 (0: no limit); ``think``: let a thinking model reason first (it gets 

376 REASONING_TOKENS more room for that).""" 

377 super().__init__() 

378 self.host, self.model, self.prompt, self.pull_only = host, model, prompt, pull_only 

379 self.system = system 

380 self.history = list(history or []) 

381 self.num_ctx, self.temperature, self.num_predict, self.think = num_ctx, temperature, num_predict, think 

382 self._cancel = threading.Event() 

383 self._client = None 

384 

385 @property 

386 def limit(self) -> int: 

387 """The tokens the model may write (reasoning included); 0: no limit.""" 

388 if self.num_predict <= 0: 

389 return 0 

390 return self.num_predict + (REASONING_TOKENS if self.think else 0) 

391 

392 def cancel(self): 

393 """Stop (from the GUI thread). Closing the connection also stops a 

394 request that is still waiting for its first answer: the model can 

395 take a long time to load and read the prompt, and only then would 

396 the stopped flag be looked at. Ollama stops generating when the 

397 connection goes away.""" 

398 self._cancel.set() 

399 client = self._client 

400 if client is not None: 400 ↛ exitline 400 didn't return from function 'cancel' because the condition on line 400 was always true

401 with contextlib.suppress(Exception): # (already closed: fine) 

402 client.close() # (its connection) 

403 

404 def _stopped_message(self) -> str: 

405 return "Download cancelled" if self.pull_only else "Stopped" 

406 

407 @Slot() 

408 def run(self): 

409 try: 

410 self._client = make_client(self.host) 

411 if self.pull_only: 

412 self._pull(self._client) 

413 else: 

414 self._review(self._client) 

415 except Exception as exc: # noqa: BLE001 - shown to the user 

416 if self._cancel.is_set(): # the connection was closed to stop it 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true

417 self.failed.emit(self._stopped_message()) 

418 else: 

419 self.failed.emit(str(exc) or type(exc).__name__) 

420 

421 def _pull(self, client): 

422 for part in client.pull(self.model, stream=True): 

423 if self._cancel.is_set(): 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 self.failed.emit(self._stopped_message()) 

425 return 

426 status = getattr(part, "status", "") or "" 

427 total = getattr(part, "total", None) or 0 

428 completed = getattr(part, "completed", None) or 0 

429 self.progress.emit(status, completed / total if total else -1.0) 

430 self.finished.emit("", {}) 

431 

432 def _review(self, client): 

433 asked = self.prompt + self.system + "".join(m["content"] for m in self.history) 

434 options = { 

435 "num_ctx": self.num_ctx or context_for(len(asked) // 3, answer=self.limit or REASONING_TOKENS), 

436 "temperature": self.temperature, 

437 # Discourage the repetition loops small models fall into, and 

438 # cap the answer so a runaway one always ends. 

439 "repeat_penalty": 1.1, 

440 "repeat_last_n": 512, 

441 "num_predict": self.limit or -1, # (-1: no limit) 

442 } 

443 started = time.monotonic() 

444 messages = [{"role": "system", "content": self.system}, *self.history, 

445 {"role": "user", "content": self.prompt}] 

446 # Thinking models (qwen3, deepseek-r1…) reason at length before they 

447 # answer, and that counts against num_predict: unless it's asked for 

448 # (with room of its own, see limit), thinking is turned off, which is 

449 # also much faster. A model that can't think, or can't stop, says so: 

450 # it's asked again as it is. 

451 stream = client.chat(model=self.model, stream=True, options=options, messages=messages, think=self.think) 

452 parts_iter = iter(stream) 

453 try: 

454 first = next(parts_iter, None) 

455 except Exception as exc: 

456 if "think" not in str(exc).lower(): 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true

457 raise 

458 stream = client.chat(model=self.model, stream=True, options=options, messages=messages) 

459 parts_iter = iter(stream) 

460 first = next(parts_iter, None) 

461 guard = RepeatGuard() 

462 parts: list[str] = [] 

463 stats: dict = {} 

464 thought = 0 # characters of reasoning (a model that thinks anyway) 

465 try: 

466 for part in _chain(first, parts_iter): 

467 if self._cancel.is_set(): 

468 self.failed.emit(self._stopped_message()) 

469 return 

470 message = getattr(part, "message", None) 

471 text = getattr(message, "content", "") if message is not None else "" 

472 thinking = (getattr(message, "thinking", "") or "") if message is not None else "" 

473 if thinking: 

474 thought += len(thinking) 

475 self.reasoning.emit(thinking) 

476 if text: 

477 shown, stop = guard.feed(text) 

478 if shown: 

479 parts.append(shown) 

480 self.chunk.emit(shown) 

481 if stop: 

482 break # closing the stream makes Ollama stop generating 

483 if getattr(part, "done", False): 

484 stats = {"tokens": getattr(part, "eval_count", 0) or 0, 

485 "prompt_tokens": getattr(part, "prompt_eval_count", 0) or 0, 

486 "seconds": (getattr(part, "total_duration", 0) or 0) / 1e9} 

487 if self.limit and (stats["tokens"] or 0) >= self.limit: 

488 stats["truncated"] = True 

489 if thought and thought > 3 * sum(map(len, parts)): 489 ↛ 466line 489 didn't jump to line 466 because the condition on line 489 was always true

490 stats["thinking"] = True # (the limit went on reasoning, not the answer) 

491 finally: 

492 close = getattr(stream, "close", None) 

493 if close is not None: 493 ↛ 496line 493 didn't jump to line 496 because the condition on line 493 was always true

494 with contextlib.suppress(Exception): # the connection may be gone (stopped) 

495 close() 

496 rest = guard.flush() 

497 if rest: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 parts.append(rest) 

499 self.chunk.emit(rest) 

500 if guard.repeated: 

501 stats = {"seconds": time.monotonic() - started, "repeated": True} 

502 self.chunk.emit("\n[The model started repeating itself, so the review was stopped here.]\n") 

503 self.finished.emit("".join(parts), stats) 

504 

505 

506def _chain(first, rest): 

507 """``first`` (if any), then the rest of an iterator.""" 

508 if first is not None: 508 ↛ 510line 508 didn't jump to line 510 because the condition on line 508 was always true

509 yield first 

510 yield from rest 

511 

512 

513def start_worker(worker: ReviewWorker, parent: QObject) -> QThread: 

514 """Run ``worker`` on a new thread that cleans itself up when done.""" 

515 thread = QThread(parent) 

516 worker.moveToThread(thread) 

517 thread.started.connect(worker.run) 

518 worker.finished.connect(thread.quit) 

519 worker.failed.connect(thread.quit) 

520 thread.finished.connect(worker.deleteLater) 

521 thread.finished.connect(thread.deleteLater) 

522 thread.start() 

523 return thread