Skip to content

Clarify dependency specifiers extra special cases in the grammar#2054

Open
danyeaw wants to merge 3 commits into
pypa:mainfrom
danyeaw:clarification-extra
Open

Clarify dependency specifiers extra special cases in the grammar#2054
danyeaw wants to merge 3 commits into
pypa:mainfrom
danyeaw:clarification-extra

Conversation

@danyeaw

@danyeaw danyeaw commented May 19, 2026

Copy link
Copy Markdown

In the dependency specifiers specification, it calls out extra as a special case to keep backwards compatibility. However, the grammar doesn't reflect this special case. This PR is a clarification to update the grammar so that it matches the text in the specification:

  1. extra == "name" and extra != "name" are valid expression
  2. Other marker operators are not valid (like >=)

I also used the following test script to test that the grammar was correct.

Test Script

from parsley import makeGrammar

grammar = r"""
    wsp           = ' ' | '\t'
    version_cmp   = wsp* <'<=' | '<' | '!=' | '===' | '==' | '>=' | '>' | '~='>
    marker_cop    = (wsp* 'in') | (wsp* 'not' wsp+ 'in')
    marker_op     = version_cmp | marker_cop
    python_str_c  = (wsp | letter | digit | '(' | ')' | '.' | '{' | '}' |
                     '-' | '_' | '*' | '#' | ':' | ';' | ',' | '/' | '?' |
                     '[' | ']' | '!' | '~' | '`' | '@' | '$' | '%' | '^' |
                     '&' | '=' | '+' | '|' | '<' | '>' )
    dquote        = '"'
    squote        = '\''
    python_str    = (squote <(python_str_c | dquote)*>:s squote |
                     dquote <(python_str_c | squote)*>:s dquote) -> s
    env_var       = ('python_version' | 'python_full_version' |
                     'os_name' | 'sys_platform' | 'platform_release' |
                     'platform_system' | 'platform_version' |
                     'platform_machine' | 'platform_python_implementation' |
                     'implementation_name' | 'implementation_version' |
                     'extras' | 'dependency_groups')
    extra_op      = '==' | '!='
    extra_expr    = wsp* 'extra' wsp* extra_op:o wsp* python_str:s -> (o, 'extra', s)
    marker_var    = wsp* (env_var | python_str)
    marker_expr   = extra_expr
                  | marker_var:l marker_op:o marker_var:r -> (o, l, r)
                  | wsp* '(' marker:m wsp* ')' -> m
    marker_and    = marker_expr:l wsp* 'and' marker_expr:r -> ('and', l, r)
                  | marker_expr:m -> m
    marker_or     = marker_and:l wsp* 'or' marker_and:r -> ('or', l, r)
                  | marker_and:m -> m
    marker        = marker_or
"""

compiled = makeGrammar(grammar, {})

valid = [
    # basic comparisons
    ("python_version > '3.10'", "env_var on left"),
    ('os_name == "posix"', "double-quoted string"),
    ("sys_platform != 'win32'", "not-equal"),
    ("python_version >= '3.8'", "gte"),
    # parenthesized grouping
    ("(os_name == 'posix')", "simple parens"),
    (
        "(os_name == 'a' or os_name == 'b') and sys_platform == 'linux'",
        "parens override precedence",
    ),
    # operator precedence: (a and b) or c
    ("os_name == 'a' and os_name == 'b' or os_name == 'c'", "and before or"),
    # operator precedence: a and (b or c)
    ("os_name == 'a' and (os_name == 'b' or os_name == 'c')", "explicit or in parens"),
    ("'SMP' in platform_version", "in comparison"),
    ('"dev" not in dependency_groups', "not in"),
    # extra field: only == and != are defined (set-like semantics)
    ("extra == 'toml'", "extra equality (set-like)"),
    ("extra != 'toml'", "extra inequality (set-like)"),
    ("'3.10' < python_version", "yoda 1"),
    ("'posix' == os_name", "yoda 2"),
    ("'win32' != sys_platform", "yoda 3"),
]

rejected = [
    # extra: ordered comparisons are not defined
    "extra > 'toml'",
    "extra >= 'toml'",
    "extra < 'toml'",
    "extra ~= 'toml'",
]

print("=== Valid expressions (should all parse) ===")
passed = 0
failed = 0
for expr, description in valid:
    try:
        result = compiled(expr).marker()
        print(f"  PASS  {description!r}: {expr!r} -> {result}")
        passed += 1
    except Exception as e:
        print(f"  FAIL  {description!r}: {expr!r} raised {e}")
        failed += 1

print()
print("=== These specifiers should all be rejected ===")
for expr in rejected:
    try:
        result = compiled(expr).marker()
        print(f"  FAIL  Yoda accepted: {expr!r} -> {result}")
        failed += 1
    except Exception:
        print(f"  PASS  Correctly rejected: {expr!r}")
        passed += 1

print()
print(f"Results: {passed} passed, {failed} failed")


📚 Documentation preview 📚: https://python-packaging-user-guide--2054.org.readthedocs.build/en/2054/

@henryiii

Copy link
Copy Markdown
Contributor

Hi, @danyeaw, what's the best way to contact you? Are you on Discord? I'm thinking we should make an explicit list of every shortcoming we'd like to fix; We can try to implement them one at a time (as done here) in PRs, with data I'm about to process. I don't remember everything we discussed at PyCon, what I do remember:

  • This one - loose spec around extras
  • Loose spec around variable vs. value
  • Packaging (by mistake) and uv both allow Yoda for a subset of values

Later today I should have an updated PyPI dataset so I can see what is done and check things like this against it.

If it's long enough and people don't want to make small edits, we could do a "tightening dependency specifiers" PEP.

@henryiii

Copy link
Copy Markdown
Contributor

I scanned the PyPI dataset for operations with extra:

  • == → 1,462,072 instances
  • != → 31 instances (18 distinct packages)
  • anything else → 0

Data is at https://github.com/sethmlarson/pypi-data/releases/tag/2026.07.21 (thanks @sethmlarson!).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants