├── .circleci
└── config.yml
├── .flake8
├── .gitignore
├── LICENSE
├── README.md
├── markdowngenerator
├── __init__.py
├── config
│ ├── __init__.py
│ ├── conf.py
│ └── syntax.py
├── markdowngenerator.py
└── test
│ ├── __init__.py
│ ├── test_blocks_quotes.py
│ ├── test_markdown_emphasis.py
│ ├── test_markdowngenerator_lib.py
│ └── test_references.py
├── pyproject.toml
├── test-requirements.txt
└── tox.ini
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | # Python CircleCI 2.0 configuration file
2 | #
3 | # Check https://circleci.com/docs/2.0/language-python/ for more details
4 | #
5 | version: 2.1
6 | orbs:
7 | codecov: codecov/codecov@1.0.5
8 |
9 | jobs:
10 | build:
11 | docker:
12 | # specify the version you desire here
13 | # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers`
14 | - image: circleci/python:3.7.3
15 |
16 |
17 | # Specify service dependencies here if necessary
18 | # CircleCI maintains a library of pre-built images
19 | # documented at https://circleci.com/docs/2.0/circleci-images/
20 | # - image: circleci/postgres:9.4
21 |
22 | working_directory: ~/repo
23 |
24 | steps:
25 | - checkout
26 |
27 | # Download and cache dependencies
28 | - restore_cache:
29 | keys:
30 | - v1-dependencies-{{ checksum "test-requirements.txt" }}
31 | # fallback to using the latest cache if no exact match is found
32 | - v1-dependencies-
33 |
34 | - run:
35 | name: install dependencies
36 | command: |
37 | python3 -m venv venv
38 | . venv/bin/activate
39 | pip install tox
40 | echo "source venv/bin/activate" >> $BASH_ENV
41 |
42 | # run tests!
43 | - run:
44 | name: run tests
45 | command: |
46 | tox
47 |
48 | - codecov/upload:
49 | file: coverage.xml
50 |
51 | - save_cache:
52 | paths:
53 | - ./venv
54 | - ./.tox
55 | key: v1-dependencies-{{ checksum "test-requirements.txt" }}
56 |
57 | - store_artifacts:
58 | path: .coverage
59 | destination: test-reports
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 81
3 | select = C,E,F,W,B,B950
4 | ignore = E501, D203
5 | exclude = .git,__pycache__,docs,old,build,dist
6 | # max-complexity = 10
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Editorthings
2 | .vscode/
3 | .vs/
4 | # Byte-compiled / optimized / DLL files
5 | __pycache__/
6 | *.py[cod]
7 | *$py.class
8 |
9 | # C extensions
10 | *.so
11 |
12 | # Distribution / packaging
13 | .Python
14 | build/
15 | develop-eggs/
16 | dist/
17 | downloads/
18 | eggs/
19 | .eggs/
20 | lib/
21 | lib64/
22 | parts/
23 | sdist/
24 | var/
25 | wheels/
26 | *.egg-info/
27 | .installed.cfg
28 | *.egg
29 | MANIFEST
30 |
31 | # PyInstaller
32 | # Usually these files are written by a python script from a template
33 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
34 | *.manifest
35 | *.spec
36 |
37 | # Installer logs
38 | pip-log.txt
39 | pip-delete-this-directory.txt
40 |
41 | # Unit test / coverage reports
42 | htmlcov/
43 | .tox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | .hypothesis/
51 | .pytest_cache/
52 |
53 | # Translations
54 | *.mo
55 | *.pot
56 |
57 | # Django stuff:
58 | *.log
59 | local_settings.py
60 | db.sqlite3
61 |
62 | # Flask stuff:
63 | instance/
64 | .webassets-cache
65 |
66 | # Scrapy stuff:
67 | .scrapy
68 |
69 | # Sphinx documentation
70 | docs/_build/
71 |
72 | # PyBuilder
73 | target/
74 |
75 | # Jupyter Notebook
76 | .ipynb_checkpoints
77 |
78 | # pyenv
79 | .python-version
80 |
81 | # celery beat schedule file
82 | celerybeat-schedule
83 |
84 | # SageMath parsed files
85 | *.sage.py
86 |
87 | # Environments
88 | .env
89 | .venv
90 | env/
91 | venv/
92 | ENV/
93 | env.bak/
94 | venv.bak/
95 |
96 | # Spyder project settings
97 | .spyderproject
98 | .spyproject
99 |
100 | # Rope project settings
101 | .ropeproject
102 |
103 | # mkdocs documentation
104 | /site
105 |
106 | # mypy
107 | .mypy_cache/
108 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2019-2022 Niklas Saari
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
19 | OR OTHER DEALINGS IN THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Python Markdown Generator
2 |
3 | Python library for generating HTML sanitised Markdown documents.
4 |
5 | It aims to bring a modular approach for dynamically building pure (with some exceptions) Markdown documents with pure Python so that they could consist of multiple reusable components and include some automated tasks such as creating a table of contents.
6 |
7 | [](https://circleci.com/gh/Nicceboy/python-markdown-generator)
8 | [](https://codecov.io/gh/Nicceboy/python-markdown-generator)
9 | [](https://opensource.org/licenses/MIT)
10 |
11 | ## But why?
12 |
13 | In most cases, Markdown is finally converted into HTML markup language, and there are already multiple libraries for dynamically generating HTML output. Markdown was created initially as a lightweight, user-friendly, user-written text formatting markup language to produce rich text format by using a plain text editor, and the existence of this kind of library might be against the original purpose of the language.
14 |
15 | *However*, there are a few rare use cases where you might want the machine to generate Markdown instead of direct HTML.
16 |
17 | * A machine can ideally generate some documents, but they might be edited by some users later on.
18 | * There might be a need to generate some Markdown templates, which are finally filled by the end-user. Still, there might be easier ways to create this kind of template.
19 | * Also, there are some external systems whereof HTML rendering is not supported. Still, you can input Markdown into them: this allows the integration of something automatically into these systems with Markdown.
20 | * Markdown syntax is simple and not a fully-featured language, resulting in a cleaner and simpler (less buggy!) result. The resulted file itself is lightweight.
21 |
22 | The original need for this kind of library came from generating different types of reports automatically into Git repositories with Python. This library was created during the [CinCan](https://cincan.io/) project to dynamically make malware analysis reports deployed and rendered directly into the Git -repositories.
23 | At the time of starting making this library, no complete existing similar library was found. If there is some, let's take this as a programming exercise...
24 |
25 | > This is **not** yet-another-Markdown-to-HTML conversion tool.
26 |
27 |
28 | ## Quick Install
29 |
30 | Python 3.7+ is required.
31 |
32 | You can install the latest version from the GitHub by using pip:
33 | ```shell
34 | pip3 install git+https://github.com/Nicceboy/python-markdown-generator
35 | ```
36 |
37 | ## Quick usage
38 |
39 | After installation, the library can be just imported, and we are ready to rock. Method names should be self-descriptive.
40 |
41 | The library supports all the syntax currently from the standard Markdown and some partial functionality of GitHub and GitLab syntax.
42 |
43 | :TODO Better example, included in wiki maybe, documentation on the way for methods.
44 |
45 | ```python
46 | from markdowngenerator import MarkdownGenerator
47 |
48 | def main():
49 | with MarkdownGenerator(
50 | # By setting enable_write as False, content of the file is written
51 | # into buffer at first, instead of writing directly into the file
52 | # This enables for example the generation of table of contents
53 | filename="example.md", enable_write=False
54 | ) as doc:
55 | doc.addHeader(1, "Hello there!")
56 | doc.writeTextLine(f'{doc.addBoldedText("This is just a test.")}')
57 | doc.addHeader(2, "Second level header.")
58 | table = [
59 | {"Column1": "col1row1 data", "Column2": "col2row1 data"},
60 | {"Column1": "col1row2 data", "Column2": "col2row2 data"},
61 | ]
62 |
63 | doc.addTable(dictionary_list=table)
64 | doc.writeTextLine("Ending the document....")
65 |
66 | if __name__ == "__main__":
67 | main()
68 | ```
69 |
70 | Which should generate following output:
71 |
72 | ```
73 | # Hello there!
74 | **This is just a test.**
75 |
76 | ### Table of Contents
77 | * [Hello there!](#hello-there)
78 | * [Second level header.](#second-level-header)
79 |
80 | ## Second level header.
81 |
82 | | Column1 | Column2 |
83 | |:---:|:---:|
84 | | col1row1 data | col2row1 data |
85 | | col1row2 data | col2row2 data |
86 |
87 | Ending the document....
88 |
89 | ```
90 |
91 |
92 |
93 |
94 | ## Licence
95 |
96 | Copyright © 2019-2022 Niklas Saari under the MIT Licence
97 |
98 |
99 |
--------------------------------------------------------------------------------
/markdowngenerator/__init__.py:
--------------------------------------------------------------------------------
1 | from .markdowngenerator import MarkdownGenerator
2 |
--------------------------------------------------------------------------------
/markdowngenerator/config/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nicceboy/python-markdown-generator/47241fa23d7c8fd20c513c6532251deb0eda4985/markdowngenerator/config/__init__.py
--------------------------------------------------------------------------------
/markdowngenerator/config/conf.py:
--------------------------------------------------------------------------------
1 | TOOL_VERSION_INFO_DIR = "results/tool-version-info"
2 | SHELLCODE_OUTPUT_DIR = "shellcode"
3 |
4 | DEFAULT_FILE_LOCATION = "default_file"
5 | MAX_HEADER_LEVEL = 6
6 | MIN_HEADER_LEVEL = 1
7 | TABLE_OF_CONTENT_LINE_POSITION = 1
8 |
9 |
10 | TMP_HEADER_INFO_PREFIX = "headerinfo"
11 | TMP_HEADER_INFO_SUFFIX = "json"
12 |
13 | # Every extension should have at least following attributes:
14 | # result_dir, tool_name, extension_name
15 |
16 | # NOTE: result_dir is name of the directory, not path. Should be implemented in other way some day
17 | EXTENSIONS_CONF = {
18 | "PEEPDF_CONF": {
19 | "result_dir": "peepdf",
20 | "tool_name": "peepdf",
21 | "extension_name": "peepdfResultGenerator",
22 | },
23 | "CLAMAV_CONF": {
24 | "result_dir": "clamav",
25 | "tool_name": "clamav",
26 | "extension_name": "ClamAVResultGenerator",
27 | },
28 | "STRINGS_CONF": {
29 | "result_dir": "strings",
30 | "tool_name": "strings",
31 | "extension_name": "stringsResultGenerator",
32 | },
33 | "OLEVBA_CONF": {
34 | "result_dir": "olevba",
35 | "tool_name": "olevba",
36 | "extension_name": "olevbaResultGenerator",
37 | },
38 | "PDFID_CONF": {
39 | "result_dir": "pdfid",
40 | "tool_name": "pdfid",
41 | "extension_name": "PDFiDResultGenerator",
42 | },
43 | "SCTEST_CONF": {
44 | "result_dir": "sctest",
45 | "tool_name": "sctest",
46 | "extension_name": "jsunpacknsctestResultGenerator",
47 | },
48 | "OLEDUMP_CONF": {
49 | "result_dir": "oledump",
50 | "tool_name": "oledump",
51 | "extension_name": "oledumpResultGenerator",
52 | },
53 | }
54 |
--------------------------------------------------------------------------------
/markdowngenerator/config/syntax.py:
--------------------------------------------------------------------------------
1 | """
2 | MARKDOWN
3 | """
4 |
5 | MARKDOWN_HEADER = "#"
6 | MARKDOWN_HORIZONTAL_RULE = "---"
7 | MARKDOWN_CODE_BLOCK = "```"
8 | MARKDOWN_SINGLE_LINE_BLOCKQUOTE = ">"
9 | MARKDOWN_MULTILINE_BLOCKQUOTE = ">>>" # GitLab only
10 | MARKDOWN_INLINE_CODE_HL = "`"
11 | """
12 | HTML
13 |
14 | Some charcters used for generating HTML
15 |
16 | For escaping, following library has been used:
17 | https://docs.python.org/3/library/html.html#html.escape
18 | """
19 | # Hard space
20 | HTML_SPACE_ALT = u"\u00A0"
21 | HTML_SPACE = ""
22 | HTML_AND = "&"
23 | HTML_LESS_THAN = "<"
24 | HTML_GRETER_THAN = ">"
25 | HMTL_SINGLE_QUOTE = "'"
26 | HTML_DOUBLE_QUOTE = """
27 |
28 | """
29 | OTHER
30 | """
31 | # FOOTNOTE IDENTIFIER is string which is replaced with actual reference
32 | # when inserting footnote reference and content into the text
33 | FOOTNOTE_IDENTIFIER = "[footnote_id]"
34 |
--------------------------------------------------------------------------------
/markdowngenerator/markdowngenerator.py:
--------------------------------------------------------------------------------
1 | import tempfile
2 | from pathlib import Path
3 | import logging
4 |
5 | # import os
6 | from os import linesep
7 | from typing import List
8 | from html import escape
9 | from string import punctuation
10 | from .config.syntax import (
11 | MARKDOWN_HEADER as HEADER,
12 | HTML_SPACE,
13 | MARKDOWN_HORIZONTAL_RULE as HORIZONTAL_RULE,
14 | MARKDOWN_SINGLE_LINE_BLOCKQUOTE as SINGLE_LINE_BLOCKQUOTE,
15 | MARKDOWN_MULTILINE_BLOCKQUOTE as MULTILINE_BLOCKQUOTE,
16 | MARKDOWN_CODE_BLOCK as CODE_BLOCK,
17 | MARKDOWN_INLINE_CODE_HL as INLINE_CODE_HIGHLIGHT,
18 | FOOTNOTE_IDENTIFIER,
19 | )
20 | from .config.conf import (
21 | DEFAULT_FILE_LOCATION,
22 | MAX_HEADER_LEVEL,
23 | MIN_HEADER_LEVEL,
24 | TABLE_OF_CONTENT_LINE_POSITION,
25 | )
26 |
27 | # from standardizer.markdown.config.log import logger
28 |
29 |
30 | class MarkdownGenerator:
31 | """Class for generating GitLab or GitHub flavored Markdown."""
32 |
33 | """
34 | Instance of this class or any subclass is excepted to initialize
35 | by using 'with' statement in most cases.
36 |
37 | This opens file where content should be written.
38 | Default filename is default_file.md
39 | """
40 |
41 | def __init__(
42 | self,
43 | _self=None,
44 | document=None,
45 | filename=None,
46 | description=None,
47 | syntax=None,
48 | root_object=True,
49 | tmp_dir=None,
50 | pending_footnote_references=None,
51 | footnote_index=None,
52 | header_data_array=None,
53 | header_index=None,
54 | document_data_array=None,
55 | enable_write=True,
56 | enable_TOC=True,
57 | logger=None,
58 | ):
59 | """
60 | Constructor method for MarkdownGenerator
61 |
62 | GitLab allows following HTML tags as well:
63 | https://www.rubydoc.info/gems/html-pipeline/1.11.0/HTML/Pipeline/SanitizationFilter#WHITELIST-constant
64 |
65 | :param document: existing opened document file, defaults to None
66 | :param filename: File to be opened, defaults to None
67 | :param description: Description of generated document, defaults to None
68 | :param syntax: Markdown syntax flavor (GitHub vs GitLab)
69 | :param root_object: Whether the instance of this class is root object, defaults to None
70 | :param tmp_dir: Path of temporal directory. NOTE: not in user, defaults to None
71 | :param pending_footnote_references, defaults to None
72 | :param footnote_index
73 | """
74 | self.logger = logger if logger else logging.getLogger()
75 | self.logger.name = __name__
76 | self.logger.debug("Filename in constructor is {}".format(filename))
77 |
78 | # Attribute for determining if object is first instance of Markdowngenerator
79 | # It has has file open, which should be noted on exit.
80 | self.root_object = root_object
81 |
82 | self.description = "Class for generating GitLab flavored Markdown."
83 |
84 | self.document = document
85 | """
86 | Attribute for storing everything what is written into the file.
87 | Makes the manipulation of data in the middle of documenting easier,
88 | without re-reading and rewriting the document again if defined that
89 | data will be written only in the end of execution.
90 |
91 | NOTE: This variable should be shared and passed into every child node
92 | in constructor, that document structure keeps in correct.
93 |
94 | One index in array is one line in file.
95 | """
96 | self.document_data_array = document_data_array if document_data_array else []
97 | self.enable_write = enable_write
98 | self.enable_TOC = enable_TOC
99 | ###########
100 | if not filename:
101 | self.logger.info(
102 | "No file location given. Using default '{}'."
103 | " Overwriting existing file.".format(DEFAULT_FILE_LOCATION)
104 | )
105 | self.filename = Path(DEFAULT_FILE_LOCATION).resolve()
106 | self.default_filename_on_use = True
107 | elif isinstance(filename, Path):
108 | self.filename = filename
109 | self.default_filename_on_use = False
110 | else:
111 | self.filename = Path(filename).resolve()
112 | self.default_filename_on_use = False
113 | self.syntax = syntax if syntax else "gitlab"
114 |
115 | # Index for amount of footnotes and list of actual notes
116 | # to be written into the end of file
117 | # We are using dict, so we can pass it by reference among objects
118 | self.footnote_index = footnote_index if footnote_index else {"value": 0}
119 | self.pending_footnote_references = (
120 | pending_footnote_references if pending_footnote_references else []
121 | )
122 |
123 | # Trailing details, details section open but not ended if count > 0.
124 | self.unfinished_details_summary_count = {"value": 0}
125 |
126 | # Header information for table of contents
127 | self.header_info = header_data_array if header_data_array else []
128 | self.header_index = header_index if header_index else 0
129 |
130 | # Directory for tmp files, currently not in use.
131 | self.tmp_dir = tmp_dir
132 |
133 | def __enter__(self):
134 | """
135 | Override default enter method to enable
136 | usage of 'with' while using class instance
137 | to safely open and close file.
138 |
139 | """
140 |
141 | if self.filename.is_dir():
142 | self.filename.mkdir(exist_ok=True)
143 | self.logger.debug(
144 | "Given path is directory without filename, using default filename."
145 | )
146 | self.filename.joinpath(DEFAULT_FILE_LOCATION, ".md")
147 | self.default_filename_on_use = True
148 | if not self.document:
149 | self.document = open(f"{self.filename}", "w+")
150 | current_tmp_dir = tempfile.gettempdir()
151 | self.tmp_dir = tempfile.TemporaryDirectory(dir=current_tmp_dir)
152 |
153 | # self.filename = os.path.basename(self.filename)
154 | return self
155 |
156 | def __exit__(self, *args, **kwargs):
157 | """
158 | Close file on exit.
159 |
160 | If there are some footnotes to be written, write them first.
161 | If Table of Contents should be written, it will be.
162 |
163 | If we have just generated the content of file into the memory,
164 | now write all at once into the file.
165 |
166 | """
167 | self.genFootNotes()
168 | if self.enable_TOC:
169 | if not self.enable_write:
170 | self.genTableOfContent()
171 | else:
172 | self.logger.warning("Warning: ToC is not enabled when the file is dynamically written.")
173 | # Everything will be written at once into the file
174 | if self.document:
175 | if not self.enable_write:
176 | self.document.writelines(self.document_data_array)
177 | self.document.close()
178 |
179 | def genFootNotes(self, genHeader=False):
180 | """
181 | Method for adding footnotes into the end of file.
182 | """
183 | if self.pending_footnote_references:
184 | # if genHeader:
185 | self.addHeader(3, "Footnotes")
186 | for footnote in self.pending_footnote_references:
187 | self.writeTextLine(footnote)
188 |
189 | if self.unfinished_details_summary_count.get("value", 0) > 0:
190 | self.logger.warning("Some of the detail blocks is not properly closed!!!")
191 |
192 | def genTableOfContent(self, linenumber=TABLE_OF_CONTENT_LINE_POSITION, max_depth=3):
193 | """
194 | Method for creating table of contents.
195 | """
196 | tableofcontents = [] # test_logger.debug(f"Expected: '{expected_output}'")
197 | # test_logger.debug(f"Generated '{generated_output}'")
198 | tableofcontents.append(f"## Table of Contents {linesep}")
199 | prevLevel = 0
200 | padding = " "
201 | footnote = None
202 | footnoteLevel = 2
203 | for header in self.header_info:
204 | name = header.get("headerName")
205 | level = header.get("headerLevel")
206 | href = header.get("headerHref")
207 | if name and level and href:
208 | if name == "Footnotes":
209 | footnote = header
210 | continue
211 | # Too big indent change from high level to low level
212 | # does not work in unordered list. Amount reduced
213 | # Line collapses to previous lane otherwise
214 | if level <= max_depth:
215 | if prevLevel != 0 and prevLevel - level < -2:
216 | level = prevLevel + 2
217 |
218 | tableofcontents.append(
219 | f"{level * padding}* {self.generateHrefNotation(name, href)}{linesep}"
220 | )
221 | prevLevel = level
222 | # Footnote should be last one.
223 | if footnote:
224 | tableofcontents.append(
225 | f'{footnoteLevel * padding}* {self.generateHrefNotation(footnote.get("headerName"), footnote.get("headerHref"))}{linesep}'
226 | )
227 | tableofcontents.append(f" {linesep}")
228 | self.document_data_array = (
229 | self.document_data_array[: linenumber - 1]
230 | + tableofcontents
231 | + self.document_data_array[linenumber - 1 :]
232 | )
233 |
234 | def writeText(self, text, html_escape: bool = True):
235 | """
236 | Method for writing arbitrary text into the document file,
237 | or just adding data into document data structure for easier manipulation.
238 |
239 | Text input has been escaped by default from HTML characters which could mess up the document..
240 |
241 | :param text: Input text string
242 | :param html_escape: bool, Whether the input should be escaped or not
243 | """
244 | if html_escape:
245 | self.document_data_array.append(escape(str(text)))
246 | if self.enable_write and self.document:
247 | self.document.write(escape(str(text)))
248 | return
249 | self.document_data_array.append(str(text))
250 | if self.enable_write and self.document:
251 | self.document.write(str(text))
252 |
253 | def writeTextLine(self, text=None, html_escape: bool = True):
254 | """
255 | Write arbitrary text into the document file and add new line,
256 | or just adding data with new line into document data structure for easier manipulation.
257 |
258 | Writing is defined in attribute 'self.enable_write' whether it is true or false
259 |
260 | Note double spaces after text.
261 |
262 | Text input has been escaped by default from HTML characters which could mess up the document..
263 |
264 | :param text: Input text string
265 | :param html_escape: bool, Whether the input should be escaped or not
266 | """
267 | if text is None:
268 | # Just forcing new line, in Markdown there should be 2 or more spaces as well
269 | self.document_data_array.append(str(" ") + linesep)
270 | if self.enable_write and self.document:
271 | self.document.write(str(" ") + linesep)
272 |
273 | return
274 | if html_escape:
275 | self.document_data_array.append(escape(str(text)) + " " + linesep)
276 | if self.enable_write and self.document:
277 | self.document.write(escape(str(text)) + " " + linesep)
278 | return
279 | self.document_data_array.append(str(text) + " " + linesep)
280 | if self.enable_write and self.document:
281 | self.document.write(str(text) + " " + linesep)
282 |
283 | def writeAttributeValuePairLine(self, key_value_pair: tuple, total_padding=30):
284 |
285 | if len(key_value_pair) == 2:
286 | required_padding = total_padding - len(key_value_pair[0])
287 | self.logger.debug(
288 | f"Line {key_value_pair[0]} Lenght of padding is {required_padding}"
289 | )
290 | self.writeTextLine(
291 | f"{self.addBoldedAndItalicizedText(key_value_pair[0])}{HTML_SPACE*required_padding}{key_value_pair[1]}"
292 | )
293 | else:
294 | self.logger.error("Not valid key value pair, when writing padded line.")
295 | return
296 |
297 | def addHeader(self, level: int, text):
298 | """
299 | Standard Markdown
300 |
301 | Method for adding named headers for the document.
302 | See: https://docs.gitlab.com/ee/user/markdown.html#headers
303 |
304 |
305 | :param level: The level of header, from 1 to 6
306 | :param text: The text for header
307 | """
308 | # NOTE Currently non-unique header names are not handled
309 |
310 | # Text for lowercase, remove punctuation, replace whitespace with dashesh
311 | anchor = "#" + text.lower().translate(
312 | str.maketrans("", "", punctuation.replace("-", ""))
313 | ).replace(" ", "-")
314 |
315 | self.header_index += 1
316 | header = {
317 | "headerName": escape(text),
318 | "headerLevel": level,
319 | "headerHref": anchor,
320 | "headerID": self.header_index,
321 | }
322 |
323 | if level <= MAX_HEADER_LEVEL and level >= MIN_HEADER_LEVEL:
324 | # Add empty line before header unless it's level is 1 (default MIN) and it's first header
325 | if level != MIN_HEADER_LEVEL or self.header_index != 1:
326 | self.writeTextLine()
327 | self.writeTextLine(f"{level * HEADER} {text}")
328 | elif level < MIN_HEADER_LEVEL:
329 | self.logger.warning(
330 | "Header level below minimum value, using minimum value."
331 | )
332 | header["headerLevel"] = MIN_HEADER_LEVEL
333 | self.writeTextLine(f"{MIN_HEADER_LEVEL * HEADER} {text}")
334 | else:
335 | self.logger.warning("Header level out of scope, using max value.")
336 | header["headerLevel"] = MAX_HEADER_LEVEL
337 | # Add empty line before header unless it's level is 1 (default MIN)
338 | self.writeTextLine()
339 | self.writeTextLine(f"{MAX_HEADER_LEVEL * HEADER} {text}")
340 |
341 | self.header_info.append(header)
342 | # self.logger.debug(f"Adding header {header}")
343 |
344 | """
345 | Emphasis, aka italics, bold or strikethrough.
346 | """
347 |
348 | def addBoldedText(self, text: str, write_as_line: bool = False) -> str:
349 | """
350 | Standard Markdown
351 |
352 | Method for bolding text
353 | See: https://docs.gitlab.com/ee/user/markdown.html#emphasis
354 | Removes leading and trailing whitespaces.
355 |
356 | :param text: Input text to be bolded.
357 | :param write_as_line: bool, Whether the text should be written to document/buffer directly
358 | :return: str, Bolded text
359 | :rtype: string
360 | """
361 | bolded = f"**{text.strip()}**"
362 | if write_as_line:
363 | self.writeTextLine(bolded)
364 | return bolded
365 |
366 | def addItalicizedText(self, text, write_as_line: bool = False) -> str:
367 | """
368 | Standard Markdown
369 |
370 | Method for italicizing text
371 | See: https://docs.gitlab.com/ee/user/markdown.html#emphasis
372 | Removes leading and trailing whitespaces.
373 |
374 | :param text: Input text to be italicized
375 | :param write_as_line: bool, Whether the text should be written to document/buffer directly
376 | :return: Italicized text
377 | :rtype: string
378 | """
379 | italicized = f"*{text.strip()}*"
380 | if write_as_line:
381 | self.writeTextLine(italicized)
382 | return italicized
383 |
384 | def addBoldedAndItalicizedText(self, text, write_as_line: bool = False) -> str:
385 | """
386 | Standard Markdown
387 |
388 | Method for bolding and italicing text
389 | See: https://docs.gitlab.com/ee/user/markdown.html#emphasis
390 | Removes leading and trailing whitespaces.
391 |
392 | :param text: Input text to be italicized and boldgin.
393 | :param write_as_line: bool, Whether the text should be written to document/buffer directly
394 | :return: Bolded text
395 | :rtype: string
396 | """
397 | bolded_italicized = f"***{text.strip()}***"
398 | if write_as_line:
399 | self.writeTextLine(bolded_italicized)
400 | return bolded_italicized
401 |
402 | def addStrikethroughText(self, text, write_as_line: bool = False) -> str:
403 | """
404 | NOTE: Non-standard Markdown
405 |
406 | Method for getting text strikethroughed
407 | See: https://docs.gitlab.com/ee/user/markdown.html#emphasis
408 | Removes leading and trailing whitespaces.
409 |
410 | :param text: Text to be converted
411 | :param write_as_line: bool, Whether the text should be written to document/buffer directly
412 | :return: Strikethourghed text
413 | :rtype: string
414 | """
415 | if self.syntax not in ["gitlab", "github"]:
416 | raise AttributeError("GitLab and GitHub Markdown syntax only.")
417 |
418 | strikethrough = f"~~{text.strip()}~~"
419 | if write_as_line:
420 | self.writeTextLine(strikethrough)
421 | return strikethrough
422 |
423 | def generateHrefNotation(self, text, url, title=None) -> str:
424 | """
425 | Standard Markdown
426 |
427 | Method for generating link into markdown document.
428 | See: https://docs.gitlab.com/ee/user/markdown.html#links
429 |
430 | :param text: Visible text
431 | :param text: URL of the wanted destination
432 | :param title: Title for URL. Tooltip on hover
433 | :return: formated markdown reprenstation text
434 | :rtype: string
435 | """
436 | if title:
437 | return f'[{text}]({url} "{title}")'
438 | return f"[{text}]({url})"
439 |
440 | def generateImageHrefNotation(self, image_uri: str, alt_text, title=None) -> str:
441 | """
442 | Standard Markdown
443 |
444 | Method for showing image in Markdown.
445 |
446 | GitLab supports videos and audio as well, and is able to play video, if extension is valid as
447 | described in here: https://docs.gitlab.com/ee/user/markdown.html#videos
448 |
449 | :param image_path: Path or URL into the image
450 | :param alt_text: Text which appears if image not loaded
451 | :param title: Title for the image
452 | :return: Formatted presentation of image in Markdown
453 | :rtype: string
454 | """
455 |
456 | if title:
457 | return f''
458 | return f""
459 |
460 | def addHorizontalRule(self):
461 | """
462 | Standard Markdown
463 |
464 | Method for appending Horizontal Rule:
465 | See: https://docs.gitlab.com/ee/user/markdown.html#horizontal-rule
466 | """
467 | self.writeTextLine(f"{linesep}{HORIZONTAL_RULE}{linesep}")
468 |
469 | def addCodeBlock(self, text, syntax: str = "", escape_html: bool = False):
470 | """
471 | Standard Markdown
472 |
473 | Method for inserting highlighted code block into the Markdown file
474 | See: https://docs.gitlab.com/ee/user/markdown.html#code-and-syntax-highlighting
475 |
476 | :param text: Actual content/code into code block
477 | :param syntax: string, Syntax highlight type for code block
478 | :param escape_html: bool, Wheather the input is html escaped or not
479 | """
480 |
481 | # Escape backtics/grave accents in attempt to deny codeblock escape
482 | grave_accent_escape = "\\`"
483 |
484 | text = text.replace("`", grave_accent_escape)
485 |
486 | if escape_html:
487 | self.writeTextLine(
488 | f"{CODE_BLOCK}{syntax}{linesep}{text}{linesep}{CODE_BLOCK}"
489 | )
490 | else:
491 | self.writeTextLine(
492 | f"{CODE_BLOCK}{syntax}{linesep}{text}{linesep}{CODE_BLOCK}",
493 | html_escape=False,
494 | )
495 |
496 | def addInlineCodeBlock(self, text, escape_html: bool = False, write: bool = False):
497 | """
498 | Standard Markdown
499 |
500 | Method for adding highlighted code in inline style in Markdown.
501 | By default in Markdown, it is done by using single backticks: `
502 |
503 | :param text: Actual content/code into code block
504 | :param escape_html: Wheather the input is html escaped or not. Default is True
505 | :param write: Wheather the output is written immediately or returned.
506 | :return: If write is false, generated InlineCodeBlock is returned
507 | :rtype: string
508 | By default constructed output is returned only.
509 | """
510 |
511 | inline_hl = f"{INLINE_CODE_HIGHLIGHT}{text}{INLINE_CODE_HIGHLIGHT}"
512 | if write:
513 | if escape_html:
514 | self.writeText(inline_hl)
515 | else:
516 | self.writeText(inline_hl, html_escape=False)
517 | else:
518 | return inline_hl
519 |
520 | def addSinglelineBlockQuote(self, text):
521 | """
522 | Standard Markdown
523 |
524 | Method for adding single line blockquote.
525 | Removes leading or trailing whitespaces from input.
526 |
527 | Escape input already here to enable ">" default blockquote character.
528 |
529 | :param text: Input to be written as single line blockquote
530 | """
531 | self.writeTextLine(
532 | f"{SINGLE_LINE_BLOCKQUOTE}{escape(text.strip())}", html_escape=False
533 | )
534 |
535 | def addMultiLineBlockQuote(self, text):
536 | """
537 | NOTE: GitLab Markdown Only
538 |
539 | Method for adding multiline blockquote.
540 | See: https://docs.gitlab.com/ee/user/markdown.html#multiline-blockquote
541 | Removes leading or trailing whitespaces from input.
542 |
543 | Escape input already here to enable ">" default blockquote character.
544 |
545 | :param text: Input text for inside blockquote
546 | """
547 | self.writeTextLine(
548 | f"{MULTILINE_BLOCKQUOTE}{linesep}{escape(text.strip())}{linesep}{MULTILINE_BLOCKQUOTE}",
549 | html_escape=False,
550 | )
551 |
552 |
553 | def addUnorderedList(self, iterableStringList):
554 | """
555 | Standard Markdown
556 |
557 | Method from constructing unordered list. Takes list of
558 | strings as argument. Each item from list going for own line.
559 |
560 | :param iterableStringList: List of strings. Each string
561 | as item in Markdown list
562 | """
563 | for item in iterableStringList:
564 | self.writeText(f" * {item}{linesep}")
565 | self.writeTextLine()
566 |
567 | def addTable(
568 | self,
569 | header_names: List[str] | None = None,
570 | row_elements=None,
571 | alignment="center",
572 | dictionary_list=None,
573 | html_escape=True,
574 | capitalize_headers=False,
575 | ):
576 | """
577 | Standard Markdown
578 |
579 | Method for generating Markdown table with centered cells.
580 |
581 | If both row_elements and dictionary_list is provided, dictionary_list is used by default.
582 |
583 | If only dictionary_list paramater is provided, function expects that it has list of same type dictionaries.
584 | Key names of dictionary will be used as header names for table.
585 |
586 | :param header_names: List of header names, defines width of table
587 | :param row_elements: List of lists. (List of rows, each row contains list of row's elements)
588 | :param dictionary_list: List of dictionary. Expecting only attributes inside dictionary, no nested objects.
589 | :param alignment: Alignment of all columns in table
590 | """
591 | useDictionaryList = False
592 | useProvidedHeadernames = False
593 |
594 | if row_elements is None:
595 | if dictionary_list is None:
596 | self.logger.warning("Invalid paramaters for generating new table.")
597 | raise TypeError(
598 | f"Invalid paramaters for generating new table. Use either dictionary list or row_elements."
599 | )
600 | else:
601 | useDictionaryList = True
602 |
603 | if row_elements and dictionary_list:
604 | self.logger.debug(
605 | "Both row elements and dictionary list provided, using dictionary list as default."
606 | )
607 |
608 | if header_names:
609 | self.logger.debug("Header names provided. Using them.")
610 | useProvidedHeadernames = True
611 | else:
612 | self.logger.debug(
613 | "No header names provided. Using dictionary attribute names as default. Using none, if row_elements used."
614 | )
615 |
616 | # Headers
617 | self.writeTextLine()
618 | if not useProvidedHeadernames and dictionary_list:
619 | try:
620 | header_names = dictionary_list[0].keys()
621 | self.logger.debug(f"Header names are: {header_names}")
622 | except AttributeError as e:
623 | self.logger.error(
624 | f"Dictionary list for addTable function was not list of objects. Table not generated. : {e}"
625 | )
626 | return
627 | try:
628 | if header_names:
629 | for header in header_names:
630 | # Capitalize header names
631 | if capitalize_headers:
632 | self.writeText(f"| {header.capitalize()} ")
633 | else:
634 | self.writeText(f"| {header} ")
635 | except TypeError as e:
636 | self.logger.error(f"Invalid header names for table. Not generated: {e}")
637 | return
638 | # Write ending vertical bar
639 | self.writeTextLine("|")
640 | # Write dashes to separate headers
641 | if alignment == "left":
642 | self.writeTextLine("".join(["|", ":---|" * len(header_names or [])]))
643 |
644 | elif alignment == "center":
645 | self.writeTextLine("".join(["|", ":---:|" * len(header_names or [])]))
646 |
647 | elif alignment == "right":
648 | self.writeTextLine("".join(["|", "---:|" * len(header_names or [])]))
649 | else:
650 | self.logger.warning("Invalid alignment value in addTable. Using default.")
651 | self.writeTextLine("".join(["|", ":---:|" * len(header_names or [])]))
652 |
653 | # Write each row into the table
654 |
655 | if not useDictionaryList:
656 | if row_elements:
657 | for row in row_elements:
658 | if len(row) > len(header_names or []):
659 | self.logger.error(
660 | f"The are more row elements than header names (Row: {len(row)} - Header: {len(header_names or [])} "
661 | )
662 | continue
663 | for element in row:
664 | # Check if element is list
665 | # If it is, add it as by line by line into single cell
666 | if isinstance(element, list):
667 | self.writeText("| ")
668 | for list_str in element:
669 | self.writeText(list_str)
670 | self.writeText("
", html_escape=False)
671 | else:
672 | self.writeText(f"| {element} ", html_escape)
673 |
674 | self.writeTextLine("|")
675 |
676 | else:
677 | # Iterate over list of dictionaries
678 | # One row contains attributes of dictionary
679 | if dictionary_list:
680 | for row in dictionary_list:
681 | for key in row.keys():
682 | if isinstance(row.get(key), list):
683 | self.writeText("| ")
684 | for list_str in row.get(key):
685 | self.writeText(list_str)
686 | self.writeText("
", html_escape=False)
687 | else:
688 | self.writeText(f"| {row.get(key)} ", html_escape)
689 | self.writeTextLine("|")
690 | self.writeTextLine()
691 |
692 | def insertDetailsAndSummary(
693 | self, summary_name="Click me to collapse/fold.", escape_html=True
694 | ):
695 | """
696 | Method for initializing Details and Summary block.
697 | See: https://docs.gitlab.com/ee/user/markdown.html#details-and-summary
698 |
699 |
700 | """
701 | self.writeTextLine("", html_escape=False)
702 |
703 | # Whether the summary name is html escaped or not
704 | if escape_html:
705 | self.writeTextLine(
706 | f"{escape(summary_name)}
", html_escape=False
707 | )
708 | else:
709 | # Makes bolding possible for summary name with HTML
710 | self.writeTextLine(f"{summary_name}
", html_escape=False)
711 | self.writeTextLine()
712 | self.unfinished_details_summary_count["value"] += 1
713 |
714 | def endDetailsAndSummary(self):
715 | """
716 | Ends details and summary block.
717 | """
718 | self.writeTextLine()
719 | self.writeTextLine(" ", html_escape=False)
720 | self.unfinished_details_summary_count["value"] -= 1
721 |
722 | def addFootNote(self, text, footnote, write=True):
723 | """
724 | Standard Markdown
725 |
726 | Placement of footnote reference id should be placed
727 | with brackets by default, as: [footnote_id], whereas the footnote_id is string
728 | footnote_id. Actual ID will be generated automatically.
729 |
730 | NOTE: Text is written by default. To get generated footnote block as return value,
731 | set 'write=False'.
732 |
733 | Method for inserting footnote into the document.
734 |
735 | :param text: Text to be written on document and wherethe footnote id is inserted.
736 | :param write: Defines if footnote should be written immmedially into the document.
737 | Same as to use 'writeTextLine' for return value when set False.
738 |
739 | :return: returns formatted text input
740 | """
741 |
742 | # Prevent duplicate footnotes
743 | # This might happen for examle if footnote is somehow in collection of
744 | # dictionaries, in part of value names
745 | # And this list is used to generated table
746 | already_in_list = False
747 | for footnote_d in self.pending_footnote_references:
748 | if footnote in footnote_d:
749 | already_in_list = True
750 | if not already_in_list:
751 | # Append footnote into the list, which will be written in the end.
752 | self.footnote_index["value"] += 1
753 | self.pending_footnote_references.append(
754 | f"[^{self.footnote_index.get('value')}]: {footnote}{linesep}"
755 | )
756 | else:
757 | self.logger.debug(
758 | f"Trying to add duplicate footnote '{footnote}'. Skipping."
759 | )
760 |
761 | # FOOTNOTE IDENTIFIER is string which is replaced with actual reference
762 | # See source of import (syntax.py)
763 | # Default should be '[footnote_id]'
764 | text = text.replace(
765 | f"{FOOTNOTE_IDENTIFIER}", f"[^{self.footnote_index.get('value')}]"
766 | )
767 | if write:
768 | self.writeTextLine(text)
769 | self.logger.debug(
770 | f'Added footnote "{footnote}" with identifier {self.footnote_index}.'
771 | )
772 | if not write:
773 | return text
774 |
--------------------------------------------------------------------------------
/markdowngenerator/test/__init__.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import os
3 | import sys
4 | import logging
5 | from markdowngenerator.markdowngenerator import MarkdownGenerator
6 |
7 | from dataclasses import dataclass
8 | from typing import List
9 |
10 | # from standardizer.markdown.config.conf import logger
11 |
12 |
13 | LOGGING_LEVEL = logging.DEBUG
14 | test_logger = logging.getLogger("markdowngenerator.test")
15 | test_logger.setLevel(LOGGING_LEVEL)
16 |
17 | # Formatter of log
18 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
19 | handler = logging.StreamHandler(sys.stdout)
20 | handler.setLevel(LOGGING_LEVEL)
21 | handler.setFormatter(formatter)
22 | test_logger.addHandler(handler)
23 | test_filename = "unittest.md"
24 |
25 |
26 | class BaseTestCase(unittest.TestCase):
27 | def __init__(self, *args, **kwargs):
28 | super(BaseTestCase, self).__init__(*args, **kwargs)
29 | self.expected_output = EXPECTED_OUTPUT()
30 | self.test_document = None
31 |
32 | def __call__(self, result=None):
33 | """
34 | Does the required configuration for each
35 | testcase function. No need to repeat when writing actual
36 | testcase.
37 | """
38 | try:
39 | self._pre_setup()
40 | super(BaseTestCase, self).__call__(result)
41 | finally:
42 | self._post_teardown()
43 |
44 | def _pre_setup(self):
45 | """
46 | Generate library instance and file creation.
47 | Doing this manually, since library is designed to be used
48 | with 'with' statement to handle all automatically.
49 | """
50 | self.test_document = MarkdownGenerator(
51 | filename=test_filename,
52 | enable_write=False,
53 | syntax="gitlab",
54 | )
55 | self.test_document.__enter__()
56 |
57 | def _post_teardown(self):
58 | """
59 | Manually use exit function to write final file.
60 | Compare file content to expected values.
61 |
62 | Cleanup afterwards. (delete instance and file)
63 | """
64 | if self.test_document is None:
65 | return None
66 | self.test_document.__exit__()
67 | with open(test_filename, "r") as final_file:
68 | self.assertIsMarkdownEqual(final_file)
69 | del self.test_document
70 | try:
71 | os.remove(test_filename)
72 | except OSError:
73 | pass
74 |
75 | def assertIsMarkdownEqual(self, final_file):
76 | """
77 | Method for comparing file contents to expected values.
78 | """
79 | if self.expected_output.value is None:
80 | raise AssertionError(
81 | "Testcase invalid. Expected output value should not be None."
82 | )
83 | expected_output_val = self.expected_output.value.split(os.linesep)
84 | generated_output = final_file.read()
85 | generated_output = generated_output.split(os.linesep)
86 | test_logger.debug(
87 | f"Expected lines: '{self.expected_output.value}', actual lines:'{generated_output}'"
88 | )
89 | assert len(expected_output_val) == len(
90 | generated_output
91 | ), f"Amount of expected and generated output lines is not equal. Generated: '{len(generated_output)}' Expected: '{len(expected_output_val)}'"
92 |
93 | for index, (expected_line, generated_line) in enumerate(
94 | zip(expected_output_val, generated_output)
95 | ):
96 | test_logger.debug(
97 | f"\nEXPECTED LINE: {expected_line}\nGENERATED LINE: {generated_line}\n"
98 | )
99 | if (
100 | self.expected_output.if_unequal.enabled
101 | and self.expected_output.if_unequal.lines and index + 1 in self.expected_output.if_unequal.lines
102 | ):
103 | assert (
104 | expected_line != generated_line
105 | ), f"Expected line '{expected_line}' is match for final line '{generated_line}'"
106 | else:
107 | assert (
108 | expected_line == generated_line
109 | ), f"Expected line '{expected_line}' is not match for final line '{generated_line}' when it shoud not."
110 | test_logger.info(f"{self.expected_output.func_name} Successful")
111 |
112 | def _insert_test_info(
113 | self, expected_output, func_name=None, test_if_UNEQUAL=False, unequal_lines=None
114 | ):
115 | """
116 | Method for getting test case function expected values
117 | and test case function name.
118 |
119 | Additionally using attribute 'test_if_UNEQUAL' for reverse testcase:
120 | expecting that specific lines are not matching.
121 | :param func_name: Name of the testcase function
122 | :param test_if_UNEQUAL: Paramater to define if tested for inequality, defaults to 'False'
123 | :param unequal_lines: List of lines from the input, which should be unequal.
124 | """
125 |
126 | test_logger.debug(expected_output)
127 | self.expected_output.value = "".join(expected_output)
128 | test_logger.debug(f"Merged list is {self.expected_output.value}")
129 | self.expected_output.func_name = func_name.upper() if func_name else "Unknown?"
130 | self.expected_output.if_unequal.enabled = test_if_UNEQUAL
131 | self.expected_output.if_unequal.lines = unequal_lines
132 |
133 | def _new_line(self):
134 | """
135 | Method for generating newline string in
136 | Markdown syntax to help testing.
137 |
138 | Two spaces before newline mark.
139 | """
140 | return f" {os.linesep}"
141 |
142 |
143 | @dataclass
144 | class IF_LINES_UNEQUAL:
145 | lines: List[int] | None = None
146 | enabled: bool = False
147 |
148 |
149 | @dataclass
150 | class EXPECTED_OUTPUT:
151 | value: str | None = None
152 | if_unequal: IF_LINES_UNEQUAL = IF_LINES_UNEQUAL()
153 | func_name: str = "Unknown"
154 |
--------------------------------------------------------------------------------
/markdowngenerator/test/test_blocks_quotes.py:
--------------------------------------------------------------------------------
1 | from . import BaseTestCase
2 | from os import linesep
3 | import textwrap
4 |
5 | class TestMarkdownBlocksAndQuotes(BaseTestCase):
6 | def test_code_block(self):
7 | if self.test_document is None:
8 | return
9 | self.test_document.enable_TOC = False
10 |
11 | syntax = "python"
12 | code = textwrap.dedent("""\
13 | if title:
14 | return f''
15 | return f\"\"""")
16 |
17 | self.test_document.addCodeBlock(code, syntax)
18 |
19 | validationlines = [
20 | f"```python{linesep}",
21 | f"if title:{linesep}",
22 | " return f''",
23 | linesep,
24 | 'return f""',
25 | linesep,
26 | f"``` {linesep}",
27 | ]
28 | self._insert_test_info(validationlines, func_name=self.test_code_block_escape_html.__name__)
29 |
30 |
31 |
32 | def test_code_block_escape_html(self):
33 | if self.test_document is None:
34 | return
35 |
36 | self.test_document.enable_TOC = False
37 |
38 | syntax = "python"
39 | code = textwrap.dedent("""\
40 | if title:
41 | return f''
42 | return f\"\"""")
43 |
44 | self.test_document.addCodeBlock(code, syntax, escape_html=True)
45 |
46 | validationlines = [
47 | f"```python{linesep}",
48 | f"if title:{linesep}",
49 | " return f''",
50 | linesep,
51 | 'return f""',
52 | linesep,
53 | f"``` {linesep}",
54 | ]
55 | self._insert_test_info(validationlines, func_name=self.test_code_block.__name__)
56 |
57 | def test_inline_code_block(self):
58 | if self.test_document is None:
59 | return None
60 |
61 | self.test_document.enable_TOC = False
62 |
63 | inline_block1 = self.test_document.addInlineCodeBlock("print(\"This is it\")")
64 | self.test_document.writeTextLine(inline_block1, html_escape=False)
65 | self.test_document.addInlineCodeBlock("print(\"Did not except this!\")", write=True)
66 | self.test_document.addInlineCodeBlock("print(\"Oooooor, Did not except this!\")", write=True, escape_html=True)
67 |
68 | validationlines = [
69 | f"`print(\"This is it\")` {linesep}",
70 | f"`print(\"Did not except this!\")``print("Oooooor, Did not except this!")`"
71 |
72 | ]
73 |
74 | self._insert_test_info(validationlines, func_name=self.test_inline_code_block.__name__)
75 |
76 |
77 | def test_single_line_blockquote(self):
78 | if self.test_document is None:
79 | return None
80 |
81 | self.test_document.enable_TOC = False
82 | self.test_document.addSinglelineBlockQuote("What's up there? Just single block quoting.")
83 | validationlines = [
84 | f">What's up there? Just single block quoting. {linesep}"
85 | ]
86 | self._insert_test_info(validationlines, func_name=self.test_single_line_blockquote.__name__)
87 |
88 |
--------------------------------------------------------------------------------
/markdowngenerator/test/test_markdown_emphasis.py:
--------------------------------------------------------------------------------
1 | # import logging
2 | from . import BaseTestCase
3 | from os import linesep
4 |
5 |
6 | class TestMarkdownEmphasis(BaseTestCase):
7 | def test_bolded_text(self):
8 | if self.test_document is None:
9 | return None
10 |
11 | self.test_document.enable_TOC = False
12 | self.test_document.addBoldedText("Is this bolded?", write_as_line=True)
13 | self.test_document.writeTextLine("This is not bolded.")
14 | self.test_document.addBoldedText("But this is.", write_as_line=True)
15 |
16 | # Test return value
17 | self.test_document.writeTextLine(
18 | self.test_document.addBoldedText("Complicated bolding...")
19 | )
20 |
21 | validationlines = [
22 | f"**Is this bolded?** {linesep}",
23 | f"This is not bolded. {linesep}",
24 | f"**But this is.** {linesep}",
25 | f"**Complicated bolding...** {linesep}",
26 | ]
27 | self._insert_test_info(
28 | validationlines, func_name=self.test_bolded_text.__name__
29 | )
30 |
31 | def test_italicized_text(self):
32 | if self.test_document is None:
33 | return None
34 |
35 | self.test_document.enable_TOC = False
36 | self.test_document.addItalicizedText("Is this italicized?", write_as_line=True)
37 | self.test_document.writeTextLine("This is not italicized.")
38 | self.test_document.addItalicizedText("But this is.", write_as_line=True)
39 |
40 | # Test return value
41 | self.test_document.writeTextLine(
42 | self.test_document.addItalicizedText("Complicated italicizing...")
43 | )
44 | validationlines = [
45 | f"*Is this italicized?* {linesep}",
46 | f"This is not italicized. {linesep}",
47 | f"*But this is.* {linesep}",
48 | f"*Complicated italicizing...* {linesep}",
49 | ]
50 | self._insert_test_info(
51 | validationlines, func_name=self.test_italicized_text.__name__
52 | )
53 |
54 | def test_bolded_italicized_text(self):
55 | if self.test_document is None:
56 | return None
57 |
58 | self.test_document.enable_TOC = False
59 | self.test_document.addBoldedAndItalicizedText(
60 | "Is this boldy italicized?", write_as_line=True
61 | )
62 | self.test_document.writeTextLine("This is not boldy italicized.")
63 | self.test_document.addBoldedAndItalicizedText(
64 | "But this is.", write_as_line=True
65 | )
66 |
67 | # Test return value
68 | self.test_document.writeTextLine(
69 | self.test_document.addBoldedAndItalicizedText(
70 | "Complicated boldy italicizing..."
71 | )
72 | )
73 | validationlines = [
74 | f"***Is this boldy italicized?*** {linesep}",
75 | f"This is not boldy italicized. {linesep}",
76 | f"***But this is.*** {linesep}",
77 | f"***Complicated boldy italicizing...*** {linesep}",
78 | ]
79 | self._insert_test_info(
80 | validationlines, func_name=self.test_bolded_italicized_text.__name__
81 | )
82 |
83 | def test_strikethrough_text(self):
84 | if self.test_document is None:
85 | return None
86 |
87 | self.test_document.enable_TOC = False
88 | self.test_document.addStrikethroughText(
89 | "Is this strikethroughed?", write_as_line=True
90 | )
91 | self.test_document.writeTextLine("This is not strikethroughed.")
92 | self.test_document.addStrikethroughText("But this is.", write_as_line=True)
93 |
94 | # Test return value
95 | self.test_document.writeTextLine(
96 | self.test_document.addStrikethroughText("Complicated strikethroughing...")
97 | )
98 | validationlines = [
99 | f"~~Is this strikethroughed?~~ {linesep}",
100 | f"This is not strikethroughed. {linesep}",
101 | f"~~But this is.~~ {linesep}",
102 | f"~~Complicated strikethroughing...~~ {linesep}",
103 | ]
104 | self._insert_test_info(
105 | validationlines, func_name=self.test_strikethrough_text.__name__
106 | )
107 |
108 | def test_emphasis_whitespace_stripping(self):
109 | if self.test_document is None:
110 | return None
111 |
112 | self.test_document.enable_TOC = False
113 | self.test_document.addBoldedText(
114 | " This is very bolded text with trailing spaces on input. ",
115 | write_as_line=True,
116 | )
117 | self.test_document.addItalicizedText(
118 | " This is very italiced text with trailing spaces on input. ",
119 | write_as_line=True,
120 | )
121 | self.test_document.addStrikethroughText(
122 | " This is strikethrough text with some extra spaces. ",
123 | write_as_line=True,
124 | )
125 |
126 | validationlines = [
127 | f"**This is very bolded text with trailing spaces on input.** {linesep}",
128 | f"*This is very italiced text with trailing spaces on input.* {linesep}",
129 | f"~~This is strikethrough text with some extra spaces.~~ {linesep}",
130 | ]
131 |
132 | self._insert_test_info(
133 | validationlines, func_name=self.test_emphasis_whitespace_stripping.__name__
134 | )
--------------------------------------------------------------------------------
/markdowngenerator/test/test_markdowngenerator_lib.py:
--------------------------------------------------------------------------------
1 | # import logging
2 | from . import BaseTestCase
3 | from os import linesep
4 | from ..config.syntax import MARKDOWN_HORIZONTAL_RULE
5 |
6 | class TestMarkdownGeneratorLib(BaseTestCase):
7 |
8 | def test_write_enabled(self):
9 | if self.test_document is None:
10 | return None
11 |
12 | self.test_document.enable_TOC = True
13 | self.test_document.enable_write = True
14 | # ToC should not be generated
15 |
16 | self.test_document.addHeader(1, "My Very First HeAder")
17 | self.test_document.addHeader(2, "My second header.")
18 | self.test_document.addHeader(3, "My third header.")
19 |
20 | line1 = f"# My Very First HeAder {linesep}"
21 | # All headers except first header gets trailing new line. Note two spaces
22 | line2 = self._new_line()
23 | line3 = f"## My second header. {linesep}"
24 | line4 = self._new_line()
25 | line5 = f"### My third header. {linesep}"
26 | self._insert_test_info(
27 | [
28 | line1,
29 | line2,
30 | line3,
31 | line4,
32 | line5,
33 | ],
34 | func_name=self.test_header_generation.__name__,
35 | )
36 | def test_write_disabled_generate_toc(self):
37 | if self.test_document is None:
38 | return None
39 |
40 | self.test_document.enable_TOC = True
41 | self.test_document.enable_write = False
42 | # ToC should be generated
43 |
44 | self.test_document.addHeader(1, "My Very First HeAder")
45 | self.test_document.addHeader(2, "My second header.")
46 | self.test_document.addHeader(3, "My third header.")
47 |
48 | line1 = f"## Table of Contents {linesep}"
49 | line2 = f" * [My Very First HeAder](#my-very-first-header){linesep}"
50 | line3 = f" * [My second header.](#my-second-header){linesep}"
51 | line4 = f" * [My third header.](#my-third-header){linesep}"
52 | line5 = self._new_line()
53 | line6 = f"# My Very First HeAder {linesep}"
54 | # All headers except first header gets trailing new line. Note two spaces
55 | line7 = self._new_line()
56 | line8 = f"## My second header. {linesep}"
57 | line9 = self._new_line()
58 | line10 = f"### My third header. {linesep}"
59 | self._insert_test_info(
60 | [
61 | line1,
62 | line2,
63 | line3,
64 | line4,
65 | line5,
66 | line6,
67 | line7,
68 | line8,
69 | line9,
70 | line10,
71 | ],
72 | func_name=self.test_header_generation.__name__,
73 | )
74 |
75 | def test_header_generation(self):
76 | # Disable TOC
77 | if self.test_document is None:
78 | return None
79 |
80 | self.test_document.enable_TOC = False
81 |
82 | self.test_document.addHeader(1, "My Very First HeAder")
83 | self.test_document.addHeader(2, "My second header.")
84 | self.test_document.addHeader(3, "My third header.")
85 | self.test_document.addHeader(4, "My fourth header.")
86 | self.test_document.addHeader(5, "My FiFtH HeAd3r.")
87 | self.test_document.addHeader(6, "My Sixth header...")
88 | line1 = f"# My Very First HeAder {linesep}"
89 | # All headers except first header gets trailing new line. Note two spaces
90 | line2 = self._new_line()
91 | line3 = f"## My second header. {linesep}"
92 | line4 = self._new_line()
93 | line5 = f"### My third header. {linesep}"
94 | line6 = self._new_line()
95 | line7 = f"#### My fourth header. {linesep}"
96 | line8 = self._new_line()
97 | line9 = f"##### My FiFtH HeAd3r. {linesep}"
98 | line10 = self._new_line()
99 | line11 = f"###### My Sixth header... {linesep}"
100 | self._insert_test_info(
101 | [
102 | line1,
103 | line2,
104 | line3,
105 | line4,
106 | line5,
107 | line6,
108 | line7,
109 | line8,
110 | line9,
111 | line10,
112 | line11,
113 | ],
114 | func_name=self.test_header_generation.__name__,
115 | )
116 |
117 | def test_header_invalid_comparisions(self):
118 | # Disable TOC
119 | if self.test_document is None:
120 | return None
121 |
122 | self.test_document.enable_TOC = False
123 |
124 | self.test_document.addHeader(1, "Header one for testINg!")
125 | self.test_document.addHeader(1, "Woaah, working huh??")
126 | line1 = f"# Header one for testing! {linesep}" # Some characters in lowercase
127 | line2 = self._new_line()
128 | line3 = f"# Woaah, working huh?? {linesep}"
129 | self._insert_test_info(
130 | [line1, line2, line3],
131 | func_name=self.test_header_invalid_comparisions.__name__,
132 | test_if_UNEQUAL=True,
133 | unequal_lines=[1],
134 | )
135 |
136 | def test_header_abnormal_header_level_values(self):
137 | # Disable table of contents
138 | if self.test_document is None:
139 | return None
140 |
141 | self.test_document.enable_TOC = False
142 |
143 | self.test_document.addHeader(-1, "Header size should be one.")
144 | self.test_document.addHeader(3242, "Header size should be in max size.")
145 | line1 = f"# Header size should be one. {linesep}"
146 | line2 = self._new_line()
147 | line3 = f"###### Header size should be in max size. {linesep}"
148 | self._insert_test_info(
149 | [line1, line2, line3],
150 | func_name=self.test_header_abnormal_header_level_values.__name__,
151 | )
152 |
153 | def test_horizontal_rule(self):
154 | if self.test_document is None:
155 | return None
156 |
157 | self.test_document.enable_TOC = False
158 | self.test_document.writeTextLine("This is some text.")
159 | self.test_document.addHorizontalRule()
160 | self.test_document.writeTextLine("This is MORE text.")
161 | self.test_document.addHorizontalRule()
162 |
163 | validationlines = [
164 | f"This is some text. {linesep}",
165 | f"{linesep}---{linesep} {linesep}",
166 | f"This is MORE text. {linesep}",
167 | f"{linesep}---{linesep} {linesep}"
168 |
169 | ]
170 | self._insert_test_info(
171 | validationlines,
172 | func_name=self.test_horizontal_rule.__name__,
173 | )
174 |
175 |
--------------------------------------------------------------------------------
/markdowngenerator/test/test_references.py:
--------------------------------------------------------------------------------
1 | from . import BaseTestCase
2 | from os import linesep
3 |
4 |
5 | class TestMarkdownReferences(BaseTestCase):
6 | def test_href_notation(self):
7 | if self.test_document is None:
8 | return
9 |
10 | self.test_document.enable_TOC = False
11 | self.test_document.writeTextLine(
12 | self.test_document.generateHrefNotation(
13 | "This text takes you into Google", "https://google.fi"
14 | )
15 | )
16 | self.test_document.writeTextLine(
17 | self.test_document.generateHrefNotation(
18 | "This text takes you into Google with title",
19 | "https://google.fi",
20 | "Such a title.",
21 | ),
22 | html_escape=False,
23 | )
24 |
25 | validationlines = [
26 | f"[This text takes you into Google](https://google.fi) {linesep}",
27 | f'[This text takes you into Google with title](https://google.fi "Such a title.") {linesep}',
28 | ]
29 | self._insert_test_info(
30 | validationlines, func_name=self.test_href_notation.__name__
31 | )
32 |
33 | def test_image_href(self):
34 | if self.test_document is None:
35 | return
36 |
37 | self.test_document.enable_TOC = False
38 | self.test_document.writeTextLine(
39 | self.test_document.generateImageHrefNotation(
40 | "/path/to/my/image", "Text once URI is broken"
41 | )
42 | )
43 | self.test_document.writeTextLine(
44 | self.test_document.generateImageHrefNotation(
45 | "http://smiley.image/yes.png",
46 | "Smiley image",
47 | "Such a title.",
48 | ),
49 | html_escape=False,
50 | )
51 | validationlines = [
52 | f" {linesep}",
53 | f' {linesep}'
54 |
55 | ]
56 |
57 | self._insert_test_info(
58 | validationlines, func_name=self.test_image_href.__name__
59 | )
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "python-markdown-generator"
3 | repository = "https://github.com/Nicceboy/python-markdown-generator"
4 | version = "0.1.0"
5 | description = "Python library for dynamically generating HTML sanitized Markdown syntax."
6 | authors = ["Niklas Saari "]
7 | license = "MIT"
8 | readme = "README.md"
9 | packages = [
10 | {include = "markdowngenerator"},
11 | # {include = "markdowngenerator.config"}
12 | ]
13 |
14 | [tool.poetry.dependencies]
15 | python = "^3.7"
16 |
17 |
18 | [build-system]
19 | requires = ["poetry-core"]
20 | build-backend = "poetry.core.masonry.api"
21 |
--------------------------------------------------------------------------------
/test-requirements.txt:
--------------------------------------------------------------------------------
1 | pytest-cov>=3.0.0
2 | pytest >=7.0.0
3 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py3
3 | isolated_build = True
4 |
5 | [testenv]
6 | deps=-r{toxinidir}/test-requirements.txt
7 |
8 | commands=
9 | pytest -sv --cov .
10 |
--------------------------------------------------------------------------------