diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dba7e06..8680551 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,11 @@ -name: CI +name: CI on: push: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: test: @@ -14,14 +15,8 @@ jobs: with: submodules: recursive - - name: Install uv - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@v6 - - name: Install Python - run: uv python install 3.12 - - - name: Install dependencies - run: uv sync - - - name: Run tests - run: uv run pytest -v + - run: uv run ruff check + - run: uv run ruff format --check + - run: uv run pytest diff --git a/.gitignore b/.gitignore index 00f2d38..cb43dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ -__pycache__/ -*.pyc .venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +dist/ +build/ +*.egg-info/ diff --git a/README.md b/README.md index 7e1b66f..c3cd7c6 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,7 @@ Python implementation of Linked Markdown, packaged for PyPI. ## API ```python -from linked_markdown.extract import extract -from linked_markdown.errors import ( - LinkedMarkdownError, - LMD_NO_FRONTMATTER, - LMD_INVALID_FRONTMATTER, -) +from linked_markdown import extract result = extract(markdown) # => ExtractResult(front_matter="...", body="...", attrs={...}) diff --git a/pyproject.toml b/pyproject.toml index de60926..cccbae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,20 @@ Tracker = "https://github.com/wazootech/linked-markdown-py/issues" [dependency-groups] dev = [ "pytest>=8.0", + "rdflib>=7.0.0", + "ruff>=0.11.0", ] [build-system] requires = ["uv_build>=0.11.19,<0.12.0"] build-backend = "uv_build" + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/linked_markdown/__init__.py b/src/linked_markdown/__init__.py index 90aa581..01e9b65 100644 --- a/src/linked_markdown/__init__.py +++ b/src/linked_markdown/__init__.py @@ -1,4 +1,4 @@ -from .errors import LinkedMarkdownError, LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER +from .errors import LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER, LinkedMarkdownError from .extract import ExtractResult, extract __all__ = [ diff --git a/src/linked_markdown/extract.py b/src/linked_markdown/extract.py index 3ff5178..59fd1b8 100644 --- a/src/linked_markdown/extract.py +++ b/src/linked_markdown/extract.py @@ -6,7 +6,7 @@ import yaml -from .errors import LinkedMarkdownError, LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER +from .errors import LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER, LinkedMarkdownError T = TypeVar("T") @@ -22,8 +22,8 @@ class ExtractResult(Generic[T]): _OPENER_RE = re.compile( r"^(?:" r"= yaml =|= json =|= toml =|" # equals delimiters - r"---yaml|---json|---toml|" # explicit markers - r"---|\+\+\+" # unmarked dash and plus + r"---yaml|---json|---toml|" # explicit markers + r"---|\+\+\+" # unmarked dash and plus r")", re.MULTILINE, ) @@ -82,7 +82,7 @@ def _split_front_matter( return format_, raw, body.lstrip("\n") return None - raw = rest[:closer_idx + 1].lstrip("\n") + raw = rest[: closer_idx + 1].lstrip("\n") body = rest[closer_idx + len(closer) + 1 :] return format_, raw, body.lstrip("\n") diff --git a/src/linked_markdown/parser.py b/src/linked_markdown/parser.py new file mode 100644 index 0000000..1aaa629 --- /dev/null +++ b/src/linked_markdown/parser.py @@ -0,0 +1,105 @@ +import json +import re +import tomllib +from typing import Any + +import yaml + +VALID_MARKERS = frozenset({"yaml", "json", "toml"}) + + +class LinkedMarkdownError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +def extract(content: str) -> dict[str, Any]: + content = content.replace("\ufeff", "").replace("\r\n", "\n") + + _check_unknown_markers(content) + + has_opener = bool(re.match(r"^(---|\+\+\+(?:\n|$)|= \w+ =(?:\n|$))", content)) + if not has_opener: + raise LinkedMarkdownError( + "LMD_NO_FRONTMATTER", "Expected frontmatter at the start of the document." + ) + + patterns: list[tuple[str, str, str]] = [ + (r"^---yaml\n", r"\n---", "yaml"), + (r"^---json\n", r"\n---", "json"), + (r"^---toml\n", r"\n---", "toml"), + (r"^\+\+\+\n", r"\n\+\+\+", "toml"), + (r"^= yaml =\n", r"\n= yaml =", "yaml"), + (r"^= json =\n", r"\n= json =", "json"), + (r"^= toml =\n", r"\n= toml =", "toml"), + ] + + for opener_pat, closer_pat, fmt in patterns: + opener_match = re.match(opener_pat, content) + if opener_match: + opener_len = len(opener_match.group(0)) + remaining = content[opener_len:] + end_match = re.search(closer_pat, remaining) + if end_match is None: + raise LinkedMarkdownError( + "LMD_INVALID_FRONTMATTER", "Expected closing frontmatter delimiter." + ) + raw_fm = remaining[: end_match.start()] + body = remaining[end_match.end() :].lstrip("\n") + return _build_extract_result(raw_fm, body, fmt) + + if content.startswith("---\n"): + end = content.find("\n---", 3) + if end == -1: + raise LinkedMarkdownError( + "LMD_INVALID_FRONTMATTER", "Expected closing frontmatter delimiter." + ) + raw_fm = content[4:end] + body = content[end + 4 :].lstrip("\n") + fmt = "json" if raw_fm.strip().startswith(("{", "[")) else "yaml" + return _build_extract_result(raw_fm, body, fmt) + + raise LinkedMarkdownError( + "LMD_INVALID_FRONTMATTER", "Unrecognized frontmatter delimiter pattern." + ) + + +def _check_unknown_markers(content: str) -> None: + marker_match = re.match(r"^---(\w+)", content) + if marker_match: + marker = marker_match.group(1).lower() + if marker and marker not in VALID_MARKERS: + raise LinkedMarkdownError( + "LMD_INVALID_FRONTMATTER", f"Unknown front matter marker: ---{marker}" + ) + + equals_match = re.match(r"^= (\w+) =", content) + if equals_match: + marker = equals_match.group(1).lower() + if marker not in VALID_MARKERS: + raise LinkedMarkdownError( + "LMD_INVALID_FRONTMATTER", f"Unknown front matter marker: = {marker} =" + ) + + +def _build_extract_result(raw_fm: str, body: str, fmt: str) -> dict[str, Any]: + try: + if fmt == "json": + attrs = json.loads(raw_fm) if raw_fm.strip() else {} + elif fmt == "toml": + attrs = tomllib.loads(raw_fm) if raw_fm.strip() else {} + else: + attrs = yaml.safe_load(raw_fm) if raw_fm.strip() else {} + except (json.JSONDecodeError, tomllib.TOMLDecodeError, yaml.YAMLError) as e: + raise LinkedMarkdownError( + "LMD_INVALID_FRONTMATTER", f"Invalid {fmt} frontmatter: {e}" + ) from e + + if attrs is None: + attrs = {} + if not isinstance(attrs, dict): + raise LinkedMarkdownError("LMD_INVALID_FRONTMATTER", "Frontmatter must parse to an object.") + + front_matter = raw_fm + "\n" if raw_fm else "" + return {"attrs": attrs, "frontMatter": front_matter, "body": body} diff --git a/test/linked-markdown-spec b/test/linked-markdown-spec index ebf8952..e4350ee 160000 --- a/test/linked-markdown-spec +++ b/test/linked-markdown-spec @@ -1 +1 @@ -Subproject commit ebf8952605dc92b16f6d6353841c0543fecdd67a +Subproject commit e4350eef21b15ad6e3a1d4282083a9d4f6f73b04 diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 9cf7c0d..7704090 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -2,8 +2,8 @@ from pathlib import Path import pytest -from linked_markdown.errors import LinkedMarkdownError +from linked_markdown.errors import LinkedMarkdownError from linked_markdown.extract import extract diff --git a/tests/test_extract.py b/tests/test_extract.py index 052e7fd..04a8744 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1,6 +1,6 @@ import pytest -from linked_markdown.errors import LinkedMarkdownError, LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER +from linked_markdown.errors import LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER, LinkedMarkdownError from linked_markdown.extract import extract diff --git a/tests/test_rdf.py b/tests/test_rdf.py new file mode 100644 index 0000000..5cdf1da --- /dev/null +++ b/tests/test_rdf.py @@ -0,0 +1,69 @@ +import json +from pathlib import Path + +import rdflib +from rdflib import RDF, Literal, URIRef + +from linked_markdown import extract + +CONFORMANCE_ROOT = Path(__file__).parent.parent / "test" / "linked-markdown-spec" / "conformance" +SCHEMA = rdflib.Namespace("https://schema.org/") + + +def _read_fixture(path: str) -> str: + return (CONFORMANCE_ROOT / path).read_text(encoding="utf-8") + + +def test_valid_yaml_marker_loads_into_rdflib(): + input_text = _read_fixture("cases/valid-yaml-marker/input.md") + result = extract(input_text) + + g = rdflib.Graph() + g.parse(data=json.dumps(result.attrs), format="json-ld") + + subject = URIRef("https://example.org/docs/fixture") + assert (subject, RDF.type, SCHEMA.Article) in g + assert (subject, SCHEMA.name, Literal("Delimiter Fixture")) in g + + +def test_missing_id_generates_blank_node(): + input_text = _read_fixture("cases/missing-id/input.md") + result = extract(input_text) + + g = rdflib.Graph() + g.parse(data=json.dumps(result.attrs), format="json-ld") + + subjects = {s for s, _, _ in g} + assert any(isinstance(s, rdflib.BNode) for s in subjects) + + +def test_missing_type_produces_no_rdf_type(): + input_text = _read_fixture("cases/missing-type/input.md") + result = extract(input_text) + + g = rdflib.Graph() + g.parse(data=json.dumps(result.attrs), format="json-ld") + + assert len(list(g.triples((None, RDF.type, None)))) == 0 + + +def test_bare_keywords_produces_no_triples_without_context(): + input_text = _read_fixture("cases/bare-keywords-preserved/input.md") + result = extract(input_text) + + g = rdflib.Graph() + g.parse(data=json.dumps(result.attrs), format="json-ld") + + assert len(g) == 0 + + +def test_canonical_type(): + input_text = _read_fixture("cases/canonical-type/input.md") + result = extract(input_text) + + g = rdflib.Graph() + g.parse(data=json.dumps(result.attrs), format="json-ld") + + subject = URIRef("https://example.org/docs/canonical-type") + assert (subject, RDF.type, SCHEMA.Note) in g + assert (subject, SCHEMA.name, Literal("Canonical Type")) in g diff --git a/uv.lock b/uv.lock index 5293f89..750621e 100644 --- a/uv.lock +++ b/uv.lock @@ -31,13 +31,19 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "rdflib" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0" }] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "rdflib", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.11.0" }, +] [[package]] name = "packaging" @@ -66,6 +72,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -127,3 +142,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] + +[[package]] +name = "rdflib" +version = "7.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +]