diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 15:53:52 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 15:53:52 +0000 |
commit | efe47381c599b07e4c7bbdb2e91e8090a541c887 (patch) | |
tree | 05cf57183f5a23394eca11b00f97a74a5dfdf79d /scripts/devscripts | |
parent | Initial commit. (diff) | |
download | devscripts-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.py | 292 | ||||
-rw-r--r-- | scripts/devscripts/logger.py | 75 | ||||
-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 |
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), "") |