proofside 0.1.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.
proofside/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Proofside's deliberately small Nagini command-line wrapper."""
2
+
3
+
proofside/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .artifacts import accepted_contract_path, candidate_contract_path
7
+ from .cli import CheckResult, _function_arguments, load_target, parse_selector
8
+ from .contracts import ContractError, parse_contract, render_human, validate_contract
9
+
10
+
11
+ class AcceptanceError(ValueError):
12
+ pass
13
+
14
+
15
+ def accept_contract(
16
+ selector: str,
17
+ candidate_path: Path | None = None,
18
+ replace: bool = False,
19
+ ) -> tuple[str, Path, Path]:
20
+ try:
21
+ file_path, function_name = parse_selector(selector)
22
+ except ValueError as error:
23
+ raise AcceptanceError(str(error)) from error
24
+ target = load_target(file_path, function_name, require_inline_contract=False)
25
+ if isinstance(target, CheckResult):
26
+ raise AcceptanceError(f"{target.status.value}: {target.detail}")
27
+ _source, function = target
28
+
29
+ candidate_path = candidate_path or candidate_contract_path(file_path, function_name)
30
+ output_path = accepted_contract_path(file_path, function_name)
31
+ if output_path.exists() and not replace:
32
+ raise AcceptanceError(f"accepted contract already exists: {output_path}; use --replace")
33
+
34
+ try:
35
+ candidate_text = candidate_path.read_text(encoding="utf-8")
36
+ except (OSError, UnicodeError) as error:
37
+ raise AcceptanceError(f"could not read candidate contract {candidate_path}: {error}") from error
38
+ try:
39
+ data = json.loads(candidate_text)
40
+ except json.JSONDecodeError as error:
41
+ raise AcceptanceError(
42
+ f"malformed candidate JSON at line {error.lineno}: {error.msg}"
43
+ ) from error
44
+ try:
45
+ contract = parse_contract(data)
46
+ parameters = {argument.arg for argument in _function_arguments(function)}
47
+ validate_contract(contract, parameters)
48
+ except ContractError as error:
49
+ raise AcceptanceError(f"invalid candidate contract: {error}") from error
50
+
51
+ accepted_text = json.dumps(data, indent=2) + "\n"
52
+ try:
53
+ output_path.parent.mkdir(exist_ok=True)
54
+ mode = "w" if replace else "x"
55
+ with output_path.open(mode, encoding="utf-8") as output_file:
56
+ output_file.write(accepted_text)
57
+ except FileExistsError as error:
58
+ raise AcceptanceError(f"accepted contract already exists: {output_path}; use --replace") from error
59
+ except OSError as error:
60
+ raise AcceptanceError(f"could not save accepted contract: {error}") from error
61
+
62
+ return render_human(contract), candidate_path, output_path
63
+
64
+
65
+ def render_acceptance_output(
66
+ contract_text: str,
67
+ candidate_path: Path,
68
+ accepted_path: Path,
69
+ ) -> str:
70
+ return (
71
+ "ACCEPTED FOR VERIFICATION — NOT VERIFIED\n\n"
72
+ f"Contract\n\n{contract_text}\n\n"
73
+ f"Candidate:\n{candidate_path}\n\n"
74
+ f"Accepted contract:\n{accepted_path}\n\n"
75
+ "This records explicit user acceptance of the contract for verification.\n"
76
+ "No verification was run."
77
+ )
proofside/artifacts.py ADDED
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def candidate_contract_path(file_path: Path, function_name: str) -> Path:
5
+ return file_path.parent / ".proofside" / f"{file_path.stem}.{function_name}.candidate.json"
6
+
7
+
8
+ def accepted_contract_path(file_path: Path, function_name: str) -> Path:
9
+ return file_path.parent / ".proofside" / f"{file_path.stem}.{function_name}.contract.json"
proofside/batch.py ADDED
@@ -0,0 +1,309 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ import os
6
+ from pathlib import Path
7
+ import tokenize
8
+
9
+ from .artifacts import accepted_contract_path, candidate_contract_path
10
+ from .cli import CheckResult, Status, check
11
+ from .proposal import ProposalError, propose_contract
12
+ from .specification import (
13
+ SpecificationAnnotationError,
14
+ marked_functions_in_source,
15
+ specification_annotations_for_function,
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class BatchCheckResult:
21
+ selector: str
22
+ result: CheckResult | None = None
23
+ contract_path: Path | None = None
24
+ unreviewed: bool = False
25
+ issue: str | None = None
26
+ detail: str = ""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class MarkedTarget:
31
+ file_path: Path
32
+ source: str
33
+ function: ast.FunctionDef
34
+
35
+ @property
36
+ def selector(self) -> str:
37
+ return f"{self.file_path}::{self.function.name}"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class BatchProposalResult:
42
+ selector: str
43
+ candidate_path: Path
44
+ sources: tuple[str, ...] = ()
45
+ proposed: bool = False
46
+ issue: str | None = None
47
+ detail: str = ""
48
+
49
+
50
+ def discover_python_files(
51
+ targets: tuple[Path, ...],
52
+ ) -> tuple[tuple[Path, ...], tuple[tuple[Path, str], ...]]:
53
+ files: dict[Path, Path] = {}
54
+ errors = []
55
+ for target in targets:
56
+ if target.is_file():
57
+ if target.suffix != ".py":
58
+ errors.append((target, "target is not a Python file"))
59
+ else:
60
+ files.setdefault(target.resolve(), target)
61
+ continue
62
+ if not target.is_dir():
63
+ errors.append((target, "target does not exist"))
64
+ continue
65
+ if target.name.startswith("."):
66
+ continue
67
+
68
+ walk_errors = []
69
+ for directory, subdirectories, names in os.walk(
70
+ target,
71
+ onerror=walk_errors.append,
72
+ ):
73
+ subdirectories[:] = sorted(
74
+ name for name in subdirectories if not name.startswith(".")
75
+ )
76
+ for name in sorted(names):
77
+ if name.endswith(".py"):
78
+ path = Path(directory, name)
79
+ files.setdefault(path.resolve(), path)
80
+ errors.extend((Path(error.filename or target), str(error)) for error in walk_errors)
81
+
82
+ paths = tuple(sorted(files.values(), key=lambda path: path.as_posix()))
83
+ return paths, tuple(errors)
84
+
85
+
86
+ def discover_marked_targets(
87
+ targets: tuple[Path, ...],
88
+ ) -> tuple[tuple[MarkedTarget, ...], tuple[tuple[Path, str], ...]]:
89
+ files, discovery_errors = discover_python_files(targets)
90
+ marked_targets = []
91
+ errors = list(discovery_errors)
92
+ for file_path in files:
93
+ try:
94
+ source = file_path.read_text(encoding="utf-8")
95
+ functions = marked_functions_in_source(source)
96
+ except (OSError, UnicodeError, SyntaxError, tokenize.TokenError) as error:
97
+ errors.append((file_path, _discovery_error_detail(error)))
98
+ continue
99
+ marked_targets.extend(
100
+ MarkedTarget(file_path, source, function) for function in functions
101
+ )
102
+ return tuple(marked_targets), tuple(errors)
103
+
104
+
105
+ def run_batch_checks(
106
+ targets: tuple[Path, ...],
107
+ allow_unreviewed: bool = False,
108
+ ) -> tuple[tuple[BatchCheckResult, ...], tuple[tuple[Path, str], ...]]:
109
+ marked_targets, discovery_errors = discover_marked_targets(targets)
110
+ results = []
111
+
112
+ for target in marked_targets:
113
+ try:
114
+ specification_annotations_for_function(target.source, target.function)
115
+ except SpecificationAnnotationError as error:
116
+ results.append(
117
+ BatchCheckResult(
118
+ target.selector,
119
+ issue="INVALID SPECIFICATION",
120
+ detail=str(error),
121
+ )
122
+ )
123
+ continue
124
+
125
+ accepted_path = accepted_contract_path(target.file_path, target.function.name)
126
+ candidate_path = candidate_contract_path(target.file_path, target.function.name)
127
+ if accepted_path.is_file():
128
+ contract_path = accepted_path
129
+ unreviewed = False
130
+ elif allow_unreviewed and candidate_path.is_file():
131
+ contract_path = candidate_path
132
+ unreviewed = True
133
+ else:
134
+ issue = "NO CONTRACT" if allow_unreviewed else "NO ACCEPTED CONTRACT"
135
+ detail = str(candidate_path if allow_unreviewed else accepted_path)
136
+ results.append(BatchCheckResult(target.selector, issue=issue, detail=detail))
137
+ continue
138
+
139
+ results.append(
140
+ BatchCheckResult(
141
+ target.selector,
142
+ check(target.selector, contract_path),
143
+ contract_path,
144
+ unreviewed,
145
+ )
146
+ )
147
+ return tuple(results), discovery_errors
148
+
149
+
150
+ def run_batch_proposals(
151
+ targets: tuple[Path, ...],
152
+ model_source: str,
153
+ model: str,
154
+ base_url: str | None = None,
155
+ api_key_env: str | None = None,
156
+ sources: tuple[str, ...] | None = None,
157
+ ) -> tuple[tuple[BatchProposalResult, ...], tuple[tuple[Path, str], ...]]:
158
+ marked_targets, discovery_errors = discover_marked_targets(targets)
159
+ results = []
160
+ for target in marked_targets:
161
+ output_path = candidate_contract_path(target.file_path, target.function.name)
162
+ if output_path.exists():
163
+ results.append(
164
+ BatchProposalResult(
165
+ target.selector,
166
+ output_path,
167
+ issue="CANDIDATE EXISTS",
168
+ detail=str(output_path),
169
+ )
170
+ )
171
+ continue
172
+ try:
173
+ _contract_text, used_sources = propose_contract(
174
+ target.selector,
175
+ model_source,
176
+ model,
177
+ None,
178
+ base_url,
179
+ api_key_env,
180
+ sources,
181
+ )
182
+ except ProposalError as error:
183
+ results.append(
184
+ BatchProposalResult(
185
+ target.selector,
186
+ output_path,
187
+ issue="PROPOSAL REJECTED",
188
+ detail=str(error),
189
+ )
190
+ )
191
+ continue
192
+ results.append(
193
+ BatchProposalResult(
194
+ target.selector,
195
+ output_path,
196
+ used_sources,
197
+ proposed=True,
198
+ )
199
+ )
200
+ return tuple(results), discovery_errors
201
+
202
+
203
+ def _discovery_error_detail(error: BaseException) -> str:
204
+ if isinstance(error, SyntaxError):
205
+ location = f"line {error.lineno}" if error.lineno else "unknown line"
206
+ return f"cannot parse ({location}): {error.msg}"
207
+ return str(error)
208
+
209
+
210
+ def batch_succeeded(
211
+ results: tuple[BatchCheckResult, ...],
212
+ discovery_errors: tuple[tuple[Path, str], ...],
213
+ ) -> bool:
214
+ return bool(results) and not discovery_errors and all(
215
+ item.result is not None
216
+ and item.result.status is Status.VERIFIED
217
+ and not item.unreviewed
218
+ for item in results
219
+ )
220
+
221
+
222
+ def batch_proposal_succeeded(
223
+ results: tuple[BatchProposalResult, ...],
224
+ discovery_errors: tuple[tuple[Path, str], ...],
225
+ ) -> bool:
226
+ return bool(results) and not discovery_errors and all(
227
+ item.proposed for item in results
228
+ )
229
+
230
+
231
+ def render_batch_output(
232
+ results: tuple[BatchCheckResult, ...],
233
+ discovery_errors: tuple[tuple[Path, str], ...],
234
+ ) -> str:
235
+ total = len(results)
236
+ verified = sum(
237
+ item.result is not None and item.result.status is Status.VERIFIED
238
+ for item in results
239
+ )
240
+ lines = ["Proofside batch", ""]
241
+ if not total:
242
+ lines.append("0 Proofside-marked functions found")
243
+ else:
244
+ lines.extend((f"{total} Proofside-marked functions", f"{verified}/{total} VERIFIED"))
245
+
246
+ groups: dict[str, list[tuple[str, str]]] = {}
247
+ for item in results:
248
+ if item.issue:
249
+ category = item.issue
250
+ detail = item.detail
251
+ elif item.unreviewed:
252
+ category = f"{item.result.status.value} (UNREVIEWED CONTRACT)"
253
+ detail = str(item.contract_path)
254
+ elif item.result.status is not Status.VERIFIED:
255
+ category = item.result.status.value
256
+ detail = item.result.detail
257
+ else:
258
+ continue
259
+ groups.setdefault(category, []).append((item.selector, detail))
260
+ if discovery_errors:
261
+ groups["DISCOVERY ERROR"] = [
262
+ (str(path), detail) for path, detail in discovery_errors
263
+ ]
264
+
265
+ for category, items in groups.items():
266
+ lines.extend(("", category))
267
+ for selector, detail in items:
268
+ lines.append(f"- {selector}")
269
+ if detail:
270
+ lines.append(f" {detail}")
271
+ return "\n".join(lines)
272
+
273
+
274
+ def render_batch_proposal_output(
275
+ results: tuple[BatchProposalResult, ...],
276
+ discovery_errors: tuple[tuple[Path, str], ...],
277
+ ) -> str:
278
+ total = len(results)
279
+ proposed = sum(item.proposed for item in results)
280
+ lines = ["Proofside batch proposal", ""]
281
+ if not total:
282
+ lines.append("0 Proofside-marked functions found")
283
+ else:
284
+ lines.extend((f"{total} Proofside-marked functions", f"{proposed}/{total} PROPOSED"))
285
+
286
+ groups: dict[str, list[tuple[str, str]]] = {}
287
+ for item in results:
288
+ if item.issue:
289
+ groups.setdefault(item.issue, []).append((item.selector, item.detail))
290
+ if discovery_errors:
291
+ groups["DISCOVERY ERROR"] = [
292
+ (str(path), detail) for path, detail in discovery_errors
293
+ ]
294
+
295
+ for category, items in groups.items():
296
+ lines.extend(("", category))
297
+ for selector, detail in items:
298
+ lines.append(f"- {selector}")
299
+ if detail:
300
+ lines.append(f" {detail}")
301
+ if proposed:
302
+ lines.extend(
303
+ (
304
+ "",
305
+ "Candidates were saved under source-adjacent .proofside/ directories.",
306
+ "Review or edit them, then accept explicitly before check-all.",
307
+ )
308
+ )
309
+ return "\n".join(lines)