Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions okf/src/reference_agent/bundle/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import re
from pathlib import Path
from urllib.parse import urlparse

_SEGMENT_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.\-]*")

Expand Down Expand Up @@ -32,3 +33,47 @@ def parse_concept_id(s: str) -> tuple[str, ...]:
for p in parts:
_validate_segment(p)
return parts


def resolve_reference_path(
bundle_root: Path,
concept_path: Path,
reference: str,
) -> str:
"""Resolve an OKF §6.2 path-valued reference.

URLs are returned unchanged.

Bundle-relative paths beginning with '/' are interpreted relative
to the bundle root.

Other paths are interpreted relative to the directory containing
the concept document.

The returned bundle-relative paths always begin with '/'.
"""
reference = str(reference).strip()

if not reference:
return reference

parsed = urlparse(reference)
if parsed.scheme:
return reference

bundle_root = bundle_root.resolve()
concept_path = concept_path.resolve()

if reference.startswith("/"):
candidate = (bundle_root / reference.lstrip("/")).resolve()
else:
candidate = (concept_path.parent / reference).resolve()

try:
relative = candidate.relative_to(bundle_root)
except ValueError as exc:
raise ValueError(
f"Reference escapes bundle root: {reference!r}"
) from exc

return "/" + relative.as_posix()
74 changes: 74 additions & 0 deletions okf/tests/test_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations

from pathlib import Path

import pytest

from reference_agent.bundle.paths import (
concept_id_to_path,
parse_concept_id,
path_to_concept_id,
resolve_reference_path,
)


def test_concept_id_to_path():
root = Path("/bundle")
assert concept_id_to_path(root, ("tables", "orders")) == Path(
"/bundle/tables/orders.md"
)


def test_path_to_concept_id():
root = Path("/bundle")
path = Path("/bundle/tables/orders.md")
assert path_to_concept_id(root, path) == ("tables", "orders")


def test_parse_concept_id():
assert parse_concept_id("tables/orders") == ("tables", "orders")
assert parse_concept_id("/tables/orders/") == ("tables", "orders")


def test_resolve_absolute_url_unchanged():
root = Path("/bundle")
concept = Path("/bundle/computations/revenue-ytd.md")

assert resolve_reference_path(
root, concept, "https://example.com/revenue"
) == "https://example.com/revenue"


def test_resolve_bundle_relative_path():
root = Path("/bundle")
concept = Path("/bundle/computations/revenue-ytd.md")

assert resolve_reference_path(
root, concept, "/tables/orders.md"
) == "/tables/orders.md"


def test_resolve_relative_path_from_concept_directory():
root = Path("/bundle")
concept = Path("/bundle/computations/revenue-ytd.md")

assert resolve_reference_path(
root, concept, "../tables/orders.md"
) == "/tables/orders.md"


def test_resolve_nested_relative_path():
root = Path("/bundle")
concept = Path("/bundle/computations/revenue-ytd.md")

assert resolve_reference_path(
root, concept, "skills/run-on-bq.md"
) == "/computations/skills/run-on-bq.md"


def test_resolve_relative_path_cannot_escape_bundle():
root = Path("/bundle")
concept = Path("/bundle/computations/revenue-ytd.md")

with pytest.raises(ValueError):
resolve_reference_path(root, concept, "../../outside.md")