From b2a5a431c6d2178dc2882bf832c940a190441de5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:23:45 +0200 Subject: [PATCH] Implemented the next missing parity slice around setup-time runtime checks. --- PARITY_CHECKLIST.md | 4 +- src/release_notes.py | 134 +++++++++++++++++++++++++++++ src/setup.py | 79 ++++++++++++++++- tests/test_release_notes.py | 101 ++++++++++++++++++++++ tests/test_setup_runtime_checks.py | 64 ++++++++++++++ 5 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 src/release_notes.py create mode 100644 tests/test_release_notes.py create mode 100644 tests/test_setup_runtime_checks.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 7c907c3..8ff0717 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -175,10 +175,10 @@ Missing: - [ ] Self-hosted runner mode - [ ] tmux fast paths - [ ] Worktree fast paths at the CLI entrypoint level -- [ ] Node.js version check and platform setup from `setup.ts` +- [x] Python (Node.js equivalent) version check and platform detection from `setup.ts` - [ ] Worktree creation/setup from `setup.ts` - [ ] Terminal backup/restore from `setup.ts` -- [ ] Release notes checking from `setup.ts` +- [x] Release notes checking from `setup.ts` (local CHANGELOG.md, no network/cache layer) - [ ] Full `entrypoints/cli.tsx` parity (version flag, feature flags, env setup, dynamic imports) - [ ] Full `entrypoints/init.ts` parity (settings validation, OAuth, policy limits, telemetry, cleanup handlers) - [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) diff --git a/src/release_notes.py b/src/release_notes.py new file mode 100644 index 0000000..318180d --- /dev/null +++ b/src/release_notes.py @@ -0,0 +1,134 @@ +"""Local release-notes parsing — Python port of utils/releaseNotes.ts. + +The Python runtime has no network/cache layer, so this module only reads a +local CHANGELOG.md (typically in the project root). The npm version fetches +from GitHub and caches under ~/.claude/cache/changelog.md; here, callers +provide the changelog text or pass the project cwd so we can read it. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Iterable + +MAX_RELEASE_NOTES_SHOWN = 5 + + +def parse_changelog(content: str) -> dict[str, list[str]]: + """Parse a markdown CHANGELOG into {version: [bullet, ...]}. + + Recognises sections starting with `## ` (optionally followed by + ` - YYYY-MM-DD`). Bullet lines starting with `- ` become entries. + """ + if not content: + return {} + notes: dict[str, list[str]] = {} + sections = re.split(r'^## ', content, flags=re.MULTILINE)[1:] + for section in sections: + lines = section.strip().splitlines() + if not lines: + continue + version = lines[0].split(' - ')[0].strip() + if not version: + continue + bullets: list[str] = [] + for line in lines[1:]: + stripped = line.strip() + if stripped.startswith('- '): + text = stripped[2:].strip() + if text: + bullets.append(text) + if bullets: + notes[version] = bullets + return notes + + +def _coerce_version(value: str | None) -> tuple[int, ...] | None: + if value is None: + return None + match = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?', value.strip()) + if match is None: + return None + return tuple(int(part) if part else 0 for part in match.groups()) + + +def _gt(a: str, b: str) -> bool: + parsed_a = _coerce_version(a) + parsed_b = _coerce_version(b) + if parsed_a is None or parsed_b is None: + return False + return parsed_a > parsed_b + + +def get_recent_release_notes( + current_version: str, + previous_version: str | None, + changelog_content: str, +) -> list[str]: + """Return up to MAX_RELEASE_NOTES_SHOWN bullets newer than previous_version.""" + notes = parse_changelog(changelog_content) + base_current = _coerce_version(current_version) + base_previous = _coerce_version(previous_version) + if base_previous is not None and base_current is not None and base_current <= base_previous: + return [] + relevant = [ + (version, bullets) + for version, bullets in notes.items() + if base_previous is None or _gt(version, previous_version or '0') + ] + relevant.sort(key=lambda item: _coerce_version(item[0]) or (), reverse=True) + flat: list[str] = [] + for _, bullets in relevant: + flat.extend(bullets) + return flat[:MAX_RELEASE_NOTES_SHOWN] + + +def get_all_release_notes(changelog_content: str) -> list[tuple[str, list[str]]]: + """Return all [(version, bullets)] entries sorted oldest-first.""" + notes = parse_changelog(changelog_content) + versions = sorted(notes.keys(), key=lambda v: _coerce_version(v) or ()) + return [(version, notes[version]) for version in versions if notes[version]] + + +def read_local_changelog(cwd: Path) -> str: + """Read CHANGELOG.md from the workspace root, returning '' if missing.""" + path = cwd / 'CHANGELOG.md' + try: + return path.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return '' + + +def check_for_release_notes( + current_version: str, + last_seen_version: str | None, + cwd: Path | None = None, + changelog_content: str | None = None, +) -> dict: + """Return {'hasReleaseNotes': bool, 'releaseNotes': [...]}. + + If `changelog_content` is supplied it is used directly; otherwise the + workspace CHANGELOG.md is read. Mirrors the npm `checkForReleaseNotes` + return shape (without the network fetch — no cache update). + """ + content = ( + changelog_content + if changelog_content is not None + else read_local_changelog(cwd or Path.cwd()) + ) + bullets = get_recent_release_notes(current_version, last_seen_version, content) + return { + 'hasReleaseNotes': bool(bullets), + 'releaseNotes': bullets, + } + + +__all__ = [ + 'MAX_RELEASE_NOTES_SHOWN', + 'parse_changelog', + 'get_recent_release_notes', + 'get_all_release_notes', + 'read_local_changelog', + 'check_for_release_notes', +] diff --git a/src/setup.py b/src/setup.py index 9a23934..75d055d 100644 --- a/src/setup.py +++ b/src/setup.py @@ -2,11 +2,61 @@ from __future__ import annotations import platform import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from .deferred_init import DeferredInitResult, run_deferred_init from .prefetch import PrefetchResult, start_keychain_prefetch, start_mdm_raw_read, start_project_scan +from .release_notes import check_for_release_notes + + +MIN_PYTHON_VERSION: tuple[int, int] = (3, 10) + + +@dataclass(frozen=True) +class RuntimeRequirementCheck: + name: str + ok: bool + detail: str + + +def check_runtime_requirements() -> tuple[RuntimeRequirementCheck, ...]: + checks: list[RuntimeRequirementCheck] = [] + py_major, py_minor = sys.version_info[:2] + py_ok = (py_major, py_minor) >= MIN_PYTHON_VERSION + checks.append(RuntimeRequirementCheck( + name='python_version', + ok=py_ok, + detail=( + f'Python {py_major}.{py_minor} >= {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}' + if py_ok + else f'Python {py_major}.{py_minor} is below required ' + f'{MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}' + ), + )) + impl = platform.python_implementation() + checks.append(RuntimeRequirementCheck( + name='python_implementation', + ok=True, + detail=impl, + )) + machine = platform.machine() or 'unknown' + system = platform.system() or 'unknown' + checks.append(RuntimeRequirementCheck( + name='platform', + ok=True, + detail=f'{system} on {machine}', + )) + return tuple(checks) + + +def _read_package_version() -> str: + try: + from importlib.metadata import version + + return version('claw-code-agent') + except Exception: + return '0.0.0' @dataclass(frozen=True) @@ -34,6 +84,11 @@ class SetupReport: deferred_init: DeferredInitResult trusted: bool cwd: Path + runtime_checks: tuple[RuntimeRequirementCheck, ...] = field(default_factory=tuple) + release_notes: tuple[str, ...] = field(default_factory=tuple) + + def has_blocking_issues(self) -> bool: + return any(not check.ok for check in self.runtime_checks) def as_markdown(self) -> str: lines = [ @@ -44,12 +99,21 @@ class SetupReport: f'- Trusted mode: {self.trusted}', f'- CWD: {self.cwd}', '', + 'Runtime checks:', + *( + f'- {check.name}: {"ok" if check.ok else "FAIL"} — {check.detail}' + for check in self.runtime_checks + ), + '', 'Prefetches:', *(f'- {prefetch.name}: {prefetch.detail}' for prefetch in self.prefetches), '', 'Deferred init:', *self.deferred_init.as_lines(), ] + if self.release_notes: + lines.extend(['', 'Release notes (newer than last seen):']) + lines.extend(f'- {note}' for note in self.release_notes) return '\n'.join(lines) @@ -61,17 +125,28 @@ def build_workspace_setup() -> WorkspaceSetup: ) -def run_setup(cwd: Path | None = None, trusted: bool = True) -> SetupReport: +def run_setup( + cwd: Path | None = None, + trusted: bool = True, + last_seen_version: str | None = None, +) -> SetupReport: root = cwd or Path(__file__).resolve().parent.parent prefetches = [ start_mdm_raw_read(), start_keychain_prefetch(), start_project_scan(root), ] + release_notes_payload = check_for_release_notes( + current_version=_read_package_version(), + last_seen_version=last_seen_version, + cwd=root, + ) return SetupReport( setup=build_workspace_setup(), prefetches=tuple(prefetches), deferred_init=run_deferred_init(trusted=trusted), trusted=trusted, cwd=root, + runtime_checks=check_runtime_requirements(), + release_notes=tuple(release_notes_payload['releaseNotes']), ) diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 0000000..8836ad0 --- /dev/null +++ b/tests/test_release_notes.py @@ -0,0 +1,101 @@ +"""Tests for the local release-notes parser ported from utils/releaseNotes.ts.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from src.release_notes import ( + MAX_RELEASE_NOTES_SHOWN, + check_for_release_notes, + get_all_release_notes, + get_recent_release_notes, + parse_changelog, + read_local_changelog, +) + + +SAMPLE = ( + '# Changelog\n\n' + '## 1.3.0 - 2026-04-15\n' + '- new shiny\n' + '- another bullet\n\n' + '## 1.2.0\n' + '- mid bullet\n\n' + '## 1.1.0\n' + '- oldest bullet\n' +) + + +class ParseChangelogTest(unittest.TestCase): + def test_returns_empty_for_blank(self) -> None: + self.assertEqual(parse_changelog(''), {}) + + def test_extracts_versions_and_bullets(self) -> None: + parsed = parse_changelog(SAMPLE) + self.assertEqual(set(parsed.keys()), {'1.3.0', '1.2.0', '1.1.0'}) + self.assertEqual(parsed['1.3.0'], ['new shiny', 'another bullet']) + self.assertEqual(parsed['1.2.0'], ['mid bullet']) + + def test_skips_versions_without_bullets(self) -> None: + parsed = parse_changelog('# X\n\n## 1.0.0\nplain text\n') + self.assertEqual(parsed, {}) + + +class RecentNotesTest(unittest.TestCase): + def test_returns_only_newer_versions(self) -> None: + notes = get_recent_release_notes('1.3.0', '1.2.0', SAMPLE) + self.assertEqual(notes, ['new shiny', 'another bullet']) + + def test_first_run_returns_all(self) -> None: + notes = get_recent_release_notes('1.3.0', None, SAMPLE) + self.assertEqual(notes[0], 'new shiny') + self.assertIn('oldest bullet', notes) + + def test_no_new_when_at_or_below_previous(self) -> None: + self.assertEqual(get_recent_release_notes('1.1.0', '1.3.0', SAMPLE), []) + + def test_caps_at_max_shown(self) -> None: + big_changelog = '# Changelog\n\n' + ''.join( + f'## 9.9.{i}\n- bullet {i}\n\n' for i in range(20) + ) + notes = get_recent_release_notes('9.9.19', '0.0.1', big_changelog) + self.assertEqual(len(notes), MAX_RELEASE_NOTES_SHOWN) + + +class AllNotesTest(unittest.TestCase): + def test_sorted_oldest_first(self) -> None: + all_notes = get_all_release_notes(SAMPLE) + versions = [version for version, _ in all_notes] + self.assertEqual(versions, ['1.1.0', '1.2.0', '1.3.0']) + + +class ReadLocalChangelogTest(unittest.TestCase): + def test_reads_when_present(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CHANGELOG.md').write_text(SAMPLE, encoding='utf-8') + self.assertIn('## 1.3.0', read_local_changelog(Path(tmp))) + + def test_returns_empty_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(read_local_changelog(Path(tmp)), '') + + +class CheckForReleaseNotesTest(unittest.TestCase): + def test_signals_when_changelog_present_and_newer(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CHANGELOG.md').write_text(SAMPLE, encoding='utf-8') + payload = check_for_release_notes('1.3.0', '1.2.0', cwd=Path(tmp)) + self.assertTrue(payload['hasReleaseNotes']) + self.assertEqual(payload['releaseNotes'][0], 'new shiny') + + def test_silent_when_missing_changelog(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + payload = check_for_release_notes('1.3.0', None, cwd=Path(tmp)) + self.assertFalse(payload['hasReleaseNotes']) + self.assertEqual(payload['releaseNotes'], []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_setup_runtime_checks.py b/tests/test_setup_runtime_checks.py new file mode 100644 index 0000000..02de29a --- /dev/null +++ b/tests/test_setup_runtime_checks.py @@ -0,0 +1,64 @@ +"""Tests for setup-time runtime checks ported from setup.ts.""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src.setup import ( + MIN_PYTHON_VERSION, + SetupReport, + check_runtime_requirements, + run_setup, +) + + +class RuntimeRequirementCheckTest(unittest.TestCase): + def test_python_check_passes_on_current_runtime(self) -> None: + checks = {check.name: check for check in check_runtime_requirements()} + self.assertTrue(checks['python_version'].ok) + self.assertGreaterEqual(sys.version_info[:2], MIN_PYTHON_VERSION) + + def test_python_check_fails_when_below_minimum(self) -> None: + # Force a lower version_info via patching to verify the failure branch. + fake_version = mock.Mock() + fake_version.__getitem__ = lambda self, idx: (3, 8)[idx] if isinstance(idx, int) else (3, 8)[idx] + with mock.patch('src.setup.sys') as fake_sys: + fake_sys.version_info = (3, 8, 0) + checks = {check.name: check for check in check_runtime_requirements()} + self.assertFalse(checks['python_version'].ok) + self.assertIn('below required', checks['python_version'].detail) + + def test_includes_platform_and_implementation(self) -> None: + names = {check.name for check in check_runtime_requirements()} + self.assertIn('python_implementation', names) + self.assertIn('platform', names) + + +class SetupReportTest(unittest.TestCase): + def test_run_setup_reports_runtime_and_release_notes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CHANGELOG.md').write_text( + '# Changelog\n\n## 9.9.9\n- big release\n', encoding='utf-8', + ) + report = run_setup(cwd=Path(tmp), trusted=True, last_seen_version='0.0.1') + self.assertIsInstance(report, SetupReport) + self.assertGreaterEqual(len(report.runtime_checks), 3) + self.assertIn('big release', report.release_notes) + markdown = report.as_markdown() + self.assertIn('Runtime checks', markdown) + self.assertIn('Release notes', markdown) + self.assertFalse(report.has_blocking_issues()) + + def test_no_release_notes_when_changelog_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + report = run_setup(cwd=Path(tmp), trusted=True) + self.assertEqual(report.release_notes, ()) + self.assertNotIn('Release notes', report.as_markdown()) + + +if __name__ == '__main__': + unittest.main()