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

492 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-09-07 09:27 +0000

1import argparse 

2import dataclasses 

3import operator 

4import os 

5import sys 

6import textwrap 

7from itertools import chain 

8from typing import ( 

9 Sequence, 

10 Union, 

11 Tuple, 

12 Iterable, 

13 Any, 

14 Optional, 

15 Type, 

16 Callable, 

17) 

18 

19from debputy.analysis.analysis_util import flatten_ppfs 

20from debputy.commands.debputy_cmd.context import ( 

21 CommandContext, 

22 add_arg, 

23 ROOT_COMMAND, 

24) 

25from debputy.commands.debputy_cmd.output import ( 

26 _stream_to_pager, 

27 _output_styling, 

28 IOBasedOutputStyling, 

29) 

30from debputy.exceptions import DebputySubstitutionError 

31from debputy.filesystem_scan import build_virtual_fs 

32from debputy.manifest_parser.declarative_parser import ( 

33 BASIC_SIMPLE_TYPES, 

34) 

35from debputy.manifest_parser.parser_doc import ( 

36 render_rule, 

37 render_multiline_documentation, 

38 render_type_mapping, 

39 render_source_type, 

40) 

41from debputy.manifest_parser.util import unpack_type 

42from debputy.packager_provided_files import detect_all_packager_provided_files 

43from debputy.plugin.api.doc_parsing import parser_type_name 

44from debputy.plugin.api.example_processing import ( 

45 process_discard_rule_example, 

46 DiscardVerdict, 

47) 

48from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

49from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

50from debputy.plugin.api.impl_types import ( 

51 PackagerProvidedFileClassSpec, 

52 PluginProvidedManifestVariable, 

53 DispatchingParserBase, 

54 PluginProvidedDiscardRule, 

55 AutomaticDiscardRuleExample, 

56 MetadataOrMaintscriptDetector, 

57 DebputyPluginMetadata, 

58 DeclarativeInputParser, 

59) 

60from debputy.plugin.api.parser_tables import ( 

61 SUPPORTED_DISPATCHABLE_TABLE_PARSERS, 

62 OPARSER_MANIFEST_ROOT, 

63) 

64from debputy.substitution import Substitution 

65from debputy.util import _error, _warn, manifest_format_doc 

66 

67plugin_dispatcher = ROOT_COMMAND.add_dispatching_subcommand( 

68 "plugin", 

69 "plugin_subcommand", 

70 default_subcommand="--help", 

71 help_description="Interact with debputy plugins", 

72 metavar="command", 

73) 

74 

75plugin_list_cmds = plugin_dispatcher.add_dispatching_subcommand( 

76 "list", 

77 "plugin_subcommand_list", 

78 metavar="topic", 

79 default_subcommand="plugins", 

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

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

82) 

83 

84plugin_show_cmds = plugin_dispatcher.add_dispatching_subcommand( 

85 "show", 

86 "plugin_subcommand_show", 

87 metavar="topic", 

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

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

90) 

91 

92 

93def format_output_arg( 

94 default_format: str, 

95 allowed_formats: Sequence[str], 

96 help_text: str, 

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

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

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

100 

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

102 argparser.add_argument( 

103 "--output-format", 

104 dest="output_format", 

105 default=default_format, 

106 choices=allowed_formats, 

107 help=help_text, 

108 ) 

109 

110 return _configurator 

111 

112 

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

114TEXT_ONLY_FORMAT = format_output_arg( 

115 "text", 

116 ["text"], 

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

118) 

119 

120 

121TEXT_CSV_FORMAT_NO_STABILITY_PROMISE = format_output_arg( 

122 "text", 

123 ["text", "csv"], 

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

125) 

126 

127 

128@plugin_list_cmds.register_subcommand( 

129 "plugins", 

130 help_description="List known plugins", 

131 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

132) 

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

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

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

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

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

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

139 fo.print_list_table( 

140 ["Plugin Name", "Plugin Path"], 

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

142 ) 

143 

144 

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

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

147 return path[1:] 

148 return path 

149 

150 

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

152 flags = [] 

153 if ppf.allow_name_segment: 

154 flags.append("named") 

155 if ppf.allow_architecture_segment: 

156 flags.append("arch") 

157 if ppf.supports_priority: 

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

159 if ppf.packageless_is_fallback_for_all_packages: 

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

161 if ppf.post_formatting_rewrite: 

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

163 return ",".join(flags) 

164 

165 

166@plugin_list_cmds.register_subcommand( 

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

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

169 argparser=TEXT_ONLY_FORMAT, 

170) 

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

172 plugin_feature_set = context.load_plugins() 

173 all_ppfs = detect_all_packager_provided_files( 

174 plugin_feature_set, 

175 context.debian_dir, 

176 context.binary_packages(), 

177 ) 

178 requested_plugins = set(context.requested_plugins()) 

179 requested_plugins.add("debputy") 

180 all_detected_ppfs = list(flatten_ppfs(all_ppfs)) 

181 

182 used_ppfs = [ 

183 p 

184 for p in all_detected_ppfs 

185 if p.definition.debputy_plugin_metadata.plugin_name in requested_plugins 

186 ] 

187 inactive_ppfs = [ 

188 p 

189 for p in all_detected_ppfs 

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

191 ] 

192 

193 if not used_ppfs and not inactive_ppfs: 

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

195 return 

196 

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

198 if used_ppfs: 

199 headers: Sequence[Union[str, Tuple[str, str]]] = [ 

200 "File", 

201 "Matched Stem", 

202 "Installed Into", 

203 "Installed As", 

204 ] 

205 fo.print_list_table( 

206 headers, 

207 [ 

208 ( 

209 ppf.path.path, 

210 ppf.definition.stem, 

211 ppf.package_name, 

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

213 ) 

214 for ppf in sorted( 

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

216 ) 

217 ], 

218 ) 

219 

220 if inactive_ppfs: 

221 headers: Sequence[Union[str, Tuple[str, str]]] = [ 

222 "UNUSED FILE", 

223 "Matched Stem", 

224 "Installed Into", 

225 "Could Be Installed As", 

226 "If B-D Had", 

227 ] 

228 fo.print_list_table( 

229 headers, 

230 [ 

231 ( 

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

233 ppf.definition.stem, 

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

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

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

237 ) 

238 for ppf in sorted( 

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

240 ) 

241 ], 

242 ) 

243 

244 

245@plugin_list_cmds.register_subcommand( 

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

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

248 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

249) 

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

251 ppfs: Iterable[PackagerProvidedFileClassSpec] 

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

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

254 headers: Sequence[Union[str, Tuple[str, str]]] = [ 

255 "Stem", 

256 "Installed As", 

257 ("Mode", ">"), 

258 "Features", 

259 "Provided by", 

260 ] 

261 fo.print_list_table( 

262 headers, 

263 [ 

264 ( 

265 ppf.stem, 

266 _path(ppf.installed_as_format), 

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

268 _ppf_flags(ppf), 

269 ppf.debputy_plugin_metadata.plugin_name, 

270 ) 

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

272 ], 

273 ) 

274 

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

276 fo.print() 

277 fo.print( 

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

279 ) 

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

281 

282 

283@plugin_list_cmds.register_subcommand( 

284 ["metadata-detectors"], 

285 help_description="List metadata detectors", 

286 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

287) 

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

289 mds = list( 

290 chain.from_iterable( 

291 context.load_plugins().metadata_maintscript_detectors.values() 

292 ) 

293 ) 

294 

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

296 return md.plugin_metadata.plugin_name, md.detector_id 

297 

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

299 fo.print_list_table( 

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

301 [ 

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

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

304 ], 

305 ) 

306 

307 

308def _resolve_variable_for_list( 

309 substitution: Substitution, 

310 variable: PluginProvidedManifestVariable, 

311) -> str: 

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

313 try: 

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

315 except DebputySubstitutionError: 

316 value = None 

317 return _render_manifest_variable_value(value) 

318 

319 

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

321 flags = [] 

322 if variable.is_for_special_case: 

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

324 if variable.is_internal: 

325 flags.append("internal") 

326 return ",".join(flags) 

327 

328 

329def _render_list_filter(v: Optional[bool]) -> str: 

330 if v is None: 

331 return "N/A" 

332 return "shown" if v else "hidden" 

333 

334 

335@plugin_list_cmds.register_subcommand( 

336 ["manifest-variables"], 

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

338) 

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

340 variables = context.load_plugins().manifest_variables 

341 substitution = context.substitution.with_extra_substitutions( 

342 PACKAGE="<package-name>" 

343 ) 

344 parsed_args = context.parsed_args 

345 show_special_case_vars = parsed_args.show_special_use_variables 

346 show_token_vars = parsed_args.show_token_variables 

347 show_all_vars = parsed_args.show_all_variables 

348 

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

350 if show_all_vars: 

351 return True 

352 if var.is_internal: 

353 return False 

354 if var.is_for_special_case and not show_special_case_vars: 

355 return False 

356 if var.is_token and not show_token_vars: 

357 return False 

358 return True 

359 

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

361 fo.print_list_table( 

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

363 [ 

364 ( 

365 k, 

366 _resolve_variable_for_list(substitution, var), 

367 _render_manifest_variable_flag(var), 

368 var.plugin_metadata.plugin_name, 

369 ) 

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

371 if _include_var(var) 

372 ], 

373 ) 

374 

375 fo.print() 

376 

377 filters = [ 

378 ( 

379 "Token variables", 

380 show_token_vars if not show_all_vars else None, 

381 "--show-token-variables", 

382 ), 

383 ( 

384 "Special use variables", 

385 show_special_case_vars if not show_all_vars else None, 

386 "--show-special-case-variables", 

387 ), 

388 ] 

389 

390 fo.print_list_table( 

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

392 [ 

393 ( 

394 fname, 

395 _render_list_filter(value or show_all_vars), 

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

397 ) 

398 for fname, value, option in filters 

399 ], 

400 ) 

401 

402 

403@plugin_cmd_list_manifest_variables.configure_handler 

404def list_manifest_variable_arg_parser( 

405 plugin_list_manifest_variables_parser: argparse.ArgumentParser, 

406) -> None: 

407 plugin_list_manifest_variables_parser.add_argument( 

408 "--show-special-case-variables", 

409 dest="show_special_use_variables", 

410 default=False, 

411 action="store_true", 

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

413 ) 

414 plugin_list_manifest_variables_parser.add_argument( 

415 "--show-token-variables", 

416 dest="show_token_variables", 

417 default=False, 

418 action="store_true", 

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

420 ) 

421 plugin_list_manifest_variables_parser.add_argument( 

422 "--show-all-variables", 

423 dest="show_all_variables", 

424 default=False, 

425 action="store_true", 

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

427 ) 

428 TEXT_ONLY_FORMAT(plugin_list_manifest_variables_parser) 

429 

430 

431@plugin_list_cmds.register_subcommand( 

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

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

434 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

435) 

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

437 feature_set = context.load_plugins() 

438 

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

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

441 base_type = Iterable[Tuple[Union[str, Type[Any]], DispatchingParserBase[Any]]] 

442 

443 parser_generator = feature_set.manifest_parser_generator 

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

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

446 

447 parsers = chain( 

448 table_parsers, 

449 object_parsers, 

450 ) 

451 

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

453 fo.print_list_table( 

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

455 [ 

456 ( 

457 rn, 

458 parser_type_name(rt), 

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

460 ) 

461 for rt, pt in parsers 

462 for rn in pt.registered_keywords() 

463 ], 

464 ) 

465 

466 

467@plugin_list_cmds.register_subcommand( 

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

469 help_description="List automatic discard rules", 

470 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE, 

471) 

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

473 auto_discard_rules = context.load_plugins().auto_discard_rules 

474 

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

476 fo.print_list_table( 

477 ["Name", "Provided By"], 

478 [ 

479 ( 

480 name, 

481 ppdr.plugin_metadata.plugin_name, 

482 ) 

483 for name, ppdr in auto_discard_rules.items() 

484 ], 

485 ) 

486 

487 

488def _render_manifest_variable_value(v: Optional[str]) -> str: 

489 if v is None: 

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

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

492 return v 

493 

494 

495@plugin_show_cmds.register_subcommand( 

496 ["manifest-variables"], 

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

498 argparser=add_arg( 

499 "manifest_variable", 

500 metavar="manifest-variable", 

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

502 ), 

503) 

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

505 plugin_feature_set = context.load_plugins() 

506 variables = plugin_feature_set.manifest_variables 

507 substitution = context.substitution 

508 parsed_args = context.parsed_args 

509 variable_name = parsed_args.manifest_variable 

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

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

512 variable_name = variable_name[2:-2] 

513 variable: Optional[PluginProvidedManifestVariable] 

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

515 env_var = variable_name[4:] 

516 variable = PluginProvidedManifestVariable( 

517 plugin_feature_set.plugin_data["debputy"], 

518 variable_name, 

519 variable_value=None, 

520 is_context_specific_variable=False, 

521 is_documentation_placeholder=True, 

522 variable_reference_documentation=textwrap.dedent( 

523 f"""\ 

524 Environment variable "{env_var}" 

525 

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

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

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

529 from `build-environment:`. 

530 """ 

531 ), 

532 ) 

533 else: 

534 variable = variables.get(variable_name) 

535 if variable is None: 

536 _error( 

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

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

539 f" provided variables." 

540 ) 

541 

542 var_with_braces = "{{" + variable_name + "}}" 

543 try: 

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

545 except DebputySubstitutionError: 

546 source_value = None 

547 binary_value = source_value 

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

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

550 print() 

551 

552 if variable.is_context_specific_variable: 

553 try: 

554 binary_value = substitution.with_extra_substitutions( 

555 PACKAGE="<package-name>", 

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

557 except DebputySubstitutionError: 

558 binary_value = None 

559 

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

561 for line in render_multiline_documentation(doc): 

562 print(line) 

563 

564 if source_value == binary_value: 

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

566 else: 

567 print("Resolved:") 

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

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

570 

571 if variable.is_for_special_case: 

572 print( 

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

574 ) 

575 

576 if not variable.is_documentation_placeholder: 

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

578 

579 if variable.is_internal: 

580 print() 

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

582 print("That was private.") 

583 

584 

585def _determine_ppf( 

586 context: CommandContext, 

587) -> Tuple[PackagerProvidedFileClassSpec, bool]: 

588 feature_set = context.load_plugins() 

589 ppf_name = context.parsed_args.ppf_name 

590 try: 

591 return feature_set.packager_provided_files[ppf_name], False 

592 except KeyError: 

593 pass 

594 

595 orig_ppf_name = ppf_name 

596 if ( 

597 ppf_name.startswith("d/") 

598 and not os.path.lexists(ppf_name) 

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

600 ): 

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

602 

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

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

605 doc = manifest_format_doc("") 

606 else: 

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

608 _error( 

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

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

611 ) 

612 

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

614 basename = ppf_name[7:] 

615 if "/" not in basename: 

616 debian_dir = build_virtual_fs([basename]) 

617 all_ppfs = detect_all_packager_provided_files( 

618 feature_set, 

619 debian_dir, 

620 context.binary_packages(), 

621 ) 

622 if all_ppfs: 

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

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

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

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

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

628 if len(reserved) == 1: 

629 return reserved[0].definition, True 

630 

631 _error( 

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

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

634 ) 

635 

636 

637@plugin_show_cmds.register_subcommand( 

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

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

640 argparser=add_arg( 

641 "ppf_name", 

642 metavar="name", 

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

644 ), 

645) 

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

647 ppf, matched_file = _determine_ppf(context) 

648 

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

650 

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

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

653 fo.print() 

654 ref_doc = ppf.reference_documentation 

655 description = ref_doc.description if ref_doc else None 

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

657 if description is None: 

658 fo.print( 

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

660 ) 

661 else: 

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

663 fo.print(line) 

664 

665 fo.print() 

666 fo.print("Features:") 

667 if ppf.packageless_is_fallback_for_all_packages: 

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

669 else: 

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

671 if ppf.allow_name_segment: 

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

673 else: 

674 fo.print( 

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

676 ) 

677 if ppf.allow_architecture_segment: 

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

679 else: 

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

681 if ppf.supports_priority: 

682 fo.print( 

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

684 ) 

685 

686 fo.print() 

687 fo.print("Examples matches:") 

688 

689 if context.has_dctrl_file: 

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

691 else: 

692 first_pkg = "example-package" 

693 example_files = [ 

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

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

696 ] 

697 if ppf.allow_name_segment: 

698 example_files.append( 

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

700 ) 

701 if ppf.allow_architecture_segment: 

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

703 if ppf.allow_name_segment: 

704 example_files.append( 

705 ( 

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

707 "my.custom.name", 

708 ) 

709 ) 

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

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

712 rendered_examples = [] 

713 for example_file, assigned_name in example_files: 

714 example_path = fs_root.lookup(example_file) 

715 assert example_path is not None and example_path.is_file 

716 dest = ppf.compute_dest( 

717 assigned_name, 

718 owning_package=first_pkg, 

719 assigned_priority=priority, 

720 path=example_path, 

721 ) 

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

723 rendered_examples.append((example_file, dest_path)) 

724 

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

726 

727 if doc_uris: 

728 fo.print() 

729 fo.print("Documentation URIs:") 

730 for uri in doc_uris: 

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

732 

733 plugin_name = ppf.debputy_plugin_metadata.plugin_name 

734 fo.print() 

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

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

737 if ( 

738 matched_file 

739 and plugin_name != "debputy" 

740 and plugin_name not in context.requested_plugins() 

741 ): 

742 fo.print() 

743 _warn( 

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

745 ) 

746 

747 

748class UnresolvableRuleError(ValueError): 

749 pass 

750 

751 

752@dataclasses.dataclass 

753class PMRRuleLookup: 

754 rule_name: str 

755 parser: DeclarativeInputParser 

756 parser_type_name: str 

757 plugin_metadata: DebputyPluginMetadata 

758 is_root_rule: bool 

759 manifest_attribute_path: str 

760 

761 

762def lookup_pmr_rule( 

763 feature_set: PluginProvidedFeatureSet, 

764 pmr_rule_name: str, 

765) -> PMRRuleLookup: 

766 req_rule_type = None 

767 rule_name = pmr_rule_name 

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

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

770 

771 matched = [] 

772 

773 base_type = Iterable[Tuple[Union[str, Type[Any]], DispatchingParserBase[Any]]] 

774 parser_generator = feature_set.manifest_parser_generator 

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

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

777 

778 parsers = chain( 

779 table_parsers, 

780 object_parsers, 

781 ) 

782 

783 for rule_type, dispatching_parser in parsers: 

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

785 rule_type 

786 ): 

787 continue 

788 if dispatching_parser.is_known_keyword(rule_name): 

789 matched.append((rule_type, dispatching_parser)) 

790 

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

792 if not matched: 

793 raise UnresolvableRuleError( 

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

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

796 ) 

797 match_a = matched[0][0] 

798 match_b = matched[1][0] 

799 raise UnresolvableRuleError( 

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

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

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

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

804 ) 

805 

806 if matched: 

807 rule_type, matched_dispatching_parser = matched[0] 

808 plugin_provided_parser = matched_dispatching_parser.parser_for(rule_name) 

809 if isinstance(rule_type, str): 

810 manifest_attribute_path = rule_type 

811 else: 

812 manifest_attribute_path = SUPPORTED_DISPATCHABLE_TABLE_PARSERS[rule_type] 

813 full_parser_type_name = parser_type_name(rule_type) 

814 parser = plugin_provided_parser.parser 

815 plugin_metadata = plugin_provided_parser.plugin_metadata 

816 else: 

817 rule_name = "::" 

818 parser = parser_generator.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] 

819 full_parser_type_name = "" 

820 plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

821 manifest_attribute_path = "" 

822 

823 is_root_rule = rule_name == "::" 

824 return PMRRuleLookup( 

825 rule_name, 

826 parser, 

827 full_parser_type_name, 

828 plugin_metadata, 

829 is_root_rule, 

830 manifest_attribute_path, 

831 ) 

832 

833 

834@plugin_show_cmds.register_subcommand( 

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

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

837 argparser=add_arg( 

838 "pmr_rule_name", 

839 metavar="rule-name", 

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

841 ), 

842) 

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

844 feature_set = context.load_plugins() 

845 parsed_args = context.parsed_args 

846 fo = _output_styling(parsed_args, sys.stdout) 

847 

848 try: 

849 pmr_rule = lookup_pmr_rule(feature_set, parsed_args.pmr_rule_name) 

850 except UnresolvableRuleError as e: 

851 _error(e.args[0]) 

852 

853 print( 

854 render_rule( 

855 pmr_rule.rule_name, 

856 pmr_rule.parser, 

857 pmr_rule.plugin_metadata, 

858 fo, 

859 is_root_rule=pmr_rule.is_root_rule, 

860 ) 

861 ) 

862 

863 if not pmr_rule.is_root_rule: 

864 manifest_attribute_path = pmr_rule.manifest_attribute_path 

865 print( 

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

867 ) 

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

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

870 else: 

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

872 

873 print() 

874 print( 

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

876 ) 

877 print( 

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

879 ) 

880 

881 

882def _render_discard_rule_example( 

883 fo: IOBasedOutputStyling, 

884 discard_rule: PluginProvidedDiscardRule, 

885 example: AutomaticDiscardRuleExample, 

886) -> None: 

887 processed = process_discard_rule_example(discard_rule, example) 

888 

889 if processed.inconsistent_paths: 

890 plugin_name = discard_rule.plugin_metadata.plugin_name 

891 _warn( 

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

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

894 ) 

895 

896 doc = example.description 

897 if doc: 

898 print(doc) 

899 

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

901 print() 

902 if fo.optimize_for_screen_reader: 

903 for p, _ in processed.rendered_paths: 

904 path_name = p.absolute 

905 print( 

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

907 ) 

908 

909 print() 

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

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

912 for p, verdict in processed.rendered_paths: 

913 path_name = p.absolute 

914 if verdict.is_consistent and verdict.is_discarded: 

915 print() 

916 if p.is_dir: 

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

918 else: 

919 print(path_name) 

920 else: 

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

922 

923 print() 

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

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

926 for p, verdict in processed.rendered_paths: 

927 path_name = p.absolute 

928 if verdict.is_consistent and verdict.is_kept: 

929 print() 

930 print(path_name) 

931 

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

933 print() 

934 print( 

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

936 " the provided example:" 

937 ) 

938 for p, verdict in processed.rendered_paths: 

939 path_name = p.absolute 

940 if not verdict.is_consistent: 

941 print() 

942 if verdict == DiscardVerdict.DISCARDED_BY_CODE: 

943 print( 

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

945 f" have been installed." 

946 ) 

947 else: 

948 print( 

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

950 f" have been discarded." 

951 ) 

952 return 

953 

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

955 max_len = max( 

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

957 ) 

958 for p, verdict in processed.rendered_paths: 

959 path_name = p.absolute 

960 if p.is_dir: 

961 path_name += "/" 

962 

963 if not verdict.is_consistent: 

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

965 elif verdict.is_discarded: 

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

967 else: 

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

969 

970 

971def _render_discard_rule( 

972 context: CommandContext, 

973 discard_rule: PluginProvidedDiscardRule, 

974) -> None: 

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

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

977 fo.print_visual_formatting( 

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

979 ) 

980 print() 

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

982 for line in render_multiline_documentation( 

983 doc, first_line_prefix="", following_line_prefix="" 

984 ): 

985 print(line) 

986 

987 if len(discard_rule.examples) > 1: 

988 print() 

989 fo.print_visual_formatting("Examples") 

990 fo.print_visual_formatting("--------") 

991 print() 

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

993 print( 

994 fo.colored( 

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

996 ) 

997 ) 

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

999 _render_discard_rule_example(fo, discard_rule, example) 

1000 elif discard_rule.examples: 

1001 print() 

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

1003 fo.print_visual_formatting("-------") 

1004 print() 

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

1006 

1007 

1008@plugin_show_cmds.register_subcommand( 

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

1010 help_description="Automatic discard rules", 

1011 argparser=add_arg( 

1012 "discard_rule", 

1013 metavar="automatic-discard-rule", 

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

1015 ), 

1016) 

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

1018 auto_discard_rules = context.load_plugins().auto_discard_rules 

1019 name = context.parsed_args.discard_rule 

1020 discard_rule = auto_discard_rules.get(name) 

1021 if discard_rule is None: 

1022 _error( 

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

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

1025 ) 

1026 

1027 _render_discard_rule(context, discard_rule) 

1028 

1029 

1030@plugin_list_cmds.register_subcommand( 

1031 "type-mappings", 

1032 help_description="Registered type mappings/descriptions", 

1033) 

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

1035 type_mappings = context.load_plugins().mapped_types 

1036 

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

1038 fo.print_list_table( 

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

1040 [ 

1041 ( 

1042 target_type.__name__, 

1043 render_source_type(type_mapping.mapped_type.source_type), 

1044 type_mapping.plugin_metadata.plugin_name, 

1045 ) 

1046 for target_type, type_mapping in type_mappings.items() 

1047 ], 

1048 ) 

1049 

1050 

1051@plugin_show_cmds.register_subcommand( 

1052 "type-mappings", 

1053 help_description="Registered type mappings/descriptions", 

1054 argparser=add_arg( 

1055 "type_mapping", 

1056 metavar="type-mapping", 

1057 help="Name of the type", 

1058 ), 

1059) 

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

1061 type_mapping_name = context.parsed_args.type_mapping 

1062 type_mappings = context.load_plugins().mapped_types 

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

1064 

1065 matches = [] 

1066 for type_ in type_mappings: 

1067 if type_.__name__ == type_mapping_name: 

1068 matches.append(type_) 

1069 

1070 if not matches: 

1071 simple_types = set(BASIC_SIMPLE_TYPES.values()) 

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

1073 

1074 if type_mapping_name in simple_types: 

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

1076 return 

1077 if type_mapping_name == "Any": 

1078 print( 

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

1080 " custom parse logic." 

1081 ) 

1082 return 

1083 

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

1085 print( 

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

1087 ) 

1088 return 

1089 

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

1091 print( 

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

1093 ) 

1094 return 

1095 

1096 if "[" in type_mapping_name: 

1097 _error( 

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

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

1100 ) 

1101 

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

1103 

1104 if len(matches) > 1: 

1105 _error( 

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

1107 ) 

1108 

1109 match = matches[0] 

1110 manifest_parser = context.manifest_parser() 

1111 

1112 pptm = type_mappings[match] 

1113 fo.print( 

1114 render_type_mapping( 

1115 pptm, 

1116 fo, 

1117 manifest_parser, 

1118 recover_from_broken_examples=context.parsed_args.debug_mode, 

1119 ) 

1120 ) 

1121 

1122 fo.print() 

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

1124 

1125 

1126def ensure_plugin_commands_are_loaded() -> None: 

1127 # Loading the module does the heavy lifting 

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

1129 # gets tempted to remove 

1130 assert ROOT_COMMAND.has_command("plugin")