Skip to content

Commit f5fb12f

Browse files
committed
feat: interlinks in own module, testing spec
1 parent 0f38df2 commit f5fb12f

File tree

5 files changed

+126
-47
lines changed

5 files changed

+126
-47
lines changed

quartodoc/interlinks.py

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import itertools
55
import json
66
import warnings
7+
import yaml
78

89
from pydantic import BaseModel, Field
910
from dataclasses import dataclass
@@ -29,10 +30,10 @@ class InvLookupError(Exception):
2930

3031
def get_path_to_root():
3132
# In lua filters you can use quarto.project.offset
32-
return os.environ[ENV_PROJECT_ROOT]
33+
return Path(os.environ[ENV_PROJECT_ROOT])
3334

3435

35-
def parse_rst_style_ref(full_text):
36+
def parse_rst_style_ref(full_text: str):
3637
"""
3738
Returns
3839
-------
@@ -52,6 +53,19 @@ def parse_rst_style_ref(full_text):
5253
return ref, text
5354

5455

56+
def parse_md_style_link(full_text: str):
57+
import re
58+
59+
m = re.match(r"\[(?P<text>.*?)\]\((?P<ref>.*?)\)", full_text)
60+
61+
if m is None:
62+
raise Exception()
63+
64+
text, ref = m.groups()
65+
66+
return ref, text
67+
68+
5569
# Dataclasses representing pandoc elements ------------------------------------
5670
# These classes are used to help indicate what elements the Interlinks class
5771
# would return in a pandoc filter.
@@ -60,15 +74,15 @@ def parse_rst_style_ref(full_text):
6074
class Link(BaseModel):
6175
"""Indicates a pandoc Link element."""
6276

63-
kind: Literal["link"] = "link"
77+
kind: Literal["Link"] = "Link"
6478
content: str
6579
url: str
6680

6781

6882
class Code(BaseModel):
6983
"""Indicates a pandoc Code element."""
7084

71-
kind: Literal["code"] = "code"
85+
kind: Literal["Code"] = "Code"
7286
content: str
7387

7488

@@ -79,11 +93,12 @@ class Unchanged(BaseModel):
7993
return the original content element.
8094
"""
8195

82-
kind: Literal["unchanged"] = "unchanged"
96+
kind: Literal["Unchanged"] = "Unchanged"
8397
content: str
8498

8599

86100
class TestSpecEntry(BaseModel):
101+
input: str
87102
output_text: Optional[str] = None
88103
output_link: Optional[str] = None
89104
output_element: Optional[
@@ -93,6 +108,10 @@ class TestSpecEntry(BaseModel):
93108
warning: Optional[str] = None
94109

95110

111+
class TestSpec(BaseModel):
112+
__root__: list[TestSpecEntry]
113+
114+
96115
# Reference syntax ------------------------------------------------------------
97116
# note that the classes above were made pydantic models so we could serialize
98117
# them from json. We could make these ones pydantic too, but there is not a
@@ -280,16 +299,16 @@ def ref_to_anchor(self, ref: str | Ref, text: "str | None"):
280299
is_shortened = ref.target.startswith("~")
281300

282301
entry = self.lookup_reference(ref)
283-
dst_url = entry["full_uri"]
302+
dst_url = entry.full_uri
284303

285304
if not text:
286-
name = entry["name"] if entry["dispname"] == "-" else entry["dispname"]
305+
name = entry.name if entry.dispname == "-" else entry.dispname
287306
if is_shortened:
288307
# shorten names from module.sub_module.func_name -> func_name
289308
name = name.split(".")[-1]
290-
return Link(name, url=dst_url)
309+
return Link(content=name, url=dst_url)
291310

292-
return Link(text, url=dst_url)
311+
return Link(content=text, url=dst_url)
293312

294313
def pandoc_ref_to_anchor(self, ref: str, text: str) -> Link | Code | Unchanged:
295314
"""Convert a ref to a Link, with special handling for pandoc filters.
@@ -299,27 +318,33 @@ def pandoc_ref_to_anchor(self, ref: str, text: str) -> Link | Code | Unchanged:
299318
non-ref urls unchanged.
300319
"""
301320

302-
if (ref.startswith("%60") or ref.startswith(":")) and ref.endswith("%60"):
321+
# detect what *might* be an interlink. note that we don't validate
322+
# that it has a closing `, to allow a RefSyntaxError to bubble up.
323+
if ref.startswith("%60") or ref.startswith(":"):
303324
# Get URL ----
304325
try:
305326
return self.ref_to_anchor(ref.replace("%60", "`"), text)
306327
except InvLookupError as e:
307-
warnings.warn(warnings.warn(str(e)))
328+
warnings.warn(f"{e.__class__.__name__}: {e}")
308329
if text:
309330
# Assuming content is a ListContainer(Str(...))
310331
body = text
311332
else:
312-
body = ref.replace("%60", "")
313-
return Code(body)
333+
body = ref.replace("%60", "`")
334+
return Code(content=body)
314335

315-
return Unchanged(ref)
336+
return Unchanged(content=ref)
316337

317338
@staticmethod
318339
def _filter_by_field(items, field_name: str, value: "str | None" = None):
319340
if value is None:
320341
return items
321342

322-
return (item for item in items if item[field_name] == value)
343+
# TODO: Ref uses invname, while EnhancedItem uses inv_name
344+
if field_name == "invname":
345+
field_name = "inv_name"
346+
347+
return (item for item in items if getattr(item, field_name) == value)
323348

324349
@classmethod
325350
def from_items(cls, items: "list[EnhancedItem]"):
@@ -331,9 +356,16 @@ def from_items(cls, items: "list[EnhancedItem]"):
331356
return invs
332357

333358
@classmethod
334-
def from_quarto_config(cls, cfg: dict):
359+
def from_quarto_config(cls, cfg: str | dict, root_dir: str | None = None):
360+
361+
if isinstance(cfg, str):
362+
if root_dir is None:
363+
root_dir = Path(cfg).parent
364+
365+
cfg = yaml.safe_load(open(cfg))
366+
335367
invs = cls()
336-
p_root = get_path_to_root()
368+
p_root = get_path_to_root() if root_dir is None else Path(root_dir)
337369

338370
interlinks = cfg["interlinks"]
339371
sources = interlinks["sources"]
@@ -354,3 +386,5 @@ def from_quarto_config(cls, cfg: dict):
354386
json_data = json.load(open(inv_path))
355387

356388
invs.load_inventory(json_data, url=cfg["url"], invname=doc_name)
389+
390+
return invs

quartodoc/tests/example_interlinks/_quarto.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ filters:
88

99
interlinks:
1010
sources:
11-
numpy:
12-
url: https://numpy.org/doc/stable/
13-
python:
14-
url: https://docs.python.org/3/
11+
other:
12+
# note that url is usually the http address it is
13+
# fetched from, but we generate the inventory for
14+
# this source manually.
15+
url: "other+"

quartodoc/tests/example_interlinks/spec.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
output_text: quartodoc.MdRenderer
1111

1212
# can look up function
13-
- input: "[](`quartodoc.Mdrenderer.render`)"
13+
- input: "[](`quartodoc.MdRenderer.render`)"
1414
output_link: /api/MdRenderer.html#quartodoc.MdRenderer.render
1515
output_text: quartodoc.MdRenderer.render
1616

quartodoc/tests/test_interlinks.py

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,37 @@
1+
import contextlib
12
import pytest
3+
import yaml
24

3-
from quartodoc.inventory import Ref
5+
from quartodoc import interlinks
6+
from quartodoc.interlinks import (
7+
Inventories,
8+
Ref,
9+
TestSpec,
10+
TestSpecEntry,
11+
parse_md_style_link,
12+
Link,
13+
)
14+
from importlib_resources import files
15+
16+
# load test spec at import time, so that we can feed each spec entry
17+
# as an individual test case using parametrize
18+
_raw = yaml.safe_load(open("quartodoc/tests/example_interlinks/spec.yml"))
19+
spec = TestSpec(__root__=_raw).__root__
420

521

622
@pytest.fixture
7-
def spec():
8-
pass
23+
def invs():
24+
invs = Inventories.from_quarto_config(
25+
str(files("quartodoc") / "tests/example_interlinks/_quarto.yml")
26+
)
27+
28+
return invs
29+
30+
31+
# def test_inventories_from_config():
32+
# invs = Inventories.from_quarto_config(
33+
# str(files("quartodoc") / "tests/example_interlinks/_quarto.yml")
34+
# )
935

1036

1137
@pytest.mark.parametrize(
@@ -25,3 +51,43 @@ def test_ref_from_string(raw, dst):
2551
src = Ref.from_string(raw)
2652

2753
assert src == dst
54+
55+
56+
@pytest.mark.parametrize("entry", spec)
57+
def test_spec_entry(invs: Inventories, entry: TestSpecEntry):
58+
59+
ref_str, text = parse_md_style_link(entry.input)
60+
ref_str = ref_str.replace("`", "%60") # weird, but matches pandoc
61+
62+
# set up error and warning contexts ----
63+
# pytest uses context managers to check warnings and errors
64+
# so we either create the relevant cm or uses a no-op cm
65+
if entry.error:
66+
ctx_err = pytest.raises(getattr(interlinks, entry.error))
67+
else:
68+
ctx_err = contextlib.nullcontext()
69+
70+
if entry.warning:
71+
ctx_warn = pytest.warns(UserWarning, match=entry.warning)
72+
else:
73+
ctx_warn = contextlib.nullcontext()
74+
75+
# fetch link ----
76+
with ctx_warn as rec_warn, ctx_err as rec_err: # noqa
77+
el = invs.pandoc_ref_to_anchor(ref_str, text)
78+
79+
if entry.error:
80+
# return on errors, since no result produced
81+
return
82+
83+
# output assertions ----
84+
if entry.output_link is not None or entry.output_text is not None:
85+
assert isinstance(el, Link)
86+
87+
if entry.output_link:
88+
assert entry.output_link == el.url
89+
90+
if entry.output_text:
91+
assert entry.output_text == el.content
92+
elif entry.output_element:
93+
assert el == entry.output_element

quartodoc/tests/test_inventory.py

Lines changed: 0 additions & 22 deletions
This file was deleted.

0 commit comments

Comments
 (0)