Add transitive dependency resolution and lock file support
Implements two major features for project dependency management: Transitive Dependency Resolution: - DependencyGraph/DependencyNode for structured dependency tracking - DependencyGraphBuilder with recursive resolution and cycle detection - Topological sorting for correct install order (dependencies first) - Version conflict detection with provenance tracking - Full semver support: ^, ~, >=, <=, >, <, = constraints - New `cmdforge deps tree` command to visualize dependency tree - Install flags: --dry-run, --force, --verbose Lock File Support (cmdforge.lock): - Lockfile/LockedPackage/LockfileMetadata dataclasses - Records exact versions of all dependencies (direct + transitive) - Integrity verification via SHA256 hashes - `cmdforge lock` to generate/update lock file - `cmdforge verify` to check installed tools match lock - Install flags: --frozen, --strict-frozen, --ignore-lock 62 new tests for version, dependency_graph, and lockfile modules. Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"""Tests for dependency_graph.py - Transitive dependency resolution."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
from cmdforge.dependency_graph import (
|
||||
DependencyNode,
|
||||
DependencyGraph,
|
||||
DependencyGraphBuilder,
|
||||
ResolutionResult,
|
||||
resolve_dependencies
|
||||
)
|
||||
from cmdforge.manifest import Dependency
|
||||
|
||||
|
||||
class TestDependencyNode:
|
||||
"""Tests for DependencyNode dataclass."""
|
||||
|
||||
def test_qualified_name_with_owner(self):
|
||||
node = DependencyNode(
|
||||
owner="official",
|
||||
name="summarize",
|
||||
version_constraint="^1.0.0",
|
||||
resolved_version="1.2.3",
|
||||
source="global",
|
||||
path=Path("/test")
|
||||
)
|
||||
assert node.qualified_name == "official/summarize"
|
||||
|
||||
def test_qualified_name_without_owner(self):
|
||||
node = DependencyNode(
|
||||
owner="",
|
||||
name="summarize",
|
||||
version_constraint="*",
|
||||
resolved_version=None,
|
||||
source=None,
|
||||
path=None
|
||||
)
|
||||
assert node.qualified_name == "summarize"
|
||||
|
||||
def test_is_resolved(self):
|
||||
resolved = DependencyNode(
|
||||
owner="official",
|
||||
name="test",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="global",
|
||||
path=Path("/test")
|
||||
)
|
||||
assert resolved.is_resolved
|
||||
|
||||
unresolved = DependencyNode(
|
||||
owner="official",
|
||||
name="test",
|
||||
version_constraint="*",
|
||||
resolved_version=None,
|
||||
source=None,
|
||||
path=None
|
||||
)
|
||||
assert not unresolved.is_resolved
|
||||
|
||||
|
||||
class TestDependencyGraph:
|
||||
"""Tests for DependencyGraph dataclass."""
|
||||
|
||||
def test_add_node(self):
|
||||
graph = DependencyGraph()
|
||||
node = DependencyNode(
|
||||
owner="official",
|
||||
name="test",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="global",
|
||||
path=Path("/test")
|
||||
)
|
||||
graph.add_node(node)
|
||||
assert "official/test" in graph.nodes
|
||||
|
||||
def test_get_node(self):
|
||||
graph = DependencyGraph()
|
||||
node = DependencyNode(
|
||||
owner="official",
|
||||
name="test",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="global",
|
||||
path=Path("/test")
|
||||
)
|
||||
graph.add_node(node)
|
||||
assert graph.get_node("official/test") == node
|
||||
assert graph.get_node("nonexistent") is None
|
||||
|
||||
def test_version_conflict_detection(self):
|
||||
graph = DependencyGraph()
|
||||
|
||||
node1 = DependencyNode(
|
||||
owner="official",
|
||||
name="test",
|
||||
version_constraint="^1.0.0",
|
||||
resolved_version="1.0.0",
|
||||
source="global",
|
||||
path=Path("/test"),
|
||||
requested_by="root"
|
||||
)
|
||||
graph.add_node(node1, requested_by="root")
|
||||
|
||||
node2 = DependencyNode(
|
||||
owner="official",
|
||||
name="test",
|
||||
version_constraint="^2.0.0",
|
||||
resolved_version="2.0.0",
|
||||
source="global",
|
||||
path=Path("/test"),
|
||||
requested_by="other"
|
||||
)
|
||||
graph.add_node(node2, requested_by="other")
|
||||
|
||||
assert graph.has_conflicts()
|
||||
assert len(graph.conflicts) == 1
|
||||
|
||||
def test_get_unresolved(self):
|
||||
graph = DependencyGraph()
|
||||
|
||||
resolved = DependencyNode(
|
||||
owner="official",
|
||||
name="resolved",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="global",
|
||||
path=Path("/test")
|
||||
)
|
||||
unresolved = DependencyNode(
|
||||
owner="official",
|
||||
name="unresolved",
|
||||
version_constraint="*",
|
||||
resolved_version=None,
|
||||
source=None,
|
||||
path=None
|
||||
)
|
||||
graph.add_node(resolved)
|
||||
graph.add_node(unresolved)
|
||||
|
||||
unresolved_list = graph.get_unresolved()
|
||||
assert len(unresolved_list) == 1
|
||||
assert unresolved_list[0].name == "unresolved"
|
||||
|
||||
|
||||
class TestDependencyGraphBuilder:
|
||||
"""Tests for DependencyGraphBuilder class."""
|
||||
|
||||
def test_build_empty_deps(self):
|
||||
builder = DependencyGraphBuilder(verbose=False)
|
||||
graph = builder.build([])
|
||||
assert len(graph.nodes) == 0
|
||||
|
||||
def test_topological_sort_simple(self):
|
||||
"""Test that dependencies come before dependents."""
|
||||
graph = DependencyGraph()
|
||||
|
||||
# A depends on B
|
||||
node_a = DependencyNode(
|
||||
owner="official",
|
||||
name="a",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="global",
|
||||
path=Path("/a"),
|
||||
children=["official/b"]
|
||||
)
|
||||
node_b = DependencyNode(
|
||||
owner="official",
|
||||
name="b",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="global",
|
||||
path=Path("/b"),
|
||||
children=[]
|
||||
)
|
||||
graph.add_node(node_a)
|
||||
graph.add_node(node_b)
|
||||
graph.root_dependencies = ["official/a"]
|
||||
|
||||
builder = DependencyGraphBuilder()
|
||||
order = builder._topological_sort(graph)
|
||||
|
||||
# B should come before A since A depends on B
|
||||
b_idx = order.index("official/b")
|
||||
a_idx = order.index("official/a")
|
||||
assert b_idx < a_idx
|
||||
|
||||
def test_cycle_detection(self):
|
||||
"""Test that cycles are detected."""
|
||||
# This requires mocking the resolver to create a cycle
|
||||
# For now, just verify the cycle list exists
|
||||
graph = DependencyGraph()
|
||||
assert not graph.has_cycles()
|
||||
graph.cycles.append(["a", "b", "a"])
|
||||
assert graph.has_cycles()
|
||||
|
||||
|
||||
class TestResolveDepencies:
|
||||
"""Tests for resolve_dependencies convenience function."""
|
||||
|
||||
@patch('cmdforge.dependency_graph.DependencyGraphBuilder.build')
|
||||
def test_categorizes_results(self, mock_build):
|
||||
"""Test that results are properly categorized."""
|
||||
# Create mock graph
|
||||
mock_graph = DependencyGraph()
|
||||
mock_graph.resolution_order = ["official/test"]
|
||||
|
||||
node = DependencyNode(
|
||||
owner="official",
|
||||
name="test",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="registry",
|
||||
path=None # Not installed yet
|
||||
)
|
||||
mock_graph.nodes["official/test"] = node
|
||||
mock_build.return_value = mock_graph
|
||||
|
||||
deps = [Dependency(name="official/test", version="*")]
|
||||
result = resolve_dependencies(deps)
|
||||
|
||||
assert isinstance(result, ResolutionResult)
|
||||
assert len(result.to_install) == 1
|
||||
assert result.to_install[0].name == "test"
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests with real file system."""
|
||||
|
||||
def test_reads_local_tool_deps(self, tmp_path):
|
||||
"""Test that dependencies can be read from a local tool config."""
|
||||
# Create a tool config with dependencies
|
||||
tool_dir = tmp_path / "test-tool"
|
||||
tool_dir.mkdir()
|
||||
|
||||
config = {
|
||||
"name": "test-tool",
|
||||
"description": "Test tool",
|
||||
"dependencies": [
|
||||
{"name": "official/helper", "version": "^1.0.0"}
|
||||
],
|
||||
"steps": [
|
||||
{"type": "prompt", "prompt": "test", "output_var": "result"}
|
||||
]
|
||||
}
|
||||
(tool_dir / "config.yaml").write_text(yaml.dump(config))
|
||||
|
||||
# Create a node pointing to this tool
|
||||
node = DependencyNode(
|
||||
owner="local",
|
||||
name="test-tool",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="local",
|
||||
path=tool_dir
|
||||
)
|
||||
|
||||
builder = DependencyGraphBuilder()
|
||||
deps = builder._read_deps_from_config(tool_dir)
|
||||
|
||||
assert len(deps) == 1
|
||||
assert deps[0].name == "official/helper"
|
||||
assert deps[0].version == "^1.0.0"
|
||||
|
||||
def test_reads_implicit_tool_deps(self, tmp_path):
|
||||
"""Test that ToolStep references are discovered as dependencies."""
|
||||
tool_dir = tmp_path / "meta-tool"
|
||||
tool_dir.mkdir()
|
||||
|
||||
config = {
|
||||
"name": "meta-tool",
|
||||
"description": "Meta tool that calls others",
|
||||
"steps": [
|
||||
{
|
||||
"type": "tool",
|
||||
"tool": "official/summarize",
|
||||
"output_var": "summary"
|
||||
}
|
||||
]
|
||||
}
|
||||
(tool_dir / "config.yaml").write_text(yaml.dump(config))
|
||||
|
||||
builder = DependencyGraphBuilder()
|
||||
deps = builder._read_deps_from_config(tool_dir)
|
||||
|
||||
assert len(deps) == 1
|
||||
assert deps[0].name == "official/summarize"
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Tests for lockfile.py - Lock file functionality."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
from cmdforge.lockfile import (
|
||||
Lockfile, LockedPackage, LockfileMetadata,
|
||||
generate_lockfile, verify_lockfile,
|
||||
compute_file_hash
|
||||
)
|
||||
from cmdforge.hash_utils import compute_yaml_hash
|
||||
|
||||
|
||||
class TestLockedPackage:
|
||||
"""Tests for LockedPackage dataclass."""
|
||||
|
||||
def test_owner_and_name_qualified(self):
|
||||
pkg = LockedPackage(
|
||||
name="official/summarize",
|
||||
version="1.0.0",
|
||||
constraint="^1.0.0",
|
||||
integrity="sha256:abc",
|
||||
source="registry",
|
||||
direct=True
|
||||
)
|
||||
assert pkg.owner == "official"
|
||||
assert pkg.tool_name == "summarize"
|
||||
|
||||
def test_owner_and_name_unqualified(self):
|
||||
pkg = LockedPackage(
|
||||
name="summarize",
|
||||
version="1.0.0",
|
||||
constraint="*",
|
||||
integrity="sha256:abc",
|
||||
source="local",
|
||||
direct=True
|
||||
)
|
||||
assert pkg.owner == ""
|
||||
assert pkg.tool_name == "summarize"
|
||||
|
||||
def test_required_by_default_empty(self):
|
||||
pkg = LockedPackage(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
constraint="*",
|
||||
integrity="",
|
||||
source="registry",
|
||||
direct=True
|
||||
)
|
||||
assert pkg.required_by == []
|
||||
|
||||
|
||||
class TestLockfile:
|
||||
"""Tests for Lockfile dataclass."""
|
||||
|
||||
def test_save_and_load(self, tmp_path):
|
||||
lock = Lockfile(
|
||||
metadata=LockfileMetadata(
|
||||
generated_at="2026-01-26T00:00:00Z",
|
||||
cmdforge_version="1.5.0",
|
||||
manifest_hash="sha256:test123",
|
||||
platform="linux",
|
||||
python_version="3.12.0"
|
||||
),
|
||||
packages={
|
||||
"official/summarize": LockedPackage(
|
||||
name="official/summarize",
|
||||
version="1.2.3",
|
||||
constraint="^1.0.0",
|
||||
integrity="sha256:abc123",
|
||||
source="registry",
|
||||
direct=True
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
lock_path = tmp_path / "cmdforge.lock"
|
||||
lock.save(lock_path)
|
||||
|
||||
loaded = Lockfile.load(lock_path)
|
||||
assert loaded is not None
|
||||
assert "official/summarize" in loaded.packages
|
||||
assert loaded.packages["official/summarize"].version == "1.2.3"
|
||||
assert loaded.packages["official/summarize"].integrity == "sha256:abc123"
|
||||
# Verify optional fields round-trip
|
||||
assert loaded.packages["official/summarize"].required_by == []
|
||||
assert loaded.packages["official/summarize"].path is None
|
||||
|
||||
def test_load_missing_file(self, tmp_path):
|
||||
lock_path = tmp_path / "nonexistent.lock"
|
||||
loaded = Lockfile.load(lock_path)
|
||||
assert loaded is None
|
||||
|
||||
def test_is_stale_when_manifest_changed(self, tmp_path):
|
||||
manifest_path = tmp_path / "cmdforge.yaml"
|
||||
manifest_path.write_text("name: test\ndependencies: []")
|
||||
|
||||
original_hash = compute_file_hash(manifest_path)
|
||||
|
||||
lock = Lockfile(
|
||||
metadata=LockfileMetadata(
|
||||
generated_at="2026-01-26T00:00:00Z",
|
||||
cmdforge_version="1.5.0",
|
||||
manifest_hash=original_hash,
|
||||
platform="linux",
|
||||
python_version="3.12.0"
|
||||
)
|
||||
)
|
||||
|
||||
assert not lock.is_stale(manifest_path)
|
||||
|
||||
# Modify manifest
|
||||
manifest_path.write_text("name: test\ndependencies:\n - official/foo")
|
||||
|
||||
assert lock.is_stale(manifest_path)
|
||||
|
||||
def test_is_stale_without_metadata(self):
|
||||
lock = Lockfile()
|
||||
assert lock.is_stale()
|
||||
|
||||
def test_get_package(self):
|
||||
lock = Lockfile(
|
||||
packages={
|
||||
"official/summarize": LockedPackage(
|
||||
name="official/summarize",
|
||||
version="1.0.0",
|
||||
constraint="*",
|
||||
integrity="",
|
||||
source="registry",
|
||||
direct=True
|
||||
)
|
||||
}
|
||||
)
|
||||
assert lock.get_package("official/summarize") is not None
|
||||
assert lock.get_package("nonexistent") is None
|
||||
|
||||
def test_lockfile_version(self):
|
||||
lock = Lockfile()
|
||||
assert lock.lockfile_version == 1
|
||||
|
||||
def test_save_includes_header(self, tmp_path):
|
||||
lock = Lockfile()
|
||||
lock_path = tmp_path / "cmdforge.lock"
|
||||
lock.save(lock_path)
|
||||
|
||||
content = lock_path.read_text()
|
||||
assert "# cmdforge.lock" in content
|
||||
assert "# Auto-generated" in content
|
||||
assert "do not edit manually" in content
|
||||
|
||||
|
||||
class TestHashFunctions:
|
||||
"""Tests for hash functions."""
|
||||
|
||||
def test_compute_file_hash(self, tmp_path):
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("hello world")
|
||||
|
||||
hash1 = compute_file_hash(test_file)
|
||||
assert hash1.startswith("sha256:")
|
||||
|
||||
# Same content = same hash
|
||||
hash2 = compute_file_hash(test_file)
|
||||
assert hash1 == hash2
|
||||
|
||||
# Different content = different hash
|
||||
test_file.write_text("different")
|
||||
hash3 = compute_file_hash(test_file)
|
||||
assert hash1 != hash3
|
||||
|
||||
def test_compute_yaml_hash_normalized(self):
|
||||
"""Test that YAML hash normalizes content (sorted keys, excluded fields)."""
|
||||
# Same logical content, different formatting
|
||||
yaml1 = "name: test\ndescription: foo"
|
||||
yaml2 = "description: foo\nname: test" # Different key order
|
||||
|
||||
# Should produce same hash (normalized)
|
||||
assert compute_yaml_hash(yaml1) == compute_yaml_hash(yaml2)
|
||||
|
||||
# Different content = different hash
|
||||
yaml3 = "name: test\ndescription: bar"
|
||||
assert compute_yaml_hash(yaml1) != compute_yaml_hash(yaml3)
|
||||
|
||||
# registry_hash field should be excluded
|
||||
yaml4 = "name: test\ndescription: foo\nregistry_hash: sha256:abc"
|
||||
assert compute_yaml_hash(yaml1) == compute_yaml_hash(yaml4)
|
||||
|
||||
|
||||
class TestGenerateLockfile:
|
||||
"""Tests for generate_lockfile function."""
|
||||
|
||||
def test_generates_complete_lockfile(self, tmp_path):
|
||||
"""Test that lock file includes all graph nodes."""
|
||||
from cmdforge.manifest import Manifest, Dependency
|
||||
from cmdforge.dependency_graph import DependencyGraph, DependencyNode
|
||||
from pathlib import Path
|
||||
|
||||
# Create mock manifest
|
||||
manifest = Manifest(
|
||||
name="test-project",
|
||||
version="1.0.0",
|
||||
dependencies=[
|
||||
Dependency(name="official/summarize", version="^1.0.0")
|
||||
]
|
||||
)
|
||||
|
||||
# Create mock graph with local tool (has path for integrity)
|
||||
tool_dir = tmp_path / "tool"
|
||||
tool_dir.mkdir()
|
||||
(tool_dir / "config.yaml").write_text("name: summarize\ndescription: test")
|
||||
|
||||
graph = DependencyGraph()
|
||||
graph.root_dependencies = ["official/summarize"]
|
||||
graph.nodes = {
|
||||
"official/summarize": DependencyNode(
|
||||
owner="official",
|
||||
name="summarize",
|
||||
version_constraint="^1.0.0",
|
||||
resolved_version="1.2.3",
|
||||
source="local", # Use local so we can compute hash from file
|
||||
path=tool_dir,
|
||||
children=["official/text-utils"]
|
||||
),
|
||||
"official/text-utils": DependencyNode(
|
||||
owner="official",
|
||||
name="text-utils",
|
||||
version_constraint="*",
|
||||
resolved_version="1.0.0",
|
||||
source="registry",
|
||||
path=None,
|
||||
children=[]
|
||||
)
|
||||
}
|
||||
|
||||
# Mock registry client - won't be called for local tools
|
||||
mock_client = MagicMock()
|
||||
mock_client.download_tool.return_value = MagicMock(
|
||||
config_hash="sha256:registry_hash",
|
||||
config_yaml="name: text-utils"
|
||||
)
|
||||
|
||||
# Create a cmdforge.yaml for manifest hash
|
||||
manifest_path = tmp_path / "cmdforge.yaml"
|
||||
manifest_path.write_text("name: test\ndependencies: []")
|
||||
|
||||
import os
|
||||
old_cwd = os.getcwd()
|
||||
os.chdir(tmp_path)
|
||||
try:
|
||||
lock = generate_lockfile(manifest, graph, mock_client)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert len(lock.packages) == 2
|
||||
assert "official/summarize" in lock.packages
|
||||
assert "official/text-utils" in lock.packages
|
||||
assert lock.packages["official/summarize"].direct is True
|
||||
assert lock.packages["official/text-utils"].direct is False
|
||||
|
||||
|
||||
class TestVerifyLockfile:
|
||||
"""Tests for verify_lockfile function."""
|
||||
|
||||
def test_reports_missing_tools(self):
|
||||
"""Test that missing tools are reported."""
|
||||
from cmdforge.resolver import ToolNotFoundError, ToolSpec
|
||||
|
||||
lock = Lockfile(
|
||||
packages={
|
||||
"official/missing": LockedPackage(
|
||||
name="official/missing",
|
||||
version="1.0.0",
|
||||
constraint="*",
|
||||
integrity="sha256:abc",
|
||||
source="registry",
|
||||
direct=True
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch('cmdforge.resolver.ToolResolver') as MockResolver:
|
||||
mock_instance = MockResolver.return_value
|
||||
mock_instance.manifest = None
|
||||
mock_instance.resolve.side_effect = ToolNotFoundError(
|
||||
ToolSpec.parse("official/missing"),
|
||||
[]
|
||||
)
|
||||
|
||||
errors = verify_lockfile(lock, mock_client)
|
||||
|
||||
assert len(errors) == 1
|
||||
assert "not installed" in errors[0]
|
||||
|
||||
|
||||
class TestLockfileRoundTrip:
|
||||
"""Tests for complete lock file round-trip."""
|
||||
|
||||
def test_full_round_trip(self, tmp_path):
|
||||
"""Test saving and loading a complete lock file."""
|
||||
lock = Lockfile(
|
||||
lockfile_version=1,
|
||||
metadata=LockfileMetadata(
|
||||
generated_at="2026-01-26T10:30:00Z",
|
||||
cmdforge_version="1.5.0",
|
||||
manifest_hash="sha256:abc123def456",
|
||||
platform="linux",
|
||||
python_version="3.12.0"
|
||||
),
|
||||
packages={
|
||||
"official/summarize": LockedPackage(
|
||||
name="official/summarize",
|
||||
version="1.2.3",
|
||||
constraint="^1.0.0",
|
||||
integrity="sha256:abc123",
|
||||
source="registry",
|
||||
direct=True,
|
||||
required_by=[]
|
||||
),
|
||||
"official/text-utils": LockedPackage(
|
||||
name="official/text-utils",
|
||||
version="1.0.0",
|
||||
constraint="*",
|
||||
integrity="sha256:def456",
|
||||
source="registry",
|
||||
direct=False,
|
||||
required_by=["official/summarize"]
|
||||
),
|
||||
"my-local-tool": LockedPackage(
|
||||
name="my-local-tool",
|
||||
version="0.1.0",
|
||||
constraint="*",
|
||||
integrity="sha256:ghi789",
|
||||
source="local",
|
||||
direct=True,
|
||||
path=".cmdforge/my-local-tool"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
lock_path = tmp_path / "cmdforge.lock"
|
||||
lock.save(lock_path)
|
||||
|
||||
loaded = Lockfile.load(lock_path)
|
||||
|
||||
# Verify metadata
|
||||
assert loaded.lockfile_version == 1
|
||||
assert loaded.metadata.generated_at == "2026-01-26T10:30:00Z"
|
||||
assert loaded.metadata.cmdforge_version == "1.5.0"
|
||||
assert loaded.metadata.manifest_hash == "sha256:abc123def456"
|
||||
assert loaded.metadata.platform == "linux"
|
||||
assert loaded.metadata.python_version == "3.12.0"
|
||||
|
||||
# Verify packages
|
||||
assert len(loaded.packages) == 3
|
||||
|
||||
summarize = loaded.packages["official/summarize"]
|
||||
assert summarize.version == "1.2.3"
|
||||
assert summarize.constraint == "^1.0.0"
|
||||
assert summarize.integrity == "sha256:abc123"
|
||||
assert summarize.source == "registry"
|
||||
assert summarize.direct is True
|
||||
|
||||
text_utils = loaded.packages["official/text-utils"]
|
||||
assert text_utils.direct is False
|
||||
assert text_utils.required_by == ["official/summarize"]
|
||||
|
||||
local_tool = loaded.packages["my-local-tool"]
|
||||
assert local_tool.source == "local"
|
||||
assert local_tool.path == ".cmdforge/my-local-tool"
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Tests for version.py - Semantic version parsing and constraint matching."""
|
||||
|
||||
import pytest
|
||||
|
||||
from cmdforge.version import (
|
||||
Version,
|
||||
matches_constraint,
|
||||
find_best_match,
|
||||
is_valid_version,
|
||||
compare_versions
|
||||
)
|
||||
|
||||
|
||||
class TestVersion:
|
||||
"""Tests for Version dataclass."""
|
||||
|
||||
def test_parse_basic(self):
|
||||
v = Version.parse("1.2.3")
|
||||
assert v is not None
|
||||
assert v.major == 1
|
||||
assert v.minor == 2
|
||||
assert v.patch == 3
|
||||
assert v.prerelease is None
|
||||
assert v.build is None
|
||||
|
||||
def test_parse_with_prerelease(self):
|
||||
v = Version.parse("1.0.0-alpha.1")
|
||||
assert v is not None
|
||||
assert v.major == 1
|
||||
assert v.minor == 0
|
||||
assert v.patch == 0
|
||||
assert v.prerelease == "alpha.1"
|
||||
|
||||
def test_parse_with_build(self):
|
||||
v = Version.parse("1.0.0+build.123")
|
||||
assert v is not None
|
||||
assert v.build == "build.123"
|
||||
|
||||
def test_parse_full(self):
|
||||
v = Version.parse("2.1.0-beta.2+20240115")
|
||||
assert v is not None
|
||||
assert v.major == 2
|
||||
assert v.minor == 1
|
||||
assert v.patch == 0
|
||||
assert v.prerelease == "beta.2"
|
||||
assert v.build == "20240115"
|
||||
|
||||
def test_parse_invalid(self):
|
||||
assert Version.parse("") is None
|
||||
assert Version.parse("invalid") is None
|
||||
assert Version.parse("1.2") is None
|
||||
assert Version.parse("1.2.3.4") is None
|
||||
|
||||
def test_str(self):
|
||||
assert str(Version(1, 2, 3)) == "1.2.3"
|
||||
assert str(Version(1, 0, 0, prerelease="alpha")) == "1.0.0-alpha"
|
||||
assert str(Version(1, 0, 0, build="123")) == "1.0.0+123"
|
||||
assert str(Version(1, 0, 0, prerelease="rc.1", build="build")) == "1.0.0-rc.1+build"
|
||||
|
||||
def test_tuple(self):
|
||||
v = Version(1, 2, 3)
|
||||
assert v.tuple == (1, 2, 3)
|
||||
|
||||
def test_equality(self):
|
||||
v1 = Version(1, 2, 3)
|
||||
v2 = Version(1, 2, 3)
|
||||
v3 = Version(1, 2, 4)
|
||||
assert v1 == v2
|
||||
assert v1 != v3
|
||||
|
||||
def test_comparison(self):
|
||||
assert Version(1, 0, 0) < Version(2, 0, 0)
|
||||
assert Version(1, 0, 0) < Version(1, 1, 0)
|
||||
assert Version(1, 0, 0) < Version(1, 0, 1)
|
||||
assert Version(2, 0, 0) > Version(1, 9, 9)
|
||||
|
||||
def test_prerelease_comparison(self):
|
||||
# Prerelease < release
|
||||
assert Version(1, 0, 0, prerelease="alpha") < Version(1, 0, 0)
|
||||
assert Version(1, 0, 0, prerelease="beta") < Version(1, 0, 0)
|
||||
# Alphabetic ordering for prerelease
|
||||
assert Version(1, 0, 0, prerelease="alpha") < Version(1, 0, 0, prerelease="beta")
|
||||
|
||||
def test_hash(self):
|
||||
v1 = Version(1, 2, 3)
|
||||
v2 = Version(1, 2, 3)
|
||||
assert hash(v1) == hash(v2)
|
||||
# Can be used in sets/dicts
|
||||
s = {v1, v2}
|
||||
assert len(s) == 1
|
||||
|
||||
|
||||
class TestMatchesConstraint:
|
||||
"""Tests for matches_constraint function."""
|
||||
|
||||
def test_any_version(self):
|
||||
assert matches_constraint("1.0.0", "*")
|
||||
assert matches_constraint("2.3.4", "*")
|
||||
assert matches_constraint("1.0.0", "latest")
|
||||
assert matches_constraint("1.0.0", "")
|
||||
|
||||
def test_exact_match(self):
|
||||
assert matches_constraint("1.2.3", "1.2.3")
|
||||
assert not matches_constraint("1.2.4", "1.2.3")
|
||||
assert matches_constraint("1.0.0", "=1.0.0")
|
||||
|
||||
def test_caret_major(self):
|
||||
# ^1.2.3 allows >=1.2.3 and <2.0.0
|
||||
assert matches_constraint("1.2.3", "^1.2.3")
|
||||
assert matches_constraint("1.2.4", "^1.2.3")
|
||||
assert matches_constraint("1.9.9", "^1.2.3")
|
||||
assert not matches_constraint("1.2.2", "^1.2.3")
|
||||
assert not matches_constraint("2.0.0", "^1.2.3")
|
||||
|
||||
def test_caret_zero_minor(self):
|
||||
# ^0.2.3 allows >=0.2.3 and <0.3.0
|
||||
assert matches_constraint("0.2.3", "^0.2.3")
|
||||
assert matches_constraint("0.2.9", "^0.2.3")
|
||||
assert not matches_constraint("0.3.0", "^0.2.3")
|
||||
assert not matches_constraint("0.2.2", "^0.2.3")
|
||||
|
||||
def test_caret_zero_zero(self):
|
||||
# ^0.0.3 allows ONLY 0.0.3
|
||||
assert matches_constraint("0.0.3", "^0.0.3")
|
||||
assert not matches_constraint("0.0.4", "^0.0.3")
|
||||
assert not matches_constraint("0.0.2", "^0.0.3")
|
||||
|
||||
def test_tilde(self):
|
||||
# ~1.2.3 allows >=1.2.3 and <1.3.0
|
||||
assert matches_constraint("1.2.3", "~1.2.3")
|
||||
assert matches_constraint("1.2.9", "~1.2.3")
|
||||
assert not matches_constraint("1.3.0", "~1.2.3")
|
||||
assert not matches_constraint("1.2.2", "~1.2.3")
|
||||
|
||||
def test_greater_than_or_equal(self):
|
||||
assert matches_constraint("1.0.0", ">=1.0.0")
|
||||
assert matches_constraint("2.0.0", ">=1.0.0")
|
||||
assert not matches_constraint("0.9.9", ">=1.0.0")
|
||||
|
||||
def test_less_than_or_equal(self):
|
||||
assert matches_constraint("1.0.0", "<=1.0.0")
|
||||
assert matches_constraint("0.9.0", "<=1.0.0")
|
||||
assert not matches_constraint("1.0.1", "<=1.0.0")
|
||||
|
||||
def test_greater_than(self):
|
||||
assert matches_constraint("1.0.1", ">1.0.0")
|
||||
assert not matches_constraint("1.0.0", ">1.0.0")
|
||||
|
||||
def test_less_than(self):
|
||||
assert matches_constraint("0.9.9", "<1.0.0")
|
||||
assert not matches_constraint("1.0.0", "<1.0.0")
|
||||
|
||||
def test_prerelease_excluded_by_default(self):
|
||||
# Prerelease versions should NOT match ranges without prerelease specifier
|
||||
assert not matches_constraint("1.0.0-alpha", "^1.0.0")
|
||||
assert not matches_constraint("1.0.0-beta", ">=1.0.0")
|
||||
|
||||
def test_invalid_version(self):
|
||||
assert not matches_constraint("invalid", "^1.0.0")
|
||||
|
||||
|
||||
class TestFindBestMatch:
|
||||
"""Tests for find_best_match function."""
|
||||
|
||||
def test_finds_highest(self):
|
||||
versions = ["1.0.0", "1.1.0", "1.2.0", "2.0.0"]
|
||||
assert find_best_match(versions, "*") == "2.0.0"
|
||||
|
||||
def test_respects_constraint(self):
|
||||
versions = ["1.0.0", "1.1.0", "1.2.0", "2.0.0"]
|
||||
assert find_best_match(versions, "^1.0.0") == "1.2.0"
|
||||
|
||||
def test_no_match(self):
|
||||
versions = ["1.0.0", "1.1.0"]
|
||||
assert find_best_match(versions, ">=2.0.0") is None
|
||||
|
||||
def test_exact_match(self):
|
||||
versions = ["1.0.0", "1.1.0", "1.2.0"]
|
||||
assert find_best_match(versions, "1.1.0") == "1.1.0"
|
||||
|
||||
def test_empty_versions(self):
|
||||
assert find_best_match([], "*") is None
|
||||
|
||||
|
||||
class TestIsValidVersion:
|
||||
"""Tests for is_valid_version function."""
|
||||
|
||||
def test_valid(self):
|
||||
assert is_valid_version("1.0.0")
|
||||
assert is_valid_version("0.1.0")
|
||||
assert is_valid_version("1.0.0-alpha")
|
||||
assert is_valid_version("1.0.0+build")
|
||||
|
||||
def test_invalid(self):
|
||||
assert not is_valid_version("")
|
||||
assert not is_valid_version("1.0")
|
||||
assert not is_valid_version("invalid")
|
||||
|
||||
|
||||
class TestCompareVersions:
|
||||
"""Tests for compare_versions function."""
|
||||
|
||||
def test_less_than(self):
|
||||
assert compare_versions("1.0.0", "2.0.0") == -1
|
||||
assert compare_versions("1.0.0", "1.1.0") == -1
|
||||
|
||||
def test_equal(self):
|
||||
assert compare_versions("1.0.0", "1.0.0") == 0
|
||||
|
||||
def test_greater_than(self):
|
||||
assert compare_versions("2.0.0", "1.0.0") == 1
|
||||
assert compare_versions("1.1.0", "1.0.0") == 1
|
||||
|
||||
def test_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
compare_versions("invalid", "1.0.0")
|
||||
with pytest.raises(ValueError):
|
||||
compare_versions("1.0.0", "invalid")
|
||||
Reference in New Issue
Block a user