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.
@@ -0,0 +1,11 @@
1
+ docs/superpowers/
2
+ .superpowers/
3
+ .env
4
+ .DS_Store
5
+ .venv/
6
+ __pycache__/
7
+ .pytest_cache/
8
+ .models/
9
+ dist/
10
+ site/
11
+ .playwright-mcp/
@@ -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.
@@ -0,0 +1,3 @@
1
+ from passageway_connector_mkdocs.connector import main
2
+
3
+ main()
@@ -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}"