summaryrefslogtreecommitdiffstats
path: root/scripts/devscripts
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 15:53:52 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 15:53:52 +0000
commitefe47381c599b07e4c7bbdb2e91e8090a541c887 (patch)
tree05cf57183f5a23394eca11b00f97a74a5dfdf79d /scripts/devscripts
parentInitial commit. (diff)
downloaddevscripts-upstream.tar.xz
devscripts-upstream.zip
Adding upstream version 2.23.4+deb12u1.upstream/2.23.4+deb12u1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'scripts/devscripts')
-rw-r--r--scripts/devscripts/control.py292
-rw-r--r--scripts/devscripts/logger.py75
-rw-r--r--scripts/devscripts/test/__init__.py67
-rw-r--r--scripts/devscripts/test/pylint.conf67
-rw-r--r--scripts/devscripts/test/test_black.py50
-rw-r--r--scripts/devscripts/test/test_control.py280
-rw-r--r--scripts/devscripts/test/test_debootsnap.py56
-rw-r--r--scripts/devscripts/test/test_flake8.py59
-rw-r--r--scripts/devscripts/test/test_help.py84
-rw-r--r--scripts/devscripts/test/test_isort.py42
-rw-r--r--scripts/devscripts/test/test_logger.py57
-rw-r--r--scripts/devscripts/test/test_pylint.py82
-rw-r--r--scripts/devscripts/test/test_suspicious_source.py41
13 files changed, 1252 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,
+ )
diff --git a/scripts/devscripts/logger.py b/scripts/devscripts/logger.py
new file mode 100644
index 0000000..f99de37
--- /dev/null
+++ b/scripts/devscripts/logger.py
@@ -0,0 +1,75 @@
+# logger.py - A simple logging helper class
+#
+# 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.
+
+import os
+import sys
+
+
+def escape_arg(arg):
+ """Shell-escape arg, if necessary.
+ Fairly simplistic, doesn't escape anything except whitespace.
+ """
+ if " " not in arg:
+ return arg
+ return '"%s"' % arg.replace("\\", r"\\").replace('"', r"\"")
+
+
+class Logger:
+ script_name = os.path.basename(sys.argv[0])
+ verbose = False
+
+ stdout = sys.stdout
+ stderr = sys.stderr
+
+ @classmethod
+ def _print(cls, format_, message, args=None, stderr=False):
+ if args:
+ message = message % args
+ stream = cls.stderr if stderr else cls.stdout
+ stream.write((format_ + "\n") % (cls.script_name, message))
+
+ @classmethod
+ def command(cls, cmd):
+ if cls.verbose:
+ cls._print("%s: I: %s", " ".join(escape_arg(arg) for arg in cmd))
+
+ @classmethod
+ def debug(cls, message, *args):
+ if cls.verbose:
+ cls._print("%s: D: %s", message, args, stderr=True)
+
+ @classmethod
+ def error(cls, message, *args):
+ cls._print("%s: Error: %s", message, args, stderr=True)
+
+ @classmethod
+ def warn(cls, message, *args):
+ cls._print("%s: Warning: %s", message, args, stderr=True)
+
+ @classmethod
+ def info(cls, message, *args):
+ if cls.verbose:
+ cls._print("%s: I: %s", message, args)
+
+ @classmethod
+ def normal(cls, message, *args):
+ cls._print("%s: %s", message, args)
+
+ @classmethod
+ def set_verbosity(cls, verbose):
+ cls.verbose = verbose
diff --git a/scripts/devscripts/test/__init__.py b/scripts/devscripts/test/__init__.py
new file mode 100644
index 0000000..59d2920
--- /dev/null
+++ b/scripts/devscripts/test/__init__.py
@@ -0,0 +1,67 @@
+# Copyright (C) 2017-2021, Benjamin Drung <benjamin.drung@ionos.com>
+#
+# 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.
+
+"""Helper functions for testing."""
+
+import inspect
+import os
+import unittest
+
+SCRIPTS = [
+ "debbisect",
+ "debdiff-apply",
+ "debootsnap",
+ "deb-janitor",
+ "reproducible-check",
+ "sadt",
+ "suspicious-source",
+ "wrap-and-sort",
+]
+
+
+def get_source_files() -> list[str]:
+ """Return a list of sources files/directories (to check with flake8/pylint)."""
+ modules = ["devscripts"]
+ py_files = ["setup.py"]
+
+ files = []
+ for code_file in SCRIPTS + modules + py_files:
+ is_script = code_file in SCRIPTS
+ if not os.path.exists(code_file): # pragma: no cover
+ # The alternative path is needed for Debian's pybuild
+ alternative = os.path.join(os.environ.get("OLDPWD", ""), code_file)
+ code_file = alternative if os.path.exists(alternative) else code_file
+ if is_script:
+ with open(code_file, "rb") as script_file:
+ shebang = script_file.readline().decode("utf-8")
+ if "python" in shebang:
+ files.append(code_file)
+ else:
+ files.append(code_file)
+ return files
+
+
+def unittest_verbosity() -> int:
+ """
+ Return the verbosity setting of the currently running unittest.
+
+ If no test is running, return 0.
+ """
+ frame = inspect.currentframe()
+ while frame:
+ self = frame.f_locals.get("self")
+ if isinstance(self, unittest.TestProgram):
+ return self.verbosity
+ frame = frame.f_back
+ return 0 # pragma: no cover
diff --git a/scripts/devscripts/test/pylint.conf b/scripts/devscripts/test/pylint.conf
new file mode 100644
index 0000000..888884e
--- /dev/null
+++ b/scripts/devscripts/test/pylint.conf
@@ -0,0 +1,67 @@
+[MASTER]
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=apt_pkg
+
+# Pickle collected data for later comparisons.
+persistent=no
+
+
+[MESSAGES CONTROL]
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once).You can also use "--disable=all" to
+# disable everything first and then re-enable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use"--disable=all --enable=classes
+# --disable=W"
+disable=fixme,locally-disabled,missing-docstring
+
+
+[REPORTS]
+
+# Tells whether to display a full report or only the messages
+reports=no
+
+
+[TYPECHECK]
+
+# List of classes names for which member attributes should not be checked
+# (useful for classes with attributes dynamically set).
+ignored-classes=magic
+
+
+[FORMAT]
+
+# Maximum number of characters on a single line.
+max-line-length=88
+
+# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
+# tab).
+indent-string=' '
+
+
+[BASIC]
+
+# Allow variables called e, f, lp
+good-names=i,j,k,ex,Run,_,e,f,lp,fd,fp,ok
+
+
+[SIMILARITIES]
+
+# Imports are removed from the similarity computation
+ignore-imports=yes
+
+# Minimum lines number of a similarity.
+min-similarity-lines=5
+
+
+[DESIGN]
+
+# Maximum number of arguments per function
+max-args=10
diff --git a/scripts/devscripts/test/test_black.py b/scripts/devscripts/test/test_black.py
new file mode 100644
index 0000000..565018f
--- /dev/null
+++ b/scripts/devscripts/test/test_black.py
@@ -0,0 +1,50 @@
+# Copyright (C) 2021, 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.
+
+"""Run black code formatter in check mode."""
+
+import subprocess
+import sys
+import unittest
+
+import black
+
+from . import get_source_files, unittest_verbosity
+
+
+class BlackTestCase(unittest.TestCase):
+ """
+ This unittest class provides a test that runs the black code
+ formatter in check mode on the Python source code. The list of
+ source files is provided by the get_source_files() function.
+ """
+
+ def test_black(self) -> None:
+ """Test: Run black code formatter on Python source code."""
+ if int(black.__version__.split(".", 1)[0]) <= 20:
+ self.skipTest("black >= 21 needed")
+ cmd = ["black", "--check", "--diff"] + get_source_files()
+ if unittest_verbosity() >= 2:
+ sys.stderr.write(f"Running following command:\n{' '.join(cmd)}\n")
+ process = subprocess.run(cmd, capture_output=True, check=False, text=True)
+
+ if process.returncode == 1: # pragma: no cover
+ self.fail(
+ f"black found code that needs reformatting:\n{process.stdout.strip()}"
+ )
+ if process.returncode != 0: # pragma: no cover
+ self.fail(
+ f"black exited with code {process.returncode}:\n"
+ f"{process.stdout.strip()}"
+ )
diff --git a/scripts/devscripts/test/test_control.py b/scripts/devscripts/test/test_control.py
new file mode 100644
index 0000000..cb6aa0b
--- /dev/null
+++ b/scripts/devscripts/test/test_control.py
@@ -0,0 +1,280 @@
+# Copyright (C) 2022, Niels Thykier <niels@thykier.net>
+#
+# 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.
+
+"""test_control.py - Run unit tests for the Control module"""
+import textwrap
+
+try:
+ from debian._deb822_repro.formatter import (
+ COMMA_SEPARATOR_FT,
+ FormatterContentToken,
+ format_field,
+ )
+except ImportError as e:
+ print(e)
+ COMMA_SEPARATOR = object()
+ FormatterContentToken = object()
+
+ def format_field(formatter, field_name, separator_token, token_iter):
+ raise AssertionError("Test should have been skipped!")
+
+
+from devscripts.control import (
+ HAS_FULL_RTS_FORMATTING,
+ HAS_RTS_PARSER,
+ Control,
+ wrap_and_sort_formatter,
+)
+from devscripts.test import unittest
+
+
+def _dedent(text):
+ """Dedent and remove "EOL" line markers
+
+ Removes ¶ which are used as "EOL" markers. The EOL markers helps humans understand
+ that there is trailing whitespace (and it is significant) but also stops "helpful"
+ editors from pruning it away and thereby ruining the text.
+ """
+ return textwrap.dedent(text).replace("¶", "")
+
+
+def _prune_trailing_whitespace(text):
+ return "\n".join(x.rstrip() for x in text.splitlines()) + (
+ "\n" if text.endswith("\n") else ""
+ )
+
+
+class ControlTestCase(unittest.TestCase):
+ @unittest.skipIf(not HAS_RTS_PARSER, "Requires a newer version of python-debian")
+ def test_rts_parsing(self):
+ orig_content = _dedent(
+ """\
+ Source: devscripts ¶
+ Maintainer: Jane Doe <jane.doe@debian.org>¶
+ # Some comment about Build-Depends: ¶
+ Build-Depends: foo, ¶
+ # We need bar (>= 1.2~) because of reason ¶
+ bar (>=1.2~) ¶
+ ¶
+ Package: devscripts ¶
+ Architecture: arm64¶
+ linux-any ¶
+ # Some comment describing why hurd-i386 would work while hurd-amd64 did not¶
+ hurd-i386¶
+ ¶
+ # This should be the "last" package after sorting¶
+ Package: z-pkg¶
+ Architecture: any¶
+ ¶
+ ¶
+ ¶
+ # Random comment here¶
+ ¶
+ ¶
+ ¶
+ # This should be the second one with -kb and the first with -b¶
+ Package: a-pkg¶
+ Architecture: any¶
+ ¶
+ ¶
+ """
+ )
+
+ # "No change" here being just immediately dumping the content again. This will
+ # only prune empty lines (we do not preserve these in wrap-and-sort).
+ no_change_dump_content = _dedent(
+ """\
+ Source: devscripts ¶
+ Maintainer: Jane Doe <jane.doe@debian.org>¶
+ # Some comment about Build-Depends: ¶
+ Build-Depends: foo, ¶
+ # We need bar (>= 1.2~) because of reason ¶
+ bar (>=1.2~) ¶
+ ¶
+ Package: devscripts ¶
+ Architecture: arm64¶
+ linux-any ¶
+ # Some comment describing why hurd-i386 would work while hurd-amd64 did not¶
+ hurd-i386¶
+ ¶
+ # This should be the "last" package after sorting¶
+ Package: z-pkg¶
+ Architecture: any¶
+ ¶
+ # Random comment here¶
+ ¶
+ # This should be the second one with -kb and the first with -b¶
+ Package: a-pkg¶
+ Architecture: any¶
+ """
+ )
+
+ last_paragraph_swap_no_trailing_space = _dedent(
+ """\
+ Source: devscripts¶
+ Maintainer: Jane Doe <jane.doe@debian.org>¶
+ # Some comment about Build-Depends:¶
+ Build-Depends: foo,¶
+ # We need bar (>= 1.2~) because of reason¶
+ bar (>=1.2~)¶
+ ¶
+ Package: devscripts¶
+ Architecture: arm64¶
+ linux-any¶
+ # Some comment describing why hurd-i386 would work while hurd-amd64 did not¶
+ hurd-i386¶
+ ¶
+ # This should be the second one with -kb and the first with -b¶
+ Package: a-pkg¶
+ Architecture: any¶
+ ¶
+ # Random comment here¶
+ ¶
+ # This should be the "last" package after sorting¶
+ Package: z-pkg¶
+ Architecture: any¶
+ """
+ )
+
+ control = Control(
+ "debian/control", fd=orig_content.splitlines(True), use_rts_parser=True
+ )
+ self.assertEqual(control.dump(), no_change_dump_content)
+
+ control.strip_trailing_whitespace_on_save = True
+ stripped_space = _prune_trailing_whitespace(no_change_dump_content)
+ self.assertNotEqual(stripped_space, no_change_dump_content)
+ self.assertEqual(control.dump(), stripped_space)
+
+ control.paragraphs[-2], control.paragraphs[-1] = (
+ control.paragraphs[-1],
+ control.paragraphs[-2],
+ )
+ self.assertEqual(control.dump(), last_paragraph_swap_no_trailing_space)
+
+ @unittest.skipIf(
+ not HAS_FULL_RTS_FORMATTING, "Requires a newer version of python-debian"
+ )
+ def test_rts_formatter(self):
+ # Note that we skip whitespace and separator tokens because:
+ # 1) The underlying formatters ignores them anyway, so they do not affect
+ # the outcome
+ # 2) It makes the test easier to understand
+ tokens_with_comment = [
+ FormatterContentToken.value_token("foo"),
+ FormatterContentToken.comment_token("# some comment about bar\n"),
+ FormatterContentToken.value_token("bar"),
+ ]
+ tokens_without_comment = [
+ FormatterContentToken.value_token("foo"),
+ FormatterContentToken.value_token("bar"),
+ ]
+
+ tokens_very_long_content = [
+ FormatterContentToken.value_token("foo"),
+ FormatterContentToken.value_token("bar"),
+ FormatterContentToken.value_token("some-very-long-token"),
+ FormatterContentToken.value_token("this-should-trigger-a-wrap"),
+ FormatterContentToken.value_token("with line length 20"),
+ FormatterContentToken.value_token(
+ "and (also) show we do not mash up spaces"
+ ),
+ FormatterContentToken.value_token("inside value tokens"),
+ ]
+
+ tokens_starting_comment = [
+ FormatterContentToken.comment_token("# some comment about foo\n"),
+ FormatterContentToken.value_token("foo"),
+ FormatterContentToken.value_token("bar"),
+ ]
+
+ formatter_stl = wrap_and_sort_formatter(
+ 1, # -s
+ immediate_empty_line=True, # -s
+ trailing_separator=True, # -t
+ max_line_length_one_liner=20, # --max-line-length
+ )
+ formatter_sl = wrap_and_sort_formatter(
+ 1, # -s
+ immediate_empty_line=True, # -s
+ trailing_separator=False, # No -t
+ max_line_length_one_liner=20, # --max-line-length
+ )
+ actual = format_field(
+ formatter_stl, "Depends", COMMA_SEPARATOR_FT, tokens_without_comment
+ )
+ # Without comments, format this as one line
+ expected = textwrap.dedent(
+ """\
+ Depends: foo, bar,
+ """
+ )
+ self.assertEqual(actual, expected)
+
+ # With comments, we degenerate into "wrap_always" mode (for simplicity)
+ actual = format_field(
+ formatter_stl, "Depends", COMMA_SEPARATOR_FT, tokens_with_comment
+ )
+ expected = textwrap.dedent(
+ """\
+ Depends:
+ foo,
+ # some comment about bar
+ bar,
+ """
+ )
+ self.assertEqual(actual, expected)
+
+ # Starting with a comment should also work
+ actual = format_field(
+ formatter_stl, "Depends", COMMA_SEPARATOR_FT, tokens_starting_comment
+ )
+ expected = textwrap.dedent(
+ """\
+ Depends:
+ # some comment about foo
+ foo,
+ bar,
+ """
+ )
+ self.assertEqual(actual, expected)
+
+ # Without trailing comma
+ actual = format_field(
+ formatter_sl, "Depends", COMMA_SEPARATOR_FT, tokens_without_comment
+ )
+ expected = textwrap.dedent(
+ """\
+ Depends: foo, bar
+ """
+ )
+ self.assertEqual(actual, expected)
+
+ # Triggering a wrap
+ actual = format_field(
+ formatter_sl, "Depends", COMMA_SEPARATOR_FT, tokens_very_long_content
+ )
+ expected = textwrap.dedent(
+ """\
+ Depends:
+ foo,
+ bar,
+ some-very-long-token,
+ this-should-trigger-a-wrap,
+ with line length 20,
+ and (also) show we do not mash up spaces,
+ inside value tokens
+ """
+ )
+ self.assertEqual(actual, expected)
diff --git a/scripts/devscripts/test/test_debootsnap.py b/scripts/devscripts/test/test_debootsnap.py
new file mode 100644
index 0000000..c1e71fd
--- /dev/null
+++ b/scripts/devscripts/test/test_debootsnap.py
@@ -0,0 +1,56 @@
+# Copyright (C) 2023, 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.
+
+"""Test debootsnap script."""
+
+import contextlib
+import io
+import tempfile
+import unittest
+import unittest.mock
+
+from debootsnap import main, parse_pkgs
+
+
+class TestDebootsnap(unittest.TestCase):
+ """Test debootsnap script."""
+
+ @unittest.mock.patch("shutil.which")
+ def test_missing_tools(self, which_mock) -> None:
+ """Test debootsnap fails cleanly if required binaries are missing."""
+ which_mock.return_value = None
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ with self.assertRaisesRegex(SystemExit, "1"):
+ main(["--packages=pkg1:arch=ver1", "chroot.tar"])
+ self.assertEqual(
+ stderr.getvalue(), "equivs-build is required but not installed\n"
+ )
+ which_mock.assert_called_once_with("equivs-build")
+
+ def test_parse_pkgs_from_file(self) -> None:
+ """Test parse_pkgs() for a given file name."""
+ with tempfile.NamedTemporaryFile(mode="w", prefix="devscripts-") as pkgfile:
+ pkgfile.write("pkg1:arch=ver1\npkg2:arch=ver2\n")
+ pkgfile.flush()
+ pkgs = parse_pkgs(pkgfile.name)
+ self.assertEqual(pkgs, [[("pkg1", "arch", "ver1"), ("pkg2", "arch", "ver2")]])
+
+ def test_parse_pkgs_missing_file(self) -> None:
+ """Test parse_pkgs() for a missing file name."""
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ with self.assertRaisesRegex(SystemExit, "1"):
+ parse_pkgs("/non-existing/pkgfile")
+ self.assertEqual(stderr.getvalue(), "/non-existing/pkgfile does not exist\n")
diff --git a/scripts/devscripts/test/test_flake8.py b/scripts/devscripts/test/test_flake8.py
new file mode 100644
index 0000000..9781a5d
--- /dev/null
+++ b/scripts/devscripts/test/test_flake8.py
@@ -0,0 +1,59 @@
+# Copyright (C) 2017-2018, 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.
+
+"""Run flake8 check."""
+
+import subprocess
+import sys
+import unittest
+
+from . import get_source_files, unittest_verbosity
+
+
+class Flake8TestCase(unittest.TestCase):
+ """
+ This unittest class provides a test that runs the flake8 code
+ checker (which combines pycodestyle and pyflakes) on the Python
+ source code. The list of source files is provided by the
+ get_source_files() function.
+ """
+
+ def test_flake8(self) -> None:
+ """Test: Run flake8 on Python source code."""
+ cmd = [
+ sys.executable,
+ "-m",
+ "flake8",
+ "--ignore=E203,W503",
+ "--max-line-length=88",
+ ] + get_source_files()
+ if unittest_verbosity() >= 2:
+ sys.stderr.write(f"Running following command:\n{' '.join(cmd)}\n")
+ process = subprocess.run(cmd, capture_output=True, check=False, text=True)
+
+ if process.returncode != 0: # pragma: no cover
+ msgs = []
+ if process.stderr:
+ msgs.append(
+ f"flake8 exited with code {process.returncode} and has"
+ f" unexpected output on stderr:\n{process.stderr.rstrip()}"
+ )
+ if process.stdout:
+ msgs.append(f"flake8 found issues:\n{process.stdout.rstrip()}")
+ if not msgs:
+ msgs.append(
+ f"flake8 exited with code {process.returncode} "
+ "and has no output on stdout or stderr."
+ )
+ self.fail("\n".join(msgs))
diff --git a/scripts/devscripts/test/test_help.py b/scripts/devscripts/test/test_help.py
new file mode 100644
index 0000000..39335f8
--- /dev/null
+++ b/scripts/devscripts/test/test_help.py
@@ -0,0 +1,84 @@
+# test_help.py - Ensure scripts can run --help.
+#
+# Copyright (C) 2010, Stefano Rivera <stefanor@ubuntu.com>
+#
+# 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.
+
+import fcntl
+import os
+import select
+import signal
+import subprocess
+import time
+import unittest
+
+from . import SCRIPTS
+
+TIMEOUT = 5
+
+
+def load_tests(loader, tests, pattern): # pylint: disable=unused-argument
+ "Give HelpTestCase a chance to populate before loading its test cases"
+ suite = unittest.TestSuite()
+ HelpTestCase.populate()
+ suite.addTests(loader.loadTestsFromTestCase(HelpTestCase))
+ return suite
+
+
+class HelpTestCase(unittest.TestCase):
+ @classmethod
+ def populate(cls):
+ for script in SCRIPTS:
+ setattr(cls, "test_" + script, cls.make_help_tester(script))
+
+ @classmethod
+ def make_help_tester(cls, script):
+ def tester(self):
+ with subprocess.Popen(
+ ["./" + script, "--help"],
+ close_fds=True,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ ) as process:
+ started = time.time()
+ out = []
+
+ fds = [process.stdout.fileno(), process.stderr.fileno()]
+ for fd in fds:
+ fcntl.fcntl(
+ fd,
+ fcntl.F_SETFL,
+ fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK,
+ )
+
+ while time.time() - started < TIMEOUT:
+ for fd in select.select(fds, [], fds, TIMEOUT)[0]:
+ out.append(os.read(fd, 1024))
+ if process.poll() is not None:
+ break
+
+ if process.poll() is None:
+ os.kill(process.pid, signal.SIGTERM)
+ time.sleep(1)
+ if process.poll() is None:
+ os.kill(process.pid, signal.SIGKILL)
+
+ self.assertEqual(
+ process.poll(),
+ 0,
+ f"{script} failed to return usage within {TIMEOUT} seconds.\n"
+ f"Output:\n{b''.join(out)}",
+ )
+
+ return tester
diff --git a/scripts/devscripts/test/test_isort.py b/scripts/devscripts/test/test_isort.py
new file mode 100644
index 0000000..4190859
--- /dev/null
+++ b/scripts/devscripts/test/test_isort.py
@@ -0,0 +1,42 @@
+# Copyright (C) 2021, 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.
+
+"""Run isort to check if Python import definitions are sorted."""
+
+import subprocess
+import sys
+import unittest
+
+from . import get_source_files, unittest_verbosity
+
+
+class IsortTestCase(unittest.TestCase):
+ """
+ This unittest class provides a test that runs isort to check if
+ Python import definitions are sorted. The list of source files
+ is provided by the get_source_files() function.
+ """
+
+ def test_isort(self) -> None:
+ """Test: Run isort on Python source code."""
+ cmd = ["isort", "--check-only", "--diff"] + get_source_files()
+ if unittest_verbosity() >= 2:
+ sys.stderr.write(f"Running following command:\n{' '.join(cmd)}\n")
+ process = subprocess.run(cmd, capture_output=True, check=False, text=True)
+
+ if process.returncode != 0: # pragma: no cover
+ self.fail(
+ f"isort found unsorted Python import definitions:\n"
+ f"{process.stdout.strip()}"
+ )
diff --git a/scripts/devscripts/test/test_logger.py b/scripts/devscripts/test/test_logger.py
new file mode 100644
index 0000000..e6322ea
--- /dev/null
+++ b/scripts/devscripts/test/test_logger.py
@@ -0,0 +1,57 @@
+# test_logger.py - Test devscripts.logger.Logger.
+#
+# Copyright (C) 2012, Stefano Rivera <stefanor@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.
+
+import io
+import sys
+
+from devscripts.logger import Logger
+from devscripts.test import unittest
+
+
+class LoggerTestCase(unittest.TestCase):
+ def setUp(self):
+ Logger.stdout = io.StringIO()
+ Logger.stderr = io.StringIO()
+ self._script_name = Logger.script_name
+ Logger.script_name = "test"
+ self._verbose = Logger.verbose
+
+ def tearDown(self):
+ Logger.stdout = sys.stdout
+ Logger.stderr = sys.stderr
+ Logger.script_name = self._script_name
+ Logger.verbose = self._verbose
+
+ def test_command(self):
+ # pylint: disable=no-member
+ Logger.command(("ls", "a b"))
+ self.assertEqual(Logger.stdout.getvalue(), "")
+ Logger.set_verbosity(True)
+ Logger.command(("ls", "a b"))
+ self.assertEqual(Logger.stdout.getvalue(), 'test: I: ls "a b"\n')
+ self.assertEqual(Logger.stderr.getvalue(), "")
+
+ def test_no_args(self):
+ # pylint: disable=no-member
+ Logger.normal("hello %s")
+ self.assertEqual(Logger.stdout.getvalue(), "test: hello %s\n")
+ self.assertEqual(Logger.stderr.getvalue(), "")
+
+ def test_args(self):
+ # pylint: disable=no-member
+ Logger.normal("hello %s", "world")
+ self.assertEqual(Logger.stdout.getvalue(), "test: hello world\n")
+ self.assertEqual(Logger.stderr.getvalue(), "")
diff --git a/scripts/devscripts/test/test_pylint.py b/scripts/devscripts/test/test_pylint.py
new file mode 100644
index 0000000..35c0162
--- /dev/null
+++ b/scripts/devscripts/test/test_pylint.py
@@ -0,0 +1,82 @@
+# Copyright (C) 2010, Stefano Rivera <stefanor@debian.org>
+# Copyright (C) 2017-2018, 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.
+
+"""Run pylint."""
+
+import os
+import re
+import subprocess
+import sys
+import unittest
+
+import pylint
+from debian.debian_support import Version
+
+from . import get_source_files, unittest_verbosity
+
+CONFIG = os.path.join(os.path.dirname(__file__), "pylint.conf")
+
+
+def check_pylint_version():
+ return Version(pylint.__version__) >= Version("2.11.1")
+
+
+@unittest.skipIf(not check_pylint_version(), "pylint version not supported")
+class PylintTestCase(unittest.TestCase):
+ """
+ This unittest class provides a test that runs the pylint code check
+ on the Python source code. The list of source files is provided by
+ the get_source_files() function and pylint is purely configured via
+ a config file.
+ """
+
+ def test_pylint(self) -> None:
+ """Test: Run pylint on Python source code."""
+ cmd = ["pylint", "--rcfile=" + CONFIG, "--"] + get_source_files()
+ if unittest_verbosity() >= 2:
+ sys.stderr.write(f"Running following command:\n{' '.join(cmd)}\n")
+ process = subprocess.run(cmd, capture_output=True, check=False, text=True)
+
+ if process.returncode != 0: # pragma: no cover
+ # Strip trailing summary (introduced in pylint 1.7).
+ # This summary might look like:
+ #
+ # ------------------------------------
+ # Your code has been rated at 10.00/10
+ #
+ out = re.sub(
+ "^(-+|Your code has been rated at .*)$",
+ "",
+ process.stdout,
+ flags=re.MULTILINE,
+ ).rstrip()
+
+ # Strip logging of used config file (introduced in pylint 1.8)
+ err = re.sub("^Using config file .*\n", "", process.stderr.rstrip())
+
+ msgs = []
+ if err:
+ msgs.append(
+ f"pylint exited with code {process.returncode} "
+ f"and has unexpected output on stderr:\n{err}"
+ )
+ if out:
+ msgs.append(f"pylint found issues:\n{out}")
+ if not msgs:
+ msgs.append(
+ f"pylint exited with code {process.returncode} "
+ "and has no output on stdout or stderr."
+ )
+ self.fail("\n".join(msgs))
diff --git a/scripts/devscripts/test/test_suspicious_source.py b/scripts/devscripts/test/test_suspicious_source.py
new file mode 100644
index 0000000..bb61156
--- /dev/null
+++ b/scripts/devscripts/test/test_suspicious_source.py
@@ -0,0 +1,41 @@
+# Copyright (C) 2023, 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.
+
+"""Test suspicious-source script."""
+
+import pathlib
+import subprocess
+import tempfile
+import unittest
+
+
+class TestSuspiciousSource(unittest.TestCase):
+ """Test suspicious-source script."""
+
+ @staticmethod
+ def _run_suspicious_source(directory: str) -> str:
+ suspicious_source = subprocess.run(
+ ["./suspicious-source", "-d", directory],
+ check=True,
+ stdout=subprocess.PIPE,
+ text=True,
+ )
+ return suspicious_source.stdout.strip()
+
+ def test_python_sript(self) -> None:
+ """Test not complaining about Python code."""
+ with tempfile.TemporaryDirectory(prefix="devscripts-") as tmpdir:
+ python_file = pathlib.Path(tmpdir) / "example.py"
+ python_file.write_text("#!/usr/bin/python3\nprint('hello world')\n")
+ self.assertEqual(self._run_suspicious_source(tmpdir), "")