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

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`. 

11 

12from __future__ import annotations 

13 

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 

26 

27import cappa 

28from typing_extensions import Doc 

29 

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 

33 

34if TYPE_CHECKING: 

35 from collections.abc import Iterator 

36 

37 

38_NAME = "yore" 

39 

40_logger = logging.getLogger(__name__) 

41 

42 

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) 

48 

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 

55 

56 

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) 

62 

63 

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 

68 

69 

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.""" 

84 

85 paths: An[ 

86 list[Path], 

87 cappa.Arg(), 

88 Doc("Path to files or directories to check."), 

89 ] = field(default_factory=list) 

90 

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 

96 

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 

109 

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 

122 

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 

134 

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 

143 

144 

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.""" 

159 

160 paths: An[ 

161 list[Path], 

162 cappa.Arg(), 

163 Doc("Path to files or directories to diff."), 

164 ] = field(default_factory=list) 

165 

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 

171 

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 

184 

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 

197 

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 

209 

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 

221 

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)) 

232 

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) 

240 

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 

254 

255 

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.""" 

269 

270 paths: An[ 

271 list[Path], 

272 cappa.Arg(), 

273 Doc("Path to files or directories to fix."), 

274 ] = field(default_factory=list) 

275 

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 

281 

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 

294 

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 

307 

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 

319 

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}") 

333 

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 

344 

345 

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.""" 

352 

353 @wraps(func) 

354 def _inner() -> None: 

355 raise cappa.Exit(func() or "", code=code) 

356 

357 return _inner 

358 

359 

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 

365 

366 

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. 

374 

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: 

378 

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 ``` 

386 

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. 

391 

392 The default **prefix** is `YORE`. For now it is only configurable through the Python API. 

393 

394 Examples: 

395 

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`.* 

398 

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 ``` 

406 

407 *Replace `lstrip` by `removeprefix` when Python 3.8 reaches its End of Life.* 

408 

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 ``` 

413 

414 *Simplify union of accepted types when we bump the project to version 1.0.0.* 

415 

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.""" 

429 

430 subcommand: An[cappa.Subcommands[CommandCheck | CommandDiff | CommandFix], Doc("The selected subcommand.")] 

431 

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 

438 

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 

452 

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) 

455 

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) 

467 

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 

479 

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 

485 

486 

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. 

491 

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}.") 

509 

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)