semver.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
12 days ago
| 1 | """Semantic versioning types, parsing, and release-channel helpers. |
| 2 | |
| 3 | This module is intentionally pure — it performs no I/O and has no dependency |
| 4 | on the object store. Anything that needs to reason about version strings, |
| 5 | release channels, or changelog shape should import from here rather than from |
| 6 | :mod:`muse.core.store`. |
| 7 | |
| 8 | Public API |
| 9 | ---------- |
| 10 | Types |
| 11 | :class:`SemVerTag` — parsed version components |
| 12 | :class:`ReleaseChannel` — ``"stable" | "beta" | "alpha" | "nightly"`` |
| 13 | :class:`ChangelogEntry` — one commit's contribution to a release |
| 14 | :class:`SemanticReleaseReport` — full snapshot analysis attached to a release |
| 15 | |
| 16 | Functions |
| 17 | :func:`parse_semver` — ``"v1.2.3-beta.1"`` → :class:`SemVerTag` |
| 18 | :func:`semver_to_str` — :class:`SemVerTag` → ``"v1.2.3-beta.1"`` |
| 19 | :func:`semver_channel` — infer :class:`ReleaseChannel` from pre-release label |
| 20 | """ |
| 21 | |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import re |
| 25 | from typing import Literal, TypedDict |
| 26 | |
| 27 | from muse.domain import SemVerBump |
| 28 | |
| 29 | # --------------------------------------------------------------------------- |
| 30 | # Release channels |
| 31 | # --------------------------------------------------------------------------- |
| 32 | |
| 33 | #: Named release channels. More expressive than a boolean ``is_prerelease``. |
| 34 | ReleaseChannel = Literal["stable", "beta", "alpha", "nightly"] |
| 35 | |
| 36 | type _ChannelMap = dict[str, ReleaseChannel] |
| 37 | |
| 38 | #: Maps the user-supplied ``--channel`` string to the canonical channel value. |
| 39 | #: Unknown strings fall back to ``"stable"`` at the call sites that use this. |
| 40 | _CHANNEL_MAP: _ChannelMap = { |
| 41 | "stable": "stable", |
| 42 | "beta": "beta", |
| 43 | "alpha": "alpha", |
| 44 | "nightly": "nightly", |
| 45 | } |
| 46 | |
| 47 | # --------------------------------------------------------------------------- |
| 48 | # Semver types |
| 49 | # --------------------------------------------------------------------------- |
| 50 | |
| 51 | #: Semver pre-release / build suffixes use only alphanumerics, hyphens, dots. |
| 52 | _SEMVER_PRE_RE = re.compile(r"^[0-9A-Za-z\-\.]*$") |
| 53 | |
| 54 | #: Strict semver regex (vMAJOR.MINOR.PATCH[-pre][+build]). |
| 55 | _SEMVER_RE = re.compile( |
| 56 | r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)" |
| 57 | r"(?:-(?P<pre>[0-9A-Za-z\-]+(?:\.[0-9A-Za-z\-]+)*))?" |
| 58 | r"(?:\+(?P<build>[0-9A-Za-z\-]+(?:\.[0-9A-Za-z\-]+)*))?$" |
| 59 | ) |
| 60 | |
| 61 | |
| 62 | class SemVerTag(TypedDict): |
| 63 | """Parsed semantic version components.""" |
| 64 | |
| 65 | major: int |
| 66 | minor: int |
| 67 | patch: int |
| 68 | pre: str # "" for stable releases; "beta.1", "alpha.2", etc. |
| 69 | build: str # "" unless a build metadata suffix is present |
| 70 | |
| 71 | |
| 72 | # --------------------------------------------------------------------------- |
| 73 | # Changelog and release-report types |
| 74 | # --------------------------------------------------------------------------- |
| 75 | |
| 76 | class ChangelogEntry(TypedDict): |
| 77 | """One commit's contribution to a release changelog. |
| 78 | |
| 79 | Auto-populated by walking the commit graph from the previous release tag |
| 80 | to the current HEAD. The ``sem_ver_bump`` field drives grouping in the |
| 81 | rendered changelog so callers never need to parse commit messages. |
| 82 | """ |
| 83 | |
| 84 | commit_id: str |
| 85 | message: str |
| 86 | sem_ver_bump: SemVerBump |
| 87 | breaking_changes: list[str] |
| 88 | author: str |
| 89 | committed_at: str |
| 90 | agent_id: str |
| 91 | model_id: str |
| 92 | |
| 93 | |
| 94 | class LanguageStat(TypedDict): |
| 95 | """File and symbol counts for a single programming language in a snapshot.""" |
| 96 | |
| 97 | language: str |
| 98 | files: int |
| 99 | symbols: int |
| 100 | |
| 101 | |
| 102 | class SymbolKindCount(TypedDict): |
| 103 | """Count of symbols of a specific kind in a snapshot.""" |
| 104 | |
| 105 | kind: str |
| 106 | count: int |
| 107 | |
| 108 | |
| 109 | class ApiChangeSummary(TypedDict): |
| 110 | """A single public-API symbol that was added, removed, or modified.""" |
| 111 | |
| 112 | address: str |
| 113 | language: str |
| 114 | kind: str # matches SymbolKind literals |
| 115 | change: str # "added" | "removed" | "modified" |
| 116 | |
| 117 | |
| 118 | class FileHotspot(TypedDict): |
| 119 | """A file and the number of times it was touched across the release's commits.""" |
| 120 | |
| 121 | file_path: str |
| 122 | change_count: int |
| 123 | language: str |
| 124 | |
| 125 | |
| 126 | class RefactorEventSummary(TypedDict): |
| 127 | """A single structural refactoring event detected across the release's commits.""" |
| 128 | |
| 129 | kind: str # "rename" | "move" | "add" | "delete" | "patch" |
| 130 | address: str |
| 131 | detail: str |
| 132 | commit_id: str |
| 133 | |
| 134 | |
| 135 | class SemanticReleaseReport(TypedDict): |
| 136 | """Semantic analysis of a release, computed at push time from the object store. |
| 137 | |
| 138 | Populated by ``muse.plugins.code.release_analysis.compute_release_analysis`` |
| 139 | before the release is transmitted to a remote. MuseHub stores it verbatim |
| 140 | and renders it in the release detail page. |
| 141 | |
| 142 | All list fields default to ``[]`` and all int fields default to ``0`` so |
| 143 | that a partial or failed analysis still produces a valid, displayable report. |
| 144 | """ |
| 145 | |
| 146 | # Snapshot composition |
| 147 | languages: list[LanguageStat] |
| 148 | total_files: int |
| 149 | semantic_files: int # files with AST-level symbol support |
| 150 | total_symbols: int |
| 151 | symbols_by_kind: list[SymbolKindCount] |
| 152 | |
| 153 | # Delta (what changed in this release vs previous release) |
| 154 | files_changed: int |
| 155 | api_added: list[ApiChangeSummary] |
| 156 | api_removed: list[ApiChangeSummary] |
| 157 | api_modified: list[ApiChangeSummary] |
| 158 | file_hotspots: list[FileHotspot] |
| 159 | refactor_events: list[RefactorEventSummary] |
| 160 | |
| 161 | # Provenance aggregated from changelog commits |
| 162 | breaking_changes: list[str] # deduplicated across all changelog entries |
| 163 | human_commits: int |
| 164 | agent_commits: int |
| 165 | unique_agents: list[str] |
| 166 | unique_models: list[str] |
| 167 | reviewers: list[str] |
| 168 | |
| 169 | |
| 170 | # --------------------------------------------------------------------------- |
| 171 | # Semver functions |
| 172 | # --------------------------------------------------------------------------- |
| 173 | |
| 174 | def parse_semver(version: str) -> SemVerTag: |
| 175 | """Parse *version* into a :class:`SemVerTag`. |
| 176 | |
| 177 | Accepts both ``v1.2.3`` and ``1.2.3`` forms. Raises :exc:`ValueError` |
| 178 | for any string that does not conform to Semantic Versioning 2.0.0. |
| 179 | """ |
| 180 | m = _SEMVER_RE.fullmatch(version.strip()) |
| 181 | if not m: |
| 182 | raise ValueError( |
| 183 | f"Version {version!r} is not valid semver (expected vMAJOR.MINOR.PATCH[-pre][+build])." |
| 184 | ) |
| 185 | return SemVerTag( |
| 186 | major=int(m.group("major")), |
| 187 | minor=int(m.group("minor")), |
| 188 | patch=int(m.group("patch")), |
| 189 | pre=m.group("pre") or "", |
| 190 | build=m.group("build") or "", |
| 191 | ) |
| 192 | |
| 193 | |
| 194 | def semver_to_str(sv: SemVerTag) -> str: |
| 195 | """Render a :class:`SemVerTag` back to a canonical version string.""" |
| 196 | base = f"v{sv['major']}.{sv['minor']}.{sv['patch']}" |
| 197 | if sv["pre"]: |
| 198 | base = f"{base}-{sv['pre']}" |
| 199 | if sv["build"]: |
| 200 | base = f"{base}+{sv['build']}" |
| 201 | return base |
| 202 | |
| 203 | |
| 204 | def semver_channel(sv: SemVerTag) -> ReleaseChannel: |
| 205 | """Infer the release channel from a semver pre-release label. |
| 206 | |
| 207 | If the caller does not supply an explicit channel, this provides a |
| 208 | sensible default: pre-releases starting with ``'alpha'`` → ``alpha``, |
| 209 | ``'beta'`` → ``beta``, ``'nightly'`` → ``nightly``, anything else → |
| 210 | ``stable``. |
| 211 | """ |
| 212 | pre = sv["pre"].lower() |
| 213 | if pre.startswith("alpha"): |
| 214 | return "alpha" |
| 215 | if pre.startswith("beta"): |
| 216 | return "beta" |
| 217 | if pre.startswith("nightly"): |
| 218 | return "nightly" |
| 219 | return "stable" |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
12 days ago