diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 6713549d35..82da24cf4f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -604,7 +604,6 @@ def _require_specify_project() -> Path: raise typer.Exit(1) - # ===== Preset Commands ===== # Moved to presets/_commands.py — registered here to preserve CLI surface. diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index df0b8ddec1..70bcf578bf 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -304,3 +304,26 @@ def _display_project_path(project_root: Path, path: str | Path) -> str: except (OSError, ValueError): return path_obj.as_posix() return rel_path.as_posix() + +def version_satisfies(current: str, required: str) -> bool: + """Check if current version satisfies required version specifier. + + Evaluates the version against the specifier using the project's + prerelease policy (prereleases are allowed). + + Args: + current: Current version (e.g., "0.1.5") + required: Required version specifier (e.g., ">=0.1.0,<2.0.0") + + Returns: + True if version satisfies requirement + """ + from packaging import version as pkg_version + from packaging.specifiers import InvalidSpecifier, SpecifierSet + + try: + current_ver = pkg_version.Version(current) + specifier = SpecifierSet(required) + return specifier.contains(current_ver, prereleases=True) + except (pkg_version.InvalidVersion, InvalidSpecifier): + return False diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 9271a9fde6..acfc3db072 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -28,7 +28,7 @@ from .._init_options import is_ai_skills_enabled from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent -from .._utils import dump_frontmatter, relative_extension_path_violation +from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies from ..catalogs import CatalogEntry as BaseCatalogEntry from ..catalogs import CatalogStackBase from ..shared_infra import verify_archive_sha256 @@ -1279,20 +1279,20 @@ def check_compatibility( CompatibilityError: If extension is incompatible """ required = manifest.requires_speckit_version - current = pkg_version.Version(speckit_version) # Parse version specifier (e.g., ">=0.1.0,<2.0.0") try: - specifier = SpecifierSet(required) - if current not in specifier: - raise CompatibilityError( - f"Extension requires spec-kit {required}, " - f"but {speckit_version} is installed.\n" - f"Upgrade spec-kit with: {REINSTALL_COMMAND}" - ) + SpecifierSet(required) # Just to validate except InvalidSpecifier: raise CompatibilityError(f"Invalid version specifier: {required}") + if not version_satisfies(speckit_version, required): + raise CompatibilityError( + f"Extension requires spec-kit {required}, " + f"but {speckit_version} is installed.\n" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" + ) + return True def install_from_directory( @@ -1871,22 +1871,6 @@ def get_extension(self, extension_id: str) -> Optional[ExtensionManifest]: return None -def version_satisfies(current: str, required: str) -> bool: - """Check if current version satisfies required version specifier. - - Args: - current: Current version (e.g., "0.1.5") - required: Required version specifier (e.g., ">=0.1.0,<2.0.0") - - Returns: - True if version satisfies requirement - """ - try: - current_ver = pkg_version.Version(current) - specifier = SpecifierSet(required) - return current_ver in specifier - except (pkg_version.InvalidVersion, InvalidSpecifier): - return False class CommandRegistrar: diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 8d5c044193..767404dcb0 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -30,7 +30,7 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority from .._init_options import is_ai_skills_enabled from ..integrations.base import IntegrationBase -from .._utils import dump_frontmatter +from .._utils import dump_frontmatter, version_satisfies from ..shared_infra import verify_archive_sha256 @@ -572,19 +572,16 @@ def check_compatibility( PresetCompatibilityError: If pack is incompatible """ required = manifest.requires_speckit_version - current = pkg_version.Version(speckit_version) - try: - specifier = SpecifierSet(required) - if current not in specifier: - raise PresetCompatibilityError( - f"Preset requires spec-kit {required}, " - f"but {speckit_version} is installed.\n" - f"Upgrade spec-kit with: {REINSTALL_COMMAND}" - ) + SpecifierSet(required) # Just to validate except InvalidSpecifier: + raise PresetCompatibilityError(f"Invalid version specifier: {required}") + + if not version_satisfies(speckit_version, required): raise PresetCompatibilityError( - f"Invalid version specifier: {required}" + f"Preset requires spec-kit {required}, " + f"but {speckit_version} is installed.\n" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" ) return True diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e8dc2b7beb..4eed6453be 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -37,8 +37,8 @@ ValidationError, CompatibilityError, normalize_priority, - version_satisfies, ) +from specify_cli._utils import version_satisfies def can_create_symlink(tmp_path: Path) -> bool: @@ -1001,6 +1001,14 @@ def test_check_compatibility_invalid(self, extension_dir, project_dir): with pytest.raises(CompatibilityError, match="Extension requires spec-kit"): manager.check_compatibility(manifest, "0.0.1") + def test_check_compatibility_allows_prerelease_builds(self, extension_dir, project_dir): + """Prerelease spec-kit builds should satisfy compatible version ranges.""" + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + result = manager.check_compatibility(manifest, "0.8.8.dev0") + assert result is True + def test_install_from_directory(self, extension_dir, project_dir): """Test installing extension from directory.""" manager = ExtensionManager(project_dir) @@ -2625,6 +2633,12 @@ def test_version_satisfies_complex(self): assert version_satisfies("1.0.5", ">=1.0.0,!=1.0.3") assert not version_satisfies("1.0.3", ">=1.0.0,!=1.0.3") + def test_version_satisfies_prerelease(self): + """Prerelease builds should satisfy compatible lower bounds, but not higher bounds.""" + assert version_satisfies("0.8.8.dev0", ">=0.2.0") + assert not version_satisfies("0.2.0.dev0", ">=0.2.0") + assert not version_satisfies("0.8.7.dev1", ">=0.8.8") + def test_version_satisfies_invalid(self): """Test invalid version strings.""" assert not version_satisfies("invalid", ">=1.0.0") diff --git a/tests/test_presets.py b/tests/test_presets.py index 0632fe3a89..11671ac149 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -710,6 +710,15 @@ def test_check_compatibility_valid(self, pack_dir, temp_dir): manifest = PresetManifest(pack_dir / "preset.yml") assert manager.check_compatibility(manifest, "0.1.5") is True + def test_check_compatibility_prerelease(self, pack_dir, temp_dir): + """Test compatibility check allows prereleases and fails on boundary.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + # manifest requires >=0.1.0 + assert manager.check_compatibility(manifest, "0.8.8.dev0") is True + with pytest.raises(PresetCompatibilityError, match="Preset requires spec-kit"): + manager.check_compatibility(manifest, "0.1.0.dev0") + def test_check_compatibility_invalid(self, pack_dir, temp_dir): """Test compatibility check with invalid specifier.""" manager = PresetManager(temp_dir)