summaryrefslogtreecommitdiffstats
path: root/scripts/devscripts/test
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/test
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/test')
-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
11 files changed, 885 insertions, 0 deletions
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), "")