summaryrefslogtreecommitdiffstats
path: root/src/debputy/lsp/style_prefs.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/debputy/lsp/style_prefs.py')
-rw-r--r--src/debputy/lsp/style_prefs.py582
1 files changed, 582 insertions, 0 deletions
diff --git a/src/debputy/lsp/style_prefs.py b/src/debputy/lsp/style_prefs.py
new file mode 100644
index 0000000..5dfdac2
--- /dev/null
+++ b/src/debputy/lsp/style_prefs.py
@@ -0,0 +1,582 @@
+import dataclasses
+import functools
+import os.path
+import re
+import textwrap
+from typing import (
+ Type,
+ TypeVar,
+ Generic,
+ Optional,
+ List,
+ Union,
+ Callable,
+ Mapping,
+ Self,
+ Dict,
+)
+
+from debputy.lsp.lsp_reference_keyword import ALL_PUBLIC_NAMED_STYLES
+from debputy.lsp.vendoring._deb822_repro.types import FormatterCallback
+from debputy.lsp.vendoring.wrap_and_sort import wrap_and_sort_formatter
+from debputy.packages import SourcePackage
+from debputy.util import _error
+from debputy.yaml import MANIFEST_YAML
+from debputy.yaml.compat import CommentedMap
+
+PT = TypeVar("PT", bool, str, int)
+
+
+BUILTIN_STYLES = os.path.join(os.path.dirname(__file__), "style-preferences.yaml")
+
+_NORMALISE_FIELD_CONTENT_KEY = ["formatting", "deb822", "normalize-field-content"]
+_UPLOADER_SPLIT_RE = re.compile(r"(?<=>)\s*,")
+
+
+@dataclasses.dataclass(slots=True, frozen=True, kw_only=True)
+class PreferenceOption(Generic[PT]):
+ key: Union[str, List[str]]
+ expected_type: Type[PT]
+ description: str
+ default_value: Optional[Union[PT, Callable[[CommentedMap], Optional[PT]]]] = None
+
+ @property
+ def name(self) -> str:
+ if isinstance(self.key, str):
+ return self.key
+ return ".".join(self.key)
+
+ @property
+ def attribute_name(self) -> str:
+ return self.name.replace("-", "_").replace(".", "_")
+
+ def extract_value(
+ self,
+ filename: str,
+ key: str,
+ data: CommentedMap,
+ ) -> Optional[PT]:
+ v = data.mlget(self.key, list_ok=True)
+ if v is None:
+ default_value = self.default_value
+ if callable(default_value):
+ return default_value(data)
+ return default_value
+ if isinstance(v, self.expected_type):
+ return v
+ raise ValueError(
+ f'The value "{self.name}" for key {key} in file "{filename}" should have been a'
+ f" {self.expected_type} but it was not"
+ )
+
+
+def _is_packaging_team_default(m: CommentedMap) -> bool:
+ v = m.get("canonical-name")
+ if not isinstance(v, str):
+ return False
+ v = v.lower()
+ return v.endswith((" maintainer", " maintainers", " team"))
+
+
+def _false_when_formatting_content(m: CommentedMap) -> Optional[bool]:
+ return m.mlget(_NORMALISE_FIELD_CONTENT_KEY, list_ok=True, default=False) is True
+
+
+OPTIONS: List[PreferenceOption] = [
+ PreferenceOption(
+ key="canonical-name",
+ expected_type=str,
+ description=textwrap.dedent(
+ """\
+ Canonical spelling/case of the maintainer name.
+
+ The `debputy` linter will emit a diagnostic if the name is not spelled exactly as provided here.
+ Can be useful to ensure your name is updated after a change of name.
+ """
+ ),
+ ),
+ PreferenceOption(
+ key="is-packaging-team",
+ expected_type=bool,
+ default_value=_is_packaging_team_default,
+ description=textwrap.dedent(
+ """\
+ Whether this entry is for a packaging team
+
+ This affects how styles are applied when multiple maintainers (`Maintainer` + `Uploaders`) are listed
+ in `debian/control`. For package teams, the team preference prevails when the team is in the `Maintainer`
+ field. For non-packaging teams, generally the rules do not apply as soon as there are co-maintainers.
+
+ The default is derived from the canonical name. If said name ends with phrases like "Team" or "Maintainer"
+ then the email is assumed to be for a team by default.
+ """
+ ),
+ ),
+ PreferenceOption(
+ key=["formatting", "deb822", "short-indent"],
+ expected_type=bool,
+ description=textwrap.dedent(
+ """\
+ Whether to use "short" indents for relationship fields (such as `Depends`).
+
+ This roughly corresponds to `wrap-and-sort`'s `-s` option.
+
+ **Example**:
+
+ When `true`, the following:
+ ```
+ Depends: foo,
+ bar
+ ```
+
+ would be reformatted as:
+
+ ```
+ Depends:
+ foo,
+ bar
+ ```
+
+ (Assuming `formatting.deb822.short-indent` is `false`)
+
+ Note that defaults to `false` *if* (and only if) other formatting options will trigger reformat of
+ the field and this option has not been set. Setting this option can trigger reformatting of fields
+ that span multiple lines.
+
+ Additionally, this only triggers when a field is being reformatted. Generally that requires
+ another option such as `formatting.deb822.normalize-field-content` for that to happen.
+ """
+ ),
+ ),
+ PreferenceOption(
+ key=["formatting", "deb822", "always-wrap"],
+ expected_type=bool,
+ description=textwrap.dedent(
+ """\
+ Whether to always wrap fields (such as `Depends`).
+
+ This roughly corresponds to `wrap-and-sort`'s `-a` option.
+
+ **Example**:
+
+ When `true`, the following:
+ ```
+ Depends: foo, bar
+ ```
+
+ would be reformatted as:
+
+ ```
+ Depends: foo,
+ bar
+ ```
+
+ (Assuming `formatting.deb822.short-indent` is `false`)
+
+ This option only applies to fields where formatting is a pure style preference. As an
+ example, `Description` (`debian/control`) or `License` (`debian/copyright`) will not
+ be affected by this option.
+
+ Note: When `true`, this option overrules `formatting.deb822.max-line-length` when they interact.
+ Additionally, this only triggers when a field is being reformatted. Generally that requires
+ another option such as `formatting.deb822.normalize-field-content` for that to happen.
+ """
+ ),
+ ),
+ PreferenceOption(
+ key=["formatting", "deb822", "trailing-separator"],
+ expected_type=bool,
+ default_value=False,
+ description=textwrap.dedent(
+ """\
+ Whether to always end relationship fields (such as `Depends`) with a trailing separator.
+
+ This roughly corresponds to `wrap-and-sort`'s `-t` option.
+
+ **Example**:
+
+ When `true`, the following:
+ ```
+ Depends: foo,
+ bar
+ ```
+
+ would be reformatted as:
+
+ ```
+ Depends: foo,
+ bar,
+ ```
+
+ Note: The trailing separator is only applied if the field is reformatted. This means this option
+ generally requires another option to trigger reformatting (like
+ `formatting.deb822.normalize-field-content`).
+ """
+ ),
+ ),
+ PreferenceOption(
+ key=["formatting", "deb822", "max-line-length"],
+ expected_type=int,
+ default_value=79,
+ description=textwrap.dedent(
+ """\
+ How long a value line can be before it should be line wrapped.
+
+ This roughly corresponds to `wrap-and-sort`'s `--max-line-length` option.
+
+ This option only applies to fields where formatting is a pure style preference. As an
+ example, `Description` (`debian/control`) or `License` (`debian/copyright`) will not
+ be affected by this option.
+
+ Note: When `formatting.deb822.always-wrap` is `true`, then this option will be overruled.
+ Additionally, this only triggers when a field is being reformatted. Generally that requires
+ another option such as `formatting.deb822.normalize-field-content` for that to happen.
+ """
+ ),
+ ),
+ PreferenceOption(
+ key=_NORMALISE_FIELD_CONTENT_KEY,
+ expected_type=bool,
+ default_value=False,
+ description=textwrap.dedent(
+ """\
+ Whether to normalize field content.
+
+ This roughly corresponds to the subset of `wrap-and-sort` that normalizes field content
+ like sorting and normalizing relations or sorting the architecture field.
+
+ **Example**:
+
+ When `true`, the following:
+ ```
+ Depends: foo,
+ bar|baz
+ ```
+
+ would be reformatted as:
+
+ ```
+ Depends: bar | baz,
+ foo,
+ ```
+
+ This causes affected fields to always be rewritten and therefore be sure that other options
+ such as `formatting.deb822.short-indent` or `formatting.deb822.always-wrap` is set according
+ to taste.
+
+ Note: The field may be rewritten without this being set to `true`. As an example, the `always-wrap`
+ option can trigger a field rewrite. However, in that case, the values (including any internal whitespace)
+ are left as-is while the whitespace normalization between the values is still applied.
+ """
+ ),
+ ),
+ PreferenceOption(
+ key=["formatting", "deb822", "normalize-field-order"],
+ expected_type=bool,
+ default_value=False,
+ description=textwrap.dedent(
+ """\
+ Whether to normalize field order in a stanza.
+
+ There is no `wrap-and-sort` feature matching this.
+
+ **Example**:
+
+ When `true`, the following:
+ ```
+ Depends: bar
+ Package: foo
+ ```
+
+ would be reformatted as:
+
+ ```
+ Depends: foo
+ Package: bar
+ ```
+
+ The field order is not by field name but by a logic order defined in `debputy` based on existing
+ conventions. The `deb822` format does not dictate any field order inside stanzas in general, so
+ reordering of fields is generally safe.
+
+ If a field of the first stanza is known to be a format discriminator such as the `Format' in
+ `debian/copyright`, then it will be put first. Generally that matches existing convention plus
+ it maximizes the odds that existing tools will correctly identify the file format.
+ """
+ ),
+ ),
+ PreferenceOption(
+ key=["formatting", "deb822", "normalize-stanza-order"],
+ expected_type=bool,
+ default_value=False,
+ description=textwrap.dedent(
+ """\
+ Whether to normalize stanza order in a file.
+
+ This roughly corresponds to `wrap-and-sort`'s `-kb` feature except this may apply to other deb822
+ files.
+
+ **Example**:
+
+ When `true`, the following:
+ ```
+ Source: zzbar
+
+ Package: zzbar
+
+ Package: zzbar-util
+
+ Package: libzzbar-dev
+
+ Package: libzzbar2
+ ```
+
+ would be reformatted as:
+
+ ```
+ Source: zzbar
+
+ Package: zzbar
+
+ Package: libzzbar2
+
+ Package: libzzbar-dev
+
+ Package: zzbar-util
+ ```
+
+ Reordering will only performed when:
+ 1) There is a convention for a normalized order
+ 2) The normalization can be performed without changing semantics
+
+ Note: This option only guards style/preference related re-ordering. It does not influence
+ warnings about the order being semantic incorrect (which will still be emitted regardless
+ of this setting).
+ """
+ ),
+ ),
+]
+
+
+@dataclasses.dataclass(slots=True, frozen=True)
+class EffectivePreference:
+ formatting_deb822_short_indent: Optional[bool] = None
+ formatting_deb822_always_wrap: Optional[bool] = None
+ formatting_deb822_trailing_separator: bool = False
+ formatting_deb822_normalize_field_content: bool = False
+ formatting_deb822_normalize_field_order: bool = False
+ formatting_deb822_normalize_stanza_order: bool = False
+ formatting_deb822_max_line_length: int = 79
+
+ @classmethod
+ def from_file(
+ cls,
+ filename: str,
+ key: str,
+ stylees: CommentedMap,
+ ) -> Self:
+ attr = {}
+
+ for option in OPTIONS:
+ if not hasattr(cls, option.attribute_name):
+ continue
+ value = option.extract_value(filename, key, stylees)
+ attr[option.attribute_name] = value
+ return cls(**attr) # type: ignore
+
+ @classmethod
+ def aligned_preference(
+ cls,
+ a: Optional["EffectivePreference"],
+ b: Optional["EffectivePreference"],
+ ) -> Optional["EffectivePreference"]:
+ if a is None or b is None:
+ return None
+
+ for option in OPTIONS:
+ attr_name = option.attribute_name
+ if not hasattr(EffectivePreference, attr_name):
+ continue
+ a_value = getattr(a, attr_name)
+ b_value = getattr(b, attr_name)
+ if a_value != b_value:
+ print(f"{attr_name} was misaligned")
+ return None
+ return a
+
+ def deb822_formatter(self) -> FormatterCallback:
+ line_length = self.formatting_deb822_max_line_length
+ return wrap_and_sort_formatter(
+ 1 if self.formatting_deb822_short_indent else "FIELD_NAME_LENGTH",
+ trailing_separator=self.formatting_deb822_trailing_separator,
+ immediate_empty_line=self.formatting_deb822_short_indent or False,
+ max_line_length_one_liner=(
+ 0 if self.formatting_deb822_always_wrap else line_length
+ ),
+ )
+
+
+@dataclasses.dataclass(slots=True, frozen=True)
+class MaintainerPreference(EffectivePreference):
+ canonical_name: Optional[str] = None
+ is_packaging_team: bool = False
+
+ def as_effective_pref(self) -> EffectivePreference:
+ fields = {
+ k: v
+ for k, v in dataclasses.asdict(self).items()
+ if hasattr(EffectivePreference, k)
+ }
+ return EffectivePreference(**fields)
+
+
+class StylePreferenceTable:
+
+ def __init__(
+ self,
+ named_styles: Mapping[str, EffectivePreference],
+ maintainer_preferences: Mapping[str, MaintainerPreference],
+ ) -> None:
+ self._named_styles = named_styles
+ self._maintainer_preferences = maintainer_preferences
+
+ @classmethod
+ def load_styles(cls) -> Self:
+ named_styles: Dict[str, EffectivePreference] = {}
+ maintainer_preferences: Dict[str, MaintainerPreference] = {}
+ with open(BUILTIN_STYLES) as fd:
+ parse_file(named_styles, maintainer_preferences, BUILTIN_STYLES, fd)
+
+ missing_keys = set(named_styles.keys()).difference(
+ ALL_PUBLIC_NAMED_STYLES.keys()
+ )
+ if missing_keys:
+ missing_styles = ", ".join(sorted(missing_keys))
+ _error(
+ f"The following named styles are public API but not present in the config file: {missing_styles}"
+ )
+
+ # TODO: Support fetching styles online to pull them in faster than waiting for a stable release.
+ return cls(named_styles, maintainer_preferences)
+
+ @property
+ def named_styles(self) -> Mapping[str, EffectivePreference]:
+ return self._named_styles
+
+ @property
+ def maintainer_preferences(self) -> Mapping[str, MaintainerPreference]:
+ return self._maintainer_preferences
+
+
+def parse_file(
+ named_styles: Dict[str, EffectivePreference],
+ maintainer_preferences: Dict[str, MaintainerPreference],
+ filename: str,
+ fd,
+) -> None:
+ content = MANIFEST_YAML.load(fd)
+ if not isinstance(content, CommentedMap):
+ raise ValueError(
+ f'The file "{filename}" should be a YAML file with a single mapping at the root'
+ )
+ try:
+ maintainer_rules = content["maintainer-rules"]
+ if not isinstance(maintainer_rules, CommentedMap):
+ raise KeyError("maintainer-rules") from None
+ except KeyError:
+ raise ValueError(
+ f'The file "{filename}" should have a "maintainer-rules" key which must be a mapping.'
+ )
+ named_styles_raw = content.get("formatting")
+ if named_styles_raw is None or not isinstance(named_styles_raw, CommentedMap):
+ named_styles_raw = {}
+
+ for style_name, content in named_styles_raw.items():
+ wrapped_style = CommentedMap({"formatting": content})
+ style = EffectivePreference.from_file(
+ filename,
+ style_name,
+ wrapped_style,
+ )
+ named_styles[style_name] = style
+
+ for maintainer_email, maintainer_styles in maintainer_rules.items():
+ if not isinstance(maintainer_styles, CommentedMap):
+ line_no = maintainer_rules.lc.key(maintainer_email).line
+ raise ValueError(
+ f'The value for maintainer "{maintainer_email}" should have been a mapping,'
+ f' but it is not. The problem entry is at line {line_no} in "{filename}"'
+ )
+ formatting = maintainer_styles.get("formatting")
+ if isinstance(formatting, str):
+ try:
+ style = named_styles_raw[formatting]
+ except KeyError:
+ line_no = maintainer_rules.lc.key(maintainer_email).line
+ raise ValueError(
+ f'The maintainer "{maintainer_email}" requested the named style "{formatting}",'
+ f' but said style was not defined {filename}. The problem entry is at line {line_no} in "{filename}"'
+ ) from None
+ maintainer_styles["formatting"] = style
+ maintainer_preferences[maintainer_email] = MaintainerPreference.from_file(
+ filename,
+ maintainer_email,
+ maintainer_styles,
+ )
+
+
+@functools.lru_cache(64)
+def extract_maint_email(maint: str) -> str:
+ if not maint.endswith(">"):
+ return ""
+
+ try:
+ idx = maint.index("<")
+ except ValueError:
+ return ""
+ return maint[idx + 1 : -1]
+
+
+def determine_effective_style(
+ style_preference_table: StylePreferenceTable,
+ source_package: SourcePackage,
+) -> Optional[EffectivePreference]:
+ style = source_package.fields.get("X-Style")
+ if style is not None:
+ if style not in ALL_PUBLIC_NAMED_STYLES:
+ return None
+ return style_preference_table.named_styles.get(style)
+
+ maint = source_package.fields.get("Maintainer")
+ if maint is None:
+ return None
+ maint_email = extract_maint_email(maint)
+ maint_style = style_preference_table.maintainer_preferences.get(maint_email)
+ # Special-case "@packages.debian.org" when missing, since they are likely to be "ad-hoc"
+ # teams that will not be registered. In that case, we fall back to looking at the uploader
+ # preferences as-if the maintainer had not been listed at all.
+ if maint_style is None and not maint_email.endswith("@packages.debian.org"):
+ return None
+ if maint_style is not None and maint_style.is_packaging_team:
+ # When the maintainer is registered as a packaging team, then we assume the packaging
+ # team's style applies unconditionally.
+ return maint_style.as_effective_pref()
+ uploaders = source_package.fields.get("Uploaders")
+ if uploaders is None:
+ return maint_style.as_effective_pref() if maint_style is not None else None
+ all_styles: List[Optional[EffectivePreference]] = []
+ if maint_style is not None:
+ all_styles.append(maint_style)
+ for uploader in _UPLOADER_SPLIT_RE.split(uploaders):
+ uploader_email = extract_maint_email(uploader)
+ uploader_style = style_preference_table.maintainer_preferences.get(
+ uploader_email
+ )
+ all_styles.append(uploader_style)
+
+ if not all_styles:
+ return None
+ r = functools.reduce(EffectivePreference.aligned_preference, all_styles)
+ if isinstance(r, MaintainerPreference):
+ return r.as_effective_pref()
+ return r