Version parsing and constraint checking for Python that behaves like Go's Masterminds/semver v3 (Helm) and hashicorp/go-version (Terraform, Packer, Nomad), verified against both libraries.
from semver_dialects import hashicorp, masterminds
c = masterminds.Constraints.parse("^1.2.3 || >=3.0.0-beta.1 <3.1.0") # Helm chart style
c.check(masterminds.Version.parse("1.9.0")) # True
c.validate(masterminds.Version.parse("0.9.0")) # (False, ['"0.9.0" is less than "1.2.3"', '"0.9.0" is less than "3.0.0-beta.1"'])
t = hashicorp.Constraints.parse("~> 1.2") # Terraform required_version style
t.check(hashicorp.Version.parse("1.9.0")) # True
hashicorp.Constraints.parse("~> 1.2.0").check(hashicorp.Version.parse("1.3.0")) # FalseHelm and many Go tools use Masterminds/semver constraints. Terraform, Packer and Nomad use hashicorp/go-version constraints. Both differ from PEP 440 and from npm ranges. Masterminds accepts 1.2.x, hyphen ranges, ||, ~> and =<, and hides prereleases unless the constraint names one. go-version accepts four or more segments, prereleases without a hyphen (1.0beta), and ~> with segment-count semantics, and its prerelease rules differ from Masterminds'. A Python tool that reads a Chart.yaml or a required_version either rejects these strings or converts them to another dialect and loses the differences.
Measured: pip install -e '.[bench]' && python bench/incumbents.py
masterminds: 600 versions, 20000 constraints, 14268 constraints valid in Go
semver-dialects (this package) version-parse 100.0% constraint-parse 100.0% constraint-full 100.0%
semantic_version.NpmSpec version-parse 75.8% constraint-parse 42.8% constraint-full 29.5%
semantic_version.SimpleSpec version-parse 75.8% constraint-parse 36.6% constraint-full 28.5%
node-semver version-parse 81.7% constraint-parse 61.2% constraint-full 35.1%
poetry-core version-parse 85.0% constraint-parse 48.8% constraint-full 31.9%
semver (python-semver, versions only) version-parse 76.0%
hashicorp: 600 versions, 20000 constraints, 16934 constraints valid in Go
semver-dialects (this package) version-parse 100.0% constraint-parse 100.0% constraint-full 100.0%
semantic_version.NpmSpec version-parse 45.2% constraint-parse 21.1% constraint-full 14.3%
semantic_version.SimpleSpec version-parse 45.2% constraint-parse 21.4% constraint-full 15.2%
node-semver version-parse 47.3% constraint-parse 32.8% constraint-full 13.9%
poetry-core version-parse 86.7% constraint-parse 53.7% constraint-full 27.7%
semver (python-semver, versions only) version-parse 45.2%
installed: poetry-core 2.5.0, semantic-version 2.10.0, node-semver 0.9.1, semver 3.1.0
Constraint-full means the constraint is accepted or rejected as Go does and every recorded Check result matches. The corpus is deliberately edge-heavy (mutated strings, oversized numbers, odd whitespace), so the percentages describe that corpus, not real manifests. packaging was not measured: it implements PEP 440, not semver. Searching the PyPI simple index (901,816 projects, 2026-09-29) for names starting with masterminds, hashicorp, go-version, terraform-version and helm-semver found hashicorp-vault-client-api and hashicorp-vault-django (Vault clients) and terraform-version (its PyPI summary: "Package for tracking existing Terraform versions"). None of them implements either dialect's constraints.
Not yet on PyPI. From a checkout:
pip install .Python 3.11 or later. No runtime dependencies.
from semver_dialects.masterminds import Constraints, Version, sort_versions
Version.parse("v1.2-beta.1+build.5") # Version(major=1, minor=2, patch=0, prerelease='beta.1', ...)
str(Version.parse("v1.2")) # '1.2.0'
Version.parse_strict("1.2") # raises VersionError: invalid semantic version
Constraints.parse(">=1.2.0").check(Version.parse("1.3.0-beta")) # False
Constraints.parse(">=1.2.0", include_prerelease=True).check(Version.parse("1.3.0-beta")) # True
str(Constraints.parse("1.2.3 - 2.3.4")) # '>=1.2.3 <=2.3.4'A prerelease version only satisfies a constraint group that itself names a prerelease, unless include_prerelease is set. validate returns Go's reasons in Go's order.
from semver_dialects.hashicorp import Constraints, Version, sort_versions
Version.parse("v1.2.3.4-beta+meta").segments # (1, 2, 3, 4)
Version.parse("1.0beta").prerelease # 'beta' (the hyphen is optional)
str(Version.parse("1.04.0")) # '1.4.0'
Constraints.parse("~> 1.2.3.4").check(Version.parse("1.2.3.5")) # True
Constraints.parse(">= 1.0.0-beta").check(Version.parse("1.0.0-rc")) # True
Constraints.parse(">= 1.0").check(Version.parse("1.5.0-beta")) # False~> 1.2 allows any 1.x at or above 1.2. ~> 1.2.0 allows 1.2.x only. The last segment given is the one that may increase.
str(Constraints.parse("~> 2.0, >= 1.0, < 4.0, = 5.0").sorted()) # ' < 4.0, = 5.0,~> 2.0, >= 1.0'sort_versions and Constraints.sorted return new lists. They reproduce Go's sort.Stable step for step, including the insertion sort Go uses up to 20 elements. This matters for go-version, whose comparison is not antisymmetric for prerelease parts such as 1 and 01. A constraint keeps its original text, whitespace included, so a sorted string can start with a space.
Rejected input raises VersionError or ConstraintError (both ValueError). The message is Go's err.Error() text, byte for byte, with Go's %q quoting:
Constraints.parse(">=1.0 é\n") # ConstraintError: improper constraint: ">=1.0 é\n"
hashicorp.Constraints.parse(">> 1") # ConstraintError: malformed constraint: >> 1| Name | Description |
|---|---|
Version.parse(text) |
Go NewVersion (default coercing mode). v prefix and missing minor or patch accepted. Limit 256 UTF-8 bytes. |
Version.parse_strict(text) |
Go StrictNewVersion. Exactly MAJOR.MINOR.PATCH, no v, no leading zeros. |
Version.major, minor, patch |
int, in the uint64 range. |
Version.prerelease, metadata, original |
str. original is the input as given. |
str(version) |
Go String(). |
compare(other) |
-1, 0 or 1. Metadata is ignored. |
equal, less_than, less_than_or_equal, greater_than, greater_than_or_equal |
Go's Equal, LessThan, and so on, via compare. |
Constraints.parse(text, *, include_prerelease=False) |
Go NewConstraint. Limits: 512 UTF-8 bytes, 32 ` |
Constraints.include_prerelease |
Writable, like Go's public field. |
Constraints.check(version) |
bool. |
Constraints.validate(version) |
(ok, reasons); reasons is [] when ok. |
str(constraints) |
Go Constraints.String(). |
sort_versions(versions) |
New list, ordered as Go sort.Stable would order it. |
VersionError, ConstraintError |
ValueError subclasses. |
Operators: none, =, !=, >, <, >=, =>, <=, =<, ~, ~>, ^. Wildcards x, X, *. Hyphen ranges A - B. ||, comma and whitespace.
| Name | Description |
|---|---|
Version.parse(text) |
Go NewVersion. Any number of numeric segments, padded to three. Prerelease with or without a hyphen. |
Version.parse_semver(text) |
Go NewSemver. A prerelease needs its hyphen. |
Version.segments |
tuple[int, ...], int64 range, at least three entries. |
Version.specificity |
Segments the input spelled out (1.2 gives 2). ~> uses it. |
Version.prerelease, metadata, original |
str. |
str(version), core() |
Go String() and Core(). |
compare(other) and the named comparisons |
As in masterminds. |
Constraints.parse(text) |
Go NewConstraint. Comma-separated. May raise VersionError for an out-of-range segment. |
Constraints.check(version) |
bool. All constraints must hold. |
Constraints.sorted() |
New Constraints, ordered by operator then version. |
str(constraints) |
The original pieces joined with ,. |
sort_versions(versions) |
As in masterminds. |
Operators: none, =, !=, >, <, >=, <=, ~>.
The Masterminds side is a port of the Go source (MIT). One piece is not: validConstraintRegex. In that pattern an empty operator makes several \s* and \s+ groups adjacent, so a run of whitespace can be divided among them in many ways. When a match fails late, a backtracking engine tries every division of every group. Go's own engine is linear-time, so Go is unaffected. Under Python's re the cost multiplies by about 20 for every additional group. The package replaces the pattern with one in which an operator, when present, is non-empty and the separator between constraints is either whitespace or a comma with optional whitespace, so each accepted string has one parse. Measured: python bench/redos.py
runs bytes original pattern replacement
3 22 0.001s 0.000019s
4 29 0.023s 0.000046s
5 36 0.495s 0.000055s
6 43 10.515s 0.000049s
7 50 >20s (killed) 0.000055s
70 491 not run (>20s) 0.000199s
tests/test_valid_regex.py checks that the replacement and the original pattern accept exactly the same strings, exhaustively for every string up to length 5 over an 18-character alphabet and up to length 6 to 9 over smaller ones, including the characters Python's \s accepts and Go's does not. tests/test_redos.py runs hostile inputs, in child processes with a 20 second limit.
The hashicorp/go-version side is an independent implementation. go-version is MPL-2.0. Its behavior was measured and reimplemented, and no file was ported or closely translated. Every rule was confirmed against go-version v1.9.0 by the differential tests below. There is no license file for it here because no go-version code is included.
Other Go details reproduced on purpose:
- Go's
\sis[\t\n\f\r ], and its\dis ASCII. The package uses explicit ASCII classes, never Python's Unicode-aware ones, and anchors with a full match so a trailing newline is not ignored. - Masterminds segments are
uint64and go-version segments areint64, with Go'sstrconverror texts. Digit runs are checked beforeint()sees them, because CPython refusesint()on more than 4300 digits. - go-version's prerelease comparison reads the value
strconv.ParseIntreturns alongside an error (0 for a syntax error, the clamped limit for a range error). That changes some orderings, and it is reproduced. - Length limits count UTF-8 bytes.
- Pinned to Masterminds/semver v3.5.0 and hashicorp/go-version v1.9.0. Other releases differ in places.
- Masterminds: only the default
CoerceNewVersion = truepath ofNewVersionis modeled. The package-level Go switches (CoerceNewVersion,DetailedNewVersionErrors) are not exposed.IncMajor,IncMinor,IncPatch,SetPrerelease,SetMetadata,Newand the JSON and SQL hooks are not implemented. - go-version: the
WithPrefixoption,Constraints.Equals,Constraint.Prerelease, and the JSON and SQL hooks are not implemented. - Errors carry Go's message but not Go's sentinel identity (
errors.Is). - Input must be
str.bytesis rejected. Lone surrogates are escaped byte by byte, as Go escapes invalid UTF-8, but that path is not covered by the Go differential. %qquoting decides which characters are printable with Python's Unicode tables, and Go uses its own. A code point assigned in one Unicode version and not the other would be quoted differently. The Go differential covers a fixed set of unusual characters, not every code point.==on aVersioncompares all fields, includingoriginalandmetadata. Useequal()for Go'sEqual.- Not designed for PEP 440. Use
packagingfor that.
Run: CPython 3.14.7 on macOS (arm64), Go 1.27.1 for the differential. Tests pass with pytest and mypy --strict src tests. The CI workflow runs the tests on 3.11, 3.12, 3.13 and 3.14. Those four are "run in CI", not tested here. Only 3.14 was run for this release.
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest -rs
.venv/bin/python -m mypy --strict src testsTests, and what each proves:
tests/test_corpus.pyreplaystests/data/*.jsonl.gz: 600 versions and 20000 constraints per dialect, with parse accept/reject and error text,String,Original,Check,Validate,Compare, andsort.Stableresults recorded from the Go libraries.tests/data/README.txtdescribes the records. The corpus was produced bytests/oraclewith seed 20260929.tests/test_live_go.pyruns the Go oracle on fresh seeds (101 and 202 by default, orSEMVER_DIALECTS_SEEDS=1,2,3), and on exhaustive short inputs, odd Unicode, size limits, out-of-range numbers, and sorts of up to 257 elements. It needsgoonPATHand the two Go modules, which Go downloads on first run (tests/oracle/go.modnames them; nothing from them is vendored). When Go is missing or cannot fetch the modules, these tests skip and-rsprints why.tests/test_valid_regex.pyandtests/test_redos.pycover the regex replacement and hostile inputs.
For a run without network access: GOPROXY=file://$(go env GOMODCACHE)/cache/download GOSUMDB=off GOFLAGS=-mod=mod.
MIT. See LICENSE. The Masterminds port carries that project's MIT license in LICENSE-masterminds-semver.