diff --git a/.github/actions/bump-shared-workflow-version/action.yml b/.github/actions/bump-shared-workflow-version/action.yml new file mode 100644 index 0000000..0743b23 --- /dev/null +++ b/.github/actions/bump-shared-workflow-version/action.yml @@ -0,0 +1,22 @@ +name: Bump shared workflow embedder version +description: Bump the embedder pip install version in update-fragments-shared.yml, commit and push. +inputs: + version: + description: Release version without v prefix. + required: true +runs: + using: composite + steps: + - name: Bump and commit + shell: bash + run: | + set -euo pipefail + file=.github/workflows/update-fragments-shared.yml + if ! grep -q 'install-embedder@v' "$file" 2>/dev/null; then + exit 0 + fi + sed -i "s|/install-embedder@v[^'\"]*|/install-embedder@v${{ inputs.version }}|" "$file" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "$file" + git diff --cached --quiet || git commit -m "chore: bump shared workflow to ${{ inputs.version }}" diff --git a/.github/actions/install-embedder/action.yml b/.github/actions/install-embedder/action.yml new file mode 100644 index 0000000..03fc10b --- /dev/null +++ b/.github/actions/install-embedder/action.yml @@ -0,0 +1,8 @@ +name: Install embedder +description: Install embedder from this repository's source tree. +runs: + using: composite + steps: + - name: Install + shell: bash + run: pip install -e "${{ github.action_path }}/../../.." diff --git a/.github/actions/open-fragments-pr/action.yml b/.github/actions/open-fragments-pr/action.yml new file mode 100644 index 0000000..f547c64 --- /dev/null +++ b/.github/actions/open-fragments-pr/action.yml @@ -0,0 +1,30 @@ +name: Open fragments PR +description: "Commit fragment updates and open a pull request." +inputs: + branch: + description: Branch name for the PR. + required: false + default: embedder/update-fragments +runs: + using: composite + steps: + - name: Commit and open pull request + shell: bash + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ inputs.branch }} + run: | + stat=$(git diff --stat) + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add -A + git commit -m "chore: update embedded fragments" + git push origin "$BRANCH" --force + existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty') + if [ -z "$existing" ]; then + gh pr create \ + --title "chore: update embedded fragments" \ + --body "$stat" \ + --head "$BRANCH" + fi diff --git a/.github/actions/refresh-local-fragments/action.yml b/.github/actions/refresh-local-fragments/action.yml new file mode 100644 index 0000000..8cc727f --- /dev/null +++ b/.github/actions/refresh-local-fragments/action.yml @@ -0,0 +1,18 @@ +name: Refresh local fragments +description: "Re-embed all local: fragment refs in the workspace without bumping external dependencies." +inputs: + paths: + description: Files or directories to scan. Defaults to the current directory. + required: false + default: "." +runs: + using: composite + steps: + - name: Refresh local fragments + shell: bash + env: + EMBEDDER_PATHS: ${{ inputs.paths }} + run: | + pip install -e "${{ github.action_path }}/../../.." + read -ra paths <<< "$EMBEDDER_PATHS" + embedder update "${paths[@]}" --local-only diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..76e2b73 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# embedder local:fragments/dependabot.yaml +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly +# /embedder diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 66bd173..af10e16 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,11 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: rubykatzen/baseline/.github/actions/lint-yamllint@v0.5.2 - - uses: rubykatzen/baseline/.github/actions/lint-ruff@v0.5.2 - - uses: rubykatzen/baseline/.github/actions/lint-actionlint@v0.5.2 - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - run: pip install -e . pytest - - run: pytest + - uses: rubykatzen/baseline/.github/actions/setup-runtimes@v0.6.2 + - uses: rubykatzen/baseline/.github/actions/lint-yamllint@v0.6.2 + - uses: rubykatzen/baseline/.github/actions/lint-ruff@v0.6.2 + - uses: rubykatzen/baseline/.github/actions/lint-actionlint@v0.6.2 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 8aaa5bd..82a12d0 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -39,6 +39,10 @@ jobs: - uses: rubykatzen/releaser/.github/actions/bump-pyproject-version@v0.3.4 with: version: ${{ inputs.version }} + - uses: ./.github/actions/bump-shared-workflow-version + with: + version: ${{ inputs.version }} + - uses: ./.github/actions/refresh-local-fragments - uses: rubykatzen/releaser/.github/actions/push-release-branch@v0.3.4 with: version: ${{ inputs.version }} diff --git a/.github/workflows/spec.yml b/.github/workflows/spec.yml new file mode 100644 index 0000000..f4c3a3c --- /dev/null +++ b/.github/workflows/spec.yml @@ -0,0 +1,16 @@ +name: Spec +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: +jobs: + spec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - run: pip install -e . pytest + - run: pytest diff --git a/.github/workflows/update-fragments-shared.yml b/.github/workflows/update-fragments-shared.yml index 145d537..5ee64be 100644 --- a/.github/workflows/update-fragments-shared.yml +++ b/.github/workflows/update-fragments-shared.yml @@ -19,18 +19,14 @@ jobs: pull-requests: write steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Install embedder - run: pip install "git+https://github.com/rubykatzen/embedder.git@v0.0.5" + - uses: rubykatzen/embedder/.github/actions/install-embedder@v0.0.6 - name: Run embedder update env: GH_TOKEN: ${{ secrets.token || github.token }} EMBEDDER_PATHS: ${{ inputs.paths }} run: | read -ra paths <<< "$EMBEDDER_PATHS" - embedder update "${paths[@]}" --json > /tmp/embedder-result.json + embedder update "${paths[@]}" - name: Detect changes id: diff run: | @@ -41,26 +37,4 @@ jobs: fi - name: Open pull request if: steps.diff.outputs.changed == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - branch="embedder/update-fragments" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -b "$branch" - git add -A - git commit -m "chore: update embedded fragments" - git push origin "$branch" --force - body=$(jq -r ' - "## Embedded fragment updates\n\n| File | Source | Asset | Old | New |\n|---|---|---|---:|---:|\n" + - ([.changed[] | .changed_blocks[] | - "| \(.block.path) | \(.block.ref.owner)/\(.block.ref.repo) | \(.block.ref.asset) | \(.block.ref.tag) | \(.latest_tag) |" - ] | join("\n")) - ' /tmp/embedder-result.json) - existing=$(gh pr list --head "$branch" --json number --jq '.[0].number // empty') - if [ -z "$existing" ]; then - gh pr create \ - --title "chore: update embedded fragments" \ - --body "$body" \ - --head "$branch" - fi + uses: ./.github/actions/open-fragments-pr diff --git a/.github/workflows/update-fragments.yml b/.github/workflows/update-fragments.yml new file mode 100644 index 0000000..a4f72bb --- /dev/null +++ b/.github/workflows/update-fragments.yml @@ -0,0 +1,30 @@ +name: Update embedded fragments +on: + schedule: + - cron: '0 8 * * *' # 10:00 Berlin (CEST, UTC+2) + workflow_dispatch: +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + - name: Run embedder update + env: + GH_TOKEN: ${{ github.token }} + run: | + pip install -e . + embedder update . + - name: Detect changes + id: diff + run: | + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + - name: Open pull request + if: steps.diff.outputs.changed == 'true' + uses: ./.github/actions/open-fragments-pr diff --git a/fragments/dependabot.yaml b/fragments/dependabot.yaml new file mode 100644 index 0000000..563cc5c --- /dev/null +++ b/fragments/dependabot.yaml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/src/embedder/blocks.py b/src/embedder/blocks.py index 943d441..49697d8 100644 --- a/src/embedder/blocks.py +++ b/src/embedder/blocks.py @@ -1,15 +1,13 @@ from __future__ import annotations import os -import re from dataclasses import dataclass from pathlib import Path -from embedder.refs import GitHubAssetRef, RefError, parse_ref +from embedder.errors import EmbedderEnvironmentError, EmbedderError, RefError +from embedder.formats import get_format +from embedder.providers import DEFAULT_PROVIDERS, Provider, get_provider -OPEN_MARKER_RE = re.compile(r"^\s*\s*$") -CLOSE_MARKER_RE = re.compile(r"^\s*\s*$") -FENCE_RE = re.compile(r"^\s*(?P`{3,}|~{3,})") SKIP_DIRS = { ".git", ".hg", @@ -24,68 +22,60 @@ } -class EmbedderError(Exception): - pass - - -class EmbedderEnvironmentError(EmbedderError): - pass - - @dataclass(frozen=True) class EmbedderBlock: path: str start_line: int end_line: int - ref: GitHubAssetRef + ref: object # AnyRef — typed as object to avoid circular import body: str @dataclass(frozen=True) class BlockUpdate: block: EmbedderBlock - new_ref: GitHubAssetRef + new_ref: object # AnyRef new_body: str -def parse_blocks(path: Path, text: str) -> list[EmbedderBlock]: +def parse_blocks( + path: Path, + text: str, + providers: list[Provider] | None = None, +) -> list[EmbedderBlock]: + _providers = providers if providers is not None else DEFAULT_PROVIDERS + fmt = get_format(path) + if fmt is None: + return [] + + scanner = fmt.make_scanner() lines = text.splitlines(keepends=True) blocks: list[EmbedderBlock] = [] active_start: int | None = None - active_ref: GitHubAssetRef | None = None + active_ref = None body_start = 0 - active_fence: str | None = None - markdown = path.suffix.lower() in {".md", ".markdown"} for index, line in enumerate(lines): - if markdown and active_start is None: - fence_match = FENCE_RE.match(line) - if fence_match: - fence = fence_match["fence"] - marker = fence[0] - if active_fence is None: - active_fence = marker - elif active_fence == marker: - active_fence = None - continue - if active_fence is not None: - continue - - open_match = OPEN_MARKER_RE.match(line) + if active_start is None and scanner.advance(line): + continue + + open_match = fmt.open_re.match(line) if open_match: if active_start is not None: raise EmbedderError( f"{path}:{index + 1}: nested embedder block is not allowed" ) try: - active_ref = parse_ref(open_match["ref"]) + active_ref = get_provider(open_match["ref"], _providers).parse_ref( + open_match["ref"] + ) except RefError as error: raise EmbedderError(f"{path}:{index + 1}: {error}") from error active_start = index body_start = index + 1 continue - if CLOSE_MARKER_RE.match(line): + if fmt.close_re.match(line): if active_start is None or active_ref is None: raise EmbedderError( f"{path}:{index + 1}: closing embedder marker without opening marker" @@ -132,7 +122,10 @@ def iter_files(paths: list[Path]) -> list[Path]: return sorted(files) -def scan_paths(paths: list[Path]) -> list[EmbedderBlock]: +def scan_paths( + paths: list[Path], + providers: list[Provider] | None = None, +) -> list[EmbedderBlock]: blocks: list[EmbedderBlock] = [] for path in iter_files(paths): if not is_probably_text(path): @@ -143,14 +136,18 @@ def scan_paths(paths: list[Path]) -> list[EmbedderBlock]: continue except OSError as error: raise EmbedderError(f"Could not read {path}: {error}") from error - blocks.extend(parse_blocks(path, text)) + blocks.extend(parse_blocks(path, text, providers)) return blocks -def apply_updates(text: str, updates: list[BlockUpdate]) -> str: +def apply_updates(path: Path, text: str, updates: list[BlockUpdate]) -> str: if not updates: return text + fmt = get_format(path) + if fmt is None: + return text + lines = text.splitlines(keepends=True) output: list[str] = [] cursor = 0 @@ -167,12 +164,29 @@ def apply_updates(text: str, updates: list[BlockUpdate]) -> str: replacement_body = update.new_body if replacement_body and not replacement_body.endswith("\n"): replacement_body += "\n" + if indent and replacement_body: + replacement_body = "".join( + indent + line if line.rstrip("\n") else line + for line in replacement_body.splitlines(keepends=True) + ) output.extend(lines[cursor:start]) - output.append(f"{indent}{newline}") + output.append(f"{indent}{fmt.render_open(update.new_ref.render())}{newline}") output.append(replacement_body) output.append(lines[end]) cursor = end + 1 output.extend(lines[cursor:]) return "".join(output) + + +__all__ = [ + "BlockUpdate", + "EmbedderBlock", + "EmbedderEnvironmentError", + "EmbedderError", + "apply_updates", + "iter_files", + "parse_blocks", + "scan_paths", +] diff --git a/src/embedder/cli.py b/src/embedder/cli.py index 5e26dc2..cd3c396 100644 --- a/src/embedder/cli.py +++ b/src/embedder/cli.py @@ -3,19 +3,15 @@ import argparse import json import sys -from dataclasses import asdict +from dataclasses import fields, is_dataclass from enum import IntEnum from pathlib import Path from typing import Any from embedder import __version__ -from embedder.blocks import ( - EmbedderBlock, - EmbedderEnvironmentError, - EmbedderError, - scan_paths, -) -from embedder.github import GitHubClient +from embedder.blocks import EmbedderBlock, scan_paths +from embedder.errors import EmbedderEnvironmentError, EmbedderError +from embedder.providers.github import GitHubProvider from embedder.updater import check_blocks, update_files @@ -28,8 +24,8 @@ class ExitCode(IntEnum): def to_json(data: Any) -> None: def default(value: Any) -> Any: - if hasattr(value, "__dataclass_fields__"): - return asdict(value) + if is_dataclass(value) and not isinstance(value, type): + return {f.name: getattr(value, f.name) for f in fields(value)} if isinstance(value, Path): return str(value) raise TypeError(f"Object is not JSON serializable: {value!r}") @@ -48,26 +44,18 @@ def command_scan(args: argparse.Namespace) -> int: return int(ExitCode.OK) for block in blocks: - ref = block.ref - print( - f"{block.path}:{block.start_line} " - f"{ref.owner}/{ref.repo}@{ref.tag}:{ref.asset}" - ) + print(f"{block.path}:{block.start_line} {block.ref.render()}") return int(ExitCode.OK) -def print_block(block: EmbedderBlock, *, latest_tag: str | None = None) -> None: - ref = block.ref - suffix = "" if latest_tag is None else f" -> {latest_tag}" - print( - f"{block.path}:{block.start_line} " - f"{ref.owner}/{ref.repo}@{ref.tag}:{ref.asset}{suffix}" - ) +def _print_block(block: EmbedderBlock, *, latest_ref: Any = None) -> None: + suffix = "" if latest_ref is None else f" -> {latest_ref.render()}" + print(f"{block.path}:{block.start_line} {block.ref.render()}{suffix}") def command_check(args: argparse.Namespace) -> int: blocks = scan_paths([Path(path) for path in args.paths]) - results = check_blocks(blocks, GitHubClient()) + results = check_blocks(blocks) updates = [result for result in results if result.update_available] if args.json: @@ -82,12 +70,12 @@ def command_check(args: argparse.Namespace) -> int: return int(ExitCode.OK) for result in updates: - print_block(result.block, latest_tag=result.latest_tag) + _print_block(result.block, latest_ref=result.latest_ref) return int(ExitCode.UPDATES_AVAILABLE) def command_update(args: argparse.Namespace) -> int: - changed = update_files([Path(path) for path in args.paths], GitHubClient()) + changed = update_files([Path(path) for path in args.paths], local_only=args.local_only) if args.json: to_json({"changed": changed}) return int(ExitCode.OK) @@ -96,12 +84,12 @@ def command_update(args: argparse.Namespace) -> int: return int(ExitCode.OK) for file_update in changed: for result in file_update.changed_blocks: - print_block(result.block, latest_tag=result.latest_tag) + _print_block(result.block, latest_ref=result.latest_ref) return int(ExitCode.OK) def command_doctor(args: argparse.Namespace) -> int: - github = GitHubClient() + github = GitHubProvider() gh_available = github.available() checks = { "gh": "ok" if gh_available else "missing", @@ -153,6 +141,11 @@ def parser() -> argparse.ArgumentParser: help="Files or directories to update. Defaults to the current directory.", ) update.add_argument("--json", action="store_true", help="Print machine-readable JSON output.") + update.add_argument( + "--local-only", + action="store_true", + help="Refresh local fragments only; skip external provider resolution.", + ) update.set_defaults(func=command_update) doctor = subparsers.add_parser("doctor", help="Check local embedder prerequisites.") diff --git a/src/embedder/errors.py b/src/embedder/errors.py new file mode 100644 index 0000000..fd790d3 --- /dev/null +++ b/src/embedder/errors.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +class EmbedderError(Exception): + pass + + +class EmbedderEnvironmentError(EmbedderError): + pass + + +class RefError(ValueError): + pass diff --git a/src/embedder/formats/__init__.py b/src/embedder/formats/__init__.py new file mode 100644 index 0000000..e6eb1e2 --- /dev/null +++ b/src/embedder/formats/__init__.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Protocol, runtime_checkable + +from embedder.formats.markdown import MarkdownFormat +from embedder.formats.yaml import YamlFormat + + +@runtime_checkable +class LineScanner(Protocol): + def advance(self, line: str) -> bool: ... + + +@runtime_checkable +class Format(Protocol): + @property + def open_re(self) -> re.Pattern[str]: ... + + @property + def close_re(self) -> re.Pattern[str]: ... + + def matches(self, path: Path) -> bool: ... + + def make_scanner(self) -> LineScanner: ... + + def render_open(self, ref_str: str) -> str: ... + + def render_close(self) -> str: ... + + +_FORMATS: list[Format] = [ + MarkdownFormat(), + YamlFormat(), +] + + +def get_format(path: Path) -> Format | None: + for fmt in _FORMATS: + if fmt.matches(path): + return fmt + return None diff --git a/src/embedder/formats/markdown.py b/src/embedder/formats/markdown.py new file mode 100644 index 0000000..660c642 --- /dev/null +++ b/src/embedder/formats/markdown.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import re +from pathlib import Path + +OPEN_RE = re.compile(r"^\s*\s*$") +CLOSE_RE = re.compile(r"^\s*\s*$") +FENCE_RE = re.compile(r"^\s*(?P`{3,}|~{3,})") + + +class _MarkdownScanner: + def __init__(self) -> None: + self._fence: str | None = None + + def advance(self, line: str) -> bool: + m = FENCE_RE.match(line) + if m: + marker = m["fence"][0] + if self._fence is None: + self._fence = marker + elif self._fence == marker: + self._fence = None + return True + return self._fence is not None + + +class MarkdownFormat: + @property + def open_re(self) -> re.Pattern[str]: + return OPEN_RE + + @property + def close_re(self) -> re.Pattern[str]: + return CLOSE_RE + + def matches(self, path: Path) -> bool: + return path.suffix.lower() in {".md", ".markdown"} + + def make_scanner(self) -> _MarkdownScanner: + return _MarkdownScanner() + + def render_open(self, ref_str: str) -> str: + return f"" + + def render_close(self) -> str: + return "" diff --git a/src/embedder/formats/yaml.py b/src/embedder/formats/yaml.py new file mode 100644 index 0000000..aaffeeb --- /dev/null +++ b/src/embedder/formats/yaml.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import re +from pathlib import Path + +OPEN_RE = re.compile(r"^\s*#\s+embedder\s+(?P\S+)\s*$") +CLOSE_RE = re.compile(r"^\s*#\s+/embedder\s*$") + +# Matches YAML block scalar headers in mapping values (key: |) and sequence items (- |). +# Per YAML spec, after | or > comes an optional indentation indicator (digit), +# optional chomp indicator (+/-), and an optional trailing comment. +_BLOCK_SCALAR_RE = re.compile( + r"^(?P\s*)(?:\S.*:\s*|-\s*)[|>][0-9]?[+-]?[0-9]?(?:\s+#.*)?\s*$" +) + + +class _YamlScanner: + """Skips lines inside YAML block scalars to prevent false-positive marker detection.""" + + def __init__(self) -> None: + self._block_indent: int | None = None + + def advance(self, line: str) -> bool: + stripped = line.rstrip("\n") + + if self._block_indent is not None: + if not stripped: + # Blank lines are valid inside a block scalar. + return True + if not stripped[0].isspace(): + self._block_indent = None + elif len(stripped) - len(stripped.lstrip()) > self._block_indent: + return True + else: + self._block_indent = None + + m = _BLOCK_SCALAR_RE.match(stripped) + if m: + self._block_indent = len(m["indent"]) + + return False + + +class YamlFormat: + @property + def open_re(self) -> re.Pattern[str]: + return OPEN_RE + + @property + def close_re(self) -> re.Pattern[str]: + return CLOSE_RE + + def matches(self, path: Path) -> bool: + return path.suffix.lower() in {".yml", ".yaml"} + + def make_scanner(self) -> _YamlScanner: + return _YamlScanner() + + def render_open(self, ref_str: str) -> str: + return f"# embedder {ref_str}" + + def render_close(self) -> str: + return "# /embedder" diff --git a/src/embedder/github.py b/src/embedder/github.py deleted file mode 100644 index debc26a..0000000 --- a/src/embedder/github.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import shutil -import subprocess -import tempfile -from dataclasses import dataclass -from pathlib import Path - -from embedder.blocks import EmbedderEnvironmentError, EmbedderError -from embedder.refs import GitHubAssetRef - - -@dataclass(frozen=True) -class CommandResult: - returncode: int - stdout: str - stderr: str - - -class GitHubClient: - def available(self) -> bool: - return shutil.which("gh") is not None - - def require(self) -> None: - if not self.available(): - raise EmbedderEnvironmentError("Required executable is missing: gh") - - def run(self, args: list[str], *, check: bool = True) -> CommandResult: - self.require() - proc = subprocess.run( - ["gh", *args], - text=True, - capture_output=True, - check=False, - ) - if check and proc.returncode != 0: - detail = proc.stderr.strip() or proc.stdout.strip() - raise EmbedderError(f"Command failed: gh {' '.join(args)}\n{detail}") - return CommandResult( - returncode=proc.returncode, - stdout=proc.stdout.strip(), - stderr=proc.stderr.strip(), - ) - - def auth_ok(self) -> bool: - return self.run(["auth", "status"], check=False).returncode == 0 - - def latest_tag(self, ref: GitHubAssetRef) -> str: - result = self.run( - [ - "release", - "view", - "--repo", - ref.repository, - "--json", - "tagName", - "--jq", - ".tagName", - ] - ) - if not result.stdout or result.stdout == "null": - raise EmbedderError(f"Could not resolve latest release for {ref.repository}") - return result.stdout - - def download_asset(self, ref: GitHubAssetRef) -> str: - with tempfile.TemporaryDirectory(prefix="embedder-") as tmpdir: - self.run( - [ - "release", - "download", - ref.tag, - "--repo", - ref.repository, - "--pattern", - ref.asset, - "--dir", - tmpdir, - ] - ) - path = Path(tmpdir) / ref.asset - if not path.is_file(): - raise EmbedderError(f"Release asset not found: {ref.render()}") - return path.read_text(encoding="utf-8") diff --git a/src/embedder/providers/__init__.py b/src/embedder/providers/__init__.py new file mode 100644 index 0000000..a7a7711 --- /dev/null +++ b/src/embedder/providers/__init__.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Protocol, runtime_checkable + +from embedder.errors import RefError +from embedder.providers.github import GitHubAssetRef, GitHubProvider +from embedder.providers.local import LocalProvider, LocalRef + +AnyRef = GitHubAssetRef | LocalRef + + +@runtime_checkable +class Provider(Protocol): + def matches(self, raw: str) -> bool: ... + + def parse_ref(self, raw: str) -> AnyRef: ... + + def resolve(self, ref: AnyRef) -> AnyRef: ... + + def resolve_cached(self, ref: AnyRef, cached: AnyRef) -> AnyRef: ... + + def always_refresh(self, ref: AnyRef) -> bool: ... + + def fetch(self, ref: AnyRef, base_dir: Path) -> str: ... + + def cache_key(self, ref: AnyRef) -> str | None: ... + + +DEFAULT_PROVIDERS: list[Provider] = [GitHubProvider(), LocalProvider()] + + +def get_provider(raw: str, providers: list[Provider] | None = None) -> Provider: + _providers = providers if providers is not None else DEFAULT_PROVIDERS + for provider in _providers: + if provider.matches(raw): + return provider + raise RefError(f"Unknown ref scheme: {raw!r}") + + +def parse_ref(raw: str) -> AnyRef: + return get_provider(raw).parse_ref(raw) diff --git a/src/embedder/providers/github.py b/src/embedder/providers/github.py new file mode 100644 index 0000000..ab01eac --- /dev/null +++ b/src/embedder/providers/github.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from embedder.errors import EmbedderEnvironmentError, EmbedderError, RefError + +_REF_RE = re.compile( + r"^github\.com/" + r"(?P[A-Za-z0-9_.-]+)/" + r"(?P[A-Za-z0-9_.-]+)" + r"@(?P[^:\s]+)" + r":(?P[^\s]+)$" +) + + +@dataclass(frozen=True) +class GitHubAssetRef: + owner: str + repo: str + tag: str + asset: str + + @property + def repository(self) -> str: + return f"{self.owner}/{self.repo}" + + def with_tag(self, tag: str) -> GitHubAssetRef: + return GitHubAssetRef(owner=self.owner, repo=self.repo, tag=tag, asset=self.asset) + + def render(self) -> str: + return f"github.com/{self.repository}@{self.tag}:{self.asset}" + + +def parse_github_ref(raw: str) -> GitHubAssetRef: + match = _REF_RE.match(raw.strip()) + if not match: + raise RefError(f"Invalid embedder ref: {raw}") + asset = match["asset"] + if "/" in asset or "\\" in asset: + raise RefError(f"Release asset must be a basename: {asset}") + return GitHubAssetRef( + owner=match["owner"], + repo=match["repo"], + tag=match["tag"], + asset=asset, + ) + + +@dataclass(frozen=True) +class CommandResult: + returncode: int + stdout: str + stderr: str + + +class GitHubProvider: + def matches(self, raw: str) -> bool: + return raw.startswith("github.com/") + + def parse_ref(self, raw: str) -> GitHubAssetRef: + return parse_github_ref(raw) + + def resolve(self, ref: GitHubAssetRef) -> GitHubAssetRef: + return ref.with_tag(self._latest_tag(ref)) + + def resolve_cached(self, ref: GitHubAssetRef, cached: GitHubAssetRef) -> GitHubAssetRef: + return ref.with_tag(cached.tag) + + def always_refresh(self, ref: GitHubAssetRef) -> bool: + return False + + def fetch(self, ref: GitHubAssetRef, base_dir: Path) -> str: + return self._download_asset(ref) + + def cache_key(self, ref: GitHubAssetRef) -> str: + return ref.repository + + def available(self) -> bool: + return shutil.which("gh") is not None + + def require(self) -> None: + if not self.available(): + raise EmbedderEnvironmentError("Required executable is missing: gh") + + def run(self, args: list[str], *, check: bool = True) -> CommandResult: + self.require() + proc = subprocess.run( + ["gh", *args], + text=True, + capture_output=True, + check=False, + ) + if check and proc.returncode != 0: + detail = proc.stderr.strip() or proc.stdout.strip() + raise EmbedderError(f"Command failed: gh {' '.join(args)}\n{detail}") + return CommandResult( + returncode=proc.returncode, + stdout=proc.stdout.strip(), + stderr=proc.stderr.strip(), + ) + + def auth_ok(self) -> bool: + return self.run(["auth", "status"], check=False).returncode == 0 + + def _latest_tag(self, ref: GitHubAssetRef) -> str: + result = self.run( + ["release", "view", "--repo", ref.repository, "--json", "tagName", "--jq", ".tagName"] + ) + if not result.stdout or result.stdout == "null": + raise EmbedderError(f"Could not resolve latest release for {ref.repository}") + return result.stdout + + def _download_asset(self, ref: GitHubAssetRef) -> str: + with tempfile.TemporaryDirectory(prefix="embedder-") as tmpdir: + self.run( + [ + "release", + "download", + ref.tag, + "--repo", + ref.repository, + "--pattern", + ref.asset, + "--dir", + tmpdir, + ] + ) + path = Path(tmpdir) / ref.asset + if not path.is_file(): + raise EmbedderError(f"Release asset not found: {ref.render()}") + return path.read_text(encoding="utf-8") diff --git a/src/embedder/providers/local.py b/src/embedder/providers/local.py new file mode 100644 index 0000000..fe73da0 --- /dev/null +++ b/src/embedder/providers/local.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from embedder.errors import EmbedderError, RefError + + +@dataclass(frozen=True) +class LocalRef: + path: str + + def render(self) -> str: + return f"local:{self.path}" + + +class LocalProvider: + def matches(self, raw: str) -> bool: + return raw.startswith("local:") + + def parse_ref(self, raw: str) -> LocalRef: + path = raw[len("local:"):] + if not path: + raise RefError("local: ref must specify a path") + return LocalRef(path=path) + + def resolve(self, ref: LocalRef) -> LocalRef: + return ref + + def resolve_cached(self, ref: LocalRef, cached: LocalRef) -> LocalRef: + return ref + + def always_refresh(self, ref: LocalRef) -> bool: + return True + + def fetch(self, ref: LocalRef, base_dir: Path) -> str: + resolved_base = base_dir.resolve() + target = (base_dir / ref.path).resolve() + if not target.is_relative_to(resolved_base): + raise EmbedderError( + f"Local fragment path escapes base directory: {ref.path!r}" + ) + if not target.is_file(): + raise EmbedderError(f"Local fragment not found: {target}") + return target.read_text(encoding="utf-8") + + def cache_key(self, ref: LocalRef) -> str | None: + return None diff --git a/src/embedder/refs.py b/src/embedder/refs.py deleted file mode 100644 index 52b3ff5..0000000 --- a/src/embedder/refs.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass - -REF_RE = re.compile( - r"^github\.com/" - r"(?P[A-Za-z0-9_.-]+)/" - r"(?P[A-Za-z0-9_.-]+)" - r"@(?P[^:\s]+)" - r":(?P[^\s]+)$" -) - - -class RefError(ValueError): - pass - - -@dataclass(frozen=True) -class GitHubAssetRef: - owner: str - repo: str - tag: str - asset: str - - @property - def repository(self) -> str: - return f"{self.owner}/{self.repo}" - - def with_tag(self, tag: str) -> GitHubAssetRef: - return GitHubAssetRef( - owner=self.owner, - repo=self.repo, - tag=tag, - asset=self.asset, - ) - - def render(self) -> str: - return f"github.com/{self.repository}@{self.tag}:{self.asset}" - - -def parse_ref(raw: str) -> GitHubAssetRef: - match = REF_RE.match(raw.strip()) - if not match: - raise RefError(f"Invalid embedder ref: {raw}") - asset = match["asset"] - if "/" in asset or "\\" in asset: - raise RefError(f"Release asset must be a basename: {asset}") - return GitHubAssetRef( - owner=match["owner"], - repo=match["repo"], - tag=match["tag"], - asset=asset, - ) diff --git a/src/embedder/updater.py b/src/embedder/updater.py index ef41d11..0f1eb63 100644 --- a/src/embedder/updater.py +++ b/src/embedder/updater.py @@ -10,17 +10,17 @@ iter_files, parse_blocks, ) -from embedder.github import GitHubClient +from embedder.providers import DEFAULT_PROVIDERS, AnyRef, Provider, get_provider @dataclass(frozen=True) class CheckResult: block: EmbedderBlock - latest_tag: str + latest_ref: AnyRef @property def update_available(self) -> bool: - return self.block.ref.tag != self.latest_tag + return self.block.ref != self.latest_ref @dataclass(frozen=True) @@ -29,21 +29,39 @@ class FileUpdate: changed_blocks: list[CheckResult] -def check_blocks(blocks: list[EmbedderBlock], github: GitHubClient) -> list[CheckResult]: - latest_by_repository: dict[str, str] = {} +def check_blocks( + blocks: list[EmbedderBlock], + providers: list[Provider] | None = None, +) -> list[CheckResult]: + _providers = providers if providers is not None else DEFAULT_PROVIDERS + resolved_cache: dict[str, AnyRef] = {} results: list[CheckResult] = [] for block in blocks: - latest = latest_by_repository.get(block.ref.repository) - if latest is None: - latest = github.latest_tag(block.ref) - latest_by_repository[block.ref.repository] = latest - results.append(CheckResult(block=block, latest_tag=latest)) + provider = get_provider(block.ref.render(), _providers) + key = provider.cache_key(block.ref) + if key is not None: + if key in resolved_cache: + latest = provider.resolve_cached(block.ref, resolved_cache[key]) + else: + latest = provider.resolve(block.ref) + resolved_cache[key] = latest + else: + latest = provider.resolve(block.ref) + results.append(CheckResult(block=block, latest_ref=latest)) return results -def update_files(paths: list[Path], github: GitHubClient) -> list[FileUpdate]: +def update_files( + paths: list[Path], + providers: list[Provider] | None = None, + *, + local_only: bool = False, + base_dir: Path | None = None, +) -> list[FileUpdate]: + _providers = providers if providers is not None else DEFAULT_PROVIDERS + _base_dir = base_dir if base_dir is not None else Path.cwd() changed: list[FileUpdate] = [] for path in iter_files(paths): @@ -51,27 +69,34 @@ def update_files(paths: list[Path], github: GitHubClient) -> list[FileUpdate]: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: continue - blocks = parse_blocks(path, text) + blocks = parse_blocks(path, text, _providers) if not blocks: continue - checks = check_blocks(blocks, github) + checks = check_blocks(blocks, _providers) if not local_only else [ + CheckResult(block=block, latest_ref=block.ref) for block in blocks + ] updates: list[BlockUpdate] = [] changed_checks: list[CheckResult] = [] + for check in checks: - if not check.update_available: + provider = get_provider(check.block.ref.render(), _providers) + if local_only and not provider.always_refresh(check.block.ref): + continue + if not check.update_available and not provider.always_refresh(check.block.ref): + continue + new_body = provider.fetch(check.latest_ref, _base_dir) + if new_body == check.block.body and not check.update_available: continue - new_ref = check.block.ref.with_tag(check.latest_tag) - new_body = github.download_asset(new_ref) updates.append( - BlockUpdate(block=check.block, new_ref=new_ref, new_body=new_body) + BlockUpdate(block=check.block, new_ref=check.latest_ref, new_body=new_body) ) changed_checks.append(check) if not updates: continue - new_text = apply_updates(text, updates) + new_text = apply_updates(path, text, updates) if new_text == text: continue path.write_text(new_text, encoding="utf-8") diff --git a/tests/helpers.py b/tests/helpers.py index 38d4632..5beb442 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -4,3 +4,11 @@ def marker(ref: str) -> str: def close_marker() -> str: return "" + + +def yaml_marker(ref: str) -> str: + return f"# embedder {ref}" + + +def yaml_close_marker() -> str: + return "# /embedder" diff --git a/tests/test_blocks.py b/tests/test_blocks.py index b82e07f..e765ff5 100644 --- a/tests/test_blocks.py +++ b/tests/test_blocks.py @@ -2,8 +2,9 @@ import pytest -from embedder.blocks import EmbedderError, parse_blocks -from tests.helpers import close_marker, marker +from embedder.blocks import BlockUpdate, apply_updates, parse_blocks +from embedder.errors import EmbedderError +from tests.helpers import close_marker, marker, yaml_close_marker, yaml_marker def test_parse_single_block() -> None: @@ -93,3 +94,193 @@ def test_ignore_markers_inside_markdown_code_fence() -> None: ) assert parse_blocks(Path("README.md"), text) == [] + + +def test_parse_yaml_block() -> None: + text = "\n".join( + [ + "before: value", + yaml_marker("local:fragments/config.yaml"), + "key: managed", + yaml_close_marker(), + "after: value", + "", + ] + ) + + blocks = parse_blocks(Path("config.yaml"), text) + + assert len(blocks) == 1 + block = blocks[0] + assert block.ref.render() == "local:fragments/config.yaml" + assert block.body == "key: managed\n" + + +def test_yaml_ignores_markers_in_block_scalar() -> None: + text = "\n".join( + [ + "description: |", + f" {yaml_marker('local:file.yaml')}", + " some content", + f" {yaml_close_marker()}", + "other: value", + "", + ] + ) + + assert parse_blocks(Path("config.yaml"), text) == [] + + +def test_yaml_ignores_markers_after_blank_line_in_block_scalar() -> None: + text = "\n".join( + [ + "description: |", + " first paragraph", + "", + f" {yaml_marker('local:file.yaml')}", + " second paragraph", + f" {yaml_close_marker()}", + "other: value", + "", + ] + ) + + assert parse_blocks(Path("config.yaml"), text) == [] + + +def test_yaml_ignores_markers_in_block_scalar_with_indent_indicator() -> None: + text = "\n".join( + [ + "description: |2", + f" {yaml_marker('local:file.yaml')}", + "other: value", + "", + ] + ) + + assert parse_blocks(Path("config.yaml"), text) == [] + + +def test_yaml_ignores_markers_in_block_scalar_with_trailing_comment() -> None: + text = "\n".join( + [ + "description: | # inline comment", + f" {yaml_marker('local:file.yaml')}", + "other: value", + "", + ] + ) + + assert parse_blocks(Path("config.yaml"), text) == [] + + +def test_parse_local_ref_in_markdown() -> None: + text = "\n".join( + [ + marker("local:fragments/file.md"), + "managed content", + close_marker(), + "", + ] + ) + + blocks = parse_blocks(Path("README.md"), text) + + assert len(blocks) == 1 + assert blocks[0].ref.render() == "local:fragments/file.md" + + +def test_yaml_ignores_markers_in_sequence_block_scalar() -> None: + text = "\n".join( + [ + "items:", + " - |", + f" {yaml_marker('local:file.yaml')}", + " some content", + f" {yaml_close_marker()}", + "other: value", + "", + ] + ) + + assert parse_blocks(Path("config.yaml"), text) == [] + + +def test_parse_blocks_uses_injected_providers() -> None: + from dataclasses import dataclass + from pathlib import Path as _Path + + @dataclass(frozen=True) + class FakeRef: + name: str + + def render(self) -> str: + return f"fake:{self.name}" + + class FakeProvider: + def matches(self, raw: str) -> bool: + return raw.startswith("fake:") + + def parse_ref(self, raw: str) -> FakeRef: + return FakeRef(name=raw[len("fake:"):]) + + def resolve(self, ref: FakeRef) -> FakeRef: + return ref + + def resolve_cached(self, ref: FakeRef, cached: FakeRef) -> FakeRef: + return ref + + def always_refresh(self, ref: FakeRef) -> bool: + return False + + def fetch(self, ref: FakeRef, base_dir: _Path) -> str: + return "" + + def cache_key(self, ref: FakeRef) -> str | None: + return None + + text = "\n".join( + [ + marker("fake:thing"), + "body", + close_marker(), + "", + ] + ) + + with pytest.raises(EmbedderError): + parse_blocks(Path("README.md"), text) + + blocks = parse_blocks(Path("README.md"), text, providers=[FakeProvider()]) + assert len(blocks) == 1 + assert blocks[0].ref.render() == "fake:thing" + + +def test_apply_updates_indents_body_to_match_yaml_marker() -> None: + text = "\n".join( + [ + "parent:", + f" {yaml_marker('local:frag.yaml')}", + " old: value", + f" {yaml_close_marker()}", + "", + ] + ) + path = Path("config.yaml") + block = parse_blocks(path, text)[0] + + updated = apply_updates( + path, + text, + [BlockUpdate(block=block, new_ref=block.ref, new_body="new: value\n")], + ) + + assert updated == "\n".join( + [ + "parent:", + f" {yaml_marker('local:frag.yaml')}", + " new: value", + f" {yaml_close_marker()}", + "", + ] + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 12d23ca..4d6c941 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,7 +23,7 @@ def test_scan_prints_blocks(tmp_path: Path, capsys) -> None: assert main(["scan", str(tmp_path)]) == int(ExitCode.OK) captured = capsys.readouterr() - assert "AGENTS.md:3 rubykatzen/embedder@v0.1.0:fragment.md" in captured.out + assert "AGENTS.md:3 github.com/rubykatzen/embedder@v0.1.0:fragment.md" in captured.out def test_scan_json(tmp_path: Path, capsys) -> None: diff --git a/tests/test_refs.py b/tests/test_refs.py index ca645a8..6a6464d 100644 --- a/tests/test_refs.py +++ b/tests/test_refs.py @@ -1,10 +1,13 @@ import pytest -from embedder.refs import RefError, parse_ref +from embedder.errors import RefError +from embedder.providers import parse_ref +from embedder.providers.github import GitHubAssetRef, parse_github_ref +from embedder.providers.local import LocalRef -def test_parse_ref() -> None: - ref = parse_ref("github.com/OWNER/repo-name@v0.3.0:agent-message-prefix.md") +def test_parse_github_ref() -> None: + ref = parse_github_ref("github.com/OWNER/repo-name@v0.3.0:agent-message-prefix.md") assert ref.owner == "OWNER" assert ref.repo == "repo-name" @@ -14,17 +17,36 @@ def test_parse_ref() -> None: assert ref.render() == "github.com/OWNER/repo-name@v0.3.0:agent-message-prefix.md" -def test_ref_with_new_tag() -> None: - ref = parse_ref("github.com/rubykatzen/embedder@v0.1.0:fragment.md") +def test_github_ref_with_new_tag() -> None: + ref = parse_github_ref("github.com/rubykatzen/embedder@v0.1.0:fragment.md") assert ref.with_tag("v0.2.0").render() == "github.com/rubykatzen/embedder@v0.2.0:fragment.md" -def test_reject_invalid_ref() -> None: +def test_reject_invalid_github_ref() -> None: with pytest.raises(RefError): - parse_ref("rubykatzen/embedder@v0.1.0:fragment.md") + parse_github_ref("rubykatzen/embedder@v0.1.0:fragment.md") def test_reject_nested_asset_path() -> None: with pytest.raises(RefError, match="basename"): - parse_ref("github.com/rubykatzen/embedder@v0.1.0:fragments/fragment.md") + parse_github_ref("github.com/rubykatzen/embedder@v0.1.0:fragments/fragment.md") + + +def test_parse_ref_dispatches_github() -> None: + ref = parse_ref("github.com/rubykatzen/embedder@v0.1.0:fragment.md") + + assert isinstance(ref, GitHubAssetRef) + + +def test_parse_ref_dispatches_local() -> None: + ref = parse_ref("local:fragments/file.md") + + assert isinstance(ref, LocalRef) + assert ref.path == "fragments/file.md" + assert ref.render() == "local:fragments/file.md" + + +def test_parse_ref_rejects_unknown_scheme() -> None: + with pytest.raises(RefError, match="Unknown ref scheme"): + parse_ref("s3://bucket/file.md") diff --git a/tests/test_updater.py b/tests/test_updater.py index 8f2091e..2800529 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -1,27 +1,50 @@ from pathlib import Path +import pytest + from embedder.blocks import BlockUpdate, parse_blocks +from embedder.errors import EmbedderError +from embedder.providers import Provider +from embedder.providers.github import GitHubAssetRef, parse_github_ref +from embedder.providers.local import LocalProvider, LocalRef from embedder.updater import check_blocks, update_files from tests.helpers import close_marker, marker -class FakeGitHub: +class FakeGitHubProvider: def __init__(self) -> None: - self.latest = { - "rubykatzen/embedder": "v0.2.0", - } - self.assets = { + self.latest: dict[str, str] = {"rubykatzen/embedder": "v0.2.0"} + self.assets: dict[str, str] = { "github.com/rubykatzen/embedder@v0.2.0:fragment.md": "new managed text\n", } - self.latest_calls = 0 + self.resolve_calls = 0 + + def matches(self, raw: str) -> bool: + return raw.startswith("github.com/") + + def parse_ref(self, raw: str) -> GitHubAssetRef: + return parse_github_ref(raw) + + def resolve(self, ref: GitHubAssetRef) -> GitHubAssetRef: + self.resolve_calls += 1 + return ref.with_tag(self.latest[ref.repository]) + + def resolve_cached(self, ref: GitHubAssetRef, cached: GitHubAssetRef) -> GitHubAssetRef: + return ref.with_tag(cached.tag) - def latest_tag(self, ref) -> str: - self.latest_calls += 1 - return self.latest[ref.repository] + def always_refresh(self, ref: GitHubAssetRef) -> bool: + return False - def download_asset(self, ref) -> str: + def fetch(self, ref: GitHubAssetRef, base_dir: Path) -> str: return self.assets[ref.render()] + def cache_key(self, ref: GitHubAssetRef) -> str: + return ref.repository + + +def fake_providers() -> list[Provider]: + return [FakeGitHubProvider(), LocalProvider()] + def test_check_blocks_marks_updates() -> None: text = "\n".join( @@ -34,10 +57,10 @@ def test_check_blocks_marks_updates() -> None: ) blocks = parse_blocks(Path("AGENTS.md"), text) - results = check_blocks(blocks, FakeGitHub()) + results = check_blocks(blocks, fake_providers()) assert len(results) == 1 - assert results[0].latest_tag == "v0.2.0" + assert results[0].latest_ref.render() == "github.com/rubykatzen/embedder@v0.2.0:fragment.md" assert results[0].update_available @@ -57,7 +80,7 @@ def test_update_files_replaces_only_managed_body(tmp_path: Path) -> None: encoding="utf-8", ) - changed = update_files([tmp_path], FakeGitHub()) + changed = update_files([tmp_path], fake_providers(), base_dir=tmp_path) assert [item.path for item in changed] == [str(target)] assert target.read_text(encoding="utf-8") == "\n".join( @@ -86,7 +109,11 @@ def test_apply_update_keeps_body_trailing_newline() -> None: from embedder.blocks import apply_updates - updated = apply_updates(text, [BlockUpdate(block=block, new_ref=new_ref, new_body="new")]) + updated = apply_updates( + Path("AGENTS.md"), + text, + [BlockUpdate(block=block, new_ref=new_ref, new_body="new")], + ) assert updated == "\n".join( [ @@ -98,7 +125,61 @@ def test_apply_update_keeps_body_trailing_newline() -> None: ) -def test_check_blocks_caches_latest_release_per_repository() -> None: +def test_check_blocks_caches_resolve_per_repository() -> None: + text = "\n".join( + [ + marker("github.com/rubykatzen/embedder@v0.1.0:first.md"), + "old", + close_marker(), + marker("github.com/rubykatzen/embedder@v0.1.0:second.md"), + "old", + close_marker(), + "", + ] + ) + blocks = parse_blocks(Path("AGENTS.md"), text) + provider = FakeGitHubProvider() + + check_blocks(blocks, [provider, LocalProvider()]) + + assert provider.resolve_calls == 1 + + +def test_local_ref_is_always_current(tmp_path: Path) -> None: + fragment = tmp_path / "fragment.md" + fragment.write_text("local content\n", encoding="utf-8") + + target = tmp_path / "README.md" + target.write_text( + "\n".join( + [ + marker("local:fragment.md"), + "old content", + close_marker(), + "", + ] + ), + encoding="utf-8", + ) + + registry = [FakeGitHubProvider(), LocalProvider()] + results = check_blocks(parse_blocks(target, target.read_text(encoding="utf-8")), registry) + + assert not results[0].update_available + + +def test_local_ref_fetch(tmp_path: Path) -> None: + fragment = tmp_path / "frag.md" + fragment.write_text("hello from local\n", encoding="utf-8") + + ref = LocalRef(path="frag.md") + content = LocalProvider().fetch(ref, tmp_path) + + assert content == "hello from local\n" + + +def test_cache_uses_correct_asset_per_block() -> None: + """Two blocks from the same repo must not share each other's asset.""" text = "\n".join( [ marker("github.com/rubykatzen/embedder@v0.1.0:first.md"), @@ -111,8 +192,97 @@ def test_check_blocks_caches_latest_release_per_repository() -> None: ] ) blocks = parse_blocks(Path("AGENTS.md"), text) - github = FakeGitHub() + registry = [FakeGitHubProvider(), LocalProvider()] + + results = check_blocks(blocks, registry) + + assert results[0].latest_ref.render() == "github.com/rubykatzen/embedder@v0.2.0:first.md" + assert results[1].latest_ref.render() == "github.com/rubykatzen/embedder@v0.2.0:second.md" - check_blocks(blocks, github) - assert github.latest_calls == 1 +def test_local_ref_body_refreshed_on_update(tmp_path: Path) -> None: + """update_files re-fetches local refs even when the ref itself hasn't changed.""" + fragment = tmp_path / "frag.md" + fragment.write_text("v1 content\n", encoding="utf-8") + + target = tmp_path / "README.md" + target.write_text( + "\n".join([marker("local:frag.md"), "old content", close_marker(), ""]), + encoding="utf-8", + ) + + fragment.write_text("v2 content\n", encoding="utf-8") + registry = [FakeGitHubProvider(), LocalProvider()] + update_files([tmp_path], registry, base_dir=tmp_path) + + assert "v2 content" in target.read_text(encoding="utf-8") + + +def test_local_ref_path_traversal_rejected(tmp_path: Path) -> None: + ref = LocalRef(path="../../etc/passwd") + with pytest.raises(EmbedderError, match="escapes base directory"): + LocalProvider().fetch(ref, tmp_path) + + +def test_local_only_skips_github_blocks(tmp_path: Path) -> None: + """update_files(local_only=True) refreshes local refs but leaves GitHub refs untouched.""" + fragment = tmp_path / "frag.md" + fragment.write_text("new local\n", encoding="utf-8") + + target = tmp_path / "README.md" + target.write_text( + "\n".join( + [ + marker("github.com/rubykatzen/embedder@v0.1.0:fragment.md"), + "old github", + close_marker(), + marker("local:frag.md"), + "old local", + close_marker(), + "", + ] + ), + encoding="utf-8", + ) + + update_files([tmp_path], fake_providers(), local_only=True, base_dir=tmp_path) + + result = target.read_text(encoding="utf-8") + assert "old github" in result + assert "new local" in result + + +def test_local_only_does_not_call_github_resolve(tmp_path: Path) -> None: + """update_files(local_only=True) makes no resolve() calls to external providers.""" + fragment = tmp_path / "frag.md" + fragment.write_text("content\n", encoding="utf-8") + + target = tmp_path / "README.md" + target.write_text( + "\n".join([marker("local:frag.md"), "old", close_marker(), ""]), + encoding="utf-8", + ) + + provider = FakeGitHubProvider() + update_files([tmp_path], [provider, LocalProvider()], local_only=True, base_dir=tmp_path) + + assert provider.resolve_calls == 0 + + +def test_default_providers_not_mutated_between_calls() -> None: + from embedder.providers import DEFAULT_PROVIDERS + + text = "\n".join( + [ + marker("github.com/rubykatzen/embedder@v0.1.0:fragment.md"), + "old", + close_marker(), + "", + ] + ) + blocks = parse_blocks(Path("AGENTS.md"), text) + + providers_before = list(DEFAULT_PROVIDERS) + check_blocks(blocks, fake_providers()) + check_blocks(blocks, fake_providers()) + assert list(DEFAULT_PROVIDERS) == providers_before