ocalc 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.
- ocalc/__init__.py +216 -0
- ocalc/bench.py +287 -0
- ocalc/cli.py +86 -0
- ocalc/comment.py +230 -0
- ocalc/fit.py +260 -0
- ocalc/generate.py +615 -0
- ocalc/mcp_server.py +108 -0
- ocalc/static.py +357 -0
- ocalc-0.1.0.dist-info/METADATA +61 -0
- ocalc-0.1.0.dist-info/RECORD +14 -0
- ocalc-0.1.0.dist-info/WHEEL +5 -0
- ocalc-0.1.0.dist-info/entry_points.txt +2 -0
- ocalc-0.1.0.dist-info/licenses/LICENSE +21 -0
- ocalc-0.1.0.dist-info/top_level.txt +1 -0
ocalc/__init__.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Measure a Python function and estimate its time and space complexity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ocalc.bench import BenchResult, Point, benchmark
|
|
11
|
+
from ocalc.comment import (
|
|
12
|
+
Protocol,
|
|
13
|
+
apply_comment,
|
|
14
|
+
existing_comment,
|
|
15
|
+
format_comment,
|
|
16
|
+
format_table,
|
|
17
|
+
)
|
|
18
|
+
from ocalc.fit import FitResult, fit_curve
|
|
19
|
+
from ocalc.generate import (
|
|
20
|
+
FunctionNotFound,
|
|
21
|
+
NeedSample,
|
|
22
|
+
callable_location,
|
|
23
|
+
load_function,
|
|
24
|
+
parse_sample,
|
|
25
|
+
plan_inputs,
|
|
26
|
+
)
|
|
27
|
+
from ocalc.static import FunctionEstimate, SourceAnalysis, analyze_source
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"FunctionEstimate",
|
|
33
|
+
"FunctionNotFound",
|
|
34
|
+
"NeedSample",
|
|
35
|
+
"Report",
|
|
36
|
+
"SourceAnalysis",
|
|
37
|
+
"analyze_source",
|
|
38
|
+
"annotate_file",
|
|
39
|
+
"measure",
|
|
40
|
+
"parse_sample",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Report:
|
|
46
|
+
comment: str
|
|
47
|
+
points: list[Point]
|
|
48
|
+
time: FitResult
|
|
49
|
+
space: FitResult
|
|
50
|
+
worst_s: float
|
|
51
|
+
median_s: float
|
|
52
|
+
worst_n: int | None
|
|
53
|
+
protocol: Protocol
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def measure(
|
|
57
|
+
func,
|
|
58
|
+
*sample_args,
|
|
59
|
+
budget: float | None = None,
|
|
60
|
+
min_n: int | None = None,
|
|
61
|
+
max_n: int | None = None,
|
|
62
|
+
measures: int | None = None,
|
|
63
|
+
repeats: int | None = None,
|
|
64
|
+
file: str | None = None,
|
|
65
|
+
qualname: str | None = None,
|
|
66
|
+
protocol: Protocol | None = None,
|
|
67
|
+
previous_s: float | None = None,
|
|
68
|
+
**sample_kwargs,
|
|
69
|
+
) -> Report:
|
|
70
|
+
"""Measure a function and return its complexity report.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
func: The callable to measure.
|
|
74
|
+
sample_args: Example positional arguments. The first collection grows with ``n``.
|
|
75
|
+
budget: Seconds to spend measuring. The default is 5.
|
|
76
|
+
min_n: Smallest input size.
|
|
77
|
+
max_n: Largest input size.
|
|
78
|
+
measures: How many sizes to try.
|
|
79
|
+
repeats: Timed calls at each size.
|
|
80
|
+
file: Source file, when the callable should be re-imported for the timed run.
|
|
81
|
+
qualname: Qualname used to re-import ``file``.
|
|
82
|
+
protocol: Stored sizes and budget from an earlier comment.
|
|
83
|
+
previous_s: Worst time from that earlier comment.
|
|
84
|
+
sample_kwargs: Example keyword arguments.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The comment, the size table, and the fitted time and space classes.
|
|
88
|
+
"""
|
|
89
|
+
plan = plan_inputs(func, sample_args, sample_kwargs)
|
|
90
|
+
if protocol is not None and protocol.constant and plan.mode == "constant":
|
|
91
|
+
if budget is None:
|
|
92
|
+
budget = protocol.budget
|
|
93
|
+
if repeats is None:
|
|
94
|
+
repeats = protocol.repeats
|
|
95
|
+
elif protocol is not None and not protocol.constant and plan.mode != "constant":
|
|
96
|
+
if budget is None:
|
|
97
|
+
budget = protocol.budget
|
|
98
|
+
if min_n is None:
|
|
99
|
+
min_n = protocol.min_n
|
|
100
|
+
if max_n is None:
|
|
101
|
+
max_n = protocol.max_n
|
|
102
|
+
if measures is None:
|
|
103
|
+
measures = protocol.measures
|
|
104
|
+
if repeats is None:
|
|
105
|
+
repeats = protocol.repeats
|
|
106
|
+
|
|
107
|
+
located_file, located_name = callable_location(func)
|
|
108
|
+
file = file or located_file
|
|
109
|
+
qualname = qualname or located_name
|
|
110
|
+
result = benchmark(
|
|
111
|
+
func,
|
|
112
|
+
plan,
|
|
113
|
+
budget=5.0 if budget is None else budget,
|
|
114
|
+
min_n=min_n,
|
|
115
|
+
max_n=max_n,
|
|
116
|
+
measures=measures,
|
|
117
|
+
repeats=repeats,
|
|
118
|
+
file=file,
|
|
119
|
+
qualname=qualname,
|
|
120
|
+
)
|
|
121
|
+
time_fit, space_fit = _fits(result)
|
|
122
|
+
comment = format_comment(
|
|
123
|
+
time=time_fit,
|
|
124
|
+
space=space_fit,
|
|
125
|
+
worst_s=result.worst_s,
|
|
126
|
+
worst_n=result.worst_n,
|
|
127
|
+
median_s=result.median_s,
|
|
128
|
+
protocol=result.protocol,
|
|
129
|
+
previous_s=previous_s,
|
|
130
|
+
)
|
|
131
|
+
return Report(
|
|
132
|
+
comment=comment,
|
|
133
|
+
points=result.points,
|
|
134
|
+
time=time_fit,
|
|
135
|
+
space=space_fit,
|
|
136
|
+
worst_s=result.worst_s,
|
|
137
|
+
median_s=result.median_s,
|
|
138
|
+
worst_n=result.worst_n,
|
|
139
|
+
protocol=result.protocol,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def annotate_file(
|
|
144
|
+
path,
|
|
145
|
+
qualname: str,
|
|
146
|
+
*sample_args,
|
|
147
|
+
budget: float | None = None,
|
|
148
|
+
min_n: int | None = None,
|
|
149
|
+
max_n: int | None = None,
|
|
150
|
+
measures: int | None = None,
|
|
151
|
+
repeats: int | None = None,
|
|
152
|
+
**sample_kwargs,
|
|
153
|
+
) -> Report:
|
|
154
|
+
"""Benchmark a function in a file and write the comment above it.
|
|
155
|
+
|
|
156
|
+
A later call reuses the protocol stored in the existing comment and records
|
|
157
|
+
the previous worst time.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
path: Python file that defines the function.
|
|
161
|
+
qualname: Function qualname, such as ``two_sum`` or ``Box.run``.
|
|
162
|
+
sample_args: Example positional arguments.
|
|
163
|
+
budget: Seconds to spend measuring.
|
|
164
|
+
min_n: Smallest input size.
|
|
165
|
+
max_n: Largest input size.
|
|
166
|
+
measures: How many sizes to try.
|
|
167
|
+
repeats: Timed calls at each size.
|
|
168
|
+
sample_kwargs: Example keyword arguments.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The report that was written into the file.
|
|
172
|
+
"""
|
|
173
|
+
file_path = Path(path)
|
|
174
|
+
source = file_path.read_text(encoding="utf-8")
|
|
175
|
+
previous = existing_comment(source, qualname)
|
|
176
|
+
protocol = previous.protocol if previous is not None else None
|
|
177
|
+
previous_s = previous.worst_s if previous is not None else None
|
|
178
|
+
func = load_function(file_path, qualname)
|
|
179
|
+
report = measure(
|
|
180
|
+
func,
|
|
181
|
+
*sample_args,
|
|
182
|
+
budget=budget,
|
|
183
|
+
min_n=min_n,
|
|
184
|
+
max_n=max_n,
|
|
185
|
+
measures=measures,
|
|
186
|
+
repeats=repeats,
|
|
187
|
+
file=str(file_path.resolve()),
|
|
188
|
+
qualname=qualname,
|
|
189
|
+
protocol=protocol,
|
|
190
|
+
previous_s=previous_s,
|
|
191
|
+
**sample_kwargs,
|
|
192
|
+
)
|
|
193
|
+
updated = apply_comment(source, qualname, report.comment)
|
|
194
|
+
with file_path.open("w", encoding="utf-8", newline="") as handle:
|
|
195
|
+
handle.write(updated)
|
|
196
|
+
return report
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _fits(result: BenchResult) -> tuple[FitResult, FitResult]:
|
|
200
|
+
if result.constant:
|
|
201
|
+
done = FitResult("1", "O(1)", "high")
|
|
202
|
+
return done, done
|
|
203
|
+
sizes = [point.n for point in result.points]
|
|
204
|
+
times = [point.time_s for point in result.points]
|
|
205
|
+
time_fit = fit_curve(sizes, times, exponential=result.exponential)
|
|
206
|
+
return time_fit, _space_fit(sizes, [point.memory_bytes for point in result.points])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _space_fit(sizes, memory) -> FitResult:
|
|
210
|
+
used = np.asarray(memory, dtype=float)
|
|
211
|
+
if used.size == 0 or np.nanmax(used) <= 0:
|
|
212
|
+
return FitResult("1", "O(1)", "high")
|
|
213
|
+
positive = used[used > 0]
|
|
214
|
+
floor = float(positive.min()) if positive.size else 1.0
|
|
215
|
+
used = np.where(used > 0, used, floor)
|
|
216
|
+
return fit_curve(sizes, used)
|
ocalc/bench.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Time a function across a size grid and record peak Python allocations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
import multiprocessing
|
|
9
|
+
import time
|
|
10
|
+
import tracemalloc
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from statistics import median
|
|
13
|
+
|
|
14
|
+
from ocalc.comment import Protocol
|
|
15
|
+
from ocalc.generate import InputPlan, load_function
|
|
16
|
+
|
|
17
|
+
DEFAULT_BUDGET = 5.0
|
|
18
|
+
DEFAULT_MEASURES = 8
|
|
19
|
+
DEFAULT_MIN_N = 100
|
|
20
|
+
DEFAULT_MAX_N = 100_000
|
|
21
|
+
DEFAULT_MIN_N_INT = 8
|
|
22
|
+
_REPEATS = 5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Point:
|
|
27
|
+
n: int | None
|
|
28
|
+
time_s: float
|
|
29
|
+
memory_bytes: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BenchResult:
|
|
34
|
+
points: list[Point]
|
|
35
|
+
protocol: Protocol
|
|
36
|
+
exponential: bool
|
|
37
|
+
constant: bool
|
|
38
|
+
timed_out: bool
|
|
39
|
+
worst_s: float
|
|
40
|
+
median_s: float
|
|
41
|
+
worst_n: int | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def geometric_sizes(min_n: int, max_n: int, measures: int) -> list[int]:
|
|
45
|
+
"""Evenly spaced sizes on a log scale, including both ends."""
|
|
46
|
+
if measures < 1:
|
|
47
|
+
raise ValueError("measures must be at least 1")
|
|
48
|
+
if min_n > max_n:
|
|
49
|
+
raise ValueError("min_n cannot be greater than max_n")
|
|
50
|
+
if measures == 1 or min_n == max_n:
|
|
51
|
+
return [int(max_n)]
|
|
52
|
+
ratio = (max_n / min_n) ** (1 / (measures - 1))
|
|
53
|
+
raw = [int(round(min_n * ratio**i)) for i in range(measures)]
|
|
54
|
+
raw[0] = int(min_n)
|
|
55
|
+
raw[-1] = int(max_n)
|
|
56
|
+
sizes: list[int] = []
|
|
57
|
+
for size in raw:
|
|
58
|
+
size = max(int(min_n), size)
|
|
59
|
+
if not sizes or size > sizes[-1]:
|
|
60
|
+
sizes.append(size)
|
|
61
|
+
if sizes[-1] != int(max_n):
|
|
62
|
+
sizes.append(int(max_n))
|
|
63
|
+
return sizes
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def benchmark(
|
|
67
|
+
func,
|
|
68
|
+
plan: InputPlan,
|
|
69
|
+
*,
|
|
70
|
+
budget: float,
|
|
71
|
+
min_n: int | None,
|
|
72
|
+
max_n: int | None,
|
|
73
|
+
measures: int | None,
|
|
74
|
+
repeats: int | None,
|
|
75
|
+
file: str | None,
|
|
76
|
+
qualname: str | None,
|
|
77
|
+
) -> BenchResult:
|
|
78
|
+
measures = DEFAULT_MEASURES if measures is None else measures
|
|
79
|
+
budget = DEFAULT_BUDGET if budget is None else budget
|
|
80
|
+
repeats = _REPEATS if repeats is None else max(1, int(repeats))
|
|
81
|
+
if plan.mode == "constant":
|
|
82
|
+
return _benchmark_constant(func, plan, budget, repeats, file, qualname)
|
|
83
|
+
|
|
84
|
+
if plan.mode == "int_n":
|
|
85
|
+
min_n = DEFAULT_MIN_N_INT if min_n is None else min_n
|
|
86
|
+
max_n = DEFAULT_MAX_N if max_n is None else max_n
|
|
87
|
+
else:
|
|
88
|
+
min_n = DEFAULT_MIN_N if min_n is None else min_n
|
|
89
|
+
max_n = DEFAULT_MAX_N if max_n is None else max_n
|
|
90
|
+
protocol = Protocol(int(min_n), int(max_n), int(measures), float(budget), repeats=repeats)
|
|
91
|
+
sizes = geometric_sizes(protocol.min_n, protocol.max_n, protocol.measures)
|
|
92
|
+
points: list[Point] = []
|
|
93
|
+
exponential = False
|
|
94
|
+
timed_out = False
|
|
95
|
+
started = time.perf_counter()
|
|
96
|
+
|
|
97
|
+
for size in sizes:
|
|
98
|
+
remaining = budget - (time.perf_counter() - started)
|
|
99
|
+
if remaining <= 0.05:
|
|
100
|
+
break
|
|
101
|
+
if len(points) >= 2 and _explosive(
|
|
102
|
+
points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s
|
|
103
|
+
):
|
|
104
|
+
exponential = True
|
|
105
|
+
break
|
|
106
|
+
if points:
|
|
107
|
+
ratio = size / points[-1].n
|
|
108
|
+
predicted = points[-1].time_s * max(ratio, 1.0) ** 2
|
|
109
|
+
if predicted > remaining:
|
|
110
|
+
break
|
|
111
|
+
args, kwargs = plan.build(size)
|
|
112
|
+
status, times, peak = _measure_call(
|
|
113
|
+
func,
|
|
114
|
+
args,
|
|
115
|
+
kwargs,
|
|
116
|
+
file=file,
|
|
117
|
+
qualname=qualname,
|
|
118
|
+
repeats=repeats,
|
|
119
|
+
timeout=max(0.2, remaining),
|
|
120
|
+
)
|
|
121
|
+
if status == "timeout":
|
|
122
|
+
timed_out = True
|
|
123
|
+
if points and (
|
|
124
|
+
points[-1].n <= 40
|
|
125
|
+
and points[-1].time_s > 1e-4
|
|
126
|
+
or (
|
|
127
|
+
len(points) >= 2
|
|
128
|
+
and _explosive(points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s)
|
|
129
|
+
)
|
|
130
|
+
):
|
|
131
|
+
exponential = True
|
|
132
|
+
break
|
|
133
|
+
if status == "error":
|
|
134
|
+
raise RuntimeError(times)
|
|
135
|
+
point = Point(size, float(median(times)), float(peak))
|
|
136
|
+
points.append(point)
|
|
137
|
+
if len(points) >= 2 and _explosive(
|
|
138
|
+
points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s
|
|
139
|
+
):
|
|
140
|
+
exponential = True
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
if not points:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
"No measurement finished within the budget. Raise --budget or lower --min-n."
|
|
146
|
+
)
|
|
147
|
+
slowest = max(points, key=lambda item: item.time_s)
|
|
148
|
+
return BenchResult(
|
|
149
|
+
points=points,
|
|
150
|
+
protocol=protocol,
|
|
151
|
+
exponential=exponential,
|
|
152
|
+
constant=False,
|
|
153
|
+
timed_out=timed_out,
|
|
154
|
+
worst_s=slowest.time_s,
|
|
155
|
+
median_s=float(median(item.time_s for item in points)),
|
|
156
|
+
worst_n=slowest.n,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _benchmark_constant(func, plan, budget, repeats, file, qualname) -> BenchResult:
|
|
161
|
+
args, kwargs = plan.build(0)
|
|
162
|
+
status, times, peak = _measure_call(
|
|
163
|
+
func,
|
|
164
|
+
args,
|
|
165
|
+
kwargs,
|
|
166
|
+
file=file,
|
|
167
|
+
qualname=qualname,
|
|
168
|
+
repeats=repeats,
|
|
169
|
+
timeout=max(0.2, budget),
|
|
170
|
+
)
|
|
171
|
+
if status == "timeout":
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
"No measurement finished within the budget. Raise --budget or lower --min-n."
|
|
174
|
+
)
|
|
175
|
+
if status == "error":
|
|
176
|
+
raise RuntimeError(times)
|
|
177
|
+
protocol = Protocol(0, 0, 0, float(budget), constant=True, repeats=repeats)
|
|
178
|
+
point = Point(None, float(median(times)), float(peak))
|
|
179
|
+
return BenchResult(
|
|
180
|
+
points=[point],
|
|
181
|
+
protocol=protocol,
|
|
182
|
+
exponential=False,
|
|
183
|
+
constant=True,
|
|
184
|
+
timed_out=False,
|
|
185
|
+
worst_s=float(max(times)),
|
|
186
|
+
median_s=float(median(times)),
|
|
187
|
+
worst_n=None,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _explosive(n0, t0, n1, t1) -> bool:
|
|
192
|
+
if t0 <= 0 or t1 <= 0 or n1 <= n0:
|
|
193
|
+
return False
|
|
194
|
+
n_ratio = n1 / n0
|
|
195
|
+
t_ratio = t1 / t0
|
|
196
|
+
if n_ratio < 1.5 and t_ratio > 2 and n1 <= 64 and t1 > 1e-4:
|
|
197
|
+
return True
|
|
198
|
+
if t1 < 1e-3 or n_ratio <= 1:
|
|
199
|
+
return False
|
|
200
|
+
exponent = math.log(t_ratio) / math.log(n_ratio)
|
|
201
|
+
return exponent > 5
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _call(func, args, kwargs):
|
|
205
|
+
result = func(*args, **kwargs)
|
|
206
|
+
if inspect.iscoroutine(result):
|
|
207
|
+
return asyncio.run(result)
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _measure_call(func, args, kwargs, *, file, qualname, repeats, timeout):
|
|
212
|
+
if file and qualname:
|
|
213
|
+
return _run_process(file, qualname, args, kwargs, repeats, timeout)
|
|
214
|
+
times, peak = collect_sample(func, args, kwargs, repeats)
|
|
215
|
+
return "ok", times, peak
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def collect_sample(func, args, kwargs, repeats: int = _REPEATS):
|
|
219
|
+
"""Warm up, time several calls, then record one tracemalloc peak."""
|
|
220
|
+
_call(func, args, kwargs)
|
|
221
|
+
batch = 1
|
|
222
|
+
|
|
223
|
+
def once() -> float:
|
|
224
|
+
started = time.perf_counter()
|
|
225
|
+
for _ in range(batch):
|
|
226
|
+
_call(func, args, kwargs)
|
|
227
|
+
return (time.perf_counter() - started) / batch
|
|
228
|
+
|
|
229
|
+
first = once()
|
|
230
|
+
if first < 0.001:
|
|
231
|
+
batch = min(10_000, max(1, math.ceil(0.002 / max(first, 1e-9))))
|
|
232
|
+
first = once()
|
|
233
|
+
times = [first]
|
|
234
|
+
if first <= 0.2:
|
|
235
|
+
for _ in range(repeats - 1):
|
|
236
|
+
duration = once()
|
|
237
|
+
times.append(duration)
|
|
238
|
+
if duration > 0.2:
|
|
239
|
+
break
|
|
240
|
+
tracemalloc.start()
|
|
241
|
+
try:
|
|
242
|
+
_call(func, args, kwargs)
|
|
243
|
+
_current, peak = tracemalloc.get_traced_memory()
|
|
244
|
+
finally:
|
|
245
|
+
tracemalloc.stop()
|
|
246
|
+
return times, float(peak)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _run_process(file, qualname, args, kwargs, repeats, timeout):
|
|
250
|
+
context = multiprocessing.get_context("spawn")
|
|
251
|
+
parent, child = context.Pipe(duplex=False)
|
|
252
|
+
process = context.Process(
|
|
253
|
+
target=_worker,
|
|
254
|
+
args=(child, str(file), qualname, args, kwargs, repeats),
|
|
255
|
+
daemon=True,
|
|
256
|
+
)
|
|
257
|
+
process.start()
|
|
258
|
+
child.close()
|
|
259
|
+
try:
|
|
260
|
+
process.join(timeout)
|
|
261
|
+
if process.is_alive():
|
|
262
|
+
process.terminate()
|
|
263
|
+
process.join()
|
|
264
|
+
return "timeout", None, None
|
|
265
|
+
if not parent.poll(0.2):
|
|
266
|
+
return "error", "The measurement process stopped without a result.", None
|
|
267
|
+
message = parent.recv()
|
|
268
|
+
finally:
|
|
269
|
+
parent.close()
|
|
270
|
+
if not message or message[0] != "ok":
|
|
271
|
+
detail = message[1] if message and len(message) > 1 else "measurement failed"
|
|
272
|
+
return "error", detail, None
|
|
273
|
+
return "ok", message[1], message[2]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _worker(conn, file, qualname, args, kwargs, repeats):
|
|
277
|
+
try:
|
|
278
|
+
func = load_function(file, qualname)
|
|
279
|
+
times, peak = collect_sample(func, args, kwargs, repeats)
|
|
280
|
+
conn.send(("ok", times, peak))
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
try:
|
|
283
|
+
conn.send(("err", f"{type(exc).__name__}: {exc}"))
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
finally:
|
|
287
|
+
conn.close()
|
ocalc/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Command line for measuring and annotating functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ocalc import FunctionNotFound, NeedSample, annotate_file, measure, parse_sample
|
|
9
|
+
from ocalc.comment import format_metrics, format_table
|
|
10
|
+
from ocalc.generate import load_function
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv=None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="ocalc",
|
|
16
|
+
description="Measure a Python function and estimate its time and space complexity.",
|
|
17
|
+
)
|
|
18
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
|
|
20
|
+
measure_cmd = commands.add_parser("measure", help="Print a complexity report.")
|
|
21
|
+
annotate_cmd = commands.add_parser("annotate", help="Write the complexity comment above a function.")
|
|
22
|
+
for command in (measure_cmd, annotate_cmd):
|
|
23
|
+
command.add_argument("file", help="Python file that defines the function.")
|
|
24
|
+
command.add_argument("-f", "--function", required=True, help="Function qualname, such as two_sum.")
|
|
25
|
+
command.add_argument(
|
|
26
|
+
"--sample",
|
|
27
|
+
default="",
|
|
28
|
+
help='Literal arguments, for example "[1, 2, 3], 9".',
|
|
29
|
+
)
|
|
30
|
+
protocol = command.add_argument_group("protocol")
|
|
31
|
+
protocol.add_argument("--budget", type=float, default=None, help="Seconds to spend measuring. Default: 5.")
|
|
32
|
+
protocol.add_argument("--min-n", type=int, default=None, help="Smallest input size. Default: 100, or 8 for a single int.")
|
|
33
|
+
protocol.add_argument("--max-n", type=int, default=None, help="Largest input size. Default: 100000.")
|
|
34
|
+
protocol.add_argument("--measures", type=int, default=None, help="How many sizes to try. Default: 8.")
|
|
35
|
+
protocol.add_argument("--repeats", type=int, default=None, help="Timed calls at each size. Default: 5.")
|
|
36
|
+
commands.add_parser("mcp", help="Serve the measure and annotate tools over stdio.")
|
|
37
|
+
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
if args.command == "mcp":
|
|
40
|
+
from ocalc.mcp_server import serve
|
|
41
|
+
|
|
42
|
+
serve()
|
|
43
|
+
return 0
|
|
44
|
+
try:
|
|
45
|
+
return _run(args)
|
|
46
|
+
except NeedSample as exc:
|
|
47
|
+
print(exc, file=sys.stderr)
|
|
48
|
+
return 2
|
|
49
|
+
except FunctionNotFound as exc:
|
|
50
|
+
print(exc, file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
except (RuntimeError, OSError) as exc:
|
|
53
|
+
print(exc, file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run(args) -> int:
|
|
58
|
+
sample_args, sample_kwargs = parse_sample(args.sample)
|
|
59
|
+
options = dict(
|
|
60
|
+
budget=args.budget,
|
|
61
|
+
min_n=args.min_n,
|
|
62
|
+
max_n=args.max_n,
|
|
63
|
+
measures=args.measures,
|
|
64
|
+
repeats=args.repeats,
|
|
65
|
+
)
|
|
66
|
+
if args.command == "measure":
|
|
67
|
+
func = load_function(args.file, args.function)
|
|
68
|
+
report = measure(
|
|
69
|
+
func,
|
|
70
|
+
*sample_args,
|
|
71
|
+
file=args.file,
|
|
72
|
+
qualname=args.function,
|
|
73
|
+
**sample_kwargs,
|
|
74
|
+
**options,
|
|
75
|
+
)
|
|
76
|
+
print(format_table(report.points))
|
|
77
|
+
print()
|
|
78
|
+
print(format_metrics(report.comment))
|
|
79
|
+
return 0
|
|
80
|
+
report = annotate_file(args.file, args.function, *sample_args, **sample_kwargs, **options)
|
|
81
|
+
print(format_metrics(report.comment))
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|