Source code for tests.test_dep_policy

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Tests for the dependency-declaration policy checks."""

from __future__ import annotations

from textwrap import dedent

import pytest

from repomatic.config import LintDepsConfig
from repomatic.deps.dep_policy import count_comment_words, scan_policy

from .conftest import PROJECT_ROOT


[docs] def write_pyproject(tmp_path, body: str): """Write a `pyproject.toml` holding *body* and return its path.""" path = tmp_path / "pyproject.toml" path.write_text(dedent(body), encoding="UTF-8") return path
[docs] def remedies(findings) -> list[str]: """The remedy of each finding, for a terse assertion.""" return [finding.remedy for finding in findings]
[docs] def test_clean_declaration_reports_nothing(tmp_path): """A list that follows every rule produces no finding.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # boltons 25.0 dropped Python 3.9, matching our requires-python. "boltons>=25", # wcmatch 10.0 changed globbing semantics. "wcmatch>=10", ] """, ) assert scan_policy(path) == []
[docs] def test_upper_bound_on_a_runtime_dependency(tmp_path): """A cap propagates to everyone installing this project.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # Pinned during the melon migration. "requests<3", ] """, ) (finding,) = scan_policy(path) assert finding.package == "requests" assert "Drop the upper bound" in finding.remedy
[docs] @pytest.mark.parametrize("specifier", ("<3", "<=2.9", "==2.31", "~=2.31", "!=2.30")) def test_every_capping_operator_is_caught(tmp_path, specifier): """`~=` counts: it implies a ceiling even without a `<` in it.""" path = write_pyproject( tmp_path, f"""\ [project] name = "orchard" dependencies = [ # Comment, so only the cap is flagged. "requests{specifier}", ] """, ) assert "Drop the upper bound" in remedies(scan_policy(path))[0]
[docs] def test_upper_bound_is_allowed_outside_runtime(tmp_path): """A dev group may pin harder: nothing downstream inherits it.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" [dependency-groups] # Pinned to keep CI reproducible. test = [ "pytest~=8.0", ] """, ) assert scan_policy(path) == []
[docs] def test_bare_dependency_is_flagged(tmp_path): """No specifier means the tested version and the installed one can differ.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # No floor at all. "requests", ] """, ) assert "Add a `>=` floor" in remedies(scan_policy(path))[0]
[docs] def test_extra_of_an_already_floored_package_is_not_bare(tmp_path): """`click-extra[sphinx]` selects an extra, it does not re-declare a floor. Repeating the floor on the second mention would just be a second number to keep in step with the first. """ path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # Floored once, here. "click-extra>=8.8", ] [dependency-groups] # Pulls the sphinx extra of the runtime dependency above. docs = [ "click-extra[sphinx]", ] """, ) assert scan_policy(path) == []
[docs] def test_aggregate_extra_selecting_the_project_itself_is_not_bare(tmp_path): """An `all` extra rolling up the project's own extras can carry no floor. A project never declares itself with a specifier in its own file, so the "floored somewhere else" gate can never catch it, and the resolver picks the version being installed: there is no other release to exclude. """ path = write_pyproject( tmp_path, """\ [project] name = "orchard" optional-dependencies.toml = [ # tomlkit.dumps() renders the harvest tables. "tomlkit>=0.13", ] optional-dependencies.xml = [ # xmltodict.unparse() renders the harvest tables. "xmltodict>=1", ] optional-dependencies.all = [ "orchard[toml,xml]", ] """, ) assert scan_policy(path) == []
[docs] def test_unsorted_list_names_only_the_first_offender(tmp_path): """One misplaced entry makes every later one look wrong too.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" # One block documenting the whole list. dependencies = [ "boltons>=25", "zzz-late>=1", "apple>=2", "banana>=3", ] """, ) (finding,) = scan_policy(path) assert finding.package == "apple" assert "Sort the list" in finding.remedy
[docs] def test_type_stub_outside_the_typing_group(tmp_path): """A stub reaching a runtime environment is pure weight.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" [dependency-groups] # Documented as a block. test = [ "pytest>=8", "types-boltons>=25.0", ] """, ) (finding,) = scan_policy(path) assert finding.package == "types-boltons" assert "typing" in finding.remedy
[docs] def test_type_stub_inside_the_typing_group_is_fine(tmp_path): """Where they belong, stubs draw no finding.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" [dependency-groups] # Documented as a block. typing = [ "types-boltons>=25.0", ] """, ) assert scan_policy(path) == []
[docs] def test_uncommented_floor_is_flagged(tmp_path): """A floor nobody justified cannot be audited later.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ "boltons>=25", ] """, ) assert "Add a comment above it" in remedies(scan_policy(path))[0]
[docs] def test_a_comment_above_the_array_documents_the_whole_group(tmp_path): """A run of related entries is usually justified in one block.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" [dependency-groups] # types-boltons and types-pyyaml cover the stubs mypy needs. typing = [ "types-boltons>=25.0", "types-pyyaml>=6.0", ] """, ) assert scan_policy(path) == []
[docs] def test_inline_array_skips_the_comment_check(tmp_path): """An inline list has no per-entry line to hang a comment on.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = ["boltons>=25"] """, ) assert scan_policy(path) == []
[docs] def test_missing_file_reports_nothing(tmp_path): """A non-Python repo has nothing to check, and must not raise.""" assert scan_policy(tmp_path / "pyproject.toml") == []
[docs] def test_malformed_requirement_is_skipped(tmp_path): """An unparsable entry is somebody else's error to report.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # Not a PEP 508 requirement. "=== nonsense ===", ] """, ) assert scan_policy(path) == []
[docs] @pytest.mark.parametrize( ("comment", "words"), ( (["A floor comment."], 3), (["Split over", "two lines."], 4), # A URL is one word, and inline code counts as written. (["See https://example.com/a/b and `humanize()`."], 4), ([], 0), ), ) def test_comment_word_count_ignores_markers(comment, words): """The `#` is stripped before counting; everything else counts.""" assert count_comment_words(comment) == words
[docs] def test_over_long_floor_comment_is_flagged(tmp_path): """A comment that narrates every past floor buries the current one.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # papaya 3.0 ships the peel() the harvest report calls. Earlier floors # remain in play: 2.4 shipped slice(), which the crate packer used # before the rewrite; 2.0 renamed the ripeness scale the orchard map # reads; 1.7 fixed the stone counter that the yield estimate consumed # until the seed table replaced it. "papaya>=3", ] """, ) (finding,) = scan_policy(path, comment_word_threshold=40) assert finding.package == "papaya" assert "documented in 50 words" in finding.detail assert "Superseded floors" in finding.remedy
[docs] def test_a_short_floor_comment_passes(tmp_path): """The rule caps the narration, not the justification.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # papaya 3.0 ships the peel() the harvest report calls. "papaya>=3", ] """, ) assert scan_policy(path, comment_word_threshold=40) == []
[docs] def test_comment_length_is_unchecked_by_default(tmp_path): """A caller with no configuration to read gets the presence check alone.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # papaya 3.0 ships the peel() the harvest report calls, and this # comment runs on well past any threshold a project would set, for # the sake of counting more words than a reader would ever want to # read about one single dependency floor in one single place. "papaya>=3", ] """, ) assert scan_policy(path) == []
[docs] def test_a_blank_line_detaches_the_comment_above(tmp_path): """An unattached comment leaves the floor undocumented.""" path = write_pyproject( tmp_path, """\ [project] name = "orchard" dependencies = [ # papaya 3.0 ships the peel() the harvest report calls. "papaya>=3", ] """, ) assert "Add a comment above it" in remedies(scan_policy(path))[0]
[docs] def test_a_block_comment_excuses_absence_but_not_length(tmp_path): """A preamble above the array would otherwise silence every entry. The escape hatch exists so a run of related entries can be justified in one block. Reading it as a blanket pass makes any project whose array carries a policy preamble unscannable, which is how `meta-package-manager` accumulated a 676-word floor comment. """ path = write_pyproject( tmp_path, """\ [project] name = "orchard" # Every floor below is documented, and none is capped from above. dependencies = [ "mango>=2", # papaya 3.0 ships the peel() the harvest report calls. Earlier floors # remain in play: 2.4 shipped slice(), which the crate packer used # before the rewrite; 2.0 renamed the ripeness scale the orchard map # reads; 1.7 fixed the stone counter that the yield estimate consumed # until the seed table replaced it. "papaya>=3", ] """, ) (finding,) = scan_policy(path, comment_word_threshold=40) assert finding.package == "papaya" assert "documented in 50 words" in finding.detail
[docs] def test_a_wall_moved_above_the_array_is_still_a_wall(tmp_path): """Moving the comment up one line must not buy an exemption. Reported once, against the first entry leaning on the block, rather than once per entry: it is one comment to rewrite. """ path = write_pyproject( tmp_path, """\ [project] name = "orchard" # papaya 3.0 ships the peel() the harvest report calls. Earlier floors # remain in play: 2.4 shipped slice(), which the crate packer used # before the rewrite; 2.0 renamed the ripeness scale the orchard map # reads; 1.7 fixed the stone counter that the yield estimate consumed # until the seed table replaced it. dependencies = [ "mango>=2", "papaya>=3", ] """, ) (finding,) = scan_policy(path, comment_word_threshold=40) assert finding.package == "mango" assert "50-word block above the array" in finding.detail
[docs] def test_an_array_preamble_documenting_no_floor_is_not_measured(tmp_path): """A version-policy header justifies nothing, so length is not its measure. Every entry below carries its own comment, so the block is a preamble. Flagging it would report a rule about floor comments against a comment that documents no floor. """ path = write_pyproject( tmp_path, """\ [project] name = "orchard" # Floors use `>=`, never `~=`, so a packager can ship a security # hotfix without waiting on this project. Every floor below names the # API it needs, and none of them caps a version from above, since a # cap here propagates to everyone installing this orchard. dependencies = [ # mango 2.0 ships the ripen() the harvest report calls. "mango>=2", # papaya 3.0 ships the peel() the crate packer calls. "papaya>=3", ] """, ) assert scan_policy(path, comment_word_threshold=40) == []
[docs] @pytest.mark.repo_maintenance def test_this_repository_follows_its_own_policy(): """The canonical reference has to pass the rules it ships. Downstream repos mirror this file's conventions, so a finding here is either real drift or a rule too strict to be worth enforcing. Run at the configured default rather than at the disabled-by-default `0`, since the threshold is exactly the rule most likely to drift back. """ findings = scan_policy( PROJECT_ROOT / "pyproject.toml", LintDepsConfig.comment_word_threshold, ) assert findings == [], "\n".join(finding.message for finding in findings)