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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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
@@ -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