Coverage for src/yore/_internal/cli.py: 51.46%
137 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-19 16:19 +0100
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-19 16:19 +0100
1# Why does this file exist, and why not put this in `__main__`?
2#
3# You might be tempted to import things from `__main__` later,
4# but that will cause problems: the code will get executed twice:
5#
6# - When you run `python -m yore` python will execute
7# `__main__.py` as a script. That means there won't be any
8# `yore.__main__` in `sys.modules`.
9# - When you import `__main__` it will get executed again (as a module) because
10# there's no `yore.__main__` in `sys.modules`.
12from __future__ import annotations
14import logging
15import re
16import subprocess
17import sys
18from dataclasses import dataclass, field
19from datetime import timedelta
20from difflib import unified_diff
21from functools import wraps
22from inspect import cleandoc
23from pathlib import Path
24from typing import TYPE_CHECKING, Any, Callable
25from typing import Annotated as An
27import cappa
28from typing_extensions import Doc
30from yore._internal import debug
31from yore._internal.config import Config, Unset
32from yore._internal.lib import DEFAULT_PREFIX, yield_buffer_comments, yield_files, yield_path_comments
34if TYPE_CHECKING:
35 from collections.abc import Iterator
38_NAME = "yore"
40_logger = logging.getLogger(__name__)
43@dataclass(frozen=True)
44class _FromConfig(cappa.ValueFrom):
45 def __init__(self, field: Unset | property, /) -> None:
46 attr_name = field.fget.__name__ if isinstance(field, property) else field.name # type: ignore[union-attr]
47 super().__init__(self._from_config, attr_name=attr_name)
49 @staticmethod
50 def _from_config(attr_name: str) -> Any:
51 # DUE: EOL 3.9: Replace `_load_config()` with `CommandMain._load_config()` within line.
52 config = _load_config()
53 value = getattr(config, attr_name)
54 return cappa.Empty if isinstance(value, Unset) else value
57def _parse_timedelta(value: str) -> timedelta:
58 """Parse a timedelta from a string."""
59 number, unit = re.match(r" *(\d+) *([a-z])[a-z]* *", value).groups() # type: ignore[union-attr]
60 multiplier = {"d": 1, "w": 7, "m": 31, "y": 365}[unit]
61 return timedelta(days=int(number) * multiplier)
64# DUE: EOL 3.9: Remove block.
65_dataclass_opts: dict[str, bool] = {}
66if sys.version_info >= (3, 10):
67 _dataclass_opts["kw_only"] = True
70@cappa.command(
71 name="check",
72 help="Check Yore comments.",
73 description=cleandoc(
74 """
75 This command checks existing Yore comments in your code base
76 against Python End of Life dates or the provided next version of your project.
77 """,
78 ),
79)
80# DUE: EOL 3.9: Replace `**_dataclass_opts` with `kw_only=True` within line.
81@dataclass(**_dataclass_opts)
82class CommandCheck:
83 """Command to check Yore comments."""
85 paths: An[
86 list[Path],
87 cappa.Arg(),
88 Doc("Path to files or directories to check."),
89 ] = field(default_factory=list)
91 bump: An[
92 str | None,
93 cappa.Arg(short=True, long=True, value_name="VERSION"),
94 Doc("The next version of your project."),
95 ] = None
97 eol_within: An[
98 timedelta | None,
99 cappa.Arg(short="-E", long="--eol/--eol-within", parse=_parse_timedelta, value_name="TIMEDELTA"),
100 Doc(
101 """
102 The time delta to start checking before the End of Life of a Python version.
103 It is provided in a human-readable format, like `2 weeks` or `1 month`.
104 Spaces are optional, and the unit can be shortened to a single letter:
105 `d` for days, `w` for weeks, `m` for months, and `y` for years.
106 """,
107 ),
108 ] = None
110 bol_within: An[
111 timedelta | None,
112 cappa.Arg(short="-B", long="--bol/--bol-within", parse=_parse_timedelta, value_name="TIMEDELTA"),
113 Doc(
114 """
115 The time delta to start checking before the Beginning of Life of a Python version.
116 It is provided in a human-readable format, like `2 weeks` or `1 month`.
117 Spaces are optional, and the unit can be shortened to a single letter:
118 `d` for days, `w` for weeks, `m` for months, and `y` for years.
119 """,
120 ),
121 ] = None
123 prefix: An[
124 str,
125 cappa.Arg(
126 short="-p",
127 long=True,
128 num_args=1,
129 default=_FromConfig(Config.prefix),
130 show_default=f"{Config.prefix} or `{DEFAULT_PREFIX}`",
131 ),
132 Doc("""The prefix for Yore comments."""),
133 ] = DEFAULT_PREFIX
135 def __call__(self) -> int:
136 """Check Yore comments."""
137 ok = True
138 paths = self.paths or [Path(".")]
139 for path in paths:
140 for comment in yield_path_comments(path, prefix=self.prefix):
141 ok &= comment.check(bump=self.bump, eol_within=self.eol_within, bol_within=self.bol_within)
142 return 0 if ok else 1
145@cappa.command(
146 name="diff",
147 help="See the diff you would get after fixing comments.",
148 description=cleandoc(
149 """
150 This command fixes all relevant Yore comments, then computes and prints
151 a Git-like diff in the console.
152 """,
153 ),
154)
155# DUE: EOL 3.9: Replace `**_dataclass_opts` with `kw_only=True` within line.
156@dataclass(**_dataclass_opts)
157class CommandDiff:
158 """Command to diff Yore comments."""
160 paths: An[
161 list[Path],
162 cappa.Arg(),
163 Doc("Path to files or directories to diff."),
164 ] = field(default_factory=list)
166 bump: An[
167 str | None,
168 cappa.Arg(short=True, long=True, value_name="VERSION"),
169 Doc("The next version of your project."),
170 ] = None
172 eol_within: An[
173 timedelta | None,
174 cappa.Arg(short="-E", long="--eol/--eol-within", parse=_parse_timedelta, value_name="TIMEDELTA"),
175 Doc(
176 """
177 The time delta to start diffing before the End of Life of a Python version.
178 It is provided in a human-readable format, like `2 weeks` or `1 month`.
179 Spaces are optional, and the unit can be shortened to a single letter:
180 `d` for days, `w` for weeks, `m` for months, and `y` for years.
181 """,
182 ),
183 ] = None
185 bol_within: An[
186 timedelta | None,
187 cappa.Arg(short="-B", long="--bol/--bol-within", parse=_parse_timedelta, value_name="TIMEDELTA"),
188 Doc(
189 """
190 The time delta to start diffing before the Beginning of Life of a Python version.
191 It is provided in a human-readable format, like `2 weeks` or `1 month`.
192 Spaces are optional, and the unit can be shortened to a single letter:
193 `d` for days, `w` for weeks, `m` for months, and `y` for years.
194 """,
195 ),
196 ] = None
198 highlight: An[
199 str | None,
200 cappa.Arg(
201 short="-H",
202 long="--highlight",
203 num_args=1,
204 default=_FromConfig(Config.diff_highlight),
205 show_default=f"{Config.diff_highlight}",
206 ),
207 Doc("The command to highlight diffs."),
208 ] = None
210 prefix: An[
211 str,
212 cappa.Arg(
213 short="-p",
214 long=True,
215 num_args=1,
216 default=_FromConfig(Config.prefix),
217 show_default=f"{Config.prefix} or `{DEFAULT_PREFIX}`",
218 ),
219 Doc("""The prefix for Yore comments."""),
220 ] = DEFAULT_PREFIX
222 def _diff(self, file: Path) -> Iterator[str]:
223 old_lines = file.read_text().splitlines(keepends=True)
224 new_lines = old_lines.copy()
225 for comment in sorted(
226 yield_buffer_comments(file, new_lines, prefix=self.prefix),
227 key=lambda c: c.lineno,
228 reverse=True,
229 ):
230 comment.fix(buffer=new_lines, bump=self.bump, eol_within=self.eol_within, bol_within=self.bol_within)
231 yield from unified_diff(old_lines, new_lines, fromfile=str(file), tofile=str(file))
233 def _diff_paths(self, paths: list[Path]) -> Iterator[str]:
234 for path in paths:
235 if path.is_file():
236 yield from self._diff(path)
237 else:
238 for file in sorted(yield_files(path)):
239 yield from self._diff(file)
241 def __call__(self) -> int:
242 """Diff Yore comments."""
243 lines = self._diff_paths(self.paths or [Path(".")])
244 if self.highlight:
245 process = subprocess.Popen(self.highlight, shell=True, text=True, stdin=subprocess.PIPE) # noqa: S602
246 for line in lines:
247 process.stdin.write(line) # type: ignore[union-attr]
248 process.stdin.close() # type: ignore[union-attr]
249 process.wait()
250 return int(process.returncode)
251 for line in lines:
252 print(line, end="")
253 return 0
256@cappa.command(
257 name="fix",
258 help="Fix Yore comments and the associated code lines.",
259 description=cleandoc(
260 """
261 This command will fix your code by transforming it according to the Yore comments.
262 """,
263 ),
264)
265# DUE: EOL 3.9: Replace `**_dataclass_opts` with `kw_only=True` within line.
266@dataclass(**_dataclass_opts)
267class CommandFix:
268 """Command to fix Yore comments."""
270 paths: An[
271 list[Path],
272 cappa.Arg(),
273 Doc("Path to files or directories to fix."),
274 ] = field(default_factory=list)
276 bump: An[
277 str | None,
278 cappa.Arg(short=True, long=True, value_name="VERSION"),
279 Doc("The next version of your project."),
280 ] = None
282 eol_within: An[
283 timedelta | None,
284 cappa.Arg(short="-E", long="--eol/--eol-within", parse=_parse_timedelta, value_name="TIMEDELTA"),
285 Doc(
286 """
287 The time delta to start fixing before the End of Life of a Python version.
288 It is provided in a human-readable format, like `2 weeks` or `1 month`.
289 Spaces are optional, and the unit can be shortened to a single letter:
290 `d` for days, `w` for weeks, `m` for months, and `y` for years.
291 """,
292 ),
293 ] = None
295 bol_within: An[
296 timedelta | None,
297 cappa.Arg(short="-B", long="--bol/--bol-within", parse=_parse_timedelta, value_name="TIMEDELTA"),
298 Doc(
299 """
300 The time delta to start fixing before the Beginning of Life of a Python version.
301 It is provided in a human-readable format, like `2 weeks` or `1 month`.
302 Spaces are optional, and the unit can be shortened to a single letter:
303 `d` for days, `w` for weeks, `m` for months, and `y` for years.
304 """,
305 ),
306 ] = None
308 prefix: An[
309 str,
310 cappa.Arg(
311 short="-p",
312 long=True,
313 num_args=1,
314 default=_FromConfig(Config.prefix),
315 show_default=f"{Config.prefix}",
316 ),
317 Doc("""The prefix for Yore comments."""),
318 ] = DEFAULT_PREFIX
320 def _fix(self, file: Path) -> None:
321 lines = file.read_text().splitlines(keepends=True)
322 count = 0
323 for comment in sorted(
324 yield_buffer_comments(file, lines, prefix=self.prefix),
325 key=lambda c: c.lineno,
326 reverse=True,
327 ):
328 if comment.fix(buffer=lines, bump=self.bump, eol_within=self.eol_within, bol_within=self.bol_within):
329 count += 1
330 if count:
331 file.write_text("".join(lines))
332 _logger.info(f"fixed {count} comment{'s' if count > 1 else ''} in {file}")
334 def __call__(self) -> int:
335 """Fix Yore comments."""
336 paths = self.paths or [Path(".")]
337 for path in paths:
338 if path.is_file():
339 self._fix(path)
340 else:
341 for file in yield_files(path):
342 self._fix(file)
343 return 0
346# DUE: EOL 3.9: Remove block.
347def _print_and_exit(
348 func: An[Callable[[], str | None], Doc("A function that returns or prints a string.")],
349 code: An[int, Doc("The status code to exit with.")] = 0,
350) -> Callable[[], None]:
351 """Argument action callable to print something and exit immediately."""
353 @wraps(func)
354 def _inner() -> None:
355 raise cappa.Exit(func() or "", code=code)
357 return _inner
360# DUE: EOL 3.9: Remove block.
361def _load_config(file: Path | None = None) -> Config:
362 if CommandMain._CONFIG is None:
363 CommandMain._CONFIG = Config.from_file(file) if file else Config.from_default_locations()
364 return CommandMain._CONFIG
367@cappa.command(
368 name=_NAME,
369 help="Manage legacy code in your code base with YORE comments.",
370 description=cleandoc(
371 """
372 This tool lets you add `# YORE` comments (similar to `# TODO` comments)
373 that will help you manage legacy code in your code base.
375 A YORE comment follows a simple syntax that tells why this legacy code
376 is here and how it can be checked, or fixed once it's time to do so.
377 The syntax is as follows:
379 ```python
380 # <PREFIX>: <eol|bump> <VERSION>: Remove <block|line>.
381 # <PREFIX>: <eol|bump> <VERSION>: replace <block|line> with line <LINENO>.
382 # <PREFIX>: <eol|bump> <VERSION>: replace <block|line> with lines <LINE-RANGE1[, LINE-RANGE2...]>.
383 # <PREFIX>: <eol|bump> <VERSION>: replace <block|line> with `<STRING>`.
384 # <PREFIX>: <eol|bump> <VERSION>: [regex-]replace `<PATTERN1>` with `<PATTERN2>` within <block|line>.
385 ```
387 Terms between `<` and `>` *must* be provided, while terms between `[` and `]` are optional.
388 Uppercase terms are placeholders that you should replace with actual values,
389 while lowercase terms are keywords that you should use literally.
390 Everything except placeholders is case-insensitive.
392 The default **prefix** is `YORE`. For now it is only configurable through the Python API.
394 Examples:
396 *Replace a block of code when Python 3.8 reaches its End of Life.
397 In this example, we want to replace the block with `from ast import unparse`.*
399 ```python
400 # YORE: EOL 3.8: Replace block with line 4.
401 if sys.version_info < (3, 9):
402 from astunparse import unparse
403 else:
404 from ast import unparse
405 ```
407 *Replace `lstrip` by `removeprefix` when Python 3.8 reaches its End of Life.*
409 ```python
410 # YORE: EOL 3.8: Replace `lstrip` with `removeprefix` within line.
411 return [cpn.lstrip("_") for cpn in a.split(".")] == [cpn.lstrip("_") for cpn in b.split(".")]
412 ```
414 *Simplify union of accepted types when we bump the project to version 1.0.0.*
416 ```python
417 def load_extensions(
418 # YORE: Bump 1.0.0: Replace ` | Sequence[LoadableExtension],` with `` within line.
419 *exts: LoadableExtension | Sequence[LoadableExtension],
420 ): ...
421 ```
422 """,
423 ),
424)
425# DUE: EOL 3.9: Replace `**_dataclass_opts` with `kw_only=True` within line.
426@dataclass(**_dataclass_opts)
427class CommandMain:
428 """Command to manage legacy code in your code base with YORE comments."""
430 subcommand: An[cappa.Subcommands[CommandCheck | CommandDiff | CommandFix], Doc("The selected subcommand.")]
432 # DUE: EOL 3.9: Replace `# ` with `` within block.
433 # @staticmethod
434 # def _load_config(file: Path | None = None) -> Config:
435 # if CommandMain._CONFIG is None:
436 # CommandMain._CONFIG = Config.from_file(file) if file else Config.from_default_locations()
437 # return CommandMain._CONFIG
439 # DUE: EOL 3.9: Replace `# ` with `` within block.
440 # @staticmethod
441 # def _print_and_exit(
442 # func: An[Callable[[], str | None], Doc("A function that returns or prints a string.")],
443 # code: An[int, Doc("The status code to exit with.")] = 0,
444 # ) -> Callable[[], None]:
445 # """Argument action callable to print something and exit immediately."""
446 #
447 # @wraps(func)
448 # def _inner() -> None:
449 # raise cappa.Exit(func() or "", code=code)
450 #
451 # return _inner
453 # DUE: EOL 3.9: Regex-replace `Config \| None = .*` with `ClassVar[Config | None] = None` within line.
454 _CONFIG: Config | None = field(default=None, init=False, repr=False)
456 config: An[
457 Config,
458 cappa.Arg(
459 short="-c",
460 long=True,
461 parse=_load_config,
462 propagate=True,
463 show_default="`config/yore.toml`, `yore.toml`, or `pyproject.toml`",
464 ),
465 Doc("Path to the configuration file."),
466 ] = field(default_factory=_load_config)
468 version: An[
469 bool,
470 cappa.Arg(
471 short="-V",
472 long=True,
473 action=_print_and_exit(debug._get_version),
474 num_args=0,
475 help="Print the program version and exit.",
476 ),
477 Doc("Version CLI option."),
478 ] = False
480 debug_info: An[
481 bool,
482 cappa.Arg(long=True, action=_print_and_exit(debug._print_debug_info), num_args=0),
483 Doc("Print debug information."),
484 ] = False
487def main(
488 args: An[list[str] | None, Doc("Arguments passed from the command line.")] = None,
489) -> An[int, Doc("An exit code.")]:
490 """Run the main program.
492 This function is executed when you type `yore` or `python -m yore`.
493 """
494 logging.basicConfig(level=logging.INFO, format="%(message)s")
495 output = cappa.Output(error_format=f"[bold]{_NAME}[/]: [bold red]error[/]: { message} ")
496 completion_option: cappa.Arg = cappa.Arg(
497 long=True,
498 action=cappa.ArgAction.completion,
499 choices=["complete", "generate"],
500 help="Print shell-specific completion source.",
501 )
502 help_option: cappa.Arg = cappa.Arg(
503 short="-h",
504 long=True,
505 action=cappa.ArgAction.help,
506 help="Print the program help and exit.",
507 )
508 help_formatter = cappa.HelpFormatter(default_format="Default: {default}.")
510 try:
511 return cappa.invoke(
512 CommandMain,
513 argv=args,
514 output=output,
515 help=help_option,
516 completion=completion_option,
517 help_formatter=help_formatter,
518 )
519 except cappa.Exit as exit:
520 return int(1 if exit.code is None else exit.code)