🐍 PythonDevOps / CI·CD

Semver Validation in Python

Validate semantic version strings in Python — parse MAJOR.MINOR.PATCH components, detect pre-release and build metadata, enforce stable-only releases on the main branch, and validate dependency manifests before publishing.

Also available in Node.js

1. Semver anatomy

  1  .  2  .  3  - beta.1  + build.42
                            Build metadata (ignored in precedence)
                 Pre-release identifier
             PATCH  backwards-compatible bug fixes
        MINOR  backwards-compatible new features
   MAJOR  breaking changes

2. API response structure

Endpoint: GET /v0/semver?value=…

{
  "valid": true,
  "version": "1.2.3-beta.1+build.42",
  "major": 1,
  "minor": 2,
  "patch": 3,
  "prerelease": "beta.1",
  "build": "build.42",
  "formatted": "1.2.3-beta.1+build.42",
  "isStable": false
}

isStable is True when MAJOR ≥ 1 and prerelease is empty.


3. Valid and invalid examples

InputValid?Note
1.0.0Stable release
1.2.3-alphaPre-release
1.2.3-beta.1+sha.abcPre-release + build metadata
0.1.0Initial development (isStable: False)
v1.2.3✗ (strip v first)Leading v — not valid semver, strip it
1.2Missing PATCH component
1.2.3.4Extra component
1.2.3-Empty pre-release identifier
^1.2.3npm range specifier — not semver
1.2.xWildcard — not semver

4. CI/CD gate — stable-only releases

💡Use isStable to prevent accidentally publishing a pre-release version from your main/production branch. Run this check in your GitHub Actions workflow before pip publish or docker push.
# scripts/check_version.py — run in CI before publish
import asyncio
import os
import sys
from isvalid_sdk import IsValidConfig, create_client

async def main():
    version = os.environ.get("RELEASE_VERSION", "").lstrip("v")
    if not version:
        print("ERROR: RELEASE_VERSION not set", file=sys.stderr)
        sys.exit(1)

    iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"]))
    result = await iv.semver(version)

    if not result.valid:
        print(f"ERROR: Invalid semver: {version!r}", file=sys.stderr)
        sys.exit(1)

    if not result.is_stable:
        print(
            f"ERROR: Pre-release {result.version!r} cannot be published to PyPI stable. "
            f"Use --pre flag or publish to TestPyPI.",
            file=sys.stderr,
        )
        sys.exit(1)

    print(f"✓ Version {result.version} is valid and stable — proceeding with publish")

asyncio.run(main())

5. Dependency manifest validation

import tomllib

async def validate_pyproject(path: str = "pyproject.toml") -> list[dict]:
    """Validate all pinned versions in pyproject.toml dependencies."""
    with open(path, "rb") as f:
        data = tomllib.load(f)

    deps = data.get("project", {}).get("dependencies", [])
    # Parse "package==1.2.3" → extract version
    pinned = {}
    for dep in deps:
        if "==" in dep:
            name, ver = dep.split("==", 1)
            pinned[name.strip()] = ver.strip()

    return await validate_dependencies(pinned)

6. Edge cases

⚠️Strip leading v before sending to the API. Git tags often use v1.2.3 but semver spec does not allow the prefix. Call version.lstrip("v") first.
# Common edge cases to handle
cases = [
    ("v1.2.3", "strip v → valid"),
    ("1.0.0-0.3.7", "numeric pre-release — valid"),
    ("1.0.0-x.7.z.92", "alphanumeric pre-release — valid"),
    ("1.0.0+20130313144700", "build metadata only — isStable: True"),
    ("1.0.0-beta+exp.sha.5114f85", "pre-release + build — isStable: False"),
]

for raw, note in cases:
    cleaned = raw.lstrip("v")
    result = await iv.semver(cleaned)
    print(f"{raw:35} valid={result.valid} stable={result.is_stable}  # {note}")

7. Full example with asyncio

import asyncio
import os
from isvalid_sdk import IsValidConfig, create_client

iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"]))

async def validate_version(version: str) -> dict:
    # Strip leading "v" if present — "v1.2.3" → "1.2.3"
    cleaned = version.lstrip("v")
    result = await iv.semver(cleaned)
    if not result.valid:
        raise ValueError(f"Invalid semver: {version!r}")
    return result

async def gate_main_branch_release(version: str) -> None:
    """Raise if version is pre-release — only stable versions go to main."""
    result = await validate_version(version)
    if not result.is_stable:
        raise ValueError(
            f"Pre-release version {result.version!r} cannot be released on main. "
            f"Pre-release: {result.prerelease}"
        )
    print(f"✓ Stable release {result.version} approved for main branch")

# Validate a batch of dependency versions
async def validate_dependencies(deps: dict[str, str]) -> list[dict]:
    tasks = {name: iv.semver(ver.lstrip("v")) for name, ver in deps.items()}
    results = await asyncio.gather(*tasks.values(), return_exceptions=True)
    return [
        {
            "package": name,
            "version": ver,
            "valid": not isinstance(r, Exception) and r.valid,
            "stable": not isinstance(r, Exception) and r.is_stable,
            "error": str(r) if isinstance(r, Exception) else None,
        }
        for (name, ver), r in zip(deps.items(), results)
    ]

async def main():
    await gate_main_branch_release("2.1.0")       # OK
    # await gate_main_branch_release("2.1.0-beta.1")  # raises

    deps = {
        "fastapi": "0.110.0",
        "pydantic": "2.6.4",
        "httpx": "0.27.0-beta.1",
    }
    report = await validate_dependencies(deps)
    for row in report:
        stable = "stable" if row["stable"] else "pre-release"
        print(f"  {row['package']}@{row['version']} — {stable}")

asyncio.run(main())

8. Summary checklist

Strip leading "v" before validation (v1.2.3 → 1.2.3)
Check isStable before publishing to PyPI stable
Block pre-release versions on main/production branches
Validate all pinned versions in pyproject.toml
Use asyncio.gather for batch dependency validation
Reject npm range specifiers (^, ~, x wildcards)
Run version gate in CI before package publish step
Return 422 with reason on invalid semver input

See also

Ready to integrate?

Free tier — 1,000 requests/month. No credit card required.

Get your API key →