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

273 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-04-19 20:37 +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 debian._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 Canonical spelling/case of the maintainer name. 

123 

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

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

126 """), 

127 ), 

128 PreferenceOption( 

129 key="is-packaging-team", 

130 expected_type=bool, 

131 default_value=_is_packaging_team_default, 

132 description=textwrap.dedent("""\ 

133 Whether this entry is for a packaging team 

134 

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

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

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

138 

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

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

141 """), 

142 ), 

143 PreferenceOption( 

144 key="formatting", 

145 expected_type=lambda x: ( 

146 None 

147 if isinstance(x, EffectiveFormattingPreference) 

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

149 ), 

150 default_value=None, 

151 description=textwrap.dedent("""\ 

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

153 style. 

154 """), 

155 ), 

156] 

157 

158FORMATTING_OPTIONS = [ 

159 PreferenceOption( 

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

161 expected_type=bool, 

162 description=textwrap.dedent("""\ 

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

164 

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

166 

167 **Example**: 

168 

169 When `true`, the following: 

170 ``` 

171 Depends: foo, 

172 bar 

173 ``` 

174 

175 would be reformatted as: 

176 

177 ``` 

178 Depends: 

179 foo, 

180 bar 

181 ``` 

182 

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

184 

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

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

187 that span multiple lines. 

188 

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

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

191 """), 

192 ), 

193 PreferenceOption( 

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

195 expected_type=bool, 

196 description=textwrap.dedent("""\ 

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

198 

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

200 

201 **Example**: 

202 

203 When `true`, the following: 

204 ``` 

205 Depends: foo, bar 

206 ``` 

207 

208 would be reformatted as: 

209 

210 ``` 

211 Depends: foo, 

212 bar 

213 ``` 

214 

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

216 

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

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

219 be affected by this option. 

220 

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

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

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

224 """), 

225 ), 

226 PreferenceOption( 

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

228 expected_type=bool, 

229 default_value=False, 

230 description=textwrap.dedent("""\ 

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

232 

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

234 

235 **Example**: 

236 

237 When `true`, the following: 

238 ``` 

239 Depends: foo, 

240 bar 

241 ``` 

242 

243 would be reformatted as: 

244 

245 ``` 

246 Depends: foo, 

247 bar, 

248 ``` 

249 

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

251 generally requires another option to trigger reformatting (like 

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

253 """), 

254 ), 

255 PreferenceOption( 

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

257 expected_type=int, 

258 default_value=79, 

259 description=textwrap.dedent("""\ 

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

261 

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

263 

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

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

266 be affected by this option. 

267 

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

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

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

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

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

273 default may cause undesired diagnostics. 

274 

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

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

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

278 """), 

279 ), 

280 PreferenceOption( 

281 key=_NORMALISE_FIELD_CONTENT_KEY, 

282 expected_type=bool, 

283 default_value=False, 

284 description=textwrap.dedent("""\ 

285 Whether to normalize field content. 

286 

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

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

289 

290 **Example**: 

291 

292 When `true`, the following: 

293 ``` 

294 Depends: foo, 

295 bar|baz 

296 ``` 

297 

298 would be reformatted as: 

299 

300 ``` 

301 Depends: bar | baz, 

302 foo, 

303 ``` 

304 

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

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

307 to taste. 

308 

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

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

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

312 """), 

313 ), 

314 PreferenceOption( 

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

316 expected_type=bool, 

317 default_value=False, 

318 description=textwrap.dedent("""\ 

319 Whether to normalize field order in a stanza. 

320 

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

322 

323 **Example**: 

324 

325 When `true`, the following: 

326 ``` 

327 Depends: bar 

328 Package: foo 

329 ``` 

330 

331 would be reformatted as: 

332 

333 ``` 

334 Depends: foo 

335 Package: bar 

336 ``` 

337 

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

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

340 reordering of fields is generally safe. 

341 

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

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

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

345 """), 

346 ), 

347 PreferenceOption( 

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

349 expected_type=bool, 

350 default_value=False, 

351 description=textwrap.dedent("""\ 

352 Whether to normalize stanza order in a file. 

353 

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

355 files. 

356 

357 **Example**: 

358 

359 When `true`, the following: 

360 ``` 

361 Source: zzbar 

362 

363 Package: zzbar 

364 

365 Package: zzbar-util 

366 

367 Package: libzzbar-dev 

368 

369 Package: libzzbar2 

370 ``` 

371 

372 would be reformatted as: 

373 

374 ``` 

375 Source: zzbar 

376 

377 Package: zzbar 

378 

379 Package: libzzbar2 

380 

381 Package: libzzbar-dev 

382 

383 Package: zzbar-util 

384 ``` 

385 

386 Reordering will only performed when: 

387 1) There is a convention for a normalized order 

388 2) The normalization can be performed without changing semantics 

389 

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

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

392 of this setting). 

393 """), 

394 ), 

395 PreferenceOption( 

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

397 expected_type=bool, 

398 default_value=False, 

399 description=textwrap.dedent("""\ 

400 Whether to canonicalize field names of known `deb822` fields. 

401 

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

403 canonical spelling. As an examples: 

404 

405 ``` 

406 source: foo 

407 RULES-REQUIRES-ROOT: no 

408 ``` 

409 

410 Would be reformatted as: 

411 

412 ``` 

413 Source: foo 

414 Rules-Requires-Root: no 

415 ``` 

416 

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

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

419 

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

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

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

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

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

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

426 other tooling. 

427 """), 

428 ), 

429] 

430 

431 

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

433class EffectiveFormattingPreference: 

434 deb822_short_indent: bool | None = None 

435 deb822_always_wrap: bool | None = None 

436 deb822_trailing_separator: bool = False 

437 deb822_normalize_field_content: bool = False 

438 deb822_normalize_field_order: bool = False 

439 deb822_normalize_stanza_order: bool = False 

440 deb822_max_line_length: int = 79 

441 deb822_auto_canonical_size_field_names: bool = False 

442 

443 @classmethod 

444 def from_file( 

445 cls, 

446 filename: str, 

447 key: str, 

448 styles: CommentedMap, 

449 ) -> Self: 

450 attr = {} 

451 

452 for option in FORMATTING_OPTIONS: 

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

454 continue 

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

456 attr[option.attribute_name] = value 

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

458 

459 @classmethod 

460 def aligned_preference( 

461 cls, 

462 a: Optional["EffectiveFormattingPreference"], 

463 b: Optional["EffectiveFormattingPreference"], 

464 ) -> Optional["EffectiveFormattingPreference"]: 

465 if a is None or b is None: 

466 return None 

467 

468 for option in MAINT_OPTIONS: 

469 attr_name = option.attribute_name 

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

471 continue 

472 a_value = getattr(a, attr_name) 

473 b_value = getattr(b, attr_name) 

474 if a_value != b_value: 

475 return None 

476 return a 

477 

478 def deb822_formatter(self) -> FormatterCallback: 

479 line_length = self.deb822_max_line_length 

480 return wrap_and_sort_formatter( 

481 1 if self.deb822_short_indent else "FIELD_NAME_LENGTH", 

482 trailing_separator=self.deb822_trailing_separator, 

483 immediate_empty_line=self.deb822_short_indent or False, 

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

485 ) 

486 

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

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

489 

490 

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

492class MaintainerPreference: 

493 canonical_name: str | None = None 

494 is_packaging_team: bool = False 

495 formatting: EffectiveFormattingPreference | None = None 

496 

497 @classmethod 

498 def from_file( 

499 cls, 

500 filename: str, 

501 key: str, 

502 styles: CommentedMap, 

503 ) -> Self: 

504 attr = {} 

505 

506 for option in MAINT_OPTIONS: 

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

508 continue 

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

510 attr[option.attribute_name] = value 

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

512 

513 

514class MaintainerPreferenceTable: 

515 

516 def __init__( 

517 self, 

518 named_styles: Mapping[str, EffectiveFormattingPreference], 

519 maintainer_preferences: Mapping[str, MaintainerPreference], 

520 ) -> None: 

521 self._named_styles = named_styles 

522 self._maintainer_preferences = maintainer_preferences 

523 

524 @classmethod 

525 def load_preferences(cls) -> Self: 

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

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

528 

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

530 

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

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

533 

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

535 ALL_PUBLIC_NAMED_STYLES.keys() 

536 ) 

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

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

539 _error( 

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

541 ) 

542 

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

544 return cls(named_styles, maintainer_preferences) 

545 

546 @property 

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

548 return self._named_styles 

549 

550 @property 

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

552 return self._maintainer_preferences 

553 

554 

555def parse_file( 

556 named_styles: dict[str, EffectiveFormattingPreference], 

557 maintainer_preferences: dict[str, MaintainerPreference], 

558 filename: str, 

559 fd, 

560) -> None: 

561 content = MANIFEST_YAML.load(fd) 

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

563 raise ValueError( 

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

565 ) 

566 try: 

567 maintainer_rules = content["maintainer-rules"] 

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

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

570 except KeyError: 

571 raise ValueError( 

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

573 ) 

574 named_styles_raw = content.get("formatting") 

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

576 named_styles_raw = {} 

577 

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

579 style = EffectiveFormattingPreference.from_file( 

580 filename, 

581 style_name, 

582 content, 

583 ) 

584 named_styles[style_name] = style 

585 

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

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

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

589 raise ValueError( 

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

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

592 ) 

593 formatting = maintainer_pref.get("formatting") 

594 if isinstance(formatting, str): 

595 try: 

596 style = named_styles[formatting] 

597 except KeyError: 

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

599 raise ValueError( 

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

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

602 ) from None 

603 maintainer_pref["formatting"] = style 

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

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

606 filename, 

607 "formatting", 

608 formatting, 

609 ) 

610 mp = MaintainerPreference.from_file( 

611 filename, 

612 maintainer_email, 

613 maintainer_pref, 

614 ) 

615 

616 maintainer_preferences[maintainer_email] = mp 

617 

618 

619@functools.lru_cache(64) 

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

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

622 return "" 

623 

624 try: 

625 idx = maint.index("<") 

626 except ValueError: 

627 return "" 

628 return maint[idx + 1 : -1] 

629 

630 

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

632 if isinstance(value, str): 

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

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

635 raise TypeError("Unsupported value") 

636 else: 

637 return value is True or value == 1 

638 

639 

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

641 sentinel = object() 

642 disable_wrap_and_sort_raw = salsa_ci.mlget( 

643 ["variables", "SALSA_CI_DISABLE_WRAP_AND_SORT"], 

644 list_ok=True, 

645 default=sentinel, 

646 ) 

647 

648 if disable_wrap_and_sort_raw is sentinel: 

649 enable_wrap_and_sort_raw = salsa_ci.mlget( 

650 ["variables", "SALSA_CI_ENABLE_WRAP_AND_SORT"], 

651 list_ok=True, 

652 default=None, 

653 ) 

654 if enable_wrap_and_sort_raw is None or not isinstance( 

655 enable_wrap_and_sort_raw, (str, int, bool) 

656 ): 

657 return False 

658 

659 return _parse_salsa_ci_boolean(enable_wrap_and_sort_raw) 

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

661 return False 

662 

663 disable_wrap_and_sort = _parse_salsa_ci_boolean(disable_wrap_and_sort_raw) 

664 return not disable_wrap_and_sort 

665 

666 

667def determine_effective_preference( 

668 maint_preference_table: MaintainerPreferenceTable, 

669 source_package: SourcePackage | None, 

670 salsa_ci: CommentedMap | None, 

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

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

673 if style is not None: 

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

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

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

677 

678 if salsa_ci and _read_salsa_ci_wrap_and_sort_enabled(salsa_ci): 

679 wrap_and_sort_options = salsa_ci.mlget( 

680 ["variables", "SALSA_CI_WRAP_AND_SORT_ARGS"], 

681 list_ok=True, 

682 default=None, 

683 ) 

684 if wrap_and_sort_options is None: 

685 wrap_and_sort_options = "" 

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

687 return ( 

688 None, 

689 None, 

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

691 ) 

692 detected_style = parse_salsa_ci_wrap_and_sort_args(wrap_and_sort_options) 

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

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

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

696 else: 

697 msg = None 

698 return detected_style, tool_w_args, msg 

699 if source_package is None: 

700 return None, None, None 

701 

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

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

704 return None, None, None 

705 maint_email = extract_maint_email(maint) 

706 maint_pref = maint_preference_table.maintainer_preferences.get(maint_email) 

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

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

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

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

711 return None, None, None 

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

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

714 # team's style applies unconditionally. 

715 effective = maint_pref.formatting 

716 tool_w_args = _guess_tool_from_style(maint_preference_table, effective) 

717 return effective, tool_w_args, None 

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

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

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

721 tool_w_args = _guess_tool_from_style(maint_preference_table, detected_style) 

722 return detected_style, tool_w_args, None 

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

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

725 all_styles.append(maint_pref.formatting) 

726 for uploader in _UPLOADER_SPLIT_RE.split(uploaders): 

727 uploader_email = extract_maint_email(uploader) 

728 uploader_pref = maint_preference_table.maintainer_preferences.get( 

729 uploader_email 

730 ) 

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

732 

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

734 return None, None, None 

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

736 assert not isinstance(r, MaintainerPreference) 

737 tool_w_args = _guess_tool_from_style(maint_preference_table, r) 

738 return r, tool_w_args, None 

739 

740 

741def _guess_tool_from_style( 

742 maint_preference_table: MaintainerPreferenceTable, 

743 pref: EffectiveFormattingPreference | None, 

744) -> str | None: 

745 if pref is None: 

746 return None 

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

748 return "debputy reformat" 

749 return None 

750 

751 

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

753 for arg in args: 

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

755 yield arg 

756 continue 

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

758 yield arg 

759 continue 

760 for sarg in arg[1:]: 

761 yield f"-{sarg}" 

762 

763 

764@functools.lru_cache 

765def parse_salsa_ci_wrap_and_sort_args( 

766 args: str, 

767) -> EffectiveFormattingPreference | None: 

768 options = dict(_WAS_DEFAULTS) 

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

770 v = _WAS_OPTIONS.get(arg) 

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

772 return None 

773 varname, value = v 

774 if varname is None: 

775 continue 

776 options[varname] = value 

777 if "DISABLE_NORMALIZE_STANZA_ORDER" in options: 

778 del options["DISABLE_NORMALIZE_STANZA_ORDER"] 

779 options["deb822_normalize_stanza_order"] = False 

780 

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