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 +3 -0
- proofside/__main__.py +6 -0
- proofside/acceptance.py +77 -0
- proofside/artifacts.py +9 -0
- proofside/batch.py +309 -0
- proofside/cli.py +423 -0
- proofside/contracts.py +372 -0
- proofside/proposal.py +290 -0
- proofside/specification.py +129 -0
- proofside-0.1.0.dist-info/METADATA +373 -0
- proofside-0.1.0.dist-info/RECORD +15 -0
- proofside-0.1.0.dist-info/WHEEL +5 -0
- proofside-0.1.0.dist-info/entry_points.txt +2 -0
- proofside-0.1.0.dist-info/licenses/LICENSE +202 -0
- proofside-0.1.0.dist-info/top_level.txt +1 -0
proofside/__init__.py
ADDED
proofside/__main__.py
ADDED
proofside/acceptance.py
ADDED
|
@@ -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)
|