Coverage for src/debputy/commands/debputy_cmd/plugin_cmds.py: 13%

493 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-12 15:06 +0000

1import argparse 

2import dataclasses 

3import operator 

4import os 

5import sys 

6import textwrap 

7from itertools import chain 

8from typing import ( 

9 Union, 

10 Tuple, 

11 Any, 

12 Optional, 

13 Type, 

14) 

15from collections.abc import Sequence, Iterable, Callable 

16 

17from debputy.analysis.analysis_util import flatten_ppfs 

18from debputy.commands.debputy_cmd.context import ( 

19 CommandContext, 

20 add_arg, 

21 ROOT_COMMAND, 

22) 

23from debputy.commands.debputy_cmd.output import ( 

24 _stream_to_pager, 

25 _output_styling, 

26 IOBasedOutputStyling, 

27) 

28from debputy.exceptions import DebputySubstitutionError 

29from debputy.filesystem_scan import build_virtual_fs 

30from debputy.manifest_parser.declarative_parser import ( 

31 BASIC_SIMPLE_TYPES, 

32) 

33from debputy.manifest_parser.parser_doc import ( 

34 render_rule, 

35 render_multiline_documentation, 

36 render_type_mapping, 

37 render_source_type, 

38) 

39from debputy.manifest_parser.util import unpack_type 

40from debputy.packager_provided_files import detect_all_packager_provided_files 

41from debputy.plugin.api.doc_parsing import parser_type_name 

42from debputy.plugin.api.example_processing import ( 

43 process_discard_rule_example, 

44 DiscardVerdict, 

45) 

46from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

47from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

48from debputy.plugin.api.impl_types import ( 

49 PackagerProvidedFileClassSpec, 

50 PluginProvidedManifestVariable, 

51 DispatchingParserBase, 

52 PluginProvidedDiscardRule, 

53 AutomaticDiscardRuleExample, 

54 MetadataOrMaintscriptDetector, 

55 DebputyPluginMetadata, 

56 DeclarativeInputParser, 

57) 

58from debputy.plugin.api.parser_tables import ( 

59 SUPPORTED_DISPATCHABLE_TABLE_PARSERS, 

60 OPARSER_MANIFEST_ROOT, 

61) 

62from debputy.substitution import Substitution 

63from debputy.util import _error, _warn, manifest_format_doc 

64 

65plugin_dispatcher = ROOT_COMMAND.add_dispatching_subcommand( 

66 "plugin", 

67 "plugin_subcommand", 

68 default_subcommand="--help", 

69 help_description="Interact with debputy plugins", 

70 metavar="command", 

71) 

72 

73plugin_list_cmds = plugin_dispatcher.add_dispatching_subcommand( 

74 "list", 

75 "plugin_subcommand_list", 

76 metavar="topic", 

77 default_subcommand="plugins", 

78 help_description="List plugins or things provided by plugins (unstable format)." 

79 " Pass `--help` *after* `list` get a topic listing", 

80) 

81 

82plugin_show_cmds = plugin_dispatcher.add_dispatching_subcommand( 

83 "show", 

84 "plugin_subcommand_show", 

85 metavar="topic", 

86 help_description="Show details about a plugin or things provided by plugins (unstable format)." 

87 " Pass `--help` *after* `show` get a topic listing", 

88) 

89 

90 

91def format_output_arg( 

92 default_format: str, 

93 allowed_formats: Sequence[str], 

94 help_text: str, 

95) -> Callable[[argparse.ArgumentParser], None]: 

96 if default_format not in allowed_formats: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true

97 raise ValueError("The default format must be in the allowed_formats...") 

98 

99 def _configurator(argparser: argparse.ArgumentParser) -> None: 

100 argparser.add_argument( 

101 "--output-format", 

102 dest="output_format", 

103 default=default_format, 

104 choices=allowed_formats, 

105 help=help_text, 

106 ) 

107 

108 return _configurator 

109 

110 

111# To let --output-format=... "always" work 

112TEXT_ONLY_FORMAT = format_output_arg( 

113 "text", 

114 ["text"], 

115 "Select a given output format (options and output are not stable between releases)", 

116) 

117 

118 

119TEXT_CSV_FORMAT_NO_STABILITY_PROMISE = format_output_arg( 

120 "text", 

121 ["text", "csv"], 

122 "Select a given output format (options and output are not stable between releases)", 

123) 

124 

125 

126@plugin_list_cmds.register_subcommand( 

127 "plugins", 

128 help_description="List known plugins", 

129 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

130) 

131def _plugin_cmd_list_plugins(context: CommandContext) -> None: 

132 plugin_metadata_entries = context.load_plugins().plugin_data.values() 

133 # Because the "plugins" part is optional, we are not guaranteed that TEXT_CSV_FORMAT applies 

134 output_format = getattr(context.parsed_args, "output_format", "text") 

135 assert output_format in {"text", "csv"} 

136 with _stream_to_pager(context.parsed_args) as (fd, fo): 

137 fo.print_list_table( 

138 ["Plugin Name", "Plugin Path"], 

139 [(p.plugin_name, p.plugin_path) for p in plugin_metadata_entries], 

140 ) 

141 

142 

143def _path(path: str) -> str: 

144 if path.startswith("./"): 

145 return path[1:] 

146 return path 

147 

148 

149def _ppf_flags(ppf: PackagerProvidedFileClassSpec) -> str: 

150 flags = [] 

151 if ppf.allow_name_segment: 

152 flags.append("named") 

153 if ppf.allow_architecture_segment: 

154 flags.append("arch") 

155 if ppf.supports_priority: 

156 flags.append(f"priority={ppf.default_priority}") 

157 if ppf.packageless_is_fallback_for_all_packages: 

158 flags.append("main-all-fallback") 

159 if ppf.post_formatting_rewrite: 

160 flags.append("post-format-hook") 

161 return ",".join(flags) 

162 

163 

164@plugin_list_cmds.register_subcommand( 

165 ["used-packager-provided-files", "uppf", "u-p-p-f"], 

166 help_description="List packager provided files used by this package (debian/pkg.foo)", 

167 argparser=TEXT_ONLY_FORMAT, 

168) 

169def _plugin_cmd_list_uppf(context: CommandContext) -> None: 

170 plugin_feature_set = context.load_plugins() 

171 all_ppfs = detect_all_packager_provided_files( 

172 plugin_feature_set, 

173 context.debian_dir, 

174 context.binary_packages(), 

175 ) 

176 requested_plugins = set(context.requested_plugins()) 

177 requested_plugins.add("debputy") 

178 all_detected_ppfs = list(flatten_ppfs(all_ppfs)) 

179 

180 used_ppfs = [ 

181 p 

182 for p in all_detected_ppfs 

183 if p.definition.debputy_plugin_metadata.plugin_name in requested_plugins 

184 ] 

185 inactive_ppfs = [ 

186 p 

187 for p in all_detected_ppfs 

188 if p.definition.debputy_plugin_metadata.plugin_name not in requested_plugins 

189 ] 

190 

191 if not used_ppfs and not inactive_ppfs: 

192 print("No packager provided files detected; not even a changelog... ?") 

193 return 

194 

195 with _stream_to_pager(context.parsed_args) as (fd, fo): 

196 if used_ppfs: 

197 headers: Sequence[str | tuple[str, str]] = [ 

198 "File", 

199 "Matched Stem", 

200 "Installed Into", 

201 "Installed As", 

202 ] 

203 fo.print_list_table( 

204 headers, 

205 [ 

206 ( 

207 ppf.path.path, 

208 ppf.definition.stem, 

209 ppf.package_name, 

210 "/".join(ppf.compute_dest()).lstrip("."), 

211 ) 

212 for ppf in sorted( 

213 used_ppfs, key=operator.attrgetter("package_name") 

214 ) 

215 ], 

216 ) 

217 

218 if inactive_ppfs: 

219 headers: Sequence[str | tuple[str, str]] = [ 

220 "UNUSED FILE", 

221 "Matched Stem", 

222 "Installed Into", 

223 "Could Be Installed As", 

224 "If B-D Had", 

225 ] 

226 fo.print_list_table( 

227 headers, 

228 [ 

229 ( 

230 f"~{ppf.path.path}~", 

231 ppf.definition.stem, 

232 f"~{ppf.package_name}~", 

233 "/".join(ppf.compute_dest()).lstrip("."), 

234 f"debputy-plugin-{ppf.definition.debputy_plugin_metadata.plugin_name}", 

235 ) 

236 for ppf in sorted( 

237 inactive_ppfs, key=operator.attrgetter("package_name") 

238 ) 

239 ], 

240 ) 

241 

242 

243@plugin_list_cmds.register_subcommand( 

244 ["packager-provided-files", "ppf", "p-p-f"], 

245 help_description="List packager provided file definitions (debian/pkg.foo)", 

246 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

247) 

248def _plugin_cmd_list_ppf(context: CommandContext) -> None: 

249 ppfs: Iterable[PackagerProvidedFileClassSpec] 

250 ppfs = context.load_plugins().packager_provided_files.values() 

251 with _stream_to_pager(context.parsed_args) as (fd, fo): 

252 headers: Sequence[str | tuple[str, str]] = [ 

253 "Stem", 

254 "Installed As", 

255 ("Mode", ">"), 

256 "Features", 

257 "Provided by", 

258 ] 

259 fo.print_list_table( 

260 headers, 

261 [ 

262 ( 

263 ppf.stem, 

264 _path(ppf.installed_as_format), 

265 "0" + oct(ppf.default_mode)[2:], 

266 _ppf_flags(ppf), 

267 ppf.debputy_plugin_metadata.plugin_name, 

268 ) 

269 for ppf in sorted(ppfs, key=operator.attrgetter("stem")) 

270 ], 

271 ) 

272 

273 if os.path.isdir("debian/") and fo.output_format == "text": 

274 fo.print() 

275 fo.print( 

276 "Hint: You can use `debputy plugin list used-packager-provided-files` to have `debputy`", 

277 ) 

278 fo.print("list all the files in debian/ that matches these definitions.") 

279 

280 

281@plugin_list_cmds.register_subcommand( 

282 ["metadata-detectors"], 

283 help_description="List metadata detectors", 

284 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

285) 

286def _plugin_cmd_list_metadata_detectors(context: CommandContext) -> None: 

287 mds = list( 

288 chain.from_iterable( 

289 context.load_plugins().metadata_maintscript_detectors.values() 

290 ) 

291 ) 

292 

293 def _sort_key(md: "MetadataOrMaintscriptDetector") -> Any: 

294 return md.plugin_metadata.plugin_name, md.detector_id 

295 

296 with _stream_to_pager(context.parsed_args) as (fd, fo): 

297 fo.print_list_table( 

298 ["Provided by", "Detector Id"], 

299 [ 

300 (md.plugin_metadata.plugin_name, md.detector_id) 

301 for md in sorted(mds, key=_sort_key) 

302 ], 

303 ) 

304 

305 

306def _resolve_variable_for_list( 

307 substitution: Substitution, 

308 variable: PluginProvidedManifestVariable, 

309) -> str: 

310 var = "{{" + variable.variable_name + "}}" 

311 try: 

312 value = substitution.substitute(var, "CLI request") 

313 except DebputySubstitutionError: 

314 value = None 

315 return _render_manifest_variable_value(value) 

316 

317 

318def _render_manifest_variable_flag(variable: PluginProvidedManifestVariable) -> str: 

319 flags = [] 

320 if variable.is_for_special_case: 

321 flags.append("special-use-case") 

322 if variable.is_internal: 

323 flags.append("internal") 

324 return ",".join(flags) 

325 

326 

327def _render_list_filter(v: bool | None) -> str: 

328 if v is None: 

329 return "N/A" 

330 return "shown" if v else "hidden" 

331 

332 

333@plugin_list_cmds.register_subcommand( 

334 ["manifest-variables"], 

335 help_description="List plugin provided manifest variables (such as `{{path:FOO}}`)", 

336) 

337def plugin_cmd_list_manifest_variables(context: CommandContext) -> None: 

338 variables = context.load_plugins().manifest_variables 

339 substitution = context.substitution.with_extra_substitutions( 

340 PACKAGE="<package-name>" 

341 ) 

342 parsed_args = context.parsed_args 

343 show_special_case_vars = parsed_args.show_special_use_variables 

344 show_token_vars = parsed_args.show_token_variables 

345 show_all_vars = parsed_args.show_all_variables 

346 

347 def _include_var(var: PluginProvidedManifestVariable) -> bool: 

348 if show_all_vars: 

349 return True 

350 if var.is_internal: 

351 return False 

352 if var.is_for_special_case and not show_special_case_vars: 

353 return False 

354 if var.is_token and not show_token_vars: 

355 return False 

356 return True 

357 

358 with _stream_to_pager(context.parsed_args) as (fd, fo): 

359 fo.print_list_table( 

360 ["Variable (use via: `{{ NAME }}`)", "Value", "Flag", "Provided by"], 

361 [ 

362 ( 

363 k, 

364 _resolve_variable_for_list(substitution, var), 

365 _render_manifest_variable_flag(var), 

366 var.plugin_metadata.plugin_name, 

367 ) 

368 for k, var in sorted(variables.items()) 

369 if _include_var(var) 

370 ], 

371 ) 

372 

373 fo.print() 

374 

375 filters = [ 

376 ( 

377 "Token variables", 

378 show_token_vars if not show_all_vars else None, 

379 "--show-token-variables", 

380 ), 

381 ( 

382 "Special use variables", 

383 show_special_case_vars if not show_all_vars else None, 

384 "--show-special-case-variables", 

385 ), 

386 ] 

387 

388 fo.print_list_table( 

389 ["Variable type", "Value", "Option"], 

390 [ 

391 ( 

392 fname, 

393 _render_list_filter(value or show_all_vars), 

394 f"{option} OR --show-all-variables", 

395 ) 

396 for fname, value, option in filters 

397 ], 

398 ) 

399 

400 

401@plugin_cmd_list_manifest_variables.configure_handler 

402def list_manifest_variable_arg_parser( 

403 plugin_list_manifest_variables_parser: argparse.ArgumentParser, 

404) -> None: 

405 plugin_list_manifest_variables_parser.add_argument( 

406 "--show-special-case-variables", 

407 dest="show_special_use_variables", 

408 default=False, 

409 action="store_true", 

410 help="Show variables that are only used in special / niche cases", 

411 ) 

412 plugin_list_manifest_variables_parser.add_argument( 

413 "--show-token-variables", 

414 dest="show_token_variables", 

415 default=False, 

416 action="store_true", 

417 help="Show token (syntactical) variables like {{token:TAB}}", 

418 ) 

419 plugin_list_manifest_variables_parser.add_argument( 

420 "--show-all-variables", 

421 dest="show_all_variables", 

422 default=False, 

423 action="store_true", 

424 help="Show all variables regardless of type/kind (overrules other filter settings)", 

425 ) 

426 TEXT_ONLY_FORMAT(plugin_list_manifest_variables_parser) 

427 

428 

429@plugin_list_cmds.register_subcommand( 

430 ["pluggable-manifest-rules", "p-m-r", "pmr"], 

431 help_description="Pluggable manifest rules (such as install rules)", 

432 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

433) 

434def _plugin_cmd_list_manifest_rules(context: CommandContext) -> None: 

435 feature_set = context.load_plugins() 

436 

437 # Type hint to make the chain call easier for the type checker, which does not seem 

438 # to derive to this common base type on its own. 

439 base_type = Iterable[tuple[Union[str, type[Any]], DispatchingParserBase[Any]]] 

440 

441 parser_generator = feature_set.manifest_parser_generator 

442 table_parsers: base_type = parser_generator.dispatchable_table_parsers.items() 

443 object_parsers: base_type = parser_generator.dispatchable_object_parsers.items() 

444 

445 parsers = chain( 

446 table_parsers, 

447 object_parsers, 

448 ) 

449 

450 with _stream_to_pager(context.parsed_args) as (fd, fo): 

451 fo.print_list_table( 

452 ["Rule Name", "Rule Type", "Provided By"], 

453 [ 

454 ( 

455 rn, 

456 parser_type_name(rt), 

457 pt.parser_for(rn).plugin_metadata.plugin_name, 

458 ) 

459 for rt, pt in parsers 

460 for rn in pt.registered_keywords() 

461 ], 

462 ) 

463 

464 

465@plugin_list_cmds.register_subcommand( 

466 ["automatic-discard-rules", "a-d-r"], 

467 help_description="List automatic discard rules", 

468 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

469) 

470def _plugin_cmd_list_automatic_discard_rules(context: CommandContext) -> None: 

471 auto_discard_rules = context.load_plugins().auto_discard_rules 

472 

473 with _stream_to_pager(context.parsed_args) as (fd, fo): 

474 fo.print_list_table( 

475 ["Name", "Provided By"], 

476 [ 

477 ( 

478 name, 

479 ppdr.plugin_metadata.plugin_name, 

480 ) 

481 for name, ppdr in auto_discard_rules.items() 

482 ], 

483 ) 

484 

485 

486def _render_manifest_variable_value(v: str | None) -> str: 

487 if v is None: 

488 return "(N/A: Cannot resolve the variable)" 

489 v = v.replace("\n", "\\n").replace("\t", "\\t") 

490 return v 

491 

492 

493@plugin_show_cmds.register_subcommand( 

494 ["manifest-variables"], 

495 help_description="Plugin provided manifest variables (such as `{{path:FOO}}`)", 

496 argparser=add_arg( 

497 "manifest_variable", 

498 metavar="manifest-variable", 

499 help="Name of the variable (such as `path:FOO` or `{{path:FOO}}`) to display details about", 

500 ), 

501) 

502def _plugin_cmd_show_manifest_variables(context: CommandContext) -> None: 

503 plugin_feature_set = context.load_plugins() 

504 variables = plugin_feature_set.manifest_variables 

505 substitution = context.substitution 

506 parsed_args = context.parsed_args 

507 variable_name = parsed_args.manifest_variable 

508 fo = _output_styling(context.parsed_args, sys.stdout) 

509 if variable_name.startswith("{{") and variable_name.endswith("}}"): 

510 variable_name = variable_name[2:-2] 

511 variable: PluginProvidedManifestVariable | None 

512 if variable_name.startswith("env:") and len(variable_name) > 4: 

513 env_var = variable_name[4:] 

514 variable = PluginProvidedManifestVariable( 

515 plugin_feature_set.plugin_data["debputy"], 

516 variable_name, 

517 variable_value=None, 

518 is_context_specific_variable=False, 

519 is_documentation_placeholder=True, 

520 variable_reference_documentation=textwrap.dedent( 

521 f"""\ 

522 Environment variable "{env_var}" 

523 

524 Note that uses beneath `builds:` may use the environment variable defined by 

525 `build-environment:` (depends on whether the rule uses eager or lazy 

526 substitution) while uses outside `builds:` will generally not use a definition 

527 from `build-environment:`. 

528 """ 

529 ), 

530 ) 

531 else: 

532 variable = variables.get(variable_name) 

533 if variable is None: 

534 _error( 

535 f'Cannot resolve "{variable_name}" as a known variable from any of the available' 

536 f" plugins. Please use `debputy plugin list manifest-variables` to list all known" 

537 f" provided variables." 

538 ) 

539 

540 var_with_braces = "{{" + variable_name + "}}" 

541 try: 

542 source_value = substitution.substitute(var_with_braces, "CLI request") 

543 except DebputySubstitutionError: 

544 source_value = None 

545 binary_value = source_value 

546 print(f"Variable: {variable_name}") 

547 fo.print_visual_formatting(f"=========={'=' * len(variable_name)}") 

548 print() 

549 

550 if variable.is_context_specific_variable: 

551 try: 

552 binary_value = substitution.with_extra_substitutions( 

553 PACKAGE="<package-name>", 

554 ).substitute(var_with_braces, "CLI request") 

555 except DebputySubstitutionError: 

556 binary_value = None 

557 

558 doc = variable.variable_reference_documentation or "No documentation provided" 

559 for line in render_multiline_documentation(doc): 

560 print(line) 

561 

562 if source_value == binary_value: 

563 print(f"Resolved: {_render_manifest_variable_value(source_value)}") 

564 else: 

565 print("Resolved:") 

566 print(f" [source context]: {_render_manifest_variable_value(source_value)}") 

567 print(f" [binary context]: {_render_manifest_variable_value(binary_value)}") 

568 

569 if variable.is_for_special_case: 

570 print( 

571 'Special-case: The variable has been marked as a "special-case"-only variable.' 

572 ) 

573 

574 if not variable.is_documentation_placeholder: 

575 print(f"Plugin: {variable.plugin_metadata.plugin_name}") 

576 

577 if variable.is_internal: 

578 print() 

579 # I knew everything I felt was showing on my face, and I hate that. I grated out, 

580 print("That was private.") 

581 

582 

583def _determine_ppf( 

584 context: CommandContext, 

585) -> tuple[PackagerProvidedFileClassSpec, bool]: 

586 feature_set = context.load_plugins() 

587 ppf_name = context.parsed_args.ppf_name 

588 try: 

589 return feature_set.packager_provided_files[ppf_name], False 

590 except KeyError: 

591 pass 

592 

593 orig_ppf_name = ppf_name 

594 if ( 

595 ppf_name.startswith("d/") 

596 and not os.path.lexists(ppf_name) 

597 and os.path.lexists("debian/" + ppf_name[2:]) 

598 ): 

599 ppf_name = "debian/" + ppf_name[2:] 

600 

601 if ppf_name in ("debian/control", "debian/debputy.manifest", "debian/rules"): 

602 if ppf_name == "debian/debputy.manifest": 

603 doc = manifest_format_doc("") 

604 else: 

605 doc = "Debian Policy Manual or a packaging tutorial" 

606 _error( 

607 f"Sorry. While {orig_ppf_name} is a well-defined packaging file, it does not match the definition of" 

608 f" a packager provided file. Please see {doc} for more information about this file" 

609 ) 

610 

611 if context.has_dctrl_file and os.path.lexists(ppf_name): 

612 basename = ppf_name[7:] 

613 if "/" not in basename: 

614 debian_dir = build_virtual_fs([basename]) 

615 all_ppfs = detect_all_packager_provided_files( 

616 feature_set, 

617 debian_dir, 

618 context.binary_packages(), 

619 ) 

620 if all_ppfs: 

621 matched = next(iter(all_ppfs.values())) 

622 if len(matched.auto_installable) == 1 and not matched.reserved_only: 

623 return matched.auto_installable[0].definition, True 

624 if not matched.auto_installable and len(matched.reserved_only) == 1: 

625 reserved = next(iter(matched.reserved_only.values())) 

626 if len(reserved) == 1: 

627 return reserved[0].definition, True 

628 

629 _error( 

630 f'Unknown packager provided file "{orig_ppf_name}". Please use' 

631 f" `debputy plugin list packager-provided-files` to see them all." 

632 ) 

633 

634 

635@plugin_show_cmds.register_subcommand( 

636 ["packager-provided-files", "ppf", "p-p-f"], 

637 help_description="Show details about a given packager provided file (debian/pkg.foo)", 

638 argparser=add_arg( 

639 "ppf_name", 

640 metavar="name", 

641 help="Name of the packager provided file (such as `changelog`) to display details about", 

642 ), 

643) 

644def _plugin_cmd_show_ppf(context: CommandContext) -> None: 

645 ppf, matched_file = _determine_ppf(context) 

646 

647 fo = _output_styling(context.parsed_args, sys.stdout) 

648 

649 fo.print(f"Packager Provided File: {ppf.stem}") 

650 fo.print_visual_formatting(f"========================{'=' * len(ppf.stem)}") 

651 fo.print() 

652 ref_doc = ppf.reference_documentation 

653 description = ref_doc.description if ref_doc else None 

654 doc_uris = ref_doc.format_documentation_uris if ref_doc else tuple() 

655 if description is None: 

656 fo.print( 

657 f"Sorry, no description provided by the plugin {ppf.debputy_plugin_metadata.plugin_name}." 

658 ) 

659 else: 

660 for line in description.splitlines(keepends=False): 

661 fo.print(line) 

662 

663 fo.print() 

664 fo.print("Features:") 

665 if ppf.packageless_is_fallback_for_all_packages: 

666 fo.print(f" * debian/{ppf.stem} is used for *ALL* packages") 

667 else: 

668 fo.print(f' * debian/{ppf.stem} is used for only for the "main" package') 

669 if ppf.allow_name_segment: 

670 fo.print(" * Supports naming segment (multiple files and custom naming).") 

671 else: 

672 fo.print( 

673 " * No naming support; at most one per package and it is named after the package." 

674 ) 

675 if ppf.allow_architecture_segment: 

676 fo.print(" * Supports architecture specific variants.") 

677 else: 

678 fo.print(" * No architecture specific variants.") 

679 if ppf.supports_priority: 

680 fo.print( 

681 f" * Has a priority system (default priority: {ppf.default_priority})." 

682 ) 

683 

684 fo.print() 

685 fo.print("Examples matches:") 

686 

687 if context.has_dctrl_file: 

688 first_pkg = next(iter(context.binary_packages())) 

689 else: 

690 first_pkg = "example-package" 

691 example_files = [ 

692 (f"debian/{ppf.stem}", first_pkg), 

693 (f"debian/{first_pkg}.{ppf.stem}", first_pkg), 

694 ] 

695 if ppf.allow_name_segment: 

696 example_files.append( 

697 (f"debian/{first_pkg}.my.custom.name.{ppf.stem}", "my.custom.name") 

698 ) 

699 if ppf.allow_architecture_segment: 

700 example_files.append((f"debian/{first_pkg}.{ppf.stem}.amd64", first_pkg)), 

701 if ppf.allow_name_segment: 

702 example_files.append( 

703 ( 

704 f"debian/{first_pkg}.my.custom.name.{ppf.stem}.amd64", 

705 "my.custom.name", 

706 ) 

707 ) 

708 fs_root = build_virtual_fs([x for x, _ in example_files]) 

709 priority = ppf.default_priority if ppf.supports_priority else None 

710 rendered_examples = [] 

711 for example_file, assigned_name in example_files: 

712 example_path = fs_root.lookup(example_file) 

713 assert example_path is not None and example_path.is_file 

714 dest = ppf.compute_dest( 

715 assigned_name, 

716 owning_package=first_pkg, 

717 assigned_priority=priority, 

718 path=example_path, 

719 ) 

720 dest_path = "/".join(dest).lstrip(".") 

721 rendered_examples.append((example_file, dest_path)) 

722 

723 fo.print_list_table(["Source file", "Installed As"], rendered_examples) 

724 

725 if doc_uris: 

726 fo.print() 

727 fo.print("Documentation URIs:") 

728 for uri in doc_uris: 

729 fo.print(f" * {fo.render_url(uri)}") 

730 

731 plugin_name = ppf.debputy_plugin_metadata.plugin_name 

732 fo.print() 

733 fo.print(f"Install Mode: 0{oct(ppf.default_mode)[2:]}") 

734 fo.print(f"Provided by plugin: {plugin_name}") 

735 if ( 

736 matched_file 

737 and plugin_name != "debputy" 

738 and plugin_name not in context.requested_plugins() 

739 ): 

740 fo.print() 

741 _warn( 

742 f"The file might *NOT* be used due to missing Build-Depends on debputy-plugin-{plugin_name}" 

743 ) 

744 

745 

746class UnresolvableRuleError(ValueError): 

747 pass 

748 

749 

750@dataclasses.dataclass 

751class PMRRuleLookup: 

752 rule_name: str 

753 parser: DeclarativeInputParser 

754 parser_type_name: str 

755 plugin_metadata: DebputyPluginMetadata 

756 is_root_rule: bool 

757 manifest_attribute_path: str 

758 

759 

760def lookup_pmr_rule( 

761 feature_set: PluginProvidedFeatureSet, 

762 pmr_rule_name: str, 

763) -> PMRRuleLookup: 

764 req_rule_type = None 

765 rule_name = pmr_rule_name 

766 if "::" in rule_name and rule_name != "::": 

767 req_rule_type, rule_name = rule_name.split("::", 1) 

768 

769 matched = [] 

770 

771 base_type = Iterable[tuple[Union[str, type[Any]], DispatchingParserBase[Any]]] 

772 parser_generator = feature_set.manifest_parser_generator 

773 table_parsers: base_type = parser_generator.dispatchable_table_parsers.items() 

774 object_parsers: base_type = parser_generator.dispatchable_object_parsers.items() 

775 

776 parsers = chain( 

777 table_parsers, 

778 object_parsers, 

779 ) 

780 

781 for rule_type, dispatching_parser in parsers: 

782 if req_rule_type is not None and req_rule_type not in parser_type_name( 

783 rule_type 

784 ): 

785 continue 

786 if dispatching_parser.is_known_keyword(rule_name): 

787 matched.append((rule_type, dispatching_parser)) 

788 

789 if len(matched) != 1 and (matched or rule_name != "::"): 

790 if not matched: 

791 raise UnresolvableRuleError( 

792 f"Could not find any pluggable manifest rule related to {pmr_rule_name}." 

793 f" Please use `debputy plugin list pluggable-manifest-rules` to see the list of rules." 

794 ) 

795 match_a = matched[0][0] 

796 match_b = matched[1][0] 

797 raise UnresolvableRuleError( 

798 f"The name {rule_name} was ambiguous and matched multiple rule types. Please use" 

799 f" <rule-type>::{rule_name} to clarify which rule to use" 

800 f" (such as {parser_type_name(match_a)}::{rule_name} or {parser_type_name(match_b)}::{rule_name})." 

801 f" Please use `debputy plugin list pluggable-manifest-rules` to see the list of rules." 

802 ) 

803 

804 if matched: 

805 rule_type, matched_dispatching_parser = matched[0] 

806 plugin_provided_parser = matched_dispatching_parser.parser_for(rule_name) 

807 if isinstance(rule_type, str): 

808 manifest_attribute_path = rule_type 

809 else: 

810 manifest_attribute_path = SUPPORTED_DISPATCHABLE_TABLE_PARSERS[rule_type] 

811 full_parser_type_name = parser_type_name(rule_type) 

812 parser = plugin_provided_parser.parser 

813 plugin_metadata = plugin_provided_parser.plugin_metadata 

814 else: 

815 rule_name = "::" 

816 parser = parser_generator.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] 

817 full_parser_type_name = "" 

818 plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

819 manifest_attribute_path = "" 

820 

821 is_root_rule = rule_name == "::" 

822 return PMRRuleLookup( 

823 rule_name, 

824 parser, 

825 full_parser_type_name, 

826 plugin_metadata, 

827 is_root_rule, 

828 manifest_attribute_path, 

829 ) 

830 

831 

832@plugin_show_cmds.register_subcommand( 

833 ["pluggable-manifest-rules", "p-m-r", "pmr"], 

834 help_description="Pluggable manifest rules (such as install rules)", 

835 argparser=add_arg( 

836 "pmr_rule_name", 

837 metavar="rule-name", 

838 help="Name of the rule (such as `install`) to display details about", 

839 ), 

840) 

841def _plugin_cmd_show_manifest_rule(context: CommandContext) -> None: 

842 feature_set = context.load_plugins() 

843 parsed_args = context.parsed_args 

844 fo = _output_styling(parsed_args, sys.stdout) 

845 

846 try: 

847 pmr_rule = lookup_pmr_rule(feature_set, parsed_args.pmr_rule_name) 

848 except UnresolvableRuleError as e: 

849 _error(e.args[0]) 

850 

851 print( 

852 render_rule( 

853 pmr_rule.rule_name, 

854 pmr_rule.parser, 

855 pmr_rule.plugin_metadata, 

856 fo, 

857 is_root_rule=pmr_rule.is_root_rule, 

858 ) 

859 ) 

860 

861 if not pmr_rule.is_root_rule: 

862 manifest_attribute_path = pmr_rule.manifest_attribute_path 

863 print( 

864 f"Used in: {manifest_attribute_path if manifest_attribute_path != '<ROOT>' else 'The manifest root'}" 

865 ) 

866 print(f"Rule reference: {pmr_rule.parser_type_name}::{pmr_rule.rule_name}") 

867 print(f"Plugin: {pmr_rule.plugin_metadata.plugin_name}") 

868 else: 

869 print(f"Rule reference: {pmr_rule.rule_name}") 

870 

871 print() 

872 print( 

873 "PS: If you want to know more about a non-trivial type of an attribute such as `FileSystemMatchRule`," 

874 ) 

875 print( 

876 "you can use `debputy plugin show type-mappings FileSystemMatchRule` to look it up " 

877 ) 

878 

879 

880def _render_discard_rule_example( 

881 fo: IOBasedOutputStyling, 

882 discard_rule: PluginProvidedDiscardRule, 

883 example: AutomaticDiscardRuleExample, 

884) -> None: 

885 processed = process_discard_rule_example(discard_rule, example) 

886 

887 if processed.inconsistent_paths: 

888 plugin_name = discard_rule.plugin_metadata.plugin_name 

889 _warn( 

890 f"This example is inconsistent with what the code actually does." 

891 f" Please consider filing a bug against the plugin {plugin_name}" 

892 ) 

893 

894 doc = example.description 

895 if doc: 

896 print(doc) 

897 

898 print("Consider the following source paths matched by a glob or directory match:") 

899 print() 

900 if fo.optimize_for_screen_reader: 

901 for p, _ in processed.rendered_paths: 

902 path_name = p.absolute 

903 print( 

904 f"The path {path_name} is a {'directory' if p.is_dir else 'file or symlink.'}" 

905 ) 

906 

907 print() 

908 if any(v.is_consistent and v.is_discarded for _, v in processed.rendered_paths): 

909 print("The following paths will be discarded by this rule:") 

910 for p, verdict in processed.rendered_paths: 

911 path_name = p.absolute 

912 if verdict.is_consistent and verdict.is_discarded: 

913 print() 

914 if p.is_dir: 

915 print(f"{path_name} along with anything beneath it") 

916 else: 

917 print(path_name) 

918 else: 

919 print("No paths will be discarded in this example.") 

920 

921 print() 

922 if any(v.is_consistent and v.is_kept for _, v in processed.rendered_paths): 

923 print("The following paths will be not be discarded by this rule:") 

924 for p, verdict in processed.rendered_paths: 

925 path_name = p.absolute 

926 if verdict.is_consistent and verdict.is_kept: 

927 print() 

928 print(path_name) 

929 

930 if any(not v.is_consistent for _, v in processed.rendered_paths): 

931 print() 

932 print( 

933 "The example was inconsistent with the code. These are the paths where the code disagrees with" 

934 " the provided example:" 

935 ) 

936 for p, verdict in processed.rendered_paths: 

937 path_name = p.absolute 

938 if not verdict.is_consistent: 

939 print() 

940 if verdict == DiscardVerdict.DISCARDED_BY_CODE: 

941 print( 

942 f"The path {path_name} was discarded by the code, but the example said it should" 

943 f" have been installed." 

944 ) 

945 else: 

946 print( 

947 f"The path {path_name} was not discarded by the code, but the example said it should" 

948 f" have been discarded." 

949 ) 

950 return 

951 

952 # Add +1 for dirs because we want trailing slashes in the output 

953 max_len = max( 

954 (len(p.absolute) + (1 if p.is_dir else 0)) for p, _ in processed.rendered_paths 

955 ) 

956 for p, verdict in processed.rendered_paths: 

957 path_name = p.absolute 

958 if p.is_dir: 

959 path_name += "/" 

960 

961 if not verdict.is_consistent: 

962 print(f" {path_name:<{max_len}} !! {verdict.message}") 

963 elif verdict.is_discarded: 

964 print(f" {path_name:<{max_len}} << {verdict.message}") 

965 else: 

966 print(f" {path_name:<{max_len}}") 

967 

968 

969def _render_discard_rule( 

970 context: CommandContext, 

971 discard_rule: PluginProvidedDiscardRule, 

972) -> None: 

973 fo = _output_styling(context.parsed_args, sys.stdout) 

974 print(fo.colored(f"Automatic Discard Rule: {discard_rule.name}", style="bold")) 

975 fo.print_visual_formatting( 

976 f"========================{'=' * len(discard_rule.name)}" 

977 ) 

978 print() 

979 doc = discard_rule.reference_documentation or "No documentation provided" 

980 for line in render_multiline_documentation( 

981 doc, first_line_prefix="", following_line_prefix="" 

982 ): 

983 print(line) 

984 

985 if len(discard_rule.examples) > 1: 

986 print() 

987 fo.print_visual_formatting("Examples") 

988 fo.print_visual_formatting("--------") 

989 print() 

990 for no, example in enumerate(discard_rule.examples, start=1): 

991 print( 

992 fo.colored( 

993 f"Example {no} of {len(discard_rule.examples)}", style="bold" 

994 ) 

995 ) 

996 fo.print_visual_formatting(f"........{'.' * len(str(no))}") 

997 _render_discard_rule_example(fo, discard_rule, example) 

998 elif discard_rule.examples: 

999 print() 

1000 print(fo.colored("Example", style="bold")) 

1001 fo.print_visual_formatting("-------") 

1002 print() 

1003 _render_discard_rule_example(fo, discard_rule, discard_rule.examples[0]) 

1004 

1005 

1006@plugin_show_cmds.register_subcommand( 

1007 ["automatic-discard-rules", "a-d-r"], 

1008 help_description="Automatic discard rules", 

1009 argparser=add_arg( 

1010 "discard_rule", 

1011 metavar="automatic-discard-rule", 

1012 help="Name of the automatic discard rule (such as `backup-files`)", 

1013 ), 

1014) 

1015def _plugin_cmd_show_automatic_discard_rules(context: CommandContext) -> None: 

1016 auto_discard_rules = context.load_plugins().auto_discard_rules 

1017 name = context.parsed_args.discard_rule 

1018 discard_rule = auto_discard_rules.get(name) 

1019 if discard_rule is None: 

1020 _error( 

1021 f'No automatic discard rule with the name "{name}". Please use' 

1022 f" `debputy plugin list automatic-discard-rules` to see the list of automatic discard rules" 

1023 ) 

1024 

1025 _render_discard_rule(context, discard_rule) 

1026 

1027 

1028@plugin_list_cmds.register_subcommand( 

1029 "type-mappings", 

1030 help_description="Registered type mappings/descriptions", 

1031) 

1032def _plugin_cmd_list_type_mappings(context: CommandContext) -> None: 

1033 type_mappings = context.load_plugins().mapped_types 

1034 

1035 with _stream_to_pager(context.parsed_args) as (fd, fo): 

1036 fo.print_list_table( 

1037 ["Type", "Base Type", "Provided By"], 

1038 [ 

1039 ( 

1040 target_type.__name__, 

1041 render_source_type(type_mapping.mapped_type.source_type), 

1042 type_mapping.plugin_metadata.plugin_name, 

1043 ) 

1044 for target_type, type_mapping in type_mappings.items() 

1045 ], 

1046 ) 

1047 

1048 

1049@plugin_show_cmds.register_subcommand( 

1050 "type-mappings", 

1051 help_description="Registered type mappings/descriptions", 

1052 argparser=add_arg( 

1053 "type_mapping", 

1054 metavar="type-mapping", 

1055 help="Name of the type", 

1056 ), 

1057) 

1058def _plugin_cmd_show_type_mappings(context: CommandContext) -> None: 

1059 type_mapping_name = context.parsed_args.type_mapping 

1060 type_mappings = context.load_plugins().mapped_types 

1061 fo = _output_styling(context.parsed_args, sys.stdout) 

1062 

1063 matches = [] 

1064 for type_ in type_mappings: 

1065 if type_.__name__ == type_mapping_name: 

1066 matches.append(type_) 

1067 

1068 if not matches: 

1069 simple_types = set(BASIC_SIMPLE_TYPES.values()) 

1070 simple_types.update(t.__name__ for t in BASIC_SIMPLE_TYPES) 

1071 

1072 if type_mapping_name in simple_types: 

1073 print(f"The type {type_mapping_name} is a YAML scalar.") 

1074 return 

1075 if type_mapping_name == "Any": 

1076 print( 

1077 "The Any type is a placeholder for when no typing information is provided. Often this implies" 

1078 " custom parse logic." 

1079 ) 

1080 return 

1081 

1082 if type_mapping_name in ("List", "list"): 

1083 print( 

1084 f"The {type_mapping_name} is a YAML Sequence. Please see the YAML documentation for examples." 

1085 ) 

1086 return 

1087 

1088 if type_mapping_name in ("Mapping", "dict"): 

1089 print( 

1090 f"The {type_mapping_name} is a YAML mapping. Please see the YAML documentation for examples." 

1091 ) 

1092 return 

1093 

1094 if "[" in type_mapping_name: 

1095 _error( 

1096 f"No known matches for {type_mapping_name}. Note: It looks like a composite type. Try searching" 

1097 " for its component parts. As an example, replace List[FileSystemMatchRule] with FileSystemMatchRule." 

1098 ) 

1099 

1100 _error(f"Sorry, no known matches for {type_mapping_name}") 

1101 

1102 if len(matches) > 1: 

1103 _error( 

1104 f"Too many matches for {type_mapping_name}... Sorry, there is no way to avoid this right now :'(" 

1105 ) 

1106 

1107 match = matches[0] 

1108 manifest_parser = context.manifest_parser() 

1109 

1110 pptm = type_mappings[match] 

1111 fo.print( 

1112 render_type_mapping( 

1113 pptm, 

1114 fo, 

1115 manifest_parser, 

1116 recover_from_broken_examples=context.parsed_args.debug_mode, 

1117 ) 

1118 ) 

1119 

1120 fo.print() 

1121 fo.print(f"Provided by plugin: {pptm.plugin_metadata.plugin_name}") 

1122 

1123 

1124def ensure_plugin_commands_are_loaded() -> None: 

1125 # Loading the module does the heavy lifting 

1126 # However, having this function means that we do not have an "unused" import that some tool 

1127 # gets tempted to remove 

1128 assert ROOT_COMMAND.has_command("plugin")