From 65eb8e0d458b5cca62a4b0adfc1328b3e8c10cba Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 23 Dec 2023 20:29:55 -0500 Subject: [PATCH 01/22] Support translation of ~=/=== version specifiers --- CHANGELOG.md | 5 ++ doc/guide/limitations.md | 38 +++------------ src/whl2conda/api/converter.py | 87 +++++++++++++++++++++++++++++++++- test/api/test_converter.py | 32 ++++++++++++- 4 files changed, 129 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7455cd..6803c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # whl2conda changes +## *in progress* + +* Add `whl2conda build` - limited drop-in replacement for `conda build` +* Support translation of `~=` and `===` version specification operators. + ## [23.9.0] - 2023-9-23 * First official stable release diff --git a/doc/guide/limitations.md b/doc/guide/limitations.md index ba080c4..18239be 100644 --- a/doc/guide/limitations.md +++ b/doc/guide/limitations.md @@ -4,38 +4,14 @@ into noarch python conda packages. It has the following limitations and known issues, some of which will be addressed in future releases. -## Version specifiers are not translated - -Version specifiers in dependencies are simply copied from -the wheel without modification. This works for many cases, -but since the version comparison operators for pip and conda -are slightly different, some version specifiers will not work -properly in conda. Specifically, - -* the *compatible release* operator `~=` is not supported by conda. - To translate, use a double expression with `>=` and `*`, e.g.: - `~= 1.2.3` would become `>=1.2.3,1.2.*` in conda. This form is - also supported by pip, so switching to this format is a viable - workaround for packages under your control. - -* the *arbitrary equality* clause `===` is not supported by conda. - I do not believe there is an equivalent to this in conda, but - this clause is also heavily discouraged in dependencies and - might not even match the corresponding conda package. - -(*There are other operations supported by conda but not pip, but -the are not a concern when translating from pip specifiers.*) - -As a workaround, users can switch to compatible specifier syntax when -possible and otherwise can remove the offending package and add it -back with compatible specifier syntax, e.g.: - -```bash -whl2conda mywheel-1.2.3-py3-none-any.whl -D foo -A 'foo >=1.2.3,1.2.*' -``` +## Arbitrary equality clause in version specifiers don't have a coda equivalent + +The *arbitrary equality* clause `===` is not supported by conda +and there is no equivalent. This clause is also heavily discouraged +in dependencies and probably will not occur that often in practice. -This will be fixed in a future release -(see [issue 84](https://github.com/zuzukin/whl2conda/issues/84)). +We handle this by simplying changinge `===` to `==` but +since this will often not work we also issue a warning. ## Wheel data directories not supported diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py index 7fbcb8f..3d3c185 100644 --- a/src/whl2conda/api/converter.py +++ b/src/whl2conda/api/converter.py @@ -71,6 +71,47 @@ def __compile_requires_dist_re() -> re.Pattern: re.compile(r"""\b(['"])(?P\w+)\1\s*==\s*extra"""), ] +# Version pattern from official python packaging spec: +# https://packaging.python.org/en/latest/specifications/version-specifiers/#appendix-parsing-version-strings-with-regular-expressions +# which original comes from: +# https://github.com/pypa/packaging (either Apache or BSD license) + +PIP_VERSION_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+_pip_version_re = re.compile(
+    r"^\s*(?P[=<>!~]+)\s*(?P" + PIP_VERSION_PATTERN + r")\s*$",
+    re.VERBOSE | re.IGNORECASE,
+)
+
 
 @dataclass
 class RequiresDistEntry:
@@ -560,7 +601,8 @@ def _compute_conda_dependencies(
                 continue
 
             conda_name = pip_name = entry.name
-            version = entry.version
+            version = self.translate_version_spec(entry.version)
+
             # TODO - do something with extras (#36)
             #   download target pip package and its extra dependencies
             # check manual renames first
@@ -719,6 +761,49 @@ def _parse_wheel_metadata(self, wheel_dir: Path) -> MetadataFromWheel:
         )
         return self.wheel_md
 
+    def translate_version_spec(self, pip_version: str) -> str:
+        """
+        Convert a pip version spec to a conda version spec.
+
+        Compatible release specs using the `~=` operator will be turned
+        into two clauses using ">=" and "==", for example
+        `~=1.2.3` will become `>=1.2.3,1.2.*`.
+
+        Arbitrary equality clauses using the `===` operator will be
+        converted to use `==`, but such clauses are likely to fail
+        so a warning will be produced.
+
+        Any leading "v" character in the version will be dropped.
+        (e.g. `v1.2.3` changes to `1.2.3`).
+        """
+        pip_version = pip_version.strip()
+        version_specs = re.split("\s*,\s*", pip_version)
+        for i, spec in enumerate(version_specs):
+            # spec for '~= '
+            # https://packaging.python.org/en/latest/specifications/version-specifiers/#compatible-release
+            if m := _pip_version_re.match(spec):
+                operator = m.group("operator")
+                v = m.group("version")
+                if v.startswith("v"): # e.g. convert v1.2 to 1.2
+                    v = v[1:]
+                if operator == "~=":
+                    # compatible operator, e.g. convert ~=1.2.3 to >=1.2.3,==1.2.*
+                    rv = m.group("release")
+                    rv_parts = rv.split(".")
+                    operator = ">="
+                    if len(rv_parts) > 1:
+                        # technically ~=1 is not valid, but if we see it, turn it into >=1
+                        v += f",=={'.'.join(rv_parts[:-1])}.*"
+                elif operator == "===":
+                    operator = "=="
+                    # TODO perhaps treat as an error in "strict" mode
+                    self._warn("Converted arbitrary equality clause %s to ==%s - may not match!", spec, v)
+                version_specs[i] = f"{operator}{v}"
+            else:
+                self._warn("Cannot convert bad version spec: '%s'", spec)
+
+        return ",".join(version_specs)
+
     def _extract_wheel(self, temp_dir: Path) -> Path:
         self.logger.info("Reading %s", self.wheel_path)
         wheel = WheelFile(self.wheel_path)
diff --git a/test/api/test_converter.py b/test/api/test_converter.py
index 3a293ba..482c75c 100644
--- a/test/api/test_converter.py
+++ b/test/api/test_converter.py
@@ -35,7 +35,7 @@
     CondaPackageFormat,
     DependencyRename,
     RequiresDistEntry,
-    Wheel2CondaError,
+    Wheel2CondaError, Wheel2CondaConverter,
 )
 from whl2conda.cli.convert import do_build_wheel
 from .converter import ConverterTestCaseFactory
@@ -504,3 +504,33 @@ def fake_input(prompt: str) -> str:
     prompts = iter(["Overwrite?"])
     responses = iter(["yes"])
     case.build()
+
+
+def test_version_translation(
+    tmp_path: Path,
+    caplog: pytest.LogCaptureFixture
+) -> None:
+    """Test for Wheel2CondaConverter.translate_version_spec"""
+    converter = Wheel2CondaConverter(tmp_path, tmp_path)
+    for spec, expected in {
+        "~= 1.2.3" : ">=1.2.3,==1.2.*",
+        "~=1" : ">=1",
+        ">=3.2 , ~=1.2.4.dev4" : ">=3.2,>=1.2.4.dev4,==1.2.*",
+        " >=1.2.3 , <4.0" : ">=1.2.3,<4.0",
+        " >v1.2+foo" : ">1.2+foo"
+    }.items():
+        assert converter.translate_version_spec(spec) == expected
+
+    caplog.clear()
+    assert converter.translate_version_spec("bad-version") =="bad-version"
+    assert len(caplog.records) == 1
+    logrec = caplog.records[0]
+    assert logrec.levelname == "WARNING"
+    assert "Cannot convert bad version" in logrec.message
+
+    caplog.clear()
+    assert converter.translate_version_spec("===1.2.3") == "==1.2.3"
+    assert len(caplog.records) == 1
+    logrec = caplog.records[0]
+    assert logrec.levelname == "WARNING"
+    assert "Converted arbitrary equality" in logrec.message

From 27e53039a6ba4dbdbffa27148fe72702f7dd6f12 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Fri, 5 Jan 2024 12:17:26 -0500
Subject: [PATCH 02/22] Use UTF8 in dist-info metadata (#112)

---
 src/whl2conda/api/converter.py | 32 ++++++++++++++++++++++++++------
 test/api/test_converter.py     |  4 ++--
 test/api/validator.py          | 20 ++++++++++++++------
 3 files changed, 42 insertions(+), 14 deletions(-)

diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py
index 3d3c185..06711d7 100644
--- a/src/whl2conda/api/converter.py
+++ b/src/whl2conda/api/converter.py
@@ -22,6 +22,7 @@
 import configparser
 import dataclasses
 import email
+import email.policy
 import io
 import json
 import logging
@@ -64,7 +65,7 @@ def __compile_requires_dist_re() -> re.Pattern:
     )
 
 
-_requires_dist_re = __compile_requires_dist_re()
+requires_dist_re = __compile_requires_dist_re()
 
 _extra_marker_re = [
     re.compile(r"""\bextra\s*==\s*(['"])(?P\w+)\1"""),
@@ -107,10 +108,13 @@ def __compile_requires_dist_re() -> re.Pattern:
     (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
 """
 
-_pip_version_re = re.compile(
+pip_version_re = re.compile(
     r"^\s*(?P[=<>!~]+)\s*(?P" + PIP_VERSION_PATTERN + r")\s*$",
     re.VERBOSE | re.IGNORECASE,
 )
+"""
+Regular expression matching pip version spec
+"""
 
 
 @dataclass
@@ -151,7 +155,7 @@ def parse(cls, raw: str) -> RequiresDistEntry:
         Raises:
             SyntaxError: if entry is not properly formatted.
         """
-        m = _requires_dist_re.fullmatch(raw)
+        m = requires_dist_re.fullmatch(raw)
         if not m:
             raise SyntaxError(f"Cannot parse Requires-Dist entry: {repr(raw)}")
         entry = RequiresDistEntry(name=m.group("name"))
@@ -363,6 +367,22 @@ def convert(self) -> Path:
 
             return conda_pkg_path
 
+    @classmethod
+    def read_metadata_file(cls, file: Path) -> email.message.Message:
+        """
+        Read a wheel email-formatted metadata file (e.g. METADATA, WHEEL)
+
+        Args:
+            file: path to file
+
+        Returns:
+            Message object
+        """
+        return email.message_from_string(
+            file.read_text(encoding="utf8"),
+            policy=email.policy.EmailPolicy(utf8=True, refold_source="none"),
+        )
+
     def _conda_package_path(self, package_name: str, version: str) -> Path:
         """Construct conda package file path"""
         if self.out_format is CondaPackageFormat.TREE:
@@ -683,7 +703,7 @@ def _copy_licenses(self, conda_info_dir: Path, wheel_md: MetadataFromWheel) -> N
     def _parse_wheel_metadata(self, wheel_dir: Path) -> MetadataFromWheel:
         wheel_info_dir = next(wheel_dir.glob("*.dist-info"))
         WHEEL_file = wheel_info_dir.joinpath("WHEEL")
-        WHEEL_msg = email.message_from_string(WHEEL_file.read_text("utf8"))
+        WHEEL_msg = self.read_metadata_file(WHEEL_file)
         # https://peps.python.org/pep-0427/#what-s-the-deal-with-purelib-vs-platlib
 
         is_pure_lib = WHEEL_msg.get("Root-Is-Purelib", "").lower() == "true"
@@ -702,7 +722,7 @@ def _parse_wheel_metadata(self, wheel_dir: Path) -> MetadataFromWheel:
         md: dict[str, list[Any]] = {}
         # Metdata spec: https://packaging.python.org/en/latest/specifications/core-metadata/
         # Required keys: Metadata-Version, Name, Version
-        md_msg = email.message_from_string(wheel_md_file.read_text())
+        md_msg = self.read_metadata_file(wheel_md_file)
         md_version_str = md_msg.get("Metadata-Version")
         if md_version_str not in self.SUPPORTED_METADATA_VERSIONS:
             # TODO - perhaps just warn about this if not in "strict" mode
@@ -781,7 +801,7 @@ def translate_version_spec(self, pip_version: str) -> str:
         for i, spec in enumerate(version_specs):
             # spec for '~= '
             # https://packaging.python.org/en/latest/specifications/version-specifiers/#compatible-release
-            if m := _pip_version_re.match(spec):
+            if m := pip_version_re.match(spec):
                 operator = m.group("operator")
                 v = m.group("version")
                 if v.startswith("v"): # e.g. convert v1.2 to 1.2
diff --git a/test/api/test_converter.py b/test/api/test_converter.py
index 482c75c..0fa8dbe 100644
--- a/test/api/test_converter.py
+++ b/test/api/test_converter.py
@@ -410,7 +410,7 @@ def test_bad_wheels(
     extract_info_dir = next(extract_dir.glob("*.dist-info"))
 
     WHEEL_file = extract_info_dir / 'WHEEL'
-    WHEEL_msg = email.message_from_string(WHEEL_file.read_text("utf8"))
+    WHEEL_msg = Wheel2CondaConverter.read_metadata_file(WHEEL_file)
 
     #
     # write bad wheelversion
@@ -456,7 +456,7 @@ def test_bad_wheels(
     WHEEL_file.write_text(WHEEL_msg.as_string())
 
     METADATA_file = extract_info_dir / 'METADATA'
-    METADATA_msg = email.message_from_string(METADATA_file.read_text("utf8"))
+    METADATA_msg = Wheel2CondaConverter.read_metadata_file(METADATA_file)
     METADATA_msg.replace_header("Metadata-Version", "999.2")
     METADATA_file.write_text(METADATA_msg.as_string())
 
diff --git a/test/api/validator.py b/test/api/validator.py
index 7e474a5..e56cf71 100644
--- a/test/api/validator.py
+++ b/test/api/validator.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Christopher Barber
+#  Copyright 2023-2024 Christopher Barber
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
@@ -93,7 +93,7 @@ def validate(
     def _parse_wheel_metadata(cls, wheel_dir: Path) -> dict[str, Any]:
         dist_info_dir = next(wheel_dir.glob("*.dist-info"))
         md_file = dist_info_dir / "METADATA"
-        md_msg = email.message_from_string(md_file.read_text())
+        md_msg = Wheel2CondaConverter.read_metadata_file(md_file)
 
         list_keys = set(s.lower() for s in Wheel2CondaConverter.MULTI_USE_METADATA_KEYS)
         md: dict[str, Any] = {}
@@ -105,7 +105,7 @@ def _parse_wheel_metadata(cls, wheel_dir: Path) -> dict[str, Any]:
                 md[key] = value
 
         wheel_file = dist_info_dir / "WHEEL"
-        wheel_msg = email.message_from_string(wheel_file.read_text())
+        wheel_msg = Wheel2CondaConverter.read_metadata_file(wheel_file)
         if build := wheel_msg.get("Build"):
             md["build"] = build
 
@@ -189,7 +189,7 @@ def _validate_about(self, info_dir: Path) -> None:
         doc_url = ""
         for urlpair in md.get("project-url", ()):
             key, url = re.split(r"\s*,\s*", urlpair)
-            assert extra.get(key) == url
+            assert extra.get(key) == url, f"{key=} {extra.get(key)} != {url}"
             first_word = re.split(r"\W+", key)[0].lower()
             if first_word in {"doc", "documentation"}:
                 doc_url = url
@@ -246,13 +246,21 @@ def _validate_dist_info(self) -> None:
                     assert wheel_require.extras == dist_require.extras
                     assert wheel_require.generic == dist_require.generic
             for md in [dist_md, wheel_md]:
-                del md["requires-dist"]
+                try:
+                    del md["requires-dist"]
+                except KeyError:
+                    pass
             provides_extra: list[str] = dist_md["provides-extra"]
             assert 'original' in provides_extra
             provides_extra.remove('original')
             if not provides_extra:
                 del dist_md["provides-extra"]
-            assert dist_md == wheel_md
+            if dist_md != wheel_md:
+                print("== dist-info metadata ==")
+                print(json.dumps(dist_md, sort_keys=True, indent=2))
+                print("== original wheel metadata ==")
+                print(json.dumps(wheel_md, sort_keys=True, indent=2))
+                assert dist_md == wheel_md
 
     def _validate_hash_input(self, info_dir: Path) -> None:
         hash_input_file = info_dir.joinpath("hash_input.json")

From 5ac128fb8b1857941153cea5c06b98a117537bd7 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Fri, 5 Jan 2024 12:23:28 -0500
Subject: [PATCH 03/22] Support space in project url key (#113)

---
 src/whl2conda/api/converter.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py
index 06711d7..b26e10a 100644
--- a/src/whl2conda/api/converter.py
+++ b/src/whl2conda/api/converter.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Christopher Barber
+#  Copyright 2023-2024 Christopher Barber
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
@@ -559,10 +559,11 @@ def _write_about(self, conda_info_dir: Path, md: dict[str, Any]) -> None:
             whl2conda_version=__version__,
         )
 
-        proj_url_pat = re.compile(r"\s*(?P\w+)\s*,\s*(?P\w.*)\s*")
+        proj_url_pat = re.compile(r"\s*(?P\w+(\s+\w+)*)\s*,\s*(?P\w.*)\s*")
         doc_url: Optional[str] = None
         dev_url: Optional[str] = None
         for urlline in md.get("project-url", ()):
+            print(urlline)
             if m := proj_url_pat.match(urlline):  # pragma: no branch
                 key = m.group("key")
                 url = m.group("url")

From 171a43bfc64dbb80649431fd2c227b43af1d67c5 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Fri, 5 Jan 2024 12:26:28 -0500
Subject: [PATCH 04/22] Support space in project url key (#113)

---
 test-projects/simple/pyproject.toml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test-projects/simple/pyproject.toml b/test-projects/simple/pyproject.toml
index bd4525a..4cefa51 100644
--- a/test-projects/simple/pyproject.toml
+++ b/test-projects/simple/pyproject.toml
@@ -33,3 +33,4 @@ test = ["pytest", "pylint"]
 
 [project.urls]
 Homepage = "https://github.com/analog-cbarber/tree/main/test-projects/simple"
+"Issue Tracker" = "https://github.com/analog-cbarber/tree/main/test-projects/simple/issues"

From 6a48567ea0806763638a9e66ba1c15b65204c324 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 09:38:21 -0500
Subject: [PATCH 05/22] Remove stray debug print statement

---
 src/whl2conda/api/converter.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py
index b26e10a..3ecad30 100644
--- a/src/whl2conda/api/converter.py
+++ b/src/whl2conda/api/converter.py
@@ -563,7 +563,6 @@ def _write_about(self, conda_info_dir: Path, md: dict[str, Any]) -> None:
         doc_url: Optional[str] = None
         dev_url: Optional[str] = None
         for urlline in md.get("project-url", ()):
-            print(urlline)
             if m := proj_url_pat.match(urlline):  # pragma: no branch
                 key = m.group("key")
                 url = m.group("url")

From 764534d9a484ba49cb01a0198ac0c2d4ac157684 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 10:48:05 -0500
Subject: [PATCH 06/22] Workaround for conda install bug (#114)

---
 src/whl2conda/cli/install.py | 175 +++++++++++++++++++++++++----------
 test/cli/test_install.py     |   6 +-
 2 files changed, 133 insertions(+), 48 deletions(-)

diff --git a/src/whl2conda/cli/install.py b/src/whl2conda/cli/install.py
index 57fcdad..8c6d650 100644
--- a/src/whl2conda/cli/install.py
+++ b/src/whl2conda/cli/install.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Christopher Barber
+#  Copyright 2023-2024 Christopher Barber
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
@@ -20,6 +20,7 @@
 
 import argparse
 import json
+import os
 import shutil
 import subprocess
 import tempfile
@@ -30,6 +31,7 @@
 from conda_package_handling.api import extract as extract_conda_pkg
 
 from .common import dedent, existing_path, add_markdown_help, get_conda_bld_path
+from ..api.converter import pip_version_re, requires_dist_re
 
 __all__ = ["install_main"]
 
@@ -44,7 +46,8 @@ class InstallArgs:
     use_mamba: bool
     name: str
     only_deps: bool
-    package_file: Path
+    no_deps: bool
+    package_files: list[Path]
     prefix: Optional[Path]
     yes: bool
     remaining_args: list[str]
@@ -70,15 +73,15 @@ def install_main(
     parser = argparse.ArgumentParser(
         usage=dedent(
             """
-            %(prog)s (-p  | -n )  [options]
+            %(prog)s (-p  | -n ) ... [options]
                    %(prog)s --conda-bld  [options]
             """
         ),
         description=dedent(
             """
-            Install a conda package file
+            Install conda package files
             
-            This can be used to install a conda package file
+            This can be used to install one or more conda package files
             (generated by `whl2conda build`) either into a
             conda environment (for testing) or into your
             local conda build directory.
@@ -90,9 +93,10 @@ def install_main(
     )
 
     parser.add_argument(
-        "package_file",
+        "package_files",
         metavar="",
         type=existing_path,
+        nargs="+",
         help=dedent(
             """
             Conda package file to be installed
@@ -132,11 +136,17 @@ def install_main(
         "--create", action="store_true", help="Create environment if it does not exist."
     )
 
-    env_options.add_argument(
+    deps_options = env_options.add_mutually_exclusive_group()
+    deps_options.add_argument(
         "--only-deps",
         action="store_true",
         help="Only install package dependencies, not the package itself.",
     )
+    deps_options.add_argument(
+        "--no-deps",
+        action="store_true",
+        help="Only packages themselves without any dependencies."
+    )
 
     env_options.add_argument(
         "--mamba",
@@ -175,28 +185,37 @@ def install_main(
 
     parsed = InstallArgs.parse(parser, args)
 
-    conda_file: Path = parsed.package_file
-
-    conda_fname = str(conda_file.name)
-    if not (conda_fname.endswith(".conda") or conda_fname.endswith(".tar.bz2")):
-        parser.error(f"Package file has unsupported suffix: {conda_file}")
-
-    with tempfile.TemporaryDirectory(prefix="whl2conda-install-") as tmpdir:
-        try:
-            # We don't need to do this for the conda-bld install, but it
-            # provides an extra validity check on the file.
-            tmp_path = Path(tmpdir)
-            extract_conda_pkg(str(conda_file), dest_dir=tmp_path)
-            index = json.loads(tmp_path.joinpath("info", "index.json").read_text("utf"))
-            subdir = index["subdir"]
-            dependencies = index.get("depends", [])
-        except Exception as ex:  # pylint: disable=broad-exception-caught
-            parser.error(f"Cannot extract conda package '{conda_file}:\n{ex}'")
+    conda_files = parsed.package_files
+
+    subdir = "noarch"
+    dependencies: list[str] = []
+    file_specs: list[tuple[str,str]] = [] # name/version pairs of package files being installed
+
+    for conda_file in conda_files:
+        conda_fname = str(conda_file.name)
+        if not (conda_fname.endswith(".conda") or conda_fname.endswith(".tar.bz2")):
+            parser.error(f"Package file has unsupported suffix: {conda_file}")
+
+        with tempfile.TemporaryDirectory(prefix="whl2conda-install-") as tmpdir:
+            try:
+                # We don't need to do this for the conda-bld install, but it
+                # provides an extra validity check on the file.
+                tmp_path = Path(tmpdir)
+                extract_conda_pkg(str(conda_file), dest_dir=tmp_path)
+                index = json.loads(tmp_path.joinpath("info", "index.json").read_text("utf"))
+                subdir = index["subdir"]
+                package_name = index["name"]
+                package_version = index.get("version", "")
+                file_specs.append((package_name, package_version))
+                dependencies.extend(index.get("depends", []))
+            except Exception as ex:  # pylint: disable=broad-exception-caught
+                parser.error(f"Cannot extract conda package '{conda_file}:\n{ex}'")
 
     if parsed.conda_bld:
         # Install into conda-bld dir
         conda_bld_install(parsed, subdir)
     else:
+        dependencies = _prune_dependencies(dependencies, file_specs)
         conda_env_install(parsed, dependencies)
 
 
@@ -205,12 +224,14 @@ def conda_bld_install(parsed: InstallArgs, subdir: str):
     conda_bld_path = get_conda_bld_path()
     subdir_path = conda_bld_path.joinpath(subdir)  # e.g. noarch/
 
-    print(f"Installing {parsed.package_file} into {subdir_path}")
+    for package_file in parsed.package_files:
+        print(f"Installing {package_file} into {subdir_path}")
+        if not parsed.dry_run:
+            subdir_path.mkdir(parents=True, exist_ok=True)
+            shutil.copyfile(
+                package_file, subdir_path.joinpath(package_file.name)
+            )
     if not parsed.dry_run:
-        subdir_path.mkdir(parents=True, exist_ok=True)
-        shutil.copyfile(
-            parsed.package_file, subdir_path.joinpath(parsed.package_file.name)
-        )
         subprocess.check_call(
             ["conda", "index", "--subdir", subdir, str(conda_bld_path)]
         )
@@ -219,10 +240,12 @@ def conda_bld_install(parsed: InstallArgs, subdir: str):
 def conda_env_install(parsed: InstallArgs, dependencies: list[str]):
     """Install package into an environment"""
     common_opts: list[str] = []
+    env_opts: list[str] = []
     if parsed.prefix:
-        common_opts.extend(["--prefix", str(parsed.prefix)])
+        env_opts.extend(["--prefix", str(parsed.prefix)])
     else:
-        common_opts.extend(["--name", parsed.name])
+        env_opts.extend(["--name", parsed.name])
+    common_opts.extend(env_opts)
 
     if parsed.yes:
         common_opts.append("--yes")
@@ -230,24 +253,82 @@ def conda_env_install(parsed: InstallArgs, dependencies: list[str]):
         common_opts.append("--dry-run")
 
     conda = "mamba" if parsed.use_mamba else "conda"
-    install_deps_cmd = [conda]
-    if parsed.create:
-        install_deps_cmd.append("create")
-    else:
-        install_deps_cmd.append("install")
-    install_deps_cmd.extend(common_opts)
-    install_deps_cmd.extend(dependencies)
-    install_deps_cmd.extend(parsed.remaining_args)
 
-    subprocess.check_call(install_deps_cmd)
+    if not parsed.no_deps:
+        install_deps_cmd = [conda]
+        if parsed.create:
+            install_deps_cmd.append("create")
+        else:
+            install_deps_cmd.append("install")
+        install_deps_cmd.extend(common_opts)
+        install_deps_cmd.extend(dependencies)
+        install_deps_cmd.extend(parsed.remaining_args)
 
-    if not parsed.only_deps:
-        install_pkg_cmd = [conda, "install"]
-        install_pkg_cmd.extend(common_opts)
-        install_pkg_cmd.append(str(parsed.package_file))
+        subprocess.check_call(install_deps_cmd)
 
+    if not parsed.only_deps:
+        for package_file in parsed.package_files:
+            install_pkg_cmd = [conda, "install"]
+            install_pkg_cmd.extend(common_opts)
+            install_pkg_cmd.append(str(package_file))
+
+            if parsed.dry_run:
+                # dry run of a package file install fails in conda
+                print("Running ", install_pkg_cmd)
+            else:
+                subprocess.check_call(install_pkg_cmd)
+
+        # Workaround for https://github.com/conda/conda/issues/13479
+        # If a package is installed directly from file, then set solver to classic
+        set_solver_cmd = ["conda", "run"] + env_opts + ["conda", "config", "--env", "--set", "solver", "classic"]
         if parsed.dry_run:
-            # dry run of a package file install fails in conda
-            print("Running ", install_pkg_cmd)
+            print("Running ", set_solver_cmd)
         else:
-            subprocess.check_call(install_pkg_cmd)
+            subprocess.check_call(set_solver_cmd)
+
+def _prune_dependencies(dependencies: list[str], file_specs: list[tuple[str,str]]) -> list[str]:
+    """
+    Prunes dependencies list according to arguments
+
+    - Does not attempt to merge package version specs
+    - Removes duplicate specs
+    - Removes references to packages in file_specs
+
+    Arguments:
+        dependencies: input list of dependency strings (package and optional version match specifier)
+        file_specs: list of package name/version tuple for packages being installed from file
+
+    Returns:
+        List of pruned dependencies.
+    """
+
+    exclude_packages: dict[str,str] = dict(file_specs)
+    deps: set[str] = set()
+
+    for dep in dependencies:
+        if m := requires_dist_re.match(dep):
+            name = m.group("name")
+            extra = m.group("extra")
+            version = m.group("version")
+            marker = m.group("marker")
+            if vm := pip_version_re.match(dep):
+                # normalize version
+                operator = vm.group("operator")
+                version_number = vm.group("version")
+                if version.startswith("v"):
+                    version = version[1:]
+                version = f"{operator}{version_number}"
+            if name in exclude_packages:
+                # TODO check version and warn or error if not match dependency
+                continue
+            dep = name
+            if extra:
+                dep += f"[{extra}]"
+            dep += f" {version}"
+            if marker:
+                dep += f" ; {marker}"
+        deps.add(dep)
+
+    return sorted(deps)
+
+
diff --git a/test/cli/test_install.py b/test/cli/test_install.py
index 4cbd0b4..14f5622 100644
--- a/test/cli/test_install.py
+++ b/test/cli/test_install.py
@@ -1,4 +1,4 @@
-#  Copyright 2023 Christopher Barber
+#  Copyright 2023-2024 Christopher Barber
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
@@ -186,6 +186,10 @@ def test_env_install(
     assert "quaternion" in packages_by_name
     assert "simple" in packages_by_name
 
+    # solver should be set to classic to avoid https://github.com/conda/conda/issues/13479
+    d = conda_json("run", "-p", str(prefix), "conda", "config", "--json", "--show", "solver")
+    assert d["solver"] == "classic"
+
     conda_output("create", "-n", "test-env", "python=3.9")
     assert test_env.is_dir()
 

From 255528f5759bc83014dc60e353f9b7dbd20fd3f2 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 10:50:20 -0500
Subject: [PATCH 07/22] Set version to 24.1.0 and update changelog

---
 CHANGELOG.md          | 11 +++++++++--
 src/whl2conda/VERSION |  3 +--
 2 files changed, 10 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6803c10..2a7d9ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,16 @@
 # whl2conda changes
 
-## *in progress*
+## [24.1.0] - *in progress*
 
-* Add `whl2conda build` - limited drop-in replacement for `conda build`
+### Features
+* Add `whl2conda build` - limited experimental drop-in replacement for `conda build`
 * Support translation of `~=` and `===` version specification operators.
+* `whl2conda install` now supports installing multiple package files at once
+
+### Bug fixes
+* Use classic installer in `whl2conda install` environments to work around conda bug (#114)
+* Include project URLs in metadata that have multi-word keys (#113)
+* Write METADATA file in dist-info using UTF8 (#112)
 
 ## [23.9.0] - 2023-9-23
 
diff --git a/src/whl2conda/VERSION b/src/whl2conda/VERSION
index 8aaf15e..7c974b0 100644
--- a/src/whl2conda/VERSION
+++ b/src/whl2conda/VERSION
@@ -1,2 +1 @@
-23.9.0
-
+24.1.0

From 91c40cb43fb7d022ecef75929e34ca3b6d6ac06f Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 10:55:42 -0500
Subject: [PATCH 08/22] pylint cleanup

---
 src/whl2conda/api/converter.py | 2 +-
 src/whl2conda/cli/install.py   | 2 +-
 test/api/test_converter.py     | 1 -
 test/api/validator.py          | 1 -
 4 files changed, 2 insertions(+), 4 deletions(-)

diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py
index 3ecad30..1db9974 100644
--- a/src/whl2conda/api/converter.py
+++ b/src/whl2conda/api/converter.py
@@ -797,7 +797,7 @@ def translate_version_spec(self, pip_version: str) -> str:
         (e.g. `v1.2.3` changes to `1.2.3`).
         """
         pip_version = pip_version.strip()
-        version_specs = re.split("\s*,\s*", pip_version)
+        version_specs = re.split(r"\s*,\s*", pip_version)
         for i, spec in enumerate(version_specs):
             # spec for '~= '
             # https://packaging.python.org/en/latest/specifications/version-specifiers/#compatible-release
diff --git a/src/whl2conda/cli/install.py b/src/whl2conda/cli/install.py
index 8c6d650..55046a9 100644
--- a/src/whl2conda/cli/install.py
+++ b/src/whl2conda/cli/install.py
@@ -20,7 +20,6 @@
 
 import argparse
 import json
-import os
 import shutil
 import subprocess
 import tempfile
@@ -239,6 +238,7 @@ def conda_bld_install(parsed: InstallArgs, subdir: str):
 
 def conda_env_install(parsed: InstallArgs, dependencies: list[str]):
     """Install package into an environment"""
+    # pylint: disable=too-many-branches
     common_opts: list[str] = []
     env_opts: list[str] = []
     if parsed.prefix:
diff --git a/test/api/test_converter.py b/test/api/test_converter.py
index 0fa8dbe..adcd0ca 100644
--- a/test/api/test_converter.py
+++ b/test/api/test_converter.py
@@ -18,7 +18,6 @@
 from __future__ import annotations
 
 # standard
-import email
 import logging
 import re
 import subprocess
diff --git a/test/api/validator.py b/test/api/validator.py
index e56cf71..5a5ee0a 100644
--- a/test/api/validator.py
+++ b/test/api/validator.py
@@ -18,7 +18,6 @@
 from __future__ import annotations
 
 import configparser
-import email
 import hashlib
 import json
 import os.path

From 80c1d45267e4396d93c8fda8359d8b8379f9d35a Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 10:56:57 -0500
Subject: [PATCH 09/22] mypy cleanup

---
 test/api/test_stdrename.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/api/test_stdrename.py b/test/api/test_stdrename.py
index aea91e4..f8055e6 100644
--- a/test/api/test_stdrename.py
+++ b/test/api/test_stdrename.py
@@ -77,11 +77,11 @@ def test_download_mappings() -> None:
     del d.headers["Etag"]
     assert d.etag == ""
     del d.headers["Cache-Control"]
-    assert d.max_age == (d.expires - d.date).seconds
+    assert d.max_age == (d.expires - d.date).seconds  # type: ignore[operator]
     now = email.utils.localtime()
     del d.headers["Date"]
     later = email.utils.localtime()
-    assert (d.expires - later).seconds <= d.max_age <= (d.expires - now).seconds
+    assert (d.expires - later).seconds <= d.max_age <= (d.expires - now).seconds  # type: ignore[operator]
     del d.headers["Expires"]
     assert d.max_age == -1
     d.headers.add_header("Cache-Control", "max-age=42")

From f083651cf13b995f1621f1ad0bcae1dd12a20d91 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 11:17:55 -0500
Subject: [PATCH 10/22] Add ruff support

---
 Makefile        | 15 +++++++++++----
 environment.yml | 17 +++++++++--------
 2 files changed, 20 insertions(+), 12 deletions(-)

diff --git a/Makefile b/Makefile
index 19248c5..c21b5e6 100644
--- a/Makefile
+++ b/Makefile
@@ -39,9 +39,10 @@ help:
 	"--- testing ---\n" \
 	"pylint        - run pylint checks\n" \
 	"mypy          - run mypy type checks\n" \
-	"black-check   - check black formatting\n" \
+	"check-format  - check formatting\n" \
 	"lint          - run all lint checkers\n" \
 	"pytest        - run pytests\n" \
+	"ruff          - run ruff checker\n" \
 	"coverage      - run pytests with test coverage\n" \
 	"open-coverage - open HTML coverage report\n" \
 	"\n" \
@@ -90,8 +91,8 @@ dev-install:
 # Test and lint targets
 #
 
-black-check:
-	$(CONDA_RUN) black --check src test
+# backward support - just use ruff-format-check instead
+black-check: check-format
 
 pylint:
 	$(CONDA_RUN) pylint src test
@@ -99,11 +100,17 @@ pylint:
 mypy:
 	$(CONDA_RUN) mypy
 
-lint: pylint mypy black-check
+lint: ruff pylint mypy black-check
 
 pytest:
 	$(CONDA_RUN) pytest -s test
 
+ruff:
+	$(CONDA_RUN) ruff check
+
+check-format:
+	$(CONDA_RUN) ruff format --check src test
+
 test: pytest
 
 coverage:
diff --git a/environment.yml b/environment.yml
index dd70c98..e864783 100644
--- a/environment.yml
+++ b/environment.yml
@@ -7,27 +7,28 @@ dependencies:
   # runtime
   - conda-package-handling >=2.2,<3.0
   - platformdirs >=3.10
-  - pyyaml
+  - pyyaml >=6.0
   - tomlkit >=0.12
   - wheel >=0.41
   # build
   - build >=0.7.0
-  - hatchling >=1.18,<2.0
+  - hatchling >=1.21,<2.0
   - make >=4.3
   # testing
-  - mypy >=1.5,<2.0
-  - pylint >=2.17
+  - mypy >=1.8,<2.0
+  - pylint >=3.0,<4.0
   - pytest >=7.4,<8.0
   - pytest-cov >=4.1.0,<5.0
+  - ruff >=0.1.11
   - types-pyyaml >=6.0
   # documentation
-  - black >=22.6
+  - black >=23.12
   - mike >=1.1,<2.0
   - mkdocs >=1.5,<2.0
-  - mkdocstrings-python >=1.3,<2.0
+  - mkdocstrings-python >=1.7,<2.0
   - mkdocs-material >9.1
-  - mkdocstrings-python-xref >=1.5.2,<2.0
-  - linkchecker >=10.2.1
+  - mkdocstrings-python-xref >=1.5.3,<2.0
+  - linkchecker >=10.4
   # for mkdocs-awesome-pages-plugin
   - natsort >=8.4
   - wcmatch >=8.5

From eebae991ba3dee290e08ece62df49872c3fdde02 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 11:18:09 -0500
Subject: [PATCH 11/22] ruff check cleanup

---
 test/api/test_converter.py | 14 +++++++++-----
 test/api/test_external.py  |  4 +++-
 test/cli/test_convert.py   |  4 +++-
 test/cli/test_diff.py      |  4 +++-
 test/cli/test_install.py   |  4 +++-
 5 files changed, 21 insertions(+), 9 deletions(-)

diff --git a/test/api/test_converter.py b/test/api/test_converter.py
index adcd0ca..f448337 100644
--- a/test/api/test_converter.py
+++ b/test/api/test_converter.py
@@ -38,13 +38,15 @@
 )
 from whl2conda.cli.convert import do_build_wheel
 from .converter import ConverterTestCaseFactory
-from .converter import test_case  # pylint: disable=unused-import
 
-from ..test_packages import (  # pylint: disable=unused-import
-    markers_wheel,
-    setup_wheel,
-    simple_wheel,
+# pylint: disable=unused-import
+from .converter import test_case  # noqa: F401
+from ..test_packages import (
+    markers_wheel,  # noqa: F401
+    setup_wheel,  # noqa: F401
+    simple_wheel,  # noqa: F401
 )
+# pylint: enable=unused-import
 
 this_dir = Path(__file__).parent.absolute()
 root_dir = this_dir.parent.parent
@@ -184,6 +186,8 @@ def test_dependency_rename() -> None:
 # Converter test cases
 #
 
+# ignore redefinition of test_case
+# ruff: noqa: F811
 
 def test_this(test_case: ConverterTestCaseFactory) -> None:
     """Test using this own project's wheel"""
diff --git a/test/api/test_external.py b/test/api/test_external.py
index fd10760..02817d8 100644
--- a/test/api/test_external.py
+++ b/test/api/test_external.py
@@ -25,7 +25,7 @@
 from whl2conda.api.converter import Wheel2CondaError
 
 from .converter import ConverterTestCaseFactory
-from .converter import test_case  # pylint: disable=unused-import
+from .converter import test_case  # pylint: disable=unused-import # noqa: F401
 
 
 # pylint: disable=redefined-outer-name
@@ -34,6 +34,8 @@
 # External pypi tests
 #
 
+# ignore redefinition of test_case
+# ruff: noqa: F811
 
 @pytest.mark.external
 def test_pypi_tomlkit(test_case: ConverterTestCaseFactory):
diff --git a/test/cli/test_convert.py b/test/cli/test_convert.py
index 4b00f90..efe1655 100644
--- a/test/cli/test_convert.py
+++ b/test/cli/test_convert.py
@@ -43,7 +43,7 @@
 
 from ..impl.test_prompt import monkeypatch_interactive
 
-from ..test_packages import simple_wheel  # pylint: disable=unused-import
+from ..test_packages import simple_wheel  # pylint: disable=unused-import # noqa: F401
 
 this_dir = Path(__file__).parent.absolute()
 root_dir = this_dir.parent.parent
@@ -623,6 +623,8 @@ def fake_call(cmd: Sequence[str], **_kwargs) -> None:
         project_root, wheel_dir, no_deps=False, no_build_isolation=True
     )
 
+# ignore redefinition of test_case
+# ruff: noqa: F811
 
 def test_input_wheel(
     test_case: CliTestCaseFactory,
diff --git a/test/cli/test_diff.py b/test/cli/test_diff.py
index abbbdb9..3c6f6b4 100644
--- a/test/cli/test_diff.py
+++ b/test/cli/test_diff.py
@@ -27,10 +27,12 @@
 from whl2conda.cli import main
 
 # pylint: disable=unused-import
-from ..test_packages import simple_conda_package, simple_wheel
+from ..test_packages import simple_conda_package, simple_wheel  # noqa: F401
 
 # pylint: disable=redefined-outer-name
 
+# ignore redefinition of simple_conda_package
+# ruff: noqa: F811
 
 def test_diff_errors(
     capsys: pytest.CaptureFixture,
diff --git a/test/cli/test_install.py b/test/cli/test_install.py
index 14f5622..8cc9079 100644
--- a/test/cli/test_install.py
+++ b/test/cli/test_install.py
@@ -28,7 +28,7 @@
 from ..test_conda import conda_config, conda_output, conda_json
 
 # pylint: disable=unused-import
-from ..test_packages import simple_conda_package, simple_wheel
+from ..test_packages import simple_conda_package, simple_wheel  # noqa: F401
 
 # pylint: disable=redefined-outer-name
 
@@ -64,6 +64,8 @@ def test_errors(capsys: pytest.CaptureFixture, tmp_path: Path):
     _out, err = capsys.readouterr()
     assert "Cannot extract" in err
 
+# ignore redefinition of simple_conda_package
+# ruff: noqa: F811
 
 # pylint: disable=too-many-locals
 def test_bld_install(

From f9704f2e07d327bbb9b83a98ada9cc01338c4cad Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 11:32:44 -0500
Subject: [PATCH 12/22] ruff format

---
 conftest.py                    |  4 +--
 pyproject.toml                 |  8 +++++
 src/whl2conda/__main__.py      |  1 +
 src/whl2conda/api/converter.py |  8 +++--
 src/whl2conda/cli/build.py     | 20 +++++------
 src/whl2conda/cli/common.py    |  1 +
 src/whl2conda/cli/install.py   | 39 ++++++++++++++--------
 src/whl2conda/cli/main.py      |  1 +
 src/whl2conda/impl/prompt.py   |  1 +
 test-projects/setup/setup.py   | 26 +++++++--------
 test/api/converter.py          | 12 +++++--
 test/api/test_converter.py     | 51 ++++++++++++++--------------
 test/api/test_external.py      | 25 +++++++-------
 test/cli/test_config.py        |  1 +
 test/cli/test_convert.py       |  2 ++
 test/cli/test_diff.py          |  1 +
 test/cli/test_install.py       | 61 ++++++++++++++++++----------------
 test/cli/test_main.py          |  1 +
 test/test_packages.py          | 15 ++++-----
 19 files changed, 157 insertions(+), 121 deletions(-)

diff --git a/conftest.py b/conftest.py
index 3ff432f..a959842 100644
--- a/conftest.py
+++ b/conftest.py
@@ -43,9 +43,7 @@ def pytest_configure(config):
     config.addinivalue_line(
         "markers", "external: mark test as depending on extenral pypi package to run"
     )
-    config.addinivalue_line(
-        "markers", "slow: mark test as slow to run"
-    )
+    config.addinivalue_line("markers", "slow: mark test as slow to run")
 
 
 def pytest_collection_modifyitems(config, items):
diff --git a/pyproject.toml b/pyproject.toml
index 909c1ff..2a99b6b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -117,3 +117,11 @@ disable = [
     "useless-suppression",
     "wrong-import-order"
 ]
+
+[tool.ruff]
+line-length = 88
+
+[tool.ruff.format]
+line-ending = "lf"
+preview = true
+quote-style = "preserve"
diff --git a/src/whl2conda/__main__.py b/src/whl2conda/__main__.py
index 60be936..5eb7584 100644
--- a/src/whl2conda/__main__.py
+++ b/src/whl2conda/__main__.py
@@ -15,6 +15,7 @@
 """
 Main module allows invocation using `python -m whl2conda`
 """
+
 from .cli.main import main
 
 if __name__ == "__main__":  # pragma: no cover
diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py
index 1db9974..e772f6e 100644
--- a/src/whl2conda/api/converter.py
+++ b/src/whl2conda/api/converter.py
@@ -804,7 +804,7 @@ def translate_version_spec(self, pip_version: str) -> str:
             if m := pip_version_re.match(spec):
                 operator = m.group("operator")
                 v = m.group("version")
-                if v.startswith("v"): # e.g. convert v1.2 to 1.2
+                if v.startswith("v"):  # e.g. convert v1.2 to 1.2
                     v = v[1:]
                 if operator == "~=":
                     # compatible operator, e.g. convert ~=1.2.3 to >=1.2.3,==1.2.*
@@ -817,7 +817,11 @@ def translate_version_spec(self, pip_version: str) -> str:
                 elif operator == "===":
                     operator = "=="
                     # TODO perhaps treat as an error in "strict" mode
-                    self._warn("Converted arbitrary equality clause %s to ==%s - may not match!", spec, v)
+                    self._warn(
+                        "Converted arbitrary equality clause %s to ==%s - may not match!",
+                        spec,
+                        v,
+                    )
                 version_specs[i] = f"{operator}{v}"
             else:
                 self._warn("Cannot convert bad version spec: '%s'", spec)
diff --git a/src/whl2conda/cli/build.py b/src/whl2conda/cli/build.py
index 823a180..3bc04e5 100644
--- a/src/whl2conda/cli/build.py
+++ b/src/whl2conda/cli/build.py
@@ -232,17 +232,15 @@ def _test_package(self, pkg: Path) -> None:
 
             if import_names := test_section.get("imports", []):
                 for import_name in import_names:
-                    subprocess.check_call(
-                        [
-                            "conda",
-                            "run",
-                            "-p",
-                            str(test_prefix),
-                            "python",
-                            "-c",
-                            f"import {import_name}",
-                        ]
-                    )
+                    subprocess.check_call([
+                        "conda",
+                        "run",
+                        "-p",
+                        str(test_prefix),
+                        "python",
+                        "-c",
+                        f"import {import_name}",
+                    ])
 
             if commands := test_section.get("commands", []):
                 for command in commands:
diff --git a/src/whl2conda/cli/common.py b/src/whl2conda/cli/common.py
index b107be1..62bbf36 100644
--- a/src/whl2conda/cli/common.py
+++ b/src/whl2conda/cli/common.py
@@ -15,6 +15,7 @@
 """
 CLI utility functions
 """
+
 from __future__ import annotations
 
 import argparse
diff --git a/src/whl2conda/cli/install.py b/src/whl2conda/cli/install.py
index 55046a9..4a78c82 100644
--- a/src/whl2conda/cli/install.py
+++ b/src/whl2conda/cli/install.py
@@ -144,7 +144,7 @@ def install_main(
     deps_options.add_argument(
         "--no-deps",
         action="store_true",
-        help="Only packages themselves without any dependencies."
+        help="Only packages themselves without any dependencies.",
     )
 
     env_options.add_argument(
@@ -188,7 +188,9 @@ def install_main(
 
     subdir = "noarch"
     dependencies: list[str] = []
-    file_specs: list[tuple[str,str]] = [] # name/version pairs of package files being installed
+    file_specs: list[
+        tuple[str, str]
+    ] = []  # name/version pairs of package files being installed
 
     for conda_file in conda_files:
         conda_fname = str(conda_file.name)
@@ -201,7 +203,9 @@ def install_main(
                 # provides an extra validity check on the file.
                 tmp_path = Path(tmpdir)
                 extract_conda_pkg(str(conda_file), dest_dir=tmp_path)
-                index = json.loads(tmp_path.joinpath("info", "index.json").read_text("utf"))
+                index = json.loads(
+                    tmp_path.joinpath("info", "index.json").read_text("utf")
+                )
                 subdir = index["subdir"]
                 package_name = index["name"]
                 package_version = index.get("version", "")
@@ -227,13 +231,15 @@ def conda_bld_install(parsed: InstallArgs, subdir: str):
         print(f"Installing {package_file} into {subdir_path}")
         if not parsed.dry_run:
             subdir_path.mkdir(parents=True, exist_ok=True)
-            shutil.copyfile(
-                package_file, subdir_path.joinpath(package_file.name)
-            )
+            shutil.copyfile(package_file, subdir_path.joinpath(package_file.name))
     if not parsed.dry_run:
-        subprocess.check_call(
-            ["conda", "index", "--subdir", subdir, str(conda_bld_path)]
-        )
+        subprocess.check_call([
+            "conda",
+            "index",
+            "--subdir",
+            subdir,
+            str(conda_bld_path),
+        ])
 
 
 def conda_env_install(parsed: InstallArgs, dependencies: list[str]):
@@ -280,13 +286,20 @@ def conda_env_install(parsed: InstallArgs, dependencies: list[str]):
 
         # Workaround for https://github.com/conda/conda/issues/13479
         # If a package is installed directly from file, then set solver to classic
-        set_solver_cmd = ["conda", "run"] + env_opts + ["conda", "config", "--env", "--set", "solver", "classic"]
+        set_solver_cmd = (
+            ["conda", "run"]
+            + env_opts
+            + ["conda", "config", "--env", "--set", "solver", "classic"]
+        )
         if parsed.dry_run:
             print("Running ", set_solver_cmd)
         else:
             subprocess.check_call(set_solver_cmd)
 
-def _prune_dependencies(dependencies: list[str], file_specs: list[tuple[str,str]]) -> list[str]:
+
+def _prune_dependencies(
+    dependencies: list[str], file_specs: list[tuple[str, str]]
+) -> list[str]:
     """
     Prunes dependencies list according to arguments
 
@@ -302,7 +315,7 @@ def _prune_dependencies(dependencies: list[str], file_specs: list[tuple[str,str]
         List of pruned dependencies.
     """
 
-    exclude_packages: dict[str,str] = dict(file_specs)
+    exclude_packages: dict[str, str] = dict(file_specs)
     deps: set[str] = set()
 
     for dep in dependencies:
@@ -330,5 +343,3 @@ def _prune_dependencies(dependencies: list[str], file_specs: list[tuple[str,str]
         deps.add(dep)
 
     return sorted(deps)
-
-
diff --git a/src/whl2conda/cli/main.py b/src/whl2conda/cli/main.py
index 64f410e..35d3984 100644
--- a/src/whl2conda/cli/main.py
+++ b/src/whl2conda/cli/main.py
@@ -15,6 +15,7 @@
 """
 Main whl2conda CLI
 """
+
 from __future__ import annotations
 
 import argparse
diff --git a/src/whl2conda/impl/prompt.py b/src/whl2conda/impl/prompt.py
index 185bace..750b411 100644
--- a/src/whl2conda/impl/prompt.py
+++ b/src/whl2conda/impl/prompt.py
@@ -15,6 +15,7 @@
 """
 Interactive prompt utilities.
 """
+
 from __future__ import annotations
 
 import io
diff --git a/test-projects/setup/setup.py b/test-projects/setup/setup.py
index fac3980..be351fd 100644
--- a/test-projects/setup/setup.py
+++ b/test-projects/setup/setup.py
@@ -17,29 +17,27 @@
 from setuptools import setup
 
 setup(
-    name = "mypkg",
-    version = "1.3.4",
-    description = "Test package using setup.py",
-    author = "John Doe",
+    name="mypkg",
+    version="1.3.4",
+    description="Test package using setup.py",
+    author="John Doe",
     author_email="jdoe@nowhere.com",
-    classifiers = [
+    classifiers=[
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
     ],
-    keywords=["python","test"],
-    maintainer = "Zuzu",
-    maintainer_email= "zuzu@nowhere.com",
+    keywords=["python", "test"],
+    maintainer="Zuzu",
+    maintainer_email="zuzu@nowhere.com",
     license_files=[
         "LICENSE.md",
         os.path.abspath("LICENSE2.rst"),
     ],
-    install_requires = [
+    install_requires=[
         "tables",
         "wheel",
     ],
-    extras_require = {
-        'bdev': [ 'black' ]
-    },
-    packages=["mypkg"]
-)
\ No newline at end of file
+    extras_require={'bdev': ['black']},
+    packages=["mypkg"],
+)
diff --git a/test/api/converter.py b/test/api/converter.py
index 9f1889e..209f570 100644
--- a/test/api/converter.py
+++ b/test/api/converter.py
@@ -28,6 +28,7 @@
 """
 Test fixtures for the converter module
 """
+
 from __future__ import annotations
 
 import shutil
@@ -144,9 +145,14 @@ def _get_wheel(self) -> Path:
         with tempfile.TemporaryDirectory(dir=self.pip_downloads) as tmpdir:
             download_dir = Path(tmpdir)
             try:
-                subprocess.check_call(
-                    ["pip", "download", spec, "--no-deps", "-d", str(download_dir)]
-                )
+                subprocess.check_call([
+                    "pip",
+                    "download",
+                    spec,
+                    "--no-deps",
+                    "-d",
+                    str(download_dir),
+                ])
             except subprocess.CalledProcessError as ex:
                 pytest.skip(f"Cannot download {spec} from pypi: {ex}")
             downloaded_wheel = next(download_dir.glob("*.whl"))
diff --git a/test/api/test_converter.py b/test/api/test_converter.py
index f448337..1fea1ab 100644
--- a/test/api/test_converter.py
+++ b/test/api/test_converter.py
@@ -34,7 +34,8 @@
     CondaPackageFormat,
     DependencyRename,
     RequiresDistEntry,
-    Wheel2CondaError, Wheel2CondaConverter,
+    Wheel2CondaError,
+    Wheel2CondaConverter,
 )
 from whl2conda.cli.convert import do_build_wheel
 from .converter import ConverterTestCaseFactory
@@ -189,6 +190,7 @@ def test_dependency_rename() -> None:
 # ignore redefinition of test_case
 # ruff: noqa: F811
 
+
 def test_this(test_case: ConverterTestCaseFactory) -> None:
     """Test using this own project's wheel"""
     wheel_dir = test_case.tmp_path_factory.mktemp("test_this_wjheel_dir")
@@ -264,22 +266,24 @@ def test_simple_wheel(
 
     # Repack wheel with build number
     dest_dir = test_case.tmp_path / "number"
-    subprocess.check_call(
-        ["wheel", "unpack", str(simple_wheel), "--dest", str(dest_dir)]
-    )
+    subprocess.check_call([
+        "wheel",
+        "unpack",
+        str(simple_wheel),
+        "--dest",
+        str(dest_dir),
+    ])
     unpack_dir = next(dest_dir.glob("*"))
     assert unpack_dir.is_dir()
-    subprocess.check_call(
-        [
-            "wheel",
-            "pack",
-            str(unpack_dir),
-            "--build-number",
-            "42",
-            "--dest",
-            str(dest_dir),
-        ]
-    )
+    subprocess.check_call([
+        "wheel",
+        "pack",
+        str(unpack_dir),
+        "--build-number",
+        "42",
+        "--dest",
+        str(dest_dir),
+    ])
     build42whl = next(dest_dir.glob("*.whl"))
 
     test_case(
@@ -509,23 +513,20 @@ def fake_input(prompt: str) -> str:
     case.build()
 
 
-def test_version_translation(
-    tmp_path: Path,
-    caplog: pytest.LogCaptureFixture
-) -> None:
+def test_version_translation(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
     """Test for Wheel2CondaConverter.translate_version_spec"""
     converter = Wheel2CondaConverter(tmp_path, tmp_path)
     for spec, expected in {
-        "~= 1.2.3" : ">=1.2.3,==1.2.*",
-        "~=1" : ">=1",
-        ">=3.2 , ~=1.2.4.dev4" : ">=3.2,>=1.2.4.dev4,==1.2.*",
-        " >=1.2.3 , <4.0" : ">=1.2.3,<4.0",
-        " >v1.2+foo" : ">1.2+foo"
+        "~= 1.2.3": ">=1.2.3,==1.2.*",
+        "~=1": ">=1",
+        ">=3.2 , ~=1.2.4.dev4": ">=3.2,>=1.2.4.dev4,==1.2.*",
+        " >=1.2.3 , <4.0": ">=1.2.3,<4.0",
+        " >v1.2+foo": ">1.2+foo",
     }.items():
         assert converter.translate_version_spec(spec) == expected
 
     caplog.clear()
-    assert converter.translate_version_spec("bad-version") =="bad-version"
+    assert converter.translate_version_spec("bad-version") == "bad-version"
     assert len(caplog.records) == 1
     logrec = caplog.records[0]
     assert logrec.levelname == "WARNING"
diff --git a/test/api/test_external.py b/test/api/test_external.py
index 02817d8..9534c06 100644
--- a/test/api/test_external.py
+++ b/test/api/test_external.py
@@ -37,6 +37,7 @@
 # ignore redefinition of test_case
 # ruff: noqa: F811
 
+
 @pytest.mark.external
 def test_pypi_tomlkit(test_case: ConverterTestCaseFactory):
     """
@@ -85,16 +86,14 @@ def test_pypi_orix(test_case: ConverterTestCaseFactory) -> None:
 
     subprocess.check_call(["conda", "install", "-p", str(test_env), "pytest", "--yes"])
 
-    subprocess.check_call(
-        [
-            "conda",
-            "run",
-            "-p",
-            str(test_env),
-            "pytest",
-            "--pyargs",
-            "orix.tests",
-            "-k",
-            "not test_restrict_to_fundamental_sector",
-        ]
-    )
+    subprocess.check_call([
+        "conda",
+        "run",
+        "-p",
+        str(test_env),
+        "pytest",
+        "--pyargs",
+        "orix.tests",
+        "-k",
+        "not test_restrict_to_fundamental_sector",
+    ])
diff --git a/test/cli/test_config.py b/test/cli/test_config.py
index 55a0630..14b00ee 100644
--- a/test/cli/test_config.py
+++ b/test/cli/test_config.py
@@ -15,6 +15,7 @@
 """
 Unit tests for `whl2conda config` CLI
 """
+
 from __future__ import annotations
 
 from pathlib import Path
diff --git a/test/cli/test_convert.py b/test/cli/test_convert.py
index efe1655..d850f2c 100644
--- a/test/cli/test_convert.py
+++ b/test/cli/test_convert.py
@@ -623,9 +623,11 @@ def fake_call(cmd: Sequence[str], **_kwargs) -> None:
         project_root, wheel_dir, no_deps=False, no_build_isolation=True
     )
 
+
 # ignore redefinition of test_case
 # ruff: noqa: F811
 
+
 def test_input_wheel(
     test_case: CliTestCaseFactory,
     simple_wheel: Path,
diff --git a/test/cli/test_diff.py b/test/cli/test_diff.py
index 3c6f6b4..f0fab0d 100644
--- a/test/cli/test_diff.py
+++ b/test/cli/test_diff.py
@@ -34,6 +34,7 @@
 # ignore redefinition of simple_conda_package
 # ruff: noqa: F811
 
+
 def test_diff_errors(
     capsys: pytest.CaptureFixture,
     tmp_path: Path,
diff --git a/test/cli/test_install.py b/test/cli/test_install.py
index 8cc9079..5094e34 100644
--- a/test/cli/test_install.py
+++ b/test/cli/test_install.py
@@ -64,9 +64,11 @@ def test_errors(capsys: pytest.CaptureFixture, tmp_path: Path):
     _out, err = capsys.readouterr()
     assert "Cannot extract" in err
 
+
 # ignore redefinition of simple_conda_package
 # ruff: noqa: F811
 
+
 # pylint: disable=too-many-locals
 def test_bld_install(
     simple_conda_package: Path,
@@ -152,33 +154,29 @@ def test_env_install(
         "config", "--file", str(condarc_file), "--append", "envs_dirs", str(envs)
     )
 
-    main(
-        [
-            "install",
-            str(simple_conda_package),
-            "-p",
-            str(prefix),
-            "--create",
-            "--yes",
-            "--dry-run",
-        ]
-    )
+    main([
+        "install",
+        str(simple_conda_package),
+        "-p",
+        str(prefix),
+        "--create",
+        "--yes",
+        "--dry-run",
+    ])
 
     assert not prefix.exists()
 
-    main(
-        [
-            "install",
-            str(simple_conda_package),
-            "-p",
-            str(prefix),
-            "--create",
-            "--yes",
-            "--extra",
-            "python=3.9",
-            "pytest >=7.4",
-        ]
-    )
+    main([
+        "install",
+        str(simple_conda_package),
+        "-p",
+        str(prefix),
+        "--create",
+        "--yes",
+        "--extra",
+        "python=3.9",
+        "pytest >=7.4",
+    ])
 
     assert prefix.is_dir()
     packages = conda_json("list", "-p", str(prefix))
@@ -189,15 +187,22 @@ def test_env_install(
     assert "simple" in packages_by_name
 
     # solver should be set to classic to avoid https://github.com/conda/conda/issues/13479
-    d = conda_json("run", "-p", str(prefix), "conda", "config", "--json", "--show", "solver")
+    d = conda_json(
+        "run", "-p", str(prefix), "conda", "config", "--json", "--show", "solver"
+    )
     assert d["solver"] == "classic"
 
     conda_output("create", "-n", "test-env", "python=3.9")
     assert test_env.is_dir()
 
-    main(
-        ["install", str(simple_conda_package), "-n", "test-env", "--yes", "--only-deps"]
-    )
+    main([
+        "install",
+        str(simple_conda_package),
+        "-n",
+        "test-env",
+        "--yes",
+        "--only-deps",
+    ])
 
     packages = conda_json("list", "-n", "test-env")
     packages_by_name = {p["name"]: p for p in packages}
diff --git a/test/cli/test_main.py b/test/cli/test_main.py
index ba889a3..2ecc6e1 100644
--- a/test/cli/test_main.py
+++ b/test/cli/test_main.py
@@ -15,6 +15,7 @@
 """
 Unit tests for main `whl2conda` CLI
 """
+
 from __future__ import annotations
 
 import re
diff --git a/test/test_packages.py b/test/test_packages.py
index 260d27c..c7e80e7 100644
--- a/test/test_packages.py
+++ b/test/test_packages.py
@@ -15,6 +15,7 @@
 """
 Test fixtures providing wheels and conda packages for tests
 """
+
 import shutil
 from pathlib import Path
 from typing import Generator
@@ -62,14 +63,12 @@ def simple_conda_package(
 ) -> Generator[Path, None, None]:
     """Provides conda package for "simple" test project"""
     # Use whl2conda build to create conda package
-    convert_main(
-        [
-            str(simple_wheel),
-            "--batch",
-            "--yes",
-            "--quiet",
-        ]
-    )
+    convert_main([
+        str(simple_wheel),
+        "--batch",
+        "--yes",
+        "--quiet",
+    ])
     yield list(simple_wheel.parent.glob("*.conda"))[0]
 
 

From fabc4b7cb5b52896c61e99c7d63c87dd3ca651ee Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 11:39:35 -0500
Subject: [PATCH 13/22] tweak ruff format settings

---
 pyproject.toml | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/pyproject.toml b/pyproject.toml
index 2a99b6b..ac00994 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -106,6 +106,7 @@ disable = [
     "file-ignored",
     "fixme",
     "invalid-name",
+    "line-too-long",
     "locally-disabled",
     "raw-checker-failed",
     "suppressed-message",
@@ -125,3 +126,6 @@ line-length = 88
 line-ending = "lf"
 preview = true
 quote-style = "preserve"
+
+[tool.ruff.lint.pydocstyle]
+convention = "google"

From bf8c1a36df022a5ae032712f1786543edd66716d Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 11:41:52 -0500
Subject: [PATCH 14/22] Use ruff in CI build

---
 .github/workflows/python-package-conda.yml | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml
index 8ab75eb..88e72fb 100644
--- a/.github/workflows/python-package-conda.yml
+++ b/.github/workflows/python-package-conda.yml
@@ -31,17 +31,21 @@ jobs:
     - name: Dev install whl2conda
       run: |
         conda run -n whl2conda-dev pip install -e . --no-deps --no-build-isolation
+    - name: ruff
+      run: |
+        make ruff
     - name: pylint
+      if: success() || failure()
       run: |
         make pylint
     - name: mypy
       if: success() || failure()
       run: |
         make mypy
-    - name: check black formatting
+    - name: check formatting
       if: success() || failure()
       run: |
-        make black-check
+        make check-format
     - name: Test with pytest
       if: success() || failure()
       run: |

From 1314cd90c3ad386c727ded3dc031811618ca62c9 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 13:03:25 -0500
Subject: [PATCH 15/22] Update docs for install

---
 doc/guide/testing.md | 16 ++++++++++++++--
 1 file changed, 14 insertions(+), 2 deletions(-)

diff --git a/doc/guide/testing.md b/doc/guide/testing.md
index 9f7a2e0..03386a4 100644
--- a/doc/guide/testing.md
+++ b/doc/guide/testing.md
@@ -1,9 +1,9 @@
 ## Installing into a test environment
 
-You will probably want to test your generated conda package before deploying
+You will probably want to test your generated conda packages before deploying
 it. Currently, `conda install` only supports installing conda package files
 without their dependencies, so `whl2conda` provides an `install` subcommand
-to install a package into a test environment along with its dependencies:
+to install one or more package files into a test environment along with its dependencies:
 
 ```bash
 $ whl2conda install mypackage-1.2.3-py_0.conda -n test-env
@@ -24,6 +24,18 @@ $ whl2conda install mypackage-1.2.3-py_0.conda \
    --extra pytest -c my-channel
 ```
 
+If you are building multiple packages with an interdependency you should install
+them in a single install command, e.g.:
+
+```bash
+$ whl2conda install mypackage-1.2.3-py_0.conda mycorepackage-1.2.3-py_0.conda ...
+```
+
+**NOTE**: *in order to work around an [issue](https://github.com/conda/conda/issues/13479)
+with conda install when using the default libmamba solver, `whl2conda install` will
+configure the target environment to use the classic solver, which can result in slower installs.
+If this is a problem, you can instead use mamba.*
+
 ## Installing into conda-bld
 
 Once you are done testing, you may either upload your package to a

From 9814f1017911073e0d6dac8f9b133103ecfd0569 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 13:03:54 -0500
Subject: [PATCH 16/22] Fix install test

---
 test/cli/test_install.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/cli/test_install.py b/test/cli/test_install.py
index 5094e34..77c8194 100644
--- a/test/cli/test_install.py
+++ b/test/cli/test_install.py
@@ -233,7 +233,7 @@ def fake_call(cmd: Sequence[str]) -> Any:
 
     main(cmd_start + ["--prefix", str(prefix), "--yes"])
 
-    assert len(call_args) == 2
+    assert len(call_args) == 3
     call1 = call_args[0]
     assert call1[0] == "conda"
     assert call1[1] == "install"
@@ -251,7 +251,7 @@ def fake_call(cmd: Sequence[str]) -> Any:
     call_args.clear()
 
     main(cmd_start + ["--name", "test-env", "--create", "--mamba"])
-    assert len(call_args) == 2
+    assert len(call_args) == 3
     call1 = call_args[0]
     call2 = call_args[1]
     assert call1[:2] == ["mamba", "create"]

From 058b4c644e5b3656f1579b7732360cfaa4d57003 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 13:49:59 -0500
Subject: [PATCH 17/22] Don't complain about empty version specs

---
 src/whl2conda/api/converter.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py
index e772f6e..b041626 100644
--- a/src/whl2conda/api/converter.py
+++ b/src/whl2conda/api/converter.py
@@ -799,6 +799,8 @@ def translate_version_spec(self, pip_version: str) -> str:
         pip_version = pip_version.strip()
         version_specs = re.split(r"\s*,\s*", pip_version)
         for i, spec in enumerate(version_specs):
+            if not spec:
+                continue
             # spec for '~= '
             # https://packaging.python.org/en/latest/specifications/version-specifiers/#compatible-release
             if m := pip_version_re.match(spec):
@@ -826,7 +828,7 @@ def translate_version_spec(self, pip_version: str) -> str:
             else:
                 self._warn("Cannot convert bad version spec: '%s'", spec)
 
-        return ",".join(version_specs)
+        return ",".join(filter(bool, version_specs))
 
     def _extract_wheel(self, temp_dir: Path) -> Path:
         self.logger.info("Reading %s", self.wheel_path)

From 5621d8b3380e83d6f8e15f49b63be8fb5c9c527a Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 14:35:42 -0500
Subject: [PATCH 18/22] Update PyCharm settings

---
 .idea/misc.xml | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/.idea/misc.xml b/.idea/misc.xml
index a0f67f4..df2a729 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,5 +1,8 @@
 
 
+  
+    
   
     

From 42a0cfeed0abeaed74eef677a77113fcf9c1afe2 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 14:36:47 -0500
Subject: [PATCH 19/22] Tests for multi-package install dependency patterns

---
 src/whl2conda/api/converter.py |  4 ++--
 src/whl2conda/cli/install.py   | 27 ++++++++++-----------------
 test/cli/test_install.py       | 17 +++++++++++++++++
 3 files changed, 29 insertions(+), 19 deletions(-)

diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py
index b041626..a65d732 100644
--- a/src/whl2conda/api/converter.py
+++ b/src/whl2conda/api/converter.py
@@ -57,11 +57,11 @@ def __compile_requires_dist_re() -> re.Pattern:
     # NOTE: these are currently fairly forgiving and will accept bad syntax
     name_re = r"(?P[a-zA-Z0-9_.-]+)"
     extra_re = r"(?:\[(?P.+?)\])?"
-    version_re = r"(?:\(?(?P.*?)\)?)?"
+    version_re = r"(?:\(?(?P[^;]*?)\)?)?"
     marker_re = r"(?:;\s*(?P.*?)\s*)?"
     space = r"\s*"
     return re.compile(
-        name_re + space + extra_re + space + version_re + space + marker_re
+        space + name_re + space + extra_re + space + version_re + space + marker_re
     )
 
 
diff --git a/src/whl2conda/cli/install.py b/src/whl2conda/cli/install.py
index 4a78c82..2c75d29 100644
--- a/src/whl2conda/cli/install.py
+++ b/src/whl2conda/cli/install.py
@@ -20,6 +20,7 @@
 
 import argparse
 import json
+import re
 import shutil
 import subprocess
 import tempfile
@@ -30,7 +31,6 @@
 from conda_package_handling.api import extract as extract_conda_pkg
 
 from .common import dedent, existing_path, add_markdown_help, get_conda_bld_path
-from ..api.converter import pip_version_re, requires_dist_re
 
 __all__ = ["install_main"]
 
@@ -297,6 +297,9 @@ def conda_env_install(parsed: InstallArgs, dependencies: list[str]):
             subprocess.check_call(set_solver_cmd)
 
 
+conda_depend_re = re.compile(r"\s*(?P[\w\d.-]+)\s*(?P.*)")
+
+
 def _prune_dependencies(
     dependencies: list[str], file_specs: list[tuple[str, str]]
 ) -> list[str]:
@@ -308,7 +311,7 @@ def _prune_dependencies(
     - Removes references to packages in file_specs
 
     Arguments:
-        dependencies: input list of dependency strings (package and optional version match specifier)
+        dependencies: input list of conda dependency strings (package and optional version match specifier)
         file_specs: list of package name/version tuple for packages being installed from file
 
     Returns:
@@ -319,27 +322,17 @@ def _prune_dependencies(
     deps: set[str] = set()
 
     for dep in dependencies:
-        if m := requires_dist_re.match(dep):
+        if m := conda_depend_re.fullmatch(dep):  # pragma: no branch
             name = m.group("name")
-            extra = m.group("extra")
             version = m.group("version")
-            marker = m.group("marker")
-            if vm := pip_version_re.match(dep):
-                # normalize version
-                operator = vm.group("operator")
-                version_number = vm.group("version")
-                if version.startswith("v"):
-                    version = version[1:]
-                version = f"{operator}{version_number}"
+            if version:
+                version = version.replace(" ", "")  # remove spaces from version spec
             if name in exclude_packages:
                 # TODO check version and warn or error if not match dependency
                 continue
             dep = name
-            if extra:
-                dep += f"[{extra}]"
-            dep += f" {version}"
-            if marker:
-                dep += f" ; {marker}"
+            if version:
+                dep += f" {version}"
         deps.add(dep)
 
     return sorted(deps)
diff --git a/test/cli/test_install.py b/test/cli/test_install.py
index 77c8194..4dbf706 100644
--- a/test/cli/test_install.py
+++ b/test/cli/test_install.py
@@ -25,6 +25,7 @@
 import pytest
 
 from whl2conda.cli import main
+from whl2conda.cli.install import _prune_dependencies
 from ..test_conda import conda_config, conda_output, conda_json
 
 # pylint: disable=unused-import
@@ -281,3 +282,19 @@ def fake_call(cmd: Sequence[str]) -> Any:
 
     out, err = capsys.readouterr()
     assert "Running" in out
+
+
+def test_prune_dependencies() -> None:
+    """Unit test for internal _prune_dependencies function"""
+    assert _prune_dependencies([], []) == []
+
+    assert _prune_dependencies([" foo ", "bar >= 1.2.3", "baz   1.5.*"], []) == [
+        "bar >=1.2.3",
+        "baz 1.5.*",
+        "foo",
+    ]
+
+    assert _prune_dependencies(
+        [" foo ", "bar >= 1.2.3", "baz   1.5.*"],
+        [("bar", "1.2.3"), ("blah", "3.4.5")],
+    ) == ["baz 1.5.*", "foo"]

From 69d57a43b939d5ab6ff7ba8fdb2d3155a74ed16d Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Sun, 7 Jan 2024 14:43:02 -0500
Subject: [PATCH 20/22] Update link to pyproject.toml spec

---
 doc/reference/links.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/doc/reference/links.md b/doc/reference/links.md
index ef25179..1bb5b32 100644
--- a/doc/reference/links.md
+++ b/doc/reference/links.md
@@ -18,7 +18,7 @@
 
 ## pyproject.toml metadata format:
 
-* [Current specification](https://packaging.python.org/en/latest/specifications/declaring-project-metadata/)
+* [Current specification](https://packaging.python.org/en/latest/specifications/pyproject-toml/)
 * [PEP 621](https://peps.python.org/pep-0621/)
 * [PEP 639](https://peps.python.org/pep-0639/) - license files
 * [PEP 725](https://peps.python.org/pep-0725/) - external dependencies

From cfd28e376c80a8e4312d245bfeccf41212aa3682 Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Mon, 8 Jan 2024 13:08:48 -0500
Subject: [PATCH 21/22] Check dependency versions for package files installed
 using `whl2conda install`

This incorporates conda's version spec source (#116)
---
 LICENSE.md                         |  11 +-
 pyproject.toml                     |   6 +
 src/whl2conda/cli/install.py       |  42 +-
 src/whl2conda/external/__init__.py |  17 +
 src/whl2conda/external/version.py  | 714 +++++++++++++++++++++++++++++
 test/cli/test_install.py           |  16 +-
 6 files changed, 792 insertions(+), 14 deletions(-)
 create mode 100644 src/whl2conda/external/__init__.py
 create mode 100644 src/whl2conda/external/version.py

diff --git a/LICENSE.md b/LICENSE.md
index 95fa028..d55091a 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -186,7 +186,7 @@
       same "printed page" as the copyright notice for easier
       identification within third-party archives.
 
-   Copyright 2023   Christopher Barber
+   Copyright 2023-2024   Christopher Barber
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
@@ -199,3 +199,12 @@
    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.
+
+## Additional licenses
+
+This source code incorporates some code taken from other projects
+with compatible licenses, specifically:
+
+* https://github.com/conda/conda/blob/main/conda/models/version.py (BSD-3)
+
+
diff --git a/pyproject.toml b/pyproject.toml
index ac00994..9390730 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -76,6 +76,9 @@ module = [
 ]
 ignore_missing_imports = true
 
+[tool.pylint.main]
+ignore-paths=['^src/whl2conda/external/.*$']
+
 [tool.pylint.build_main]
 jobs = 0  # enable parallel checks
 py-version = "3.8" # min python version
@@ -121,6 +124,9 @@ disable = [
 
 [tool.ruff]
 line-length = 88
+exclude = [
+    "src/whl2conda/external"
+]
 
 [tool.ruff.format]
 line-ending = "lf"
diff --git a/src/whl2conda/cli/install.py b/src/whl2conda/cli/install.py
index 2c75d29..077e250 100644
--- a/src/whl2conda/cli/install.py
+++ b/src/whl2conda/cli/install.py
@@ -26,11 +26,12 @@
 import tempfile
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Optional, Sequence
+from typing import NamedTuple, Optional, Sequence
 
 from conda_package_handling.api import extract as extract_conda_pkg
 
 from .common import dedent, existing_path, add_markdown_help, get_conda_bld_path
+from ..external.version import ver_eval
 
 __all__ = ["install_main"]
 
@@ -62,6 +63,14 @@ def parse(
         return cls(**vars(ns))
 
 
+class InstallFileInfo(NamedTuple):
+    """Holds information about a conda file to be installed"""
+
+    path: Path
+    name: str
+    version: str
+
+
 # pylint: disable=too-many-locals
 def install_main(
     args: Optional[Sequence[str]] = None,
@@ -188,9 +197,7 @@ def install_main(
 
     subdir = "noarch"
     dependencies: list[str] = []
-    file_specs: list[
-        tuple[str, str]
-    ] = []  # name/version pairs of package files being installed
+    file_specs: list[InstallFileInfo] = []
 
     for conda_file in conda_files:
         conda_fname = str(conda_file.name)
@@ -209,7 +216,9 @@ def install_main(
                 subdir = index["subdir"]
                 package_name = index["name"]
                 package_version = index.get("version", "")
-                file_specs.append((package_name, package_version))
+                file_specs.append(
+                    InstallFileInfo(conda_file, package_name, package_version)
+                )
                 dependencies.extend(index.get("depends", []))
             except Exception as ex:  # pylint: disable=broad-exception-caught
                 parser.error(f"Cannot extract conda package '{conda_file}:\n{ex}'")
@@ -218,7 +227,10 @@ def install_main(
         # Install into conda-bld dir
         conda_bld_install(parsed, subdir)
     else:
-        dependencies = _prune_dependencies(dependencies, file_specs)
+        try:
+            dependencies = _prune_dependencies(dependencies, file_specs)
+        except Exception as ex:  # pylint: disable=broad-exception-caught
+            parser.error(str(ex))
         conda_env_install(parsed, dependencies)
 
 
@@ -301,7 +313,7 @@ def conda_env_install(parsed: InstallArgs, dependencies: list[str]):
 
 
 def _prune_dependencies(
-    dependencies: list[str], file_specs: list[tuple[str, str]]
+    dependencies: list[str], file_specs: list[InstallFileInfo]
 ) -> list[str]:
     """
     Prunes dependencies list according to arguments
@@ -312,13 +324,18 @@ def _prune_dependencies(
 
     Arguments:
         dependencies: input list of conda dependency strings (package and optional version match specifier)
-        file_specs: list of package name/version tuple for packages being installed from file
+        file_specs: list of information on package files being installed
 
     Returns:
         List of pruned dependencies.
+
+    Raises:
+        ValueError if a dependency for a package file in file_specs does not match
     """
 
-    exclude_packages: dict[str, str] = dict(file_specs)
+    exclude_packages: dict[str, InstallFileInfo] = {
+        spec.name: spec for spec in file_specs
+    }
     deps: set[str] = set()
 
     for dep in dependencies:
@@ -327,8 +344,11 @@ def _prune_dependencies(
             version = m.group("version")
             if version:
                 version = version.replace(" ", "")  # remove spaces from version spec
-            if name in exclude_packages:
-                # TODO check version and warn or error if not match dependency
+            if exclude := exclude_packages.get(name):
+                if not ver_eval(exclude.version, version):
+                    raise ValueError(
+                        f"{exclude.path} does not match dependency '{dep}'"
+                    )
                 continue
             dep = name
             if version:
diff --git a/src/whl2conda/external/__init__.py b/src/whl2conda/external/__init__.py
new file mode 100644
index 0000000..9b3da73
--- /dev/null
+++ b/src/whl2conda/external/__init__.py
@@ -0,0 +1,17 @@
+#  Copyright 2024 Christopher Barber
+#
+#  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.
+#
+"""
+Code copied from external projects
+"""
diff --git a/src/whl2conda/external/version.py b/src/whl2conda/external/version.py
new file mode 100644
index 0000000..5977eaa
--- /dev/null
+++ b/src/whl2conda/external/version.py
@@ -0,0 +1,714 @@
+# Copyright (C) 2012 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+# Copied from https://github.com/conda/conda/blob/main/conda/models/version.py
+"""Implements the version spec with parsing and comparison logic.
+
+Object inheritance:
+
+.. autoapi-inheritance-diagram:: BaseSpec VersionSpec BuildNumberMatch
+   :top-classes: conda.models.version.BaseSpec
+   :parts: 1
+"""
+# mypy: ignore-errors
+from __future__ import annotations
+
+import operator as op
+import re
+from itertools import zip_longest
+from logging import getLogger
+
+#from ..exceptions import InvalidVersionSpec
+class InvalidVersionSpec(ValueError):
+    def __init__(self, invalid_spec: str, details: str):
+        super().__init__(f"Invalid version '{invalid_spec}': {details}")
+
+log = getLogger(__name__)
+
+
+def normalized_version(version: str) -> VersionOrder:
+    """Parse a version string and return VersionOrder object."""
+    return VersionOrder(version)
+
+
+def ver_eval(vtest, spec):
+    return VersionSpec(spec).match(vtest)
+
+
+version_check_re = re.compile(r"^[\*\.\+!_0-9a-z]+$")
+version_split_re = re.compile("([0-9]+|[*]+|[^0-9*]+)")
+version_cache = {}
+
+
+class SingleStrArgCachingType(type):
+    def __call__(cls, arg):
+        if isinstance(arg, cls):
+            return arg
+        elif isinstance(arg, str):
+            try:
+                return cls._cache_[arg]
+            except KeyError:
+                val = cls._cache_[arg] = super().__call__(arg)
+                return val
+        else:
+            return super().__call__(arg)
+
+
+class VersionOrder(metaclass=SingleStrArgCachingType):
+    """Implement an order relation between version strings.
+
+    Version strings can contain the usual alphanumeric characters
+    (A-Za-z0-9), separated into components by dots and underscores. Empty
+    segments (i.e. two consecutive dots, a leading/trailing underscore)
+    are not permitted. An optional epoch number - an integer
+    followed by '!' - can proceed the actual version string
+    (this is useful to indicate a change in the versioning
+    scheme itself). Version comparison is case-insensitive.
+
+    Conda supports six types of version strings:
+    * Release versions contain only integers, e.g. '1.0', '2.3.5'.
+    * Pre-release versions use additional letters such as 'a' or 'rc',
+      for example '1.0a1', '1.2.beta3', '2.3.5rc3'.
+    * Development versions are indicated by the string 'dev',
+      for example '1.0dev42', '2.3.5.dev12'.
+    * Post-release versions are indicated by the string 'post',
+      for example '1.0post1', '2.3.5.post2'.
+    * Tagged versions have a suffix that specifies a particular
+      property of interest, e.g. '1.1.parallel'. Tags can be added
+      to any of the preceding four types. As far as sorting is concerned,
+      tags are treated like strings in pre-release versions.
+    * An optional local version string separated by '+' can be appended
+      to the main (upstream) version string. It is only considered
+      in comparisons when the main versions are equal, but otherwise
+      handled in exactly the same manner.
+
+    To obtain a predictable version ordering, it is crucial to keep the
+    version number scheme of a given package consistent over time.
+    Specifically,
+    * version strings should always have the same number of components
+      (except for an optional tag suffix or local version string),
+    * letters/strings indicating non-release versions should always
+      occur at the same position.
+
+    Before comparison, version strings are parsed as follows:
+    * They are first split into epoch, version number, and local version
+      number at '!' and '+' respectively. If there is no '!', the epoch is
+      set to 0. If there is no '+', the local version is empty.
+    * The version part is then split into components at '.' and '_'.
+    * Each component is split again into runs of numerals and non-numerals
+    * Subcomponents containing only numerals are converted to integers.
+    * Strings are converted to lower case, with special treatment for 'dev'
+      and 'post'.
+    * When a component starts with a letter, the fillvalue 0 is inserted
+      to keep numbers and strings in phase, resulting in '1.1.a1' == 1.1.0a1'.
+    * The same is repeated for the local version part.
+
+    Examples:
+        1.2g.beta15.rc  =>  [[0], [1], [2, 'g'], [0, 'beta', 15], [0, 'rc']]
+        1!2.15.1_ALPHA  =>  [[1], [2], [15], [1, '_alpha']]
+
+    The resulting lists are compared lexicographically, where the following
+    rules are applied to each pair of corresponding subcomponents:
+    * integers are compared numerically
+    * strings are compared lexicographically, case-insensitive
+    * strings are smaller than integers, except
+    * 'dev' versions are smaller than all corresponding versions of other types
+    * 'post' versions are greater than all corresponding versions of other types
+    * if a subcomponent has no correspondent, the missing correspondent is
+      treated as integer 0 to ensure '1.1' == '1.1.0'.
+
+    The resulting order is:
+           0.4
+         < 0.4.0
+         < 0.4.1.rc
+        == 0.4.1.RC   # case-insensitive comparison
+         < 0.4.1
+         < 0.5a1
+         < 0.5b3
+         < 0.5C1      # case-insensitive comparison
+         < 0.5
+         < 0.9.6
+         < 0.960923
+         < 1.0
+         < 1.1dev1    # special case 'dev'
+         < 1.1_       # appended underscore is special case for openssl-like versions
+         < 1.1a1
+         < 1.1.0dev1  # special case 'dev'
+        == 1.1.dev1   # 0 is inserted before string
+         < 1.1.a1
+         < 1.1.0rc1
+         < 1.1.0
+        == 1.1
+         < 1.1.0post1 # special case 'post'
+        == 1.1.post1  # 0 is inserted before string
+         < 1.1post1   # special case 'post'
+         < 1996.07.12
+         < 1!0.4.1    # epoch increased
+         < 1!3.1.1.6
+         < 2!0.4.1    # epoch increased again
+
+    Some packages (most notably openssl) have incompatible version conventions.
+    In particular, openssl interprets letters as version counters rather than
+    pre-release identifiers. For openssl, the relation
+
+      1.0.1 < 1.0.1a  =>  False  # should be true for openssl
+
+    holds, whereas conda packages use the opposite ordering. You can work-around
+    this problem by appending an underscore to plain version numbers:
+
+      1.0.1_ < 1.0.1a =>  True   # ensure correct ordering for openssl
+    """
+
+    _cache_ = {}
+
+    def __init__(self, vstr: str):
+        # version comparison is case-insensitive
+        version = vstr.strip().rstrip().lower()
+        # basic validity checks
+        if version == "":
+            raise InvalidVersionSpec(vstr, "empty version string")
+        invalid = not version_check_re.match(version)
+        if invalid and "-" in version and "_" not in version:
+            # Allow for dashes as long as there are no underscores
+            # as well, by converting the former to the latter.
+            version = version.replace("-", "_")
+            invalid = not version_check_re.match(version)
+        if invalid:
+            raise InvalidVersionSpec(vstr, "invalid character(s)")
+
+        # when fillvalue ==  0  =>  1.1 == 1.1.0
+        # when fillvalue == -1  =>  1.1  < 1.1.0
+        self.norm_version = version
+        self.fillvalue = 0
+
+        # find epoch
+        version = version.split("!")
+        if len(version) == 1:
+            # epoch not given => set it to '0'
+            epoch = ["0"]
+        elif len(version) == 2:
+            # epoch given, must be an integer
+            if not version[0].isdigit():
+                raise InvalidVersionSpec(vstr, "epoch must be an integer")
+            epoch = [version[0]]
+        else:
+            raise InvalidVersionSpec(vstr, "duplicated epoch separator '!'")
+
+        # find local version string
+        version = version[-1].split("+")
+        if len(version) == 1:
+            # no local version
+            self.local = []
+        # Case 2: We have a local version component in version[1]
+        elif len(version) == 2:
+            # local version given
+            self.local = version[1].replace("_", ".").split(".")
+        else:
+            raise InvalidVersionSpec(vstr, "duplicated local version separator '+'")
+
+        # Error Case: Version is empty because the version string started with +.
+        # e.g. "+", "1.2", "+a", "+1".
+        # This is an error because specifying only a local version is invalid.
+        # version[0] is empty because vstr.split("+") returns something like ['', '1.2']
+        if version[0] == "":
+            raise InvalidVersionSpec(
+                vstr, "Missing version before local version separator '+'"
+            )
+
+        if version[0][-1] == "_":
+            # If the last character of version is "-" or "_", don't split that out
+            # individually. Implements the instructions for openssl-like versions
+            #   > You can work-around this problem by appending a dash to plain version numbers
+            split_version = version[0][:-1].replace("_", ".").split(".")
+            split_version[-1] += "_"
+        else:
+            split_version = version[0].replace("_", ".").split(".")
+        self.version = epoch + split_version
+
+        # split components into runs of numerals and non-numerals,
+        # convert numerals to int, handle special strings
+        for v in (self.version, self.local):
+            for k in range(len(v)):
+                c = version_split_re.findall(v[k])
+                if not c:
+                    raise InvalidVersionSpec(vstr, "empty version component")
+                for j in range(len(c)):
+                    if c[j].isdigit():
+                        c[j] = int(c[j])
+                    elif c[j] == "post":
+                        # ensure number < 'post' == infinity
+                        c[j] = float("inf")
+                    elif c[j] == "dev":
+                        # ensure '*' < 'DEV' < '_' < 'a' < number
+                        # by upper-casing (all other strings are lower case)
+                        c[j] = "DEV"
+                if v[k][0].isdigit():
+                    v[k] = c
+                else:
+                    # components shall start with a number to keep numbers and
+                    # strings in phase => prepend fillvalue
+                    v[k] = [self.fillvalue] + c
+
+    def __str__(self) -> str:
+        return self.norm_version
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}("{self}")'
+
+    def _eq(self, t1: list[str], t2: list[str]) -> bool:
+        for v1, v2 in zip_longest(t1, t2, fillvalue=[]):
+            for c1, c2 in zip_longest(v1, v2, fillvalue=self.fillvalue):
+                if c1 != c2:
+                    return False
+        return True
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, VersionOrder):
+            return False
+        return self._eq(self.version, other.version) and self._eq(
+            self.local, other.local
+        )
+
+    def startswith(self, other: object) -> bool:
+        if not isinstance(other, VersionOrder):
+            return False
+        # Tests if the version lists match up to the last element in "other".
+        if other.local:
+            if not self._eq(self.version, other.version):
+                return False
+            t1 = self.local
+            t2 = other.local
+        else:
+            t1 = self.version
+            t2 = other.version
+        nt = len(t2) - 1
+        if not self._eq(t1[:nt], t2[:nt]):
+            return False
+        v1 = [] if len(t1) <= nt else t1[nt]
+        v2 = t2[nt]
+        nt = len(v2) - 1
+        if not self._eq([v1[:nt]], [v2[:nt]]):
+            return False
+        c1 = self.fillvalue if len(v1) <= nt else v1[nt]
+        c2 = v2[nt]
+        if isinstance(c2, str):
+            return isinstance(c1, str) and c1.startswith(c2)
+        return c1 == c2
+
+    def __ne__(self, other: object) -> bool:
+        return not (self == other)
+
+    def __lt__(self, other: object) -> bool:
+        if not isinstance(other, VersionOrder):
+            return False
+        for t1, t2 in zip([self.version, self.local], [other.version, other.local]):
+            for v1, v2 in zip_longest(t1, t2, fillvalue=[]):
+                for c1, c2 in zip_longest(v1, v2, fillvalue=self.fillvalue):
+                    if c1 == c2:
+                        continue
+                    elif isinstance(c1, str):
+                        if not isinstance(c2, str):
+                            # str < int
+                            return True
+                    elif isinstance(c2, str):
+                        # not (int < str)
+                        return False
+                    # c1 and c2 have the same type
+                    return c1 < c2
+        # self == other
+        return False
+
+    def __gt__(self, other: object) -> bool:
+        return other < self
+
+    def __le__(self, other: object) -> bool:
+        return not (other < self)
+
+    def __ge__(self, other: object) -> bool:
+        return not (self < other)
+
+
+# each token slurps up leading whitespace, which we strip out.
+VSPEC_TOKENS = (
+    r"\s*\^[^$]*[$]|"  # regexes
+    r"\s*[()|,]|"  # parentheses, logical and, logical or
+    r"[^()|,]+"
+)  # everything else
+
+
+def treeify(spec_str):
+    """
+    Examples:
+        >>> treeify("1.2.3")
+        '1.2.3'
+        >>> treeify("1.2.3,>4.5.6")
+        (',', '1.2.3', '>4.5.6')
+        >>> treeify("1.2.3,4.5.6|<=7.8.9")
+        ('|', (',', '1.2.3', '4.5.6'), '<=7.8.9')
+        >>> treeify("(1.2.3|4.5.6),<=7.8.9")
+        (',', ('|', '1.2.3', '4.5.6'), '<=7.8.9')
+        >>> treeify("((1.5|((1.6|1.7), 1.8), 1.9 |2.0))|2.1")
+        ('|', '1.5', (',', ('|', '1.6', '1.7'), '1.8', '1.9'), '2.0', '2.1')
+        >>> treeify("1.5|(1.6|1.7),1.8,1.9|2.0|2.1")
+        ('|', '1.5', (',', ('|', '1.6', '1.7'), '1.8', '1.9'), '2.0', '2.1')
+    """
+    # Converts a VersionSpec expression string into a tuple-based
+    # expression tree.
+    assert isinstance(spec_str, str)
+    tokens = re.findall(VSPEC_TOKENS, "(%s)" % spec_str)
+    output = []
+    stack = []
+
+    def apply_ops(cstop):
+        # cstop: operators with lower precedence
+        while stack and stack[-1] not in cstop:
+            if len(output) < 2:
+                raise InvalidVersionSpec(spec_str, "cannot join single expression")
+            c = stack.pop()
+            r = output.pop()
+            # Fuse expressions with the same operator; e.g.,
+            #   ('|', ('|', a, b), ('|', c, d))becomes
+            #   ('|', a, b, c d)
+            # We're playing a bit of a trick here. Instead of checking
+            # if the left or right entries are tuples, we're counting
+            # on the fact that if we _do_ see a string instead, its
+            # first character cannot possibly be equal to the operator.
+            r = r[1:] if r[0] == c else (r,)
+            left = output.pop()
+            left = left[1:] if left[0] == c else (left,)
+            output.append((c,) + left + r)
+
+    for item in tokens:
+        item = item.strip()
+        if item == "|":
+            apply_ops("(")
+            stack.append("|")
+        elif item == ",":
+            apply_ops("|(")
+            stack.append(",")
+        elif item == "(":
+            stack.append("(")
+        elif item == ")":
+            apply_ops("(")
+            if not stack or stack[-1] != "(":
+                raise InvalidVersionSpec(spec_str, "expression must start with '('")
+            stack.pop()
+        else:
+            output.append(item)
+    if stack:
+        raise InvalidVersionSpec(
+            spec_str, "unable to convert to expression tree: %s" % stack
+        )
+    if not output:
+        raise InvalidVersionSpec(spec_str, "unable to determine version from spec")
+    return output[0]
+
+
+def untreeify(spec, _inand=False, depth=0):
+    """
+    Examples:
+        >>> untreeify('1.2.3')
+        '1.2.3'
+        >>> untreeify((',', '1.2.3', '>4.5.6'))
+        '1.2.3,>4.5.6'
+        >>> untreeify(('|', (',', '1.2.3', '4.5.6'), '<=7.8.9'))
+        '(1.2.3,4.5.6)|<=7.8.9'
+        >>> untreeify((',', ('|', '1.2.3', '4.5.6'), '<=7.8.9'))
+        '(1.2.3|4.5.6),<=7.8.9'
+        >>> untreeify(('|', '1.5', (',', ('|', '1.6', '1.7'), '1.8', '1.9'), '2.0', '2.1'))
+        '1.5|((1.6|1.7),1.8,1.9)|2.0|2.1'
+    """
+    if isinstance(spec, tuple):
+        if spec[0] == "|":
+            res = "|".join(map(lambda x: untreeify(x, depth=depth + 1), spec[1:]))
+            if _inand or depth > 0:
+                res = "(%s)" % res
+        else:
+            res = ",".join(
+                map(lambda x: untreeify(x, _inand=True, depth=depth + 1), spec[1:])
+            )
+            if depth > 0:
+                res = "(%s)" % res
+        return res
+    return spec
+
+
+def compatible_release_operator(x, y):
+    return op.__ge__(x, y) and x.startswith(
+        VersionOrder(".".join(str(y).split(".")[:-1]))
+    )
+
+
+# This RE matches the operators '==', '!=', '<=', '>=', '<', '>'
+# followed by a version string. It rejects expressions like
+# '<= 1.2' (space after operator), '<>1.2' (unknown operator),
+# and '<=!1.2' (nonsensical operator).
+version_relation_re = re.compile(r"^(=|==|!=|<=|>=|<|>|~=)(?![=<>!~])(\S+)$")
+regex_split_re = re.compile(r".*[()|,^$]")
+OPERATOR_MAP = {
+    "==": op.__eq__,
+    "!=": op.__ne__,
+    "<=": op.__le__,
+    ">=": op.__ge__,
+    "<": op.__lt__,
+    ">": op.__gt__,
+    "=": lambda x, y: x.startswith(y),
+    "!=startswith": lambda x, y: not x.startswith(y),
+    "~=": compatible_release_operator,
+}
+OPERATOR_START = frozenset(("=", "<", ">", "!", "~"))
+
+
+class BaseSpec:
+    def __init__(self, spec_str, matcher, is_exact):
+        self.spec_str = spec_str
+        self._is_exact = is_exact
+        self.match = matcher
+
+    @property
+    def spec(self):
+        return self.spec_str
+
+    def is_exact(self):
+        return self._is_exact
+
+    def __eq__(self, other):
+        try:
+            other_spec = other.spec
+        except AttributeError:
+            other_spec = self.__class__(other).spec
+        return self.spec == other_spec
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __hash__(self):
+        return hash(self.spec)
+
+    def __str__(self):
+        return self.spec
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}('{self.spec}')"
+
+    @property
+    def raw_value(self):
+        return self.spec
+
+    @property
+    def exact_value(self):
+        return self.is_exact() and self.spec or None
+
+    def merge(self, other):
+        raise NotImplementedError()
+
+    def regex_match(self, spec_str):
+        return bool(self.regex.match(spec_str))
+
+    def operator_match(self, spec_str):
+        return self.operator_func(VersionOrder(str(spec_str)), self.matcher_vo)
+
+    def any_match(self, spec_str):
+        return any(s.match(spec_str) for s in self.tup)
+
+    def all_match(self, spec_str):
+        return all(s.match(spec_str) for s in self.tup)
+
+    def exact_match(self, spec_str):
+        return self.spec == spec_str
+
+    def always_true_match(self, spec_str):
+        return True
+
+
+class VersionSpec(BaseSpec, metaclass=SingleStrArgCachingType):
+    _cache_ = {}
+
+    def __init__(self, vspec):
+        vspec_str, matcher, is_exact = self.get_matcher(vspec)
+        super().__init__(vspec_str, matcher, is_exact)
+
+    def get_matcher(self, vspec):
+        if isinstance(vspec, str) and regex_split_re.match(vspec):
+            vspec = treeify(vspec)
+
+        if isinstance(vspec, tuple):
+            vspec_tree = vspec
+            _matcher = self.any_match if vspec_tree[0] == "|" else self.all_match
+            tup = tuple(VersionSpec(s) for s in vspec_tree[1:])
+            vspec_str = untreeify((vspec_tree[0],) + tuple(t.spec for t in tup))
+            self.tup = tup
+            matcher = _matcher
+            is_exact = False
+            return vspec_str, matcher, is_exact
+
+        vspec_str = str(vspec).strip()
+        if vspec_str[0] == "^" or vspec_str[-1] == "$":
+            if vspec_str[0] != "^" or vspec_str[-1] != "$":
+                raise InvalidVersionSpec(
+                    vspec_str, "regex specs must start with '^' and end with '$'"
+                )
+            self.regex = re.compile(vspec_str)
+            matcher = self.regex_match
+            is_exact = False
+        elif vspec_str[0] in OPERATOR_START:
+            m = version_relation_re.match(vspec_str)
+            if m is None:
+                raise InvalidVersionSpec(vspec_str, "invalid operator")
+            operator_str, vo_str = m.groups()
+            if vo_str[-2:] == ".*":
+                if operator_str in ("=", ">="):
+                    vo_str = vo_str[:-2]
+                elif operator_str == "!=":
+                    vo_str = vo_str[:-2]
+                    operator_str = "!=startswith"
+                elif operator_str == "~=":
+                    raise InvalidVersionSpec(vspec_str, "invalid operator with '.*'")
+                else:
+                    log.warning(
+                        "Using .* with relational operator is superfluous and deprecated "
+                        "and will be removed in a future version of conda. Your spec was "
+                        "{}, but conda is ignoring the .* and treating it as {}".format(
+                            vo_str, vo_str[:-2]
+                        )
+                    )
+                    vo_str = vo_str[:-2]
+            try:
+                self.operator_func = OPERATOR_MAP[operator_str]
+            except KeyError:
+                raise InvalidVersionSpec(
+                    vspec_str, "invalid operator: %s" % operator_str
+                )
+            self.matcher_vo = VersionOrder(vo_str)
+            matcher = self.operator_match
+            is_exact = operator_str == "=="
+        elif vspec_str == "*":
+            matcher = self.always_true_match
+            is_exact = False
+        elif "*" in vspec_str.rstrip("*"):
+            rx = vspec_str.replace(".", r"\.").replace("+", r"\+").replace("*", r".*")
+            rx = r"^(?:%s)$" % rx
+
+            self.regex = re.compile(rx)
+            matcher = self.regex_match
+            is_exact = False
+        elif vspec_str[-1] == "*":
+            if vspec_str[-2:] != ".*":
+                vspec_str = vspec_str[:-1] + ".*"
+
+            # if vspec_str[-1] in OPERATOR_START:
+            #     m = version_relation_re.match(vspec_str)
+            #     if m is None:
+            #         raise InvalidVersionSpecError(vspec_str)
+            #     operator_str, vo_str = m.groups()
+            #
+            #
+            # else:
+            #     pass
+
+            vo_str = vspec_str.rstrip("*").rstrip(".")
+            self.operator_func = VersionOrder.startswith
+            self.matcher_vo = VersionOrder(vo_str)
+            matcher = self.operator_match
+            is_exact = False
+        elif "@" not in vspec_str:
+            self.operator_func = OPERATOR_MAP["=="]
+            self.matcher_vo = VersionOrder(vspec_str)
+            matcher = self.operator_match
+            is_exact = True
+        else:
+            matcher = self.exact_match
+            is_exact = True
+        return vspec_str, matcher, is_exact
+
+    def merge(self, other):
+        assert isinstance(other, self.__class__)
+        return self.__class__(",".join(sorted((self.raw_value, other.raw_value))))
+
+    def union(self, other):
+        assert isinstance(other, self.__class__)
+        options = {self.raw_value, other.raw_value}
+        # important: we only return a string here because the parens get gobbled otherwise
+        #    this info is for visual display only, not for feeding into actual matches
+        return "|".join(sorted(options))
+
+
+# TODO: someday switch out these class names for consistency
+VersionMatch = VersionSpec
+
+
+class BuildNumberMatch(BaseSpec, metaclass=SingleStrArgCachingType):
+    _cache_ = {}
+
+    def __init__(self, vspec):
+        vspec_str, matcher, is_exact = self.get_matcher(vspec)
+        super().__init__(vspec_str, matcher, is_exact)
+
+    def get_matcher(self, vspec):
+        try:
+            vspec = int(vspec)
+        except ValueError:
+            pass
+        else:
+            matcher = self.exact_match
+            is_exact = True
+            return vspec, matcher, is_exact
+
+        vspec_str = str(vspec).strip()
+        if vspec_str == "*":
+            matcher = self.always_true_match
+            is_exact = False
+        elif vspec_str.startswith(("=", "<", ">", "!")):
+            m = version_relation_re.match(vspec_str)
+            if m is None:
+                raise InvalidVersionSpec(vspec_str, "invalid operator")
+            operator_str, vo_str = m.groups()
+            try:
+                self.operator_func = OPERATOR_MAP[operator_str]
+            except KeyError:
+                raise InvalidVersionSpec(
+                    vspec_str, "invalid operator: %s" % operator_str
+                )
+            self.matcher_vo = VersionOrder(vo_str)
+            matcher = self.operator_match
+
+            is_exact = operator_str == "=="
+        elif vspec_str[0] == "^" or vspec_str[-1] == "$":
+            if vspec_str[0] != "^" or vspec_str[-1] != "$":
+                raise InvalidVersionSpec(
+                    vspec_str, "regex specs must start with '^' and end with '$'"
+                )
+            self.regex = re.compile(vspec_str)
+
+            matcher = self.regex_match
+            is_exact = False
+        # if hasattr(spec, 'match'):
+        #     self.spec = _spec
+        #     self.match = spec.match
+        else:
+            matcher = self.exact_match
+            is_exact = True
+        return vspec_str, matcher, is_exact
+
+    def merge(self, other):
+        if self.raw_value != other.raw_value:
+            raise ValueError(
+                f"Incompatible component merge:\n  - {self.raw_value!r}\n  - {other.raw_value!r}"
+            )
+        return self.raw_value
+
+    def union(self, other):
+        options = {self.raw_value, other.raw_value}
+        return "|".join(options)
+
+    @property
+    def exact_value(self) -> int | None:
+        try:
+            return int(self.raw_value)
+        except ValueError:
+            return None
+
+    def __str__(self):
+        return str(self.spec)
+
+    def __repr__(self):
+        return str(self.spec)
diff --git a/test/cli/test_install.py b/test/cli/test_install.py
index 4dbf706..446abfa 100644
--- a/test/cli/test_install.py
+++ b/test/cli/test_install.py
@@ -25,7 +25,7 @@
 import pytest
 
 from whl2conda.cli import main
-from whl2conda.cli.install import _prune_dependencies
+from whl2conda.cli.install import InstallFileInfo, _prune_dependencies
 from ..test_conda import conda_config, conda_output, conda_json
 
 # pylint: disable=unused-import
@@ -296,5 +296,17 @@ def test_prune_dependencies() -> None:
 
     assert _prune_dependencies(
         [" foo ", "bar >= 1.2.3", "baz   1.5.*"],
-        [("bar", "1.2.3"), ("blah", "3.4.5")],
+        [
+            InstallFileInfo(Path("bar-1.2.3.conda"), "bar", "1.2.3"),
+            InstallFileInfo(Path("blah-3.4.5.tar.bz2"), "blah", "3.4.5"),
+        ],
     ) == ["baz 1.5.*", "foo"]
+
+    with pytest.raises(ValueError, match="does not match dependency"):
+        _prune_dependencies(
+            [" foo ", "bar >= 1.2.4", "baz   1.5.*"],
+            [
+                InstallFileInfo(Path("bar-1.2.3.conda"), "bar", "1.2.3"),
+                InstallFileInfo(Path("blah-3.4.5.tar.bz2"), "blah", "3.4.5"),
+            ],
+        )

From e50181c4003f4c0dca30b6ff8c3e1bb720021b4d Mon Sep 17 00:00:00 2001
From: Christopher Barber 
Date: Mon, 8 Jan 2024 13:11:37 -0500
Subject: [PATCH 22/22] Update pypi/conda package mappings

---
 src/whl2conda/api/stdrename.json | 52 +++++++++++++++++++++++++-------
 1 file changed, 41 insertions(+), 11 deletions(-)

diff --git a/src/whl2conda/api/stdrename.json b/src/whl2conda/api/stdrename.json
index 897f33e..abbee17 100644
--- a/src/whl2conda/api/stdrename.json
+++ b/src/whl2conda/api/stdrename.json
@@ -1,6 +1,6 @@
 {
-  "$date": "Sat, 23 Sep 2023 19:04:05 GMT",
-  "$etag": "a6b953d76135806ecc774eaa121cfffedbd39529ad8bdbf4daa9d35dd5646e61",
+  "$date": "Mon, 08 Jan 2024 18:09:49 GMT",
+  "$etag": "9758d8352fa3299513c6da7fbf666411734444344202e6bc3bec55a5c26c4289",
   "$max-age": "300",
   "$source": "https://raw.githubusercontent.com/regro/cf-graph-countyfair/master/mappings/pypi/name_mapping.json",
   "0164e082b29777fbc56c2373b68da93d23a29a040787e7f1c65e1562f133": "django-jsoneditor",
@@ -95,10 +95,12 @@
   "backports-abc": "backports_abc",
   "backports-cached-property": "backports.cached-property",
   "backports-csv": "backports.csv",
+  "backports-entry-points-selectable": "backports.entry-points-selectable",
   "backports-functools-lru-cache": "backports.functools_lru_cache",
   "backports-shutil-get-terminal-size": "backports.shutil_get_terminal_size",
   "backports-shutil-which": "backports.shutil_which",
   "backports-ssl-match-hostname": "ssl_match_hostname",
+  "backports-strenum": "backports.strenum",
   "backports-tempfile": "backports.tempfile",
   "backports-test-support": "backports.test.support",
   "backports-unittest-mock": "backports.unittest_mock",
@@ -123,6 +125,7 @@
   "blis": "cython-blis",
   "block-tracing": "block_tracing",
   "blosc": "python-blosc",
+  "blosc2": "python-blosc2",
   "boolean-py": "boolean.py",
   "bootstrap-contrast": "bootstrap_contrast",
   "boruta": "boruta_py",
@@ -133,6 +136,7 @@
   "bullet": "bullet-python",
   "bw-migrations": "bw_migrations",
   "bw-processing": "bw_processing",
+  "cache-decorator": "cache_decorator",
   "cached-interpolate": "cached_interpolate",
   "cached-property": "cached_property",
   "caf-space": "caf.space",
@@ -150,6 +154,7 @@
   "cdo": "python-cdo",
   "cf-pandas": "cf_pandas",
   "cf-xarray": "cf_xarray",
+  "channels-redis": "channels_redis",
   "cheap-repr": "cheap_repr",
   "chembl-webresource-client": "chembl_webresource_client",
   "chi2comb": "chi2comb-py",
@@ -175,6 +180,7 @@
   "compas-slicer": "compas_slicer",
   "compas-tna": "compas_tna",
   "compas-view2": "compas_view2",
+  "compress-json": "compress_json",
   "connection-pool": "connection_pool",
   "constructive-geometries": "constructive_geometries",
   "contact-map": "contact_map",
@@ -182,12 +188,12 @@
   "cookiecutter-project-upgrader": "cookiecutter_project_upgrader",
   "coreapi": "python-coreapi",
   "coreschema": "python-coreschema",
-  "cosapp-lab": "cosapp_lab",
   "cosmic-profiles": "cosmic_profiles",
   "couchdb": "python-couchdb",
   "country-converter": "country_converter",
   "crlibm": "pycrlibm",
   "cross-cal-resourcesat": "cross_cal_resourcesat",
+  "css-inline": "css_inline",
   "ctapipe-io-lst": "ctapipe_io_lst",
   "cube-helper": "cube_helper",
   "cufflinks": "python-cufflinks",
@@ -205,10 +211,14 @@
   "datacommons-pandas": "datacommons_pandas",
   "datadotworld": "datadotworld-py",
   "dbsp-drp": "dbsp_drp",
+  "deflate-dict": "deflate_dict",
   "delegator-py": "delegator",
+  "delft-fiat": "delft_fiat",
   "dem-stitcher": "dem_stitcher",
   "dependency-injector": "dependency_injector",
   "devtools": "python-devtools",
+  "dict-hash": "dict_hash",
+  "diffpy-pdffit2": "diffpy.pdffit2",
   "diffpy-utils": "diffpy.utils",
   "dirac": "dirac-grid",
   "dirty-cat": "dirty_cat",
@@ -220,6 +230,7 @@
   "djangorestframework-simplejwt": "djangorestframework_simplejwt",
   "docker": "docker-py",
   "docstring-parser": "docstring_parser",
+  "docstring-parser-fork": "docstring_parser_fork",
   "dogpile-cache": "dogpile.cache",
   "dpd-components": "dpd_components",
   "draftjs-exporter": "draftjs_exporter",
@@ -254,6 +265,7 @@
   "duet": "duet-python",
   "dust-extinction": "dust_extinction",
   "ecco-v4-py": "ecco_v4_py",
+  "ecoinvent-interface": "ecoinvent_interface",
   "edgedb": "edgedb-python",
   "edit-distance": "edit_distance",
   "edn-format": "edn_format",
@@ -262,6 +274,7 @@
   "enterprise-extensions": "enterprise_extensions",
   "enum-tools": "enum_tools",
   "environment-settings": "environment_settings",
+  "environments-utils": "environments_utils",
   "epiweeks": "epiweeks4cf",
   "eppy": "eppy-core",
   "espnet-model-zoo": "espnet_model_zoo",
@@ -274,6 +287,7 @@
   "facebook-business": "facebook_business",
   "factory-boy": "factory_boy",
   "fast-dp": "fast_dp",
+  "fast-hdbscan": "fast_hdbscan",
   "fast-matrix-market": "fast_matrix_market",
   "fastapi-utils": "fastapi_utils",
   "fastjsonschema": "python-fastjsonschema",
@@ -326,6 +340,7 @@
   "gw-eccentricity": "gw_eccentricity",
   "h2o": "h2o-py",
   "h3": "h3-py",
+  "h5io-browser": "h5io_browser",
   "haesleinhuepf-pyqode-core": "haesleinhuepf-pyqode.core",
   "haesleinhuepf-pyqode-python": "haesleinhuepf-pyqode.python",
   "hdfs": "python-hdfs",
@@ -337,6 +352,7 @@
   "hockey-rink": "hockey_rink",
   "hs-restclient": "hs_restclient",
   "hspf-reader": "hspf_reader",
+  "hspf-utils": "hspf_utils",
   "huggingface-hub": "huggingface_hub",
   "hunspell": "pyhunspell",
   "hurry-filesize": "hurry.filesize",
@@ -363,6 +379,7 @@
   "interval-tree": "interval_tree",
   "ioos-qartod": "ioos_qartod",
   "ioos-qc": "ioos_qc",
+  "ipopt": "cyipopt",
   "ipython-genutils": "ipython_genutils",
   "ipython-unittest": "ipython_unittest",
   "isal": "python-isal",
@@ -428,6 +445,7 @@
   "jupyterlab-vim": "jupyterlab_vim",
   "jupyterlab-widgets": "jupyterlab_widgets",
   "kaldi-io": "kaldi_io",
+  "kerberos": "python-kerberos",
   "kernel-driver": "kernel_driver",
   "keyrings-alt": "keyrings.alt",
   "korean-lunar-calendar": "korean_lunar_calendar",
@@ -480,6 +498,7 @@
   "more-forwarded": "more.forwarded",
   "more-pathtool": "more.pathtool",
   "more-static": "more.static",
+  "mp-pytorch": "mp_pytorch",
   "mpl-animators": "mpl_animators",
   "mpl-plotter": "mpl_plotter",
   "mpl-qt-viz": "mpl_qt_viz",
@@ -530,6 +549,7 @@
   "nr-types": "nr.types",
   "nr-util": "nr.util",
   "nr-utils-re": "nr.utils.re",
+  "nrel-routee-compass": "nrel.routee.compass",
   "nteract-on-jupyter": "nteract_on_jupyter",
   "nu-gitflow": "gitflow",
   "numba-celltree": "numba_celltree",
@@ -541,6 +561,7 @@
   "nvidia-ml-py3": "nvidia-ml",
   "ocean-data-gateway": "ocean_data_gateway",
   "octave-kernel": "octave_kernel",
+  "om-pycycle": "pycycle",
   "omfit-classes": "omfit_classes",
   "openalpr": "py-openalpr",
   "opencv-python": "opencv",
@@ -564,7 +585,6 @@
   "oxasl-enable": "oxasl_enable",
   "oxasl-ve": "oxasl_ve",
   "panda3d-viewer": "panda3d_viewer",
-  "pandas-flavor": "pandas_flavor",
   "pandas-market-calendars": "pandas_market_calendars",
   "pandas-ml": "pandas_ml",
   "pandas-schema": "pandas_schema",
@@ -595,6 +615,7 @@
   "plaster-pastedeploy": "plaster_pastedeploy",
   "plot-map": "plot_map",
   "plotly-express": "plotly_express",
+  "polaris-lib": "polaris",
   "policy-sentry": "policy_sentry",
   "premise-gwp": "premise_gwp",
   "pretty-errors": "pretty_errors",
@@ -604,6 +625,7 @@
   "prince": "prince-factor-analysis",
   "progress-reporter": "progress_reporter",
   "project-name": "project_name",
+  "prometheus-client": "prometheus_client",
   "prometheus-flask-exporter": "prometheus_flask_exporter",
   "psycopg2-binary": "psycopg2",
   "pure-eval": "pure_eval",
@@ -618,17 +640,16 @@
   "pyannote-database": "pyannote.database",
   "pyart-mch": "pyart_mch",
   "pybloom-live": "pybloom_live",
-  "pyct": "pyct-core",
   "pydantic-factories": "pydantic_factories",
   "pydatamail-google": "pydatamail_google",
   "pydatamail-ml": "pydatamail_ml",
   "pyfai": "pyfai-base",
   "pygments-anyscript": "pygments_anyscript",
   "pygments-pytest": "pygments_pytest",
-  "pyinstrument-cext": "pyinstrument_cext",
   "pyiron-atomistics": "pyiron_atomistics",
   "pyiron-base": "pyiron_base",
   "pyiron-continuum": "pyiron_continuum",
+  "pyiron-contrib": "pyiron_contrib",
   "pyiron-dft": "pyiron_dft",
   "pyiron-example-job": "pyiron_example_job",
   "pyiron-experimental": "pyiron_experimental",
@@ -636,9 +657,12 @@
   "pyiron-gui": "pyiron_gui",
   "pyiron-lammps": "pyiron_lammps",
   "pyiron-ontology": "pyiron_ontology",
+  "pyiron-potentialfit": "pyiron_potentialfit",
   "pyiron-vasp": "pyiron_vasp",
+  "pyiron-workflow": "pyiron_workflow",
   "pylhc-submitter": "pylhc_submitter",
   "pymca5": "pymca",
+  "pymongo-inmemory": "pymongo_inmemory",
   "pymt-landlab": "pymt_landlab",
   "pymt-rafem": "pymt_rafem",
   "pypdf2-fields": "pypdf2_fields",
@@ -660,7 +684,6 @@
   "pyramid-session-redis": "pyramid_session_redis",
   "pyramid-tm": "pyramid_tm",
   "pyscamp": "pyscamp-gpu",
-  "pytest-notebook": "pytest_notebook",
   "python-fileinspector": "fileinspector",
   "python-http-client": "python_http_client",
   "python-pseudorandom": "pseudorandom",
@@ -679,6 +702,8 @@
   "querystring-parser": "querystring_parser",
   "quetz-server": "quetz",
   "quickjs": "python-quickjs",
+  "r5py-sampledata-helsinki": "r5py.sampledata.helsinki",
+  "r5py-sampledata-sao-paulo": "r5py.sampledata.sao_paulo",
   "radical-analytics": "radical.analytics",
   "radical-entk": "radical.entk",
   "radical-gtod": "radical.gtod",
@@ -686,8 +711,8 @@
   "radical-saga": "radical.saga",
   "radical-utils": "radical.utils",
   "rake-nltk": "rake_nltk",
-  "rank-filter": "rank_filter",
   "rapidfuzz-capi": "rapidfuzz_capi",
+  "ray": "ray-core",
   "rbc-project": "rbc",
   "rdkit-utilities": "rdkit_utilities",
   "readme-renderer": "readme_renderer",
@@ -728,7 +753,6 @@
   "segment-anything-py": "segment-anything",
   "selinux": "python-selinux",
   "semantic-version": "semantic_version",
-  "service-identity": "service_identity",
   "setuptools-dso": "setuptools_dso",
   "setuptools-markdown": "setuptools_markdown",
   "setuptools-scm-git-archive": "setuptools_scm_git_archive",
@@ -742,6 +766,7 @@
   "sounddevice": "python-sounddevice",
   "soundfile": "pysoundfile",
   "sourmash": "sourmash-minimal",
+  "sourmash-plugin-branchwater": "sourmash_plugin_branchwater",
   "soxr": "soxr-python",
   "sparse-dot-mkl": "sparse_dot_mkl",
   "sparse-dot-topn": "sparse_dot_topn",
@@ -759,6 +784,7 @@
   "sre-yield": "sre_yield",
   "srtm-py": "srtm.py",
   "ssh-ipykernel-interrupt": "ssh_ipykernel_interrupt",
+  "ssp-detector": "ssp_detector",
   "stack-data": "stack_data",
   "start-jupyter-cm": "start_jupyter_cm",
   "stats-arrays": "stats_arrays",
@@ -770,6 +796,7 @@
   "substrait": "python-substrait",
   "suitesparse-graphblas": "python-suitesparse-graphblas",
   "super-state-machine": "super_state_machine",
+  "support-developer": "support_developer",
   "svg-py": "svg.py",
   "switch-model": "switch_model",
   "sympy-plot-backends": "sympy_plot_backends",
@@ -811,15 +838,17 @@
   "urbansim-templates": "urbansim_templates",
   "urwid-readline": "urwid_readline",
   "usedave": "dave",
+  "va-am": "va_am",
   "validate-email": "validate_email",
+  "validate-version-code": "validate_version_code",
   "variable-generators": "variable_generators",
   "vega-datasets": "vega_datasets",
   "version-information": "version_information",
+  "vertica-highcharts": "vertica_highcharts",
   "virtue-skill": "virtue",
   "vsts": "vsts-python-api",
   "web-py": "web.py",
   "webp": "pywebp",
-  "weldx-widgets": "weldx_widgets",
   "wget": "python-wget",
   "wgpu": "wgpu-py",
   "wheelhouse-uploader": "wheelhouse_uploader",
@@ -856,5 +885,6 @@
   "zope-hookable": "zope.hookable",
   "zope-index": "zope.index",
   "zope-interface": "zope.interface",
-  "zope-proxy": "zope.proxy"
+  "zope-proxy": "zope.proxy",
+  "zope-sqlalchemy": "zope.sqlalchemy"
 }
\ No newline at end of file