Repository: sputt/req-compile

Issue:
req-compile incompatible with setuptools>=60
Newer setuptools has dropped support for parsing legacy versions with wildcards.  Req-compile incorrectly parses versions in a constraint expression using pkg_resources.parse_version, which is not always possible because they may not be valid versions and could be a wildcard expression like this.

Update that logic to allow for any valid version in a requirement expression.

Discussion:

Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 373616d..3c56acf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -29,7 +29,7 @@ jobs:
         python-version: "${{ matrix.python_version }}"
     - name: Install dependencies
       run: |
-        python -m pip install --upgrade pip
+        python -m pip install --upgrade pip setuptools
         pip install -r requirements.txt --user
     - name: Run mypy
       run: |
@@ -38,7 +38,7 @@ jobs:
         MYPYPATH: req-compile/stubs
     - name: Run pylint
       run: |
-        python -m pylint req_compile
+        python -m pylint req_compile tests
     - name: Test with pytest
       run: |
         python -m pytest -v
diff --git a/req_compile/utils.py b/req_compile/utils.py
index a0a2797..a3051c1 100644
--- a/req_compile/utils.py
+++ b/req_compile/utils.py
@@ -245,7 +245,13 @@ def is_pinned_requirement(req: pkg_resources.Requirement) -> bool:
 
 def has_prerelease(req: pkg_resources.Requirement) -> bool:
     """Returns whether an InstallRequirement has a prerelease specifier."""
-    return any(parse_version(spec.version).is_prerelease for spec in req.specifier)
+    for spec in req.specifier:
+        try:
+            if parse_version(spec.version).is_prerelease:
+                return True
+        except Exception:  # pylint: disable=broad-exception-caught
+            pass
+    return False
 
 
 @lru_cache(maxsize=None)
diff --git a/requirements.txt b/requirements.txt
index 153224e..7ef9910 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,30 +3,30 @@
 appdirs==1.4.4
     # via requirements.in
     # https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl#sha256=a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128
-astroid==2.15.6
-    # via pylint (<=2.17.0-dev0,>=2.15.6)
-    # https://files.pythonhosted.org/packages/86/6f/22a8153395e193369fd43ca9d54123e74360115df4f10902516313eb9f10/astroid-2.15.6-py3-none-any.whl#sha256=389656ca57b6108f939cf5d2f9a2a825a3be50ba9d589670f393236e0a03b91c
+astroid==2.15.8
+    # via pylint (<=2.17.0-dev0,>=2.15.8)
+    # https://files.pythonhosted.org/packages/16/b6/c0b5394ec6149e0129421f1a762b805e0e583974bc3cd65e3c7ce7c95444/astroid-2.15.8-py3-none-any.whl#sha256=1aa149fc5c6589e3d0ece885b4491acd80af4f087baafa3fb5203b113e68cd3c
 black==23.3.0
     # via test-requirements.in
     # https://files.pythonhosted.org/packages/24/eb/2d2d2c27cb64cfd073896f62a952a802cd83cf943a692a2f278525b57ca9/black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl#sha256=1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b
-certifi==2023.7.22
+certifi==2024.2.2
     # via requests (>=2017.4.17)
-    # https://files.pythonhosted.org/packages/4c/dd/2234eab22353ffc7d94e8d13177aaa050113286e93e7b40eae01fbf7c3d9/certifi-2023.7.22-py3-none-any.whl#sha256=92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9
-charset-normalizer==3.2.0
+    # https://files.pythonhosted.org/packages/ba/06/a07f096c664aeb9f01624f858c3add0a4e913d6c96257acb4fce61e7de14/certifi-2024.2.2-py3-none-any.whl#sha256=dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1
+charset-normalizer==3.3.2
     # via requests (<4,>=2)
-    # https://files.pythonhosted.org/packages/95/ee/8bb03c3518a228dc5956d1b4f46d8258639ff118881fba456b72b06561cf/charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl#sha256=c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346
+    # https://files.pythonhosted.org/packages/4f/d1/d547cc26acdb0cc458b152f79b2679d7422f29d41581e6fa907861e88af1/charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl#sha256=95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c
 click==8.1.7
     # via black (>=8.0.0)
     # https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl#sha256=ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28
 dill==0.3.7
     # via pylint (>=0.2)
     # https://files.pythonhosted.org/packages/f5/3a/74a29b11cf2cdfcd6ba89c0cecd70b37cd1ba7b77978ce611eb7a146a832/dill-0.3.7-py3-none-any.whl#sha256=76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e
-exceptiongroup==1.1.3
+exceptiongroup==1.2.0
     # via pytest (>=1.0.0rc8)
-    # https://files.pythonhosted.org/packages/ad/83/b71e58666f156a39fb29417e4c8ca4bc7400c0dd4ed9e8842ab54dc8c344/exceptiongroup-1.1.3-py3-none-any.whl#sha256=343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3
-idna==3.4
+    # https://files.pythonhosted.org/packages/b8/9a/5028fd52db10e600f1c4674441b968cf2ea4959085bfb5b99fb1250e5f68/exceptiongroup-1.2.0-py3-none-any.whl#sha256=4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14
+idna==3.6
     # via requests (<4,>=2.5)
-    # https://files.pythonhosted.org/packages/fc/34/3030de6f1370931b9dbb4dad48f6ab1015ab1d32447850b9fc94e60097be/idna-3.4-py3-none-any.whl#sha256=90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2
+    # https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl#sha256=c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f
 importlib-metadata==6.7.0
     # via
     #   click
@@ -55,34 +55,34 @@ mypy-extensions==1.0.0
     #   black (>=0.4.3)
     #   mypy (>=1.0.0)
     # https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl#sha256=4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d
-overrides==7.4.0
+overrides==7.7.0
     # via requirements.in
-    # https://files.pythonhosted.org/packages/da/28/3fa6ef8297302fc7b3844980b6c5dbc71cdbd4b61e9b2591234214d5ab39/overrides-7.4.0-py3-none-any.whl#sha256=3ad24583f86d6d7a49049695efe9933e67ba62f0c7625d53c59fa832ce4b8b7d
-packaging==23.1
+    # https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl#sha256=c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49
+packaging==23.2
     # via
     #   black (>=22.0)
     #   pytest
     #   requirements.in
-    # https://files.pythonhosted.org/packages/ab/c3/57f0601a2d4fe15de7a553c00adbc901425661bf048f2a22dfc500caf121/packaging-23.1-py3-none-any.whl#sha256=994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61
+    # https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl#sha256=8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7
 pathspec==0.11.2
     # via black (>=0.9.0)
     # https://files.pythonhosted.org/packages/b4/2a/9b1be29146139ef459188f5e420a66e835dda921208db600b7037093891f/pathspec-0.11.2-py3-none-any.whl#sha256=1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20
-platformdirs==3.10.0
+platformdirs==4.0.0
     # via
     #   black (>=2)
     #   pylint (>=2.2.0)
-    # https://files.pythonhosted.org/packages/14/51/fe5a0d6ea589f0d4a1b97824fb518962ad48b27cd346dcdfa2405187997a/platformdirs-3.10.0-py3-none-any.whl#sha256=d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d
+    # https://files.pythonhosted.org/packages/31/16/70be3b725073035aa5fc3229321d06e22e73e3e09f6af78dcfdf16c7636c/platformdirs-4.0.0-py3-none-any.whl#sha256=118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b
 pluggy==1.2.0
     # via pytest (<2.0,>=0.12)
     # https://files.pythonhosted.org/packages/51/32/4a79112b8b87b21450b066e102d6608907f4c885ed7b04c3fdb085d4d6ae/pluggy-1.2.0-py3-none-any.whl#sha256=c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849
-pylint==2.17.5
+pylint==2.17.7
     # via test-requirements.in
-    # https://files.pythonhosted.org/packages/63/cc/00cbe3f09bd6d98d79ee66cf76451d253fb1a8a59029535ea2b6ba8a824d/pylint-2.17.5-py3-none-any.whl#sha256=73995fb8216d3bed149c8d51bba25b2c52a8251a2c8ac846ec668ce38fab5413
-pytest==7.4.2
+    # https://files.pythonhosted.org/packages/b4/49/cea450a83079445a84f16050e571a7c383d3f474b13c5caedfebd4e35def/pylint-2.17.7-py3-none-any.whl#sha256=27a8d4c7ddc8c2f8c18aa0050148f89ffc09838142193fdbe98f172781a3ff87
+pytest==7.4.4
     # via
     #   pytest-mock (>=5.0)
     #   test-requirements.in
-    # https://files.pythonhosted.org/packages/df/d0/e192c4275aecabf74faa1aacd75ef700091913236ec78b1a98f62a2412ee/pytest-7.4.2-py3-none-any.whl#sha256=1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002
+    # https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl#sha256=b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8
 pytest-mock==3.11.1
     # via test-requirements.in
     # https://files.pythonhosted.org/packages/da/85/80ae98e019a429445bfb74e153d4cb47c3695e3e908515e95e95c18237e5/pytest_mock-3.11.1-py3-none-any.whl#sha256=21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39
@@ -113,9 +113,9 @@ tomli==2.0.1
     #   pylint (>=1.1.0)
     #   pytest (>=1.0.0)
     # https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl#sha256=939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc
-tomlkit==0.12.1
+tomlkit==0.12.3
     # via pylint (>=0.10.1)
-    # https://files.pythonhosted.org/packages/a0/6d/808775ed618e51edaa7bbe6759e22e1c7eafe359af6e084700c6d39d3455/tomlkit-0.12.1-py3-none-any.whl#sha256=712cbd236609acc6a3e2e97253dfc52d4c2082982a88f61b640ecf0817eab899
+    # https://files.pythonhosted.org/packages/6e/43/159750d32481f16e34cc60090b53bc0a14314ad0c1f67a9bb64f3f3a0551/tomlkit-0.12.3-py3-none-any.whl#sha256=b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba
 typed-ast==1.5.5
     # via
     #   astroid (<2.0,>=1.4.0)
@@ -125,21 +125,18 @@ typed-ast==1.5.5
 types-appdirs==1.4.3.5
     # via test-requirements.in
     # https://files.pythonhosted.org/packages/cf/07/41f5b9b11f11855eb67760ed680330e0ce9136a44b51c24dd52edb1c4eb1/types_appdirs-1.4.3.5-py3-none-any.whl#sha256=337c750e423c40911d389359b4edabe5bbc2cdd5cd0bd0518b71d2839646273b
-types-PyYAML==6.0.12.11
+types-PyYAML==6.0.12.12
     # via responses
-    # https://files.pythonhosted.org/packages/da/14/7ee3c82b073aa56ba51a7c61e1c37045171fde3d7e60a6e2b1763bdb455c/types_PyYAML-6.0.12.11-py3-none-any.whl#sha256=a461508f3096d1d5810ec5ab95d7eeecb651f3a15b71959999988942063bf01d
-types-requests==2.31.0.2
+    # https://files.pythonhosted.org/packages/9d/df/aabb870a04254ceb8a406b0a4222c1b14f7fdf3d2d7633ba49364aca27f3/types_PyYAML-6.0.12.12-py3-none-any.whl#sha256=c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24
+types-requests==2.31.0.20231231
     # via test-requirements.in
-    # https://files.pythonhosted.org/packages/06/9b/04bb62f11a6824df5d4568439cf0715118c265d0ffbebeb7cf4b8c9caa15/types_requests-2.31.0.2-py3-none-any.whl#sha256=56d181c85b5925cbc59f4489a57e72a8b2166f18273fd8ba7b6fe0c0b986f12a
-types-setuptools==68.2.0.0
+    # https://files.pythonhosted.org/packages/1b/23/126ffd0c885926fbd95eac1148093a4d87e9698a1f938be16d109e63ca64/types_requests-2.31.0.20231231-py3-none-any.whl#sha256=2e2230c7bc8dd63fa3153c1c0ae335f8a368447f0582fc332f17d54f88e69027
+types-setuptools==69.0.0.0
     # via test-requirements.in
-    # https://files.pythonhosted.org/packages/5c/5a/fbfbe3d1db90c59fb0240cf13a84953677b15874d00e80e773425447633c/types_setuptools-68.2.0.0-py3-none-any.whl#sha256=77edcc843e53f8fc83bb1a840684841f3dc804ec94562623bfa2ea70d5a2ba1b
+    # https://files.pythonhosted.org/packages/66/a3/9800e99c4081cb1bff0ec10bf6effb93edc9253ce2ec6db50be1a9d57053/types_setuptools-69.0.0.0-py3-none-any.whl#sha256=8c86195bae2ad81e6dea900a570fe9d64a59dbce2b11cc63c046b03246ea77bf
 types-toml==0.10.8.7
     # via test-requirements.in
     # https://files.pythonhosted.org/packages/be/0e/caa95b3a1ea0f9625c4dc980bfb0a1686529c6fbfa222fffc77c52918464/types_toml-0.10.8.7-py3-none-any.whl#sha256=61951da6ad410794c97bec035d59376ce1cbf4453dc9b6f90477e81e4442d631
-types-urllib3==1.26.25.14
-    # via types-requests
-    # https://files.pythonhosted.org/packages/11/7b/3fc711b2efea5e85a7a0bbfe269ea944aa767bbba5ec52f9ee45d362ccf3/types_urllib3-1.26.25.14-py3-none-any.whl#sha256=9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e
 typing_extensions==4.7.1
     # via
     #   astroid (>=4.0.0)
@@ -151,17 +148,18 @@ typing_extensions==4.7.1
     #   requirements.in
     #   responses
     # https://files.pythonhosted.org/packages/ec/6b/63cc3df74987c36fe26157ee12e09e8f9db4de771e0f3404263117e75b95/typing_extensions-4.7.1-py3-none-any.whl#sha256=440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36
-urllib3==2.0.4
+urllib3==2.0.7
     # via
     #   requests (<3,>=1.21.1)
     #   responses (<3.0,>=1.25.10)
-    # https://files.pythonhosted.org/packages/9b/81/62fd61001fa4b9d0df6e31d47ff49cfa9de4af03adecf339c7bc30656b37/urllib3-2.0.4-py3-none-any.whl#sha256=de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4
-wheel==0.41.2
+    #   types-requests (>=2)
+    # https://files.pythonhosted.org/packages/d2/b2/b157855192a68541a91ba7b2bbcb91f1b4faa51f8bae38d8005c034be524/urllib3-2.0.7-py3-none-any.whl#sha256=fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e
+wheel==0.42.0
     # via requirements.in
-    # https://files.pythonhosted.org/packages/b8/8b/31273bf66016be6ad22bb7345c37ff350276cfd46e389a0c2ac5da9d9073/wheel-0.41.2-py3-none-any.whl#sha256=75909db2664838d015e3d9139004ee16711748a52c8f336b52882266540215d8
-wrapt==1.15.0
+    # https://files.pythonhosted.org/packages/c7/c3/55076fc728723ef927521abaa1955213d094933dc36d4a2008d5101e1af5/wheel-0.42.0-py3-none-any.whl#sha256=177f9c9b0d45c47873b619f5b650346d632cdc35fb5e4d25058e09c9e581433d
+wrapt==1.16.0
     # via astroid (<2,>=1.11)
-    # https://files.pythonhosted.org/packages/47/dd/bee4d33058656c0b2e045530224fcddd9492c354af5d20499e5261148dcb/wrapt-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl#sha256=5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317
+    # https://files.pythonhosted.org/packages/47/cf/c2861bc5e0d5f4f277e1cefd7b3f8904794cc58469d35eaa82032a84e1c9/wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl#sha256=a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593
 zipp==3.15.0
     # via importlib-metadata (>=0.5)
     # https://files.pythonhosted.org/packages/5b/fa/c9e82bbe1af6266adf08afb563905eb87cab83fde00a0a08963510621047/zipp-3.15.0-py3-none-any.whl#sha256=48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556
diff --git a/setup.py b/setup.py
index c67fd12..880145a 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
 
 setup(
     name="req-compile",
-    version="1.0.0rc9",
+    version="1.0.0rc10",
     author="Spencer Putt",
     author_email="sputt@alumni.iu.edu",
     description="Python requirements compiler",


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 30b1ba8..5e88689 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,4 @@
+# pylint: disable=unused-variable,redefined-outer-name
 import collections
 import logging
 import os
@@ -30,13 +31,15 @@ def clear_caches():
 
 @pytest.fixture
 def metadata_provider():
+    # pylint: disable-next=unused-argument
     def _parse_metadata(filename, origin=None, extras=()):
         full_name = (
             filename
             if os.path.isabs(filename)
             else os.path.join(os.path.dirname(__file__), filename)
         )
-        with open(full_name, "r") as handle:
+        with open(full_name, "r", encoding="utf-8") as handle:
+            # pylint: disable-next=protected-access
             return req_compile.metadata.dist_info._parse_flat_metadata(handle.read())
 
     return _parse_metadata
@@ -88,7 +91,8 @@ class MockRepository(Repository):
             if os.path.isabs(path)
             else os.path.join(os.path.dirname(__file__), path)
         )
-        with open(full_name, "r") as handle:
+        with open(full_name, "r", encoding="utf-8") as handle:
+            # pylint: disable-next=protected-access
             metadata = req_compile.metadata.dist_info._parse_flat_metadata(
                 handle.read()
             )
@@ -169,7 +173,7 @@ def mock_zip():
 
         with ZipFile(zip_archive, "w") as handle:
             handle.write(directory, arcname=os.path.basename(directory))
-            for root, dirs, files in os.walk(directory):
+            for root, _, files in os.walk(directory):
                 for file in files:
                     full_path = os.path.join(root, file)
 
diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py
index d936577..87e2448 100644
--- a/tests/test_cmdline.py
+++ b/tests/test_cmdline.py
@@ -1,3 +1,4 @@
+# pylint: disable=redefined-outer-name
 from io import StringIO
 import os
 
@@ -112,7 +113,11 @@ def test_stdin_paths(mock_stdin):
     extra_sources = []
     result = _create_input_reqs("-", extra_sources)
 
-    assert set(extra_sources) == {f"--source={mono1}", f"--source={mono2}", f"--source={mono3}"}
+    assert set(extra_sources) == {
+        f"--source={mono1}",
+        f"--source={mono2}",
+        f"--source={mono3}",
+    }
     assert set(result.reqs) == set(
         pkg_resources.parse_requirements(["pkg1==1.0.0", "pkg2==2.0.1", "pkg3==0.0.0"])
     )
@@ -125,7 +130,7 @@ def test_stdin_reqs(mock_stdin):
     extra_sources = []
     result = _create_input_reqs("-", extra_sources)
 
-    assert extra_sources == []
+    assert not extra_sources
     assert set(result.reqs) == set(
         pkg_resources.parse_requirements(["pytest", "pytest-mock"])
     )
diff --git a/tests/test_compile.py b/tests/test_compile.py
index 2ac32ff..3d36ea7 100644
--- a/tests/test_compile.py
+++ b/tests/test_compile.py
@@ -1,3 +1,4 @@
+# pylint: disable=redefined-outer-name
 import os
 from typing import Iterable, Optional, Tuple
 from unittest import mock
@@ -19,10 +20,11 @@ import req_compile.utils
 from req_compile.utils import normalize_project_name
 
 
-def test_mock_pypi(mock_metadata, mock_pypi):
+@pytest.mark.usefixtures("mock_metadata")
+def test_mock_pypi(mock_pypi):
     mock_pypi.load_scenario("normal")
 
-    metadata, cached = mock_pypi.get_dist(pkg_resources.Requirement.parse("test"))
+    metadata, _ = mock_pypi.get_dist(pkg_resources.Requirement.parse("test"))
     assert metadata.name == "test"
     assert metadata.version == req_compile.utils.parse_version("1.0.0")
 
@@ -34,7 +36,8 @@ def _real_outputs(results):
 
 
 @fixture
-def perform_compile(mock_metadata, mock_pypi):
+# pylint: disable-next=unused-argument
+def perform_compile(mock_pypi, mock_metadata):
     def _compile(scenario, reqs, constraint_reqs=None, limit_reqs=None):
         mock_pypi.load_scenario(scenario, limit_reqs=limit_reqs)
         if constraint_reqs is not None:
@@ -75,7 +78,12 @@ def perform_compile(mock_metadata, mock_pypi):
         ("normal", ["a"], None, ["a==0.1.0"]),
         ("normal", ["a[x1]"], None, ["a[x1]==0.1.0", "b==1.1.0", "c==1.0.0"]),
         ("normal", ["a", "b", "c"], None, ["a==0.1.0", "b==1.1.0", "c==1.0.0"]),
-        ("normal", ["d"], None, ["a[x1]==0.1.0", "b==1.1.0", "c==1.0.0", "d==0.9.0"],),
+        (
+            "normal",
+            ["d"],
+            None,
+            ["a[x1]==0.1.0", "b==1.1.0", "c==1.0.0", "d==0.9.0"],
+        ),
         (
             "normal",
             ["e", "d"],
@@ -97,7 +105,12 @@ def perform_compile(mock_metadata, mock_pypi):
         ),
         ("multi", ["x<1"], None, ["x==0.9.0"]),
         # Test that top level pins apply regardless of source
-        ("multi", {"a.txt": ["x"], "b.txt": ["x<1"]}, None, ["x==0.9.0"],),
+        (
+            "multi",
+            {"a.txt": ["x"], "b.txt": ["x<1"]},
+            None,
+            ["x==0.9.0"],
+        ),
         # Check for a transitive pin violation
         (
             "multi",
@@ -106,10 +119,25 @@ def perform_compile(mock_metadata, mock_pypi):
             ["x==0.9.0", "y==4.0.0"],
         ),
         ("multi", ["x"], ["x<1"], ["x==0.9.0"]),
-        ("multi", ["x==1"], ["y==5"], ["x==1.0.0"],),
+        (
+            "multi",
+            ["x==1"],
+            ["y==5"],
+            ["x==1.0.0"],
+        ),
         # Check that metadata that declares to requirements on the same dependency is processed correctly
-        ("multi", ["z"], None, ["z==1.0.0", "y==4.0.0", "x==0.9.0"],),
-        ("walk-back", ["a<3.7", "b"], None, ["a==3.6", "b==1.0"],),
+        (
+            "multi",
+            ["z"],
+            None,
+            ["z==1.0.0", "y==4.0.0", "x==0.9.0"],
+        ),
+        (
+            "walk-back",
+            ["a<3.7", "b"],
+            None,
+            ["a==3.6", "b==1.0"],
+        ),
         (
             "early-violated",
             ["a", "y"],
@@ -242,19 +270,26 @@ class OnlyBinaryRepository(Repository):
 def test_only_binary_skips_source():
     """Verify the newer source dist is skipped."""
     repo = OnlyBinaryRepository("test", True)
-    input = [DistInfo("-", None, [pkg_resources.Requirement.parse("test")])]
-    results = req_compile.compile.perform_compile(input, repo,)
+    inputs = [DistInfo("-", None, [pkg_resources.Requirement.parse("test")])]
+    results = req_compile.compile.perform_compile(
+        inputs,
+        repo,
+    )
     assert _real_outputs(results) == {
         "test==2.0.0",
     }
     results = req_compile.compile.perform_compile(
-        input, repo, only_binary={normalize_project_name("test")},
+        inputs,
+        repo,
+        only_binary={normalize_project_name("test")},
     )
     assert _real_outputs(results) == {
         "test==1.0.0",
     }
     results = req_compile.compile.perform_compile(
-        input, repo, only_binary=AllOnlyBinarySet(),
+        inputs,
+        repo,
+        only_binary=AllOnlyBinarySet(),
     )
     assert _real_outputs(results) == {
         "test==1.0.0",
diff --git a/tests/test_extractor.py b/tests/test_extractor.py
index 88289d8..7569d32 100644
--- a/tests/test_extractor.py
+++ b/tests/test_extractor.py
@@ -1,5 +1,4 @@
 import contextlib
-import logging
 import os
 
 import pytest
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 841ebd7..a4097e5 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -13,6 +13,7 @@ def test_source_candidates():
         encoding="utf-8",
         capture_output=True,
         cwd=ROOT_DIR,
+        check=True,
     )
     assert "req-compile" in result.stdout
     assert result.stdout.count("\n") == 1
@@ -30,8 +31,8 @@ def test_no_candidates(tmp_path):
         capture_output=True,
         env=env_copy,
         cwd=tmp_path,
+        check=True,
     )
-    assert result.returncode == 0, result.stderr
     assert result.stdout == ""
     assert "0" in result.stderr, result.stderr
 
@@ -49,11 +50,11 @@ def test_compile_req_compile(tmp_path):
             "--wheel-dir",
             str(tmp_path),
         ],
+        check=True,
         encoding="utf-8",
         capture_output=True,
         cwd=ROOT_DIR,
     )
-    assert result.returncode == 0
     assert "req-compile" in result.stdout
     assert "toml" in result.stdout
     assert result.stderr == ""
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index 3411b5a..92bf455 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -1,3 +1,4 @@
+# pylint: disable=protected-access
 import os
 import sys
 
@@ -16,36 +17,46 @@ def test_a_with_no_extra(metadata_provider):
     info = metadata_provider("normal/a-1.0.0.METADATA")
     assert info.name == "a"
     assert info.version == pkg_resources.parse_version("0.1.0")
-    assert list(info.requires()) == []
+    assert not list(info.requires())
 
 
 def test_parse_flat_metadata_extra_space():
     results = req_compile.metadata.dist_info._parse_flat_metadata(
-        open(os.path.join(os.path.dirname(__file__), "METADATA-extra-space")).read()
+        open(
+            os.path.join(os.path.dirname(__file__), "METADATA-extra-space"),
+            encoding="utf-8",
+        ).read()
     )
     assert results.requires() == [pkg_resources.Requirement.parse("django")]
 
 
 def test_parse_flat_metadata_two_names():
     results = req_compile.metadata.dist_info._parse_flat_metadata(
-        open(os.path.join(os.path.dirname(__file__), "METADATA-two-names")).read()
+        open(
+            os.path.join(os.path.dirname(__file__), "METADATA-two-names"),
+            encoding="utf-8",
+        ).read()
     )
     assert results.name == "fabio"
 
 
 def test_parse_flat_metadata_bizarre_extra():
     results = req_compile.metadata.dist_info._parse_flat_metadata(
-        open(os.path.join(os.path.dirname(__file__), "METADATA-bizarre-extra")).read()
+        open(
+            os.path.join(os.path.dirname(__file__), "METADATA-bizarre-extra"),
+            encoding="utf-8",
+        ).read()
     )
     assert results.name == "setuptools"
-    assert results.requires() == []
+    assert not list(results.requires())
     assert results.requires(extra="ssl:sys_platform=='win32'")[0].name == "wincertstore"
 
 
 def test_parse_flat_metadata_complex_marker():
     results = req_compile.metadata.dist_info._parse_flat_metadata(
         open(
-            os.path.join(os.path.dirname(__file__), "METADATA-implementation-marker")
+            os.path.join(os.path.dirname(__file__), "METADATA-implementation-marker"),
+            encoding="utf-8",
         ).read()
     )
     assert {req.name for req in results.requires()} == {"yaml.clib"}
@@ -64,7 +75,7 @@ def test_a_with_wrong_extra(metadata_provider):
     info = metadata_provider("normal/a-1.0.0.METADATA", extras=("plop",))
     assert info.name == "a"
     assert info.version == pkg_resources.parse_version("0.1.0")
-    assert list(info.requires()) == []
+    assert not list(info.requires())
 
 
 def test_pylint_python(metadata_provider):
@@ -194,6 +205,7 @@ def test_extern_import(mock_targz):
     archive = mock_targz("extern-importer-1.0")
 
     metadata = req_compile.metadata.metadata.extract_metadata(archive)
+    assert metadata.name == "extern-importer"
 
 
 def test_self_source():
diff --git a/tests/test_repositories.py b/tests/test_repositories.py
index fd5d30c..8f55534 100644
--- a/tests/test_repositories.py
+++ b/tests/test_repositories.py
@@ -88,7 +88,8 @@ def test_sort_specific_platforms(mock_py_version, mocker):
         ("this_platform",),
     )
     candidate_wheels = (
-        "sounddevice-0.4.1-cp32.cp33.cp34.cp35.cp36.cp37.cp38.cp39.pp32.pp33.pp34.pp35.pp36.pp37.py3-None-this_platform.whl",
+        "sounddevice-0.4.1-cp32.cp33.cp34.cp35.cp36.cp37.cp38.cp39.pp32."
+        + "pp33.pp34.pp35.pp36.pp37.py3-None-this_platform.whl",
         "sounddevice-0.4.1-py3-None-any.whl",
     )
     candidates = []
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..7d86569
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,18 @@
+import pytest
+
+from req_compile.utils import has_prerelease, parse_requirement
+
+
+@pytest.mark.parametrize(
+    "expression,result",
+    [
+        ("thing>=2.3.1b1", True),
+        ("thing==2.*", False),
+        ("thing==1!2.2.*", False),
+        ("thing==2pre3", True),
+        ("thing==4a4", True),
+    ],
+)
+def test_has_prerelease(expression, result):
+    """Verify requirement expressions can be scanned for prerelease."""
+    assert has_prerelease(parse_requirement(expression)) == result