markdown-to-confluence 0.1.4__tar.gz → 0.1.6__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.
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/LICENSE +1 -1
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/PKG-INFO +7 -2
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/README.md +1 -1
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/markdown_to_confluence.egg-info/PKG-INFO +7 -2
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/markdown_to_confluence.egg-info/SOURCES.txt +3 -1
- markdown-to-confluence-0.1.6/markdown_to_confluence.egg-info/requires.txt +6 -0
- markdown-to-confluence-0.1.6/md2conf/__init__.py +13 -0
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/md2conf/__main__.py +31 -2
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/md2conf/api.py +104 -49
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/md2conf/application.py +5 -3
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/md2conf/converter.py +42 -12
- markdown-to-confluence-0.1.6/md2conf/py.typed +0 -0
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/setup.cfg +15 -4
- markdown-to-confluence-0.1.6/tests/test_api.py +73 -0
- markdown-to-confluence-0.1.4/markdown_to_confluence.egg-info/requires.txt +0 -4
- markdown-to-confluence-0.1.4/md2conf/__init__.py +0 -1
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/markdown_to_confluence.egg-info/dependency_links.txt +0 -0
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/markdown_to_confluence.egg-info/top_level.txt +0 -0
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/markdown_to_confluence.egg-info/zip-safe +0 -0
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/pyproject.toml +0 -0
- {markdown-to-confluence-0.1.4 → markdown-to-confluence-0.1.6}/setup.py +0 -0
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Publish Markdown files to Confluence wiki
|
|
5
5
|
Home-page: https://github.com/hunyadi/md2conf
|
|
6
6
|
Author: Levente Hunyadi
|
|
7
7
|
Author-email: hunyadi@gmail.com
|
|
8
8
|
License: MIT
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
10
|
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
10
12
|
Classifier: License :: OSI Approved :: MIT License
|
|
11
13
|
Classifier: Operating System :: OS Independent
|
|
12
14
|
Classifier: Programming Language :: Python :: 3
|
|
13
15
|
Classifier: Programming Language :: Python :: 3.8
|
|
14
16
|
Classifier: Programming Language :: Python :: 3.9
|
|
15
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.8
|
|
16
21
|
Description-Content-Type: text/markdown
|
|
17
22
|
License-File: LICENSE
|
|
18
23
|
|
|
@@ -39,7 +44,7 @@ This Python package
|
|
|
39
44
|
## Getting started
|
|
40
45
|
|
|
41
46
|
In order to get started, you will need
|
|
42
|
-
* your organization
|
|
47
|
+
* your organization domain name (e.g. `instructure.atlassian.net`),
|
|
43
48
|
* your Confluence username (e.g. `levente.hunyadi@instructure.com`),
|
|
44
49
|
* a Confluence API token (a string of alphanumeric characters), and
|
|
45
50
|
* the space key in Confluence (e.g. `DAP`) you are publishing content to.
|
|
@@ -21,7 +21,7 @@ This Python package
|
|
|
21
21
|
## Getting started
|
|
22
22
|
|
|
23
23
|
In order to get started, you will need
|
|
24
|
-
* your organization
|
|
24
|
+
* your organization domain name (e.g. `instructure.atlassian.net`),
|
|
25
25
|
* your Confluence username (e.g. `levente.hunyadi@instructure.com`),
|
|
26
26
|
* a Confluence API token (a string of alphanumeric characters), and
|
|
27
27
|
* the space key in Confluence (e.g. `DAP`) you are publishing content to.
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Publish Markdown files to Confluence wiki
|
|
5
5
|
Home-page: https://github.com/hunyadi/md2conf
|
|
6
6
|
Author: Levente Hunyadi
|
|
7
7
|
Author-email: hunyadi@gmail.com
|
|
8
8
|
License: MIT
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
10
|
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
10
12
|
Classifier: License :: OSI Approved :: MIT License
|
|
11
13
|
Classifier: Operating System :: OS Independent
|
|
12
14
|
Classifier: Programming Language :: Python :: 3
|
|
13
15
|
Classifier: Programming Language :: Python :: 3.8
|
|
14
16
|
Classifier: Programming Language :: Python :: 3.9
|
|
15
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.8
|
|
16
21
|
Description-Content-Type: text/markdown
|
|
17
22
|
License-File: LICENSE
|
|
18
23
|
|
|
@@ -39,7 +44,7 @@ This Python package
|
|
|
39
44
|
## Getting started
|
|
40
45
|
|
|
41
46
|
In order to get started, you will need
|
|
42
|
-
* your organization
|
|
47
|
+
* your organization domain name (e.g. `instructure.atlassian.net`),
|
|
43
48
|
* your Confluence username (e.g. `levente.hunyadi@instructure.com`),
|
|
44
49
|
* a Confluence API token (a string of alphanumeric characters), and
|
|
45
50
|
* the space key in Confluence (e.g. `DAP`) you are publishing content to.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Parses Markdown files, converts Markdown content into the Confluence Storage Format (XHTML), and invokes
|
|
5
|
+
Confluence API endpoints to upload images and content.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.6"
|
|
9
|
+
__author__ = "Levente Hunyadi"
|
|
10
|
+
__copyright__ = "Copyright 2022-2023, Levente Hunyadi"
|
|
11
|
+
__license__ = "MIT"
|
|
12
|
+
__maintainer__ = "Levente Hunyadi"
|
|
13
|
+
__status__ = "Production"
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import logging
|
|
3
3
|
import os.path
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import requests
|
|
4
7
|
|
|
5
8
|
from .api import ConfluenceAPI
|
|
6
9
|
from .application import synchronize_page
|
|
10
|
+
from .converter import ConfluenceDocumentOptions
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
class Arguments(argparse.Namespace):
|
|
@@ -13,6 +17,7 @@ class Arguments(argparse.Namespace):
|
|
|
13
17
|
apikey: str
|
|
14
18
|
space: str
|
|
15
19
|
loglevel: str
|
|
20
|
+
generated_by: bool
|
|
16
21
|
|
|
17
22
|
|
|
18
23
|
parser = argparse.ArgumentParser()
|
|
@@ -46,6 +51,18 @@ parser.add_argument(
|
|
|
46
51
|
default=logging.getLevelName(logging.INFO),
|
|
47
52
|
help="Use this option to set the log verbosity.",
|
|
48
53
|
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--generated-by",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Add 'generated by a tool' prompt to pages.",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--no-generated-by",
|
|
61
|
+
dest="generated_by",
|
|
62
|
+
action="store_false",
|
|
63
|
+
help="Do not add 'generated by a tool' prompt to pages.",
|
|
64
|
+
)
|
|
65
|
+
parser.set_defaults(generated_by=True)
|
|
49
66
|
|
|
50
67
|
args = Arguments()
|
|
51
68
|
parser.parse_args(namespace=args)
|
|
@@ -55,5 +72,17 @@ logging.basicConfig(
|
|
|
55
72
|
format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
|
|
56
73
|
)
|
|
57
74
|
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
try:
|
|
76
|
+
with ConfluenceAPI(args.domain, args.username, args.apikey, args.space) as api:
|
|
77
|
+
synchronize_page(api, args.mdfile, ConfluenceDocumentOptions(args.generated_by))
|
|
78
|
+
except requests.exceptions.HTTPError as err:
|
|
79
|
+
logging.error(err)
|
|
80
|
+
|
|
81
|
+
# print details for a response with JSON body
|
|
82
|
+
try:
|
|
83
|
+
response: requests.Response = err.response
|
|
84
|
+
logging.error(response.json())
|
|
85
|
+
except requests.exceptions.JSONDecodeError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
sys.exit(1)
|
|
@@ -3,20 +3,34 @@ import logging
|
|
|
3
3
|
import mimetypes
|
|
4
4
|
import os
|
|
5
5
|
import os.path
|
|
6
|
-
import
|
|
6
|
+
import sys
|
|
7
|
+
import typing
|
|
7
8
|
from contextlib import contextmanager
|
|
8
9
|
from dataclasses import dataclass
|
|
9
|
-
from
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Dict, Generator, List, Optional, Type, Union
|
|
12
|
+
from urllib.parse import urlencode, urlparse, urlunparse
|
|
10
13
|
|
|
11
14
|
import requests
|
|
12
15
|
|
|
13
16
|
from .converter import ParseError, sanitize_confluence
|
|
14
17
|
|
|
18
|
+
# a JSON type with possible `null` values
|
|
19
|
+
JsonType = Union[
|
|
20
|
+
None,
|
|
21
|
+
bool,
|
|
22
|
+
int,
|
|
23
|
+
float,
|
|
24
|
+
str,
|
|
25
|
+
Dict[str, "JsonType"],
|
|
26
|
+
List["JsonType"],
|
|
27
|
+
]
|
|
15
28
|
|
|
16
|
-
|
|
29
|
+
|
|
30
|
+
def build_url(base_url: str, query: Optional[Dict[str, str]] = None) -> str:
|
|
17
31
|
"Builds a URL with scheme, host, port, path and query string parameters."
|
|
18
32
|
|
|
19
|
-
scheme, netloc, path, params, query_str, fragment =
|
|
33
|
+
scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
|
|
20
34
|
|
|
21
35
|
if params:
|
|
22
36
|
raise ValueError("expected: url with no parameters")
|
|
@@ -25,9 +39,26 @@ def build_url(base_url: str, query: Dict[str, str] = None):
|
|
|
25
39
|
if fragment:
|
|
26
40
|
raise ValueError("expected: url with no fragment")
|
|
27
41
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
|
|
43
|
+
return urlunparse(url_parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if sys.version_info >= (3, 9):
|
|
47
|
+
|
|
48
|
+
def removeprefix(string: str, prefix: str) -> str:
|
|
49
|
+
"If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
|
|
50
|
+
|
|
51
|
+
return string.removeprefix(prefix)
|
|
52
|
+
|
|
53
|
+
else:
|
|
54
|
+
|
|
55
|
+
def removeprefix(string: str, prefix: str) -> str:
|
|
56
|
+
"If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
|
|
57
|
+
|
|
58
|
+
if string.startswith(prefix):
|
|
59
|
+
return string[len(prefix) :]
|
|
60
|
+
else:
|
|
61
|
+
return string
|
|
31
62
|
|
|
32
63
|
|
|
33
64
|
LOGGER = logging.getLogger(__name__)
|
|
@@ -59,36 +90,54 @@ class ConfluenceAPI:
|
|
|
59
90
|
user_name: str
|
|
60
91
|
api_key: str
|
|
61
92
|
|
|
62
|
-
session: "ConfluenceSession"
|
|
93
|
+
session: Optional["ConfluenceSession"] = None
|
|
63
94
|
|
|
64
95
|
def __init__(
|
|
65
96
|
self,
|
|
66
|
-
domain: str = None,
|
|
67
|
-
user_name: str = None,
|
|
68
|
-
api_key: str = None,
|
|
69
|
-
space_key: str = None,
|
|
97
|
+
domain: Optional[str] = None,
|
|
98
|
+
user_name: Optional[str] = None,
|
|
99
|
+
api_key: Optional[str] = None,
|
|
100
|
+
space_key: Optional[str] = None,
|
|
70
101
|
) -> None:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
102
|
+
opt_domain = domain or os.getenv("CONFLUENCE_DOMAIN")
|
|
103
|
+
opt_user_name = user_name or os.getenv("CONFLUENCE_USER_NAME")
|
|
104
|
+
opt_api_key = api_key or os.getenv("CONFLUENCE_API_KEY")
|
|
105
|
+
opt_space_key = space_key or os.getenv("CONFLUENCE_SPACE_KEY")
|
|
75
106
|
|
|
76
|
-
if not
|
|
107
|
+
if not opt_domain:
|
|
77
108
|
raise ConfluenceError("Confluence domain not specified")
|
|
78
|
-
if not
|
|
109
|
+
if not opt_user_name:
|
|
79
110
|
raise ConfluenceError("Confluence user name not specified")
|
|
80
|
-
if not
|
|
111
|
+
if not opt_api_key:
|
|
81
112
|
raise ConfluenceError("Confluence API key not specified")
|
|
113
|
+
if not opt_space_key:
|
|
114
|
+
raise ConfluenceError("Confluence space key not specified")
|
|
82
115
|
|
|
83
|
-
|
|
116
|
+
if opt_domain.startswith(("http://", "https://")):
|
|
117
|
+
raise ConfluenceError(
|
|
118
|
+
"Confluence domain looks like a URL; only host name required"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self.domain = opt_domain
|
|
122
|
+
self.user_name = opt_user_name
|
|
123
|
+
self.api_key = opt_api_key
|
|
124
|
+
self.space_key = opt_space_key
|
|
125
|
+
|
|
126
|
+
def __enter__(self) -> "ConfluenceSession":
|
|
84
127
|
session = requests.Session()
|
|
85
128
|
session.auth = (self.user_name, self.api_key)
|
|
86
129
|
self.session = ConfluenceSession(session, self.domain, self.space_key)
|
|
87
130
|
return self.session
|
|
88
131
|
|
|
89
|
-
def __exit__(
|
|
90
|
-
self
|
|
91
|
-
|
|
132
|
+
def __exit__(
|
|
133
|
+
self,
|
|
134
|
+
exc_type: Optional[Type[BaseException]],
|
|
135
|
+
exc_val: Optional[BaseException],
|
|
136
|
+
exc_tb: Optional[TracebackType],
|
|
137
|
+
) -> None:
|
|
138
|
+
if self.session is not None:
|
|
139
|
+
self.session.close()
|
|
140
|
+
self.session = None
|
|
92
141
|
|
|
93
142
|
|
|
94
143
|
class ConfluenceSession:
|
|
@@ -113,11 +162,11 @@ class ConfluenceSession:
|
|
|
113
162
|
finally:
|
|
114
163
|
self.space_key = old_space_key
|
|
115
164
|
|
|
116
|
-
def _build_url(self, path: str, query: Dict[str, str] = None) -> str:
|
|
165
|
+
def _build_url(self, path: str, query: Optional[Dict[str, str]] = None) -> str:
|
|
117
166
|
base_url = f"https://{self.domain}/wiki/rest/api{path}"
|
|
118
167
|
return build_url(base_url, query)
|
|
119
168
|
|
|
120
|
-
def _invoke(self, path: str, query: Dict[str, str]) ->
|
|
169
|
+
def _invoke(self, path: str, query: Dict[str, str]) -> JsonType:
|
|
121
170
|
url = self._build_url(path, query)
|
|
122
171
|
response = self.session.get(url)
|
|
123
172
|
response.raise_for_status()
|
|
@@ -137,17 +186,18 @@ class ConfluenceSession:
|
|
|
137
186
|
) -> ConfluenceAttachment:
|
|
138
187
|
path = f"/content/{page_id}/child/attachment"
|
|
139
188
|
query = {"spaceKey": self.space_key, "filename": filename}
|
|
140
|
-
data = self._invoke(path, query)
|
|
189
|
+
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
141
190
|
|
|
142
|
-
results = data["results"]
|
|
191
|
+
results = typing.cast(List[JsonType], data["results"])
|
|
143
192
|
if len(results) != 1:
|
|
144
193
|
raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
|
|
194
|
+
result = typing.cast(Dict[str, JsonType], results[0])
|
|
145
195
|
|
|
146
|
-
id =
|
|
147
|
-
extensions =
|
|
148
|
-
media_type = extensions["mediaType"]
|
|
149
|
-
file_size = extensions["fileSize"]
|
|
150
|
-
comment = extensions["comment"]
|
|
196
|
+
id = typing.cast(str, result["id"])
|
|
197
|
+
extensions = typing.cast(Dict[str, JsonType], result["extensions"])
|
|
198
|
+
media_type = typing.cast(str, extensions["mediaType"])
|
|
199
|
+
file_size = typing.cast(int, extensions["fileSize"])
|
|
200
|
+
comment = typing.cast(str, extensions["comment"])
|
|
151
201
|
return ConfluenceAttachment(id, media_type, file_size, comment)
|
|
152
202
|
|
|
153
203
|
def upload_attachment(
|
|
@@ -155,9 +205,8 @@ class ConfluenceSession:
|
|
|
155
205
|
page_id: str,
|
|
156
206
|
attachment_path: str,
|
|
157
207
|
attachment_name: str,
|
|
158
|
-
comment: str = None,
|
|
208
|
+
comment: Optional[str] = None,
|
|
159
209
|
) -> None:
|
|
160
|
-
|
|
161
210
|
content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
|
|
162
211
|
|
|
163
212
|
if not os.path.isfile(attachment_path):
|
|
@@ -170,7 +219,7 @@ class ConfluenceSession:
|
|
|
170
219
|
LOGGER.info("Up-to-date attachment: %s", attachment_name)
|
|
171
220
|
return
|
|
172
221
|
|
|
173
|
-
id = attachment.id
|
|
222
|
+
id = removeprefix(attachment.id, "att")
|
|
174
223
|
path = f"/content/{page_id}/child/attachment/{id}/data"
|
|
175
224
|
|
|
176
225
|
except ConfluenceError:
|
|
@@ -190,7 +239,9 @@ class ConfluenceSession:
|
|
|
190
239
|
}
|
|
191
240
|
LOGGER.info("Uploading attachment: %s", attachment_name)
|
|
192
241
|
response = self.session.post(
|
|
193
|
-
url,
|
|
242
|
+
url,
|
|
243
|
+
files=file_to_upload, # type: ignore
|
|
244
|
+
headers={"X-Atlassian-Token": "no-check"},
|
|
194
245
|
)
|
|
195
246
|
|
|
196
247
|
response.raise_for_status()
|
|
@@ -210,8 +261,7 @@ class ConfluenceSession:
|
|
|
210
261
|
def _update_attachment(
|
|
211
262
|
self, page_id: str, attachment_id: str, version: int, attachment_title: str
|
|
212
263
|
) -> None:
|
|
213
|
-
|
|
214
|
-
id = attachment_id.removeprefix("att")
|
|
264
|
+
id = removeprefix(attachment_id, "att")
|
|
215
265
|
path = f"/content/{page_id}/child/attachment/{id}"
|
|
216
266
|
data = {
|
|
217
267
|
"id": attachment_id,
|
|
@@ -236,13 +286,14 @@ class ConfluenceSession:
|
|
|
236
286
|
LOGGER.info("Looking up page with title: %s", title)
|
|
237
287
|
path = "/content"
|
|
238
288
|
query = {"title": title, "spaceKey": self.space_key}
|
|
239
|
-
data = self._invoke(path, query)
|
|
289
|
+
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
240
290
|
|
|
241
|
-
results = data["results"]
|
|
291
|
+
results = typing.cast(List[JsonType], data["results"])
|
|
242
292
|
if len(results) != 1:
|
|
243
293
|
raise ConfluenceError(f"page not found with title: {title}")
|
|
244
294
|
|
|
245
|
-
|
|
295
|
+
result = typing.cast(Dict[str, JsonType], results[0])
|
|
296
|
+
id = typing.cast(str, result["id"])
|
|
246
297
|
return id
|
|
247
298
|
|
|
248
299
|
def get_page(self, page_id: str) -> ConfluencePage:
|
|
@@ -251,13 +302,16 @@ class ConfluenceSession:
|
|
|
251
302
|
"spaceKey": self.space_key,
|
|
252
303
|
"expand": "body.storage,version",
|
|
253
304
|
}
|
|
254
|
-
data = self._invoke(path, query)
|
|
305
|
+
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
306
|
+
version = typing.cast(Dict[str, JsonType], data["version"])
|
|
307
|
+
body = typing.cast(Dict[str, JsonType], data["body"])
|
|
308
|
+
storage = typing.cast(Dict[str, JsonType], body["storage"])
|
|
255
309
|
|
|
256
310
|
return ConfluencePage(
|
|
257
311
|
id=page_id,
|
|
258
|
-
title=data["title"],
|
|
259
|
-
version=
|
|
260
|
-
content=
|
|
312
|
+
title=typing.cast(str, data["title"]),
|
|
313
|
+
version=typing.cast(int, version["number"]),
|
|
314
|
+
content=typing.cast(str, storage["value"]),
|
|
261
315
|
)
|
|
262
316
|
|
|
263
317
|
def get_page_version(self, page_id: str) -> int:
|
|
@@ -266,8 +320,9 @@ class ConfluenceSession:
|
|
|
266
320
|
"spaceKey": self.space_key,
|
|
267
321
|
"expand": "version",
|
|
268
322
|
}
|
|
269
|
-
data = self._invoke(path, query)
|
|
270
|
-
|
|
323
|
+
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
324
|
+
version = typing.cast(Dict[str, JsonType], data["version"])
|
|
325
|
+
return typing.cast(int, version["number"])
|
|
271
326
|
|
|
272
327
|
def update_page(self, page_id: str, new_content: str) -> None:
|
|
273
328
|
page = self.get_page(page_id)
|
|
@@ -277,8 +332,8 @@ class ConfluenceSession:
|
|
|
277
332
|
if old_content == new_content:
|
|
278
333
|
LOGGER.info("Up-to-date page: %s", page_id)
|
|
279
334
|
return
|
|
280
|
-
except ParseError:
|
|
281
|
-
|
|
335
|
+
except ParseError as exc:
|
|
336
|
+
LOGGER.warning(exc)
|
|
282
337
|
|
|
283
338
|
path = f"/content/{page_id}"
|
|
284
339
|
data = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os.path
|
|
2
2
|
|
|
3
3
|
from .api import ConfluenceSession
|
|
4
|
-
from .converter import ConfluenceDocument,
|
|
4
|
+
from .converter import ConfluenceDocument, ConfluenceDocumentOptions
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def update_document(
|
|
@@ -15,11 +15,13 @@ def update_document(
|
|
|
15
15
|
api.update_page(document.page_id, document.xhtml())
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def synchronize_page(
|
|
18
|
+
def synchronize_page(
|
|
19
|
+
api: ConfluenceSession, path: str, options: ConfluenceDocumentOptions
|
|
20
|
+
) -> None:
|
|
19
21
|
page_path = os.path.abspath(path)
|
|
20
22
|
base_path = os.path.dirname(page_path)
|
|
21
23
|
|
|
22
|
-
document = ConfluenceDocument(path)
|
|
24
|
+
document = ConfluenceDocument(path, options)
|
|
23
25
|
|
|
24
26
|
if document.space_key:
|
|
25
27
|
with api.switch_space(document.space_key):
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os.path
|
|
2
2
|
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
3
4
|
from typing import List, Optional, Tuple
|
|
4
5
|
from urllib.parse import urlparse
|
|
5
6
|
|
|
@@ -24,7 +25,7 @@ class ParseError(RuntimeError):
|
|
|
24
25
|
pass
|
|
25
26
|
|
|
26
27
|
|
|
27
|
-
def is_absolute_url(url):
|
|
28
|
+
def is_absolute_url(url: str) -> bool:
|
|
28
29
|
return bool(urlparse(url).netloc)
|
|
29
30
|
|
|
30
31
|
|
|
@@ -153,7 +154,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
153
154
|
def _transform_block(self, code: ET.Element) -> ET.Element:
|
|
154
155
|
language = code.attrib.get("class")
|
|
155
156
|
if language:
|
|
156
|
-
|
|
157
|
+
m = re.match("^language-(.*)$", language)
|
|
158
|
+
if m:
|
|
159
|
+
language = m.group(1)
|
|
160
|
+
else:
|
|
161
|
+
language = "none"
|
|
157
162
|
if language not in _languages:
|
|
158
163
|
language = "none"
|
|
159
164
|
content: str = code.text
|
|
@@ -175,11 +180,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
175
180
|
def transform(self, child: ET.Element) -> Optional[ET.Element]:
|
|
176
181
|
# normalize line breaks to regular space in element text
|
|
177
182
|
if child.text:
|
|
178
|
-
|
|
179
|
-
child.text =
|
|
183
|
+
text: str = child.text
|
|
184
|
+
child.text = text.replace("\n", " ")
|
|
180
185
|
if child.tail:
|
|
181
|
-
|
|
182
|
-
child.tail =
|
|
186
|
+
tail: str = child.tail
|
|
187
|
+
child.tail = tail.replace("\n", " ")
|
|
183
188
|
|
|
184
189
|
# <p><img src="..." /></p>
|
|
185
190
|
if child.tag == "p" and len(child) == 1 and child[0].tag == "img":
|
|
@@ -197,6 +202,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
197
202
|
elif child.tag == "pre" and len(child) == 1 and child[0].tag == "code":
|
|
198
203
|
return self._transform_block(child[0])
|
|
199
204
|
|
|
205
|
+
return None
|
|
206
|
+
|
|
200
207
|
|
|
201
208
|
class ConfluenceStorageFormatCleaner(NodeVisitor):
|
|
202
209
|
"Removes volatile attributes from a Confluence storage format XHTML document."
|
|
@@ -204,13 +211,14 @@ class ConfluenceStorageFormatCleaner(NodeVisitor):
|
|
|
204
211
|
def transform(self, child: ET.Element) -> Optional[ET.Element]:
|
|
205
212
|
child.attrib.pop(ET.QName(namespaces["ac"], "macro-id"), None)
|
|
206
213
|
child.attrib.pop(ET.QName(namespaces["ri"], "version-at-save"), None)
|
|
214
|
+
return None
|
|
207
215
|
|
|
208
216
|
|
|
209
217
|
class DocumentError(RuntimeError):
|
|
210
218
|
pass
|
|
211
219
|
|
|
212
220
|
|
|
213
|
-
def _extract_value(pattern, string) -> Tuple[Optional[str], str]:
|
|
221
|
+
def _extract_value(pattern: str, string: str) -> Tuple[Optional[str], str]:
|
|
214
222
|
values: List[str] = []
|
|
215
223
|
|
|
216
224
|
def _repl_func(matchobj: re.Match) -> str:
|
|
@@ -222,15 +230,29 @@ def _extract_value(pattern, string) -> Tuple[Optional[str], str]:
|
|
|
222
230
|
return value, string
|
|
223
231
|
|
|
224
232
|
|
|
233
|
+
@dataclass
|
|
234
|
+
class ConfluenceDocumentOptions:
|
|
235
|
+
"""
|
|
236
|
+
Options that control the generated page content.
|
|
237
|
+
|
|
238
|
+
:param show_generated: Whether to display a prompt "This page has been generated with a tool."
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
generated_by: bool = True
|
|
242
|
+
|
|
243
|
+
|
|
225
244
|
class ConfluenceDocument:
|
|
226
245
|
page_id: str
|
|
227
246
|
space_key: Optional[str] = None
|
|
228
247
|
links: List[str]
|
|
229
248
|
images: List[str]
|
|
230
249
|
|
|
250
|
+
options: ConfluenceDocumentOptions
|
|
231
251
|
root: ET.Element
|
|
232
252
|
|
|
233
|
-
def __init__(self, path: str) -> None:
|
|
253
|
+
def __init__(self, path: str, options: ConfluenceDocumentOptions) -> None:
|
|
254
|
+
self.options = options
|
|
255
|
+
|
|
234
256
|
path = os.path.abspath(path)
|
|
235
257
|
|
|
236
258
|
with open(path, "r") as f:
|
|
@@ -252,14 +274,16 @@ class ConfluenceDocument:
|
|
|
252
274
|
)
|
|
253
275
|
|
|
254
276
|
# parse Markdown document
|
|
255
|
-
self.
|
|
256
|
-
[
|
|
277
|
+
if self.options.generated_by:
|
|
278
|
+
content = [
|
|
257
279
|
'<ac:structured-macro ac:name="info" ac:schema-version="1">',
|
|
258
280
|
"<ac:rich-text-body><p>This page has been generated with a tool.</p></ac:rich-text-body>",
|
|
259
281
|
"</ac:structured-macro>",
|
|
260
282
|
html,
|
|
261
283
|
]
|
|
262
|
-
|
|
284
|
+
else:
|
|
285
|
+
content = [html]
|
|
286
|
+
self.root = elements_from_strings(content)
|
|
263
287
|
|
|
264
288
|
converter = ConfluenceStorageFormatConverter(os.path.dirname(path))
|
|
265
289
|
converter.visit(self.root)
|
|
@@ -273,6 +297,9 @@ class ConfluenceDocument:
|
|
|
273
297
|
def sanitize_confluence(html: str) -> str:
|
|
274
298
|
"Generates a sanitized version of a Confluence storage format XHTML document with no volatile attributes."
|
|
275
299
|
|
|
300
|
+
if not html:
|
|
301
|
+
return ""
|
|
302
|
+
|
|
276
303
|
root = elements_from_strings([html])
|
|
277
304
|
ConfluenceStorageFormatCleaner().visit(root)
|
|
278
305
|
return _content_to_string(root)
|
|
@@ -281,4 +308,7 @@ def sanitize_confluence(html: str) -> str:
|
|
|
281
308
|
def _content_to_string(root: ET.Element) -> str:
|
|
282
309
|
xml = ET.tostring(root, encoding="utf8", method="xml").decode("utf8")
|
|
283
310
|
m = re.match(r"^<root\s+[^>]*>(.*)</root>\s*$", xml, re.DOTALL)
|
|
284
|
-
|
|
311
|
+
if m:
|
|
312
|
+
return m.group(1)
|
|
313
|
+
else:
|
|
314
|
+
raise ValueError("expected: Confluence content")
|
|
File without changes
|
|
@@ -9,28 +9,39 @@ long_description = file: README.md
|
|
|
9
9
|
long_description_content_type = text/markdown
|
|
10
10
|
license = MIT
|
|
11
11
|
classifiers =
|
|
12
|
+
Development Status :: 5 - Production/Stable
|
|
12
13
|
Environment :: Console
|
|
14
|
+
Intended Audience :: End Users/Desktop
|
|
13
15
|
License :: OSI Approved :: MIT License
|
|
14
16
|
Operating System :: OS Independent
|
|
15
17
|
Programming Language :: Python :: 3
|
|
16
18
|
Programming Language :: Python :: 3.8
|
|
17
19
|
Programming Language :: Python :: 3.9
|
|
18
20
|
Programming Language :: Python :: 3.10
|
|
21
|
+
Programming Language :: Python :: 3.11
|
|
22
|
+
Typing :: Typed
|
|
19
23
|
|
|
20
24
|
[options]
|
|
21
25
|
zip_safe = True
|
|
22
26
|
include_package_data = True
|
|
23
27
|
packages = find:
|
|
28
|
+
python_requires = >=3.8
|
|
24
29
|
install_requires =
|
|
25
|
-
lxml
|
|
26
|
-
markdown
|
|
27
|
-
pymdown-extensions
|
|
28
|
-
requests
|
|
30
|
+
lxml >= 4.9
|
|
31
|
+
markdown >= 3.4
|
|
32
|
+
pymdown-extensions >= 9.11
|
|
33
|
+
requests >= 2.28
|
|
34
|
+
types-markdown >= 3.4
|
|
35
|
+
types-requests >= 2.28
|
|
29
36
|
|
|
30
37
|
[options.packages.find]
|
|
31
38
|
exclude =
|
|
32
39
|
tests*
|
|
33
40
|
|
|
41
|
+
[options.package_data]
|
|
42
|
+
md2conf =
|
|
43
|
+
py.typed
|
|
44
|
+
|
|
34
45
|
[egg_info]
|
|
35
46
|
tag_build =
|
|
36
47
|
tag_date = 0
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import os.path
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from md2conf.api import ConfluenceAPI, ConfluenceAttachment, ConfluencePage
|
|
7
|
+
from md2conf.application import synchronize_page
|
|
8
|
+
from md2conf.converter import (
|
|
9
|
+
ConfluenceDocument,
|
|
10
|
+
ConfluenceDocumentOptions,
|
|
11
|
+
sanitize_confluence,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
logging.basicConfig(
|
|
15
|
+
level=logging.INFO,
|
|
16
|
+
format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestAPI(unittest.TestCase):
|
|
21
|
+
def test_markdown(self) -> None:
|
|
22
|
+
document = ConfluenceDocument("example.md", ConfluenceDocumentOptions())
|
|
23
|
+
self.assertListEqual(document.links, [])
|
|
24
|
+
self.assertListEqual(
|
|
25
|
+
document.images,
|
|
26
|
+
["figure/interoperability.png", "figure/interoperability.png"],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
with open(os.path.join("tests", "output", "document.html"), "w") as f:
|
|
30
|
+
f.write(document.xhtml())
|
|
31
|
+
|
|
32
|
+
def test_find_page_by_title(self) -> None:
|
|
33
|
+
with ConfluenceAPI() as api:
|
|
34
|
+
id = api.get_page_id_by_title("Publish to Confluence")
|
|
35
|
+
self.assertEqual(id, "85668266616")
|
|
36
|
+
|
|
37
|
+
def test_switch_space(self) -> None:
|
|
38
|
+
with ConfluenceAPI(space_key="PLAT") as api:
|
|
39
|
+
with api.switch_space("DAP"):
|
|
40
|
+
id = api.get_page_id_by_title("Publish to Confluence")
|
|
41
|
+
self.assertEqual(id, "85668266616")
|
|
42
|
+
|
|
43
|
+
def test_get_page(self) -> None:
|
|
44
|
+
with ConfluenceAPI() as api:
|
|
45
|
+
page = api.get_page("85668266616")
|
|
46
|
+
self.assertIsInstance(page, ConfluencePage)
|
|
47
|
+
|
|
48
|
+
with open(os.path.join("tests", "output", "page.html"), "w") as f:
|
|
49
|
+
f.write(sanitize_confluence(page.content))
|
|
50
|
+
|
|
51
|
+
def test_get_attachment(self) -> None:
|
|
52
|
+
with ConfluenceAPI() as api:
|
|
53
|
+
data = api.get_attachment_by_name(
|
|
54
|
+
"85668266616", "figure/interoperability.png"
|
|
55
|
+
)
|
|
56
|
+
self.assertIsInstance(data, ConfluenceAttachment)
|
|
57
|
+
|
|
58
|
+
def test_upload_attachment(self) -> None:
|
|
59
|
+
with ConfluenceAPI() as api:
|
|
60
|
+
api.upload_attachment(
|
|
61
|
+
"85668266616",
|
|
62
|
+
os.path.join(os.getcwd(), "figure", "interoperability.png"),
|
|
63
|
+
"figure/interoperability.png",
|
|
64
|
+
"A sample figure",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def test_synchronize_page(self) -> None:
|
|
68
|
+
with ConfluenceAPI() as api:
|
|
69
|
+
synchronize_page(api, "example.md", ConfluenceDocumentOptions())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|