diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 54e1b467..85628fe2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,9 @@ jobs: test: name: ${{ matrix.platform }} py${{ matrix.python-version }} runs-on: ${{ matrix.platform }} + defaults: + run: + shell: bash -el {0} strategy: matrix: platform: [ubuntu-latest, windows-latest, macos-latest] @@ -25,21 +28,33 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: conda-incubator/setup-miniconda@v2 with: python-version: ${{ matrix.python-version }} + activate-environment: "" + auto-activate-base: true - - name: Install dependencies cli + - name: Install dependencies on 'base' run: | + conda install conda-lock mamba packaging requests pyyaml -c conda-forge -y + conda install pytest pytest-cov -c conda-forge -y cd constructor-manager-cli - python -m pip install --upgrade pip - python -m pip install setuptools tox tox-gh-actions - pip list + pip install -e . --no-deps - # this runs the platform-specific tests declared in tox.ini - - name: Test with tox + - name: Run tests on 'base' run: | - cd constructor-manager-cli - python -m tox - env: - PLATFORM: ${{ matrix.platform }} + cd src/constructor_manager_cli + pytest constructor_manager_cli --cov=constructor_manager_cli --cov-report term-missing + + - name: Install dependencies on 'napari-0.4.15' + run: | + conda create -n napari-0.4.15 napari=0.4.5=*pyside* -c conda-forge -y + conda install -n napari-0.4.15 pytest pytest-cov -c conda-forge -y + conda activate napari-0.4.15 + cd constructor-manager + pip install -e . --no-deps + - name: Run tests on 'napari-0.4.15' + run: | + conda activate napari-0.4.15 + cd src/constructor_manager + pytest constructor_manager --cov=constructor_manager --cov-report term-missing diff --git a/constructor-manager-cli/README.md b/constructor-manager-cli/README.md index 1dee463d..127b2ecf 100644 --- a/constructor-manager-cli/README.md +++ b/constructor-manager-cli/README.md @@ -405,3 +405,10 @@ This command will install a specified on a fresh environment, deleting the old e ```bash constructor-manager restore "napari=0.4.16=*pyside*" -c conda-forge ``` + +### Run tests + +```bash +cd constructor-manager-cli/src +pytest constructor_manager_cli --cov=constructor_manager_cli --cov-report term-missing +``` diff --git a/constructor-manager-cli/src/constructor_manager_cli/installer.py b/constructor-manager-cli/src/constructor_manager_cli/installer.py index e5b371e6..47a776bb 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/installer.py +++ b/constructor-manager-cli/src/constructor_manager_cli/installer.py @@ -244,7 +244,10 @@ def _get_args(self, arg0, pkg_list: Sequence[str], prefix: Optional[str]): for channel in self._channels: cmd.extend(["-c", channel]) - return tuple(cmd + list(pkg_list)) + if pkg_list: + cmd.extend(pkg_list) + + return tuple(cmd) # -------------------------- Public API ---------------------------------- def info(self) -> Dict[Any, Any]: @@ -253,9 +256,53 @@ def info(self) -> Dict[Any, Any]: res = cast(dict, self._queue_args(args, block=True)) return res - def create( - self, pkg_list: Sequence[str], *, prefix: Optional[str] = None + def list(self, prefix: str, block=True) -> job_id: + """List packages for `prefix`. + + Parameters + ---------- + prefix : str + Prefix from which to list packages. + + Returns + ------- + job_id : int + ID that can be used to cancel the process. + """ + return self._queue_args( + ("list", "--prefix", str(prefix), "--json"), block=block + ) + + def lock( + self, + env_path: str, + platforms: Optional[Tuple[str, ...]] = None, + lockfile: Optional[str] = None, + block: bool = False, ) -> job_id: + """List packages for `prefix`. + + Parameters + ---------- + prefix : str + Prefix from which to list packages. + + Returns + ------- + job_id : int + ID that can be used to cancel the process. + """ + args = ["-f", env_path] + if platforms: + for platform in platforms: + args.extend(["-p", platform]) + + if lockfile: + args.extend(["--lockfile", lockfile]) + + return self._queue_args(args, bin="conda-lock", block=block) + + def create(self, prefix: str, *, pkg_list: Sequence[str] = ()) -> job_id: """Create a new conda environment with `pkg_list` in `prefix`. Parameters @@ -324,49 +371,3 @@ def uninstall( ID that can be used to cancel the process. """ return self._queue_args(self._get_uninstall_args(pkg_list, prefix)) - - def list(self, prefix: str, block=True) -> job_id: - """List packages for `prefix`. - - Parameters - ---------- - prefix : str - Prefix from which to list packages. - - Returns - ------- - job_id : int - ID that can be used to cancel the process. - """ - return self._queue_args( - ("list", "--prefix", str(prefix), "--json"), block=block - ) - - def lock( - self, - env_path: str, - platforms: Optional[Tuple[str, ...]] = None, - lockfile: Optional[str] = None, - block: bool = False, - ) -> job_id: - """List packages for `prefix`. - - Parameters - ---------- - prefix : str - Prefix from which to list packages. - - Returns - ------- - job_id : int - ID that can be used to cancel the process. - """ - args = ["-f", env_path] - if platforms: - for platform in platforms: - args.extend(["-p", platform]) - - if lockfile: - args.extend(["--lockfile", lockfile]) - - return self._queue_args(args, bin="conda-lock", block=block) diff --git a/constructor-manager-cli/src/constructor_manager_cli/main.py b/constructor-manager-cli/src/constructor_manager_cli/main.py index d62faf60..4c147f54 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/main.py +++ b/constructor-manager-cli/src/constructor_manager_cli/main.py @@ -231,8 +231,6 @@ def _handle_excecute(args, lock, lock_created=None): lock_created: bool, optional Whether the lock was created or not, by default ``None``. """ - _execute(args, lock, lock_created) - return try: _execute(args, lock, lock_created) except Exception as e: diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/log.py b/constructor-manager-cli/src/constructor_manager_cli/tests/test_actions.py similarity index 100% rename from constructor-manager-cli/src/constructor_manager_cli/utils/log.py rename to constructor-manager-cli/src/constructor_manager_cli/tests/test_actions.py diff --git a/constructor-manager-cli/src/constructor_manager_cli/tests/test_installer.py b/constructor-manager-cli/src/constructor_manager_cli/tests/test_installer.py index e69de29b..fd4f6fe2 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/tests/test_installer.py +++ b/constructor-manager-cli/src/constructor_manager_cli/tests/test_installer.py @@ -0,0 +1,58 @@ +import shutil +import random +from constructor_manager_cli.installer import CondaInstaller +from constructor_manager_cli.utils.conda import get_base_prefix, get_prefix_by_name + + +def test_conda_installer_info(): + data = CondaInstaller().info() + assert data["conda_prefix"] == str(get_base_prefix()) + + +def test_conda_installer_list(): + pkgs = CondaInstaller().list(get_base_prefix()) + pkg_names = [pkg["name"] for pkg in pkgs] + assert pkgs + assert "conda" in pkg_names + assert "mamba" in pkg_names + + +def test_conda_installer_create_remove(): + prefix = get_prefix_by_name( + f"test-constructor-manager-{random.randint(1000, 9999)}" + ) + if prefix.exists() and prefix.is_dir(): + shutil.rmtree(prefix) + shutil.rmtree(prefix, ignore_errors=True) + + installer = CondaInstaller() + # Create + job_id = installer.create(prefix=prefix) + assert prefix.exists() + assert installer._exit_codes[job_id] == 0 + + # Remove + job_id = installer.remove(prefix=prefix) + assert not prefix.exists() + assert installer._exit_codes[job_id] == 0 + + +def test_conda_installer_install_uninstall(): + prefix = get_base_prefix() + pkg = "loghub" + installer = CondaInstaller() + + # Install + job_id = installer.install([pkg], prefix=prefix) + + pkgs = installer.list(prefix=prefix) + pkg_names = [pkg["name"] for pkg in pkgs] + assert pkg in pkg_names + assert installer._exit_codes[job_id] == 0 + + # Uninstall + job_id = installer.uninstall([pkg], prefix=prefix) + pkgs = installer.list(prefix=prefix) + pkg_names = [pkg["name"] for pkg in pkgs] + assert pkg not in pkg_names + assert installer._exit_codes[job_id] == 0 diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/anaconda.py b/constructor-manager-cli/src/constructor_manager_cli/utils/anaconda.py index f45b8ab6..ba03910d 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/utils/anaconda.py +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/anaconda.py @@ -2,10 +2,9 @@ import re from functools import lru_cache -from typing import List +from typing import List, Optional from constructor_manager_cli.defaults import DEFAULT_CHANNEL -from constructor_manager_cli.utils.packages import normalized_name from constructor_manager_cli.utils.request import get_request from constructor_manager_cli.utils.versions import sort_versions @@ -37,7 +36,7 @@ def conda_package_data( @lru_cache def conda_package_versions( package_name: str, - build: str, + build: Optional[str] = None, channels: List[str] = [DEFAULT_CHANNEL], reverse: bool = False, ) -> List[str]: @@ -85,27 +84,3 @@ def conda_package_versions( ) return sort_versions(set(versions), reverse=reverse) - - -@lru_cache -def plugin_versions( - url: str, -) -> List[str]: - """Return information on package plugins from endpoint in json. - - Parameters - ---------- - url : str - Url to json endpoint. - - Returns - ------- - list of str - Package versions. - """ - response = get_request(url) - plugins = [] - for key in response.json(): - plugins.append(normalized_name(key)) - - return list(sorted(plugins)) diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/conda.py b/constructor-manager-cli/src/constructor_manager_cli/utils/conda.py index 328ac9ce..8e349ba6 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/utils/conda.py +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/conda.py @@ -2,11 +2,9 @@ import sys from pathlib import Path -from typing import List, Optional, Tuple, Union +from typing import Optional, Tuple from conda.models.match_spec import MatchSpec # type: ignore -from constructor_manager_cli.installer import CondaInstaller -from constructor_manager_cli.utils.packages import sentinel_file_name def parse_conda_version_spec(package: str) -> Tuple[str, str, str]: @@ -41,35 +39,6 @@ def parse_conda_version_spec(package: str) -> Tuple[str, str, str]: return package_name, version, build_string -def check_if_constructor_app(package_name, path=None) -> bool: - """FIXME:""" - if path is None: - path = Path(sys.prefix) - - return (path.parent.parent / sentinel_file_name(package_name)).exists() - - -def check_if_conda_environment( - path: Union[Optional[Path], Optional[str]] = None -) -> bool: - """Check if path is a conda environment. - - Parameters - ---------- - path : str, optional - If `None` then check if current process is running in a conda - environment. - - Returns - ------- - bool - """ - if path is None: - path = Path(sys.prefix) - - return (Path(path) / "conda-meta" / "history").exists() - - def get_base_prefix() -> Path: """Get base conda prefix. @@ -110,39 +79,3 @@ def get_prefix_by_name(name: Optional[str] = None) -> Path: return base_prefix else: return base_prefix / "envs" / name - - -def list_packages2(prefix: str, plugins: Optional[List] = None): - """List packages in a conda environment. - - Optionally filter by plugin list. - - Parameters - ---------- - prefix : str - The conda environment prefix. - plugins : list, optional - List of plugins to filter by. - - Returns - ------- - list - List of packages in the environment. - """ - packages = [] - for path in (Path(prefix) / "conda-meta").iterdir(): - if path.is_file() and path.name.endswith(".json"): - parts = path.name.rsplit("-") - b, v, name = parts[-1], parts[-2], "-".join(parts[:-2]) - b = b.replace(".json", "") - packages.append((name, v, b)) - - if plugins is not None: - packages = [pkg for pkg in packages if pkg[0] in plugins] - - return packages - - -def list_packages(): - installer = CondaInstaller() - print("hello", installer.list(sys.prefix)) diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/io.py b/constructor-manager-cli/src/constructor_manager_cli/utils/io.py index 1f720d54..b592c197 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/utils/io.py +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/io.py @@ -2,9 +2,8 @@ import json import os -import sys from pathlib import Path -from typing import List, Tuple +from typing import List, Tuple, Union from constructor_manager_cli.utils.conda import get_prefix_by_name from constructor_manager_cli.utils.packages import ( @@ -14,12 +13,12 @@ def get_broken_envs(package_name: str) -> List[Path]: - """TODO + """Find broken conda application environments. Parameters ---------- - package_name : str - Name of the package. + package_name : str + Name of the package. Returns ------- @@ -49,7 +48,7 @@ def get_broken_envs(package_name: str) -> List[Path]: def get_installed_versions(package_name: str) -> List[Tuple[str, ...]]: - """Check the current conda prefix for installed versions. + """Check the current conda installation for installed versions in environments. Parameters ---------- @@ -87,30 +86,55 @@ def get_installed_versions(package_name: str) -> List[Tuple[str, ...]]: return versions -def check_if_constructor_app(package_name, path=None) -> bool: - """""" - if path is None: - path = Path(sys.prefix) - - return (path.parent.parent / sentinel_file_name(package_name)).exists() +def get_sentinel_path(prefix: Union[Path, str], package_name: str) -> Path: + """Sentinel file path for a given environment. + Parameters + ---------- + prefix : Path or str + Path to the environment. + package_name : str + Name of the package. -def get_sentinel_path(prefix, package_name): - """""" + Returns + ------- + Path + Path to the sentinel file. + """ + prefix = Path(prefix) return prefix / "conda-meta" / sentinel_file_name(package_name) -def create_sentinel_file(package_name, version): - """""" +def create_sentinel_file(package_name: str, version: str): + """Create a sentinel file in the corresponding environment for a given + package and version. + + Parameters + ---------- + package_name : str + Name of the package. + version : str + Version of the package. + """ package_name = normalized_name(package_name) env_name = f"{package_name}-{version}" prefix = get_prefix_by_name(env_name) + with open(get_sentinel_path(prefix, package_name), "w") as f: f.write("") -def remove_sentinel_file(package_name, version): - """""" +def remove_sentinel_file(package_name: str, version: str): + """Remove a sentinel file in the corresponding environment for a given + package and version. + + Parameters + ---------- + package_name : str + Name of the package. + version : str + Version of the package. + """ package_name = normalized_name(package_name) env_name = f"{package_name}-{version}" prefix = get_prefix_by_name(env_name) @@ -141,14 +165,20 @@ def get_env_path() -> Path: return get_config_path() / "env" -def save_state_file(application, packages, channel, dev, plugins): +def save_state_file( + application: str, + packages: List[str], + channels: List[str], + dev: bool, + plugins: List[str], +): """""" base_path = get_state_path() base_path.mkdir(parents=True, exist_ok=True) data = { "application": application, "packages": packages, - "channel": channel, + "channels": channels, "dev": dev, "plugins": plugins, } @@ -156,7 +186,7 @@ def save_state_file(application, packages, channel, dev, plugins): f.write(json.dumps(data, indent=4)) -def load_state_file(application): +def load_state_file(application: str): """""" path = get_state_path() / f"{application}.json" data = {} diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/packages.py b/constructor-manager-cli/src/constructor_manager_cli/utils/packages.py index 12e3957e..a9bc73ac 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/utils/packages.py +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/packages.py @@ -41,27 +41,3 @@ def normalized_name(name: str) -> str: The normalized package name. """ return re.sub(r"[-_.]+", "-", name).lower() - - -def get_package_spec(package, version, build): - """Return the package spec for a package. - - Parameters - ---------- - package : str - The name of the package. - version : str - The version of the package. - build : str - The build string of the package. - - Returns - ------- - str - The package spec. - """ - spec = f"{package}=={version}" - if build: - spec = spec + f"=*{build}*" - - return spec diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/pypi.py b/constructor-manager-cli/src/constructor_manager_cli/utils/pypi.py deleted file mode 100644 index 73fea3f8..00000000 --- a/constructor-manager-cli/src/constructor_manager_cli/utils/pypi.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Convenience functions for searching packages in `pypi.org`.""" - -import re -from functools import lru_cache -from typing import List - -from constructor_manager_cli.utils.request import get_request - - -@lru_cache -def pypi_package_data(package_name: str) -> dict: - """Return package information on package. - - Parameters - ---------- - package_name : str - Name of package. - - Returns - ------- - dict - Package information. - """ - url = f"https://pypi.org/pypi/{package_name}/json" - return get_request(url).json() - - -@lru_cache -def pypi_package_versions(package_name: str) -> List[str]: - """Get available versions of a package on pypi. - - Parameters - ---------- - package_name : str - Name of the package. - - Returns - ------- - list - Versions available on pypi. - """ - url = f"https://pypi.org/simple/{package_name}" - html = get_request(url).text - return re.findall(f">{package_name}-(.+).tar", html) diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/request.py b/constructor-manager-cli/src/constructor_manager_cli/utils/request.py index b6a0a4c0..c50ba223 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/utils/request.py +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/request.py @@ -1,8 +1,11 @@ from functools import lru_cache +from typing import List import requests + from constructor_manager_cli import __version__ from constructor_manager_cli.defaults import DEFAULT_TIMEOUT +from constructor_manager_cli.utils.packages import normalized_name @lru_cache @@ -14,7 +17,7 @@ def _user_agent() -> str: str User agent string. """ - return f"constructor-updater-{__version__}" + return f"constructor-manager-{__version__}" def get_request(url: str) -> requests.Response: @@ -33,3 +36,27 @@ def get_request(url: str) -> requests.Response: session = requests.Session() session.headers.update({"user-agent": _user_agent()}) return session.get(url, timeout=DEFAULT_TIMEOUT) + + +@lru_cache +def plugin_versions( + url: str, +) -> List[str]: + """Return information on package plugins from endpoint in json. + + Parameters + ---------- + url : str + Url to json endpoint. + + Returns + ------- + list of str + Package versions. + """ + response = get_request(url) + plugins = [] + for key in response.json(): + plugins.append(normalized_name(key)) + + return list(sorted(plugins)) diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_anaconda.py b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_anaconda.py new file mode 100644 index 00000000..947bd75c --- /dev/null +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_anaconda.py @@ -0,0 +1,57 @@ +from constructor_manager_cli.utils.anaconda import ( + conda_package_data, + conda_package_versions, +) + + +def test_conda_package_data(): + package = "napari" + data = conda_package_data(package, channel="conda-forge") + expected_keys = [ + "app_entry", + "conda_platforms", + "full_name", + "owner", + "home", + "source_git_url", + "source_git_tag", + "app_type", + "upvoted", + "id", + "app_summary", + "public", + "revision", + "files", + "package_types", + "description", + "releases", + "html_url", + "builds", + "watchers", + "dev_url", + "name", + "license", + "versions", + "url", + "created_at", + "modified_at", + "latest_version", + "summary", + "license_url", + "doc_url", + ] + assert data["name"] == package + for key in expected_keys: + assert key in data + + +def test_conda_package_versions(): + versions = conda_package_versions("napari", channels=("conda-forge", "napari")) + assert versions[0] == "0.2.12" + + +def test_conda_package_versions_build(): + versions = conda_package_versions( + "napari", "*pyside*", channels=("conda-forge", "napari") + ) + assert "0.2.12" not in versions diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_conda.py b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_conda.py new file mode 100644 index 00000000..1599a5e8 --- /dev/null +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_conda.py @@ -0,0 +1,32 @@ +import sys + +import pytest + +from constructor_manager_cli.utils.conda import ( + get_base_prefix, + get_prefix_by_name, + parse_conda_version_spec, +) + + +def test_get_base_prefix(): + assert str(get_base_prefix()) == sys.prefix + + +def test_get_prefix_by_name(): + assert get_prefix_by_name() == get_base_prefix() + assert get_prefix_by_name("base") == get_base_prefix() + assert get_prefix_by_name("foo") == get_base_prefix() / "envs" / "foo" + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("napari=0.4.1=*pyside*", ("napari", "0.4.1", "*pyside*")), + ("napari=0.4.1", ("napari", "0.4.1", "")), + ("napari=*=*pyside*", ("napari", "", "*pyside*")), + ("napari", ("napari", "", "")), + ], +) +def test_parse_conda_version_spec(test_input, expected): + assert parse_conda_version_spec(test_input) == expected diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_packages.py b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_packages.py new file mode 100644 index 00000000..b0977fe2 --- /dev/null +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_packages.py @@ -0,0 +1,23 @@ +import pytest + +from constructor_manager_cli.utils.packages import ( + sentinel_file_name, + normalized_name, +) + + +def test_sentinel_file_name(): + assert sentinel_file_name("foo") == ".foo_is_bundled_constructor" + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("napari_svg", "napari-svg"), + ("napari.svg", "napari-svg"), + ("napari-SVG", "napari-svg"), + ("napari_SVG.2", "napari-svg-2"), + ], +) +def test_normalized_name(test_input, expected): + assert normalized_name(test_input) == expected diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_request.py b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_request.py new file mode 100644 index 00000000..49ec05bc --- /dev/null +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_request.py @@ -0,0 +1,25 @@ +from requests import Response + + +from constructor_manager_cli.utils.request import ( + _user_agent, + get_request, + plugin_versions, +) + + +def test_user_agent(): + from constructor_manager_cli import __version__ + + assert _user_agent() == f"constructor-manager-{__version__}" + + +def test_get_request(): + r = get_request("https://google.com") + assert isinstance(r, Response) + + +def test_plugin_versions(): + data = plugin_versions("https://api.napari-hub.org/plugins") + assert len(data) > 0 + assert "napari-svg" in data diff --git a/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_versions.py b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_versions.py index 86247030..4fd877f9 100644 --- a/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_versions.py +++ b/constructor-manager-cli/src/constructor_manager_cli/utils/tests/test_versions.py @@ -1,5 +1,9 @@ import pytest # type: ignore -from constructor_manager_cli.utils.versions import is_stable_version +from constructor_manager_cli.utils.versions import ( + is_stable_version, + parse_version, + sort_versions, +) @pytest.mark.parametrize( @@ -19,3 +23,32 @@ ) def test_is_stable_version(test_input, expected): assert is_stable_version(test_input) == expected + + +@pytest.mark.parametrize( + "test_input", + [ + "0.4", + "0.4.15", + "0.4.15rc1", + "0.4.15dev0", + "0.4.15beta", + "0.4.15alfa", + "whatever", + ], +) +def test_parse_version(test_input): + str(parse_version(test_input)) == test_input + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ( + ["0.4.1", "0.4.1dev1", "0.1.1", "0.4.1dev0", "0.4.1a1"], + ["0.1.1", "0.4.1dev0", "0.4.1dev1", "0.4.1a1", "0.4.1"], + ), + ], +) +def test_sort_versions(test_input, expected): + assert sort_versions(test_input) == expected