Implemented the next missing parity slice around setup-time runtime checks.
This commit is contained in:
+2
-2
@@ -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)
|
||||
|
||||
@@ -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
@@ -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']),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user