feat: add versioning and changelog system
Conventional-Commit-driven version bumps (scripts/bump.py), a commit-msg validation hook, and a /release-notes workflow that produces a human-reviewed CHANGELOG.md. Adds an in-app version badge + 'What's new' dialog on the launcher. The version is single-sourced from pyproject.toml (cim_suite.__version__), and the deterministic bump backbone lives in scripts/release/ with tests in tests/release/. Marks the 1.0.0 baseline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
44
.claude/commands/release-notes.md
Normal file
44
.claude/commands/release-notes.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
description: Draft plain-language CHANGELOG.md release notes from commits since the last version, for human review before committing.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are drafting the human-facing release notes for the CIMTechniques Service Suite.
|
||||||
|
The git history is the technical record; `CHANGELOG.md` is what non-technical users
|
||||||
|
read. Accuracy is paramount — **never invent a user-facing benefit a commit doesn't
|
||||||
|
support**, and when a commit's user impact is unclear, flag it for the user instead of
|
||||||
|
guessing.
|
||||||
|
|
||||||
|
Follow these steps exactly:
|
||||||
|
|
||||||
|
1. **Collect the commits.** Run:
|
||||||
|
```
|
||||||
|
.venv\Scripts\python scripts/release_notes.py --json
|
||||||
|
```
|
||||||
|
If there is no version tag yet and the digest reaches back into pre-rewrite history,
|
||||||
|
ask the user for a `--baseline <commit-or-tag>` and re-run with it. The JSON gives you
|
||||||
|
`current_version`, `suggested_version`, `suggested_bump`, and commits bucketed into
|
||||||
|
`features`, `fixes`, `breaking`, and an `internal_count`.
|
||||||
|
|
||||||
|
2. **Draft entries** in Keep a Changelog format for the **suggested_version** (or a
|
||||||
|
version the user specifies), dated today:
|
||||||
|
- Group under **Added / Changed / Fixed / Removed** — omit any empty group.
|
||||||
|
- `feat` commits → usually Added (or Changed if they alter existing behavior).
|
||||||
|
`fix` → Fixed. `breaking` → call out clearly (often Changed/Removed).
|
||||||
|
- Write for a non-technical reader: explain **what** changed and **why it matters to
|
||||||
|
them**, not the implementation. One bullet per user-visible change; merge related
|
||||||
|
commits into a single clear bullet.
|
||||||
|
- Collapse all internal work (refactor/test/chore/style/docs/build/ci) into at most a
|
||||||
|
single line: "Maintenance and under-the-hood improvements." Drop it if there were
|
||||||
|
none.
|
||||||
|
- Flag any commit whose user impact you're unsure of, and ask rather than guess.
|
||||||
|
|
||||||
|
3. **Show the draft to the user** in chat and ask them to edit/approve. Do **not** write
|
||||||
|
the file yet.
|
||||||
|
|
||||||
|
4. **Only after approval**, prepend the approved entry above the previous version's entry
|
||||||
|
in `CHANGELOG.md` (reverse-chronological). Do not auto-commit; leave the change in the
|
||||||
|
working tree for the user unless they ask you to commit it.
|
||||||
|
|
||||||
|
5. If the version number should change, remind the user to run
|
||||||
|
`.venv\Scripts\python scripts/bump.py` (which is deterministic and edits
|
||||||
|
`pyproject.toml`). You never set the version number yourself.
|
||||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,5 +1,7 @@
|
|||||||
# Normalize line endings; keep legacy VB6 sources as-is.
|
# Normalize line endings; keep legacy VB6 sources as-is.
|
||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
# The commit-msg hook is run by git's sh; CRLF would break the shebang. Pin LF.
|
||||||
|
/.githooks/** text eol=lf
|
||||||
*.frm text eol=crlf
|
*.frm text eol=crlf
|
||||||
*.bas text eol=crlf
|
*.bas text eol=crlf
|
||||||
*.vbp text eol=crlf
|
*.vbp text eol=crlf
|
||||||
|
|||||||
16
.githooks/commit-msg
Normal file
16
.githooks/commit-msg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Validate the commit message against the Conventional Commit format.
|
||||||
|
# Delegates to scripts/commit_msg_hook.py (logic is unit-tested in tests/release).
|
||||||
|
# Installed via: python scripts/setup_hooks.py (sets core.hooksPath -> .githooks)
|
||||||
|
|
||||||
|
hook_dir=$(dirname "$0")
|
||||||
|
script="$hook_dir/../scripts/commit_msg_hook.py"
|
||||||
|
|
||||||
|
for py in python python3 py; do
|
||||||
|
if command -v "$py" >/dev/null 2>&1; then
|
||||||
|
exec "$py" "$script" "$1"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "commit-msg hook: no python interpreter found on PATH; skipping validation" >&2
|
||||||
|
exit 0
|
||||||
28
CHANGELOG.md
Normal file
28
CHANGELOG.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the CIMTechniques Service Suite are recorded here in plain
|
||||||
|
language, for the people who use it. (The full technical record lives in the project's
|
||||||
|
git history.)
|
||||||
|
|
||||||
|
This project follows [Keep a Changelog](https://keepachangelog.com/) and
|
||||||
|
[Semantic Versioning](https://semver.org/): the version number is `MAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
|
## 1.0.0 - 2026-06-06
|
||||||
|
|
||||||
|
First release of the unified **CIMTechniques Service Suite** — one application that
|
||||||
|
brings the separate legacy service tools together under a single, modern interface.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **One app for every instrument.** A start page where you pick your service cable /
|
||||||
|
COM port once, then open the tool you need.
|
||||||
|
- **DA-12 Monitoring Station Service Tool** — configure and monitor DA-12 sensor
|
||||||
|
stations: sensors, alarm limits, statistics, and calibration.
|
||||||
|
- **DA-07 (eLink) Service Tool** — set up and monitor DA-07 stations, their pods,
|
||||||
|
and channels.
|
||||||
|
- **IOModbus Service Utility** — read and write supported Modbus devices using a
|
||||||
|
built-in device catalog.
|
||||||
|
- **Setting history.** The suite remembers configuration changes made to your DA-12
|
||||||
|
and DA-07 stations, so you can see what changed and when.
|
||||||
|
- **Export to spreadsheet.** Any data screen can be saved to an Excel (`.xlsx`) file.
|
||||||
|
- **What's new.** The current version and these release notes are shown right on the
|
||||||
|
start page.
|
||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -213,4 +213,17 @@ test fixtures.
|
|||||||
checklist.
|
checklist.
|
||||||
- **Spreadsheet export:** every data screen can be exported to `.xlsx`; the engine is
|
- **Spreadsheet export:** every data screen can be exported to `.xlsx`; the engine is
|
||||||
`cim_suite/core/export/` and the per-module adoption checklist is in `docs/EXPORT.md`.
|
`cim_suite/core/export/` and the per-module adoption checklist is in `docs/EXPORT.md`.
|
||||||
|
- **Versioning & changelog:** SemVer, single-sourced from `[project] version` in
|
||||||
|
`pyproject.toml` (`cim_suite.__version__` reads it; the frozen build bundles the file).
|
||||||
|
Commits are Conventional Commits (`<type>: <desc>`) — validated by `.githooks/commit-msg`
|
||||||
|
(install once with `python scripts/setup_hooks.py`). The deterministic
|
||||||
|
`python scripts/bump.py` computes the next version from commit prefixes
|
||||||
|
(`fix`=patch, `feat`=minor, `BREAKING CHANGE`/`!`=major) — **never let an LLM pick the
|
||||||
|
bump.** The pure logic lives in `scripts/release/` (tested in `tests/release/`); git I/O
|
||||||
|
is isolated in `scripts/release/gitio.py`. **Two records, two audiences:** git history is
|
||||||
|
the technical record; `CHANGELOG.md` is plain-language release notes for non-technical
|
||||||
|
users (also shown via the launcher's "What's new" dialog). The changelog *prose* is the
|
||||||
|
only LLM-assisted step — draft it with the `/release-notes` command, and **never commit
|
||||||
|
it unreviewed**; accuracy over flattery (don't invent user benefits a commit doesn't
|
||||||
|
support).
|
||||||
```
|
```
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -64,6 +64,45 @@ A green `pytest` and a clean `ruff` are both part of "done" here.
|
|||||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" packaging\installer.iss
|
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" packaging\installer.iss
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Versioning & releases
|
||||||
|
|
||||||
|
The suite uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`). There
|
||||||
|
are **two records of change, with different audiences**:
|
||||||
|
|
||||||
|
- **Git history** — the technical record. Every commit uses the
|
||||||
|
[Conventional Commit](https://www.conventionalcommits.org/) format `<type>: <description>`.
|
||||||
|
- **[`CHANGELOG.md`](CHANGELOG.md)** — the human-facing record: plain-language release
|
||||||
|
notes for the (non-technical) people who use the app. It's also shown in-app via the
|
||||||
|
**What's new** button on the start page.
|
||||||
|
|
||||||
|
| Commit type | Meaning | Version bump | In changelog? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `feat` | A new user-facing capability | **minor** | Yes (Added / Changed) |
|
||||||
|
| `fix` | A bug fix | **patch** | Yes (Fixed) |
|
||||||
|
| `feat!` / `BREAKING CHANGE:` in body | Removes or breaks existing behavior | **major** | Yes (Changed / Removed) |
|
||||||
|
| `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` | Internal work | none | Collapsed to one "maintenance" line, or dropped |
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# One-time on a fresh clone: install the commit-message validation hook
|
||||||
|
.venv\Scripts\python scripts\setup_hooks.py
|
||||||
|
|
||||||
|
# Bump the version (deterministic; edits pyproject.toml only). --dry-run to preview.
|
||||||
|
.venv\Scripts\python scripts\bump.py --dry-run
|
||||||
|
.venv\Scripts\python scripts\bump.py # auto-detect from commits
|
||||||
|
.venv\Scripts\python scripts\bump.py --minor # or force a bump level
|
||||||
|
|
||||||
|
# Draft release notes: in Claude Code run /release-notes (collects commits, drafts
|
||||||
|
# plain-language entries for your review, and only writes CHANGELOG.md once approved).
|
||||||
|
# Under the hood it calls:
|
||||||
|
.venv\Scripts\python scripts\release_notes.py --json
|
||||||
|
```
|
||||||
|
|
||||||
|
The version string lives in **one place** — `[project] version` in `pyproject.toml`;
|
||||||
|
`cim_suite.__version__` reads from it. The bump level is computed from commit prefixes,
|
||||||
|
never by an LLM. The changelog *prose* is the only LLM-assisted step and is never
|
||||||
|
committed without review. Releases start from the last `vX.Y.Z` git tag (or a
|
||||||
|
`--baseline <rev>` you pass when there's no tag yet).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Five strictly-ordered layers. The protocol core is **pure** (no I/O, no Qt) and the
|
Five strictly-ordered layers. The protocol core is **pure** (no I/O, no Qt) and the
|
||||||
@@ -105,6 +144,7 @@ UI edit → controller.set_* → encode → transport.write
|
|||||||
| [`docs/DESIGN-SYSTEM.md`](docs/DESIGN-SYSTEM.md) | Suite-wide visual standard |
|
| [`docs/DESIGN-SYSTEM.md`](docs/DESIGN-SYSTEM.md) | Suite-wide visual standard |
|
||||||
| [`docs/EXPORT.md`](docs/EXPORT.md) | `.xlsx` export engine + adoption checklist |
|
| [`docs/EXPORT.md`](docs/EXPORT.md) | `.xlsx` export engine + adoption checklist |
|
||||||
| [`docs/VB6-MIGRATION-PLAYBOOK.md`](docs/VB6-MIGRATION-PLAYBOOK.md) | Reverse-engineering traps from the VB6 port |
|
| [`docs/VB6-MIGRATION-PLAYBOOK.md`](docs/VB6-MIGRATION-PLAYBOOK.md) | Reverse-engineering traps from the VB6 port |
|
||||||
|
| [`CHANGELOG.md`](CHANGELOG.md) | Human-facing release notes (see _Versioning & releases_ above) |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,64 @@
|
|||||||
"""CIMTechniques Service Suite — unified desktop suite of instrument service tools."""
|
"""CIMTechniques Service Suite — unified desktop suite of instrument service tools."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_pyprojects() -> list[Path]:
|
||||||
|
"""Places pyproject.toml may live, in priority order (frozen bundle, then source)."""
|
||||||
|
candidates = []
|
||||||
|
meipass = getattr(sys, "_MEIPASS", None)
|
||||||
|
if meipass is not None:
|
||||||
|
candidates.append(Path(meipass) / "pyproject.toml")
|
||||||
|
candidates.append(Path(__file__).resolve().parent.parent / "pyproject.toml")
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_version() -> str:
|
||||||
|
"""Resolve the suite version from its single source of truth, ``pyproject.toml``.
|
||||||
|
|
||||||
|
A source checkout (incl. editable installs) reads the live file, so a
|
||||||
|
``scripts/bump.py`` edit shows up immediately with no reinstall; the frozen build
|
||||||
|
bundles the same file (see packaging/suite.spec). A plain wheel install with no
|
||||||
|
pyproject alongside the package falls back to the baked-in distribution metadata.
|
||||||
|
"""
|
||||||
|
for pyproject in _candidate_pyprojects():
|
||||||
|
if pyproject.is_file():
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
return tomllib.loads(pyproject.read_text("utf-8"))["project"]["version"]
|
||||||
|
except Exception: # pragma: no cover - malformed pyproject falls through
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
return version("cim-service-suite")
|
||||||
|
except PackageNotFoundError: # pragma: no cover - not installed and no pyproject
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
__version__ = _detect_version()
|
||||||
|
|
||||||
|
|
||||||
|
def changelog_path() -> Path | None:
|
||||||
|
"""Locate the bundled ``CHANGELOG.md`` (repo root in source, bundle dir when frozen)."""
|
||||||
|
candidates = []
|
||||||
|
meipass = getattr(sys, "_MEIPASS", None)
|
||||||
|
if meipass is not None:
|
||||||
|
candidates.append(Path(meipass) / "CHANGELOG.md")
|
||||||
|
candidates.append(Path(__file__).resolve().parent.parent / "CHANGELOG.md")
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def changelog_markdown() -> str:
|
||||||
|
"""The release notes as markdown, or a friendly placeholder if none is bundled."""
|
||||||
|
path = changelog_path()
|
||||||
|
if path is None:
|
||||||
|
return "# Changelog\n\nNo release notes are available in this build."
|
||||||
|
return path.read_text("utf-8")
|
||||||
|
|||||||
@@ -371,6 +371,24 @@ QLabel#LauncherHeading {{
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
padding: {S.LG}px {S.XS}px {S.SM}px {S.XS}px;
|
padding: {S.LG}px {S.XS}px {S.SM}px {S.XS}px;
|
||||||
}}
|
}}
|
||||||
|
QLabel#LauncherVersion {{
|
||||||
|
color: {C.TEXT_MUTED};
|
||||||
|
font-size: {T.SMALL_PT}pt;
|
||||||
|
font-weight: 700;
|
||||||
|
padding-right: {S.SM}px;
|
||||||
|
}}
|
||||||
|
QPushButton#WhatsNewButton {{
|
||||||
|
background-color: transparent;
|
||||||
|
color: {C.ACCENT};
|
||||||
|
border: 1px solid {C.ACCENT_SOFT_LINE};
|
||||||
|
border-radius: {R.MD}px;
|
||||||
|
padding: {S.XS}px {S.MD}px;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
QPushButton#WhatsNewButton:hover {{
|
||||||
|
background-color: {C.ACCENT_SOFT};
|
||||||
|
border-color: {C.ACCENT};
|
||||||
|
}}
|
||||||
QFrame#ModuleCard {{
|
QFrame#ModuleCard {{
|
||||||
background-color: {C.BG_SURFACE};
|
background-color: {C.BG_SURFACE};
|
||||||
border: 1px solid {C.BORDER};
|
border: 1px solid {C.BORDER};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
"""DA-07 ("eLink") Monitoring Station Service Tool — modern rebuild."""
|
"""DA-07 ("eLink") Monitoring Station Service Tool — modern rebuild."""
|
||||||
|
|
||||||
__version__ = "3.0.0"
|
from cim_suite import __version__ # one suite-wide version (single-sourced in pyproject)
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
"""DA-12 Monitoring Station Service Tool — modern rebuild."""
|
"""DA-12 Monitoring Station Service Tool — modern rebuild."""
|
||||||
|
|
||||||
__version__ = "3.0.0"
|
from cim_suite import __version__ # one suite-wide version (single-sourced in pyproject)
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ A config-driven Modbus RTU master (rebuild of the legacy VB6 ``IOModbus.exe``).
|
|||||||
``docs/superpowers/specs/2026-06-03-iomodbus-service-tool-rebuild-design.md``.
|
``docs/superpowers/specs/2026-06-03-iomodbus-service-tool-rebuild-design.md``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.0.0"
|
from cim_suite import __version__ # one suite-wide version (single-sourced in pyproject)
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from PySide6.QtCore import Qt, Signal
|
|||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
@@ -19,11 +20,13 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import cim_suite
|
||||||
from cim_suite.core.module import Module
|
from cim_suite.core.module import Module
|
||||||
from cim_suite.core.transport.port_scan import DetectedPort, scan_ports
|
from cim_suite.core.transport.port_scan import DetectedPort, scan_ports
|
||||||
from cim_suite.core.ui.theme import Space
|
from cim_suite.core.ui.theme import Space
|
||||||
|
|
||||||
from .cable_panel import CablePanel
|
from .cable_panel import CablePanel
|
||||||
|
from .whats_new import WhatsNewDialog
|
||||||
|
|
||||||
_COLUMNS = 3
|
_COLUMNS = 3
|
||||||
|
|
||||||
@@ -87,9 +90,21 @@ class LauncherView(QScrollArea):
|
|||||||
outer.setContentsMargins(Space.XL, Space.LG, Space.XL, Space.XL)
|
outer.setContentsMargins(Space.XL, Space.LG, Space.XL, Space.XL)
|
||||||
outer.setSpacing(Space.MD)
|
outer.setSpacing(Space.MD)
|
||||||
|
|
||||||
|
header = QHBoxLayout()
|
||||||
heading = QLabel("CIMTechniques Service Suite")
|
heading = QLabel("CIMTechniques Service Suite")
|
||||||
heading.setObjectName("LauncherHeading")
|
heading.setObjectName("LauncherHeading")
|
||||||
outer.addWidget(heading)
|
header.addWidget(heading)
|
||||||
|
header.addStretch(1)
|
||||||
|
|
||||||
|
version = QLabel(f"v{cim_suite.__version__}")
|
||||||
|
version.setObjectName("LauncherVersion")
|
||||||
|
header.addWidget(version, alignment=Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
|
||||||
|
self._whats_new_btn = QPushButton("What's new")
|
||||||
|
self._whats_new_btn.setObjectName("WhatsNewButton")
|
||||||
|
self._whats_new_btn.clicked.connect(self._show_whats_new)
|
||||||
|
header.addWidget(self._whats_new_btn, alignment=Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
outer.addLayout(header)
|
||||||
|
|
||||||
self._cable = CablePanel(include_simulator=include_simulator, scan_fn=scan_fn)
|
self._cable = CablePanel(include_simulator=include_simulator, scan_fn=scan_fn)
|
||||||
self._cable.sourceChanged.connect(self._on_source_changed)
|
self._cable.sourceChanged.connect(self._on_source_changed)
|
||||||
@@ -120,6 +135,9 @@ class LauncherView(QScrollArea):
|
|||||||
self._cable.set_scan_fn(self._scan_fn)
|
self._cable.set_scan_fn(self._scan_fn)
|
||||||
return self._cable.rescan()
|
return self._cable.rescan()
|
||||||
|
|
||||||
|
def _show_whats_new(self) -> None:
|
||||||
|
WhatsNewDialog(self).exec()
|
||||||
|
|
||||||
def _on_source_changed(self, source: str) -> None:
|
def _on_source_changed(self, source: str) -> None:
|
||||||
self._set_cards_enabled(bool(source))
|
self._set_cards_enabled(bool(source))
|
||||||
|
|
||||||
|
|||||||
44
cim_suite/shell/whats_new.py
Normal file
44
cim_suite/shell/whats_new.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""The "What's new" dialog: renders the bundled CHANGELOG.md as release notes.
|
||||||
|
|
||||||
|
Plain-language notes for a non-technical audience live in CHANGELOG.md; this just
|
||||||
|
displays them. The markdown is located/read by :mod:`cim_suite` so the same file
|
||||||
|
works from a source checkout and a frozen build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QPushButton,
|
||||||
|
QTextBrowser,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
import cim_suite
|
||||||
|
from cim_suite.core.ui.theme import Space
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsNewDialog(QDialog):
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("What's new")
|
||||||
|
self.resize(580, 540)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(Space.LG, Space.LG, Space.LG, Space.LG)
|
||||||
|
layout.setSpacing(Space.MD)
|
||||||
|
|
||||||
|
self.notes = QTextBrowser()
|
||||||
|
self.notes.setOpenExternalLinks(True)
|
||||||
|
self.notes.setMarkdown(cim_suite.changelog_markdown())
|
||||||
|
layout.addWidget(self.notes)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
buttons.addStretch(1)
|
||||||
|
close = QPushButton("Close")
|
||||||
|
close.setProperty("variant", "primary")
|
||||||
|
close.clicked.connect(self.accept)
|
||||||
|
buttons.addWidget(close)
|
||||||
|
layout.addLayout(buttons)
|
||||||
@@ -25,6 +25,11 @@ _font_src = os.path.join(_repo_root, "cim_suite", "core", "ui", "theme", "fonts"
|
|||||||
# (importlib.resources) can read it.
|
# (importlib.resources) can read it.
|
||||||
_iomodbus_res = os.path.join(_repo_root, "cim_suite", "modules", "iomodbus", "resources")
|
_iomodbus_res = os.path.join(_repo_root, "cim_suite", "modules", "iomodbus", "resources")
|
||||||
|
|
||||||
|
# Version is single-sourced from pyproject.toml and the "What's new" dialog renders
|
||||||
|
# CHANGELOG.md; both are read at runtime from the bundle root (sys._MEIPASS).
|
||||||
|
_pyproject = os.path.join(_repo_root, "pyproject.toml")
|
||||||
|
_changelog = os.path.join(_repo_root, "CHANGELOG.md")
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
[os.path.join(SPECPATH, "suite_launcher.py")],
|
[os.path.join(SPECPATH, "suite_launcher.py")],
|
||||||
pathex=[_repo_root],
|
pathex=[_repo_root],
|
||||||
@@ -32,6 +37,8 @@ a = Analysis(
|
|||||||
datas=[
|
datas=[
|
||||||
(_font_src, os.path.join("cim_suite", "core", "ui", "theme", "fonts")),
|
(_font_src, os.path.join("cim_suite", "core", "ui", "theme", "fonts")),
|
||||||
(_iomodbus_res, os.path.join("cim_suite", "modules", "iomodbus", "resources")),
|
(_iomodbus_res, os.path.join("cim_suite", "modules", "iomodbus", "resources")),
|
||||||
|
(_pyproject, "."),
|
||||||
|
(_changelog, "."),
|
||||||
],
|
],
|
||||||
hiddenimports=["serial.tools.list_ports", "openpyxl", "PySide6.QtCharts"],
|
hiddenimports=["serial.tools.list_ports", "openpyxl", "PySide6.QtCharts"],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "cim-service-suite"
|
name = "cim-service-suite"
|
||||||
version = "3.0.0"
|
version = "1.0.0"
|
||||||
description = "DA-12 Monitoring Station Service Tool (modern rebuild)"
|
description = "DA-12 Monitoring Station Service Tool (modern rebuild)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["PySide6>=6.7", "pyserial>=3.5", "openpyxl>=3.1"]
|
dependencies = ["PySide6>=6.7", "pyserial>=3.5", "openpyxl>=3.1"]
|
||||||
|
|||||||
75
scripts/bump.py
Normal file
75
scripts/bump.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Deterministically bump the suite version from Conventional Commit history.
|
||||||
|
|
||||||
|
The bump type is computed from commit prefixes (fix=patch, feat=minor, a
|
||||||
|
``BREAKING CHANGE`` body / ``!`` marker = major) unless overridden. The version
|
||||||
|
lives in exactly one place — ``[project] version`` in pyproject.toml — and this is
|
||||||
|
the only writer. An LLM is never asked to decide the bump.
|
||||||
|
|
||||||
|
python scripts/bump.py # auto-detect from commits, write pyproject
|
||||||
|
python scripts/bump.py --dry-run # show what it would do, change nothing
|
||||||
|
python scripts/bump.py --minor # force a minor bump
|
||||||
|
python scripts/bump.py --baseline <rev> # range start when there is no version tag
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from release import repo # noqa: E402
|
||||||
|
from release.versioning import BumpKind, set_version # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _override(args: argparse.Namespace) -> BumpKind | None:
|
||||||
|
for kind in ("major", "minor", "patch"):
|
||||||
|
if getattr(args, kind):
|
||||||
|
return kind # type: ignore[return-value]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
group = parser.add_mutually_exclusive_group()
|
||||||
|
group.add_argument("--major", action="store_true", help="force a major bump")
|
||||||
|
group.add_argument("--minor", action="store_true", help="force a minor bump")
|
||||||
|
group.add_argument("--patch", action="store_true", help="force a patch bump")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="print, do not write")
|
||||||
|
parser.add_argument("--baseline", help="range start commit/tag when no version tag exists")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
plan = repo.gather(baseline=args.baseline)
|
||||||
|
rng = plan.range_spec or "(all commits)"
|
||||||
|
print(f"Current version: {plan.current}")
|
||||||
|
print(f"Scanning {rng}: {len(plan.commits)} commit(s).")
|
||||||
|
|
||||||
|
bump = _override(args) or plan.detected_bump
|
||||||
|
if bump is None:
|
||||||
|
print(
|
||||||
|
"No releasable commits (no feat/fix/breaking) in range.\n"
|
||||||
|
"Nothing to bump — pass --patch/--minor/--major to force."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
new_version = plan.current.bump(bump)
|
||||||
|
source = "forced" if _override(args) else "detected"
|
||||||
|
print(f"Bump ({source}): {bump} -> {new_version}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry run - pyproject.toml not modified.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
repo.PYPROJECT.write_text(set_version(repo.PYPROJECT.read_text("utf-8"), str(new_version)))
|
||||||
|
print(f"Updated {repo.PYPROJECT.name} to version {new_version}.")
|
||||||
|
print(
|
||||||
|
f"Next: draft release notes (/release-notes), review CHANGELOG.md, then\n"
|
||||||
|
f" git commit -am 'chore: release {new_version}' && git tag v{new_version}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
37
scripts/commit_msg_hook.py
Normal file
37
scripts/commit_msg_hook.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""commit-msg hook body: reject messages that aren't ``<type>: <description>``.
|
||||||
|
|
||||||
|
Invoked by ``.githooks/commit-msg`` with the path to the commit message file.
|
||||||
|
Validation logic lives in :mod:`release.commits` so it is unit-tested.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from release.commits import KNOWN_TYPES, validate_commit_message # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) < 2:
|
||||||
|
return 0
|
||||||
|
message = Path(argv[1]).read_text("utf-8")
|
||||||
|
error = validate_commit_message(message)
|
||||||
|
if error is None:
|
||||||
|
return 0
|
||||||
|
indented = "\n ".join(error.splitlines())
|
||||||
|
sys.stderr.write(
|
||||||
|
"\n x Commit rejected: invalid commit message.\n\n"
|
||||||
|
f" {indented}\n\n"
|
||||||
|
f" Format : <type>(<optional scope>): <description>\n"
|
||||||
|
f" Types : {', '.join(KNOWN_TYPES)}\n"
|
||||||
|
" Example: feat(da12): add live sensor sparkline\n\n"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
8
scripts/release/__init__.py
Normal file
8
scripts/release/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Pure, testable logic for the suite's versioning + changelog tooling.
|
||||||
|
|
||||||
|
This package holds the deterministic backbone (semver arithmetic, commit parsing,
|
||||||
|
range resolution, classification) used by the ``scripts/*.py`` CLIs and the
|
||||||
|
``.githooks/commit-msg`` hook. It is import-only: no side effects, no git I/O.
|
||||||
|
The git plumbing lives in :mod:`release.gitio`; the prose summarization is a
|
||||||
|
human+LLM step that never lives in code.
|
||||||
|
"""
|
||||||
154
scripts/release/commits.py
Normal file
154
scripts/release/commits.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Conventional-commit parsing, bump detection, range resolution, classification.
|
||||||
|
|
||||||
|
All pure functions over plain data — the git plumbing that produces ``ParsedCommit``
|
||||||
|
records lives in :mod:`release.gitio`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .versioning import BumpKind, Version
|
||||||
|
|
||||||
|
# The commit types we recognise. feat/fix are user-visible; the rest are internal
|
||||||
|
# noise the changelog collapses. Unknown prefixes (e.g. a stray "polish:") are
|
||||||
|
# rejected by the commit-msg hook and treated as internal if they slip through.
|
||||||
|
USER_VISIBLE_TYPES = ("feat", "fix")
|
||||||
|
INTERNAL_TYPES = (
|
||||||
|
"docs",
|
||||||
|
"style",
|
||||||
|
"refactor",
|
||||||
|
"perf",
|
||||||
|
"test",
|
||||||
|
"build",
|
||||||
|
"ci",
|
||||||
|
"chore",
|
||||||
|
"revert",
|
||||||
|
)
|
||||||
|
KNOWN_TYPES = (*USER_VISIBLE_TYPES, *INTERNAL_TYPES)
|
||||||
|
|
||||||
|
_SUBJECT_RE = re.compile(
|
||||||
|
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<bang>!)?:\s*(?P<desc>.*)$"
|
||||||
|
)
|
||||||
|
_BREAKING_BODY_RE = re.compile(r"(?m)^BREAKING[ -]CHANGE:")
|
||||||
|
# Subjects git generates itself, which are not Conventional Commits but are valid.
|
||||||
|
_MERGE_RE = re.compile(r"^Merge ")
|
||||||
|
_REVERT_RE = re.compile(r'^Revert "')
|
||||||
|
_AUTOSQUASH_RE = re.compile(r"^(fixup|squash|amend)! ")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedCommit:
|
||||||
|
"""One commit decomposed into Conventional-Commit parts.
|
||||||
|
|
||||||
|
``type`` is ``None`` for a non-conventional subject (e.g. a merge commit).
|
||||||
|
"""
|
||||||
|
|
||||||
|
sha: str
|
||||||
|
type: str | None
|
||||||
|
scope: str | None
|
||||||
|
breaking: bool
|
||||||
|
description: str
|
||||||
|
body: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_commit(sha: str, subject: str, body: str = "") -> ParsedCommit:
|
||||||
|
"""Decompose a commit subject/body into a :class:`ParsedCommit`."""
|
||||||
|
breaking = bool(_BREAKING_BODY_RE.search(body))
|
||||||
|
m = _SUBJECT_RE.match(subject)
|
||||||
|
if m is None or m.group("type") not in KNOWN_TYPES:
|
||||||
|
return ParsedCommit(sha, None, None, breaking, subject.strip(), body)
|
||||||
|
return ParsedCommit(
|
||||||
|
sha=sha,
|
||||||
|
type=m.group("type"),
|
||||||
|
scope=m.group("scope"),
|
||||||
|
breaking=breaking or bool(m.group("bang")),
|
||||||
|
description=m.group("desc").strip(),
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def determine_bump(commits: list[ParsedCommit]) -> BumpKind | None:
|
||||||
|
"""Highest-priority bump implied by the commits, or ``None`` if none are releasable."""
|
||||||
|
if any(c.breaking for c in commits):
|
||||||
|
return "major"
|
||||||
|
if any(c.type == "feat" for c in commits):
|
||||||
|
return "minor"
|
||||||
|
if any(c.type == "fix" for c in commits):
|
||||||
|
return "patch"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def last_version_tag(tags: list[str]) -> str | None:
|
||||||
|
"""The highest ``vX.Y.Z`` tag by semver, or ``None`` if there are none."""
|
||||||
|
versioned = [t for t in tags if _TAG_RE.match(t)]
|
||||||
|
if not versioned:
|
||||||
|
return None
|
||||||
|
return max(versioned, key=lambda t: Version.parse(t[1:]))
|
||||||
|
|
||||||
|
|
||||||
|
def commit_range(*, last_tag: str | None, baseline: str | None) -> str | None:
|
||||||
|
"""Git revision range to scan. ``None`` means 'all commits'."""
|
||||||
|
start = last_tag or baseline
|
||||||
|
if start is None:
|
||||||
|
return None
|
||||||
|
return f"{start}..HEAD"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReleaseDigest:
|
||||||
|
"""The user-review material for one release: visible changes + collapsed internals."""
|
||||||
|
|
||||||
|
features: list[ParsedCommit]
|
||||||
|
fixes: list[ParsedCommit]
|
||||||
|
breaking: list[ParsedCommit]
|
||||||
|
internal: list[ParsedCommit]
|
||||||
|
|
||||||
|
|
||||||
|
def build_digest(commits: list[ParsedCommit]) -> ReleaseDigest:
|
||||||
|
"""Split commits into feat / fix / breaking / internal buckets for summarization."""
|
||||||
|
return ReleaseDigest(
|
||||||
|
features=[c for c in commits if c.type == "feat"],
|
||||||
|
fixes=[c for c in commits if c.type == "fix"],
|
||||||
|
breaking=[c for c in commits if c.breaking],
|
||||||
|
internal=[c for c in commits if c.type not in USER_VISIBLE_TYPES],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_commit_message(message: str) -> str | None:
|
||||||
|
"""Return an error string if ``message`` is not a valid commit subject, else ``None``."""
|
||||||
|
subject = ""
|
||||||
|
for line in message.splitlines():
|
||||||
|
if line.startswith("#"):
|
||||||
|
continue # git comment lines (and the editor template) are not the subject
|
||||||
|
if line.strip():
|
||||||
|
subject = line.rstrip()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not subject:
|
||||||
|
return "empty commit message"
|
||||||
|
if (
|
||||||
|
_MERGE_RE.match(subject)
|
||||||
|
or _REVERT_RE.match(subject)
|
||||||
|
or _AUTOSQUASH_RE.match(subject)
|
||||||
|
):
|
||||||
|
return None # git-generated subjects (merge/revert/autosquash) are allowed through
|
||||||
|
|
||||||
|
m = _SUBJECT_RE.match(subject)
|
||||||
|
if m is None:
|
||||||
|
return (
|
||||||
|
f"commit subject must be '<type>: <description>' (got: {subject!r}).\n"
|
||||||
|
f" Valid types: {', '.join(KNOWN_TYPES)}"
|
||||||
|
)
|
||||||
|
if m.group("type") not in KNOWN_TYPES:
|
||||||
|
return (
|
||||||
|
f"unknown commit type {m.group('type')!r}.\n"
|
||||||
|
f" Valid types: {', '.join(KNOWN_TYPES)}"
|
||||||
|
)
|
||||||
|
if not m.group("desc").strip():
|
||||||
|
return "commit description is empty after the ':'"
|
||||||
|
return None
|
||||||
50
scripts/release/gitio.py
Normal file
50
scripts/release/gitio.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Thin git plumbing: list tags, read commits in a range. The only impure module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .commits import ParsedCommit, parse_commit
|
||||||
|
|
||||||
|
# Field/record separators unlikely to appear in commit text.
|
||||||
|
_FS = "\x1f"
|
||||||
|
_RS = "\x1e"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(args: list[str], cwd: Path | str | None) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=cwd,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def list_tags(*, cwd: Path | str | None = None) -> list[str]:
|
||||||
|
"""All tag names in the repo."""
|
||||||
|
out = _git(["tag", "--list"], cwd)
|
||||||
|
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def commits_in_range(
|
||||||
|
range_spec: str | None, *, cwd: Path | str | None = None
|
||||||
|
) -> list[ParsedCommit]:
|
||||||
|
"""Parsed commits in ``range_spec`` (newest first). ``None`` means all commits."""
|
||||||
|
fmt = f"%H{_FS}%s{_FS}%b{_RS}"
|
||||||
|
args = ["log", f"--pretty=format:{fmt}"]
|
||||||
|
if range_spec:
|
||||||
|
args.append(range_spec)
|
||||||
|
out = _git(args, cwd)
|
||||||
|
|
||||||
|
commits: list[ParsedCommit] = []
|
||||||
|
for record in out.split(_RS):
|
||||||
|
record = record.strip("\n")
|
||||||
|
if not record:
|
||||||
|
continue
|
||||||
|
sha, subject, body = record.split(_FS)
|
||||||
|
commits.append(parse_commit(sha, subject, body))
|
||||||
|
return commits
|
||||||
40
scripts/release/repo.py
Normal file
40
scripts/release/repo.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Shared glue between the pure logic and the on-disk repo for the CLI entry points."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import gitio
|
||||||
|
from .commits import ParsedCommit, commit_range, determine_bump, last_version_tag
|
||||||
|
from .versioning import BumpKind, Version, read_version
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReleasePlan:
|
||||||
|
"""Everything the bump/changelog CLIs need, computed once from the repo state."""
|
||||||
|
|
||||||
|
current: Version
|
||||||
|
range_spec: str | None
|
||||||
|
commits: list[ParsedCommit]
|
||||||
|
detected_bump: BumpKind | None
|
||||||
|
baseline: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def gather(baseline: str | None = None) -> ReleasePlan:
|
||||||
|
"""Read the current version and the commits since the last tag (or baseline)."""
|
||||||
|
current = Version.parse(read_version(PYPROJECT.read_text("utf-8")))
|
||||||
|
tags = gitio.list_tags(cwd=REPO_ROOT)
|
||||||
|
last_tag = last_version_tag(tags)
|
||||||
|
range_spec = commit_range(last_tag=last_tag, baseline=baseline)
|
||||||
|
commits = gitio.commits_in_range(range_spec, cwd=REPO_ROOT)
|
||||||
|
return ReleasePlan(
|
||||||
|
current=current,
|
||||||
|
range_spec=range_spec,
|
||||||
|
commits=commits,
|
||||||
|
detected_bump=determine_bump(commits),
|
||||||
|
baseline=baseline,
|
||||||
|
)
|
||||||
67
scripts/release/versioning.py
Normal file
67
scripts/release/versioning.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Semver value object and the pyproject version single-source read/write."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
BumpKind = Literal["major", "minor", "patch"]
|
||||||
|
|
||||||
|
_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class Version:
|
||||||
|
"""A semantic version ``major.minor.patch`` (no pre-release/build metadata)."""
|
||||||
|
|
||||||
|
major: int
|
||||||
|
minor: int
|
||||||
|
patch: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, text: str) -> "Version":
|
||||||
|
m = _SEMVER_RE.match(text.strip())
|
||||||
|
if m is None:
|
||||||
|
raise ValueError(f"not a MAJOR.MINOR.PATCH version: {text!r}")
|
||||||
|
return cls(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.major}.{self.minor}.{self.patch}"
|
||||||
|
|
||||||
|
def bump(self, kind: BumpKind) -> "Version":
|
||||||
|
if kind == "major":
|
||||||
|
return Version(self.major + 1, 0, 0)
|
||||||
|
if kind == "minor":
|
||||||
|
return Version(self.major, self.minor + 1, 0)
|
||||||
|
if kind == "patch":
|
||||||
|
return Version(self.major, self.minor, self.patch + 1)
|
||||||
|
raise ValueError(f"unknown bump kind: {kind!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# The [project] table header up to the next table header or end of text.
|
||||||
|
_PROJECT_TABLE_RE = re.compile(r"(?ms)^\[project\]\s*$.*?(?=^\[|\Z)")
|
||||||
|
_VERSION_LINE_RE = re.compile(r'(?m)^(version\s*=\s*")([^"]*)(")')
|
||||||
|
|
||||||
|
|
||||||
|
def read_version(pyproject_text: str) -> str:
|
||||||
|
"""Return the ``[project] version`` string from pyproject.toml text."""
|
||||||
|
return tomllib.loads(pyproject_text)["project"]["version"]
|
||||||
|
|
||||||
|
|
||||||
|
def set_version(pyproject_text: str, new_version: str) -> str:
|
||||||
|
"""Return pyproject.toml text with the ``[project] version`` set to ``new_version``.
|
||||||
|
|
||||||
|
Only the version line inside the ``[project]`` table is touched; identical-looking
|
||||||
|
lines in other tables (e.g. ``[tool.x] version = ...``) are left alone, and all
|
||||||
|
surrounding formatting/comments are preserved.
|
||||||
|
"""
|
||||||
|
Version.parse(new_version) # reject garbage before we rewrite the file
|
||||||
|
|
||||||
|
table = _PROJECT_TABLE_RE.search(pyproject_text)
|
||||||
|
if table is None or not _VERSION_LINE_RE.search(table.group(0)):
|
||||||
|
raise ValueError("no [project] version line found in pyproject.toml")
|
||||||
|
|
||||||
|
new_table = _VERSION_LINE_RE.sub(rf"\g<1>{new_version}\g<3>", table.group(0), count=1)
|
||||||
|
return pyproject_text[: table.start()] + new_table + pyproject_text[table.end() :]
|
||||||
84
scripts/release_notes.py
Normal file
84
scripts/release_notes.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Collect commits since the last release and emit a digest for changelog drafting.
|
||||||
|
|
||||||
|
This produces the STRUCTURED INPUT for the human + Claude changelog step. It never
|
||||||
|
writes CHANGELOG.md and never invents prose — it just buckets commits into
|
||||||
|
user-visible features/fixes/breaking changes vs. collapsible internal work, using the
|
||||||
|
same range logic as the bump script. The plain-language summary is drafted by Claude
|
||||||
|
from this digest and reviewed before it lands (see ``.claude/commands/release-notes.md``).
|
||||||
|
|
||||||
|
python scripts/release_notes.py # human-readable digest
|
||||||
|
python scripts/release_notes.py --json # machine-readable digest (for Claude)
|
||||||
|
python scripts/release_notes.py --baseline <rev>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from release import repo # noqa: E402
|
||||||
|
from release.commits import ParsedCommit, build_digest # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_dict(c: ParsedCommit) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"sha": c.sha[:8],
|
||||||
|
"type": c.type,
|
||||||
|
"scope": c.scope,
|
||||||
|
"breaking": c.breaking,
|
||||||
|
"description": c.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--json", action="store_true", help="emit JSON for tooling/Claude")
|
||||||
|
parser.add_argument("--baseline", help="range start commit/tag when no version tag exists")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
plan = repo.gather(baseline=args.baseline)
|
||||||
|
digest = build_digest(plan.commits)
|
||||||
|
bump = plan.detected_bump or "patch"
|
||||||
|
suggested = plan.current.bump(bump)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
payload = {
|
||||||
|
"current_version": str(plan.current),
|
||||||
|
"suggested_version": str(suggested),
|
||||||
|
"suggested_bump": bump,
|
||||||
|
"detected_bump": plan.detected_bump,
|
||||||
|
"range": plan.range_spec,
|
||||||
|
"features": [_commit_dict(c) for c in digest.features],
|
||||||
|
"fixes": [_commit_dict(c) for c in digest.fixes],
|
||||||
|
"breaking": [_commit_dict(c) for c in digest.breaking],
|
||||||
|
"internal_count": len(digest.internal),
|
||||||
|
"internal_types": sorted({c.type or "other" for c in digest.internal}),
|
||||||
|
}
|
||||||
|
print(json.dumps(payload, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rng = plan.range_spec or "(all commits)"
|
||||||
|
print(f"# Release digest ({plan.current} -> {suggested}, {bump})")
|
||||||
|
print(f"Range: {rng} Commits: {len(plan.commits)}\n")
|
||||||
|
for title, items in (
|
||||||
|
("Breaking changes", digest.breaking),
|
||||||
|
("Features (feat)", digest.features),
|
||||||
|
("Fixes (fix)", digest.fixes),
|
||||||
|
):
|
||||||
|
print(f"## {title} ({len(items)})")
|
||||||
|
for c in items:
|
||||||
|
scope = f"({c.scope})" if c.scope else ""
|
||||||
|
print(f" - {c.sha[:8]} {c.type}{scope}: {c.description}")
|
||||||
|
print()
|
||||||
|
types = sorted({c.type or "other" for c in digest.internal})
|
||||||
|
print(f"## Internal (collapse to one line): {len(digest.internal)} commit(s) {types}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
33
scripts/setup_hooks.py
Normal file
33
scripts/setup_hooks.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""One-time install of the repo's git hooks on a fresh clone.
|
||||||
|
|
||||||
|
Points git at the version-controlled ``.githooks`` directory so the commit-msg
|
||||||
|
validator runs without copying anything into ``.git``.
|
||||||
|
|
||||||
|
python scripts/setup_hooks.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
subprocess.run(["git", "config", "core.hooksPath", ".githooks"], cwd=ROOT, check=True)
|
||||||
|
|
||||||
|
hook = ROOT / ".githooks" / "commit-msg"
|
||||||
|
if hook.is_file() and os.name != "nt":
|
||||||
|
hook.chmod(hook.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
|
print("Git hooks installed: core.hooksPath -> .githooks")
|
||||||
|
print("Commit messages will now be validated against the Conventional Commit format.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
0
tests/release/__init__.py
Normal file
0
tests/release/__init__.py
Normal file
14
tests/release/conftest.py
Normal file
14
tests/release/conftest.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Make the dev-only ``scripts/release`` package importable as ``release``.
|
||||||
|
|
||||||
|
It is deliberately not part of the shipped ``cim_suite`` wheel, so it is not on
|
||||||
|
``sys.path`` via the editable install. Prepend the repo's ``scripts`` dir here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS = Path(__file__).resolve().parents[2] / "scripts"
|
||||||
|
if str(_SCRIPTS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS))
|
||||||
173
tests/release/test_commits.py
Normal file
173
tests/release/test_commits.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for commit parsing, bump detection, range resolution, and classification."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from release.commits import (
|
||||||
|
ParsedCommit,
|
||||||
|
build_digest,
|
||||||
|
commit_range,
|
||||||
|
determine_bump,
|
||||||
|
last_version_tag,
|
||||||
|
parse_commit,
|
||||||
|
validate_commit_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- parse_commit ----------------------------------------------------------
|
||||||
|
def test_parse_plain_type():
|
||||||
|
c = parse_commit("abc", "feat: add launcher")
|
||||||
|
assert c.type == "feat"
|
||||||
|
assert c.scope is None
|
||||||
|
assert c.breaking is False
|
||||||
|
assert c.description == "add launcher"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_with_scope():
|
||||||
|
c = parse_commit("abc", "fix(repository): stretch Value column")
|
||||||
|
assert c.type == "fix"
|
||||||
|
assert c.scope == "repository"
|
||||||
|
assert c.description == "stretch Value column"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_bang_marks_breaking():
|
||||||
|
c = parse_commit("abc", "feat(api)!: drop wireless sensors")
|
||||||
|
assert c.type == "feat"
|
||||||
|
assert c.scope == "api"
|
||||||
|
assert c.breaking is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_breaking_change_in_body():
|
||||||
|
c = parse_commit("abc", "refactor: rework transport", body="BREAKING CHANGE: ports moved")
|
||||||
|
assert c.breaking is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_breaking_change_hyphen_variant():
|
||||||
|
c = parse_commit("abc", "feat: x", body="BREAKING-CHANGE: y")
|
||||||
|
assert c.breaking is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_non_conventional_subject():
|
||||||
|
c = parse_commit("abc", "Merge branch 'feat/foo' into main")
|
||||||
|
assert c.type is None
|
||||||
|
assert c.breaking is False
|
||||||
|
assert c.description == "Merge branch 'feat/foo' into main"
|
||||||
|
|
||||||
|
|
||||||
|
# --- determine_bump --------------------------------------------------------
|
||||||
|
def test_bump_breaking_wins():
|
||||||
|
commits = [
|
||||||
|
parse_commit("1", "feat: a"),
|
||||||
|
parse_commit("2", "fix: b"),
|
||||||
|
parse_commit("3", "feat!: c"),
|
||||||
|
]
|
||||||
|
assert determine_bump(commits) == "major"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_feat_over_fix():
|
||||||
|
commits = [parse_commit("1", "fix: a"), parse_commit("2", "feat: b")]
|
||||||
|
assert determine_bump(commits) == "minor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_fix_only():
|
||||||
|
commits = [parse_commit("1", "fix: a"), parse_commit("2", "docs: b")]
|
||||||
|
assert determine_bump(commits) == "patch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_none_when_no_releasable_commits():
|
||||||
|
commits = [parse_commit("1", "docs: a"), parse_commit("2", "chore: b")]
|
||||||
|
assert determine_bump(commits) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_none_for_empty():
|
||||||
|
assert determine_bump([]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- last_version_tag ------------------------------------------------------
|
||||||
|
def test_last_version_tag_picks_highest_semver():
|
||||||
|
tags = ["v1.0.0", "v1.2.0", "v1.10.0", "v1.9.0"]
|
||||||
|
assert last_version_tag(tags) == "v1.10.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_version_tag_ignores_non_version_tags():
|
||||||
|
tags = ["baseline", "v1.0.0", "release-candidate", "not-a-version"]
|
||||||
|
assert last_version_tag(tags) == "v1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_version_tag_none_when_empty():
|
||||||
|
assert last_version_tag([]) is None
|
||||||
|
assert last_version_tag(["nightly", "stable"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- commit_range ----------------------------------------------------------
|
||||||
|
def test_range_prefers_last_tag():
|
||||||
|
assert commit_range(last_tag="v1.2.0", baseline="abc123") == "v1.2.0..HEAD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_falls_back_to_baseline():
|
||||||
|
assert commit_range(last_tag=None, baseline="abc123") == "abc123..HEAD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_all_commits_when_neither():
|
||||||
|
assert commit_range(last_tag=None, baseline=None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_digest ----------------------------------------------------------
|
||||||
|
def _commits() -> list[ParsedCommit]:
|
||||||
|
return [
|
||||||
|
parse_commit("1", "feat: new screen"),
|
||||||
|
parse_commit("2", "fix: crash on open"),
|
||||||
|
parse_commit("3", "feat!: remove old mode"),
|
||||||
|
parse_commit("4", "refactor: tidy"),
|
||||||
|
parse_commit("5", "test: add coverage"),
|
||||||
|
parse_commit("6", "Merge branch 'x'"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_buckets():
|
||||||
|
d = build_digest(_commits())
|
||||||
|
assert [c.sha for c in d.features] == ["1", "3"]
|
||||||
|
assert [c.sha for c in d.fixes] == ["2"]
|
||||||
|
assert [c.sha for c in d.breaking] == ["3"]
|
||||||
|
# refactor, test, and the merge all collapse into internal.
|
||||||
|
assert [c.sha for c in d.internal] == ["4", "5", "6"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- validate_commit_message ----------------------------------------------
|
||||||
|
def test_validate_accepts_conventional():
|
||||||
|
assert validate_commit_message("feat: add thing") is None
|
||||||
|
assert validate_commit_message("fix(scope): bug") is None
|
||||||
|
assert validate_commit_message("feat(api)!: breaking") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_merge_and_revert():
|
||||||
|
assert validate_commit_message("Merge branch 'main' into feat/x") is None
|
||||||
|
assert validate_commit_message('Revert "feat: add thing"') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_fixup_and_squash():
|
||||||
|
# git commit --fixup/--squash generate these; rejecting them breaks autosquash.
|
||||||
|
assert validate_commit_message("fixup! feat: add thing") is None
|
||||||
|
assert validate_commit_message("squash! fix: the bug") is None
|
||||||
|
assert validate_commit_message("amend! feat: add thing") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_missing_type():
|
||||||
|
err = validate_commit_message("add a thing")
|
||||||
|
assert err is not None
|
||||||
|
assert "type" in err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_unknown_type():
|
||||||
|
err = validate_commit_message("feet: typo in type")
|
||||||
|
assert err is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_empty_description():
|
||||||
|
err = validate_commit_message("feat: ")
|
||||||
|
assert err is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_ignores_comment_lines():
|
||||||
|
# git passes the whole COMMIT_EDITMSG; comment lines must not be validated.
|
||||||
|
msg = "feat: real subject\n\n# Please enter the commit message\n# comment"
|
||||||
|
assert validate_commit_message(msg) is None
|
||||||
61
tests/release/test_gitio.py
Normal file
61
tests/release/test_gitio.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Integration tests for the git plumbing, against throwaway temp repos."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from release import gitio
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: Path, *args: str) -> None:
|
||||||
|
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repo(tmp_path: Path) -> Path:
|
||||||
|
r = tmp_path / "repo"
|
||||||
|
r.mkdir()
|
||||||
|
_git(r, "init", "-q")
|
||||||
|
_git(r, "config", "user.email", "t@t.t")
|
||||||
|
_git(r, "config", "user.name", "t")
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(repo: Path, subject: str, body: str = "") -> None:
|
||||||
|
(repo / "f.txt").write_text(subject)
|
||||||
|
_git(repo, "add", "-A")
|
||||||
|
msg = subject if not body else f"{subject}\n\n{body}"
|
||||||
|
_git(repo, "commit", "-q", "-m", msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tags(repo: Path):
|
||||||
|
_commit(repo, "feat: one")
|
||||||
|
_git(repo, "tag", "v1.0.0")
|
||||||
|
_git(repo, "tag", "baseline")
|
||||||
|
assert set(gitio.list_tags(cwd=repo)) == {"v1.0.0", "baseline"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_commits_in_range_all(repo: Path):
|
||||||
|
_commit(repo, "feat: one")
|
||||||
|
_commit(repo, "fix: two")
|
||||||
|
commits = gitio.commits_in_range(None, cwd=repo)
|
||||||
|
# Newest first, parsed.
|
||||||
|
assert [c.type for c in commits] == ["fix", "feat"]
|
||||||
|
assert [c.description for c in commits] == ["two", "one"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_commits_in_range_since_tag(repo: Path):
|
||||||
|
_commit(repo, "feat: before")
|
||||||
|
_git(repo, "tag", "v1.0.0")
|
||||||
|
_commit(repo, "fix: after")
|
||||||
|
commits = gitio.commits_in_range("v1.0.0..HEAD", cwd=repo)
|
||||||
|
assert [c.description for c in commits] == ["after"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_commits_in_range_parses_body_breaking(repo: Path):
|
||||||
|
_commit(repo, "refactor: rework", body="BREAKING CHANGE: moved ports")
|
||||||
|
commits = gitio.commits_in_range(None, cwd=repo)
|
||||||
|
assert commits[0].breaking is True
|
||||||
92
tests/release/test_versioning.py
Normal file
92
tests/release/test_versioning.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for the semver value object and the pyproject version single-source."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from release.versioning import Version, read_version, set_version
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_roundtrip():
|
||||||
|
assert str(Version.parse("1.2.3")) == "1.2.3"
|
||||||
|
assert Version.parse("1.2.3") == Version(1, 2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rejects_garbage():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Version.parse("1.2")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Version.parse("v1.2.3") # the 'v' prefix is a tag concern, not a version string
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Version.parse("1.2.x")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_patch():
|
||||||
|
assert Version(1, 2, 3).bump("patch") == Version(1, 2, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_minor_resets_patch():
|
||||||
|
assert Version(1, 2, 3).bump("minor") == Version(1, 3, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_major_resets_minor_and_patch():
|
||||||
|
assert Version(1, 2, 3).bump("major") == Version(2, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_are_ordered():
|
||||||
|
assert Version(1, 0, 0) < Version(1, 0, 1) < Version(1, 1, 0) < Version(2, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
PYPROJECT = """\
|
||||||
|
[project]
|
||||||
|
name = "cim-service-suite"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "x"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_version():
|
||||||
|
assert read_version(PYPROJECT) == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_version_changes_only_project_version():
|
||||||
|
out = set_version(PYPROJECT, "1.1.0")
|
||||||
|
assert read_version(out) == "1.1.0"
|
||||||
|
# Everything else is preserved verbatim.
|
||||||
|
assert 'name = "cim-service-suite"' in out
|
||||||
|
assert 'requires = ["setuptools>=68"]' in out
|
||||||
|
assert out.count('version = "') == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_version_ignores_lookalike_version_in_other_table():
|
||||||
|
text = (
|
||||||
|
'[project]\nname = "x"\nversion = "1.0.0"\n\n'
|
||||||
|
'[tool.thing]\nversion = "9.9.9"\n'
|
||||||
|
)
|
||||||
|
out = set_version(text, "1.0.1")
|
||||||
|
assert read_version(out) == "1.0.1"
|
||||||
|
assert 'version = "9.9.9"' in out # untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_cim_suite_version_is_single_sourced_from_pyproject():
|
||||||
|
import cim_suite
|
||||||
|
|
||||||
|
pyproject = (_REPO_ROOT / "pyproject.toml").read_text("utf-8")
|
||||||
|
assert cim_suite.__version__ == read_version(pyproject)
|
||||||
|
|
||||||
|
|
||||||
|
def test_modules_reexport_the_suite_version():
|
||||||
|
import cim_suite
|
||||||
|
from cim_suite.modules import da07, da12, iomodbus
|
||||||
|
|
||||||
|
assert da12.__version__ == cim_suite.__version__
|
||||||
|
assert da07.__version__ == cim_suite.__version__
|
||||||
|
assert iomodbus.__version__ == cim_suite.__version__
|
||||||
28
tests/shell/test_whats_new.py
Normal file
28
tests/shell/test_whats_new.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Launcher version badge + the What's-new release-notes dialog."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QLabel
|
||||||
|
|
||||||
|
import cim_suite
|
||||||
|
from cim_suite.modules.da12.module import Da12Module
|
||||||
|
from cim_suite.shell.launcher import LauncherView
|
||||||
|
from cim_suite.shell.whats_new import WhatsNewDialog
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_none(*, include_simulator=False):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def test_launcher_shows_suite_version(qtbot):
|
||||||
|
view = LauncherView([Da12Module(simulate=True)], scan_fn=_scan_none)
|
||||||
|
qtbot.addWidget(view)
|
||||||
|
texts = [w.text() for w in view.findChildren(QLabel)]
|
||||||
|
assert f"v{cim_suite.__version__}" in texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_whats_new_dialog_renders_changelog(qtbot):
|
||||||
|
dlg = WhatsNewDialog()
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
rendered = dlg.notes.toPlainText()
|
||||||
|
assert rendered.strip() # never empty (real changelog or a friendly placeholder)
|
||||||
Reference in New Issue
Block a user