summaryrefslogtreecommitdiffstats
path: root/scripts/devscripts/control.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/devscripts/control.py')
-rw-r--r--scripts/devscripts/control.py292
1 files changed, 292 insertions, 0 deletions
diff --git a/scripts/devscripts/control.py b/scripts/devscripts/control.py
new file mode 100644
index 0000000..17a22c3
--- /dev/null
+++ b/scripts/devscripts/control.py
@@ -0,0 +1,292 @@
+# control.py - Represents a debian/control file
+#
+# Copyright (C) 2010, Benjamin Drung <bdrung@debian.org>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""This module implements facilities to deal with Debian control."""
+import contextlib
+import os
+import sys
+
+from devscripts.logger import Logger
+
+try:
+ import debian.deb822
+except ImportError:
+ Logger.error("Please install 'python3-debian' in order to use this utility.")
+ sys.exit(1)
+
+try:
+ from debian._deb822_repro import Deb822ParagraphElement, parse_deb822_file
+ from debian._deb822_repro.tokens import Deb822Token
+
+ HAS_RTS_PARSER = True
+except ImportError:
+ HAS_RTS_PARSER = False
+
+try:
+ from debian._deb822_repro.formatter import one_value_per_line_formatter
+
+ HAS_FULL_RTS_FORMATTING = True
+except ImportError:
+
+ def one_value_per_line_formatter(
+ indentation, trailing_separator=True, immediate_empty_line=False
+ ):
+ raise AssertionError(
+ "Bug: The dummy one_value_per_line_formatter method should not be called!"
+ )
+
+ HAS_FULL_RTS_FORMATTING = False
+
+
+def _emit_one_line_value(value_tokens, sep_token, trailing_separator):
+ first_token = True
+ yield " "
+ for token in value_tokens:
+ if not first_token:
+ yield sep_token
+ if not sep_token.is_whitespace:
+ yield " "
+ first_token = False
+ yield token
+ if trailing_separator and not sep_token.is_whitespace:
+ yield sep_token
+ yield "\n"
+
+
+def wrap_and_sort_formatter(
+ indentation,
+ trailing_separator=True,
+ immediate_empty_line=False,
+ max_line_length_one_liner=0,
+):
+ """Provide a formatter that can handle indentation and trailing separators
+
+ This is a custom wrap-and-sort formatter capable of supporting wrap-and-sort's
+ needs. Where possible it delegates to python-debian's own formatter.
+
+ :param indentation: Either the literal string "FIELD_NAME_LENGTH" or a positive
+ integer, which determines the indentation fields. If it is an integer,
+ then a fixed indentation is used (notably the value 1 ensures the shortest
+ possible indentation). Otherwise, if it is "FIELD_NAME_LENGTH", then the
+ indentation is set such that it aligns the values based on the field name.
+ This parameter only affects values placed on the second line or later lines.
+ :param trailing_separator: If True, then the last value will have a trailing
+ separator token (e.g., ",") after it.
+ :param immediate_empty_line: Whether the value should always start with an
+ empty line. If True, then the result becomes something like "Field:\n value".
+ This parameter only applies to the values that will be formatted over more than
+ one line.
+ :param max_line_length_one_liner: If greater than zero, then this is the max length
+ of the value if it is crammed into a "one-liner" value. If the value(s) fit into
+ one line, this parameter will overrule immediate_empty_line.
+
+ """
+ if not HAS_FULL_RTS_FORMATTING:
+ raise NotImplementedError(
+ "wrap_and_sort_formatter requires python-debian 0.1.44"
+ )
+ if indentation != "FIELD_NAME_LENGTH" and indentation < 1:
+ raise ValueError('indentation must be at least 1 (or "FIELD_NAME_LENGTH")')
+
+ # The python-debian library provides support for all cases except cramming
+ # everything into a single line. So we "only" have to implement the single-line
+ # case(s) ourselves (which sadly takes plenty of code on its own)
+
+ _chain_formatter = one_value_per_line_formatter(
+ indentation,
+ trailing_separator=trailing_separator,
+ immediate_empty_line=immediate_empty_line,
+ )
+
+ if max_line_length_one_liner < 1:
+ return _chain_formatter
+
+ def _formatter(name, sep_token, formatter_tokens):
+ # We should have unconditionally delegated to the python-debian formatter
+ # if max_line_length_one_liner was set to "wrap_always"
+ assert max_line_length_one_liner > 0
+ all_tokens = list(formatter_tokens)
+ values_and_comments = [x for x in all_tokens if x.is_comment or x.is_value]
+ # There are special-cases where you could do a one-liner with comments, but
+ # they are probably a lot more effort than it is worth investing.
+ # - If you are here because you disagree, patches welcome. :)
+ if all(x.is_value for x in values_and_comments):
+ # We use " " (1 char) or ", " (2 chars) as separated depending on the field.
+ # (at the time of writing, wrap-and-sort only uses this formatted for
+ # dependency fields meaning this will be "2" - but now it is future proof).
+ chars_between_values = 1 + (0 if sep_token.is_whitespace else 1)
+ # Compute the total line length of the field as the sum of all values
+ total_len = sum(len(x.text) for x in values_and_comments)
+ # ... plus the separators
+ total_len += (len(values_and_comments) - 1) * chars_between_values
+ # plus the field name + the ": " after the field name
+ total_len += len(name) + 2
+ if total_len <= max_line_length_one_liner:
+ yield from _emit_one_line_value(
+ values_and_comments, sep_token, trailing_separator
+ )
+ return
+ # If it does not fit in one line, we fall through
+ # Chain into the python-debian provided formatter, which will handle this
+ # formatting for us.
+ yield from _chain_formatter(name, sep_token, all_tokens)
+
+ return _formatter
+
+
+def _insert_after(paragraph, item_before, new_item, new_value):
+ """Insert new_item into directly after item_before
+
+ New items added to a dictionary are appended."""
+ try:
+ paragraph.order_after
+ except AttributeError:
+ pass
+ else:
+ # Use order_after from python-debian (>= 0.1.42~), which is O(1) performance
+ paragraph[new_item] = new_value
+ try:
+ paragraph.order_after(new_item, item_before)
+ except KeyError:
+ # Happens if `item_before` is not present. We ignore this error because we
+ # are fine with `new_item` ending the "end" of the paragraph in that case.
+ pass
+ return
+ # Old method - O(n) performance
+ item_found = False
+ for item in paragraph:
+ if item_found:
+ value = paragraph.pop(item)
+ paragraph[item] = value
+ if item == item_before:
+ item_found = True
+ paragraph[new_item] = new_value
+ if not item_found:
+ paragraph[new_item] = new_value
+
+
+@contextlib.contextmanager
+def _open(filename, fd=None, encoding="utf-8", **kwargs):
+ if fd is None:
+ with open(filename, encoding=encoding, **kwargs) as fileobj:
+ yield fileobj
+ else:
+ yield fd
+
+
+class Control:
+ """Represents a debian/control file"""
+
+ def __init__(self, filename, fd=None, use_rts_parser=None):
+ assert fd is not None or os.path.isfile(filename), f"{filename} does not exist."
+ self.filename = filename
+ self._is_roundtrip_safe = use_rts_parser
+ self.strip_trailing_whitespace_on_save = False
+
+ if self._is_roundtrip_safe:
+ # Note: wrap-and-sort does not trigger this code path without python-debian
+ # 0.1.44 due to the lack of formatter support (that we are not willing to
+ # re-implement ourselves). However, the 0.1.43 version is correct for the
+ # Control class itself and is left as-is for non-"wrap-and-sort" consumers
+ # (if any)
+ if not HAS_RTS_PARSER:
+ raise ValueError(
+ "The use_rts_parser option requires python-debian 0.1.43 or later"
+ )
+ with _open(filename, fd=fd, encoding="utf8") as sequence:
+ self._deb822_file = parse_deb822_file(sequence)
+ self.paragraphs = list(self._deb822_file)
+ else:
+ self._deb822_file = None
+ self.paragraphs = []
+ with _open(filename, fd=fd, encoding="utf8") as sequence:
+ for paragraph in debian.deb822.Deb822.iter_paragraphs(sequence):
+ self.paragraphs.append(paragraph)
+
+ @property
+ def is_roundtrip_safe(self):
+ return self._is_roundtrip_safe
+
+ def get_maintainer(self):
+ """Returns the value of the Maintainer field."""
+ return self.paragraphs[0].get("Maintainer")
+
+ def get_original_maintainer(self):
+ """Returns the value of the XSBC-Original-Maintainer field."""
+ return self.paragraphs[0].get("XSBC-Original-Maintainer")
+
+ def dump(self):
+ if self.is_roundtrip_safe:
+ content = self._dump_rts_file()
+ else:
+ content = "\n".join(x.dump() for x in self.paragraphs)
+ if self.strip_trailing_whitespace_on_save:
+ content = "\n".join(x.rstrip() for x in content.splitlines()) + "\n"
+ return content
+
+ def _dump_rts_file(self):
+ # Use a custom dump of the RTS parser in order to:
+ # 1) support sorting of paragraphs
+ # 2) normalize whitespace between paragraphs
+ #
+ # Ideally, there would be a simpler way to do this - but for now, this is
+ # the best the RTS parser can offer. (Without the above constraints, we
+ # could just have used `self._deb822_file.dump()`)
+ paragraph_index = 0
+ new_content = ""
+ pending_newline = False
+ for part in self._deb822_file.iter_parts():
+ if isinstance(part, Deb822ParagraphElement):
+ part_content = self.paragraphs[paragraph_index].dump()
+ paragraph_index += 1
+ elif isinstance(part, Deb822Token) and part.is_whitespace:
+ # Normalize empty lines between paragraphs to a single newline.
+ #
+ # Note we do this unconditionally of
+ # strip_trailing_whitespace_on_save because preserving whitespace
+ # between paragraphs while reordering them produce funky results.
+ pending_newline = True
+ continue
+ else:
+ part_content = part.convert_to_text()
+ if pending_newline:
+ new_content += "\n"
+ new_content += part_content
+ return new_content
+
+ def save(self, filename=None):
+ """Saves the control file."""
+ if filename:
+ self.filename = filename
+ content = self.dump()
+ with open(self.filename, "wb") as control_file:
+ control_file.write(content.encode("utf-8"))
+
+ def set_maintainer(self, maintainer):
+ """Sets the value of the Maintainer field."""
+ self.paragraphs[0]["Maintainer"] = maintainer
+
+ def set_original_maintainer(self, original_maintainer):
+ """Sets the value of the XSBC-Original-Maintainer field."""
+ if "XSBC-Original-Maintainer" in self.paragraphs[0]:
+ self.paragraphs[0]["XSBC-Original-Maintainer"] = original_maintainer
+ else:
+ _insert_after(
+ self.paragraphs[0],
+ "Maintainer",
+ "XSBC-Original-Maintainer",
+ original_maintainer,
+ )