markdown-to-confluence 0.1.11__tar.gz → 0.1.12__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 (27) hide show
  1. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/PKG-INFO +18 -13
  2. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/README.md +10 -5
  3. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/PKG-INFO +18 -13
  4. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/SOURCES.txt +4 -1
  5. markdown_to_confluence-0.1.12/markdown_to_confluence.egg-info/entry_points.txt +2 -0
  6. markdown_to_confluence-0.1.12/markdown_to_confluence.egg-info/requires.txt +7 -0
  7. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/__init__.py +1 -1
  8. markdown_to_confluence-0.1.12/md2conf/__main__.py +140 -0
  9. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/api.py +5 -5
  10. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/application.py +28 -24
  11. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/converter.py +48 -26
  12. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/processor.py +22 -20
  13. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/properties.py +0 -1
  14. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/setup.cfg +11 -7
  15. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/tests/test_api.py +33 -23
  16. markdown_to_confluence-0.1.12/tests/test_conversion.py +58 -0
  17. markdown_to_confluence-0.1.12/tests/test_processor.py +64 -0
  18. markdown-to-confluence-0.1.11/markdown_to_confluence.egg-info/requires.txt +0 -7
  19. markdown-to-confluence-0.1.11/md2conf/__main__.py +0 -129
  20. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/LICENSE +0 -0
  21. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/dependency_links.txt +0 -0
  22. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/top_level.txt +0 -0
  23. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/zip-safe +0 -0
  24. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/entities.dtd +0 -0
  25. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/py.typed +0 -0
  26. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/pyproject.toml +0 -0
  27. {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: markdown-to-confluence
3
- Version: 0.1.11
3
+ Version: 0.1.12
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -21,13 +21,13 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.8
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: lxml>=4.9
25
- Requires-Dist: types-lxml>=2023.10.21
26
- Requires-Dist: markdown>=3.5
27
- Requires-Dist: types-markdown>=3.5
28
- Requires-Dist: pymdown-extensions>=10.3
29
- Requires-Dist: requests>=2.31
30
- Requires-Dist: types-requests>=2.31
24
+ Requires-Dist: lxml>=5.2
25
+ Requires-Dist: types-lxml>=2024.4.14
26
+ Requires-Dist: markdown>=3.6
27
+ Requires-Dist: types-markdown>=3.6
28
+ Requires-Dist: pymdown-extensions>=10.8
29
+ Requires-Dist: requests>=2.32
30
+ Requires-Dist: types-requests>=2.32
31
31
 
32
32
  # Publish Markdown files to Confluence wiki
33
33
 
@@ -36,6 +36,7 @@ Contributors to software projects typically write documentation in Markdown form
36
36
  Replicating documentation to Confluence by hand is tedious, and a lack of automated synchronization with the project repositories where the documents live leads to outdated documentation.
37
37
 
38
38
  This Python package
39
+
39
40
  * parses Markdown files,
40
41
  * converts Markdown content into the Confluence Storage Format (XHTML),
41
42
  * invokes Confluence API endpoints to upload images and content.
@@ -54,6 +55,7 @@ This Python package
54
55
  ## Getting started
55
56
 
56
57
  In order to get started, you will need
58
+
57
59
  * your organization domain name (e.g. `instructure.atlassian.net`),
58
60
  * base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
59
61
  * your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
@@ -62,7 +64,7 @@ In order to get started, you will need
62
64
 
63
65
  ### Obtaining an API token
64
66
 
65
- 1. Log in to https://id.atlassian.com/manage/api-tokens.
67
+ 1. Log in to <https://id.atlassian.com/manage/api-tokens>.
66
68
  2. Click *Create API token*.
67
69
  3. From the dialog that appears, enter a memorable and concise *Label* for your token and click *Create*.
68
70
  4. Click *Copy to clipboard*, then paste the token to your script, or elsewhere to save.
@@ -70,6 +72,7 @@ In order to get started, you will need
70
72
  ### Setting up the environment
71
73
 
72
74
  Confluence organization domain, base path, username, API token and space key can be specified at runtime or set as Confluence environment variables (e.g. add to your `~/.profile` on Linux, or `~/.bash_profile` or `~/.zshenv` on MacOS):
75
+
73
76
  ```bash
74
77
  export CONFLUENCE_DOMAIN='instructure.atlassian.net'
75
78
  export CONFLUENCE_PATH='/wiki/'
@@ -136,18 +139,18 @@ Use the `--help` switch to get a full list of supported command-line options:
136
139
 
137
140
  ```console
138
141
  $ python3 -m md2conf --help
139
- usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}]
140
- [--generated-by GENERATED_BY] [--no-generated-by] [--ignore-invalid-url]
142
+ usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY]
143
+ [--no-generated-by] [--ignore-invalid-url] [--local]
141
144
  mdpath
142
145
 
143
146
  positional arguments:
144
147
  mdpath Path to Markdown file or directory to convert and publish.
145
148
 
146
- options:
149
+ optional arguments:
147
150
  -h, --help show this help message and exit
148
151
  -d DOMAIN, --domain DOMAIN
149
152
  Confluence organization domain.
150
- -p PATH, --path PATH Base path for Confluece wiki.
153
+ -p PATH, --path PATH Base path for Confluence (default: '/wiki/').
151
154
  -u USERNAME, --username USERNAME
152
155
  Confluence user name.
153
156
  -a APIKEY, --apikey APIKEY
@@ -156,8 +159,10 @@ options:
156
159
  Confluence space key for pages to be published. If omitted, will default to user space.
157
160
  -l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
158
161
  Use this option to set the log verbosity.
162
+ -r ROOT_PAGE Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.
159
163
  --generated-by GENERATED_BY
160
164
  Add prompt to pages (default: 'This page has been generated with a tool.').
161
165
  --no-generated-by Do not add 'generated by a tool' prompt to pages.
162
166
  --ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
167
+ --local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
163
168
  ```
@@ -5,6 +5,7 @@ Contributors to software projects typically write documentation in Markdown form
5
5
  Replicating documentation to Confluence by hand is tedious, and a lack of automated synchronization with the project repositories where the documents live leads to outdated documentation.
6
6
 
7
7
  This Python package
8
+
8
9
  * parses Markdown files,
9
10
  * converts Markdown content into the Confluence Storage Format (XHTML),
10
11
  * invokes Confluence API endpoints to upload images and content.
@@ -23,6 +24,7 @@ This Python package
23
24
  ## Getting started
24
25
 
25
26
  In order to get started, you will need
27
+
26
28
  * your organization domain name (e.g. `instructure.atlassian.net`),
27
29
  * base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
28
30
  * your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
@@ -31,7 +33,7 @@ In order to get started, you will need
31
33
 
32
34
  ### Obtaining an API token
33
35
 
34
- 1. Log in to https://id.atlassian.com/manage/api-tokens.
36
+ 1. Log in to <https://id.atlassian.com/manage/api-tokens>.
35
37
  2. Click *Create API token*.
36
38
  3. From the dialog that appears, enter a memorable and concise *Label* for your token and click *Create*.
37
39
  4. Click *Copy to clipboard*, then paste the token to your script, or elsewhere to save.
@@ -39,6 +41,7 @@ In order to get started, you will need
39
41
  ### Setting up the environment
40
42
 
41
43
  Confluence organization domain, base path, username, API token and space key can be specified at runtime or set as Confluence environment variables (e.g. add to your `~/.profile` on Linux, or `~/.bash_profile` or `~/.zshenv` on MacOS):
44
+
42
45
  ```bash
43
46
  export CONFLUENCE_DOMAIN='instructure.atlassian.net'
44
47
  export CONFLUENCE_PATH='/wiki/'
@@ -105,18 +108,18 @@ Use the `--help` switch to get a full list of supported command-line options:
105
108
 
106
109
  ```console
107
110
  $ python3 -m md2conf --help
108
- usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}]
109
- [--generated-by GENERATED_BY] [--no-generated-by] [--ignore-invalid-url]
111
+ usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY]
112
+ [--no-generated-by] [--ignore-invalid-url] [--local]
110
113
  mdpath
111
114
 
112
115
  positional arguments:
113
116
  mdpath Path to Markdown file or directory to convert and publish.
114
117
 
115
- options:
118
+ optional arguments:
116
119
  -h, --help show this help message and exit
117
120
  -d DOMAIN, --domain DOMAIN
118
121
  Confluence organization domain.
119
- -p PATH, --path PATH Base path for Confluece wiki.
122
+ -p PATH, --path PATH Base path for Confluence (default: '/wiki/').
120
123
  -u USERNAME, --username USERNAME
121
124
  Confluence user name.
122
125
  -a APIKEY, --apikey APIKEY
@@ -125,8 +128,10 @@ options:
125
128
  Confluence space key for pages to be published. If omitted, will default to user space.
126
129
  -l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
127
130
  Use this option to set the log verbosity.
131
+ -r ROOT_PAGE Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.
128
132
  --generated-by GENERATED_BY
129
133
  Add prompt to pages (default: 'This page has been generated with a tool.').
130
134
  --no-generated-by Do not add 'generated by a tool' prompt to pages.
131
135
  --ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
136
+ --local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
132
137
  ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: markdown-to-confluence
3
- Version: 0.1.11
3
+ Version: 0.1.12
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -21,13 +21,13 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.8
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: lxml>=4.9
25
- Requires-Dist: types-lxml>=2023.10.21
26
- Requires-Dist: markdown>=3.5
27
- Requires-Dist: types-markdown>=3.5
28
- Requires-Dist: pymdown-extensions>=10.3
29
- Requires-Dist: requests>=2.31
30
- Requires-Dist: types-requests>=2.31
24
+ Requires-Dist: lxml>=5.2
25
+ Requires-Dist: types-lxml>=2024.4.14
26
+ Requires-Dist: markdown>=3.6
27
+ Requires-Dist: types-markdown>=3.6
28
+ Requires-Dist: pymdown-extensions>=10.8
29
+ Requires-Dist: requests>=2.32
30
+ Requires-Dist: types-requests>=2.32
31
31
 
32
32
  # Publish Markdown files to Confluence wiki
33
33
 
@@ -36,6 +36,7 @@ Contributors to software projects typically write documentation in Markdown form
36
36
  Replicating documentation to Confluence by hand is tedious, and a lack of automated synchronization with the project repositories where the documents live leads to outdated documentation.
37
37
 
38
38
  This Python package
39
+
39
40
  * parses Markdown files,
40
41
  * converts Markdown content into the Confluence Storage Format (XHTML),
41
42
  * invokes Confluence API endpoints to upload images and content.
@@ -54,6 +55,7 @@ This Python package
54
55
  ## Getting started
55
56
 
56
57
  In order to get started, you will need
58
+
57
59
  * your organization domain name (e.g. `instructure.atlassian.net`),
58
60
  * base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
59
61
  * your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
@@ -62,7 +64,7 @@ In order to get started, you will need
62
64
 
63
65
  ### Obtaining an API token
64
66
 
65
- 1. Log in to https://id.atlassian.com/manage/api-tokens.
67
+ 1. Log in to <https://id.atlassian.com/manage/api-tokens>.
66
68
  2. Click *Create API token*.
67
69
  3. From the dialog that appears, enter a memorable and concise *Label* for your token and click *Create*.
68
70
  4. Click *Copy to clipboard*, then paste the token to your script, or elsewhere to save.
@@ -70,6 +72,7 @@ In order to get started, you will need
70
72
  ### Setting up the environment
71
73
 
72
74
  Confluence organization domain, base path, username, API token and space key can be specified at runtime or set as Confluence environment variables (e.g. add to your `~/.profile` on Linux, or `~/.bash_profile` or `~/.zshenv` on MacOS):
75
+
73
76
  ```bash
74
77
  export CONFLUENCE_DOMAIN='instructure.atlassian.net'
75
78
  export CONFLUENCE_PATH='/wiki/'
@@ -136,18 +139,18 @@ Use the `--help` switch to get a full list of supported command-line options:
136
139
 
137
140
  ```console
138
141
  $ python3 -m md2conf --help
139
- usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}]
140
- [--generated-by GENERATED_BY] [--no-generated-by] [--ignore-invalid-url]
142
+ usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY]
143
+ [--no-generated-by] [--ignore-invalid-url] [--local]
141
144
  mdpath
142
145
 
143
146
  positional arguments:
144
147
  mdpath Path to Markdown file or directory to convert and publish.
145
148
 
146
- options:
149
+ optional arguments:
147
150
  -h, --help show this help message and exit
148
151
  -d DOMAIN, --domain DOMAIN
149
152
  Confluence organization domain.
150
- -p PATH, --path PATH Base path for Confluece wiki.
153
+ -p PATH, --path PATH Base path for Confluence (default: '/wiki/').
151
154
  -u USERNAME, --username USERNAME
152
155
  Confluence user name.
153
156
  -a APIKEY, --apikey APIKEY
@@ -156,8 +159,10 @@ options:
156
159
  Confluence space key for pages to be published. If omitted, will default to user space.
157
160
  -l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
158
161
  Use this option to set the log verbosity.
162
+ -r ROOT_PAGE Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.
159
163
  --generated-by GENERATED_BY
160
164
  Add prompt to pages (default: 'This page has been generated with a tool.').
161
165
  --no-generated-by Do not add 'generated by a tool' prompt to pages.
162
166
  --ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
167
+ --local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
163
168
  ```
@@ -6,6 +6,7 @@ setup.py
6
6
  markdown_to_confluence.egg-info/PKG-INFO
7
7
  markdown_to_confluence.egg-info/SOURCES.txt
8
8
  markdown_to_confluence.egg-info/dependency_links.txt
9
+ markdown_to_confluence.egg-info/entry_points.txt
9
10
  markdown_to_confluence.egg-info/requires.txt
10
11
  markdown_to_confluence.egg-info/top_level.txt
11
12
  markdown_to_confluence.egg-info/zip-safe
@@ -18,4 +19,6 @@ md2conf/entities.dtd
18
19
  md2conf/processor.py
19
20
  md2conf/properties.py
20
21
  md2conf/py.typed
21
- tests/test_api.py
22
+ tests/test_api.py
23
+ tests/test_conversion.py
24
+ tests/test_processor.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ md2conf = md2conf.__main__:main
@@ -0,0 +1,7 @@
1
+ lxml>=5.2
2
+ types-lxml>=2024.4.14
3
+ markdown>=3.6
4
+ types-markdown>=3.6
5
+ pymdown-extensions>=10.8
6
+ requests>=2.32
7
+ types-requests>=2.32
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
5
5
  Confluence API endpoints to upload images and content.
6
6
  """
7
7
 
8
- __version__ = "0.1.11"
8
+ __version__ = "0.1.12"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2024, Levente Hunyadi"
11
11
  __license__ = "MIT"
@@ -0,0 +1,140 @@
1
+ import argparse
2
+ import logging
3
+ import os.path
4
+ import sys
5
+ import typing
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import requests
10
+
11
+ from .api import ConfluenceAPI
12
+ from .application import Application
13
+ from .converter import ConfluenceDocumentOptions
14
+ from .processor import Processor
15
+ from .properties import ConfluenceProperties
16
+
17
+
18
+ class Arguments(argparse.Namespace):
19
+ mdpath: Path
20
+ domain: str
21
+ path: str
22
+ username: str
23
+ apikey: str
24
+ space: str
25
+ loglevel: str
26
+ ignore_invalid_url: bool
27
+ generated_by: Optional[str]
28
+
29
+
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser()
32
+ parser.prog = os.path.basename(os.path.dirname(__file__))
33
+ parser.add_argument(
34
+ "mdpath", help="Path to Markdown file or directory to convert and publish."
35
+ )
36
+ parser.add_argument("-d", "--domain", help="Confluence organization domain.")
37
+ parser.add_argument(
38
+ "-p", "--path", help="Base path for Confluence (default: '/wiki/')."
39
+ )
40
+ parser.add_argument("-u", "--username", help="Confluence user name.")
41
+ parser.add_argument(
42
+ "-a",
43
+ "--apikey",
44
+ help="Confluence API key. Refer to documentation how to obtain one.",
45
+ )
46
+ parser.add_argument(
47
+ "-s",
48
+ "--space",
49
+ help="Confluence space key for pages to be published. If omitted, will default to user space.",
50
+ )
51
+ parser.add_argument(
52
+ "-l",
53
+ "--loglevel",
54
+ choices=[
55
+ typing.cast(str, logging.getLevelName(level)).lower()
56
+ for level in (
57
+ logging.DEBUG,
58
+ logging.INFO,
59
+ logging.WARN,
60
+ logging.ERROR,
61
+ logging.CRITICAL,
62
+ )
63
+ ],
64
+ default=logging.getLevelName(logging.INFO),
65
+ help="Use this option to set the log verbosity.",
66
+ )
67
+ parser.add_argument(
68
+ "-r",
69
+ dest="root_page",
70
+ help="Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.",
71
+ )
72
+ parser.add_argument(
73
+ "--generated-by",
74
+ default="This page has been generated with a tool.",
75
+ help="Add prompt to pages (default: 'This page has been generated with a tool.').",
76
+ )
77
+ parser.add_argument(
78
+ "--no-generated-by",
79
+ dest="generated_by",
80
+ action="store_const",
81
+ const=None,
82
+ help="Do not add 'generated by a tool' prompt to pages.",
83
+ )
84
+ parser.add_argument(
85
+ "--ignore-invalid-url",
86
+ action="store_true",
87
+ default=False,
88
+ help="Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.",
89
+ )
90
+ parser.add_argument(
91
+ "--local",
92
+ action="store_true",
93
+ default=False,
94
+ help="Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.",
95
+ )
96
+
97
+ args = Arguments()
98
+ parser.parse_args(namespace=args)
99
+
100
+ # NOTE: If we switch to modern type aware CLI tool like typer
101
+ # the following line won't be necessary
102
+ args.mdpath = Path(args.mdpath)
103
+
104
+ logging.basicConfig(
105
+ level=getattr(logging, args.loglevel.upper(), logging.INFO),
106
+ format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
107
+ )
108
+
109
+ options = ConfluenceDocumentOptions(
110
+ ignore_invalid_url=args.ignore_invalid_url,
111
+ generated_by=args.generated_by,
112
+ root_page_id=args.root_page,
113
+ )
114
+ properties = ConfluenceProperties(
115
+ args.domain, args.path, args.username, args.apikey, args.space
116
+ )
117
+ if args.local:
118
+ Processor(options, properties).process(args.mdpath)
119
+ else:
120
+ try:
121
+ with ConfluenceAPI(properties) as api:
122
+ Application(
123
+ api,
124
+ options,
125
+ ).synchronize(args.mdpath)
126
+ except requests.exceptions.HTTPError as err:
127
+ logging.error(err)
128
+
129
+ # print details for a response with JSON body
130
+ if err.response is not None:
131
+ try:
132
+ logging.error(err.response.json())
133
+ except requests.exceptions.JSONDecodeError:
134
+ pass
135
+
136
+ sys.exit(1)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -1,12 +1,11 @@
1
1
  import json
2
2
  import logging
3
3
  import mimetypes
4
- import os
5
- import os.path
6
4
  import sys
7
5
  import typing
8
6
  from contextlib import contextmanager
9
7
  from dataclasses import dataclass
8
+ from pathlib import Path
10
9
  from types import TracebackType
11
10
  from typing import Dict, Generator, List, Optional, Type, Union
12
11
  from urllib.parse import urlencode, urlparse, urlunparse
@@ -184,15 +183,16 @@ class ConfluenceSession:
184
183
  def upload_attachment(
185
184
  self,
186
185
  page_id: str,
187
- attachment_path: str,
186
+ attachment_path: Path,
188
187
  attachment_name: str,
189
188
  comment: Optional[str] = None,
190
189
  *,
191
190
  space_key: Optional[str] = None,
191
+ force: bool = False,
192
192
  ) -> None:
193
193
  content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
194
194
 
195
- if not os.path.isfile(attachment_path):
195
+ if not attachment_path.is_file():
196
196
  raise ConfluenceError(f"file not found: {attachment_path}")
197
197
 
198
198
  try:
@@ -200,7 +200,7 @@ class ConfluenceSession:
200
200
  page_id, attachment_name, space_key=space_key
201
201
  )
202
202
 
203
- if attachment.file_size == os.path.getsize(attachment_path):
203
+ if not force and attachment.file_size == attachment_path.stat().st_size:
204
204
  LOGGER.info("Up-to-date attachment: %s", attachment_name)
205
205
  return
206
206
 
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import os.path
3
+ from pathlib import Path
3
4
  from typing import Dict, Optional
4
5
 
5
6
  from .api import ConfluenceSession
@@ -7,6 +8,7 @@ from .converter import (
7
8
  ConfluenceDocument,
8
9
  ConfluenceDocumentOptions,
9
10
  ConfluencePageMetadata,
11
+ attachment_name,
10
12
  extract_qualified_id,
11
13
  )
12
14
 
@@ -25,40 +27,42 @@ class Application:
25
27
  self.api = api
26
28
  self.options = options
27
29
 
28
- def synchronize(self, path: str) -> None:
30
+ def synchronize(self, path: Path) -> None:
29
31
  "Synchronizes a single Markdown page or a directory of Markdown pages."
30
32
 
31
- if os.path.isdir(path):
33
+ if path.is_dir():
32
34
  self.synchronize_directory(path)
33
- elif os.path.isfile(path):
35
+ elif path.is_file():
34
36
  self.synchronize_page(path)
35
37
  else:
36
38
  raise ValueError(f"expected: valid file or directory path; got: {path}")
37
39
 
38
- def synchronize_page(self, page_path: str) -> None:
40
+ def synchronize_page(self, page_path: Path) -> None:
39
41
  "Synchronizes a single Markdown page with Confluence."
40
42
 
41
43
  self._synchronize_page(page_path, {})
42
44
 
43
- def synchronize_directory(self, dir: str) -> None:
45
+ def synchronize_directory(self, local_dir: Path) -> None:
44
46
  "Synchronizes a directory of Markdown pages with Confluence."
45
47
 
46
- page_metadata: Dict[str, ConfluencePageMetadata] = {}
47
- LOGGER.info(f"Synchronizing directory: {dir}")
48
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
49
+ LOGGER.info(f"Synchronizing directory: {local_dir}")
48
50
 
49
51
  # Step 1: build index of all page metadata
50
- for root, directories, files in os.walk(dir):
52
+ # NOTE: Pathlib.walk() is implemented only in Python 3.12+
53
+ # so sticking for old os.walk
54
+ for root, directories, files in os.walk(local_dir):
51
55
  for file_name in files:
52
- # check the file extension
53
- _, file_extension = os.path.splitext(file_name)
54
- if file_extension.lower() != ".md":
55
- continue
56
+ # Reconstitute Path object back
57
+ docfile = (Path(root) / file_name).absolute()
56
58
 
57
- absolute_path = os.path.join(os.path.abspath(root), file_name)
58
- metadata = self._get_or_create_page(absolute_path)
59
+ # Skip non-markdown files
60
+ if docfile.suffix.lower() != ".md":
61
+ continue
62
+ metadata = self._get_or_create_page(docfile)
59
63
 
60
- LOGGER.debug(f"indexed {absolute_path} with metadata: {metadata}")
61
- page_metadata[absolute_path] = metadata
64
+ LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
65
+ page_metadata[docfile] = metadata
62
66
 
63
67
  LOGGER.info(f"indexed {len(page_metadata)} pages")
64
68
 
@@ -68,10 +72,10 @@ class Application:
68
72
 
69
73
  def _synchronize_page(
70
74
  self,
71
- page_path: str,
72
- page_metadata: Dict[str, ConfluencePageMetadata],
75
+ page_path: Path,
76
+ page_metadata: Dict[Path, ConfluencePageMetadata],
73
77
  ) -> None:
74
- base_path = os.path.dirname(page_path)
78
+ base_path = page_path.parent
75
79
 
76
80
  LOGGER.info(f"Synchronizing page: {page_path}")
77
81
  document = ConfluenceDocument(page_path, self.options, page_metadata)
@@ -83,7 +87,7 @@ class Application:
83
87
  self._update_document(document, base_path)
84
88
 
85
89
  def _get_or_create_page(
86
- self, absolute_path: str, title: Optional[str] = None
90
+ self, absolute_path: Path, title: Optional[str] = None
87
91
  ) -> ConfluencePageMetadata:
88
92
  """
89
93
  Creates a new Confluence page if no page is linked in the Markdown document.
@@ -106,7 +110,7 @@ class Application:
106
110
 
107
111
  # use file name without extension if no title is supplied
108
112
  if title is None:
109
- title, _ = os.path.splitext(os.path.basename(absolute_path))
113
+ title = absolute_path.stem
110
114
 
111
115
  confluence_page = self.api.get_or_create_page(
112
116
  title, self.options.root_page_id
@@ -126,10 +130,10 @@ class Application:
126
130
  title=confluence_page.title or "",
127
131
  )
128
132
 
129
- def _update_document(self, document: ConfluenceDocument, base_path: str) -> None:
133
+ def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
130
134
  for image in document.images:
131
135
  self.api.upload_attachment(
132
- document.id.page_id, os.path.join(base_path, image), image, ""
136
+ document.id.page_id, base_path / image, attachment_name(image), ""
133
137
  )
134
138
 
135
139
  content = document.xhtml()
@@ -138,7 +142,7 @@ class Application:
138
142
 
139
143
  def _update_markdown(
140
144
  self,
141
- path: str,
145
+ path: Path,
142
146
  document: str,
143
147
  page_id: str,
144
148
  space_key: Optional[str],
@@ -207,11 +207,6 @@ class NodeVisitor:
207
207
  pass
208
208
 
209
209
 
210
- def _change_ext(path: str, target_ext: str) -> str:
211
- root, source_ext = os.path.splitext(path)
212
- return f"{root}{target_ext}"
213
-
214
-
215
210
  @dataclass
216
211
  class ConfluenceConverterOptions:
217
212
  """
@@ -228,22 +223,22 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
228
223
  "Transforms a plain HTML tree into the Confluence storage format."
229
224
 
230
225
  options: ConfluenceConverterOptions
231
- path: str
232
- base_path: str
226
+ path: pathlib.Path
227
+ base_path: pathlib.Path
233
228
  links: List[str]
234
229
  images: List[str]
235
- page_metadata: Dict[str, ConfluencePageMetadata]
230
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata]
236
231
 
237
232
  def __init__(
238
233
  self,
239
234
  options: ConfluenceConverterOptions,
240
- path: str,
241
- page_metadata: Dict[str, ConfluencePageMetadata],
235
+ path: pathlib.Path,
236
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
242
237
  ) -> None:
243
238
  super().__init__()
244
239
  self.options = options
245
240
  self.path = path
246
- self.base_path = os.path.abspath(os.path.dirname(path)) + os.sep
241
+ self.base_path = path.parent
247
242
  self.links = []
248
243
  self.images = []
249
244
  self.page_metadata = page_metadata
@@ -270,9 +265,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
270
265
  # convert the relative URL to absolute URL based on the base path value, then look up
271
266
  # the absolute path in the page metadata dictionary to discover the relative path
272
267
  # within Confluence that should be used
273
- absolute_path = os.path.abspath(os.path.join(self.base_path, relative_url.path))
274
- if not absolute_path.startswith(self.base_path):
275
- msg = f"relative URL points to outside base path: {url}"
268
+ absolute_path = (self.base_path / relative_url.path).absolute()
269
+ if not str(absolute_path).startswith(str(self.base_path)):
270
+ msg = f"relative URL {url} points to outside base path: {self.base_path}"
276
271
  if self.options.ignore_invalid_url:
277
272
  LOGGER.warning(msg)
278
273
  anchor.attrib.pop("href")
@@ -314,10 +309,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
314
309
  path: str = image.attrib["src"]
315
310
 
316
311
  # prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
317
- if path and is_relative_url(path) and path.endswith(".svg"):
318
- replacement_path = _change_ext(path, ".png")
319
- if os.path.exists(os.path.join(self.base_path, replacement_path)):
320
- path = replacement_path
312
+ if path and is_relative_url(path):
313
+ relative_path = pathlib.Path(path)
314
+ if (
315
+ relative_path.suffix == ".svg"
316
+ and (self.base_path / relative_path.with_suffix(".png")).exists()
317
+ ):
318
+ path = str(relative_path.with_suffix(".png"))
321
319
 
322
320
  self.images.append(path)
323
321
  caption = image.attrib["alt"]
@@ -327,7 +325,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
327
325
  ET.QName(namespaces["ac"], "align"): "center",
328
326
  ET.QName(namespaces["ac"], "layout"): "center",
329
327
  },
330
- RI("attachment", {ET.QName(namespaces["ri"], "filename"): path}),
328
+ RI(
329
+ "attachment",
330
+ {ET.QName(namespaces["ri"], "filename"): attachment_name(path)},
331
+ ),
331
332
  AC("caption", HTML.p(caption)),
332
333
  )
333
334
 
@@ -391,6 +392,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
391
392
  if class_name is None:
392
393
  raise DocumentError(f"unsupported admonition label: {class_list}")
393
394
 
395
+ for e in elem:
396
+ self.visit(e)
397
+
394
398
  # <p class="admonition-title">Note</p>
395
399
  if "admonition-title" in elem[0].attrib.get("class", "").split(" "):
396
400
  content = [
@@ -530,27 +534,33 @@ class ConfluenceDocument:
530
534
 
531
535
  def __init__(
532
536
  self,
533
- path: str,
537
+ path: pathlib.Path,
534
538
  options: ConfluenceDocumentOptions,
535
- page_metadata: Dict[str, ConfluencePageMetadata],
539
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
536
540
  ) -> None:
537
541
  self.options = options
538
- path = os.path.abspath(path)
542
+ path = path.absolute()
539
543
 
540
544
  with open(path, "r") as f:
541
- html = markdown_to_html(f.read())
545
+ text = f.read()
542
546
 
543
547
  # extract Confluence page ID
544
- qualified_id, html = extract_qualified_id(html)
548
+ qualified_id, text = extract_qualified_id(text)
545
549
  if qualified_id is None:
546
550
  raise ValueError("missing Confluence page ID")
547
551
  self.id = qualified_id
548
552
 
549
553
  # extract 'generated-by' tag text
550
- generated_by_tag, html = extract_value(
551
- r"<!--\s+generated-by:\s*(.*)\s+-->", html
554
+ generated_by_tag, text = extract_value(
555
+ r"<!--\s+generated-by:\s*(.*)\s+-->", text
552
556
  )
553
557
 
558
+ # extract frontmatter
559
+ frontmatter, text = extract_value(r"(?ms)\A---$(.+?)^---$", text)
560
+
561
+ # convert to HTML
562
+ html = markdown_to_html(text)
563
+
554
564
  # parse Markdown document
555
565
  if self.options.generated_by is not None:
556
566
  generated_by = self.options.generated_by
@@ -582,6 +592,18 @@ class ConfluenceDocument:
582
592
  return _content_to_string(self.root)
583
593
 
584
594
 
595
+ def attachment_name(name: str) -> str:
596
+ """
597
+ Safe name for use with attachment uploads.
598
+
599
+ Allowed characters:
600
+ * Alphanumeric characters: 0-9, a-z, A-Z
601
+ * Special characters: hyphen (-), underscore (_), period (.)
602
+ """
603
+
604
+ return re.sub(r"[^\-0-9A-Za-z_.]", "_", name)
605
+
606
+
585
607
  def sanitize_confluence(html: str) -> str:
586
608
  "Generates a sanitized version of a Confluence storage format XHTML document with no volatile attributes."
587
609
 
@@ -1,5 +1,6 @@
1
1
  import logging
2
- import os.path
2
+ import os
3
+ from pathlib import Path
3
4
  from typing import Dict
4
5
 
5
6
  from .converter import (
@@ -23,35 +24,37 @@ class Processor:
23
24
  self.options = options
24
25
  self.properties = properties
25
26
 
26
- def process(self, path: str) -> None:
27
+ def process(self, path: Path) -> None:
27
28
  "Processes a single Markdown file or a directory of Markdown files."
28
29
 
29
- if os.path.isdir(path):
30
+ if path.is_dir():
30
31
  self.process_directory(path)
31
- elif os.path.isfile(path):
32
+ elif path.is_file():
32
33
  self.process_page(path, {})
33
34
  else:
34
35
  raise ValueError(f"expected: valid file or directory path; got: {path}")
35
36
 
36
- def process_directory(self, dir: str) -> None:
37
+ def process_directory(self, local_dir: Path) -> None:
37
38
  "Recursively scans a directory hierarchy for Markdown files."
38
39
 
39
- page_metadata: Dict[str, ConfluencePageMetadata] = {}
40
- LOGGER.info(f"Synchronizing directory: {dir}")
40
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
41
+ LOGGER.info(f"Synchronizing directory: {local_dir}")
41
42
 
42
43
  # Step 1: build index of all page metadata
43
- for root, directories, files in os.walk(dir):
44
+ # NOTE: Pathlib.walk() is implemented only in Python 3.12+
45
+ # so sticking for old os.walk
46
+ for root, directories, files in os.walk(local_dir):
44
47
  for file_name in files:
45
- # check the file extension
46
- _, file_extension = os.path.splitext(file_name)
47
- if file_extension.lower() != ".md":
48
- continue
48
+ # Reconstitute Path object back
49
+ docfile = (Path(root) / file_name).absolute()
49
50
 
50
- absolute_path = os.path.join(os.path.abspath(root), file_name)
51
- metadata = self._get_page(absolute_path)
51
+ # Skip non-markdown files
52
+ if docfile.suffix.lower() != ".md":
53
+ continue
52
54
 
53
- LOGGER.debug(f"indexed {absolute_path} with metadata: {metadata}")
54
- page_metadata[absolute_path] = metadata
55
+ metadata = self._get_page(docfile)
56
+ LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
57
+ page_metadata[docfile] = metadata
55
58
 
56
59
  LOGGER.info(f"indexed {len(page_metadata)} pages")
57
60
 
@@ -60,17 +63,16 @@ class Processor:
60
63
  self.process_page(page_path, page_metadata)
61
64
 
62
65
  def process_page(
63
- self, path: str, page_metadata: Dict[str, ConfluencePageMetadata]
66
+ self, path: Path, page_metadata: Dict[Path, ConfluencePageMetadata]
64
67
  ) -> None:
65
68
  "Processes a single Markdown file."
66
69
 
67
70
  document = ConfluenceDocument(path, self.options, page_metadata)
68
71
  content = document.xhtml()
69
- output_path, _ = os.path.splitext(path)
70
- with open(f"{output_path}.csf", "w") as f:
72
+ with open(path.with_suffix(".csf"), "w") as f:
71
73
  f.write(content)
72
74
 
73
- def _get_page(self, absolute_path: str) -> ConfluencePageMetadata:
75
+ def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
74
76
  "Extracts metadata from a Markdown file."
75
77
 
76
78
  with open(absolute_path, "r") as f:
@@ -1,5 +1,4 @@
1
1
  import os
2
- import os.path
3
2
  from typing import Optional
4
3
 
5
4
 
@@ -28,13 +28,13 @@ include_package_data = True
28
28
  packages = find:
29
29
  python_requires = >=3.8
30
30
  install_requires =
31
- lxml >= 4.9
32
- types-lxml >= 2023.10.21
33
- markdown >= 3.5
34
- types-markdown >= 3.5
35
- pymdown-extensions >= 10.3
36
- requests >= 2.31
37
- types-requests >= 2.31
31
+ lxml >= 5.2
32
+ types-lxml >= 2024.4.14
33
+ markdown >= 3.6
34
+ types-markdown >= 3.6
35
+ pymdown-extensions >= 10.8
36
+ requests >= 2.32
37
+ types-requests >= 2.32
38
38
 
39
39
  [options.packages.find]
40
40
  exclude =
@@ -45,6 +45,10 @@ md2conf =
45
45
  entities.dtd
46
46
  py.typed
47
47
 
48
+ [options.entry_points]
49
+ console_scripts =
50
+ md2conf = md2conf.__main__:main
51
+
48
52
  [flake8]
49
53
  extend_ignore = DAR101,DAR201,DAR301,DAR401
50
54
  max_line_length = 140
@@ -3,6 +3,7 @@ import os
3
3
  import os.path
4
4
  import shutil
5
5
  import unittest
6
+ from pathlib import Path
6
7
 
7
8
  from md2conf.api import ConfluenceAPI, ConfluenceAttachment, ConfluencePage
8
9
  from md2conf.application import Application
@@ -13,6 +14,10 @@ from md2conf.converter import (
13
14
  )
14
15
  from md2conf.properties import ConfluenceProperties
15
16
 
17
+ TEST_PAGE_TITLE = "Publish to Confluence"
18
+ TEST_SPACE = "DAP"
19
+ TEST_PAGE_ID = "85668266616"
20
+
16
21
  logging.basicConfig(
17
22
  level=logging.INFO,
18
23
  format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
@@ -20,10 +25,14 @@ logging.basicConfig(
20
25
 
21
26
 
22
27
  class TestAPI(unittest.TestCase):
23
- out_dir: str
28
+ out_dir: Path
24
29
 
25
30
  def setUp(self) -> None:
26
- self.out_dir = os.path.join(os.getcwd(), "tests", "output")
31
+ test_dir = Path(__file__).parent
32
+ parent_dir = test_dir.parent
33
+
34
+ self.out_dir = test_dir / "tests" / "output"
35
+ self.sample_dir = parent_dir / "sample"
27
36
  os.makedirs(self.out_dir, exist_ok=True)
28
37
 
29
38
  def tearDown(self) -> None:
@@ -31,7 +40,7 @@ class TestAPI(unittest.TestCase):
31
40
 
32
41
  def test_markdown(self) -> None:
33
42
  document = ConfluenceDocument(
34
- os.path.join(os.getcwd(), "sample", "example.md"),
43
+ self.sample_dir / "example.md",
35
44
  ConfluenceDocumentOptions(ignore_invalid_url=True),
36
45
  {},
37
46
  )
@@ -41,67 +50,68 @@ class TestAPI(unittest.TestCase):
41
50
  ["figure/interoperability.png", "figure/interoperability.png"],
42
51
  )
43
52
 
44
- with open(os.path.join(self.out_dir, "document.html"), "w") as f:
53
+ with open(self.out_dir / "document.html", "w") as f:
45
54
  f.write(document.xhtml())
46
55
 
47
56
  def test_find_page_by_title(self) -> None:
48
57
  with ConfluenceAPI() as api:
49
- id = api.get_page_id_by_title("Publish to Confluence")
50
- self.assertEqual(id, "85668266616")
58
+ id = api.get_page_id_by_title(TEST_PAGE_TITLE)
59
+ self.assertEqual(id, "%s" % TEST_PAGE_ID)
51
60
 
52
61
  def test_switch_space(self) -> None:
53
62
  with ConfluenceAPI(ConfluenceProperties(space_key="PLAT")) as api:
54
- with api.switch_space("DAP"):
55
- id = api.get_page_id_by_title("Publish to Confluence")
56
- self.assertEqual(id, "85668266616")
63
+ with api.switch_space(TEST_SPACE):
64
+ id = api.get_page_id_by_title(TEST_PAGE_TITLE)
65
+ self.assertEqual(id, TEST_PAGE_ID)
57
66
 
58
67
  def test_get_page(self) -> None:
59
68
  with ConfluenceAPI() as api:
60
- page = api.get_page("85668266616")
69
+ page = api.get_page(TEST_PAGE_ID)
61
70
  self.assertIsInstance(page, ConfluencePage)
62
71
 
63
- with open(os.path.join(self.out_dir, "page.html"), "w") as f:
72
+ with open(self.out_dir / "page.html", "w") as f:
64
73
  f.write(sanitize_confluence(page.content))
65
74
 
66
75
  def test_get_attachment(self) -> None:
67
76
  with ConfluenceAPI() as api:
68
77
  data = api.get_attachment_by_name(
69
- "85668266616", "figure/interoperability.png"
78
+ TEST_PAGE_ID, "figure_interoperability.png"
70
79
  )
71
80
  self.assertIsInstance(data, ConfluenceAttachment)
72
81
 
73
82
  def test_upload_attachment(self) -> None:
74
83
  with ConfluenceAPI() as api:
75
84
  api.upload_attachment(
76
- "85668266616",
77
- os.path.join(os.getcwd(), "sample", "figure", "interoperability.png"),
78
- "figure/interoperability.png",
85
+ TEST_PAGE_ID,
86
+ self.sample_dir / "figure" / "interoperability.png",
87
+ "figure_interoperability.png",
79
88
  "A sample figure",
89
+ force=True,
80
90
  )
81
91
 
82
92
  def test_synchronize(self) -> None:
83
93
  with ConfluenceAPI() as api:
84
94
  Application(
85
95
  api, ConfluenceDocumentOptions(ignore_invalid_url=True)
86
- ).synchronize(os.path.join(os.getcwd(), "sample", "example.md"))
96
+ ).synchronize(self.sample_dir / "example.md")
87
97
 
88
98
  def test_synchronize_page(self) -> None:
89
99
  with ConfluenceAPI() as api:
90
100
  Application(
91
101
  api, ConfluenceDocumentOptions(ignore_invalid_url=True)
92
- ).synchronize_page(os.path.join(os.getcwd(), "sample", "example.md"))
102
+ ).synchronize_page(self.sample_dir / "example.md")
93
103
 
94
104
  def test_synchronize_directory(self) -> None:
95
105
  with ConfluenceAPI() as api:
96
106
  Application(
97
107
  api, ConfluenceDocumentOptions(ignore_invalid_url=True)
98
- ).synchronize_directory(os.path.join(os.getcwd(), "sample"))
108
+ ).synchronize_directory(self.sample_dir)
99
109
 
100
110
  def test_synchronize_create(self) -> None:
101
- dir = os.path.join(self.out_dir, "markdown")
102
- os.makedirs(dir, exist_ok=True)
111
+ source_dir = self.out_dir / "markdown"
112
+ os.makedirs(source_dir, exist_ok=True)
103
113
 
104
- child = os.path.join(dir, "child.md")
114
+ child = source_dir / "child.md"
105
115
  with open(child, "w") as f:
106
116
  f.write(
107
117
  "This is a document without an explicitly linked Confluence document.\n"
@@ -113,13 +123,13 @@ class TestAPI(unittest.TestCase):
113
123
  ConfluenceDocumentOptions(
114
124
  ignore_invalid_url=True, root_page_id="86090481730"
115
125
  ),
116
- ).synchronize_directory(dir)
126
+ ).synchronize_directory(source_dir)
117
127
 
118
128
  with open(child, "r") as f:
119
129
  self.assertEqual(
120
130
  f.read(),
121
131
  "<!-- confluence-page-id: 86269493445 -->\n"
122
- "<!-- confluence-space-key: DAP -->\n"
132
+ f"<!-- confluence-space-key: {TEST_SPACE} -->\n"
123
133
  "This is a document without an explicitly linked Confluence document.\n",
124
134
  )
125
135
 
@@ -0,0 +1,58 @@
1
+ import logging
2
+ import os
3
+ import os.path
4
+ import re
5
+ import shutil
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from md2conf.converter import (
10
+ ConfluenceDocument,
11
+ ConfluenceDocumentOptions,
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 TestConversion(unittest.TestCase):
21
+ out_dir: Path
22
+
23
+ def setUp(self) -> None:
24
+ self.maxDiff = 1024
25
+
26
+ test_dir = Path(__file__).parent
27
+ parent_dir = test_dir.parent
28
+
29
+ self.out_dir = test_dir / "output"
30
+ self.sample_dir = parent_dir / "sample"
31
+ os.makedirs(self.out_dir, exist_ok=True)
32
+
33
+ def tearDown(self) -> None:
34
+ shutil.rmtree(self.out_dir)
35
+
36
+ @staticmethod
37
+ def make_canonical(content: str) -> str:
38
+ uuid_pattern = re.compile(r'\b[0-9a-fA-F-]{36}\b')
39
+ content = re.sub(uuid_pattern, 'UUID', content)
40
+ content = content.strip()
41
+ return content
42
+
43
+ def test_markdown(self) -> None:
44
+ actual = ConfluenceDocument(
45
+ self.sample_dir / "example.md",
46
+ ConfluenceDocumentOptions(ignore_invalid_url=True),
47
+ {},
48
+ ).xhtml()
49
+ actual = self.make_canonical(actual)
50
+
51
+ with open(self.sample_dir / "expected" / "example.xml", "r") as f:
52
+ expected = f.read().strip()
53
+
54
+ self.assertEqual(actual, expected)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()
@@ -0,0 +1,64 @@
1
+ import logging
2
+ import shutil
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from md2conf.converter import ConfluenceDocumentOptions
7
+ from md2conf.processor import Processor
8
+ from md2conf.properties import ConfluenceProperties
9
+
10
+ logging.basicConfig(
11
+ level=logging.INFO,
12
+ format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
13
+ )
14
+
15
+
16
+ class TestProcessor(unittest.TestCase):
17
+ out_dir: Path
18
+
19
+ def setUp(self) -> None:
20
+ self.maxDiff = 1024
21
+
22
+ test_dir = Path(__file__).parent
23
+ parent_dir = test_dir.parent
24
+
25
+ self.out_dir = test_dir / "output"
26
+ self.sample_dir = parent_dir / "sample"
27
+ self.out_dir.mkdir(exist_ok=True, parents=True)
28
+
29
+ def tearDown(self) -> None:
30
+ shutil.rmtree(self.out_dir)
31
+
32
+ def test_process_document(self) -> None:
33
+ options = ConfluenceDocumentOptions(
34
+ ignore_invalid_url=False,
35
+ generated_by="Test Case",
36
+ root_page_id="None",
37
+ )
38
+
39
+ properties = ConfluenceProperties(
40
+ "example.com", "/wiki/", "bob@example.com", "API_KEY", "SPACE_KEY"
41
+ )
42
+ Processor(options, properties).process(self.sample_dir / "code.md")
43
+
44
+ self.assertTrue((self.sample_dir / "example.csf").exists())
45
+
46
+ def test_process_directory(self) -> None:
47
+ options = ConfluenceDocumentOptions(
48
+ ignore_invalid_url=True,
49
+ generated_by="The Author",
50
+ root_page_id="ROOT_PAGE_ID",
51
+ )
52
+
53
+ properties = ConfluenceProperties(
54
+ "example.com", "/wiki/", "bob@example.com", "API_KEY", "SPACE_KEY"
55
+ )
56
+ Processor(options, properties).process(self.sample_dir)
57
+
58
+ self.assertTrue((self.sample_dir / "example.csf").exists())
59
+ self.assertTrue((self.sample_dir / "sibling.csf").exists())
60
+ self.assertTrue((self.sample_dir / "code.csf").exists())
61
+
62
+
63
+ if __name__ == "__main__":
64
+ unittest.main()
@@ -1,7 +0,0 @@
1
- lxml>=4.9
2
- types-lxml>=2023.10.21
3
- markdown>=3.5
4
- types-markdown>=3.5
5
- pymdown-extensions>=10.3
6
- requests>=2.31
7
- types-requests>=2.31
@@ -1,129 +0,0 @@
1
- import argparse
2
- import logging
3
- import os.path
4
- import sys
5
- import typing
6
- from typing import Optional
7
-
8
- import requests
9
-
10
- from .api import ConfluenceAPI
11
- from .application import Application
12
- from .converter import ConfluenceDocumentOptions
13
- from .processor import Processor
14
- from .properties import ConfluenceProperties
15
-
16
-
17
- class Arguments(argparse.Namespace):
18
- mdpath: str
19
- domain: str
20
- path: str
21
- username: str
22
- apikey: str
23
- space: str
24
- loglevel: str
25
- ignore_invalid_url: bool
26
- generated_by: Optional[str]
27
-
28
-
29
- parser = argparse.ArgumentParser()
30
- parser.prog = os.path.basename(os.path.dirname(__file__))
31
- parser.add_argument(
32
- "mdpath", help="Path to Markdown file or directory to convert and publish."
33
- )
34
- parser.add_argument("-d", "--domain", help="Confluence organization domain.")
35
- parser.add_argument("-p", "--path", help="Base path for Confluece wiki.")
36
- parser.add_argument("-u", "--username", help="Confluence user name.")
37
- parser.add_argument(
38
- "-a",
39
- "--apikey",
40
- help="Confluence API key. Refer to documentation how to obtain one.",
41
- )
42
- parser.add_argument(
43
- "-s",
44
- "--space",
45
- help="Confluence space key for pages to be published. If omitted, will default to user space.",
46
- )
47
- parser.add_argument(
48
- "-l",
49
- "--loglevel",
50
- choices=[
51
- typing.cast(str, logging.getLevelName(level)).lower()
52
- for level in (
53
- logging.DEBUG,
54
- logging.INFO,
55
- logging.WARN,
56
- logging.ERROR,
57
- logging.CRITICAL,
58
- )
59
- ],
60
- default=logging.getLevelName(logging.INFO),
61
- help="Use this option to set the log verbosity.",
62
- )
63
- parser.add_argument(
64
- "-r",
65
- dest="root_page",
66
- help="Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.",
67
- )
68
- parser.add_argument(
69
- "--generated-by",
70
- default="This page has been generated with a tool.",
71
- help="Add prompt to pages (default: 'This page has been generated with a tool.').",
72
- )
73
- parser.add_argument(
74
- "--no-generated-by",
75
- dest="generated_by",
76
- action="store_const",
77
- const=None,
78
- help="Do not add 'generated by a tool' prompt to pages.",
79
- )
80
- parser.add_argument(
81
- "--ignore-invalid-url",
82
- action="store_true",
83
- default=False,
84
- help="Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.",
85
- )
86
- parser.add_argument(
87
- "--local",
88
- action="store_true",
89
- default=False,
90
- help="Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.",
91
- )
92
-
93
- args = Arguments()
94
- parser.parse_args(namespace=args)
95
-
96
- logging.basicConfig(
97
- level=getattr(logging, args.loglevel.upper(), logging.INFO),
98
- format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
99
- )
100
-
101
-
102
- options = ConfluenceDocumentOptions(
103
- ignore_invalid_url=args.ignore_invalid_url,
104
- generated_by=args.generated_by,
105
- root_page_id=args.root_page,
106
- )
107
- properties = ConfluenceProperties(
108
- args.domain, args.path, args.username, args.apikey, args.space
109
- )
110
- if args.local:
111
- Processor(options, properties).process(args.mdpath)
112
- else:
113
- try:
114
- with ConfluenceAPI(properties) as api:
115
- Application(
116
- api,
117
- options,
118
- ).synchronize(args.mdpath)
119
- except requests.exceptions.HTTPError as err:
120
- logging.error(err)
121
-
122
- # print details for a response with JSON body
123
- if err.response is not None:
124
- try:
125
- logging.error(err.response.json())
126
- except requests.exceptions.JSONDecodeError:
127
- pass
128
-
129
- sys.exit(1)