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