Skip to content

Clarify dependency specifiers extra special cases in the grammar#2054

Open
danyeaw wants to merge 1 commit into
pypa:mainfrom
danyeaw:clarification-extra
Open

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

Conversation

@danyeaw
Copy link
Copy Markdown

@danyeaw danyeaw commented May 19, 2026

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/

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.

1 participant