python-constricter 0.3.3__py3-none-any.whl → 0.3.6__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.
- constricter/__init__.py +1 -1
- constricter/__main__.py +19 -2
- constricter/cli/command.py +175 -92
- constricter/cli/config.py +28 -1
- constricter/cli/hints.py +19 -13
- constricter/cli/options.py +63 -3
- constricter/cli/protocol.py +25 -5
- constricter/cli/remembered.py +52 -0
- constricter/cli/report.py +10 -5
- constricter/cli/runs.py +49 -2
- constricter/cli/schedule.py +221 -68
- constricter/cli/traced.py +191 -79
- constricter/cli/workers.py +85 -23
- constricter/fix/core/fixes.py +77 -8
- constricter/fix/core/imports.py +151 -4
- constricter/fix/core/inherited.py +117 -17
- constricter/fix/core/known.py +218 -53
- constricter/fix/core/signatures.py +22 -4
- constricter/fix/index/atoms.py +18 -3
- constricter/fix/index/awaits.py +90 -0
- constricter/fix/index/beyond.py +87 -29
- constricter/fix/index/callers.py +2 -1
- constricter/fix/index/chained.py +69 -0
- constricter/fix/index/classnames.py +17 -0
- constricter/fix/index/declared.py +68 -4
- constricter/fix/index/decorated.py +3 -1
- constricter/fix/index/installed.py +35 -3
- constricter/fix/index/linked.py +142 -0
- constricter/fix/index/loose.py +98 -0
- constricter/fix/index/managed.py +63 -0
- constricter/fix/index/modules.py +346 -56
- constricter/fix/index/offers.py +4 -1
- constricter/fix/index/order.py +10 -3
- constricter/fix/index/own_methods.py +67 -0
- constricter/fix/index/own_overloads.py +14 -1
- constricter/fix/index/own_types.py +161 -0
- constricter/fix/index/plain.py +3 -1
- constricter/fix/index/project.py +142 -162
- constricter/fix/index/sides.py +102 -7
- constricter/fix/index/strings.py +46 -0
- constricter/fix/index/stubbed.py +51 -12
- constricter/fix/index/taken.py +45 -0
- constricter/fix/index/tuples.py +54 -28
- constricter/fix/libraries/library.py +95 -7
- constricter/fix/libraries/opened.py +57 -16
- constricter/fix/libraries/overloads.py +32 -6
- constricter/fix/libraries/stdlib.py +64 -13
- constricter/fix/libraries/walks.py +49 -0
- constricter/fix/tables/attributes.json +2 -0
- constricter/fix/tables/classes.json +1 -0
- constricter/fix/tables/generic_attributes.json +2 -0
- constricter/fix/tables/method_overloads.json +14 -6
- constricter/fix/tables/method_signatures.json +50 -20
- constricter/fix/tables/methods.json +1 -0
- constricter/fix/tables/overloads.json +38 -37
- constricter/fix/tables/scalars.json +2 -0
- constricter/fix/tables/source.json +1 -1
- constricter/fix/tables/type_parameters.json +1 -0
- constricter/fix/values/aliased.py +18 -5
- constricter/fix/values/bodies.py +76 -0
- constricter/fix/values/callables.py +321 -0
- constricter/fix/values/called.py +10 -12
- constricter/fix/values/classvars.py +124 -21
- constricter/fix/values/decided.py +22 -2
- constricter/fix/values/doubts.py +117 -29
- constricter/fix/values/entered.py +192 -34
- constricter/fix/values/fills.py +161 -13
- constricter/fix/values/guesses.py +51 -19
- constricter/fix/values/inference.py +50 -54
- constricter/fix/values/members.py +284 -34
- constricter/fix/values/narrowed.py +60 -4
- constricter/fix/values/operated.py +7 -6
- constricter/fix/values/ordered.py +74 -0
- constricter/fix/values/returned.py +319 -224
- constricter/fix/values/returns.py +44 -1
- constricter/fix/values/shapes.py +218 -33
- constricter/fix/values/targets.py +42 -15
- constricter/fix/values/unchecked.py +278 -0
- constricter/offences.py +119 -7
- constricter/recording.py +583 -0
- constricter/rules/annotations.py +30 -10
- constricter/rules/binding.py +230 -37
- constricter/rules/calls.py +58 -8
- constricter/rules/checker.py +182 -117
- constricter/rules/keys.py +79 -0
- constricter/rules/late.py +356 -10
- constricter/rules/quoted.py +53 -0
- constricter/rules/rebinding.py +35 -10
- constricter/rules/recorded.py +12 -8
- constricter/rules/scope.py +254 -86
- constricter/rules/syntax.py +20 -0
- constricter/rules/tables.py +18 -4
- constricter/rules/widened.py +322 -0
- constricter/trace.py +247 -254
- {python_constricter-0.3.3.dist-info → python_constricter-0.3.6.dist-info}/METADATA +45 -41
- python_constricter-0.3.6.dist-info/RECORD +140 -0
- {python_constricter-0.3.3.dist-info → python_constricter-0.3.6.dist-info}/entry_points.txt +1 -1
- python_constricter-0.3.3.dist-info/RECORD +0 -122
- {python_constricter-0.3.3.dist-info → python_constricter-0.3.6.dist-info}/WHEEL +0 -0
- {python_constricter-0.3.3.dist-info → python_constricter-0.3.6.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py
CHANGED
constricter/__main__.py
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
"""`python -m constricter
|
|
2
|
+
"""`python -m constricter`, and the `constricter` script."""
|
|
3
3
|
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
4
6
|
import sys
|
|
5
7
|
|
|
6
8
|
from constricter.cli.command import main
|
|
7
9
|
|
|
10
|
+
|
|
11
|
+
def run() -> None:
|
|
12
|
+
"""Run the command, and leave with its status at once.
|
|
13
|
+
|
|
14
|
+
Not by the interpreter's own exit, which frees every parsed file and the index an object at a
|
|
15
|
+
time: a fifth of a check of a thousand files. What the command wrote is flushed first; nothing
|
|
16
|
+
else waits on the exit (its workers have ended, its output file is closed).
|
|
17
|
+
"""
|
|
18
|
+
status: int = main()
|
|
19
|
+
with contextlib.suppress(OSError, ValueError): # a reader that left, a stream closed already
|
|
20
|
+
_ = sys.stdout.flush()
|
|
21
|
+
_ = sys.stderr.flush()
|
|
22
|
+
os._exit(status)
|
|
23
|
+
|
|
24
|
+
|
|
8
25
|
if __name__ == "__main__": # not when `--jobs` workers import it
|
|
9
|
-
|
|
26
|
+
run()
|
constricter/cli/command.py
CHANGED
|
@@ -21,16 +21,17 @@ from constricter.cli.options import Mode, Options, Output
|
|
|
21
21
|
from constricter.cli.paths import STDIN, python_files
|
|
22
22
|
from constricter.cli.protocol import HintError
|
|
23
23
|
from constricter.cli.report import Format, Result, fix_reasons, render, statistics
|
|
24
|
-
from constricter.cli.runs import BaselineRun, CheckRun, CoverageRun, FileRun
|
|
25
|
-
from constricter.cli.workers import Workers
|
|
24
|
+
from constricter.cli.runs import Added, BaselineRun, CheckRun, CoverageRun, FileRun, merged
|
|
25
|
+
from constricter.cli.workers import Workers, kept, started
|
|
26
26
|
from constricter.fix.core import fixes
|
|
27
27
|
from constricter.fix.core.known import Callee, Hints, Outside, Returns
|
|
28
|
-
from constricter.fix.index import callers, decorated, installed, plain, project
|
|
28
|
+
from constricter.fix.index import callers, decorated, installed, loose, plain, project
|
|
29
29
|
from constricter.noqa import lines, unsuppressed
|
|
30
30
|
from constricter.offences import (
|
|
31
31
|
DEFAULT_CHECKS,
|
|
32
32
|
Checks,
|
|
33
33
|
Edit,
|
|
34
|
+
Fix,
|
|
34
35
|
Offence,
|
|
35
36
|
)
|
|
36
37
|
from constricter.rules import parsed
|
|
@@ -127,6 +128,7 @@ def _placed(offence: Offence, where: list[notebook.Line]) -> Offence:
|
|
|
127
128
|
|
|
128
129
|
|
|
129
130
|
# One changed part of a fixed file (the file, or a notebook's cell): its label, old and new lines.
|
|
131
|
+
_Hinted: TypeAlias = dict[Path, tuple[Hints, ...]] # each file's hints, a checker's each
|
|
130
132
|
_Change: TypeAlias = tuple[str, list[str], list[str]]
|
|
131
133
|
# One JSON value a coverage report holds, and one file's row of them.
|
|
132
134
|
_Scalar: TypeAlias = str | int | float
|
|
@@ -183,7 +185,10 @@ def fix_file(path: Path, offences: Sequence[Offence]) -> int:
|
|
|
183
185
|
return count
|
|
184
186
|
|
|
185
187
|
|
|
186
|
-
HINT_ROUNDS: Final = 4 # with `--fix
|
|
188
|
+
HINT_ROUNDS: Final = 4 # with `--fix`: how many times a file is fixed, at most (see `_settled`)
|
|
189
|
+
# The fix kinds a scope's check comes to late, once the scope is read: what was bound to such a
|
|
190
|
+
# name's value before had no type yet, and has one once the fix is written.
|
|
191
|
+
_LATE_KINDS: Final = frozenset({"optional", "filled"})
|
|
187
192
|
|
|
188
193
|
|
|
189
194
|
def _shown_lines(raw: str, name: Path) -> dict[tuple[int | None, int], str]:
|
|
@@ -287,9 +292,8 @@ def _handled(path: Path, text: tuple[str, Path], offences: list[Offence], option
|
|
|
287
292
|
baselined: int
|
|
288
293
|
offences, baselined = options.filter.unbaselined(name, offences)
|
|
289
294
|
results: list[Result] = _results(raw, name, offences, options)
|
|
290
|
-
unsafe: bool = options.unsafe_fixes
|
|
291
295
|
fixing: list[Offence] = [
|
|
292
|
-
r.offence for r in results if r.offence.edit is not None and (
|
|
296
|
+
r.offence for r in results if r.offence.edit is not None and options.guesses.applies(r.offence)
|
|
293
297
|
]
|
|
294
298
|
if options.mode is Mode.DIFF:
|
|
295
299
|
return CheckRun(text=_diff(raw, name, fixing))
|
|
@@ -308,7 +312,29 @@ def _handled(path: Path, text: tuple[str, Path], offences: list[Offence], option
|
|
|
308
312
|
return CheckRun(results, baselined, error=f"{name}: error: {message}")
|
|
309
313
|
if fixed:
|
|
310
314
|
_ = parsed.take(raw) # the tree of the text it had: no check will ask for it again
|
|
311
|
-
return CheckRun(
|
|
315
|
+
return CheckRun(
|
|
316
|
+
left,
|
|
317
|
+
baselined,
|
|
318
|
+
fixed,
|
|
319
|
+
made=[r._replace(replacements=(), fixed=True) for r in results if r.offence in fixing],
|
|
320
|
+
rounds=(_added(raw, name, fixing),),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _added(raw: str, name: Path, fixing: Sequence[Offence]) -> Added:
|
|
325
|
+
"""Find the lines fixing `fixing` adds to `raw`, the text of `name` (see `fixes.inserted`).
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Each one's cell (`None` outside a notebook) and the line it goes before; a notebook's cells
|
|
329
|
+
get declarations alone.
|
|
330
|
+
|
|
331
|
+
"""
|
|
332
|
+
if name.suffix != notebook.SUFFIX:
|
|
333
|
+
return tuple((None, line) for line in fixes.inserted(lines(raw), fixing))
|
|
334
|
+
declared: list[tuple[int, int]] = [
|
|
335
|
+
(o.cell or 0, o.edit.span[0]) for o in fixing if o.edit is not None and o.edit.edit is Edit.DECLARE
|
|
336
|
+
]
|
|
337
|
+
return tuple(sorted(declared))
|
|
312
338
|
|
|
313
339
|
|
|
314
340
|
def _baseline_path(path: Path, outside: Outside, options: Options) -> BaselineRun:
|
|
@@ -340,13 +366,23 @@ def _cover_path(path: Path, _outside: Outside, options: Options) -> CoverageRun:
|
|
|
340
366
|
|
|
341
367
|
|
|
342
368
|
def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
369
|
+
"""Check every file (see `_check_rounds`), its rounds by one set of worker processes.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
The names, and what each file found.
|
|
373
|
+
|
|
374
|
+
"""
|
|
375
|
+
with kept():
|
|
376
|
+
return _check_rounds(options)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _check_rounds(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
343
380
|
"""Check every file (`--jobs` at a time), in order.
|
|
344
381
|
|
|
345
382
|
With `--infer-with`, the type checker's server runs throughout: it's asked for every file's
|
|
346
383
|
hints first, and with `--fix`, each file a round changed is asked again and checked again, as its
|
|
347
|
-
new annotations change what the checker infers
|
|
348
|
-
|
|
349
|
-
neither, only what `--fix` offers.
|
|
384
|
+
new annotations change what the checker infers (see `_settled`). A baseline records offences,
|
|
385
|
+
and coverage counts annotations: hints change neither, only what `--fix` offers.
|
|
350
386
|
|
|
351
387
|
Returns:
|
|
352
388
|
The names, and what each file found.
|
|
@@ -364,7 +400,7 @@ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
|
364
400
|
if options.mode in {Mode.COVERAGE, Mode.WRITE_BASELINE}:
|
|
365
401
|
return names, _checked_all(paths, check, options)[0]
|
|
366
402
|
if not options.infer_with:
|
|
367
|
-
return names,
|
|
403
|
+
return names, _settled(paths, check, options, None)
|
|
368
404
|
session: hints.Session
|
|
369
405
|
# Imported only for `--infer-with`: it's slow to import, for every run's start.
|
|
370
406
|
with cast("type[hints.Session]", importlib.import_module("constricter.cli.hints").Session)(
|
|
@@ -373,36 +409,7 @@ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
|
373
409
|
options.jobs,
|
|
374
410
|
options.infer_memory,
|
|
375
411
|
) as session:
|
|
376
|
-
runs: list[FileRun]
|
|
377
|
-
modules: project.Index
|
|
378
|
-
runs, modules = _called_again(
|
|
379
|
-
paths,
|
|
380
|
-
_checked_all(paths, check, options, session),
|
|
381
|
-
check,
|
|
382
|
-
options,
|
|
383
|
-
session,
|
|
384
|
-
)
|
|
385
|
-
# The files the last round changed: only their hints can have changed.
|
|
386
|
-
again: list[int] = [
|
|
387
|
-
index
|
|
388
|
-
for index, run in enumerate(runs)
|
|
389
|
-
if options.mode is Mode.FIX and isinstance(run, CheckRun) and run.fixed and paths[index] != STDIN
|
|
390
|
-
]
|
|
391
|
-
for _ in range(HINT_ROUNDS - 1):
|
|
392
|
-
if not again:
|
|
393
|
-
break
|
|
394
|
-
redone: list[FileRun] = _checked_all(
|
|
395
|
-
[paths[i] for i in again],
|
|
396
|
-
check,
|
|
397
|
-
options,
|
|
398
|
-
session,
|
|
399
|
-
modules,
|
|
400
|
-
)[0]
|
|
401
|
-
index: int
|
|
402
|
-
run: FileRun
|
|
403
|
-
for index, run in zip(again, redone, strict=True):
|
|
404
|
-
runs[index] = _merged(runs[index], run)
|
|
405
|
-
again = [index for index, run in zip(again, redone, strict=True) if cast("CheckRun", run).fixed]
|
|
412
|
+
runs: list[FileRun] = _settled(paths, check, options, session)
|
|
406
413
|
checker: str
|
|
407
414
|
path: Path
|
|
408
415
|
for checker, path in session.abandoned:
|
|
@@ -411,6 +418,65 @@ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
|
411
418
|
return names, runs
|
|
412
419
|
|
|
413
420
|
|
|
421
|
+
def _settled(
|
|
422
|
+
paths: Sequence[Path],
|
|
423
|
+
check: Callable[[Path, Outside], FileRun],
|
|
424
|
+
options: Options,
|
|
425
|
+
session: "hints.Session | None",
|
|
426
|
+
) -> list[FileRun]:
|
|
427
|
+
"""Check every file, then again each one `--fix` changed in a way a second check would see.
|
|
428
|
+
|
|
429
|
+
Until a round changes nothing, `HINT_ROUNDS` at most: a second pass is then left nothing.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
What each file found, a file's rounds joined.
|
|
433
|
+
|
|
434
|
+
"""
|
|
435
|
+
runs: list[FileRun]
|
|
436
|
+
modules: project.Index
|
|
437
|
+
runs, modules = _called_again(
|
|
438
|
+
paths,
|
|
439
|
+
_checked_all(paths, check, options, session),
|
|
440
|
+
check,
|
|
441
|
+
options,
|
|
442
|
+
session,
|
|
443
|
+
)
|
|
444
|
+
again: list[int] = [
|
|
445
|
+
index
|
|
446
|
+
for index, run in enumerate(runs)
|
|
447
|
+
if options.mode is Mode.FIX and paths[index] != STDIN and _unsettled(run, session)
|
|
448
|
+
]
|
|
449
|
+
for _ in range(HINT_ROUNDS - 1):
|
|
450
|
+
if not again:
|
|
451
|
+
break
|
|
452
|
+
redone: list[FileRun] = _checked_all([paths[i] for i in again], check, options, session, modules)[0]
|
|
453
|
+
index: int
|
|
454
|
+
run: FileRun
|
|
455
|
+
for index, run in zip(again, redone, strict=True):
|
|
456
|
+
runs[index] = merged(runs[index], run)
|
|
457
|
+
again = [index for index, run in zip(again, redone, strict=True) if _unsettled(run, session)]
|
|
458
|
+
return runs
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _unsettled(run: FileRun, session: "hints.Session | None") -> bool:
|
|
462
|
+
"""Check whether a round of `--fix` changed a file in a way that changes what's inferred there.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Whether it fixed anything, with a type checker's hints: its new annotations change them.
|
|
466
|
+
Without, whether it made a fix the scope's check comes to late (`_LATE_KINDS`): what's bound
|
|
467
|
+
to such a name's value was read before its type was known.
|
|
468
|
+
|
|
469
|
+
"""
|
|
470
|
+
checked: CheckRun = cast("CheckRun", run)
|
|
471
|
+
return bool(checked.fixed) and (
|
|
472
|
+
session is not None
|
|
473
|
+
or any(
|
|
474
|
+
isinstance(made.offence.edit, Fix) and made.offence.edit.kinds & _LATE_KINDS
|
|
475
|
+
for made in checked.made
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
414
480
|
def _called_again(
|
|
415
481
|
paths: Sequence[Path],
|
|
416
482
|
first: tuple[list[FileRun], project.Index],
|
|
@@ -447,7 +513,7 @@ def _called_again(
|
|
|
447
513
|
calling = [
|
|
448
514
|
at
|
|
449
515
|
for at in calling
|
|
450
|
-
if
|
|
516
|
+
if loose.returned(modules, paths[at], {}) != loose.returned(before, paths[at], {})
|
|
451
517
|
]
|
|
452
518
|
return runs, _checked_more(paths, runs, calling, (check, options, session), modules)
|
|
453
519
|
|
|
@@ -473,7 +539,7 @@ def _checked_more(
|
|
|
473
539
|
at: int
|
|
474
540
|
run: FileRun
|
|
475
541
|
for at, run in zip(which, redone, strict=True):
|
|
476
|
-
runs[at] =
|
|
542
|
+
runs[at] = merged(runs[at], run) if how[1].mode is Mode.FIX else run
|
|
477
543
|
return modules
|
|
478
544
|
|
|
479
545
|
|
|
@@ -509,44 +575,29 @@ def _checked_all(
|
|
|
509
575
|
What each file found, and the index of every file's module.
|
|
510
576
|
|
|
511
577
|
"""
|
|
512
|
-
|
|
578
|
+
# Before a worker starts: one forked while the servers' threads run could wait on a lock one holds.
|
|
579
|
+
hinted: _Hinted = {} if session is None else session.hints(_texts(paths))
|
|
513
580
|
if options.input.trace is not None:
|
|
514
581
|
hinted = traced.merged(hinted, traced.load(options.input.trace), paths)
|
|
515
582
|
coverage: bool = options.mode is Mode.COVERAGE # needs nothing from the other files
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if not coverage:
|
|
527
|
-
modules = plain.settled(
|
|
528
|
-
decorated.passed(installed.with_installed(modules, installed.search_path())),
|
|
529
|
-
options.checks.plain_bases,
|
|
530
|
-
)
|
|
531
|
-
return schedule.checked(
|
|
532
|
-
paths,
|
|
533
|
-
check,
|
|
534
|
-
pool,
|
|
535
|
-
(modules, hinted),
|
|
536
|
-
_merged if options.mode is Mode.FIX else None,
|
|
583
|
+
pool: Workers | None = started(paths, options.jobs) if options.jobs != 1 and len(paths) > 1 else None
|
|
584
|
+
if modules is None and pool:
|
|
585
|
+
modules = project.Index({}, []) if coverage else pool.index()
|
|
586
|
+
elif modules is None:
|
|
587
|
+
modules = project.Index({}, []) if coverage else project.index(paths)
|
|
588
|
+
collecting.indexed()
|
|
589
|
+
if not coverage:
|
|
590
|
+
modules = plain.settled(
|
|
591
|
+
decorated.passed(installed.with_installed(modules, installed.search_path())),
|
|
592
|
+
options.checks.plain_bases,
|
|
537
593
|
)
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
"""
|
|
547
|
-
first: CheckRun = cast("CheckRun", before)
|
|
548
|
-
later: CheckRun = cast("CheckRun", after)
|
|
549
|
-
return replace(later, fixed=first.fixed + later.fixed)
|
|
594
|
+
return schedule.checked(
|
|
595
|
+
paths,
|
|
596
|
+
check,
|
|
597
|
+
pool,
|
|
598
|
+
(modules, hinted),
|
|
599
|
+
merged if options.mode is Mode.FIX else None,
|
|
600
|
+
)
|
|
550
601
|
|
|
551
602
|
|
|
552
603
|
def _texts(paths: Sequence[Path]) -> dict[Path, str]:
|
|
@@ -579,10 +630,16 @@ def _report(options: Options, runs: Sequence[FileRun], files: int) -> int:
|
|
|
579
630
|
results: list[Result] = [result for run in checked for result in run.results]
|
|
580
631
|
output: Output = options.output
|
|
581
632
|
text: bool = output.fmt in {Format.TEXT, Format.FULL}
|
|
633
|
+
# With `--show-fixes`, the fixes `--fix` made too: after the rest, as text or JSON.
|
|
634
|
+
shown: list[Result] = [*results, *(made for run in checked if output.show_fixes for made in run.made)]
|
|
582
635
|
line: str
|
|
583
|
-
for line in
|
|
636
|
+
for line in (
|
|
637
|
+
statistics(results)
|
|
638
|
+
if text and output.statistics
|
|
639
|
+
else render(output.fmt, shown if output.fmt is Format.JSON else results)
|
|
640
|
+
):
|
|
584
641
|
_ = sys.stdout.write(f"{line}\n")
|
|
585
|
-
for line in fix_reasons(
|
|
642
|
+
for line in fix_reasons(shown) if text and output.show_fixes else ():
|
|
586
643
|
_ = sys.stdout.write(f"{line}\n")
|
|
587
644
|
errors: int = sum(r.offence.is_error(r.level) for r in results)
|
|
588
645
|
if text and not output.quiet:
|
|
@@ -593,7 +650,10 @@ def _report(options: Options, runs: Sequence[FileRun], files: int) -> int:
|
|
|
593
650
|
parts.append(f"fixed {sum(run.fixed for run in checked)}")
|
|
594
651
|
guesses: int
|
|
595
652
|
if guesses := sum(r.offence.unsafe for r in results):
|
|
596
|
-
|
|
653
|
+
likely: int = sum(r.offence.likely for r in results)
|
|
654
|
+
parts.append(
|
|
655
|
+
f"{guesses} more with --unsafe-fixes" + f" ({likely} with --likely)" * bool(likely),
|
|
656
|
+
)
|
|
597
657
|
if options.filter.baseline_file is not None:
|
|
598
658
|
parts.append(f"{sum(run.baselined for run in checked)} baselined")
|
|
599
659
|
_ = sys.stdout.write("; ".join(parts) + ".\n")
|
|
@@ -611,30 +671,53 @@ def _coverage(options: Options, paths: Sequence[Path], runs: Sequence[FileRun])
|
|
|
611
671
|
counted: list[tuple[Path, Coverage]] = [
|
|
612
672
|
(path, run.coverage) for path, run in zip(paths, covered, strict=True) if run.coverage is not None
|
|
613
673
|
]
|
|
614
|
-
total: Coverage = Coverage(
|
|
674
|
+
total: Coverage = Coverage(
|
|
675
|
+
sum(c.typed for _, c in counted),
|
|
676
|
+
sum(c.total for _, c in counted),
|
|
677
|
+
sum(c.widened for _, c in counted),
|
|
678
|
+
)
|
|
615
679
|
if options.output.fmt is Format.JSON:
|
|
616
680
|
report: dict[str, _Scalar | list[_Row]] = {
|
|
617
|
-
|
|
618
|
-
"
|
|
619
|
-
"percent": round(total.percent, 1),
|
|
620
|
-
"files": [
|
|
621
|
-
{"path": str(path), "typed": c.typed, "total": c.total, "percent": round(c.percent, 1)}
|
|
622
|
-
for path, c in counted
|
|
623
|
-
],
|
|
681
|
+
**_counts(total),
|
|
682
|
+
"files": [{"path": str(path), **_counts(c)} for path, c in counted],
|
|
624
683
|
}
|
|
625
684
|
_ = sys.stdout.write(json.dumps(report, indent=2) + "\n")
|
|
626
685
|
else:
|
|
627
686
|
path: Path
|
|
628
687
|
c: Coverage
|
|
629
688
|
for path, c in counted:
|
|
630
|
-
_ = sys.stdout.write(f"{path}: {
|
|
631
|
-
_ = sys.stdout.write(
|
|
632
|
-
f"Total: {total.typed}/{total.total} typed ({total.percent:.1f}%) in {len(counted)} file(s).\n",
|
|
633
|
-
)
|
|
689
|
+
_ = sys.stdout.write(f"{path}: {_typed(c)}\n")
|
|
690
|
+
_ = sys.stdout.write(f"Total: {_typed(total)} in {len(counted)} file(s).\n")
|
|
634
691
|
threshold: float | None = options.output.fail_under
|
|
635
692
|
return EXIT_FOUND if threshold is not None and total.percent < threshold else EXIT_CLEAN
|
|
636
693
|
|
|
637
694
|
|
|
695
|
+
def _counts(found: Coverage) -> _Row:
|
|
696
|
+
"""Name a coverage's counts, for `--format=json`.
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
Them, by name.
|
|
700
|
+
|
|
701
|
+
"""
|
|
702
|
+
return {
|
|
703
|
+
"typed": found.typed,
|
|
704
|
+
"total": found.total,
|
|
705
|
+
"widened": found.widened,
|
|
706
|
+
"percent": round(found.percent, 1),
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _typed(found: Coverage) -> str:
|
|
711
|
+
"""Write a coverage as text: `3/4 typed (75.0%)`, then what a widening annotated, if anything.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
It.
|
|
715
|
+
|
|
716
|
+
"""
|
|
717
|
+
wider: str = f", {found.widened} widened" if found.widened else ""
|
|
718
|
+
return f"{found.typed}/{found.total} typed ({found.percent:.1f}%){wider}"
|
|
719
|
+
|
|
720
|
+
|
|
638
721
|
def _run(options: Options) -> int:
|
|
639
722
|
"""Check, fix, diff, count or baseline, printing what that finds.
|
|
640
723
|
|
constricter/cli/config.py
CHANGED
|
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Final, TypeAlias, cast
|
|
|
10
10
|
|
|
11
11
|
from constricter.cli.protocol import SERVERS
|
|
12
12
|
from constricter.jsonc import is_int
|
|
13
|
-
from constricter.offences import FIX_KINDS, LEVELS, MESSAGES
|
|
13
|
+
from constricter.offences import FIX_KINDS, LEVELS, MESSAGES, WIDEN_KINDS
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from datetime import date, datetime, time
|
|
@@ -32,6 +32,7 @@ def unknown_codes(codes: Sequence[str]) -> list[str]:
|
|
|
32
32
|
|
|
33
33
|
DEFAULT_BASELINE: Final = "constricter-baseline.json"
|
|
34
34
|
_MIN_PYTHON: Final = "min_python"
|
|
35
|
+
_ALL_KINDS: Final = "all" # `fix-widen`'s: every wider type
|
|
35
36
|
_VERSION: Final = re.compile(r"\d+\.\d+") # `min-python`'s: `3.11`
|
|
36
37
|
# A lower bound among `requires-python`'s specifiers: `>=3.11`, `~=3.11`, `==3.11.*`, `>3.10`.
|
|
37
38
|
_LOWER_BOUND: Final = re.compile(r"(?:>=|~=|==|>)\s*(\d+)(?:\.(\d+))?")
|
|
@@ -160,6 +161,31 @@ def unknown_fix_kinds(kinds: Sequence[str]) -> list[str]:
|
|
|
160
161
|
return [kind for kind in kinds if kind not in FIX_KINDS]
|
|
161
162
|
|
|
162
163
|
|
|
164
|
+
def unknown_widen_kinds(kinds: Sequence[str]) -> list[str]:
|
|
165
|
+
"""Check `kinds` against `WIDEN_KINDS`' ids, and `all`.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Those that aren't one.
|
|
169
|
+
|
|
170
|
+
"""
|
|
171
|
+
return [kind for kind in kinds if kind not in WIDEN_KINDS | {_ALL_KINDS}]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def widen_kinds(kinds: Sequence[str]) -> list[str]:
|
|
175
|
+
"""Read `fix-widen`'s kinds, `all` as every one.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Them.
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
return sorted(WIDEN_KINDS) if _ALL_KINDS in kinds else list(kinds)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _widen_kinds(value: _Toml) -> list[str] | None:
|
|
185
|
+
kinds: list[str] | None = [value] if isinstance(value, str) else _strings(value)
|
|
186
|
+
return None if kinds is None or unknown_widen_kinds(kinds) else widen_kinds(kinds)
|
|
187
|
+
|
|
188
|
+
|
|
163
189
|
def _fix_kinds(value: _Toml) -> list[str] | None:
|
|
164
190
|
kinds: list[str] | None = _strings(value)
|
|
165
191
|
return None if kinds is None or unknown_fix_kinds(kinds) else kinds
|
|
@@ -246,6 +272,7 @@ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
|
|
|
246
272
|
"fix-select": _fix_kinds,
|
|
247
273
|
"fix-ignore": _fix_kinds,
|
|
248
274
|
"unsafe-fix-select": _fix_kinds,
|
|
275
|
+
"fix-widen": _widen_kinds,
|
|
249
276
|
"fix-plain-bases": _strings,
|
|
250
277
|
"type-comments": _flag,
|
|
251
278
|
"all-scopes": _flag,
|
constricter/cli/hints.py
CHANGED
|
@@ -33,7 +33,7 @@ from functools import partial
|
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
from typing import IO, Final, NamedTuple, Self, TypeAlias, cast
|
|
35
35
|
|
|
36
|
-
from constricter.cli import edits, guard, protocol
|
|
36
|
+
from constricter.cli import edits, guard, protocol, remembered
|
|
37
37
|
from constricter.cli.protocol import SERVERS, HintError, HungError, Server
|
|
38
38
|
from constricter.fix.core.known import Hints, Offered
|
|
39
39
|
|
|
@@ -47,8 +47,6 @@ _Task: TypeAlias = Callable[[], _Found] # one server's work: the hints of each
|
|
|
47
47
|
|
|
48
48
|
_BATCH: Final = 8 # files a free server takes at a time: few, so none is left with the slow ones
|
|
49
49
|
MEMORY: Final = 8 << 30 # the most a checker's servers use together, by default (`--infer-memory`)
|
|
50
|
-
_SERVER_MEMORY: Final = 600 << 20 # a server's memory, before the files (basedpyright's, measured)
|
|
51
|
-
_MEMORY_PER_BYTE: Final = 130 # and for each byte of them: 1.2 GB for SQLAlchemy's 8 MB, 3.4 for pandas' 21
|
|
52
50
|
_MEMINFO: Final = Path("/proc/meminfo")
|
|
53
51
|
_FILES_PER_SERVER: Final = 32 # fewer files than this each don't pay for another server's start
|
|
54
52
|
# Seconds without a word from a server before it's taken for hung. Not an answer's wait: pandas'
|
|
@@ -228,11 +226,10 @@ class Checker:
|
|
|
228
226
|
"""One checker's servers, and which of them has each file.
|
|
229
227
|
|
|
230
228
|
It has as many as its files need (one per `_FILES_PER_SERVER`), up to the command's `--jobs`, the
|
|
231
|
-
checker's own most (`Server.most
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
One server it always has, whatever the memory.
|
|
229
|
+
checker's own most (`Server.most`), and as many as fit in its memory: `--infer-memory` (never
|
|
230
|
+
more than is available), or by default `MEMORY` (never more than half what's available). Each
|
|
231
|
+
server loads the whole program it checks, or its share of it, by its checker's own measure (see
|
|
232
|
+
`Server.fitting`). One server it always has, whatever the memory.
|
|
236
233
|
|
|
237
234
|
The files are handed out a few at a time (`_BATCH`), the biggest first, to whichever server is
|
|
238
235
|
free: one file's analysis can take far longer than another's, and none waits on a busy one. A
|
|
@@ -244,7 +241,8 @@ class Checker:
|
|
|
244
241
|
self.name: str = name
|
|
245
242
|
self.root: Path = root
|
|
246
243
|
self.command: list[str] = command(name)
|
|
247
|
-
self.
|
|
244
|
+
self.server: Server = SERVERS[name]
|
|
245
|
+
self.most: int = max(1, min(servers, self.server.most))
|
|
248
246
|
self.memory: int = budget(memory, available_memory())
|
|
249
247
|
self.servers: list[Connection] = []
|
|
250
248
|
self.assigned: dict[Path, int] = {} # each file's server, by its place in `servers`
|
|
@@ -258,13 +256,14 @@ class Checker:
|
|
|
258
256
|
|
|
259
257
|
"""
|
|
260
258
|
size: int = sum(len(text) for text in files.values())
|
|
261
|
-
fit: int = max(1, self.memory
|
|
259
|
+
fit: int = max(1, self.server.fitting(self.memory, size))
|
|
262
260
|
return max(len(self.servers), min(self.most, -(-len(files) // _FILES_PER_SERVER), fit))
|
|
263
261
|
|
|
264
262
|
def tasks(self, files: Mapping[Path, str]) -> list[_Task]:
|
|
265
263
|
"""Make each server's work on `files`: its own files first, then new ones as it's free for them.
|
|
266
264
|
|
|
267
|
-
A file abandoned (see `_alone`) isn't
|
|
265
|
+
A file abandoned (see `_alone`), in this run or an earlier one (see `remembered`), isn't
|
|
266
|
+
asked about again.
|
|
268
267
|
|
|
269
268
|
Returns:
|
|
270
269
|
A task per server, giving the hints of every file it was sent.
|
|
@@ -274,8 +273,14 @@ class Checker:
|
|
|
274
273
|
path: Path
|
|
275
274
|
for path in files.keys() & self.assigned.keys() - self.abandoned:
|
|
276
275
|
own.setdefault(self.assigned[path], {})[path] = files[path]
|
|
276
|
+
# One it hung on in an earlier run, unchanged since: left without hints from the start.
|
|
277
|
+
self.abandoned.update(
|
|
278
|
+
path
|
|
279
|
+
for path in files.keys() - self.assigned.keys() - self.abandoned
|
|
280
|
+
if remembered.hung(self.name, self.command, files[path])
|
|
281
|
+
)
|
|
277
282
|
fresh: list[Path] = sorted(
|
|
278
|
-
files.keys() - self.assigned.keys(),
|
|
283
|
+
files.keys() - self.assigned.keys() - self.abandoned,
|
|
279
284
|
key=lambda new: (-len(files[new]), new),
|
|
280
285
|
)
|
|
281
286
|
batches: queue.SimpleQueue[dict[Path, str]] = queue.SimpleQueue()
|
|
@@ -352,6 +357,7 @@ class Checker:
|
|
|
352
357
|
found.update(server.answers(server.ask({path: text})))
|
|
353
358
|
except HungError as hung:
|
|
354
359
|
self.abandoned.add(path)
|
|
360
|
+
remembered.remember(self.name, self.command, text)
|
|
355
361
|
if len(self.abandoned) > _MOST_ABANDONED:
|
|
356
362
|
error: str = f"{self.name} hung on {len(self.abandoned)} file(s), each asked about alone"
|
|
357
363
|
raise HintError(error) from hung
|
|
@@ -810,6 +816,6 @@ class Connection:
|
|
|
810
816
|
cast("IO[bytes]", self.process.stdin),
|
|
811
817
|
f"Content-Length: {len(body)}\r\n\r\n".encode() + body,
|
|
812
818
|
)
|
|
813
|
-
except OSError as error:
|
|
819
|
+
except (OSError, ValueError) as error: # it has gone, or its input was closed to stop it
|
|
814
820
|
failure: str = f"{self.name} exited: {error}"
|
|
815
821
|
raise HintError(failure) from error
|