Option C Tools
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
38Dependencies
39------------
40- oct (package version)
41- oct.linter.oct_lint (reused validation checks)
42- oct.core.exclusions (directory and wildcard exclusion lists)
43- oct.core.project_root (project root detection)
44- oct.core.parsing (safe AST parsing helper)
45- oct.core.git (git changed-file discovery)
46"""
47
48import sys
49import json
50import argparse
51import re
52from dataclasses import dataclass
53from pathlib import Path
54from datetime import datetime
55import ast
56from typing import Tuple, List, Dict, Any, Optional
57
58from oct import __version__
59from oct.linter.oct_lint import (
60 validate_header_block,
61 check_docstring,
62 check_diagnostics_profile,
63 check_dbg_import,
64 check_dbg_usage,
65 find_func_pattern_violations,
66 _extract_module_docstring,
67 find_python_files,
68 LinterContext,
69)
70from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
71from oct.core.project_root import find_project_root
72from oct.core.parsing import safe_parse
73
74
75def _git_changed_files(root: Path) -> list[Path]:
76 """Return .py files modified in the git working tree (staged + unstaged).
77
78 Thin shim over :func:`oct.core.git.git_changed_files` (Phase 4A).
79 Collapses all ``GitError`` to empty list so the formatter never
80 crashes due to git unavailability.
81 """
82 from oct.core import git as core_git
83 try:
84 return core_git.git_changed_files(root, extensions={".py"})
85 except core_git.GitError:
86 return []
87
88# Template constants reused from generator
89SHEBANG = "#!/usr/bin/env python3"
90ENCODING = "# -*- coding: utf-8 -*-"
91
92IMPORT_BLOCK = "from oc_diagnostics import _dbg\n"
93
94DOCSTRING_SKELETON = '''\
95"""
96Purpose
97-------
98Describe the purpose of this module.
99
100Responsibilities
101----------------
102- List the responsibilities of this module.
103
104Diagnostics
105-----------
106Domain: <DOMAIN>
107Levels:
108 L2 — lifecycle
109 L3 — semantic details
110 L4 — deep tracing
111
112Contracts
113---------
114- State the contracts and guarantees of this module.
115"""
116'''
117
118MISSING_DOCSTRING_SECTIONS = {
119 "Purpose": """
120Purpose
121-------
122Describe the purpose of this module.""",
123 "Responsibilities": """
124Responsibilities
125----------------
126- List the responsibilities of this module.""",
127 "Diagnostics": """
128Diagnostics
129-----------
130Domain: <DOMAIN>
131Levels:
132 L2 — lifecycle
133 L3 — semantic details
134 L4 — deep tracing""",
135 "Contracts": """
136Contracts
137---------
138- State the contracts and guarantees of this module.""",
139}
140
141
142#: OI-424 — default cap on retained backups per file in
143#: ``.formatter_archive/``. Projects can override via
144#: ``formatter.max_backups_per_file`` in ``.octrc.json``.
145_DEFAULT_MAX_BACKUPS_PER_FILE: int = 10
146
147#: OI-516 — aggregate size cap per ``.formatter_archive/`` directory
148#: (5 MiB). Enforced after per-file count pruning so a single giant
149#: source file cannot fill the archive even within its 10-backup budget.
150_DEFAULT_MAX_ARCHIVE_BYTES: int = 5 * 1024 * 1024
151
152
153@dataclass
155 """Immutable per-run context for the formatter."""
156 project_root: Path
157 project_name: str
158 diagnostics_dir: Path
159 tests_dir: Path
160 docs_dir: Path
161 fix_mode: bool
162 dry_run_mode: bool
163 json_mode: bool
164 verbose: bool
165 max_backups_per_file: int = _DEFAULT_MAX_BACKUPS_PER_FILE
166 #: OI-516 — aggregate cap across all backups in one
167 #: ``.formatter_archive/`` directory.
168 max_archive_bytes: int = _DEFAULT_MAX_ARCHIVE_BYTES
169
170
171# ============================================================================
172# Helper Functions
173# ============================================================================
174
175
176def _get_relative_path(path: Path, root: Path) -> str:
177 """Get relative path from root to path."""
178 try:
179 return str(path.relative_to(root))
180 except ValueError:
181 return str(path)
182
183
185 archive_dir: Path, original_name: str, max_backups: int,
186) -> int:
187 """Keep only the ``max_backups`` most recent backups of ``original_name``.
188
189 Matches files shaped like ``{original_name}.*.bak`` and sorts them
190 by mtime (newest first). Older entries beyond the cap are removed.
191 Best-effort: any OS error is swallowed so a failed prune can never
192 block ``oct format`` (OI-424).
193
194 Returns the number of files removed. ``max_backups <= 0`` disables
195 pruning entirely (useful when a project deliberately wants unbounded
196 history).
197 """
198 func = "_prune_archive"
199 if max_backups <= 0:
200 return 0
201 try:
202 candidates = sorted(
203 archive_dir.glob(f"{original_name}.*.bak"),
204 key=lambda p: p.stat().st_mtime,
205 reverse=True,
206 )
207 except OSError:
208 return 0
209 removed = 0
210 for stale in candidates[max_backups:]:
211 try:
212 stale.unlink()
213 removed += 1
214 except OSError:
215 continue
216 return removed
217
218
220 archive_dir: Path, max_total_bytes: int,
221) -> int:
222 """OI-516: evict oldest ``.bak`` files until total size fits under ``max_total_bytes``.
223
224 Runs *after* per-file count pruning so the archive floor is always
225 correct under the two coexisting policies. Any OS error on size or
226 unlink is swallowed — a rotation failure must never block formatting.
227 Returns the number of files removed.
228 """
229 func = "_prune_archive_by_size"
230 if max_total_bytes <= 0:
231 return 0
232 try:
233 entries = [(p, p.stat().st_mtime, p.stat().st_size)
234 for p in archive_dir.glob("*.bak") if p.is_file()]
235 except OSError:
236 return 0
237 total = sum(size for _, _, size in entries)
238 if total <= max_total_bytes:
239 return 0
240 # Oldest first — evict until we fit.
241 entries.sort(key=lambda row: row[1])
242 removed = 0
243 for path, _, size in entries:
244 if total <= max_total_bytes:
245 break
246 try:
247 path.unlink()
248 total -= size
249 removed += 1
250 except OSError:
251 continue
252 return removed
253
254
256 path: Path,
257 max_backups: int = _DEFAULT_MAX_BACKUPS_PER_FILE,
258 max_archive_bytes: int = _DEFAULT_MAX_ARCHIVE_BYTES,
259) -> None:
260 """Archive original file to ``.formatter_archive/`` with timestamp.
261
262 After writing the new backup, prune in two passes:
263
264 1. Per-file count cap (OI-424): keep only the N newest backups per
265 source file name.
266 2. Per-directory size cap (OI-516): enforce aggregate ``max_archive_bytes``
267 across *all* backups in the archive, evicting oldest first.
268 """
269 func = "archive_file"
270 archive_dir = path.parent / ".formatter_archive"
271 archive_dir.mkdir(exist_ok=True)
272 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
273 backup_path = archive_dir / f"{path.name}.{timestamp}.bak"
274 backup_path.write_bytes(path.read_bytes())
275 _prune_archive(archive_dir, path.name, max_backups)
276 _prune_archive_by_size(archive_dir, max_archive_bytes)
277
278
279def _detect_indent_style(lines: List[str]) -> str:
280 """Return the dominant body indentation unit used in the file.
281
282 Returns ``"\\t"`` if the file uses tabs for body indentation more
283 often than spaces, otherwise ``" "`` (four spaces). Only
284 considers lines with a non-whitespace character so blank lines do
285 not skew the result (OI-425).
286 """
287 func = "_detect_indent_style"
288 tabs = 0
289 spaces = 0
290 for line in lines:
291 if not line or not line.strip():
292 continue
293 if line.startswith("\t"):
294 tabs += 1
295 elif line.startswith(" "):
296 spaces += 1
297 return "\t" if tabs > spaces else " "
298
299
301 lines: List[str], line_num: int, fallback: str = " ",
302) -> str:
303 """Return the indentation string of a given line.
304
305 If the requested line is out of range or whitespace-only, return
306 ``fallback``. The default fallback preserves the historical four-
307 space behaviour, so existing callers remain unchanged (OI-425).
308 """
309 if line_num >= len(lines):
310 return fallback
311 line = lines[line_num]
312 stripped = line.lstrip()
313 if stripped:
314 return line[:len(line) - len(stripped)]
315 return fallback
316
317
318def _detect_missing_docstring_sections(text: str) -> List[str]:
319 """Detect which docstring sections are missing."""
320 func = "_detect_missing_docstring_sections"
321 docstring = _extract_module_docstring(text)
322 if not docstring:
323 return ["Purpose", "Responsibilities", "Diagnostics", "Contracts"]
324
325 missing = []
326 lines = docstring.split('\n')
327 for section in ["Purpose", "Responsibilities", "Diagnostics", "Contracts"]:
328 found = any(line.strip().startswith(section) for line in lines)
329 if not found:
330 missing.append(section)
331 return missing
332
333
334# ============================================================================
335# Fix Functions
336# ============================================================================
337
338
339def fix_header_block(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
340 """
341 Ensure file has correct 4-line header block.
342
343 Returns (changed, new_text, messages). Caller is responsible for
344 archiving and writing to disk.
345 """
346 func = "fix_header_block"
347
348 # Validate using linter
349 header_lines = text.split('\n')[:4] if len(text.split('\n')) >= 4 else text.split('\n')
350 ok, errors = validate_header_block(header_lines, path, ctx)
351
352 if ok:
353 return False, text, []
354
355 # Reconstruct correct header
356 relative_path = _get_relative_path(path, ctx.project_root)
357 new_shebang = f"{SHEBANG}"
358 new_encoding = f"{ENCODING}"
359 new_identity = f"# {relative_path}"
360 new_blank = ""
361
362 new_header = new_shebang + "\n" + new_encoding + "\n" + new_identity + "\n" + new_blank + "\n"
363
364 # Find where the actual file content starts (after header block).
365 # OI-509: delegate to shared oct.core.parsing.header_boundary so the
366 # formatter and linter use identical scanning rules.
367 from oct.core.parsing import header_boundary
368 lines = text.split('\n')
369 relative_identity = f"# {relative_path}"
370 content_start = header_boundary(
371 lines, path, expected_identity=relative_identity,
372 )
373
374 # Take content after the old header
375 if content_start < len(lines):
376 remaining_content = '\n'.join(lines[content_start:])
377 else:
378 remaining_content = ""
379
380 new_text = new_header + remaining_content
381
382 messages = ["Fixed header block (shebang, encoding, file identity, blank line)"]
383 return True, new_text, messages
384
385
386def fix_docstring(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
387 """
388 Ensure module has complete docstring with all required sections.
389
390 If no docstring: insert skeleton.
391 If incomplete: add missing sections.
392 If complete: no change.
393 """
394 func = "fix_docstring"
395
396 docstring = _extract_module_docstring(text)
397
398 if not docstring:
399 # Insert complete skeleton after header + blank line
400 lines = text.split('\n')
401 insert_pos = 4 # After header block (4 lines)
402
403 new_text = '\n'.join(lines[:insert_pos]) + '\n' + DOCSTRING_SKELETON + '\n'.join(lines[insert_pos:])
404
405 return True, new_text, ["Added complete module docstring"]
406
407 # Check for missing sections
409 if not missing:
410 return False, text, []
411
412 # Add missing sections to existing docstring
413 sections_to_add = "\n".join(MISSING_DOCSTRING_SECTIONS[section] for section in missing)
414
415 # Find docstring closing """ and insert before it
416 docstring_start = text.find('"""')
417 docstring_end = text.find('"""', docstring_start + 3)
418
419 if docstring_end == -1:
420 return False, text, []
421
422 new_text = (text[:docstring_end] + "\n" + sections_to_add + '\n' + text[docstring_end:])
423
424 messages = [f"Added missing docstring sections: {', '.join(missing)}"]
425 return True, new_text, messages
426
427
428def fix_dbg_import(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
429 """
430 Ensure canonical _dbg import exists at module level.
431
432 Skips trivial files (< 20 lines).
433 """
434 func = "fix_dbg_import"
435
436 # Skip trivial files
437 if len(text.splitlines()) < 20:
438 return False, text, []
439
440 # Check if canonical import exists
441 ok, msg = check_dbg_import(text)
442 if ok:
443 return False, text, []
444
445 # Parse AST to find import insertion point
446 tree, _ = safe_parse(text)
447 if tree is None:
448 return False, text, []
449
450 # Find position after header + blank line
451 lines = text.split('\n')
452
453 # Find any existing _dbg imports and remove them
454 new_lines = []
455 removed_count = 0
456
457 for i, line in enumerate(lines):
458 # Skip incorrect _dbg imports (aliased or from wrong module)
459 # Only remove if it's clearly a _dbg import line
460 if ("from oc_diagnostics import" in line and "_dbg" in line and "import _dbg" not in line) or \
461 ("import _dbg" in line and "from oc_diagnostics" not in line):
462 removed_count += 1
463 continue
464
465 new_lines.append(line)
466
467 # Insert canonical import after header + blank line
468 insert_pos = min(5, len(new_lines)) # After header block (lines 0-4)
469 new_lines.insert(insert_pos, IMPORT_BLOCK.rstrip())
470 new_text = '\n'.join(new_lines)
471
472 msg = "Added canonical _dbg import"
473 if removed_count > 0:
474 msg += f" (removed {removed_count} incorrect import(s))"
475
476 return True, new_text, [msg]
477
478
479def fix_func_pattern(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
480 """
481 Ensure every function with _dbg() calls has func = "function_name" as first statement.
482
483 Delegates violation detection to :func:`oct.linter.oct_lint.find_func_pattern_violations`
484 so the linter remains the single source of truth for what constitutes a
485 func-pattern violation. This function only handles the fix (inserting
486 the ``func = "..."`` line).
487
488 Skips trivial files (< 20 lines).
489 """
490 func = "fix_func_pattern"
491
492 functions_to_fix = find_func_pattern_violations(text)
493 if not functions_to_fix:
494 return False, text, []
495
496 lines = text.split('\n')
497
498 # OI-425: detect the file's dominant indentation unit once so the
499 # fallback used when insert_idx points at a blank or out-of-range
500 # line matches the surrounding file (tab-indented files must not
501 # receive space-indented ``func = "..."`` injections).
502 file_indent_unit = _detect_indent_style(lines)
503
504 # Insert func = "..." for each function (in reverse order to maintain line numbers)
505 for func_name, func_lineno in sorted(functions_to_fix, key=lambda x: x[1], reverse=True):
506 # func_lineno is 1-indexed, we need 0-indexed
507 func_line_idx = func_lineno - 1
508
509 # Validate that AST lineno points to actual def line
510 if func_line_idx >= len(lines) or "def " not in lines[func_line_idx]:
511 continue
512
513 # Find the first statement after the function def and docstring
514 insert_idx = func_line_idx + 1
515 while insert_idx < len(lines):
516 line_content = lines[insert_idx].strip()
517 # Skip docstrings, comments, and empty lines
518 if not line_content or line_content.startswith("#"):
519 insert_idx += 1
520 elif line_content.startswith('"""') or line_content.startswith("'''"):
521 # Skip entire docstring
522 quote = '"""' if '"""' in line_content else "'''"
523 if line_content.count(quote) == 1:
524 # Docstring continues on next lines
525 insert_idx += 1
526 while insert_idx < len(lines) and quote not in lines[insert_idx]:
527 insert_idx += 1
528 insert_idx += 1
529 else:
530 break
531
532 # OI-425: derive a tab-safe fallback from the ``def`` line itself.
533 # ``def_indent`` is the leading whitespace on the ``def`` line
534 # (empty for module-level functions, one unit deeper for nested
535 # defs); adding ``file_indent_unit`` yields the expected body
536 # indentation in whatever style the rest of the file uses.
537 def_indent = _get_indent_at_line(
538 lines, func_line_idx, fallback="",
539 )
540 body_fallback = def_indent + file_indent_unit
541 indent = _get_indent_at_line(
542 lines, insert_idx, fallback=body_fallback,
543 )
544
545 pattern = f'{indent}func = "{func_name}"'
546 lines.insert(insert_idx, pattern)
547
548 new_text = '\n'.join(lines)
549
550 fixed_names = [name for name, _ in functions_to_fix]
551 messages = [f"Added func pattern to: {', '.join(fixed_names)}"]
552 return True, new_text, messages
553
554
555# ============================================================================
556# File Processing
557# ============================================================================
558
559
560def format_file(path: Path, ctx: FormatterContext) -> Dict[str, Any]:
561 """Process a single file through all formatters."""
562 func = "format_file"
563
564 try:
565 text = path.read_text(encoding="utf-8")
566 except Exception as e:
567 return {
568 "path": _get_relative_path(path, ctx.project_root),
569 "changed": False,
570 "fixes": {
571 "header": {"changed": False, "messages": []},
572 "docstring": {"changed": False, "messages": []},
573 "dbg_import": {"changed": False, "messages": []},
574 "func_pattern": {"changed": False, "messages": []},
575 },
576 "errors": [f"Could not read file: {str(e)}"],
577 }
578
579 result = {
580 "path": _get_relative_path(path, ctx.project_root),
581 "changed": False,
582 "fixes": {},
583 "errors": [],
584 }
585
586 # Apply fixers in sequence
587 fixers = [
588 ("header", fix_header_block),
589 ("docstring", fix_docstring),
590 ("dbg_import", fix_dbg_import),
591 ("func_pattern", fix_func_pattern),
592 ]
593
594 any_changed = False
595
596 for fix_name, fixer in fixers:
597 try:
598 changed, text, messages = fixer(path, text, ctx)
599 result["fixes"][fix_name] = {"changed": changed, "messages": messages}
600 if changed:
601 any_changed = True
602 except Exception as e:
603 result["errors"].append(f"{fix_name}: {str(e)}")
604
605 # Single archive + write at end
606 if any_changed and ctx.fix_mode and not ctx.dry_run_mode:
608 path,
609 max_backups=ctx.max_backups_per_file,
610 max_archive_bytes=ctx.max_archive_bytes,
611 )
612 path.write_text(text, encoding="utf-8")
613
614 result["changed"] = any_changed
615 return result
616
617
618def compute_summary(file_results: List[Dict[str, Any]]) -> Dict[str, Any]:
619 """Aggregate results into summary."""
620 func = "compute_summary"
621
622 summary = {
623 "total_files": len(file_results),
624 "total_changed": sum(1 for r in file_results if r.get("changed")),
625 "fixes": {
626 "header": 0,
627 "docstring": 0,
628 "dbg_import": 0,
629 "func_pattern": 0,
630 },
631 }
632
633 for result in file_results:
634 for fix_type in summary["fixes"]:
635 if result["fixes"].get(fix_type, {}).get("changed"):
636 summary["fixes"][fix_type] += 1
637
638 return summary
639
640
641# ============================================================================
642# Output Formatting
643# ============================================================================
644
645
646def _print_terminal_report(summary: Dict[str, Any], file_results: List[Dict[str, Any]],
647 verbose: bool, dry_run: bool) -> None:
648 """Print human-readable terminal report."""
649 func = "_print_terminal_report"
650
651 mode = "DRY-RUN" if dry_run else "FIXED"
652
653 print(f"\n{'=' * 70}")
654 print(f"OCT FORMATTER REPORT — {mode}")
655 print(f"{'=' * 70}\n")
656
657 print(f"Files processed: {summary['total_files']}")
658 print(f"Files with changes: {summary['total_changed']}")
659 print()
660 print("Fixes applied:")
661 print(f" Header blocks: {summary['fixes']['header']}")
662 print(f" Docstrings: {summary['fixes']['docstring']}")
663 print(f" _dbg imports: {summary['fixes']['dbg_import']}")
664 print(f" func patterns: {summary['fixes']['func_pattern']}")
665
666 if verbose:
667 print(f"\n{'-' * 70}")
668 print("Detailed results:")
669 print(f"{'-' * 70}\n")
670
671 for result in file_results:
672 if result["changed"] or result["errors"]:
673 print(f"{result['path']}")
674
675 for fix_type, fix_result in result["fixes"].items():
676 if fix_result["changed"]:
677 print(f" [OK] {fix_type}: {', '.join(fix_result['messages'])}")
678
679 for error in result["errors"]:
680 print(f" [ERROR] {error}")
681
682 print()
683
684 print(f"{'=' * 70}\n")
685
686
687def _print_json_report(summary: Dict[str, Any], file_results: List[Dict[str, Any]],
688 ctx: FormatterContext) -> None:
689 """Print JSON output."""
690 func = "_print_json_report"
691
692 output = {
693 "project": ctx.project_name,
694 "oct_version": __version__,
695 "timestamp": datetime.now().isoformat(),
696 "dry_run": ctx.dry_run_mode,
697 "summary": summary,
698 "files": file_results,
699 }
700
701 print(json.dumps(output, indent=2))
702
703
704# ============================================================================
705# Main Entry Point
706# ============================================================================
707
708
709def run_formatter(project_root: Path, argv: Optional[List[str]] = None) -> None:
710 """
711 Run the Option C formatter on the given project root.
712
713 Parameters
714 ----------
715 project_root : Path
716 The detected Option C project root directory.
717 argv : list[str] | None
718 Optional list of command-line arguments.
719 If None, argparse will use sys.argv[1:].
720 """
721 func = "run_formatter"
722
723 # Parse arguments
724 parser = argparse.ArgumentParser(
725 description="Auto-fix Option C compliance violations",
726 prog="oct format",
727 )
728 parser.add_argument(
729 "--fix",
730 action="store_true",
731 help="Apply formatting changes. Without this, only reports what would change.",
732 )
733 parser.add_argument(
734 "--dry-run",
735 action="store_true",
736 help="Report what would change without modifying files (default behavior).",
737 )
738 parser.add_argument(
739 "--json",
740 action="store_true",
741 help="Output machine-readable JSON instead of colorized terminal report.",
742 )
743 parser.add_argument(
744 "--verbose",
745 action="store_true",
746 help="Show per-file details in addition to summary.",
747 )
748 parser.add_argument(
749 "--changed",
750 action="store_true",
751 help="Format only git-modified Python files.",
752 )
753
754 args = parser.parse_args(argv)
755
756 # Create context
757 diagnostics_dir = project_root / "oc_diagnostics"
758 tests_dir = project_root / "tests"
759 docs_dir = project_root / "docs"
760
761 # Find and process all Python files
762 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
763 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
764
765 # Load per-project overrides from .octrc.json (same as linter).
766 # OI-519: routed through central oct.core.octrc loader.
767 max_backups_per_file = _DEFAULT_MAX_BACKUPS_PER_FILE
768 from oct.core.octrc import load_octrc_safe
769 octrc = load_octrc_safe(project_root)
770 linter_cfg = octrc.get("linter", {})
771 if isinstance(linter_cfg, dict):
772 for d in linter_cfg.get("exclude_dirs_add", []):
773 exclude_dirs.add(d)
774 for d in linter_cfg.get("exclude_dirs_remove", []):
775 exclude_dirs.discard(d)
776 for d in linter_cfg.get("wildcard_exclude_add", []):
777 if d not in wildcard_exclude:
778 wildcard_exclude.append(d)
779 # OI-424: optional formatter.max_backups_per_file override.
780 # OI-516: optional formatter.max_archive_mib override — written as MiB
781 # in .octrc.json (readable) and converted to bytes on load.
782 max_archive_bytes = _DEFAULT_MAX_ARCHIVE_BYTES
783 formatter_cfg = octrc.get("formatter", {})
784 if isinstance(formatter_cfg, dict):
785 raw = formatter_cfg.get("max_backups_per_file")
786 if isinstance(raw, int) and raw >= 0:
787 max_backups_per_file = raw
788 raw_mib = formatter_cfg.get("max_archive_mib")
789 if isinstance(raw_mib, (int, float)) and raw_mib >= 0:
790 max_archive_bytes = int(raw_mib * 1024 * 1024)
791
792 ctx = FormatterContext(
793 project_root=project_root,
794 project_name=project_root.name,
795 diagnostics_dir=diagnostics_dir,
796 tests_dir=tests_dir,
797 docs_dir=docs_dir,
798 fix_mode=args.fix,
799 dry_run_mode=not args.fix,
800 json_mode=args.json,
801 verbose=args.verbose,
802 max_backups_per_file=max_backups_per_file,
803 max_archive_bytes=max_archive_bytes,
804 )
805
806 if args.changed:
807 from oct.linter.oct_lint import is_excluded_dir
808 python_files = _git_changed_files(project_root)
809 python_files = [
810 p for p in python_files
811 if p.is_file() and not any(
812 is_excluded_dir(Path(part), exclude_dirs, wildcard_exclude)
813 for part in p.relative_to(project_root).parts
814 )
815 ]
816 if not python_files:
817 if not args.json:
818 print("No modified Python files found.", file=sys.stderr)
819 return
820 else:
821 python_files = find_python_files(project_root, exclude_dirs, wildcard_exclude)
822
823 file_results = []
824 for py_file in python_files:
825 result = format_file(py_file, ctx)
826 file_results.append(result)
827
828 # Compute summary
829 summary = compute_summary(file_results)
830
831 # Output report
832 if ctx.json_mode:
833 _print_json_report(summary, file_results, ctx)
834 else:
835 _print_terminal_report(summary, file_results, ctx.verbose, ctx.dry_run_mode)
836
837
838if __name__ == "__main__":
839 project_root = find_project_root(Path.cwd())
840 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)
str _get_indent_at_line(List[str] lines, int line_num, str fallback=" ")
list[Path] _git_changed_files(Path root)
Definition oct_format.py:75
None run_formatter(Path project_root, Optional[List[str]] argv=None)
int _prune_archive_by_size(Path archive_dir, int max_total_bytes)
str _detect_indent_style(List[str] lines)
Tuple[bool, str, List[str]] fix_dbg_import(Path path, str text, FormatterContext ctx)
str _get_relative_path(Path path, Path root)
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)
None _print_json_report(Dict[str, Any] summary, List[Dict[str, Any]] file_results, FormatterContext ctx)
Dict[str, Any] format_file(Path path, FormatterContext ctx)