passageway-connector-llmstxt 0.3.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,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: passageway-connector-llmstxt
3
+ Version: 0.3.0
4
+ Summary: passageway connector for the llms-full.txt of a list of websites
5
+ License-Expression: PolyForm-Noncommercial-1.0.0
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: passageway-sdk[web]~=0.3.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # passageway-connector-llmstxt
11
+
12
+ A [passageway](https://github.com/getpassageway/passageway#readme) connector that indexes the `llms-full.txt` of a list of websites, a single markdown file such as a `SKILL.md`, or the pages an `llms.txt` index links to. Most documentation sites publish one of these for language models, and this connector makes them searchable without crawling.
13
+
14
+ ## What it does
15
+
16
+ - **Each top-level heading is one item.** A `llms-full.txt` that concatenates a whole site becomes one item per page, with the sections below each heading as passages. Its id is the file's address plus the heading (`https://docs.example.org/llms-full.txt#install`), and its version is a hash of the text, so an edit re-indexes only that page. A `#` inside a code block is not a heading, and front matter at the top of a file is not indexed.
17
+ - **A front matter block with a `url:` key starts a page, wherever it is in the file.** Some `llms-full.txt` files (the VitePress llms plugin writes them) open every page with `---`, `url: /howto/install.md` and `---`. Each such page is one item that runs to the next block, however many headings it has. Its hit links to the page itself (`https://docs.example.org/howto/install`, the `.md` dropped), its title is the page's first heading (else the last part of the path), and its `path` is the host and the folders of the page, so a `path:` filter works. Its id is the file's address plus the `url:` value. A file without such blocks is split by heading, as above.
18
+ - **A site address reads its `llms-full.txt`.** `https://docs.example.org` is read as `https://docs.example.org/llms-full.txt`.
19
+ - **A file address is read as it is:** `https://docs.example.org/llms-full.txt`, `https://passageway.marceltov.de/SKILL.md`, any markdown or text file.
20
+ - **An `llms.txt` index is followed.** Every link in it that stays on the same host is fetched and indexed as a file of its own. Links to other hosts are not followed.
21
+ - **Any failure fails the whole listing.** A page that returns an error, or a server that is down, stops the sync with a message that names it, so a half-read site never deletes what was indexed before.
22
+
23
+ ## Install
24
+
25
+ Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
26
+
27
+ ```bash
28
+ uv tool install \
29
+ --with 'passageway-connector-llmstxt @ git+https://github.com/getpassageway/passageway#subdirectory=connectors/llmstxt' \
30
+ git+https://github.com/getpassageway/passageway
31
+ ```
32
+
33
+ ## An example
34
+
35
+ A file `https://docs.example.org/llms-full.txt`:
36
+
37
+ ```markdown
38
+ # Install
39
+
40
+ Run the installer.
41
+
42
+ ## Linux
43
+
44
+ Use the package manager.
45
+ ```
46
+
47
+ becomes the item:
48
+
49
+ ```json
50
+ {
51
+ "title": "Install",
52
+ "sections": [
53
+ {
54
+ "heading": null,
55
+ "text": "Run the installer."
56
+ },
57
+ {
58
+ "heading": "Linux",
59
+ "text": "Use the package manager."
60
+ }
61
+ ]
62
+ }
63
+ ```
64
+
65
+ and is configured as:
66
+
67
+ ```toml
68
+ [[connector]]
69
+ name = "docs"
70
+ command = ["/path/to/python", "-m", "passageway_connector_llmstxt"]
71
+ env = { URLS = "https://docs.example.org|https://passageway.marceltov.de/SKILL.md" }
72
+ ```
73
+
74
+ For a site whose `llms-full.txt` opens every page with a `url:` front matter block, and whose release notes should rank below its how-to pages:
75
+
76
+ ```toml
77
+ [[connector]]
78
+ name = "docs"
79
+ command = ["/path/to/python", "-m", "passageway_connector_llmstxt"]
80
+ env = { URLS = "https://docs.example.org", WEIGHTS = "changelog=0.5" }
81
+ ```
82
+
83
+ ## Settings
84
+
85
+ All settings are strings in the connector's `env`.
86
+
87
+ | Setting | Meaning | Default |
88
+ |---|---|---|
89
+ | `URLS` | Required. Site addresses, file addresses and `llms.txt` addresses, separated by `\|`. | |
90
+ | `MAX_BYTES` | The largest file that is read, in bytes. A larger one fails the sync. | `20000000` |
91
+ | `WEIGHTS` | Lowers or raises the weight of the pages whose path starts with a prefix: `changelog=0.5,archive/=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 host. | all pages 1.0 |
92
+ | `MAX_PAGES` | The most pages followed from one `llms.txt` index. The rest are skipped with a warning. | `500` |
93
+
94
+ Requests carry the user agent `passageway-connector-llmstxt`, follow redirects and verify HTTPS against the operating system's trust store.
@@ -0,0 +1,85 @@
1
+ # passageway-connector-llmstxt
2
+
3
+ A [passageway](https://github.com/getpassageway/passageway#readme) connector that indexes the `llms-full.txt` of a list of websites, a single markdown file such as a `SKILL.md`, or the pages an `llms.txt` index links to. Most documentation sites publish one of these for language models, and this connector makes them searchable without crawling.
4
+
5
+ ## What it does
6
+
7
+ - **Each top-level heading is one item.** A `llms-full.txt` that concatenates a whole site becomes one item per page, with the sections below each heading as passages. Its id is the file's address plus the heading (`https://docs.example.org/llms-full.txt#install`), and its version is a hash of the text, so an edit re-indexes only that page. A `#` inside a code block is not a heading, and front matter at the top of a file is not indexed.
8
+ - **A front matter block with a `url:` key starts a page, wherever it is in the file.** Some `llms-full.txt` files (the VitePress llms plugin writes them) open every page with `---`, `url: /howto/install.md` and `---`. Each such page is one item that runs to the next block, however many headings it has. Its hit links to the page itself (`https://docs.example.org/howto/install`, the `.md` dropped), its title is the page's first heading (else the last part of the path), and its `path` is the host and the folders of the page, so a `path:` filter works. Its id is the file's address plus the `url:` value. A file without such blocks is split by heading, as above.
9
+ - **A site address reads its `llms-full.txt`.** `https://docs.example.org` is read as `https://docs.example.org/llms-full.txt`.
10
+ - **A file address is read as it is:** `https://docs.example.org/llms-full.txt`, `https://passageway.marceltov.de/SKILL.md`, any markdown or text file.
11
+ - **An `llms.txt` index is followed.** Every link in it that stays on the same host is fetched and indexed as a file of its own. Links to other hosts are not followed.
12
+ - **Any failure fails the whole listing.** A page that returns an error, or a server that is down, stops the sync with a message that names it, so a half-read site never deletes what was indexed before.
13
+
14
+ ## Install
15
+
16
+ Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
17
+
18
+ ```bash
19
+ uv tool install \
20
+ --with 'passageway-connector-llmstxt @ git+https://github.com/getpassageway/passageway#subdirectory=connectors/llmstxt' \
21
+ git+https://github.com/getpassageway/passageway
22
+ ```
23
+
24
+ ## An example
25
+
26
+ A file `https://docs.example.org/llms-full.txt`:
27
+
28
+ ```markdown
29
+ # Install
30
+
31
+ Run the installer.
32
+
33
+ ## Linux
34
+
35
+ Use the package manager.
36
+ ```
37
+
38
+ becomes the item:
39
+
40
+ ```json
41
+ {
42
+ "title": "Install",
43
+ "sections": [
44
+ {
45
+ "heading": null,
46
+ "text": "Run the installer."
47
+ },
48
+ {
49
+ "heading": "Linux",
50
+ "text": "Use the package manager."
51
+ }
52
+ ]
53
+ }
54
+ ```
55
+
56
+ and is configured as:
57
+
58
+ ```toml
59
+ [[connector]]
60
+ name = "docs"
61
+ command = ["/path/to/python", "-m", "passageway_connector_llmstxt"]
62
+ env = { URLS = "https://docs.example.org|https://passageway.marceltov.de/SKILL.md" }
63
+ ```
64
+
65
+ For a site whose `llms-full.txt` opens every page with a `url:` front matter block, and whose release notes should rank below its how-to pages:
66
+
67
+ ```toml
68
+ [[connector]]
69
+ name = "docs"
70
+ command = ["/path/to/python", "-m", "passageway_connector_llmstxt"]
71
+ env = { URLS = "https://docs.example.org", WEIGHTS = "changelog=0.5" }
72
+ ```
73
+
74
+ ## Settings
75
+
76
+ All settings are strings in the connector's `env`.
77
+
78
+ | Setting | Meaning | Default |
79
+ |---|---|---|
80
+ | `URLS` | Required. Site addresses, file addresses and `llms.txt` addresses, separated by `\|`. | |
81
+ | `MAX_BYTES` | The largest file that is read, in bytes. A larger one fails the sync. | `20000000` |
82
+ | `WEIGHTS` | Lowers or raises the weight of the pages whose path starts with a prefix: `changelog=0.5,archive/=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 host. | all pages 1.0 |
83
+ | `MAX_PAGES` | The most pages followed from one `llms.txt` index. The rest are skipped with a warning. | `500` |
84
+
85
+ Requests carry the user agent `passageway-connector-llmstxt`, follow redirects and verify HTTPS against the operating system's trust store.
@@ -0,0 +1,3 @@
1
+ from passageway_connector_llmstxt.connector import main
2
+
3
+ main()
@@ -0,0 +1,209 @@
1
+ """passageway connector for the llms-full.txt (and llms.txt) of a list of websites."""
2
+
3
+ import hashlib
4
+ import re
5
+ import time
6
+ from collections.abc import Callable, Iterator, Mapping
7
+ from dataclasses import dataclass
8
+ from urllib.parse import urldefrag, urljoin, urlparse
9
+
10
+ import httpx
11
+ from passageway_sdk import Connector, SettingsError, SourceError, split_markdown
12
+ from passageway_sdk.settings import whole
13
+ from passageway_sdk.web import TooLarge, Web
14
+
15
+ FRONT_MATTER = re.compile(r"\A---[ \t]*\r?\n.*?\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\Z)", re.S)
16
+ LINK = re.compile(r"\]\(\s*<?([^)\s>]+)")
17
+ FENCE = re.compile(r"^\s*(```|~~~)")
18
+ PAGE_URL = re.compile(r"url:[ \t]*(\S+)[ \t]*$")
19
+ BLOCK_LINES = 20 # a front matter block longer than this is not one
20
+
21
+
22
+ def _weights(text: str) -> list[tuple[str, float]]:
23
+ """`changelog=0.5,archive/=0.3`: a weight for every page whose path starts with a prefix; the first match wins."""
24
+ out = []
25
+ for part in (p.strip() for p in text.split(",") if p.strip()):
26
+ prefix, _, value = part.partition("=")
27
+ try:
28
+ weight = float(value)
29
+ except ValueError:
30
+ weight = 0.0
31
+ if not prefix.strip() or weight <= 0:
32
+ raise SettingsError(f"WEIGHTS must be prefix=weight pairs with a weight above 0, got {part!r}")
33
+ out.append((prefix.strip().lstrip("/"), weight))
34
+ return out
35
+
36
+
37
+ def _slug(title: str) -> str:
38
+ return re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "page"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Page:
43
+ id: str
44
+ title: str
45
+ file: str
46
+ text: str
47
+ url: str = "" # the address a hit opens; the file's own when the page has none of its own
48
+ path: tuple[str, ...] = ()
49
+
50
+
51
+ def split_pages(file: str, text: str) -> list[Page]:
52
+ """Pages of a file: at every front matter block with a `url:` key (the layout of the VitePress llms plugin), else at every
53
+ top-level heading. A block or a `#` inside a code fence does not count; text before the first block is split by heading."""
54
+ lines = text.splitlines()
55
+ starts = _page_blocks(lines)
56
+ if not starts:
57
+ return _by_headings(file, FRONT_MATTER.sub("", text, count=1))
58
+ pages = _by_headings(file, "\n".join(lines[:starts[0][0]]))
59
+ used = {p.id for p in pages}
60
+ for n, (start, end, url) in enumerate(starts):
61
+ stop = starts[n + 1][0] if n + 1 < len(starts) else len(lines)
62
+ body = "\n".join(lines[end:stop]).strip()
63
+ if body:
64
+ pages.append(_page_of_url(file, url, body, used))
65
+ return pages
66
+
67
+
68
+ def _page_blocks(lines: list[str]) -> list[tuple[int, int, str]]:
69
+ """(first line, line after the last, url) of every front matter block with a `url:` key, outside code fences."""
70
+ blocks, fenced, i = [], False, 0
71
+ while i < len(lines):
72
+ if FENCE.match(lines[i]):
73
+ fenced = not fenced
74
+ elif not fenced and lines[i].strip() == "---":
75
+ close = next((j for j in range(i + 1, min(i + BLOCK_LINES, len(lines))) if lines[j].strip() == "---"), None)
76
+ found = close and next((m.group(1) for m in map(PAGE_URL.match, (l.strip() for l in lines[i + 1:close])) if m), None)
77
+ if found:
78
+ blocks.append((i, close + 1, found))
79
+ i = close
80
+ i += 1
81
+ return blocks
82
+
83
+
84
+ def _page_of_url(file: str, url: str, body: str, used: set[str]) -> Page:
85
+ path = urlparse(urljoin(file, url)).path
86
+ target = urljoin(file, url[:-3] if url.endswith(".md") else url) # the HTML page, not the raw markdown
87
+ heading = next((l[2:].strip() for l in body.splitlines() if l.startswith("# ") and l[2:].strip()), "")
88
+ title = heading or (path.rstrip("/").rsplit("/", 1)[-1].removesuffix(".md") or urlparse(file).netloc)
89
+ page_id, n = f"{file}#{url}", 1
90
+ while page_id in used:
91
+ n += 1
92
+ page_id = f"{file}#{url}-{n}"
93
+ used.add(page_id)
94
+ folders = tuple(p for p in path.split("/")[1:-1] if p)
95
+ return Page(page_id, title, file, body, target, (urlparse(file).netloc, *folders))
96
+
97
+
98
+ def _by_headings(file: str, text: str) -> list[Page]:
99
+ """One page per top-level heading; text before the first goes to a page of its own."""
100
+ host = urlparse(file).netloc
101
+ chunks: list[tuple[str, list[str]]] = [(host, [])]
102
+ fenced = False
103
+ for line in text.splitlines():
104
+ if FENCE.match(line):
105
+ fenced = not fenced
106
+ if not fenced and line.startswith("# "):
107
+ chunks.append((line[2:].strip() or host, [line]))
108
+ else:
109
+ chunks[-1][1].append(line)
110
+ pages, used = [], set()
111
+ for title, lines in chunks:
112
+ body = "\n".join(lines).strip()
113
+ if not body:
114
+ continue
115
+ slug, n = _slug(title), 1
116
+ while slug in used:
117
+ n += 1
118
+ slug = f"{_slug(title)}-{n}"
119
+ used.add(slug)
120
+ pages.append(Page(f"{file}#{slug}", title, file, body, file, (host,)))
121
+ return pages
122
+
123
+
124
+ class LlmsTxt(Connector):
125
+ name = "llmstxt connector"
126
+
127
+ def __init__(self, env: Mapping[str, str], *, transport: httpx.BaseTransport | None = None,
128
+ sleep: Callable[[float], None] = time.sleep):
129
+ self.urls = [u.strip() for u in env.get("URLS", "").split("|") if u.strip()]
130
+ if not self.urls:
131
+ raise SettingsError("URLS must name at least one site or file")
132
+ for url in self.urls:
133
+ if urlparse(url).scheme not in ("http", "https") or not urlparse(url).netloc:
134
+ raise SettingsError(f"URLS must be http(s) addresses, got {url!r}")
135
+ self.max_bytes = whole(env, "MAX_BYTES", 20_000_000)
136
+ self.max_pages = whole(env, "MAX_PAGES", 500)
137
+ self.weights = _weights(env.get("WEIGHTS", ""))
138
+ self._web = Web("passageway-connector-llmstxt", max_bytes=self.max_bytes, transport=transport, sleep=sleep)
139
+
140
+ # -- the protocol ------------------------------------------------------------------------
141
+
142
+ def versions(self) -> dict[str, str]:
143
+ return {p.id: self._version(p) for p in self._pages()}
144
+
145
+ def fetch(self, ids: list[str]) -> Iterator[dict]:
146
+ wanted = set(ids)
147
+ for file in dict.fromkeys(i.split("#")[0] for i in ids if urlparse(i).scheme in ("http", "https")): # an id that is no address names nothing
148
+ for page in split_pages(file, self._get(file)):
149
+ if page.id in wanted:
150
+ yield self._item(page)
151
+
152
+ # -- the sites ---------------------------------------------------------------------------
153
+
154
+ def _files(self) -> list[str]:
155
+ files: list[str] = []
156
+ for url in self.urls:
157
+ parsed = urlparse(url)
158
+ if parsed.path in ("", "/"):
159
+ files.append(f"{parsed.scheme}://{parsed.netloc}/llms-full.txt")
160
+ elif parsed.path.rsplit("/", 1)[-1] == "llms.txt":
161
+ files.extend(self._linked(url))
162
+ else:
163
+ files.append(urldefrag(url)[0])
164
+ return list(dict.fromkeys(files))
165
+
166
+ def _linked(self, index: str) -> list[str]:
167
+ host, links = urlparse(index).netloc, []
168
+ for target in LINK.findall(self._get(index)):
169
+ link = urldefrag(urljoin(index, target))[0]
170
+ if urlparse(link).scheme in ("http", "https") and urlparse(link).netloc == host and link != index:
171
+ links.append(link)
172
+ links = list(dict.fromkeys(links))
173
+ if len(links) > self.max_pages:
174
+ self.warn(f"{index} links to {len(links)} pages; only the first {self.max_pages} are indexed (MAX_PAGES)")
175
+ return links[:self.max_pages]
176
+
177
+ def _pages(self) -> list[Page]:
178
+ return [page for file in self._files() for page in split_pages(file, self._get(file))]
179
+
180
+ def _get(self, url: str) -> str:
181
+ try:
182
+ return self._web.get(url, required=True).text
183
+ except TooLarge:
184
+ raise SourceError(f"{url}: larger than MAX_BYTES ({self.max_bytes})") from None
185
+
186
+ def _weight(self, page: Page) -> float:
187
+ where = urlparse(page.url).path.lstrip("/")
188
+ return next((w for prefix, w in self.weights if where.startswith(prefix)), 1.0)
189
+
190
+ def _version(self, page: Page) -> str:
191
+ weight = self._weight(page) # a changed weight is a changed item, so the index follows it
192
+ return hashlib.sha256((page.text if weight == 1.0 else f"{page.text}\0{weight}").encode()).hexdigest()[:16]
193
+
194
+ def _item(self, page: Page) -> dict:
195
+ item = {
196
+ "id": page.id,
197
+ "title": page.title,
198
+ "url": page.url,
199
+ "version": self._version(page),
200
+ "sections": split_markdown(page.text, skip_first_h1=True),
201
+ "path": list(page.path),
202
+ }
203
+ if self._weight(page) != 1.0:
204
+ item["weight"] = self._weight(page)
205
+ return item
206
+
207
+
208
+ def main() -> None:
209
+ LlmsTxt.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-llmstxt"
7
+ version = "0.3.0"
8
+ description = "passageway connector for the llms-full.txt of a list of websites"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "PolyForm-Noncommercial-1.0.0"
12
+ dependencies = ["passageway-sdk[web]~=0.3.0"]
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["passageway_connector_llmstxt"]
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,54 @@
1
+ import io
2
+ import json
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from passageway_connector_llmstxt.connector import LlmsTxt
8
+
9
+ DOCS = """# Install
10
+
11
+ Run the installer.
12
+
13
+ ## Linux
14
+
15
+ Use the package manager.
16
+
17
+ # Configure
18
+
19
+ Set the token in the settings file.
20
+
21
+ ```
22
+ # not a heading
23
+ ```
24
+ """
25
+
26
+
27
+ class Sites:
28
+ """A fake web: path -> (status, body); and a way to run the connector against it in-process."""
29
+
30
+ def __init__(self):
31
+ self.pages = {"https://docs.example/llms-full.txt": DOCS}
32
+ self.requests = []
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
+ return httpx.Response(404) if body is None else httpx.Response(200, text=body)
38
+
39
+ def call(self, mode, stdin="", **settings):
40
+ out, err = io.StringIO(), io.StringIO()
41
+ env = {"URLS": "https://docs.example", **settings}
42
+ code = LlmsTxt.run(["llmstxt", mode], env, io.StringIO(stdin), out, err, transport=httpx.MockTransport(self.handler),
43
+ sleep=lambda s: None)
44
+ return code, [json.loads(line) for line in out.getvalue().splitlines()], err.getvalue()
45
+
46
+ def fetch(self, *ids, **settings):
47
+ code, records, err = self.call("fetch", "".join(json.dumps({"id": i}) + "\n" for i in ids), **settings)
48
+ assert code == 0, err
49
+ return {r["id"]: r for r in records}
50
+
51
+
52
+ @pytest.fixture
53
+ def sites():
54
+ return Sites()
@@ -0,0 +1,183 @@
1
+ import json
2
+
3
+
4
+ def test_each_top_level_heading_is_an_item_of_the_file(sites):
5
+ code, records, err = sites.call("list")
6
+ assert code == 0, err
7
+ ids = [r["id"] for r in records]
8
+ assert ids == ["https://docs.example/llms-full.txt#install", "https://docs.example/llms-full.txt#configure"]
9
+
10
+
11
+ def test_a_page_has_its_title_sections_and_the_file_as_address(sites):
12
+ page = sites.fetch("https://docs.example/llms-full.txt#install")["https://docs.example/llms-full.txt#install"]
13
+ assert page["title"] == "Install" and page["url"] == "https://docs.example/llms-full.txt"
14
+ assert page["path"] == ["docs.example"]
15
+ assert {"heading": "Linux", "text": "Use the package manager."} in page["sections"]
16
+
17
+
18
+ def test_a_hash_in_a_code_block_does_not_start_a_page(sites):
19
+ page = sites.fetch("https://docs.example/llms-full.txt#configure")["https://docs.example/llms-full.txt#configure"]
20
+ assert "not a heading" in json.dumps(page["sections"])
21
+ assert len(sites.call("list")[1]) == 2
22
+
23
+
24
+ def test_the_version_changes_only_when_a_page_changes(sites):
25
+ before = {r["id"]: r["version"] for r in sites.call("list")[1]}
26
+ sites.pages["https://docs.example/llms-full.txt"] = sites.pages["https://docs.example/llms-full.txt"].replace("installer", "setup tool")
27
+ after = {r["id"]: r["version"] for r in sites.call("list")[1]}
28
+ assert before["https://docs.example/llms-full.txt#configure"] == after["https://docs.example/llms-full.txt#configure"]
29
+ assert before["https://docs.example/llms-full.txt#install"] != after["https://docs.example/llms-full.txt#install"]
30
+
31
+
32
+ def test_a_direct_url_and_several_sites_are_read(sites):
33
+ sites.pages["https://other.example/docs/llms-full.txt"] = "# Other\n\nText."
34
+ code, records, err = sites.call("list", URLS="https://docs.example|https://other.example/docs/llms-full.txt")
35
+ assert code == 0, err
36
+ assert len(records) == 3
37
+
38
+
39
+ def test_a_file_without_a_heading_is_one_item_named_after_the_host(sites):
40
+ sites.pages["https://docs.example/llms-full.txt"] = "Just text."
41
+ page = next(iter(sites.fetch("https://docs.example/llms-full.txt#docs-example").values()))
42
+ assert page["title"] == "docs.example"
43
+
44
+
45
+ def test_a_missing_file_fails_the_whole_listing_so_nothing_is_deleted(sites):
46
+ code, records, err = sites.call("list", URLS="https://docs.example|https://gone.example")
47
+ assert code == 1 and records == [] and "gone.example" in err
48
+
49
+
50
+ def test_settings_are_checked(sites):
51
+ assert sites.call("list", URLS="")[0] == 2
52
+ assert sites.call("list", URLS="ftp://x.example")[0] == 2
53
+ assert sites.call("list", MAX_BYTES="0")[0] == 2
54
+
55
+
56
+ def test_a_file_over_the_size_limit_fails_the_listing(sites):
57
+ code, _, err = sites.call("list", MAX_BYTES="10")
58
+ assert code == 1 and "MAX_BYTES" in err
59
+
60
+
61
+ def test_a_single_markdown_file_with_front_matter_is_one_item(sites):
62
+ sites.pages["https://docs.example/SKILL.md"] = "---\nname: passageway\n---\n# Passageway\n\nAsk the sources.\n"
63
+ code, records, err = sites.call("list", URLS="https://docs.example/SKILL.md")
64
+ assert code == 0, err
65
+ assert [r["id"] for r in records] == ["https://docs.example/SKILL.md#passageway"]
66
+ page = sites.fetch("https://docs.example/SKILL.md#passageway", URLS="https://docs.example/SKILL.md")
67
+ assert "name: passageway" not in json.dumps(page) # the front matter is not indexed as text
68
+
69
+
70
+ def test_an_llms_txt_index_is_followed_page_by_page_on_the_same_host(sites):
71
+ sites.pages["https://docs.example/llms.txt"] = (
72
+ "# Example\n\n> A site.\n\n## Docs\n\n- [Start](https://docs.example/start.md): begin here\n"
73
+ "- [Relative](/guide/setup.md)\n- [Elsewhere](https://evil.example/x.md)\n- [Start again](https://docs.example/start.md#top)\n")
74
+ sites.pages["https://docs.example/start.md"] = "# Start\n\nBegin here."
75
+ sites.pages["https://docs.example/guide/setup.md"] = "# Setup\n\nInstall it."
76
+ code, records, err = sites.call("list", URLS="https://docs.example/llms.txt")
77
+ assert code == 0, err
78
+ assert [r["id"] for r in records] == ["https://docs.example/start.md#start", "https://docs.example/guide/setup.md#setup"]
79
+ assert not any("evil.example" in url for url in sites.requests)
80
+
81
+
82
+ def test_a_broken_link_in_an_index_fails_the_listing(sites):
83
+ sites.pages["https://docs.example/llms.txt"] = "# Example\n\n- [Gone](https://docs.example/gone.md)\n"
84
+ code, records, err = sites.call("list", URLS="https://docs.example/llms.txt")
85
+ assert code == 1 and records == [] and "gone.md" in err
86
+
87
+
88
+ def test_the_number_of_pages_followed_from_an_index_is_capped(sites):
89
+ sites.pages["https://docs.example/llms.txt"] = "".join(f"- [P{n}](https://docs.example/p{n}.md)\n" for n in range(5))
90
+ for n in range(5):
91
+ sites.pages[f"https://docs.example/p{n}.md"] = f"# P{n}\n\ntext"
92
+ code, records, err = sites.call("list", URLS="https://docs.example/llms.txt", MAX_PAGES="3")
93
+ assert code == 0 and len(records) == 3 and "MAX_PAGES" in err
94
+
95
+
96
+ TEAMSCALE = """---
97
+ url: /changelog/v1/patch-releases.md
98
+ ---
99
+
100
+ # Patch Releases
101
+
102
+ Fixes.
103
+
104
+ ## Details
105
+
106
+ # Not a new page
107
+
108
+ Still the same page.
109
+
110
+ ---
111
+ url: /howto/install.md
112
+ ---
113
+
114
+ # Install
115
+
116
+ Run it.
117
+
118
+ ---
119
+ url: /howto/no-heading.md
120
+ ---
121
+
122
+ Text without any heading.
123
+ """
124
+ FULL = "https://docs.example/llms-full.txt"
125
+
126
+
127
+ def test_front_matter_with_a_url_starts_a_page_wherever_it_is_in_the_file(sites):
128
+ sites.pages[FULL] = TEAMSCALE
129
+ ids = [r["id"] for r in sites.call("list")[1]]
130
+ assert ids == [f"{FULL}#/changelog/v1/patch-releases.md", f"{FULL}#/howto/install.md", f"{FULL}#/howto/no-heading.md"]
131
+
132
+
133
+ def test_such_a_page_links_to_its_own_address_and_carries_its_folders(sites):
134
+ sites.pages[FULL] = TEAMSCALE
135
+ page = sites.fetch(f"{FULL}#/howto/install.md")[f"{FULL}#/howto/install.md"]
136
+ assert page["url"] == "https://docs.example/howto/install" # the .md of the file's path is dropped
137
+ assert page["title"] == "Install"
138
+ assert page["path"] == ["docs.example", "howto"]
139
+ assert "url:" not in json.dumps(page["sections"]) # the front matter block is not text
140
+
141
+
142
+ def test_a_page_runs_to_the_next_block_even_over_its_own_headings(sites):
143
+ sites.pages[FULL] = TEAMSCALE
144
+ page = sites.fetch(f"{FULL}#/changelog/v1/patch-releases.md")[f"{FULL}#/changelog/v1/patch-releases.md"]
145
+ assert "Still the same page" in json.dumps(page["sections"])
146
+ assert page["path"] == ["docs.example", "changelog", "v1"]
147
+
148
+
149
+ def test_a_page_without_a_heading_is_titled_by_the_last_part_of_its_path(sites):
150
+ sites.pages[FULL] = TEAMSCALE
151
+ page = sites.fetch(f"{FULL}#/howto/no-heading.md")[f"{FULL}#/howto/no-heading.md"]
152
+ assert page["title"] == "no-heading"
153
+
154
+
155
+ def test_text_before_the_first_block_is_still_indexed(sites):
156
+ sites.pages[FULL] = "# Intro\n\nWelcome.\n\n" + TEAMSCALE
157
+ ids = [r["id"] for r in sites.call("list")[1]]
158
+ assert ids[0] == f"{FULL}#intro" and len(ids) == 4
159
+
160
+
161
+ def test_front_matter_in_a_code_block_is_not_a_page(sites):
162
+ sites.pages[FULL] = "# Guide\n\n```\n---\nurl: /fake.md\n---\n```\n"
163
+ assert [r["id"] for r in sites.call("list")[1]] == [f"{FULL}#guide"]
164
+
165
+
166
+ def test_a_weight_per_path_prefix_lowers_a_part_of_a_site(sites):
167
+ sites.pages[FULL] = TEAMSCALE
168
+ pages = sites.fetch(f"{FULL}#/changelog/v1/patch-releases.md", f"{FULL}#/howto/install.md", WEIGHTS="changelog=0.5")
169
+ assert pages[f"{FULL}#/changelog/v1/patch-releases.md"]["weight"] == 0.5
170
+ assert "weight" not in pages[f"{FULL}#/howto/install.md"]
171
+
172
+
173
+ def test_a_changed_weight_changes_the_version_so_the_index_follows(sites):
174
+ sites.pages[FULL] = TEAMSCALE
175
+ before = {r["id"]: r["version"] for r in sites.call("list")[1]}
176
+ after = {r["id"]: r["version"] for r in sites.call("list", WEIGHTS="changelog=0.5")[1]}
177
+ assert before[f"{FULL}#/changelog/v1/patch-releases.md"] != after[f"{FULL}#/changelog/v1/patch-releases.md"]
178
+ assert before[f"{FULL}#/howto/install.md"] == after[f"{FULL}#/howto/install.md"]
179
+
180
+
181
+ def test_weights_are_checked(sites):
182
+ for bad in ("changelog", "changelog=heavy", "changelog=0", "=0.5"):
183
+ assert sites.call("list", WEIGHTS=bad)[0] == 2, bad
@@ -0,0 +1,87 @@
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
+ DOCS = "# Install\n\nRun the installer.\n\n## Linux\n\nUse the package manager.\n\n# Configure\n\nSet the token.\n"
20
+
21
+
22
+ class Site(str):
23
+ def __new__(cls, url, root):
24
+ site = super().__new__(cls, url)
25
+ site.root = root
26
+ return site
27
+
28
+
29
+ @pytest.fixture
30
+ def site(tmp_path):
31
+ """A real web server on localhost serving a llms-full.txt, so the connector runs as a real subprocess."""
32
+ (tmp_path / "llms-full.txt").write_text(DOCS)
33
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))
34
+ handler.log_message = lambda *args: None
35
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
36
+ threading.Thread(target=server.serve_forever, daemon=True).start()
37
+ yield Site(f"http://127.0.0.1:{server.server_port}", tmp_path)
38
+ server.shutdown()
39
+
40
+
41
+ def command():
42
+ return [sys.executable, "-m", "passageway_connector_llmstxt"]
43
+
44
+
45
+ def test_the_connector_passes_the_shared_contract_as_a_real_subprocess(site):
46
+ connector = Connector("llmstxt", command(), env={"URLS": site})
47
+ check_connector(connector)
48
+ items = {i.id: i for i in connector.fetch([f"{site}/llms-full.txt#install"])}
49
+ assert items[f"{site}/llms-full.txt#install"].title == "Install"
50
+
51
+
52
+ def test_the_output_validates_against_the_published_json_schema(site):
53
+ schema = json.loads(SCHEMA.read_text())
54
+
55
+ def valid(name, record):
56
+ return jsonschema.Draft202012Validator({"$ref": f"#/$defs/{name}", "$defs": schema["$defs"]}).is_valid(record)
57
+
58
+ def lines(mode, stdin=""):
59
+ out = subprocess.run(command() + [mode], env={**os.environ, "URLS": site}, input=stdin,
60
+ capture_output=True, text=True, check=True).stdout
61
+ return [json.loads(line) for line in out.splitlines()]
62
+
63
+ assert lines("check") and all(valid("check", r) for r in lines("check"))
64
+ assert lines("list") and all(valid("listRecord", r) for r in lines("list"))
65
+ items = lines("fetch", f'{{"id": "{site}/llms-full.txt#install"}}\n')
66
+ assert len(items) == 1 and valid("item", items[0])
67
+
68
+
69
+ def test_every_setting_is_in_the_readme():
70
+ code = (Path(__file__).resolve().parents[1] / "passageway_connector_llmstxt" / "connector.py").read_text()
71
+ names = sorted(set(re.findall(r'(?:env\.get|names|whole|flag|seconds)\(\s*(?:env,\s*)?"([A-Z_]+)"', code)) | {"URLS"})
72
+ assert len(names) >= 3, names # the pattern must actually find the settings
73
+ readme = README.read_text()
74
+ for name in names:
75
+ assert f"`{name}`" in readme, f"{name} is read by the llmstxt connector but missing from its README"
76
+
77
+
78
+ def test_the_example_in_the_readme_is_what_the_connector_really_produces(site):
79
+ readme = README.read_text()
80
+ text = re.search(r"```markdown\n(.*?)```", readme, re.S).group(1)
81
+ shown = json.loads(re.search(r"```json\n(.*?)```", readme, re.S).group(1))
82
+ (site.root / "example.md").write_text(text)
83
+ out = subprocess.run(command() + ["fetch"], env={**os.environ, "URLS": f"{site}/example.md"},
84
+ input=f'{{"id": "{site}/example.md#install"}}\n', capture_output=True, text=True, check=True).stdout
85
+ real = json.loads(out)
86
+ for key, value in shown.items():
87
+ assert real[key] == value, f"the README shows {key} = {value!r} but the connector prints {real[key]!r}"