├── .bumpversion.cfg ├── .circleci └── config.yml ├── .gitignore ├── .pyup.yml ├── LICENSE ├── README.md ├── README.mdt ├── conftest.py ├── examples ├── simple.mdt └── testfile.py ├── license_markplates.jpg ├── markplates.py ├── markplates ├── __init__.py └── __main__.py ├── requirements.txt ├── setup.py ├── tasks.py ├── tests ├── data │ └── source.py ├── test_cli.py ├── test_import_file.py ├── test_import_function.py ├── test_import_repl.py ├── test_paths.py ├── test_ranges.py └── test_src.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.7.0 3 | 4 | [bumpversion:file:markplates/__init__.py] 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | [bumpversion:file:tasks.py] 9 | 10 | -------------------------------------------------------------------------------- /.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 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` 11 | # - image: circleci/python:3.6.1 12 | - image: themattrix/tox 13 | 14 | # Specify service dependencies here if necessary 15 | # CircleCI maintains a library of pre-built images 16 | # documented at https://circleci.com/docs/2.0/circleci-images/ 17 | # - image: circleci/postgres:9.4 18 | 19 | working_directory: ~/repo 20 | 21 | steps: 22 | - checkout 23 | 24 | # Download and cache dependencies 25 | - restore_cache: 26 | keys: 27 | - v1-dependencies-{{ checksum "requirements.txt" }} 28 | # fallback to using the latest cache if no exact match is found 29 | - v1-dependencies- 30 | 31 | - run: 32 | name: install dependencies 33 | command: | 34 | python3 -m venv venv 35 | . venv/bin/activate 36 | python3 -m pip install --upgrade pip 37 | python3 -m pip install -r requirements.txt 38 | 39 | - save_cache: 40 | paths: 41 | - ./venv 42 | key: v1-dependencies-{{ checksum "requirements.txt" }} 43 | 44 | # run tests! 45 | - run: 46 | name: run tox 47 | command: | 48 | . venv/bin/activate 49 | tox 50 | 51 | # - run: 52 | # name: run coverage tests 53 | # command: | 54 | # . venv/bin/activate 55 | # inv test 56 | # coveralls 57 | 58 | - store_artifacts: 59 | path: test-reports 60 | destination: test-reports 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Editor files 107 | *.swp 108 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # configure updates globally 2 | # default: all 3 | # allowed: all, insecure, False 4 | update: all 5 | 6 | # update schedule 7 | # default: empty 8 | # allowed: "every day", "every week", .. 9 | schedule: "every week" 10 | 11 | # allow to close stale PRs 12 | # default: True 13 | close_prs: True 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jim Anderson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![license_markplates](https://raw.githubusercontent.com/jima80525/markplates/master/license_markplates.jpg) 2 | 3 | # MarkPlates 4 | 5 | > A templating utility for keeping code included in Markdown documents in sync with the original source. 6 | 7 | [![CircleCI](https://circleci.com/gh/jima80525/markplates.svg?style=svg)](https://circleci.com/gh/jima80525/markplates) ![black](https://img.shields.io/badge/code%20style-black-000000.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![pyup.io](https://pyup.io/repos/github/jima80525/markplates/shield.svg)](https://pyup.io/account/repos/github/jima80525/markplates/) [![PyPI version](https://badge.fury.io/py/markplates.svg)](https://badge.fury.io/py/markplates) 8 | 9 | The problem I hope to solve is to simplify keeping external files up to date with markdown documents that contain them. This happens to me frequently when an editor makes a suggestion to an article that will modify the underlying code it is quoting. 10 | 11 | > **NOTE:** This project is not under active development but should still be functional. In early 2023 there were some new incompatibilities between CircleCI and Coveralls which I did not resolve, I merely commented out the code coverage section of the `config.yml` file for CircleCI. If (or when) this project starts seeing active development again, some sort of code coverage should be reinstated. 12 | 13 | ## Installing 14 | 15 | You can download and install the latest version of MarkPlates from the PyPI with this command: 16 | 17 | ```bash 18 | $ python3 -m pip install --upgrade markplates 19 | ``` 20 | 21 | MarkPlates is currently tested against Python 3.6, 3.7, 3.8, and 3.9. 22 | 23 | ## Usage 24 | 25 | Running `markplates` is as simple as handing it a file: 26 | 27 | ```bash 28 | $ markplates template.mdt 29 | ``` 30 | 31 | This will process the template in `template.mdt`, filling it in with data specified in the template. 32 | 33 | The `examples` directory has the `simple.mdt` template: 34 | 35 | ```markdown 36 | # Sample MarkPlates Template 37 | {{ set_path("./examples") }} 38 | 39 | This is an example of importing an entire file. Without a range specified, the first line is skipped to not include the shell specifier. Double blank lines are condensed into single lines: 40 | {{ import_source("testfile.py") }} 41 | 42 | If you want to include the shell specifier, you can include it explicitly in a range. This silly example imports some of the lines from the file, demonstrating different ranges. Note that the ranges are compiled into a single list, so over-lapping ranges are only shown once: 43 | {{ import_source("testfile.py", [1, 5, "2", 3, "8-$", "$", "$-2"]) }} 44 | 45 | Functions can be imported from a file by name: 46 | 47 | {{ import_function("testfile.py", "flying_pig_menu") }} 48 | 49 | The import_repl tag captures stdout and stderr from a REPL session: 50 | {{ import_repl( 51 | """ 52 | def func(x): 53 | if x: 54 | print(x) 55 | 56 | func(True) 57 | func(False) """) }} 58 | ``` 59 | 60 | This demonstrates setting the path and pulling in some of the lines of a file. You can also examine the `README.mdt` file in this library which is used to create this `README.md`. 61 | 62 | To use on your own project create a markdown document with special tags to indicate a `markplates` function call. The delimiter for these tags is `{{` function goes here `}}`. 63 | 64 | > **Note:** if you need to add `{{` characters which should not be processed as a template, you can put them in a `{{ '' }}` block to escape them. Template processing is done with `Jinja2` so Markplates uses the same escape sequences. 65 | 66 | `Markplates` supports these functions: 67 | 68 | * `set_path("path/to/source/files", [show_skipped_section_marker])` 69 | * `import_source("source_file_name", [list of line number ranges], language=None, filename=False)` 70 | * `import_function("source_file_name", "function_name", language=None, filename=False)` 71 | * `import_repl("code to run in repl")` 72 | 73 | ### `set_path()` 74 | 75 | The `set_path()` function allows you to specify the base directory to use when searching for source files. Each call to this function will apply from that point in the template down. 76 | 77 | The path must be included in single or double qoutes. If not specified, the path defaults to ".", the current directory. 78 | 79 | Examples: 80 | 81 | 82 | ``` 83 | {{set_path(".")}} #sets path to the default 84 | {{set_path("/home/user/my/project")}} # points the path to your project 85 | ``` 86 | 87 | The `set_path()` command is not required as all other functions will take full paths to files. 88 | 89 | This command takes an optional `show_skipped_section_marker` parameter which defaults to `False`. When set to true, if a template expansion shows disjoint sections of a file which are separated by 2 or more lines, that gap will be shown in the output with a marker line: 90 | 91 | ​ `# ...` 92 | 93 | This option defaults to false to not break backward compatibility. 94 | 95 | ### `import_source()` 96 | 97 | The `import_source()` function will pull in the contents of a source file. Optional line number ranges can be specified (see description below). The filename must be in quotes. 98 | 99 | If no line number ranges are specified, the first line of the file will be omitted. This is to prevent the `#!/usr/bin/env python` line from cluttering the markdown article. If you want to include the first line, use the range: `1-$`. 100 | 101 | Examples: 102 | 103 | ``` 104 | {{import_source("__main__.py")}} # includes all but line 1 from `__main__.py` file 105 | {{import_source("__main__.py", ["1-$",])}} # imports all lines from `__main__.py` 106 | {{import_source("__main__.py", [1, "3", "5-$"])}} # imports all lines except lines 2 and 4 from `__main__.py` 107 | {{import_source("__main__.py", language="python", filename=True)}} 108 | # includes all but line 1 from `__main__.py` file, puts the 109 | # contents in a python block with the filename as a comment in 110 | # the first line of the block. 111 | ``` 112 | 113 | 114 | `MarkPlates` will display an error message to `stderr` if a file is not found. 115 | 116 | ### `import_function()` 117 | 118 | The `import_function` function searches a source file for the named function, class, class method or assigned variable. The function name supports dot-references, for example to get at the class method `area()` inside the class `Sqaure`, the function name would be "Square.area". To retrieve a nested function, name both the parent and child function, for example "outer.inner". 119 | 120 | The first piece of code matching the given name is returned, (and you shouldn't have multiple things with the same name anyway!). The source file is parsed, not loaded, so no import side-effects take place. 121 | 122 | Whitespace following the function will not be included. 123 | 124 | Examples: 125 | 126 | ``` 127 | {{import_function("__main__.py", "condense_ranges")}} # imports the function named `condense_ranges` from `__main__.py` 128 | ``` 129 | 130 | 131 | The `language` and `filename` parameters are treated the same way they are in `import_source()`. 132 | 133 | ### `import_repl()` 134 | 135 | The `import_repl` function takes the input parameter and splits it into separate lines. Each line of the input will be run in a REPL with the `stdout` and `stderr` captured to insert into the final output. The result should appear similar to a copy-paste from running the same commands manually. 136 | 137 | There is an exception, however. Blank input lines are used for spacing and will not display the `>>>` prompt one would normally see in the REPL. 138 | 139 | Example: 140 | 141 | ``` 142 | {{import_repl( 143 | """ 144 | def func(x): 145 | if x: 146 | print(x) 147 | 148 | func(True) 149 | func(False) """) }} 150 | ``` 151 | 152 | 153 | Output: 154 | ``` 155 | >>> def func(x): 156 | ... if x: 157 | ... print(x) 158 | 159 | >>> func(True) 160 | True 161 | >>> func(False) 162 | 163 | ``` 164 | 165 | ### Line Number Ranges 166 | 167 | Line number ranges allow you to specify which lines you want to include from the source file. Ranges can be in the following form: 168 | 169 | * 3 or "3" : an integer adds just that line from the input 170 | 171 | * "5-7" : a range adds lines between start and end inclusive (so 5, 6, 7) 172 | 173 | * "10-$" : an unlimited range includes start line to the end of the file 174 | 175 | * "$" : the last line 176 | 177 | * "$-3" : negative indexing, the last line and the three lines that proceed it 178 | 179 | > **Note:** LINE NUMBERING STARTS AT 1! 180 | 181 | ### Copy to Clipboard 182 | 183 | The `-c` option will copy most of the output to the clipboard. The first two lines are skipped, which generally are the `h1` title and the following blank line. This is done to simplify copying the output to the Real Python CMS system. 184 | 185 | ### Square Brackets 186 | 187 | The `-s` / `--square` option tells the parser to use `[[` and `]]` as the 188 | function delimiters. Instead of ` {{ import_function("foo", "bar") 189 | }} `, use `[[ import_function("foo", "bar") ]]`. 190 | 191 | ## Features to Come 192 | 193 | I'd like to add: 194 | 195 | * Developer Instructions 196 | * Capturing the results of a shell command and inserting into the file 197 | * Running `black` over the included Python source 198 | * Windows and Mac testing/support 199 | 200 | ## Interested? 201 | 202 | Let me know! If you're interested in the results or would like to help out, please raise an issue and I'll be in touch! 203 | 204 | ## Release History 205 | 206 | * 1.6.0 Add `show_skipped` option to `set_path()` and improved error reporting 207 | * 1.5.0 Added `$` and `$-n` as valid line ranges. Fixed several bugs 208 | * 1.4.0 Added -c option, bug fixes 209 | * 1.3.0 Minor bug fixes 210 | * 1.2.0 Added `language` and `filename` options for `import_source` and `import_repl` methods 211 | * 1.1.0 Added `import_repl` functionality 212 | * 1.0.0 Initial release 213 | 214 | License plate graphic thanks to [ACME License Maker](https://www.acme.com/licensemaker/) 215 | -------------------------------------------------------------------------------- /README.mdt: -------------------------------------------------------------------------------- 1 | # ![license_markplates](https://raw.githubusercontent.com/jima80525/markplates/master/license_markplates.jpg) 2 | 3 | # MarkPlates 4 | 5 | > A templating utility for keeping code included in Markdown documents in sync with the original source. 6 | 7 | [![CircleCI](https://circleci.com/gh/jima80525/markplates.svg?style=svg)](https://circleci.com/gh/jima80525/markplates) ![black](https://img.shields.io/badge/code%20style-black-000000.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![pyup.io](https://pyup.io/repos/github/jima80525/markplates/shield.svg)](https://pyup.io/account/repos/github/jima80525/markplates/) [![PyPI version](https://badge.fury.io/py/markplates.svg)](https://badge.fury.io/py/markplates) [![Coverage Status](https://coveralls.io/repos/github/jima80525/markplates/badge.svg?branch=master)](https://coveralls.io/github/jima80525/markplates?branch=master) 8 | 9 | The problem I hope to solve is to simplify keeping external files up to date with markdown documents that contain them. This happens to me frequently when an editor makes a suggestion to an article that will modify the underlying code it is quoting. 10 | 11 | ## Installing 12 | 13 | You can download and install the latest version of MarkPlates from the PyPI with this command: 14 | 15 | ```bash 16 | $ python3 -m pip install --upgrade markplates 17 | ``` 18 | 19 | MarkPlates is currently tested against Python 3.6, 3.7, 3.8, and 3.9. 20 | 21 | ## Usage 22 | 23 | Running `markplates` is as simple as handing it a file: 24 | 25 | ```bash 26 | $ markplates template.mdt 27 | ``` 28 | 29 | This will process the template in `template.mdt`, filling it in with data specified in the template. 30 | 31 | The `examples` directory has the `simple.mdt` template: 32 | 33 | ```markdown 34 | {{set_path("./examples")}}{{import_source("simple.mdt", ["1-$",])}} 35 | ``` 36 | 37 | This demonstrates setting the path and pulling in some of the lines of a file. You can also examine the `README.mdt` file in this library which is used to create this `README.md`. 38 | 39 | To use on your own project create a markdown document with special tags to indicate a `markplates` function call. The delimiter for these tags is {{ '`{{` function goes here `}}`' }}. 40 | 41 | > **Note:** if you need to add {{ '`{{`'}} characters which should not be processed as a template, you can put them in a {{ "`{{ '" "' }}` " }} block to escape them. Template processing is done with `Jinja2` so Markplates uses the same escape sequences. 42 | 43 | `Markplates` supports these functions: 44 | 45 | * `set_path("path/to/source/files", [show_skipped_section_marker])` 46 | * `import_source("source_file_name", [list of line number ranges], language=None, filename=False)` 47 | * `import_function("source_file_name", "function_name", language=None, filename=False)` 48 | * `import_repl("code to run in repl")` 49 | 50 | ### `set_path()` 51 | 52 | The `set_path()` function allows you to specify the base directory to use when searching for source files. Each call to this function will apply from that point in the template down. 53 | 54 | The path must be included in single or double qoutes. If not specified, the path defaults to ".", the current directory. 55 | 56 | Examples: 57 | {{ ' 58 | 59 | ``` 60 | {{set_path(".")}} #sets path to the default 61 | {{set_path("/home/user/my/project")}} # points the path to your project 62 | ``` 63 | ' }} 64 | The `set_path()` command is not required as all other functions will take full paths to files. 65 | 66 | This command takes an optional `show_skipped_section_marker` parameter which defaults to `False`. When set to true, if a template expansion shows disjoint sections of a file which are separated by 2 or more lines, that gap will be shown in the output with a marker line: 67 | 68 | ​ `# ...` 69 | 70 | This option defaults to false to not break backward compatibility. 71 | 72 | ### `import_source()` 73 | 74 | The `import_source()` function will pull in the contents of a source file. Optional line number ranges can be specified (see description below). The filename must be in quotes. 75 | 76 | If no line number ranges are specified, the first line of the file will be omitted. This is to prevent the `#!/usr/bin/env python` line from cluttering the markdown article. If you want to include the first line, use the range: `1-$`. 77 | 78 | Examples: 79 | {{ ' 80 | ``` 81 | {{import_source("__main__.py")}} # includes all but line 1 from `__main__.py` file 82 | {{import_source("__main__.py", ["1-$",])}} # imports all lines from `__main__.py` 83 | {{import_source("__main__.py", [1, "3", "5-$"])}} # imports all lines except lines 2 and 4 from `__main__.py` 84 | {{import_source("__main__.py", language="python", filename=True)}} 85 | # includes all but line 1 from `__main__.py` file, puts the 86 | # contents in a python block with the filename as a comment in 87 | # the first line of the block. 88 | ``` 89 | ' }} 90 | 91 | `MarkPlates` will display an error message to `stderr` if a file is not found. 92 | 93 | ### `import_function()` 94 | 95 | The `import_function` function searches a source file for the named function, class, class method or assigned variable. The function name supports dot-references, for example to get at the class method `area()` inside the class `Sqaure`, the function name would be "Square.area". To retrieve a nested function, name both the parent and child function, for example "outer.inner". 96 | 97 | The first piece of code matching the given name is returned, (and you shouldn't have multiple things with the same name anyway!). The source file is parsed, not loaded, so no import side-effects take place. 98 | 99 | Whitespace following the function will not be included. 100 | 101 | Examples: 102 | {{ ' 103 | ``` 104 | {{import_function("__main__.py", "condense_ranges")}} # imports the function named `condense_ranges` from `__main__.py` 105 | ``` 106 | ' }} 107 | 108 | The `language` and `filename` parameters are treated the same way they are in `import_source()`. 109 | 110 | ### `import_repl()` 111 | 112 | The `import_repl` function takes the input parameter and splits it into separate lines. Each line of the input will be run in a REPL with the `stdout` and `stderr` captured to insert into the final output. The result should appear similar to a copy-paste from running the same commands manually. 113 | 114 | There is an exception, however. Blank input lines are used for spacing and will not display the `>>>` prompt one would normally see in the REPL. 115 | 116 | Example: 117 | {{ ' 118 | ``` 119 | {{import_repl( 120 | """ 121 | def func(x): 122 | if x: 123 | print(x) 124 | 125 | func(True) 126 | func(False) """) }} 127 | ``` 128 | ' }} 129 | 130 | Output: 131 | ``` 132 | >>> def func(x): 133 | ... if x: 134 | ... print(x) 135 | 136 | >>> func(True) 137 | True 138 | >>> func(False) 139 | 140 | ``` 141 | 142 | ### Line Number Ranges 143 | 144 | Line number ranges allow you to specify which lines you want to include from the source file. Ranges can be in the following form: 145 | 146 | * 3 or "3" : an integer adds just that line from the input 147 | 148 | * "5-7" : a range adds lines between start and end inclusive (so 5, 6, 7) 149 | 150 | * "10-$" : an unlimited range includes start line to the end of the file 151 | 152 | * "$" : the last line 153 | 154 | * "$-3" : negative indexing, the last line and the three lines that proceed it 155 | 156 | > **Note:** LINE NUMBERING STARTS AT 1! 157 | 158 | ### Copy to Clipboard 159 | 160 | The `-c` option will copy most of the output to the clipboard. The first two lines are skipped, which generally are the `h1` title and the following blank line. This is done to simplify copying the output to the Real Python CMS system. 161 | 162 | ### Square Brackets 163 | 164 | The `-s` / `--square` option tells the parser to use `[[` and `]]` as the 165 | function delimiters. Instead of `{{ ' {{ import_function("foo", "bar") 166 | }} ' }}`, use `[[ import_function("foo", "bar") ]]`. 167 | 168 | ## Features to Come 169 | 170 | I'd like to add: 171 | 172 | * Developer Instructions 173 | * Capturing the results of a shell command and inserting into the file 174 | * Running `black` over the included Python source 175 | * Windows and Mac testing/support 176 | 177 | ## Interested? 178 | 179 | Let me know! If you're interested in the results or would like to help out, please raise an issue and I'll be in touch! 180 | 181 | ## Release History 182 | 183 | * 1.6.0 Add `show_skipped` option to `set_path()` and improved error reporting 184 | * 1.5.0 Added `$` and `$-n` as valid line ranges. Fixed several bugs 185 | * 1.4.0 Added -c option, bug fixes 186 | * 1.3.0 Minor bug fixes 187 | * 1.2.0 Added `language` and `filename` options for `import_source` and `import_repl` methods 188 | * 1.1.0 Added `import_repl` functionality 189 | * 1.0.0 Initial release 190 | 191 | License plate graphic thanks to [ACME License Maker](https://www.acme.com/licensemaker/) 192 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | # Adding this file helps pytest work out path to simplify testing! 4 | 5 | 6 | @pytest.fixture 7 | def counting_lines(): 8 | """ A simple list of strings, each with x\n where x is the line number. """ 9 | def _gen_lines(limit=13): 10 | return [str(x + 1) + "\n" for x in range(limit)] 11 | return _gen_lines 12 | -------------------------------------------------------------------------------- /examples/simple.mdt: -------------------------------------------------------------------------------- 1 | # Sample MarkPlates Template 2 | {{ set_path("./examples") }} 3 | 4 | This is an example of importing an entire file. Without a range specified, the first line is skipped to not include the shell specifier. Double blank lines are condensed into single lines: 5 | {{ import_source("testfile.py") }} 6 | 7 | If you want to include the shell specifier, you can include it explicitly in a range. This silly example imports some of the lines from the file, demonstrating different ranges. Note that the ranges are compiled into a single list, so over-lapping ranges are only shown once: 8 | {{ import_source("testfile.py", [1, 5, "2", 3, "8-$", "$", "$-2"]) }} 9 | 10 | Functions can be imported from a file by name: 11 | 12 | {{ import_function("testfile.py", "flying_pig_menu") }} 13 | 14 | The import_repl tag captures stdout and stderr from a REPL session: 15 | {{ import_repl( 16 | """ 17 | def func(x): 18 | if x: 19 | print(x) 20 | 21 | func(True) 22 | func(False) """) }} 23 | 24 | -------------------------------------------------------------------------------- /examples/testfile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | def flying_pig_menu(): 4 | components = ["SPAM", "BAKED BEANS"] 5 | menu_item = [component[0] for component in range(0, 6)] 6 | menu_item.append(components[1]) 7 | 8 | menu_string = ",".join(menu_item) + f"+{component[1]}" 9 | return menu_string 10 | 11 | 12 | print("Hello world") 13 | print("Vikings love:", viking_menu) 14 | -------------------------------------------------------------------------------- /license_markplates.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jima80525/markplates/39325f948fced4aa69a50a9ab19db231281e0505/license_markplates.jpg -------------------------------------------------------------------------------- /markplates.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import markplates 3 | 4 | 5 | if __name__ == "__main__": 6 | markplates.main() 7 | -------------------------------------------------------------------------------- /markplates/__init__.py: -------------------------------------------------------------------------------- 1 | from .__main__ import condense_ranges 2 | from .__main__ import process_template 3 | from .__main__ import main 4 | 5 | __version__ = "1.7.0" 6 | -------------------------------------------------------------------------------- /markplates/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import ast 3 | import asttokens 4 | import click 5 | import code 6 | import contextlib 7 | import errno 8 | import io 9 | import jinja2 10 | import os 11 | import pathlib 12 | import re 13 | import sys 14 | import pyperclip 15 | 16 | # Global set from command line options. Don't know of a good way to pass options 17 | # through jinja2 to the functions being called from the template 18 | g_indicate_skipped_lines = False 19 | 20 | 21 | def _descend_tree(atok, parent, local_name, name_parts): 22 | for node in ast.iter_child_nodes(parent): 23 | if ( 24 | node.__class__ in [ast.FunctionDef, ast.ClassDef] 25 | and node.name == local_name 26 | ): 27 | if len(name_parts) == 0: 28 | # Found the node, return the code 29 | return atok.get_text(node) + "\n" 30 | 31 | # Found a fn or class, but looking for a child of it 32 | return _descend_tree(atok, node, name_parts[0], name_parts[1:]) 33 | 34 | if ( 35 | node.__class__ == ast.Assign 36 | and node.first_token.string == local_name 37 | and len(name_parts) == 0 38 | ): 39 | return atok.get_text(node) + "\n" 40 | 41 | return "" 42 | 43 | 44 | def find_in_source(source, name): 45 | with open(source) as f: 46 | source_text = f.read() 47 | 48 | try: 49 | atok = asttokens.ASTTokens(source_text, parse=True) 50 | except SyntaxError as synErr: 51 | print(f"Failed to parse {source}: {synErr}") 52 | sys.exit(1) 53 | 54 | name_parts = name.split(".") 55 | return _descend_tree(atok, atok.tree, name_parts[0], name_parts[1:]) 56 | 57 | 58 | class TemplateState: 59 | def __init__(self): 60 | self.path = pathlib.Path(".") 61 | 62 | def set_path(self, path, show_skipped=False): 63 | global g_indicate_skipped_lines 64 | g_indicate_skipped_lines = show_skipped 65 | self.path = pathlib.Path(path) 66 | if not self.path.is_dir(): 67 | raise FileNotFoundError( 68 | errno.ENOENT, os.strerror(errno.ENOENT), self.path 69 | ) 70 | return "" 71 | 72 | def _add_filename(self, to_add, source, lines): 73 | if to_add: 74 | lines.insert(0, "# %s\n" % source) 75 | 76 | def _add_language(self, language, lines): 77 | if language: 78 | if language.lower() in ["c", "cpp", "c++"]: 79 | language = "cpp" 80 | lines.insert(0, "```%s\n" % language.lower()) 81 | lines.append("```") 82 | 83 | def _strip_trailing_blanks(self, lines): 84 | if lines: 85 | while re.search(r"^ *$", lines[-1]): 86 | del lines[-1] 87 | 88 | def import_source(self, source, ranges=None, language=None, filename=False): 89 | source_name = self.path / source 90 | lines = open(source_name, "r").readlines() 91 | if not ranges: 92 | ranges = ["2-$"] 93 | 94 | lines = condense_ranges(lines, ranges, source_name) 95 | lines = remove_double_blanks(lines) 96 | # If the trailing line doesn't have a \n, add one here 97 | if lines and not lines[-1].endswith("\n"): 98 | lines[-1] += "\n" 99 | self._strip_trailing_blanks(lines) 100 | lines = left_justify(lines) 101 | self._add_filename(filename, source, lines) 102 | self._add_language(language, lines) 103 | return "".join(lines).rstrip() 104 | 105 | def import_function( 106 | self, source, function_name, language=None, filename=False 107 | ): 108 | """ Search for and extract a function.""" 109 | source_name = self.path / source 110 | code = find_in_source(source_name, function_name) 111 | if not code: 112 | raise Exception(f"Function not found: {function_name}") 113 | 114 | output_lines = code.splitlines(keepends=True) 115 | self._strip_trailing_blanks(output_lines) 116 | output_lines = left_justify(output_lines) 117 | self._add_filename(filename, source, output_lines) 118 | self._add_language(language, output_lines) 119 | return "".join(output_lines).rstrip() 120 | 121 | def import_repl(self, source): 122 | # split into individual lines 123 | lines = source.split("\n") 124 | # it's a bit cleaner to start the first line of code on the line after 125 | # the start of the string, so remove a single blank line from the start 126 | # if it's present 127 | if len(lines[0]) == 0: 128 | lines.pop(0) 129 | 130 | # set up the console and prompts 131 | console = code.InteractiveConsole() 132 | ps1 = ">>> " 133 | prompt = ps1 134 | 135 | with io.StringIO() as output: 136 | with contextlib.redirect_stdout(output): 137 | with contextlib.redirect_stderr(output): 138 | ps2 = "... " 139 | for line in lines: 140 | # don't show prompt on blank lines - spacing looks 141 | # better this way 142 | if len(line) != 0: 143 | print(f"{prompt}{line}") 144 | else: 145 | print() 146 | console.push(line) 147 | if line.endswith(":"): 148 | prompt = ps2 149 | elif len(line) == 0: 150 | prompt = ps1 151 | # Trim trailing blank lines 152 | outputString = output.getvalue() 153 | while outputString[-1] == "\n": 154 | outputString = outputString[:-1] 155 | # degenerate case of entirely empty repl block 156 | # still return a single blank line 157 | if len(outputString) < 2: 158 | return outputString 159 | return outputString 160 | 161 | 162 | def remove_double_blanks(lines): 163 | """ Takes a list of lines and condenses multiple blank lines into a single 164 | blank line. 165 | """ 166 | new_lines = [] 167 | prev_line = "" # empty line here will remove leading blank space 168 | for line in lines: 169 | if len(line.strip()) or len(prev_line.strip()): 170 | new_lines.append(line) 171 | prev_line = line 172 | return new_lines 173 | 174 | 175 | def left_justify(lines): 176 | """ Removes a consistent amount of leading whitespace from the front of each 177 | line so that at least one line is left-justified. 178 | WARNING: this will fail on mixed tabs and spaces. Don't do that. 179 | """ 180 | leads = [ 181 | len(line) - len(line.lstrip()) for line in lines if len(line.strip()) 182 | ] 183 | if not leads: # degenerate case where there are only blank lines 184 | return lines 185 | min_lead = min(leads) 186 | new_lines = [] 187 | for line in lines: 188 | if len(line.strip()): 189 | new_lines.append(line[min_lead:]) 190 | else: 191 | new_lines.append(line) 192 | return new_lines 193 | 194 | 195 | def condense_ranges(input_lines, ranges, source_name): 196 | """ Takes a list of ranges and produces a sorted list of lines from the 197 | input file. 198 | Ranges can be in the following form: 199 | 3 or "3" : an integer adds just that line from the input 200 | "5-7" : a range adds lines between start and end inclusive (so 5, 6, 7) 201 | "10-$" : an unlimited range includes start line to the end of the file 202 | LINE NUMBERING STARTS AT 1! 203 | The algorithm to do this is wildly inefficient. There is almost certainly a 204 | clever way to use array slices to pull this off, but I found it easier to 205 | normalize the line numbers (i.e. got them in sorted order and removed 206 | duplicate number and combined overlaps) by simply creating a list of lines. 207 | """ 208 | output_numbers = set() 209 | for _range in ranges: 210 | # For the single line instances, just convert to int(). This will 211 | # cover values that are already ints and '3'. 212 | try: 213 | rint = int(_range) 214 | output_numbers.add(rint) 215 | except ValueError: 216 | if _range == "$": 217 | # $ on its own means last line 218 | output_numbers.add(len(input_lines)) 219 | else: 220 | # If it's not a single line, look for a range 221 | start, end = _range.split("-") 222 | if start == "$": 223 | # Support negative indexing on lines, end will be the last 224 | # line, start will be the last line minus the "end" value 225 | # in the range 226 | count = int(end) 227 | end = len(input_lines) 228 | start = end - count 229 | else: 230 | # Start of range is a number 231 | start = int(start) 232 | # check for $ on end first 233 | end = len(input_lines) if end.strip() == "$" else int(end) 234 | # output_numbers.update(list(range(start, end+1))) 235 | output_numbers.update(range(start, end + 1)) 236 | output_numbers = sorted(output_numbers) 237 | # fail if they explicitly requested beyond the end of the file 238 | if len(input_lines) < output_numbers[-1]: 239 | print( 240 | f"Requested {output_numbers[-1]} lines from {source_name}. " 241 | "Past end of file!" 242 | ) 243 | sys.exit(1) 244 | if g_indicate_skipped_lines: 245 | output = [] 246 | for index, line_number in enumerate(output_numbers): 247 | line = input_lines[line_number - 1] 248 | output.append(line) 249 | if ( 250 | index + 1 < len(output_numbers) 251 | and output_numbers[index + 1] - output_numbers[index] > 2 252 | ): 253 | if len(input_lines[line_number - 2]) != 0: 254 | output.append("\n") 255 | 256 | num_indent = len(line) - len(line.lstrip()) 257 | prefix = " " * num_indent 258 | output.append(f"{prefix}# ...\n") 259 | if len(input_lines[line_number]) != 0: 260 | output.append("\n") 261 | else: 262 | output = [input_lines[number - 1] for number in output_numbers] 263 | 264 | return output 265 | 266 | 267 | def process_template(template, square): 268 | # alias the block start and stop strings as they conflict with the 269 | # templating on RealPython. Currently these are unused here. 270 | file_loader = jinja2.FileSystemLoader(str(template.parent)) 271 | 272 | kwargs = { 273 | "loader":file_loader, 274 | "block_start_string":"&&&&", 275 | "block_end_string":"&&&&" 276 | } 277 | 278 | if square: 279 | kwargs.update({ 280 | 'variable_start_string':'[[', 281 | 'variable_end_string':']]', 282 | }) 283 | 284 | env = jinja2.Environment(**kwargs) 285 | template = env.get_template(str(template.name)) 286 | 287 | template_state = TemplateState() 288 | for item in dir(TemplateState): 289 | if not item.startswith("__"): 290 | template.globals[item] = getattr(template_state, item) 291 | return template.render() 292 | 293 | 294 | @click.command(context_settings=dict(help_option_names=["-h", "--help"])) 295 | @click.option("-v", "--verbose", is_flag=True, help="Verbose debugging info") 296 | @click.option( 297 | "-c", "--clip", is_flag=True, help="RealPython output to clipboard" 298 | ) 299 | @click.option( 300 | "-s", "--square", is_flag=True, help="Use [[ ]] for template tags" 301 | ) 302 | @click.argument("template", type=str) 303 | def main(verbose, clip, square, template): 304 | try: 305 | output = process_template(pathlib.Path(template), square) 306 | print(output) 307 | sys.stdout.flush() 308 | if clip: 309 | # copy lines to clipboard, but skip the first title and the 310 | # subsequent blank line 311 | lines = output.split("\n") 312 | to_clip = "\n".join(lines[2:]) 313 | 314 | # NOTE: there seems to be a bug in pyperclip that is emitting output 315 | # to stdout when the clipboard gets too large. Redirecting stdout 316 | # to devnull seems to resolve the issue (along with the flush() 317 | # above). This is ugly, but works 318 | fdnull = os.open(os.devnull, os.O_WRONLY) 319 | os.dup2(fdnull, 1) 320 | try: 321 | pyperclip.copy(to_clip) 322 | finally: 323 | os.close(fdnull) 324 | 325 | except FileNotFoundError as e: 326 | print(f"Unable to import file:{e.filename}", file=sys.stderr) 327 | sys.exit(1) 328 | except jinja2.exceptions.TemplateNotFound as e: 329 | print(f"Unable to import file:{e}", file=sys.stderr) 330 | sys.exit(1) 331 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asttokens==2.4.1 2 | appdirs==1.4.4 3 | atomicwrites==1.4.1 4 | attrs==23.2.0 5 | black==24.3.0 6 | bleach==6.1.0 7 | bumpversion==0.6.0 8 | certifi==2024.2.2 9 | chardet==5.2.0 10 | Click==8.1.7 11 | coverage==7.4.4 12 | docopt==0.6.2 13 | docutils==0.20.1 14 | filelock==3.13.3 15 | idna==3.6 16 | invoke==2.2.0 17 | Jinja2==3.1.3 18 | MarkupSafe==2.1.5 19 | more-itertools==10.2.0 20 | pkginfo==1.10.0 21 | pluggy==1.4.0 22 | py==1.11.0 23 | pyperclip==1.8.2 24 | Pygments==2.17.2 25 | pytest==8.1.1 26 | pytest-cov==5.0.0 27 | requests==2.31.0 28 | requests-toolbelt==1.0.0 29 | six==1.16.0 30 | toml==0.10.2 31 | tox==4.14.2 32 | tqdm==4.66.2 33 | twine==5.0.0 34 | urllib3==2.2.1 35 | virtualenv==20.25.1 36 | webencodings==0.5.1 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup script for realpython-reader""" 2 | 3 | import pathlib 4 | from setuptools import setup 5 | 6 | # The directory containing this file 7 | HERE = pathlib.Path(__file__).parent 8 | README = (HERE / "README.md").read_text() 9 | NAME = "markplates" 10 | 11 | # This call to setup() does all the work 12 | setup( 13 | name=NAME, 14 | version="1.7.0", 15 | description="Inject code snippets into your Markdown docs", 16 | long_description=README, 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/jima80525/markplates", 19 | author="Jim Anderson", 20 | author_email="jima.coding@gmail.com", 21 | python_requires=">=3.6.0", 22 | license="MIT", 23 | classifiers=[ 24 | "License :: OSI Approved :: MIT License", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.7", 28 | ], 29 | packages=[NAME], 30 | include_package_data=False, 31 | install_requires=[ 32 | "asttokens==2.0.4", 33 | "Click==7.1.2", 34 | "Jinja2==2.11.3", 35 | "pyperclip==1.8.0", 36 | ], 37 | entry_points={"console_scripts": ["markplates=markplates.__main__:main"]}, 38 | ) 39 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | """ Task definitions for invoke command line utility for building, testing and 2 | releasing markplates. """ 3 | from invoke import run 4 | from invoke import task 5 | import pytest 6 | import setuptools 7 | import sys 8 | 9 | VERSION = "1.7.0" 10 | 11 | 12 | @task 13 | def test(c): 14 | """ Run unit tests with coverage report. """ 15 | pytest.main( 16 | [ 17 | "--cov=markplates", 18 | "--cov-report=term-missing", 19 | "--cov-report=term:skip-covered", 20 | "tests", 21 | ] 22 | ) 23 | 24 | 25 | @task 26 | def tox(c): 27 | """ Run tox to test all supported Python versions. """ 28 | run("tox") 29 | 30 | 31 | @task 32 | def format(c): 33 | """ Run black over all source to reformat. """ 34 | files = ["markplates", "tests", "tasks.py", "setup.py"] 35 | for name in files: 36 | run(f"black -l 80 {name}") 37 | 38 | 39 | @task 40 | def clean(c, bytecode=False, test=False, extra=""): 41 | """ Remove any built objects. -b removes bytecode, -t testfiles -e extra""" 42 | patterns = ["build/", "dist/", "markplates.egg-info/"] 43 | if bytecode: 44 | patterns.append("__pycache__/") 45 | patterns.append("markplates/__pycache__/") 46 | patterns.append("tests/__pycache__/") 47 | if test: 48 | patterns.append(".coverage") 49 | patterns.append(".pytest_cache/") 50 | patterns.append(".tox/") 51 | if extra: 52 | patterns.append(extra) 53 | for pattern in patterns: 54 | c.run("rm -rf {}".format(pattern)) 55 | 56 | 57 | def status(s): 58 | """Prints things in bold.""" 59 | print("\033[1m{0}\033[0m".format(s)) 60 | 61 | 62 | @task 63 | def readme(c): 64 | """ Process the README.mdt into README.md. """ 65 | status(f"Creating README.md…") 66 | run("./markplates.py README.mdt > README.md") 67 | 68 | 69 | @task 70 | def patch(c): 71 | """ Update version for patch release. """ 72 | status(f"Updating version from {VERSION}…") 73 | run("bumpversion patch --tag --commit") 74 | 75 | 76 | @task 77 | def version(c): 78 | """ Update version for minor release. """ 79 | status(f"Updating version from {VERSION}…") 80 | run("bumpversion minor --tag --commit") 81 | 82 | 83 | @task 84 | def distclean(c): 85 | """ Cleans up everything. """ 86 | status("Cleaning project…") 87 | clean(c, True, True) 88 | 89 | 90 | @task 91 | def build(c): 92 | """ Builds source and wheel distributions """ 93 | status("Building Source and Wheel (universal) distribution…") 94 | run("{0} setup.py sdist bdist_wheel --universal".format(sys.executable)) 95 | 96 | 97 | @task 98 | def check_dist(c): 99 | """ Uses twine to check distribution. """ 100 | status("Checking dist") 101 | run("twine check dist/*") 102 | 103 | 104 | @task(distclean, build, check_dist) 105 | def release(c): 106 | """ Creates distribution and pushes to PyPi. """ 107 | status("Uploading the package to PyPI via Twine…") 108 | run("twine upload dist/*") 109 | 110 | status("Pushing git tags…") 111 | run("git push --tags") 112 | 113 | 114 | @task(distclean, build, check_dist) 115 | def test_release(c): 116 | """ Push to test PyPi. 117 | Use this command to test the download: 118 | pip install --index-url https://test.pypi.org/simple/ \ 119 | --extra-index-url https://pypi.org/simple your-package 120 | """ 121 | status("Uploading the package to TEST PyPI via Twine…") 122 | run("twine upload --repository-url https://test.pypi.org/legacy/ dist/*") 123 | -------------------------------------------------------------------------------- /tests/data/source.py: -------------------------------------------------------------------------------- 1 | class Square: 2 | def __init__(self, side): 3 | self.side = side 4 | 5 | def area(self): 6 | """Area of this square""" 7 | return self.side * self.side 8 | 9 | 10 | def area(length, width): 11 | """Badly named function returning the area of a rectangle""" 12 | return length * width 13 | 14 | 15 | my_squares = [Square(1, 1), Square(2, 2)] # twice as much square 16 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import markplates 2 | import click.testing 3 | 4 | 5 | def test_no_template(): 6 | """ Specifying a non-existent template in a non-zero exit code. """ 7 | runner = click.testing.CliRunner() 8 | result = runner.invoke(markplates.main, ["NoTemplateAtAll"]) 9 | assert result.exit_code == 1 10 | assert result.exception 11 | 12 | 13 | def test_bad_path_from_main(tmp_path): 14 | # test setpath to non existent directory 15 | template = tmp_path / "bad_path.mdt" 16 | template.write_text('{{ set_path("/does/not/exist") }}') 17 | runner = click.testing.CliRunner() 18 | result = runner.invoke(markplates.main, [str(template)]) 19 | assert result.exit_code == 1 20 | assert result.output.startswith("Unable to import file:") 21 | 22 | 23 | def test_successful_path_in_main(tmp_path): 24 | """ Being anal retentive here to get the last line covered. :) """ 25 | template = tmp_path / "good_path.mdt" 26 | template.write_text("just normal text") 27 | markplates.process_template(template, False) 28 | runner = click.testing.CliRunner() 29 | result = runner.invoke(markplates.main, [str(template)]) 30 | assert result.exit_code == 0 31 | assert result.output == "just normal text\n" 32 | -------------------------------------------------------------------------------- /tests/test_import_file.py: -------------------------------------------------------------------------------- 1 | import markplates 2 | 3 | 4 | def process(tmp_path, lines, language=None, filename=False): 5 | """ Utility to create a fake file and process a template. """ 6 | file_name = "fake_source.py" 7 | source_file = tmp_path / file_name 8 | source_file.write_text("\n".join(lines)) 9 | 10 | # need to special case language. If it's present, we want it in "". If 11 | # it's None, we don't 12 | if language: 13 | language = '"%s"' % language 14 | 15 | template = tmp_path / "t_import.mdt" 16 | template.write_text( 17 | '{{ set_path("%s") }}{{ import_source("%s", ["1-$"], %s, %s) }}' 18 | % (tmp_path, file_name, language, filename) 19 | ) 20 | return markplates.process_template(template, False) 21 | 22 | 23 | def test_import_full(tmp_path, counting_lines): 24 | lines = counting_lines(3) 25 | # expect all lines except the first 26 | expected_result = "".join(lines[1:]).rstrip() 27 | 28 | file_name = "fake_source.py" 29 | source_file = tmp_path / file_name 30 | source_file.write_text("".join(lines)) 31 | template = tmp_path / "t_import.mdt" 32 | template.write_text( 33 | '{{ set_path("%s") }}{{ import_source("%s") }}' % (tmp_path, file_name) 34 | ) 35 | fred = markplates.process_template(template, False) 36 | assert fred == expected_result 37 | 38 | 39 | def test_import_partial(tmp_path, counting_lines): 40 | lines = counting_lines(5) 41 | # expect all lines except the first two 42 | expected_result = "".join(lines[2:]).rstrip() 43 | 44 | file_name = "fake_source.py" 45 | source_file = tmp_path / file_name 46 | source_file.write_text("".join(lines)) 47 | template = tmp_path / "t_import.mdt" 48 | template.write_text( 49 | '{{ set_path("%s") }}{{ import_source("%s", ["3-5"]) }}' 50 | % (tmp_path, file_name) 51 | ) 52 | fred = markplates.process_template(template, False) 53 | assert fred == expected_result 54 | 55 | 56 | def test_reduce_double_lines_after_import(tmp_path): 57 | # for RP articles, we want to remove the double blank lines after import 58 | # or between functions. Ensure that these are removed. 59 | lines = [ 60 | "import markplates", 61 | "", 62 | "", 63 | "def test_import_full(tmp_path, counting_lines):", 64 | " lines = counting_lines(3)", 65 | " # expect all lines except the first with \n after all except the", 66 | " expected_result = " ".join(lines[1:]).rstrip()", 67 | "", 68 | " file_name = 'fake_source.py'", 69 | ] 70 | expected_result = lines.copy() 71 | del expected_result[2] 72 | expected_result = "\n".join(expected_result) 73 | fred = process(tmp_path, lines) 74 | assert fred == expected_result 75 | 76 | # leading blank line failed initial implementation 77 | lines = ["", "", ""] 78 | expected_result = "" 79 | fred = process(tmp_path, lines) 80 | assert fred == expected_result 81 | 82 | # test more than two blanks 83 | lines = ["import markplates", "", "", "", "", "", "", "import markplates"] 84 | expected_result = ["import markplates", "", "import markplates"] 85 | expected_result = "\n".join(expected_result) 86 | fred = process(tmp_path, lines) 87 | assert fred == expected_result 88 | 89 | 90 | def test_left_justify(tmp_path): 91 | # for RP articles, we want to have all code blocks left justified even if 92 | # they are pulled from inside a class def. 93 | lines = ["0", " 1", " 2", " 3"] 94 | expected_result = lines.copy() 95 | expected_result = "\n".join(expected_result) 96 | fred = process(tmp_path, lines) 97 | assert fred == expected_result 98 | 99 | # remove a leading space 100 | lines = [" 1", " 2", " 3"] 101 | expected_result = ["1", " 2", " 3"] 102 | expected_result = "\n".join(expected_result) 103 | fred = process(tmp_path, lines) 104 | assert fred == expected_result 105 | 106 | # remove several leading spaces 107 | lines = [" 1", " 2", " 3"] 108 | expected_result = [" 1", "2", " 3"] 109 | expected_result = "\n".join(expected_result) 110 | fred = process(tmp_path, lines) 111 | assert fred == expected_result 112 | 113 | # left most line does not need to be first 114 | lines = [" 2", " middle", " 3"] 115 | expected_result = [" 2", "middle", " 3"] 116 | expected_result = "\n".join(expected_result) 117 | fred = process(tmp_path, lines) 118 | assert fred == expected_result 119 | 120 | # blank line doesn't count 121 | lines = [" 1", " 2", "", " 3"] 122 | expected_result = ["1", " 2", "", " 3"] 123 | expected_result = "\n".join(expected_result) 124 | fred = process(tmp_path, lines) 125 | assert fred == expected_result 126 | 127 | 128 | def test_add_filename(tmp_path): 129 | lines = ["0", " 1", " 2", " 3"] 130 | expected_result = lines.copy() 131 | expected_result.insert(0, "# fake_source.py") 132 | expected_result = "\n".join(expected_result) 133 | fred = process(tmp_path, lines, None, True) 134 | assert fred == expected_result 135 | 136 | lines = ["0", " 1", " 2", " 3"] 137 | expected_result = lines.copy() 138 | # expected_result.insert(0, "# fake_source.py") 139 | expected_result = "\n".join(expected_result) 140 | fred = process(tmp_path, lines, None, False) 141 | assert fred == expected_result 142 | 143 | 144 | def test_add_language(tmp_path): 145 | lines = ["0", " 1", " 2", " 3"] 146 | langs = { 147 | "c": "cpp", 148 | "c++": "cpp", 149 | "cpp": "cpp", 150 | "python": "python", 151 | "console": "console", 152 | } 153 | 154 | for lang, lang_marker in langs.items(): 155 | expected_result = lines.copy() 156 | expected_result.insert(0, "```%s" % lang_marker) 157 | expected_result.append("```") 158 | expected_result = "\n".join(expected_result) 159 | fred = process(tmp_path, lines, lang, False) 160 | assert fred == expected_result 161 | 162 | 163 | def test_add_filename_and_language(tmp_path): 164 | lines = ["0", " 1", " 2", " 3"] 165 | expected_result = lines.copy() 166 | expected_result.insert(0, "# fake_source.py") 167 | expected_result.insert(0, "```python") 168 | expected_result.append("```") 169 | expected_result = "\n".join(expected_result) 170 | fred = process(tmp_path, lines, "Python", True) 171 | assert fred == expected_result 172 | -------------------------------------------------------------------------------- /tests/test_import_function.py: -------------------------------------------------------------------------------- 1 | import markplates 2 | from pathlib import Path 3 | import pytest 4 | 5 | 6 | def __load_source(): 7 | source_file = Path(__file__).parent.resolve() / "data/source.py" 8 | with open(source_file) as f: 9 | source_content = f.read() 10 | 11 | source = source_content.splitlines(keepends=True) 12 | return source_file, source 13 | 14 | 15 | def test_import_func(tmp_path): 16 | source_file, source = __load_source() 17 | expected = ("".join(source[9:12])).rstrip() 18 | 19 | template = tmp_path / "t_import.mdt" 20 | template.write_text( 21 | '{{ set_path("%s") }}{{ import_function("%s", "area") }}' 22 | % (source_file.parent, source_file.name) 23 | ) 24 | fred = markplates.process_template(template, False) 25 | assert fred == expected 26 | 27 | 28 | def test_import_bad_name(tmp_path): 29 | source_file, source = __load_source() 30 | 31 | # must match simple_source 32 | template = tmp_path / "t_import.mdt" 33 | template.write_text( 34 | '{{ set_path("%s") }}{{ import_function("%s", "not_present") }}' 35 | % (source_file.parent, source_file.name) 36 | ) 37 | with pytest.raises(Exception): 38 | markplates.process_template(template, False) 39 | 40 | 41 | def test_add_filename(tmp_path): 42 | source_file, source = __load_source() 43 | expected = ("".join(source[9:12])).rstrip() 44 | expected = "# source.py\n" + expected 45 | 46 | template = tmp_path / "t_import.mdt" 47 | template.write_text( 48 | '{{ set_path("%s") }}{{ import_function("%s", "area", None, True) }}' 49 | % (source_file.parent, source_file.name) 50 | ) 51 | fred = markplates.process_template(template, False) 52 | assert fred == expected 53 | 54 | 55 | # a more complete test of languages is in import_source tests. It uses the 56 | # same underlying function so we won't retest all of it here. 57 | def test_add_language(tmp_path): 58 | source_file, source = __load_source() 59 | expected = "".join(source[9:12]) 60 | expected = "```python\n" + expected + "```" 61 | 62 | template = tmp_path / "t_import.mdt" 63 | template.write_text( 64 | '{{ set_path("%s") }}{{ import_function("%s", "area", "Python", False) }}' 65 | % (source_file.parent, source_file.name) 66 | ) 67 | fred = markplates.process_template(template, False) 68 | assert fred == expected 69 | -------------------------------------------------------------------------------- /tests/test_import_repl.py: -------------------------------------------------------------------------------- 1 | import markplates 2 | 3 | 4 | def test_syntax_error(tmp_path): 5 | template = tmp_path / "t_import.mdt" 6 | template.write_text('{{ import_repl("""++++++""") }}') 7 | result = markplates.process_template(template, False) 8 | assert "SyntaxError" in result 9 | 10 | 11 | def test_all_spaces(tmp_path): 12 | expected_result = "before\nafter" 13 | template = tmp_path / "t_import.mdt" 14 | with open(template, "w") as temp: 15 | temp.write('before{{ import_repl("""\n') 16 | temp.write("\n") 17 | temp.write("\n") 18 | temp.write("\n") 19 | temp.write('"""\n') 20 | temp.write(") }}after\n") 21 | result = markplates.process_template(template, False) 22 | assert result == expected_result 23 | 24 | 25 | def test_bad_import(tmp_path): 26 | template = tmp_path / "t_import.mdt" 27 | template.write_text('{{ import_repl("""os.path()""") }}') 28 | result = markplates.process_template(template, False) 29 | assert "NameError" in result 30 | 31 | 32 | def test_function_format(tmp_path): 33 | expected_result = ( 34 | """before\n>>> def func():\n... pass\n\n>>> pass\nafter""" 35 | ) 36 | 37 | template = tmp_path / "t_import.mdt" 38 | with open(template, "w") as temp: 39 | temp.write("before\n") 40 | temp.write('{{ import_repl("""\n') 41 | temp.write("def func():\n") 42 | temp.write(" pass\n") 43 | temp.write("\n") 44 | temp.write("pass\n") 45 | temp.write('"""\n') 46 | temp.write(") }}\n") 47 | temp.write("after\n") 48 | result = markplates.process_template(template, False) 49 | assert result == expected_result 50 | -------------------------------------------------------------------------------- /tests/test_paths.py: -------------------------------------------------------------------------------- 1 | import markplates 2 | import pathlib 3 | import pytest 4 | 5 | 6 | def test_bad_import(tmp_path): 7 | # test failing import 8 | template = tmp_path / "bad_import.mdt" 9 | template.write_text('{{ import_source("no_file.py") }}') 10 | with pytest.raises(FileNotFoundError): 11 | markplates.process_template(template, False) 12 | 13 | # test successful import 14 | template = tmp_path / "good_import.mdt" 15 | template.write_text('{{ import_source("%s") }}' % __file__) 16 | markplates.process_template(template, False) 17 | 18 | 19 | def test_bad_path(tmp_path): 20 | # test setpath to non existent directory 21 | template = tmp_path / "bad_path.mdt" 22 | template.write_text('{{ set_path("/does/not/exist") }}') 23 | with pytest.raises(FileNotFoundError): 24 | markplates.process_template(template, False) 25 | 26 | # test with filename 27 | template = tmp_path / "good_path.mdt" 28 | template.write_text('{{ set_path("%s") }}' % __file__) 29 | with pytest.raises(FileNotFoundError): 30 | markplates.process_template(template, False) 31 | 32 | # test with proper path 33 | good_dir = pathlib.Path(__file__).parent 34 | template = tmp_path / "good_path.mdt" 35 | template.write_text('{{ set_path("%s") }}' % good_dir) 36 | markplates.process_template(template, False) 37 | -------------------------------------------------------------------------------- /tests/test_ranges.py: -------------------------------------------------------------------------------- 1 | import markplates 2 | 3 | 4 | def test_counting_range(counting_lines): 5 | # only pull line two 6 | ranges = [2] 7 | lines = counting_lines() 8 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 9 | assert output_lines == ["2\n"] 10 | 11 | # only pull line two as string 12 | ranges = ["2"] 13 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 14 | assert output_lines == ["2\n"] 15 | 16 | # pull even lines 17 | ranges = [2, "4", 6, "8"] 18 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 19 | assert output_lines == ["2\n", "4\n", "6\n", "8\n"] 20 | 21 | # pull 8 to the end 22 | ranges = ["8-$"] 23 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 24 | assert output_lines == ["8\n", "9\n", "10\n", "11\n", "12\n", "13\n"] 25 | 26 | # pull 8 to 10 27 | ranges = ["8-10"] 28 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 29 | assert output_lines == ["8\n", "9\n", "10\n"] 30 | 31 | # last line only 32 | ranges = ["$"] 33 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 34 | assert output_lines == ["13\n"] 35 | 36 | # negative indexing, border condition 37 | ranges = ["$-1"] 38 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 39 | assert output_lines == ["12\n", "13\n"] 40 | 41 | # negative indexing 42 | ranges = ["$-3"] 43 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 44 | assert output_lines == ["10\n", "11\n", "12\n", "13\n"] 45 | 46 | 47 | def test_spacing(counting_lines): 48 | lines = counting_lines() 49 | # only pull line two 50 | ranges = [" 2 "] 51 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 52 | assert output_lines == ["2\n"] 53 | 54 | # pull 8 to the end 55 | ranges = ["8 -\t$ "] 56 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 57 | assert output_lines == ["8\n", "9\n", "10\n", "11\n", "12\n", "13\n"] 58 | 59 | # pull 8 to 10 60 | ranges = ["\t8\t-\t10\t"] 61 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 62 | assert output_lines == ["8\n", "9\n", "10\n"] 63 | 64 | 65 | def test_ordering(counting_lines): 66 | lines = counting_lines() 67 | # test mixed up rants 68 | ranges = ["9-$", "2", "5-6", 3, "5-6", "9-$"] 69 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 70 | assert output_lines == [ 71 | "2\n", 72 | "3\n", 73 | "5\n", 74 | "6\n", 75 | "9\n", 76 | "10\n", 77 | "11\n", 78 | "12\n", 79 | "13\n", 80 | ] 81 | 82 | # test repeated lines 83 | ranges = ["2", "5-6", 2, "5-6", "6"] 84 | output_lines = markplates.condense_ranges(lines, ranges, "filename") 85 | assert output_lines == ["2\n", "5\n", "6\n"] 86 | -------------------------------------------------------------------------------- /tests/test_src.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from markplates.__main__ import find_in_source 4 | 5 | 6 | def test_find_source(): 7 | p = Path(__file__).resolve().parent / "data/source.py" 8 | 9 | code = find_in_source(p, "area") 10 | assert "area of a rectangle" in code 11 | assert code.count("\n") == 3 12 | 13 | code = find_in_source(p, "Square.area") 14 | assert "Area of this square" in code 15 | assert code.count("\n") == 3 16 | 17 | # find_in_source strips comments due to using AST 18 | # code = find_in_source(p, "my_squares") 19 | # assert "twice as much" in code 20 | # assert code.count("\n") == 4 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | python3.9 9 | python3.10 10 | 11 | [testenv] 12 | deps = 13 | asttokens 14 | click 15 | jinja2 16 | markupsafe==2.0.1 17 | pytest 18 | pyperclip 19 | commands = 20 | pytest 21 | --------------------------------------------------------------------------------