codeecho 1.1.0__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codeecho/__init__.py CHANGED
@@ -2,21 +2,25 @@
2
2
  codeecho - A developer tool that scans your codebase to detect and highlight ECHOES
3
3
  of duplicated or near-duplicated code so you can refactor toward cleaner, more
4
4
  maintainable designs.
5
+
6
+ :author: Ron Webb
7
+ :since: 1.0.0
5
8
  """
6
9
 
7
10
  from env_dir_bootstrap import EnvDirBootstrap
8
11
  from logenrich import setup_logger
9
12
 
10
- __version__ = "1.1.0"
13
+ __version__ = "1.2.0"
11
14
 
12
15
  _bootstrapper = EnvDirBootstrap(
13
16
  env_var="CODEECHO_CONFIG_DIR",
14
- resources=["logging.ini", ".ignore"],
17
+ resources=["logging.ini", ".ignore", "config.ini"],
15
18
  package="codeecho",
16
19
  )
17
20
 
18
21
  _bootstrapper.setup()
19
22
 
20
23
  CONF_DIR = str(_bootstrapper.get_dir())
24
+ DEFAULT_IGNORE_PATH = _bootstrapper.resolve(".ignore")
21
25
 
22
26
  setup_logger("codeecho", conf_dir=CONF_DIR)
codeecho/__main__.py CHANGED
@@ -4,9 +4,13 @@ CLI entry point for codeecho.
4
4
  Invoked via::
5
5
 
6
6
  poetry run python -m codeecho [OPTIONS] PATH
7
+
8
+ :author: Ron Webb
9
+ :since: 1.0.0
7
10
  """
8
11
 
9
12
  import json
13
+ import logging
10
14
  import os
11
15
  import uuid
12
16
  from pathlib import Path
@@ -24,20 +28,27 @@ from rich.progress import (
24
28
  )
25
29
  from rich.table import Table
26
30
 
27
- from codeecho import __version__, CONF_DIR
28
- from codeecho import extractor, fingerprint, parser as ts_parser, scanner
29
- from codeecho.db import SessionDB, get_db_path
30
- from codeecho.detector import detect
31
- from codeecho.models import ScanResult
32
- from codeecho.reporter import html_reporter, json_reporter
31
+ from . import __version__, CONF_DIR, DEFAULT_IGNORE_PATH
32
+ from . import basis as basis_module
33
+ from . import extractor, fingerprint, parser as ts_parser, scanner
34
+ from .config import Config
35
+ from .db import SessionDB, get_db_path
36
+ from .detector import detect
37
+ from .models import ScanResult
38
+ from .reporter import html_reporter, json_reporter
33
39
 
34
40
  _console = Console()
41
+ _config = Config()
42
+ _logger = logging.getLogger("codeecho.__main__")
35
43
 
36
44
  _FORMAT_CHOICES = click.Choice(["json", "html", "both"])
37
45
 
38
46
 
39
47
  def _parse_types(types_str: str) -> set[int]:
40
- """Parse ``--types`` value into a set of integers."""
48
+ """Parse ``--types`` value into a set of integers.
49
+
50
+ :since: 1.0.0
51
+ """
41
52
  if types_str.strip().lower() == "all":
42
53
  return {1, 2, 3}
43
54
  result: set[int] = set()
@@ -52,6 +63,120 @@ def _parse_types(types_str: str) -> set[int]:
52
63
  return result
53
64
 
54
65
 
66
+ def _read_target_list(list_file: Path) -> list[Path]:
67
+ """Read one target path per line from *list_file*.
68
+
69
+ Blank lines and lines starting with ``#`` are skipped.
70
+
71
+ :since: 1.2.0
72
+ """
73
+ result: list[Path] = []
74
+ for line in list_file.read_text(encoding="utf-8").splitlines():
75
+ stripped = line.strip()
76
+ if stripped and not stripped.startswith("#"):
77
+ result.append(Path(stripped))
78
+ return result
79
+
80
+
81
+ def _load_ignore_file(ignore_path: Path, base_dir: Path) -> IgnoreFile | None:
82
+ """Load an :class:`IgnoreFile` from *ignore_path*, logging failures.
83
+
84
+ Returns ``None`` on a missing or non-UTF-8 file instead of raising.
85
+
86
+ :since: 1.2.0
87
+ """
88
+ try:
89
+ return IgnoreFile(ignore_path, base_dir=base_dir)
90
+ except FileNotFoundError:
91
+ _logger.warning("Ignore file not found at %s", ignore_path)
92
+ return None
93
+ except UnicodeDecodeError as exc:
94
+ _logger.warning("Ignore file at %s is not valid UTF-8: %s", ignore_path, exc)
95
+ return None
96
+
97
+
98
+ def _build_ignore(base_dir: Path) -> IgnoreFile | None:
99
+ """Build a path-ignore matcher anchored at *base_dir*.
100
+
101
+ The ignore filename is resolved from ``config.ini``'s ``[override]
102
+ ignore-file`` setting under :data:`CONF_DIR`; when that file is missing,
103
+ the bundled default ``.ignore`` is tried instead.
104
+
105
+ :since: 1.2.0
106
+ """
107
+ custom_path = Path(CONF_DIR) / _config.get_ignore_file()
108
+ ignore = _load_ignore_file(custom_path, base_dir)
109
+ if ignore is not None:
110
+ return ignore
111
+ if custom_path == Path(DEFAULT_IGNORE_PATH):
112
+ return None
113
+ return _load_ignore_file(Path(DEFAULT_IGNORE_PATH), base_dir)
114
+
115
+
116
+ def _resolve_target_paths(
117
+ paths: tuple[Path, ...], target_list: bool
118
+ ) -> tuple[Path, ...]:
119
+ """Validate *paths* and expand ``--target-list`` into concrete target paths.
120
+
121
+ :since: 1.2.0
122
+ """
123
+ if not paths:
124
+ raise click.UsageError("At least one PATH is required.")
125
+
126
+ if not target_list:
127
+ return paths
128
+
129
+ if len(paths) != 1 or not paths[0].is_file():
130
+ raise click.UsageError(
131
+ "--target-list requires PATH to be a single existing file."
132
+ )
133
+ expanded = tuple(_read_target_list(paths[0]))
134
+ if not expanded:
135
+ raise click.UsageError("--target-list file contains no target paths.")
136
+ return expanded
137
+
138
+
139
+ def _read_basis_targets(basis_path: Path | None) -> list[Path]:
140
+ """Read the basis path(s) listed in *basis_path*, if given.
141
+
142
+ Absolute entries are resolved (normalising case/symlinks); relative entries
143
+ (e.g. a bare filename) are left as-is so they can be matched by filename/suffix
144
+ against the scanned files instead of being resolved against the current
145
+ working directory.
146
+
147
+ :since: 1.2.0
148
+ """
149
+ if basis_path is None:
150
+ return []
151
+ targets = [
152
+ p.resolve() if p.is_absolute() else p for p in _read_target_list(basis_path)
153
+ ]
154
+ if not targets:
155
+ raise click.UsageError("--basis file contains no target paths.")
156
+ return targets
157
+
158
+
159
+ def _discover_files(
160
+ resolved: list[Path], exclude: tuple[str, ...]
161
+ ) -> list[tuple[Path, str]]:
162
+ """Print scan targets and return the discovered ``(file, language)`` pairs.
163
+
164
+ :since: 1.2.0
165
+ """
166
+ for target_path in resolved:
167
+ _console.print(f"[dim]Scanning:[/dim] [bold]{target_path}[/bold]")
168
+ try:
169
+ base_dir = Path(os.path.commonpath(resolved))
170
+ if not base_dir.is_dir():
171
+ base_dir = base_dir.parent
172
+ except ValueError:
173
+ base_dir = Path.cwd()
174
+ ignore_file = _build_ignore(base_dir)
175
+ return scanner.scan(
176
+ tuple(resolved), exclude_patterns=exclude, ignore_file=ignore_file
177
+ )
178
+
179
+
55
180
  @click.command(
56
181
  name="codeecho", context_settings={"help_option_names": ["-h", "--help"]}
57
182
  )
@@ -120,6 +245,33 @@ def _parse_types(types_str: str) -> set[int]:
120
245
  metavar="PATTERN",
121
246
  help="Glob pattern(s) to exclude from scanning (repeatable).",
122
247
  )
248
+ @click.option(
249
+ "--target-list",
250
+ "target_list",
251
+ is_flag=True,
252
+ default=False,
253
+ help=(
254
+ "Treat PATH as a single existing file listing target paths (files "
255
+ "and/or directories), one per line, instead of individual PATH "
256
+ "arguments. Blank lines and lines starting with '#' are skipped."
257
+ ),
258
+ )
259
+ @click.option(
260
+ "--basis",
261
+ "basis_path",
262
+ default=None,
263
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
264
+ metavar="FILE",
265
+ help=(
266
+ "File listing basis target paths, one per line, same format as "
267
+ "--target-list. Absolute file/directory entries are added to the scan "
268
+ "automatically and matched exactly; relative entries (e.g. a bare "
269
+ "filename) are matched by filename/suffix against any file discovered "
270
+ "in the scan. The report is filtered to only clone groups that touch "
271
+ "at least one basis file; groups duplicated purely among basis files "
272
+ "are flagged as such."
273
+ ),
274
+ )
123
275
  def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,invalid-name
124
276
  paths: tuple[Path, ...],
125
277
  types: str,
@@ -130,11 +282,15 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
130
282
  fmt: str,
131
283
  min_tokens: int,
132
284
  exclude: tuple[str, ...],
285
+ target_list: bool,
286
+ basis_path: Path | None,
133
287
  ) -> None:
134
288
  """Scan one or more PATH(s) for duplicate and near-duplicate code.
135
289
 
136
290
  Each PATH may be a file or a directory. Generates JSON and/or HTML reports,
137
291
  then removes the intermediate session data from the embedded database.
292
+
293
+ :since: 1.0.0
138
294
  """
139
295
  _console.print(
140
296
  Panel(
@@ -143,23 +299,26 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
143
299
  )
144
300
  )
145
301
 
146
- if not paths:
147
- raise click.UsageError("At least one PATH is required.")
148
-
302
+ paths = _resolve_target_paths(paths, target_list)
149
303
  detect_types = _parse_types(types)
150
304
  if output_dir is None:
151
305
  output_dir = Path.cwd() / "reports"
306
+ output_dir.mkdir(parents=True, exist_ok=True)
307
+
308
+ basis_targets = _read_basis_targets(basis_path)
309
+
152
310
  session_id = str(uuid.uuid4())
153
311
  config = {
154
312
  "types": types,
155
313
  "threshold": threshold,
156
314
  "min_tokens": min_tokens,
157
315
  "exclude": list(exclude),
316
+ "basis": [str(p) for p in basis_targets],
158
317
  }
159
-
160
- output_dir.mkdir(parents=True, exist_ok=True)
161
-
162
318
  resolved = [p.resolve() for p in paths]
319
+ for target in basis_targets:
320
+ if target.is_absolute() and target not in resolved:
321
+ resolved.append(target)
163
322
 
164
323
  db_path = Path(get_db_path(str(db_dir) if db_dir else None))
165
324
  with SessionDB(db_path=db_path) as session_db:
@@ -167,34 +326,37 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
167
326
  session_id, json.dumps([str(p) for p in resolved]), config
168
327
  )
169
328
 
170
- # ── Phase 1: File discovery ─────────────────────────────────────────
171
- for p in resolved:
172
- _console.print(f"[dim]Scanning:[/dim] [bold]{p}[/bold]")
173
- try:
174
- base_dir = Path(os.path.commonpath(resolved))
175
- if not base_dir.is_dir():
176
- base_dir = base_dir.parent
177
- except ValueError:
178
- base_dir = Path.cwd()
179
- _ignore_file = IgnoreFile(Path(CONF_DIR) / ".ignore", base_dir=base_dir)
180
- files = scanner.scan(
181
- tuple(resolved), exclude_patterns=exclude, ignore_file=_ignore_file
182
- )
183
- total_fragments = 0
329
+ files = _discover_files(resolved, exclude)
184
330
 
185
331
  if not files:
186
332
  _console.print("[yellow]No supported source files found.[/yellow]")
187
333
  session_db.delete_session(session_id)
188
334
  return
189
335
 
190
- # ── Phase 2: Parse → Extract → Hash ────────────────────────────────
336
+ basis_files = None
337
+ if basis_targets:
338
+ basis_files = basis_module.resolve_basis_files(files, basis_targets)
339
+ if not basis_files:
340
+ _console.print(
341
+ "[yellow]Warning: none of the --basis paths matched any scanned files.[/yellow]"
342
+ )
343
+
191
344
  total_fragments = _process_files(files, session_db, session_id, min_tokens)
192
345
 
193
- # ── Phase 3: Clone detection ────────────────────────────────────────
194
346
  cnt1, cnt2, cnt3 = _detect_clones(
195
347
  session_db, session_id, detect_types, threshold
196
348
  )
197
349
 
350
+ basis_counts = {1: 0, 2: 0, 3: 0}
351
+ if basis_files is not None:
352
+ groups = session_db.get_clone_groups(session_id)
353
+ groups_with_members = [
354
+ (g, session_db.get_fragments_for_group(g)) for g in groups
355
+ ]
356
+ basis_counts = basis_module.count_basis_groups_by_type(
357
+ groups_with_members, basis_files
358
+ )
359
+
198
360
  result = ScanResult(
199
361
  session_id=session_id,
200
362
  version=__version__,
@@ -204,12 +366,16 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
204
366
  type1_groups=cnt1,
205
367
  type2_groups=cnt2,
206
368
  type3_groups=cnt3,
369
+ basis_paths=[str(p) for p in basis_targets],
370
+ basis_type1_groups=basis_counts[1],
371
+ basis_type2_groups=basis_counts[2],
372
+ basis_type3_groups=basis_counts[3],
207
373
  )
208
374
 
209
- # ── Phase 4: Report generation ──────────────────────────────────────
210
- written = _write_reports(session_db, result, output_dir, output, fmt)
375
+ written = _write_reports(
376
+ session_db, result, output_dir, output, fmt, basis_files
377
+ )
211
378
 
212
- # ── Phase 5: Clean up session ───────────────────────────────────────
213
379
  session_db.delete_session(session_id)
214
380
 
215
381
  # ── Summary table (printed after DB is closed) ──────────────────────────
@@ -222,46 +388,72 @@ def _detect_clones(
222
388
  detect_types: set[int],
223
389
  threshold: float,
224
390
  ) -> tuple[int, int, int]:
225
- """Run clone detection and return (type1_count, type2_count, type3_count)."""
391
+ """Run clone detection and return (type1_count, type2_count, type3_count).
392
+
393
+ :since: 1.0.0
394
+ """
226
395
  _console.print("[dim]Detecting clones…[/dim]")
227
396
  return detect(session_db, session_id, detect_types, threshold)
228
397
 
229
398
 
230
- def _write_reports(
399
+ def _write_reports( # pylint: disable=too-many-arguments,too-many-positional-arguments
231
400
  session_db: SessionDB,
232
401
  result: ScanResult,
233
402
  output_dir: Path,
234
403
  output: str,
235
404
  fmt: str,
405
+ basis_files: frozenset[str] | None = None,
236
406
  ) -> list[Path]:
237
- """Write the requested report formats and return a list of written paths."""
407
+ """Write the requested report formats and return a list of written paths.
408
+
409
+ :since: 1.0.0
410
+ """
238
411
  written: list[Path] = []
239
412
  if fmt in ("json", "both"):
240
413
  written.append(
241
- json_reporter.write(session_db, result, output_dir / f"{output}.json")
414
+ json_reporter.write(
415
+ session_db, result, output_dir / f"{output}.json", basis_files
416
+ )
242
417
  )
243
418
  if fmt in ("html", "both"):
244
419
  written.append(
245
- html_reporter.write(session_db, result, output_dir / f"{output}.html")
420
+ html_reporter.write(
421
+ session_db, result, output_dir / f"{output}.html", basis_files
422
+ )
246
423
  )
247
424
  return written
248
425
 
249
426
 
250
427
  def _print_summary(result: ScanResult, written: list[Path]) -> None:
251
- """Print the scan summary table and list of saved report paths."""
428
+ """Print the scan summary table and list of saved report paths.
429
+
430
+ :since: 1.0.0
431
+ """
432
+ has_basis = bool(result.basis_paths)
433
+
434
+ def _value(count: int, basis_count: int) -> str:
435
+ return f"{count} ({basis_count})" if has_basis else str(count)
436
+
252
437
  table = Table(title="Scan Summary", show_header=True, header_style="bold magenta")
253
438
  table.add_column("Metric", style="dim", min_width=26)
254
439
  table.add_column("Value", justify="right", style="bold")
255
440
  table.add_row("Files scanned", str(result.files_scanned))
256
441
  table.add_row("Fragments extracted", str(result.fragments_extracted))
257
- table.add_row("[red]Type-1[/red] clone groups (exact)", str(result.type1_groups))
258
442
  table.add_row(
259
- "[yellow]Type-2[/yellow] clone groups (structural)", str(result.type2_groups)
443
+ "[red]Type-1[/red] clone groups (exact)",
444
+ _value(result.type1_groups, result.basis_type1_groups),
445
+ )
446
+ table.add_row(
447
+ "[yellow]Type-2[/yellow] clone groups (structural)",
448
+ _value(result.type2_groups, result.basis_type2_groups),
260
449
  )
261
450
  table.add_row(
262
- "[green]Type-3[/green] clone groups (near-duplicate)", str(result.type3_groups)
451
+ "[green]Type-3[/green] clone groups (near-duplicate)",
452
+ _value(result.type3_groups, result.basis_type3_groups),
263
453
  )
264
454
  _console.print(table)
455
+ if has_basis:
456
+ _console.print("[dim]Value in parentheses = basis-touching groups.[/dim]")
265
457
  _console.print("\n[bold]Reports saved:[/bold]")
266
458
  for dest in written:
267
459
  _console.print(f" [cyan]•[/cyan] {dest}")
@@ -277,6 +469,8 @@ def _process_files(
277
469
 
278
470
  Returns:
279
471
  Total number of fragments extracted.
472
+
473
+ :since: 1.0.0
280
474
  """
281
475
  total = 0
282
476
  with Progress(
codeecho/basis.py ADDED
@@ -0,0 +1,101 @@
1
+ """
2
+ Basis-file resolution and clone-group filtering for ``--basis`` scans.
3
+
4
+ A "basis" is a user-supplied subset of the scanned files that acts as the reference
5
+ source for duplicate detection: clone groups are filtered down to only those that
6
+ touch at least one basis file, and groups whose members are *all* basis files are
7
+ flagged as ``basis_internal`` (duplication found purely among the basis files).
8
+
9
+ :author: Ron Webb
10
+ :since: 1.2.0
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ from .models import CloneGroup, Fragment
16
+
17
+
18
+ def _matches_relative_suffix(file_path: Path, relative_target: Path) -> bool:
19
+ """Return True when *file_path*'s trailing path segments equal *relative_target*.
20
+
21
+ Comparison is case-insensitive so a bare filename like ``IInfuser.gs`` matches
22
+ the file regardless of the casing baked into either path.
23
+
24
+ :since: 1.2.0
25
+ """
26
+ rel_parts = relative_target.parts
27
+ file_parts = file_path.parts
28
+ if len(rel_parts) > len(file_parts):
29
+ return False
30
+ tail = file_parts[len(file_parts) - len(rel_parts) :]
31
+ return tuple(p.casefold() for p in tail) == tuple(p.casefold() for p in rel_parts)
32
+
33
+
34
+ def resolve_basis_files(
35
+ discovered: list[tuple[Path, str]], basis_targets: list[Path]
36
+ ) -> frozenset[str]:
37
+ """Return the resolved file paths from *discovered* that fall under *basis_targets*.
38
+
39
+ An absolute basis target matches a discovered file that equals it exactly, or that
40
+ lies inside it (when it's a directory). A relative basis target (e.g. a bare
41
+ filename like ``IInfuser.gs``, or a partial path like ``sub/File.gs``) instead
42
+ matches any discovered file whose trailing path segments equal it, wherever that
43
+ file lives within the scanned tree.
44
+
45
+ :param discovered: ``(path, language)`` pairs as returned by :func:`codeecho.scanner.scan`.
46
+ :param basis_targets: Basis file/directory paths read from the ``--basis`` file;
47
+ absolute entries are resolved, relative entries are kept as-is.
48
+ :returns: Frozen set of matching file paths (as strings, matching :attr:`Fragment.file_path`).
49
+ :since: 1.2.0
50
+ """
51
+ absolute_targets = [p for p in basis_targets if p.is_absolute()]
52
+ relative_targets = [p for p in basis_targets if not p.is_absolute()]
53
+ basis_dirs = [p for p in absolute_targets if p.is_dir()]
54
+ basis_files = {p for p in absolute_targets if p.is_file()}
55
+
56
+ matched: set[str] = set()
57
+ for file_path, _ in discovered:
58
+ if file_path in basis_files or any(
59
+ file_path.is_relative_to(d) for d in basis_dirs
60
+ ):
61
+ matched.add(str(file_path))
62
+ elif any(_matches_relative_suffix(file_path, rel) for rel in relative_targets):
63
+ matched.add(str(file_path))
64
+ return frozenset(matched)
65
+
66
+
67
+ def filter_groups_for_basis(
68
+ groups_with_members: list[tuple[CloneGroup, list[Fragment]]],
69
+ basis_files: frozenset[str],
70
+ ) -> list[tuple[CloneGroup, list[Fragment], bool]]:
71
+ """Keep only groups touching a basis file, flagging groups that are basis-only.
72
+
73
+ :param groups_with_members: ``(group, members)`` pairs for every detected clone group.
74
+ :param basis_files: Resolved basis file paths, as returned by :func:`resolve_basis_files`.
75
+ :returns: ``(group, members, basis_internal)`` triples for groups with >=1 basis member.
76
+ :since: 1.2.0
77
+ """
78
+ result: list[tuple[CloneGroup, list[Fragment], bool]] = []
79
+ for group, members in groups_with_members:
80
+ member_is_basis = [m.file_path in basis_files for m in members]
81
+ if not any(member_is_basis):
82
+ continue
83
+ result.append((group, members, all(member_is_basis)))
84
+ return result
85
+
86
+
87
+ def count_basis_groups_by_type(
88
+ groups_with_members: list[tuple[CloneGroup, list[Fragment]]],
89
+ basis_files: frozenset[str],
90
+ ) -> dict[int, int]:
91
+ """Count basis-touching clone groups per clone type (1, 2, 3).
92
+
93
+ :param groups_with_members: ``(group, members)`` pairs for every detected clone group.
94
+ :param basis_files: Resolved basis file paths, as returned by :func:`resolve_basis_files`.
95
+ :returns: Mapping of clone type to the number of basis-touching groups of that type.
96
+ :since: 1.2.0
97
+ """
98
+ counts: dict[int, int] = {1: 0, 2: 0, 3: 0}
99
+ for group, _, _ in filter_groups_for_basis(groups_with_members, basis_files):
100
+ counts[group.clone_type] = counts.get(group.clone_type, 0) + 1
101
+ return counts
codeecho/config.ini ADDED
@@ -0,0 +1,2 @@
1
+ [override]
2
+ ignore-file = .ignore
codeecho/config.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ User-configurable overrides for codeecho, read from ``config.ini``.
3
+
4
+ :author: Ron Webb
5
+ :since: 1.2.0
6
+ """
7
+
8
+ import configparser
9
+ from pathlib import Path
10
+
11
+ from . import CONF_DIR
12
+
13
+ _DEFAULT_IGNORE_FILE = ".ignore"
14
+
15
+
16
+ class Config:
17
+ """Reads user-configurable overrides from ``config.ini`` in *conf_dir*.
18
+
19
+ :author: Ron Webb
20
+ :since: 1.2.0
21
+ """
22
+
23
+ def __init__(self, conf_dir: str | None = None) -> None:
24
+ """Load ``config.ini`` from *conf_dir* (defaults to ``CONF_DIR``).
25
+
26
+ :since: 1.2.0
27
+ """
28
+ self._config = configparser.ConfigParser()
29
+ config_path = Path(conf_dir or CONF_DIR) / "config.ini"
30
+ self._config.read(config_path)
31
+
32
+ def get_ignore_file(self) -> str:
33
+ """Return the configured ignore filename override.
34
+
35
+ Falls back to ``.ignore`` when the section/key is absent.
36
+
37
+ :since: 1.2.0
38
+ """
39
+ return self._config.get(
40
+ "override", "ignore-file", fallback=_DEFAULT_IGNORE_FILE
41
+ )