kb2md 0.1.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.
- kb2md-0.1.0/.gitignore +27 -0
- kb2md-0.1.0/.superpowers/sdd/.gitignore +1 -0
- kb2md-0.1.0/.superpowers/sdd/final-fix-report.md +59 -0
- kb2md-0.1.0/.superpowers/sdd/progress.md +9 -0
- kb2md-0.1.0/.superpowers/sdd/review-2a4b57f..7b6d1c0.diff +208 -0
- kb2md-0.1.0/.superpowers/sdd/review-449f7a0..f984a36.diff +1052 -0
- kb2md-0.1.0/.superpowers/sdd/review-4bdefe0..690ccc9.diff +228 -0
- kb2md-0.1.0/.superpowers/sdd/review-690ccc9..ba1f9d1.diff +1084 -0
- kb2md-0.1.0/.superpowers/sdd/review-7b6d1c0..27b1395.diff +421 -0
- kb2md-0.1.0/.superpowers/sdd/review-7b6d1c0..4bdefe0.diff +574 -0
- kb2md-0.1.0/.superpowers/sdd/review-7b6d1c0..c1b5fc2.diff +310 -0
- kb2md-0.1.0/.superpowers/sdd/review-92e9601..fe5d151.diff +114 -0
- kb2md-0.1.0/.superpowers/sdd/review-f984a36..2a4b57f.diff +189 -0
- kb2md-0.1.0/.superpowers/sdd/review-f984a36..e5f5437.diff +176 -0
- kb2md-0.1.0/.superpowers/sdd/review-fa1705f..92e9601.diff +220 -0
- kb2md-0.1.0/.superpowers/sdd/review-fa1705f..ba1f9d1.diff +2954 -0
- kb2md-0.1.0/.superpowers/sdd/review-fe5d151..449f7a0.diff +124 -0
- kb2md-0.1.0/.superpowers/sdd/task-1-brief.md +221 -0
- kb2md-0.1.0/.superpowers/sdd/task-1-report.md +91 -0
- kb2md-0.1.0/.superpowers/sdd/task-2-brief.md +140 -0
- kb2md-0.1.0/.superpowers/sdd/task-2-report.md +75 -0
- kb2md-0.1.0/.superpowers/sdd/task-3-brief.md +142 -0
- kb2md-0.1.0/.superpowers/sdd/task-3-report.md +82 -0
- kb2md-0.1.0/.superpowers/sdd/task-4-brief.md +131 -0
- kb2md-0.1.0/.superpowers/sdd/task-4-report.md +67 -0
- kb2md-0.1.0/.superpowers/sdd/task-5-brief.md +201 -0
- kb2md-0.1.0/.superpowers/sdd/task-5-report.md +71 -0
- kb2md-0.1.0/.superpowers/sdd/task-6-brief.md +203 -0
- kb2md-0.1.0/.superpowers/sdd/task-6-report.md +92 -0
- kb2md-0.1.0/.superpowers/sdd/task-7-brief.md +192 -0
- kb2md-0.1.0/.superpowers/sdd/task-7-report.md +200 -0
- kb2md-0.1.0/.superpowers/sdd/task-8-brief.md +108 -0
- kb2md-0.1.0/.superpowers/sdd/task-8-report.md +34 -0
- kb2md-0.1.0/.superpowers/sdd/task-9-brief.md +123 -0
- kb2md-0.1.0/.superpowers/sdd/task-9-report.md +80 -0
- kb2md-0.1.0/LICENSE +21 -0
- kb2md-0.1.0/PKG-INFO +132 -0
- kb2md-0.1.0/README.md +96 -0
- kb2md-0.1.0/docs/superpowers/plans/2026-07-10-kb2md.md +1513 -0
- kb2md-0.1.0/docs/superpowers/specs/2026-07-10-kb2md-design.md +427 -0
- kb2md-0.1.0/pyproject.toml +92 -0
- kb2md-0.1.0/src/kb2md/__init__.py +4 -0
- kb2md-0.1.0/src/kb2md/__main__.py +6 -0
- kb2md-0.1.0/src/kb2md/cli.py +223 -0
- kb2md-0.1.0/src/kb2md/config.py +113 -0
- kb2md-0.1.0/src/kb2md/converter.py +995 -0
- kb2md-0.1.0/src/kb2md/subpage.py +75 -0
- kb2md-0.1.0/src/kb2md/utils.py +48 -0
- kb2md-0.1.0/tests/__init__.py +0 -0
- kb2md-0.1.0/tests/test_cli.py +612 -0
- kb2md-0.1.0/tests/test_config.py +137 -0
- kb2md-0.1.0/tests/test_converter.py +466 -0
- kb2md-0.1.0/tests/test_main.py +11 -0
- kb2md-0.1.0/tests/test_subpage.py +149 -0
- kb2md-0.1.0/tests/test_utils.py +43 -0
kb2md-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
*.whl
|
|
9
|
+
*.tar.gz
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# IDE
|
|
16
|
+
.vscode/
|
|
17
|
+
.idea/
|
|
18
|
+
|
|
19
|
+
# Output
|
|
20
|
+
output/
|
|
21
|
+
|
|
22
|
+
# Coverage
|
|
23
|
+
.coverage
|
|
24
|
+
htmlcov/
|
|
25
|
+
|
|
26
|
+
# Misc
|
|
27
|
+
.DS_Store
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Final Fix Report
|
|
2
|
+
|
|
3
|
+
## What Was Fixed
|
|
4
|
+
|
|
5
|
+
### 1. mypy strict errors with BeautifulSoup types
|
|
6
|
+
- Added `[[tool.mypy.overrides]]` for `bs4` with `follow_imports = "skip"` in `pyproject.toml`.
|
|
7
|
+
- Removed four now-unused `# type: ignore[arg-type]` / `# type: ignore[assignment]` comments in `src/kb2md/converter.py` that were masking `cell.get()` and `temp_grid` errors.
|
|
8
|
+
- Because `types-beautifulsoup4` returns wider unions (`Tag | NavigableString`) that `follow_imports = "skip"` does not fully suppress for installed stub packages, added minimal `isinstance(..., Tag)` narrowing in three places and one targeted `# type: ignore[no-untyped-call]` for `soup.new_string()`. Core conversion logic is unchanged.
|
|
9
|
+
|
|
10
|
+
### 2. CLI config commands mutually exclusive with `page_ref`
|
|
11
|
+
- In `src/kb2md/cli.py`, `_handle_default_url_args()` now rejects `--set-default-url` and `--get-default-url` when `args.page_ref` is present, logging a clear error and returning `1`.
|
|
12
|
+
- Added `test_main_set_default_url_with_page_ref` and `test_main_get_default_url_with_page_ref` in `tests/test_cli.py`.
|
|
13
|
+
|
|
14
|
+
### 3. Config write errors not handled
|
|
15
|
+
- In `src/kb2md/config.py`, `set_default_url()` now wraps `CONFIG_DIR.mkdir()` and `CONFIG_FILE.write_text()` in `try/except OSError`, logs the failure, and raises `RuntimeError("Could not save default URL to ...")`.
|
|
16
|
+
- Added `test_set_default_url_raises_on_write_error` in `tests/test_config.py`.
|
|
17
|
+
|
|
18
|
+
### 4. Subpage downloader duplicate children
|
|
19
|
+
- In `src/kb2md/subpage.py`, each `child_id` is now marked visited (or skipped if already visited) **before** `confluence_to_markdown()` is called in the child loop.
|
|
20
|
+
- Removed the early return at the top of `_download_children()` so a child reached through multiple parents still processes its own descendants; deduplication is enforced by the visited set.
|
|
21
|
+
- Added `test_download_subpages_deduplicates_shared_children` and `test_download_subpages_deduplicates_duplicate_siblings` in `tests/test_subpage.py`.
|
|
22
|
+
|
|
23
|
+
## Test Results
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pytest --cov=src --cov-report=term-missing tests/
|
|
27
|
+
89 passed
|
|
28
|
+
Total coverage: 85.41% (required 80.0%)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
All existing tests continue to pass and coverage remains above the required threshold.
|
|
32
|
+
|
|
33
|
+
## Static Analysis Results
|
|
34
|
+
|
|
35
|
+
| Tool | Result |
|
|
36
|
+
|--------|--------|
|
|
37
|
+
| black | passed (`black --check src tests`) |
|
|
38
|
+
| ruff | passed (`ruff check src tests`) |
|
|
39
|
+
| mypy | passed (`mypy src`) with `types-beautifulsoup4` installed |
|
|
40
|
+
| bandit | passed (`bandit -r src/`) |
|
|
41
|
+
|
|
42
|
+
## Files Changed
|
|
43
|
+
|
|
44
|
+
- `pyproject.toml`
|
|
45
|
+
- `src/kb2md/cli.py`
|
|
46
|
+
- `src/kb2md/config.py`
|
|
47
|
+
- `src/kb2md/converter.py`
|
|
48
|
+
- `src/kb2md/subpage.py`
|
|
49
|
+
- `tests/test_cli.py`
|
|
50
|
+
- `tests/test_config.py`
|
|
51
|
+
- `tests/test_subpage.py`
|
|
52
|
+
|
|
53
|
+
## Commits Created
|
|
54
|
+
|
|
55
|
+
- `c411594` fix: address final review issues
|
|
56
|
+
|
|
57
|
+
## Remaining Concerns
|
|
58
|
+
|
|
59
|
+
None. The four Important issues are resolved, all tests pass, and all configured static-analysis tools are green.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Task 1: complete (commits fa1705f..92e9601, review clean)
|
|
2
|
+
Task 2: complete (commits 92e9601..fe5d151, review clean)
|
|
3
|
+
Task 3: complete (commits fe5d151..449f7a0, review clean)
|
|
4
|
+
Task 4: complete (commits 449f7a0..f984a36, review clean)
|
|
5
|
+
Task 5: complete (commits f984a36..2a4b57f, review clean)
|
|
6
|
+
Task 6: complete (commits 2a4b57f..7b6d1c0, review clean)
|
|
7
|
+
Task 7: complete (commits 7b6d1c0..4bdefe0, review clean after fixes)
|
|
8
|
+
Task 8: complete (commits 4bdefe0..690ccc9, review clean)
|
|
9
|
+
Task 9: complete (commits 690ccc9..ba1f9d1, review clean)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Review package: 2a4b57f..7b6d1c0
|
|
2
|
+
|
|
3
|
+
## Commits
|
|
4
|
+
7b6d1c0 refactor: address review feedback for subpage downloader
|
|
5
|
+
43ff655 feat: add subpage and recursive subpage download
|
|
6
|
+
|
|
7
|
+
## Files changed
|
|
8
|
+
src/kb2md/converter.py | 2 +-
|
|
9
|
+
src/kb2md/subpage.py | 67 +++++++++++++++++++++++++++++++++++++
|
|
10
|
+
tests/test_subpage.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
11
|
+
3 files changed, 157 insertions(+), 1 deletion(-)
|
|
12
|
+
|
|
13
|
+
## Diff
|
|
14
|
+
diff --git a/src/kb2md/converter.py b/src/kb2md/converter.py
|
|
15
|
+
index 5373d9e..dc104a7 100644
|
|
16
|
+
--- a/src/kb2md/converter.py
|
|
17
|
+
+++ b/src/kb2md/converter.py
|
|
18
|
+
@@ -39,21 +39,21 @@ class ConfluenceToMarkdown:
|
|
19
|
+
if use_chrome_cookies:
|
|
20
|
+
try:
|
|
21
|
+
self.chrome_cookies = chrome_cookies(confluence_url)
|
|
22
|
+
LOGGER.info("Successfully loaded Chrome cookies")
|
|
23
|
+
except Exception as e:
|
|
24
|
+
LOGGER.warning("Could not load Chrome cookies: %s", e)
|
|
25
|
+
self.chrome_cookies = None
|
|
26
|
+
else:
|
|
27
|
+
self.chrome_cookies = None
|
|
28
|
+
|
|
29
|
+
- def confluence_to_markdown(self, page_id: int, output_dir: str = "./output") -> Path:
|
|
30
|
+
+ def confluence_to_markdown(self, page_id: int, output_dir: str | Path = "./output") -> Path:
|
|
31
|
+
"""
|
|
32
|
+
将Confluence页面转换为Markdown
|
|
33
|
+
"""
|
|
34
|
+
LOGGER.info("Starting conversion for page ID: %s", page_id)
|
|
35
|
+
|
|
36
|
+
# 创建输出目录
|
|
37
|
+
output_path = Path(output_dir)
|
|
38
|
+
output_path.mkdir(exist_ok=True)
|
|
39
|
+
attachments_dir = output_path / f"{page_id}_attach"
|
|
40
|
+
attachments_dir.mkdir(exist_ok=True)
|
|
41
|
+
diff --git a/src/kb2md/subpage.py b/src/kb2md/subpage.py
|
|
42
|
+
new file mode 100644
|
|
43
|
+
index 0000000..54e9f3b
|
|
44
|
+
--- /dev/null
|
|
45
|
+
+++ b/src/kb2md/subpage.py
|
|
46
|
+
@@ -0,0 +1,67 @@
|
|
47
|
+
+"""Subpage download logic for kb2md."""
|
|
48
|
+
+
|
|
49
|
+
+import logging
|
|
50
|
+
+from pathlib import Path
|
|
51
|
+
+
|
|
52
|
+
+from kb2md.converter import ConfluenceToMarkdown
|
|
53
|
+
+
|
|
54
|
+
+LOGGER = logging.getLogger(__name__)
|
|
55
|
+
+
|
|
56
|
+
+
|
|
57
|
+
+class SubpageDownloader:
|
|
58
|
+
+ """Download child pages of a Confluence page."""
|
|
59
|
+
+
|
|
60
|
+
+ def __init__(self, converter: ConfluenceToMarkdown):
|
|
61
|
+
+ self.converter = converter
|
|
62
|
+
+
|
|
63
|
+
+ def download_subpages(self, page_id: int, output_dir: Path, recursive: bool = False) -> None:
|
|
64
|
+
+ """Download subpages of ``page_id`` into ``output_dir``.
|
|
65
|
+
+
|
|
66
|
+
+ Direct children are placed in ``{page_id}_page/``.
|
|
67
|
+
+ When ``recursive`` is True, descendants are placed in nested
|
|
68
|
+
+ ``{child_id}_page/`` directories to preserve hierarchy.
|
|
69
|
+
+ """
|
|
70
|
+
+ visited: set[str] = set()
|
|
71
|
+
+ self._download_children(page_id, output_dir, recursive, visited)
|
|
72
|
+
+
|
|
73
|
+
+ def _download_children(
|
|
74
|
+
+ self,
|
|
75
|
+
+ page_id: int,
|
|
76
|
+
+ output_dir: Path,
|
|
77
|
+
+ recursive: bool,
|
|
78
|
+
+ visited: set[str],
|
|
79
|
+
+ ) -> None:
|
|
80
|
+
+ page_id_str = str(page_id)
|
|
81
|
+
+ if page_id_str in visited:
|
|
82
|
+
+ return
|
|
83
|
+
+ visited.add(page_id_str)
|
|
84
|
+
+
|
|
85
|
+
+ try:
|
|
86
|
+
+ response = self.converter.confluence.get_page_child_by_type(
|
|
87
|
+
+ page_id, type="page", start=0, limit=500
|
|
88
|
+
+ )
|
|
89
|
+
+ except Exception as e:
|
|
90
|
+
+ LOGGER.error("Failed to fetch children of page %s: %s", page_id, e)
|
|
91
|
+
+ return
|
|
92
|
+
+
|
|
93
|
+
+ children = response.get("results", [])
|
|
94
|
+
+ if not children:
|
|
95
|
+
+ LOGGER.info("No subpages found for page %s", page_id)
|
|
96
|
+
+ return
|
|
97
|
+
+
|
|
98
|
+
+ subpage_dir = output_dir / f"{page_id}_page"
|
|
99
|
+
+ subpage_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
+
|
|
101
|
+
+ for child in children:
|
|
102
|
+
+ child_id = int(child["id"])
|
|
103
|
+
+ if str(child_id) in visited:
|
|
104
|
+
+ continue
|
|
105
|
+
+ try:
|
|
106
|
+
+ self.converter.confluence_to_markdown(child_id, output_dir=subpage_dir)
|
|
107
|
+
+ LOGGER.info("Downloaded subpage %s into %s", child_id, subpage_dir)
|
|
108
|
+
+ except Exception as e:
|
|
109
|
+
+ LOGGER.warning("Failed to download subpage %s: %s", child_id, e)
|
|
110
|
+
+ continue
|
|
111
|
+
+
|
|
112
|
+
+ if recursive:
|
|
113
|
+
+ self._download_children(child_id, subpage_dir, recursive, visited)
|
|
114
|
+
diff --git a/tests/test_subpage.py b/tests/test_subpage.py
|
|
115
|
+
new file mode 100644
|
|
116
|
+
index 0000000..b6b17f3
|
|
117
|
+
--- /dev/null
|
|
118
|
+
+++ b/tests/test_subpage.py
|
|
119
|
+
@@ -0,0 +1,89 @@
|
|
120
|
+
+from pathlib import Path
|
|
121
|
+
+from typing import Any
|
|
122
|
+
+from unittest.mock import MagicMock, call
|
|
123
|
+
+
|
|
124
|
+
+import pytest
|
|
125
|
+
+
|
|
126
|
+
+from kb2md.converter import ConfluenceToMarkdown
|
|
127
|
+
+from kb2md.subpage import SubpageDownloader
|
|
128
|
+
+
|
|
129
|
+
+
|
|
130
|
+
+@pytest.fixture
|
|
131
|
+
+def mock_converter(tmp_path: Path) -> MagicMock:
|
|
132
|
+
+ converter = MagicMock(spec=ConfluenceToMarkdown)
|
|
133
|
+
+ converter.confluence = MagicMock()
|
|
134
|
+
+ converter.confluence_to_markdown.side_effect = lambda pid, output_dir: (
|
|
135
|
+
+ Path(output_dir) / f"page[{pid}].md"
|
|
136
|
+
+ )
|
|
137
|
+
+ return converter
|
|
138
|
+
+
|
|
139
|
+
+
|
|
140
|
+
+def test_download_direct_subpages(mock_converter: MagicMock, tmp_path: Path) -> None:
|
|
141
|
+
+ mock_converter.confluence.get_page_child_by_type.return_value = {
|
|
142
|
+
+ "results": [
|
|
143
|
+
+ {"id": "111"},
|
|
144
|
+
+ {"id": "222"},
|
|
145
|
+
+ ]
|
|
146
|
+
+ }
|
|
147
|
+
+
|
|
148
|
+
+ downloader = SubpageDownloader(mock_converter)
|
|
149
|
+
+ downloader.download_subpages(100, tmp_path, recursive=False)
|
|
150
|
+
+
|
|
151
|
+
+ mock_converter.confluence.get_page_child_by_type.assert_called_once_with(
|
|
152
|
+
+ 100, type="page", start=0, limit=500
|
|
153
|
+
+ )
|
|
154
|
+
+ assert mock_converter.confluence_to_markdown.call_count == 2
|
|
155
|
+
+ calls = [
|
|
156
|
+
+ call(111, output_dir=tmp_path / "100_page"),
|
|
157
|
+
+ call(222, output_dir=tmp_path / "100_page"),
|
|
158
|
+
+ ]
|
|
159
|
+
+ mock_converter.confluence_to_markdown.assert_has_calls(calls, any_order=True)
|
|
160
|
+
+
|
|
161
|
+
+
|
|
162
|
+
+def test_download_recursive_subpages(mock_converter: MagicMock, tmp_path: Path) -> None:
|
|
163
|
+
+ # Root has child 111, child 111 has child 222
|
|
164
|
+
+ def child_results(page_id: int, **kwargs: Any) -> dict[str, list[dict[str, str]]]:
|
|
165
|
+
+ if page_id == 100:
|
|
166
|
+
+ return {"results": [{"id": "111"}]}
|
|
167
|
+
+ if page_id == 111:
|
|
168
|
+
+ return {"results": [{"id": "222"}]}
|
|
169
|
+
+ return {"results": []}
|
|
170
|
+
+
|
|
171
|
+
+ mock_converter.confluence.get_page_child_by_type.side_effect = child_results
|
|
172
|
+
+
|
|
173
|
+
+ downloader = SubpageDownloader(mock_converter)
|
|
174
|
+
+ downloader.download_subpages(100, tmp_path, recursive=True)
|
|
175
|
+
+
|
|
176
|
+
+ # 100 -> 111 -> 222
|
|
177
|
+
+ assert mock_converter.confluence_to_markdown.call_count == 2
|
|
178
|
+
+ mock_converter.confluence_to_markdown.assert_any_call(111, output_dir=tmp_path / "100_page")
|
|
179
|
+
+ mock_converter.confluence_to_markdown.assert_any_call(
|
|
180
|
+
+ 222, output_dir=tmp_path / "100_page" / "111_page"
|
|
181
|
+
+ )
|
|
182
|
+
+
|
|
183
|
+
+
|
|
184
|
+
+def test_download_subpages_no_children(mock_converter: MagicMock, tmp_path: Path) -> None:
|
|
185
|
+
+ mock_converter.confluence.get_page_child_by_type.return_value = {"results": []}
|
|
186
|
+
+
|
|
187
|
+
+ downloader = SubpageDownloader(mock_converter)
|
|
188
|
+
+ downloader.download_subpages(100, tmp_path, recursive=False)
|
|
189
|
+
+
|
|
190
|
+
+ mock_converter.confluence_to_markdown.assert_not_called()
|
|
191
|
+
+
|
|
192
|
+
+
|
|
193
|
+
+def test_download_subpages_avoids_cycles(mock_converter: MagicMock, tmp_path: Path) -> None:
|
|
194
|
+
+ # 100 -> 111 -> 100 (cycle)
|
|
195
|
+
+ def child_results(page_id: int, **kwargs: Any) -> dict[str, list[dict[str, str]]]:
|
|
196
|
+
+ if page_id == 100:
|
|
197
|
+
+ return {"results": [{"id": "111"}]}
|
|
198
|
+
+ if page_id == 111:
|
|
199
|
+
+ return {"results": [{"id": "100"}]}
|
|
200
|
+
+ return {"results": []}
|
|
201
|
+
+
|
|
202
|
+
+ mock_converter.confluence.get_page_child_by_type.side_effect = child_results
|
|
203
|
+
+
|
|
204
|
+
+ downloader = SubpageDownloader(mock_converter)
|
|
205
|
+
+ downloader.download_subpages(100, tmp_path, recursive=True)
|
|
206
|
+
+
|
|
207
|
+
+ # Should only download 111 once, not revisit 100
|
|
208
|
+
+ assert mock_converter.confluence_to_markdown.call_count == 1
|