mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
562 lines
20 KiB
Python
562 lines
20 KiB
Python
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# 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.
|
|
|
|
"""Guard the dependency manifests that Dependabot, CI, and the images consume.
|
|
|
|
Three regressions are pinned here:
|
|
|
|
* ``.github/dependabot.yml`` named ecosystems GitHub does not document
|
|
(``ruby`` and ``python`` instead of ``bundler`` and ``pip``). The file was
|
|
invalid, so Dependabot never ran a version update for any ecosystem.
|
|
* ``web/Gemfile.lock`` was git-ignored, so the web image and CI resolved every
|
|
gem afresh at build time instead of installing a reviewed set.
|
|
* ``data/requirements.txt`` carried only ``>=`` floors plus the dev tools, so
|
|
the ingestor image installed black and pytest and whatever release was
|
|
newest on build day.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import importlib.util
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from collections.abc import Iterable
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
import pytest
|
|
from packaging.requirements import Requirement
|
|
from packaging.utils import canonicalize_name
|
|
from packaging.version import Version
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEPENDABOT = REPO_ROOT / ".github" / "dependabot.yml"
|
|
GEMFILE_LOCK = REPO_ROOT / "web" / "Gemfile.lock"
|
|
WEB_DOCKERFILE = REPO_ROOT / "web" / "Dockerfile"
|
|
RUNTIME_REQUIREMENTS = REPO_ROOT / "data" / "requirements.txt"
|
|
DEV_REQUIREMENTS = REPO_ROOT / "data" / "requirements-dev.txt"
|
|
EDIT_GUARD = REPO_ROOT / ".claude" / "hooks" / "guard-edits.py"
|
|
|
|
# Every ``package-ecosystem`` YAML value in the table of GitHub's Dependabot
|
|
# options reference, as retrieved on 2026-10-04:
|
|
# https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#package-ecosystem-
|
|
# Any other value invalidates the whole file, so add one here only once it
|
|
# appears in that table.
|
|
DOCUMENTED_ECOSYSTEMS = frozenset(
|
|
{
|
|
"bazel",
|
|
"bun",
|
|
"bundler",
|
|
"cargo",
|
|
"composer",
|
|
"conda",
|
|
"deno",
|
|
"devcontainers",
|
|
"docker",
|
|
"docker-compose",
|
|
"dotnet-sdk",
|
|
"elm",
|
|
"github-actions",
|
|
"gitsubmodule",
|
|
"gomod",
|
|
"gradle",
|
|
"helm",
|
|
"julia",
|
|
"maven",
|
|
"mix",
|
|
"nix",
|
|
"npm",
|
|
"nuget",
|
|
"opentofu",
|
|
"pip",
|
|
"pre-commit",
|
|
"pub",
|
|
"rust-toolchain",
|
|
"sbt",
|
|
"swift",
|
|
"terraform",
|
|
"uv",
|
|
"vcpkg",
|
|
}
|
|
)
|
|
|
|
# Language dependency manifests (file-name glob, Dependabot ecosystem) that
|
|
# need an entry for their directory. Lockfiles sit beside their manifests, so
|
|
# the manifests alone decide the directories. Container base images and the
|
|
# Flutter platform build files (Gradle, CocoaPods) are deliberately absent:
|
|
# the image pins are bounded on purpose (ACCEPTANCE DK-A2) and the platform
|
|
# files belong to the Flutter toolchain.
|
|
MANIFEST_ECOSYSTEMS = (
|
|
("Gemfile", "bundler"),
|
|
("requirements*.txt", "pip"),
|
|
("pyproject.toml", "pip"),
|
|
("package.json", "npm"),
|
|
("Cargo.toml", "cargo"),
|
|
("pubspec.yaml", "pub"),
|
|
)
|
|
|
|
# Developer tools that belong in requirements-dev.txt, never in the image.
|
|
DEV_TOOLS = ("black", "pytest", "pytest-cov")
|
|
|
|
# meshcore 2.3.7 introduced EventType.CONTACT_DELETED; the RF5 handler needs
|
|
# it, so the pin must never drop below the 2.3.8 floor (ACCEPTANCE EC-A2).
|
|
MESHCORE_FLOOR = Version("2.3.8")
|
|
|
|
# rns 1.5.0/1.5.1 carried a security fix; the pin must never drop below 1.5.1
|
|
# (ACCEPTANCE DP-A14).
|
|
RNS_FLOOR = Version("1.5.1")
|
|
|
|
# A trailing ``# comment``. A ``#`` that belongs to a value is never preceded
|
|
# by whitespace in these files, so this cannot cut a value short.
|
|
_COMMENT = re.compile(r"(^|\s)#.*$")
|
|
# A YAML block-sequence item, capturing its indentation and its body.
|
|
_ITEM = re.compile(r"^(?P<indent>\s+)-\s+(?P<body>.*)$")
|
|
# A YAML ``key: value`` line; nested keys are matched at any depth.
|
|
_PAIR = re.compile(r"^\s*(?P<key>[\w-]+):\s*(?P<value>.*)$")
|
|
# Dependabot's github-actions entry for ``/`` covers the workflows here.
|
|
_WORKFLOWS = PurePosixPath(".github/workflows")
|
|
|
|
requires_git = pytest.mark.skipif(
|
|
shutil.which("git") is None or not (REPO_ROOT / ".git").exists(),
|
|
reason="needs a git checkout",
|
|
)
|
|
|
|
|
|
def _dependabot_entries(text: str) -> list[dict[str, str]]:
|
|
"""Return the ``updates`` entries of a ``dependabot.yml`` as flat dicts.
|
|
|
|
The file is block-style YAML, so a line scanner keeps the guard free of a
|
|
YAML dependency. Each ``-`` item at the first item's indentation starts an
|
|
entry, and every ``key: value`` line inside it (``schedule.interval``
|
|
included) is stored under its bare key. Deeper list items (``ignore``,
|
|
``directories``) are not entries, and the next top-level key ends the
|
|
list. A layout this scanner cannot read yields missing keys, so the
|
|
guards below fail rather than pass on it.
|
|
|
|
Args:
|
|
text: The contents of a ``dependabot.yml`` file.
|
|
|
|
Returns:
|
|
One ``{key: value}`` dict per entry, with quotes stripped from values.
|
|
"""
|
|
|
|
entries: list[dict[str, str]] = []
|
|
item_indent = None
|
|
for raw in text.partition("\nupdates:\n")[2].splitlines():
|
|
line = _COMMENT.sub("", raw).rstrip()
|
|
if line and not line[0].isspace():
|
|
break
|
|
item = _ITEM.match(line)
|
|
if item and item_indent in (None, len(item.group("indent"))):
|
|
item_indent = len(item.group("indent"))
|
|
entries.append({})
|
|
line = item.group("body")
|
|
pair = _PAIR.match(line)
|
|
if pair and entries:
|
|
entries[-1][pair.group("key")] = pair.group("value").strip("\"' ")
|
|
return entries
|
|
|
|
|
|
def _required_entries(paths: Iterable[str]) -> set[tuple[str, str]]:
|
|
"""Return the ``(ecosystem, directory)`` pairs the given files need.
|
|
|
|
Args:
|
|
paths: Repository-relative POSIX paths, e.g. from ``git ls-files``.
|
|
|
|
Returns:
|
|
One pair per manifest directory, using Dependabot's leading-slash
|
|
directory form (``/`` for the repository root).
|
|
"""
|
|
|
|
required: set[tuple[str, str]] = set()
|
|
for path in map(PurePosixPath, paths):
|
|
if path.parent == _WORKFLOWS and path.suffix in (".yml", ".yaml"):
|
|
required.add(("github-actions", "/"))
|
|
continue
|
|
for pattern, ecosystem in MANIFEST_ECOSYSTEMS:
|
|
if fnmatch.fnmatchcase(path.name, pattern):
|
|
parent = path.parent.as_posix()
|
|
required.add((ecosystem, "/" if parent == "." else f"/{parent}"))
|
|
return required
|
|
|
|
|
|
def _tracked_files() -> list[str]:
|
|
"""Return every path git tracks, relative to the repository root.
|
|
|
|
Returns:
|
|
The ``git ls-files`` listing.
|
|
"""
|
|
|
|
listing = subprocess.run(
|
|
["git", "-C", str(REPO_ROOT), "ls-files", "-z"],
|
|
capture_output=True,
|
|
check=True,
|
|
text=True,
|
|
)
|
|
return [path for path in listing.stdout.split("\0") if path]
|
|
|
|
|
|
def _lockfile_section(text: str, name: str) -> list[str]:
|
|
"""Return the stripped lines of one top-level ``Gemfile.lock`` section.
|
|
|
|
Args:
|
|
text: The lockfile contents.
|
|
name: A section header such as ``PLATFORMS`` or ``BUNDLED WITH``.
|
|
|
|
Returns:
|
|
The indented lines under ``name``, up to the next unindented line.
|
|
"""
|
|
|
|
lines: list[str] = []
|
|
inside = False
|
|
for line in text.splitlines():
|
|
if not line.startswith(" "):
|
|
inside = line == name
|
|
elif inside:
|
|
lines.append(line.strip())
|
|
return lines
|
|
|
|
|
|
def _repository_ignore_rule(verbose_output: str) -> str | None:
|
|
"""Return the committed ``.gitignore`` rule that ignores a path, if any.
|
|
|
|
Args:
|
|
verbose_output: The output of ``git check-ignore --no-index --verbose``
|
|
for one path: empty when no rule matches, else
|
|
``<source>:<line>:<pattern>`` and a tab before the path.
|
|
|
|
Returns:
|
|
``<source>:<line>:<pattern>`` when the deciding rule ignores the path
|
|
and lives in a ``.gitignore`` inside the repository, otherwise
|
|
``None``. A ``!`` pattern re-includes the path. The clone-local
|
|
``info/exclude`` and a global excludes file are a developer's own
|
|
settings, not the repository's: git reports them by their own file
|
|
name (``exclude``) or by an absolute path, so they never count.
|
|
"""
|
|
|
|
rule = verbose_output.split("\t", 1)[0].strip()
|
|
source, _, rest = rule.partition(":")
|
|
pattern = rest.partition(":")[2]
|
|
path = PurePosixPath(source)
|
|
if path.name != ".gitignore" or path.is_absolute() or pattern.startswith("!"):
|
|
return None
|
|
return rule
|
|
|
|
|
|
def _copy_sources(dockerfile: str, needle: str) -> list[str]:
|
|
"""Return the ``COPY`` sources of a Dockerfile that contain ``needle``.
|
|
|
|
Args:
|
|
dockerfile: The Dockerfile contents.
|
|
needle: A substring of the source path, such as ``Gemfile.lock``.
|
|
|
|
Returns:
|
|
The matching source operands, without ``--flags`` or the destination.
|
|
"""
|
|
|
|
sources: list[str] = []
|
|
for line in dockerfile.splitlines():
|
|
words = line.split()
|
|
if words[:1] == ["COPY"]:
|
|
sources += [w for w in words[1:-1] if needle in w and w[:2] != "--"]
|
|
return sources
|
|
|
|
|
|
def _requirements(path: Path) -> tuple[list[Requirement], list[str]]:
|
|
"""Parse a pip requirements file into requirements and option lines.
|
|
|
|
Args:
|
|
path: The requirements file to read.
|
|
|
|
Returns:
|
|
``(requirements, options)``: the parsed requirement specifiers, and
|
|
the ``-``-prefixed option lines (such as ``-r requirements.txt``)
|
|
with their comments removed.
|
|
"""
|
|
|
|
requirements: list[Requirement] = []
|
|
options: list[str] = []
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = _COMMENT.sub("", raw).strip()
|
|
if line.startswith("-"):
|
|
options.append(line)
|
|
elif line:
|
|
requirements.append(Requirement(line))
|
|
return requirements, options
|
|
|
|
|
|
def _exact_pin(requirement: Requirement) -> Version | None:
|
|
"""Return the version a requirement pins with ``==``, or ``None``.
|
|
|
|
Args:
|
|
requirement: A parsed requirement.
|
|
|
|
Returns:
|
|
The pinned version when the specifier is a single ``==`` clause
|
|
without a wildcard, otherwise ``None``.
|
|
"""
|
|
|
|
clauses = list(requirement.specifier)
|
|
if len(clauses) == 1 and clauses[0].operator == "==":
|
|
if "*" not in clauses[0].version:
|
|
return Version(clauses[0].version)
|
|
return None
|
|
|
|
|
|
def _edit_guard_manifests() -> tuple[str, ...]:
|
|
"""Return the manifest names the apex edit hook scans.
|
|
|
|
Returns:
|
|
``MANIFESTS`` from ``.claude/hooks/guard-edits.py``, loaded from its
|
|
path because the hyphenated file name is not importable.
|
|
"""
|
|
|
|
spec = importlib.util.spec_from_file_location("guard_edits", EDIT_GUARD)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module.MANIFESTS
|
|
|
|
|
|
def _pins(path: Path) -> dict[str, Version | None]:
|
|
"""Map each requirement name in ``path`` to its exact pin, if any.
|
|
|
|
Args:
|
|
path: The requirements file to read.
|
|
|
|
Returns:
|
|
``{name: version}`` keyed by the canonical project name (so
|
|
``Pytest_Cov`` reads as ``pytest-cov``); the version is ``None`` for
|
|
a requirement that is not pinned with ``==``.
|
|
"""
|
|
|
|
requirements, _ = _requirements(path)
|
|
return {canonicalize_name(req.name): _exact_pin(req) for req in requirements}
|
|
|
|
|
|
def _assert_pinned_at_least(name: str, floor: Version) -> None:
|
|
"""Assert that ``data/requirements.txt`` pins ``name`` with ``==`` at ``floor`` or above.
|
|
|
|
Args:
|
|
name: Project name; matched by its canonical form, as ``_pins`` keys.
|
|
floor: Lowest acceptable pinned version.
|
|
"""
|
|
|
|
pinned = _pins(RUNTIME_REQUIREMENTS).get(canonicalize_name(name))
|
|
assert pinned is not None, f"{name} is not pinned with == in data/requirements.txt"
|
|
assert pinned >= floor, f"{name}=={pinned} is below {floor}"
|
|
|
|
|
|
def test_dependabot_ecosystems_are_documented() -> None:
|
|
"""Every ``package-ecosystem`` value is one GitHub documents."""
|
|
|
|
entries = _dependabot_entries(DEPENDABOT.read_text(encoding="utf-8"))
|
|
ecosystems = {entry.get("package-ecosystem", "") for entry in entries}
|
|
undocumented = sorted(ecosystems - DOCUMENTED_ECOSYSTEMS)
|
|
assert entries, "no updates entries parsed from .github/dependabot.yml"
|
|
assert not undocumented, (
|
|
f"undocumented package-ecosystem values in .github/dependabot.yml: "
|
|
f"{undocumented} (Ruby is `bundler`, Python is `pip`)"
|
|
)
|
|
|
|
|
|
@requires_git
|
|
def test_dependabot_covers_every_manifest_directory() -> None:
|
|
"""Each tracked manifest's directory has a weekly entry for its ecosystem."""
|
|
|
|
entries = _dependabot_entries(DEPENDABOT.read_text(encoding="utf-8"))
|
|
configured = {(e.get("package-ecosystem"), e.get("directory")) for e in entries}
|
|
missing = sorted(_required_entries(_tracked_files()) - configured)
|
|
not_weekly = sorted(
|
|
f"{e.get('package-ecosystem')} {e.get('directory')}"
|
|
for e in entries
|
|
if e.get("interval") != "weekly"
|
|
)
|
|
assert not missing, f"no Dependabot entry for (ecosystem, directory): {missing}"
|
|
assert not not_weekly, f"Dependabot entries not on a weekly schedule: {not_weekly}"
|
|
|
|
|
|
@requires_git
|
|
def test_gemfile_lock_is_not_git_ignored() -> None:
|
|
"""No committed ``.gitignore`` rule ignores ``web/Gemfile.lock``.
|
|
|
|
``--no-index`` applies the rules even to a tracked file. The global
|
|
excludes file is replaced by ``/dev/null``, and a match from the clone's
|
|
``info/exclude`` does not count, so a developer's own ``Gemfile.lock``
|
|
pattern cannot fail this guard.
|
|
"""
|
|
|
|
command = ["git", "-C", str(REPO_ROOT), "-c", "core.excludesFile=/dev/null"]
|
|
check = subprocess.run(
|
|
command + ["check-ignore", "--no-index", "--verbose", "web/Gemfile.lock"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
# Exit status 0 prints the deciding rule, 1 means no rule matched.
|
|
assert check.returncode in (0, 1), check.stderr
|
|
rule = _repository_ignore_rule(check.stdout)
|
|
assert rule is None, f"web/Gemfile.lock is git-ignored by {rule}"
|
|
|
|
|
|
def test_gemfile_lock_locks_the_build_platforms() -> None:
|
|
"""The lockfile covers CI's runners and the Alpine image builds.
|
|
|
|
``ruby/setup-ruby`` installs in deployment mode, which rejects a lockfile
|
|
without the runner's platform (x86_64 glibc Linux), and the web image
|
|
compiles every gem for the generic ``ruby`` platform
|
|
(``BUNDLE_FORCE_RUBY_PLATFORM``). ``BUNDLED WITH`` selects the Bundler
|
|
that both of them install.
|
|
"""
|
|
|
|
assert GEMFILE_LOCK.is_file(), (
|
|
"web/Gemfile.lock is missing: the web image and CI resolve every gem "
|
|
"afresh at build time"
|
|
)
|
|
text = GEMFILE_LOCK.read_text(encoding="utf-8")
|
|
platforms = set(_lockfile_section(text, "PLATFORMS"))
|
|
assert "ruby" in platforms, f"PLATFORMS lacks ruby: {sorted(platforms)}"
|
|
runners = platforms & {"x86_64-linux", "x86_64-linux-gnu"}
|
|
assert runners, f"PLATFORMS lacks x86_64-linux(-gnu) for CI: {sorted(platforms)}"
|
|
assert _lockfile_section(text, "BUNDLED WITH"), "no BUNDLED WITH version"
|
|
|
|
|
|
def test_web_image_copies_the_committed_lockfile() -> None:
|
|
"""Every ``COPY`` of the lockfile into the web image matches a real file."""
|
|
|
|
sources = _copy_sources(WEB_DOCKERFILE.read_text(encoding="utf-8"), "Gemfile.lock")
|
|
unmatched = sorted({s for s in sources if not list(REPO_ROOT.glob(s))})
|
|
assert sources, "web/Dockerfile never copies web/Gemfile.lock"
|
|
assert not unmatched, (
|
|
f"web/Dockerfile COPY sources {unmatched} match no file, so the image "
|
|
f"resolves gems at build time"
|
|
)
|
|
|
|
|
|
def test_runtime_requirements_pin_exact_versions() -> None:
|
|
"""``data/requirements.txt`` pins every runtime dependency with ``==``."""
|
|
|
|
pins = _pins(RUNTIME_REQUIREMENTS)
|
|
unpinned = sorted(name for name, version in pins.items() if version is None)
|
|
assert pins, "no requirements parsed from data/requirements.txt"
|
|
assert not unpinned, f"data/requirements.txt does not pin with ==: {unpinned}"
|
|
|
|
|
|
def test_runtime_requirements_exclude_dev_tools() -> None:
|
|
"""The ingestor image installs no formatter or test runner."""
|
|
|
|
leaked = sorted(set(_pins(RUNTIME_REQUIREMENTS)) & set(DEV_TOOLS))
|
|
assert not leaked, f"dev tools in data/requirements.txt (the image): {leaked}"
|
|
|
|
|
|
def test_dev_requirements_extend_runtime_with_pinned_tools() -> None:
|
|
"""``data/requirements-dev.txt`` adds the pinned dev tools to the runtime set."""
|
|
|
|
assert DEV_REQUIREMENTS.is_file(), "data/requirements-dev.txt is missing"
|
|
_, options = _requirements(DEV_REQUIREMENTS)
|
|
pins = _pins(DEV_REQUIREMENTS)
|
|
unpinned = [tool for tool in DEV_TOOLS if pins.get(tool) is None]
|
|
assert "-r requirements.txt" in options, "missing -r requirements.txt"
|
|
assert not unpinned, f"data/requirements-dev.txt does not pin with ==: {unpinned}"
|
|
|
|
|
|
def test_meshcore_pin_defines_contact_deleted() -> None:
|
|
"""The pinned ``meshcore`` is at least 2.3.8 (``EventType.CONTACT_DELETED``)."""
|
|
|
|
_assert_pinned_at_least("meshcore", MESHCORE_FLOOR)
|
|
|
|
|
|
def test_rns_pin_keeps_the_security_fix() -> None:
|
|
"""The pinned ``rns`` is at least 1.5.1, past the 1.5 security fix."""
|
|
|
|
_assert_pinned_at_least("rns", RNS_FLOOR)
|
|
|
|
|
|
def test_edit_guard_watches_every_requirements_file() -> None:
|
|
"""The apex edit hook scans both pip requirements files."""
|
|
|
|
found = RUNTIME_REQUIREMENTS.parent.glob("requirements*.txt")
|
|
names = {path.name for path in found} | {DEV_REQUIREMENTS.name}
|
|
unwatched = sorted(names - set(_edit_guard_manifests()))
|
|
assert not unwatched, f".claude/hooks/guard-edits.py does not scan {unwatched}"
|
|
|
|
|
|
def test_dependabot_parser_reads_only_top_level_update_items() -> None:
|
|
"""Nested list items, comments, and later top-level keys are not entries."""
|
|
|
|
text = (
|
|
"version: 2\n"
|
|
"updates:\n"
|
|
' - package-ecosystem: "npm" # trailing comment\n'
|
|
" directory: '/web'\n"
|
|
" schedule:\n"
|
|
" interval: weekly\n"
|
|
" ignore:\n"
|
|
' - dependency-name: "left-pad"\n'
|
|
"\n"
|
|
" - package-ecosystem: cargo\n"
|
|
"registries:\n"
|
|
" - package-ecosystem: not-an-entry\n"
|
|
)
|
|
assert _dependabot_entries(text) == [
|
|
{
|
|
"package-ecosystem": "npm",
|
|
"directory": "/web",
|
|
"schedule": "",
|
|
"interval": "weekly",
|
|
"ignore": "",
|
|
},
|
|
{"package-ecosystem": "cargo"},
|
|
]
|
|
|
|
|
|
def test_required_entries_map_manifests_to_their_directories() -> None:
|
|
"""Manifests map to ecosystem and directory; other files need no entry."""
|
|
|
|
paths = [
|
|
"Gemfile",
|
|
"web/package.json",
|
|
"data/requirements-dev.txt",
|
|
".github/workflows/ruby.yml",
|
|
".github/workflows/README.md",
|
|
"README.md",
|
|
]
|
|
assert _required_entries(paths) == {
|
|
("bundler", "/"),
|
|
("npm", "/web"),
|
|
("pip", "/data"),
|
|
("github-actions", "/"),
|
|
}
|
|
|
|
|
|
def test_repository_ignore_rule_counts_only_committed_gitignores() -> None:
|
|
"""Repository rules count; negations and developer-local files do not."""
|
|
|
|
rule = ".gitignore:48:Gemfile.lock"
|
|
assert _repository_ignore_rule(f"{rule}\tweb/Gemfile.lock\n") == rule
|
|
for output in (
|
|
"",
|
|
"web/.gitignore:3:!Gemfile.lock\tweb/Gemfile.lock\n",
|
|
".git/info/exclude:7:Gemfile.lock\tweb/Gemfile.lock\n",
|
|
"/home/dev/.gitignore:1:Gemfile.lock\tweb/Gemfile.lock\n",
|
|
):
|
|
assert _repository_ignore_rule(output) is None, output
|
|
|
|
|
|
def test_exact_pin_accepts_only_a_single_equality_clause() -> None:
|
|
"""``==X`` is a pin; floors, wildcards, ranges, and bare names are not."""
|
|
|
|
assert _exact_pin(Requirement("meshcore==2.3.15")) == Version("2.3.15")
|
|
for loose in ("meshcore>=2.3.8", "meshcore==2.3.*", "meshcore>=2,<3", "meshcore"):
|
|
assert _exact_pin(Requirement(loose)) is None, loose
|