├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── code.png └── output.png ├── docs ├── CNAME ├── cli.md ├── gen_ref_nav.py ├── index.md ├── options.md ├── overrides │ └── partials │ │ └── content.html ├── plugin.md └── src │ ├── dimension_demo.py │ └── source.py ├── mkdocs.yml ├── pyproject.toml ├── setup.py └── termage ├── __init__.py ├── __main__.py ├── execution.py └── mkdocs_plugin.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Platform info** 27 | Browser: 28 | 29 | PTG: 30 | ``` 31 | Insert the output of `ptg -v` here. 32 | ``` 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[REQUEST]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.6.1] - 2022-08-23 2 | 3 | ### Bugfixes 4 | 5 | - Fix `highlight` option not having an effect in the plugin 6 | 7 | 8 | 9 | ## [0.6.0] - 2022-08-22 10 | 11 | ### Additions 12 | 13 | - Add support for inserting SVGs inline, without writing files 14 | 15 | ### Refactors 16 | 17 | - Unify the codeblock-formatting API under `execution.format_codeblock` 18 | - Rewrite & refactor most of the plugin to be more maintainable 19 | - Refactor the Python API so that the `termage` function is called by the CLI, not the other way around. 20 | 21 | 22 | ## [0.5.0] - 2022-08-16 23 | 24 | ### Additions 25 | 26 | - Support inserting unhandled plugin arguments into formatted markdown 27 | - Add `--run` argument 28 | 29 | 30 | ## [0.4.0] - 2022-08-13 31 | 32 | ### Additions 33 | 34 | - Inject `termage` mock-module into execution namespace to allow manipulating the 35 | terminal instance. 36 | 37 | ### Bugfixes 38 | 39 | - Fix the target width used for line-breaking not updating per `_write` call 40 | - Fix `title` parameter defaulting (and showing) `None` 41 | 42 | 43 | ## [0.3.2] - 2022-08-11 44 | 45 | ### Bugfixes 46 | 47 | - Fix various issues caused by migration to Hatch that broke the MkDocs plugin. 48 | 49 | 50 | ## [0.3.1] - 2022-08-11 51 | 52 | ### Bugfixes 53 | 54 | - Fix PyPi README being incorrect 55 | 56 | 57 | ## [0.3.0] - 2022-08-11 58 | 59 | ### Additions 60 | 61 | - Support using global MkDocs configuration 62 | - Add `chrome` CLI switch 63 | - Export all `execution` functions, as well as a wrapper for the CLI, `termage` 64 | 65 | ### Refactors 66 | 67 | - Move to `Hatch` build system 68 | 69 | 70 | ## [0.2.0] - 2022-05-26 71 | 72 | ### Refactors 73 | 74 | - Rewrite the entire program to provide a standalone module, with CLI and MkDocs plugin bindings. 75 | 76 | 77 | ## [0.1.0] - 2022-05-25 78 | 79 | - Initial version 80 | 81 | 82 | 83 | 84 | [0.6.1]: https://github.com/bczsalba/termage/compare/0.6.0...0.6.1 85 | [0.6.0]: https://github.com/bczsalba/termage/compare/0.5.0...0.6.0 86 | [0.5.0]: https://github.com/bczsalba/termage/compare/0.4.0...0.5.0 87 | [0.4.0]: https://github.com/bczsalba/termage/compare/0.3.2...0.4.0 88 | [0.3.2]: https://github.com/bczsalba/termage/compare/0.3.1...0.3.2 89 | [0.3.1]: https://github.com/bczsalba/termage/compare/0.3.0...0.3.1 90 | [0.3.0]: https://github.com/bczsalba/termage/compare/0.2.0...0.3.0 91 | [0.2.0]: https://github.com/bczsalba/termage/compare/0.1.0...0.2.0 92 | [0.1.0]: https://github.com/bczsalba/termage/tree/v0.1.0 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 bczsalba 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 | # Termage 2 | 3 | See the [docs](https://termage.bczsalba.com) for some live examples. 4 | 5 | `Termage` allows you to generate up-to-date, reproducible and _real_ screenshots of Python output while building your documentation. It uses [PyTermGUI](https://github.com/bczsalba/pytermgui) to create the SVGs, and pre-processes your markdown file into a `codefences` format. 6 | 7 | ![Code](https://raw.githubusercontent.com/bczsalba/mkdocs-termage-plugin/master/assets/code.png) 8 | ![Output](https://raw.githubusercontent.com/bczsalba/mkdocs-termage-plugin/master/assets/output.png) 9 | 10 | 11 | ## Installation 12 | 13 | `Termage` is best installed using `pip`: 14 | 15 | ``` 16 | $ pip install mkdocs-termage-plugin 17 | ``` 18 | 19 | This installs the plugin, as well as PyTermGUI as a dependency. By this point you probably _should_ already have mkdocs installed. 20 | 21 | 22 | ## Setup 23 | 24 | To use the plugin, you should first add it to your `mkdocs.yml` plugin list: 25 | 26 | ```yaml 27 | plugins: 28 | - termage 29 | ``` 30 | 31 | 32 | -------------------------------------------------------------------------------- /assets/code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bczsalba/Termage/110769ab55796ae9dc1668c90554b2705685661d/assets/code.png -------------------------------------------------------------------------------- /assets/output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bczsalba/Termage/110769ab55796ae9dc1668c90554b2705685661d/assets/output.png -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | termage.bczsalba.com -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | The Termage CLI offers a quick and customizable way to export SVGs from your terminal. 2 | 3 | ## Usage 4 | 5 | You simply call `termage` with some code, and provide [options](options.md) to customize the output. By default, the output file will be printed to STDOUT, but you can export directly to a file using the `-o` flag. 6 | 7 | `Termage` also accepts having code piped to it; this sets the `code` argument to `-`, which will cause it to read from STDIN. 8 | 9 | ## Showcase 10 | 11 | ```python3 title="source.py" 12 | 13 | --8<-- 14 | docs/src/source.py 15 | --8<-- 16 | 17 | ``` 18 | 19 | ### Syntax highlighting 20 | 21 | === "Code output" 22 | ``` 23 | termage docs/source.py --title="Welcome to the Termage CLI!" 24 | ``` 25 | 26 | 27 | ```termage-svg include=docs/src/source.py title=Welcome\ to\ the\ Termage\ CLI! height=12 28 | ``` 29 | 30 | === "Code highlighting" 31 | ``` 32 | termage source.py --title="Welcome to the Termage CLI!" --highlight 33 | ``` 34 | 35 | 36 | ```termage-svg include=docs/src/source.py title=Welcome\ to\ the\ Termage\ CLI! highlight=1 height=10 37 | ``` 38 | 39 | 40 | -------------------------------------------------------------------------------- /docs/gen_ref_nav.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages and navigation.""" 2 | 3 | import re 4 | from pathlib import Path 5 | 6 | import mkdocs_gen_files 7 | 8 | nav = mkdocs_gen_files.Nav() 9 | 10 | EXCLUDE = [ 11 | re.compile(pat) 12 | for pat in [ 13 | # "ansi_interface", 14 | "style_maps", 15 | "color_info", 16 | ] 17 | ] 18 | 19 | 20 | for path in sorted(Path("termage").rglob("*.py")): 21 | module_path = path.relative_to(".").with_suffix("") 22 | 23 | if any(pat.match(path.parts[-1]) is not None for pat in EXCLUDE): 24 | continue 25 | 26 | doc_path = path.relative_to(".").with_suffix(".md") 27 | full_doc_path = Path("reference", doc_path) 28 | 29 | parts = tuple(module_path.parts) 30 | 31 | if parts[-1] == "__init__": 32 | parts = parts[:-1] 33 | doc_path = doc_path.with_name("index.md") 34 | full_doc_path = full_doc_path.with_name("index.md") 35 | elif parts[-1] == "__main__": 36 | continue 37 | 38 | nav[parts] = doc_path.as_posix() 39 | 40 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 41 | ident = ".".join(parts) 42 | fd.write(f"::: {ident}") 43 | 44 | mkdocs_gen_files.set_edit_path(full_doc_path, path) 45 | 46 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: 47 | content = list(nav.build_literate_nav()) 48 | nav_file.writelines(content) 49 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | `Termage` is a wrapper library for [PyTermGUI](https://github.com/bczsalba/pytermgui)'s SVG export functionalities. Other than providing the module, it also offers a [CLI](cli.md) and an [MkDocs plugin](plugin.md) to put SVGs just about anywhere you can think of. 2 | 3 | !!! success "" 4 | Termage has native support for capturing applications based on `PyTermGUI`'s `WindowManager`! 5 | 6 | 7 | ```termage title=Hey\ there! 8 | from pytermgui import tim, ColorPicker 9 | from pytermgui.pretty import print 10 | 11 | tim.print("Welcome to [!gradient(112) bold]Termage[/]!\n") 12 | tim.print("Termage allows you to display [italic]any[/italic] terminal output in a terminal-mimicking [bold]SVG[/bold]!") 13 | 14 | tim.print("\nHere are the current locals:") 15 | print(locals()) 16 | ``` 17 | 18 | 19 | ## Installation 20 | 21 | `Termage` is best installed using `pip`: 22 | 23 | ``` 24 | $ pip install termage 25 | ``` 26 | 27 | This will install PyTermGUI, as well as Termage. The MkDocs plugin is included within the installation as well. 28 | 29 | ```termage include=source_file.py title=My\ SVG width=84 height=15 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/options.md: -------------------------------------------------------------------------------- 1 | ## MkDocs plugin config options 2 | 3 | Other than the ones listed below, the MkDocs plugin exposes a couple of configuration options. 4 | 5 | ### `write_files` 6 | 7 | Write files during generation, instead of inserting their contents directly into the HTML. 8 | 9 | !!! warning "" 10 | 11 | This setting, when used during `mkdocs serve`, can and likely will cause infinite reload-loops at the first file change. This is due to `assets/` (the default path) being watched by MkDocs' livereload implementation, so every time the docs regenerate, we generate SVGs which then triggers another reload. 12 | 13 | The only way I've found around this issue was by allowing inline SVG insertions, though it shoulnd't be a problem if you don't use `serve` or run it with `--no-livereload`. 14 | 15 | **Default**: `False` 16 | 17 | ### `inline_styles` 18 | 19 | 20 | Controls the PyTermGUI SVG export option of the same name. When set, element styles will be applied as `style=` attributes, instead of as classes defined earlier in the export. 21 | 22 | **Default**: `True` 23 | 24 | ### `path` 25 | 26 | Sets the path that output files will be written to. This path must be relative to `docs/`, NOT to the root of the repository. 27 | 28 | **Requires**: `#!py3 write_files == True` 29 | 30 | **Default**: `assets/` 31 | 32 | ### `name_template` 33 | 34 | 35 | Controls the template string used to generate filenames. Templated variables available are: 36 | 37 | - `count`: The generation-index of the given SVG 38 | - `title`: The title passed as an option of the SVG. 39 | 40 | !!! warning 41 | Since `title` may be empty, you should always include `count` in your template to avoid filename overlaps (and lost files). 42 | 43 | 44 | **Requires**: `#!py3 write_files == True` 45 | 46 | **Default**: `termage_{count}.svg` 47 | 48 | 49 | ## General (Python & MkDocs) options 50 | 51 | Regardless of your entrypoint, the options available are going to be the same. 52 | 53 | 54 | ### Include 55 | 56 | Includes a file within a codeblock. The file path must originate from the same directory as `mkdocs.yml`. 57 | 58 | For example, let's say we have the following structure: 59 | 60 | ``` 61 | mkdocs.yml 62 | docs/ 63 | index.md 64 | src/ 65 | intro01.py 66 | intro02.py 67 | ``` 68 | 69 | To include `intro01.py` into a Termage block within `index.md`, you could use: 70 | 71 | ```` 72 | \```termage include=docs/src/intro01.py 73 | print("Code from the original codeblock is retained!") 74 | ``` 75 | ```` 76 | 77 | !!! info 78 | The `include` option will always "prefix" the actual codeblock's value with whatever is included. 79 | 80 | As such, if `docs/src/intro01.py` had the content: 81 | 82 | ```python3 83 | print("Included text will always preface the real value of a codeblock") 84 | ``` 85 | 86 | Termage will parse the block as: 87 | 88 | ````markdown 89 | \```termage 90 | print("Included text will always preface the real value of a codeblock") 91 | print("Code from the original codeblock is retained!") 92 | ``` 93 | ```` 94 | 95 | 96 | ### Hide lines 97 | 98 | The plugin has a special bit of syntax to signify `Run this line of code, but don't display it`. It is denoted by prefacing any hidden line with an ampersand (&): 99 | 100 | !!! note "" 101 | 102 | ```` title="Source" 103 | \```termage title=Hidden\ lines 104 | &from pytermgui.pretty import print 105 | 106 | print(locals()) 107 | ``` 108 | ```` 109 | 110 | ```termage title=Hidden\ lines 111 | &from pytermgui.pretty import print 112 | 113 | print(locals()) 114 | ``` 115 | 116 | 117 | 118 | ### Width and Height 119 | 120 | Sets the terminal's dimension of the given axis. Must be given an integer, which will be taken as a character-count. 121 | 122 | === "`width=50` `height=10`" 123 | 124 | ```termage-svg include=docs/src/dimension_demo.py width=50 height=10 125 | ``` 126 | 127 | === "`width=100` `height=20`" 128 | 129 | ```termage-svg include=docs/src/dimension_demo.py width=100 height=20 130 | ``` 131 | 132 | !!! info 133 | If no dimensions are provided, they default to (80, 24). 134 | 135 | 136 | 137 | ### Foreground & Background 138 | 139 | Modifies the terminal's default colors. `foreground` is used for all non-styled text, and background is used as both the background to the terminal's contents as well as the window that it emulates. 140 | 141 | === "Default" 142 | 143 | ```termage-svg 144 | print("Hello") 145 | ``` 146 | 147 | === "`foreground=green` `background=#DDDDDD`" 148 | 149 | ```termage-svg foreground=green background=#DDDDDD 150 | print("Hello") 151 | ``` 152 | 153 | !!! info 154 | Foreground defaults to __#DDDDDD__, and background defaults to __#212121__. 155 | 156 | 157 | ### Tabs 158 | 159 | Sets the text labels of each of the tabs. 160 | 161 | Accepts two values, delimited by a single `,`. The first value is used for the Python code, and the second is used for the SVG output. 162 | 163 | === "Default" 164 | 165 | ```termage include=docs/src/dimension_demo.py 166 | ``` 167 | 168 | === "`tabs=Code,SVG`" 169 | 170 | ```termage include=docs/src/dimension_demo.py tabs=Code,SVG 171 | ``` 172 | 173 | ### Title 174 | 175 | Sets the title at the top of the output terminal. 176 | 177 | !!! warning 178 | When using the plugin, make sure to escape any spaces present in your title! 179 | 180 | For example, instead of `title=My title`, or `title="My title"` use `title=My\ Title`. 181 | 182 | === "Default" 183 | 184 | ```termage-svg include=docs/src/dimension_demo.py 185 | ``` 186 | 187 | === "`title=My\ fancy\ title`" 188 | 189 | ```termage-svg title=My\ fancy\ title include=docs/src/dimension_demo.py 190 | ``` 191 | -------------------------------------------------------------------------------- /docs/overrides/partials/content.html: -------------------------------------------------------------------------------- 1 | 2 | {% if page.edit_url %} 3 | {% set edit = "https://github.com/bczsalba/mkdocs-termage-plugin/edit" %} 4 | {% set view = "https://raw.githubusercontent.com/bczsalba/mkdocs-termage-plugin" %} 5 | 10 | {% include ".icons/material/file-edit-outline.svg" %} 11 | 12 | 13 | 14 | 19 | {% include ".icons/material/file-eye-outline.svg" %} 20 | 21 | {% endif %} 22 | 23 | 24 | {% if "tags" in config.plugins %} 25 | {% include "partials/tags.html" %} 26 | {% endif %} 27 | 28 | 33 | {% if not "\x3ch1" in page.content %} 34 |

{{ page.title | d(config.site_name, true)}}

35 | {% endif %} 36 | 37 | 38 | {{ page.content }} 39 | 40 | 41 | {% if page and page.meta and ( 42 | page.meta.git_revision_date_localized or 43 | page.meta.revision_date 44 | ) %} 45 | {% include "partials/source-file.html" %} 46 | {% endif %} 47 | -------------------------------------------------------------------------------- /docs/plugin.md: -------------------------------------------------------------------------------- 1 | The `termage` MkDocs plugin lets you generate your documentation's SVGs every time you build it. 2 | 3 | This has some advantages: 4 | 5 | - Your screenshots will always remain up to date 6 | 7 | !!! note "" 8 | I know from personal experience how draining it can be to go through all images in your documentaition, see if they need to be replaced, re-create the original screenshot with the right settings & size and upload it to your site. `Termage` simplifies this by basically doing all of that for you, _every time_ your docs need to be updated. 9 | 10 | - You are always going to test some parts of your function. 11 | 12 | !!! note "" 13 | Some errors, even in well-traversed codepaths may be very hard to catch. By putting your raw output _straight into_ your documentation, you ensure that as many people see it as possible. This hugely increases the chance of finding issues that no one would have reported for weeks. 14 | 15 | - You have a good opportunity to write some visual example code! 16 | 17 | !!! note "" 18 | The easiest way to work with the plugin is to keep a set of source code files, and use the `include` option to display them on the page. By doing so, you offer some really good "getting started" material to newcomers to your project, and _ensure_ that it is fully functional! 19 | 20 | 21 | ## Set up 22 | 23 | In order to use the plugin, you need to enable the following built-in markdown extensions: 24 | 25 | ```yaml title="mkdocs.yaml" 26 | markdown_extensions: 27 | - attr_list 28 | - pymdownx.superfences 29 | - pymdownx.tabbed: 30 | alternate_style: true 31 | ``` 32 | 33 | Additionally, you must also activate the plugin: 34 | 35 | ``` yaml title="mkdocs.yaml" 36 | plugins: 37 | - termage: 38 | # Default config options 39 | write_files: False 40 | inline_styles: True 41 | name_template: "termage_{count}.svg" 42 | path: "assets" 43 | background: "#212121" 44 | foreground: "#dddddd" 45 | tabs: ["Python", "Output"] 46 | chrome: True 47 | width: 0 48 | height: 4 49 | ``` 50 | 51 | !!! warning 52 | Make sure to put this plugin in front of any other markdown pre-processors. This helps cut down on unintended, and hard to debug behaviour. 53 | 54 | 55 | ## Usage 56 | 57 | The plugin will look for the syntax: 58 | 59 | ```` 60 | \```termage(-svg) option=value option=value... 61 | {code} 62 | ``` 63 | ```` 64 | 65 | 66 | All possible options can be found on their [page](options.md). 67 | 68 | There are 2 directives possible: 69 | 70 | - `termage `: 71 | Generates a tabbed layout, with one tab for the Python source, and the other for the SVG output. 72 | 73 | ??? note "Example" 74 | ```` 75 | \```termage title=Tabbed\ layout include=docs/src/source.py 76 | ``` 77 | ```` 78 | 79 | ```termage title=Tabbed\ layout include=docs/src/source.py 80 | ``` 81 | 82 | - `termage-svg `: 83 | Generates only the output SVG, without the tabbed layout. 84 | 85 | ??? note "Example" 86 | ```` 87 | \```termage-svg title=Tabbed\ layout include=docs/src/source.py 88 | ``` 89 | ```` 90 | 91 | ```termage-svg title=Tabbed\ layout include=docs/src/source.py 92 | ``` 93 | -------------------------------------------------------------------------------- /docs/src/dimension_demo.py: -------------------------------------------------------------------------------- 1 | import pytermgui as ptg 2 | 3 | 4 | with ptg.WindowManager() as manager: 5 | manager.layout.add_slot() 6 | manager.add(ptg.Window("Some window", box="EMPTY")) 7 | -------------------------------------------------------------------------------- /docs/src/source.py: -------------------------------------------------------------------------------- 1 | import pytermgui as ptg 2 | 3 | inspector = ptg.inspect(ptg.inspect) 4 | print(inspector) 5 | termage.fit(inspector) 6 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Termage::docs 2 | 3 | theme: 4 | name: material 5 | 6 | custom_dir: docs/overrides 7 | 8 | font: 9 | text: Open Sans 10 | 11 | features: 12 | - navigation.tabs 13 | - content.code.annotate 14 | 15 | palette: 16 | - media: "(prefers-color-scheme: light)" 17 | scheme: default 18 | primary: light-green 19 | accent: light-blue 20 | toggle: 21 | icon: material/lightbulb 22 | name: Switch to light mode 23 | 24 | - media: "(prefers-color-scheme: dark)" 25 | scheme: slate 26 | primary: lime 27 | accent: blue 28 | toggle: 29 | icon: material/lightbulb-outline 30 | name: Switch to dark mode 31 | 32 | plugins: 33 | - search 34 | - gen-files: 35 | scripts: 36 | - docs/gen_ref_nav.py 37 | 38 | - literate-nav: 39 | nav_file: SUMMARY.md 40 | 41 | - mkdocstrings: 42 | handlers: 43 | python: 44 | paths: [termage] 45 | options: 46 | docstring_style: google 47 | docstring_options: 48 | ignore_init_summary: yes 49 | merge_init_into_class: yes 50 | show_submodules: no 51 | 52 | - termage: 53 | # MkDocs Material's theme colors 54 | background: "#21222C" 55 | foreground: "#D5D7E2" 56 | 57 | repo_url: https://github.com/bczsalba/termage 58 | repo_name: bczsalba/termage 59 | 60 | markdown_extensions: 61 | - admonition 62 | - attr_list 63 | - pymdownx.superfences 64 | - pymdownx.details 65 | - pymdownx.snippets 66 | - pymdownx.inlinehilite 67 | - pymdownx.tabbed: 68 | alternate_style: true 69 | 70 | nav: 71 | - Termage: 72 | - Introduction: index.md 73 | - Options: options.md 74 | - In the command line: cli.md 75 | - As an MkDocs plugin: plugin.md 76 | 77 | - Reference: reference/ 78 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-fancy-pypi-readme"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "Termage" 7 | authors = [ { name = "Balázs Cene", email= "bczsalba@gmail.com" } ] 8 | description = """Generate SVGs from any Python code, even in your documentation.""" 9 | 10 | license = {text = "MIT"} 11 | 12 | requires-python = ">=3.8" 13 | dependencies = ["pytermgui"] 14 | 15 | keywords = [ 16 | "terminal", 17 | "documentation", 18 | "mkdocs", 19 | "mkdocs-plugin", 20 | "mkdocs-material", 21 | "PyTermGUI", 22 | "svg-images", 23 | ] 24 | 25 | classifiers = [ 26 | "Development Status :: 4 - Beta", 27 | "Environment :: Console", 28 | "Intended Audience :: Developers", 29 | "Programming Language :: Python", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Topic :: Documentation", 36 | "Topic :: Software Development", 37 | "Topic :: Software Development :: Documentation", 38 | "Topic :: Utilities", 39 | "Topic :: Terminals", 40 | "Typing :: Typed", 41 | "License :: OSI Approved :: MIT License", 42 | ] 43 | 44 | dynamic = ["readme", "version"] 45 | 46 | [project.urls] 47 | homepage = "https://github.com/bczsalba/Termage" 48 | repository = "https://github.com/bczsalba/Termage" 49 | documentation = "https://termage.bczsalba.com" 50 | 51 | [project.scripts] 52 | termage = "termage.__main__:main" 53 | 54 | [project.entry-points."mkdocs.plugins"] 55 | termage = "termage.mkdocs_plugin:TermagePlugin" 56 | 57 | [tool.hatch.version] 58 | path = "termage/__init__.py" 59 | 60 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 61 | content-type = "text/markdown" 62 | 63 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 64 | path = "README.md" 65 | end-before = "\n" 66 | 67 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 68 | text = """ 69 | 70 | ## Latest release 71 | 72 | #""" 73 | 74 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 75 | path = "CHANGELOG.md" 76 | end-before = "\n" 77 | 78 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 79 | text = "\n\nRead the full changelog [here](https://github.com/bczsalba/Termage/blob/master/CHANGELOG.md).\n\n" 80 | 81 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 82 | path = "CHANGELOG.md" 83 | start-after = "\n" 84 | 85 | [tool.mypy] 86 | show_error_codes = true 87 | 88 | [tool.pylint.messages_control] 89 | disable = [ 90 | "fixme", 91 | # If this is a problem, it should occur during runtime 92 | "not-callable" 93 | ] 94 | 95 | [tool.pylint.basic] 96 | good-names = ["i", "j", "k", "ex", "Run", "_", "x" ,"y", "fd"] 97 | 98 | [tool.coverage.report] 99 | exclude_lines = [ 100 | "pragma: no cover", 101 | "if TYPE_CHECKING:", 102 | "def __fancy_repr__", 103 | "def __repr__", 104 | ] 105 | 106 | omit = [ 107 | "pytermgui/cmd.py" 108 | ] 109 | 110 | [tool.isort] 111 | profile = "black" 112 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Project setup. 2 | 3 | Most of the metadata comes from `pyproject.toml`. 4 | 5 | This file is for: 6 | 7 | - Adding in the remaining, non-supported bits of data 8 | - Support of `pip install -e` 9 | - Support for GitHub's dependency indexing 10 | """ 11 | 12 | from setuptools import setup 13 | 14 | # These fields aren't supported properly by setuptools' pyproject.toml 15 | # reading, so we'll add it manually. 16 | # 17 | # `name` is needed for GitHub's dependency tracking to function properly. 18 | setup(name="termage", author="Balázs Cene", url="https://github.com/bczsalba/Termage") 19 | -------------------------------------------------------------------------------- /termage/__init__.py: -------------------------------------------------------------------------------- 1 | """Generate SVGs from any Python code, even in your documentation.""" 2 | 3 | from __future__ import annotations 4 | 5 | from . import mkdocs_plugin 6 | from .__main__ import main 7 | from .execution import execute, patched_stdout_recorder, set_colors, termage 8 | 9 | __version__ = "0.6.1" 10 | -------------------------------------------------------------------------------- /termage/__main__.py: -------------------------------------------------------------------------------- 1 | """Runs Termage from the CLI. 2 | 3 | See `termage -h` for more info. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import sys 9 | from argparse import ArgumentParser, Namespace 10 | from pathlib import Path 11 | 12 | from pytermgui import highlight_python, tim 13 | 14 | from .execution import execute, format_codeblock, termage 15 | 16 | 17 | def _process_args(argv: list[str] | None) -> Namespace: 18 | """Processes CLI args.""" 19 | 20 | parser = ArgumentParser() 21 | 22 | parser.add_argument( 23 | "code", 24 | help="Code to execute. Uses STDIN when not given or set to '-'.", 25 | nargs="?", 26 | ) 27 | 28 | parser.add_argument( 29 | "-f", 30 | "--file", 31 | help="Includes code from a path.", 32 | type=Path, 33 | ) 34 | 35 | parser.add_argument( 36 | "-o", 37 | "--out", 38 | help=( 39 | "The file to save into." 40 | + " SVG content will be written to STDOUT if this is not given." 41 | ), 42 | metavar="FILE", 43 | ) 44 | 45 | parser.add_argument( 46 | "-m", 47 | "--module", 48 | help="Executes a module, using runpy.", 49 | ) 50 | 51 | parser.add_argument( 52 | "--width", 53 | type=int, 54 | help="Sets the width, in characters.", 55 | ) 56 | parser.add_argument( 57 | "--height", 58 | type=int, 59 | help="Sets the height, in characters.", 60 | ) 61 | 62 | parser.add_argument( 63 | "--title", 64 | type=str, 65 | help="Sets the title displayed at the top of the window.", 66 | ) 67 | 68 | parser.add_argument( 69 | "--chrome", 70 | choices=["show", "hide"], 71 | default="show", 72 | help="Highlights the given code, instead of running it.", 73 | ) 74 | 75 | parser.add_argument( 76 | "--highlight-only", 77 | action="store_true", 78 | help="Highlights the given code, instead of running it.", 79 | ) 80 | 81 | parser.add_argument( 82 | "--run", 83 | metavar="FILE", 84 | type=Path, 85 | help="Emulates running a file through the MkDocs plugin.", 86 | ) 87 | 88 | parser.add_argument("--fg", help="Sets the foreground color.", metavar="COLOR") 89 | parser.add_argument("--bg", help="Sets the background color.", metavar="COLOR") 90 | 91 | return parser.parse_args(argv) 92 | 93 | 94 | def main(argv: list[str] | None = None) -> None: 95 | """Executes the project.""" 96 | 97 | args = _process_args(argv) 98 | 99 | if args.run: 100 | with open(args.run, "r", encoding="utf-8") as runfile: 101 | # TODO: This should be done in a central location 102 | _, code_exec = format_codeblock(runfile.read()) 103 | 104 | tim.print(highlight_python(code_exec)) 105 | print() 106 | 107 | execute(code=code_exec) 108 | 109 | return 110 | 111 | if args.code is None and not sys.stdin.isatty(): 112 | args.code = sys.stdin.read() 113 | 114 | args.code = args.code or "" 115 | args.title = args.title or "" 116 | 117 | export = termage( 118 | code=args.code, 119 | include=args.file, 120 | width=args.width, 121 | height=args.height, 122 | title=args.title, 123 | chrome=args.chrome.lower() == "show", 124 | foreground=args.fg, 125 | background=args.bg, 126 | save_as=args.out, 127 | highlight_only=args.highlight_only, 128 | ) 129 | 130 | if args.out is None: 131 | print(export) 132 | 133 | 134 | if __name__ == "__main__": 135 | main() 136 | -------------------------------------------------------------------------------- /termage/execution.py: -------------------------------------------------------------------------------- 1 | """All the code used to record code execution.""" 2 | 3 | # pylint: disable=exec-used 4 | 5 | from __future__ import annotations 6 | 7 | import builtins 8 | import sys 9 | from contextlib import contextmanager 10 | from io import StringIO 11 | from pathlib import Path 12 | from typing import Any, Generator 13 | 14 | import pytermgui as ptg 15 | 16 | DEFAULT_WIDTH = 80 17 | DEFAULT_HEIGHT = 24 18 | 19 | __all__ = [ 20 | "execute", 21 | "EXEC_GLOBALS", 22 | "format_codeblock", 23 | "patched_stdout_recorder", 24 | "set_colors", 25 | "termage", 26 | ] 27 | 28 | 29 | def format_codeblock(block: str) -> tuple[str, str]: 30 | """Formats a codeblock into display and executed lines.""" 31 | 32 | disp_lines, exec_lines = [], [] 33 | 34 | lines = block.splitlines() 35 | indent = " " * (len(lines[0]) - len(lines[0].lstrip())) 36 | 37 | for line in lines: 38 | line = line.replace(indent, "", 1) 39 | if line.startswith("&"): 40 | exec_lines.append(line[1:]) 41 | continue 42 | 43 | exec_lines.append(line) 44 | disp_lines.append(line) 45 | 46 | return "\n".join(disp_lines), "\n".join(exec_lines) 47 | 48 | 49 | class TermageNamespace: 50 | """A simple namespace for exec globals, exposed as `termage`. 51 | 52 | You can use all below methods by referencing the `termage` object 53 | in termage-run code, which you don't have to import. 54 | 55 | You _usually_ want to hide these lines, as they are generally for 56 | styling purposes. 57 | """ 58 | 59 | @property 60 | def terminal(self) -> ptg.Terminal: 61 | """Returns the current terminal object.""" 62 | 63 | return ptg.get_terminal() 64 | 65 | def fit(self, widget: ptg.Widget) -> None: 66 | """Fits the output terminal around the given widget.""" 67 | 68 | self.terminal.size = widget.width, widget.height 69 | 70 | def resize(self, width: int, height: int) -> None: 71 | """Resizes the output terminal to the given dimensions.""" 72 | 73 | self.terminal.size = width, height 74 | 75 | 76 | EXEC_GLOBALS: dict[str, Any] = { 77 | "__name__": "__main__", 78 | "__doc__": None, 79 | "__package__": None, 80 | "__annotations__": {}, 81 | "__builtins__": builtins, 82 | "termage": TermageNamespace(), 83 | } 84 | 85 | 86 | @contextmanager 87 | def patched_stdout_recorder( 88 | width: int | None, height: int | None 89 | ) -> Generator[ptg.Recorder, None, None]: 90 | """Records everything written to stdout, even built is print. 91 | 92 | It does so by monkeypathing `sys.stdout.write` to a custom function, 93 | which first writes to a custom `Terminal`. 94 | 95 | Args: 96 | width: The width of the terminal used for the recording. 97 | height: The height of the terminal used for the recording. 98 | 99 | Returns: 100 | The recorder object, with all the data written to it. 101 | """ 102 | 103 | if width is None: 104 | width = DEFAULT_WIDTH 105 | 106 | if height is None: 107 | height = DEFAULT_HEIGHT 108 | 109 | stdout_write = sys.stdout.write 110 | 111 | stream = StringIO() 112 | terminal = ptg.Terminal(stream=stream, size=(width, height)) 113 | 114 | ptg.set_global_terminal(terminal) 115 | 116 | def _write(item, **kwargs) -> None: 117 | """Writes something, breaks lines.""" 118 | 119 | ends_with_linebreak = item.endswith("\n") 120 | 121 | lines = list(ptg.break_line(item, terminal.width)) 122 | 123 | for i, line in enumerate(lines): 124 | if ends_with_linebreak or i < len(lines) - 1: 125 | line += "\n" 126 | 127 | terminal.write(line, **kwargs) 128 | 129 | with terminal.record() as recorder: 130 | try: 131 | sys.stdout.write = _write 132 | yield recorder 133 | 134 | finally: 135 | sys.stdout.write = stdout_write # type: ignore 136 | 137 | 138 | def execute( 139 | code: str | None = None, 140 | file: Path | None = None, 141 | highlight: bool = False, 142 | *, 143 | exec_globals: dict[str, Any] = EXEC_GLOBALS, 144 | ) -> None: 145 | """Executes the given code under a custom context. 146 | 147 | Args: 148 | code: The Python code to execute. 149 | file: A file that will be opened, and its contents will 150 | be added to the executed code *before* the `code` argument. 151 | highlight: If set, the combined code will only be highlighted using 152 | PyTermGUI's `highlight_python` function, with that result being 153 | written to the SVG. Great for quick code screenshots! 154 | exec_globals: The dictionary that will be passed to `exec`, which 155 | makes it the global namespace of the context. 156 | """ 157 | 158 | ptg.WindowManager.autorun = False 159 | 160 | exec_globals = exec_globals.copy() 161 | code = code or "" 162 | 163 | # if module is not None: 164 | # mod_name, *args = module.split() 165 | # sys.argv = [*args] 166 | # out = runpy.run_module(mod_name, init_globals={"sys": sys}) 167 | # print(out) 168 | 169 | if file is not None: 170 | with open(file, "r", encoding="utf-8") as source: 171 | code = source.read() + code 172 | 173 | if highlight: 174 | print(ptg.tim.parse(ptg.highlight_python(code))) 175 | return exec_globals 176 | 177 | exec(code, exec_globals) 178 | sys.argv = old_argv 179 | 180 | if "manager" in exec_globals: 181 | exec_globals["manager"].compositor.draw() 182 | 183 | return exec_globals 184 | 185 | 186 | def set_colors(foreground: str | None, background: str | None) -> None: 187 | """Sets the colors that will be used by the terminal.""" 188 | 189 | if foreground is not None: 190 | ptg.Color.default_foreground = ptg.str_to_color(foreground) 191 | 192 | if background is not None: 193 | ptg.Color.default_background = ptg.str_to_color(background) 194 | 195 | 196 | def termage( # pylint: disable=too-many-arguments 197 | code: str = "", 198 | include: str | Path | None = None, 199 | width: int | None = None, 200 | height: int | None = None, 201 | title: str = "", 202 | chrome: bool = True, 203 | foreground: str | None = None, 204 | background: str | None = None, 205 | highlight_only: bool = False, 206 | save_as: str | Path | None = None, 207 | ) -> str: 208 | """A generalized wrapper for Termage functionality. 209 | 210 | Args: 211 | code: The code that will be run to generate the file. 212 | include: A path to a Python file that will be included before `code`. 213 | width: The output terminal's width. 214 | height: The output terminal's height. 215 | title: The output terminal's window title. Has no effect when `chrome` 216 | is `False`. 217 | chrome: Shows or hides the window decorations. 218 | foreground: Sets the default foreground (text) style of the output. Only 219 | applies to unstyled text. 220 | background: Sets the output terminal's background color. 221 | highlight_only: If set, the given code is not run, rather given to the 222 | `ptg.highlight_python` function. 223 | save_as: If set, the export will be written to this filepath. The export 224 | will be returned regardless of this setting. 225 | 226 | Returns: 227 | The exported SVG file. 228 | """ 229 | 230 | set_colors(foreground, background) 231 | if include is not None: 232 | with open(include, "r", encoding="utf-8") as includefile: 233 | code = includefile.read() + code 234 | 235 | with patched_stdout_recorder(width, height) as recording: 236 | execute(code=code, highlight=highlight_only) 237 | 238 | export = recording.export_svg(title=title, chrome=chrome) 239 | 240 | if save_as is not None: 241 | with open(save_as, "w", encoding="utf-8") as save: 242 | save.write(export) 243 | 244 | return export 245 | -------------------------------------------------------------------------------- /termage/mkdocs_plugin.py: -------------------------------------------------------------------------------- 1 | """A plugin for MkDocs that allows generating & inserting SVGs using Termage.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from dataclasses import dataclass 7 | from pathlib import Path 8 | from typing import Match 9 | 10 | from mkdocs.config.config_options import Type 11 | from mkdocs.plugins import BasePlugin 12 | 13 | from .execution import execute, format_codeblock, patched_stdout_recorder, set_colors 14 | 15 | RE_BLOCK = re.compile(r"(([^\n]*)\`\`\`termage(-svg)?(.*?)\n([\s\S]*?)\`\`\`)") 16 | TAB_TEMPLATE = """ 17 | === "{code_tab}" 18 | ```python {extra_opts} 19 | {code} 20 | ``` 21 | 22 | === "{svg_tab}" 23 | {content} 24 | """ 25 | 26 | OPTS = [ 27 | "width", 28 | "height", 29 | "tabs", 30 | "foreground", 31 | "background", 32 | "chrome", 33 | "title", 34 | "include", 35 | "highlight", 36 | ] 37 | 38 | 39 | def indent(text: str, amount: int) -> str: 40 | """Indents the text by the given amount. 41 | 42 | Works multiline too!""" 43 | 44 | pad = amount * " " 45 | return "\n".join(pad + line for line in text.splitlines()) 46 | 47 | 48 | @dataclass 49 | class TermageOptions: # pylint: disable=too-many-instance-attributes 50 | """Options passed into the Termage plugin.""" 51 | 52 | title: str 53 | width: int 54 | height: int 55 | include: str 56 | foreground: str 57 | background: str 58 | chrome: bool 59 | tabs: tuple[str, str] 60 | highlight: bool 61 | 62 | 63 | class TermagePlugin(BasePlugin): 64 | """An mkdocs plugin for Termage.""" 65 | 66 | config_scheme = ( 67 | # File configuration 68 | ("write_files", Type(bool, default=False)), 69 | ("inline_styles", Type(bool, default=True)), 70 | ("path", Type(str, default="assets")), 71 | ("name_template", Type(str, default="termage_{count}.svg")), 72 | # SVG content configuration 73 | ("background", Type(str, default="#212121")), 74 | ("foreground", Type(str, default="#dddddd")), 75 | ("tabs", Type(list, default=["Python", "Output"])), 76 | ("chrome", Type(bool, default=True)), 77 | ("width", Type(int, default=80)), 78 | ("height", Type(int, default=24)), 79 | ) 80 | 81 | def __init__(self) -> None: 82 | """Sets the initial SVG count.""" 83 | 84 | self._svg_count = 0 85 | 86 | def _get_next_path(self, title: str | None) -> str: 87 | """Gets the next SVG path.""" 88 | 89 | base = self.config["path"] 90 | name_template = self.config["name_template"] 91 | name = name_template.format( 92 | count=self._svg_count, 93 | title=str(title), 94 | ) 95 | 96 | return f"{base}/{name}" 97 | 98 | def parse_options(self, options: str) -> TermageOptions: 99 | """Parses the options given to a block.""" 100 | 101 | opt_dict = {key: self.config.get(key, None) for key in OPTS} 102 | 103 | extra_opts = "" 104 | 105 | for option in re.split(r"(? str: # pylint: disable=too-many-locals 136 | """Replaces a code block match with a generated SVG.""" 137 | 138 | full, indentation, svg_only, options, code = matchobj.groups() 139 | indent_len = len(indentation) 140 | 141 | if indentation.endswith("\\"): 142 | return full.replace(r"\`", "`", 1) 143 | 144 | opts, extra_opts = self.parse_options(options) 145 | set_colors(opts.foreground, opts.background) 146 | 147 | if opts.include is not None: 148 | with open(opts.include, "r", encoding="utf-8") as includefile: 149 | included = "" 150 | for line in includefile: 151 | if line.startswith("&"): 152 | included += "&" + indentation + line[1:] 153 | continue 154 | 155 | included += indentation + line 156 | 157 | code = included + code 158 | 159 | opts.title = opts.title or opts.include 160 | 161 | code_disp, code_exec = format_codeblock(code) 162 | 163 | with patched_stdout_recorder(opts.width, opts.height) as recording: 164 | execute(code=code_exec, highlight=opts.highlight) 165 | 166 | svg = ( 167 | recording.export_svg( 168 | title=opts.title, 169 | chrome=opts.chrome, 170 | prefix=f"termage-{self._svg_count}", 171 | inline_styles=self.config["inline_styles"], 172 | ) 173 | .replace("_", r"\_") 174 | .replace("`", r"\`") 175 | .replace("*", r"\*") 176 | ) 177 | 178 | self._svg_count += 1 179 | style = "margin-top: -1em;" if not opts.chrome else "" 180 | 181 | if self.config["write_files"]: 182 | filepath = self._get_next_path(opts.title) 183 | 184 | with open(Path("docs") / filepath, "w", encoding="utf-8") as export: 185 | export.write(svg) 186 | 187 | img_tag = ( 188 | f"{opts.title}" 189 | ) 190 | 191 | if svg_only: 192 | return img_tag 193 | 194 | return indent( 195 | TAB_TEMPLATE.format( 196 | code_tab=opts.tabs[0], 197 | extra_opts=extra_opts, 198 | svg_tab=opts.tabs[1], 199 | code=indent(code_disp, amount=4), 200 | content=img_tag, 201 | ), 202 | amount=indent_len, 203 | ) 204 | 205 | if style != "": 206 | svg = svg[: len(" str: 225 | """Replaces the termage markdown syntax.""" 226 | 227 | return RE_BLOCK.sub(self.replace, markdown) 228 | --------------------------------------------------------------------------------