whycode-cli 0.4.0__tar.gz → 0.4.2__tar.gz
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.
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/PKG-INFO +1 -1
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/pyproject.toml +1 -1
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/__init__.py +1 -1
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/cache.py +33 -7
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/cli.py +120 -34
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/git_facts.py +298 -13
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/ignore.py +53 -1
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/signals.py +18 -1
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/PKG-INFO +1 -1
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_cache.py +51 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_cli.py +177 -1
- whycode_cli-0.4.2/tests/test_git_facts.py +394 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_ignore.py +51 -1
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_signals.py +72 -10
- whycode_cli-0.4.0/tests/test_git_facts.py +0 -188
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/LICENSE +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/README.md +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/setup.cfg +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/__main__.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/decisions.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/llm.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/mcp_server.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/risk_card.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/scorer.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/suppressions.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/templates/__init__.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/templates/github-workflow.yml +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/templates/pre-commit +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/SOURCES.txt +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/entry_points.txt +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/requires.txt +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/top_level.txt +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_decisions.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_mcp_prompts.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_scorer.py +0 -0
- {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_suppressions.py +0 -0
|
@@ -112,10 +112,21 @@ class CacheStore:
|
|
|
112
112
|
cache misses; this class never invokes ``git`` itself.
|
|
113
113
|
"""
|
|
114
114
|
|
|
115
|
-
def __init__(self, db_path: Path) -> None:
|
|
115
|
+
def __init__(self, db_path: Path, *, in_memory: bool = False) -> None:
|
|
116
|
+
"""Open (creating if needed) the SQLite cache at ``db_path``.
|
|
117
|
+
|
|
118
|
+
``in_memory=True`` opens a transient ``:memory:`` connection
|
|
119
|
+
instead — the disk file is never created and is never read.
|
|
120
|
+
Used by ``--no-cache`` to retain in-session amortisation
|
|
121
|
+
(matches the cold-fill code path) without persisting anything.
|
|
122
|
+
"""
|
|
116
123
|
self.db_path = db_path
|
|
117
|
-
self.
|
|
118
|
-
|
|
124
|
+
self._in_memory = in_memory
|
|
125
|
+
if in_memory:
|
|
126
|
+
self._conn = sqlite3.connect(":memory:")
|
|
127
|
+
else:
|
|
128
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
self._conn = sqlite3.connect(self.db_path)
|
|
119
130
|
# row_factory makes column access readable in tests / debug.
|
|
120
131
|
self._conn.row_factory = sqlite3.Row
|
|
121
132
|
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
@@ -402,13 +413,18 @@ class CacheStore:
|
|
|
402
413
|
file_row_count = int(
|
|
403
414
|
self._conn.execute("SELECT COUNT(*) FROM commit_files").fetchone()[0]
|
|
404
415
|
)
|
|
405
|
-
|
|
406
|
-
size_bytes = self.db_path.stat().st_size
|
|
407
|
-
except OSError:
|
|
416
|
+
if self._in_memory:
|
|
408
417
|
size_bytes = 0
|
|
418
|
+
exists = False
|
|
419
|
+
else:
|
|
420
|
+
try:
|
|
421
|
+
size_bytes = self.db_path.stat().st_size
|
|
422
|
+
except OSError:
|
|
423
|
+
size_bytes = 0
|
|
424
|
+
exists = self.db_path.exists()
|
|
409
425
|
return CacheStats(
|
|
410
426
|
path=self.db_path,
|
|
411
|
-
exists=
|
|
427
|
+
exists=exists,
|
|
412
428
|
schema_version=self.schema_version,
|
|
413
429
|
head_sha=self.head_sha,
|
|
414
430
|
commit_count=commit_count,
|
|
@@ -430,6 +446,16 @@ def open_for(repo_root: Path) -> CacheStore:
|
|
|
430
446
|
return CacheStore(cache_path_for(repo_root))
|
|
431
447
|
|
|
432
448
|
|
|
449
|
+
def open_in_memory(repo_root: Path) -> CacheStore:
|
|
450
|
+
"""Open a transient in-memory cache for ``repo_root``.
|
|
451
|
+
|
|
452
|
+
Used by ``--no-cache`` to keep within-session amortisation (the same
|
|
453
|
+
cold-fill code path everything else uses) while never touching disk.
|
|
454
|
+
The store is destroyed on ``close()`` and has no after-effects.
|
|
455
|
+
"""
|
|
456
|
+
return CacheStore(cache_path_for(repo_root), in_memory=True)
|
|
457
|
+
|
|
458
|
+
|
|
433
459
|
def parse_authored_at(value: str) -> datetime:
|
|
434
460
|
"""Parse the ``authored_at`` string we stored from git.
|
|
435
461
|
|
|
@@ -20,10 +20,12 @@ Commands
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import functools
|
|
23
24
|
import json
|
|
24
25
|
import sys
|
|
26
|
+
from collections.abc import Callable
|
|
25
27
|
from pathlib import Path
|
|
26
|
-
from typing import Any
|
|
28
|
+
from typing import Any, TypeVar
|
|
27
29
|
|
|
28
30
|
import typer
|
|
29
31
|
from rich.console import Console
|
|
@@ -48,18 +50,27 @@ err = Console(stderr=True)
|
|
|
48
50
|
|
|
49
51
|
|
|
50
52
|
def _open_cache(repo_root: Path, no_cache: bool) -> ch.CacheStore | None:
|
|
51
|
-
"""Open the
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
"""Open the cache for ``repo_root`` according to the no-cache flag.
|
|
54
|
+
|
|
55
|
+
Modes:
|
|
56
|
+
* ``no_cache=False`` (the default): persistent on-disk SQLite at
|
|
57
|
+
``.whycode/cache.db``.
|
|
58
|
+
* ``no_cache=True``: a transient ``:memory:`` SQLite store. The
|
|
59
|
+
same git-walk code path runs as for the cold-fill, but the
|
|
60
|
+
database is destroyed on ``close()`` — nothing lands on disk
|
|
61
|
+
and the next run starts cold. Keeping per-run amortisation
|
|
62
|
+
(one ``git log`` walk shared across files) is what makes
|
|
63
|
+
``--no-cache`` at most as slow as a cold persistent fill;
|
|
64
|
+
the previous ``cache=None`` short-circuit lost that and so
|
|
65
|
+
``--no-cache`` re-issued per-file walks every iteration.
|
|
66
|
+
|
|
67
|
+
A ``None`` return means "do not pass a cache through git_facts".
|
|
68
|
+
Happens only when even an in-memory open fails — very rare and
|
|
69
|
+
we never want a cache problem to block the main read path.
|
|
59
70
|
"""
|
|
60
|
-
if no_cache:
|
|
61
|
-
return None
|
|
62
71
|
try:
|
|
72
|
+
if no_cache:
|
|
73
|
+
return ch.open_in_memory(repo_root)
|
|
63
74
|
return ch.open_for(repo_root)
|
|
64
75
|
except OSError:
|
|
65
76
|
return None
|
|
@@ -115,6 +126,37 @@ def _require_tracked(path_arg: str) -> tuple[Path, str]:
|
|
|
115
126
|
return repo_root, rel
|
|
116
127
|
|
|
117
128
|
|
|
129
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _propagate_failures(func: _F) -> _F:
|
|
133
|
+
"""Convert any uncaught exception into ``typer.Exit(2)``.
|
|
134
|
+
|
|
135
|
+
A read-only field test against psf/requests caught a bug where a single
|
|
136
|
+
bad-timezone commit raised ``ValueError`` deep inside ``_parse_log_records``;
|
|
137
|
+
Rich rendered the traceback to stderr, but the process exited with status
|
|
138
|
+
0. CI integrations could not tell that the run had silently failed
|
|
139
|
+
(a ``whycode diff --fail-on history`` step was reported as green even
|
|
140
|
+
though it had crashed). We wrap each command body so any unhandled
|
|
141
|
+
exception leaves the existing rich traceback rendering in place but
|
|
142
|
+
forces a non-zero exit code (``2`` for general failure). ``typer.Exit``
|
|
143
|
+
and ``KeyboardInterrupt`` propagate untouched so explicit exit-code
|
|
144
|
+
paths and Ctrl-C still behave normally.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
@functools.wraps(func)
|
|
148
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
149
|
+
try:
|
|
150
|
+
return func(*args, **kwargs)
|
|
151
|
+
except (typer.Exit, typer.Abort, KeyboardInterrupt):
|
|
152
|
+
raise
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
err.print_exception(show_locals=False)
|
|
155
|
+
raise typer.Exit(2) from exc
|
|
156
|
+
|
|
157
|
+
return wrapper # type: ignore[return-value]
|
|
158
|
+
|
|
159
|
+
|
|
118
160
|
# --- shared: band threshold parsing ----------------------------------------
|
|
119
161
|
|
|
120
162
|
_BAND_THRESHOLDS_BY_KEY: dict[str, int] = {
|
|
@@ -148,6 +190,7 @@ def _print_brief(card: rc.RiskCard) -> None:
|
|
|
148
190
|
|
|
149
191
|
|
|
150
192
|
@app.command()
|
|
193
|
+
@_propagate_failures
|
|
151
194
|
def why(
|
|
152
195
|
path: str = typer.Argument(..., help="File path to inspect."),
|
|
153
196
|
json_out: bool = typer.Option(
|
|
@@ -317,6 +360,7 @@ def _resolve_base_ref(repo_root: Path, requested: str | None) -> str:
|
|
|
317
360
|
|
|
318
361
|
|
|
319
362
|
@app.command()
|
|
363
|
+
@_propagate_failures
|
|
320
364
|
def diff(
|
|
321
365
|
base: str | None = typer.Option(
|
|
322
366
|
None, "--base", help="Base ref (default: origin/main → main → HEAD~1)."
|
|
@@ -390,7 +434,9 @@ def diff(
|
|
|
390
434
|
cards.append(rc.build(repo_root, f, cache=cache))
|
|
391
435
|
except gf.GitError:
|
|
392
436
|
continue
|
|
393
|
-
|
|
437
|
+
# Stable tie-break: lex smallest path on identical scores so cache
|
|
438
|
+
# and --no-cache truncate the same files at --top N.
|
|
439
|
+
cards.sort(key=lambda c: (-c.score.value, c.path))
|
|
394
440
|
cards = cards[:top]
|
|
395
441
|
finally:
|
|
396
442
|
if cache is not None:
|
|
@@ -482,6 +528,7 @@ def diff(
|
|
|
482
528
|
|
|
483
529
|
|
|
484
530
|
@app.command()
|
|
531
|
+
@_propagate_failures
|
|
485
532
|
def highlights(
|
|
486
533
|
invariants: int = typer.Option(
|
|
487
534
|
5, "--invariants", help="How many invariant lines to surface."
|
|
@@ -529,16 +576,17 @@ def highlights(
|
|
|
529
576
|
|
|
530
577
|
inv_pairs = gf.extract_invariant_quotes(commits)
|
|
531
578
|
sha_to_commit = {c.sha: c for c in commits}
|
|
532
|
-
|
|
533
|
-
for sha, line in inv_pairs:
|
|
534
|
-
seen_lines.setdefault(line, sha)
|
|
579
|
+
deduped = gf.dedupe_invariant_lines(inv_pairs, sha_to_commit)
|
|
535
580
|
inv_records: list[tuple[str, str, gf.Commit]] = []
|
|
536
|
-
for
|
|
581
|
+
for sha, line in deduped:
|
|
537
582
|
commit = sha_to_commit.get(sha)
|
|
538
583
|
if commit is None:
|
|
539
584
|
continue
|
|
540
585
|
inv_records.append((line, sha, commit))
|
|
541
|
-
|
|
586
|
+
# Sort newest first; on identical timestamps fall back to lexicographically
|
|
587
|
+
# smallest sha so cache and --no-cache emit byte-identical output.
|
|
588
|
+
inv_records.sort(key=lambda t: t[1]) # secondary: sha asc
|
|
589
|
+
inv_records.sort(key=lambda t: t[2].authored_at, reverse=True) # primary
|
|
542
590
|
inv_records = inv_records[:invariants]
|
|
543
591
|
|
|
544
592
|
incident_records = gf.find_incidents(commits)[:incidents]
|
|
@@ -636,6 +684,7 @@ def _sample_indices(total: int, max_samples: int) -> list[int]:
|
|
|
636
684
|
|
|
637
685
|
|
|
638
686
|
@app.command()
|
|
687
|
+
@_propagate_failures
|
|
639
688
|
def timeline(
|
|
640
689
|
path: str = typer.Argument(..., help="File path to inspect."),
|
|
641
690
|
samples: int = typer.Option(
|
|
@@ -677,6 +726,12 @@ def timeline(
|
|
|
677
726
|
top,
|
|
678
727
|
)
|
|
679
728
|
)
|
|
729
|
+
# Field-test report F14: ``timeline`` used to render rows in whatever
|
|
730
|
+
# non-monotonic order ``_sample_indices`` produced (uniform-across-index
|
|
731
|
+
# selection on a list whose ordering is git's parent traversal). Sort
|
|
732
|
+
# by date ascending before rendering so a reader can scan left-to-right
|
|
733
|
+
# without misreading the trajectory.
|
|
734
|
+
rows.sort(key=lambda r: r[0])
|
|
680
735
|
|
|
681
736
|
if json_out:
|
|
682
737
|
console.print_json(
|
|
@@ -714,6 +769,7 @@ def timeline(
|
|
|
714
769
|
|
|
715
770
|
|
|
716
771
|
@app.command()
|
|
772
|
+
@_propagate_failures
|
|
717
773
|
def scan(
|
|
718
774
|
top: int = typer.Option(10, "--top", help="How many files to list."),
|
|
719
775
|
sample: int = typer.Option(
|
|
@@ -783,7 +839,10 @@ def scan(
|
|
|
783
839
|
if cache is not None:
|
|
784
840
|
cache.close()
|
|
785
841
|
|
|
786
|
-
|
|
842
|
+
# Stable tie-break on identical scores: lexicographically smallest path
|
|
843
|
+
# so cache and --no-cache produce byte-identical text output for the
|
|
844
|
+
# same HEAD. Without this, the truncation at --top N is non-deterministic.
|
|
845
|
+
cards.sort(key=lambda c: (-c.score.value, c.path))
|
|
787
846
|
top_cards = cards[:top]
|
|
788
847
|
if not top_cards:
|
|
789
848
|
# Be honest about what "no flagged files" actually means. A user who
|
|
@@ -811,6 +870,7 @@ def scan(
|
|
|
811
870
|
|
|
812
871
|
|
|
813
872
|
@app.command()
|
|
873
|
+
@_propagate_failures
|
|
814
874
|
def honest(
|
|
815
875
|
path: str = typer.Argument(..., help="File path to inspect."),
|
|
816
876
|
json_out: bool = typer.Option(False, "--json", help="Emit JSON instead of prose."),
|
|
@@ -874,6 +934,7 @@ def honest(
|
|
|
874
934
|
|
|
875
935
|
|
|
876
936
|
@app.command()
|
|
937
|
+
@_propagate_failures
|
|
877
938
|
def show(
|
|
878
939
|
sha: str = typer.Argument(..., help="Commit SHA (full or short) to inspect."),
|
|
879
940
|
repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
|
|
@@ -903,7 +964,8 @@ def show(
|
|
|
903
964
|
cards.append(rc.build(repo_root, change.path))
|
|
904
965
|
except gf.GitError:
|
|
905
966
|
continue
|
|
906
|
-
|
|
967
|
+
# Stable tie-break on identical scores: lex smallest path.
|
|
968
|
+
cards.sort(key=lambda c: (-c.score.value, c.path))
|
|
907
969
|
|
|
908
970
|
if json_out:
|
|
909
971
|
console.print_json(
|
|
@@ -981,6 +1043,7 @@ _MCP_SNIPPET = ''' {
|
|
|
981
1043
|
|
|
982
1044
|
|
|
983
1045
|
@app.command()
|
|
1046
|
+
@_propagate_failures
|
|
984
1047
|
def tour(
|
|
985
1048
|
repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
|
|
986
1049
|
no_cache: bool = typer.Option(
|
|
@@ -1018,29 +1081,50 @@ def tour(
|
|
|
1018
1081
|
|
|
1019
1082
|
inv_pairs = gf.extract_invariant_quotes(commits)
|
|
1020
1083
|
sha_to_commit = {c.sha: c for c in commits}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1084
|
+
deduped = gf.dedupe_invariant_lines(inv_pairs, sha_to_commit)
|
|
1085
|
+
# Sort newest first with sha-asc tie-break so cache and --no-cache
|
|
1086
|
+
# surface the same three lines in the same order.
|
|
1087
|
+
deduped_sorted = sorted(
|
|
1088
|
+
(p for p in deduped if p[0] in sha_to_commit),
|
|
1089
|
+
key=lambda p: p[0],
|
|
1090
|
+
)
|
|
1091
|
+
deduped_sorted.sort(
|
|
1092
|
+
key=lambda p: sha_to_commit[p[0]].authored_at, reverse=True
|
|
1093
|
+
)
|
|
1024
1094
|
invariants_top = [
|
|
1025
|
-
(line, sha_to_commit[sha])
|
|
1026
|
-
for line, sha in seen_lines.items()
|
|
1027
|
-
if sha in sha_to_commit
|
|
1095
|
+
(line, sha_to_commit[sha]) for sha, line in deduped_sorted
|
|
1028
1096
|
][:3]
|
|
1029
1097
|
incidents_top = gf.find_incidents(commits)[:3]
|
|
1030
1098
|
|
|
1031
1099
|
if invariants_top or incidents_top:
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1100
|
+
# Field-test report F16: the original tour rendered both classes
|
|
1101
|
+
# under one ``Decisions and incidents`` header, so a parenthetical
|
|
1102
|
+
# invariant prose line was visually indistinguishable from a real
|
|
1103
|
+
# incident commit. Render two subheads matching the layout
|
|
1104
|
+
# ``highlights`` already uses.
|
|
1105
|
+
if invariants_top:
|
|
1035
1106
|
console.print(
|
|
1036
|
-
f"
|
|
1107
|
+
f"[bold yellow]Stated invariants[/bold yellow] "
|
|
1108
|
+
f"[dim]({len(invariants_top)} most recent)[/dim]"
|
|
1037
1109
|
)
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1110
|
+
for line, c in invariants_top:
|
|
1111
|
+
console.print(f" [italic]{line}[/italic]")
|
|
1112
|
+
console.print(
|
|
1113
|
+
f" [dim]{c.sha[:7]} {c.authored_at.date()} "
|
|
1114
|
+
f"{c.author_name}[/dim]\n"
|
|
1115
|
+
)
|
|
1116
|
+
if incidents_top:
|
|
1041
1117
|
console.print(
|
|
1042
|
-
f"
|
|
1118
|
+
f"[bold red]Recent incidents[/bold red] "
|
|
1119
|
+
f"[dim]({len(incidents_top)} most recent)[/dim]"
|
|
1043
1120
|
)
|
|
1121
|
+
for c in incidents_top:
|
|
1122
|
+
subj = c.subject if len(c.subject) <= 70 else c.subject[:69] + "…"
|
|
1123
|
+
console.print(f" [red]{subj}[/red]")
|
|
1124
|
+
console.print(
|
|
1125
|
+
f" [dim]{c.sha[:7]} {c.authored_at.date()} "
|
|
1126
|
+
f"{c.author_name}[/dim]\n"
|
|
1127
|
+
)
|
|
1044
1128
|
else:
|
|
1045
1129
|
console.print(
|
|
1046
1130
|
"[dim]No headline decisions or incidents in recent history.[/dim]"
|
|
@@ -1072,7 +1156,8 @@ def tour(
|
|
|
1072
1156
|
]
|
|
1073
1157
|
if useful:
|
|
1074
1158
|
cards.append(card)
|
|
1075
|
-
|
|
1159
|
+
# Stable tie-break: lex smallest path on identical scores.
|
|
1160
|
+
cards.sort(key=lambda c: (-c.score.value, c.path))
|
|
1076
1161
|
|
|
1077
1162
|
if cards:
|
|
1078
1163
|
console.print("[bold red]Top 3 risky files[/bold red]")
|
|
@@ -1113,6 +1198,7 @@ def tour(
|
|
|
1113
1198
|
|
|
1114
1199
|
|
|
1115
1200
|
@app.command()
|
|
1201
|
+
@_propagate_failures
|
|
1116
1202
|
def init(
|
|
1117
1203
|
force: bool = typer.Option(
|
|
1118
1204
|
False, "--force", "-f", help="Overwrite existing files instead of skipping."
|