passageway-connector-mkdocs 0.4.0__tar.gz
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.
- passageway_connector_mkdocs-0.4.0/.gitignore +11 -0
- passageway_connector_mkdocs-0.4.0/PKG-INFO +80 -0
- passageway_connector_mkdocs-0.4.0/README.md +71 -0
- passageway_connector_mkdocs-0.4.0/passageway_connector_mkdocs/__init__.py +0 -0
- passageway_connector_mkdocs-0.4.0/passageway_connector_mkdocs/__main__.py +3 -0
- passageway_connector_mkdocs-0.4.0/passageway_connector_mkdocs/connector.py +146 -0
- passageway_connector_mkdocs-0.4.0/pyproject.toml +22 -0
- passageway_connector_mkdocs-0.4.0/tests/conftest.py +61 -0
- passageway_connector_mkdocs-0.4.0/tests/test_mkdocs_connector.py +79 -0
- passageway_connector_mkdocs-0.4.0/tests/test_mkdocs_contract.py +96 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: passageway-connector-mkdocs
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: passageway connector for websites built with MkDocs, read from their search index
|
|
5
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: passageway-sdk[html,web]~=0.4.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# passageway-connector-mkdocs
|
|
11
|
+
|
|
12
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector that indexes a website built with [MkDocs](https://www.mkdocs.org/) or [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/): someone else's documentation, when you have the site but not its repository. It reads the site's search index, the file its search box uses, so it needs no crawling, and every hit opens the page at its heading.
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
- **Each section of the search index is one item.** A built MkDocs site publishes `search/search_index.json`, with an entry for every page and every heading in it. Each entry with text becomes an item whose address is the page at that heading (`https://docs.example.org/guide/setup/#linux`), so a hit lands on the passage, not at the top of a long page. The text before a page's first heading is an item of its own at the page's address.
|
|
17
|
+
- **The page title is the item's title, the heading is its section,** and the folders of the address are its breadcrumb (`path`), so a `path:` filter works.
|
|
18
|
+
- **The id is the address, the version a hash of the text,** so an edit re-indexes only the sections it touched.
|
|
19
|
+
- **A site address means its search index.** `https://docs.example.org` is read as `https://docs.example.org/search/search_index.json`; a site under a path (`https://user.github.io/project`) works the same, and the index's own address can be given too.
|
|
20
|
+
- **Both search plugins are understood.** Material for MkDocs writes HTML into the index, which becomes text with code blocks and lists kept. MkDocs' own search plugin writes plain text and repeats the whole page in the page's entry; only the part before the first heading is kept there, so a passage is not indexed twice.
|
|
21
|
+
- **Any failure fails the whole listing.** A site without a search index, an index that is not one, a server that is down: the sync stops with a message that names the address, so a half-read site never deletes what was indexed before.
|
|
22
|
+
|
|
23
|
+
When the site publishes an `llms-full.txt` (the `mkdocs-llmstxt` plugin writes one), the [llms.txt connector](https://getpassageway.com/connectors/llmstxt/) reads the markdown itself; when you have the repository, the [Markdown connector](https://getpassageway.com/connectors/markdown/) does. This connector is for the site as it is published.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv tool install --python 3.14 "passageway[mkdocs]"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## An example
|
|
34
|
+
|
|
35
|
+
A site `https://docs.example.org` whose `search/search_index.json` is:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{"docs": [
|
|
39
|
+
{"location": "guide/setup/", "title": "Setup", "text": "<p>How to set it up.</p>"},
|
|
40
|
+
{"location": "guide/setup/#linux", "title": "Linux", "text": "<p>Use the <code>apt</code> package.</p>"}
|
|
41
|
+
]}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
gives, for the second entry, the item:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"id": "https://docs.example.org/guide/setup/#linux",
|
|
49
|
+
"title": "Setup",
|
|
50
|
+
"url": "https://docs.example.org/guide/setup/#linux",
|
|
51
|
+
"path": ["guide", "setup"],
|
|
52
|
+
"sections": [
|
|
53
|
+
{
|
|
54
|
+
"heading": "Linux",
|
|
55
|
+
"text": "Use the apt package."
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
and is configured as:
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
[[connector]]
|
|
65
|
+
name = "vendor-docs"
|
|
66
|
+
command = ["/path/to/python", "-m", "passageway_connector_mkdocs"]
|
|
67
|
+
env = { URLS = "https://docs.example.org|https://user.github.io/project", WEIGHTS = "changelog=0.5" }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Settings
|
|
71
|
+
|
|
72
|
+
All settings are strings in the connector's `env`.
|
|
73
|
+
|
|
74
|
+
| Setting | Meaning | Default |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `URLS` | Required. Site addresses, or the addresses of their `search_index.json`, separated by `\|`. | |
|
|
77
|
+
| `MAX_BYTES` | The largest search index that is read, in bytes. A larger one fails the sync. | `50000000` |
|
|
78
|
+
| `WEIGHTS` | Lowers or raises the weight of the sections whose path starts with a prefix: `changelog=0.5,blog/=0.3` (comma-separated `prefix=weight`, the weight above 0, the first match wins). Release notes repeat the vocabulary of how-to pages and push them down the results; a weight below 1 ranks them lower. The path is the page's address without the site's own. | all sections 1.0 |
|
|
79
|
+
|
|
80
|
+
Requests carry the user agent `passageway-connector-mkdocs`, follow redirects and verify HTTPS against the operating system's trust store.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# passageway-connector-mkdocs
|
|
2
|
+
|
|
3
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector that indexes a website built with [MkDocs](https://www.mkdocs.org/) or [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/): someone else's documentation, when you have the site but not its repository. It reads the site's search index, the file its search box uses, so it needs no crawling, and every hit opens the page at its heading.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- **Each section of the search index is one item.** A built MkDocs site publishes `search/search_index.json`, with an entry for every page and every heading in it. Each entry with text becomes an item whose address is the page at that heading (`https://docs.example.org/guide/setup/#linux`), so a hit lands on the passage, not at the top of a long page. The text before a page's first heading is an item of its own at the page's address.
|
|
8
|
+
- **The page title is the item's title, the heading is its section,** and the folders of the address are its breadcrumb (`path`), so a `path:` filter works.
|
|
9
|
+
- **The id is the address, the version a hash of the text,** so an edit re-indexes only the sections it touched.
|
|
10
|
+
- **A site address means its search index.** `https://docs.example.org` is read as `https://docs.example.org/search/search_index.json`; a site under a path (`https://user.github.io/project`) works the same, and the index's own address can be given too.
|
|
11
|
+
- **Both search plugins are understood.** Material for MkDocs writes HTML into the index, which becomes text with code blocks and lists kept. MkDocs' own search plugin writes plain text and repeats the whole page in the page's entry; only the part before the first heading is kept there, so a passage is not indexed twice.
|
|
12
|
+
- **Any failure fails the whole listing.** A site without a search index, an index that is not one, a server that is down: the sync stops with a message that names the address, so a half-read site never deletes what was indexed before.
|
|
13
|
+
|
|
14
|
+
When the site publishes an `llms-full.txt` (the `mkdocs-llmstxt` plugin writes one), the [llms.txt connector](https://getpassageway.com/connectors/llmstxt/) reads the markdown itself; when you have the repository, the [Markdown connector](https://getpassageway.com/connectors/markdown/) does. This connector is for the site as it is published.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv tool install --python 3.14 "passageway[mkdocs]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## An example
|
|
25
|
+
|
|
26
|
+
A site `https://docs.example.org` whose `search/search_index.json` is:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{"docs": [
|
|
30
|
+
{"location": "guide/setup/", "title": "Setup", "text": "<p>How to set it up.</p>"},
|
|
31
|
+
{"location": "guide/setup/#linux", "title": "Linux", "text": "<p>Use the <code>apt</code> package.</p>"}
|
|
32
|
+
]}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
gives, for the second entry, the item:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"id": "https://docs.example.org/guide/setup/#linux",
|
|
40
|
+
"title": "Setup",
|
|
41
|
+
"url": "https://docs.example.org/guide/setup/#linux",
|
|
42
|
+
"path": ["guide", "setup"],
|
|
43
|
+
"sections": [
|
|
44
|
+
{
|
|
45
|
+
"heading": "Linux",
|
|
46
|
+
"text": "Use the apt package."
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
and is configured as:
|
|
53
|
+
|
|
54
|
+
```toml
|
|
55
|
+
[[connector]]
|
|
56
|
+
name = "vendor-docs"
|
|
57
|
+
command = ["/path/to/python", "-m", "passageway_connector_mkdocs"]
|
|
58
|
+
env = { URLS = "https://docs.example.org|https://user.github.io/project", WEIGHTS = "changelog=0.5" }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Settings
|
|
62
|
+
|
|
63
|
+
All settings are strings in the connector's `env`.
|
|
64
|
+
|
|
65
|
+
| Setting | Meaning | Default |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `URLS` | Required. Site addresses, or the addresses of their `search_index.json`, separated by `\|`. | |
|
|
68
|
+
| `MAX_BYTES` | The largest search index that is read, in bytes. A larger one fails the sync. | `50000000` |
|
|
69
|
+
| `WEIGHTS` | Lowers or raises the weight of the sections whose path starts with a prefix: `changelog=0.5,blog/=0.3` (comma-separated `prefix=weight`, the weight above 0, the first match wins). Release notes repeat the vocabulary of how-to pages and push them down the results; a weight below 1 ranks them lower. The path is the page's address without the site's own. | all sections 1.0 |
|
|
70
|
+
|
|
71
|
+
Requests carry the user agent `passageway-connector-mkdocs`, follow redirects and verify HTTPS against the operating system's trust store.
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""passageway connector for websites built with MkDocs: one item per section of the site's search index."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urldefrag, urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from passageway_sdk import Connector, SettingsError, SourceError
|
|
12
|
+
from passageway_sdk.html import clean, to_markdown
|
|
13
|
+
from passageway_sdk.settings import names, weight_of, weights, whole
|
|
14
|
+
from passageway_sdk.web import TooLarge, Web
|
|
15
|
+
|
|
16
|
+
INDEX = "search/search_index.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Section:
|
|
21
|
+
id: str # the address: the site, the page, and the heading's anchor if it has one
|
|
22
|
+
page_title: str
|
|
23
|
+
heading: str | None # None for the text before the page's first heading
|
|
24
|
+
text: str
|
|
25
|
+
where: str # the page's location below the site, for WEIGHTS and the breadcrumb
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _base(url: str) -> str:
|
|
29
|
+
"""The site an address names: the folder of a search index, else the address itself, ending in `/`."""
|
|
30
|
+
url = urldefrag(url)[0]
|
|
31
|
+
if url.endswith(INDEX):
|
|
32
|
+
return url.removesuffix(INDEX)
|
|
33
|
+
return url if url.endswith("/") else url + "/"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _intro(page_text: str, first: str | None) -> str:
|
|
37
|
+
"""The page entry of MkDocs' own search plugin holds the whole page; keep what comes before its first section."""
|
|
38
|
+
if first:
|
|
39
|
+
at = page_text.find(first)
|
|
40
|
+
if at > 0:
|
|
41
|
+
return page_text[:at]
|
|
42
|
+
return page_text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sections(base: str, docs: list[dict]) -> list[Section]:
|
|
46
|
+
"""The sections of a search index, in its order; entries without text are left out, a repeated address counts once."""
|
|
47
|
+
pages: dict[str, str] = {} # page location -> page title
|
|
48
|
+
firsts: dict[str, tuple[str, str]] = {} # page location -> (title, text) of its first section with text
|
|
49
|
+
texts = {d["location"]: to_markdown(d["text"]) for d in docs}
|
|
50
|
+
for d in docs:
|
|
51
|
+
page, _, anchor = d["location"].partition("#")
|
|
52
|
+
if not anchor:
|
|
53
|
+
pages.setdefault(page, clean(d["title"]))
|
|
54
|
+
elif texts[d["location"]]:
|
|
55
|
+
firsts.setdefault(page, (clean(d["title"]), texts[d["location"]]))
|
|
56
|
+
out, seen = [], set()
|
|
57
|
+
for d in docs:
|
|
58
|
+
location = d["location"]
|
|
59
|
+
if location in seen:
|
|
60
|
+
continue
|
|
61
|
+
seen.add(location)
|
|
62
|
+
page, _, anchor = location.partition("#")
|
|
63
|
+
text = texts[location]
|
|
64
|
+
if not anchor:
|
|
65
|
+
# the first section of this page, with its heading, starts where the intro ends
|
|
66
|
+
first_title, first = firsts.get(page, (None, None))
|
|
67
|
+
text = _intro(text, first)
|
|
68
|
+
if first_title and text.rstrip().endswith(first_title):
|
|
69
|
+
text = text.rstrip()[: -len(first_title)]
|
|
70
|
+
text = text.strip()
|
|
71
|
+
if text:
|
|
72
|
+
title = pages.get(page) or clean(d["title"]) or urlparse(base).netloc
|
|
73
|
+
out.append(Section(base + location, title, clean(d["title"]) if anchor else None, text, page))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MkDocs(Connector):
|
|
78
|
+
name = "mkdocs connector"
|
|
79
|
+
|
|
80
|
+
def __init__(self, env: Mapping[str, str], *, transport: httpx.BaseTransport | None = None,
|
|
81
|
+
sleep: Callable[[float], None] = time.sleep):
|
|
82
|
+
urls = names(env, "URLS")
|
|
83
|
+
if not urls:
|
|
84
|
+
raise SettingsError("URLS must name at least one site")
|
|
85
|
+
for url in urls:
|
|
86
|
+
if urlparse(url).scheme not in ("http", "https") or not urlparse(url).netloc:
|
|
87
|
+
raise SettingsError(f"URLS must be http(s) addresses, got {url!r}")
|
|
88
|
+
self.sites = list(dict.fromkeys(_base(u) for u in urls))
|
|
89
|
+
self.max_bytes = whole(env, "MAX_BYTES", 50_000_000)
|
|
90
|
+
self.weights = weights(env, "WEIGHTS")
|
|
91
|
+
self._web = Web("passageway-connector-mkdocs", max_bytes=self.max_bytes, transport=transport, sleep=sleep)
|
|
92
|
+
|
|
93
|
+
# -- the protocol ------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def versions(self) -> dict[str, str]:
|
|
96
|
+
return {s.id: self._version(s) for site in self.sites for s in self._sections(site)}
|
|
97
|
+
|
|
98
|
+
def fetch(self, ids: list[str]) -> Iterator[dict]:
|
|
99
|
+
wanted = set(ids)
|
|
100
|
+
for site in self.sites:
|
|
101
|
+
if any(i.startswith(site) for i in wanted):
|
|
102
|
+
for section in self._sections(site):
|
|
103
|
+
if section.id in wanted:
|
|
104
|
+
yield self._item(section)
|
|
105
|
+
|
|
106
|
+
# -- the sites ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _sections(self, site: str) -> list[Section]:
|
|
109
|
+
url = site + INDEX
|
|
110
|
+
try:
|
|
111
|
+
text = self._web.get(url, required=True).text
|
|
112
|
+
except TooLarge:
|
|
113
|
+
raise SourceError(f"{url}: larger than MAX_BYTES ({self.max_bytes})") from None
|
|
114
|
+
try:
|
|
115
|
+
docs = json.loads(text)["docs"]
|
|
116
|
+
docs = [{"location": str(d["location"]), "title": str(d.get("title") or ""), "text": str(d.get("text") or "")}
|
|
117
|
+
for d in docs]
|
|
118
|
+
except (ValueError, KeyError, TypeError):
|
|
119
|
+
raise SourceError(f"{url}: not an MkDocs search index") from None
|
|
120
|
+
return sections(site, docs)
|
|
121
|
+
|
|
122
|
+
def _weight(self, section: Section) -> float:
|
|
123
|
+
return weight_of(self.weights, section.where)
|
|
124
|
+
|
|
125
|
+
def _version(self, section: Section) -> str:
|
|
126
|
+
weight = self._weight(section) # a changed weight is a changed item, so the index follows it
|
|
127
|
+
body = f"{section.page_title}\0{section.heading}\0{section.text}" + (f"\0{weight}" if weight != 1.0 else "")
|
|
128
|
+
return hashlib.sha256(body.encode()).hexdigest()[:16]
|
|
129
|
+
|
|
130
|
+
def _item(self, section: Section) -> dict:
|
|
131
|
+
item = {
|
|
132
|
+
"id": section.id,
|
|
133
|
+
"title": section.page_title,
|
|
134
|
+
"url": section.id,
|
|
135
|
+
"version": self._version(section),
|
|
136
|
+
"sections": [{"heading": section.heading, "text": section.text}],
|
|
137
|
+
}
|
|
138
|
+
if folders := [p for p in section.where.split("/") if p]:
|
|
139
|
+
item["path"] = folders
|
|
140
|
+
if (weight := self._weight(section)) != 1.0:
|
|
141
|
+
item["weight"] = weight
|
|
142
|
+
return item
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> None:
|
|
146
|
+
MkDocs.main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "passageway-connector-mkdocs"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "passageway connector for websites built with MkDocs, read from their search index"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "PolyForm-Noncommercial-1.0.0"
|
|
12
|
+
dependencies = ["passageway-sdk[web,html]~=0.4.0"]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["passageway_connector_mkdocs"]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = ["jsonschema>=4.23", "pytest>=8.3", "passageway"]
|
|
19
|
+
|
|
20
|
+
[tool.uv.sources]
|
|
21
|
+
passageway = { workspace = true }
|
|
22
|
+
passageway-sdk = { workspace = true }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from passageway_connector_mkdocs.connector import MkDocs
|
|
8
|
+
|
|
9
|
+
SITE = "https://docs.example"
|
|
10
|
+
|
|
11
|
+
# the shape Material for MkDocs writes: a page entry (its text before the first heading) and one entry per section, HTML in text
|
|
12
|
+
INDEX = {
|
|
13
|
+
"config": {"lang": ["en"], "separator": "[\\s\\-]+"},
|
|
14
|
+
"docs": [
|
|
15
|
+
{"location": "", "title": "Home", "text": "<p>Welcome to the docs.</p>"},
|
|
16
|
+
{"location": "guide/setup/", "title": "Setup", "text": "<p>How to set it up.</p>"},
|
|
17
|
+
{"location": "guide/setup/#linux", "title": "Linux",
|
|
18
|
+
"text": "<p>Use the <code>apt</code> package.</p><pre><code>apt install example\n</code></pre>"},
|
|
19
|
+
{"location": "guide/setup/#windows", "title": "Windows", "text": ""},
|
|
20
|
+
{"location": "changelog/", "title": "Changelog", "text": "<p>Version 2 fixes the installer.</p>"},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Sites:
|
|
26
|
+
"""A fake web: url -> JSON body; and a way to run the connector against it in-process."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.index = INDEX
|
|
30
|
+
self.pages = {f"{SITE}/search/search_index.json": INDEX}
|
|
31
|
+
self.requests = []
|
|
32
|
+
self.status = 200
|
|
33
|
+
|
|
34
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
35
|
+
self.requests.append(str(request.url))
|
|
36
|
+
body = self.pages.get(str(request.url))
|
|
37
|
+
if body is None:
|
|
38
|
+
return httpx.Response(404)
|
|
39
|
+
return httpx.Response(self.status, text=body if isinstance(body, str) else json.dumps(body))
|
|
40
|
+
|
|
41
|
+
def call(self, mode, stdin="", **settings):
|
|
42
|
+
out, err = io.StringIO(), io.StringIO()
|
|
43
|
+
env = {"URLS": SITE, **settings}
|
|
44
|
+
code = MkDocs.run(["mkdocs", mode], env, io.StringIO(stdin), out, err, transport=httpx.MockTransport(self.handler),
|
|
45
|
+
sleep=lambda s: None)
|
|
46
|
+
return code, [json.loads(line) for line in out.getvalue().splitlines()], err.getvalue()
|
|
47
|
+
|
|
48
|
+
def versions(self, **settings):
|
|
49
|
+
code, records, err = self.call("list", **settings)
|
|
50
|
+
assert code == 0, err
|
|
51
|
+
return {r["id"]: r["version"] for r in records}
|
|
52
|
+
|
|
53
|
+
def fetch(self, *ids, **settings):
|
|
54
|
+
code, records, err = self.call("fetch", "".join(json.dumps({"id": i}) + "\n" for i in ids), **settings)
|
|
55
|
+
assert code == 0, err
|
|
56
|
+
return {r["id"]: r for r in records}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def sites():
|
|
61
|
+
return Sites()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
SITE = "https://docs.example" # the site of the fixture
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_every_section_with_text_is_an_item_whose_address_opens_at_its_heading(sites):
|
|
7
|
+
assert set(sites.versions()) == {f"{SITE}/", f"{SITE}/guide/setup/", f"{SITE}/guide/setup/#linux", f"{SITE}/changelog/"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_a_section_carries_its_page_title_and_heading_and_the_folders_as_breadcrumb(sites):
|
|
11
|
+
item = sites.fetch(f"{SITE}/guide/setup/#linux")[f"{SITE}/guide/setup/#linux"]
|
|
12
|
+
assert item["title"] == "Setup"
|
|
13
|
+
assert item["url"] == f"{SITE}/guide/setup/#linux"
|
|
14
|
+
assert item["path"] == ["guide", "setup"]
|
|
15
|
+
assert item["sections"] == [{"heading": "Linux", "text": "Use the apt package.\n\n```\napt install example\n```"}]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_the_text_before_the_first_heading_is_the_page_item(sites):
|
|
19
|
+
item = sites.fetch(f"{SITE}/guide/setup/")[f"{SITE}/guide/setup/"]
|
|
20
|
+
assert item["title"] == "Setup" and item["sections"] == [{"heading": None, "text": "How to set it up."}]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_plain_mkdocs_repeats_the_whole_page_in_its_page_entry_and_only_the_intro_is_kept(sites):
|
|
24
|
+
# mkdocs' own search plugin: tag-stripped text, and the page entry holds the whole page, headings included
|
|
25
|
+
sites.pages[f"{SITE}/search/search_index.json"] = {"docs": [
|
|
26
|
+
{"location": "setup/", "title": "Setup", "text": "How to set it up. Linux Use the package. Windows Use the installer."},
|
|
27
|
+
{"location": "setup/#linux", "title": "Linux", "text": "Use the package."},
|
|
28
|
+
{"location": "setup/#windows", "title": "Windows", "text": "Use the installer."},
|
|
29
|
+
]}
|
|
30
|
+
items = sites.fetch(f"{SITE}/setup/", f"{SITE}/setup/#windows")
|
|
31
|
+
assert items[f"{SITE}/setup/"]["sections"] == [{"heading": None, "text": "How to set it up."}]
|
|
32
|
+
assert items[f"{SITE}/setup/#windows"]["sections"] == [{"heading": "Windows", "text": "Use the installer."}]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_a_site_under_a_path_and_an_index_address_both_work(sites):
|
|
36
|
+
sites.pages["https://user.example/project/search/search_index.json"] = sites.index
|
|
37
|
+
assert f"https://user.example/project/guide/setup/#linux" in sites.versions(URLS="https://user.example/project")
|
|
38
|
+
assert f"https://user.example/project/changelog/" in sites.versions(URLS="https://user.example/project/search/search_index.json")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_an_edit_changes_only_that_sections_version(sites):
|
|
42
|
+
before = sites.versions()
|
|
43
|
+
index = json.loads(json.dumps(sites.index))
|
|
44
|
+
index["docs"][2]["text"] = "<p>Use the <code>dnf</code> package.</p>"
|
|
45
|
+
sites.pages[f"{SITE}/search/search_index.json"] = index
|
|
46
|
+
after = sites.versions()
|
|
47
|
+
assert [i for i in before if before[i] != after[i]] == [f"{SITE}/guide/setup/#linux"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_a_weight_per_path_prefix_lowers_a_part_of_a_site_and_changes_its_version(sites):
|
|
51
|
+
before = sites.versions()
|
|
52
|
+
items = sites.fetch(f"{SITE}/changelog/", f"{SITE}/guide/setup/", WEIGHTS="changelog=0.5")
|
|
53
|
+
assert items[f"{SITE}/changelog/"]["weight"] == 0.5 and "weight" not in items[f"{SITE}/guide/setup/"]
|
|
54
|
+
assert sites.versions(WEIGHTS="changelog=0.5")[f"{SITE}/changelog/"] != before[f"{SITE}/changelog/"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_a_site_without_a_search_index_fails_the_listing_and_names_it(sites):
|
|
58
|
+
code, records, err = sites.call("list", URLS="https://other.example")
|
|
59
|
+
assert code == 1 and records == [] and "https://other.example/search/search_index.json" in err
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_server_that_is_down_fails_the_listing(sites):
|
|
63
|
+
sites.status = 503
|
|
64
|
+
assert sites.call("list")[0] == 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_an_index_that_is_not_an_mkdocs_index_fails_the_listing(sites):
|
|
68
|
+
sites.pages[f"{SITE}/search/search_index.json"] = "<html>not json</html>"
|
|
69
|
+
code, _, err = sites.call("list")
|
|
70
|
+
assert code == 1 and "search index" in err
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_fetch_skips_ids_that_are_gone_or_belong_to_no_site(sites):
|
|
74
|
+
assert sites.fetch(f"{SITE}/gone/", "https://elsewhere.example/x/", "not an address") == {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_settings_are_checked(sites):
|
|
78
|
+
for bad in ({"URLS": ""}, {"URLS": "ftp://docs.example"}, {"WEIGHTS": "changelog"}, {"MAX_BYTES": "0"}):
|
|
79
|
+
assert sites.call("list", **bad)[0] == 2, bad
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import http.server
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import jsonschema
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from passageway.protocol import Connector
|
|
15
|
+
from passageway.testing.contract import check_connector
|
|
16
|
+
|
|
17
|
+
README = Path(__file__).resolve().parents[1] / "README.md"
|
|
18
|
+
SCHEMA = Path(__file__).resolve().parents[3] / "docs" / "reference" / "connector-protocol.schema.json"
|
|
19
|
+
INDEX = {"docs": [
|
|
20
|
+
{"location": "", "title": "Home", "text": "<p>Welcome.</p>"},
|
|
21
|
+
{"location": "setup/", "title": "Setup", "text": "<p>How to set it up.</p>"},
|
|
22
|
+
{"location": "setup/#linux", "title": "Linux", "text": "<p>Use the package manager.</p>"},
|
|
23
|
+
]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Site(str):
|
|
27
|
+
def __new__(cls, url, root):
|
|
28
|
+
site = super().__new__(cls, url)
|
|
29
|
+
site.root = root
|
|
30
|
+
return site
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def site(tmp_path):
|
|
35
|
+
"""A real web server on localhost serving a built site's search index, so the connector runs as a real subprocess."""
|
|
36
|
+
(tmp_path / "search").mkdir()
|
|
37
|
+
(tmp_path / "search" / "search_index.json").write_text(json.dumps(INDEX))
|
|
38
|
+
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))
|
|
39
|
+
handler.log_message = lambda *args: None
|
|
40
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
41
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
42
|
+
yield Site(f"http://127.0.0.1:{server.server_port}", tmp_path)
|
|
43
|
+
server.shutdown()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def command():
|
|
47
|
+
return [sys.executable, "-m", "passageway_connector_mkdocs"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_the_connector_passes_the_shared_contract_as_a_real_subprocess(site):
|
|
51
|
+
connector = Connector("mkdocs", command(), env={"URLS": site})
|
|
52
|
+
check_connector(connector)
|
|
53
|
+
items = {i.id: i for i in connector.fetch([f"{site}/setup/#linux"])}
|
|
54
|
+
assert items[f"{site}/setup/#linux"].title == "Setup"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_the_output_validates_against_the_published_json_schema(site):
|
|
58
|
+
schema = json.loads(SCHEMA.read_text())
|
|
59
|
+
|
|
60
|
+
def valid(name, record):
|
|
61
|
+
return jsonschema.Draft202012Validator({"$ref": f"#/$defs/{name}", "$defs": schema["$defs"]}).is_valid(record)
|
|
62
|
+
|
|
63
|
+
def lines(mode, stdin=""):
|
|
64
|
+
out = subprocess.run(command() + [mode], env={**os.environ, "URLS": site}, input=stdin,
|
|
65
|
+
capture_output=True, text=True, check=True).stdout
|
|
66
|
+
return [json.loads(line) for line in out.splitlines()]
|
|
67
|
+
|
|
68
|
+
assert lines("check") and all(valid("check", r) for r in lines("check"))
|
|
69
|
+
assert lines("list") and all(valid("listRecord", r) for r in lines("list"))
|
|
70
|
+
items = lines("fetch", f'{{"id": "{site}/setup/#linux"}}\n')
|
|
71
|
+
assert len(items) == 1 and valid("item", items[0])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_every_setting_is_in_the_readme():
|
|
75
|
+
code = (Path(__file__).resolve().parents[1] / "passageway_connector_mkdocs" / "connector.py").read_text()
|
|
76
|
+
names = sorted(set(re.findall(r'(?:env\.get|names|whole|flag|seconds|weights)\(\s*(?:env,\s*)?"([A-Z_]+)"', code)))
|
|
77
|
+
assert len(names) >= 3, names # the pattern must actually find the settings
|
|
78
|
+
readme = README.read_text()
|
|
79
|
+
for name in names:
|
|
80
|
+
assert f"`{name}`" in readme, f"{name} is read by the mkdocs connector but missing from its README"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_the_example_in_the_readme_is_what_the_connector_really_produces(site):
|
|
84
|
+
readme = README.read_text()
|
|
85
|
+
index = json.loads(re.search(r"```json\n(.*?)```", readme, re.S).group(1))
|
|
86
|
+
shown = json.loads(re.findall(r"```json\n(.*?)```", readme, re.S)[1])
|
|
87
|
+
(site.root / "search" / "search_index.json").write_text(json.dumps(index))
|
|
88
|
+
location = re.search(r'"id": "https://docs\.example\.org/(.*?)"', readme).group(1)
|
|
89
|
+
out = subprocess.run(command() + ["fetch"], env={**os.environ, "URLS": site}, input=f'{{"id": "{site}/{location}"}}\n',
|
|
90
|
+
capture_output=True, text=True, check=True).stdout
|
|
91
|
+
real = json.loads(out)
|
|
92
|
+
for key, value in shown.items():
|
|
93
|
+
if key in ("id", "url"):
|
|
94
|
+
value = value.replace("https://docs.example.org", site)
|
|
95
|
+
if key != "version":
|
|
96
|
+
assert real[key] == value, f"the README shows {key} = {value!r} but the connector prints {real[key]!r}"
|