# 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
import tomlrt
from repomatic.config import LintDepsConfig
from repomatic.deps.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 write_lock(tmp_path, requested: list[str], resolved: list[str] | None):
"""Write a `uv.lock` in which orchard selects *requested* extras of papaya.
:param resolved: The extras the papaya entry resolves to a package, or
`None` to leave papaya out of the lock.
"""
packages: list[dict] = [
{
"name": "orchard",
"metadata": {
"requires-dist": [
{"name": "papaya", "extras": requested, "specifier": ">=3"}
]
},
}
]
if resolved is not None:
packages.append({
"name": "papaya",
"optional-dependencies": {extra: [{"name": "soil"}] for extra in resolved},
})
path = tmp_path / "uv.lock"
path.write_text(tomlrt.dumps({"package": packages}), encoding="UTF-8")
return path
[docs]
@pytest.mark.parametrize(
("body", "threshold"),
(
pytest.param(
"""\
[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",
]
""",
None,
id="clean-declaration",
),
# A `[[...]]` header closes the table before it, like a `[...]` one.
#
# A comment scan reading on past it would take an array further down for the
# runtime list, and find it written inline.
pytest.param(
"""\
[project]
name = "orchard"
dependencies = ["requests>=2"]
[[tool.mango.index]]
dependencies = [
"kiwi>=1",
]
""",
None,
id="array-of-tables-ends-table",
),
# A dev group may pin harder: nothing downstream inherits it.
pytest.param(
"""\
[project]
name = "orchard"
[dependency-groups]
# Pinned to keep CI reproducible.
test = [
"pytest~=8.0",
]
""",
None,
id="cap-outside-runtime",
),
# `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.
pytest.param(
"""\
[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]",
]
""",
None,
id="extra-of-floored-package",
),
# 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.
pytest.param(
"""\
[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]",
]
""",
None,
id="aggregate-self-extra",
),
# Where they belong, stubs draw no finding.
pytest.param(
"""\
[project]
name = "orchard"
[dependency-groups]
# Documented as a block.
typing = [
"types-boltons>=25.0",
]
""",
None,
id="stub-in-typing-group",
),
# A run of related entries is usually justified in one block.
pytest.param(
"""\
[project]
name = "orchard"
[dependency-groups]
# types-boltons and types-pyyaml cover the stubs mypy needs.
typing = [
"types-boltons>=25.0",
"types-pyyaml>=6.0",
]
""",
None,
id="array-comment-covers-group",
),
# An inline list has no per-entry line to hang a comment on.
pytest.param(
"""\
[project]
name = "orchard"
dependencies = ["boltons>=25"]
""",
None,
id="inline-array",
),
# An unparsable entry is somebody else's error to report.
pytest.param(
"""\
[project]
name = "orchard"
dependencies = [
# Not a PEP 508 requirement.
"=== nonsense ===",
]
""",
None,
id="malformed-requirement",
),
# The rule caps the narration, not the justification.
pytest.param(
"""\
[project]
name = "orchard"
dependencies = [
# papaya 3.0 ships the peel() the harvest report calls.
"papaya>=3",
]
""",
40,
id="short-floor-comment",
),
# A caller with no configuration to read gets the presence check alone.
pytest.param(
"""\
[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",
]
""",
None,
id="length-unchecked-by-default",
),
# 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.
pytest.param(
"""\
[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",
]
""",
40,
id="policy-preamble",
),
),
)
def test_policy_accepts(tmp_path, body, threshold):
"""A declaration that follows every rule produces no finding."""
path = write_pyproject(tmp_path, body)
findings = (
scan_policy(path)
if threshold is None
else scan_policy(path, comment_word_threshold=threshold)
)
assert findings == []
[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]
def test_a_file_under_another_name_is_scanned(tmp_path):
"""`lint-deps --pyproject` can name any file, and that file is the one read.
A scan reading the `pyproject.toml` beside the named file would report
nothing at all for a file named anything else.
"""
path = tmp_path / "orchard.toml"
path.write_text(
dedent(
"""\
[project]
name = "orchard"
dependencies = [
# Pinned during the melon migration.
"requests<3",
]
"""
),
encoding="UTF-8",
)
(finding,) = scan_policy(path)
assert finding.package == "requests"
assert finding.location == "project.dependencies"
[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]
@pytest.mark.parametrize(
("body", "remedy"),
(
# No specifier means the tested version and the installed one can differ.
pytest.param(
"""\
[project]
name = "orchard"
dependencies = [
# No floor at all.
"requests",
]
""",
"Add a `>=` floor",
id="bare-dependency",
),
# A floor nobody justified cannot be audited later.
pytest.param(
"""\
[project]
name = "orchard"
dependencies = [
"boltons>=25",
]
""",
"Add a comment above it",
id="uncommented-floor",
),
# An unattached comment leaves the floor undocumented.
pytest.param(
"""\
[project]
name = "orchard"
dependencies = [
# papaya 3.0 ships the peel() the harvest report calls.
"papaya>=3",
]
""",
"Add a comment above it",
id="blank-line-detaches-comment",
),
),
)
def test_policy_flags_a_floor_problem(tmp_path, body, remedy):
"""A missing or undocumented floor draws the remedy that fixes it."""
assert remedy in remedies(scan_policy(write_pyproject(tmp_path, body)))[0]
[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
SELECTS_PAPAYA_EXTRAS = """\
[project]
name = "orchard"
dependencies = [
# papaya 3.0 ships the peel() the harvest report calls.
"papaya[juice,seeds]>=3",
]
"""
"""A project selecting two extras of papaya, for the lock to judge."""
[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_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]
@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,
PROJECT_ROOT / "uv.lock",
)
assert findings == [], "\n".join(finding.message for finding in findings)