Skip to content

marker: report which comparisons could not be decided - #47

Merged
jonyoder merged 1 commit into
mainfrom
feat/44-marker-undecidable
Aug 27, 2026
Merged

jonyoder merged 1 commit into
mainfrom
feat/44-marker-undecidable

Conversation

@jonyoder

Copy link
Copy Markdown
Collaborator

Closes #44.

Evaluate returns a bare bool, so a caller cannot distinguish "this marker is false" from "this marker could not be decided". This adds two methods that recover the difference, and leaves Evaluate bit-for-bit unchanged.

func (m Marker) EvaluateUndecidable(env Environment, extraList []string) (bool, []Undecidable)
func (m Marker) Variables() []string

What counts as undecidable

Exactly two things, both of which currently answer false indistinguishably from a real false:

  • ~= or === reaching the generic string-operator table, which has no semantics for them. pypa/packaging raises UndefinedComparison here; this package returns false as a documented divergence, because there was no channel to report it on. Note it cannot happen when either operand is version-typed and both sides parse as PEP 440 versions, since the specifier path answers first and answers correctly.
  • An environment variable resolving to "". EnvironmentFromTarget legitimately cannot know platform_release or platform_version for a declared target and leaves them empty, and comparing "" against anything is meaningless. A literal "" written by the marker author is not reported.

What is deliberately not undecidable

Ordered comparisons on string operands. < and > returning false, and <=/>= collapsing to equality, is faithful to packaging, whose _operators table is literally {"<": lambda: False, "<=": eq, ">=": eq, ">": lambda: False} and where sys_platform >= "darwin" evaluates False. Verified against 26.3 and asserted in TestEvaluateUndecidable_Decidable so a future reader does not "fix" it.

Why Variables ships alongside

EvaluateUndecidable cannot cover the case where the caller invented a value. A declared interpreter of 3.13 forces some PythonFullVersion, and the answer then depends on the invention: python_full_version >= "3.13.2" is false at 3.13.0 and true at 3.13.99, with nothing empty and no operator undefined. Only the caller knows which fields it fabricated; it just needs to ask which variables a marker touches. TestInventedPatchLevelIsDecidableButArbitrary pins that distinction explicitly.

Without these two methods the only available workaround is a substring scan of Marker.String() for the operators and variable names, which has false positives on quoted literals (sys_platform == "platform_version" matches) and a token list for every consumer to hand-maintain against this library.

Behaviour and scope

Short-circuiting limits reporting to comparisons actually reached, which is what a caller wants: an and already decided false by its first operand needs no further explanation. TestEvaluateUndecidable_ShortCircuitLimitsReporting covers both orderings.

Evaluate becomes a one-line wrapper, and a nil collector makes the reporting path free on that route. The pre-existing marker tests pin that its behaviour is unchanged.

gofmt clean, go vet ./... clean, full module suite green.

Found while designing declared-environment filtering for Posit Package Manager's air-gapped PyPI mirror, where a dependency edge dropped because a marker could not be evaluated means a missing package and no fallback.

🤖 Generated with Claude Code

Evaluate returns a bare bool, so a caller cannot tell "this marker is
false" from "this marker could not be decided". Two things produce the
second: `~=` and `===` reaching the generic string-operator table, which
has no semantics for them and where packaging raises UndefinedComparison;
and an environment variable resolving to "", which EnvironmentFromTarget
legitimately does for platform_release and platform_version because a
DECLARED target has no kernel to report.

EvaluateUndecidable returns both the answer and the comparisons behind it
that were undecidable. A consumer that must not discard a dependency edge
-- a mirror, an offline bundle, where a dropped edge means a missing
package and no fallback -- can then include on a non-empty slice instead
of scanning Marker.String() for operators and variable names, which has
false positives on quoted literals and a token list to hand-maintain.

Variables covers the half this library cannot decide for the caller. A
declared "3.13" forces the caller to invent a PythonFullVersion, and an
invented value is decidable but arbitrary: `>= "3.13.2"` is false at
3.13.0 and true at 3.13.99. Only the caller knows which fields it
fabricated, so it just needs to ask which variables a marker touches.
TestInventedPatchLevelIsDecidableButArbitrary pins that distinction.

Ordered comparisons on string operands are deliberately NOT undecidable.
`<`/`>` returning false and `<=`/`>=` collapsing to equality is faithful
to pypa/packaging, whose _operators table is literally the same and where
`sys_platform >= "darwin"` evaluates False. Verified against 26.3, and
asserted here so a future reader does not "fix" it.

Short-circuiting limits reporting to the comparisons actually reached,
which is the behaviour a caller wants: an `and` already decided false by
its first operand needs no further explanation.

Evaluate becomes a wrapper; its behaviour is unchanged and the existing
tests pin that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonyoder
jonyoder merged commit fc63895 into main Aug 27, 2026
4 checks passed
@jonyoder
jonyoder deleted the feat/44-marker-undecidable branch August 27, 2026 18:27
jonyoder added a commit that referenced this pull request Aug 28, 2026
Dates [Unreleased] as 0.9.0 (2026-08-28) and adds its compare link. CHANGELOG only.

A minor bump under the 0.x policy because two changes alter observable behaviour: reqtxt now recognizes --all-releases/--only-final/--use-feature (an unrecognized option was assumed boolean, so its argument became a fabricated requirement), and a standalone --hash line is accepted rather than rejected, matching pip.

Contents since v0.8.0: tags manylinux floor fix (#43), marker EvaluateUndecidable/Variables (#47), tags IsCompatibleOrNewer/CompileAnyLibc/Archs (#48), reqtxt Source and pip-parity fixes (#49), dependency bumps (#50).
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.

marker: Evaluate cannot report that a marker was undecidable

1 participant