diff options
Diffstat (limited to 'scripts/devscripts/test')
-rw-r--r-- | scripts/devscripts/test/__init__.py | 67 | ||||
-rw-r--r-- | scripts/devscripts/test/pylint.conf | 67 | ||||
-rw-r--r-- | scripts/devscripts/test/test_black.py | 50 | ||||
-rw-r--r-- | scripts/devscripts/test/test_control.py | 280 | ||||
-rw-r--r-- | scripts/devscripts/test/test_debootsnap.py | 56 | ||||
-rw-r--r-- | scripts/devscripts/test/test_flake8.py | 59 | ||||
-rw-r--r-- | scripts/devscripts/test/test_help.py | 84 | ||||
-rw-r--r-- | scripts/devscripts/test/test_isort.py | 42 | ||||
-rw-r--r-- | scripts/devscripts/test/test_logger.py | 57 | ||||
-rw-r--r-- | scripts/devscripts/test/test_pylint.py | 82 | ||||
-rw-r--r-- | scripts/devscripts/test/test_suspicious_source.py | 41 |
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), "") |