Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FR] Improve error feedback for improper one-liner requirement with environment markers in setup.cfg #3467

Closed
frenzymadness opened this issue Jul 26, 2022 · 19 comments · Fixed by #3481
Labels
bug Needs Triage Issues that need to be evaluated for severity and status.

Comments

@frenzymadness
Copy link
Contributor

setuptools version

63.2.0

Python version

3.9 and 3.11

OS

Fedora Linux

Additional environment information

No response

Description

Project: https://github.com/thoth-station/micropipenv
The line I have a problem with: https://github.com/thoth-station/micropipenv/blob/2127293c4c9fa344fd1535c5189c39ca7ba4594d/setup.cfg#L40

It seems to me that there is a difference between this:

[options.extras_require]
toml = toml;python_version<"3.11"

and this:

[options.extras_require]
toml =
    toml;python_version<"3.11"

Micropipenv version 1.4.0 contains the [toml] definition on a single line and when you try to install micropipenv[toml], it does not work (Python 3.9.13):

Collecting micropipenv[toml]==1.4.0
  Using cached micropipenv-1.4.0-py3-none-any.whl (38 kB)
Requirement already satisfied: pip>=9 in /home/lbalhar/.virtualenvs/micropipenv/lib/python3.9/site-packages (from micropipenv[toml]==1.4.0) (22.2)
Collecting toml
  Using cached toml-0.10.2-py2.py3-none-any.whl (16 kB)
ERROR: Could not find a version that satisfies the requirement python-version<"3.11"; extra == "toml" (from micropipenv[toml]) (from versions: 0.0.2)
ERROR: No matching distribution found for python-version<"3.11"; extra == "toml"

the same happens when you use Python 3.11.

But when I use the multiline definition:

[options.extras_require]
toml =
    toml;python_version<"3.11"

it works correctly and omits toml in env with Python 3.11 and installs it if Python<3.11.

When I build a sdist from the package, there is a difference in requires.txt in egg-info directory:
single line definition:

pip>=9

[toml]
toml
python_version<"3.11"

two lines:

pip>=9

[toml]

[toml:python_version < "3.11"]
toml

Expected behavior

I'd expect the two definitions mentioned above to behave the same way.

How to Reproduce

You can try to install "micropipenv[toml]==1.4.0" and then you can change the extras_require definition as described above to see that it fixes the problem.

Output

$ tail -n 3 setup.cfg

[options.extras_require]
toml = toml;python_version<"3.11"

$ pip install .[toml]
Processing /home/lbalhar/Software/micropipenv
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Requirement already satisfied: pip>=9 in /home/lbalhar/.virtualenvs/micropipenv/lib/python3.9/site-packages (from micropipenv==1.4.0) (22.2)
Collecting toml
  Using cached toml-0.10.2-py2.py3-none-any.whl (16 kB)
ERROR: Could not find a version that satisfies the requirement python-version<"3.11"; extra == "toml" (from micropipenv[toml]) (from versions: 0.0.2)
ERROR: No matching distribution found for python-version<"3.11"; extra == "toml"

## changed the definition

$ tail -n 3 setup.cfg
[options.extras_require]
toml =
    toml;python_version<"3.11"

$ pip install .[toml]
Processing /home/lbalhar/Software/micropipenv
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Requirement already satisfied: pip>=9 in /home/lbalhar/.virtualenvs/micropipenv/lib/python3.9/site-packages (from micropipenv==1.4.0) (22.2)
Collecting toml
  Using cached toml-0.10.2-py2.py3-none-any.whl (16 kB)
Building wheels for collected packages: micropipenv
  Building wheel for micropipenv (pyproject.toml) ... done
  Created wheel for micropipenv: filename=micropipenv-1.4.0-py3-none-any.whl size=23303 sha256=b645cc71f7404f367dabe8c203cf80025f3080c7d2ac6a929aec3ae5bbab2a6a
  Stored in directory: /tmp/pip-ephem-wheel-cache-zh2n98et/wheels/2a/c1/ee/2caed205bb1a97b553aa44d9a5f1966c5bec9edea862cc2c72
Successfully built micropipenv
Installing collected packages: toml, micropipenv
Successfully installed micropipenv-1.4.0 toml-0.10.2
@frenzymadness frenzymadness added bug Needs Triage Issues that need to be evaluated for severity and status. labels Jul 26, 2022
@frenzymadness
Copy link
Contributor Author

From the configparser perspective, the difference is this:

>>> c['options.extras_require']["toml"]
'toml;python_version<"3.11"'

versus

>>> c['options.extras_require']["toml"]
'\ntoml;python_version<"3.11"'

frenzymadness added a commit to thoth-station/micropipenv that referenced this issue Jul 26, 2022
@hroncok
Copy link
Contributor

hroncok commented Jul 26, 2022

The (broken) wheel has this in the metadata:

Requires-Dist: toml ; extra == 'toml'
Requires-Dist: python-version (<"3.11") ; extra == 'toml'

@abravalheri
Copy link
Contributor

Hi @frenzymadness , this is a limitation in terms of setup.cfg that is explained in the docs:

In the extras_require section, values are parsed as list-semi. This implies that in order to include markers, they must be dangling

The rationale is that if you don't include newlines in the value, the character used to split different requirements is ;.

@frenzymadness
Copy link
Contributor Author

I've fixed the problem in release 1.4.1 so now it's even easier to reproduce. The old version fails to install no matter which version of Python you use:
Python 3.11:

$ pip install "micropipenv[toml]==1.4.0"
Collecting micropipenv[toml]==1.4.0
  Using cached micropipenv-1.4.0-py3-none-any.whl (38 kB)
Requirement already satisfied: pip>=9 in ./venv/lib/python3.11/site-packages (from micropipenv[toml]==1.4.0) (21.3.1)
ERROR: Could not find a version that satisfies the requirement python-version<"3.11"; extra == "toml" (from micropipenv[toml]) (from versions: 0.0.2)
ERROR: No matching distribution found for python-version<"3.11"; extra == "toml"

Python 3.9

$ pip install "micropipenv[toml]==1.4.0"
Collecting micropipenv[toml]==1.4.0
  Using cached micropipenv-1.4.0-py3-none-any.whl (38 kB)
Requirement already satisfied: pip>=9 in ./venv/lib/python3.9/site-packages (from micropipenv[toml]==1.4.0) (21.3.1)
Collecting toml
  Using cached toml-0.10.2-py2.py3-none-any.whl (16 kB)
ERROR: Could not find a version that satisfies the requirement python-version<"3.11"; extra == "toml" (from micropipenv[toml]) (from versions: 0.0.2)
ERROR: No matching distribution found for python-version<"3.11"; extra == "toml"

The new version works correctly and omits toml for Python 3.11:

$ pip install "micropipenv[toml]==1.4.1"
Collecting micropipenv[toml]==1.4.1
  Downloading micropipenv-1.4.1-py3-none-any.whl (38 kB)
Requirement already satisfied: pip>=9 in ./venv/lib/python3.11/site-packages (from micropipenv[toml]==1.4.1) (21.3.1)
Installing collected packages: micropipenv
Successfully installed micropipenv-1.4.1

Python 3.9

$ pip install "micropipenv[toml]==1.4.1"
Collecting micropipenv[toml]==1.4.1
  Using cached micropipenv-1.4.1-py3-none-any.whl (38 kB)
Requirement already satisfied: pip>=9 in ./venv/lib/python3.9/site-packages (from micropipenv[toml]==1.4.1) (21.3.1)
Collecting toml
  Using cached toml-0.10.2-py2.py3-none-any.whl (16 kB)
Installing collected packages: toml, micropipenv
Successfully installed micropipenv-1.4.1 toml-0.10.2

@hroncok
Copy link
Contributor

hroncok commented Jul 26, 2022

My assumption is that:

toml = toml;python_version<"3.11"

Is read as:

toml = <requirement1>;<requirement2>

While:

toml =
    toml;python_version<"3.11"

Is read as:

toml =
    <requirement1>

This behavior seems consistent with the examples from the docs:

[options.extras_require]
pdf = ReportLab>=1.2; RXP
rest = docutils>=0.3; pack ==1.1, ==1.3

Albeit, quite confusing with environment markers.

@hroncok
Copy link
Contributor

hroncok commented Jul 26, 2022

Hi @frenzymadness , this is a limitation in terms of setup.cfg that is explained in the docs:

In the extras_require section, values are parsed as list-semi. This implies that in order to include markers, they must be dangling

The rationale is that if you don't include newlines in the value, the character used to split different requirements is ;.

Oh, I sent my comment before seeing this. I agree that this is a known limitation, however I would argue that it is a trap.

We've discussed with @frenzymadness and we belive a warning might be presented when the entire line:

  • contains exactly 1 ;
  • parses as a valid requirement string
  • the part after ; parses as a valid environment marker

WDYT?

@frenzymadness
Copy link
Contributor Author

Thanks for the info @abravalheri ! What do you think about an automated check for this? I think it'd be easy to implement it. Something like: If you have extras_require on a single line and after split by ; one of the requirements contains env marker, issue a warning (or error?).

@abravalheri
Copy link
Contributor

abravalheri commented Jul 26, 2022

@hroncok yes, that is precisely the documented behaviour.

extras_require is parsed as a list-semi value, which means that if the value does not contain any new line character, the value will be split using the ; separator.

@frenzymadness , I am afraid this behaviour is not a bug but rather an intentional design decision.

If you prefer having more clarity in terms of the configuration language syntax, maybe you would like to try pyproject.toml1. Just please be aware that due to PEP 621, this kind of configuration is handled in a much more strict way.

Footnotes

  1. Differently from INI files, the TOML language has first class support for lists, so setuptools don't need to perform any arbitrary string split operation. I think this is one of the biggest "pros" of using TOML (although the language also have it's own perks).

@abravalheri
Copy link
Contributor

a warning might be presented ... the part after ; parses as a valid environment marker

That could be a nice addition. Thank you very much @hroncok and @frenzymadness . You one of you guys would like to give it a try in a PR?

@frenzymadness
Copy link
Contributor Author

We will try it soon.

@abravalheri abravalheri changed the title [BUG] extras_require with environment markers skip extra entirely [FR] Improve error feedback for improper one-liner requirement with environment markers in setup.cfg Jul 26, 2022
@frenzymadness
Copy link
Contributor Author

Before I try to create a test for it, would something like this be considered as an acceptable solution?

--- a/setuptools/config/setupcfg.py
+++ b/setuptools/config/setupcfg.py
@@ -10,11 +10,13 @@ import functools
 from collections import defaultdict
 from functools import partial
 from functools import wraps
+from itertools import chain
 from typing import (TYPE_CHECKING, Callable, Any, Dict, Generic, Iterable, List,
                     Optional, Tuple, TypeVar, Union)
 
 from distutils.errors import DistutilsOptionError, DistutilsFileError
 from setuptools.extern.packaging.version import Version, InvalidVersion
+from setuptools.extern.packaging.markers import Marker, InvalidMarker
 from setuptools.extern.packaging.specifiers import SpecifierSet
 from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
 
@@ -702,6 +704,19 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
             section_options,
             self._parse_requirements_list,
         )
+
+        for requirement in chain(*parsed.values()):
+            try:
+                Marker(requirement)
+            except InvalidMarker:
+                pass
+            else:
+                msg = ( "One of the parsed requirements in extras_require section "
+                       f"looks like a valid environment marker: '{requirement}'\n"
+                        "Make sure that the config is correct and check "
+                        "https://setuptools.pypa.io/en/latest/userguide/declarative_config.html#opt-2")
+                warnings.warn(msg, UserWarning)
+
         self['extras_require'] = parsed
 
     def parse_section_data_files(self, section_options):

implemented in parse_section_extras_require.

It basically checks all the parsed requirements – it actually tries to create a marker from them – and reports a warning if it succeedes.

@abravalheri
Copy link
Contributor

Hi @frenzymadness, thank you very much for having a look on this.

I am not entirely sure about this approach... For example, is it a given that valid markers are invalid/reserved package names? (I just found a few packages on PyPI that might invalidate this assumption, e.g. python_version, extra).

@frenzymadness
Copy link
Contributor Author

You are right that

[options.extras_require]
toml = toml;python_version<"5"

shows the warning even it's a correct definition. Any idea how to make it more robust?

@abravalheri
Copy link
Contributor

abravalheri commented Aug 1, 2022

I imagine that the best we can do here is to reduce the probability of false positives...

How about performing the check only if:

  • the string value is a single line AND
  • the number of requirements is exactly 2

?

(This way we can only check the second requirement for a marker)

Would this check cover the most likely scenarios where the warning is useful?

@frenzymadness
Copy link
Contributor Author

Thanks for the help. I've run all tests to see what structure the section_options attribute contains and now I have this solution:

--- a/setuptools/config/setupcfg.py
+++ b/setuptools/config/setupcfg.py
@@ -15,6 +15,7 @@ from typing import (TYPE_CHECKING, Callable, Any, Dict, Generic, Iterable, List,
 
 from distutils.errors import DistutilsOptionError, DistutilsFileError
 from setuptools.extern.packaging.version import Version, InvalidVersion
+from setuptools.extern.packaging.markers import Marker, InvalidMarker
 from setuptools.extern.packaging.specifiers import SpecifierSet
 from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
 
@@ -702,6 +703,35 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
             section_options,
             self._parse_requirements_list,
         )
+
+        # Because users sometimes misinterpret this configuration:
+        #
+        # [options.extras_require]
+        # foo = bar;python_version<"4"
+        #
+        # It looks like one requirement with an environment marker
+        # but because there is no newline, it's parsed as two requirements
+        # with a semicolon as separator.
+        # Therefore, if:
+        # * input string does not contain a newline AND
+        # * parsed result contains two requirements AND
+        # * the second requirement is a valid environment marker
+        # a UserWarning is shown to inform the user about the possible problem.
+
+        for name, (file, requirements) in section_options.items():
+            if "\n" not in requirements and len(parsed[name]) == 2:
+                second_requirement = parsed[name][1]
+                try:
+                    Marker(second_requirement)
+                except InvalidMarker:
+                    pass
+                else:
+                    msg = ( "One of the parsed requirements in extras_require section "
+                           f"looks like a valid environment marker: '{second_requirement}'\n"
+                            "Make sure that the config is correct and check "
+                            "https://setuptools.pypa.io/en/latest/userguide/declarative_config.html#opt-2")
+                    warnings.warn(msg, UserWarning)
+
         self['extras_require'] = parsed
 
     def parse_section_data_files(self, section_options):

@frenzymadness
Copy link
Contributor Author

Another idea from @hroncok is: If there is no \n in the original input and the length of parsed requirements is == 2, concatenate them with ; between them and then try to parse this string as a Requirement - if the parsing succeeded and the parsed requirement has marker attribute, the warning should be shown.

@hroncok
Copy link
Contributor

hroncok commented Aug 2, 2022

if the parsing succeeded and the parsed requirement has marker attribute, the warning should be shown.

If the marker attribute is not None.

@abravalheri
Copy link
Contributor

abravalheri commented Aug 2, 2022

Another idea from @hroncok is: If there is no \n in the original input and the length of parsed requirements is == 2, concatenate them with ; between them and then try to parse this string as a Requirement - if the parsing succeeded and the parsed requirement has marker attribute, the warning should be shown.

That sounds good and resilient!

I have a single note about the implementation details: ideally (if possible) it would be nice to split the added code (or at least the inner part of the for-loop) in a separated auxiliary function (but that is already part of the code review :P).

Thank you very much @frenzymadness for working on this.

@frenzymadness
Copy link
Contributor Author

Ok, we can move the discussion to the PR: #3481

frenzymadness added a commit to frenzymadness/setuptools that referenced this issue Aug 3, 2022
frenzymadness added a commit to frenzymadness/setuptools that referenced this issue Aug 3, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Needs Triage Issues that need to be evaluated for severity and status.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants