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

497 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-04-19 20:37 +0000

1import argparse 

2import dataclasses 

3import operator 

4import os 

5import sys 

6import textwrap 

7from itertools import chain 

8from typing import ( 

9 Union, 

10 Any, 

11) 

12from collections.abc import Sequence, Iterable, Callable 

13 

14from debputy.analysis.analysis_util import flatten_ppfs 

15from debputy.commands.debputy_cmd.context import ( 

16 CommandContext, 

17 add_arg, 

18 ROOT_COMMAND, 

19) 

20from debputy.commands.debputy_cmd.output import ( 

21 _stream_to_pager, 

22 _output_styling, 

23 IOBasedOutputStyling, 

24) 

25from debputy.exceptions import DebputySubstitutionError 

26from debputy.filesystem_scan import build_virtual_fs 

27from debputy.manifest_parser.declarative_parser import ( 

28 BASIC_SIMPLE_TYPES, 

29) 

30from debputy.manifest_parser.parser_doc import ( 

31 render_rule, 

32 render_multiline_documentation, 

33 render_type_mapping, 

34 render_source_type, 

35) 

36from debputy.packager_provided_files import detect_all_packager_provided_files 

37from debputy.plugin.api.doc_parsing import parser_type_name 

38from debputy.plugin.api.example_processing import ( 

39 process_discard_rule_example, 

40 DiscardVerdict, 

41) 

42from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

43from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

44from debputy.plugin.api.impl_types import ( 

45 PackagerProvidedFileClassSpec, 

46 PluginProvidedManifestVariable, 

47 DispatchingParserBase, 

48 PluginProvidedDiscardRule, 

49 AutomaticDiscardRuleExample, 

50 MetadataOrMaintscriptDetector, 

51 DebputyPluginMetadata, 

52 DeclarativeInputParser, 

53) 

54from debputy.plugin.api.parser_tables import ( 

55 SUPPORTED_DISPATCHABLE_TABLE_PARSERS, 

56 OPARSER_MANIFEST_ROOT, 

57) 

58from debputy.substitution import Substitution 

59from debputy.util import _error, _warn, manifest_format_doc, PackageTypeSelector 

60 

61plugin_dispatcher = ROOT_COMMAND.add_dispatching_subcommand( 

62 "plugin", 

63 "plugin_subcommand", 

64 default_subcommand="--help", 

65 help_description="Interact with debputy plugins", 

66 metavar="command", 

67) 

68 

69plugin_list_cmds = plugin_dispatcher.add_dispatching_subcommand( 

70 "list", 

71 "plugin_subcommand_list", 

72 metavar="topic", 

73 default_subcommand="plugins", 

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

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

76) 

77 

78plugin_show_cmds = plugin_dispatcher.add_dispatching_subcommand( 

79 "show", 

80 "plugin_subcommand_show", 

81 metavar="topic", 

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

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

84) 

85 

86 

87def format_output_arg( 

88 default_format: str, 

89 allowed_formats: Sequence[str], 

90 help_text: str, 

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

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

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

94 

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

96 argparser.add_argument( 

97 "--output-format", 

98 dest="output_format", 

99 default=default_format, 

100 choices=allowed_formats, 

101 help=help_text, 

102 ) 

103 

104 return _configurator 

105 

106 

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

108TEXT_ONLY_FORMAT = format_output_arg( 

109 "text", 

110 ["text"], 

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

112) 

113 

114 

115TEXT_CSV_FORMAT_NO_STABILITY_PROMISE = format_output_arg( 

116 "text", 

117 ["text", "csv"], 

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

119) 

120 

121 

122@plugin_list_cmds.register_subcommand( 

123 "plugins", 

124 help_description="List known plugins", 

125 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

126) 

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

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

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

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

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

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

133 fo.print_list_table( 

134 headers=("Plugin Name", "Plugin Path"), 

135 rows=tuple((p.plugin_name, p.plugin_path) for p in plugin_metadata_entries), 

136 ) 

137 

138 

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

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

141 return path[1:] 

142 return path 

143 

144 

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

146 flags = [] 

147 if ppf.allow_name_segment: 

148 flags.append("named") 

149 if ppf.allow_architecture_segment: 

150 flags.append("arch") 

151 if ppf.supports_priority: 

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

153 if ppf.packageless_is_fallback_for_all_packages: 

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

155 if ppf.post_formatting_rewrite: 

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

157 return ",".join(flags) 

158 

159 

160@plugin_list_cmds.register_subcommand( 

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

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

163 argparser=TEXT_ONLY_FORMAT, 

164) 

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

166 plugin_feature_set = context.load_plugins() 

167 all_ppfs = detect_all_packager_provided_files( 

168 plugin_feature_set, 

169 context.debian_dir, 

170 context.binary_packages(), 

171 ) 

172 requested_plugins = set(context.requested_plugins()) 

173 requested_plugins.add("debputy") 

174 all_detected_ppfs = list(flatten_ppfs(all_ppfs)) 

175 

176 used_ppfs = [ 

177 p 

178 for p in all_detected_ppfs 

179 if p.definition.debputy_plugin_metadata.plugin_name in requested_plugins 

180 ] 

181 inactive_ppfs = [ 

182 p 

183 for p in all_detected_ppfs 

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

185 ] 

186 

187 if not used_ppfs and not inactive_ppfs: 

188 binary_packages = context.binary_packages() 

189 # In theory, we could be run on an incomplete `d/control`, so we only show the `udeb` case 

190 # when there is at least one binary package. 

191 if binary_packages and all( 

192 p.package_type == PackageTypeSelector.UDEB for p in binary_packages.values() 

193 ): 

194 print("No packager provided files detected.") 

195 print( 

196 'Since it is an "all udeb" source package, this is not unreasonable' 

197 " as `changelog` and `copyright` are excluded from `udebs`" 

198 ) 

199 else: 

200 print( 

201 "No packager provided files detected; not even a changelog. Perhaps a `dch --create` is needed?" 

202 ) 

203 return 

204 

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

206 if used_ppfs: 

207 fo.print_list_table( 

208 headers=( 

209 "File", 

210 "Matched Stem", 

211 "Installed Into", 

212 "Installed As", 

213 ), 

214 rows=tuple( 

215 ( 

216 ppf.path.path, 

217 ppf.definition.stem, 

218 ppf.package_name, 

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

220 ) 

221 for ppf in sorted( 

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

223 ) 

224 ), 

225 ) 

226 

227 if inactive_ppfs: 

228 fo.print_list_table( 

229 headers=( 

230 "UNUSED FILE", 

231 "Matched Stem", 

232 "Installed Into", 

233 "Could Be Installed As", 

234 "If B-D Had", 

235 ), 

236 rows=tuple( 

237 ( 

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

239 ppf.definition.stem, 

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

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

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

243 ) 

244 for ppf in sorted( 

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

246 ) 

247 ), 

248 ) 

249 

250 

251@plugin_list_cmds.register_subcommand( 

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

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

254 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

255) 

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

257 ppfs: Iterable[PackagerProvidedFileClassSpec] 

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

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

260 fo.print_list_table( 

261 headers=( 

262 "Stem", 

263 "Installed As", 

264 ("Mode", ">"), 

265 "Features", 

266 "Provided by", 

267 ), 

268 rows=tuple( 

269 ( 

270 ppf.stem, 

271 _path(ppf.installed_as_format), 

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

273 _ppf_flags(ppf), 

274 ppf.debputy_plugin_metadata.plugin_name, 

275 ) 

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

277 ), 

278 ) 

279 

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

281 fo.print() 

282 fo.print( 

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

284 ) 

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

286 

287 

288@plugin_list_cmds.register_subcommand( 

289 ["metadata-detectors"], 

290 help_description="List metadata detectors", 

291 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

292) 

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

294 mds = list( 

295 chain.from_iterable( 

296 context.load_plugins().metadata_maintscript_detectors.values() 

297 ) 

298 ) 

299 

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

301 return md.plugin_metadata.plugin_name, md.detector_id 

302 

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

304 fo.print_list_table( 

305 headers=("Provided by", "Detector Id"), 

306 rows=tuple( 

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

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

309 ), 

310 ) 

311 

312 

313def _resolve_variable_for_list( 

314 substitution: Substitution, 

315 variable: PluginProvidedManifestVariable, 

316) -> str: 

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

318 try: 

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

320 except DebputySubstitutionError: 

321 value = None 

322 return _render_manifest_variable_value(value) 

323 

324 

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

326 flags = [] 

327 if variable.is_for_special_case: 

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

329 if variable.is_internal: 

330 flags.append("internal") 

331 return ",".join(flags) 

332 

333 

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

335 if v is None: 

336 return "N/A" 

337 return "shown" if v else "hidden" 

338 

339 

340@plugin_list_cmds.register_subcommand( 

341 ["manifest-variables"], 

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

343) 

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

345 variables = context.load_plugins().manifest_variables 

346 substitution = context.substitution.with_extra_substitutions( 

347 PACKAGE="<package-name>" 

348 ) 

349 parsed_args = context.parsed_args 

350 show_special_case_vars = parsed_args.show_special_use_variables 

351 show_token_vars = parsed_args.show_token_variables 

352 show_all_vars = parsed_args.show_all_variables 

353 

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

355 if show_all_vars: 

356 return True 

357 if var.is_internal: 

358 return False 

359 if var.is_for_special_case and not show_special_case_vars: 

360 return False 

361 if var.is_token and not show_token_vars: 

362 return False 

363 return True 

364 

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

366 fo.print_list_table( 

367 headers=( 

368 "Variable (use via: `{{ NAME }}`)", 

369 "Value", 

370 "Flag", 

371 "Provided by", 

372 ), 

373 rows=tuple( 

374 ( 

375 k, 

376 _resolve_variable_for_list(substitution, var), 

377 _render_manifest_variable_flag(var), 

378 var.plugin_metadata.plugin_name, 

379 ) 

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

381 if _include_var(var) 

382 ), 

383 ) 

384 

385 fo.print() 

386 

387 filters = [ 

388 ( 

389 "Token variables", 

390 show_token_vars if not show_all_vars else None, 

391 "--show-token-variables", 

392 ), 

393 ( 

394 "Special use variables", 

395 show_special_case_vars if not show_all_vars else None, 

396 "--show-special-case-variables", 

397 ), 

398 ] 

399 

400 fo.print_list_table( 

401 headers=("Variable type", "Value", "Option"), 

402 rows=tuple( 

403 ( 

404 fname, 

405 _render_list_filter(value or show_all_vars), 

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

407 ) 

408 for fname, value, option in filters 

409 ), 

410 ) 

411 

412 

413@plugin_cmd_list_manifest_variables.configure_handler 

414def list_manifest_variable_arg_parser( 

415 plugin_list_manifest_variables_parser: argparse.ArgumentParser, 

416) -> None: 

417 plugin_list_manifest_variables_parser.add_argument( 

418 "--show-special-case-variables", 

419 dest="show_special_use_variables", 

420 default=False, 

421 action="store_true", 

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

423 ) 

424 plugin_list_manifest_variables_parser.add_argument( 

425 "--show-token-variables", 

426 dest="show_token_variables", 

427 default=False, 

428 action="store_true", 

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

430 ) 

431 plugin_list_manifest_variables_parser.add_argument( 

432 "--show-all-variables", 

433 dest="show_all_variables", 

434 default=False, 

435 action="store_true", 

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

437 ) 

438 TEXT_ONLY_FORMAT(plugin_list_manifest_variables_parser) 

439 

440 

441@plugin_list_cmds.register_subcommand( 

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

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

444 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

445) 

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

447 feature_set = context.load_plugins() 

448 

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

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

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

452 

453 parser_generator = feature_set.manifest_parser_generator 

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

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

456 

457 parsers = chain( 

458 table_parsers, 

459 object_parsers, 

460 ) 

461 

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

463 fo.print_list_table( 

464 headers=("Rule Name", "Rule Type", "Provided By"), 

465 rows=tuple( 

466 ( 

467 rn, 

468 parser_type_name(rt), 

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

470 ) 

471 for rt, pt in parsers 

472 for rn in pt.registered_keywords() 

473 ), 

474 ) 

475 

476 

477@plugin_list_cmds.register_subcommand( 

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

479 help_description="List automatic discard rules", 

480 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

481) 

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

483 auto_discard_rules = context.load_plugins().auto_discard_rules 

484 

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

486 fo.print_list_table( 

487 headers=("Name", "Provided By"), 

488 rows=tuple( 

489 ( 

490 name, 

491 ppdr.plugin_metadata.plugin_name, 

492 ) 

493 for name, ppdr in auto_discard_rules.items() 

494 ), 

495 ) 

496 

497 

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

499 if v is None: 

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

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

502 return v 

503 

504 

505@plugin_show_cmds.register_subcommand( 

506 ["manifest-variables"], 

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

508 argparser=add_arg( 

509 "manifest_variable", 

510 metavar="manifest-variable", 

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

512 ), 

513) 

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

515 plugin_feature_set = context.load_plugins() 

516 variables = plugin_feature_set.manifest_variables 

517 substitution = context.substitution 

518 parsed_args = context.parsed_args 

519 variable_name = parsed_args.manifest_variable 

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

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

522 variable_name = variable_name[2:-2] 

523 variable: PluginProvidedManifestVariable | None 

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

525 env_var = variable_name[4:] 

526 variable = PluginProvidedManifestVariable( 

527 plugin_feature_set.plugin_data["debputy"], 

528 variable_name, 

529 variable_value=None, 

530 is_context_specific_variable=False, 

531 is_documentation_placeholder=True, 

532 variable_reference_documentation=textwrap.dedent(f"""\ 

533 Environment variable "{env_var}" 

534 

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

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

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

538 from `build-environment:`. 

539 """), 

540 ) 

541 else: 

542 variable = variables.get(variable_name) 

543 if variable is None: 

544 _error( 

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

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

547 f" provided variables." 

548 ) 

549 

550 var_with_braces = "{{" + variable_name + "}}" 

551 try: 

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

553 except DebputySubstitutionError: 

554 source_value = None 

555 binary_value = source_value 

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

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

558 print() 

559 

560 if variable.is_context_specific_variable: 

561 try: 

562 binary_value = substitution.with_extra_substitutions( 

563 PACKAGE="<package-name>", 

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

565 except DebputySubstitutionError: 

566 binary_value = None 

567 

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

569 for line in render_multiline_documentation(doc): 

570 print(line) 

571 

572 if source_value == binary_value: 

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

574 else: 

575 print("Resolved:") 

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

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

578 

579 if variable.is_for_special_case: 

580 print( 

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

582 ) 

583 

584 if not variable.is_documentation_placeholder: 

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

586 

587 if variable.is_internal: 

588 print() 

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

590 print("That was private.") 

591 

592 

593def _determine_ppf( 

594 context: CommandContext, 

595) -> tuple[PackagerProvidedFileClassSpec, bool]: 

596 feature_set = context.load_plugins() 

597 ppf_name = context.parsed_args.ppf_name 

598 try: 

599 return feature_set.packager_provided_files[ppf_name], False 

600 except KeyError: 

601 pass 

602 

603 orig_ppf_name = ppf_name 

604 if ( 

605 ppf_name.startswith("d/") 

606 and not os.path.lexists(ppf_name) 

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

608 ): 

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

610 

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

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

613 doc = manifest_format_doc("") 

614 else: 

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

616 _error( 

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

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

619 ) 

620 

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

622 basename = ppf_name[7:] 

623 if "/" not in basename: 

624 debian_dir = build_virtual_fs([basename]) 

625 all_ppfs = detect_all_packager_provided_files( 

626 feature_set, 

627 debian_dir, 

628 context.binary_packages(), 

629 ) 

630 if all_ppfs: 

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

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

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

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

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

636 if len(reserved) == 1: 

637 return reserved[0].definition, True 

638 

639 _error( 

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

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

642 ) 

643 

644 

645@plugin_show_cmds.register_subcommand( 

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

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

648 argparser=add_arg( 

649 "ppf_name", 

650 metavar="name", 

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

652 ), 

653) 

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

655 ppf, matched_file = _determine_ppf(context) 

656 

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

658 

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

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

661 fo.print() 

662 ref_doc = ppf.reference_documentation 

663 description = ref_doc.description if ref_doc else None 

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

665 if description is None: 

666 fo.print( 

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

668 ) 

669 else: 

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

671 fo.print(line) 

672 

673 fo.print() 

674 fo.print("Features:") 

675 if ppf.packageless_is_fallback_for_all_packages: 

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

677 else: 

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

679 if ppf.allow_name_segment: 

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

681 else: 

682 fo.print( 

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

684 ) 

685 if ppf.allow_architecture_segment: 

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

687 else: 

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

689 if ppf.supports_priority: 

690 fo.print( 

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

692 ) 

693 

694 fo.print() 

695 fo.print("Examples matches:") 

696 

697 try: 

698 if not context.has_dctrl_file: 

699 raise StopIteration 

700 first_pkg = next( 

701 iter( 

702 n 

703 for n, p in context.binary_packages().items() 

704 if p.package_type in ppf.package_types 

705 ) 

706 ) 

707 except StopIteration: 

708 first_pkg = "example-package" 

709 

710 example_files = [ 

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

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

713 ] 

714 if ppf.allow_name_segment: 

715 example_files.append( 

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

717 ) 

718 if ppf.allow_architecture_segment: 

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

720 if ppf.allow_name_segment: 

721 example_files.append( 

722 ( 

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

724 "my.custom.name", 

725 ) 

726 ) 

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

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

729 rendered_examples = [] 

730 for example_file, assigned_name in example_files: 

731 example_path = fs_root.lookup(example_file) 

732 assert example_path is not None and example_path.is_file 

733 dest = ppf.compute_dest( 

734 assigned_name, 

735 owning_package=first_pkg, 

736 assigned_priority=priority, 

737 path=example_path, 

738 ) 

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

740 rendered_examples.append((example_file, dest_path)) 

741 

742 fo.print_list_table( 

743 headers=("Source file", "Installed As"), 

744 rows=rendered_examples, 

745 ) 

746 

747 if doc_uris: 

748 fo.print() 

749 fo.print("Documentation URIs:") 

750 for uri in doc_uris: 

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

752 

753 plugin_name = ppf.debputy_plugin_metadata.plugin_name 

754 fo.print() 

755 fo.print(f"Package Type(s): {ppf.package_types}") 

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

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

758 if ( 

759 matched_file 

760 and plugin_name != "debputy" 

761 and plugin_name not in context.requested_plugins() 

762 ): 

763 fo.print() 

764 _warn( 

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

766 ) 

767 

768 

769class UnresolvableRuleError(ValueError): 

770 pass 

771 

772 

773@dataclasses.dataclass 

774class PMRRuleLookup: 

775 rule_name: str 

776 parser: DeclarativeInputParser 

777 parser_type_name: str 

778 plugin_metadata: DebputyPluginMetadata 

779 is_root_rule: bool 

780 manifest_attribute_path: str 

781 

782 

783def lookup_pmr_rule( 

784 feature_set: PluginProvidedFeatureSet, 

785 pmr_rule_name: str, 

786) -> PMRRuleLookup: 

787 req_rule_type = None 

788 rule_name = pmr_rule_name 

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

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

791 

792 matched = [] 

793 

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

795 parser_generator = feature_set.manifest_parser_generator 

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

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

798 

799 parsers = chain( 

800 table_parsers, 

801 object_parsers, 

802 ) 

803 

804 for rule_type, dispatching_parser in parsers: 

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

806 rule_type 

807 ): 

808 continue 

809 if dispatching_parser.is_known_keyword(rule_name): 

810 matched.append((rule_type, dispatching_parser)) 

811 

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

813 if not matched: 

814 raise UnresolvableRuleError( 

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

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

817 ) 

818 match_a = matched[0][0] 

819 match_b = matched[1][0] 

820 raise UnresolvableRuleError( 

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

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

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

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

825 ) 

826 

827 if matched: 

828 rule_type, matched_dispatching_parser = matched[0] 

829 plugin_provided_parser = matched_dispatching_parser.parser_for(rule_name) 

830 if isinstance(rule_type, str): 

831 manifest_attribute_path = rule_type 

832 else: 

833 manifest_attribute_path = SUPPORTED_DISPATCHABLE_TABLE_PARSERS[rule_type] 

834 full_parser_type_name = parser_type_name(rule_type) 

835 parser = plugin_provided_parser.parser 

836 plugin_metadata = plugin_provided_parser.plugin_metadata 

837 else: 

838 rule_name = "::" 

839 parser = parser_generator.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] 

840 full_parser_type_name = "" 

841 plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

842 manifest_attribute_path = "" 

843 

844 is_root_rule = rule_name == "::" 

845 return PMRRuleLookup( 

846 rule_name, 

847 parser, 

848 full_parser_type_name, 

849 plugin_metadata, 

850 is_root_rule, 

851 manifest_attribute_path, 

852 ) 

853 

854 

855@plugin_show_cmds.register_subcommand( 

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

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

858 argparser=add_arg( 

859 "pmr_rule_name", 

860 metavar="rule-name", 

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

862 ), 

863) 

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

865 feature_set = context.load_plugins() 

866 parsed_args = context.parsed_args 

867 fo = _output_styling(parsed_args, sys.stdout) 

868 

869 try: 

870 pmr_rule = lookup_pmr_rule(feature_set, parsed_args.pmr_rule_name) 

871 except UnresolvableRuleError as e: 

872 _error(e.args[0]) 

873 

874 print( 

875 render_rule( 

876 pmr_rule.rule_name, 

877 pmr_rule.parser, 

878 pmr_rule.plugin_metadata, 

879 fo, 

880 is_root_rule=pmr_rule.is_root_rule, 

881 ) 

882 ) 

883 

884 if not pmr_rule.is_root_rule: 

885 manifest_attribute_path = pmr_rule.manifest_attribute_path 

886 print( 

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

888 ) 

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

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

891 else: 

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

893 

894 print() 

895 print( 

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

897 ) 

898 print( 

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

900 ) 

901 

902 

903def _render_discard_rule_example( 

904 fo: IOBasedOutputStyling, 

905 discard_rule: PluginProvidedDiscardRule, 

906 example: AutomaticDiscardRuleExample, 

907) -> None: 

908 processed = process_discard_rule_example(discard_rule, example) 

909 

910 if processed.inconsistent_paths: 

911 plugin_name = discard_rule.plugin_metadata.plugin_name 

912 _warn( 

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

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

915 ) 

916 

917 doc = example.description 

918 if doc: 

919 print(doc) 

920 

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

922 print() 

923 if fo.optimize_for_screen_reader: 

924 for p, _ in processed.rendered_paths: 

925 path_name = p.absolute 

926 print( 

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

928 ) 

929 

930 print() 

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

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

933 for p, verdict in processed.rendered_paths: 

934 path_name = p.absolute 

935 if verdict.is_consistent and verdict.is_discarded: 

936 print() 

937 if p.is_dir: 

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

939 else: 

940 print(path_name) 

941 else: 

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

943 

944 print() 

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

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

947 for p, verdict in processed.rendered_paths: 

948 path_name = p.absolute 

949 if verdict.is_consistent and verdict.is_kept: 

950 print() 

951 print(path_name) 

952 

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

954 print() 

955 print( 

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

957 " the provided example:" 

958 ) 

959 for p, verdict in processed.rendered_paths: 

960 path_name = p.absolute 

961 if not verdict.is_consistent: 

962 print() 

963 if verdict == DiscardVerdict.DISCARDED_BY_CODE: 

964 print( 

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

966 f" have been installed." 

967 ) 

968 else: 

969 print( 

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

971 f" have been discarded." 

972 ) 

973 return 

974 

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

976 max_len = max( 

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

978 ) 

979 for p, verdict in processed.rendered_paths: 

980 path_name = p.absolute 

981 if p.is_dir: 

982 path_name += "/" 

983 

984 if not verdict.is_consistent: 

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

986 elif verdict.is_discarded: 

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

988 else: 

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

990 

991 

992def _render_discard_rule( 

993 context: CommandContext, 

994 discard_rule: PluginProvidedDiscardRule, 

995) -> None: 

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

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

998 fo.print_visual_formatting( 

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

1000 ) 

1001 print() 

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

1003 for line in render_multiline_documentation( 

1004 doc, first_line_prefix="", following_line_prefix="" 

1005 ): 

1006 print(line) 

1007 

1008 if len(discard_rule.examples) > 1: 

1009 print() 

1010 fo.print_visual_formatting("Examples") 

1011 fo.print_visual_formatting("--------") 

1012 print() 

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

1014 print( 

1015 fo.colored( 

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

1017 ) 

1018 ) 

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

1020 _render_discard_rule_example(fo, discard_rule, example) 

1021 elif discard_rule.examples: 

1022 print() 

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

1024 fo.print_visual_formatting("-------") 

1025 print() 

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

1027 

1028 

1029@plugin_show_cmds.register_subcommand( 

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

1031 help_description="Automatic discard rules", 

1032 argparser=add_arg( 

1033 "discard_rule", 

1034 metavar="automatic-discard-rule", 

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

1036 ), 

1037) 

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

1039 auto_discard_rules = context.load_plugins().auto_discard_rules 

1040 name = context.parsed_args.discard_rule 

1041 discard_rule = auto_discard_rules.get(name) 

1042 if discard_rule is None: 

1043 _error( 

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

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

1046 ) 

1047 

1048 _render_discard_rule(context, discard_rule) 

1049 

1050 

1051@plugin_list_cmds.register_subcommand( 

1052 "type-mappings", 

1053 help_description="Registered type mappings/descriptions", 

1054) 

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

1056 type_mappings = context.load_plugins().mapped_types 

1057 

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

1059 fo.print_list_table( 

1060 headers=("Type", "Base Type", "Provided By"), 

1061 rows=tuple( 

1062 ( 

1063 target_type.__name__, 

1064 render_source_type(type_mapping.mapped_type.source_type), 

1065 type_mapping.plugin_metadata.plugin_name, 

1066 ) 

1067 for target_type, type_mapping in type_mappings.items() 

1068 ), 

1069 ) 

1070 

1071 

1072@plugin_show_cmds.register_subcommand( 

1073 "type-mappings", 

1074 help_description="Registered type mappings/descriptions", 

1075 argparser=add_arg( 

1076 "type_mapping", 

1077 metavar="type-mapping", 

1078 help="Name of the type", 

1079 ), 

1080) 

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

1082 type_mapping_name = context.parsed_args.type_mapping 

1083 type_mappings = context.load_plugins().mapped_types 

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

1085 

1086 matches = [] 

1087 for type_ in type_mappings: 

1088 if type_.__name__ == type_mapping_name: 

1089 matches.append(type_) 

1090 

1091 if not matches: 

1092 simple_types = set(BASIC_SIMPLE_TYPES.values()) 

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

1094 

1095 if type_mapping_name in simple_types: 

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

1097 return 

1098 if type_mapping_name == "Any": 

1099 print( 

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

1101 " custom parse logic." 

1102 ) 

1103 return 

1104 

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

1106 print( 

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

1108 ) 

1109 return 

1110 

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

1112 print( 

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

1114 ) 

1115 return 

1116 

1117 if "[" in type_mapping_name: 

1118 _error( 

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

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

1121 ) 

1122 

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

1124 

1125 if len(matches) > 1: 

1126 _error( 

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

1128 ) 

1129 

1130 match = matches[0] 

1131 manifest_parser = context.manifest_parser() 

1132 

1133 pptm = type_mappings[match] 

1134 fo.print( 

1135 render_type_mapping( 

1136 pptm, 

1137 fo, 

1138 manifest_parser, 

1139 recover_from_broken_examples=context.parsed_args.debug_mode, 

1140 ) 

1141 ) 

1142 

1143 fo.print() 

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

1145 

1146 

1147def ensure_plugin_commands_are_loaded() -> None: 

1148 # Loading the module does the heavy lifting 

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

1150 # gets tempted to remove 

1151 assert ROOT_COMMAND.has_command("plugin")