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 ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: BSD-2-Clause
2
+ # Copyright jdknight
3
+
4
+ __version__ = '0.0.0'
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')