Skip to content

fix: resolve solidity pragma versions across imports#163

Merged
fubuloubu merged 5 commits into
ApeWorX:mainfrom
banteg:feat/version-pragma-selection
May 27, 2026
Merged

fix: resolve solidity pragma versions across imports#163
fubuloubu merged 5 commits into
ApeWorX:mainfrom
banteg:feat/version-pragma-selection

Conversation

@banteg

@banteg banteg commented May 9, 2026

Copy link
Copy Markdown
Contributor

Motivation

Solidity validates version pragmas across every source compiled in a single solc invocation, including imported files. Ape installs or selects the compiler before invoking solc, so choosing from only the requested root source can pick a compiler version that an imported source rejects.

Ape also needs to understand Solidity pragma expressions before it can safely preselect a compiler. The previous parser handled simple constraints, but did not cover the full Solidity semver matcher behavior used by solc, including hyphen ranges, wildcards, tilde/caret ranges, quoted ranges, OR alternatives, adjacent constraints, and Solidity's prerelease comparison rules.

Summary

  • Select Solidity compiler versions from each requested source plus its transitive import closure.
  • Keep imported sources in each standard-json input where solc needs them, while avoiding duplicate root-source compilation when a more constrained closure owns that source.
  • Replace the previous pragma parsing path with SolidityVersionSpecifier, which mirrors Solidity's semver matcher instead of delegating range matching to Python PEP 440 specifiers or npm range semantics.
  • Continue using packaging.version.Version as Ape's compiler-version type for stable py-solc-x compiler versions, while preserving exact Solidity semver parsing for pragma candidates with a tiny local SoliditySemVer value.
  • Preserve Solidity-specific prerelease behavior, including cases where prerelease builds satisfy <, <=, wildcard, hyphen, and partial-version constraints differently from npm range semantics.
  • Avoid adding semantic-version as a direct dependency; Solidity semver parsing and matching are local to this plugin.
  • Remove dead best-version helpers and avoid repeated candidate sorting while combining pragma constraints.
  • Include installed and available compiler versions in incompatible-pragma errors.
  • Align package metadata and CI with Ape's Python >=3.10,<4 support range, including Python 3.14.

Why Not packaging Or NpmSpec

Solidity pragmas are semver match expressions, not PEP 440 specifiers. packaging.version.Version is still appropriate for Ape's stable compiler-version flow, but it cannot faithfully represent every solc semver string. Some published solc versions are normalized into different PEP 440 spellings, and nightly-style prereleases can be invalid PEP 440 entirely.

Solidity pragmas also look npm-like, but solc's matcher is not equivalent to semantic_version.NpmSpec. In Solidity's SemVerHandler, candidate prerelease versions compare as lower than the release when the compared numeric levels tie. That accepts cases such as:

<=1.2.3              matches 1.2.3-beta
<1.2.3               matches 1.2.3-beta
>1.2                 matches 1.3.0-beta
0.8.30 - 0.8.31      matches 0.8.31-pre.1+commit.e4323a63
*                    matches prerelease/nightly builds

NpmSpec rejects some of these, and forcing always-on prerelease matching creates false positives for Solidity caret ranges, such as accepting prereleases that solc rejects. This PR therefore owns both the small Solidity semver value parser and the Solidity-specific range matcher locally.

References

Validation

  • uv run black --check ape_solidity/_utils.py
  • uv run isort --check-only ape_solidity/_utils.py
  • uv run flake8 ape_solidity/_utils.py
  • uv run mypy .
  • uv run pytest tests/test_compiler.py -k 'pragma_spec or get_version_map' (164 passed on Python 3.12.12)
  • solc-bin manifest smoke check: parsed 2,017 unique published longVersion strings, generated 1,237 pragma expressions, and ran 2,495,029 matcher comparisons without parser errors.

Note: a full local tests/test_compiler.py run was previously blocked by the existing local dependency fixture/remapping setup for Safe contracts, but the live PR checks have been passing.

@banteg banteg force-pushed the feat/version-pragma-selection branch 4 times, most recently from 670f559 to 2d28ca1 Compare May 9, 2026 14:15
@banteg banteg force-pushed the feat/version-pragma-selection branch from 2d28ca1 to 86c6dfe Compare May 9, 2026 18:50
Comment thread .github/workflows/test.yaml Outdated
Comment thread ape_solidity/compiler.py Outdated
Comment thread ape_solidity/_utils.py Outdated
Comment thread ape_solidity/compiler.py Outdated
Comment thread ape_solidity/compiler.py Outdated
Comment thread ape_solidity/compiler.py
@banteg

banteg commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on whether we can keep using semantic_version.NpmSpec for pragma matching:

I checked this against Solidity's liblangutil/SemVerHandler.cpp and against the published argotorg/solc-bin manifest versions. This does not appear to be a semantic_version bug; it is a semantic mismatch. semantic_version.NpmSpec implements npm-style range behavior, while Solidity's pragma matcher has different prerelease and partial-version rules.

The key Solidity rule is that after comparing only the version levels present in the pragma expression, if the numeric comparison ties and the candidate version has prerelease metadata, Solidity treats the candidate as lower than the compared release. That means Solidity accepts cases such as:

<=1.2.3      matches 1.2.3-beta
<1.2.3       matches 1.2.3-beta
>1.2         matches 1.3.0-beta
0.8.30 - 0.8.31 matches 0.8.31-pre.1+commit.e4323a63
*            matches prerelease/nightly builds

NpmSpec does not match all of those. For example, after the existing normalization:

NpmSpec("*").match("1.2.3-foo") == False
NpmSpec(">1.2").match("1.3.0-beta") == False
NpmSpec("0.8.30 - 0.8.31").match("0.8.31-pre.1+commit.e4323a63") == False

I also checked whether semantic_version.Range(..., prerelease_policy=PRERELEASE_ALWAYS) could be used to patch this behavior. It fixes some false negatives, but creates false positives relative to Solidity, such as allowing prereleases through caret ranges that Solidity rejects:

^0.6   should reject 0.6.2-alpha
^1.2.3 should reject 2.0.0-alpha

So the current approach is intentional: still use semantic_version.Version for candidate version parsing, but use Solidity-specific pragma range matching instead of delegating the range expression to NpmSpec.

antazoey
antazoey previously approved these changes May 26, 2026
antazoey
antazoey previously approved these changes May 26, 2026
fubuloubu
fubuloubu previously approved these changes May 27, 2026
Comment thread ape_solidity/_utils.py Outdated
@banteg

banteg commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Additional context on packaging.version.Version vs semantic_version / a local semver parser:

packaging.Version is fine for the stable compiler versions Ape normally gets from py-solc-x, and this PR still uses packaging.Version as the compiler-version type in the existing Ape flow. But it is not a faithful representation for the full solc semver universe.

Some published solc semver strings either get normalized into a different PEP 440 spelling or cannot be represented by packaging.Version at all:

0.8.31-pre.1+commit.e4323a63
-> packaging.Version("0.8.31rc1+commit.e4323a63")

1.2.3-beta
-> packaging.Version("1.2.3b0")

0.8.36-nightly.2026.5.21+commit.8471cf2f
-> packaging.version.InvalidVersion

So packaging.Version is a good fit for stable installable compiler releases, but it should not become the single canonical representation for arbitrary solc longVersion strings or user-requested prerelease/nightly versions. It can corrupt the exact spelling or reject valid semver identifiers before Ape can reason about them.

I also prototyped a variant that removes the direct semantic-version dependency. It replaces semantic_version.Version with a small local Solidity semver value/parser and keeps the Solidity-specific range matcher local. That prototype is small mechanically: one local dataclass/parser plus removing semantic-version>=2.10,<3 from setup.py.

Prototype validation:

uv run pytest tests/test_compiler.py -k 'pragma_spec or get_version_map'  # 164 passed
uv run mypy .
uv run black --check ape_solidity/_utils.py
uv run isort --check-only ape_solidity/_utils.py
uv run flake8 ape_solidity/_utils.py

I also smoke-tested the prototype against the published argotorg/solc-bin manifests: it parsed 2,017 unique longVersion strings, generated 1,237 pragma expressions, and ran 2,495,029 matcher comparisons without parser errors.

My read from the prototype: this functionality does not cleanly belong in packaging, because Solidity pragmas are semver match expressions with solc-specific behavior, not PEP 440 specifiers. The maintainers have two reasonable options:

  1. Keep this PR as-is and use semantic_version.Version only as a semver value/parser, while owning Solidity range matching locally.
  2. Drop the direct dependency and own a tiny Solidity semver parser/value object locally.

If dependency avoidance is important, option 2 looks feasible and fairly small. The important part either way is not to model all solc versions as packaging.Version.

@banteg banteg dismissed stale reviews from fubuloubu and antazoey via b79afb2 May 27, 2026 11:54
@fubuloubu fubuloubu merged commit eee84bf into ApeWorX:main May 27, 2026
15 checks passed
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.

3 participants