oct
Loading...
Searching...
No Matches
sandbox.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mutation/sandbox.py
4
5"""
6Purpose
7-------
8Apply one mutant to a real file and guarantee the pristine bytes come back.
9
10Responsibilities
11----------------
12- Refuse a target that git cannot restore before anything is written.
13- Record the pristine bytes, write the mutant, and restore unconditionally.
14- Preserve the target's declared source encoding when writing a mutant.
15
16Diagnostics
17-----------
18Domain: OCT-MUTATION
19Levels:
20 L2 - lifecycle
21 L3 - apply and restore details
22 L4 - deep tracing
23
24Contracts
25---------
26- Restoration runs in a ``finally``, so it also executes on ``KeyboardInterrupt``
27 and on any exception raised inside the managed block.
28- Restoration writes the recorded bytes back unconditionally, so it repairs a
29 target that the managed block replaced rather than merely mutated.
30- A refusal is raised before the first write, never after a partial one.
31- This module makes no claim about processes it did not start: a child that
32 outlives the managed block can still observe or rewrite the target, which is
33 a named residual rather than a closed gap.
34
35Dependencies
36------------
37- oct.core.git (repository discovery and working-tree state, read-only)
38- oct.mutation.engine (mutant application)
39- tokenize (declared source encodings)
40"""
41
42# OCT-LINT: disable-file=dbg_usage reason="Restoration must not depend on diagnostics being initialised; a _dbg failure inside the finally would defeat the guarantee"
43# OCT-LINT: disable-file=dbg_import reason="Restoration path is kept import-minimal by design"
44
45from __future__ import annotations
46
47import io
48import tokenize
49from contextlib import contextmanager
50from pathlib import Path
51from typing import Iterator
52
53from oct.core import git as core_git
54from oct.mutation.engine import apply_mutant
55
56
57class MutationRefusal(Exception):
58 """A named refusal: the run stopped before any file was written."""
59
60
61def ensure_recoverable_target(path: str | Path) -> Path:
62 """Return the repository root for a target git can restore, or refuse.
63
64 The refusal is the point of the function. Mutation rewrites a real file in
65 place, so the only honest precondition is that a committed copy exists to
66 fall back on if the process dies between the write and the restore. An
67 untracked or already-modified target has no such copy.
68 """
69 resolved = Path(path).resolve()
70 if not resolved.is_file():
71 raise MutationRefusal(f"target is not a file: {resolved}")
72 if not core_git.is_git_repo(resolved.parent):
73 raise MutationRefusal(
74 f"target is not inside a git repository: {resolved}. Mutation "
75 "rewrites the file in place and will not touch a target that "
76 "cannot be restored from git if this process dies."
77 )
78 try:
79 repo_root = core_git.get_repo_root(resolved.parent)
80 except core_git.GitError as exc:
81 raise MutationRefusal(f"could not resolve the repository for {resolved}: {exc}") from exc
82 relative = _repo_relative(resolved, repo_root)
83 _refuse_untracked(relative, repo_root)
84 _refuse_dirty(relative, repo_root)
85 return repo_root
86
87
88def _repo_relative(resolved: Path, repo_root: Path) -> str:
89 """Return the target's repo-relative posix path for git plumbing calls."""
90 try:
91 return resolved.relative_to(repo_root).as_posix()
92 except ValueError as exc:
93 raise MutationRefusal(
94 f"target {resolved} is not inside its own repository root {repo_root}"
95 ) from exc
96
97
98def _refuse_untracked(relative: str, repo_root: Path) -> None:
99 """Refuse a target git does not track."""
100 try:
101 proc = core_git.git_run(
102 ["ls-files", "--error-unmatch", "--", relative],
103 cwd=repo_root,
104 check=False,
105 )
106 except core_git.GitError as exc:
107 raise MutationRefusal(f"could not query git for {relative}: {exc}") from exc
108 if proc.returncode != 0:
109 raise MutationRefusal(
110 f"target is not tracked by git: {relative}. An untracked file has "
111 "no committed copy to restore from, so it is never mutated."
112 )
113
114
115def _refuse_dirty(relative: str, repo_root: Path) -> None:
116 """Refuse a target that carries uncommitted working-tree or index changes."""
117 try:
118 proc = core_git.git_run(
119 ["status", "--porcelain", "--", relative],
120 cwd=repo_root,
121 check=False,
122 )
123 except core_git.GitError as exc:
124 raise MutationRefusal(f"could not query git for {relative}: {exc}") from exc
125 if proc.returncode != 0:
126 raise MutationRefusal(
127 f"git status failed for {relative}: {proc.stderr.strip()}"
128 )
129 if proc.stdout.strip():
130 raise MutationRefusal(
131 f"target has uncommitted changes: {relative}. Commit or stash them "
132 "first; mutation must be able to restore the file from git."
133 )
134
135
136def read_target(path: str | Path) -> tuple[bytes, str, str]:
137 """Return a target's pristine bytes, decoded source, and declared encoding."""
138 resolved = Path(path)
139 pristine = resolved.read_bytes()
140 encoding, _ = tokenize.detect_encoding(io.BytesIO(pristine).readline)
141 return pristine, pristine.decode(encoding), encoding
142
143
144@contextmanager
146 path: str | Path,
147 mutant_id: str,
148 relpath: str,
149) -> Iterator[str]:
150 """Write one mutant to ``path``, yield its text, and always restore.
151
152 The pristine bytes are captured before anything else happens and written
153 back in a ``finally``, so restoration survives an exception in the body, a
154 ``KeyboardInterrupt``, and a body that replaced the file outright. The
155 restore is a plain unconditional write rather than a conditional repair:
156 comparing first would leave a window where a crash between the comparison
157 and the write loses the original.
158 """
159 resolved = Path(path)
160 pristine, source, encoding = read_target(resolved)
161 try:
162 mutated_source = apply_mutant(source, mutant_id, relpath)
163 resolved.write_bytes(mutated_source.encode(encoding))
164 yield mutated_source
165 finally:
166 resolved.write_bytes(pristine)
None _refuse_untracked(str relative, Path repo_root)
Definition sandbox.py:98
Iterator[str] mutated_target(str|Path path, str mutant_id, str relpath)
Definition sandbox.py:149
Path ensure_recoverable_target(str|Path path)
Definition sandbox.py:61
None _refuse_dirty(str relative, Path repo_root)
Definition sandbox.py:115
str _repo_relative(Path resolved, Path repo_root)
Definition sandbox.py:88
tuple[bytes, str, str] read_target(str|Path path)
Definition sandbox.py:136