cds2md 0.0.0__py3-none-any.whl
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.
- cds2md/__init__.py +4 -0
- cds2md/__main__.py +42 -0
- cds2md/cache.py +41 -0
- cds2md/convert.py +210 -0
- cds2md/defs.py +27 -0
- cds2md/engine.py +103 -0
- cds2md/fetch.py +613 -0
- cds2md/logger.py +54 -0
- cds2md/opts.py +417 -0
- cds2md/py.typed +0 -0
- cds2md/translator.py +925 -0
- cds2md/validate_connection.py +24 -0
- cds2md/validate_space.py +33 -0
- cds2md-0.0.0.dist-info/METADATA +130 -0
- cds2md-0.0.0.dist-info/RECORD +18 -0
- cds2md-0.0.0.dist-info/WHEEL +4 -0
- cds2md-0.0.0.dist-info/entry_points.txt +3 -0
- cds2md-0.0.0.dist-info/licenses/LICENSE +22 -0
cds2md/__init__.py
ADDED
cds2md/__main__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-2-Clause
|
|
2
|
+
# Copyright jdknight
|
|
3
|
+
|
|
4
|
+
from cds2md.engine import engine
|
|
5
|
+
from cds2md.logger import logger
|
|
6
|
+
from cds2md.opts import process_options
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""
|
|
11
|
+
publish helper mainline
|
|
12
|
+
|
|
13
|
+
The mainline for cds2md's publisher helper utility.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# process command line/environment options
|
|
17
|
+
opts = process_options()
|
|
18
|
+
|
|
19
|
+
# if running a debugging, output the configuration used
|
|
20
|
+
if opts.debug:
|
|
21
|
+
debug_opts = vars(opts).copy()
|
|
22
|
+
|
|
23
|
+
# sanitize options which may expose token/etc. information
|
|
24
|
+
if debug_opts.get('confluence_token'):
|
|
25
|
+
debug_opts['confluence_token'] = '(set)' # noqa: S105
|
|
26
|
+
if debug_opts.get('header'):
|
|
27
|
+
debug_opts['header'] = '(set)'
|
|
28
|
+
if debug_opts.get('headers'):
|
|
29
|
+
debug_opts['headers'] = '(set)'
|
|
30
|
+
|
|
31
|
+
# pretty paths
|
|
32
|
+
debug_opts['cache'] = debug_opts['cache'].as_posix() + '/'
|
|
33
|
+
debug_opts['output'] = debug_opts['output'].as_posix() + '/'
|
|
34
|
+
|
|
35
|
+
logger.debug(f'configuration: {debug_opts}')
|
|
36
|
+
|
|
37
|
+
# run the utility
|
|
38
|
+
engine(opts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == '__main__':
|
|
42
|
+
main() # pragma: no cover
|
cds2md/cache.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-2-Clause
|
|
2
|
+
# Copyright jdknight
|
|
3
|
+
|
|
4
|
+
from argparse import Namespace
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from hashlib import sha1
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class AttachmentMetaCache:
|
|
11
|
+
id: int
|
|
12
|
+
page_id: int
|
|
13
|
+
size: int
|
|
14
|
+
title: str
|
|
15
|
+
type: str
|
|
16
|
+
url: str
|
|
17
|
+
version: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PageMetaCache:
|
|
22
|
+
id: int
|
|
23
|
+
version: int
|
|
24
|
+
parent_id: int | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cache_key(opts: Namespace) -> str:
|
|
28
|
+
"""
|
|
29
|
+
generate a cache string
|
|
30
|
+
|
|
31
|
+
Build a cache string based on the configured runtime options. This will
|
|
32
|
+
be used to populate a cache path based on the Confluence URL and space.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
opts: the runtime options
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
the cache string
|
|
39
|
+
"""
|
|
40
|
+
value = f'{opts.confluence_url}--{opts.confluence_space_key}'
|
|
41
|
+
return sha1(value.encode()).hexdigest() # noqa: S324
|
cds2md/convert.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-2-Clause
|
|
2
|
+
# Copyright jdknight
|
|
3
|
+
|
|
4
|
+
from argparse import Namespace
|
|
5
|
+
from bs4 import BeautifulSoup
|
|
6
|
+
from cds2md.cache import cache_key
|
|
7
|
+
from cds2md.defs import CACHE_DB
|
|
8
|
+
from cds2md.logger import logger
|
|
9
|
+
from cds2md.translator import Translator
|
|
10
|
+
from cds2md.translator import TranslatorContext
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from slugify import slugify
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AttachmentCache:
|
|
20
|
+
id: int
|
|
21
|
+
name: str
|
|
22
|
+
version: int
|
|
23
|
+
target_path: Path | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PageCache:
|
|
28
|
+
attachments: list[AttachmentCache]
|
|
29
|
+
body: str
|
|
30
|
+
id: int
|
|
31
|
+
title: str
|
|
32
|
+
version: int
|
|
33
|
+
parent_id: int | None = None
|
|
34
|
+
target_path: Path | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def convert(opts: Namespace) -> bool:
|
|
38
|
+
# ensure cache directory is prepared
|
|
39
|
+
cache_dir = opts.cache / cache_key(opts)
|
|
40
|
+
cache_db = cache_dir / CACHE_DB
|
|
41
|
+
if not cache_db.is_file():
|
|
42
|
+
logger.error('no cache database found')
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
# extract cached fetch data to be processed
|
|
46
|
+
pages_cache = []
|
|
47
|
+
pageid2cache = {}
|
|
48
|
+
|
|
49
|
+
with cache_db.open() as f:
|
|
50
|
+
pages = json.load(f)['pages']
|
|
51
|
+
|
|
52
|
+
for page_id_str, page_data in pages.items():
|
|
53
|
+
page_atachments = page_data['attachments']
|
|
54
|
+
page_id = int(page_id_str)
|
|
55
|
+
page_parent = page_data['parent']
|
|
56
|
+
page_version = page_data['version']
|
|
57
|
+
page_cache_dir = cache_dir / page_id_str
|
|
58
|
+
page_cache_filename = f'{page_id}-{page_version}.json'
|
|
59
|
+
page_cache_file = page_cache_dir / page_cache_filename
|
|
60
|
+
|
|
61
|
+
attachments = []
|
|
62
|
+
for attachment_name, attachment_data in page_atachments.items():
|
|
63
|
+
attachment_id = attachment_data['id']
|
|
64
|
+
attachment_version = attachment_data['version']
|
|
65
|
+
|
|
66
|
+
attachment_cache = AttachmentCache(
|
|
67
|
+
id=attachment_id,
|
|
68
|
+
name=attachment_name,
|
|
69
|
+
version=attachment_version,
|
|
70
|
+
)
|
|
71
|
+
attachments.append(attachment_cache)
|
|
72
|
+
|
|
73
|
+
with page_cache_file.open() as f2:
|
|
74
|
+
page_cache_data = json.load(f2)
|
|
75
|
+
page_body = page_cache_data['body']
|
|
76
|
+
page_title = page_cache_data['title']
|
|
77
|
+
|
|
78
|
+
page_cache = PageCache(
|
|
79
|
+
attachments=attachments,
|
|
80
|
+
body=page_body,
|
|
81
|
+
id=page_id,
|
|
82
|
+
parent_id=page_parent,
|
|
83
|
+
title=page_title,
|
|
84
|
+
version=page_version,
|
|
85
|
+
)
|
|
86
|
+
pageid2cache[page_id] = page_cache
|
|
87
|
+
pages_cache.append(page_cache)
|
|
88
|
+
|
|
89
|
+
# determine the paths for each page cache to be stored
|
|
90
|
+
attachment2target: dict[tuple[str, str], str] = {}
|
|
91
|
+
title2target: dict[str, str] = {}
|
|
92
|
+
reserved_attachment_targets = set()
|
|
93
|
+
|
|
94
|
+
for page_cache in pages_cache:
|
|
95
|
+
title_slug = slugify(page_cache.title)
|
|
96
|
+
md_filename = f'{title_slug}.md'
|
|
97
|
+
|
|
98
|
+
# if a flat setup, place the page at the root of the output
|
|
99
|
+
if opts.flat:
|
|
100
|
+
page_path = opts.output / md_filename
|
|
101
|
+
# if using hierarchy hints, determine based with parent data
|
|
102
|
+
else:
|
|
103
|
+
path_parts = [
|
|
104
|
+
md_filename,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
target_parent = page_cache.parent_id
|
|
108
|
+
while target_parent:
|
|
109
|
+
parent = pageid2cache.get(target_parent)
|
|
110
|
+
assert parent
|
|
111
|
+
parent_slug = slugify(parent.title)
|
|
112
|
+
path_parts.append(parent_slug)
|
|
113
|
+
target_parent = parent.parent_id
|
|
114
|
+
|
|
115
|
+
page_path = opts.output
|
|
116
|
+
for s in reversed(path_parts):
|
|
117
|
+
page_path = page_path / s
|
|
118
|
+
|
|
119
|
+
page_cache.target_path = page_path
|
|
120
|
+
|
|
121
|
+
root_path = page_path.relative_to(opts.output)
|
|
122
|
+
page_full_slug = root_path.as_posix()
|
|
123
|
+
title2target[page_cache.title] = page_full_slug
|
|
124
|
+
|
|
125
|
+
for attachment_cache in page_cache.attachments:
|
|
126
|
+
attachment_pathname = Path(attachment_cache.name)
|
|
127
|
+
attachment_name = slugify(attachment_pathname.stem)
|
|
128
|
+
attachment_ext = slugify(attachment_pathname.suffix)
|
|
129
|
+
|
|
130
|
+
attachment_suffix = 1
|
|
131
|
+
while True:
|
|
132
|
+
sfx = attachment_suffix if attachment_suffix > 1 else ''
|
|
133
|
+
attachment_filename = f'{attachment_name}{sfx}.{attachment_ext}'
|
|
134
|
+
attachment_path = page_path.parent / attachment_filename
|
|
135
|
+
|
|
136
|
+
# check if we have a conflicting attachment
|
|
137
|
+
if attachment_path in reserved_attachment_targets:
|
|
138
|
+
attachment_suffix += 1
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
attachment_cache.target_path = attachment_path
|
|
144
|
+
reserved_attachment_targets.add(attachment_path)
|
|
145
|
+
|
|
146
|
+
attachment_ref = (page_full_slug, attachment_cache.name)
|
|
147
|
+
|
|
148
|
+
attachment_root_path = attachment_path.relative_to(opts.output)
|
|
149
|
+
attachment_full_slug = attachment_root_path.as_posix()
|
|
150
|
+
attachment2target[attachment_ref] = attachment_full_slug
|
|
151
|
+
|
|
152
|
+
# process each page
|
|
153
|
+
ctx = TranslatorContext(
|
|
154
|
+
attachment2target=attachment2target,
|
|
155
|
+
opts=opts,
|
|
156
|
+
title2target=title2target,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
for page_cache in pages_cache:
|
|
160
|
+
assert page_cache.target_path
|
|
161
|
+
doc_path = page_cache.target_path.as_posix()
|
|
162
|
+
|
|
163
|
+
# parse the page's body
|
|
164
|
+
logger.debug(f'processing body of page: {doc_path}')
|
|
165
|
+
soup = BeautifulSoup(page_cache.body, 'html.parser')
|
|
166
|
+
|
|
167
|
+
# translate the parsed content into a markdown output
|
|
168
|
+
translator = Translator(ctx, page_cache.title)
|
|
169
|
+
logger.debug(f'walking page: {doc_path}')
|
|
170
|
+
translator.walk(soup)
|
|
171
|
+
|
|
172
|
+
# build the final markdown document contents
|
|
173
|
+
final_body = ''.join(translator.output).strip() + '\n'
|
|
174
|
+
|
|
175
|
+
# write the markdown content to its output file
|
|
176
|
+
assert page_cache.target_path
|
|
177
|
+
md_file = page_cache.target_path
|
|
178
|
+
md_file.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
logger.debug(f'writing to markdown file: {md_file.as_posix()}')
|
|
180
|
+
|
|
181
|
+
if md_file.is_dir():
|
|
182
|
+
logger.error('cannot write markdown into path '
|
|
183
|
+
f'that is a directory: {md_file.as_posix()}')
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
md_file.write_text(final_body)
|
|
187
|
+
|
|
188
|
+
# process each attachment
|
|
189
|
+
if page_cache.attachments:
|
|
190
|
+
logger.debug(f'processing attachments for page: {doc_path}')
|
|
191
|
+
page_id = page_cache.id
|
|
192
|
+
attachment_cache_dir = cache_dir / f'{page_id}' / 'attachments'
|
|
193
|
+
|
|
194
|
+
for attachment_cache in page_cache.attachments:
|
|
195
|
+
attachment_id = attachment_cache.id
|
|
196
|
+
attachment_version = attachment_cache.version
|
|
197
|
+
attachment_fname = f'{attachment_id}-{attachment_version}.dat'
|
|
198
|
+
attachment_data = attachment_cache_dir / attachment_fname
|
|
199
|
+
|
|
200
|
+
assert attachment_cache.target_path
|
|
201
|
+
attachment_folder = attachment_cache.target_path.parent
|
|
202
|
+
attachment_folder.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
|
|
204
|
+
attachment_desc = attachment_cache.target_path.as_posix()
|
|
205
|
+
logger.debug(f'copying attachment (source): {attachment_data}')
|
|
206
|
+
logger.debug(f'copying attachment (target): {attachment_desc}')
|
|
207
|
+
shutil.copy2(attachment_data, attachment_cache.target_path)
|
|
208
|
+
|
|
209
|
+
return True
|
|
210
|
+
|
cds2md/defs.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-2-Clause
|
|
2
|
+
# Copyright jdknight
|
|
3
|
+
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# name of the cache database for a cached dataset
|
|
8
|
+
CACHE_DB = 'cache.json'
|
|
9
|
+
|
|
10
|
+
# name of the cache directory
|
|
11
|
+
CACHE_DIRNAME = '.cds2md-cache'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Action(StrEnum):
|
|
15
|
+
ALL = 'all'
|
|
16
|
+
CONNECTION_CHECK = 'connection-check'
|
|
17
|
+
FETCH = 'fetch'
|
|
18
|
+
PROCESS = 'process'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AdmonitionType(StrEnum):
|
|
22
|
+
DEFAULT = 'default'
|
|
23
|
+
DOCUSAURUS = 'docusaurus'
|
|
24
|
+
GITHUB = 'github'
|
|
25
|
+
GITLAB = 'gitlab'
|
|
26
|
+
MYST = 'myst'
|
|
27
|
+
PLAIN = 'plain'
|
cds2md/engine.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-2-Clause
|
|
2
|
+
# Copyright jdknight
|
|
3
|
+
|
|
4
|
+
from argparse import Namespace
|
|
5
|
+
from cds2md import __version__ as cds2md_version
|
|
6
|
+
from cds2md.defs import Action
|
|
7
|
+
from cds2md.convert import convert
|
|
8
|
+
from cds2md.fetch import fetch
|
|
9
|
+
from cds2md.logger import logger
|
|
10
|
+
from cds2md.validate_connection import validate_connection
|
|
11
|
+
from cds2md.validate_space import validate_space
|
|
12
|
+
import httpx
|
|
13
|
+
import ssl
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def engine(opts: Namespace) -> None:
|
|
17
|
+
# perform any action requiring remote work
|
|
18
|
+
if opts.action in [Action.ALL, Action.CONNECTION_CHECK, Action.FETCH]:
|
|
19
|
+
perform_web_action(opts)
|
|
20
|
+
|
|
21
|
+
# perform process action
|
|
22
|
+
if opts.action in [Action.ALL, Action.PROCESS]:
|
|
23
|
+
process(opts)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def perform_web_action(opts: Namespace) -> None:
|
|
27
|
+
# prepare an http client for requests
|
|
28
|
+
timeout = httpx.Timeout(
|
|
29
|
+
connect=opts.timeout,
|
|
30
|
+
read=15.0,
|
|
31
|
+
write=5.0,
|
|
32
|
+
pool=5.0,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
headers: dict[str, str] = {}
|
|
36
|
+
headers |= opts.headers
|
|
37
|
+
headers |= {
|
|
38
|
+
# will only process json
|
|
39
|
+
'Accept': 'application/json',
|
|
40
|
+
# configure confluence pat to perform queries with
|
|
41
|
+
'Authorization': f'Bearer {opts.confluence_token}',
|
|
42
|
+
# user agent for our interactions
|
|
43
|
+
'User-Agent': f'Cds2md/{cds2md_version}',
|
|
44
|
+
# ignore csrf
|
|
45
|
+
'X-Atlassian-Token': 'no-check',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# setup ssl context
|
|
49
|
+
ssl_context = ssl.create_default_context()
|
|
50
|
+
if opts.ca_certificate:
|
|
51
|
+
ssl_context.load_verify_locations(cafile=opts.ca_certificate)
|
|
52
|
+
elif opts.ignore_tls:
|
|
53
|
+
ssl_context = False # type: ignore[assignment]
|
|
54
|
+
|
|
55
|
+
with httpx.Client(
|
|
56
|
+
headers=headers,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
verify=ssl_context,
|
|
59
|
+
) as client:
|
|
60
|
+
|
|
61
|
+
# perform connection check action
|
|
62
|
+
if opts.action in [Action.ALL, Action.CONNECTION_CHECK]:
|
|
63
|
+
connection_check(opts, client)
|
|
64
|
+
|
|
65
|
+
# perform data fetch action
|
|
66
|
+
if opts.action in [Action.ALL, Action.FETCH]:
|
|
67
|
+
fetch_attempt(opts, client)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def connection_check(opts: Namespace, client: httpx.Client) -> None:
|
|
71
|
+
logger.info('validating availability of confluence instance...')
|
|
72
|
+
logger.debug(f'instance: {opts.confluence_url}')
|
|
73
|
+
|
|
74
|
+
if not validate_connection(opts, client):
|
|
75
|
+
raise SystemExit(3)
|
|
76
|
+
|
|
77
|
+
logger.info('confluence instance appears to be online')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fetch_attempt(opts: Namespace, client: httpx.Client) -> None:
|
|
81
|
+
logger.info('validating the confluence space can be found...')
|
|
82
|
+
logger.debug(f'space-key: {opts.confluence_space_key}')
|
|
83
|
+
|
|
84
|
+
# verify the space exists
|
|
85
|
+
if not validate_space(opts, client):
|
|
86
|
+
raise SystemExit(4)
|
|
87
|
+
|
|
88
|
+
logger.info('confluence space has been detected')
|
|
89
|
+
|
|
90
|
+
# try to pull space content
|
|
91
|
+
logger.info('attempting to fetch content...')
|
|
92
|
+
|
|
93
|
+
if not fetch(opts, client):
|
|
94
|
+
raise SystemExit(5)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def process(opts: Namespace) -> None:
|
|
98
|
+
logger.info('converting cached data into markdown files')
|
|
99
|
+
|
|
100
|
+
if not convert(opts):
|
|
101
|
+
raise SystemExit(3)
|
|
102
|
+
|
|
103
|
+
logger.info('conversion completed')
|