ai-push-hooks 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +91 -80
  3. package/ai-push-hooks.toml +1 -1
  4. package/docs/configuration.md +263 -0
  5. package/package.json +3 -3
  6. package/pyproject.toml +12 -3
  7. package/src/ai_push_hooks/artifacts.py +19 -6
  8. package/src/ai_push_hooks/cli.py +24 -8
  9. package/src/ai_push_hooks/config.py +220 -49
  10. package/src/ai_push_hooks/engine.py +93 -43
  11. package/src/ai_push_hooks/executors/apply.py +121 -43
  12. package/src/ai_push_hooks/executors/ask.py +30 -13
  13. package/src/ai_push_hooks/executors/exec.py +61 -22
  14. package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
  15. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  16. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  17. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  18. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  19. package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
  20. package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
  21. package/src/ai_push_hooks/executors/runners/process.py +26 -7
  22. package/src/ai_push_hooks/executors/runners/registry.py +14 -5
  23. package/src/ai_push_hooks/executors/step_commands.py +58 -18
  24. package/src/ai_push_hooks/git_utils.py +92 -27
  25. package/src/ai_push_hooks/hook.py +48 -12
  26. package/src/ai_push_hooks/install.py +40 -18
  27. package/src/ai_push_hooks/modules/beads.py +18 -7
  28. package/src/ai_push_hooks/modules/docs.py +17 -7
  29. package/src/ai_push_hooks/modules/pr.py +18 -7
  30. package/src/ai_push_hooks/paths.py +6 -2
  31. package/src/ai_push_hooks/plugin_loader.py +79 -26
  32. package/src/ai_push_hooks/plugins.py +3 -1
  33. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  34. package/src/ai_push_hooks/types.py +47 -27
  35. package/run.sh +0 -29
@@ -88,7 +88,9 @@ def _validate_text(
88
88
  if allow_controls:
89
89
  return value
90
90
  allowed_controls = "\r\n\t" if allow_line_breaks else ""
91
- if "\x00" in value or any(ord(character) < 32 and character not in allowed_controls for character in value):
91
+ if "\x00" in value or any(
92
+ ord(character) < 32 and character not in allowed_controls for character in value
93
+ ):
92
94
  raise RunnerContractError(f"{label} contains control characters")
93
95
  return value
94
96
 
@@ -156,7 +158,9 @@ class PromptPacket:
156
158
  if not isinstance(self.artifacts, (tuple, list)):
157
159
  raise RunnerContractError("prompt packet artifacts must be ordered")
158
160
  if not all(isinstance(item, RunnerArtifact) for item in self.artifacts):
159
- raise RunnerContractError("prompt packet artifacts must contain RunnerArtifact values")
161
+ raise RunnerContractError(
162
+ "prompt packet artifacts must contain RunnerArtifact values"
163
+ )
160
164
  object.__setattr__(self, "artifacts", tuple(self.artifacts))
161
165
 
162
166
  def render(self) -> str:
@@ -208,13 +212,17 @@ class RunnerRequest:
208
212
  _validate_text(self.purpose, "purpose")
209
213
  if self.mode not in {"ask", "apply"}:
210
214
  raise RunnerContractError("mode must be 'ask' or 'apply'")
211
- _validate_text(self.instruction, "instruction", allow_empty=True, allow_line_breaks=True)
215
+ _validate_text(
216
+ self.instruction, "instruction", allow_empty=True, allow_line_breaks=True
217
+ )
212
218
  if not isinstance(self.artifacts, (tuple, list)):
213
219
  raise RunnerContractError("artifacts must be an ordered sequence")
214
220
  normalized_artifacts: list[RunnerArtifact] = []
215
221
  for artifact in self.artifacts:
216
222
  if not isinstance(artifact, RunnerArtifact):
217
- raise RunnerContractError("artifacts must contain RunnerArtifact values")
223
+ raise RunnerContractError(
224
+ "artifacts must contain RunnerArtifact values"
225
+ )
218
226
  normalized_artifacts.append(artifact)
219
227
  object.__setattr__(self, "artifacts", tuple(normalized_artifacts))
220
228
  if not isinstance(self.cwd, pathlib.Path):
@@ -310,7 +318,9 @@ class RunnerResult:
310
318
  transcript: str | None = field(default=None, repr=False)
311
319
 
312
320
  def __post_init__(self) -> None:
313
- _validate_text(self.final_text, "final_text", allow_empty=True, allow_controls=True)
321
+ _validate_text(
322
+ self.final_text, "final_text", allow_empty=True, allow_controls=True
323
+ )
314
324
  if isinstance(self.returncode, bool) or not isinstance(self.returncode, int):
315
325
  raise RunnerContractError("returncode must be an integer")
316
326
  _validate_text(self.stdout, "stdout", allow_empty=True, allow_controls=True)
@@ -318,7 +328,9 @@ class RunnerResult:
318
328
  if self.session is not None and not isinstance(self.session, SessionMetadata):
319
329
  raise RunnerContractError("session must be SessionMetadata or None")
320
330
  if self.transcript is not None:
321
- _validate_text(self.transcript, "transcript", allow_empty=True, allow_line_breaks=True)
331
+ _validate_text(
332
+ self.transcript, "transcript", allow_empty=True, allow_line_breaks=True
333
+ )
322
334
 
323
335
  def __repr__(self) -> str:
324
336
  return (
@@ -342,18 +354,20 @@ class Runner(Protocol):
342
354
 
343
355
  capabilities: RunnerCapabilities
344
356
 
345
- def run(self, request: RunnerRequest) -> RunnerResult:
346
- ...
357
+ def run(self, request: RunnerRequest) -> RunnerResult: ...
347
358
 
348
359
 
349
360
  class RunnerLifecycle(Protocol):
350
361
  """Optional lifecycle API, implemented only by adapters that need it."""
351
362
 
352
- def finalize(self, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
353
- ...
363
+ def finalize(
364
+ self, request: RunnerRequest, result: RunnerResult
365
+ ) -> RunnerResult: ...
354
366
 
355
367
 
356
- def finalize_runner(runner: Runner, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
368
+ def finalize_runner(
369
+ runner: Runner, request: RunnerRequest, result: RunnerResult
370
+ ) -> RunnerResult:
357
371
  """Finalize a result when the adapter explicitly advertises that capability."""
358
372
 
359
373
  capabilities = getattr(runner, "capabilities", RunnerCapabilities())
@@ -396,9 +410,7 @@ def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
396
410
  for pattern in _SECRET_PATTERNS:
397
411
  redacted = pattern.sub(
398
412
  lambda match: (
399
- f"{match.group(1)}[REDACTED]"
400
- if match.lastindex
401
- else "[REDACTED]"
413
+ f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
402
414
  ),
403
415
  redacted,
404
416
  )
@@ -450,7 +462,10 @@ def bounded_diagnostic(
450
462
  return safe
451
463
  if max_chars <= len(DIAGNOSTIC_TRUNCATION_MARKER):
452
464
  return DIAGNOSTIC_TRUNCATION_MARKER[:max_chars]
453
- return safe[: max_chars - len(DIAGNOSTIC_TRUNCATION_MARKER)] + DIAGNOSTIC_TRUNCATION_MARKER
465
+ return (
466
+ safe[: max_chars - len(DIAGNOSTIC_TRUNCATION_MARKER)]
467
+ + DIAGNOSTIC_TRUNCATION_MARKER
468
+ )
454
469
 
455
470
 
456
471
  def bounded_redacted_diagnostics(
@@ -465,7 +480,9 @@ def bounded_redacted_diagnostics(
465
480
  parts: list[str] = []
466
481
  for label, value in (("stdout", stdout), ("stderr", stderr)):
467
482
  if value:
468
- parts.append(f"{label}: {bounded_diagnostic(value, max_chars=max_chars, secrets=secrets)}")
483
+ parts.append(
484
+ f"{label}: {bounded_diagnostic(value, max_chars=max_chars, secrets=secrets)}"
485
+ )
469
486
  combined = "\n".join(parts)
470
487
  return bounded_diagnostic(combined, max_chars=max_chars, secrets=secrets)
471
488
 
@@ -473,7 +490,14 @@ def bounded_redacted_diagnostics(
473
490
  def _credential_environment_values(env: Mapping[str, str] | None) -> tuple[str, ...]:
474
491
  if env is None:
475
492
  return ()
476
- credential_markers = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
493
+ credential_markers = (
494
+ "API_KEY",
495
+ "TOKEN",
496
+ "SECRET",
497
+ "PASSWORD",
498
+ "AUTH",
499
+ "CREDENTIAL",
500
+ )
477
501
  return tuple(
478
502
  value
479
503
  for name, value in env.items()
@@ -582,14 +606,17 @@ def require_zero_exit(
582
606
  """Raise a bounded non-zero diagnostic while preserving normalized results."""
583
607
 
584
608
  if result.returncode != 0:
585
- details = request_sensitive_diagnostics(
586
- request,
587
- result.stdout,
588
- result.stderr,
589
- max_chars=diagnostic_limit,
590
- env=env,
591
- extra=secrets,
592
- ) or f"exit code {result.returncode}"
609
+ details = (
610
+ request_sensitive_diagnostics(
611
+ request,
612
+ result.stdout,
613
+ result.stderr,
614
+ max_chars=diagnostic_limit,
615
+ env=env,
616
+ extra=secrets,
617
+ )
618
+ or f"exit code {result.returncode}"
619
+ )
593
620
  raise RunnerNonzeroExitError(
594
621
  f"runner {request.profile_id!r} ({request.runner_type}) failed at {request.stage!r}",
595
622
  details=details,
@@ -25,7 +25,11 @@ from .opencode_support import (
25
25
  sanitize_filename_component,
26
26
  validate_hook_owned_artifacts,
27
27
  )
28
- from ...paths import ensure_private_directory, resolve_contained_path, write_text_no_follow
28
+ from ...paths import (
29
+ ensure_private_directory,
30
+ resolve_contained_path,
31
+ write_text_no_follow,
32
+ )
29
33
  from .process import ProcessResult
30
34
  from .contracts import (
31
35
  RunnerCapabilities,
@@ -65,7 +69,9 @@ class OpenCodeRunner:
65
69
  session_id: str | None = None
66
70
  text_parts: list[str] = []
67
71
 
68
- def protocol_failure(message: str, *, missing: bool = False) -> RunnerProtocolError:
72
+ def protocol_failure(
73
+ message: str, *, missing: bool = False
74
+ ) -> RunnerProtocolError:
69
75
  error: RunnerProtocolError
70
76
  if missing:
71
77
  error = RunnerMissingOutputError(message)
@@ -86,7 +92,9 @@ class OpenCodeRunner:
86
92
  try:
87
93
  event = json.loads(line)
88
94
  except json.JSONDecodeError as exc:
89
- raise protocol_failure("OpenCode emitted malformed JSONL output") from exc
95
+ raise protocol_failure(
96
+ "OpenCode emitted malformed JSONL output"
97
+ ) from exc
90
98
  if not isinstance(event, dict):
91
99
  raise protocol_failure("OpenCode emitted a non-object JSONL event")
92
100
 
@@ -220,7 +228,11 @@ class OpenCodeRunner:
220
228
  *,
221
229
  prompt: str,
222
230
  ) -> list[str]:
223
- agent = OPENCODE_APPLY_AGENT if request.mode == "apply" else OPENCODE_READ_ONLY_AGENT
231
+ agent = (
232
+ OPENCODE_APPLY_AGENT
233
+ if request.mode == "apply"
234
+ else OPENCODE_READ_ONLY_AGENT
235
+ )
224
236
  config = getattr(getattr(context, "config", None), "llm", None)
225
237
  title_prefix = getattr(config, "session_title_prefix", "ai-push-hooks")
226
238
  argv = [
@@ -251,7 +263,9 @@ class OpenCodeRunner:
251
263
  return argv
252
264
 
253
265
  @staticmethod
254
- def _require_staging_directory(context: Any, working_directory: pathlib.Path) -> None:
266
+ def _require_staging_directory(
267
+ context: Any, working_directory: pathlib.Path
268
+ ) -> None:
255
269
  """Reject repository worktrees as apply targets.
256
270
 
257
271
  The apply workflow supplies a disposable, non-VCS staging projection.
@@ -262,11 +276,16 @@ class OpenCodeRunner:
262
276
  """
263
277
 
264
278
  repository_root = context.repo_root.resolve(strict=True)
265
- if working_directory == repository_root or working_directory.is_relative_to(repository_root):
279
+ if working_directory == repository_root or working_directory.is_relative_to(
280
+ repository_root
281
+ ):
266
282
  raise RunnerContractError(
267
283
  "OpenCode apply requests require an isolated staging directory"
268
284
  )
269
- if any((parent / ".git").exists() for parent in (working_directory, *working_directory.parents)):
285
+ if any(
286
+ (parent / ".git").exists()
287
+ for parent in (working_directory, *working_directory.parents)
288
+ ):
270
289
  raise RunnerContractError(
271
290
  "OpenCode apply requests require a non-VCS isolated staging directory"
272
291
  )
@@ -296,7 +315,9 @@ class OpenCodeRunner:
296
315
  "OpenCode request cwd must be an existing directory"
297
316
  ) from exc
298
317
  if not working_directory.is_dir():
299
- raise RunnerContractError("OpenCode request cwd must be an existing directory")
318
+ raise RunnerContractError(
319
+ "OpenCode request cwd must be an existing directory"
320
+ )
300
321
  if request.mode == "apply":
301
322
  self._require_staging_directory(context, working_directory)
302
323
 
@@ -311,12 +332,17 @@ class OpenCodeRunner:
311
332
  working_directory if request.project_access == "project" else None
312
333
  ),
313
334
  )
314
- executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
335
+ executable = (
336
+ getattr(context, "opencode_executable", None)
337
+ or resolve_opencode_executable()
338
+ )
315
339
  isolated_env = opencode_isolation_env(context, security_config, request.stage)
316
340
  attachment_dir: pathlib.Path | None = None
317
341
  session_id: str | None = None
318
342
  try:
319
- attachments, attachment_dir = self._materialize_attachments(context, request)
343
+ attachments, attachment_dir = self._materialize_attachments(
344
+ context, request
345
+ )
320
346
  # Every logical artifact is materialized, including pathless
321
347
  # contract artifacts. Therefore native attachments carry the
322
348
  # complete snapshot and the instruction is never duplicated in a
@@ -351,14 +377,18 @@ class OpenCodeRunner:
351
377
 
352
378
  try:
353
379
  if working_directory is None:
354
- with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as directory:
380
+ with tempfile.TemporaryDirectory(
381
+ prefix="ai-push-hooks-readonly-"
382
+ ) as directory:
355
383
  completed = invoke(pathlib.Path(directory).resolve(strict=True))
356
384
  else:
357
385
  completed = invoke(working_directory)
358
386
  except (RunnerError,) as error:
359
387
  process_result = getattr(error, "_process_result", None)
360
388
  partial_stdout = (
361
- process_result.stdout if isinstance(process_result, ProcessResult) else ""
389
+ process_result.stdout
390
+ if isinstance(process_result, ProcessResult)
391
+ else ""
362
392
  )
363
393
  setattr(
364
394
  error,
@@ -397,7 +427,9 @@ class OpenCodeRunner:
397
427
  stdout=completed.stdout,
398
428
  stderr=completed.stderr,
399
429
  session=(
400
- SessionMetadata(session_id=session_id, state="persisted", resumable=True)
430
+ SessionMetadata(
431
+ session_id=session_id, state="persisted", resumable=True
432
+ )
401
433
  if session_id
402
434
  else SessionMetadata()
403
435
  ),
@@ -427,7 +459,9 @@ class OpenCodeRunner:
427
459
  non_agent_opencode_config(),
428
460
  request.stage,
429
461
  )
430
- with tempfile.TemporaryDirectory(prefix=f"ai-push-hooks-session-{action}-") as directory:
462
+ with tempfile.TemporaryDirectory(
463
+ prefix=f"ai-push-hooks-session-{action}-"
464
+ ) as directory:
431
465
  return run_process(
432
466
  argv,
433
467
  cwd=pathlib.Path(directory).resolve(strict=True),
@@ -456,7 +490,10 @@ class OpenCodeRunner:
456
490
  "OpenCode transcript path",
457
491
  )
458
492
  try:
459
- executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
493
+ executable = (
494
+ getattr(context, "opencode_executable", None)
495
+ or resolve_opencode_executable()
496
+ )
460
497
  exported = self._lifecycle_process(
461
498
  request,
462
499
  context,
@@ -469,10 +506,17 @@ class OpenCodeRunner:
469
506
  and not getattr(exported, "stderr_truncated", False)
470
507
  and exported.stdout.strip()
471
508
  ):
472
- write_text_no_follow(transcript_path, exported.stdout.strip() + "\n")
509
+ write_text_no_follow(
510
+ transcript_path, exported.stdout.strip() + "\n"
511
+ )
473
512
  else:
474
513
  transcript_path = None
475
- self._warn_export(context, request, session_id, "export returned no transcript")
514
+ self._warn_export(
515
+ context,
516
+ request,
517
+ session_id,
518
+ "export returned no transcript",
519
+ )
476
520
  except Exception as exc: # noqa: BLE001
477
521
  transcript_path = None
478
522
  self._warn_export(context, request, session_id, type(exc).__name__)
@@ -480,7 +524,10 @@ class OpenCodeRunner:
480
524
  deleted = False
481
525
  if getattr(context.config.llm, "delete_session_after_run", False):
482
526
  try:
483
- executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
527
+ executable = (
528
+ getattr(context, "opencode_executable", None)
529
+ or resolve_opencode_executable()
530
+ )
484
531
  deleted_result = self._lifecycle_process(
485
532
  request,
486
533
  context,
@@ -489,9 +536,13 @@ class OpenCodeRunner:
489
536
  )
490
537
  deleted = deleted_result.returncode == 0
491
538
  if not deleted:
492
- self._warn_export(context, request, session_id, "session deletion failed")
539
+ self._warn_export(
540
+ context, request, session_id, "session deletion failed"
541
+ )
493
542
  except Exception as exc: # noqa: BLE001
494
- self._warn_export(context, request, session_id, f"delete {type(exc).__name__}")
543
+ self._warn_export(
544
+ context, request, session_id, f"delete {type(exc).__name__}"
545
+ )
495
546
 
496
547
  state = "deleted" if deleted else "persisted"
497
548
  finalized_session = SessionMetadata(
@@ -510,7 +561,9 @@ class OpenCodeRunner:
510
561
  )
511
562
 
512
563
  @staticmethod
513
- def _warn_export(context: Any, request: RunnerRequest, session_id: str, reason: str) -> None:
564
+ def _warn_export(
565
+ context: Any, request: RunnerRequest, session_id: str, reason: str
566
+ ) -> None:
514
567
  logger = getattr(context, "logger", None)
515
568
  if logger is not None:
516
569
  logger.warn(
@@ -71,7 +71,9 @@ def opencode_isolation_env(
71
71
  context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
72
72
  )
73
73
  if path_has_symlink(context.run_dir, lexical_isolation_root):
74
- raise HookError(f"OpenCode isolation directory must not traverse a symlink: {stage_name}")
74
+ raise HookError(
75
+ f"OpenCode isolation directory must not traverse a symlink: {stage_name}"
76
+ )
75
77
  isolation_root = resolve_contained_path(
76
78
  context.run_dir,
77
79
  f"opencode-isolation/{sanitize_filename_component(stage_name)}",
@@ -184,7 +186,9 @@ def build_opencode_security_config(
184
186
  description = "Read-only ai-push-hooks analysis agent"
185
187
  elif agent_policy == "apply":
186
188
  if not allow_paths:
187
- raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
189
+ raise HookError(
190
+ "OpenCode apply agent requires an explicit non-empty allow_paths"
191
+ )
188
192
  agent_name = OPENCODE_APPLY_AGENT
189
193
  description = "Path-restricted ai-push-hooks apply agent"
190
194
  permissions["read"] = "allow"
@@ -266,11 +270,17 @@ def validate_hook_owned_artifacts(
266
270
  for file_path in files:
267
271
  lexical_path = pathlib.Path(os.path.abspath(file_path))
268
272
  if not is_path_within(lexical_path, run_root):
269
- raise HookError(f"OpenCode attachment is not a hook-owned artifact: {file_path}")
273
+ raise HookError(
274
+ f"OpenCode attachment is not a hook-owned artifact: {file_path}"
275
+ )
270
276
  if path_has_symlink(run_root, lexical_path):
271
- raise HookError(f"OpenCode attachment must not traverse a symlink: {file_path}")
277
+ raise HookError(
278
+ f"OpenCode attachment must not traverse a symlink: {file_path}"
279
+ )
272
280
  resolved_path = lexical_path.resolve(strict=True)
273
281
  if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
274
- raise HookError(f"OpenCode attachment must be a regular hook-owned file: {file_path}")
282
+ raise HookError(
283
+ f"OpenCode attachment must be a regular hook-owned file: {file_path}"
284
+ )
275
285
  validated.append(resolved_path)
276
286
  return validated
@@ -226,8 +226,13 @@ def run_process(
226
226
  timeout and signal termination are raised as distinct fail-closed errors.
227
227
  """
228
228
 
229
- if not isinstance(argv, (tuple, list)) or not argv or any(
230
- not isinstance(argument, str) or not argument or "\x00" in argument for argument in argv
229
+ if (
230
+ not isinstance(argv, (tuple, list))
231
+ or not argv
232
+ or any(
233
+ not isinstance(argument, str) or not argument or "\x00" in argument
234
+ for argument in argv
235
+ )
231
236
  ):
232
237
  raise RunnerError("runner command must be a non-empty NUL-free argv vector")
233
238
  if not isinstance(cwd, pathlib.Path):
@@ -261,7 +266,9 @@ def run_process(
261
266
  if not isinstance(input_path, pathlib.Path):
262
267
  input_path = pathlib.Path(input_path)
263
268
  if path_is_link_or_reparse(input_path):
264
- raise RunnerError("runner input file must not be a symlink or reparse point")
269
+ raise RunnerError(
270
+ "runner input file must not be a symlink or reparse point"
271
+ )
265
272
  descriptor = -1
266
273
  try:
267
274
  # O_NONBLOCK prevents opening a FIFO from waiting for a writer;
@@ -312,7 +319,9 @@ def run_process(
312
319
  except OSError as exc:
313
320
  if input_file is not None:
314
321
  input_file.close()
315
- raise RunnerError("runner process could not be started", details=type(exc).__name__) from exc
322
+ raise RunnerError(
323
+ "runner process could not be started", details=type(exc).__name__
324
+ ) from exc
316
325
 
317
326
  if process.stdout is None or process.stderr is None or process.stdin is None:
318
327
  _stop_process(process, time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS)
@@ -354,7 +363,11 @@ def run_process(
354
363
  stdout_thread.start()
355
364
  stderr_thread.start()
356
365
 
357
- input_bytes = None if input_text is None else input_text.encode("utf-8", errors="surrogateescape")
366
+ input_bytes = (
367
+ None
368
+ if input_text is None
369
+ else input_text.encode("utf-8", errors="surrogateescape")
370
+ )
358
371
 
359
372
  def write_input() -> None:
360
373
  try:
@@ -427,9 +440,15 @@ def run_process(
427
440
  )
428
441
 
429
442
  if returncode is None:
430
- returncode = process_returncode[0] if process_returncode[0] is not None else process.poll()
443
+ returncode = (
444
+ process_returncode[0]
445
+ if process_returncode[0] is not None
446
+ else process.poll()
447
+ )
431
448
  if timeout_cause is not None:
432
- timeout_returncode = returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
449
+ timeout_returncode = (
450
+ returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
451
+ )
433
452
  process_result = _captured_result(
434
453
  timeout_returncode,
435
454
  stdout,
@@ -53,7 +53,9 @@ class LazyRunnerSpec:
53
53
  details=type(exc).__name__,
54
54
  ) from exc
55
55
  if not callable(getattr(runner, "run", None)):
56
- raise RunnerAdapterUnavailableError("selected runner does not implement run(request)")
56
+ raise RunnerAdapterUnavailableError(
57
+ "selected runner does not implement run(request)"
58
+ )
57
59
  return runner
58
60
 
59
61
 
@@ -68,15 +70,20 @@ _DEFAULT_SPECS = {
68
70
  class RunnerRegistry:
69
71
  """A fixed registry; it intentionally has no runtime plugin registration API."""
70
72
 
71
- def __init__(self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None) -> None:
73
+ def __init__(
74
+ self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None
75
+ ) -> None:
72
76
  selected = _DEFAULT_SPECS if specs is None else dict(specs)
73
77
  unknown = set(selected) - set(KNOWN_RUNNER_TYPES)
74
78
  if unknown:
75
79
  raise RunnerContractError(
76
- "runner registry contains unsupported adapter types: " + ", ".join(sorted(unknown))
80
+ "runner registry contains unsupported adapter types: "
81
+ + ", ".join(sorted(unknown))
77
82
  )
78
83
  if set(selected) != set(KNOWN_RUNNER_TYPES):
79
- raise RunnerContractError("runner registry must contain all four known adapter types")
84
+ raise RunnerContractError(
85
+ "runner registry must contain all four known adapter types"
86
+ )
80
87
  self._specs = dict(selected)
81
88
  self._loaded: dict[str, Runner] = {}
82
89
  self._load_locks = {
@@ -111,7 +118,9 @@ class RunnerRegistry:
111
118
  DEFAULT_RUNNER_REGISTRY = RunnerRegistry()
112
119
 
113
120
 
114
- def get_runner(runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY) -> Runner:
121
+ def get_runner(
122
+ runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY
123
+ ) -> Runner:
115
124
  """Resolve one of the four static adapter types lazily."""
116
125
 
117
126
  return registry.get(runner_type)