mkdocs-owl-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mkdocs_owl_api/__init__.py +1 -0
- mkdocs_owl_api/loader.py +223 -0
- mkdocs_owl_api/plugin.py +94 -0
- mkdocs_owl_api/render/__init__.py +0 -0
- mkdocs_owl_api/render/asyncapi.py +570 -0
- mkdocs_owl_api/render/common.py +612 -0
- mkdocs_owl_api/render/openapi.py +293 -0
- mkdocs_owl_api/static/techdocs-owl-api.css +109 -0
- mkdocs_owl_api-0.1.0.dist-info/METADATA +157 -0
- mkdocs_owl_api-0.1.0.dist-info/RECORD +13 -0
- mkdocs_owl_api-0.1.0.dist-info/WHEEL +4 -0
- mkdocs_owl_api-0.1.0.dist-info/entry_points.txt +2 -0
- mkdocs_owl_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
mkdocs_owl_api/loader.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fetch AsyncAPI/OpenAPI specs from local paths or HTTP(S) URLs, resolve external `$ref`s recursively,
|
|
3
|
+
persist the resolved spec plus any declared attachments as downloadable build assets.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .render.common import _error_page
|
|
17
|
+
|
|
18
|
+
ASSET_DIR = "assets/techdocs-owl-api"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fetch_and_parse(uri: str, cache: dict[str, Any]) -> Any:
|
|
22
|
+
"""
|
|
23
|
+
Fetch a URI (HTTP URL or local file path) and parse as JSON/YAML.
|
|
24
|
+
"""
|
|
25
|
+
if uri in cache:
|
|
26
|
+
return cache[uri]
|
|
27
|
+
|
|
28
|
+
if uri.startswith("http://") or uri.startswith("https://"):
|
|
29
|
+
try:
|
|
30
|
+
with urllib.request.urlopen(uri, timeout=30) as resp:
|
|
31
|
+
text = resp.read().decode("utf-8")
|
|
32
|
+
except (urllib.error.URLError, OSError):
|
|
33
|
+
cache[uri] = None
|
|
34
|
+
return None
|
|
35
|
+
else:
|
|
36
|
+
try:
|
|
37
|
+
text = Path(uri).read_text(encoding="utf-8")
|
|
38
|
+
except OSError:
|
|
39
|
+
cache[uri] = None
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
result = json.loads(text)
|
|
44
|
+
except (json.JSONDecodeError, ValueError):
|
|
45
|
+
try:
|
|
46
|
+
result = yaml.safe_load(text)
|
|
47
|
+
except yaml.YAMLError:
|
|
48
|
+
result = None
|
|
49
|
+
cache[uri] = result
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_uri(ref: str, base_uri: str) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Resolve a $ref against a base URI (URL or file path).
|
|
56
|
+
"""
|
|
57
|
+
if ref.startswith("http://") or ref.startswith("https://"):
|
|
58
|
+
return ref
|
|
59
|
+
if base_uri.startswith("http://") or base_uri.startswith("https://"):
|
|
60
|
+
from urllib.parse import urljoin
|
|
61
|
+
return urljoin(base_uri, ref)
|
|
62
|
+
return str((Path(base_uri).parent / ref).resolve())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _base_uri_for(uri: str) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Return the base URI to use for resolving relative refs within a document.
|
|
68
|
+
"""
|
|
69
|
+
return uri
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_external_refs(node: Any, base_uri: str, cache: dict[str, Any] | None = None) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Recursively resolve external `$ref` values in-place.
|
|
75
|
+
Internal `#/...` refs are left untouched.
|
|
76
|
+
"""
|
|
77
|
+
if cache is None:
|
|
78
|
+
cache = {}
|
|
79
|
+
if isinstance(node, dict):
|
|
80
|
+
ref = node.get("$ref")
|
|
81
|
+
if isinstance(ref, str) and not ref.startswith("#"):
|
|
82
|
+
resolved_uri = _resolve_uri(ref, base_uri)
|
|
83
|
+
resolved = _fetch_and_parse(resolved_uri, cache)
|
|
84
|
+
if isinstance(resolved, dict):
|
|
85
|
+
node.pop("$ref")
|
|
86
|
+
node.update(resolved)
|
|
87
|
+
_resolve_external_refs(node, _base_uri_for(resolved_uri), cache)
|
|
88
|
+
return
|
|
89
|
+
for v in node.values():
|
|
90
|
+
_resolve_external_refs(v, base_uri, cache)
|
|
91
|
+
elif isinstance(node, list):
|
|
92
|
+
for item in node:
|
|
93
|
+
if isinstance(item, (dict, list)):
|
|
94
|
+
_resolve_external_refs(item, base_uri, cache)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_spec(spec_ref: str, base: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
98
|
+
"""
|
|
99
|
+
Load and parse (JSON or YAML) an AsyncAPI/OpenAPI spec from a local path or HTTP(S) URL.
|
|
100
|
+
Returns (spec_dict, None) on success or (None, error_page_markdown) on failure.
|
|
101
|
+
"""
|
|
102
|
+
is_url = spec_ref.startswith("http://") or spec_ref.startswith("https://")
|
|
103
|
+
|
|
104
|
+
if is_url:
|
|
105
|
+
try:
|
|
106
|
+
with urllib.request.urlopen(spec_ref, timeout=30) as resp:
|
|
107
|
+
text = resp.read().decode("utf-8")
|
|
108
|
+
except urllib.error.HTTPError as exc:
|
|
109
|
+
return None, _error_page("spec HTTP error", f"`{spec_ref}`: {exc.code} {exc.reason}")
|
|
110
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
111
|
+
return None, _error_page("spec fetch error", f"`{spec_ref}`: {exc}")
|
|
112
|
+
else:
|
|
113
|
+
spec_path = (base / spec_ref).resolve()
|
|
114
|
+
try:
|
|
115
|
+
text = spec_path.read_text(encoding="utf-8")
|
|
116
|
+
except FileNotFoundError:
|
|
117
|
+
return None, _error_page("spec file not found", f"`{spec_path}`")
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
return None, _error_page("spec read error", f"`{spec_path}`: {exc}")
|
|
120
|
+
|
|
121
|
+
source_label = spec_ref if is_url else str(spec_path)
|
|
122
|
+
|
|
123
|
+
spec: Any = None
|
|
124
|
+
try:
|
|
125
|
+
spec = json.loads(text)
|
|
126
|
+
except (json.JSONDecodeError, ValueError):
|
|
127
|
+
try:
|
|
128
|
+
spec = yaml.safe_load(text)
|
|
129
|
+
except yaml.YAMLError as exc:
|
|
130
|
+
return None, _error_page("spec parse error", f"`{source_label}`: {exc}")
|
|
131
|
+
|
|
132
|
+
if spec is None:
|
|
133
|
+
return None, _error_page("spec file is empty", f"`{source_label}` contains no content.")
|
|
134
|
+
if not isinstance(spec, dict):
|
|
135
|
+
return None, _error_page("unexpected spec content", f"`{source_label}` did not parse to a mapping.")
|
|
136
|
+
|
|
137
|
+
_resolve_external_refs(spec, spec_ref if is_url else str(spec_path))
|
|
138
|
+
|
|
139
|
+
return spec, None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_bytes(src: str, base: Path) -> tuple[bytes | None, str | None]:
|
|
143
|
+
"""Read raw bytes from a local path or HTTP(S) URL.
|
|
144
|
+
|
|
145
|
+
Returns (content, None) on success or (None, error_message) on failure.
|
|
146
|
+
"""
|
|
147
|
+
if src.startswith("http://") or src.startswith("https://"):
|
|
148
|
+
try:
|
|
149
|
+
with urllib.request.urlopen(src, timeout=30) as resp:
|
|
150
|
+
return resp.read(), None
|
|
151
|
+
except urllib.error.HTTPError as exc:
|
|
152
|
+
return None, f"{exc.code} {exc.reason}"
|
|
153
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
154
|
+
return None, str(exc)
|
|
155
|
+
path = (base / src).resolve()
|
|
156
|
+
try:
|
|
157
|
+
return path.read_bytes(), None
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
return None, str(exc)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _save_spec(spec: dict[str, Any], page, config) -> str:
|
|
163
|
+
"""Write the resolved spec to both docs/ and site/ {ASSET_DIR}/<slug>.json
|
|
164
|
+
and return the relative URL to the spec file from the page.
|
|
165
|
+
"""
|
|
166
|
+
slug = Path(page.file.src_path).stem
|
|
167
|
+
rel_spec = f"{ASSET_DIR}/{slug}.json"
|
|
168
|
+
content = json.dumps(spec, indent=2, default=str)
|
|
169
|
+
|
|
170
|
+
docs_out = Path(config["docs_dir"]) / rel_spec
|
|
171
|
+
docs_out.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
docs_out.write_text(content, encoding="utf-8")
|
|
173
|
+
|
|
174
|
+
site_out = Path(config["site_dir"]) / rel_spec
|
|
175
|
+
site_out.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
site_out.write_text(content, encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
page_dir = Path(page.file.src_path).parent
|
|
179
|
+
up = "../" * len(page_dir.parts)
|
|
180
|
+
return f"{up}{rel_spec}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _save_attachments(opts: dict[str, Any], page, config) -> list[dict[str, Any]]:
|
|
184
|
+
"""Read each attachment, copy it to {ASSET_DIR}/<slug>-<filename>,
|
|
185
|
+
and return a list of {title, url, error} dicts (url is None on failure).
|
|
186
|
+
"""
|
|
187
|
+
raw = opts.get("attachments")
|
|
188
|
+
if not isinstance(raw, list) or not raw:
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
base = Path(page.file.abs_src_path).resolve().parent
|
|
192
|
+
slug = Path(page.file.src_path).stem
|
|
193
|
+
page_dir = Path(page.file.src_path).parent
|
|
194
|
+
up = "../" * len(page_dir.parts)
|
|
195
|
+
|
|
196
|
+
results: list[dict[str, Any]] = []
|
|
197
|
+
for item in raw:
|
|
198
|
+
if isinstance(item, str):
|
|
199
|
+
src, title = item, None
|
|
200
|
+
elif isinstance(item, dict) and isinstance(item.get("path"), str):
|
|
201
|
+
src, title = item["path"], item.get("title")
|
|
202
|
+
else:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
is_url = src.startswith("http://") or src.startswith("https://")
|
|
206
|
+
filename = src.split("?")[0].rsplit("/", 1)[-1] if is_url else Path(src).name
|
|
207
|
+
label = title or filename
|
|
208
|
+
|
|
209
|
+
content, err = _read_bytes(src, base)
|
|
210
|
+
if err is not None:
|
|
211
|
+
results.append({"title": label, "url": None, "error": err})
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
out_name = f"{slug}-{filename}"
|
|
215
|
+
rel_path = f"{ASSET_DIR}/{out_name}"
|
|
216
|
+
for root_key in ("docs_dir", "site_dir"):
|
|
217
|
+
out = Path(config[root_key]) / rel_path
|
|
218
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
out.write_bytes(content)
|
|
220
|
+
|
|
221
|
+
results.append({"title": label, "url": f"{up}{rel_path}", "error": None})
|
|
222
|
+
|
|
223
|
+
return results
|
mkdocs_owl_api/plugin.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The `owl-api` mkdocs plugin.
|
|
3
|
+
|
|
4
|
+
Renders OpenApi/AsyncApi specification in md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mkdocs.config import config_options as c
|
|
13
|
+
from mkdocs.config.base import Config
|
|
14
|
+
from mkdocs.plugins import BasePlugin, get_plugin_logger
|
|
15
|
+
from mkdocs.structure.files import File
|
|
16
|
+
|
|
17
|
+
from .loader import _load_spec, _save_attachments, _save_spec
|
|
18
|
+
from .render.asyncapi import _render_page as _render_asyncapi_page
|
|
19
|
+
from .render.common import _error_page
|
|
20
|
+
from .render.openapi import _render_openapi_page
|
|
21
|
+
|
|
22
|
+
log = get_plugin_logger(__name__)
|
|
23
|
+
|
|
24
|
+
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
25
|
+
_CSS_FILENAME = "techdocs-owl-api.css"
|
|
26
|
+
_CSS_SRC_URI = f"assets/{_CSS_FILENAME}"
|
|
27
|
+
|
|
28
|
+
_ASYNCAPI_KEY = "techdocs-owl-asyncapi"
|
|
29
|
+
_OPENAPI_KEY = "techdocs-owl-openapi"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OwlApiConfig(Config):
|
|
33
|
+
schema_depth = c.Type(int, default=3)
|
|
34
|
+
hide_internal = c.Type(bool, default=False)
|
|
35
|
+
hide_bindings = c.Type(bool, default=False)
|
|
36
|
+
hide_traits = c.Type(bool, default=False)
|
|
37
|
+
hide_security = c.Type(bool, default=False)
|
|
38
|
+
hide_version = c.Type(bool, default=False)
|
|
39
|
+
hide_download_link = c.Type(bool, default=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize_frontmatter(raw: Any) -> dict[str, Any] | None:
|
|
43
|
+
"""
|
|
44
|
+
Accept the short form (a bare spec path/URL string) or a mapping with a `spec` key. Returns a dict or None if unparseable.
|
|
45
|
+
"""
|
|
46
|
+
if isinstance(raw, str):
|
|
47
|
+
return {"spec": raw}
|
|
48
|
+
if isinstance(raw, dict) and isinstance(raw.get("spec"), str):
|
|
49
|
+
return dict(raw)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OwlApiPlugin(BasePlugin[OwlApiConfig]):
|
|
54
|
+
def on_config(self, config, **kwargs):
|
|
55
|
+
config.extra_css.append(_CSS_SRC_URI)
|
|
56
|
+
return config
|
|
57
|
+
|
|
58
|
+
def on_files(self, files, *, config, **kwargs):
|
|
59
|
+
css_path = _STATIC_DIR / _CSS_FILENAME
|
|
60
|
+
files.append(File.generated(
|
|
61
|
+
config, _CSS_SRC_URI, content=css_path.read_text(encoding="utf-8"),
|
|
62
|
+
))
|
|
63
|
+
return files
|
|
64
|
+
|
|
65
|
+
def on_page_markdown(self, markdown, *, page, config, files, **kwargs):
|
|
66
|
+
defaults = dict(self.config)
|
|
67
|
+
|
|
68
|
+
raw = (page.meta or {}).get(_ASYNCAPI_KEY)
|
|
69
|
+
if raw is not None:
|
|
70
|
+
return self._render(raw, page, config, defaults, kind="asyncapi", key=_ASYNCAPI_KEY)
|
|
71
|
+
|
|
72
|
+
raw = (page.meta or {}).get(_OPENAPI_KEY)
|
|
73
|
+
if raw is not None:
|
|
74
|
+
return self._render(raw, page, config, defaults, kind="openapi", key=_OPENAPI_KEY)
|
|
75
|
+
|
|
76
|
+
return markdown
|
|
77
|
+
|
|
78
|
+
def _render(self, raw, page, config, defaults, *, kind: str, key: str) -> str:
|
|
79
|
+
page_opts = _normalize_frontmatter(raw)
|
|
80
|
+
if page_opts is None:
|
|
81
|
+
return _error_page(
|
|
82
|
+
"invalid frontmatter",
|
|
83
|
+
f"`{key}:` must be a path string or a mapping with a `spec:` key.",
|
|
84
|
+
)
|
|
85
|
+
opts = {**defaults, **page_opts}
|
|
86
|
+
base = Path(page.file.abs_src_path).resolve().parent
|
|
87
|
+
log.info("found %s spec in page '%s' with url '%s'", kind, page.file.src_path, opts["spec"])
|
|
88
|
+
spec, error = _load_spec(opts["spec"], base)
|
|
89
|
+
if error:
|
|
90
|
+
return error
|
|
91
|
+
download_link = _save_spec(spec, page, config)
|
|
92
|
+
attachments = _save_attachments(opts, page, config)
|
|
93
|
+
renderer = _render_asyncapi_page if kind == "asyncapi" else _render_openapi_page
|
|
94
|
+
return renderer(spec, opts, spec_url=download_link, attachments=attachments)
|
|
File without changes
|