pytest-r-snapshot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ name: CI tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - main
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
17
+ fail-fast: false
18
+
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v6
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ - name: Install dependencies
26
+ run: |
27
+ # we are using the -e flag, so that code cov finds the source.
28
+ # this is not ideal, since installing an editable can technically
29
+ # differ from a normal install in surprising ways.
30
+ pip install -e '.[all]'
31
+ - name: Test with pytest
32
+ run: |
33
+ pip install pytest
34
+ pytest
35
+
36
+ # - name: Upload coverage reports to Codecov
37
+ # uses: codecov/codecov-action@v4
38
+ # with:
39
+ # name: "py${{ matrix.python-version }}"
40
+ # token: ${{ secrets.CODECOV_TOKEN }}
41
+
42
+ test-windows:
43
+ runs-on: windows-latest
44
+ steps:
45
+ - uses: actions/checkout@v6
46
+ - name: Set up Python
47
+ uses: actions/setup-python@v6
48
+ with:
49
+ python-version: "3.14"
50
+ - name: Install dependencies
51
+ run: |
52
+ pip install -e '.[all]'
53
+ - name: Test with pytest
54
+ run: |
55
+ pip install pytest
56
+ pytest
@@ -0,0 +1,34 @@
1
+ name: mkdocs
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ deploy:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v6
16
+ - name: Configure Git Credentials
17
+ run: |
18
+ git config user.name github-actions[bot]
19
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
20
+
21
+ - uses: actions/setup-python@v6
22
+ with:
23
+ python-version: 3.x
24
+ - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
25
+
26
+ - uses: actions/cache@v4
27
+ with:
28
+ key: mkdocs-material-${{ env.cache_id }}
29
+ path: .cache
30
+ restore-keys: |
31
+ mkdocs-material-
32
+ - run: pip install mkdocs-material
33
+ - run: pip install mkdocstrings-python
34
+ - run: mkdocs gh-deploy --force
@@ -0,0 +1,34 @@
1
+ name: Mypy check
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - "**"
10
+
11
+ jobs:
12
+ mypy:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v6
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v6
21
+ with:
22
+ python-version: "3.14"
23
+
24
+ - name: Install uv
25
+ run: |
26
+ pip install uv
27
+
28
+ - name: Install dependencies
29
+ run: |
30
+ uv sync --dev
31
+
32
+ - name: Run Type Checks
33
+ run: |
34
+ uv run mypy .
@@ -0,0 +1,25 @@
1
+ name: Ruff check
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - "**"
10
+
11
+ jobs:
12
+ ruff-check:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v6
16
+ - name: Install Python
17
+ uses: actions/setup-python@v6
18
+ with:
19
+ python-version: "3.14"
20
+ - name: Install dependencies
21
+ run: |
22
+ python -m pip install --upgrade pip
23
+ pip install ruff
24
+ - name: Run Ruff
25
+ run: ruff check --output-format=github .
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # mkdocs site
13
+ /site/
@@ -0,0 +1 @@
1
+ 3.14.2
@@ -0,0 +1,20 @@
1
+ ## pytest-r-snapshot 0.1.0
2
+
3
+ ### New features
4
+
5
+ - Initial release of the `pytest-r-snapshot` pytest plugin for snapshot testing against R code outputs.
6
+ - Extract labelled R fenced chunks from Python source (commented chunks and raw chunks in docstrings/multiline strings).
7
+ - Snapshot modes: `replay` (default), `record`, and `auto`.
8
+ - Default snapshot layout: `__r_snapshots__/<test_file_stem>/<name><ext>` with configurable root via `--r-snapshot-dir` / `r_snapshot_dir`.
9
+ - `r_snapshot` fixture with `assert_match_text()`, `read_text()`, `record_text()`, and `path_for()`.
10
+ - Configurable R execution: `Rscript` path, env overrides, working directory, timeout, and snapshot encoding.
11
+ - Optional marker `@pytest.mark.r_snapshot(...)` and decorator alias `pytest_r_snapshot.r_snapshot(...)`.
12
+ - Built-in text normalizers: newline normalization and stripping trailing whitespace.
13
+
14
+ ### Documentation
15
+
16
+ - Added README and mkdocs articles (setup, usage, configuration, troubleshooting, design, migration).
17
+
18
+ ### Testing
19
+
20
+ - Added a pytester-based test suite with a mock R runner (no real R required).
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2025, pytest-r-snapshot authors
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-r-snapshot
3
+ Version: 0.1.0
4
+ Summary: A pytest plugin for snapshot testing against R code outputs
5
+ Project-URL: Homepage, https://nanx.me/pytest-r-snapshot/
6
+ Project-URL: Documentation, https://nanx.me/pytest-r-snapshot/
7
+ Project-URL: Repository, https://github.com/nanxstats/pytest-r-snapshot
8
+ Project-URL: Issues, https://github.com/nanxstats/pytest-r-snapshot/issues
9
+ Project-URL: Changelog, https://github.com/nanxstats/pytest-r-snapshot/blob/main/CHANGELOG.md
10
+ Author-email: Nan Xiao <me@nanx.me>
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: pytest>=7.0.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pytest-r-snapshot <img src="https://github.com/nanxstats/pytest-r-snapshot/raw/main/docs/assets/logo.png" align="right" width="120" />
28
+
29
+ [![PyPI version](https://img.shields.io/pypi/v/pytest-r-snapshot)](https://pypi.org/project/pytest-r-snapshot/)
30
+ ![Python versions](https://img.shields.io/pypi/pyversions/pytest-r-snapshot)
31
+ [![CI tests](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/ci-tests.yml)
32
+ [![Mypy check](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/mypy.yml/badge.svg)](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/mypy.yml)
33
+ [![Ruff check](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/ruff-check.yml/badge.svg)](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/ruff-check.yml)
34
+ [![mkdocs](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/mkdocs.yml/badge.svg)](https://nanx.me/pytest-r-snapshot/)
35
+ ![License](https://img.shields.io/pypi/l/pytest-r-snapshot)
36
+
37
+ A pytest plugin for snapshot testing against reference outputs produced by R code.
38
+
39
+ It is designed for a portable workflow:
40
+
41
+ - **Record locally (requires R)**: run labelled R chunks embedded in your
42
+ Python tests and write snapshot files.
43
+ - **Replay everywhere (default; no R required)**: read committed snapshot
44
+ files and compare them to Python outputs.
45
+
46
+ The R chunks live close to the test assertion (copy/paste-able from R scripts
47
+ or R Markdown), but snapshots are stored as deterministic UTF-8 text files.
48
+
49
+ ## Installation
50
+
51
+ You can install pytest-r-snapshot from PyPI:
52
+
53
+ ```bash
54
+ pip install pytest-r-snapshot
55
+ ```
56
+
57
+ If your project is managed with `uv`, add it as a dev dependency:
58
+
59
+ ```bash
60
+ uv add --dev pytest-r-snapshot
61
+ ```
62
+
63
+ Or install the development version from GitHub:
64
+
65
+ ```bash
66
+ git clone https://github.com/nanxstats/pytest-r-snapshot.git
67
+ cd pytest-r-snapshot
68
+ python3 -m pip install -e .
69
+ ```
70
+
71
+ ## Quick start
72
+
73
+ Embed a labelled R fenced chunk (commented or in a docstring),
74
+ then compare your Python output to the recorded snapshot:
75
+
76
+ ```python
77
+ def test_summary_matches_r(r_snapshot):
78
+ # ```{r, summary}
79
+ # x <- c(1, 2, 3)
80
+ # summary(x)
81
+ # ```
82
+
83
+ actual = my_python_summary(...)
84
+ r_snapshot.assert_match_text(actual, name="summary")
85
+ ```
86
+
87
+ Generate (or update) snapshots locally:
88
+
89
+ ```bash
90
+ pytest --r-snapshot=record
91
+ ```
92
+
93
+ Commit the generated snapshot files, and run CI with the default
94
+ replay mode (no R required).
95
+
96
+ ## What you get
97
+
98
+ - A pytest fixture `r_snapshot` with methods:
99
+ - `assert_match_text(actual, name=..., ext=".txt", normalize=...)`
100
+ - `read_text(name=..., ext=".txt")`
101
+ - `record_text(name=..., ext=".txt")`
102
+ - `path_for(name=..., ext=".txt")`
103
+ - Snapshot modes: `replay` (default), `record`, `auto`.
104
+ - Chunk extraction for R Markdown-style fenced chunks inside Python source:
105
+ - Commented fences (chunk lines prefixed with `#`).
106
+ - Raw chunks in docstrings / multi-line strings.
107
+ - Configurable R execution: `Rscript` path, environment, working directory,
108
+ timeout, encoding.
@@ -0,0 +1,82 @@
1
+ # pytest-r-snapshot <img src="https://github.com/nanxstats/pytest-r-snapshot/raw/main/docs/assets/logo.png" align="right" width="120" />
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/pytest-r-snapshot)](https://pypi.org/project/pytest-r-snapshot/)
4
+ ![Python versions](https://img.shields.io/pypi/pyversions/pytest-r-snapshot)
5
+ [![CI tests](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/ci-tests.yml)
6
+ [![Mypy check](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/mypy.yml/badge.svg)](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/mypy.yml)
7
+ [![Ruff check](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/ruff-check.yml/badge.svg)](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/ruff-check.yml)
8
+ [![mkdocs](https://github.com/nanxstats/pytest-r-snapshot/actions/workflows/mkdocs.yml/badge.svg)](https://nanx.me/pytest-r-snapshot/)
9
+ ![License](https://img.shields.io/pypi/l/pytest-r-snapshot)
10
+
11
+ A pytest plugin for snapshot testing against reference outputs produced by R code.
12
+
13
+ It is designed for a portable workflow:
14
+
15
+ - **Record locally (requires R)**: run labelled R chunks embedded in your
16
+ Python tests and write snapshot files.
17
+ - **Replay everywhere (default; no R required)**: read committed snapshot
18
+ files and compare them to Python outputs.
19
+
20
+ The R chunks live close to the test assertion (copy/paste-able from R scripts
21
+ or R Markdown), but snapshots are stored as deterministic UTF-8 text files.
22
+
23
+ ## Installation
24
+
25
+ You can install pytest-r-snapshot from PyPI:
26
+
27
+ ```bash
28
+ pip install pytest-r-snapshot
29
+ ```
30
+
31
+ If your project is managed with `uv`, add it as a dev dependency:
32
+
33
+ ```bash
34
+ uv add --dev pytest-r-snapshot
35
+ ```
36
+
37
+ Or install the development version from GitHub:
38
+
39
+ ```bash
40
+ git clone https://github.com/nanxstats/pytest-r-snapshot.git
41
+ cd pytest-r-snapshot
42
+ python3 -m pip install -e .
43
+ ```
44
+
45
+ ## Quick start
46
+
47
+ Embed a labelled R fenced chunk (commented or in a docstring),
48
+ then compare your Python output to the recorded snapshot:
49
+
50
+ ```python
51
+ def test_summary_matches_r(r_snapshot):
52
+ # ```{r, summary}
53
+ # x <- c(1, 2, 3)
54
+ # summary(x)
55
+ # ```
56
+
57
+ actual = my_python_summary(...)
58
+ r_snapshot.assert_match_text(actual, name="summary")
59
+ ```
60
+
61
+ Generate (or update) snapshots locally:
62
+
63
+ ```bash
64
+ pytest --r-snapshot=record
65
+ ```
66
+
67
+ Commit the generated snapshot files, and run CI with the default
68
+ replay mode (no R required).
69
+
70
+ ## What you get
71
+
72
+ - A pytest fixture `r_snapshot` with methods:
73
+ - `assert_match_text(actual, name=..., ext=".txt", normalize=...)`
74
+ - `read_text(name=..., ext=".txt")`
75
+ - `record_text(name=..., ext=".txt")`
76
+ - `path_for(name=..., ext=".txt")`
77
+ - Snapshot modes: `replay` (default), `record`, `auto`.
78
+ - Chunk extraction for R Markdown-style fenced chunks inside Python source:
79
+ - Commented fences (chunk lines prefixed with `#`).
80
+ - Raw chunks in docstrings / multi-line strings.
81
+ - Configurable R execution: `Rscript` path, environment, working directory,
82
+ timeout, encoding.
@@ -0,0 +1,76 @@
1
+ [project]
2
+ name = "pytest-r-snapshot"
3
+ version = "0.1.0"
4
+ description = "A pytest plugin for snapshot testing against R code outputs"
5
+ authors = [{ name = "Nan Xiao", email = "me@nanx.me" }]
6
+ dependencies = ["pytest>=7.0.0"]
7
+ readme = "README.md"
8
+
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+
12
+ "Intended Audience :: Developers",
13
+
14
+ "Topic :: Software Development :: Quality Assurance",
15
+ "Topic :: Software Development :: Testing",
16
+
17
+ "License :: OSI Approved :: MIT License",
18
+
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ ]
26
+
27
+ requires-python = ">= 3.10"
28
+
29
+ [project.urls]
30
+ Homepage = "https://nanx.me/pytest-r-snapshot/"
31
+ Documentation = "https://nanx.me/pytest-r-snapshot/"
32
+ Repository = "https://github.com/nanxstats/pytest-r-snapshot"
33
+ Issues = "https://github.com/nanxstats/pytest-r-snapshot/issues"
34
+ Changelog = "https://github.com/nanxstats/pytest-r-snapshot/blob/main/CHANGELOG.md"
35
+
36
+ [project.entry-points.pytest11]
37
+ r-snapshot = "pytest_r_snapshot.plugin"
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ exclude = ["/docs", "/AGENTS.md", "/mkdocs.yml", "/uv.lock"]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ exclude = ["/docs", "/AGENTS.md", "/mkdocs.yml", "/uv.lock"]
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "isort>=7.0.0",
52
+ "mkdocs>=1.6.1",
53
+ "mkdocs-material>=9.7.0",
54
+ "mkdocstrings-python>=2.0.1",
55
+ "mypy>=1.19.0",
56
+ "ruff>=0.14.9",
57
+ ]
58
+
59
+ [tool.ruff.lint]
60
+ select = [
61
+ # pycodestyle
62
+ "E",
63
+ # Pyflakes
64
+ "F",
65
+ # pyupgrade
66
+ "UP",
67
+ # flake8-bugbear
68
+ "B",
69
+ # flake8-simplify
70
+ "SIM",
71
+ # isort
72
+ "I",
73
+ ]
74
+
75
+ [tool.pytest.ini_options]
76
+ pythonpath = ["src"]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .normalize import normalize_newlines, strip_trailing_whitespace
6
+ from .settings import RSnapshotSettings, SnapshotMode
7
+ from .snapshot import RSnapshot
8
+
9
+ if TYPE_CHECKING: # pragma: no cover
10
+ from _pytest.mark.structures import MarkDecorator
11
+
12
+
13
+ def r_snapshot(name: str, *, ext: str = ".txt") -> MarkDecorator:
14
+ """Decorator alias for :func:`pytest.mark.r_snapshot`.
15
+
16
+ This is a small convenience wrapper around the plugin's marker.
17
+ """
18
+
19
+ import pytest
20
+
21
+ return pytest.mark.r_snapshot(name, ext=ext)
22
+
23
+
24
+ __all__ = [
25
+ "RSnapshot",
26
+ "RSnapshotSettings",
27
+ "SnapshotMode",
28
+ "normalize_newlines",
29
+ "r_snapshot",
30
+ "strip_trailing_whitespace",
31
+ ]
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import textwrap
5
+ import tokenize
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .errors import (
10
+ ChunkLabelMissingError,
11
+ ChunkParseError,
12
+ DuplicateChunkLabelError,
13
+ UnclosedChunkError,
14
+ )
15
+
16
+ _START_FENCE_RE = re.compile(r"^```\s*\{([^}]*)\}\s*$")
17
+ _END_FENCE_RE = re.compile(r"^```\s*$")
18
+ _LABEL_KV_RE = re.compile(
19
+ r"""(?x)
20
+ \blabel
21
+ \s*=\s*
22
+ (?:
23
+ " (?P<dq> [^"]+ ) "
24
+ |
25
+ ' (?P<sq> [^']+ ) '
26
+ |
27
+ (?P<bare> [^,\s]+ )
28
+ )
29
+ """
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RChunk:
35
+ """A labelled R chunk extracted from a Python source file."""
36
+
37
+ label: str
38
+ code: str
39
+ path: Path
40
+ start_line: int
41
+ end_line: int
42
+
43
+
44
+ def parse_r_chunks(path: Path) -> dict[str, RChunk]:
45
+ """Parse labelled R fenced chunks from a Python source file."""
46
+
47
+ with tokenize.open(path) as handle:
48
+ source = handle.read()
49
+ return parse_r_chunks_from_text(source, path=path)
50
+
51
+
52
+ def parse_r_chunks_from_text(source: str, *, path: Path) -> dict[str, RChunk]:
53
+ """Parse labelled R fenced chunks from source text."""
54
+
55
+ lines = source.splitlines()
56
+ chunks: dict[str, RChunk] = {}
57
+ label_to_lines: dict[str, list[int]] = {}
58
+
59
+ line_index = 0
60
+ while line_index < len(lines):
61
+ raw_line = lines[line_index]
62
+ stripped, is_commented = _strip_comment_prefix(raw_line)
63
+ header = _parse_start_fence_header(stripped)
64
+ if header is None:
65
+ line_index += 1
66
+ continue
67
+
68
+ start_line = line_index + 1
69
+ label = _extract_label(header, path=path, line=start_line)
70
+
71
+ body_lines: list[str] = []
72
+ line_index += 1
73
+ while line_index < len(lines):
74
+ raw_body_line = lines[line_index]
75
+ body_stripped, _ = _strip_comment_prefix(raw_body_line)
76
+ if _END_FENCE_RE.match(body_stripped) is not None:
77
+ end_line = line_index + 1
78
+ code = _normalize_body(body_lines, commented=is_commented)
79
+ chunk = RChunk(
80
+ label=label,
81
+ code=code,
82
+ path=path,
83
+ start_line=start_line,
84
+ end_line=end_line,
85
+ )
86
+ chunks[label] = chunk
87
+ label_to_lines.setdefault(label, []).append(start_line)
88
+ break
89
+ body_lines.append(raw_body_line)
90
+ line_index += 1
91
+ else:
92
+ raise UnclosedChunkError(path=path, label=label, start_line=start_line)
93
+
94
+ line_index += 1
95
+
96
+ duplicates = {k: v for k, v in label_to_lines.items() if len(v) > 1}
97
+ if duplicates:
98
+ label, dup_lines = sorted(duplicates.items(), key=lambda kv: kv[0])[0]
99
+ raise DuplicateChunkLabelError(path=path, label=label, lines=dup_lines)
100
+
101
+ return chunks
102
+
103
+
104
+ def _strip_comment_prefix(line: str) -> tuple[str, bool]:
105
+ stripped = line.lstrip()
106
+ if not stripped.startswith("#"):
107
+ return stripped, False
108
+ stripped = stripped[1:]
109
+ if stripped.startswith(" "):
110
+ stripped = stripped[1:]
111
+ return stripped, True
112
+
113
+
114
+ def _parse_start_fence_header(line: str) -> str | None:
115
+ if not line.startswith("```") or line.startswith("````"):
116
+ return None
117
+ match = _START_FENCE_RE.match(line)
118
+ if match is None:
119
+ return None
120
+ header = match.group(1).strip()
121
+ if not header.startswith("r") or (len(header) > 1 and header[1] not in (" ", ",")):
122
+ return None
123
+ return header
124
+
125
+
126
+ def _extract_label(header: str, *, path: Path, line: int) -> str:
127
+ label_match = _LABEL_KV_RE.search(header)
128
+ if label_match is not None:
129
+ label = (
130
+ label_match.group("dq")
131
+ or label_match.group("sq")
132
+ or label_match.group("bare")
133
+ )
134
+ return label.strip()
135
+
136
+ tokens = header.replace(",", " ").split()
137
+ if not tokens or tokens[0] != "r":
138
+ raise ChunkLabelMissingError(path=path, line=line, header=header)
139
+
140
+ for token in tokens[1:]:
141
+ if token and "=" not in token:
142
+ return token.strip().strip("'\"")
143
+
144
+ raise ChunkLabelMissingError(path=path, line=line, header=header)
145
+
146
+
147
+ def _normalize_body(body_lines: list[str], *, commented: bool) -> str:
148
+ if not body_lines:
149
+ return ""
150
+ if commented:
151
+ normalized: list[str] = []
152
+ for raw in body_lines:
153
+ if raw.strip() == "":
154
+ normalized.append("")
155
+ continue
156
+ stripped = raw.lstrip()
157
+ if not stripped.startswith("#"):
158
+ raise ChunkParseError(
159
+ "Found a non-comment line inside a commented R chunk body. "
160
+ "Commented chunks must prefix each line with '#'."
161
+ )
162
+ stripped = stripped[1:]
163
+ if stripped.startswith(" "):
164
+ stripped = stripped[1:]
165
+ normalized.append(stripped)
166
+ return "\n".join(normalized)
167
+
168
+ return textwrap.dedent("\n".join(body_lines))