From e6240a04c781ff31681db919ee8efdd4cd565209 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 12:47:16 -0300 Subject: [PATCH 1/4] chore(workspace): propagate unified make and release automation --- .python-version | 1 + scripts/maintenance/enforce_python_version.py | 243 ++++++++++++++++++ scripts/release/build.py | 7 +- scripts/release/changelog.py | 5 +- scripts/release/notes.py | 9 +- scripts/release/run.py | 120 +++++---- scripts/release/shared.py | 20 +- scripts/release/version.py | 46 ++-- tests/conftest.py | 22 ++ 9 files changed, 389 insertions(+), 84 deletions(-) create mode 100644 .python-version create mode 100644 scripts/maintenance/enforce_python_version.py diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/scripts/maintenance/enforce_python_version.py b/scripts/maintenance/enforce_python_version.py new file mode 100644 index 00000000..3db5aa99 --- /dev/null +++ b/scripts/maintenance/enforce_python_version.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# Owner-Skill: .claude/skills/workspace-maintenance/SKILL.md +"""Enforce Python version constraints across all workspace projects. + +Creates .python-version files and injects conftest.py version guards +to prevent venv creation with wrong Python interpreter. + +Usage:: + + python scripts/maintenance/enforce_python_version.py [--check] [--verbose] + +Modes: + (default) Apply: create .python-version, inject conftest guards + --check Verify: exit non-zero if any project is missing guards +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +REQUIRED_MINOR = 13 +PYTHON_VERSION_CONTENT = f"3.{REQUIRED_MINOR}\n" + +# Marker comment used to detect if the guard is already injected +GUARD_MARKER = "# PYTHON_VERSION_GUARD" + +# The guard block injected into conftest.py files +GUARD_BLOCK = f"""\ +{GUARD_MARKER} — Do not remove. Managed by scripts/maintenance/enforce_python_version.py +import sys as _sys + +if _sys.version_info[:2] != (3, {REQUIRED_MINOR}): + _v = f"{{_sys.version_info.major}}.{{_sys.version_info.minor}}.{{_sys.version_info.micro}}" + raise RuntimeError( + f"\\n{{'=' * 72}}\\n" + f"FATAL: Python {{_v}} detected — this project requires Python 3.{REQUIRED_MINOR}.\\n" + f"\\n" + f"The virtual environment was created with the WRONG Python interpreter.\\n" + f"\\n" + f"Fix:\\n" + f" 1. rm -rf .venv\\n" + f" 2. poetry env use python3.{REQUIRED_MINOR}\\n" + f" 3. poetry install\\n" + f"\\n" + f"Or use the workspace Makefile:\\n" + f" make setup PROJECT=\\n" + f"{{'=' * 72}}\\n" + ) +del _sys +{GUARD_MARKER}_END +""" + + +def _discover_projects(workspace_root: Path) -> list[Path]: + """Discover all flext-* projects with pyproject.toml.""" + projects: list[Path] = [] + for entry in sorted(workspace_root.iterdir(), key=lambda v: v.name): + if not entry.is_dir() or not entry.name.startswith("flext-"): + continue + if (entry / "pyproject.toml").exists(): + projects.append(entry) + return projects + + +def _ensure_python_version_file( + project: Path, *, check_only: bool, verbose: bool +) -> bool: + """Ensure .python-version exists with correct content.""" + pv_file = project / ".python-version" + if pv_file.exists(): + content = pv_file.read_text(encoding="utf-8").strip() + if content == f"3.{REQUIRED_MINOR}": + if verbose: + print(f" ✓ .python-version OK: {project.name}") + return True + if check_only: + print(f" ✗ .python-version WRONG ({content}): {project.name}") + return False + if verbose: + print( + f" ↻ .python-version FIXED ({content} → 3.{REQUIRED_MINOR}): {project.name}" + ) + else: + if check_only: + print(f" ✗ .python-version MISSING: {project.name}") + return False + if verbose: + print(f" + .python-version CREATED: {project.name}") + + pv_file.write_text(PYTHON_VERSION_CONTENT, encoding="utf-8") + return True + + +def _has_guard(content: str) -> bool: + """Check if conftest.py already has the version guard.""" + return GUARD_MARKER in content + + +def _remove_existing_guard(content: str) -> str: + """Remove existing guard block (for replacement).""" + pattern = re.compile( + rf"^{re.escape(GUARD_MARKER)}.*?^{re.escape(GUARD_MARKER)}_END\n?", + re.MULTILINE | re.DOTALL, + ) + return pattern.sub("", content) + + +def _inject_guard(content: str) -> str: + """Inject version guard after the module docstring, before other imports.""" + # Remove any existing guard first + content = _remove_existing_guard(content) + + # Find insertion point: after module docstring, before first import + # Strategy: find the end of the docstring block, insert guard there + lines = content.split("\n") + insert_idx = 0 + + # Skip shebang + if lines and lines[0].startswith("#!"): + insert_idx = 1 + + # Skip leading comments + while insert_idx < len(lines) and lines[insert_idx].startswith("#"): + insert_idx += 1 + + # Skip blank lines + while insert_idx < len(lines) and not lines[insert_idx].strip(): + insert_idx += 1 + + # Skip docstring (triple-quoted) + if insert_idx < len(lines): + line = lines[insert_idx].strip() + if line.startswith('"""') or line.startswith("'''"): + quote = line[:3] + # Check if single-line docstring + if line.count(quote) >= 2 and len(line) > 3: + insert_idx += 1 + else: + # Multi-line docstring — find closing quotes + insert_idx += 1 + while insert_idx < len(lines) and quote not in lines[insert_idx]: + insert_idx += 1 + if insert_idx < len(lines): + insert_idx += 1 + + # Skip blank lines after docstring + while insert_idx < len(lines) and not lines[insert_idx].strip(): + insert_idx += 1 + + # Skip __future__ imports (must come before guard) + while insert_idx < len(lines) and lines[insert_idx].strip().startswith( + "from __future__" + ): + insert_idx += 1 + + # Skip blank lines after __future__ + while insert_idx < len(lines) and not lines[insert_idx].strip(): + insert_idx += 1 + + # Insert guard + before = "\n".join(lines[:insert_idx]) + after = "\n".join(lines[insert_idx:]) + + if before and not before.endswith("\n"): + before += "\n" + + return f"{before}{GUARD_BLOCK}\n{after}" + + +def _ensure_conftest_guard(project: Path, *, check_only: bool, verbose: bool) -> bool: + """Ensure tests/conftest.py has the Python version guard.""" + conftest = project / "tests" / "conftest.py" + + if not conftest.exists(): + if verbose: + print(f" - No tests/conftest.py: {project.name} (skipped)") + return True # Not a failure — project might not have tests + + content = conftest.read_text(encoding="utf-8") + + if _has_guard(content): + if verbose: + print(f" ✓ conftest.py guard OK: {project.name}") + return True + + if check_only: + print(f" ✗ conftest.py guard MISSING: {project.name}") + return False + + new_content = _inject_guard(content) + conftest.write_text(new_content, encoding="utf-8") + if verbose: + print(f" + conftest.py guard INJECTED: {project.name}") + return True + + +def main(argv: list[str] | None = None) -> int: + """Run enforcement.""" + parser = argparse.ArgumentParser(description="Enforce Python version constraints") + parser.add_argument("--check", action="store_true", help="Check mode (no writes)") + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + args = parser.parse_args(argv) + + projects = _discover_projects(ROOT) + all_ok = True + mode = "Checking" if args.check else "Enforcing" + + print(f"{mode} Python 3.{REQUIRED_MINOR} for {len(projects)} projects...") + + # Workspace root .python-version + if not _ensure_python_version_file( + ROOT, check_only=args.check, verbose=args.verbose + ): + all_ok = False + + for project in projects: + if not _ensure_python_version_file( + project, check_only=args.check, verbose=args.verbose + ): + all_ok = False + if not _ensure_conftest_guard( + project, check_only=args.check, verbose=args.verbose + ): + all_ok = False + + if all_ok: + print(f"✓ All {len(projects)} projects enforce Python 3.{REQUIRED_MINOR}") + return 0 + + if args.check: + print(f"✗ Some projects missing Python 3.{REQUIRED_MINOR} enforcement") + print(f" Run: python scripts/maintenance/enforce_python_version.py") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/build.py b/scripts/release/build.py index 09519406..bc0a2ee0 100644 --- a/scripts/release/build.py +++ b/scripts/release/build.py @@ -11,7 +11,7 @@ if str(SCRIPTS_ROOT) not in sys.path: sys.path.insert(0, str(SCRIPTS_ROOT)) -from release.shared import discover_projects, workspace_root +from release.shared import resolve_projects, workspace_root def _parse_args() -> argparse.Namespace: @@ -19,6 +19,7 @@ def _parse_args() -> argparse.Namespace: _ = parser.add_argument("--root", type=Path, default=Path(".")) _ = parser.add_argument("--version", required=True) _ = parser.add_argument("--output-dir", type=Path, required=True) + _ = parser.add_argument("--projects", nargs="*", default=[]) return parser.parse_args() @@ -38,12 +39,10 @@ def main() -> int: output_dir.mkdir(parents=True, exist_ok=True) report_path = output_dir / "build-report.json" - projects = discover_projects(root) + projects = resolve_projects(root, args.projects) targets = [ ("root", root), - ("algar-oud-mig", root / "algar-oud-mig"), *[(project.name, project.path) for project in projects], - ("gruponos-meltano-native", root / "gruponos-meltano-native"), ] seen: set[str] = set() diff --git a/scripts/release/changelog.py b/scripts/release/changelog.py index 4d01ea59..c056b958 100644 --- a/scripts/release/changelog.py +++ b/scripts/release/changelog.py @@ -25,13 +25,14 @@ def _parse_args() -> argparse.Namespace: def _update_changelog(existing: str, version: str, tag: str) -> str: date = datetime.now(UTC).date().isoformat() + heading = f"## {version} - " section = ( - f"## {version} - {date}\n\n" + f"{heading}{date}\n\n" f"- Workspace release tag: `{tag}`\n" "- Status: Alpha, non-production\n\n" f"Full notes: `docs/releases/{tag}.md`\n\n" ) - if section in existing: + if heading in existing: return existing marker = "# Changelog\n\n" if marker in existing: diff --git a/scripts/release/notes.py b/scripts/release/notes.py index 50741f64..0fa8c547 100644 --- a/scripts/release/notes.py +++ b/scripts/release/notes.py @@ -9,7 +9,7 @@ if str(SCRIPTS_ROOT) not in sys.path: sys.path.insert(0, str(SCRIPTS_ROOT)) -from release.shared import discover_projects, run_capture, workspace_root +from release.shared import resolve_projects, run_capture, workspace_root def _parse_args() -> argparse.Namespace: @@ -18,6 +18,7 @@ def _parse_args() -> argparse.Namespace: _ = parser.add_argument("--tag", required=True) _ = parser.add_argument("--output", type=Path, required=True) _ = parser.add_argument("--version", default="") + _ = parser.add_argument("--projects", nargs="*", default=[]) return parser.parse_args() @@ -56,7 +57,7 @@ def main() -> int: previous = _previous_tag(root, args.tag) changes = _collect_changes(root, previous, args.tag) - projects = discover_projects(root) + projects = resolve_projects(root, args.projects) version = args.version or args.tag.removeprefix("v") lines: list[str] = [ @@ -70,7 +71,7 @@ def main() -> int: "## Scope", "", f"- Workspace release version: {version}", - f"- Projects packaged: {len(projects) + 2}", + f"- Projects packaged: {len(projects) + 1}", "", "## Projects impacted", "", @@ -79,9 +80,7 @@ def main() -> int: f"- {name}" for name in [ "root", - "algar-oud-mig", *[project.name for project in projects], - "gruponos-meltano-native", ] ) lines.extend([ diff --git a/scripts/release/run.py b/scripts/release/run.py index acaad58b..36758acf 100644 --- a/scripts/release/run.py +++ b/scripts/release/run.py @@ -2,9 +2,9 @@ from __future__ import annotations import argparse -import re from pathlib import Path import sys +import tomllib SCRIPTS_ROOT = Path(__file__).resolve().parents[1] if str(SCRIPTS_ROOT) not in sys.path: @@ -12,8 +12,8 @@ from release.shared import ( bump_version, - discover_projects, parse_semver, + resolve_projects, run_capture, run_checked, workspace_root, @@ -31,17 +31,21 @@ def _parse_args() -> argparse.Namespace: _ = parser.add_argument("--push", type=int, default=0) _ = parser.add_argument("--dry-run", type=int, default=0) _ = parser.add_argument("--create-branches", type=int, default=1) + _ = parser.add_argument("--projects", nargs="*", default=[]) return parser.parse_args() def _current_version(root: Path) -> str: pyproject = root / "pyproject.toml" - content = pyproject.read_text(encoding="utf-8") - match = re.search(r'^version\s*=\s*"(?P[^"]+)"', content, flags=re.M) - if not match: + content = pyproject.read_bytes() + data = tomllib.loads(content.decode("utf-8")) + project = data.get("project") + if not isinstance(project, dict): + raise RuntimeError("unable to detect [project] section from pyproject.toml") + version = project.get("version") + if not isinstance(version, str) or not version: raise RuntimeError("unable to detect version from pyproject.toml") - value = match.group("version") - return value.removesuffix("-dev") + return version.removesuffix("-dev") def _resolve_version(args: argparse.Namespace, root: Path) -> str: @@ -71,18 +75,18 @@ def _resolve_tag(args: argparse.Namespace, version: str) -> str: return f"v{version}" -def _create_release_branches(root: Path, version: str) -> None: +def _create_release_branches( + root: Path, version: str, selected_projects: list[Path] +) -> None: branch = f"release/{version}" run_checked(["git", "checkout", "-B", branch], cwd=root) - for project in discover_projects(root): - run_checked(["git", "checkout", "-B", branch], cwd=project.path) - for extra in ("algar-oud-mig", "gruponos-meltano-native"): - project_root = root / extra - if project_root.exists(): - run_checked(["git", "checkout", "-B", branch], cwd=project_root) + for project_path in selected_projects: + run_checked(["git", "checkout", "-B", branch], cwd=project_path) -def _phase_version(root: Path, version: str, dry_run: bool) -> None: +def _phase_version( + root: Path, version: str, dry_run: bool, project_names: list[str] +) -> None: command = [ "python", "scripts/release/version.py", @@ -92,6 +96,8 @@ def _phase_version(root: Path, version: str, dry_run: bool) -> None: version, "--check" if dry_run else "--apply", ] + if project_names: + command.extend(["--projects", *project_names]) run_checked(command, cwd=root) @@ -99,43 +105,48 @@ def _phase_validate(root: Path) -> None: run_checked(["make", "validate", "VALIDATE_SCOPE=workspace"], cwd=root) -def _phase_build(root: Path, version: str) -> None: +def _phase_build(root: Path, version: str, project_names: list[str]) -> None: output = root / ".reports" / "release" / f"v{version}" - run_checked( - [ - "python", - "scripts/release/build.py", - "--root", - str(root), - "--version", - version, - "--output-dir", - str(output), - ], - cwd=root, - ) + command = [ + "python", + "scripts/release/build.py", + "--root", + str(root), + "--version", + version, + "--output-dir", + str(output), + ] + if project_names: + command.extend(["--projects", *project_names]) + run_checked(command, cwd=root) def _phase_publish( - root: Path, version: str, tag: str, push: bool, dry_run: bool + root: Path, + version: str, + tag: str, + push: bool, + dry_run: bool, + project_names: list[str], ) -> None: notes = root / ".reports" / "release" / tag / "RELEASE_NOTES.md" notes.parent.mkdir(parents=True, exist_ok=True) - run_checked( - [ - "python", - "scripts/release/notes.py", - "--root", - str(root), - "--tag", - tag, - "--version", - version, - "--output", - str(notes), - ], - cwd=root, - ) + command = [ + "python", + "scripts/release/notes.py", + "--root", + str(root), + "--tag", + tag, + "--version", + version, + "--output", + str(notes), + ] + if project_names: + command.extend(["--projects", *project_names]) + run_checked(command, cwd=root) if not dry_run: run_checked( [ @@ -164,6 +175,9 @@ def _phase_publish( def main() -> int: args = _parse_args() root = workspace_root(args.root) + selected_projects = resolve_projects(root, args.projects) + selected_project_names = [project.name for project in selected_projects] + selected_project_paths = [project.path for project in selected_projects] version = _resolve_version(args, root) tag = _resolve_tag(args, version) phases = ( @@ -175,22 +189,30 @@ def main() -> int: _ = print(f"release_version={version}") _ = print(f"release_tag={tag}") _ = print(f"phases={','.join(phases)}") + _ = print(f"projects={','.join(selected_project_names)}") if args.create_branches == 1 and args.dry_run == 0: - _create_release_branches(root, version) + _create_release_branches(root, version, selected_project_paths) for phase in phases: if phase == "validate": _phase_validate(root) continue if phase == "version": - _phase_version(root, version, args.dry_run == 1) + _phase_version(root, version, args.dry_run == 1, selected_project_names) continue if phase == "build": - _phase_build(root, version) + _phase_build(root, version, selected_project_names) continue if phase == "publish": - _phase_publish(root, version, tag, args.push == 1, args.dry_run == 1) + _phase_publish( + root, + version, + tag, + args.push == 1, + args.dry_run == 1, + selected_project_names, + ) continue raise RuntimeError(f"invalid phase: {phase}") diff --git a/scripts/release/shared.py b/scripts/release/shared.py index 0598b719..5c1d5c81 100644 --- a/scripts/release/shared.py +++ b/scripts/release/shared.py @@ -4,6 +4,7 @@ import json import re +import shlex import subprocess import sys from dataclasses import dataclass @@ -54,6 +55,21 @@ def discover_projects(root: Path) -> list[Project]: return sorted(projects, key=lambda project: project.name) +def resolve_projects(root: Path, names: list[str]) -> list[Project]: + projects = discover_projects(root) + if not names: + return projects + + by_name = {project.name: project for project in projects} + missing = [name for name in names if name not in by_name] + if missing: + missing_text = ", ".join(sorted(missing)) + raise RuntimeError(f"unknown release projects: {missing_text}") + + resolved = [by_name[name] for name in names] + return sorted(resolved, key=lambda project: project.name) + + def parse_semver(version: str) -> tuple[int, int, int]: match = SEMVER_RE.match(version) if not match: @@ -79,7 +95,7 @@ def bump_version(current_version: str, bump: str) -> str: def run_checked(command: list[str], cwd: Path | None = None) -> None: result = subprocess.run(command, cwd=cwd, check=False) if result.returncode != 0: - cmd = " ".join(command) + cmd = shlex.join(command) raise RuntimeError(f"command failed ({result.returncode}): {cmd}") @@ -88,7 +104,7 @@ def run_capture(command: list[str], cwd: Path | None = None) -> str: command, cwd=cwd, capture_output=True, text=True, check=False ) if result.returncode != 0: - cmd = " ".join(command) + cmd = shlex.join(command) detail = (result.stderr or result.stdout).strip() raise RuntimeError(f"command failed ({result.returncode}): {cmd}: {detail}") return result.stdout.strip() diff --git a/scripts/release/version.py b/scripts/release/version.py index 48f49775..3851de0f 100644 --- a/scripts/release/version.py +++ b/scripts/release/version.py @@ -3,49 +3,50 @@ import argparse from pathlib import Path +import re import sys SCRIPTS_ROOT = Path(__file__).resolve().parents[1] if str(SCRIPTS_ROOT) not in sys.path: sys.path.insert(0, str(SCRIPTS_ROOT)) -from release.shared import discover_projects, parse_semver, workspace_root +from release.shared import parse_semver, resolve_projects, workspace_root def _replace_version(content: str, version: str) -> tuple[str, bool]: - old = 'version = "0.10.0-dev"' - new = f'version = "{version}"' - if old in content: - return content.replace(old, new), True - - marker = 'version = "' - start = content.find(marker) - if start < 0: + project_match = re.search(r"(?ms)^\[project\]\n(?P.*?)(?:^\[|\Z)", content) + if not project_match: return content, False - value_start = start + len(marker) - value_end = content.find('"', value_start) - if value_end < 0: + + body = project_match.group("body") + version_match = re.search(r'(?m)^version\s*=\s*"(?P[^"]+)"\s*$', body) + if not version_match: return content, False - current = content[value_start:value_end] + current = version_match.group("value") current_clean = current.removesuffix("-dev") _ = parse_semver(current_clean) if current == version: return content, False - updated = content[:value_start] + version + content[value_end:] - return updated, True + + replacement = f'version = "{version}"' + updated_body = re.sub( + r'(?m)^version\s*=\s*"[^"]+"\s*$', + replacement, + body, + count=1, + ) + start, end = project_match.span("body") + updated = content[:start] + updated_body + content[end:] + return updated, updated != content -def _version_files(root: Path) -> list[Path]: +def _version_files(root: Path, project_names: list[str]) -> list[Path]: files: list[Path] = [root / "pyproject.toml"] - for project in discover_projects(root): + for project in resolve_projects(root, project_names): pyproject = project.path / "pyproject.toml" if pyproject.exists(): files.append(pyproject) - for extra in ("algar-oud-mig", "gruponos-meltano-native"): - pyproject = root / extra / "pyproject.toml" - if pyproject.exists(): - files.append(pyproject) dedup = sorted({path.resolve() for path in files}) return dedup @@ -54,6 +55,7 @@ def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() _ = parser.add_argument("--root", type=Path, default=Path(".")) _ = parser.add_argument("--version", required=True) + _ = parser.add_argument("--projects", nargs="*", default=[]) _ = parser.add_argument("--apply", action="store_true") _ = parser.add_argument("--check", action="store_true") return parser.parse_args() @@ -65,7 +67,7 @@ def main() -> int: _ = parse_semver(args.version) changed = 0 - for file_path in _version_files(root): + for file_path in _version_files(root, args.projects): content = file_path.read_text(encoding="utf-8") updated, did_change = _replace_version(content, args.version) if did_change: diff --git a/tests/conftest.py b/tests/conftest.py index 41c418d5..e0ad8a50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,28 @@ """ from __future__ import annotations +# PYTHON_VERSION_GUARD — Do not remove. Managed by scripts/maintenance/enforce_python_version.py +import sys as _sys + +if _sys.version_info[:2] != (3, 13): + _v = f"{_sys.version_info.major}.{_sys.version_info.minor}.{_sys.version_info.micro}" + raise RuntimeError( + f"\n{'=' * 72}\n" + f"FATAL: Python {_v} detected — this project requires Python 3.13.\n" + f"\n" + f"The virtual environment was created with the WRONG Python interpreter.\n" + f"\n" + f"Fix:\n" + f" 1. rm -rf .venv\n" + f" 2. poetry env use python3.13\n" + f" 3. poetry install\n" + f"\n" + f"Or use the workspace Makefile:\n" + f" make setup PROJECT=\n" + f"{'=' * 72}\n" + ) +del _sys +# PYTHON_VERSION_GUARD_END # ============================================================================ # TEST CONSTANTS - Available in all tests via TestsCliConstants (c) From 82ccd5dfa4bba93651ec6eba9525e372ea068a0f Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 13:00:49 -0300 Subject: [PATCH 2/4] chore(workspace): add centralized make pr management --- scripts/github/pr_manager.py | 199 ++++++++++++++++++ scripts/maintenance/enforce_python_version.py | 12 +- 2 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 scripts/github/pr_manager.py diff --git a/scripts/github/pr_manager.py b/scripts/github/pr_manager.py new file mode 100644 index 00000000..f3a2e061 --- /dev/null +++ b/scripts/github/pr_manager.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + + +def _run_capture(command: list[str], cwd: Path) -> str: + result = subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise RuntimeError( + f"command failed ({result.returncode}): {' '.join(command)}: {detail}" + ) + return result.stdout.strip() + + +def _run_stream(command: list[str], cwd: Path) -> int: + result = subprocess.run(command, cwd=cwd, check=False) + return result.returncode + + +def _current_branch(repo_root: Path) -> str: + return _run_capture(["git", "rev-parse", "--abbrev-ref", "HEAD"], repo_root) + + +def _open_pr_for_head(repo_root: Path, head: str) -> dict[str, object] | None: + raw = _run_capture( + [ + "gh", + "pr", + "list", + "--state", + "open", + "--head", + head, + "--json", + "number,title,state,baseRefName,headRefName,url,isDraft", + "--limit", + "1", + ], + repo_root, + ) + payload = json.loads(raw) + if not payload: + return None + first = payload[0] + if not isinstance(first, dict): + return None + return first + + +def _print_status(repo_root: Path, base: str, head: str) -> int: + pr = _open_pr_for_head(repo_root, head) + print(f"repo={repo_root}") + print(f"base={base}") + print(f"head={head}") + if pr is None: + print("status=no-open-pr") + return 0 + print("status=open") + print(f"pr_number={pr.get('number')}") + print(f"pr_title={pr.get('title')}") + print(f"pr_url={pr.get('url')}") + print(f"pr_state={pr.get('state')}") + print(f"pr_draft={pr.get('isDraft')}") + return 0 + + +def _selector(pr_number: str, head: str) -> str: + return pr_number if pr_number else head + + +def _create_pr( + repo_root: Path, + base: str, + head: str, + title: str, + body: str, + draft: int, +) -> int: + existing = _open_pr_for_head(repo_root, head) + if existing is not None: + print(f"status=already-open") + print(f"pr_url={existing.get('url')}") + return 0 + + command = [ + "gh", + "pr", + "create", + "--base", + base, + "--head", + head, + "--title", + title, + "--body", + body, + ] + if draft == 1: + command.append("--draft") + + created = _run_capture(command, repo_root) + print("status=created") + print(f"pr_url={created}") + return 0 + + +def _merge_pr( + repo_root: Path, + selector: str, + method: str, + auto: int, + delete_branch: int, +) -> int: + command = ["gh", "pr", "merge", selector] + merge_flag = { + "merge": "--merge", + "rebase": "--rebase", + "squash": "--squash", + }.get(method, "--squash") + command.append(merge_flag) + if auto == 1: + command.append("--auto") + if delete_branch == 1: + command.append("--delete-branch") + exit_code = _run_stream(command, repo_root) + if exit_code == 0: + print("status=merged") + return exit_code + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + _ = parser.add_argument("--repo-root", type=Path, default=Path(".")) + _ = parser.add_argument( + "--action", + default="status", + choices=["status", "create", "view", "checks", "merge", "close"], + ) + _ = parser.add_argument("--base", default="main") + _ = parser.add_argument("--head", default="") + _ = parser.add_argument("--number", default="") + _ = parser.add_argument("--title", default="") + _ = parser.add_argument("--body", default="") + _ = parser.add_argument("--draft", type=int, default=0) + _ = parser.add_argument("--merge-method", default="squash") + _ = parser.add_argument("--auto", type=int, default=0) + _ = parser.add_argument("--delete-branch", type=int, default=0) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + repo_root = args.repo_root.resolve() + head = args.head or _current_branch(repo_root) + base = args.base + selector = _selector(args.number, head) + + if args.action == "status": + return _print_status(repo_root, base, head) + + if args.action == "create": + title = args.title or f"chore: sync {head}" + body = args.body or "Automated PR managed by scripts/github/pr_manager.py" + return _create_pr(repo_root, base, head, title, body, args.draft) + + if args.action == "view": + return _run_stream(["gh", "pr", "view", selector], repo_root) + + if args.action == "checks": + return _run_stream(["gh", "pr", "checks", selector], repo_root) + + if args.action == "merge": + return _merge_pr( + repo_root, + selector, + args.merge_method, + args.auto, + args.delete_branch, + ) + + if args.action == "close": + return _run_stream(["gh", "pr", "close", selector], repo_root) + + raise RuntimeError(f"unknown action: {args.action}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/maintenance/enforce_python_version.py b/scripts/maintenance/enforce_python_version.py index 3db5aa99..9606966d 100644 --- a/scripts/maintenance/enforce_python_version.py +++ b/scripts/maintenance/enforce_python_version.py @@ -91,7 +91,7 @@ def _ensure_python_version_file( if verbose: print(f" + .python-version CREATED: {project.name}") - pv_file.write_text(PYTHON_VERSION_CONTENT, encoding="utf-8") + _ = pv_file.write_text(PYTHON_VERSION_CONTENT, encoding="utf-8") return True @@ -192,7 +192,7 @@ def _ensure_conftest_guard(project: Path, *, check_only: bool, verbose: bool) -> return False new_content = _inject_guard(content) - conftest.write_text(new_content, encoding="utf-8") + _ = conftest.write_text(new_content, encoding="utf-8") if verbose: print(f" + conftest.py guard INJECTED: {project.name}") return True @@ -201,8 +201,12 @@ def _ensure_conftest_guard(project: Path, *, check_only: bool, verbose: bool) -> def main(argv: list[str] | None = None) -> int: """Run enforcement.""" parser = argparse.ArgumentParser(description="Enforce Python version constraints") - parser.add_argument("--check", action="store_true", help="Check mode (no writes)") - parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + _ = parser.add_argument( + "--check", action="store_true", help="Check mode (no writes)" + ) + _ = parser.add_argument( + "--verbose", "-v", action="store_true", help="Verbose output" + ) args = parser.parse_args(argv) projects = _discover_projects(ROOT) From 79727351fc048a65c28ca135c01ade185dcaf955 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 13:23:26 -0300 Subject: [PATCH 3/4] chore: checkpoint pending 0.11.0-dev changes --- scripts/maintenance/_discover.py | 45 ++---------------- scripts/release/shared.py | 82 ++++++++------------------------ 2 files changed, 25 insertions(+), 102 deletions(-) diff --git a/scripts/maintenance/_discover.py b/scripts/maintenance/_discover.py index 07fa9429..0b117449 100644 --- a/scripts/maintenance/_discover.py +++ b/scripts/maintenance/_discover.py @@ -4,49 +4,14 @@ import argparse import json -import re import sys -from dataclasses import dataclass from pathlib import Path +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) -@dataclass(frozen=True) -class ProjectInfo: - path: Path - name: str - kind: str - - -def _is_git_project(path: Path) -> bool: - return (path / ".git").exists() - - -def _submodule_names(workspace_root: Path) -> set[str]: - gitmodules = workspace_root / ".gitmodules" - if not gitmodules.exists(): - return set() - try: - content = gitmodules.read_text(encoding="utf-8") - except OSError: - return set() - return set(re.findall(r"^\s*path\s*=\s*(.+?)\s*$", content, re.MULTILINE)) - - -def _discover(workspace_root: Path) -> list[ProjectInfo]: - projects: list[ProjectInfo] = [] - submodules = _submodule_names(workspace_root) - for entry in sorted(workspace_root.iterdir(), key=lambda value: value.name): - if not entry.is_dir() or entry.name == "cmd" or entry.name.startswith("."): - continue - if not _is_git_project(entry): - continue - if not (entry / "Makefile").exists(): - continue - if not (entry / "pyproject.toml").exists() and not (entry / "go.mod").exists(): - continue - kind = "submodule" if entry.name in submodules else "external" - projects.append(ProjectInfo(path=entry, name=entry.name, kind=kind)) - return projects +from libs.discovery import discover_projects def main() -> int: @@ -60,7 +25,7 @@ def main() -> int: _ = parser.add_argument("--workspace-root", type=Path, default=Path.cwd()) args = parser.parse_args() - projects = _discover(args.workspace_root.resolve()) + projects = discover_projects(args.workspace_root.resolve()) if args.kind != "all": projects = [p for p in projects if p.kind == args.kind] diff --git a/scripts/release/shared.py b/scripts/release/shared.py index 5c1d5c81..a1abb23c 100644 --- a/scripts/release/shared.py +++ b/scripts/release/shared.py @@ -2,72 +2,40 @@ # Owner-Skill: .claude/skills/scripts-maintenance/SKILL.md from __future__ import annotations -import json import re -import shlex -import subprocess import sys -from dataclasses import dataclass from pathlib import Path +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from libs.discovery import ProjectInfo +from libs.paths import workspace_root as _workspace_root +from libs.selection import resolve_projects as _resolve_projects +from libs.subprocess import run_capture as _run_capture +from libs.subprocess import run_checked as _run_checked + SEMVER_RE = re.compile( r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)$" ) -@dataclass(frozen=True) -class Project: - name: str - path: Path +Project = ProjectInfo def workspace_root(path: str | Path = ".") -> Path: - return Path(path).resolve() - - -def discover_projects(root: Path) -> list[Project]: - discover = root / "scripts" / "maintenance" / "_discover.py" - command = [ - sys.executable, - str(discover), - "--workspace-root", - str(root), - "--kind", - "all", - "--format", - "json", - ] - result = subprocess.run(command, capture_output=True, text=True, check=False) - if result.returncode != 0: - msg = (result.stderr or result.stdout).strip() - raise RuntimeError(f"project discovery failed: {msg}") - payload = json.loads(result.stdout) - projects: list[Project] = [] - for item in payload.get("projects", []): - if not isinstance(item, dict): - continue - name = item.get("name") - path_value = item.get("path") - if not isinstance(name, str) or not isinstance(path_value, str): - continue - projects.append(Project(name=name, path=Path(path_value).resolve())) - return sorted(projects, key=lambda project: project.name) + return _workspace_root(path) def resolve_projects(root: Path, names: list[str]) -> list[Project]: - projects = discover_projects(root) - if not names: - return projects - - by_name = {project.name: project for project in projects} - missing = [name for name in names if name not in by_name] - if missing: - missing_text = ", ".join(sorted(missing)) - raise RuntimeError(f"unknown release projects: {missing_text}") - - resolved = [by_name[name] for name in names] - return sorted(resolved, key=lambda project: project.name) + try: + return _resolve_projects(root, names) + except RuntimeError as exc: + raise RuntimeError( + str(exc).replace("unknown projects", "unknown release projects") + ) from exc def parse_semver(version: str) -> tuple[int, int, int]: @@ -93,18 +61,8 @@ def bump_version(current_version: str, bump: str) -> str: def run_checked(command: list[str], cwd: Path | None = None) -> None: - result = subprocess.run(command, cwd=cwd, check=False) - if result.returncode != 0: - cmd = shlex.join(command) - raise RuntimeError(f"command failed ({result.returncode}): {cmd}") + _run_checked(command, cwd=cwd) def run_capture(command: list[str], cwd: Path | None = None) -> str: - result = subprocess.run( - command, cwd=cwd, capture_output=True, text=True, check=False - ) - if result.returncode != 0: - cmd = shlex.join(command) - detail = (result.stderr or result.stdout).strip() - raise RuntimeError(f"command failed ({result.returncode}): {cmd}: {detail}") - return result.stdout.strip() + return _run_capture(command, cwd=cwd) From a07e51d8f7a791a204af4db53e30ee4376ecda42 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 13:37:40 -0300 Subject: [PATCH 4/4] chore: checkpoint pending 0.11.0-dev changes --- scripts/core/skill_validate.py | 49 +++++-------------- scripts/core/stub_supply_chain.py | 18 ++++--- scripts/dependencies/dependency_detection.py | 39 ++++++++------- scripts/github/pr_manager.py | 7 ++- scripts/github/sync_workflows.py | 36 ++++---------- scripts/maintenance/enforce_python_version.py | 18 ++++--- 6 files changed, 68 insertions(+), 99 deletions(-) diff --git a/scripts/core/skill_validate.py b/scripts/core/skill_validate.py index a603630e..661d6488 100644 --- a/scripts/core/skill_validate.py +++ b/scripts/core/skill_validate.py @@ -12,6 +12,11 @@ import time from pathlib import Path +if str(Path(__file__).resolve().parents[2]) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from libs.discovery import discover_projects as ssot_discover_projects + try: import yaml # type: ignore[import-untyped] except ImportError: @@ -198,43 +203,13 @@ def discover_projects(root: Path) -> dict[str, object]: "root": "."} """ - gitmodules = root / ".gitmodules" - gitmodules_text = "" - if gitmodules.exists(): - try: - gitmodules_text = gitmodules.read_text(encoding="utf-8") - except OSError as exc: - msg = f"Cannot read {gitmodules}" - raise SkillInfraError(msg) from exc - - flext_projects: list[str] = [] - for line in gitmodules_text.splitlines(): - stripped = line.strip() - if "path = flext-" not in stripped: - continue - name = stripped.split("path = ", 1)[-1].strip() - if not name: - continue - if (root / name / "pyproject.toml").is_file(): - flext_projects.append(name) - - external_projects: list[str] = [] - for child in sorted(root.iterdir(), key=lambda p: p.name): - if not child.is_dir(): - continue - name = child.name - pyproject = child / "pyproject.toml" - if not pyproject.is_file(): - continue - if f"path = {name}" in gitmodules_text: - continue - try: - pyproject_text = pyproject.read_text(encoding="utf-8") - except OSError as exc: - msg = f"Cannot read {pyproject}" - raise SkillInfraError(msg) from exc - if "flext-core" in pyproject_text or "flext_core" in pyproject_text: - external_projects.append(name) + discovered = ssot_discover_projects(root) + flext_projects = [ + project.name for project in discovered if project.kind == "submodule" + ] + external_projects = [ + project.name for project in discovered if project.kind == "external" + ] return { "flext": unique_sorted(flext_projects), diff --git a/scripts/core/stub_supply_chain.py b/scripts/core/stub_supply_chain.py index 1fd9024e..ba32a048 100644 --- a/scripts/core/stub_supply_chain.py +++ b/scripts/core/stub_supply_chain.py @@ -16,6 +16,11 @@ from dataclasses import dataclass from pathlib import Path +if str(Path(__file__).resolve().parents[2]) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from libs.selection import resolve_projects + MISSING_IMPORT_RE = re.compile(r"Cannot find module `([^`]+)` \[missing-import\]") MYPY_HINT_RE = re.compile(r'note: Hint: "python3 -m pip install ([^"]+)"') MYPY_STUB_RE = re.compile(r'Library stubs not installed for "([^"]+)"') @@ -65,13 +70,12 @@ def run_cmd( def discover_projects(root: Path) -> list[Path]: - projects: list[Path] = [] - for entry in sorted(root.iterdir()): - if not entry.is_dir(): - continue - if (entry / "pyproject.toml").exists() and (entry / "src").is_dir(): - projects.append(entry) - return projects + return [ + project.path + for project in resolve_projects(root, names=[]) + if (project.path / "pyproject.toml").exists() + and (project.path / "src").is_dir() + ] def load_pyproject(project_dir: Path) -> dict[str, object]: diff --git a/scripts/dependencies/dependency_detection.py b/scripts/dependencies/dependency_detection.py index 8d932fa2..a31bbf6e 100644 --- a/scripts/dependencies/dependency_detection.py +++ b/scripts/dependencies/dependency_detection.py @@ -12,12 +12,19 @@ from __future__ import annotations import json +import os import re import subprocess +import sys import tomllib from pathlib import Path from typing import Any +if str(Path(__file__).resolve().parents[2]) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from libs.selection import resolve_projects + # Mypy output patterns for typing library detection (aligned with stub_supply_chain) MYPY_HINT_RE = re.compile(r'note: Hint: "python3 -m pip install ([^"]+)"') MYPY_STUB_RE = re.compile(r'Library stubs not installed for "([^"]+)"') @@ -62,22 +69,16 @@ def discover_projects( workspace_root: Path, projects_filter: list[str] | None = None, ) -> list[Path]: - """Discover all Python projects under workspace (top-level dirs with pyproject.toml). - - Matches root Makefile / sync_dependencies: any dir with pyproject.toml, excluding SKIP_DIRS. - """ - projects: list[Path] = [] - for item in sorted(workspace_root.iterdir()): - if not item.is_dir(): - continue - if any(skip in item.name for skip in SKIP_DIRS): - continue - if not (item / "pyproject.toml").exists(): - continue - if projects_filter is not None and item.name not in projects_filter: - continue - projects.append(item) - return projects + projects = [ + project.path + for project in resolve_projects(workspace_root, names=[]) + if (project.path / "pyproject.toml").exists() + and not any(skip in project.name for skip in SKIP_DIRS) + ] + if projects_filter is not None: + filter_set = set(projects_filter) + projects = [path for path in projects if path.name in filter_set] + return sorted(projects) def run_deptry( @@ -146,7 +147,7 @@ def run_pip_check(workspace_root: Path, venv_bin: Path) -> tuple[list[str], int] capture_output=True, text=True, timeout=60, - env={**subprocess.os.environ, "VIRTUAL_ENV": str(venv_bin.parent)}, + env={**os.environ, "VIRTUAL_ENV": str(venv_bin.parent)}, ) out = (result.stdout or "").strip().splitlines() if result.stdout else [] return out, result.returncode @@ -219,9 +220,9 @@ def run_mypy_stub_hints( "--no-error-summary", ] env = { - **subprocess.os.environ, + **os.environ, "VIRTUAL_ENV": str(venv_bin.parent), - "PATH": f"{venv_bin}:{subprocess.os.environ.get('PATH', '')}", + "PATH": f"{venv_bin}:{os.environ.get('PATH', '')}", } result = subprocess.run( cmd, diff --git a/scripts/github/pr_manager.py b/scripts/github/pr_manager.py index f3a2e061..d1cb7f7b 100644 --- a/scripts/github/pr_manager.py +++ b/scripts/github/pr_manager.py @@ -156,6 +156,7 @@ def _parse_args() -> argparse.Namespace: _ = parser.add_argument("--merge-method", default="squash") _ = parser.add_argument("--auto", type=int, default=0) _ = parser.add_argument("--delete-branch", type=int, default=0) + _ = parser.add_argument("--checks-strict", type=int, default=0) return parser.parse_args() @@ -178,7 +179,11 @@ def main() -> int: return _run_stream(["gh", "pr", "view", selector], repo_root) if args.action == "checks": - return _run_stream(["gh", "pr", "checks", selector], repo_root) + exit_code = _run_stream(["gh", "pr", "checks", selector], repo_root) + if exit_code != 0 and args.checks_strict == 0: + print("status=checks-nonblocking") + return 0 + return exit_code if args.action == "merge": return _merge_pr( diff --git a/scripts/github/sync_workflows.py b/scripts/github/sync_workflows.py index b6ba7684..7bf1dcb7 100644 --- a/scripts/github/sync_workflows.py +++ b/scripts/github/sync_workflows.py @@ -7,7 +7,12 @@ import sys from dataclasses import dataclass from pathlib import Path -from subprocess import CalledProcessError, run + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from libs.selection import resolve_projects GENERATED_HEADER = "# Generated by scripts/github/sync_workflows.py - DO NOT EDIT\n" MANAGED_FILES = {"ci.yml"} @@ -22,33 +27,10 @@ class Operation: def _discover_projects(workspace_root: Path) -> list[tuple[str, Path]]: - discover_script = workspace_root / "scripts" / "maintenance" / "_discover.py" - command = [ - sys.executable, - str(discover_script), - "--workspace-root", - str(workspace_root), - "--kind", - "all", - "--format", - "json", + return [ + (project.name, project.path) + for project in resolve_projects(workspace_root, names=[]) ] - try: - result = run(command, check=True, capture_output=True, text=True) - except CalledProcessError as exc: - message = (exc.stderr or exc.stdout or str(exc)).strip() - raise RuntimeError(f"project discovery failed: {message}") from exc - payload = json.loads(result.stdout) - projects: list[tuple[str, Path]] = [] - for item in payload.get("projects", []): - if not isinstance(item, dict): - continue - name = item.get("name") - path_value = item.get("path") - if not isinstance(name, str) or not isinstance(path_value, str): - continue - projects.append((name, Path(path_value).resolve())) - return projects def _render_template(template_path: Path) -> str: diff --git a/scripts/maintenance/enforce_python_version.py b/scripts/maintenance/enforce_python_version.py index 9606966d..6de692b7 100644 --- a/scripts/maintenance/enforce_python_version.py +++ b/scripts/maintenance/enforce_python_version.py @@ -21,6 +21,11 @@ import sys from pathlib import Path +if str(Path(__file__).resolve().parents[2]) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from libs.selection import resolve_projects + ROOT = Path(__file__).resolve().parents[2] REQUIRED_MINOR = 13 PYTHON_VERSION_CONTENT = f"3.{REQUIRED_MINOR}\n" @@ -56,14 +61,11 @@ def _discover_projects(workspace_root: Path) -> list[Path]: - """Discover all flext-* projects with pyproject.toml.""" - projects: list[Path] = [] - for entry in sorted(workspace_root.iterdir(), key=lambda v: v.name): - if not entry.is_dir() or not entry.name.startswith("flext-"): - continue - if (entry / "pyproject.toml").exists(): - projects.append(entry) - return projects + return [ + project.path + for project in resolve_projects(workspace_root, names=[]) + if (project.path / "pyproject.toml").exists() + ] def _ensure_python_version_file(