diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a5e5a1e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "external/org-mode"] + path = testdata/external/org-mode + url = https://github.com/bzg/org-mode.git + shallow = true diff --git a/README.md b/README.md index aa3a6fb..db8f9c1 100644 --- a/README.md +++ b/README.md @@ -116,9 +116,14 @@ The tests show additional supported features: ## Development and documentation +Clone with `git clone --recurse-submodules https://github.com/karlicoss/orgparse.git` to include the test corpus. +For an existing checkout, run `git submodule update --init --recursive`. + Run the tests with `uv tool run --with tox-uv tox -e tests`. This also checks the examples in this README. +The normal test suite parses the `.org` files in `testdata/external/org-mode/testing/examples` and checks tree structure and attribute access. + Edit `README.qmd`, then regenerate `README.md` with `uv tool run --with tox-uv tox -e quarto`. Quarto computes links to source code and tests from their definitions, so line numbers are refreshed when rendering. Commit both files together; CI checks that the generated README is current. diff --git a/README.qmd b/README.qmd index 44e1da6..6902ba7 100644 --- a/README.qmd +++ b/README.qmd @@ -145,9 +145,14 @@ The tests show additional supported features: ## Development and documentation +Clone with `git clone --recurse-submodules https://github.com/karlicoss/orgparse.git` to include the test corpus. +For an existing checkout, run `git submodule update --init --recursive`. + Run the tests with `uv tool run --with tox-uv tox -e tests`. This also checks the examples in this README. +The normal test suite parses the `.org` files in `testdata/external/org-mode/testing/examples` and checks tree structure and attribute access. + Edit `README.qmd`, then regenerate `README.md` with `uv tool run --with tox-uv tox -e quarto`. Quarto computes links to source code and tests from their definitions, so line numbers are refreshed when rendering. Commit both files together; CI checks that the generated README is current. diff --git a/pyproject.toml b/pyproject.toml index c0b3784..d4f9897 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,10 @@ quarto = [ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +[tool.hatch.build] +# Upstream test corpora are development inputs and retain their own licenses. +exclude = ["/testdata/external"] + # unfortunately have to duplicate project name here atm, see https://github.com/pypa/hatch/issues/1894 [tool.hatch.build.targets.wheel] packages = ["src/orgparse"] @@ -111,7 +115,7 @@ enable_error_code = [ # suppressing rules/changing error level goes here [tool.ty.src] -# exclude = [...] +exclude = ["testdata/external"] [tool.ruff] @@ -231,6 +235,7 @@ lint.ignore = [ # "scratch/**", # ] extend-exclude = [ + "testdata/external", ] [tool.ruff.format] @@ -238,6 +243,8 @@ quote-style = "preserve" [tool.pytest.ini_options] +testpaths = ["src/orgparse"] + # discover files that don't follow test_ naming. Useful to keep tests along with the source code python_files = ["*.py"] diff --git a/src/orgparse/tests/test_corpus.py b/src/orgparse/tests/test_corpus.py new file mode 100644 index 0000000..723807f --- /dev/null +++ b/src/orgparse/tests/test_corpus.py @@ -0,0 +1,70 @@ +"""Exercise the public parser API on Org mode's upstream example files.""" + +from pathlib import Path + +import pytest + +from .. import load +from ..extra import Table +from ..node import OrgNode + +CORPUS = Path(__file__).resolve().parents[3] / 'testdata' / 'external' / 'org-mode' / 'testing' / 'examples' + + +def corpus_files() -> list[Path]: + paths = sorted(CORPUS.rglob('*.org')) + assert len(paths) > 0, 'Missing Org corpus: run git submodule update --init --recursive' + return paths + + +@pytest.mark.parametrize('path', corpus_files(), ids=lambda path: path.relative_to(CORPUS).as_posix()) +def test_corpus(path: Path) -> None: + """Check parser robustness and tree consistency across upstream Org examples. + + Exercise heading/body formatting, property lookups, table row/block iteration, + and formatting of populated timestamps. + Check tag inclusion, parent/child links, a shared root, increasing heading line numbers, + and heading levels against the source text. + These are smoke and consistency checks; exact parsed values require separate expected-output tests. + """ + root = load(path) + lines = path.read_text(encoding='utf-8').splitlines() + assert root.is_root() + assert root.parent is None + + previous_line = 0 + for node in root: + # Access lazy formatting as well as the eagerly parsed attributes. + assert isinstance(node.heading, str) + assert isinstance(node.body, str) + assert node.shallow_tags <= node.tags + for key, value in node.properties.items(): + assert node.get_property(key) == value + for part in node.body_rich: + if isinstance(part, Table): + list(part.rows) + list(part.blocks) + + dates = node.get_timestamps(active=True, inactive=True) + if isinstance(node, OrgNode): + dates = [*dates, node.scheduled, node.deadline, node.closed, *node.clock, *node.repeated_tasks] + for date in dates: + if date.start is not None: + str(date) + + assert node.root is root + for child in node.children: + assert child.parent is node + assert child.level > node.level + if node is root: + continue + + assert previous_line < node.linenumber <= len(lines) + previous_line = node.linenumber + heading_line = lines[node.linenumber - 1] + assert heading_line[: node.level] == '*' * node.level + assert heading_line[node.level].isspace() + parent = node.parent + assert parent is not None + assert parent.level < node.level + assert any(child is node for child in parent.children) diff --git a/testdata/external/org-mode b/testdata/external/org-mode new file mode 160000 index 0000000..9aae2ea --- /dev/null +++ b/testdata/external/org-mode @@ -0,0 +1 @@ +Subproject commit 9aae2ea533ca58a5a859613a5ed91821a7a65e60