Coverage for src/debputy/lsp/maint_prefs.py: 82%

273 statements  

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

1import dataclasses 

2import functools 

3import importlib.resources 

4import re 

5import textwrap 

6from typing import ( 

7 Type, 

8 TypeVar, 

9 Generic, 

10 Optional, 

11 List, 

12 Union, 

13 Self, 

14 Dict, 

15 Any, 

16 Tuple, 

17) 

18from collections.abc import Callable, Mapping, Iterable 

19 

20import debputy.lsp.data as data_dir 

21from debputy.lsp.named_styles import ALL_PUBLIC_NAMED_STYLES 

22from debputy.lsp.vendoring._deb822_repro.types import FormatterCallback 

23from debputy.lsp.vendoring.wrap_and_sort import wrap_and_sort_formatter 

24from debputy.packages import SourcePackage 

25from debputy.util import _error 

26from debputy.yaml import MANIFEST_YAML 

27from debputy.yaml.compat import CommentedMap 

28 

29PT = TypeVar("PT", bool, str, int) 

30 

31 

32BUILTIN_STYLES = "maint-preferences.yaml" 

33 

34_NORMALISE_FIELD_CONTENT_KEY = ["deb822", "normalize-field-content"] 

35_UPLOADER_SPLIT_RE = re.compile(r"(?<=>)\s*,") 

36 

37_WAS_OPTIONS = { 

38 "-a": ("deb822_always_wrap", True), 

39 "--always-wrap": ("deb822_always_wrap", True), 

40 "-s": ("deb822_short_indent", True), 

41 "--short-indent": ("deb822_short_indent", True), 

42 "-t": ("deb822_trailing_separator", True), 

43 "--trailing-separator": ("deb822_trailing_separator", True), 

44 # Noise option for us; we do not accept `--no-keep-first` though 

45 "-k": (None, True), 

46 "--keep-first": (None, True), 

47 "--no-keep-first": ("DISABLE_NORMALIZE_STANZA_ORDER", True), 

48 "-b": ("deb822_normalize_stanza_order", True), 

49 "--sort-binary-packages": ("deb822_normalize_stanza_order", True), 

50} 

51 

52_WAS_DEFAULTS = { 

53 "deb822_always_wrap": False, 

54 "deb822_short_indent": False, 

55 "deb822_trailing_separator": False, 

56 "deb822_normalize_stanza_order": False, 

57 "deb822_normalize_field_content": True, 

58 "deb822_auto_canonical_size_field_names": False, 

59} 

60 

61 

62@dataclasses.dataclass(slots=True, frozen=True, kw_only=True) 

63class PreferenceOption(Generic[PT]): 

64 key: str | list[str] 

65 expected_type: type[PT] | Callable[[Any], str | None] 

66 description: str 

67 default_value: PT | Callable[[CommentedMap], PT | None] | None = None 

68 

69 @property 

70 def name(self) -> str: 

71 if isinstance(self.key, str): 

72 return self.key 

73 return ".".join(self.key) 

74 

75 @property 

76 def attribute_name(self) -> str: 

77 return self.name.replace("-", "_").replace(".", "_") 

78 

79 def extract_value( 

80 self, 

81 filename: str, 

82 key: str, 

83 data: CommentedMap, 

84 ) -> PT | None: 

85 v = data.mlget(self.key, list_ok=True) 

86 if v is None: 

87 default_value = self.default_value 

88 if callable(default_value): 

89 return default_value(data) 

90 return default_value 

91 val_issue: str | None = None 

92 expected_type = self.expected_type 

93 if not isinstance(expected_type, type) and callable(self.expected_type): 

94 val_issue = self.expected_type(v) 

95 elif not isinstance(v, self.expected_type): 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 val_issue = f"It should have been a {self.expected_type} but it was not" 

97 

98 if val_issue is None: 98 ↛ 100line 98 didn't jump to line 100 because the condition on line 98 was always true

99 return v 

100 raise ValueError( 

101 f'The value "{self.name}" for key {key} in file "{filename}" was incorrect: {val_issue}' 

102 ) 

103 

104 

105def _is_packaging_team_default(m: CommentedMap) -> bool: 

106 v = m.get("canonical-name") 

107 if not isinstance(v, str): 

108 return False 

109 v = v.lower() 

110 return v.endswith((" maintainer", " maintainers", " team")) 

111 

112 

113def _false_when_formatting_content(m: CommentedMap) -> bool | None: 

114 return m.mlget(_NORMALISE_FIELD_CONTENT_KEY, list_ok=True, default=False) is True 

115 

116 

117MAINT_OPTIONS: list[PreferenceOption] = [ 

118 PreferenceOption( 

119 key="canonical-name", 

120 expected_type=str, 

121 description=textwrap.dedent( 

122 """\ 

123 Canonical spelling/case of the maintainer name. 

124 

125 The `debputy` linter will emit a diagnostic if the name is not spelled exactly as provided here. 

126 Can be useful to ensure your name is updated after a change of name. 

127 """ 

128 ), 

129 ), 

130 PreferenceOption( 

131 key="is-packaging-team", 

132 expected_type=bool, 

133 default_value=_is_packaging_team_default, 

134 description=textwrap.dedent( 

135 """\ 

136 Whether this entry is for a packaging team 

137 

138 This affects how styles are applied when multiple maintainers (`Maintainer` + `Uploaders`) are listed 

139 in `debian/control`. For package teams, the team preference prevails when the team is in the `Maintainer` 

140 field. For non-packaging teams, generally the rules do not apply as soon as there are co-maintainers. 

141 

142 The default is derived from the canonical name. If said name ends with phrases like "Team" or "Maintainer" 

143 then the email is assumed to be for a team by default. 

144 """ 

145 ), 

146 ), 

147 PreferenceOption( 

148 key="formatting", 

149 expected_type=lambda x: ( 

150 None 

151 if isinstance(x, EffectiveFormattingPreference) 

152 else "It should have been a EffectiveFormattingPreference but it was not" 

153 ), 

154 default_value=None, 

155 description=textwrap.dedent( 

156 """\ 

157 The formatting preference of the maintainer. Can either be a string for a named style or an inline 

158 style. 

159 """ 

160 ), 

161 ), 

162] 

163 

164FORMATTING_OPTIONS = [ 

165 PreferenceOption( 

166 key=["deb822", "short-indent"], 

167 expected_type=bool, 

168 description=textwrap.dedent( 

169 """\ 

170 Whether to use "short" indents for relationship fields (such as `Depends`). 

171 

172 This roughly corresponds to `wrap-and-sort`'s `-s` option. 

173 

174 **Example**: 

175 

176 When `true`, the following: 

177 ``` 

178 Depends: foo, 

179 bar 

180 ``` 

181 

182 would be reformatted as: 

183 

184 ``` 

185 Depends: 

186 foo, 

187 bar 

188 ``` 

189 

190 (Assuming `formatting.deb822.short-indent` is `false`) 

191 

192 Note that defaults to `false` *if* (and only if) other formatting options will trigger reformat of 

193 the field and this option has not been set. Setting this option can trigger reformatting of fields 

194 that span multiple lines. 

195 

196 Additionally, this only triggers when a field is being reformatted. Generally that requires 

197 another option such as `formatting.deb822.normalize-field-content` for that to happen. 

198 """ 

199 ), 

200 ), 

201 PreferenceOption( 

202 key=["deb822", "always-wrap"], 

203 expected_type=bool, 

204 description=textwrap.dedent( 

205 """\ 

206 Whether to always wrap fields (such as `Depends`). 

207 

208 This roughly corresponds to `wrap-and-sort`'s `-a` option. 

209 

210 **Example**: 

211 

212 When `true`, the following: 

213 ``` 

214 Depends: foo, bar 

215 ``` 

216 

217 would be reformatted as: 

218 

219 ``` 

220 Depends: foo, 

221 bar 

222 ``` 

223 

224 (Assuming `formatting.deb822.short-indent` is `false`) 

225 

226 This option only applies to fields where formatting is a pure style preference. As an 

227 example, `Description` (`debian/control`) or `License` (`debian/copyright`) will not 

228 be affected by this option. 

229 

230 Note: When `true`, this option overrules `formatting.deb822.max-line-length` when they interact.  

231 Additionally, this only triggers when a field is being reformatted. Generally that requires 

232 another option such as `formatting.deb822.normalize-field-content` for that to happen. 

233 """ 

234 ), 

235 ), 

236 PreferenceOption( 

237 key=["deb822", "trailing-separator"], 

238 expected_type=bool, 

239 default_value=False, 

240 description=textwrap.dedent( 

241 """\ 

242 Whether to always end relationship fields (such as `Depends`) with a trailing separator. 

243 

244 This roughly corresponds to `wrap-and-sort`'s `-t` option. 

245 

246 **Example**: 

247 

248 When `true`, the following: 

249 ``` 

250 Depends: foo, 

251 bar 

252 ``` 

253 

254 would be reformatted as: 

255 

256 ``` 

257 Depends: foo, 

258 bar, 

259 ``` 

260 

261 Note: The trailing separator is only applied if the field is reformatted. This means this option 

262 generally requires another option to trigger reformatting (like 

263 `formatting.deb822.normalize-field-content`). 

264 """ 

265 ), 

266 ), 

267 PreferenceOption( 

268 key=["deb822", "max-line-length"], 

269 expected_type=int, 

270 default_value=79, 

271 description=textwrap.dedent( 

272 """\ 

273 How long a value line can be before it should be line wrapped. 

274 

275 This roughly corresponds to `wrap-and-sort`'s `--max-line-length` option. 

276 

277 This option only applies to fields where formatting is a pure style preference. As an 

278 example, `Description` (`debian/control`) or `License` (`debian/copyright`) will not 

279 be affected by this option. 

280 

281 This setting may affect style-related diagnostics. Notably, many tools with linting or 

282 diagnostics capabilities (`debputy` included) will generally flag lines of 80 or more 

283 characters as suboptimal. The diagnostics are because some fields are displayed to end 

284 users in settings where 80 character-wide displays are or were the norm with no or 

285 only awkward horizontal scrolling options available. Using a value higher than the 

286 default may cause undesired diagnostics. 

287 

288 Note: When `formatting.deb822.always-wrap` is `true`, then this option will be overruled. 

289 Additionally, this only triggers when a field is being reformatted. Generally that requires 

290 another option such as `formatting.deb822.normalize-field-content` for that to happen. 

291 """ 

292 ), 

293 ), 

294 PreferenceOption( 

295 key=_NORMALISE_FIELD_CONTENT_KEY, 

296 expected_type=bool, 

297 default_value=False, 

298 description=textwrap.dedent( 

299 """\ 

300 Whether to normalize field content. 

301 

302 This roughly corresponds to the subset of `wrap-and-sort` that normalizes field content 

303 like sorting and normalizing relations or sorting the architecture field. 

304 

305 **Example**: 

306 

307 When `true`, the following: 

308 ``` 

309 Depends: foo, 

310 bar|baz 

311 ``` 

312 

313 would be reformatted as: 

314 

315 ``` 

316 Depends: bar | baz, 

317 foo, 

318 ``` 

319 

320 This causes affected fields to always be rewritten and therefore be sure that other options 

321 such as `formatting.deb822.short-indent` or `formatting.deb822.always-wrap` is set according 

322 to taste. 

323 

324 Note: The field may be rewritten without this being set to `true`. As an example, the `always-wrap` 

325 option can trigger a field rewrite. However, in that case, the values (including any internal whitespace) 

326 are left as-is while the whitespace normalization between the values is still applied. 

327 """ 

328 ), 

329 ), 

330 PreferenceOption( 

331 key=["deb822", "normalize-field-order"], 

332 expected_type=bool, 

333 default_value=False, 

334 description=textwrap.dedent( 

335 """\ 

336 Whether to normalize field order in a stanza. 

337 

338 There is no `wrap-and-sort` feature matching this. 

339 

340 **Example**: 

341 

342 When `true`, the following: 

343 ``` 

344 Depends: bar 

345 Package: foo 

346 ``` 

347 

348 would be reformatted as: 

349 

350 ``` 

351 Depends: foo 

352 Package: bar 

353 ``` 

354 

355 The field order is not by field name but by a logic order defined in `debputy` based on existing 

356 conventions. The `deb822` format does not dictate any field order inside stanzas in general, so 

357 reordering of fields is generally safe. 

358 

359 If a field of the first stanza is known to be a format discriminator such as the `Format' in 

360 `debian/copyright`, then it will be put first. Generally that matches existing convention plus 

361 it maximizes the odds that existing tools will correctly identify the file format. 

362 """ 

363 ), 

364 ), 

365 PreferenceOption( 

366 key=["deb822", "normalize-stanza-order"], 

367 expected_type=bool, 

368 default_value=False, 

369 description=textwrap.dedent( 

370 """\ 

371 Whether to normalize stanza order in a file. 

372 

373 This roughly corresponds to `wrap-and-sort`'s `-kb` feature except this may apply to other deb822 

374 files. 

375 

376 **Example**: 

377 

378 When `true`, the following: 

379 ``` 

380 Source: zzbar 

381 

382 Package: zzbar 

383 

384 Package: zzbar-util 

385 

386 Package: libzzbar-dev 

387 

388 Package: libzzbar2 

389 ``` 

390 

391 would be reformatted as: 

392 

393 ``` 

394 Source: zzbar 

395 

396 Package: zzbar 

397 

398 Package: libzzbar2 

399 

400 Package: libzzbar-dev 

401 

402 Package: zzbar-util 

403 ``` 

404 

405 Reordering will only performed when: 

406 1) There is a convention for a normalized order 

407 2) The normalization can be performed without changing semantics 

408 

409 Note: This option only guards style/preference related re-ordering. It does not influence 

410 warnings about the order being semantic incorrect (which will still be emitted regardless 

411 of this setting). 

412 """ 

413 ), 

414 ), 

415 PreferenceOption( 

416 key=["deb822", "auto-canonical-size-field-names"], 

417 expected_type=bool, 

418 default_value=False, 

419 description=textwrap.dedent( 

420 """\ 

421 Whether to canonical field names of known `deb822` fields. 

422 

423 This causes formatting to align known fields in `deb822` files with their 

424 canonical spelling. As an examples: 

425 

426 ``` 

427 source: foo 

428 RULES-REQUIRES-ROOT: no 

429 ``` 

430 

431 Would be reformatted as: 

432 

433 ``` 

434 Source: foo 

435 Rules-Requires-Root: no 

436 ``` 

437 

438 The formatting only applies when the canonical spelling of the field is known 

439 to `debputy`. Unknown fields retain their original casing/formatting. 

440 

441 This setting may affect style-related diagnostics. Notably, many tools with linting or 

442 diagnostics capabilities (`debputy` included) will generally flag non-canonical spellings 

443 of field names as suboptimal. Note that while `debputy` will only flag and correct 

444 non-canonical casing of fields, some tooling may be more opinionated and flag even 

445 fields they do not know using an algorithm to guess the canonical casing. Therefore, 

446 even with this enabled, you can still canonical spelling related diagnostics from 

447 other tooling. 

448 """ 

449 ), 

450 ), 

451] 

452 

453 

454@dataclasses.dataclass(slots=True, frozen=True) 

455class EffectiveFormattingPreference: 

456 deb822_short_indent: bool | None = None 

457 deb822_always_wrap: bool | None = None 

458 deb822_trailing_separator: bool = False 

459 deb822_normalize_field_content: bool = False 

460 deb822_normalize_field_order: bool = False 

461 deb822_normalize_stanza_order: bool = False 

462 deb822_max_line_length: int = 79 

463 deb822_auto_canonical_size_field_names: bool = False 

464 

465 @classmethod 

466 def from_file( 

467 cls, 

468 filename: str, 

469 key: str, 

470 styles: CommentedMap, 

471 ) -> Self: 

472 attr = {} 

473 

474 for option in FORMATTING_OPTIONS: 

475 if not hasattr(cls, option.attribute_name): 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true

476 continue 

477 value = option.extract_value(filename, key, styles) 

478 attr[option.attribute_name] = value 

479 return cls(**attr) # type: ignore 

480 

481 @classmethod 

482 def aligned_preference( 

483 cls, 

484 a: Optional["EffectiveFormattingPreference"], 

485 b: Optional["EffectiveFormattingPreference"], 

486 ) -> Optional["EffectiveFormattingPreference"]: 

487 if a is None or b is None: 

488 return None 

489 

490 for option in MAINT_OPTIONS: 

491 attr_name = option.attribute_name 

492 if not hasattr(EffectiveFormattingPreference, attr_name): 492 ↛ 494line 492 didn't jump to line 494 because the condition on line 492 was always true

493 continue 

494 a_value = getattr(a, attr_name) 

495 b_value = getattr(b, attr_name) 

496 if a_value != b_value: 

497 return None 

498 return a 

499 

500 def deb822_formatter(self) -> FormatterCallback: 

501 line_length = self.deb822_max_line_length 

502 return wrap_and_sort_formatter( 

503 1 if self.deb822_short_indent else "FIELD_NAME_LENGTH", 

504 trailing_separator=self.deb822_trailing_separator, 

505 immediate_empty_line=self.deb822_short_indent or False, 

506 max_line_length_one_liner=(0 if self.deb822_always_wrap else line_length), 

507 ) 

508 

509 def replace(self, /, **changes: Any) -> Self: 

510 return dataclasses.replace(self, **changes) 

511 

512 

513@dataclasses.dataclass(slots=True, frozen=True) 

514class MaintainerPreference: 

515 canonical_name: str | None = None 

516 is_packaging_team: bool = False 

517 formatting: EffectiveFormattingPreference | None = None 

518 

519 @classmethod 

520 def from_file( 

521 cls, 

522 filename: str, 

523 key: str, 

524 styles: CommentedMap, 

525 ) -> Self: 

526 attr = {} 

527 

528 for option in MAINT_OPTIONS: 

529 if not hasattr(cls, option.attribute_name): 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true

530 continue 

531 value = option.extract_value(filename, key, styles) 

532 attr[option.attribute_name] = value 

533 return cls(**attr) # type: ignore 

534 

535 

536class MaintainerPreferenceTable: 

537 

538 def __init__( 

539 self, 

540 named_styles: Mapping[str, EffectiveFormattingPreference], 

541 maintainer_preferences: Mapping[str, MaintainerPreference], 

542 ) -> None: 

543 self._named_styles = named_styles 

544 self._maintainer_preferences = maintainer_preferences 

545 

546 @classmethod 

547 def load_preferences(cls) -> Self: 

548 named_styles: dict[str, EffectiveFormattingPreference] = {} 

549 maintainer_preferences: dict[str, MaintainerPreference] = {} 

550 

551 path = importlib.resources.files(data_dir).joinpath(BUILTIN_STYLES) 

552 

553 with path.open("r", encoding="utf-8") as fd: 

554 parse_file(named_styles, maintainer_preferences, str(path), fd) 

555 

556 missing_keys = set(named_styles.keys()).difference( 

557 ALL_PUBLIC_NAMED_STYLES.keys() 

558 ) 

559 if missing_keys: 559 ↛ 560line 559 didn't jump to line 560 because the condition on line 559 was never true

560 missing_styles = ", ".join(sorted(missing_keys)) 

561 _error( 

562 f"The following named styles are public API but not present in the config file: {missing_styles}" 

563 ) 

564 

565 # TODO: Support fetching styles online to pull them in faster than waiting for a stable release. 

566 return cls(named_styles, maintainer_preferences) 

567 

568 @property 

569 def named_styles(self) -> Mapping[str, EffectiveFormattingPreference]: 

570 return self._named_styles 

571 

572 @property 

573 def maintainer_preferences(self) -> Mapping[str, MaintainerPreference]: 

574 return self._maintainer_preferences 

575 

576 

577def parse_file( 

578 named_styles: dict[str, EffectiveFormattingPreference], 

579 maintainer_preferences: dict[str, MaintainerPreference], 

580 filename: str, 

581 fd, 

582) -> None: 

583 content = MANIFEST_YAML.load(fd) 

584 if not isinstance(content, CommentedMap): 584 ↛ 585line 584 didn't jump to line 585 because the condition on line 584 was never true

585 raise ValueError( 

586 f'The file "{filename}" should be a YAML file with a single mapping at the root' 

587 ) 

588 try: 

589 maintainer_rules = content["maintainer-rules"] 

590 if not isinstance(maintainer_rules, CommentedMap): 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true

591 raise KeyError("maintainer-rules") from None 

592 except KeyError: 

593 raise ValueError( 

594 f'The file "{filename}" should have a "maintainer-rules" key which must be a mapping.' 

595 ) 

596 named_styles_raw = content.get("formatting") 

597 if named_styles_raw is None or not isinstance(named_styles_raw, CommentedMap): 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true

598 named_styles_raw = {} 

599 

600 for style_name, content in named_styles_raw.items(): 

601 style = EffectiveFormattingPreference.from_file( 

602 filename, 

603 style_name, 

604 content, 

605 ) 

606 named_styles[style_name] = style 

607 

608 for maintainer_email, maintainer_pref in maintainer_rules.items(): 

609 if not isinstance(maintainer_pref, CommentedMap): 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true

610 line_no = maintainer_rules.lc.key(maintainer_email).line 

611 raise ValueError( 

612 f'The value for maintainer "{maintainer_email}" should have been a mapping,' 

613 f' but it is not. The problem entry is at line {line_no} in "{filename}"' 

614 ) 

615 formatting = maintainer_pref.get("formatting") 

616 if isinstance(formatting, str): 

617 try: 

618 style = named_styles[formatting] 

619 except KeyError: 

620 line_no = maintainer_rules.lc.key(maintainer_email).line 

621 raise ValueError( 

622 f'The maintainer "{maintainer_email}" requested the named style "{formatting}",' 

623 f' but said style was not defined {filename}. The problem entry is at line {line_no} in "{filename}"' 

624 ) from None 

625 maintainer_pref["formatting"] = style 

626 elif formatting is not None: 626 ↛ 627line 626 didn't jump to line 627 because the condition on line 626 was never true

627 maintainer_pref["formatting"] = EffectiveFormattingPreference.from_file( 

628 filename, 

629 "formatting", 

630 formatting, 

631 ) 

632 mp = MaintainerPreference.from_file( 

633 filename, 

634 maintainer_email, 

635 maintainer_pref, 

636 ) 

637 

638 maintainer_preferences[maintainer_email] = mp 

639 

640 

641@functools.lru_cache(64) 

642def extract_maint_email(maint: str) -> str: 

643 if not maint.endswith(">"): 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true

644 return "" 

645 

646 try: 

647 idx = maint.index("<") 

648 except ValueError: 

649 return "" 

650 return maint[idx + 1 : -1] 

651 

652 

653def _parse_salsa_ci_boolean(value: str | int | bool) -> bool: 

654 if isinstance(value, str): 

655 return value in ("yes", "1", "true") 

656 elif not isinstance(value, (int, bool)): 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true

657 raise TypeError("Unsupported value") 

658 else: 

659 return value is True or value == 1 

660 

661 

662def _read_salsa_ci_wrap_and_sort_enabled(salsa_ci: CommentedMap | None) -> bool: 

663 sentinel = object() 

664 disable_wrap_and_sort_raw = salsa_ci.mlget( 

665 ["variables", "SALSA_CI_DISABLE_WRAP_AND_SORT"], 

666 list_ok=True, 

667 default=sentinel, 

668 ) 

669 

670 if disable_wrap_and_sort_raw is sentinel: 

671 enable_wrap_and_sort_raw = salsa_ci.mlget( 

672 ["variables", "SALSA_CI_ENABLE_WRAP_AND_SORT"], 

673 list_ok=True, 

674 default=None, 

675 ) 

676 if enable_wrap_and_sort_raw is None or not isinstance( 

677 enable_wrap_and_sort_raw, (str, int, bool) 

678 ): 

679 return False 

680 

681 return _parse_salsa_ci_boolean(enable_wrap_and_sort_raw) 

682 if not isinstance(disable_wrap_and_sort_raw, (str, int, bool)): 

683 return False 

684 

685 disable_wrap_and_sort = _parse_salsa_ci_boolean(disable_wrap_and_sort_raw) 

686 return not disable_wrap_and_sort 

687 

688 

689def determine_effective_preference( 

690 maint_preference_table: MaintainerPreferenceTable, 

691 source_package: SourcePackage | None, 

692 salsa_ci: CommentedMap | None, 

693) -> tuple[EffectiveFormattingPreference | None, str | None, str | None]: 

694 style = source_package.fields.get("X-Style") if source_package is not None else None 

695 if style is not None: 

696 if style not in ALL_PUBLIC_NAMED_STYLES: 696 ↛ 697line 696 didn't jump to line 697 because the condition on line 696 was never true

697 return None, None, "X-Style contained an unknown/unsupported style" 

698 return maint_preference_table.named_styles.get(style), "debputy reformat", None 

699 

700 if salsa_ci and _read_salsa_ci_wrap_and_sort_enabled(salsa_ci): 

701 wrap_and_sort_options = salsa_ci.mlget( 

702 ["variables", "SALSA_CI_WRAP_AND_SORT_ARGS"], 

703 list_ok=True, 

704 default=None, 

705 ) 

706 if wrap_and_sort_options is None: 

707 wrap_and_sort_options = "" 

708 elif not isinstance(wrap_and_sort_options, str): 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true

709 return ( 

710 None, 

711 None, 

712 "The salsa-ci had a non-string option for wrap-and-sort", 

713 ) 

714 detected_style = parse_salsa_ci_wrap_and_sort_args(wrap_and_sort_options) 

715 tool_w_args = f"wrap-and-sort {wrap_and_sort_options}".strip() 

716 if detected_style is None: 716 ↛ 717line 716 didn't jump to line 717 because the condition on line 716 was never true

717 msg = "One or more of the wrap-and-sort options in the salsa-ci file was not supported" 

718 else: 

719 msg = None 

720 return detected_style, tool_w_args, msg 

721 if source_package is None: 

722 return None, None, None 

723 

724 maint = source_package.fields.get("Maintainer") 

725 if maint is None: 725 ↛ 726line 725 didn't jump to line 726 because the condition on line 725 was never true

726 return None, None, None 

727 maint_email = extract_maint_email(maint) 

728 maint_pref = maint_preference_table.maintainer_preferences.get(maint_email) 

729 # Special-case "@packages.debian.org" when missing, since they are likely to be "ad-hoc" 

730 # teams that will not be registered. In that case, we fall back to looking at the uploader 

731 # preferences as-if the maintainer had not been listed at all. 

732 if maint_pref is None and not maint_email.endswith("@packages.debian.org"): 

733 return None, None, None 

734 if maint_pref is not None and maint_pref.is_packaging_team: 734 ↛ 737line 734 didn't jump to line 737 because the condition on line 734 was never true

735 # When the maintainer is registered as a packaging team, then we assume the packaging 

736 # team's style applies unconditionally. 

737 effective = maint_pref.formatting 

738 tool_w_args = _guess_tool_from_style(maint_preference_table, effective) 

739 return effective, tool_w_args, None 

740 uploaders = source_package.fields.get("Uploaders") 

741 if uploaders is None: 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true

742 detected_style = maint_pref.formatting if maint_pref is not None else None 

743 tool_w_args = _guess_tool_from_style(maint_preference_table, detected_style) 

744 return detected_style, tool_w_args, None 

745 all_styles: list[EffectiveFormattingPreference | None] = [] 

746 if maint_pref is not None: 746 ↛ 747line 746 didn't jump to line 747 because the condition on line 746 was never true

747 all_styles.append(maint_pref.formatting) 

748 for uploader in _UPLOADER_SPLIT_RE.split(uploaders): 

749 uploader_email = extract_maint_email(uploader) 

750 uploader_pref = maint_preference_table.maintainer_preferences.get( 

751 uploader_email 

752 ) 

753 all_styles.append(uploader_pref.formatting if uploader_pref else None) 

754 

755 if not all_styles: 755 ↛ 756line 755 didn't jump to line 756 because the condition on line 755 was never true

756 return None, None, None 

757 r = functools.reduce(EffectiveFormattingPreference.aligned_preference, all_styles) 

758 assert not isinstance(r, MaintainerPreference) 

759 tool_w_args = _guess_tool_from_style(maint_preference_table, r) 

760 return r, tool_w_args, None 

761 

762 

763def _guess_tool_from_style( 

764 maint_preference_table: MaintainerPreferenceTable, 

765 pref: EffectiveFormattingPreference | None, 

766) -> str | None: 

767 if pref is None: 

768 return None 

769 if maint_preference_table.named_styles["black"] == pref: 769 ↛ 771line 769 didn't jump to line 771 because the condition on line 769 was always true

770 return "debputy reformat" 

771 return None 

772 

773 

774def _split_options(args: Iterable[str]) -> Iterable[str]: 

775 for arg in args: 

776 if arg.startswith("--"): 

777 yield arg 

778 continue 

779 if not arg.startswith("-") or len(arg) < 2: 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true

780 yield arg 

781 continue 

782 for sarg in arg[1:]: 

783 yield f"-{sarg}" 

784 

785 

786@functools.lru_cache 

787def parse_salsa_ci_wrap_and_sort_args( 

788 args: str, 

789) -> EffectiveFormattingPreference | None: 

790 options = dict(_WAS_DEFAULTS) 

791 for arg in _split_options(args.split()): 

792 v = _WAS_OPTIONS.get(arg) 

793 if v is None: 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true

794 return None 

795 varname, value = v 

796 if varname is None: 

797 continue 

798 options[varname] = value 

799 if "DISABLE_NORMALIZE_STANZA_ORDER" in options: 

800 del options["DISABLE_NORMALIZE_STANZA_ORDER"] 

801 options["deb822_normalize_stanza_order"] = False 

802 

803 return EffectiveFormattingPreference(**options) # type: ignore