├── .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 | [![CircleCI](https://img.shields.io/circleci/build/github/Nicceboy/python-markdown-generator?label=CircleCI&logo=circleci)](https://circleci.com/gh/Nicceboy/python-markdown-generator) 8 | [![codecov](https://codecov.io/gh/Nicceboy/python-markdown-generator/branch/master/graph/badge.svg)](https://codecov.io/gh/Nicceboy/python-markdown-generator) 9 | [![License](https://img.shields.io/badge/license-MIT-black.svg)](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'![{alt_text}]({image_uri} "{title}")' 458 | return f"![{alt_text}]({image_uri})" 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'![{alt_text}]({image_uri} \"{title}\")' 15 | return f\"![{alt_text}]({image_uri})\"""") 16 | 17 | self.test_document.addCodeBlock(code, syntax) 18 | 19 | validationlines = [ 20 | f"```python{linesep}", 21 | f"if title:{linesep}", 22 | " return f'![{alt_text}]({image_uri} \"{title}\")'", 23 | linesep, 24 | 'return f"![{alt_text}]({image_uri})"', 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'![{alt_text}]({image_uri} \"{title}\")' 42 | return f\"![{alt_text}]({image_uri})\"""") 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'![{alt_text}]({image_uri} "{title}")'", 50 | linesep, 51 | 'return f"![{alt_text}]({image_uri})"', 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"![Text once URI is broken](/path/to/my/image) {linesep}", 53 | f'![Smiley image](http://smiley.image/yes.png "Such a title.") {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 | --------------------------------------------------------------------------------