Skip to content

Commit a874f7b

Browse files
committed
Support specifying source URLs per directory
1 parent 4a24f53 commit a874f7b

4 files changed

Lines changed: 91 additions & 19 deletions

File tree

mkdocstrings/handlers/crystal/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Optional, Sequence
1+
from typing import Any, Mapping, Optional, Sequence
22

33
from mkdocstrings.handlers.base import BaseHandler
44

@@ -14,9 +14,12 @@ def get_handler(
1414
theme: str,
1515
custom_templates: Optional[str] = None,
1616
crystal_docs_flags: Sequence[str] = (),
17+
source_locations: Mapping[str, str] = (),
1718
**config: Any
1819
) -> CrystalHandler:
19-
collector = CrystalCollector(crystal_docs_flags=crystal_docs_flags)
20+
collector = CrystalCollector(
21+
crystal_docs_flags=crystal_docs_flags, source_locations=source_locations
22+
)
2023
renderer = CrystalRenderer("crystal", theme, custom_templates)
2124
renderer.collector = collector
2225
return CrystalHandler(collector, renderer)

mkdocstrings/handlers/crystal/collector.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import collections
2+
import dataclasses
3+
import functools
24
import json
35
import logging
6+
import os
47
import re
58
import shlex
69
import subprocess
7-
from typing import Any, Callable, Iterable, Iterator, Mapping, Sequence, TypeVar, Union
10+
from typing import Any, Callable, Iterable, Iterator, List, Mapping, Sequence, TypeVar, Union
811

912
import mkdocs.exceptions
1013
import mkdocs.utils
1114
from cached_property import cached_property
1215
from mkdocstrings.handlers.base import BaseCollector, CollectionError
1316

14-
from .items import DocConstant, DocItem, DocMapping, DocMethod, DocModule, DocType
17+
from .items import DocConstant, DocItem, DocLocation, DocMapping, DocMethod, DocModule, DocType
1518

1619
try:
1720
from mkdocs.exceptions import PluginError
@@ -25,7 +28,9 @@
2528

2629

2730
class CrystalCollector(BaseCollector):
28-
def __init__(self, crystal_docs_flags: Sequence[str] = ()):
31+
def __init__(
32+
self, crystal_docs_flags: Sequence[str] = (), source_locations: Mapping[str, str] = ()
33+
):
2934
"""Create a "collector", reading docs from `crystal doc` in the current directory.
3035
3136
Normally this should not be instantiated.
@@ -40,23 +45,35 @@ def __init__(self, crystal_docs_flags: Sequence[str] = ()):
4045
"--format=json",
4146
"--project-name=",
4247
"--project-version=",
43-
"--source-refname=master",
44-
*crystal_docs_flags,
4548
]
49+
if source_locations:
50+
command.append("--source-refname=master")
51+
command += crystal_docs_flags
4652
log.debug("Running `%s`", " ".join(shlex.quote(arg) for arg in command))
4753

4854
self._proc = subprocess.Popen(command, stdout=subprocess.PIPE)
4955
self._collected = collections.Counter()
5056

57+
# For unambiguous prefix match: add trailing slash, sort by longest path first.
58+
self._source_locations = sorted(
59+
(
60+
_SourceDestination(os.path.relpath(k) + os.sep, source_locations[k])
61+
for k in source_locations
62+
),
63+
key=lambda d: -d.src_path.count("/"),
64+
)
65+
5166
# pytype: disable=bad-return-type
5267
@cached_property
53-
def root(self) -> DocModule:
68+
def root(self) -> "DocRoot":
5469
"""The top-level namespace, represented as a fake module."""
5570
try:
5671
with self._proc:
5772
data = json.load(self._proc.stdout)
5873
data["program"]["full_name"] = ""
59-
return DocModule(data["program"], None, None)
74+
root = DocRoot(data["program"], None, None)
75+
root.source_locations = self._source_locations
76+
return root
6077
finally:
6178
if self._proc.returncode:
6279
cmd = " ".join(shlex.quote(arg) for arg in self._proc.args)
@@ -104,6 +121,56 @@ def teardown(self):
104121
log.debug(f"These types were never put into the documentation: {not_collected}")
105122

106123

124+
@dataclasses.dataclass
125+
class _SourceDestination:
126+
src_path: str
127+
dest_url: str
128+
129+
def substitute(self, location: DocLocation) -> str:
130+
data = {"file": location.filename[len(self.src_path) :], "line": location.line}
131+
try:
132+
return self.dest_url.format_map(collections.ChainMap(data, self)) # type: ignore
133+
except KeyError as e:
134+
raise PluginError(
135+
f"The source_locations template {self.dest_url!r} did not resolve correctly: {e}"
136+
)
137+
138+
def __getitem__(self, key):
139+
try:
140+
return getattr(self, key)
141+
except AttributeError as e:
142+
raise KeyError(str(e))
143+
144+
@property
145+
def shard_version(self):
146+
return self._shard_version(os.path.dirname(self.src_path))
147+
148+
@classmethod
149+
@functools.lru_cache(maxsize=None)
150+
def _shard_version(cls, path: str):
151+
file_path = os.path.join(path, "shard.yml")
152+
if os.path.isfile(file_path):
153+
with open(file_path, "rb") as f:
154+
m = re.search(rb"^version: *([\S+]+)", f.read(), flags=re.MULTILINE)
155+
if not m:
156+
raise PluginError(f"`version:` not found in {file_path!r}")
157+
return m[1].decode()
158+
if not path:
159+
raise PluginError(f"'shard.yml' not found anywhere above {path!r}")
160+
return cls._shard_version(os.path.dirname(path))
161+
162+
163+
class DocRoot(DocModule):
164+
source_locations: List[_SourceDestination]
165+
166+
def update_url(self, location: DocLocation) -> DocLocation:
167+
for dest in self.source_locations:
168+
if (location.filename or "").startswith(dest.src_path):
169+
location.url = dest.substitute(location)
170+
break
171+
return location
172+
173+
107174
class DocView:
108175
def __init__(self, item: DocItem, config: Mapping[str, Any]):
109176
self.item = item

mkdocstrings/handlers/crystal/crystal_html.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import collections
22
import html.parser
33
import io
4-
from typing import Callable, Sequence, Tuple
4+
from typing import Callable, List, Sequence, Tuple
55

66
from markupsafe import Markup, escape
77

mkdocstrings/handlers/crystal/items.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def including_types(self) -> Sequence[DocPath]:
230230
def locations(self) -> Sequence[DocLocation]:
231231
"""The [code locations][mkdocstrings.handlers.crystal.items.DocLocation] over which the definitions of this type span."""
232232
return [
233-
DocLocation(loc["filename"], loc["line_number"], loc["url"])
233+
self.root.update_url(DocLocation(loc["filename"], loc["line_number"], loc["url"]))
234234
for loc in self.data["locations"]
235235
]
236236

@@ -361,15 +361,17 @@ def location(self) -> Optional[DocLocation]:
361361
try:
362362
loc = self.data["location"]
363363
except KeyError:
364-
m = re.fullmatch(
365-
r".+?/(?:blob|tree)/[^/]+/(.+)#L(\d+)", self.data.get("source_link") or ""
364+
loc = None
365+
url = self.data.get("source_link")
366+
if url:
367+
regex = r"(?P<url>.+?/(?:blob|tree)/[^/]+/(?P<filename>.+)#L(?P<line>\d+))"
368+
m = re.fullmatch(regex, url)
369+
if m:
370+
loc = m.groupdict()
371+
if loc:
372+
return self.root.update_url(
373+
DocLocation(loc["filename"], loc["line_number"], loc["url"])
366374
)
367-
if m:
368-
filename, line = m.groups()
369-
return DocLocation(filename, line, self.data.get("source_link"))
370-
else:
371-
if loc:
372-
return DocLocation(loc["filename"], loc["line_number"], loc["url"])
373375

374376

375377
class DocInstanceMethod(DocMethod):

0 commit comments

Comments
 (0)