Implemented the next missing parity slice around setup-time runtime checks.

This commit is contained in:
Abdelrahman Abdallah
2026-04-20 00:23:45 +02:00
parent 266e1ac563
commit b2a5a431c6
5 changed files with 378 additions and 4 deletions
+2 -2
View File
@@ -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)
+134
View File
@@ -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 `## <version>` (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',
]
+77 -2
View File
@@ -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']),
)
+101
View File
@@ -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()
+64
View File
@@ -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()