-
Notifications
You must be signed in to change notification settings - Fork 28
/
Copy pathrender.py
211 lines (175 loc) · 6.96 KB
/
render.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
from __future__ import annotations
import inspect
import json
import os
import re
import sys
from contextlib import contextmanager
from functools import lru_cache, partial
from inspect import getsource
from pathlib import Path
from types import FunctionType
from typing import Dict, Optional, Set
from urllib.request import urlopen
import yaml
from jinja2 import Environment, FileSystemLoader, select_autoescape
from npe2 import PluginManager, PluginManifest
from npe2.manifest.contributions import ContributionPoints
from npe2.manifest.utils import Executable
SCHEMA_URL = "https://github.com/napari/npe2/releases/latest/download/schema.json"
DOCS = Path(__file__).parent
TEMPLATES = DOCS / "templates"
_BUILD = DOCS.parent / "docs" / "plugins"
EXAMPLE_MANIFEST = PluginManifest.from_file(DOCS / "example_manifest.yaml")
@contextmanager
def _mocked_qtwidgets():
# just mocking a "qtpy.QtWidgets" so we don't need to include PyQt just to build
# documentation.
from types import ModuleType
mock = ModuleType("qtpy.QtWidgets")
mock.__dict__["QWidget"] = object
before, sys.modules["qtpy.QtWidgets"] = sys.modules.get("qtpy.QtWidgets"), mock
try:
yield
finally:
if before is not None:
sys.modules["qtpy.QtWidgets"] = mock
else:
del sys.modules["qtpy.QtWidgets"]
@lru_cache
def type_strings() -> Dict[str, str]:
"""Return map of type name to source code for all types in types.py"""
from npe2 import types as _t
type_strings = {}
type_lines = getsource(_t).splitlines()
for r, line in enumerate(type_lines):
if not line or line.startswith((" ", "#", "]", ")", "if", "from")):
continue
end = 0
if r + 1 >= len(type_lines):
continue
next_line = type_lines[r + 1]
if next_line.startswith(" "):
end = next(
(
i
for i, x in enumerate(type_lines[r + 1 :])
if not x.startswith((" ", "#"))
)
)
if end:
end += 1
name = line.split()[0]
if name == "class":
name = line.split()[1].split("(")[0]
type_strings[name] = "\n".join(type_lines[r : r + end + 1])
return type_strings
def _get_needed_types(source: str, so_far: Optional[Set[str]] = None) -> Set[str]:
"""Return the names of types in the npe2.types.py that are used in `source`"""
so_far = so_far or set()
for name, string in type_strings().items():
# we treat LayerData specially
if (
name != "LayerData"
and name not in so_far
and re.search(rf"\W{name}\W", source)
):
so_far.add(name)
so_far.update(_get_needed_types(string, so_far=so_far))
return so_far
def _build_example(contrib: Executable) -> str:
"""Extract just the source code for a specific executable contribution"""
if not isinstance(contrib, Executable):
return ""
with _mocked_qtwidgets():
func = contrib.get_callable()
if not callable(func):
return ""
if isinstance(func, partial):
func = func.keywords["function"]
source = inspect.getsource(func)
# additionally get source code of all internally referenced functions
# i.e. for get_reader we also get the source for the returned reader.
if isinstance(func, FunctionType):
for name in func.__code__.co_names:
if name in func.__globals__:
f = func.__globals__[name]
source += "\n\n" + inspect.getsource(f)
needed = _get_needed_types(source)
lines = [v for k, v in type_strings().items() if k in needed]
if lines:
lines.extend(["", ""])
lines.extend(source.splitlines())
return "\n".join(lines)
def example_implementation(contrib_name: str) -> str:
"""Build an example string of python source implementing a specific contribution."""
contrib = getattr(EXAMPLE_MANIFEST.contributions, contrib_name)
if isinstance(contrib, list):
return "\n\n".join([_build_example(x) for x in contrib]).strip()
return _build_example(contrib)
def example_contribution(
contrib_name: str, format="yaml", manifest: PluginManifest = EXAMPLE_MANIFEST
) -> str:
"""Get small manifest example for just contribution named `contrib_name`"""
assert manifest.contributions
contribs = getattr(manifest.contributions, contrib_name)
# only take the first command example ... the rest are for executables
if contrib_name == "commands":
contribs = [contribs[0]]
ex = ContributionPoints(**{contrib_name: contribs})
# for "executables", include associated command
ExampleCommands = manifest.contributions.commands
assert ExampleCommands
for c in contribs or ():
if isinstance(c, Executable):
associated_command = next(i for i in ExampleCommands if i.id == c.command)
if not ex.commands:
ex.commands = []
ex.commands.append(associated_command)
output = {"contributions": json.loads(ex.json(exclude_unset=True))}
if format == "yaml":
return yaml.safe_dump(output, sort_keys=False)
if format == "toml":
import tomli_w
return tomli_w.dumps(output)
if format == "json":
return json.dumps(output)
raise ValueError("Invalid format: {format}. Must be 'yaml', 'toml' or 'json'.")
def has_guide(contrib_name: str) -> bool:
"""Return true if a guide exists for this contribution."""
return (TEMPLATES / f"_npe2_{contrib_name}_guide.md.jinja").exists()
def main(dest: Path = _BUILD):
"""Render all jinja docs in ./templates and output to `dest`"""
# register the example plugin so we can use `.get_callable()` in _build_example
sys.path.append(str(DOCS.absolute()))
PluginManager.instance().register(EXAMPLE_MANIFEST)
env = Environment(
loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape()
)
env.filters["example_contribution"] = example_contribution
env.filters["example_implementation"] = example_implementation
env.filters["has_guide"] = has_guide
dest.mkdir(exist_ok=True, parents=True)
schema = PluginManifest.schema()
if local_schema := os.getenv("NPE2_SCHEMA"):
with open(local_schema) as f:
schema = json.load(f)
else:
with urlopen(SCHEMA_URL) as response:
schema = json.load(response)
contributions = schema["definitions"]["ContributionPoints"]["properties"]
context = {
"schema": schema,
"contributions": contributions,
"example": EXAMPLE_MANIFEST,
# "specs": _get_specs(),
"specs": {},
}
for t in TEMPLATES.glob("*.jinja"):
template = env.get_template(t.name)
_dest = dest / f"{t.stem}"
_dest.write_text(template.render(context), encoding="utf-8")
print(f"Rendered {_dest}")
if __name__ == "__main__":
dest = Path(sys.argv[1]).absolute() if len(sys.argv) > 1 else _BUILD
main(dest)