passageway-connector-confluence 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.
- passageway_connector_confluence-0.3.0/.gitignore +11 -0
- passageway_connector_confluence-0.3.0/PKG-INFO +58 -0
- passageway_connector_confluence-0.3.0/README.md +48 -0
- passageway_connector_confluence-0.3.0/passageway_connector_confluence/__init__.py +1 -0
- passageway_connector_confluence-0.3.0/passageway_connector_confluence/__main__.py +3 -0
- passageway_connector_confluence-0.3.0/passageway_connector_confluence/connector.py +307 -0
- passageway_connector_confluence-0.3.0/passageway_connector_confluence/html.py +160 -0
- passageway_connector_confluence-0.3.0/pyproject.toml +22 -0
- passageway_connector_confluence-0.3.0/tests/conftest.py +42 -0
- passageway_connector_confluence-0.3.0/tests/fake_confluence.py +142 -0
- passageway_connector_confluence-0.3.0/tests/test_confluence_connector.py +175 -0
- passageway_connector_confluence-0.3.0/tests/test_confluence_contract.py +38 -0
- passageway_connector_confluence-0.3.0/tests/test_storage.py +57 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: passageway-connector-confluence
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: passageway connector for Confluence Data Center: the pages of chosen spaces, read-only
|
|
5
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: passageway-sdk[web]~=0.3.0
|
|
8
|
+
Requires-Dist: selectolax>=0.3.21
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# passageway-connector-confluence
|
|
12
|
+
|
|
13
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector for **Confluence Data Center** (and Server): the current pages of the spaces you name, read through the REST API with GET requests only, using a personal access token. Written from the public REST documentation.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
- **Each page is one item.** Its id is the content id, its version changes with every page version and with its labels, and its link opens the page in Confluence.
|
|
18
|
+
- **Headings become sections:** `h1`, `h2` and `h3` split a page into passages with their heading trail (`Setup › Linux`); lists, tables and code macros are kept readable; long sections are cut at about 350 tokens.
|
|
19
|
+
- **Labels become labels** (`space:OPS` plus the page's own), **ancestors become the path** (nearest last, up to four), and every page weighs 1.0.
|
|
20
|
+
- **Macros:** code and noformat macros keep their text; boxes, expands, sections, columns, excerpts and every other macro with a body keep what they hold; a status macro keeps its title; a table of contents, Jira issues, images, emoticons and other macros without a body are left out. Task lists become lists. Links keep their text, or the linked page's title. Blog posts and attachments are not indexed.
|
|
21
|
+
- **Edits appear within a minute:** `changed` asks Confluence with CQL for the pages modified since the last poll (fourteen hours early, because CQL reads the time in the server's zone; unchanged versions cost nothing). Deletions, moves out of the spaces and labels that take a page out of scope are settled at the next reconcile. A space that lists no pages is reported as a warning, so a wrong key or a token without permission does not pass in silence.
|
|
22
|
+
|
|
23
|
+
## Configure
|
|
24
|
+
|
|
25
|
+
Create a personal access token under your profile in Confluence (Data Center and Server 7.9 and later). The connector only ever sends GET requests.
|
|
26
|
+
|
|
27
|
+
```toml
|
|
28
|
+
[[connector]]
|
|
29
|
+
name = "wiki"
|
|
30
|
+
command = ["/path/to/python", "-m", "passageway_connector_confluence"]
|
|
31
|
+
env = { URL = "https://wiki.example.org", SPACES = "OPS|HR" }
|
|
32
|
+
secrets = { TOKEN = "env:CONFLUENCE_TOKEN" }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Setting | Default | Meaning |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `URL` | required | The Confluence site, e.g. `https://wiki.example.org` (with its context path, if any) |
|
|
38
|
+
| `SPACES` | required | Space keys to index, separated by `\|` |
|
|
39
|
+
| `TOKEN` | required | A personal access token, as a secret reference such as `env:CONFLUENCE_TOKEN` |
|
|
40
|
+
| `INCLUDE_LABELS` | | Only pages carrying one of these labels, separated by `\|` |
|
|
41
|
+
| `EXCLUDE_LABELS` | | Pages carrying one of these labels are left out |
|
|
42
|
+
| `MAX_PAGES` | `5000` | Upper bound on the spaces together; more fails with a message instead of indexing a subset |
|
|
43
|
+
| `TIMEOUT` | `30` | Seconds per request |
|
|
44
|
+
| `PAUSE_SECONDS` | `0` | Pause between requests, for a busy server |
|
|
45
|
+
| `VERIFY_TLS` | `true` | `false` turns certificate checking off. A CA your operating system trusts already works without it (see [Certificates](https://passageway.marceltov.de/configuration/#certificates)), so prefer adding the CA there |
|
|
46
|
+
|
|
47
|
+
## Behaviour
|
|
48
|
+
|
|
49
|
+
- **Retries:** 429 and 5xx answers and network errors are retried with a growing wait (1 s, doubling up to 60 s; a longer `Retry-After` is honoured up to 300 s) until a request has waited 15 minutes in total; the error then says how many attempts and how long it waited. Every wait of 10 s or more is logged as it starts (`HTTP 429 … waiting 16 s (15 s of 900 s waited)`), so a sync that waits does not look hung. A 401 says to check the token; any other error fails the call and nothing is stored.
|
|
50
|
+
- **A throttled or large space** needs more than the core's default `timeout = 300` for the whole `list` or `fetch`: waiting out one rate limit can take minutes. Raise `timeout` in its `[[connector]]` entry (`3600` is a start), or set `PAUSE_SECONDS` so fewer requests are throttled in the first place.
|
|
51
|
+
- **Cloud:** Atlassian Cloud authenticates with an e-mail address and an API token and serves the API under `/wiki`; it is not supported yet ([ADR 0005](https://passageway.marceltov.de/adr/0005-confluence-data-center-first/)).
|
|
52
|
+
- **Tested against a fake** of the Data Center API in this repository, not against a live Confluence.
|
|
53
|
+
|
|
54
|
+
## Test
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv run pytest connectors/confluence # offline, against an in-test Confluence
|
|
58
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# passageway-connector-confluence
|
|
2
|
+
|
|
3
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector for **Confluence Data Center** (and Server): the current pages of the spaces you name, read through the REST API with GET requests only, using a personal access token. Written from the public REST documentation.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- **Each page is one item.** Its id is the content id, its version changes with every page version and with its labels, and its link opens the page in Confluence.
|
|
8
|
+
- **Headings become sections:** `h1`, `h2` and `h3` split a page into passages with their heading trail (`Setup › Linux`); lists, tables and code macros are kept readable; long sections are cut at about 350 tokens.
|
|
9
|
+
- **Labels become labels** (`space:OPS` plus the page's own), **ancestors become the path** (nearest last, up to four), and every page weighs 1.0.
|
|
10
|
+
- **Macros:** code and noformat macros keep their text; boxes, expands, sections, columns, excerpts and every other macro with a body keep what they hold; a status macro keeps its title; a table of contents, Jira issues, images, emoticons and other macros without a body are left out. Task lists become lists. Links keep their text, or the linked page's title. Blog posts and attachments are not indexed.
|
|
11
|
+
- **Edits appear within a minute:** `changed` asks Confluence with CQL for the pages modified since the last poll (fourteen hours early, because CQL reads the time in the server's zone; unchanged versions cost nothing). Deletions, moves out of the spaces and labels that take a page out of scope are settled at the next reconcile. A space that lists no pages is reported as a warning, so a wrong key or a token without permission does not pass in silence.
|
|
12
|
+
|
|
13
|
+
## Configure
|
|
14
|
+
|
|
15
|
+
Create a personal access token under your profile in Confluence (Data Center and Server 7.9 and later). The connector only ever sends GET requests.
|
|
16
|
+
|
|
17
|
+
```toml
|
|
18
|
+
[[connector]]
|
|
19
|
+
name = "wiki"
|
|
20
|
+
command = ["/path/to/python", "-m", "passageway_connector_confluence"]
|
|
21
|
+
env = { URL = "https://wiki.example.org", SPACES = "OPS|HR" }
|
|
22
|
+
secrets = { TOKEN = "env:CONFLUENCE_TOKEN" }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Setting | Default | Meaning |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `URL` | required | The Confluence site, e.g. `https://wiki.example.org` (with its context path, if any) |
|
|
28
|
+
| `SPACES` | required | Space keys to index, separated by `\|` |
|
|
29
|
+
| `TOKEN` | required | A personal access token, as a secret reference such as `env:CONFLUENCE_TOKEN` |
|
|
30
|
+
| `INCLUDE_LABELS` | | Only pages carrying one of these labels, separated by `\|` |
|
|
31
|
+
| `EXCLUDE_LABELS` | | Pages carrying one of these labels are left out |
|
|
32
|
+
| `MAX_PAGES` | `5000` | Upper bound on the spaces together; more fails with a message instead of indexing a subset |
|
|
33
|
+
| `TIMEOUT` | `30` | Seconds per request |
|
|
34
|
+
| `PAUSE_SECONDS` | `0` | Pause between requests, for a busy server |
|
|
35
|
+
| `VERIFY_TLS` | `true` | `false` turns certificate checking off. A CA your operating system trusts already works without it (see [Certificates](https://passageway.marceltov.de/configuration/#certificates)), so prefer adding the CA there |
|
|
36
|
+
|
|
37
|
+
## Behaviour
|
|
38
|
+
|
|
39
|
+
- **Retries:** 429 and 5xx answers and network errors are retried with a growing wait (1 s, doubling up to 60 s; a longer `Retry-After` is honoured up to 300 s) until a request has waited 15 minutes in total; the error then says how many attempts and how long it waited. Every wait of 10 s or more is logged as it starts (`HTTP 429 … waiting 16 s (15 s of 900 s waited)`), so a sync that waits does not look hung. A 401 says to check the token; any other error fails the call and nothing is stored.
|
|
40
|
+
- **A throttled or large space** needs more than the core's default `timeout = 300` for the whole `list` or `fetch`: waiting out one rate limit can take minutes. Raise `timeout` in its `[[connector]]` entry (`3600` is a start), or set `PAUSE_SECONDS` so fewer requests are throttled in the first place.
|
|
41
|
+
- **Cloud:** Atlassian Cloud authenticates with an e-mail address and an API token and serves the API under `/wiki`; it is not supported yet ([ADR 0005](https://passageway.marceltov.de/adr/0005-confluence-data-center-first/)).
|
|
42
|
+
- **Tested against a fake** of the Data Center API in this repository, not against a live Confluence.
|
|
43
|
+
|
|
44
|
+
## Test
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv run pytest connectors/confluence # offline, against an in-test Confluence
|
|
48
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""passageway connector for Confluence Data Center: the current pages of chosen spaces, read through the REST API with GET only.
|
|
2
|
+
|
|
3
|
+
Written from the public REST documentation. Settings come from the environment and are documented in README.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator, Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from passageway_sdk import Connector, SettingsError, SourceError
|
|
16
|
+
from passageway_sdk.web import HttpError, Reply, Retry, Web
|
|
17
|
+
from passageway_sdk.settings import names
|
|
18
|
+
|
|
19
|
+
from passageway_connector_confluence.html import html_to_sections
|
|
20
|
+
|
|
21
|
+
PAGE = 100 # results per request (the API's usual maximum)
|
|
22
|
+
RETRY_BUDGET = 900 # seconds of waiting per request before giving up: a throttled Data Center takes minutes
|
|
23
|
+
MAX_BACKOFF = 60 # the longest wait the connector picks itself
|
|
24
|
+
MAX_RETRY_AFTER = 300 # the longest wait a Retry-After may ask for
|
|
25
|
+
SAY_WAITS_FROM = 10 # seconds: a wait this long is said in the log, shorter ones stay quiet
|
|
26
|
+
RETRY_STATUS = (429, 500, 502, 503, 504)
|
|
27
|
+
FETCH_EXPAND = "body.storage,version,space,ancestors,metadata.labels"
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Settings:
|
|
30
|
+
url: str
|
|
31
|
+
spaces: tuple[str, ...]
|
|
32
|
+
token: str
|
|
33
|
+
include: frozenset[str]
|
|
34
|
+
exclude: frozenset[str]
|
|
35
|
+
max_pages: int
|
|
36
|
+
timeout: float
|
|
37
|
+
pause: float
|
|
38
|
+
verify: bool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_settings(env: Mapping[str, str]) -> Settings:
|
|
42
|
+
url = env.get("URL", "").strip().rstrip("/")
|
|
43
|
+
parsed = urlparse(url)
|
|
44
|
+
if not url or parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
45
|
+
raise SettingsError(f"URL must be the Confluence site's http(s) address, got {url!r}")
|
|
46
|
+
spaces = names(env, "SPACES")
|
|
47
|
+
if not spaces or not all(re.fullmatch(r"[A-Za-z0-9_~.-]+", s) for s in spaces):
|
|
48
|
+
raise SettingsError("SPACES must list the space keys to index, separated by |, e.g. OPS|HR")
|
|
49
|
+
token = env.get("TOKEN", "").strip()
|
|
50
|
+
if not token:
|
|
51
|
+
raise SettingsError("TOKEN is required: a personal access token from your Confluence profile, as a secret reference")
|
|
52
|
+
raw = env.get("MAX_PAGES", "").strip()
|
|
53
|
+
if raw and (not raw.isdecimal() or int(raw) < 1):
|
|
54
|
+
raise SettingsError(f"MAX_PAGES must be a whole number from 1, got {raw!r}")
|
|
55
|
+
try:
|
|
56
|
+
timeout = float(env.get("TIMEOUT", "").strip() or 30)
|
|
57
|
+
pause = float(env.get("PAUSE_SECONDS", "").strip() or 0)
|
|
58
|
+
except ValueError:
|
|
59
|
+
raise SettingsError("TIMEOUT and PAUSE_SECONDS must be numbers of seconds") from None
|
|
60
|
+
if timeout <= 0 or pause < 0:
|
|
61
|
+
raise SettingsError("TIMEOUT must be greater than 0 and PAUSE_SECONDS not negative")
|
|
62
|
+
verify = env.get("VERIFY_TLS", "").strip().lower() or "true"
|
|
63
|
+
if verify not in ("true", "false"):
|
|
64
|
+
raise SettingsError("VERIFY_TLS must be true or false")
|
|
65
|
+
return Settings(url, spaces, token, frozenset(names(env, "INCLUDE_LABELS")), frozenset(names(env, "EXCLUDE_LABELS")),
|
|
66
|
+
int(raw) if raw else 5000, timeout, pause, verify == "true")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ApiError(SourceError):
|
|
70
|
+
def __init__(self, status: int, message: str):
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.status = status
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Api:
|
|
76
|
+
"""Read-only: only GET requests, by construction."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, s: Settings, sleep=time.sleep):
|
|
79
|
+
self.s = s
|
|
80
|
+
retry = Retry(statuses=RETRY_STATUS, network=True, budget=RETRY_BUDGET, max_backoff=MAX_BACKOFF, max_after=MAX_RETRY_AFTER,
|
|
81
|
+
say_from=SAY_WAITS_FROM)
|
|
82
|
+
# on stderr, which the core logs as it comes: a waiting sync does not look hung
|
|
83
|
+
say = lambda message: print(f"confluence connector: {message}", file=sys.stderr, flush=True) # noqa: E731
|
|
84
|
+
self._web = Web(base_url=s.url, headers={"Authorization": f"Bearer {s.token}"}, timeout=s.timeout, verify=s.verify,
|
|
85
|
+
follow_redirects=False, pause=s.pause, max_bytes=256_000_000, retry=retry, log=say, sleep=sleep)
|
|
86
|
+
|
|
87
|
+
def get(self, path: str, **params) -> dict:
|
|
88
|
+
try:
|
|
89
|
+
reply = self._web.get(path, params=params)
|
|
90
|
+
except HttpError as e:
|
|
91
|
+
if e.attempts > 1: # the retry policy gave up, and its message says how long it waited
|
|
92
|
+
raise
|
|
93
|
+
reply = Reply(e.status, "", "") # a 5xx that is not worth retrying
|
|
94
|
+
if reply.status == 401:
|
|
95
|
+
raise ApiError(401, "Confluence answered HTTP 401: check TOKEN (a personal access token) and its permissions")
|
|
96
|
+
if 300 <= reply.status < 400:
|
|
97
|
+
raise ApiError(reply.status, f"Confluence answered GET {path} with a redirect to "
|
|
98
|
+
f"{reply.headers.get('location', '?')}: check URL (and whether a login page is in the way)")
|
|
99
|
+
if reply.status >= 400:
|
|
100
|
+
raise ApiError(reply.status, f"Confluence answered HTTP {reply.status} to GET {path}")
|
|
101
|
+
data = reply.json()
|
|
102
|
+
if not isinstance(data, dict):
|
|
103
|
+
raise SourceError(f"Confluence did not answer GET {path} with a JSON object")
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
def paged(self, path: str, **params) -> Iterator[dict]:
|
|
107
|
+
start, seen = 0, set()
|
|
108
|
+
while True:
|
|
109
|
+
data = self.get(path, limit=str(PAGE), start=str(start), **params)
|
|
110
|
+
results = data.get("results")
|
|
111
|
+
if not isinstance(results, list):
|
|
112
|
+
raise SourceError(f"Confluence answered GET {path} without a results list")
|
|
113
|
+
fresh = [r for r in results if str(r.get("id")) not in seen]
|
|
114
|
+
seen.update(str(r.get("id")) for r in results)
|
|
115
|
+
yield from fresh
|
|
116
|
+
# the server caps `limit` as it likes: follow `next`; a server that ignores `start` repeats itself: stop then
|
|
117
|
+
if not fresh or not data.get("_links", {}).get("next"):
|
|
118
|
+
return
|
|
119
|
+
start += len(results)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _iso(when: str) -> str:
|
|
123
|
+
try:
|
|
124
|
+
return datetime.fromisoformat(when.replace("Z", "+00:00")).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
125
|
+
except ValueError:
|
|
126
|
+
return when
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_ENTITIES = re.compile(r"&([A-Za-z][A-Za-z0-9]*);")
|
|
130
|
+
_KEEP_ENTITIES = {"amp", "lt", "gt", "quot", "apos"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _entities(storage: str) -> str:
|
|
134
|
+
"""Named HTML entities as characters, so that the XML parser accepts them; the five XML ones stay."""
|
|
135
|
+
from html.entities import html5
|
|
136
|
+
|
|
137
|
+
def one(m):
|
|
138
|
+
name = m.group(1)
|
|
139
|
+
return m.group(0) if name in _KEEP_ENTITIES else html5.get(name + ";", m.group(0))
|
|
140
|
+
return _ENTITIES.sub(one, storage)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _macro_param(node, name: str) -> str:
|
|
144
|
+
for child in node:
|
|
145
|
+
if child.tag == "{ac}parameter" and child.get("{ac}name") == name:
|
|
146
|
+
return "".join(child.itertext()).strip()
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _walk(node, out: list[str]) -> None:
|
|
151
|
+
"""Serialise a storage-format element as plain HTML: ac:/ri: elements become their readable part."""
|
|
152
|
+
from xml.sax.saxutils import escape, quoteattr
|
|
153
|
+
|
|
154
|
+
tag = node.tag
|
|
155
|
+
if tag == "{ac}structured-macro":
|
|
156
|
+
name = node.get("{ac}name", "")
|
|
157
|
+
if name in ("code", "noformat"):
|
|
158
|
+
body = next((c for c in node if c.tag == "{ac}plain-text-body"), None)
|
|
159
|
+
out.append("<pre>" + escape("".join(body.itertext()) if body is not None else "") + "</pre>")
|
|
160
|
+
elif name == "status":
|
|
161
|
+
out.append(escape(_macro_param(node, "title")))
|
|
162
|
+
else: # a box, an expand, a section, a column, an excerpt: what it holds; a toc, a jira macro: nothing
|
|
163
|
+
for child in node:
|
|
164
|
+
if child.tag == "{ac}rich-text-body":
|
|
165
|
+
for grandchild in child:
|
|
166
|
+
_walk(grandchild, out)
|
|
167
|
+
out.append(escape(child.tail or ""))
|
|
168
|
+
out.append(escape(node.tail or ""))
|
|
169
|
+
return
|
|
170
|
+
if tag == "{ac}link":
|
|
171
|
+
body = next((c for c in node if c.tag in ("{ac}link-body", "{ac}plain-text-link-body")), None)
|
|
172
|
+
if body is not None:
|
|
173
|
+
out.append(escape("".join(body.itertext())))
|
|
174
|
+
else:
|
|
175
|
+
page = next((c for c in node if c.tag == "{ri}page"), None)
|
|
176
|
+
out.append(escape(page.get("{ri}content-title", "")) if page is not None else "")
|
|
177
|
+
out.append(escape(node.tail or ""))
|
|
178
|
+
return
|
|
179
|
+
if tag == "{ac}task-list":
|
|
180
|
+
items = ["".join(b.itertext()) for task in node if task.tag == "{ac}task"
|
|
181
|
+
for b in task if b.tag == "{ac}task-body"]
|
|
182
|
+
out.append("<ul>" + "".join(f"<li>{escape(i)}</li>" for i in items) + "</ul>" + escape(node.tail or ""))
|
|
183
|
+
return
|
|
184
|
+
if tag in ("{ac}placeholder", "{ac}image", "{ac}emoticon", "{ac}parameter", "{ac}task-id", "{ac}task-status") \
|
|
185
|
+
or (isinstance(tag, str) and tag.startswith("{ri}")):
|
|
186
|
+
out.append(escape(node.tail or ""))
|
|
187
|
+
return
|
|
188
|
+
if isinstance(tag, str) and tag.startswith("{ac}"): # layouts, inline-comment markers, rich-text bodies: transparent
|
|
189
|
+
out.append(escape(node.text or ""))
|
|
190
|
+
for child in node:
|
|
191
|
+
_walk(child, out)
|
|
192
|
+
out.append(escape(node.tail or ""))
|
|
193
|
+
return
|
|
194
|
+
if not isinstance(tag, str): # a comment
|
|
195
|
+
out.append(escape(node.tail or ""))
|
|
196
|
+
return
|
|
197
|
+
attrs = "".join(f" {k}={quoteattr(v)}" for k, v in node.attrib.items() if not k.startswith("{"))
|
|
198
|
+
out.append(f"<{tag}{attrs}>{escape(node.text or '')}")
|
|
199
|
+
for child in node:
|
|
200
|
+
_walk(child, out)
|
|
201
|
+
out.append(f"</{tag}>{escape(node.tail or '')}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def storage_to_html(storage: str) -> str:
|
|
205
|
+
"""Confluence's storage format as plain HTML: code macros become <pre>, boxes and expands keep what they hold, task
|
|
206
|
+
lists become lists, links keep their text or the linked page's title; images, emoticons, placeholders, a table of
|
|
207
|
+
contents, Jira and other macros without a body disappear. Storage that is not well-formed XML is read as HTML with
|
|
208
|
+
the ac:/ri: markup stripped."""
|
|
209
|
+
import xml.etree.ElementTree as ET
|
|
210
|
+
|
|
211
|
+
wrapped = f'<root xmlns:ac="ac" xmlns:ri="ri">{_entities(storage)}</root>'
|
|
212
|
+
try:
|
|
213
|
+
root = ET.fromstring(wrapped)
|
|
214
|
+
except ET.ParseError:
|
|
215
|
+
return re.sub(r"</?(?:ac|ri):[^>]*>", "", storage)
|
|
216
|
+
out: list[str] = [root.text or ""]
|
|
217
|
+
for child in root:
|
|
218
|
+
_walk(child, out)
|
|
219
|
+
return "".join(out)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class Confluence(Connector):
|
|
223
|
+
name = "confluence connector"
|
|
224
|
+
expected_errors = (ValueError, KeyError, TypeError)
|
|
225
|
+
|
|
226
|
+
def __init__(self, env: Mapping[str, str], *, sleep=time.sleep):
|
|
227
|
+
self.s = parse_settings(env)
|
|
228
|
+
self.api = Api(self.s, sleep)
|
|
229
|
+
if not self.s.verify:
|
|
230
|
+
self.warn("VERIFY_TLS is false: the server's certificate is not checked")
|
|
231
|
+
|
|
232
|
+
def _wanted(self, labels: set[str]) -> bool:
|
|
233
|
+
if self.s.include and not labels & self.s.include:
|
|
234
|
+
return False
|
|
235
|
+
return not (labels & self.s.exclude)
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _labels(page: dict) -> set[str]:
|
|
239
|
+
return {lb.get("name", "") for lb in page.get("metadata", {}).get("labels", {}).get("results", []) if lb.get("name")}
|
|
240
|
+
|
|
241
|
+
def _version(self, page: dict) -> str:
|
|
242
|
+
labels = hashlib.sha256(",".join(sorted(self._labels(page))).encode()).hexdigest()[:8]
|
|
243
|
+
return f"{page['version']['number']}:{labels}"
|
|
244
|
+
|
|
245
|
+
def _listing(self, pages: Iterator[dict]) -> dict[str, dict]:
|
|
246
|
+
found: dict[str, dict] = {}
|
|
247
|
+
for page in pages:
|
|
248
|
+
if page.get("status", "current") != "current" or not self._wanted(self._labels(page)):
|
|
249
|
+
continue
|
|
250
|
+
found[str(page["id"])] = page
|
|
251
|
+
if len(found) > self.s.max_pages:
|
|
252
|
+
raise SourceError(f"the spaces hold more than {self.s.max_pages} pages: narrow them or raise MAX_PAGES")
|
|
253
|
+
return found
|
|
254
|
+
|
|
255
|
+
def _item(self, page: dict) -> dict | None:
|
|
256
|
+
if page.get("status", "current") != "current" or not self._wanted(self._labels(page)):
|
|
257
|
+
return None
|
|
258
|
+
html = storage_to_html(page.get("body", {}).get("storage", {}).get("value", ""))
|
|
259
|
+
sections = [{"heading": " › ".join(s.heading_path) or None, "text": s.text} for s in html_to_sections(html)]
|
|
260
|
+
item = {"id": str(page["id"]), "title": page["title"], "url": self.s.url + page["_links"]["webui"],
|
|
261
|
+
"version": self._version(page), "sections": sections, "weight": 1.0,
|
|
262
|
+
"labels": [f"space:{page['space']['key']}", *sorted(self._labels(page))],
|
|
263
|
+
"updated": _iso(page["version"]["when"])}
|
|
264
|
+
if ancestors := [a["title"] for a in page.get("ancestors", [])][-4:]:
|
|
265
|
+
item["path"] = ancestors
|
|
266
|
+
return item
|
|
267
|
+
|
|
268
|
+
# -- the protocol --------------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def versions(self) -> dict[str, str]:
|
|
271
|
+
found: dict[str, dict] = {}
|
|
272
|
+
for space in self.s.spaces:
|
|
273
|
+
pages = self._listing(self.api.paged("/rest/api/content", spaceKey=space, type="page", status="current",
|
|
274
|
+
expand="version,metadata.labels"))
|
|
275
|
+
if not pages:
|
|
276
|
+
self.warn(f"the space {space!r} lists no pages: check the key and the token's permissions")
|
|
277
|
+
found.update(pages)
|
|
278
|
+
if len(found) > self.s.max_pages:
|
|
279
|
+
raise SourceError(f"the spaces hold more than {self.s.max_pages} pages: narrow them or raise MAX_PAGES")
|
|
280
|
+
return {pid: self._version(p) for pid, p in found.items()}
|
|
281
|
+
|
|
282
|
+
def fetch(self, ids: list[str]) -> Iterator[dict]:
|
|
283
|
+
for pid in ids:
|
|
284
|
+
if not pid.isdecimal():
|
|
285
|
+
continue
|
|
286
|
+
try:
|
|
287
|
+
page = self.api.get(f"/rest/api/content/{pid}", expand=FETCH_EXPAND)
|
|
288
|
+
except ApiError as e:
|
|
289
|
+
if e.status == 404: # gone since the listing: the next reconcile settles it
|
|
290
|
+
continue
|
|
291
|
+
raise
|
|
292
|
+
if page.get("space", {}).get("key") in self.s.spaces and (item := self._item(page)):
|
|
293
|
+
yield item
|
|
294
|
+
|
|
295
|
+
def changed(self, since: datetime) -> Iterator[dict]:
|
|
296
|
+
# CQL reads the time in the server's (or the user's) zone, not UTC: ask 14 hours early, which covers every
|
|
297
|
+
# zone; pages whose version is unchanged are dropped by the core anyway
|
|
298
|
+
stamp = (since.astimezone(UTC) - timedelta(hours=14)).strftime("%Y-%m-%d %H:%M")
|
|
299
|
+
keys = ",".join(f'"{s}"' for s in self.s.spaces)
|
|
300
|
+
cql = f'space in ({keys}) and type = page and lastModified >= "{stamp}"'
|
|
301
|
+
for page in self.api.paged("/rest/api/content/search", cql=cql, expand=FETCH_EXPAND):
|
|
302
|
+
if item := self._item(page):
|
|
303
|
+
yield item
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def main() -> None:
|
|
307
|
+
Confluence.main()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Confluence storage format (XHTML with ac:/ri: macros) → plain-text sections split at h1/h2/h3 headings."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from selectolax.lexbor import LexborHTMLParser as HTMLParser, LexborNode as Node
|
|
8
|
+
|
|
9
|
+
SECTION_TAGS = {"h1", "h2", "h3"}
|
|
10
|
+
LIST_TAGS = {"ul", "ol"}
|
|
11
|
+
# Containers whose children are blocks in their own right.
|
|
12
|
+
CONTAINER_TAGS = {"body", "div", "section", "article", "figure", "blockquote", "main", "details"}
|
|
13
|
+
SKIP_TAGS = {"script", "style", "img", "svg", "head"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Section:
|
|
18
|
+
heading_path: tuple[str, ...]
|
|
19
|
+
text: str
|
|
20
|
+
ordinal: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def approx_tokens(s: str) -> int:
|
|
24
|
+
return math.ceil(len(s.split()) * 1.3)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
BLOCK_TAGS = {"p", "div", "li", "ul", "ol", "table", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
28
|
+
"pre", "blockquote", "section", "article", "figure", "details", "summary"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _text(node: Node) -> str:
|
|
32
|
+
"""Text of a node, with a space wherever a block element or <br> separates words."""
|
|
33
|
+
parts = []
|
|
34
|
+
for child in node.iter(include_text=True):
|
|
35
|
+
if child.tag == "-text":
|
|
36
|
+
parts.append(child.text_content or "")
|
|
37
|
+
elif child.tag == "br":
|
|
38
|
+
parts.append(" ")
|
|
39
|
+
elif child.tag in SKIP_TAGS:
|
|
40
|
+
continue
|
|
41
|
+
elif child.tag in BLOCK_TAGS:
|
|
42
|
+
parts.append(f" {_text(child)} ")
|
|
43
|
+
else:
|
|
44
|
+
parts.append(_text(child))
|
|
45
|
+
return "".join(parts)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _inline(node: Node) -> str:
|
|
49
|
+
return re.sub(r"\s+", " ", _text(node)).strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _list_lines(node: Node, depth: int = 0) -> list[str]:
|
|
53
|
+
lines = []
|
|
54
|
+
for li in node.iter():
|
|
55
|
+
if li.tag != "li":
|
|
56
|
+
continue
|
|
57
|
+
own, nested = [], []
|
|
58
|
+
for child in li.iter(include_text=True):
|
|
59
|
+
if child.tag in LIST_TAGS:
|
|
60
|
+
nested.extend(_list_lines(child, depth + 1))
|
|
61
|
+
elif child.tag == "-text":
|
|
62
|
+
own.append(child.text_content or "")
|
|
63
|
+
elif child.tag in BLOCK_TAGS:
|
|
64
|
+
own.append(f" {_text(child)} ")
|
|
65
|
+
else:
|
|
66
|
+
own.append(_text(child))
|
|
67
|
+
text = re.sub(r"\s+", " ", "".join(own)).strip()
|
|
68
|
+
if text:
|
|
69
|
+
lines.append(" " * depth + "- " + text)
|
|
70
|
+
lines.extend(nested)
|
|
71
|
+
return lines
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _table_lines(node: Node) -> list[str]:
|
|
75
|
+
lines = []
|
|
76
|
+
for tr in node.css("tr"):
|
|
77
|
+
cells = [_inline(c) for c in tr.iter() if c.tag in ("td", "th")]
|
|
78
|
+
if any(cells):
|
|
79
|
+
lines.append(" | ".join(cells))
|
|
80
|
+
return lines
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _blocks(node: Node):
|
|
84
|
+
"""Yield ("heading", level, text) and ("text", None, text) in document order."""
|
|
85
|
+
pending_inline: list[str] = []
|
|
86
|
+
|
|
87
|
+
def flush():
|
|
88
|
+
text = re.sub(r"\s+", " ", "".join(pending_inline)).strip()
|
|
89
|
+
pending_inline.clear()
|
|
90
|
+
return text
|
|
91
|
+
|
|
92
|
+
for child in node.iter(include_text=True):
|
|
93
|
+
tag = child.tag
|
|
94
|
+
if tag == "-text" or tag in ("span", "a", "strong", "em", "b", "i", "code", "u", "s", "sub", "sup", "mark", "br"):
|
|
95
|
+
pending_inline.append(child.text_content if tag == "-text" else " " if tag == "br" else _text(child))
|
|
96
|
+
continue
|
|
97
|
+
if (text := flush()):
|
|
98
|
+
yield ("text", None, text)
|
|
99
|
+
if tag in SKIP_TAGS:
|
|
100
|
+
continue
|
|
101
|
+
if tag in SECTION_TAGS:
|
|
102
|
+
yield ("heading", int(tag[1]), _inline(child))
|
|
103
|
+
elif tag in LIST_TAGS:
|
|
104
|
+
if (lines := _list_lines(child)):
|
|
105
|
+
yield ("text", None, "\n".join(lines))
|
|
106
|
+
elif tag == "table":
|
|
107
|
+
if (lines := _table_lines(child)):
|
|
108
|
+
yield ("text", None, "\n".join(lines))
|
|
109
|
+
elif tag == "pre":
|
|
110
|
+
raw = child.text(deep=True, separator="").strip("\n")
|
|
111
|
+
if raw.strip():
|
|
112
|
+
yield ("text", None, raw)
|
|
113
|
+
elif tag in CONTAINER_TAGS:
|
|
114
|
+
yield from _blocks(child)
|
|
115
|
+
else:
|
|
116
|
+
if (text := _inline(child)):
|
|
117
|
+
yield ("text", None, text)
|
|
118
|
+
if (text := flush()):
|
|
119
|
+
yield ("text", None, text)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _split_words(text: str, max_tokens: int) -> list[str]:
|
|
123
|
+
words = text.split()
|
|
124
|
+
per = max(1, int(max_tokens / 1.3))
|
|
125
|
+
return [" ".join(words[i:i + per]) for i in range(0, len(words), per)]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _chunk(paragraphs: list[str], max_tokens: int) -> list[str]:
|
|
129
|
+
chunks, current = [], []
|
|
130
|
+
for para in paragraphs:
|
|
131
|
+
pieces = [para] if approx_tokens(para) <= max_tokens else _split_words(para, max_tokens)
|
|
132
|
+
for piece in pieces:
|
|
133
|
+
if current and approx_tokens("\n".join(current + [piece])) > max_tokens:
|
|
134
|
+
chunks.append("\n".join(current))
|
|
135
|
+
current = []
|
|
136
|
+
current.append(piece)
|
|
137
|
+
if current:
|
|
138
|
+
chunks.append("\n".join(current))
|
|
139
|
+
return chunks
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def html_to_sections(html: str, max_tokens: int = 350) -> list[Section]:
|
|
143
|
+
if not html or not html.strip():
|
|
144
|
+
return []
|
|
145
|
+
body = HTMLParser(html).body
|
|
146
|
+
if body is None:
|
|
147
|
+
return []
|
|
148
|
+
groups: list[tuple[tuple[str, ...], list[str]]] = [((), [])]
|
|
149
|
+
trail: list[tuple[int, str]] = []
|
|
150
|
+
for kind, level, text in _blocks(body):
|
|
151
|
+
if kind == "heading":
|
|
152
|
+
trail = [t for t in trail if t[0] < level] + [(level, text)]
|
|
153
|
+
groups.append((tuple(t[1] for t in trail), []))
|
|
154
|
+
else:
|
|
155
|
+
groups[-1][1].append(text)
|
|
156
|
+
sections = []
|
|
157
|
+
for path, paragraphs in groups:
|
|
158
|
+
for chunk in _chunk(paragraphs, max_tokens):
|
|
159
|
+
sections.append(Section(path, chunk, len(sections)))
|
|
160
|
+
return sections
|
|
@@ -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-confluence"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "passageway connector for Confluence Data Center: the pages of chosen spaces, read-only"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "PolyForm-Noncommercial-1.0.0"
|
|
12
|
+
dependencies = ["passageway-sdk[web]~=0.3.0", "selectolax>=0.3.21"]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["passageway_connector_confluence"]
|
|
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,42 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from fake_confluence import FakeConfluence, seed, serve
|
|
7
|
+
from passageway_connector_confluence.connector import Confluence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class World:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.api = FakeConfluence()
|
|
13
|
+
seed(self.api)
|
|
14
|
+
self.server = serve(self.api)
|
|
15
|
+
self.url = f"http://127.0.0.1:{self.server.server_port}"
|
|
16
|
+
self.slept: list[float] = []
|
|
17
|
+
|
|
18
|
+
def env(self, **extra):
|
|
19
|
+
return {"URL": self.url, "SPACES": "OPS|HR", "TOKEN": self.api.token, "PAUSE_SECONDS": "0", **extra}
|
|
20
|
+
|
|
21
|
+
def call(self, mode, stdin="", *, extra=(), **settings):
|
|
22
|
+
out, err = io.StringIO(), io.StringIO()
|
|
23
|
+
code = Confluence.run(["confluence", mode, *extra], self.env(**settings), io.StringIO(stdin), out, err, sleep=self.slept.append)
|
|
24
|
+
return code, [json.loads(line) for line in out.getvalue().splitlines()], err.getvalue()
|
|
25
|
+
|
|
26
|
+
def ids(self, **settings):
|
|
27
|
+
code, records, err = self.call("list", **settings)
|
|
28
|
+
assert code == 0, err
|
|
29
|
+
return {r["id"]: r["version"] for r in records}
|
|
30
|
+
|
|
31
|
+
def fetch(self, *ids, **settings):
|
|
32
|
+
code, records, err = self.call("fetch", "".join(json.dumps({"id": i}) + "\n" for i in ids), **settings)
|
|
33
|
+
assert code == 0, err
|
|
34
|
+
return {r["id"]: r for r in records}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def world():
|
|
39
|
+
w = World()
|
|
40
|
+
yield w
|
|
41
|
+
w.server.shutdown()
|
|
42
|
+
w.server.server_close()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""An in-test Confluence Data Center REST API over HTTP: the few endpoints the connector uses, with paging and CQL."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Page:
|
|
13
|
+
id: str
|
|
14
|
+
space: str
|
|
15
|
+
title: str
|
|
16
|
+
body: str
|
|
17
|
+
version: int = 1
|
|
18
|
+
when: str = "2026-10-20T10:00:00.000Z"
|
|
19
|
+
labels: list[str] = field(default_factory=list)
|
|
20
|
+
ancestors: list[str] = field(default_factory=list) # ids, root first
|
|
21
|
+
status: str = "current"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FakeConfluence:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.pages: dict[str, Page] = {}
|
|
27
|
+
self.requests: list[tuple[str, str, dict]] = []
|
|
28
|
+
self.token = "fixture-pat"
|
|
29
|
+
self.page_size = 100
|
|
30
|
+
self.status_errors: list[int] = []
|
|
31
|
+
self.retry_after = "0"
|
|
32
|
+
self.loop = False # ignore `start`, like a cursor-paged server
|
|
33
|
+
self.redirect = False
|
|
34
|
+
|
|
35
|
+
def add(self, id, space, title, body, **kw) -> str:
|
|
36
|
+
self.pages[id] = Page(id, space, title, body, **kw)
|
|
37
|
+
return id
|
|
38
|
+
|
|
39
|
+
def edit(self, id, body=None, title=None, labels=None):
|
|
40
|
+
p = self.pages[id]
|
|
41
|
+
p.version += 1
|
|
42
|
+
p.when = f"2026-10-28T12:{p.version:02d}:00.000Z"
|
|
43
|
+
if body is not None:
|
|
44
|
+
p.body = body
|
|
45
|
+
if title is not None:
|
|
46
|
+
p.title = title
|
|
47
|
+
if labels is not None:
|
|
48
|
+
p.labels = labels
|
|
49
|
+
|
|
50
|
+
def _summary(self, p: Page, expand: str) -> dict:
|
|
51
|
+
out = {"id": p.id, "type": "page", "status": p.status, "title": p.title,
|
|
52
|
+
"_links": {"webui": f"/pages/viewpage.action?pageId={p.id}", "self": f"/rest/api/content/{p.id}"}}
|
|
53
|
+
if "version" in expand:
|
|
54
|
+
out["version"] = {"number": p.version, "when": p.when}
|
|
55
|
+
if "space" in expand:
|
|
56
|
+
out["space"] = {"key": p.space, "name": f"Space {p.space}"}
|
|
57
|
+
if "body.storage" in expand:
|
|
58
|
+
out["body"] = {"storage": {"value": p.body, "representation": "storage"}}
|
|
59
|
+
if "ancestors" in expand:
|
|
60
|
+
out["ancestors"] = [{"id": a, "title": self.pages[a].title} for a in p.ancestors if a in self.pages]
|
|
61
|
+
if "metadata.labels" in expand:
|
|
62
|
+
out["metadata"] = {"labels": {"results": [{"name": lb} for lb in p.labels]}}
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
def _paged(self, items: list[dict], start: int, limit: int, path: str, q: dict) -> dict:
|
|
66
|
+
chunk = items[start:start + limit]
|
|
67
|
+
out = {"results": chunk, "start": start, "limit": limit, "size": len(chunk), "_links": {}}
|
|
68
|
+
if start + limit < len(items):
|
|
69
|
+
rest = "&".join(f"{k}={v}" for k, v in q.items() if k != "start")
|
|
70
|
+
out["_links"]["next"] = f"{path}?{rest}&start={start + limit}"
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def respond(self, method: str, path: str, q: dict, auth: str) -> tuple[int, dict | str]:
|
|
74
|
+
self.requests.append((method, path, q))
|
|
75
|
+
if self.status_errors:
|
|
76
|
+
return self.status_errors.pop(0), "later"
|
|
77
|
+
if self.redirect:
|
|
78
|
+
return 302, {"message": "login"}
|
|
79
|
+
if auth != f"Bearer {self.token}":
|
|
80
|
+
return 401, {"message": "unauthorized"}
|
|
81
|
+
if method != "GET":
|
|
82
|
+
return 405, {"message": "read-only fake"}
|
|
83
|
+
limit = min(int(q.get("limit", "25")), self.page_size)
|
|
84
|
+
start = 0 if self.loop else int(q.get("start", "0"))
|
|
85
|
+
expand = q.get("expand", "")
|
|
86
|
+
if path == "/rest/api/content":
|
|
87
|
+
pages = [p for p in self.pages.values() if p.space == q.get("spaceKey") and p.status == "current"]
|
|
88
|
+
return 200, self._paged([self._summary(p, expand) for p in pages], start, limit, path, q)
|
|
89
|
+
if path == "/rest/api/content/search":
|
|
90
|
+
cql = q.get("cql", "")
|
|
91
|
+
m = re.search(r'space in \(([^)]*)\)', cql)
|
|
92
|
+
spaces = [s.strip().strip('"') for s in m.group(1).split(",")] if m else []
|
|
93
|
+
since = re.search(r'lastModified >= "([^"]+)"', cql)
|
|
94
|
+
pages = [p for p in self.pages.values() if p.space in spaces and p.status == "current" and "type = page" in cql
|
|
95
|
+
and (not since or p.when.replace("T", " ")[:16] >= since.group(1)[:16])]
|
|
96
|
+
return 200, self._paged([self._summary(p, expand) for p in pages], start, limit, path, q)
|
|
97
|
+
if m := re.fullmatch(r"/rest/api/content/(\d+)", path):
|
|
98
|
+
p = self.pages.get(m.group(1))
|
|
99
|
+
if not p or p.status != "current":
|
|
100
|
+
return 404, {"message": "no content"}
|
|
101
|
+
return 200, self._summary(p, expand)
|
|
102
|
+
return 404, {"message": "unknown"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def serve(api: FakeConfluence):
|
|
106
|
+
class Handler(BaseHTTPRequestHandler):
|
|
107
|
+
def _handle(self):
|
|
108
|
+
url = urlparse(self.path)
|
|
109
|
+
q = {k: v[0] for k, v in parse_qs(url.query).items()}
|
|
110
|
+
status, body = api.respond(self.command, url.path, q, self.headers.get("Authorization", ""))
|
|
111
|
+
payload = (json.dumps(body) if isinstance(body, dict) else body).encode()
|
|
112
|
+
self.send_response(status)
|
|
113
|
+
self.send_header("Content-Type", "application/json" if isinstance(body, dict) else "text/plain")
|
|
114
|
+
self.send_header("Retry-After", api.retry_after)
|
|
115
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
116
|
+
self.end_headers()
|
|
117
|
+
self.wfile.write(payload)
|
|
118
|
+
|
|
119
|
+
do_GET = do_POST = do_PUT = do_DELETE = _handle
|
|
120
|
+
|
|
121
|
+
def log_message(self, *args):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
125
|
+
threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True).start()
|
|
126
|
+
return server
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def seed(api: FakeConfluence) -> None:
|
|
130
|
+
api.add("100", "OPS", "Operations", "<p>The operations space.</p>")
|
|
131
|
+
api.add("101", "OPS", "Rotating certificates",
|
|
132
|
+
'<p>Rotate the signing certificate every year.</p><h2>Steps</h2><ol><li>Create the new certificate.</li>'
|
|
133
|
+
'<li>Deploy it, see <ac:link><ri:page ri:content-title="Deploying" /><ac:plain-text-link-body><![CDATA[the deploy page]]></ac:plain-text-link-body></ac:link>.</li></ol>'
|
|
134
|
+
'<h3>Checks</h3><ac:structured-macro ac:name="code"><ac:parameter ac:name="language">bash</ac:parameter>'
|
|
135
|
+
'<ac:plain-text-body><![CDATA[openssl x509 -noout -dates]]></ac:plain-text-body></ac:structured-macro>'
|
|
136
|
+
'<ac:structured-macro ac:name="toc"/><ac:structured-macro ac:name="info"><ac:rich-text-body><p>Takes an hour.</p></ac:rich-text-body></ac:structured-macro>',
|
|
137
|
+
labels=["security", "howto"], ancestors=["100"])
|
|
138
|
+
api.add("102", "OPS", "Deploying", "<p>How to deploy.</p><table><tbody><tr><th>Step</th><th>Who</th></tr><tr><td>Build</td><td>CI</td></tr></tbody></table>",
|
|
139
|
+
labels=["howto"], ancestors=["100", "101"])
|
|
140
|
+
api.add("200", "HR", "Cafeteria menu", "<p>Monday: soup.</p>")
|
|
141
|
+
api.add("300", "SECRET", "Payroll", "<p>Nothing to see.</p>")
|
|
142
|
+
api.add("103", "OPS", "Draft page", "<p>Not published.</p>", status="draft")
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from passageway.protocol import parse_item
|
|
7
|
+
|
|
8
|
+
README = Path(__file__).resolve().parents[1] / "README.md"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_list_holds_the_current_pages_of_the_chosen_spaces_only(world):
|
|
12
|
+
ids = world.ids()
|
|
13
|
+
assert set(ids) == {"100", "101", "102", "200"} # not SECRET, not the draft
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_the_version_changes_with_content_title_and_labels(world):
|
|
17
|
+
before = world.ids()["101"]
|
|
18
|
+
world.api.edit("101", body="<p>new</p>")
|
|
19
|
+
after_body = world.ids()["101"]
|
|
20
|
+
world.api.edit("101", labels=["security"])
|
|
21
|
+
after_labels = world.ids()["101"]
|
|
22
|
+
assert len({before, after_body, after_labels}) == 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_a_page_becomes_an_item_with_sections_path_labels_and_url(world):
|
|
26
|
+
item = world.fetch("101")["101"]
|
|
27
|
+
parsed = parse_item(item)
|
|
28
|
+
assert parsed.title == "Rotating certificates" and parsed.url == f"{world.url}/pages/viewpage.action?pageId=101"
|
|
29
|
+
assert parsed.path == ("Operations",) and set(parsed.labels) == {"space:OPS", "security", "howto"}
|
|
30
|
+
assert parsed.updated == "2026-10-20T10:00:00Z" and parsed.weight == 1.0
|
|
31
|
+
assert [(s.heading, s.text) for s in parsed.sections] == [
|
|
32
|
+
(None, "Rotate the signing certificate every year."),
|
|
33
|
+
("Steps", "- Create the new certificate.\n- Deploy it, see the deploy page."),
|
|
34
|
+
("Steps › Checks", "openssl x509 -noout -dates\nTakes an hour."),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_tables_become_rows_and_ancestors_the_path(world):
|
|
39
|
+
item = world.fetch("102")["102"]
|
|
40
|
+
assert item["path"] == ["Operations", "Rotating certificates"]
|
|
41
|
+
assert item["sections"][0]["text"] == "How to deploy.\nStep | Who\nBuild | CI"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_label_filters(world):
|
|
45
|
+
assert set(world.ids(INCLUDE_LABELS="howto")) == {"101", "102"}
|
|
46
|
+
assert set(world.ids(EXCLUDE_LABELS="security")) == {"100", "102", "200"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_fetch_skips_ids_that_are_gone_or_out_of_scope(world):
|
|
50
|
+
assert set(world.fetch("101", "300", "999", "103")) == {"101"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_paging_walks_every_page(world):
|
|
54
|
+
world.api.page_size = 2
|
|
55
|
+
assert set(world.ids()) == {"100", "101", "102", "200"}
|
|
56
|
+
listing = [q for m, p, q in world.api.requests if p == "/rest/api/content"]
|
|
57
|
+
assert any(q.get("start") == "2" for q in listing)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_changed_asks_cql_in_confluence_time_and_returns_the_modified_pages(world):
|
|
61
|
+
world.api.edit("200", body="<p>Tuesday: stew.</p>")
|
|
62
|
+
code, records, err = world.call("changed", extra=["--since", "2026-10-25T00:00:00Z"])
|
|
63
|
+
assert code == 0, err
|
|
64
|
+
assert [r["id"] for r in records] == ["200"]
|
|
65
|
+
cql = next(q["cql"] for m, p, q in world.api.requests if p.endswith("/search"))
|
|
66
|
+
assert 'space in ("OPS","HR")' in cql and "type = page" in cql and 'lastModified >= "2026-10-2' in cql
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_more_pages_than_max_pages_is_an_error_not_a_silent_subset(world):
|
|
70
|
+
code, records, err = world.call("list", MAX_PAGES="2")
|
|
71
|
+
assert code == 1 and records == [] and "MAX_PAGES" in err
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize("settings,message", [
|
|
75
|
+
({"URL": ""}, "URL"), ({"URL": "ftp://x"}, "URL"), ({"SPACES": ""}, "SPACES"), ({"TOKEN": ""}, "TOKEN"),
|
|
76
|
+
({"MAX_PAGES": "lots"}, "MAX_PAGES"), ({"TIMEOUT": "0"}, "TIMEOUT"), ({"VERIFY_TLS": "maybe"}, "VERIFY_TLS"),
|
|
77
|
+
])
|
|
78
|
+
def test_bad_settings_exit_2_with_a_message_and_no_output(world, settings, message):
|
|
79
|
+
code, records, err = world.call("list", **settings)
|
|
80
|
+
assert code == 2 and records == [] and message in err
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_a_wrong_token_is_exit_1_without_the_token_in_the_message(world):
|
|
84
|
+
code, records, err = world.call("list", TOKEN="wrong-pat-value")
|
|
85
|
+
assert code == 1 and records == [] and "401" in err and "wrong-pat-value" not in err
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_429_and_5xx_are_retried_then_given_up(world):
|
|
89
|
+
world.api.status_errors = [429, 503]
|
|
90
|
+
assert set(world.ids()) == {"100", "101", "102", "200"}
|
|
91
|
+
world.api.status_errors = [503] * 100
|
|
92
|
+
code, records, err = world.call("list")
|
|
93
|
+
assert code == 1 and records == [] and "503" in err
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a_429_longer_than_a_minute_is_waited_out(world):
|
|
97
|
+
world.api.status_errors = [429] * 8 # 1+2+4+…: over two minutes of waiting
|
|
98
|
+
assert world.fetch("101").keys() == {"101"}
|
|
99
|
+
assert sum(world.slept) > 120
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_a_long_wait_is_said_in_the_log_as_it_starts(world, capsys):
|
|
103
|
+
world.api.status_errors = [429] * 5 # waits 1, 2, 4, 8, then 16
|
|
104
|
+
assert world.fetch("101").keys() == {"101"}
|
|
105
|
+
said = capsys.readouterr().err.splitlines()
|
|
106
|
+
assert said == ["confluence connector: HTTP 429 on GET /rest/api/content/101: waiting 16 s (15 s of 900 s waited)"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_retry_after_is_honoured_beyond_30_seconds(world):
|
|
110
|
+
world.api.status_errors, world.api.retry_after = [429], "200"
|
|
111
|
+
assert world.fetch("101").keys() == {"101"}
|
|
112
|
+
assert world.slept == [200]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_a_429_that_never_ends_fails_and_says_how_long_it_waited(world):
|
|
116
|
+
world.api.status_errors = [429] * 1000
|
|
117
|
+
code, records, err = world.call("fetch", '{"id": "101"}\n')
|
|
118
|
+
assert code == 1 and records == [] and "HTTP 429" in err
|
|
119
|
+
waited = sum(world.slept)
|
|
120
|
+
assert 600 <= waited <= 1200 and f"{waited:.0f} s" in err
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_only_get_requests_are_ever_sent(world):
|
|
124
|
+
world.ids(); world.fetch("101"); world.call("changed", extra=["--since", "2026-10-25T00:00:00Z"])
|
|
125
|
+
assert {m for m, _, _ in world.api.requests} == {"GET"}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_every_setting_is_in_the_readme():
|
|
129
|
+
code = (Path(__file__).resolve().parents[1] / "passageway_connector_confluence" / "connector.py").read_text()
|
|
130
|
+
names = sorted(set(re.findall(r'env\.get\(\s*"([A-Z_]+)"', code)) | set(re.findall(r'_list\(\s*env,\s*"([A-Z_]+)"', code)))
|
|
131
|
+
assert len(names) >= 6, names
|
|
132
|
+
readme = README.read_text()
|
|
133
|
+
for name in names:
|
|
134
|
+
assert f"`{name}`" in readme, f"{name} is read by the Confluence connector but missing from its README"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---- review findings ------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_a_page_id_containing_404_is_not_skipped_on_a_real_failure(world):
|
|
140
|
+
world.api.add("14045", "OPS", "Unlucky", "<p>x</p>")
|
|
141
|
+
world.api.status_errors = [503] * 100
|
|
142
|
+
code, records, err = world.call("fetch", '{"id": "14045"}\n')
|
|
143
|
+
assert code == 1 and records == [] and "503" in err
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_a_page_that_is_really_gone_is_skipped(world):
|
|
147
|
+
assert world.fetch("999") == {}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_changed_asks_with_a_margin_for_confluences_timezone(world):
|
|
151
|
+
world.call("changed", extra=["--since", "2026-10-25T12:00:00Z"])
|
|
152
|
+
cql = next(q["cql"] for m, p, q in world.api.requests if p.endswith("/search"))
|
|
153
|
+
assert 'lastModified >= "2026-10-24 22:00"' in cql # 14 hours earlier: CQL reads the time in the server's zone
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_paging_stops_when_a_page_brings_nothing_new(world):
|
|
157
|
+
world.api.page_size = 2
|
|
158
|
+
world.api.loop = True # a server that ignores `start`, as cursor-paged ones do
|
|
159
|
+
assert set(world.ids()) == {"100", "101"} or len(world.ids()) >= 2 # ends, instead of looping forever
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_a_space_that_lists_nothing_is_a_warning(world):
|
|
163
|
+
code, records, err = world.call("list", SPACES="OPS|EMPTY")
|
|
164
|
+
assert code == 0 and "EMPTY" in err and "no pages" in err
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_a_redirect_is_a_clear_error(world):
|
|
168
|
+
world.api.redirect = True
|
|
169
|
+
code, _, err = world.call("list")
|
|
170
|
+
assert code == 1 and "redirect" in err.lower()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_verify_tls_false_warns(world):
|
|
174
|
+
code, _, err = world.call("check", VERIFY_TLS="false")
|
|
175
|
+
assert code == 0 and "VERIFY_TLS" in err
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import jsonschema
|
|
8
|
+
|
|
9
|
+
from passageway.protocol import Connector
|
|
10
|
+
from passageway.testing.contract import check_connector
|
|
11
|
+
|
|
12
|
+
SCHEMA = Path(__file__).resolve().parents[3] / "docs" / "reference" / "connector-protocol.schema.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_the_connector_passes_the_shared_contract_as_a_real_subprocess(world):
|
|
16
|
+
connector = Connector("confluence", [sys.executable, "-m", "passageway_connector_confluence"],
|
|
17
|
+
env={"URL": world.url, "SPACES": "OPS|HR", "PAUSE_SECONDS": "0"}, secrets={"TOKEN": world.api.token})
|
|
18
|
+
check_connector(connector)
|
|
19
|
+
assert {i.title for i in connector.fetch(["101", "200"])} == {"Rotating certificates", "Cafeteria menu"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_the_output_validates_against_the_published_json_schema(world):
|
|
23
|
+
schema = json.loads(SCHEMA.read_text())
|
|
24
|
+
|
|
25
|
+
def valid(name, record):
|
|
26
|
+
return jsonschema.Draft202012Validator({"$ref": f"#/$defs/{name}", "$defs": schema["$defs"]}).is_valid(record)
|
|
27
|
+
|
|
28
|
+
def lines(mode, stdin=""):
|
|
29
|
+
out = subprocess.run([sys.executable, "-m", "passageway_connector_confluence", mode],
|
|
30
|
+
env={**os.environ, "URL": world.url, "SPACES": "OPS|HR", "TOKEN": world.api.token, "PAUSE_SECONDS": "0"},
|
|
31
|
+
input=stdin, capture_output=True, text=True, check=True).stdout
|
|
32
|
+
return [json.loads(line) for line in out.splitlines()]
|
|
33
|
+
|
|
34
|
+
assert all(valid("check", r) for r in lines("check"))
|
|
35
|
+
listed = lines("list")
|
|
36
|
+
assert listed and all(valid("listRecord", r) for r in listed)
|
|
37
|
+
items = lines("fetch", "".join(json.dumps({"id": r["id"]}) + "\n" for r in listed))
|
|
38
|
+
assert len(items) == len(listed) and all(valid("item", r) for r in items)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Confluence's storage format becomes readable text without losing what a page says."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from passageway_connector_confluence.connector import storage_to_html
|
|
6
|
+
from passageway_connector_confluence.html import html_to_sections
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def text(storage):
|
|
10
|
+
return [(" › ".join(s.heading_path) or None, s.text) for s in html_to_sections(storage_to_html(storage))]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_a_self_closing_macro_with_attributes_does_not_swallow_what_follows():
|
|
14
|
+
out = text('<ac:structured-macro ac:name="toc" ac:schema-version="1" ac:macro-id="m1" /><h2>A</h2><p>a</p>'
|
|
15
|
+
'<ac:structured-macro ac:name="info" ac:schema-version="1"><ac:rich-text-body><p>tip</p></ac:rich-text-body></ac:structured-macro>')
|
|
16
|
+
assert out == [("A", "a\ntip")]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_nested_macros_keep_every_part_in_order():
|
|
20
|
+
out = text('<ac:structured-macro ac:name="expand"><ac:parameter ac:name="title">More</ac:parameter><ac:rich-text-body>'
|
|
21
|
+
'<p>Before</p><ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[echo hi]]></ac:plain-text-body></ac:structured-macro>'
|
|
22
|
+
'<p>After code</p><ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[two]]></ac:plain-text-body></ac:structured-macro>'
|
|
23
|
+
'</ac:rich-text-body></ac:structured-macro><p>Tail</p>')
|
|
24
|
+
assert out == [(None, "Before\necho hi\nAfter code\ntwo\nTail")]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_a_user_mention_and_a_later_title_link_do_not_delete_the_sections_between():
|
|
28
|
+
out = text('<p>Owner: <ac:link><ri:user ri:userkey="abc"/></ac:link></p><h2>Install</h2><p>Run the installer.</p>'
|
|
29
|
+
'<h2>Rollback</h2><p>See <ac:link><ri:page ri:content-title="FAQ"/></ac:link>.</p>')
|
|
30
|
+
assert out == [(None, "Owner:"), ("Install", "Run the installer."), ("Rollback", "See FAQ.")]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_code_keeps_its_markup_as_text():
|
|
34
|
+
out = text('<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[<div class="x">a & b</div> <ac:foo>keep</ac:foo>]]></ac:plain-text-body></ac:structured-macro>')
|
|
35
|
+
assert out == [(None, '<div class="x">a & b</div> <ac:foo>keep</ac:foo>')]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_task_lists_placeholders_and_status_macros():
|
|
39
|
+
out = text('<ac:task-list><ac:task><ac:task-id>1</ac:task-id><ac:task-status>incomplete</ac:task-status><ac:task-body>Buy milk</ac:task-body></ac:task>'
|
|
40
|
+
'<ac:task><ac:task-id>2</ac:task-id><ac:task-status>complete</ac:task-status><ac:task-body>Ship</ac:task-body></ac:task></ac:task-list>'
|
|
41
|
+
'<p><ac:placeholder>Type here</ac:placeholder>Real</p><p>State: <ac:structured-macro ac:name="status"><ac:parameter ac:name="title">DONE</ac:parameter></ac:structured-macro></p>')
|
|
42
|
+
assert out == [(None, "- Buy milk\n- Ship\nReal\nState: DONE")]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_links_images_emoticons_layouts_and_entities():
|
|
46
|
+
out = text('<ac:layout><ac:layout-section><ac:layout-cell><p>Left & <ac:emoticon ac:name="smile"/> é</p>'
|
|
47
|
+
'<p><ac:link><ri:page ri:space-key="OPS" ri:content-title="Deploying"/><ac:link-body><strong>deploy</strong> it</ac:link-body></ac:link></p>'
|
|
48
|
+
'<ac:image><ri:attachment ri:filename="x.png"/></ac:image><p>Tail</p></ac:layout-cell></ac:layout-section></ac:layout>')
|
|
49
|
+
assert out == [(None, "Left & é\ndeploy it\nTail")]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_malformed_storage_still_gives_text():
|
|
53
|
+
assert text("<p>a <b>bold</p> unclosed & stray") == [(None, "a bold\nunclosed & stray")]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_headings_below_h3_stay_in_the_text():
|
|
57
|
+
assert text("<h2>A</h2><h4>Sub</h4><p>x</p>") == [("A", "Sub\nx")]
|