Skip to content

Commit

Permalink
Revise test_command handling
Browse files Browse the repository at this point in the history
* Require use of new +placeholder+ syntax for figuring out which commands
  need to be replaced with macros/versioned aliases/full paths
* Clean up multi-statement vs single command handing for Alpine, Arch and
  Fedora
* Prevent rpmbuild's parser freaking out if test_command contains % or \n\n
* Replace indentation with the distribution's preferred width and choice of
  tabs vs spaces
  • Loading branch information
bwoodsend committed Dec 2, 2024
1 parent b6ae751 commit c6c37c5
Show file tree
Hide file tree
Showing 15 changed files with 141 additions and 54 deletions.
22 changes: 14 additions & 8 deletions docs/source/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -168,18 +168,24 @@ maintainer: Your Name <[email protected]>
# non-ambiguous, `polycotylus` will default to the ``project.maintainer``
# field in the ``pyproject.toml``.

test_command: pytest -k 'not unrunable_test'
# The verification command, defaulting to ``pytest``. This command will be
test_command: +pytest+ -k 'not unrunable_test'
# A verification command, defaulting to ``+pytest+``. This command will be
# invoked using ``sh`` from the root of a copy of your project but note that
# only the files listed in `test_files` will be available when `polycotylus`
# runs its end to end test. If you want to invoke Python, use ``python`` and
# not ``python3`` or ``/usr/bin/python`` so that `polycotylus` can replace
# ``python`` with whatever s̶i̶l̶l̶y̶ ̶m̶a̶c̶r̶o̶ *abstraction* each RPM based
# distribution insists on using. Note that ``python -m unittest`` is supported
# but strongly discouraged in favour of `pytest's unittest support`_ due to
# its rather dangerous behaviour of not failing if no tests are found.
# runs its end to end test. To accommodate distributions that like to
# perpetuate `xkcd 1987`_, annotate commands coming from Python environments
# by wrapping then in plus signs. i.e. Use ``+python+`` instead
# ``python``/``python3`` or ``+pytest+`` instead of ``pytest`` so that
# `polycotylus` can substitute them for whatever s̶i̶l̶l̶y̶ ̶m̶a̶c̶r̶o̶ *"abstraction"*
# each RPM based distribution uses. Note that ``+python+ -m unittest`` is
# supported but strongly discouraged in favour of `pytest's unittest support`_
# due to its rather dangerous behaviour of not failing if no tests are found.
# Testing can be disabled by setting `test_command` to an empty string
# although, in the absence of a proper test suite, even a dumb ``+python+ -c
# 'import my_package'`` is a lot better than nothing.
#
# .. _`pytest's unittest support`: https://docs.pytest.org/en/7.3.x/how-to/unittest.html
# .. _`xkcd 1987`: https://xkcd.com/1987/

test_files:
- tests
Expand Down
2 changes: 1 addition & 1 deletion examples/bare-minimum/polycotylus.yaml
Original file line number Diff line number Diff line change
@@ -1 +1 @@
test_command: python -m unittest discover tests 2>&1 | grep 'Ran 1 test'
test_command: +python+ -m unittest discover tests 2>&1 | grep 'Ran 1 test'
19 changes: 14 additions & 5 deletions polycotylus/_alpine.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,26 @@ def apkbuild(self):
out += self.install_icons(1, "$builddir")
out += "}\n\n"

_set_pythonpath = 'PYTHONPATH="$builddir/usr/lib/python$(_py3ver)/site-packages"'
test_command = self.project.test_command.evaluate()
out += self._formatter(f"""
check() {{
cd "$srcdir/{top_level}"
""")
if self.project.test_command.multistatement:
out += self._formatter(f"export {_set_pythonpath}", 1)
out += self._formatter(test_command, 1)
else:
out += self._formatter(f"{_set_pythonpath} {test_command.strip()}", 1)

out += self._formatter("""
check() {
cd "$srcdir/%s"
PYTHONPATH="$builddir/usr/lib/python$(_py3ver)/site-packages" %s
}
package() {
mkdir -p "$(dirname "$pkgdir")"
cp -r "$builddir" "$pkgdir"
}
""" % (top_level, self.project.test_command))
""")
out += "\n"
out += self._formatter("""
sha512sums="
Expand Down Expand Up @@ -280,7 +289,7 @@ def test(self, package):
with self.mirror:
return _docker.run(base, f"""
sudo apk add /pkg/{package.path.name}
{self.project.test_command}
{self.project.test_command.evaluate()}
""", volumes=volumes, tty=True, root=False, post_mortem=True,
architecture=self.docker_architecture)

Expand Down
15 changes: 11 additions & 4 deletions polycotylus/_arch.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,17 @@ def pkgbuild(self):
out += self._formatter(f"""
check() {{
cd "{top_level}"
PYTHONPATH="$(echo _build/usr/lib/python*/site-packages/)"
PYTHONPATH="$PYTHONPATH" {self.project.test_command}
}}
local _site_packages="$(python -c "import site; print(site.getsitepackages()[0])")"
""")
test_command = self.project.test_command.evaluate()
if self.project.test_command.multistatement:
out += self._formatter("""export PYTHONPATH="$PWD/_build/$_site_packages" """, 1)
out += self._formatter(test_command, 1)
else:
out += self._formatter(f"""
PYTHONPATH="$PWD/_build/$_site_packages" {test_command.strip()}
""", 1)
out += self._formatter("}")
return out

patch_gpg_locale = ""
Expand Down Expand Up @@ -200,7 +207,7 @@ def test(self, package):
return _docker.run(base, f"""
sudo pacman -Syu --noconfirm
sudo pacman -U --noconfirm /pkg/{package.path.name}
{self.project.test_command}
{self.project.test_command.evaluate()}
""", volumes=volumes, tty=True, root=False, post_mortem=True,
architecture=self.docker_architecture)

Expand Down
7 changes: 5 additions & 2 deletions polycotylus/_debian.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,13 @@ def generate(self):

test_script = debian_root / "tests/test"
test_script.parent.mkdir(parents=True, exist_ok=True)
test_command = self.project.test_command.evaluate(
lambda x: "python3" if x == "python" else x)
_misc.unix_write(test_script, self._formatter(f"""
#!/usr/bin/env sh
set -e
export PYTHONPATH=".pybuild/cpython3_$(python3 -c 'import sys; print("{{0}}.{{1}}".format(*sys.version_info))')_{self.project.name}/build/"
""") + re.sub(r"\bpython\b", "python3", self.project.test_command))
""") + test_command)
test_script.chmod(0o700)
(debian_root / "source").mkdir(exist_ok=True)
_misc.unix_write(debian_root / "source" / "format", "3.0 (quilt)\n")
Expand All @@ -269,7 +271,8 @@ def build(self):
return packages

def test(self, package):
test_command = re.sub(r"\bpython\b", "python3", self.project.test_command)
test_command = self.project.test_command.evaluate(
lambda x: "python3" if x == "python" else x)
with self.mirror:
return _docker.run(self.build_builder_image(), f"""
sudo apt-get update
Expand Down
28 changes: 11 additions & 17 deletions polycotylus/_fedora.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from packaging.requirements import Requirement

from polycotylus import _misc, _docker, _exceptions
from polycotylus._project import TestCommandLexer
from polycotylus._mirror import cache_root
from polycotylus._base import BaseDistribution, _deduplicate, GPGBased

Expand Down Expand Up @@ -172,22 +173,14 @@ def spec(self):
""".format(self.project.source_top_level.format(version="%{version}")))

out += "\n\n%check\n"
if self.project.test_command != "pytest":
parts = []
for part in self.project.test_command.split(" "):
if part in ("python", "python3"):
parts.append("%{python3}")
elif part == "pytest":
parts.append("%{python3} -m pytest")
elif part == "xvfb-run":
parts.append("/usr/bin/xvfb-run")
else:
parts.append(part)
out += f"%global __pytest {' '.join(parts)}\n"
out += self._formatter(f"""
%pytest
escaped_template = self.project.test_command.template.replace("%", "%%")
test_command = TestCommandLexer(escaped_template).evaluate(lambda x: {
"python": "%{python3}",
"pytest": "%pytest",
}.get(x, "/usr/bin/" + x))
test_command = re.sub("([\t ]*\n)+", "\n", test_command)
out += self._formatter(test_command)
out += "\n\n" + self._formatter(f"""
%files -n {self.package_name} -f %{{pyproject_files}}
""")
licenses = shlex.join(self.project.licenses).replace("'", '"')
Expand Down Expand Up @@ -323,7 +316,8 @@ def test(self, rpm):
test_dependencies = []
for package in self.project.test_dependencies["pip"]:
test_dependencies.append(self.python_package(package))
test_command = re.sub(r"\bpython\b", "python3", self.project.test_command)
test_command = self.project.test_command.evaluate(
lambda x: "python3" if x == "python" else x)
with self.mirror:
return _docker.run(self.build_test_image(), f"""
sudo dnf install -y /pkg/{rpm.path.name}
Expand Down
6 changes: 4 additions & 2 deletions polycotylus/_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ def __init__(self, indentation=" "):

def __call__(self, text, level=0):
text = textwrap.dedent(text).strip()
text = re.sub("^ +", lambda m: len(m[0]) // 4 * self.indentation, text, flags=re.M)
return textwrap.indent(text, self.indentation * level) + "\n"
levels = sorted({i for i in re.findall("^[\t ]*", text, flags=re.M)})
map = dict(zip(levels, (self.indentation * (i + level) for i in range(len(levels)))))
text = re.sub("^[\t ]*", lambda m: map[m[0]], text, flags=re.M)
return text + "\n"


class classproperty:
Expand Down
36 changes: 33 additions & 3 deletions polycotylus/_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@
from polycotylus import _exceptions, _yaml_schema, _misc


class TestCommandLexer:
_pattern = re.compile(r"\+([^+\n]*)\+")

def __init__(self, source):
self.template = source

@property
def placeholders(self):
return [m[1] for m in self._pattern.finditer(self.template) if m[1]]

def evaluate(self, replace=lambda x: x):
return self._pattern.sub(lambda m: replace(m[1]) if m[1] else "+", self.template)

@property
def multistatement(self):
return re.search("[\n;&|]", self.template.strip()) is not None


@dataclass
class Project:
root: Path
Expand All @@ -31,7 +49,7 @@ class Project:
build_dependencies: dict
test_dependencies: dict
dependency_name_map: dict
test_command: str
test_command: TestCommandLexer
test_files: list
license_names: list
licenses: list
Expand Down Expand Up @@ -268,9 +286,21 @@ def from_root(cls, root):
""")
_yaml_schema.revalidation_error(
polycotylus_yaml["test_command"], message)
test_command = polycotylus_options.get("test_command", "xvfb-run pytest")
test_command = polycotylus_options.get("test_command", "xvfb-run +pytest+")
else:
test_command = polycotylus_options.get("test_command", "pytest")
test_command = polycotylus_options.get("test_command", "+pytest+")
test_command = TestCommandLexer(test_command)
if test_command.template.strip() and not test_command.placeholders:
_yaml_schema.revalidation_error(
polycotylus_yaml["test_command"], _exceptions._unravel(f"""
The {key("test_command")} contains no Python command
placeholders. Polycotylus requires executables from Python
environments to be marked as such by wrapping them in plus
signs. E.g. replace {string("python")} with
{string("+python+")} or {string("pytest")} with
{string("+pytest+")}. Wrapper scripts or tools like tox can
not be used
"""))

if "architecture" in polycotylus_options:
architecture = polycotylus_options["architecture"]
Expand Down
2 changes: 1 addition & 1 deletion polycotylus/_void.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ def test(self, package):
with self.mirror:
container = _docker.run(base, f"""
{"yes | " if self.private_key else ""} sudo xbps-install -ySu -R /pkg/ xbps {self.package_name}
{self.project.test_command}
{self.project.test_command.evaluate()}
""", volumes=volumes, tty=True, root=False, post_mortem=True,
architecture=self.docker_architecture)
if self.private_key:
Expand Down
1 change: 1 addition & 0 deletions tests/error-messages/no-test-command-placeholder
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The test_command contains no Python command placeholders. Polycotylus requires executables from Python environments to be marked as such by wrapping them in plus signs. E.g. replace python with +python+ or pytest with +pytest+. Wrapper scripts or tools like tox can not be used.
12 changes: 10 additions & 2 deletions tests/mock-packages/kitchen-sink/polycotylus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,21 @@ dependencies:
test:
arch manjaro fedora void alpine opensuse debian ubuntu: nano
python: sqlite3 tkinter
pip: pytest[feet]
pip: pytest[feet] pyflakes

dependency_name_map:
tzlocal:
arch manjaro: gnu-netcat

test_command: TEST_VARIABLE=helló pytest -k 'not unrunable' && python -c 'print("hello 🦄")'
test_command: |
TEST_VARIABLE=helló +pytest+ -k 'not unrunable' && +python+ -c 'print("hello 🦄")'
+python+ -c 'assert "python" == "++%python%++"[2:-2] == bytes([112, 121, 116, 104, 111, 110]).decode()'
echo '
indented
'
+pyflakes+ --version
test_files:
- the_test_suíte
Expand Down
2 changes: 1 addition & 1 deletion tests/mock-packages/poetry-based/polycotylus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ dependencies:
pip: pytest

test_command: |
pytest
+pytest+
stat polycotylus.yaml || print_hello
22 changes: 22 additions & 0 deletions tests/test_alpine.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ def test_abuild_lint():
""", volumes=[(self.distro_root, "/io")], architecture=self.docker_architecture)


def test_test_command(polycotylus_yaml):
dependencies = "dependencies:\n test:\n pip: pytest\n"

polycotylus_yaml(dependencies)
apkbuild = Alpine(Project.from_root(shared.bare_minimum)).apkbuild()
assert re.search("\tPYTHONPATH=.*pytest", apkbuild)

polycotylus_yaml(dependencies + "test_command: |\n +foo+\n bar\n")
apkbuild = Alpine(Project.from_root(shared.bare_minimum)).apkbuild()
assert "\texport PYTHONPATH" in apkbuild
assert "\tfoo\n\t\tbar\n" in apkbuild

polycotylus_yaml(dependencies + "test_command: +foo+ && bar")
apkbuild = Alpine(Project.from_root(shared.bare_minimum)).apkbuild()
assert "\texport PYTHONPATH" in apkbuild
assert "\tfoo && bar\n" in apkbuild

polycotylus_yaml(dependencies + "test_command:\n")
apkbuild = Alpine(Project.from_root(shared.bare_minimum)).apkbuild()
assert "\tpytest" not in apkbuild


def test_dumb_text_viewer():
extraneous_desktop_file = shared.dumb_text_viewer / ".polycotylus" / "delete-me.desktop"
extraneous_desktop_file.write_bytes(b"")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_arch.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def test_post_mortem(polycotylus_yaml):
script = textwrap.dedent("""
import polycotylus.__main__
polycotylus._yaml_schema._read_text = lambda x: \"""
test_command: cat polycotylus.yaml
test_command: %python% -c 'import os; os.path.stat("polycotylus.yaml")'
dependencies:
test:
pip: pytest
Expand Down
19 changes: 12 additions & 7 deletions tests/test_fedora.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,23 +163,28 @@ def test_test_command(polycotylus_yaml):

polycotylus_yaml(dependencies)
spec = Fedora(Project.from_root(shared.bare_minimum)).spec()
assert "%global __pytest" not in spec
assert "%check\n%pytest\n\n" in spec
spec = Fedora(Project.from_root(shared.dumb_text_viewer)).spec()
assert "%global __pytest /usr/bin/xvfb-run %{python3} -m pytest" in spec
assert "%check\nxvfb-run %pytest\n\n" in spec

polycotylus_yaml(dependencies + "test_command: python3 -c 'print(10)'")
polycotylus_yaml(dependencies + "test_command: +python+ -c 'print(10)'")
spec = Fedora(Project.from_root(shared.bare_minimum)).spec()
assert "%global __pytest %{python3} -c 'print(10)'" in spec
assert "%check\n%{python3} -c 'print(10)'\n\n" in spec
with pytest.raises(_exceptions.PolycotylusUsageError, match="implicit"):
spec = Fedora(Project.from_root(shared.dumb_text_viewer)).spec()

polycotylus_yaml(dependencies + "test_command: python3 -c 'print(10)'\ngui: true")
polycotylus_yaml(dependencies + "test_command: +python+ -c 'print(10)'\ngui: true")
with pytest.raises(_exceptions.PolycotylusUsageError, match="specified"):
spec = Fedora(Project.from_root(shared.dumb_text_viewer)).spec()

polycotylus_yaml(dependencies + "test_command: xvfb-run python3 -c 'print(10)'")
polycotylus_yaml(dependencies + "test_command: pytest")
with pytest.raises(_exceptions.PolycotylusUsageError) as capture:
spec = Fedora(Project.from_root(shared.bare_minimum)).spec()
assert str(capture.value).endswith(shared.error_messages["no-test-command-placeholder"])

polycotylus_yaml(dependencies + "test_command: xvfb-run +python+ -c 'print(10)'")
spec = Fedora(Project.from_root(shared.dumb_text_viewer)).spec()
assert "%global __pytest /usr/bin/xvfb-run %{python3} -c 'print(10)'" in spec
assert "%check\nxvfb-run %{python3} -c 'print(10)'\n\n" in spec


def test_cli_signing(monkeypatch, capsys, force_color):
Expand Down

0 comments on commit c6c37c5

Please sign in to comment.