oct
Loading...
Searching...
No Matches
oct_format.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/formatter/oct_format.py
4
5"""
6Option C auto-formatter implementation.
7
8Purpose
9-------
10Detect and fix structural Option C compliance violations automatically.
11Complements the linter by fixing violations while preserving code semantics.
12
13Responsibilities
14----------------
15- Detect formatting violations using reused linter checks.
16- Fix violations: headers, docstrings, imports, function patterns.
17- Support report-only (dry-run) and fix modes.
18- Archive originals before modification.
19- Provide terminal and JSON output.
20- Respect project exclusions and configuration.
21
22Diagnostics
23-----------
24Domain: OCT.FORMATTER
25Levels:
26 L2 - formatter lifecycle and command routing
27 L3 - file discovery and processing, fix application decisions
28 L4 - detailed fixer implementations, archival operations, output formatting
29
30Contracts
31---------
32- Files are only modified if fix_mode is True.
33- Originals are archived before any modification.
34- All code below headers is preserved exactly.
35- Docstrings are not replaced; missing sections are appended.
36- Each fixer is idempotent (running twice is same as once).
37- A module is never given an import of a symbol its own package
38 provides, so formatting can never make a package import itself.
39
40Dependencies
41------------
42- oct (package version)
43- oct.linter.oct_lint (reused validation checks)
44- oct.core.exclusions (directory and wildcard exclusion lists)
45- oct.core.project_root (project root detection)
46- oct.core.parsing (safe AST parsing helper)
47- oct.core.git (lazy; git changed-file discovery)
48- oct.core.paths (to_project_relative - the identity header path)
49- oct.core.diagnostics (_dbg structured logger)
50- oct.core.octrc (lazy; .octrc.json exclusion and archive overrides)
51- oct.core.workspace (lazy; subproject lookup for the per-subproject fan-out)
52"""
53
54import sys
55import json
56import argparse
57import re
58from dataclasses import dataclass
59from pathlib import Path
60from datetime import datetime
61import ast
62from typing import Tuple, List, Dict, Any, Optional
63
64from oct import __version__
65from oct.linter.oct_lint import (
66 validate_header_block,
67 check_docstring,
68 check_diagnostics_profile,
69 check_dbg_import,
70 check_dbg_usage,
71 find_func_pattern_violations,
72 _extract_module_docstring,
73 _module_docstring_slice,
74 _describe_syntax_error,
75 find_python_files,
76 LinterContext,
77)
78from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
79from oct.core.project_root import find_project_root
80from oct.core.parsing import safe_parse
81from oct.core.paths import to_project_relative
82from oct.core.diagnostics import _dbg
83
84
85def _git_changed_files(root: Path) -> list[Path]:
86 """Return .py files modified in the git working tree (staged + unstaged).
87
88 Thin shim over :func:`oct.core.git.git_changed_files` (Phase 4A).
89 Collapses all ``GitError`` to empty list so the formatter never
90 crashes due to git unavailability.
91 """
92 from oct.core import git as core_git
93 try:
94 return core_git.git_changed_files(root, extensions={".py"})
95 except core_git.GitError:
96 return []
97
98# Template constants reused from generator
99SHEBANG = "#!/usr/bin/env python3"
100ENCODING = "# -*- coding: utf-8 -*-"
101
102IMPORT_BLOCK = "from oc_diagnostics import _dbg\n"
103
104DOCSTRING_SKELETON = '''\
105"""
106Purpose
107-------
108Describe the purpose of this module.
109
110Responsibilities
111----------------
112- List the responsibilities of this module.
113
114Diagnostics
115-----------
116Domain: <DOMAIN>
117Levels:
118 L2 - lifecycle
119 L3 - semantic details
120 L4 - deep tracing
121
122Contracts
123---------
124- State the contracts and guarantees of this module.
125"""
126'''
127
128MISSING_DOCSTRING_SECTIONS = {
129 "Purpose": """
130Purpose
131-------
132Describe the purpose of this module.""",
133 "Responsibilities": """
134Responsibilities
135----------------
136- List the responsibilities of this module.""",
137 "Diagnostics": """
138Diagnostics
139-----------
140Domain: <DOMAIN>
141Levels:
142 L2 - lifecycle
143 L3 - semantic details
144 L4 - deep tracing""",
145 "Contracts": """
146Contracts
147---------
148- State the contracts and guarantees of this module.""",
149}
150
151
152#: OI-424 - default cap on retained backups per file in
153#: ``.formatter_archive/``. Projects can override via
154#: ``formatter.max_backups_per_file`` in ``.octrc.json``.
155_DEFAULT_MAX_BACKUPS_PER_FILE: int = 10
156
157#: OI-516 - aggregate size cap per ``.formatter_archive/`` directory
158#: (5 MiB). Enforced after per-file count pruning so a single giant
159#: source file cannot fill the archive even within its 10-backup budget.
160_DEFAULT_MAX_ARCHIVE_BYTES: int = 5 * 1024 * 1024
161
162
163@dataclass
165 """Immutable per-run context for the formatter."""
166 project_root: Path
167 project_name: str
168 diagnostics_dir: Path
169 tests_dir: Path
170 docs_dir: Path
171 fix_mode: bool
172 dry_run_mode: bool
173 json_mode: bool
174 verbose: bool
175 max_backups_per_file: int = _DEFAULT_MAX_BACKUPS_PER_FILE
176 #: OI-516 - aggregate cap across all backups in one
177 #: ``.formatter_archive/`` directory.
178 max_archive_bytes: int = _DEFAULT_MAX_ARCHIVE_BYTES
179 #: Plan 277 - fixer names :func:`format_file` must NOT run on this
180 #: file. Empty by default, so ``oct format`` run directly behaves
181 #: exactly as it always has. The quality gate fills it per file from
182 #: the octrc rules, the path rules and the file's own inline waivers,
183 #: and does so in FIX mode too: before, a disabled fixer still ran
184 #: and ``--fix`` wrote its change to disk, and only the gate's count
185 #: of that change was suppressed afterwards.
186 disabled_fixers: frozenset = frozenset()
187
188
189# ============================================================================
190# Helper Functions
191# ============================================================================
192
193
194def _get_relative_path(path: Path, root: Path) -> str:
195 """Render *path* as a project-relative POSIX string.
196
197 200: delegates to :func:`oct.core.paths.to_project_relative`, the helper
198 OI-533 introduced to standardise path output - and the one the linter's
199 ``expected_path_header`` already uses. This function previously returned
200 ``str(path.relative_to(root))``, which renders with the OS separator, so
201 on Windows the identity header the formatter WROTE
202 (``# pkg\\sub\\mod.py``) was never the one the linter EXPECTED
203 (``# pkg/sub/mod.py``). Every file in a subdirectory therefore had its
204 header rewritten on every run, forever, each pass archiving the file
205 again - a direct breach of the module's own per-fixer idempotence
206 contract.
207
208 There is one source of truth for "the project-relative path" and both
209 tools now call it.
210 """
211 return to_project_relative(path, root)
212
213
215 archive_dir: Path, original_name: str, max_backups: int,
216) -> int:
217 """Keep only the ``max_backups`` most recent backups of ``original_name``.
218
219 Matches files shaped like ``{original_name}.*.bak`` and sorts them
220 by mtime (newest first). Older entries beyond the cap are removed.
221 Best-effort: any OS error is swallowed so a failed prune can never
222 block ``oct format`` (OI-424).
223
224 Returns the number of files removed. ``max_backups <= 0`` disables
225 pruning entirely (useful when a project deliberately wants unbounded
226 history).
227 """
228 func = "_prune_archive"
229 if max_backups <= 0:
230 return 0
231 try:
232 candidates = sorted(
233 archive_dir.glob(f"{original_name}.*.bak"),
234 key=lambda p: p.stat().st_mtime,
235 reverse=True,
236 )
237 except OSError:
238 return 0
239 removed = 0
240 for stale in candidates[max_backups:]:
241 try:
242 stale.unlink()
243 removed += 1
244 except OSError:
245 continue
246 return removed
247
248
250 archive_dir: Path, max_total_bytes: int,
251) -> int:
252 """OI-516: evict oldest ``.bak`` files until total size fits under ``max_total_bytes``.
253
254 Runs *after* per-file count pruning so the archive floor is always
255 correct under the two coexisting policies. Any OS error on size or
256 unlink is swallowed - a rotation failure must never block formatting.
257 Returns the number of files removed.
258 """
259 func = "_prune_archive_by_size"
260 if max_total_bytes <= 0:
261 return 0
262 try:
263 entries = [(p, p.stat().st_mtime, p.stat().st_size)
264 for p in archive_dir.glob("*.bak") if p.is_file()]
265 except OSError:
266 return 0
267 total = sum(size for _, _, size in entries)
268 if total <= max_total_bytes:
269 return 0
270 # Oldest first - evict until we fit.
271 entries.sort(key=lambda row: row[1])
272 removed = 0
273 for path, _, size in entries:
274 if total <= max_total_bytes:
275 break
276 try:
277 path.unlink()
278 total -= size
279 removed += 1
280 except OSError:
281 continue
282 return removed
283
284
286 path: Path,
287 max_backups: int = _DEFAULT_MAX_BACKUPS_PER_FILE,
288 max_archive_bytes: int = _DEFAULT_MAX_ARCHIVE_BYTES,
289) -> None:
290 """Archive original file to ``.formatter_archive/`` with timestamp.
291
292 After writing the new backup, prune in two passes:
293
294 1. Per-file count cap (OI-424): keep only the N newest backups per
295 source file name.
296 2. Per-directory size cap (OI-516): enforce aggregate ``max_archive_bytes``
297 across *all* backups in the archive, evicting oldest first.
298 """
299 func = "archive_file"
300 archive_dir = path.parent / ".formatter_archive"
301 archive_dir.mkdir(exist_ok=True)
302 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
303 backup_path = archive_dir / f"{path.name}.{timestamp}.bak"
304 backup_path.write_bytes(path.read_bytes())
305 _prune_archive(archive_dir, path.name, max_backups)
306 _prune_archive_by_size(archive_dir, max_archive_bytes)
307
308
309def _leading_comment_block_end(lines: List[str]) -> int:
310 """Return the 0-based index just past the leading comment/blank block.
311
312 Scans from the top while every line is blank or a comment, however long
313 the block runs, so an inserted construct never lands inside it. Shared
314 by the import and docstring fixers so both follow the same rule (186,
315 generalised by 198).
316 """
317 index = 0
318 for i, line in enumerate(lines):
319 stripped = line.strip()
320 if stripped and not stripped.startswith("#"):
321 break
322 index = i + 1
323 return index
324
325
326def _leading_whitespace(line: str) -> str:
327 """Return the indentation prefix of ``line``, tabs and spaces alike."""
328 return line[:len(line) - len(line.lstrip())]
329
330
331def _statement_start_index(stmt: ast.stmt) -> int:
332 """Return the 0-based index of the first source line owned by ``stmt``.
333
334 A decorated statement's own ``lineno`` points at its ``def``/``class``
335 keyword, not at its first decorator. Inserting a line at that index
336 would separate a decorator from the statement it decorates, which does
337 not parse - so decorators are folded in here (198).
338 """
339 lineno = stmt.lineno
340 for decorator in getattr(stmt, "decorator_list", []):
341 lineno = min(lineno, decorator.lineno)
342 return lineno - 1
343
344
345def _begins_its_own_line(stmt: ast.stmt, lines: List[str]) -> bool:
346 """Return True when nothing but whitespace precedes ``stmt`` on its line.
347
348 Distinguishes a body that occupies its own lines from one written inline
349 with its signature - ``def f(): return x``, or a multi-line signature
350 closed as ``) -> int: return x``. An inline body has no line of its own
351 to insert before (198).
352 """
353 index = stmt.lineno - 1
354 if index >= len(lines):
355 return False
356 return not lines[index][:stmt.col_offset].strip()
357
358
359def _module_docstring_span(text: str) -> Optional[Tuple[str, int]]:
360 """Locate the module docstring, returning ``(docstring, close_index)``.
361
362 ``close_index`` is the offset in *text* of the docstring's CLOSING
363 triple quote, which is where :func:`fix_docstring` appends the Option C
364 sections it finds missing.
365
366 200: the docstring itself comes from the linter's shared
367 :func:`~oct.linter.oct_lint._extract_module_docstring` - the helper this
368 module already uses at :func:`_detect_missing_docstring_sections` and at
369 the top of :func:`fix_docstring`. Only the POSITION is computed here.
370 That is the whole point: ``fix_docstring`` used to find its insertion
371 point with a raw scan for the first triple-double-quote in the file,
372 which is a second, independent opinion about which string is the module
373 docstring. With a single-quoted module docstring the raw scan found a
374 FUNCTION's docstring and the module sections were appended there. The
375 output still parsed, so nothing caught it.
376
377 204: the position is no longer recovered by searching the source for the
378 shared helper's answer - the helper now reports the span it found, so the
379 formatter and the linter agree on WHERE the docstring is structurally,
380 not by locating the same substring twice.
381
382 Returns ``None`` when there is no module docstring, or when its delimiter
383 is not a triple quote: the Option C sections are appended as new lines,
384 which a one-line ``'x'`` literal cannot carry without breaking the parse.
385 Callers must treat ``None`` as "make no change": misplacing (or
386 duplicating) Option C sections is worse than declining to add them.
387 """
388 func = "_module_docstring_span"
389
390 located = _module_docstring_slice(text)
391 if located is None or len(located.quote) != 3:
392 return None
393
394 return located.text, located.close_index
395
396
397def _detect_missing_docstring_sections(text: str) -> List[str]:
398 """Detect which docstring sections are missing."""
399 func = "_detect_missing_docstring_sections"
400 docstring = _extract_module_docstring(text)
401 if not docstring:
402 return ["Purpose", "Responsibilities", "Diagnostics", "Contracts"]
403
404 missing = []
405 lines = docstring.split('\n')
406 for section in ["Purpose", "Responsibilities", "Diagnostics", "Contracts"]:
407 found = any(line.strip().startswith(section) for line in lines)
408 if not found:
409 missing.append(section)
410 return missing
411
412
413# ============================================================================
414# Fix Functions
415# ============================================================================
416
417
418def fix_header_block(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
419 """
420 Ensure file has correct 4-line header block.
421
422 Returns (changed, new_text, messages). Caller is responsible for
423 archiving and writing to disk.
424 """
425 func = "fix_header_block"
426
427 # Validate using linter
428 header_lines = text.split('\n')[:4] if len(text.split('\n')) >= 4 else text.split('\n')
429 ok, errors = validate_header_block(header_lines, path, ctx)
430
431 if ok:
432 return False, text, []
433
434 # Reconstruct correct header
435 relative_path = _get_relative_path(path, ctx.project_root)
436 new_shebang = f"{SHEBANG}"
437 new_encoding = f"{ENCODING}"
438 new_identity = f"# {relative_path}"
439 new_blank = ""
440
441 new_header = new_shebang + "\n" + new_encoding + "\n" + new_identity + "\n" + new_blank + "\n"
442
443 # Find where the actual file content starts (after header block).
444 # OI-509: delegate to shared oct.core.parsing.header_boundary so the
445 # formatter and linter use identical scanning rules.
446 from oct.core.parsing import header_boundary
447 lines = text.split('\n')
448 relative_identity = f"# {relative_path}"
449 content_start = header_boundary(
450 lines, path, expected_identity=relative_identity,
451 )
452
453 # Take content after the old header
454 if content_start < len(lines):
455 remaining_content = '\n'.join(lines[content_start:])
456 else:
457 remaining_content = ""
458
459 new_text = new_header + remaining_content
460
461 messages = ["Fixed header block (shebang, encoding, file identity, blank line)"]
462 return True, new_text, messages
463
464
465def fix_docstring(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
466 """
467 Ensure module has complete docstring with all required sections.
468
469 If no docstring: insert skeleton.
470 If incomplete: add missing sections.
471 If complete: no change.
472 """
473 func = "fix_docstring"
474
475 docstring = _extract_module_docstring(text)
476
477 if not docstring:
478 # 204: "the authority cannot address a docstring here" is not the
479 # same statement as "this module has no docstring", and only the
480 # second one licenses injecting a skeleton. A module docstring that
481 # is not expressible as one source slice - implicitly concatenated
482 # literals - would otherwise be answered with a SECOND docstring
483 # above it, which parses, so nothing downstream would catch it.
484 # Unparseable text is declined for the same reason ``format_file``
485 # refuses to format it at all (175/F21): never rewrite what we
486 # cannot read.
487 tree, _ = safe_parse(text)
488 if tree is None or ast.get_docstring(tree) is not None:
489 return False, text, []
490
491 # 198: the skeleton goes after the leading comment block, wherever
492 # it actually ends. The constant 4 was the same hardcoded-offset
493 # assumption 186 removed from the import fixer; it happened to be
494 # right only because ``fix_header_block`` runs first in the
495 # pipeline and normalises the header to exactly four lines. Called
496 # on its own, or on a file carrying waiver comments above the code,
497 # it split the comment block instead.
498 lines = text.split('\n')
499 insert_pos = _leading_comment_block_end(lines)
500
501 new_text = '\n'.join(lines[:insert_pos]) + '\n' + DOCSTRING_SKELETON + '\n'.join(lines[insert_pos:])
502
503 return True, new_text, ["Added complete module docstring"]
504
505 # Check for missing sections
507 if not missing:
508 return False, text, []
509
510 # Add missing sections to existing docstring
511 sections_to_add = "\n".join(MISSING_DOCSTRING_SECTIONS[section] for section in missing)
512
513 # Insert before the MODULE docstring's closing quotes. 200: the position
514 # is derived from the shared extractor's own answer rather than from an
515 # independent scan, so this fixer cannot append module sections to some
516 # other string in the file. If the docstring cannot be located, decline
517 # to change anything - a misplaced section is worse than a missing one.
518 span = _module_docstring_span(text)
519 if span is None:
520 return False, text, []
521 _docstring, docstring_end = span
522
523 new_text = (text[:docstring_end] + "\n" + sections_to_add + '\n' + text[docstring_end:])
524
525 messages = [f"Added missing docstring sections: {', '.join(missing)}"]
526 return True, new_text, messages
527
528
529def _dbg_import_insert_index(tree: ast.Module, lines: List[str]) -> int:
530 """
531 Return the 0-based line index at which the canonical ``_dbg`` import belongs.
532
533 The placement is derived from the module structure, never from a fixed
534 line number (184-D7). The Option C canonical order is: header comments,
535 module docstring, ``from __future__`` imports, then ordinary imports.
536
537 - With a module docstring, start at the line after its closing quotes,
538 then move past any ``from __future__`` imports - the language requires
539 those to be the first statement after the docstring.
540 - Without one, start after the whole leading comment/blank block, however
541 long it runs, so the import never lands inside it.
542
543 In both cases trailing blank lines are skipped, so the import joins the
544 top of the existing import block instead of hugging the docstring or the
545 comment header.
546 """
547 body = tree.body
548 index = 0
549 consumed = 0
550
551 if (body and isinstance(body[0], ast.Expr)
552 and isinstance(body[0].value, ast.Constant)
553 and isinstance(body[0].value.value, str)):
554 # ``end_lineno`` is 1-based, so it already *is* the 0-based index of
555 # the line following the docstring's closing quotes.
556 index = body[0].end_lineno
557 consumed = 1
558 else:
559 index = _leading_comment_block_end(lines)
560
561 for node in body[consumed:]:
562 if isinstance(node, ast.ImportFrom) and node.module == "__future__":
563 index = max(index, node.end_lineno)
564 else:
565 break
566
567 while index < len(lines) and not lines[index].strip():
568 index += 1
569
570 return min(index, len(lines))
571
572
573def _import_block_package() -> Optional[str]:
574 """
575 Return the top-level package named by :data:`IMPORT_BLOCK`.
576
577 Derived by parsing the canonical import rather than hardcoded, so the
578 self-import guard keeps following ``IMPORT_BLOCK`` if the package that
579 provides the diagnostics symbol is ever renamed. Returns ``None`` if
580 the constant is not a parseable ``from <package> import ...``.
581 """
582 tree, _ = safe_parse(IMPORT_BLOCK)
583 if tree is None:
584 return None
585
586 for node in ast.iter_child_nodes(tree):
587 if isinstance(node, ast.ImportFrom) and node.module:
588 return node.module.split(".")[0]
589
590 return None
591
592
593def _import_block_binding() -> Tuple[Optional[str], Optional[str]]:
594 """
595 Return ``(module, symbol)`` named by :data:`IMPORT_BLOCK`.
596
597 Derived by parsing the canonical import rather than hardcoded, so the
598 removal pass keeps following ``IMPORT_BLOCK`` if either the module or
599 the symbol it provides is ever renamed. Returns ``(None, None)`` if the
600 constant is not a parseable ``from <module> import <symbol>``.
601 """
602 tree, _ = safe_parse(IMPORT_BLOCK)
603 if tree is None:
604 return None, None
605
606 for node in ast.iter_child_nodes(tree):
607 if isinstance(node, ast.ImportFrom) and node.module and node.names:
608 return node.module, node.names[0].name
609
610 return None, None
611
612
614 tree: ast.Module, module: str, symbol: str,
615) -> Tuple[set, int]:
616 """
617 Return ``(line_indices, statement_count)`` for imports that conflict.
618
619 A statement conflicts only when it binds the BARE name ``symbol``, since
620 that is the sole binding the canonical import can collide with. This is
621 a structural test over module-level import nodes, replacing a substring
622 scan that treated any line containing ``"import <symbol>"`` as an import
623 (198). That scan deleted prose inside docstrings, string literals that
624 merely spelled an import, and - because ``import _dbg_shim`` contains
625 ``import _dbg`` - the sole body of a ``try:`` block, which does not
626 parse.
627
628 Two conservative limits keep removal from causing the very breakage this
629 fixer exists to avoid:
630
631 - Aliased imports are never removed. They bind a different name, so they
632 cannot conflict, and deleting one unbinds a name the module still uses.
633 - A statement that binds anything else is never removed, so a
634 co-imported symbol can never be taken down with it.
635
636 Only module-level statements are considered, matching the scope of
637 :func:`oct.linter.oct_lint.check_dbg_import`.
638 """
639 doomed: set = set()
640 statements = 0
641
642 for node in ast.iter_child_nodes(tree):
643 if not isinstance(node, (ast.Import, ast.ImportFrom)):
644 continue
645
646 binds_symbol = any(
647 alias.asname is None and alias.name.split(".")[0] == symbol
648 for alias in node.names
649 )
650 if not binds_symbol:
651 continue
652
653 if isinstance(node, ast.ImportFrom) and node.module == module:
654 continue
655
656 if len(node.names) > 1:
657 continue
658
659 end_lineno = node.end_lineno or node.lineno
660 doomed.update(range(node.lineno - 1, end_lineno))
661 statements += 1
662
663 return doomed, statements
664
665
666def _owning_top_level_package(path: Path) -> Optional[str]:
667 """
668 Return the name of the top-level package ``path`` belongs to.
669
670 Walks up from the file's directory while each level is a real package
671 (contains an ``__init__.py``); the highest such directory names the
672 top-level package. A nested submodule therefore reports the package at
673 the top of its chain, not its immediate parent.
674
675 Returns ``None`` when the file is not inside a package at all, which
676 keeps the test structural: a directory that merely *shares* a
677 package's name is not that package.
678 """
679 package = None
680 directory = path.parent
681
682 while (directory / "__init__.py").exists():
683 package = directory.name
684 parent = directory.parent
685 if parent == directory:
686 break
687 directory = parent
688
689 return package
690
691
692def fix_dbg_import(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
693 """
694 Ensure canonical _dbg import exists at module level.
695
696 Skips trivial files (< 20 lines), and skips any module belonging to the
697 package that *provides* the symbol (195) - see the guard below.
698 """
699 func = "fix_dbg_import"
700
701 # Skip trivial files
702 if len(text.splitlines()) < 20:
703 return False, text, []
704
705 # 195: a package must never be made to import itself. Inserting the
706 # canonical import into a module of the package that DEFINES the symbol
707 # is circular at module scope - the owning package's ``__init__``
708 # re-exports from that very module, so the import runs against a
709 # half-initialised package and raises ImportError, taking the whole
710 # package down. The test is structural (walk up while ``__init__.py``
711 # exists) and the package name comes from IMPORT_BLOCK, so this is the
712 # general rule "a package does not import a symbol it provides" rather
713 # than a special case for one package name.
714 owning_package = _owning_top_level_package(path)
715 if owning_package is not None and owning_package == _import_block_package():
716 return False, text, [
717 f"Skipped canonical import: this module belongs to "
718 f"'{owning_package}', the package that provides it"
719 ]
720
721 # Check if canonical import exists
722 ok, msg = check_dbg_import(text)
723 if ok:
724 return False, text, []
725
726 # Parse AST to find import insertion point
727 tree, _ = safe_parse(text)
728 if tree is None:
729 return False, text, []
730
731 lines = text.split('\n')
732
733 # Derive the structural insertion point from the AST before any lines are
734 # dropped, so the index refers to the file as parsed.
735 insert_pos = _dbg_import_insert_index(tree, lines)
736
737 # Find any conflicting _dbg imports and remove them. The set is derived
738 # from the AST, so only real import statements are ever deleted (198).
739 canonical_module, canonical_symbol = _import_block_binding()
740 if canonical_module is None or canonical_symbol is None:
741 return False, text, []
742
743 doomed, removed_count = _conflicting_import_lines(
744 tree, canonical_module, canonical_symbol,
745 )
746
747 new_lines = [line for i, line in enumerate(lines) if i not in doomed]
748 removed_before_insert = sum(1 for i in doomed if i < insert_pos)
749
750 # Removal shifts every line below it up, so rebase the index onto the
751 # surviving lines rather than recomputing it from a mutated file.
752 insert_pos = min(insert_pos - removed_before_insert, len(new_lines))
753 new_lines.insert(insert_pos, IMPORT_BLOCK.rstrip())
754 new_text = '\n'.join(new_lines)
755
756 msg = "Added canonical _dbg import"
757 if removed_count > 0:
758 msg += f" (removed {removed_count} incorrect import(s))"
759
760 return True, new_text, [msg]
761
762
764 node: Any, lines: List[str],
765) -> Optional[Tuple[int, str]]:
766 """
767 Return ``(line_index, indent)`` for a function's ``func = "..."`` line.
768
769 The position is derived from the function's AST body, never by counting
770 lines from the ``def`` (198). A multi-line signature puts a PARAMETER on
771 the line after the ``def``, so "the next line" is not the body - taking
772 it as the insertion point injects the assignment into the parameter list
773 and the file stops parsing.
774
775 Docstring-first ordering is preserved: when the body opens with a
776 docstring the assignment lands after it, so the string remains the
777 function's docstring instead of being demoted to a plain expression.
778
779 Returns ``None`` when there is no line to insert before - a body written
780 inline with its signature (``def f(): return x``). Leaving such a
781 function unfixed is the correct outcome: the fixer's contract is that it
782 never corrupts a file, not that it always succeeds.
783 """
784 body = node.body
785 if not body:
786 return None
787
788 first = body[0]
789 if not _begins_its_own_line(first, lines):
790 return None
791
792 target = first
793 if (isinstance(first, ast.Expr)
794 and isinstance(first.value, ast.Constant)
795 and isinstance(first.value.value, str)):
796 if len(body) > 1 and _begins_its_own_line(body[1], lines):
797 target = body[1]
798 else:
799 # Nothing below the docstring to anchor to, so land on the line
800 # after its closing quotes. ``end_lineno`` is 1-based, so it
801 # already is the 0-based index of the following line.
802 index = min(first.end_lineno, len(lines))
803 return index, _leading_whitespace(lines[first.lineno - 1])
804
805 index = _statement_start_index(target)
806 if index >= len(lines):
807 return None
808 return index, _leading_whitespace(lines[index])
809
810
811def fix_func_pattern(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
812 """
813 Ensure every function with _dbg() calls has func = "function_name" as first statement.
814
815 Delegates violation detection to :func:`oct.linter.oct_lint.find_func_pattern_violations`
816 so the linter remains the single source of truth for what constitutes a
817 func-pattern violation. This function only handles the fix (inserting
818 the ``func = "..."`` line), and takes every insertion point from the AST
819 rather than from line arithmetic (198).
820
821 Skips trivial files (< 20 lines).
822 """
823 func = "fix_func_pattern"
824
825 tree, _ = safe_parse(text)
826 if tree is None:
827 return False, text, []
828
829 functions_to_fix = find_func_pattern_violations(text, tree)
830 if not functions_to_fix:
831 return False, text, []
832
833 lines = text.split('\n')
834
835 nodes = {
836 (node.name, node.lineno): node
837 for node in ast.walk(tree)
838 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
839 }
840
841 # Resolve every insertion point against the ORIGINAL line list first.
842 # Indices derived from the AST are only valid for the text that AST was
843 # parsed from, so nothing may be mutated until all of them are known.
844 insertions: List[Tuple[int, str, str]] = []
845 skipped: List[str] = []
846
847 for func_name, func_lineno in functions_to_fix:
848 node = nodes.get((func_name, func_lineno))
849 if node is None:
850 continue
851 point = _func_pattern_insert_point(node, lines)
852 if point is None:
853 skipped.append(func_name)
854 continue
855 insert_idx, indent = point
856 insertions.append((insert_idx, func_name, indent))
857
858 if not insertions:
859 messages = []
860 if skipped:
861 messages.append(
862 "Skipped func pattern (body shares a line with its "
863 f"signature): {', '.join(skipped)}"
864 )
865 return False, text, messages
866
867 # Apply bottom-up so an earlier insertion can never shift a later index.
868 for insert_idx, func_name, indent in sorted(insertions, reverse=True):
869 lines.insert(insert_idx, f'{indent}func = "{func_name}"')
870
871 new_text = '\n'.join(lines)
872
873 fixed_names = [name for _idx, name, _indent in insertions]
874 messages = [f"Added func pattern to: {', '.join(fixed_names)}"]
875 if skipped:
876 messages.append(
877 "Skipped func pattern (body shares a line with its "
878 f"signature): {', '.join(skipped)}"
879 )
880 return True, new_text, messages
881
882
883# ============================================================================
884# File Processing
885# ============================================================================
886
887
888def format_file(path: Path, ctx: FormatterContext) -> Dict[str, Any]:
889 """Process a single file through all formatters.
890
891 Plan 277: a fixer named in ``ctx.disabled_fixers`` does not run. Its
892 entry in ``fixes`` still appears, reporting ``changed`` False and
893 carrying an extra ``"skipped": "disabled"`` key, so the result stays
894 a superset of the shape every existing reader expects. With the
895 default empty set - ``oct format`` run directly, and every caller
896 that builds its own context - nothing about this function changes.
897 """
898 func = "format_file"
899
900 try:
901 text = path.read_text(encoding="utf-8")
902 except Exception as e:
903 return {
904 "path": _get_relative_path(path, ctx.project_root),
905 "changed": False,
906 "fixes": {
907 "header": {"changed": False, "messages": []},
908 "docstring": {"changed": False, "messages": []},
909 "dbg_import": {"changed": False, "messages": []},
910 "func_pattern": {"changed": False, "messages": []},
911 },
912 "errors": [f"Could not read file: {str(e)}"],
913 }
914
915 result = {
916 "path": _get_relative_path(path, ctx.project_root),
917 "changed": False,
918 "fixes": {},
919 "errors": [],
920 }
921
922 # F21 / Plan 175 (item 2): an unparseable file cannot be safely
923 # formatted. The AST-dependent fixers used to swallow ``tree is None``
924 # into ``return False, text, []`` - a silent "no changes" that is
925 # indistinguishable from a genuinely clean file - while the text-based
926 # fixers (docstring/header) would actively REWRITE the broken text.
927 # Surface the SyntaxError through the same error channel a read failure
928 # or exception uses, apply no fixes, and never rewrite text we cannot
929 # parse. Mirrors the linter's syntax_error gate (Plan 170).
930 tree, _ = safe_parse(text)
931 if tree is None:
932 result["errors"].append(f"Syntax error: {_describe_syntax_error(text)}")
933 result["fixes"] = {
934 fix_name: {"changed": False, "messages": []}
935 for fix_name in ("header", "docstring", "dbg_import", "func_pattern")
936 }
937 return result
938
939 # Apply fixers in sequence
940 fixers = [
941 ("header", fix_header_block),
942 ("docstring", fix_docstring),
943 ("dbg_import", fix_dbg_import),
944 ("func_pattern", fix_func_pattern),
945 ]
946
947 any_changed = False
948
949 for fix_name, fixer in fixers:
950 # Plan 277: skip BEFORE the call, not after it. The gate used to
951 # filter a disabled fixer out of its violation count once the
952 # fixer had already rewritten ``text``, which is invisible in
953 # check mode and destructive in fix mode.
954 if fix_name in ctx.disabled_fixers:
955 result["fixes"][fix_name] = {
956 "changed": False, "messages": [], "skipped": "disabled",
957 }
958 _dbg("OCT.FORMATTER", func, f"{fix_name} disabled for {path.name}", 4)
959 continue
960 try:
961 changed, text, messages = fixer(path, text, ctx)
962 result["fixes"][fix_name] = {"changed": changed, "messages": messages}
963 if changed:
964 any_changed = True
965 # One L3 record per fixer, so a debug log says which fixers ran
966 # and what each found (the fixers log nothing themselves; found
967 # by the chart C runtime trace, 2026-09-27).
968 outcome = ("would change" if not ctx.fix_mode else "changed") if changed else "clean"
969 _dbg("OCT.FORMATTER", func,
970 f"fixer {fix_name} {outcome} ({len(messages)} messages) {result['path']}", 3)
971 except Exception as e:
972 result["errors"].append(f"{fix_name}: {str(e)}")
973 _dbg("OCT.FORMATTER", func,
974 f"fixer {fix_name} raised {type(e).__name__} {result['path']}", 3)
975
976 # 198: the closing invariant. Every member of the insertion-point family
977 # (186, 195, 198) had the same consequence - text that no longer parses.
978 # The individual fixers are now structural, but this backstop removes the
979 # CLASS rather than three instances of it, and it covers fixers that do
980 # not exist yet. The input was already proven parseable by the gate
981 # above, so a failure here is unambiguously the formatter's doing:
982 # discard the whole result and keep the file the operator had.
983 if any_changed:
984 verified, _ = safe_parse(text)
985 if verified is None:
986 result["errors"].append(
987 "Discarded formatter output: the result does not parse "
988 f"({_describe_syntax_error(text)}). The file was left "
989 "unchanged."
990 )
991 result["fixes"] = {
992 fix_name: {"changed": False, "messages": []}
993 for fix_name, _fixer in fixers
994 }
995 result["changed"] = False
996 return result
997
998 # Single archive + write at end
999 if any_changed and ctx.fix_mode and not ctx.dry_run_mode:
1001 path,
1002 max_backups=ctx.max_backups_per_file,
1003 max_archive_bytes=ctx.max_archive_bytes,
1004 )
1005 path.write_text(text, encoding="utf-8")
1006
1007 result["changed"] = any_changed
1008 return result
1009
1010
1011def compute_summary(file_results: List[Dict[str, Any]]) -> Dict[str, Any]:
1012 """Aggregate results into summary."""
1013 func = "compute_summary"
1014 _dbg("OCT.FORMATTER", func, f"summarising {len(file_results)} formatted files", 3)
1015
1016 summary = {
1017 "total_files": len(file_results),
1018 "total_changed": sum(1 for r in file_results if r.get("changed")),
1019 "fixes": {
1020 "header": 0,
1021 "docstring": 0,
1022 "dbg_import": 0,
1023 "func_pattern": 0,
1024 },
1025 }
1026
1027 for result in file_results:
1028 for fix_type in summary["fixes"]:
1029 if result["fixes"].get(fix_type, {}).get("changed"):
1030 summary["fixes"][fix_type] += 1
1031
1032 return summary
1033
1034
1035# ============================================================================
1036# Output Formatting
1037# ============================================================================
1038
1039
1040def _print_terminal_report(summary: Dict[str, Any], file_results: List[Dict[str, Any]],
1041 verbose: bool, dry_run: bool) -> None:
1042 """Print human-readable terminal report."""
1043 func = "_print_terminal_report"
1044
1045 mode = "DRY-RUN" if dry_run else "FIXED"
1046
1047 print(f"\n{'=' * 70}")
1048 print(f"OCT FORMATTER REPORT - {mode}")
1049 print(f"{'=' * 70}\n")
1050
1051 print(f"Files processed: {summary['total_files']}")
1052 print(f"Files with changes: {summary['total_changed']}")
1053 print()
1054 print("Fixes applied:")
1055 print(f" Header blocks: {summary['fixes']['header']}")
1056 print(f" Docstrings: {summary['fixes']['docstring']}")
1057 print(f" _dbg imports: {summary['fixes']['dbg_import']}")
1058 print(f" func patterns: {summary['fixes']['func_pattern']}")
1059
1060 if verbose:
1061 print(f"\n{'-' * 70}")
1062 print("Detailed results:")
1063 print(f"{'-' * 70}\n")
1064
1065 for result in file_results:
1066 if result["changed"] or result["errors"]:
1067 print(f"{result['path']}")
1068
1069 for fix_type, fix_result in result["fixes"].items():
1070 if fix_result["changed"]:
1071 print(f" [OK] {fix_type}: {', '.join(fix_result['messages'])}")
1072
1073 for error in result["errors"]:
1074 print(f" [ERROR] {error}")
1075
1076 print()
1077
1078 print(f"{'=' * 70}\n")
1079
1080
1081def _print_json_report(summary: Dict[str, Any], file_results: List[Dict[str, Any]],
1082 ctx: FormatterContext) -> None:
1083 """Print JSON output."""
1084 func = "_print_json_report"
1085
1086 output = {
1087 "project": ctx.project_name,
1088 "oct_version": __version__,
1089 "timestamp": datetime.now().isoformat(),
1090 "dry_run": ctx.dry_run_mode,
1091 "summary": summary,
1092 "files": file_results,
1093 }
1094
1095 print(json.dumps(output, indent=2))
1096
1097
1098# ============================================================================
1099# Main Entry Point
1100# ============================================================================
1101
1102
1103def _formatter_flag_argv(args) -> List[str]:
1104 """Rebuild the formatter's argv from parsed *args*.
1105
1106 Plan 213 / D-D. ``--changed`` is deliberately NOT reproduced: the
1107 dispatcher has already resolved the changed set at the workspace root
1108 and hands each lens group its own explicit file list.
1109 """
1110 flags: List[str] = []
1111 if args.fix:
1112 flags.append("--fix")
1113 if args.dry_run:
1114 flags.append("--dry-run")
1115 if args.json:
1116 flags.append("--json")
1117 if args.verbose:
1118 flags.append("--verbose")
1119 return flags
1120
1121
1122def _maybe_dispatch_format_by_lens(project_root: Path, args) -> bool:
1123 """Fan ``oct format`` out to one engine run per owning subproject.
1124
1125 Plan 213 / D-D, the formatter half. Returns True when the invocation
1126 fanned out (the caller then returns immediately), False to let the
1127 ordinary single-root pass run.
1128
1129 This matters more here than for the linter: ``run_formatter`` has no
1130 path targets at all, so before 213 a workspace-root invocation walked
1131 the entire tree under ONE ``FormatterContext`` and judged every
1132 subproject's file against the workspace-root lens. In ``--fix`` mode
1133 that did not merely misreport - it REWROTE correct
1134 subproject-relative identity headers into workspace-relative ones.
1135
1136 Only a workspace-root invocation fans out. From inside a subproject,
1137 project-root discovery already resolves to that subproject, so the
1138 lens is correct and behaviour is byte-identical to before.
1139 """
1140 try:
1141 from oct.core.workspace import (
1142 assign_to_subproject,
1143 find_workspace_root,
1144 load_workspace,
1145 )
1146 except ImportError:
1147 return False
1148
1149 ws_path = find_workspace_root(project_root)
1150 if ws_path is None or ws_path.resolve() != project_root.resolve():
1151 return False
1152 try:
1153 workspace = load_workspace(ws_path)
1154 except Exception:
1155 return False
1156 if not workspace.subprojects:
1157 return False
1158
1159 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
1160 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
1161 flags = _formatter_flag_argv(args)
1162
1163 def _owned(path: Path) -> bool:
1164 return assign_to_subproject(path, workspace) is not None
1165
1166 if args.changed:
1167 changed = [
1168 p for p in _git_changed_files(workspace.root)
1169 if p.is_file() and p.suffix == ".py"
1170 ]
1171 if not changed:
1172 if not args.json:
1173 print("No modified Python files found.", file=sys.stderr)
1174 return True
1175 groups: dict[Path, List[Path]] = {}
1176 for path in changed:
1177 subproject = assign_to_subproject(path, workspace)
1178 root = (
1179 subproject.path if subproject is not None else workspace.root
1180 )
1181 groups.setdefault(root, []).append(path)
1182 for group_root, group_files in groups.items():
1184 group_root, list(flags),
1185 _files=group_files, _dispatch=False,
1186 )
1187 return True
1188
1189 # Full sweep: one pass per subproject under its own lens, then the
1190 # workspace-root leftovers under the workspace lens.
1191 for subproject in workspace.subprojects:
1192 if not subproject.path.is_dir():
1193 continue
1194 run_formatter(subproject.path, list(flags), _dispatch=False)
1195
1196 leftovers = [
1197 path
1198 for path in find_python_files(
1199 workspace.root, exclude_dirs, wildcard_exclude,
1200 )
1201 if not _owned(path)
1202 ]
1203 if leftovers:
1205 workspace.root, list(flags),
1206 _files=leftovers, _dispatch=False,
1207 )
1208 return True
1209
1210
1212 project_root: Path,
1213 argv: Optional[List[str]] = None,
1214 *,
1215 _files: Optional[List[Path]] = None,
1216 _dispatch: bool = True,
1217) -> None:
1218 """
1219 Run the Option C formatter on the given project root.
1220
1221 Parameters
1222 ----------
1223 project_root : Path
1224 The detected Option C project root directory.
1225 argv : list[str] | None
1226 Optional list of command-line arguments.
1227 If None, argparse will use sys.argv[1:].
1228 _files : list[Path] | None
1229 Plan 213 internal. An explicit file list that overrides scope
1230 discovery, used by the workspace lens dispatcher to hand a lens
1231 group exactly the files it owns.
1232 _dispatch : bool
1233 Plan 213 internal re-entrancy guard. The lens dispatcher re-invokes
1234 this function once per owning subproject with ``_dispatch=False``
1235 so the per-group runs cannot fan out again.
1236 """
1237 func = "run_formatter"
1238 _dbg("OCT.FORMATTER", func, f"format run starting at {project_root}", 3)
1239
1240 # Parse arguments
1241 parser = argparse.ArgumentParser(
1242 description="Auto-fix Option C compliance violations",
1243 prog="oct format",
1244 )
1245 parser.add_argument(
1246 "--fix",
1247 action="store_true",
1248 help="Apply formatting changes. Without this, only reports what would change.",
1249 )
1250 parser.add_argument(
1251 "--dry-run",
1252 action="store_true",
1253 help="Report what would change without modifying files (default behavior).",
1254 )
1255 parser.add_argument(
1256 "--json",
1257 action="store_true",
1258 help="Output machine-readable JSON instead of colorized terminal report.",
1259 )
1260 parser.add_argument(
1261 "--verbose",
1262 action="store_true",
1263 help="Show per-file details in addition to summary.",
1264 )
1265 parser.add_argument(
1266 "--changed",
1267 action="store_true",
1268 help="Format only git-modified Python files.",
1269 )
1270
1271 args = parser.parse_args(argv)
1272
1273 # ---- Plan 213 / D-D: dispatch each file to its owning lens ----
1274 if _dispatch and _files is None and _maybe_dispatch_format_by_lens(
1275 project_root, args,
1276 ):
1277 return
1278
1279 # Create context
1280 diagnostics_dir = project_root / "oc_diagnostics"
1281 tests_dir = project_root / "tests"
1282 docs_dir = project_root / "docs"
1283
1284 # Find and process all Python files
1285 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
1286 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
1287
1288 # Load per-project overrides from .octrc.json (same as linter).
1289 # OI-519: routed through central oct.core.octrc loader.
1290 max_backups_per_file = _DEFAULT_MAX_BACKUPS_PER_FILE
1291 from oct.core.octrc import load_octrc_safe
1292 octrc = load_octrc_safe(project_root)
1293 linter_cfg = octrc.get("linter", {})
1294 if isinstance(linter_cfg, dict):
1295 for d in linter_cfg.get("exclude_dirs_add", []):
1296 exclude_dirs.add(d)
1297 for d in linter_cfg.get("exclude_dirs_remove", []):
1298 exclude_dirs.discard(d)
1299 for d in linter_cfg.get("wildcard_exclude_add", []):
1300 if d not in wildcard_exclude:
1301 wildcard_exclude.append(d)
1302 # OI-424: optional formatter.max_backups_per_file override.
1303 # OI-516: optional formatter.max_archive_mib override - written as MiB
1304 # in .octrc.json (readable) and converted to bytes on load.
1305 max_archive_bytes = _DEFAULT_MAX_ARCHIVE_BYTES
1306 formatter_cfg = octrc.get("formatter", {})
1307 if isinstance(formatter_cfg, dict):
1308 raw = formatter_cfg.get("max_backups_per_file")
1309 if isinstance(raw, int) and raw >= 0:
1310 max_backups_per_file = raw
1311 raw_mib = formatter_cfg.get("max_archive_mib")
1312 if isinstance(raw_mib, (int, float)) and raw_mib >= 0:
1313 max_archive_bytes = int(raw_mib * 1024 * 1024)
1314
1315 ctx = FormatterContext(
1316 project_root=project_root,
1317 project_name=project_root.name,
1318 diagnostics_dir=diagnostics_dir,
1319 tests_dir=tests_dir,
1320 docs_dir=docs_dir,
1321 fix_mode=args.fix,
1322 dry_run_mode=not args.fix,
1323 json_mode=args.json,
1324 verbose=args.verbose,
1325 max_backups_per_file=max_backups_per_file,
1326 max_archive_bytes=max_archive_bytes,
1327 )
1328
1329 if _files is not None:
1330 # Plan 213: an explicit lens-group file list from the dispatcher.
1331 python_files = list(_files)
1332 elif args.changed:
1333 from oct.linter.oct_lint import is_excluded_dir
1334 python_files = _git_changed_files(project_root)
1335 # Plan 282: the exclusion is asked of the DIRECTORY parts only, the
1336 # same slice ``find_python_files`` takes, so a module named like an
1337 # excluded directory is formatted under ``--changed`` exactly as it
1338 # is under a full scan.
1339 python_files = [
1340 p for p in python_files
1341 if p.is_file() and not any(
1342 is_excluded_dir(Path(part), exclude_dirs, wildcard_exclude)
1343 for part in p.relative_to(project_root).parts[:-1]
1344 )
1345 ]
1346 if not python_files:
1347 if not args.json:
1348 print("No modified Python files found.", file=sys.stderr)
1349 return
1350 else:
1351 python_files = find_python_files(project_root, exclude_dirs, wildcard_exclude)
1352
1353 file_results = []
1354 for py_file in python_files:
1355 result = format_file(py_file, ctx)
1356 file_results.append(result)
1357
1358 # Compute summary
1359 summary = compute_summary(file_results)
1360
1361 # Output report
1362 if ctx.json_mode:
1363 _print_json_report(summary, file_results, ctx)
1364 else:
1365 _print_terminal_report(summary, file_results, ctx.verbose, ctx.dry_run_mode)
1366
1367
1368if __name__ == "__main__":
1369 project_root = find_project_root(Path.cwd())
1370 run_formatter(project_root)
Dict[str, Any] compute_summary(List[Dict[str, Any]] file_results)
List[str] _detect_missing_docstring_sections(str text)
Tuple[bool, str, List[str]] fix_docstring(Path path, str text, FormatterContext ctx)
None _print_terminal_report(Dict[str, Any] summary, List[Dict[str, Any]] file_results, bool verbose, bool dry_run)
Tuple[Optional[str], Optional[str]] _import_block_binding()
list[Path] _git_changed_files(Path root)
Definition oct_format.py:85
Optional[Tuple[str, int]] _module_docstring_span(str text)
Optional[str] _import_block_package()
str _leading_whitespace(str line)
int _prune_archive_by_size(Path archive_dir, int max_total_bytes)
Optional[Tuple[int, str]] _func_pattern_insert_point(Any node, List[str] lines)
int _dbg_import_insert_index(ast.Module tree, List[str] lines)
Tuple[set, int] _conflicting_import_lines(ast.Module tree, str module, str symbol)
List[str] _formatter_flag_argv(args)
Tuple[bool, str, List[str]] fix_dbg_import(Path path, str text, FormatterContext ctx)
int _statement_start_index(ast.stmt stmt)
str _get_relative_path(Path path, Path root)
None run_formatter(Path project_root, Optional[List[str]] argv=None, *, Optional[List[Path]] _files=None, bool _dispatch=True)
bool _maybe_dispatch_format_by_lens(Path project_root, args)
int _prune_archive(Path archive_dir, str original_name, int max_backups)
Tuple[bool, str, List[str]] fix_func_pattern(Path path, str text, FormatterContext ctx)
Tuple[bool, str, List[str]] fix_header_block(Path path, str text, FormatterContext ctx)
None _archive_file(Path path, int max_backups=_DEFAULT_MAX_BACKUPS_PER_FILE, int max_archive_bytes=_DEFAULT_MAX_ARCHIVE_BYTES)
bool _begins_its_own_line(ast.stmt stmt, List[str] lines)
None _print_json_report(Dict[str, Any] summary, List[Dict[str, Any]] file_results, FormatterContext ctx)
int _leading_comment_block_end(List[str] lines)
Dict[str, Any] format_file(Path path, FormatterContext ctx)
Optional[str] _owning_top_level_package(Path path)