py-harness-cli 0.3.0__py3-none-any.whl
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.
- finetune/__init__.py +1 -0
- finetune/agent_system.py +41 -0
- finetune/agent_traces.py +157 -0
- finetune/everyday.py +30 -0
- finetune/hf_ollama.py +158 -0
- finetune/huggingface_store.py +144 -0
- finetune/models.py +74 -0
- finetune/paths.py +11 -0
- finetune/python_vibe.py +788 -0
- finetune/splits.py +54 -0
- finetune/systems.py +9 -0
- harness/__init__.py +42 -0
- harness/__main__.py +8 -0
- harness/act/__init__.py +6 -0
- harness/act/autofix/__init__.py +110 -0
- harness/act/autofix/additions.py +217 -0
- harness/act/autofix/conflicts.py +124 -0
- harness/act/autofix/cover.py +419 -0
- harness/act/autofix/mechanical.py +151 -0
- harness/act/autofix/missing_imports.py +50 -0
- harness/act/autofix/moves.py +439 -0
- harness/act/autofix/names.py +339 -0
- harness/act/autofix/scaffold.py +224 -0
- harness/act/code.py +157 -0
- harness/act/gate.py +229 -0
- harness/act/parse.py +247 -0
- harness/act/patch_fix.py +138 -0
- harness/act/tools.py +244 -0
- harness/agent/__init__.py +11 -0
- harness/agent/dispatch.py +235 -0
- harness/agent/loop.py +699 -0
- harness/agent/options.py +144 -0
- harness/agent/policy.py +856 -0
- harness/agent/prompt.py +170 -0
- harness/cli.py +393 -0
- harness/editor_kit.py +265 -0
- harness/guard/__init__.py +6 -0
- harness/guard/fallbacks.py +6 -0
- harness/guard/loop_guard.py +57 -0
- harness/guard/python_vibe.py +68 -0
- harness/guard/run.py +41 -0
- harness/guard/types.py +19 -0
- harness/locate.py +767 -0
- harness/mcp_stdio.py +306 -0
- harness/memory/__init__.py +5 -0
- harness/memory/conversation.py +104 -0
- harness/model/__init__.py +6 -0
- harness/model/chat_backend.py +100 -0
- harness/model/engine.py +165 -0
- harness/model/ollama_generate.py +60 -0
- harness/model/openai_generate.py +156 -0
- harness/model/outbound.py +83 -0
- harness/model/route.py +90 -0
- harness/observe/__init__.py +6 -0
- harness/observe/eval_gate.py +80 -0
- harness/observe/eval_loop.py +185 -0
- harness/observe/eval_tasks.py +399 -0
- harness/observe/report_md.py +102 -0
- harness/observe/trace_record.py +79 -0
- harness/openai_api.py +81 -0
- harness/paths.py +88 -0
- harness/py.typed +0 -0
- harness/scan/__init__.py +6 -0
- harness/scan/app_spec.py +338 -0
- harness/scan/design.py +112 -0
- harness/scan/existing.py +131 -0
- harness/scan/layout.py +254 -0
- harness/scan/names.py +308 -0
- harness/scan/project_brief.py +287 -0
- harness/scan/project_docs.py +42 -0
- harness/scan/project_scan.py +49 -0
- harness/scan/repo_map.py +101 -0
- harness/secrets.py +39 -0
- harness/server.py +199 -0
- harness/ship/__init__.py +1 -0
- harness/ship/bot_pr.py +221 -0
- harness/ship/git_ship.py +262 -0
- harness/ship/identity.py +62 -0
- harness/ship/ticket.py +251 -0
- harness/skillkit/__init__.py +6 -0
- harness/skillkit/catalog.py +241 -0
- harness/skillkit/refuse_change.py +640 -0
- harness/skillkit/refuse_finish.py +295 -0
- harness/skillkit/target.py +238 -0
- harness/task.py +717 -0
- py_harness_cli-0.3.0.dist-info/METADATA +177 -0
- py_harness_cli-0.3.0.dist-info/RECORD +92 -0
- py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
- py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
- py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
- py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
- py_harness_cli-0.3.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Moving a file, and repairing what pointed at it.
|
|
2
|
+
|
|
3
|
+
Splitting a module, moving a helper to where it belongs and renaming a
|
|
4
|
+
file are ordinary work, and the harness could not do any of it. Asked to
|
|
5
|
+
move something it spent twenty steps and changed nothing.
|
|
6
|
+
|
|
7
|
+
The decision is usually already made by whoever asked — this file goes
|
|
8
|
+
there — so the work is mechanical, and mechanical work is what this
|
|
9
|
+
harness is good at. The part worth doing carefully is the imports: a
|
|
10
|
+
moved module leaves every `from pkg.old import name` pointing at
|
|
11
|
+
nothing, and asking a model to remember them all is how they get missed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import builtins
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from harness.act.code import apply_source, resolve_project_file
|
|
23
|
+
from harness.paths import rel_posix
|
|
24
|
+
|
|
25
|
+
# "move a/b.py to c/d.py", "rename a/b.py to c/d.py"
|
|
26
|
+
_MOVE = re.compile(
|
|
27
|
+
r"\b(?:move|rename)\b[^\w/]*([\w./-]+\.py)\b.{0,20}?\b(?:to|into|as)\b[^\w/]*([\w./-]+\.py)\b",
|
|
28
|
+
re.I,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def move_targets(task: str) -> tuple[str, str] | None:
|
|
33
|
+
"""The two paths a move names, or None when it names fewer than two."""
|
|
34
|
+
found = _MOVE.search(task)
|
|
35
|
+
if not found:
|
|
36
|
+
return None
|
|
37
|
+
source, destination = found.group(1), found.group(2)
|
|
38
|
+
if source == destination:
|
|
39
|
+
return None
|
|
40
|
+
return source, destination
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Folders a project puts its code under, which imports do not name.
|
|
44
|
+
# A file at src/pkg/mod.py is imported as pkg.mod, not src.pkg.mod.
|
|
45
|
+
IMPORT_ROOTS = ("src", "lib")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def module_names(rel: str) -> list[str]:
|
|
49
|
+
"""Every dotted name an import might use for this path.
|
|
50
|
+
|
|
51
|
+
A file under `src/` is imported without the `src`, because that is
|
|
52
|
+
what ends up on the path. Computing only the name relative to the
|
|
53
|
+
project root meant a real move rewrote nothing: the file became
|
|
54
|
+
`src.harness.observe.report_md` while every import said
|
|
55
|
+
`harness.observe.report_md`.
|
|
56
|
+
"""
|
|
57
|
+
parts = list(Path(rel).with_suffix("").parts)
|
|
58
|
+
if parts and parts[-1] == "__init__":
|
|
59
|
+
parts = parts[:-1]
|
|
60
|
+
if not parts:
|
|
61
|
+
return []
|
|
62
|
+
names = [".".join(parts)]
|
|
63
|
+
if parts[0] in IMPORT_ROOTS and len(parts) > 1:
|
|
64
|
+
names.append(".".join(parts[1:]))
|
|
65
|
+
return names
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def module_name(rel: str) -> str:
|
|
69
|
+
"""The name an import is most likely to use for this path."""
|
|
70
|
+
names = module_names(rel)
|
|
71
|
+
return names[-1] if names else ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _rewritten(source: str, old: str, new: str) -> str:
|
|
75
|
+
"""`source` with every import of `old` pointing at `new` instead."""
|
|
76
|
+
if not old or not new:
|
|
77
|
+
return source
|
|
78
|
+
text = re.sub(rf"(?<![\w.]){re.escape(old)}(?![\w])", new, source)
|
|
79
|
+
return text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def importers(project: Path, old: str) -> list[Path]:
|
|
83
|
+
"""Files that name the module being moved."""
|
|
84
|
+
root = Path(project).resolve()
|
|
85
|
+
hits = []
|
|
86
|
+
for path in sorted(root.rglob("*.py")):
|
|
87
|
+
if any(part.startswith(".") or part == "__pycache__" for part in path.parts):
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
body = path.read_text(encoding="utf-8")
|
|
91
|
+
except OSError:
|
|
92
|
+
continue
|
|
93
|
+
if re.search(rf"(?<![\w.]){re.escape(old)}(?![\w])", body):
|
|
94
|
+
hits.append(path)
|
|
95
|
+
return hits
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def apply_file_move(project: Path, task: str, *, write: bool = True) -> str:
|
|
99
|
+
"""Move the file the task names and repair the imports. "" if unsure.
|
|
100
|
+
|
|
101
|
+
Everything is checked before anything is written: both paths stay
|
|
102
|
+
inside the project, the source exists, the destination does not, and
|
|
103
|
+
every file that would be rewritten still parses afterwards. A move
|
|
104
|
+
that would leave the project unparseable is refused whole rather
|
|
105
|
+
than half-applied.
|
|
106
|
+
"""
|
|
107
|
+
targets = move_targets(task)
|
|
108
|
+
if targets is None:
|
|
109
|
+
return ""
|
|
110
|
+
source_rel, destination_rel = targets
|
|
111
|
+
try:
|
|
112
|
+
source = resolve_project_file(project, source_rel)
|
|
113
|
+
destination = resolve_project_file(project, destination_rel)
|
|
114
|
+
except (ValueError, OSError):
|
|
115
|
+
return ""
|
|
116
|
+
if not source.is_file() or destination.exists():
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
root = Path(project).resolve()
|
|
120
|
+
old_names = module_names(rel_posix(source, root))
|
|
121
|
+
new_names = module_names(rel_posix(destination, root))
|
|
122
|
+
if not old_names or len(old_names) != len(new_names):
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
edits: list[tuple[Path, str, str]] = []
|
|
126
|
+
seen: set[Path] = set()
|
|
127
|
+
for old_module, new_module in zip(old_names, new_names, strict=True):
|
|
128
|
+
for path in importers(project, old_module):
|
|
129
|
+
if path == source or path in seen:
|
|
130
|
+
continue
|
|
131
|
+
original = path.read_text(encoding="utf-8")
|
|
132
|
+
changed = _rewritten(original, old_module, new_module)
|
|
133
|
+
if changed == original:
|
|
134
|
+
continue
|
|
135
|
+
seen.add(path)
|
|
136
|
+
try:
|
|
137
|
+
ast.parse(changed)
|
|
138
|
+
except SyntaxError:
|
|
139
|
+
return ""
|
|
140
|
+
edits.append((path, original, changed))
|
|
141
|
+
|
|
142
|
+
body = source.read_text(encoding="utf-8")
|
|
143
|
+
if not write:
|
|
144
|
+
return (
|
|
145
|
+
f"would move {source_rel} to {destination_rel} "
|
|
146
|
+
f"and repair {len(edits)} file(s)"
|
|
147
|
+
)
|
|
148
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
destination.write_text(body, encoding="utf-8")
|
|
150
|
+
source.unlink()
|
|
151
|
+
for path, original, changed in edits:
|
|
152
|
+
apply_source(path, changed, original=original)
|
|
153
|
+
repaired = f", and repaired {len(edits)} import(s)" if edits else ""
|
|
154
|
+
return f"moved {source_rel} to {destination_rel}{repaired}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# "move the function refuse_x out of a/b.py into a/c.py", and the same
|
|
158
|
+
# sentence with `from`/`to`. The function name comes first because that
|
|
159
|
+
# is how people write it; the two paths follow in source-then-
|
|
160
|
+
# destination order.
|
|
161
|
+
_MOVE_FUNCTION = re.compile(
|
|
162
|
+
r"\bmove\b[^\w]*(?:the\s+)?(?:function\s+|def\s+)?"
|
|
163
|
+
r"(?P<name>[A-Za-z_]\w*)\b(?!\.py)"
|
|
164
|
+
r"[^\w/]*(?:\bout\s+of\b|\bfrom\b|\bin\b)[^\w/]*(?P<source>[\w./-]+\.py)\b"
|
|
165
|
+
r".{0,24}?\b(?:into|to)\b[^\w/]*(?P<destination>[\w./-]+\.py)\b",
|
|
166
|
+
re.I,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def function_move_targets(task: str) -> tuple[str, str, str] | None:
|
|
171
|
+
"""(name, source, destination) for a function move, or None."""
|
|
172
|
+
found = _MOVE_FUNCTION.search(task)
|
|
173
|
+
if not found:
|
|
174
|
+
return None
|
|
175
|
+
source, destination = found.group("source"), found.group("destination")
|
|
176
|
+
if source == destination:
|
|
177
|
+
return None
|
|
178
|
+
return found.group("name"), source, destination
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _definition(tree: ast.Module, name: str) -> ast.stmt | None:
|
|
182
|
+
"""The top-level def or class of that name, decorators included."""
|
|
183
|
+
for node in tree.body:
|
|
184
|
+
if isinstance(
|
|
185
|
+
node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
186
|
+
) and node.name == name:
|
|
187
|
+
return node
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _span(node: ast.stmt, lines: list[str]) -> tuple[int, int]:
|
|
192
|
+
"""The line range to cut, counting decorators and the blank lines after."""
|
|
193
|
+
start = node.lineno - 1
|
|
194
|
+
for decorator in getattr(node, "decorator_list", []):
|
|
195
|
+
start = min(start, decorator.lineno - 1)
|
|
196
|
+
end = node.end_lineno or node.lineno
|
|
197
|
+
while end < len(lines) and not lines[end].strip():
|
|
198
|
+
end += 1
|
|
199
|
+
return start, end
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _free_names(node: ast.stmt) -> set[str]:
|
|
203
|
+
"""Names the definition reads but does not create for itself.
|
|
204
|
+
|
|
205
|
+
A function carried into another file keeps whatever it referred to,
|
|
206
|
+
and the destination may not have it. Reporting that is the whole
|
|
207
|
+
reason this refuses instead of writing half a move.
|
|
208
|
+
"""
|
|
209
|
+
bound: set[str] = {node.name}
|
|
210
|
+
for child in ast.walk(node):
|
|
211
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
212
|
+
bound.add(child.name)
|
|
213
|
+
for arg in [
|
|
214
|
+
*child.args.posonlyargs,
|
|
215
|
+
*child.args.args,
|
|
216
|
+
*child.args.kwonlyargs,
|
|
217
|
+
]:
|
|
218
|
+
bound.add(arg.arg)
|
|
219
|
+
for extra in (child.args.vararg, child.args.kwarg):
|
|
220
|
+
if extra is not None:
|
|
221
|
+
bound.add(extra.arg)
|
|
222
|
+
elif isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store):
|
|
223
|
+
bound.add(child.id)
|
|
224
|
+
elif isinstance(child, ast.alias):
|
|
225
|
+
bound.add((child.asname or child.name).split(".")[0])
|
|
226
|
+
elif isinstance(child, ast.ExceptHandler) and child.name:
|
|
227
|
+
bound.add(child.name)
|
|
228
|
+
used = {
|
|
229
|
+
child.id
|
|
230
|
+
for child in ast.walk(node)
|
|
231
|
+
if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load)
|
|
232
|
+
}
|
|
233
|
+
return {name for name in used - bound if not hasattr(builtins, name)}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _already_there(tree: ast.Module, name: str) -> bool:
|
|
237
|
+
return _definition(tree, name) is not None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class _PlannedMove:
|
|
242
|
+
"""A function move that has passed every check and written nothing."""
|
|
243
|
+
|
|
244
|
+
name: str
|
|
245
|
+
source: Path
|
|
246
|
+
source_before: str
|
|
247
|
+
source_after: str
|
|
248
|
+
destination: Path
|
|
249
|
+
destination_before: str
|
|
250
|
+
destination_after: str
|
|
251
|
+
# (path, before, after) — `apply_source` keeps a .bak from the
|
|
252
|
+
# original, so every write carries the text it replaces.
|
|
253
|
+
edits: tuple[tuple[Path, str, str], ...]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _plan_function_move(project: Path, task: str) -> _PlannedMove | None:
|
|
257
|
+
"""Work the move out in full, or return None. Writes nothing.
|
|
258
|
+
|
|
259
|
+
Everything is decided here so that `apply_function_move` either
|
|
260
|
+
writes a whole move or writes nothing at all. A half-applied move
|
|
261
|
+
leaves a project that does not import.
|
|
262
|
+
"""
|
|
263
|
+
targets = function_move_targets(task)
|
|
264
|
+
if targets is None:
|
|
265
|
+
return None
|
|
266
|
+
name, source_rel, destination_rel = targets
|
|
267
|
+
root = Path(project).resolve()
|
|
268
|
+
source = resolve_project_file(project, source_rel)
|
|
269
|
+
destination = resolve_project_file(project, destination_rel)
|
|
270
|
+
if source is None or destination is None or not source.is_file():
|
|
271
|
+
return None
|
|
272
|
+
try:
|
|
273
|
+
source_text = source.read_text(encoding="utf-8")
|
|
274
|
+
source_tree = ast.parse(source_text)
|
|
275
|
+
destination_text = (
|
|
276
|
+
destination.read_text(encoding="utf-8") if destination.is_file() else ""
|
|
277
|
+
)
|
|
278
|
+
destination_tree = ast.parse(destination_text)
|
|
279
|
+
except (OSError, SyntaxError):
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
node = _definition(source_tree, name)
|
|
283
|
+
if node is None or _already_there(destination_tree, name):
|
|
284
|
+
return None
|
|
285
|
+
# What the definition reads has to exist where it lands, or the move
|
|
286
|
+
# produces a file that fails on first call.
|
|
287
|
+
if any(
|
|
288
|
+
free not in _module_level_names(destination_tree)
|
|
289
|
+
for free in _free_names(node)
|
|
290
|
+
):
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
lines = source_text.splitlines(keepends=True)
|
|
294
|
+
first, last = _span(node, lines)
|
|
295
|
+
body = "".join(lines[first:last]).rstrip("\n")
|
|
296
|
+
source_after = "".join(lines[:first] + lines[last:])
|
|
297
|
+
landed = destination_text.rstrip("\n")
|
|
298
|
+
destination_after = f"{landed}\n\n\n{body}\n" if landed else f"{body}\n"
|
|
299
|
+
for text in (source_after, destination_after):
|
|
300
|
+
try:
|
|
301
|
+
ast.parse(text)
|
|
302
|
+
except SyntaxError:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
edits = _repointed_callers(
|
|
306
|
+
project,
|
|
307
|
+
name,
|
|
308
|
+
module_name(rel_posix(source, root)),
|
|
309
|
+
module_name(rel_posix(destination, root)),
|
|
310
|
+
skip={source, destination},
|
|
311
|
+
)
|
|
312
|
+
if edits is None:
|
|
313
|
+
return None
|
|
314
|
+
return _PlannedMove(
|
|
315
|
+
name,
|
|
316
|
+
source,
|
|
317
|
+
source_text,
|
|
318
|
+
source_after,
|
|
319
|
+
destination,
|
|
320
|
+
destination_text,
|
|
321
|
+
destination_after,
|
|
322
|
+
edits,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _import_repaired(text: str, old_module: str, new_module: str, name: str) -> str:
|
|
327
|
+
"""Take one name out of an import and give it its own line. "" if absent.
|
|
328
|
+
|
|
329
|
+
A real project does not write `from pkg.mod import one_name`. It
|
|
330
|
+
writes a parenthesised list of ten, and a plain string replacement
|
|
331
|
+
matches none of them: on this repository the first version moved the
|
|
332
|
+
function and left every caller importing it from where it used to
|
|
333
|
+
be, and the suite stopped loading.
|
|
334
|
+
"""
|
|
335
|
+
try:
|
|
336
|
+
tree = ast.parse(text)
|
|
337
|
+
except SyntaxError:
|
|
338
|
+
return ""
|
|
339
|
+
lines = text.splitlines(keepends=True)
|
|
340
|
+
for node in tree.body:
|
|
341
|
+
if not isinstance(node, ast.ImportFrom) or node.module != old_module:
|
|
342
|
+
continue
|
|
343
|
+
kept = [alias for alias in node.names if alias.name != name]
|
|
344
|
+
if len(kept) == len(node.names):
|
|
345
|
+
continue
|
|
346
|
+
first, last = node.lineno - 1, node.end_lineno or node.lineno
|
|
347
|
+
replacement = f"from {new_module} import {name}\n"
|
|
348
|
+
if kept:
|
|
349
|
+
spelled = ", ".join(
|
|
350
|
+
a.name if not a.asname else f"{a.name} as {a.asname}" for a in kept
|
|
351
|
+
)
|
|
352
|
+
one_line = f"from {old_module} import {spelled}\n"
|
|
353
|
+
if len(one_line) > 80:
|
|
354
|
+
joined = ",\n ".join(
|
|
355
|
+
a.name if not a.asname else f"{a.name} as {a.asname}" for a in kept
|
|
356
|
+
)
|
|
357
|
+
one_line = f"from {old_module} import (\n {joined},\n)\n"
|
|
358
|
+
replacement = one_line + replacement
|
|
359
|
+
return "".join(lines[:first]) + replacement + "".join(lines[last:])
|
|
360
|
+
return ""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _repointed_callers(
|
|
364
|
+
project: Path, name: str, old_module: str, new_module: str, *, skip: set[Path]
|
|
365
|
+
) -> tuple[tuple[Path, str, str], ...] | None:
|
|
366
|
+
"""Files whose import of `name` must follow it. None if one breaks."""
|
|
367
|
+
edits: list[tuple[Path, str, str]] = []
|
|
368
|
+
for path in importers(project, old_module):
|
|
369
|
+
if path in skip:
|
|
370
|
+
continue
|
|
371
|
+
original = path.read_text(encoding="utf-8")
|
|
372
|
+
changed = _import_repaired(original, old_module, new_module, name)
|
|
373
|
+
if not changed or changed == original:
|
|
374
|
+
continue
|
|
375
|
+
try:
|
|
376
|
+
ast.parse(changed)
|
|
377
|
+
except SyntaxError:
|
|
378
|
+
return None
|
|
379
|
+
edits.append((path, original, changed))
|
|
380
|
+
return tuple(edits)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _write_keeping_a_backup(path: Path, source: str) -> None:
|
|
384
|
+
"""Write a file that is deliberately shorter, and keep the original.
|
|
385
|
+
|
|
386
|
+
`apply_source` refuses a draft two thirds the length of what it
|
|
387
|
+
replaces, which is right when a model hands over a whole file and
|
|
388
|
+
wrong here: taking a function out makes the source shorter on
|
|
389
|
+
purpose. The backup is kept the same way, so nothing is lost and
|
|
390
|
+
the honest-finish check can still see what changed.
|
|
391
|
+
"""
|
|
392
|
+
ast.parse(source)
|
|
393
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
394
|
+
if path.is_file():
|
|
395
|
+
backup.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
396
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
path.write_text(source.rstrip() + "\n", encoding="utf-8")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def apply_function_move(project: Path, task: str, *, write: bool = True) -> str:
|
|
401
|
+
"""Move the one function the task names, and repair its callers.
|
|
402
|
+
|
|
403
|
+
Returns a note, or "" when the task asks for something else or the
|
|
404
|
+
move cannot be made whole. Moving part of a file is the job people
|
|
405
|
+
reach for once a module has grown too big, and doing it by hand
|
|
406
|
+
means finding every caller by eye.
|
|
407
|
+
"""
|
|
408
|
+
planned = _plan_function_move(project, task)
|
|
409
|
+
if planned is None:
|
|
410
|
+
return ""
|
|
411
|
+
where = rel_posix(planned.destination, Path(project).resolve())
|
|
412
|
+
if not write:
|
|
413
|
+
return f"would move {planned.name} to {where}"
|
|
414
|
+
_write_keeping_a_backup(planned.destination, planned.destination_after)
|
|
415
|
+
_write_keeping_a_backup(planned.source, planned.source_after)
|
|
416
|
+
for path, original, changed in planned.edits:
|
|
417
|
+
apply_source(path, changed, original=original)
|
|
418
|
+
repaired = (
|
|
419
|
+
f", and repaired {len(planned.edits)} import(s)" if planned.edits else ""
|
|
420
|
+
)
|
|
421
|
+
return f"moved {planned.name} to {where}{repaired}"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _module_level_names(tree: ast.Module) -> set[str]:
|
|
425
|
+
"""Everything a file defines or imports at the top level."""
|
|
426
|
+
found: set[str] = set()
|
|
427
|
+
for node in tree.body:
|
|
428
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
429
|
+
found.add(node.name)
|
|
430
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
431
|
+
for alias in node.names:
|
|
432
|
+
found.add((alias.asname or alias.name).split(".")[0])
|
|
433
|
+
elif isinstance(node, ast.Assign):
|
|
434
|
+
for target in node.targets:
|
|
435
|
+
if isinstance(target, ast.Name):
|
|
436
|
+
found.add(target.id)
|
|
437
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
438
|
+
found.add(node.target.id)
|
|
439
|
+
return found
|