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.
Files changed (100) hide show
  1. constricter/__init__.py +1 -1
  2. constricter/__main__.py +19 -2
  3. constricter/cli/command.py +175 -92
  4. constricter/cli/config.py +28 -1
  5. constricter/cli/hints.py +19 -13
  6. constricter/cli/options.py +63 -3
  7. constricter/cli/protocol.py +25 -5
  8. constricter/cli/remembered.py +52 -0
  9. constricter/cli/report.py +10 -5
  10. constricter/cli/runs.py +49 -2
  11. constricter/cli/schedule.py +221 -68
  12. constricter/cli/traced.py +191 -79
  13. constricter/cli/workers.py +85 -23
  14. constricter/fix/core/fixes.py +77 -8
  15. constricter/fix/core/imports.py +151 -4
  16. constricter/fix/core/inherited.py +117 -17
  17. constricter/fix/core/known.py +218 -53
  18. constricter/fix/core/signatures.py +22 -4
  19. constricter/fix/index/atoms.py +18 -3
  20. constricter/fix/index/awaits.py +90 -0
  21. constricter/fix/index/beyond.py +87 -29
  22. constricter/fix/index/callers.py +2 -1
  23. constricter/fix/index/chained.py +69 -0
  24. constricter/fix/index/classnames.py +17 -0
  25. constricter/fix/index/declared.py +68 -4
  26. constricter/fix/index/decorated.py +3 -1
  27. constricter/fix/index/installed.py +35 -3
  28. constricter/fix/index/linked.py +142 -0
  29. constricter/fix/index/loose.py +98 -0
  30. constricter/fix/index/managed.py +63 -0
  31. constricter/fix/index/modules.py +346 -56
  32. constricter/fix/index/offers.py +4 -1
  33. constricter/fix/index/order.py +10 -3
  34. constricter/fix/index/own_methods.py +67 -0
  35. constricter/fix/index/own_overloads.py +14 -1
  36. constricter/fix/index/own_types.py +161 -0
  37. constricter/fix/index/plain.py +3 -1
  38. constricter/fix/index/project.py +142 -162
  39. constricter/fix/index/sides.py +102 -7
  40. constricter/fix/index/strings.py +46 -0
  41. constricter/fix/index/stubbed.py +51 -12
  42. constricter/fix/index/taken.py +45 -0
  43. constricter/fix/index/tuples.py +54 -28
  44. constricter/fix/libraries/library.py +95 -7
  45. constricter/fix/libraries/opened.py +57 -16
  46. constricter/fix/libraries/overloads.py +32 -6
  47. constricter/fix/libraries/stdlib.py +64 -13
  48. constricter/fix/libraries/walks.py +49 -0
  49. constricter/fix/tables/attributes.json +2 -0
  50. constricter/fix/tables/classes.json +1 -0
  51. constricter/fix/tables/generic_attributes.json +2 -0
  52. constricter/fix/tables/method_overloads.json +14 -6
  53. constricter/fix/tables/method_signatures.json +50 -20
  54. constricter/fix/tables/methods.json +1 -0
  55. constricter/fix/tables/overloads.json +38 -37
  56. constricter/fix/tables/scalars.json +2 -0
  57. constricter/fix/tables/source.json +1 -1
  58. constricter/fix/tables/type_parameters.json +1 -0
  59. constricter/fix/values/aliased.py +18 -5
  60. constricter/fix/values/bodies.py +76 -0
  61. constricter/fix/values/callables.py +321 -0
  62. constricter/fix/values/called.py +10 -12
  63. constricter/fix/values/classvars.py +124 -21
  64. constricter/fix/values/decided.py +22 -2
  65. constricter/fix/values/doubts.py +117 -29
  66. constricter/fix/values/entered.py +192 -34
  67. constricter/fix/values/fills.py +161 -13
  68. constricter/fix/values/guesses.py +51 -19
  69. constricter/fix/values/inference.py +50 -54
  70. constricter/fix/values/members.py +284 -34
  71. constricter/fix/values/narrowed.py +60 -4
  72. constricter/fix/values/operated.py +7 -6
  73. constricter/fix/values/ordered.py +74 -0
  74. constricter/fix/values/returned.py +319 -224
  75. constricter/fix/values/returns.py +44 -1
  76. constricter/fix/values/shapes.py +218 -33
  77. constricter/fix/values/targets.py +42 -15
  78. constricter/fix/values/unchecked.py +278 -0
  79. constricter/offences.py +119 -7
  80. constricter/recording.py +583 -0
  81. constricter/rules/annotations.py +30 -10
  82. constricter/rules/binding.py +230 -37
  83. constricter/rules/calls.py +58 -8
  84. constricter/rules/checker.py +182 -117
  85. constricter/rules/keys.py +79 -0
  86. constricter/rules/late.py +356 -10
  87. constricter/rules/quoted.py +53 -0
  88. constricter/rules/rebinding.py +35 -10
  89. constricter/rules/recorded.py +12 -8
  90. constricter/rules/scope.py +254 -86
  91. constricter/rules/syntax.py +20 -0
  92. constricter/rules/tables.py +18 -4
  93. constricter/rules/widened.py +322 -0
  94. constricter/trace.py +247 -254
  95. {python_constricter-0.3.3.dist-info → python_constricter-0.3.6.dist-info}/METADATA +45 -41
  96. python_constricter-0.3.6.dist-info/RECORD +140 -0
  97. {python_constricter-0.3.3.dist-info → python_constricter-0.3.6.dist-info}/entry_points.txt +1 -1
  98. python_constricter-0.3.3.dist-info/RECORD +0 -122
  99. {python_constricter-0.3.3.dist-info → python_constricter-0.3.6.dist-info}/WHEEL +0 -0
  100. {python_constricter-0.3.3.dist-info → python_constricter-0.3.6.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py CHANGED
@@ -25,7 +25,7 @@ from constricter.offences import (
25
25
  )
26
26
  from constricter.rules.checker import Coverage, annotation_coverage, check_source, check_tree
27
27
 
28
- __version__ = "0.3.3"
28
+ __version__ = "0.3.6"
29
29
  __all__ = [
30
30
  "COMMENT_TYPED_TARGET",
31
31
  "DEFAULT_CHECKS",
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
- sys.exit(main())
26
+ run()
@@ -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 --infer-with`: how many times each file is fixed, at most
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 (unsafe or not r.offence.unsafe)
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(left, baselined, fixed)
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, until a round changes nothing (at most
348
- `HINT_ROUNDS`). A baseline records offences, and coverage counts annotations: hints change
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, _called_again(paths, _checked_all(paths, check, options), check, options)[0]
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 project.returned(modules, paths[at], {}) != project.returned(before, paths[at], {})
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] = _merged(runs[at], run) if how[1].mode is Mode.FIX else run
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
- hinted: dict[Path, tuple[Hints, ...]] = {} if session is None else session.hints(_texts(paths))
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
- stack: contextlib.ExitStack
517
- with contextlib.ExitStack() as stack:
518
- pool: Workers | None = None
519
- if options.jobs != 1 and len(paths) > 1:
520
- pool = stack.enter_context(Workers(paths, options.jobs))
521
- if modules is None and pool:
522
- modules = project.Index({}, []) if coverage else pool.index()
523
- elif modules is None:
524
- modules = project.Index({}, []) if coverage else project.index(paths)
525
- collecting.indexed()
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
- def _merged(before: FileRun, after: FileRun) -> FileRun:
541
- """Join a file's two `--fix` rounds' `CheckRun`s: what's left is the later's, what's fixed is both's.
542
-
543
- Returns:
544
- The joined run.
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 statistics(results) if text and output.statistics else render(output.fmt, results):
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(results) if text and output.show_fixes else ():
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
- parts.append(f"{guesses} more with --unsafe-fixes")
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(sum(c.typed for _, c in counted), sum(c.total for _, c in counted))
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
- "typed": total.typed,
618
- "total": total.total,
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}: {c.typed}/{c.total} typed ({c.percent:.1f}%)\n")
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`: one for a checker that works in parallel itself), and as many
232
- as fit in its memory: `--infer-memory` (never more than is available), or by default `MEMORY`
233
- (never more than half what's available). Each server loads the whole program it checks, which
234
- takes (as measured) about `_SERVER_MEMORY` plus `_MEMORY_PER_BYTE` for each byte of the files.
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.most: int = max(1, min(servers, SERVERS[name].most))
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 // (_SERVER_MEMORY + _MEMORY_PER_BYTE * size))
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 asked about again.
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