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.
Files changed (55) hide show
  1. kb2md-0.1.0/.gitignore +27 -0
  2. kb2md-0.1.0/.superpowers/sdd/.gitignore +1 -0
  3. kb2md-0.1.0/.superpowers/sdd/final-fix-report.md +59 -0
  4. kb2md-0.1.0/.superpowers/sdd/progress.md +9 -0
  5. kb2md-0.1.0/.superpowers/sdd/review-2a4b57f..7b6d1c0.diff +208 -0
  6. kb2md-0.1.0/.superpowers/sdd/review-449f7a0..f984a36.diff +1052 -0
  7. kb2md-0.1.0/.superpowers/sdd/review-4bdefe0..690ccc9.diff +228 -0
  8. kb2md-0.1.0/.superpowers/sdd/review-690ccc9..ba1f9d1.diff +1084 -0
  9. kb2md-0.1.0/.superpowers/sdd/review-7b6d1c0..27b1395.diff +421 -0
  10. kb2md-0.1.0/.superpowers/sdd/review-7b6d1c0..4bdefe0.diff +574 -0
  11. kb2md-0.1.0/.superpowers/sdd/review-7b6d1c0..c1b5fc2.diff +310 -0
  12. kb2md-0.1.0/.superpowers/sdd/review-92e9601..fe5d151.diff +114 -0
  13. kb2md-0.1.0/.superpowers/sdd/review-f984a36..2a4b57f.diff +189 -0
  14. kb2md-0.1.0/.superpowers/sdd/review-f984a36..e5f5437.diff +176 -0
  15. kb2md-0.1.0/.superpowers/sdd/review-fa1705f..92e9601.diff +220 -0
  16. kb2md-0.1.0/.superpowers/sdd/review-fa1705f..ba1f9d1.diff +2954 -0
  17. kb2md-0.1.0/.superpowers/sdd/review-fe5d151..449f7a0.diff +124 -0
  18. kb2md-0.1.0/.superpowers/sdd/task-1-brief.md +221 -0
  19. kb2md-0.1.0/.superpowers/sdd/task-1-report.md +91 -0
  20. kb2md-0.1.0/.superpowers/sdd/task-2-brief.md +140 -0
  21. kb2md-0.1.0/.superpowers/sdd/task-2-report.md +75 -0
  22. kb2md-0.1.0/.superpowers/sdd/task-3-brief.md +142 -0
  23. kb2md-0.1.0/.superpowers/sdd/task-3-report.md +82 -0
  24. kb2md-0.1.0/.superpowers/sdd/task-4-brief.md +131 -0
  25. kb2md-0.1.0/.superpowers/sdd/task-4-report.md +67 -0
  26. kb2md-0.1.0/.superpowers/sdd/task-5-brief.md +201 -0
  27. kb2md-0.1.0/.superpowers/sdd/task-5-report.md +71 -0
  28. kb2md-0.1.0/.superpowers/sdd/task-6-brief.md +203 -0
  29. kb2md-0.1.0/.superpowers/sdd/task-6-report.md +92 -0
  30. kb2md-0.1.0/.superpowers/sdd/task-7-brief.md +192 -0
  31. kb2md-0.1.0/.superpowers/sdd/task-7-report.md +200 -0
  32. kb2md-0.1.0/.superpowers/sdd/task-8-brief.md +108 -0
  33. kb2md-0.1.0/.superpowers/sdd/task-8-report.md +34 -0
  34. kb2md-0.1.0/.superpowers/sdd/task-9-brief.md +123 -0
  35. kb2md-0.1.0/.superpowers/sdd/task-9-report.md +80 -0
  36. kb2md-0.1.0/LICENSE +21 -0
  37. kb2md-0.1.0/PKG-INFO +132 -0
  38. kb2md-0.1.0/README.md +96 -0
  39. kb2md-0.1.0/docs/superpowers/plans/2026-07-10-kb2md.md +1513 -0
  40. kb2md-0.1.0/docs/superpowers/specs/2026-07-10-kb2md-design.md +427 -0
  41. kb2md-0.1.0/pyproject.toml +92 -0
  42. kb2md-0.1.0/src/kb2md/__init__.py +4 -0
  43. kb2md-0.1.0/src/kb2md/__main__.py +6 -0
  44. kb2md-0.1.0/src/kb2md/cli.py +223 -0
  45. kb2md-0.1.0/src/kb2md/config.py +113 -0
  46. kb2md-0.1.0/src/kb2md/converter.py +995 -0
  47. kb2md-0.1.0/src/kb2md/subpage.py +75 -0
  48. kb2md-0.1.0/src/kb2md/utils.py +48 -0
  49. kb2md-0.1.0/tests/__init__.py +0 -0
  50. kb2md-0.1.0/tests/test_cli.py +612 -0
  51. kb2md-0.1.0/tests/test_config.py +137 -0
  52. kb2md-0.1.0/tests/test_converter.py +466 -0
  53. kb2md-0.1.0/tests/test_main.py +11 -0
  54. kb2md-0.1.0/tests/test_subpage.py +149 -0
  55. 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