― "Package Philosophy," from the R reprex documentation
12 | 13 | Writing a good reprex takes thought and effort (see ["Reprex Do's and Don'ts"](../dos-and-donts) for tips). The goal of reprexlite is to be a tool for Python that handles the mechanical stuff—running your code, capturing the output, formatting everything—so you don't have to worry about it, and you can devote your full attention to the important, creative work of writing the content. The action of running your code and seeing the outputs can also be a helpful forcing function in really making sure your example works and produces what you intend. 14 | 15 | ## Why reproducible examples? 16 | 17 |From the R reprex readme.
― "Reprex do's and don'ts," from the R reprex documentation
52 | -------------------------------------------------------------------------------- /docs/docs/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jayqi/reprexlite/b6a624a11b485073de8d680402a66756b65ea264/docs/docs/images/demo.gif -------------------------------------------------------------------------------- /docs/docs/images/help-me-help-you.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jayqi/reprexlite/b6a624a11b485073de8d680402a66756b65ea264/docs/docs/images/help-me-help-you.png -------------------------------------------------------------------------------- /docs/docs/images/reprexlite.svg: -------------------------------------------------------------------------------- 1 | 2 | 81 | -------------------------------------------------------------------------------- /docs/docs/images/reprexlite_white_blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 87 | -------------------------------------------------------------------------------- /docs/docs/images/reprexlite_white_transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 87 | -------------------------------------------------------------------------------- /docs/docs/images/vs-code-interactive-python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jayqi/reprexlite/b6a624a11b485073de8d680402a66756b65ea264/docs/docs/images/vs-code-interactive-python.png -------------------------------------------------------------------------------- /docs/docs/ipython-jupyter-magic.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "spoken-simulation", 6 | "metadata": {}, 7 | "source": [ 8 | "# IPython/Jupyter Magic" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "id": "coated-touch", 14 | "metadata": {}, 15 | "source": [ 16 | "Reprex-rendering is also available in IPython, Jupyter, and VS Code through an IPython cell magic. This functionality requires IPython to be installed at a minimum. (You can install both reprexlite and IPython together with `reprexlite[ipython]`.) \n", 17 | "\n", 18 | "To use, first load the extension:" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": 1, 24 | "id": "presidential-affiliation", 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "%load_ext reprexlite" 29 | ] 30 | }, 31 | { 32 | "cell_type": "markdown", 33 | "id": "impressed-rogers", 34 | "metadata": {}, 35 | "source": [ 36 | "and then simply use the `%%reprex` magic with a cell containing the code you want a reprex of." 37 | ] 38 | }, 39 | { 40 | "cell_type": "code", 41 | "execution_count": 2, 42 | "id": "driven-moderator", 43 | "metadata": {}, 44 | "outputs": [ 45 | { 46 | "name": "stdout", 47 | "output_type": "stream", 48 | "text": [ 49 | "```python\n", 50 | "from itertools import product\n", 51 | "\n", 52 | "grid = list(product([1, 2, 3], [8, 16]))\n", 53 | "grid\n", 54 | "#> [(1, 8), (1, 16), (2, 8), (2, 16), (3, 8), (3, 16)]\n", 55 | "list(zip(*grid))\n", 56 | "#> [(1, 1, 2, 2, 3, 3), (8, 16, 8, 16, 8, 16)]\n", 57 | "```\n", 58 | "\n", 59 | "Created at 2021-02-27 16:08:34 PST by [reprexlite](https://github.com/jayqi/reprexlite) v0.3.1\n", 60 | "\n" 61 | ] 62 | } 63 | ], 64 | "source": [ 65 | "%%reprex\n", 66 | "\n", 67 | "from itertools import product\n", 68 | "\n", 69 | "grid = list(product([1, 2, 3], [8, 16]))\n", 70 | "grid\n", 71 | "list(zip(*grid))" 72 | ] 73 | }, 74 | { 75 | "cell_type": "markdown", 76 | "id": "lesbian-beverage", 77 | "metadata": {}, 78 | "source": [ 79 | "That's it! The cell magic shares the same interface and command-line options as the CLI. " 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": 3, 85 | "id": "israeli-start", 86 | "metadata": {}, 87 | "outputs": [ 88 | { 89 | "name": "stdout", 90 | "output_type": "stream", 91 | "text": [ 92 | "```\n", 93 | "x = 2\n", 94 | "x + 2\n", 95 | "#> 4\n", 96 | "```\n", 97 | "\n" 98 | ] 99 | } 100 | ], 101 | "source": [ 102 | "%%reprex -v slack\n", 103 | "x = 2\n", 104 | "x + 2" 105 | ] 106 | }, 107 | { 108 | "cell_type": "markdown", 109 | "id": "amazing-fireplace", 110 | "metadata": {}, 111 | "source": [ 112 | "## Print Help Documentation\n", 113 | "\n", 114 | "You can use the `%reprex` line magic (single-`%`) to print out documentation." 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": 4, 120 | "id": "preliminary-amino", 121 | "metadata": {}, 122 | "outputs": [ 123 | { 124 | "name": "stdout", 125 | "output_type": "stream", 126 | "text": [ 127 | "reprexlite v0.3.1 IPython Magic\n", 128 | "\n", 129 | "Cell Magic Usage: %%reprex [OPTIONS]\n", 130 | "\n", 131 | " Render reproducible examples of Python code for sharing. Your code will be\n", 132 | " executed and the results will be embedded as comments below their associated\n", 133 | " lines.\n", 134 | "\n", 135 | " Additional markup will be added that is appropriate to the choice of venue\n", 136 | " option. For example, for the default `gh` venue for GitHub Flavored\n", 137 | " Markdown, the final reprex will look like:\n", 138 | "\n", 139 | " ----------------------------------------\n", 140 | " ```python\n", 141 | " arr = [1, 2, 3, 4, 5]\n", 142 | " [x + 1 for x in arr]\n", 143 | " #> [2, 3, 4, 5, 6]\n", 144 | " max(arr) - min(arr)\n", 145 | " #> 4\n", 146 | " ```\n", 147 | " \n", 148 | " Created at 2021-02-27 00:13:55 PST by [reprexlite](https://github.com/jayqi/reprexlite) v0.3.1\n", 149 | " ----------------------------------------\n", 150 | "\n", 151 | " The supported venue formats are:\n", 152 | " \n", 153 | " - gh : GitHub Flavored Markdown\n", 154 | " - so : StackOverflow, alias for gh\n", 155 | " - ds : Discourse, alias for gh\n", 156 | " - html : HTML\n", 157 | " - py : Python script\n", 158 | " - rtf : Rich Text Format\n", 159 | " - slack : Slack\n", 160 | "\n", 161 | "Options:\n", 162 | " -i, --infile PATH Read code from an input file instead via\n", 163 | " editor.\n", 164 | "\n", 165 | " -o, --outfile PATH Write output to file instead of printing to\n", 166 | " console.\n", 167 | "\n", 168 | " -v, --venue [gh|so|ds|html|py|rtf|slack]\n", 169 | " Output format appropriate to the venue where\n", 170 | " you plan to share this code. [default: gh]\n", 171 | "\n", 172 | " --advertise / --no-advertise Whether to include footer that credits\n", 173 | " reprexlite. If unspecified, will depend on\n", 174 | " specified venue's default.\n", 175 | "\n", 176 | " --session-info Whether to include details about session and\n", 177 | " installed packages.\n", 178 | "\n", 179 | " --style Autoformat code with black. Requires black to\n", 180 | " be installed.\n", 181 | "\n", 182 | " --comment TEXT Comment prefix to use for results returned by\n", 183 | " expressions. [default: #>]\n", 184 | "\n", 185 | " --old-results Keep old results, i.e., lines that match the\n", 186 | " prefix specified by the --comment option. If\n", 187 | " not using this option, then such lines are\n", 188 | " removed, meaning that an input that is a\n", 189 | " reprex will be effectively regenerated.\n", 190 | "\n" 191 | ] 192 | } 193 | ], 194 | "source": [ 195 | "%reprex" 196 | ] 197 | }, 198 | { 199 | "cell_type": "markdown", 200 | "id": "minor-wrong", 201 | "metadata": {}, 202 | "source": [ 203 | "## VS Code Interactive Python Windows" 204 | ] 205 | }, 206 | { 207 | "cell_type": "markdown", 208 | "id": "introductory-degree", 209 | "metadata": {}, 210 | "source": [ 211 | "If you're in VS Code and `ipykernel` is installed, you similarly use the `%%reprex` cell magic with [Python Interactive windows](https://code.visualstudio.com/docs/python/jupyter-support-py). For a file set to Python language mode, use `# %%` to mark an IPython cell that can then be run. Or you can open the Interactive window on its own via \"Jupyter: Create Interactive Window\" through the [Command Palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette). See [VS Code docs](https://code.visualstudio.com/docs/python/jupyter-support-py) for more info." 212 | ] 213 | }, 214 | { 215 | "cell_type": "markdown", 216 | "id": "significant-found", 217 | "metadata": {}, 218 | "source": [ 219 | "" 222 | ] 223 | }, 224 | { 225 | "cell_type": "markdown", 226 | "id": "virgin-resident", 227 | "metadata": {}, 228 | "source": [ 229 | "## Isolated Namespace" 230 | ] 231 | }, 232 | { 233 | "cell_type": "markdown", 234 | "id": "aboriginal-whale", 235 | "metadata": {}, 236 | "source": [ 237 | "Note that—just like other ways of rendering a reprex—your code is evaluated in an isolated namespace that is separate from the namespace of your IPython session or your notebook. That means, for example, variables defined in your notebook won't exist in your reprex." 238 | ] 239 | }, 240 | { 241 | "cell_type": "code", 242 | "execution_count": 5, 243 | "id": "cosmetic-oklahoma", 244 | "metadata": {}, 245 | "outputs": [], 246 | "source": [ 247 | "notebook_var = 2" 248 | ] 249 | }, 250 | { 251 | "cell_type": "code", 252 | "execution_count": 6, 253 | "id": "regular-checklist", 254 | "metadata": {}, 255 | "outputs": [ 256 | { 257 | "name": "stdout", 258 | "output_type": "stream", 259 | "text": [ 260 | "```python\n", 261 | "notebook_var\n", 262 | "#> Traceback (most recent call last):\n", 263 | "#> File \"/Users/jqi/repos/reprexlite/reprexlite/code.py\", line 69, in evaluate\n", 264 | "#> result = eval(str(self).strip(), scope, scope)\n", 265 | "#> File \"Name | 29 |Type | 30 |Description | 31 |
---|
{field.name}
{typenames(field.type)}
2+2
116 | #> 4
117 | """
118 | if config is None:
119 | config = ReprexConfig()
120 | advertise = config.advertise if config.advertise is not None else True
121 | out = []
122 | try:
123 | from pygments import highlight
124 | from pygments.formatters import HtmlFormatter
125 | from pygments.lexers import PythonLexer
126 |
127 | formatter = HtmlFormatter(style="friendly", lineanchors=True, linenos=True, wrapcode=True)
128 | out.append(f"")
129 | out.append(highlight(str(reprex_str), PythonLexer(), formatter))
130 | except ImportError:
131 | out.append(f"{reprex_str}
")
132 |
133 | if advertise:
134 | out.append(Advertisement().html().strip())
135 | if config.session_info:
136 | out.append("{SessionInfo()}
")
138 | out.append("Created at {self.timestamp} by " 251 | f'{self.pkg} {self.version}
' 252 | ) 253 | 254 | def code_comment(self) -> str: 255 | """Render reprexlite advertisement as a comment in Python code.""" 256 | return f"# {self.text()}" 257 | 258 | def text(self) -> str: 259 | """Render reprexlite advertisement in plain text.""" 260 | return f"Created at {self.timestamp} by {self.pkg} {self.version} <{self.url}>" 261 | -------------------------------------------------------------------------------- /reprexlite/ipython.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager, redirect_stdout 2 | import io 3 | import re 4 | from typing import Optional 5 | 6 | import reprexlite.cli 7 | from reprexlite.config import ReprexConfig 8 | from reprexlite.exceptions import IPythonNotFoundError 9 | from reprexlite.reprexes import Reprex 10 | from reprexlite.version import __version__ 11 | 12 | try: 13 | from IPython import InteractiveShell 14 | from IPython.core.magic import Magics, line_cell_magic, magics_class 15 | from IPython.core.release import version as ipython_version 16 | from IPython.core.usage import default_banner_parts 17 | from IPython.terminal.interactiveshell import TerminalInteractiveShell 18 | from IPython.terminal.ipapp import TerminalIPythonApp 19 | except ModuleNotFoundError as e: 20 | if e.name == "IPython": 21 | raise IPythonNotFoundError(*e.args, name="IPython") 22 | else: 23 | raise 24 | 25 | 26 | @contextmanager 27 | def patch_edit(input: str): 28 | """Patches typer.edit to return the input string instead of opening up the text editor. This 29 | is a trick to hook up the IPython cell magic's cell contents to the typer CLI app. 30 | """ 31 | 32 | def return_input(*args, **kwargs) -> str: 33 | return input 34 | 35 | original = reprexlite.cli.handle_editor 36 | setattr(reprexlite.cli, "handle_editor", return_input) 37 | yield 38 | setattr(reprexlite.cli, "handle_editor", original) 39 | 40 | 41 | @magics_class 42 | class ReprexMagics(Magics): 43 | @line_cell_magic 44 | def reprex(self, line: str, cell: Optional[str] = None): 45 | """reprex IPython magic. Use line magic %reprex to print help. Use cell magic %%reprex to 46 | render a reprex.""" 47 | # Line magic, print help 48 | if cell is None: 49 | with io.StringIO() as buffer, redirect_stdout(buffer): 50 | reprexlite.cli.app("--help") 51 | help_text = buffer.getvalue() 52 | help_text = re.sub(r"^Usage: reprex", r"Cell Magic Usage: %%reprex", help_text) 53 | print(f"reprexlite v{__version__} IPython Magic\n\n" + help_text) 54 | return 55 | # Cell magic, render reprex 56 | with patch_edit(cell): 57 | reprexlite.cli.app(line.split()) 58 | # print(stdout, end="") 59 | 60 | 61 | def load_ipython_extension(ipython: InteractiveShell): 62 | """Special function to register this module as an IPython extension. 63 | https://ipython.readthedocs.io/en/stable/config/extensions/#writing-extensions 64 | """ 65 | 66 | ipython.register_magics(ReprexMagics) 67 | 68 | 69 | ipython_banner_parts = [ 70 | default_banner_parts[0], 71 | f"reprexlite {__version__} -- Interactive reprex editor via IPython {ipython_version}.", 72 | ] 73 | 74 | 75 | class ReprexTerminalInteractiveShell(TerminalInteractiveShell): 76 | """Subclass of IPython's TerminalInteractiveShell that automatically executes all cells using 77 | reprexlite instead of normally.""" 78 | 79 | banner1 = "".join(ipython_banner_parts) # type: ignore 80 | _reprex_config: Optional[ReprexConfig] = None 81 | 82 | def run_cell(self, raw_cell: str, *args, **kwargs): 83 | # "exit()" and Ctrl+D short-circuit this and don't need to be handled 84 | if raw_cell != "exit": 85 | try: 86 | r = Reprex.from_input(raw_cell, config=self.reprex_config) 87 | print(r.render_and_format(terminal=True), end="") 88 | except Exception as e: 89 | print("ERROR: reprexlite has encountered an error while evaluating your input.") 90 | print(e, end="") 91 | 92 | # Store history 93 | self.history_manager.store_inputs(self.execution_count, raw_cell, raw_cell) # type: ignore 94 | self.execution_count += 1 95 | 96 | return None 97 | else: 98 | return super().run_cell(raw_cell, *args, **kwargs) 99 | 100 | @property 101 | def reprex_config(self) -> ReprexConfig: 102 | if self._reprex_config is None: 103 | self._reprex_config = ReprexConfig() 104 | return self._reprex_config 105 | 106 | 107 | class ReprexTerminalIPythonApp(TerminalIPythonApp): 108 | """Subclass of TerminalIPythonApp that launches ReprexTerminalInteractiveShell.""" 109 | 110 | interactive_shell_class = ReprexTerminalInteractiveShell # type: ignore 111 | 112 | @classmethod 113 | def set_reprex_config(cls, config: ReprexConfig): 114 | """Set the reprex config bound on the interactive shell.""" 115 | cls.interactive_shell_class._reprex_config = config # type: ignore 116 | -------------------------------------------------------------------------------- /reprexlite/parsing.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Iterator, Optional, Tuple 3 | 4 | from reprexlite.exceptions import ( 5 | InvalidInputPrefixesError, 6 | NoPrefixMatchError, 7 | PromptLengthMismatchError, 8 | UnexpectedError, 9 | ) 10 | 11 | 12 | def removeprefix(s: str, prefix: str) -> str: 13 | """Utility function to strip a prefix from a string, whether or not there is a single 14 | whitespace character. 15 | """ 16 | if s.startswith(prefix + " "): 17 | return s[len(prefix) + 1 :] 18 | elif s.startswith(prefix): 19 | return s[len(prefix) :] 20 | else: 21 | raise UnexpectedError( # pragma: nocover 22 | "removeprefix should not be called on input that does not match the prefix. " 23 | ) 24 | 25 | 26 | class LineType(Enum): 27 | """An enum for different types of lines in text input to [parse][reprexlite.parsing.parse]. 28 | 29 | Args: 30 | CODE (str): Line is code. 31 | RESULT (str): Line is the result of executing code. 32 | """ 33 | 34 | CODE = "CODE" 35 | RESULT = "RESULT" 36 | 37 | 38 | def parse( 39 | input: str, 40 | prompt: Optional[str], 41 | continuation: Optional[str], 42 | comment: Optional[str], 43 | ) -> Iterator[Tuple[str, LineType]]: 44 | """Generator function that parses input into lines of code or results. 45 | 46 | Args: 47 | input (str): String to parse 48 | prompt (Optional[str]): Prefix used as primary prompt of code lines 49 | continuation (Optional[str]): Prefix used as continuation prompt of code lines 50 | comment (Optional[str]): Prefix used to indicate result lines 51 | 52 | Yields: 53 | Iterator[Tuple[str, LineType]]: tuple of parsed line and line type 54 | """ 55 | if not any((prompt, continuation, comment)): 56 | raise InvalidInputPrefixesError( 57 | "Cannot parse input if all of prompt, continuation, and comment are blank." 58 | ) 59 | if len(prompt or "") != len(continuation or ""): 60 | raise PromptLengthMismatchError( 61 | f"Primary prompt ('{prompt}') and continuation prompt ('{continuation}') must be " 62 | "equal lengths." 63 | ) 64 | 65 | for line_no, line in enumerate(input.split("\n")): 66 | # Case 1: With Prompt/Continuation, no Comment (e.g., doctest style) 67 | if prompt and continuation and not comment: 68 | if line.startswith(prompt): 69 | yield removeprefix(line, prompt), LineType.CODE 70 | elif line.startswith(continuation): 71 | yield removeprefix(line, continuation), LineType.CODE 72 | elif line == "": 73 | yield line, LineType.CODE 74 | else: 75 | yield line, LineType.RESULT 76 | 77 | # Case 2: No Prompt or Continuation, with Comment (e.g., reprex style) 78 | elif not prompt and not continuation and comment: 79 | if line.startswith(comment): 80 | yield removeprefix(line, comment), LineType.RESULT 81 | else: 82 | yield line, LineType.CODE 83 | 84 | # Case 3: Both Prompt/Contiuation and Comment 85 | elif prompt and continuation and comment: 86 | if line.startswith(prompt): 87 | yield removeprefix(line, prompt), LineType.CODE 88 | elif line.startswith(continuation): 89 | yield removeprefix(line, continuation), LineType.CODE 90 | elif line.startswith(comment): 91 | yield removeprefix(line, comment), LineType.RESULT 92 | elif line == "": 93 | yield line, LineType.CODE 94 | else: 95 | raise NoPrefixMatchError( 96 | f"Line {line_no + 1} does not match any of prompt, continuation, or comment " 97 | f"prefixes: '{line}'" 98 | ) 99 | 100 | else: 101 | raise UnexpectedError("Unexpected case when using parse.") # pragma: nocover 102 | 103 | 104 | def parse_reprex(input: str) -> Iterator[Tuple[str, LineType]]: 105 | """Wrapper around [parse][reprexlite.parsing.parse] for parsing reprex-style input.""" 106 | yield from parse(input=input, prompt=None, continuation=None, comment="#>") 107 | 108 | 109 | def parse_doctest(input: str) -> Iterator[Tuple[str, LineType]]: 110 | """Wrapper around [parse][reprexlite.parsing.parse] for parsing doctest-style input.""" 111 | yield from parse(input=input, prompt=">>>", continuation="...", comment=None) 112 | 113 | 114 | def auto_parse(input: str) -> Iterator[Tuple[str, LineType]]: 115 | """Automatically parse input that is either doctest-style and reprex-style.""" 116 | if any(line.startswith(">>>") for line in input.split("\n")): 117 | yield from parse_doctest(input) 118 | else: 119 | yield from parse_reprex(input) 120 | -------------------------------------------------------------------------------- /reprexlite/reprexes.py: -------------------------------------------------------------------------------- 1 | from contextlib import redirect_stdout 2 | import dataclasses 3 | from io import StringIO 4 | from itertools import chain 5 | import os 6 | from pathlib import Path 7 | from pprint import pformat 8 | import traceback 9 | from typing import Any, Dict, List, Optional, Sequence, Tuple, Union 10 | 11 | try: 12 | from typing import Self # type: ignore # Python 3.11+ 13 | except ImportError: 14 | from typing_extensions import Self 15 | 16 | import libcst as cst 17 | 18 | from reprexlite.config import ParsingMethod, ReprexConfig 19 | from reprexlite.exceptions import BlackNotFoundError, InputSyntaxError, UnexpectedError 20 | from reprexlite.formatting import formatter_registry 21 | from reprexlite.parsing import LineType, auto_parse, parse 22 | 23 | 24 | @dataclasses.dataclass 25 | class RawResult: 26 | """Class that holds the result of evaluated code. Use `str(...)` on an instance to produce a 27 | pretty-formatted comment block representation of the result. 28 | 29 | Args: 30 | config (ReprexConfig): Configuration for formatting and parsing 31 | raw (Any): Some Python object that is the raw return value of evaluated Python code. 32 | stdout (str): Standard output from evaluated Python code. 33 | """ 34 | 35 | config: ReprexConfig 36 | raw: Any 37 | stdout: Optional[str] 38 | 39 | def __str__(self) -> str: 40 | if not self: 41 | raise UnexpectedError("Should not print a RawResult if it tests False.") 42 | lines = [] 43 | if self.stdout: 44 | lines.extend(self.stdout.split("\n")) 45 | if self.raw is not None: 46 | lines.extend(pformat(self.raw, indent=2, width=77).split("\n")) 47 | if self.config.comment: 48 | return "\n".join(self.config.comment + " " + line for line in lines) 49 | else: 50 | return "\n".join(lines) 51 | 52 | def __bool__(self) -> bool: 53 | """Tests whether instance contains anything to print.""" 54 | return not (self.raw is None and self.stdout is None) 55 | 56 | def __repr__(self) -> str: 57 | return ( 58 | f"{self.render_and_format()}
")
448 | return "\n".join(out)
449 |
450 |
451 | def to_snippet(s: str, n: int) -> str:
452 | if len(s) <= n:
453 | return rf"{s}"
454 | else:
455 | return rf"{s[:n]}..."
456 |
457 |
458 | def reprex(
459 | input: str,
460 | outfile: Optional[Union[str, os.PathLike]] = None,
461 | print_: bool = True,
462 | terminal: bool = False,
463 | config: Optional[ReprexConfig] = None,
464 | **kwargs,
465 | ) -> Reprex:
466 | """A convenient functional interface to render reproducible examples of Python code for
467 | sharing. This function will evaluate your code and, by default, print out your code with the
468 | evaluated results embedded as comments, formatted with additional markup appropriate to the
469 | sharing venue set by the `venue` keyword argument. The function returns an instance of
470 | [`Reprex`][reprexlite.reprexes.Reprex] which holds the relevant data.
471 |
472 | For example, for the `gh` venue for GitHub Flavored Markdown, you'll get a reprex whose
473 | formatted output looks like:
474 |
475 | ````
476 | ```python
477 | x = 2
478 | x + 2
479 | #> 4
480 | ```
481 |
482 | Created at 2021-02-15 16:58:47 PST by [reprexlite](https://github.com/jayqi/reprexlite)
483 | ````
484 |
485 |
486 | Args:
487 | input (str): Input code to create a reprex for.
488 | outfile (Optional[str | os.PathLike]): If provided, path to write formatted reprex
489 | output to. Defaults to None, which does not write to any file.
490 | print_ (bool): Whether to print formatted reprex output to console.
491 | terminal (bool): Whether currently in a terminal. If true, will automatically apply code
492 | highlighting if pygments is installed.
493 | config (Optional[ReprexConfig]): Instance of the configuration dataclass. Default of none
494 | will instantiate one with default values.
495 | **kwargs: Configuration options from [ReprexConfig][reprexlite.config.ReprexConfig]. Any
496 | provided values will override values from provided config or the defaults.
497 |
498 | Returns:
499 | (Reprex) Reprex instance
500 | """ # noqa: E501
501 |
502 | if config is None:
503 | config = ReprexConfig(**kwargs)
504 | else:
505 | config = dataclasses.replace(config, **kwargs)
506 |
507 | config = ReprexConfig(**kwargs)
508 | if config.venue in ["html", "rtf"]:
509 | # Don't screw up output file or lexing for HTML and RTF with terminal syntax highlighting
510 | terminal = False
511 | r = Reprex.from_input(input, config=config)
512 | output = r.render_and_format(terminal=terminal)
513 | if outfile is not None:
514 | with Path(outfile).open("w") as fp:
515 | fp.write(r.render_and_format(terminal=False))
516 | if print_:
517 | print(output)
518 | return r
519 |
--------------------------------------------------------------------------------
/reprexlite/session_info.py:
--------------------------------------------------------------------------------
1 | import importlib.metadata
2 | import platform
3 | from typing import List, Tuple
4 |
5 |
6 | class SessionInfo:
7 | """Class for pretty-formatting Python session info. Includes details about your Python version,
8 | your operating system, and the Python packages installed in your current environment.
9 |
10 | Attributes:
11 | python_version (str): Python version for current session
12 | python_build_date (str): Date
13 | os (str): OS information for current session
14 | packages (List[Package]): List of Python packages installed in current virtual environment.
15 | """
16 |
17 | def __init__(self) -> None:
18 | self.python_version: str = platform.python_version()
19 | self.python_build_date: str = platform.python_build()[1]
20 |
21 | self.os: str = platform.platform()
22 | self.packages: List[Package] = [
23 | Package(distr) for distr in importlib.metadata.Distribution.discover()
24 | ]
25 |
26 | def __str__(self) -> str:
27 | lines = ["-- Session Info --" + "-" * 60]
28 | lines += tabulate(
29 | [
30 | ("version", f"Python {self.python_version} ({self.python_build_date})"),
31 | ("os", self.os),
32 | ]
33 | )
34 | lines += ["-- Packages --" + "-" * 64]
35 | lines += tabulate([(pkg.name, pkg.version) for pkg in sorted(self.packages)])
36 | return "\n".join(lines).strip()
37 |
38 |
39 | class Package:
40 | """Interface for adapting [`importlib.metadata.Distribution`](https://docs.python.org/3/library/importlib.metadata.html#distributions)
41 | instances for introspection by [`SessionInfo`][reprexlite.session_info.SessionInfo].
42 | """ # noqa: E501
43 |
44 | def __init__(self, distribution: importlib.metadata.Distribution):
45 | self.distribution = distribution
46 |
47 | @property
48 | def name(self) -> str:
49 | return self.distribution.metadata["Name"]
50 |
51 | @property
52 | def version(self) -> str:
53 | return self.distribution.version
54 |
55 | def __lt__(self, other) -> bool:
56 | if isinstance(other, Package):
57 | return self.name < other.name
58 | return NotImplemented # pragma: nocover
59 |
60 |
61 | def tabulate(rows: List[Tuple[str, str]]) -> List[str]:
62 | """Utility function for printing a two-column table as text with whitespace padding.
63 |
64 | Args:
65 | rows (List[Tuple[str, str]]): Rows of table as tuples of (left cell, right cell)
66 |
67 | Returns:
68 | Rows of table formatted as strings with whitespace padding
69 | """
70 | left_max = max(len(row[0]) for row in rows)
71 | out = []
72 | for left, right in rows:
73 | padding = (left_max + 1 - len(left)) * " "
74 | out.append(left + padding + right)
75 | return out
76 |
--------------------------------------------------------------------------------
/reprexlite/version.py:
--------------------------------------------------------------------------------
1 | import importlib.metadata
2 |
3 | __version__ = importlib.metadata.version(__name__.split(".", 1)[0])
4 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | -e .[black,pygments,ipython]
2 |
3 | black
4 | build
5 | mdx_truly_sane_lists==1.3
6 | mike
7 | mkdocs>=1.2.2
8 | mkdocs-jupyter
9 | mkdocs-macros-plugin
10 | mkdocs-material>=7.2.6
11 | mkdocstrings[python-legacy]>=0.15.2
12 | mypy
13 | pip>=21.3
14 | py-markdown-table==0.3.3
15 | pytest
16 | pytest-cov
17 | ruff
18 | tqdm
19 | typenames
20 | wheel
21 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jayqi/reprexlite/b6a624a11b485073de8d680402a66756b65ea264/tests/__init__.py
--------------------------------------------------------------------------------
/tests/assets/ad/ds.md:
--------------------------------------------------------------------------------
1 | ```python
2 | x = 2
3 | x + 2
4 | #> 4
5 | ```
6 |
7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION
8 |
--------------------------------------------------------------------------------
/tests/assets/ad/gh.md:
--------------------------------------------------------------------------------
1 | ```python
2 | x = 2
3 | x + 2
4 | #> 4
5 | ```
6 |
7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION
8 |
--------------------------------------------------------------------------------
/tests/assets/ad/html.html:
--------------------------------------------------------------------------------
1 |
76 |
82 |
83 | Created at DATETIME by reprexlite vVERSION
84 | -------------------------------------------------------------------------------- /tests/assets/ad/py.py: -------------------------------------------------------------------------------- 1 | x = 2 2 | x + 2 3 | #> 4 4 | 5 | # Created at DATETIME by reprexlite vVERSIONCreated at DATETIME by reprexlite vVERSION
84 | -------------------------------------------------------------------------------- /tests/assets/no_ad/ds.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/no_ad/gh.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/no_ad/html.html: -------------------------------------------------------------------------------- 1 | 76 | 82 | 83 | -------------------------------------------------------------------------------- /tests/assets/no_ad/py.py: -------------------------------------------------------------------------------- 1 | x = 2 2 | x + 2 3 | #> 4 4 | -------------------------------------------------------------------------------- /tests/assets/no_ad/rtf.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\uc0\deff0{\fonttbl{\f0\fmodern\fprq1\fcharset0;}} 2 | {\colortbl; 3 | \red187\green187\blue187; 4 | \red61\green123\blue123; 5 | \red156\green101\blue0; 6 | \red0\green128\blue0; 7 | \red176\green0\blue64; 8 | \red102\green102\blue102; 9 | \red170\green34\blue255; 10 | \red0\green0\blue255; 11 | \red203\green63\blue56; 12 | \red25\green23\blue124; 13 | \red136\green0\blue0; 14 | \red118\green118\blue0; 15 | \red113\green113\blue113; 16 | \red104\green120\blue34; 17 | \red186\green33\blue33; 18 | \red164\green90\blue119; 19 | \red170\green93\blue31; 20 | \red0\green0\blue128; 21 | \red128\green0\blue128; 22 | \red160\green0\blue0; 23 | \red0\green132\blue0; 24 | \red228\green0\blue0; 25 | \red0\green68\blue221; 26 | \red255\green0\blue0; 27 | } 28 | \f0\sa0 29 | \dntblnsbdb 30 | x {\cf6 =} {\cf6 2}{\cf1 \par} 31 | x {\cf6 +} {\cf6 2}{\cf1 \par} 32 | {\cf2\i #> 4}{\cf1 \par} 33 | } 34 | 35 | -------------------------------------------------------------------------------- /tests/assets/no_ad/slack.txt: -------------------------------------------------------------------------------- 1 | ``` 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/no_ad/so.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/py.py: -------------------------------------------------------------------------------- 1 | x = 2 2 | x + 2 3 | #> 4 4 | -------------------------------------------------------------------------------- /tests/assets/rtf.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\uc0\deff0{\fonttbl{\f0\fmodern\fprq1\fcharset0;}} 2 | {\colortbl; 3 | \red187\green187\blue187; 4 | \red61\green123\blue123; 5 | \red156\green101\blue0; 6 | \red0\green128\blue0; 7 | \red176\green0\blue64; 8 | \red102\green102\blue102; 9 | \red170\green34\blue255; 10 | \red0\green0\blue255; 11 | \red203\green63\blue56; 12 | \red25\green23\blue124; 13 | \red136\green0\blue0; 14 | \red118\green118\blue0; 15 | \red113\green113\blue113; 16 | \red104\green120\blue34; 17 | \red186\green33\blue33; 18 | \red164\green90\blue119; 19 | \red170\green93\blue31; 20 | \red0\green0\blue128; 21 | \red128\green0\blue128; 22 | \red160\green0\blue0; 23 | \red0\green132\blue0; 24 | \red228\green0\blue0; 25 | \red0\green68\blue221; 26 | \red255\green0\blue0; 27 | } 28 | \f0\sa0 29 | \dntblnsbdb 30 | x {\cf6 =} {\cf6 2}{\cf1 \par} 31 | x {\cf6 +} {\cf6 2}{\cf1 \par} 32 | {\cf2\i #> 4}{\cf1 \par} 33 | } 34 | 35 | -------------------------------------------------------------------------------- /tests/assets/session_info/ds.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | 9 |Created at DATETIME by reprexlite vVERSION
84 |-- Session Info --------------------------------------------------------------
86 | version Python 3.x.y (Jan 01 2020 03:33:33)
87 | os GLaDOS
88 | -- Packages ------------------------------------------------------------------
89 | datatable 1.0
90 | ggplot2 2.0
91 | pkgnet 3.0
92 | x = 2
70 | x + 2
71 | #> 4
72 | Created at DATETIME by reprexlite vVERSION
73 | """ # noqa: E501 74 | ) 75 | assert_str_equals(expected, str(actual)) 76 | assert str(actual).endswith("\n") 77 | 78 | 79 | def test_rtf_no_pygments(patch_datetime, patch_version, no_pygments): 80 | with pytest.raises(PygmentsNotFoundError): 81 | r = Reprex.from_input(INPUT, ReprexConfig(venue="rtf")) 82 | r.render_and_format() 83 | 84 | 85 | @pytest.fixture 86 | def pygments_bad_dependency(monkeypatch): 87 | """ModuleNotFoundError inside pygments""" 88 | module_name = "dependency_of_pygments" 89 | import_orig = builtins.__import__ 90 | 91 | def mocked_import(name, *args): 92 | if name.startswith("pygments"): 93 | raise ModuleNotFoundError(name=module_name) 94 | return import_orig(name, *args) 95 | 96 | monkeypatch.setattr(builtins, "__import__", mocked_import) 97 | yield module_name 98 | 99 | 100 | def test_rtf_pygments_bad_dependency(patch_datetime, patch_version, pygments_bad_dependency): 101 | """Test that a bad import inside pygments does not trigger PygmentsNotFoundError""" 102 | with pytest.raises(ModuleNotFoundError) as exc_info: 103 | r = Reprex.from_input(INPUT, ReprexConfig(venue="rtf")) 104 | r.render_and_format() 105 | assert not isinstance(exc_info.type, PygmentsNotFoundError) 106 | assert exc_info.value.name != "pygments" 107 | assert exc_info.value.name == pygments_bad_dependency 108 | 109 | 110 | def test_registry_methods(): 111 | keys = list(formatter_registry.keys()) 112 | assert keys 113 | values = list(formatter_registry.values()) 114 | assert values 115 | items = list(formatter_registry.items()) 116 | assert items 117 | assert items == list(zip(keys, values)) 118 | -------------------------------------------------------------------------------- /tests/test_ipython_editor.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import importlib 3 | import sys 4 | from textwrap import dedent 5 | 6 | from IPython.testing import globalipapp 7 | import pytest 8 | 9 | from reprexlite.exceptions import IPythonNotFoundError 10 | from reprexlite.ipython import ReprexTerminalInteractiveShell 11 | from reprexlite.reprexes import Reprex 12 | from tests.utils import remove_ansi_escape 13 | 14 | 15 | @pytest.fixture() 16 | def reprexlite_ipython(monkeypatch): 17 | monkeypatch.setattr(globalipapp, "TerminalInteractiveShell", ReprexTerminalInteractiveShell) 18 | monkeypatch.setattr(ReprexTerminalInteractiveShell, "_instance", None) 19 | ipython = globalipapp.start_ipython() 20 | yield ipython 21 | ipython.run_cell("exit") 22 | del globalipapp.start_ipython.already_called 23 | 24 | 25 | @pytest.fixture() 26 | def no_ipython(monkeypatch): 27 | import_orig = builtins.__import__ 28 | 29 | def mocked_import(name, *args): 30 | if name.startswith("IPython"): 31 | raise ModuleNotFoundError(name="IPython") 32 | return import_orig(name, *args) 33 | 34 | monkeypatch.setattr(builtins, "__import__", mocked_import) 35 | 36 | 37 | @pytest.fixture() 38 | def ipython_bad_dependency(monkeypatch): 39 | module_name = "dependency_of_ipython" 40 | import_orig = builtins.__import__ 41 | 42 | def mocked_import(name, *args): 43 | if name.startswith("IPython"): 44 | raise ModuleNotFoundError(name=module_name) 45 | return import_orig(name, *args) 46 | 47 | monkeypatch.setattr(builtins, "__import__", mocked_import) 48 | yield module_name 49 | 50 | 51 | def test_ipython_editor(reprexlite_ipython, capsys): 52 | input = dedent( 53 | """\ 54 | x = 2 55 | x + 2 56 | """ 57 | ) 58 | reprexlite_ipython.run_cell(input) 59 | captured = capsys.readouterr() 60 | r = Reprex.from_input(input) 61 | expected = r.render_and_format() 62 | 63 | print("\n---EXPECTED---\n") 64 | print(expected) 65 | print("\n---ACTUAL-----\n") 66 | print(captured.out) 67 | print("\n--------------\n") 68 | assert remove_ansi_escape(captured.out) == expected 69 | 70 | 71 | def test_no_ipython_error(no_ipython, monkeypatch): 72 | monkeypatch.delitem(sys.modules, "reprexlite.ipython") 73 | with pytest.raises(IPythonNotFoundError): 74 | importlib.import_module("reprexlite.ipython") 75 | 76 | 77 | def test_bad_ipython_dependency(ipython_bad_dependency, monkeypatch): 78 | """Test that a bad import inside IPython does not trigger IPythonNotFoundError""" 79 | monkeypatch.delitem(sys.modules, "reprexlite.ipython") 80 | with pytest.raises(ModuleNotFoundError) as exc_info: 81 | importlib.import_module("reprexlite.ipython") 82 | assert not isinstance(exc_info.type, IPythonNotFoundError) 83 | assert exc_info.value.name != "IPython" 84 | assert exc_info.value.name == ipython_bad_dependency 85 | -------------------------------------------------------------------------------- /tests/test_ipython_magics.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import importlib 3 | import sys 4 | from textwrap import dedent 5 | 6 | from IPython.terminal.interactiveshell import TerminalInteractiveShell 7 | from IPython.testing import globalipapp 8 | import pytest 9 | 10 | from reprexlite.config import ReprexConfig 11 | from reprexlite.reprexes import Reprex 12 | 13 | 14 | @pytest.fixture() 15 | def ipython(monkeypatch): 16 | monkeypatch.setattr(TerminalInteractiveShell, "_instance", None) 17 | ipython = globalipapp.start_ipython() 18 | ipython.run_line_magic("load_ext", "reprexlite") 19 | yield ipython 20 | ipython.run_cell("exit") 21 | del globalipapp.start_ipython.already_called 22 | 23 | 24 | @pytest.fixture() 25 | def no_ipython(monkeypatch): 26 | import_orig = builtins.__import__ 27 | 28 | def mocked_import(name, *args): 29 | if name.startswith("IPython"): 30 | raise ModuleNotFoundError(name="IPython") 31 | return import_orig(name, *args) 32 | 33 | monkeypatch.setattr(builtins, "__import__", mocked_import) 34 | 35 | 36 | def test_line_magic(ipython, capsys): 37 | ipython.run_line_magic("reprex", line="") 38 | captured = capsys.readouterr() 39 | print(captured.out) 40 | assert r"Cell Magic Usage: %%reprex" in captured.out 41 | 42 | 43 | def test_cell_magic(ipython, capsys): 44 | input = dedent( 45 | """\ 46 | x = 2 47 | x + 2 48 | """ 49 | ) 50 | ipython.run_cell_magic("reprex", line="--no-advertise --session-info", cell=input) 51 | captured = capsys.readouterr() 52 | 53 | r = Reprex.from_input(input, config=ReprexConfig(advertise=False, session_info=True)) 54 | expected = r.render_and_format(terminal=True) 55 | 56 | print("\n---EXPECTED---\n") 57 | print(expected) 58 | print("\n---ACTUAL-----\n") 59 | print(captured.out) 60 | print("\n--------------\n") 61 | 62 | assert captured.out == expected 63 | 64 | 65 | def test_no_ipython(no_ipython, monkeypatch): 66 | """Tests that not having ipython installed should not cause any import errors.""" 67 | monkeypatch.delitem(sys.modules, "reprexlite") 68 | monkeypatch.delitem(sys.modules, "reprexlite.ipython") 69 | importlib.import_module("reprexlite") 70 | -------------------------------------------------------------------------------- /tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | 5 | from reprexlite.exceptions import ( 6 | InvalidInputPrefixesError, 7 | NoPrefixMatchError, 8 | PromptLengthMismatchError, 9 | ) 10 | from reprexlite.parsing import LineType, auto_parse, parse, parse_doctest, parse_reprex 11 | 12 | 13 | def test_parse_reprex(): 14 | input = """\ 15 | import math 16 | 17 | def sqrt(x): 18 | return math.sqrt(x) 19 | 20 | # Here's a comment 21 | sqrt(4) 22 | #> 2.0 23 | """ 24 | 25 | actual = list(parse_reprex(dedent(input))) 26 | expected = [ 27 | ("import math", LineType.CODE), 28 | ("", LineType.CODE), 29 | ("def sqrt(x):", LineType.CODE), 30 | (" return math.sqrt(x)", LineType.CODE), 31 | ("", LineType.CODE), 32 | ("# Here's a comment", LineType.CODE), 33 | ("sqrt(4)", LineType.CODE), 34 | ("2.0", LineType.RESULT), 35 | ("", LineType.CODE), 36 | ] 37 | 38 | assert actual == expected 39 | 40 | 41 | def test_parse_doctest(): 42 | input = """\ 43 | >>> import math 44 | >>> 45 | >>> def sqrt(x): 46 | ... return math.sqrt(x) 47 | ... 48 | >>> # Here's a comment 49 | >>> sqrt(4) 50 | 2.0 51 | """ 52 | 53 | actual = list(parse_doctest(dedent(input))) 54 | expected = [ 55 | ("import math", LineType.CODE), 56 | ("", LineType.CODE), 57 | ("def sqrt(x):", LineType.CODE), 58 | (" return math.sqrt(x)", LineType.CODE), 59 | ("", LineType.CODE), 60 | ("# Here's a comment", LineType.CODE), 61 | ("sqrt(4)", LineType.CODE), 62 | ("2.0", LineType.RESULT), 63 | ("", LineType.CODE), 64 | ] 65 | 66 | assert actual == expected 67 | 68 | 69 | def test_parse_with_both_prompt_and_comment(): 70 | input = """\ 71 | >>> import math 72 | >>> 73 | >>> def sqrt(x): 74 | ... return math.sqrt(x) 75 | ... 76 | >>> # Here's a comment 77 | >>> sqrt(4) 78 | #> 2.0 79 | """ 80 | 81 | actual = list(parse(dedent(input), prompt=">>>", continuation="...", comment="#>")) 82 | expected = [ 83 | ("import math", LineType.CODE), 84 | ("", LineType.CODE), 85 | ("def sqrt(x):", LineType.CODE), 86 | (" return math.sqrt(x)", LineType.CODE), 87 | ("", LineType.CODE), 88 | ("# Here's a comment", LineType.CODE), 89 | ("sqrt(4)", LineType.CODE), 90 | ("2.0", LineType.RESULT), 91 | ("", LineType.CODE), 92 | ] 93 | 94 | assert actual == expected 95 | 96 | 97 | def test_parse_all_blank_prefixes(): 98 | input = """\ 99 | 2+2 100 | """ 101 | with pytest.raises(InvalidInputPrefixesError): 102 | list(parse(dedent(input), prompt=None, continuation=None, comment=None)) 103 | 104 | 105 | def test_auto_parse(): 106 | input_reprex = """\ 107 | import math 108 | 109 | def sqrt(x): 110 | return math.sqrt(x) 111 | 112 | # Here's a comment 113 | sqrt(4) 114 | #> 2.0 115 | """ 116 | 117 | input_doctest = """\ 118 | >>> import math 119 | >>> 120 | >>> def sqrt(x): 121 | ... return math.sqrt(x) 122 | ... 123 | >>> # Here's a comment 124 | >>> sqrt(4) 125 | 2.0 126 | """ 127 | 128 | expected = [ 129 | ("import math", LineType.CODE), 130 | ("", LineType.CODE), 131 | ("def sqrt(x):", LineType.CODE), 132 | (" return math.sqrt(x)", LineType.CODE), 133 | ("", LineType.CODE), 134 | ("# Here's a comment", LineType.CODE), 135 | ("sqrt(4)", LineType.CODE), 136 | ("2.0", LineType.RESULT), 137 | ("", LineType.CODE), 138 | ] 139 | 140 | actual_reprex = list(auto_parse(dedent(input_reprex))) 141 | assert actual_reprex == expected 142 | 143 | actual_doctest = list(auto_parse(dedent(input_doctest))) 144 | assert actual_doctest == expected 145 | 146 | 147 | def test_prompt_continuation_length_mismatch(): 148 | with pytest.raises(PromptLengthMismatchError): 149 | next(parse("2+2", prompt="123", continuation="1234", comment=None)) 150 | with pytest.raises(PromptLengthMismatchError): 151 | next(parse("", prompt=">>>", continuation=None, comment=None)) 152 | with pytest.raises(PromptLengthMismatchError): 153 | next(parse("", prompt=None, continuation="...", comment=None)) 154 | 155 | 156 | def test_no_prefix_match_error(): 157 | with pytest.raises(NoPrefixMatchError): 158 | list(parse("2+2", prompt=">>>", continuation=">>>", comment="#>")) 159 | -------------------------------------------------------------------------------- /tests/test_session_info.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from reprexlite.session_info import SessionInfo 4 | 5 | 6 | def test_session_info(): 7 | session_info = str(SessionInfo()) 8 | assert session_info 9 | assert platform.python_version() in session_info 10 | assert "pytest" in session_info 11 | 12 | lines = session_info.split("\n") 13 | for line in lines: 14 | assert len(line) <= 80 15 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any 3 | 4 | # https://stackoverflow.com/a/14693789/5957621 5 | ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 6 | 7 | 8 | def remove_ansi_escape(s: str) -> str: 9 | return ANSI_ESCAPE_REGEX.sub("", s) 10 | 11 | 12 | def assert_str_equals(expected: str, actual: str): 13 | """Tests that strings are equivalent and prints out both if failure.""" 14 | to_print = "\n".join( 15 | [ 16 | "", 17 | "---EXPECTED---", 18 | expected, 19 | "---ACTUAL-----", 20 | actual, 21 | "--------------", 22 | ] 23 | ) 24 | assert expected == actual, to_print 25 | 26 | 27 | def assert_equals(left: Any, right: Any): 28 | """Tests equals in both directions""" 29 | assert left == right 30 | assert right == left 31 | 32 | 33 | def assert_not_equals(left: Any, right: Any): 34 | """Tests not equals in both directions""" 35 | assert left != right 36 | assert right != left 37 | --------------------------------------------------------------------------------