├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── consolemd.spec ├── consolemd ├── __init__.py ├── cli.py ├── colormap.py ├── escapeseq.py ├── logger.py ├── renderer.py └── styler.py ├── pytest.ini ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── test_cmdline.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.md 2 | /pygments* 3 | consolemd.egg-info 4 | build 5 | dist 6 | *.pyc 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.6" 5 | 6 | install: 7 | - pip install -e .[test] 8 | 9 | script: 10 | - pytest 11 | - pip install pyinstaller 12 | - pyinstaller --onefile consolemd.spec 13 | # sanity test of consolemd 14 | - consolemd --version 15 | 16 | deploy: 17 | provider: releases 18 | api_key: 19 | secure: cr2jnPXoAawuZDrunlAwmLf6sO0DLqVlH8aroUaMIYw9zswN21kmMRw2HPQbR3SHbdVQRoqXyslkwPsxeg1RwDyCcf/otXXKXlk1hCka/jlTWx+nW2/gKfb33CkV9OqoMAJvmFr+SfxIeLGOE6mvXyYIblMS4HKX3NKM7+xXuJ0VqprXpTm6vIjlUDOUKPaFOO5re5uHGn2kRehp3AlawAhqQYAC9wxMk5NsA/sBTCTape69rq1OmQ78CC0QvUIKvuWiu8XRMmrijasTp+07fR67SPNXU6LaOSMnSJ93F9enmff7h9JTVuO72+ba1ulCU2Vmc99nT+7B7JWnZ6eRk0LNkyAdMmAETOlUQZWl+/tdIPgB/XNSaLY0BiEFtQc3amkkK9sFVTGk0ZR4NRQXCsqXQJReRjXwZ/ZnOvRfV13FNrz60Z6sbiRsCVDAK60lJ9eooYqyLSos+WOBK8RQOkcCl445n2XPkpB1aY8cYsRU6jlI2NsTuXi+fS6EhLhVFgzTiGKq3x+wlnasDezzN4DfUDhW94OAMHaxr65agOGbnQ0vZf9utCXqgO/8YBBxkmRssc79NydDodV8sy0VOgv6kJhRpZjq/rmeB0HYk0nlA5ho9aJXvQNIPqHGuZeNABQwAW4uMWjcwtZpGyT0GYikSdWrIrQLzJL9UVFUQYI= 20 | file: 'dist/consolemd-linux-x86_64' 21 | skip_cleanup: true 22 | on: 23 | # repo: kneufeld/consolemd 24 | tags: true 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Here's what we all hope is an accurate list of things that have changed 4 | between versions. 5 | 6 | ## v0.5.1 7 | 8 | * fixes wrapping (and extra spaces) when width specified on command line 9 | * thematic break now uses unicode wide dash and obeys width 10 | 11 | ## v0.5.0 12 | 13 | * adding --width flag to command line 14 | * added some more tests 15 | 16 | ## v0.4.5 17 | 18 | * attempting to freeze with pyinstaller 19 | * attempting to create release build with travisci 20 | 21 | ## v0.4.4 22 | 23 | * fix deprecated CommonMark to commonmark 24 | 25 | ## v0.4.3 26 | 27 | * fixed `--version` option which always ran 28 | 29 | ## v0.4.2 30 | 31 | * adding `--version` option to command line 32 | 33 | ## v0.4.1 34 | 35 | * using list comprehension instead of list() function 36 | * hopefully doing a proper deploy to pypi 37 | 38 | ## v0.4.0 39 | 40 | * consolemd is now a python3 app, thanks to tek and kseistrup 41 | * added a few sanity tests 42 | 43 | ## v0.3.2 44 | 45 | * fixed several errors with unicode decoding 46 | * fixed bug where headers get continously darker 47 | 48 | ## v0.3.1 49 | 50 | * mainly doc updates, bug fixes and some refactoring 51 | * exit with error if chosen style doesn't exist 52 | * minor tweaks to README.md, show README.md as example image 53 | * added section on OSX italics to README.md 54 | 55 | ## v0.3.0 56 | 57 | * show image/link text and show their urls as footnotes at end of document 58 | 59 | ## v0.2.1 60 | 61 | * can now load and render a utf-8 encoded file 62 | 63 | ## v0.2.0 64 | 65 | * inserting vertical whitespace is much simpler and much more effective 66 | * added --soft-wrap option to break at source line endings 67 | 68 | ## v0.1.0 69 | 70 | First release. Appears to work even if the styling code is pretty ugly. 71 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | No contribution is too small, please add your name if you've helped 4 | `consolmd` along. 5 | 6 | See [github](https://github.com/kneufeld/consolemd/graphs/contributors) 7 | for some pretty graphs. 8 | 9 | ## Contributors 10 | 11 | * kneufeld, original author 12 | * zmarouf, doc fix 13 | * tek, python3 migration 14 | * kseistrup, python3 migration 15 | * fboender, filed great bug report showing a bad deploy to pypi 16 | * vext01, pointed out version bug 17 | * ap8322, changed setup.py from CommonMark to commonmark 18 | * pmiddend, asked about click 7, prompted pyinstaller freezing 19 | * igalic, idea for --width 20 | * lordmauve, pointed out width and spaces bug 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | ## Copyright (c) 2016 Kurt Neufeld 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE.md CHANGELOG.md CONTRIBUTORS.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ConsoleMD 2 | 3 | ConsoleMD renders markdown to the console. 4 | 5 | ## Python 3 6 | 7 | Due to the inexorable tides, ConsoleMD is now Python3 only. If things happen 8 | to work in Python2 then super great, but that is no longer a requirement. 9 | 10 | ## Installation 11 | 12 | It's highly recommended to install ConsoleMD inside a Python3 virtual environment. 13 | 14 | ```bash 15 | # activate your python venv 16 | pip install consolemd 17 | 18 | # you probably want to make consolemd more accessible so... 19 | cd ~/bin 20 | ln -s ~/path/to/venv/bin/consolemd consolemd 21 | ``` 22 | 23 | ## Development 24 | 25 | If you'd like to hack on ConsoleMD and/or run some mediocre tests, then 26 | please do the following: 27 | 28 | ```bash 29 | git clone https://github.com/kneufeld/consolemd.git 30 | cd consolemd 31 | 32 | # make a python3 virtual environment and activate it 33 | 34 | pip install -e .[test] 35 | python setup.py test # this will install some extra deps and run tests 36 | ``` 37 | 38 | ## Usage 39 | 40 | You can treat `consolemd` pretty much like you would `less` or `pygmentize`. 41 | 42 | ```bash 43 | consolemd --help 44 | consolemd README.md 45 | cat README.md | consolemd 46 | ``` 47 | 48 | You can change the colors `consolemd` uses via `-s` or the environment 49 | variable `CONSOLEMD_STYLE=name`. 50 | 51 | If your terminal doesn't support true color (16 million colors) then 52 | run `consolemd` with `--no-true-color` or set environment variable 53 | `CONSOLEMD_TRUECOL=0`. 54 | 55 | If you like really long lines that wrap at terminal edge then 56 | use `--no-soft-wrap` or set `CONSOLEMD_WRAP=0`. 57 | 58 | To specify a max line width, then add `-w N` to the command line or 59 | export variable `CONSOLEMD_WIDTH` or `MANWIDTH`. Note that this feature 60 | is pretty hacky and lines with internal formatting will likely end up 61 | longer than the desired width. 62 | 63 | A current at-time-of-writing list of pygment styles is the following: 64 | 65 | ```text 66 | abap algol algol_nu arduino autumn borland bw colorful default emacs 67 | friendly fruity igor lovelace manni monokai murphy native pastie perldoc 68 | rainbow_dash rrt sas stata tango trac vim vs xcode 69 | ``` 70 | 71 | `consolemd` uses `native` by default but `monokai` is also very nice. 72 | 73 | ## Your terminal 74 | 75 | iTerm2 under OSX is great and nicely supports true color. Urxvt under 76 | Linux only pretends to be true color and doesn't do it very well. 77 | 78 | If you have strange color issues then running with `--no-true-color` 79 | should be the first thing you try. If that fixes your problem then 80 | make sure to add `CONSOLEMD_TRUECOL=0` to your `.bashrc`. 81 | 82 | See this [gist](https://gist.github.com/XVilka/8346728) for more info. 83 | 84 | ## Italics, OSX, and you 85 | 86 | For some reason Apple has disabled _italics_ in the their `terminfo` files 87 | so you have to do a little patching to enable them. This is irrespective 88 | of which terminal program you use but may depend on what `$TERM` is set 89 | to. 90 | 91 | Anyhow, based on this excellent [post](http://www.eddieantonio.ca/blog/2015/04/16/iterm-italics/) 92 | here's the `tl;dr` on enabling _italics_ in OSX. 93 | 94 | ```bash 95 | infocmp xterm-256color > /tmp/xterm-256color.terminfo 96 | printf '\tsitm=\\E[3m, ritm=\\E[23m,\n' >> /tmp/xterm-256color.terminfo 97 | tic /tmp/xterm-256color.terminfo 98 | ``` 99 | 100 | Note, the `printf` line above may not work quite right so edit 101 | `/tmp/xterm-256color.terminfo` and manually append `sitm=\E[3m, ritm=\E[23m,` 102 | to the end of the file. 103 | 104 | Regardless, restart your terminal program of choice and italics should 105 | work. Test with: 106 | 107 | ```bash 108 | echo `tput sitm`italics`tput ritm` 109 | ``` 110 | 111 | ## Why not just use pygments? 112 | 113 | Because pygments highlights the markdown but doesn't strip out 114 | the control characters. Also, ConsoleMD uses CommonMark to parse 115 | the markdown instead of the lousy one in pygments _(I'm allowed to 116 | say that since I'm the guy that wrote it)_. 117 | 118 | Also, ConsoleMD uses some parts of pygments internally, and uses 119 | pygments to highlight code blocks. 120 | 121 | ## CommonMark 122 | 123 | Over the last few years there's been work on standardizing 124 | markdown with an official spec, that work happens at 125 | [CommonMark.org](http://commonmark.org/). 126 | 127 | The python implementation of the specification that I used is 128 | called [CommonMark-py](https://github.com/rtfd/CommonMark-py). 129 | 130 | Github has recently (March 2017) converted all internal markdown 131 | to use a CommonMark parser, an interesting article can be found 132 | [here](https://githubengineering.com/a-formal-spec-for-github-markdown/). 133 | 134 | ## Bugs? 135 | 136 | Probably. There are lots of corner cases and it's not always clear what 137 | the proper output should even be. For instance, an executive decision 138 | was made to show url links as a list at the end of the document. 139 | 140 | Unfortunately `commonmark-py` isn't very easy to use as a library so if 141 | any node types got missed then chaos may ensue. Please open a bug (or even 142 | better a pull request) so that `consolemd` can get patched up. 143 | 144 | Unicode is always fun: ȧƈƈḗƞŧḗḓ ŧḗẋŧ ƒǿř ŧḗşŧīƞɠ 145 | 146 | ## Contributing 147 | 148 | If you help out in any way make sure you add your name to `CONTRIBUTORS.md`. 149 | 150 | ## Example 151 | 152 | ![](http://i.imgur.com/9zoSZdb.png) 153 | -------------------------------------------------------------------------------- /consolemd.spec: -------------------------------------------------------------------------------- 1 | # vim: ft=python 2 | 3 | # entrypoint code from: 4 | # https://github.com/pyinstaller/pyinstaller/wiki/Recipe-Setuptools-Entry-Point 5 | 6 | block_cipher = None 7 | 8 | def platform(): 9 | import platform 10 | name = "%s-%s" % (platform.system(), platform.machine()) 11 | return name.lower() 12 | 13 | def entrypoint(dist, group, name, **kwargs): 14 | import pkg_resources 15 | 16 | # get toplevel packages of distribution from metadata 17 | def get_toplevel(dist): 18 | distribution = pkg_resources.get_distribution(dist) 19 | if distribution.has_metadata('top_level.txt'): 20 | return list(distribution.get_metadata('top_level.txt').split()) 21 | else: 22 | return [] 23 | 24 | kwargs.setdefault('hiddenimports', []) 25 | packages = [] 26 | for distribution in kwargs['hiddenimports']: 27 | packages += get_toplevel(distribution) 28 | 29 | kwargs.setdefault('pathex', []) 30 | # get the entry point 31 | ep = pkg_resources.get_entry_info(dist, group, name) 32 | # insert path of the egg at the verify front of the search path 33 | kwargs['pathex'] = [ep.dist.location] + kwargs['pathex'] 34 | # script name must not be a valid module name to avoid name clashes on import 35 | script_path = os.path.join(workpath, name + '-script.py') 36 | print("creating script for entry point", dist, group, name) 37 | with open(script_path, 'w') as fh: 38 | print("import", ep.module_name, file=fh) 39 | print("%s.%s()" % (ep.module_name, '.'.join(ep.attrs)), file=fh) 40 | for package in packages: 41 | print("import", package, file=fh) 42 | 43 | return Analysis( 44 | [script_path] + kwargs.get('scripts', []), 45 | **kwargs 46 | ) 47 | 48 | a = entrypoint('consolemd', 'console_scripts', 'consolemd') 49 | 50 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) 51 | 52 | exe = EXE(pyz, 53 | a.scripts, 54 | a.binaries, 55 | a.zipfiles, 56 | a.datas, 57 | [], 58 | name='consolemd-%s' % platform(), 59 | debug=False, 60 | bootloader_ignore_signals=False, 61 | strip=False, 62 | upx=True, 63 | runtime_tmpdir=None, 64 | console=True ) 65 | -------------------------------------------------------------------------------- /consolemd/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'consolemd' 2 | __version__ = '0.5.1' 3 | __author__ = 'Kurt Neufeld' 4 | __author_email__ = 'kneufeld@burgundywall.com' 5 | __license__ = 'MIT License' 6 | __url__ = 'https://github.com/kneufeld/consolemd' 7 | __copyright__ = 'Copyright 2017 Kurt Neufeld' 8 | 9 | from .renderer import Renderer 10 | -------------------------------------------------------------------------------- /consolemd/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | ConsoleMD renders markdown to the console instead of just syntax 3 | highlighting the markdown. The difference being that the control 4 | characters are stripped. 5 | """ 6 | 7 | import os 8 | import sys 9 | import click 10 | 11 | import logging 12 | from .logger import create_logger 13 | logger = create_logger('consolemd') 14 | 15 | 16 | def rename_proc( name ): 17 | """ 18 | rename executabe from 'python ' to just '' 19 | you can't show arguments because then `pidof ` would fail 20 | """ 21 | from setproctitle import setproctitle 22 | setproctitle( name ) 23 | 24 | 25 | # this is a click related function, not logging per se, hence it lives here 26 | def change_loglevel(ctx, param, value): 27 | # 'stdout' is the name of a handler defined in .logger 28 | 29 | def get_handler(name): 30 | handlers = [ 31 | h for h in logger.handlers 32 | if h.get_name() == name 33 | ] 34 | if handlers: 35 | return handlers[0] 36 | 37 | if param.name == 'debug' and value: 38 | logger.setLevel(logging.DEBUG) 39 | handler = get_handler('stderr') 40 | if handler: 41 | handler.setLevel(logging.DEBUG) 42 | elif param.name == 'quiet' and value: 43 | logger.setLevel(logging.WARNING) 44 | handler = get_handler('stderr') 45 | if handler: 46 | handler.setLevel(logging.WARNING) 47 | 48 | 49 | def get_width(ctx, param, value): 50 | min_width = 20 51 | width = None 52 | 53 | if value: 54 | width = value 55 | else: 56 | # if one of these env var is set, use it 57 | for key in ['CONSOLEMD_WIDTH', 'MANWIDTH']: 58 | value = os.environ.get(key, None) 59 | if value is not None: 60 | width = int(value) 61 | logger.debug("using envvar %s to set width to %d", key, width) 62 | break 63 | 64 | if width is not None and width < min_width: 65 | logger.warning("overriding width to %d", min_width) 66 | width = min_width 67 | 68 | if width is not None: 69 | logger.debug("using width of %d", width) 70 | 71 | return width 72 | 73 | # this is a click related function, not logging per se, hence it lives here 74 | def enable_color(ctx, param, value): 75 | if value is False: 76 | for h in logger.handlers: 77 | h._enabled = False 78 | 79 | 80 | def set_true_color(ctx, param, value): 81 | import consolemd.escapeseq 82 | consolemd.escapeseq._true_color = value 83 | 84 | 85 | def verify_style_name(ctx, param, value): 86 | import pygments.styles 87 | import pygments.util 88 | 89 | try: 90 | pygments.styles.get_style_by_name(value) 91 | return value 92 | except pygments.util.ClassNotFound: 93 | ctx.fail("invalid style name: {}".format(value)) 94 | 95 | def show_version(ctx, param, value): 96 | if not value or ctx.resilient_parsing: 97 | return 98 | from . import __version__ 99 | click.echo(__version__) 100 | ctx.exit() 101 | 102 | CTX_SETTINGS = dict(help_option_names=['-h', '--help']) 103 | 104 | @click.command(context_settings=CTX_SETTINGS) 105 | @click.option('--version', 106 | is_flag=True, callback=show_version, expose_value=False, is_eager=True, 107 | help="show version and exit") 108 | @click.option('-d', '--debug', 109 | is_flag=True, callback=change_loglevel, expose_value=True, is_eager=True, 110 | help="show extra info") 111 | @click.option('-q', '--quiet', 112 | is_flag=True, callback=change_loglevel, expose_value=True, is_eager=True, 113 | help="show less info") 114 | @click.option('--color/--no-color', 115 | default=True, callback=enable_color, is_eager=True, 116 | help="enable/disable logging color") 117 | @click.option('--true-color/--no-true-color', 118 | default=os.environ.get('CONSOLEMD_TRUECOL', True), 119 | callback=set_true_color, is_eager=True, 120 | help="enable/disable true color (16m colors)") 121 | @click.option('--soft-wrap/--no-soft-wrap', 122 | default=os.environ.get('CONSOLEMD_WRAP', True), 123 | help="output lines wrap along with source lines") 124 | @click.option('-w', '--width', 125 | callback=get_width, expose_value=True, is_eager=False, type=int, 126 | help="format text to given width, othewise use soft-wrap") 127 | @click.option('-o', '--output', 128 | type=click.File('w'), default=sys.stdout, 129 | help="output to a file, stdout by default") 130 | @click.option('-s', '--style', 131 | type=str, default=os.environ.get('CONSOLEMD_STYLE', 'native'), 132 | callback=verify_style_name, is_eager=True, 133 | help="what pygments style to use for coloring (def: native)") 134 | @click.argument('input', type=click.File('r'), default=sys.stdin) 135 | @click.pass_context 136 | def cli(ctx, input, **kw): 137 | """ 138 | render some markdown 139 | """ 140 | 141 | rename_proc( 'consolemd' ) 142 | 143 | md = input.read() 144 | 145 | import consolemd 146 | renderer = consolemd.Renderer(style_name=kw['style']) 147 | renderer.render( md, **kw ) 148 | 149 | if __name__ == "__main__": 150 | cli() 151 | -------------------------------------------------------------------------------- /consolemd/colormap.py: -------------------------------------------------------------------------------- 1 | """ 2 | contains color utility functions and xterm color data 3 | """ 4 | 5 | # Default mapping of #ansixxx to RGB colors. 6 | ansicolors = { 7 | # dark 8 | '#ansiblack' : '#000000', 9 | '#ansidarkred' : '#7f0000', 10 | '#ansidarkgreen' : '#007f00', 11 | '#ansibrown' : '#7f7fe0', 12 | '#ansidarkblue' : '#40407f', # lighter than normal 13 | '#ansipurple' : '#7f007f', 14 | '#ansiteal' : '#007f7f', 15 | '#ansilightgray' : '#e5e5e5', 16 | # normal 17 | '#ansidarkgray' : '#555555', 18 | '#ansired' : '#ff0000', 19 | '#ansigreen' : '#00ff00', 20 | '#ansiyellow' : '#ffff00', 21 | '#ansiblue' : '#6060ff', # lighter than normal 22 | '#ansifuchsia' : '#ff00ff', 23 | '#ansiturquoise' : '#00ffff', 24 | '#ansiwhite' : '#ffffff', 25 | } 26 | 27 | def _build_color_table(): 28 | # colors 0..15: 16 basic colors 29 | 30 | xterm_colors = [] 31 | 32 | xterm_colors.append((0x00, 0x00, 0x00)) # 0 33 | xterm_colors.append((0xcd, 0x00, 0x00)) # 1 34 | xterm_colors.append((0x00, 0xcd, 0x00)) # 2 35 | xterm_colors.append((0xcd, 0xcd, 0x00)) # 3 36 | xterm_colors.append((0x00, 0x00, 0xee)) # 4 37 | xterm_colors.append((0xcd, 0x00, 0xcd)) # 5 38 | xterm_colors.append((0x00, 0xcd, 0xcd)) # 6 39 | xterm_colors.append((0xe5, 0xe5, 0xe5)) # 7 40 | xterm_colors.append((0x7f, 0x7f, 0x7f)) # 8 41 | xterm_colors.append((0xff, 0x00, 0x00)) # 9 42 | xterm_colors.append((0x00, 0xff, 0x00)) # 10 43 | xterm_colors.append((0xff, 0xff, 0x00)) # 11 44 | xterm_colors.append((0x5c, 0x5c, 0xff)) # 12 45 | xterm_colors.append((0xff, 0x00, 0xff)) # 13 46 | xterm_colors.append((0x00, 0xff, 0xff)) # 14 47 | xterm_colors.append((0xff, 0xff, 0xff)) # 15 48 | 49 | # colors 16..232: the 6x6x6 color cube 50 | 51 | valuerange = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff) 52 | 53 | for i in range(217): 54 | r = valuerange[(i // 36) % 6] 55 | g = valuerange[(i // 6) % 6] 56 | b = valuerange[i % 6] 57 | xterm_colors.append((r, g, b)) 58 | 59 | # colors 233..253: grayscale 60 | 61 | for i in range(1, 22): 62 | v = 8 + i * 10 63 | xterm_colors.append((v, v, v)) 64 | 65 | return xterm_colors 66 | 67 | def to_rgb(color): 68 | """ 69 | return r,g,b integers from #colorstring 70 | """ 71 | color = ansicolors.get(color, color) 72 | 73 | if color[0] == '#': 74 | color = color[1:] 75 | 76 | color = int(color, 16) 77 | 78 | r = (color >> 16) & 0xff 79 | g = (color >> 8) & 0xff 80 | b = color & 0xff 81 | 82 | return r,g,b 83 | 84 | 85 | def from_rgb(r,g,b): 86 | """ 87 | return #colorstring from r,g,b integers 88 | """ 89 | # hex() produces "0x08", we want just "08" 90 | rgb = [hex(i)[2:].zfill(2) for i in map(int, [r,g,b])] 91 | return "#" + "".join(rgb) 92 | 93 | 94 | def reshade( color, per): 95 | """ 96 | given a #colorstring and a percentage, darken/lighten each 97 | r,g,b channel 98 | """ 99 | def scale(c, per): 100 | return max(0, min(255, int(c*per))) 101 | 102 | if not color: 103 | return '' 104 | 105 | if per == 1.0: 106 | return color 107 | 108 | r,g,b = to_rgb(color) 109 | r,g,b = map( lambda c: scale(c,per), [r,g,b] ) 110 | return from_rgb(r,g,b) 111 | 112 | 113 | class ColorMap(object): 114 | """ 115 | return the closest xterm color index based on a #colorstring 116 | """ 117 | 118 | # only called once for all ColorMap instances 119 | xterm_colors = _build_color_table() 120 | 121 | def __init__(self, color): 122 | """ 123 | color can be a name (eg. #ansiyellow) or value (eg. #fe348c) 124 | """ 125 | self._color = ansicolors.get(color, color) 126 | 127 | @property 128 | def color(self): 129 | """ 130 | by returning an index, we're using a built-in xterm color in the console 131 | """ 132 | return self._color_index(self._color) 133 | 134 | def _closest_color(self, r, g, b): 135 | distance = 257*257*3 # "infinity" (>distance from #000000 to #ffffff) 136 | match = 0 137 | 138 | for i in range(0, 254): 139 | values = self.xterm_colors[i] 140 | 141 | rd = r - values[0] 142 | gd = g - values[1] 143 | bd = b - values[2] 144 | d = rd*rd + gd*gd + bd*bd 145 | 146 | if d < distance: 147 | match = i 148 | distance = d 149 | 150 | return match 151 | 152 | def _color_index(self, color): 153 | if color in ansicolors: 154 | color = ansicolors[color] 155 | 156 | color = color[1:] 157 | 158 | try: 159 | rgb = int(str(color), 16) 160 | except ValueError: 161 | rgb = 0 162 | 163 | r = (rgb >> 16) & 0xff 164 | g = (rgb >> 8) & 0xff 165 | b = rgb & 0xff 166 | 167 | return self._closest_color(r, g, b) 168 | -------------------------------------------------------------------------------- /consolemd/escapeseq.py: -------------------------------------------------------------------------------- 1 | from consolemd.colormap import ColorMap, to_rgb, ansicolors 2 | 3 | _true_color = True 4 | 5 | class EscapeSequence(object): 6 | def __init__(self, 7 | fg=None, bg=None, 8 | bold=False, underline=False, italic=False, true_color=None, 9 | stream=None, 10 | ): 11 | 12 | self.fg = fg 13 | self.bg = bg 14 | self.bold = bold 15 | self.underline = underline 16 | self.italic = italic 17 | self.stream = None 18 | 19 | if true_color is None: 20 | true_color = _true_color 21 | 22 | if true_color: 23 | self.color_string = self.true_color_string 24 | else: 25 | self.color_string = self.low_color_string 26 | 27 | def __str__(self): 28 | return self.color_string() 29 | 30 | def __repr__(self): 31 | return "".format( 32 | self.fg or '_', self.bg or '_', self.bold, self.underline, self.italic 33 | ) 34 | 35 | def __enter__(self): 36 | self.stream.write( self.color_string() ) 37 | 38 | def __exit__(self, exc_type, exc_value, traceback): 39 | self.stream.write( self.reset_string() ) 40 | 41 | @property 42 | def fg(self): 43 | return self._fg 44 | 45 | @fg.setter 46 | def fg(self, color): 47 | # convert incoming color to rgb string 48 | self._fg = ansicolors.get(color, color) 49 | 50 | @property 51 | def bg(self): 52 | return self._bg 53 | 54 | @bg.setter 55 | def bg(self, color): 56 | # convert incoming color to rgb string 57 | self._bg = ansicolors.get(color, color) 58 | 59 | def escape(self, attrs): 60 | if len(attrs): 61 | return "\x1b[" + ";".join(attrs) + "m" 62 | return '' 63 | 64 | def low_color_string(self): 65 | attrs = [] 66 | 67 | if self.fg is not None: 68 | color = ColorMap( self.fg ).color 69 | attrs.extend(("38", "5", "%i" % color)) 70 | 71 | if self.bg is not None: 72 | color = ColorMap( self.bg ).color 73 | attrs.extend(("48", "5", "%i" % color)) 74 | 75 | if self.bold: 76 | attrs.append("01") 77 | 78 | if self.underline: 79 | attrs.append("04") 80 | 81 | if self.italic: 82 | attrs.append("03") 83 | 84 | return self.escape(attrs) 85 | 86 | def true_color_string(self): 87 | attrs = [] 88 | if self.fg: 89 | r,g,b = map(str, to_rgb(self.fg)) 90 | attrs.extend(("38", "2", r, g, b)) 91 | if self.bg: 92 | r,g,b = map(str, to_rgb(self.bg)) 93 | attrs.extend(("48", "2", r, g, b)) 94 | if self.bold: 95 | attrs.append("01") 96 | if self.underline: 97 | attrs.append("04") 98 | if self.italic: 99 | attrs.append("03") 100 | return self.escape(attrs) 101 | 102 | def reset_string(self): 103 | """ 104 | tries to minimally reset current terminal state 105 | ie: only reset fg color and not everything 106 | """ 107 | attrs = [] 108 | if self.fg is not None: 109 | attrs.append("39") 110 | if self.bg is not None: 111 | attrs.append("49") 112 | if self.bold or self.underline or self.italic: 113 | attrs.append("00") 114 | return self.escape(attrs) 115 | 116 | @staticmethod 117 | def full_reset_string(): 118 | return EscapeSequence(fg=1, bg=1, bold=1).reset_string() 119 | -------------------------------------------------------------------------------- /consolemd/logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | 4 | from .escapeseq import EscapeSequence 5 | 6 | class ColoredStream( logging.StreamHandler ): 7 | colors = { 8 | 'DEBUG' : EscapeSequence(fg='#ansiblue'), 9 | 'INFO' : EscapeSequence(), 10 | 'WARNING' : EscapeSequence(fg='#ansiyellow'), 11 | 'ERROR' : EscapeSequence(fg='#ansired', bg='#400000'), 12 | 'CRITICAL' : EscapeSequence(fg='#ansired', bg='#400000', bold=True), 13 | } 14 | 15 | def __init__(self, stream=None): 16 | # don't output color to pipes or files 17 | self._enabled = sys.stderr.isatty() 18 | return super(ColoredStream,self).__init__(stream) 19 | 20 | def emit(self, record): 21 | 22 | try: 23 | eseq = ColoredStream.colors[record.levelname] 24 | except KeyError: 25 | eseq = EscapeSequence() 26 | 27 | msg = self.format(record) 28 | 29 | if self._enabled: 30 | self.stream.write( "{}{}{}\n".format(eseq, msg, eseq.reset_string()) ) 31 | else: 32 | self.stream.write( "{}\n".format(msg) ) 33 | 34 | self.flush() 35 | 36 | 37 | def create_logger(name): 38 | 39 | logger = logging.getLogger(name) 40 | logger.setLevel( logging.NOTSET ) 41 | 42 | h1 = ColoredStream( sys.stderr ) 43 | h1.setLevel( logging.INFO ) 44 | h1.set_name( 'stderr' ) 45 | 46 | logger.addHandler(h1) 47 | 48 | return logger 49 | -------------------------------------------------------------------------------- /consolemd/renderer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import re 3 | import textwrap 4 | 5 | import commonmark 6 | import pygments 7 | import pygments.lexers 8 | import pygments.styles 9 | import pygments.formatters 10 | 11 | from .styler import Styler, Style 12 | from .escapeseq import EscapeSequence, _true_color 13 | 14 | import logging 15 | logger = logging.getLogger('consolemd') 16 | 17 | endl = '\n' 18 | 19 | 20 | def debug_tag(obj, entering, match): 21 | if entering != match: 22 | return '' 23 | 24 | if entering: 25 | return u"<{}>".format(obj.t) 26 | 27 | return u"".format(obj.t) 28 | 29 | 30 | class Renderer: 31 | 32 | def __init__(self, parser=None, style_name=None): 33 | 34 | if parser is None: 35 | parser = commonmark.Parser() 36 | 37 | if style_name is None: 38 | style_name = 'native' 39 | 40 | self.parser = parser 41 | self.style_name = style_name 42 | self.list_level = -1 43 | self.counters = {} 44 | self.footnotes = [] 45 | 46 | def render(self, text, **kw): 47 | stream = kw.get('output', sys.stdout) 48 | self.width = kw.get('width', None) 49 | self.soft_wrap = kw.get('soft_wrap', True) 50 | self.soft_wrap_char = endl if self.soft_wrap else ' ' 51 | 52 | text = self.wrap_paragraphs(text) 53 | 54 | self.styler = Styler(stream, self.style_name) 55 | ast = self.parser.parse(text) 56 | 57 | for obj, entering in ast.walker(): 58 | with self.styler.cm(obj, entering): 59 | prefix = self.prefix(obj, entering) 60 | stream.write(prefix) 61 | 62 | logger.debug(debug_tag(obj, entering, True)) 63 | 64 | out = self.dispatch(obj, entering) 65 | stream.write(out) 66 | 67 | logger.debug(debug_tag(obj, entering, False)) 68 | 69 | stream.flush() 70 | 71 | def dispatch(self, obj, entering): 72 | try: 73 | handler = getattr(self, obj.t) 74 | out = handler(obj, entering) 75 | return out 76 | except AttributeError: 77 | logger.error(u"unhandled ast type: {}".format(obj.t)) 78 | 79 | return '' 80 | 81 | def wrap_paragraphs(self, text): 82 | """ 83 | unfortunately textwrap expects to work on paragraphs, not entire 84 | documents. If the user has specified a width then we need to wrap 85 | the paragraphs individually before we parse the document. 86 | """ 87 | if not self.width: 88 | return text 89 | 90 | para_edge = re.compile(r"(\n\s*\n)", re.MULTILINE) 91 | paragraphs = para_edge.split(text) 92 | 93 | wrapped_lines = [] 94 | for para in paragraphs: 95 | wrapped_lines.append( 96 | textwrap.fill(para, width=self.width) 97 | ) 98 | 99 | return '\n'.join(wrapped_lines) 100 | 101 | def prefix(self, obj, entering): 102 | """ 103 | having newlines before text blocks is problematic, this function 104 | tries to catch those corner cases 105 | """ 106 | if not entering: 107 | return '' 108 | 109 | if obj.t == 'document': 110 | return '' 111 | 112 | # if our parent is the document the prefix a newline 113 | if obj.parent.t == 'document': 114 | # don't prefix the very first one though 115 | if obj.parent.first_child != obj: 116 | return endl 117 | 118 | return '' 119 | 120 | def document(self, obj, entering): 121 | if entering: 122 | return '' 123 | else: 124 | formatted_footnotes = [] 125 | for i, footnote in enumerate(self.footnotes): 126 | i += 1 127 | 128 | f = u"[{}] - {}".format(i, footnote) 129 | formatted_footnotes.append(f) 130 | 131 | if formatted_footnotes: 132 | return endl + endl.join(formatted_footnotes) + endl 133 | 134 | return '' 135 | 136 | def paragraph(self, obj, entering): 137 | if entering: 138 | return '' 139 | else: 140 | return endl 141 | 142 | def text(self, obj, entering): 143 | return obj.literal 144 | 145 | def linebreak(self, obj, entering): 146 | return endl 147 | 148 | def softbreak(self, obj, entering): 149 | return self.soft_wrap_char 150 | 151 | def thematic_break(self, obj, entering): 152 | width = self.width if self.width else 75 153 | return u"{}".format('—' * width) + endl 154 | 155 | def emph(self, obj, entering): 156 | return '' 157 | 158 | def strong(self, obj, entering): 159 | return '' 160 | 161 | def heading(self, obj, entering): 162 | if entering: 163 | level = 1 if obj.level is None else obj.level 164 | return u"{} ".format('#' * level) 165 | else: 166 | return endl 167 | 168 | def list(self, obj, entering): 169 | if entering: 170 | self.list_level += 1 171 | else: 172 | self.list_level -= 1 173 | 174 | if obj.list_data['type'] == 'ordered': 175 | if entering: 176 | # item nodes will increment this 177 | start = obj.list_data['start'] - 1 178 | self.counters[ tuple(obj.sourcepos[0]) ] = start 179 | else: 180 | del self.counters[ tuple(obj.sourcepos[0]) ] 181 | 182 | return '' 183 | 184 | def item(self, obj, entering): 185 | if entering: 186 | if obj.list_data['type'] == 'ordered': 187 | key = tuple(obj.parent.sourcepos[0]) 188 | self.counters[key] += 1 189 | num = self.counters[key] 190 | bullet_char = u"{}.".format(num) 191 | else: 192 | bullet_char = obj.list_data.get('bullet_char') or '*' # -,+,* 193 | 194 | text = u"{}{} ".format(' ' * self.list_level * 2, bullet_char) 195 | eseq = self.styler.style.entering('bullet') 196 | 197 | return self.styler.stylize(eseq, text) 198 | 199 | return '' 200 | 201 | def code(self, obj, entering): 202 | # backticks 203 | return obj.literal 204 | 205 | def code_block(self, obj, entering): 206 | # farm out code highlighting to pygments 207 | # note: unfortunately you can't set your own background color 208 | # because after the first token the color codes would get reset 209 | 210 | try: 211 | lang = obj.info or 'text' 212 | lexer = pygments.lexers.get_lexer_by_name(lang) 213 | style = Style.get_style_by_name(self.style_name) 214 | except pygments.util.ClassNotFound: # lang is unknown to pygments 215 | lang = 'text' 216 | lexer = pygments.lexers.get_lexer_by_name(lang) 217 | style = Style.get_style_by_name(self.style_name) 218 | 219 | formatter_name = 'console16m' if _true_color else 'console' 220 | formatter = pygments.formatters.get_formatter_by_name(formatter_name, style=style) 221 | 222 | highlighted = u"{}{}".format( 223 | pygments.highlight(obj.literal.encode('utf-8'), lexer, formatter).rstrip(), 224 | EscapeSequence.full_reset_string() + endl, 225 | ) 226 | eseq = EscapeSequence(bg="#202020") 227 | 228 | return self.styler.stylize(eseq, highlighted) 229 | 230 | def block_quote(self, obj, entering): 231 | # has text children 232 | return '' 233 | 234 | def link(self, obj, entering): 235 | if entering: 236 | self.footnotes.append(obj.destination) 237 | return '' 238 | else: 239 | return u"[{}]".format(len(self.footnotes)) 240 | 241 | def image(self, obj, entering): 242 | if entering: 243 | self.footnotes.append(obj.destination) 244 | return '[{}]".format(len(self.footnotes)) 247 | 248 | def html_inline(self, obj, entering): 249 | if obj.literal.lower() in ['
', '
']: 250 | return endl 251 | 252 | return obj.literal 253 | 254 | def html_block(self, obj, entering): 255 | logger.warning("ignoring html_block") 256 | return '' 257 | 258 | renderer = Renderer(self.parser, self.style_name) 259 | renderer.render(obj.literal[4:-3]) 260 | return '' 261 | -------------------------------------------------------------------------------- /consolemd/styler.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import pygments.styles 4 | from pygments import token 5 | 6 | from .escapeseq import EscapeSequence 7 | from .colormap import reshade 8 | 9 | import logging 10 | logger = logging.getLogger('consolemd.styler') 11 | 12 | # based on solarized dark 13 | solarized = { 14 | 'text': '', 15 | 'heading': '#cb4b16 bold', # orange 16 | 'emph': 'italic', 17 | 'strong': 'bold', 18 | 'block_quote': 'italic', 19 | 'code': '#af8700', # yellow 20 | 'link': '#0087ff', 21 | 'image': '#0087ff', 22 | 'bullet': '#268bd2 bold', # blue 23 | } 24 | 25 | class Style(object): 26 | 27 | plain = EscapeSequence() 28 | 29 | def __init__(self, style_name): 30 | self.pyg_style = Style.get_style_by_name(style_name) 31 | 32 | f = self.eseq_from_pygments # shortcut 33 | 34 | # each style has an entering value and an exiting value, None means reset 35 | self.styles = { 36 | 'heading': (f(token.Generic.Heading , solarized['heading']) , None) , 37 | 'emph': (f(token.Generic.Emph , solarized['emph']) , None) , 38 | 'strong': (f(token.Generic.Strong , solarized['strong']) , None) , 39 | 'link': (f(token.Name.Entity , solarized['link']) , None) , 40 | 'image': (f(token.Name.Entity , solarized['image']) , None) , 41 | 'code': (f(token.String.Backtick , solarized['code']) , None) , 42 | 'block_quote': (f(token.Generic.Emph , solarized['block_quote']) , None) , 43 | 'bullet': (f(token.Literal , solarized['bullet']) , None) , 44 | } 45 | 46 | #print self.styles 47 | 48 | @staticmethod 49 | def get_style_by_name(style_name): 50 | try: 51 | return pygments.styles.get_style_by_name(style_name) 52 | except pygments.util.ClassNotFound: 53 | logger.error("no such style: %s", style_name) 54 | return pygments.styles.get_style_by_name('native') 55 | 56 | def eseq_from_pygments(self, token, default=''): 57 | value = self.pyg_style.styles.get(token, '') or default 58 | values = value.split() 59 | 60 | def fg(values): 61 | try: 62 | return [val for val in values if val.startswith('#')][0] 63 | except IndexError: 64 | return '' 65 | 66 | def bg(values): 67 | try: 68 | bg = [val for val in values if val.startswith('bg:')][0] 69 | return bg.split('bg:')[1] 70 | except IndexError: 71 | return '' 72 | 73 | return EscapeSequence( 74 | fg = fg(values), 75 | bg = bg(values), 76 | bold = 'bold' in values, 77 | underline = 'underline' in values, 78 | italic = 'italic' in values, 79 | ) 80 | 81 | def entering(self, key): 82 | return self.styles.get(key, (None,None))[0] or Style.plain 83 | 84 | def exiting(self, key): 85 | return self.styles.get(key, (None,None))[1] or self.entering(key) 86 | 87 | 88 | class Styler(object): 89 | 90 | no_closing_node = [ 91 | 'text', 'code', 'code_block', 92 | 'thematic_break', 'softbreak', 'linebreak', 93 | 'html_inline', 'html_block', 94 | ] 95 | 96 | def __init__(self, stream, style_name): 97 | 98 | self.stream = stream 99 | self.style_name = style_name 100 | self.style = Style(style_name) 101 | 102 | self._stack = [] 103 | self._curr_call = None 104 | 105 | def cm(self, obj, entering): 106 | """ 107 | return ourselves as a context manager, unfortunately __enter__ 108 | can't take any parameters 109 | """ 110 | assert self._curr_call == None, "Styler is not re-entrant" 111 | self._curr_call = (obj, entering) 112 | return self 113 | 114 | def __enter__(self): 115 | obj, entering = self._curr_call 116 | 117 | if entering: 118 | eseq = self.dispatch( obj, entering ) 119 | self.push( eseq ) 120 | self.stream.write( eseq.color_string() ) 121 | 122 | def __exit__(self, exc_type, exc_value, traceback): 123 | obj, entering = self._curr_call 124 | self._curr_call = None 125 | 126 | if not entering or obj.t in Styler.no_closing_node: 127 | eseq = self._stack.pop() 128 | self.stream.write( eseq.reset_string() ) 129 | 130 | if obj.t != 'document': 131 | eseq = self._stack[-1] 132 | self.stream.write( eseq.color_string() ) 133 | else: 134 | assert len(self._stack) == 0, "missed an ast type in no_closing_node" 135 | 136 | def __getattr__(self, name): 137 | from functools import partial 138 | return partial(self._default, name) 139 | 140 | @staticmethod 141 | def stylize( eseq, text ): 142 | return u"{}{}{}".format(eseq.color_string(), text, eseq.reset_string()) 143 | 144 | def _default(self, name, obj, entering): 145 | """ 146 | __getattr__ returns this function if a specific/overriding method 147 | is not provided in this class 148 | """ 149 | if entering: 150 | return self.style.entering(name) 151 | else: 152 | return self.style.exiting(name) 153 | 154 | def push(self, eseq): 155 | self._stack.append(eseq) 156 | return eseq 157 | 158 | def pop(self): 159 | return self._stack.pop() 160 | 161 | def dispatch(self, obj, entering): 162 | """ 163 | returns an EscapeSequence object 164 | """ 165 | handler = getattr(self, obj.t) 166 | return handler(obj, entering) 167 | 168 | def heading(self, obj, entering): 169 | """ 170 | do specialized styling for headers, make each heading level a bit darker 171 | """ 172 | eseq = copy.deepcopy( self.style.entering('heading') ) 173 | color = eseq.fg 174 | 175 | level = 1 if obj.level is None else obj.level 176 | per = 1.0 - .10 * (level-1) 177 | eseq.fg = reshade(color, per) 178 | 179 | return eseq 180 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | script_launch_mode = subprocess 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE.md 6 | 7 | [aliases] 8 | test = pytest 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | ConsoleMD parses markdown using CommonMark-py (implementation of the 5 | CommonMarkdown spec) and then fully renders it to the console in true color. 6 | """ 7 | 8 | import os 9 | from setuptools import setup 10 | 11 | base_dir = os.path.dirname(os.path.abspath(__file__)) 12 | pkg_name = 'consolemd' 13 | 14 | # adapted from: http://code.activestate.com/recipes/82234-importing-a-dynamically-generated-module/ 15 | def pseudo_import( pkg_name ): 16 | """ 17 | return a new module that contains the variables of pkg_name.__init__ 18 | """ 19 | init = os.path.join( pkg_name, '__init__.py' ) 20 | 21 | # remove imports and 'from foo import' 22 | lines = open(init,'r').readlines() 23 | lines = filter( lambda l: not l.startswith('from'), lines) 24 | lines = filter( lambda l: not l.startswith('import'), lines) 25 | 26 | code = '\n'.join(lines) 27 | 28 | import imp 29 | module = imp.new_module(pkg_name) 30 | 31 | exec(code, module.__dict__) 32 | return module 33 | 34 | # trying to make this setup.py as generic as possible 35 | module = pseudo_import(pkg_name) 36 | 37 | setup( 38 | name=pkg_name, 39 | packages=[pkg_name], 40 | 41 | install_requires=[ 42 | 'click', 43 | 'pygments', 44 | 'setproctitle', 45 | 'commonmark', 46 | ], 47 | 48 | extras_require = { 49 | 'test': [ 50 | 'pytest>=4.3.1', 51 | 'pytest-runner>=4.4', 52 | 'pytest-console-scripts>=0.1.9', 53 | ], 54 | }, 55 | 56 | entry_points=''' 57 | [console_scripts] 58 | consolemd=consolemd.cli:cli 59 | ''', 60 | 61 | # metadata for upload to PyPI 62 | description = "ConsoleMD renders markdown to the console", 63 | long_description = __doc__, 64 | version = module.__version__, 65 | author = module.__author__, 66 | author_email = module.__author_email__, 67 | license = module.__license__, 68 | keywords = "markdown console terminal".split(), 69 | url = module.__url__, 70 | 71 | classifiers = [ 72 | "Development Status :: 4 - Beta", 73 | "Intended Audience :: Developers", 74 | "Intended Audience :: System Administrators", 75 | "Natural Language :: English", 76 | "License :: OSI Approved :: MIT License", 77 | "Operating System :: OS Independent", 78 | "Programming Language :: Python :: 3", 79 | "Topic :: Software Development :: Documentation", 80 | "Topic :: Software Development :: Libraries :: Python Modules", 81 | "Topic :: Terminals", 82 | "Topic :: Text Processing :: Markup", 83 | "Topic :: Utilities", 84 | ], 85 | 86 | data_files = [], 87 | ) 88 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kneufeld/consolemd/3535f556f6f23f875ba8ddfdcf39f4915ce26889/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cmdline.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | import pytest 3 | 4 | # requires pytest-console-scripts 5 | 6 | def test_unknown_file(script_runner): 7 | ret = script_runner.run('consolemd', 'no_such_file') 8 | assert not ret.success 9 | assert ret.returncode == 2 10 | 11 | 12 | def test_readme_file(script_runner): 13 | ret = script_runner.run('consolemd', 'README.md') 14 | assert ret.success 15 | assert "ConsoleMD renders markdown to the console." in ret.stdout 16 | 17 | 18 | def test_version(script_runner): 19 | from consolemd import __version__ 20 | ret = script_runner.run('consolemd', '--version') 21 | assert ret.success 22 | assert ret.stdout.strip() == __version__ 23 | assert ret.stderr == '' 24 | 25 | 26 | def test_readme_file_with_all_args(script_runner): 27 | ret = script_runner.run('consolemd', '-d', '--color', '--true-color', '--soft-wrap', '-w', '10', '-s', 'native', 'README.md') 28 | assert ret.success 29 | assert ret.stdout != '' 30 | assert ret.stderr != '' 31 | 32 | 33 | def test_pipe_empty_stdin(script_runner): 34 | stdin = StringIO() 35 | ret = script_runner.run('consolemd', stdin=stdin) 36 | assert ret.success 37 | assert ret.stdout == '' 38 | assert ret.stderr == '' 39 | 40 | 41 | def test_pipe_stdin(script_runner): 42 | input = "ȧƈƈḗƞŧḗḓ ŧḗẋŧ ƒǿř ŧḗşŧīƞɠ" 43 | stdin = StringIO(input) 44 | ret = script_runner.run('consolemd', stdin=stdin) 45 | assert ret.success 46 | assert ret.stdout == input + '\n' 47 | assert ret.stderr == '' 48 | --------------------------------------------------------------------------------