diff --git a/.github/workflows/pythontest.yml b/.github/workflows/pythontest.yml index 5e6b9a241e..64f80820a5 100644 --- a/.github/workflows/pythontest.yml +++ b/.github/workflows/pythontest.yml @@ -77,6 +77,12 @@ jobs: run: | pip install --upgrade coverage python -m coverage run -p -m unittest tests.test_suite + - name: Property-based tests + run: | + pip install --upgrade ".[hypothesis]" + python -m unittest property_tests.test_config + env: + HYPOTHESIS_PROFILE: ci - name: Run contrib tests run: | python -m unittest contrib.test_suite diff --git a/.gitignore b/.gitignore index 5a89d2946a..1d34840826 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ dulwich.egg-info/ htmlcov/ docs/api/*.txt .mypy_cache/ +.hypothesis/ .eggs dulwich.dist-info .stestr diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 2272b2e3f0..6c0319ce7d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -122,14 +122,27 @@ This will run the tests using unittest. .. code:: console - $ python -m unittest dulwich.tests.test_suite + $ python -m unittest tests.test_suite The compatibility tests that verify Dulwich behaves in a way that is compatible with C Git are the slowest, so you may want to avoid them while developing: .. code:: console - $ python -m unittest dulwich.tests.nocompat_test_suite + $ python -m unittest tests.nocompat_test_suite + +Property-based tests +~~~~~~~~~~~~~~~~~~~~ + +Property-based tests are present under ``property_tests/`` and use Hypothesis +to check general properties of Dulwich's parsers and serializers. They are +separate from the regular ``unittest`` suite so that contributors only run +them explicitly. Their default Hypothesis profile is deterministic. + +.. code:: console + + $ pip install -e ".[hypothesis]" + $ python -m unittest property_tests.test_config testr and tox configuration is also present. diff --git a/property_tests/__init__.py b/property_tests/__init__.py new file mode 100644 index 0000000000..c72e6798ae --- /dev/null +++ b/property_tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later + +"""Property-based tests for Dulwich.""" diff --git a/property_tests/test_config.py b/property_tests/test_config.py new file mode 100644 index 0000000000..66f863742c --- /dev/null +++ b/property_tests/test_config.py @@ -0,0 +1,153 @@ +# test_config.py -- property tests for config.py +# Copyright (C) 2026 The Dulwich contributors +# +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later +# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU +# General Public License as published by the Free Software Foundation; version 2.0 +# or (at your option) any later version. You can redistribute it and/or +# modify it under the terms of either of these two licenses. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# You should have received a copy of the licenses; if not, see +# for a copy of the GNU General Public License +# and for a copy of the Apache +# License, Version 2.0. +# + +"""Property tests for reading and writing configuration files.""" + +import os +from io import BytesIO +from unittest import SkipTest + +from dulwich.config import ConfigFile +from tests import TestCase + +try: + from hypothesis import given, settings + from hypothesis import strategies as st +except ImportError: + HYPOTHESIS_AVAILABLE = False +else: + HYPOTHESIS_AVAILABLE = True + + +EXPECTED_PARSE_ERRORS = ( + "without section", + "invalid variable name", + "expected trailing ]", + "invalid section name", + "Invalid subsection", + "escape character", + "missing end quote", +) + + +if HYPOTHESIS_AVAILABLE: + settings.register_profile( + "deterministic", max_examples=50, deadline=None, derandomize=True + ) + settings.register_profile("ci", max_examples=50, deadline=None, derandomize=True) + settings.register_profile( + "local-deep", max_examples=1000, deadline=None, derandomize=True + ) + settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "deterministic")) + + section_names = st.text( + alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-", + min_size=1, + max_size=12, + ).map(lambda value: value.encode("ascii")) + + subsection_names = st.text( + alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._/", + min_size=1, + max_size=16, + ).map(lambda value: value.encode("ascii")) + + variable_names = st.text( + alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-", + min_size=1, + max_size=12, + ).map(lambda value: value.encode("ascii")) + + values = ( + st.text( + alphabet=( + "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789" + ' -_./:#\t\n\\"' + ), + max_size=32, + ) + .map(lambda value: value.encode("ascii")) + .filter(lambda value: not value.endswith((b" ", b"\t"))) + ) + + sections = st.tuples( + section_names, + st.one_of(st.none(), subsection_names), + st.dictionaries(variable_names, values, min_size=1, max_size=6), + ) + + configs = st.lists(sections, min_size=0, max_size=6) + + +def _config_from_sections( + sections: list[tuple[bytes, bytes | None, dict[bytes, bytes]]], +) -> ConfigFile: + config = ConfigFile() + for section_name, subsection_name, variables in sections: + section = ( + (section_name,) + if subsection_name is None + else (section_name, subsection_name) + ) + for key, value in variables.items(): + config.set(section, key, value) + return config + + +class ConfigFilePropertyTests(TestCase): + """Property tests for ConfigFile.""" + + if not HYPOTHESIS_AVAILABLE: + + def test_hypothesis_available(self) -> None: + """Skip these tests when Hypothesis is unavailable.""" + raise SkipTest("hypothesis is not available") + + else: + + @given(st.binary(max_size=512)) + def test_binary_input_only_raises_expected_parse_errors( + self, data: bytes + ) -> None: + """Check that arbitrary bytes only raise expected parse errors.""" + try: + ConfigFile.from_file(BytesIO(data)) + except ValueError as exc: + self.assertTrue( + any(message in str(exc) for message in EXPECTED_PARSE_ERRORS), + str(exc), + ) + + @given(configs) + def test_write_roundtrip_preserves_effective_config( + self, + generated_sections: list[tuple[bytes, bytes | None, dict[bytes, bytes]]], + ) -> None: + """Check that writing and reading preserves generated configs.""" + config = _config_from_sections(generated_sections) + + output = BytesIO() + config.write_to_file(output) + + reparsed = ConfigFile.from_file(BytesIO(output.getvalue())) + self.assertEqual(config, reparsed) diff --git a/pyproject.toml b/pyproject.toml index a2c929ec6a..07baeab665 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "dissolve>=0.1.1", "codespell==2.4.2", ] +hypothesis = ["hypothesis>=6"] merge = ["merge3"] fuzzing = ["atheris"] patiencediff = ["patiencediff"]