diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b310f14..4d18ec3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,7 +35,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e646a..909205b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This release involves major changes to reprexlite. There is a significant refactoring of the library internals and also many changes to the API. This enabled new feature and more customizability. +_This release also removes support for Python 3.6, 3.7, and 3.8._ + ### CLI and IPython User Interfaces #### Added @@ -17,6 +19,7 @@ This release involves major changes to reprexlite. There is a significant refact - A new `--parsing-method` option controls input-parsing behavior. - The default value `auto` can automatically handle "reprex-style" input as well as "doctest-style`/Python REPL input. - A value `declared` will use the values of `--prompt`, `--continuation`, and `--comment` for parsing input in addition to styling output. To handle input and output with different styes, you can override input-side values with the `--input-prompt`, `--input-continuation`, and `--input-comment` options. +- Added support for configuration files, including support for `[tool.reprexlite]` in `pyproject.toml` files and for user-level configuration. See ["Configuration"](https://jayqi.github.io/reprexlite/stable/configuration/#configuration-files) for more details. #### Changed @@ -45,9 +48,9 @@ This release involves major changes to reprexlite. There is a significant refact #### Changed - Changed formatting abstractions in `reprexlite.formatting` module. - - Rather than `*Reprex` classes that encapsulate reprex data, we now have `*Formatter` classes and take a rendered reprex output string as input to a `format` class method that appropriately prepares the reprex output for a venue, such as adding venue-specific markup. - - The `venues_dispatcher` dictionary in `reprexlite.formatting` is now a `formatter_registry` dictionary. - - Formatters are added to the registry using a `register_formatter` decorator instead of being hard-coded. + - Rather than `*Reprex` classes that encapsulate reprex data, we now have formatter callables and take a rendered reprex output string as input and appropriately prepares the reprex output for a venue, such as adding venue-specific markup. + - The `venues_dispatcher` dictionary in `reprexlite.formatting` is now a `formatter_registry` dictionary-like. + - Formatters are added to the registry using a `formatter_registry.register` decorator instead of being hard-coded. #### Removed @@ -58,8 +61,9 @@ This release involves major changes to reprexlite. There is a significant refact #### Added +- Added a "Rendering and Output Venues" page to the documentation that documents the different formatting options with examples. +- Added a "Configuration" page to the documentation that provides a reference for configuration options and documents how to use configuration files. - Added an "Alternatives" page to the documentation that documents alternative tools. -- Added a "Venues Formatting" page to the documentation that documents the different formatting options with examples. #### Changed diff --git a/README.md b/README.md index dc474f6..c700846 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ [![tests](https://github.com/jayqi/reprexlite/workflows/tests/badge.svg?branch=main)](https://github.com/jayqi/reprexlite/actions?query=workflow%3Atests+branch%3Amain) [![codecov](https://codecov.io/gh/jayqi/reprexlite/branch/main/graph/badge.svg)](https://codecov.io/gh/jayqi/reprexlite) -**reprexlite** is a tool for rendering **repr**oducible **ex**amples of Python code for sharing. With a [convenient CLI](#command-line-interface) and lightweight dependencies, you can quickly get it up and running in any virtual environment. It has an optional [IPython extension with cell magic](#ipythonjupyter-cell-magic) for easy use in Jupyter or VS Code. This project is inspired by R's [reprex](https://reprex.tidyverse.org/) package. +**reprexlite** is a tool for rendering **repr**oducible **ex**amples of Python code for sharing. With a [convenient CLI](#command-line-interface) and lightweight dependencies, you can quickly get it up and running in any virtual environment. It has an optional [integration with IPython](#ipython-integrations) for easy use with IPython or in Jupyter or VS Code. This project is inspired by R's [reprex](https://reprex.tidyverse.org/) package. +### What it does + - Paste or type some Python code that you're interested in sharing. - reprexlite will execute that code in an isolated namespace. Any returned values or standard output will be captured and displayed as comments below their associated code. - The rendered reprex will be printed for you to share. Its format can be easily copied, pasted, and run as-is by someone else. Here's an example of an outputted reprex: @@ -26,7 +28,7 @@ list(zip(*grid)) #> [(1, 1, 2, 2, 3, 3), (8, 16, 8, 16, 8, 16)] ``` -Writing a good reprex takes thought and effort (see ["Reprex Do's and Don'ts"](https://jayqi.github.io/reprexlite/stable/dos-and-donts) for tips). The goal of reprexlite is to be a tool that seamlessly handles the mechanical stuff, so you can devote your full attention to the important, creative work of writing the content. +Writing a good reprex takes thought and effort (see ["Reprex Do's and Don'ts"](https://jayqi.github.io/reprexlite/stable/dos-and-donts/) for tips). The goal of reprexlite is to be a tool that seamlessly handles the mechanical stuff, so you can devote your full attention to the important, creative work of writing the content. Reprex-style code formatting—namely, with outputs as comments—is also great for documentation. Users can copy and run with no modification. Consider using reprexlite when writing your documentation instead of copying code with `>>>` prompts from an interactive Python shell. In fact, reprexlite can parse code with `>>>` prompts and convert it into a reprex for you instead. @@ -48,7 +50,7 @@ Optional dependencies can be specified using the ["extras" mechanism](https://pa - `black` : for optionally autoformatting your code - `ipython` : to use the IPython interactive shell editor or `%%reprex` IPython cell magic -- `pygments` : for syntax highlighting and the RTF venue +- `pygments` : for syntax highlighting and rendering the output as RTF ### Development version @@ -74,11 +76,18 @@ Once you're done, reprexlite will print out your reprex to console. To see available options, use the `--help` flag. +### IPython integrations + +There are two kinds of IPython integration: + +1. [IPython interactive shell editor](#ipython-interactive-shell-editor), which opens up a special IPython session where all cells are run through reprexlite +2. [Cell magics](#ipythonjupyter-cell-magic), which let you designate individual cells in a normal IPython or Jupyter notebook for being run through reprexlite + ### IPython interactive shell editor _Requires IPython._ `[ipython]` -reprexlite optionally supports an IPython interactive shell editor. This is basically like a normal IPython interactive shell except that all cell contents are piped through reprexlite for rendering instead of the normal cell execution. It has the typical advantages of using IPython like auto-suggestions, history scrolling, and syntax highlighting. You can start the IPython editor by using the `--editor`/`-e` option: +reprexlite optionally supports an IPython interactive shell editor. This is basically like a normal IPython interactive shell except that all cells are piped through reprexlite for rendering instead of the normal cell execution. It has the typical advantages of using IPython like auto-suggestions, history scrolling, and syntax highlighting. You can start the IPython editor by using the `--editor`/`-e` option: ```bash reprex -e ipython diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index 94a799e..be858c4 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -2,4 +2,49 @@ reprexlite has the following configuration options. +> [!NOTE] +> Command-line option names for these configuration variables use hyphens instead of underscores. + {{ create_config_help_table() }} + +## Configuration files + +reprexlite supports reading default configuration values from configuration files. Both project-level files and user-level files are supported. + +### `pyproject.toml` + +reprexlite will search the nearest `pyproject.toml` file in the current working directory and any parent directory. +Configuration for reprexlite should be in the `[tool.reprexlite]` table following standard `pyproject.toml` specifications. For example: + +```toml +[tool.reprexlite] +editor = "some_editor" +``` + +### `reprexlite.toml` or `.reprexlite.toml` + +reprexlite also supports files named `reprexlite.toml` or `.reprexlite.toml` for project-level configuration. It will also search for these in the current working directory or any parent directory. + +For reprexlite-specific files, all configuration options should be declared in the root namespace. + +```toml +editor = "some_editor" +``` + +### User-level configuration + +reprexlite supports searching standard platform-specific user configuration directories as determined by [platformdirs](https://github.com/tox-dev/platformdirs). Here are typical locations depending on platform: + +| Platform | Path | +|----------|------------------------------------------------------------| +| Linux | `~/.config/reprexlite/config.toml` | +| MacOS | `~/Library/Application Support/reprexlite/config.toml` | +| Windows | `C:\Users\\AppData\Local\reprexlite\config.toml` | + +You can check where your user configuration would be with + +```bash +python -m platformdirs +``` + +Look for the section `-- app dirs (without optional 'version')` for the value of `user_config_dir`. The value for `MyApp` is `reprexlite`. The configuration file should be named `config.toml` inside that directory. diff --git a/docs/docs/design-philosophy.md b/docs/docs/design-philosophy.md index bb46e43..c30e85d 100644 --- a/docs/docs/design-philosophy.md +++ b/docs/docs/design-philosophy.md @@ -38,7 +38,7 @@ A widely used approach for Python code examples is copying from an interactive P This style of code example takes no special tools to generate: simply open a `python` shell from command line, write your code, and copy what you see. Many Python packages use it for their documentation, e.g., [requests](https://requests.readthedocs.io/en/master/). There is also tooling for parsing it. The doctest module can run such examples in the docstrings of your scripts, and test that the output matches what is written. Other tools like [Sphinx](https://www.sphinx-doc.org/en/1.4.9/markup/code.html) are able to parse it when rendering your documentation into other formats. -The drawback of doctest-style examples is that they are _not_ valid Python syntax, so you can't just copy, paste, and run such examples. The `>>>` prompt is not valid. While IPython's interactive shell and Jupyter notebooks _do_ support executing code with the prompt, it won't work in a regular Python REPL or in Python scripts. Furthermore, since the outputs might be anything, they may not be valid Python syntax either, depending on their `repr`. A barebones class, for example, will look like `<__main__.MyClass object at 0x7f932a001400>` and is not valid. +The drawback of doctest-style examples is that they are _not_ valid Python syntax, so you can't always just copy, paste, and run such examples. The `>>>` prompt is not valid. While IPython's interactive shell and Jupyter notebooks _do_ support executing code with the prompt, it won't work in a regular Python REPL or in Python scripts. Furthermore, since the outputs might be anything, they may not be valid Python syntax either, depending on their `repr`. A barebones class, for example, will look like `<__main__.MyClass object at 0x7f932a001400>` and is not valid. So, while no special tools were needed to _generate_ a doctest-style example, either special tools or manual editing are needed to _run_ it. This puts the burden on the person you're sharing with, which is counterproductive. As discussed in the previous section, we want reproducible examples to make it _easier_ for others to run your code. @@ -56,7 +56,7 @@ If this has convinced you, you can take advantage of reprexlite's ability to par The primary design goal of reprexlite is that it should be **quick and convenient** to use. That objective drove the emphasis on following the design characteristics: -- **Lightweight**. reprexlite needs to be in your virtual environment to be able to run your code. By having minimal and lightweight dependencies itself, reprexlite is quick to install and is unlikely to conflict with your other dependencies. +- **Lightweight**. reprexlite needs to be in your virtual environment to be able to run your code. By having minimal and lightweight dependencies itself, reprexlite is quick to install and is unlikely to conflict with your other dependencies. Any advanced functionality that require heavier dependencies are optional. - **Easy access**. reprexlite comes with a CLI, so you can quickly create a reprex without needing to start a Python shell or to import anything. - **And flexible**. The CLI isn't the only option. The [Python library](../api-reference/reprex/) provides programmatic access, and there is an optional [IPython/Jupyter extension](../ipython-jupyter-magic/) for use with a cell magic. diff --git a/docs/docs/formatting.md b/docs/docs/formatting.md deleted file mode 100644 index fa3e7c1..0000000 --- a/docs/docs/formatting.md +++ /dev/null @@ -1,33 +0,0 @@ -# Venues Formatting - -reprexlite comes with support for formatting reprexes for the following venues. - -{{ create_venue_help_table() }} - -## Examples - -{{ create_venue_help_examples() }} - -## Custom Formatter - -There are two steps to creating a custom formatter: - -1. Subclass the `Formatter` abstract base class and implement a `format` method. -2. Register your custom formatter with the `@register_formatter(...)` decorator. - -```python -from reprexlite.formatting import Formatter, register_formatter - -@register_formatter(venue="myformat", label="My Custom Format") -class MyCustomerFormatter(Formatter): - - @classmethod - def format( - cls, reprex_str: str, advertise: bool | None = None, session_info: bool = False - ) -> str: - ... -``` - -This will make your formatter available under the venue key you provide to the registration decorator. - -Currently, reprexlite does not have any extension or plugin system to load your code with your custom formatter. This means that you will only be able to easily access it with certain usage options where you can import or run your code, such as using reprexlite as a library or with the IPython cell magic. diff --git a/docs/docs/rendering-and-output-venues.md b/docs/docs/rendering-and-output-venues.md new file mode 100644 index 0000000..5b53176 --- /dev/null +++ b/docs/docs/rendering-and-output-venues.md @@ -0,0 +1,29 @@ +# Rendering and Output Venues + +A rendered reprex will be code plus the computed outputs plus additional formatting markup appropriate for some particular output venue. For example, the `gh` venue (GitHub) will be in GitHub-flavored markdown and may look like this: + +```` +```python +2+2 +#> 4 +``` +```` + +The venue can be set using the `--venue / -v` command-line flag or the `venue` configuration file option. The following section documents the available output venue options. + +## Venue options + +{{ create_venue_help_table() }} + +## Formatter functions + +{{ create_venue_help_examples() }} + +## Under the hood and Python API + +There are two steps to rendering a reprex: + +1. The `Reprex.render()` method renders a reprex instance as just the code and outputs. +2. A formatter function from `reprexlite.formatting` (see [above](#formatter-functions)) formats the rendered reprex code and outputs for the specified venue. + +The whole process is encapsulated in the `Reprex.render_and_format()` method. diff --git a/docs/main.py b/docs/main.py index d20f645..b8b9c18 100644 --- a/docs/main.py +++ b/docs/main.py @@ -1,8 +1,10 @@ +from collections import defaultdict import dataclasses from textwrap import dedent from typing import Union -from markdownTable import markdownTable +from griffe import Docstring, DocstringSectionAdmonition, DocstringSectionText +from py_markdown_table.markdown_table import markdown_table from typenames import typenames from reprexlite.config import ReprexConfig @@ -12,6 +14,10 @@ def define_env(env): "Hook function" + docstring = Docstring(ReprexConfig.__doc__, lineno=1) + parsed = docstring.parse("google") + descriptions = {param.name: param.description.replace("\n", " ") for param in parsed[1].value} + @env.macro def create_config_help_table(): out = dedent( @@ -34,7 +40,7 @@ def create_config_help_table(): {field.name} {typenames(field.type)} - {field.metadata['help']} + {descriptions[field.name]} """ for field in dataclasses.fields(ReprexConfig) @@ -44,46 +50,54 @@ def create_config_help_table(): '"Venues Formatting"', '"Venues Formatting"' ) return out - # data = [ - # { - # "Name": f"**`{field.name}`**", - # "Type": f"`{typenames(field.type)}`", - # "Description": field.metadata["help"], - # } - # for field in dataclasses.fields(ReprexConfig) - # ] - # table = markdownTable(data) - # return table.setParams(row_sep="markdown", quote=False).getMarkdown() @env.macro def create_venue_help_table(): data = [ { - "Venue Keyword": venue_key, - "Description": formatter.meta.venues[venue_key], - "Formatter": f"[`{formatter.__name__}`](#{formatter.__name__.lower()})", + "Venue Keyword": f"`{venue_key.value}`", + "Description": formatter_entry.label, + "Formatter Function": f"[`{formatter_entry.fn.__name__}`](#{formatter_entry.fn.__name__})", } - for venue_key, formatter in formatter_registry.items() + for venue_key, formatter_entry in formatter_registry.items() ] - table = markdownTable(data) - return table.setParams(row_sep="markdown", quote=False).getMarkdown() + table = markdown_table(data) + return table.set_params(row_sep="markdown", quote=False).get_markdown() @env.macro def create_venue_help_examples(): + data = defaultdict(list) + for key, entry in formatter_registry.items(): + data[entry.fn].append(key) + out = [] - for formatter in dict.fromkeys(formatter_registry.values()): - out.append(f"### `{formatter.__name__}`") - out.append("") - out.append(formatter.__doc__) - out.append("") - out.append("````") - out.append(formatter.meta.example or "Example not shown.") - out.append("````") + for fn, keys in data.items(): + keys_list = ", ".join(f"`{key.value}`" for key in keys) + out.append(f"### `{fn.__name__}`") + + # Parse docstring + docstring = Docstring(fn.__doc__, lineno=1) + parsed = docstring.parse("google") + + for section in parsed: + if isinstance(section, DocstringSectionText): + out.append("") + out.append(section.value) + elif isinstance(section, DocstringSectionAdmonition): + out.append("") + out.append(f"**Used for venues**: {keys_list}") + out.append("") + out.append(f"**{section.title}**") + out.append("") + out.append("````") + admonition = section.value + out.append(admonition.description) + out.append("````") out.append("") out.append( "" "↳ [API documentation]" - f"(api-reference/formatting.md#reprexlite.formatting.{formatter.__qualname__})" + f"(api-reference/formatting.md#reprexlite.formatting.{fn.__qualname__})" "" ) return "\n".join(out) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 5dd9cae..fc004ea 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -10,9 +10,9 @@ nav: - Usage: - Basic Usage: "./#basic-usage" - CLI Help: "cli.md" - - IPython/Jupyter Magic: "ipython-jupyter-magic.ipynb" - - Venues Formatting: "formatting.md" + - Rendering and Output Venues: "rendering-and-output-venues.md" - Configuration: "configuration.md" + - IPython/Jupyter Magic: "ipython-jupyter-magic.ipynb" - Library: - API Reference: - reprexlite.config: "api-reference/config.md" @@ -53,10 +53,11 @@ extra_css: markdown_extensions: - attr_list: + - github-callouts: - mdx_truly_sane_lists: - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji - emoji_generator: !!python/name:materialx.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg - pymdownx.highlight: - pymdownx.superfences: - tables: diff --git a/justfile b/justfile index 32eab2f..be64275 100644 --- a/justfile +++ b/justfile @@ -18,13 +18,13 @@ typecheck: mypy reprexlite --install-types --non-interactive # Run tests -test: +test *args: uv run --python {{python}} --no-editable --all-extras --no-dev --group test --isolated \ - python -I -m pytest + python -I -m pytest {{args}} # Run all tests with Python version matrix test-all: - for python in 3.8 3.9 3.10 3.11 3.12 3.13; do \ + for python in 3.9 3.10 3.11 3.12 3.13; do \ just python=$python test; \ done @@ -54,4 +54,4 @@ docs: # Serve built docs docs-serve: - uv tool --verbose run quickhttp docs/site/ + uv tool run quickhttp docs/site/ diff --git a/pyproject.toml b/pyproject.toml index 5e31ca7..d5e95c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,17 +15,17 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] -requires-python = ">=3.7" +requires-python = ">=3.9" dependencies = [ - "importlib_metadata ; python_version < '3.8'", + "cyclopts>=3", "libcst", - "typer", + "platformdirs", "typing_extensions>4 ; python_version < '3.11'", ] @@ -35,7 +35,7 @@ pygments = ["Pygments"] ipython = ["ipython"] [project.scripts] -reprex = "reprexlite.cli:app" +reprex = "reprexlite.cli:entrypoint" [project.urls] "Repository" = "https://github.com/jayqi/reprexlite" @@ -53,6 +53,7 @@ lint = [ "ruff", ] docs = [ + "markdown-callouts", "mkdocs", "mkdocs-jupyter", "mkdocs-macros-plugin", @@ -60,13 +61,14 @@ docs = [ "mike", "mkdocstrings[python]", "mdx-truly-sane-lists", - "py-markdown-table==0.3.3", + "py-markdown-table", "typenames", ] test = [ "pytest", "coverage", "pytest-cov", + "pytest-echo>=1.8.1", "tqdm", ] @@ -113,7 +115,7 @@ ignore_missing_imports = true [tool.pytest.ini_options] minversion = "6.0" -addopts = "--cov=reprexlite --cov-report=term --cov-report=html --cov-report=xml" +addopts = "--cov=reprexlite --cov-report=term --cov-report=html --cov-report=xml --echo-version=*" testpaths = ["tests"] [tool.coverage.run] diff --git a/reprexlite/__main__.py b/reprexlite/__main__.py index 6a4469f..a9afc2d 100644 --- a/reprexlite/__main__.py +++ b/reprexlite/__main__.py @@ -1,3 +1,5 @@ from reprexlite.cli import app -app(prog_name="python -m reprexlite") +if __name__ == "__main__": + app._name = ("python -m reprexlite",) + app() diff --git a/reprexlite/cli.py b/reprexlite/cli.py index afc323f..7c63e87 100644 --- a/reprexlite/cli.py +++ b/reprexlite/cli.py @@ -1,184 +1,246 @@ -from enum import Enum +import dataclasses +import json +import os from pathlib import Path +import platform +import subprocess +import sys +import tempfile from typing import Optional -import typer +try: + from typing import Annotated # type: ignore # Python 3.9+ +except ImportError: + from typing_extensions import Annotated # type: ignore -from reprexlite.config import ParsingMethod, ReprexConfig -from reprexlite.exceptions import InputSyntaxError, IPythonNotFoundError + +from cyclopts import App, Parameter +import cyclopts.config +from platformdirs import user_config_dir + +from reprexlite.config import ReprexConfig +from reprexlite.exceptions import ( + EditorError, + InputSyntaxError, + IPythonNotFoundError, +) from reprexlite.formatting import formatter_registry from reprexlite.reprexes import Reprex from reprexlite.version import __version__ -app = typer.Typer() +def get_version(): + return __version__ + + +HELP_TEMPLATE = """ +Render reproducible examples of Python code for sharing. Your code will be executed and, in +the default output style, the results will be embedded as comments below their associated +lines. -def version_callback(version: bool): - """Print reprexlite version to console.""" - if version: - print(__version__) - raise typer.Exit() +By default, your system's default command-line text editor will open for you to type or paste +in your code. This editor can be changed by setting either of the `VISUAL` or `EDITOR` environment +variables, or by explicitly passing in the --editor program. The interactive IPython editor +requires IPython to be installed. You can also instead specify an input file with the +--infile / -i option. +Additional markup will be added that is appropriate to the choice of venue formatting. For +example, for the default 'gh' venue for GitHub Flavored Markdown, the final reprex output will +look like: -Venue = Enum( # type: ignore - "Venue", names={v.upper(): v for v in formatter_registry.keys()}, type=str +```` +```python +arr = [1, 2, 3, 4, 5] +[x + 1 for x in arr] +#> [2, 3, 4, 5, 6] +max(arr) - min(arr) +#> 4 +``` + +Created at 2021-02-27 00:13 PST by [reprexlite](https://github.com/jayqi/reprexlite) v{version} +```` + +The supported venue formats are: +{venue_formats} +""" # noqa: E501 + + +def get_help(): + help_text = HELP_TEMPLATE.format( + version=get_version(), + venue_formats="\n".join( + f"- {key.value} : {entry.label}" for key, entry in formatter_registry.items() + ), + ) + return help_text + + +pyproject_toml_loader = cyclopts.config.Toml( + "pyproject.toml", + root_keys=("tool", "reprexlite"), + search_parents=True, + use_commands_as_keys=False, ) -Venue.__doc__ = """Enum for valid venue options.""" +reprexlite_toml_loader = cyclopts.config.Toml( + "reprexlite.toml", + search_parents=True, + use_commands_as_keys=False, +) -def get_help(key: str): - return ReprexConfig.get_help(key).replace("_", "-") +dot_reprexlite_toml_loader = cyclopts.config.Toml( + ".reprexlite.toml", + search_parents=True, + use_commands_as_keys=False, +) -@app.command() +user_reprexlite_toml_loader = cyclopts.config.Toml( + Path(user_config_dir(appname="reprexlite")) / "config.toml", + search_parents=False, + use_commands_as_keys=False, +) + +app = App( + name="reprex", + version=get_version, + help_format="markdown", + help=get_help(), + config=( + pyproject_toml_loader, + reprexlite_toml_loader, + dot_reprexlite_toml_loader, + user_reprexlite_toml_loader, + ), +) + + +def launch_ipython(config: ReprexConfig): + try: + from reprexlite.ipython import ReprexTerminalIPythonApp + except IPythonNotFoundError: + print( + "IPythonNotFoundError: ipython is required to be installed to use the IPython " + "interactive editor." + ) + sys.exit(1) + ReprexTerminalIPythonApp.set_reprex_config(config) + ReprexTerminalIPythonApp.launch_instance(argv=[]) + sys.exit(0) + + +def launch_editor(editor) -> str: + fw, name = tempfile.mkstemp(prefix="reprexlite-", suffix=".py") + try: + os.close(fw) # Close the file descriptor + # Open editor and edit the file + proc = subprocess.Popen(args=f"{editor} {name}", shell=True) + exit_code = proc.wait() + if exit_code != 0: + raise EditorError(f"{editor}: Editing failed with exit code {exit_code}") + + # Read the file back in + with open(name, "rb") as fp: + content = fp.read() + return content.decode("utf-8-sig").replace("\r\n", "\n") + except OSError as e: + raise EditorError(f"{editor}: Editing failed: {e}") from e + finally: + os.unlink(name) + + +def get_editor() -> str: + """Determine an editor to use for editing code.""" + for key in "VISUAL", "EDITOR": + env_val = os.environ.get(key) + if env_val: + return env_val + if platform.system() == "Windows": + return "notepad" + for editor in ("sensible-editor", "vim", "nano"): + if os.system(f"which {editor} >/dev/null 2>&1") == 0: + return editor + return "vi" + + +def handle_editor(config: ReprexConfig) -> str: + """Determines what to do based on the editor configuration.""" + editor = config.editor or get_editor() + if editor == "ipython": + launch_ipython(config) + sys.exit(0) + else: + return launch_editor(editor) + + +@app.default def main( - editor: Optional[str] = typer.Option( - None, - "--editor", - "-e", - help=( - "Specify editor to open in. Can be full path to executable or name to be searched on " - "system search path. If None, will use Click's automatic editor detection. This will " - "typically use the editor set to environment variable VISUAL or EDITOR. If value is " - "'ipython' and IPython is installed, this will launch the interactive IPython editor " - "where all cells are automatically run through reprexlite." + *, + infile: Annotated[ + Optional[Path], + Parameter( + name=("--infile", "-i"), + help="Read code from this file instead of opening an editor.", ), - ), - infile: Optional[Path] = typer.Option( - None, - "--infile", - "-i", - help="Read code from an input file instead of entering in an editor.", - ), - outfile: Optional[Path] = typer.Option( - None, "--outfile", "-o", help="Write output to file instead of printing to console." - ), - # Formatting - venue: Venue = typer.Option( - "gh", - "--venue", - "-v", - help=get_help("venue"), - ), - advertise: Optional[bool] = typer.Option( - None, - "--advertise/--no-advertise", - help=get_help("advertise"), - is_flag=False, - show_default=False, - ), - session_info: bool = typer.Option(False, help=get_help("session_info")), - style: bool = typer.Option(False, help=get_help("style")), - prompt: str = typer.Option("", help=get_help("prompt")), - continuation: str = typer.Option("", help=get_help("continuation")), - comment: str = typer.Option("#>", help=get_help("comment")), - keep_old_results: bool = typer.Option(False, help=get_help("keep_old_results")), - # Parsing - parsing_method: ParsingMethod = typer.Option("auto", help=get_help("parsing_method")), - input_prompt: Optional[str] = typer.Option(None, help=get_help("input_prompt")), - input_continuation: Optional[str] = typer.Option(None, help=get_help("input_continuation")), - input_comment: Optional[str] = typer.Option(None, help=get_help("input_comment")), - verbose: Optional[bool] = typer.Option(None, "--verbose"), - # Callbacks - version: Optional[bool] = typer.Option( - None, - "--version", - callback=version_callback, - is_eager=True, - help="Show reprexlite version and exit.", - ), + ] = None, + outfile: Annotated[ + Optional[Path], + Parameter( + name=("--outfile", "-o"), + help="Write rendered reprex to this file instead of standard out.", + ), + ] = None, + config: Annotated[ReprexConfig, Parameter(name="*")] = ReprexConfig(), + verbose: Annotated[ + tuple[bool, ...], + Parameter( + name=("--verbose",), show_default=False, negative=False, help="Increase verbosity." + ), + ] = (), + debug: Annotated[bool, Parameter(show=False)] = False, ): - """Render reproducible examples of Python code for sharing. Your code will be executed and, in - the default output style, the results will be embedded as comments below their associated - lines. - - By default, your system's default command-line text editor will open for you to type or paste - in your code. This editor can be changed by setting the VISUAL or EDITOR environment variable, - or by explicitly passing in the --editor program. You can instead specify an input file with - the --infile / -i option. If IPython is installed, an interactive IPython editor can also be - launched using the --ipython flag. - - Additional markup will be added that is appropriate to the choice of venue formatting. For - example, for the default `gh` venue for GitHub Flavored Markdown, the final reprex output will - look like: - - \b - ---------------------------------------- - ```python - arr = [1, 2, 3, 4, 5] - [x + 1 for x in arr] - #> [2, 3, 4, 5, 6] - max(arr) - min(arr) - #> 4 - ``` - \b - Created at 2021-02-27 00:13:55 PST by [reprexlite](https://github.com/jayqi/reprexlite) v{version} - ---------------------------------------- - - \b - The supported venue formats are: - \b - - gh : GitHub Flavored Markdown - - so : StackOverflow, alias for gh - - ds : Discourse, alias for gh - - html : HTML - - py : Python script - - rtf : Rich Text Format - - slack : Slack - """ # noqa: E501 - - config = ReprexConfig( - venue=venue.value, - advertise=advertise, - session_info=session_info or False, - style=style or False, - prompt=prompt, - continuation=continuation, - comment=comment, - parsing_method=parsing_method.value, - input_prompt=input_prompt, - input_continuation=input_continuation, - input_comment=input_comment, - keep_old_results=keep_old_results or False, - ) - - if editor and editor.lower() == "ipython": - try: - from reprexlite.ipython import ReprexTerminalIPythonApp - except IPythonNotFoundError: - print( - "IPythonNotFoundError: ipython is required to be installed to use the IPython " - "interactive editor." - ) - raise typer.Exit(code=1) - ReprexTerminalIPythonApp.set_reprex_config(config) - ReprexTerminalIPythonApp.launch_instance(argv=[]) - raise typer.Exit() - elif infile: + verbosity = sum(verbose) + if verbosity: + sys.stderr.write("infile: {}\n".format(infile)) + sys.stderr.write("outfile: {}\n".format(outfile)) + sys.stderr.write("config: {}\n".format(config)) + + if debug: + data = {"infile": infile, "outfile": outfile, "config": dataclasses.asdict(config)} + sys.stdout.write(json.dumps(data)) + return data + + if infile: + if verbose: + sys.stderr.write(f"Reading from input file: {infile}") with infile.open("r") as fp: input = fp.read() else: - input = typer.edit(editor=editor) or "" - - if verbose: - typer.echo(config) + input = handle_editor(config) + if input.strip() == "": + print("No input provided or saved via the editor. Exiting.") + sys.exit(0) try: r = Reprex.from_input(input=input, config=config) except InputSyntaxError as e: print("ERROR: reprexlite has encountered an error while evaluating your input.") print(e) - raise typer.Exit(1) from e + raise if outfile: with outfile.open("w") as fp: - fp.write(r.format(terminal=False)) + fp.write(r.render_and_format(terminal=False)) print(f"Wrote rendered reprex to {outfile}") else: - print(r.format(terminal=True), end="") + print(r.render_and_format(terminal=True), end="") return r -if main.__doc__: - main.__doc__ = main.__doc__.format(version=__version__) +def entrypoint(): + """Entrypoint for the reprex command-line interface. This function is configured as the reprex + entrypoint under [project.scripts]. + https://packaging.python.org/en/latest/specifications/entry-points/#use-for-scripts + """ + app() diff --git a/reprexlite/config.py b/reprexlite/config.py index 5a37d82..59cd706 100644 --- a/reprexlite/config.py +++ b/reprexlite/config.py @@ -1,13 +1,19 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from typing import Optional +try: + from typing import Annotated # type: ignore # Python 3.9+ +except ImportError: + from typing_extensions import Annotated # type: ignore + +from cyclopts import Parameter + from reprexlite.exceptions import ( InvalidParsingMethodError, InvalidVenueError, PromptLengthMismatchError, ) -from reprexlite.formatting import formatter_registry class ParsingMethod(str, Enum): @@ -22,117 +28,80 @@ class ParsingMethod(str, Enum): DECLARED = "declared" +class Venue(str, Enum): + """Enum for specifying the output venue for a reprex.""" + + GH = "gh" + DS = "ds" + SO = "so" + HTML = "html" + PY = "py" + RTF = "rtf" + SLACK = "slack" + + @dataclass class ReprexConfig: """Configuration dataclass for reprexlite. Used to configure input parsing and output formatting. + + Args: + editor (str): Command-line program name of editor to use. If not specified, check $EDITOR + and $VISUAL environment variables. If 'ipython', will launch the IPython interactive + editor. + venue (str): Key to identify the output venue that the reprex will be shared in. Used to + select an appropriate formatter. See "Venues Formatting" documentation for formats + included with reprexlite. + advertise (bool): Whether to include a footer that credits reprexlite. If unspecified, will + depend on specified venue formatter's default. + session_info (bool): Include details about session and environment that the reprex was + generated with. + style (bool): Whether to autoformat code with black. Requires black to be installed. + prompt (str): Prefix to use as primary prompt for code lines. + continuation (str): Prefix to use as secondary prompt for continued code lines. + comment (str): Prefix to use for results returned by expressions. + keep_old_results (bool): Whether to additionally include results of expressions detected in + the original input when formatting the reprex output. + parsing_method (str): Method for parsing input. 'auto' will automatically detect either + default reprex-style input or standard doctest-style input. 'declared' will allow you + to specify custom line prefixes. Values for 'prompt', 'continuation', and 'comment' + will be used for both output formatting and input parsing, unless the associated + 'input_*' override settings are supplied. + input_prompt (str): Prefix to use as primary prompt for code lines when parsing input. Only + used if 'parsing_method' is 'declared'. If not set, 'prompt' is used for both input + parsing and output formatting. + input_continuation (str): Prefix to use as secondary prompt for continued code lines when + parsing input. Only used if 'parsing_method' is 'declared'. If not set, 'prompt' is + used for both input parsing and output formatting. + input_comment (str): Prefix to use for results returned by expressions when parsing input. + Only used if 'parsing_method' is 'declared'. If not set, 'prompt' is used for both + input parsing and output formatting. """ + editor: Annotated[Optional[str], Parameter(name=("--editor", "-e"))] = None # Formatting - venue: str = field( - default="gh", - metadata={ - "help": ( - "Key to identify the output venue that the reprex will be shared in. Used to " - 'select an appropriate formatter. See "Venues Formatting" documentation for ' - "formats included with reprexlite." - ) - }, - ) - advertise: Optional[bool] = field( - default=None, - metadata={ - "help": ( - "Whether to include a footer that credits reprexlite. If unspecified, will depend " - "on specified venue formatter's default." - ) - }, - ) - session_info: bool = field( - default=False, - metadata={ - "help": ( - "Include details about session and environment that the reprex was generated with." - ) - }, - ) - style: bool = field( - default=False, - metadata={ - "help": "Whether to autoformat code with black. Requires black to be installed." - }, - ) - prompt: str = field( - default="", - metadata={"help": "Prefix to use as primary prompt for code lines."}, - ) - continuation: str = field( - default="", - metadata={"help": "Prefix to use as secondary prompt for continued code lines."}, - ) - comment: str = field( - default="#>", - metadata={"help": "Prefix to use for results returned by expressions."}, - ) - keep_old_results: bool = field( - default=False, - metadata={ - "help": ( - "Whether to additionally include results of expressions detected in the original " - "input when formatting the reprex output." - ) - }, - ) + venue: Annotated[Venue, Parameter(name=("--venue", "-v"))] = Venue.GH + advertise: Optional[bool] = None + session_info: bool = False + style: bool = False + prompt: str = "" + continuation: str = "" + comment: str = "#>" + keep_old_results: bool = False # Parsing - parsing_method: str = field( - default="auto", - metadata={ - "help": ( - "Method for parsing input. 'auto' will automatically detect either default " - "reprex-style input or standard doctest-style input. 'declared' will allow you to " - "specify custom line prefixes. Values for 'prompt', 'continuation', and 'comment' " - "will be used for both output formatting and input parsing, unless the associated " - "'input_*' override settings are supplied." - ) - }, - ) - input_prompt: Optional[str] = field( - default=None, - metadata={ - "help": ( - "Prefix to use as primary prompt for code lines when parsing input. Only used if " - "'parsing_method' is 'declared'. If not set, 'prompt' is used for both input " - "parsing and output formatting." - ) - }, - ) - input_continuation: Optional[str] = field( - default=None, - metadata={ - "help": ( - "Prefix to use as secondary prompt for continued code lines when parsing input. " - "Only used if 'parsing_method' is 'declared'. If not set, 'prompt' is used for " - "both input parsing and output formatting." - ) - }, - ) - input_comment: Optional[str] = field( - default=None, - metadata={ - "help": ( - "Prefix to use for results returned by expressions when parsing input. Only used " - "if 'parsing_method' is 'declared'. If not set, 'prompt' is used for both input " - "parsing and output formatting." - ) - }, - ) + parsing_method: ParsingMethod = ParsingMethod.AUTO + input_prompt: Optional[str] = None + input_continuation: Optional[str] = None + input_comment: Optional[str] = None def __post_init__(self): # Validate venue - if self.venue not in formatter_registry: + try: + Venue(self.venue) + except ValueError: raise InvalidVenueError( f"{self.venue} is not a valid value for parsing method." - f"Valid values are: {list(formatter_registry.keys())}" + f"Valid values are: {list(m.value for m in Venue)}" ) # Validate prompt and continuation prefixes if len(self.prompt) != len(self.continuation): @@ -166,30 +135,3 @@ def resolved_input_comment(self): if self.input_comment is not None: return self.input_comment return self.comment - - @classmethod - def get_help(cls, field_name: str): - return cls.__dataclass_fields__[field_name].metadata["help"] - - -# def format_args_google_style(): -# docs = [] -# for field in dataclasses.fields(ReprexConfig): -# field_name = field.name -# try: -# field_type = field.type.__name__ -# except AttributeError: -# field_type = str(field.type) -# docs.extend( -# textwrap.wrap( -# f"{field_name} ({field_type}): {CONFIG_DOCS[field_name]}", -# width=99, -# initial_indent=" " * 8, -# subsequent_indent=" " * 12, -# ) -# ) -# return "\n".join(docs)[4:] - - -# if ReprexConfig.__doc__: -# ReprexConfig.__doc__ = ReprexConfig.__doc__.replace("{{args}}", format_args_google_style()) diff --git a/reprexlite/exceptions.py b/reprexlite/exceptions.py index d13dcd2..26cfdd8 100644 --- a/reprexlite/exceptions.py +++ b/reprexlite/exceptions.py @@ -6,6 +6,10 @@ class BlackNotFoundError(ModuleNotFoundError, ReprexliteException): """Raised when ipython cannot be found when using a black-dependent feature.""" +class EditorError(ReprexliteException): + """Raised when an error occurs with the editor.""" + + class InputSyntaxError(SyntaxError, ReprexliteException): """Raised when encountering a syntax error when parsing input.""" @@ -34,10 +38,6 @@ class NoPrefixMatchError(ValueError, ReprexliteException): pass -class NotAFormatterError(TypeError, ReprexliteException): - """Raised when registering a formatter that is not a subclass of the Formatter base class.""" - - class PromptLengthMismatchError(ReprexliteException): pass diff --git a/reprexlite/formatting.py b/reprexlite/formatting.py index 17727ef..46331c7 100644 --- a/reprexlite/formatting.py +++ b/reprexlite/formatting.py @@ -1,255 +1,228 @@ -from abc import ABC, abstractmethod -import dataclasses +from dataclasses import dataclass from datetime import datetime -from textwrap import dedent -from typing import ClassVar, Dict, Optional, Type +from typing import Dict, Optional, Protocol -from reprexlite.exceptions import NotAFormatterError, PygmentsNotFoundError +from reprexlite.config import ReprexConfig, Venue +from reprexlite.exceptions import PygmentsNotFoundError from reprexlite.session_info import SessionInfo from reprexlite.version import __version__ -@dataclasses.dataclass -class FormatterMetadata: - example: Optional[str] - venues: Dict[str, str] = dataclasses.field(default_factory=lambda: dict()) +class Formatter(Protocol): + def __call__(self, reprex_str: str, config: Optional[ReprexConfig] = None) -> str: ... -class Formatter(ABC): - """Abstract base class for a reprex formatter. Concrete subclasses should implement the - formatting logic appropriate to a specific venue for sharing. Call `str(...)` on an instance - to return the formatted reprex. +@dataclass +class FormatterRegistration: + fn: Formatter + label: str - Attributes: - default_advertise (bool): Whether to render reprexlite advertisement by default - meta (FormatterMeta): Contains metadata for the formatter, such as label text and an - example - """ - default_advertise: ClassVar[bool] = True - meta: ClassVar[FormatterMetadata] +class FormatterRegistry: + """Registry of formatters keyed by venue keywords.""" + + _registry: Dict[str, FormatterRegistration] = {} + + def __getitem__(self, key: Venue) -> FormatterRegistration: + return self._registry[Venue(key)] + + def __contains__(self, key: Venue) -> bool: + return Venue(key) in self._registry - @classmethod - @abstractmethod - def format( - cls, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False - ) -> str: - """Format a reprex string for a specific sharing venue. + def items(self): + return self._registry.items() + + def keys(self): + return self._registry.keys() + + def values(self): + return self._registry.values() + + def register(self, venue: Venue, label: str): + """Decorator that registers a formatter implementation. Args: - reprex_str (str): String containing rendered reprex output. - advertise (Optional[bool], optional): Whether to include the advertisement for - reprexlite. Defaults to None, which uses a per-formatter default. - session_info (bool, optional): Whether to include detailed session information. - Defaults to False. - - Returns: - str: String containing formatted reprex code. Ends with newline. + venue (str): Venue keyword that formatter will be registered to. + label (str): Short human-readable label explaining the venue. """ + def _register(fn: Formatter): + self._registry[Venue(venue)] = FormatterRegistration(fn=fn, label=label) + return fn -formatter_registry: Dict[str, Type[Formatter]] = {} -"""Registry of formatters keyed by venue keywords.""" + return _register -def register_formatter(venue: str, label: str): - """Decorator that registers a formatter implementation. +formatter_registry = FormatterRegistry() - Args: - venue (str): Venue keyword that formatter will be registered to. - label (str): Short human-readable label explaining the venue. + +@formatter_registry.register(venue=Venue.DS, label=f"Discourse (alias for '{Venue.GH.value}')") +@formatter_registry.register(venue=Venue.SO, label=f"StackOverflow (alias for '{Venue.GH.value}')") +@formatter_registry.register(venue=Venue.GH, label="Github Flavored Markdown") +def format_as_markdown( + reprex_str: str, + config: Optional[ReprexConfig] = None, +) -> str: """ + Format a rendered reprex reprex as a GitHub-Flavored Markdown code block. By default, includes + a footer that credits reprexlite. - def registrar(cls): - global formatter_registry - if not isinstance(cls, type) or not issubclass(cls, Formatter): - raise NotAFormatterError("Only subclasses of Formatter can be registered.") - formatter_registry[venue] = cls - cls.meta.venues[venue] = label - return cls - - return registrar - - -@register_formatter(venue="ds", label="Discourse (alias for 'gh')") -@register_formatter(venue="so", label="StackOverflow (alias for 'gh')") -@register_formatter(venue="gh", label="Github Flavored Markdown") -class GitHubFormatter(Formatter): - """Formatter for rendering reprexes in GitHub Flavored Markdown.""" - - default_advertise = True - meta = FormatterMetadata( - example=dedent( - """\ - ```python - 2+2 - #> 4 - ``` - """ - ) - ) - - @classmethod - def format( - cls, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False - ) -> str: - if advertise is None: - advertise = cls.default_advertise - out = [] - out.append("```python") - out.append(reprex_str) - out.append("```") - if advertise: - out.append("\n" + Advertisement().markdown()) - if session_info: - out.append("\n
Session Info") - out.append("```text") - out.append(str(SessionInfo())) - out.append("```") - out.append("
") - return "\n".join(out) + "\n" - - -@register_formatter(venue="html", label="HTML") -class HtmlFormatter(Formatter): - """Formatter for rendering reprexes in HTML. If optional dependency Pygments is - available, the rendered HTML will have syntax highlighting for the Python code.""" - - default_advertise = True - meta = FormatterMetadata( - example=dedent( - """\ -
2+2
-            #> 4
- """ - ) - ) - - @classmethod - def format( - cls, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False - ) -> str: - if advertise is None: - advertise = cls.default_advertise - out = [] - try: - from pygments import highlight - from pygments.formatters import HtmlFormatter - from pygments.lexers import PythonLexer - - formatter = HtmlFormatter( - style="friendly", lineanchors=True, linenos=True, wrapcode=True - ) - out.append(f"") - out.append(highlight(str(reprex_str), PythonLexer(), formatter)) - except ImportError: - out.append(f"
{reprex_str}
") - - if advertise: - out.append(Advertisement().html().strip()) - if session_info: - out.append("
Session Info") - out.append(f"
{SessionInfo()}
") - out.append("
") - return "\n".join(out) + "\n" - - -@register_formatter(venue="py", label="Python script") -class PyScriptFormatter(Formatter): - """Formatter for rendering reprexes as a Python script.""" - - default_advertise = False - meta = FormatterMetadata( - example=dedent( - """\ - 2+2 - #> 4 - """ - ) - ) - - @classmethod - def format( - cls, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False - ) -> str: - if advertise is None: - advertise = cls.default_advertise - out = [str(reprex_str)] - if advertise: - out.append("\n" + Advertisement().code_comment()) - if session_info: - out.append("") - sess_lines = str(SessionInfo()).split("\n") - out.extend("# " + line for line in sess_lines) - return "\n".join(out) + "\n" - - -@register_formatter(venue="rtf", label="Rich Text Format") -class RtfFormatter(Formatter): - """Formatter for rendering reprexes in Rich Text Format.""" - - default_advertise = False - meta = FormatterMetadata(example=None) - - @classmethod - def format( - cls, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False - ) -> str: - if advertise is None: - advertise = cls.default_advertise - try: - from pygments import highlight - from pygments.formatters import RtfFormatter - from pygments.lexers import PythonLexer - except ModuleNotFoundError as e: - if e.name == "pygments": - raise PygmentsNotFoundError( - "Pygments is required for RTF output.", name="pygments" - ) - else: - raise - - out = str(reprex_str) - if advertise: - out += "\n\n" + Advertisement().text() - if session_info: - out += "\n\n" + str(SessionInfo()) - return highlight(out, PythonLexer(), RtfFormatter()) + "\n" - - -@register_formatter(venue="slack", label="Slack") -class SlackFormatter(Formatter): - """Formatter for rendering reprexes as Slack markup.""" - - default_advertise = False - meta = FormatterMetadata( - example=dedent( - """\ - ``` - 2+2 - #> 4 - ``` - """ - ) - ) - - @classmethod - def format( - cls, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False - ) -> str: - if advertise is None: - advertise = cls.default_advertise - out = [] + Args: + reprex_str (str): The reprex string to render. + config (Optional[ReprexConfig]): Configuration for the reprex. Defaults to None. + + Returns: + str: The rendered reprex + + Example: + ```python + 2+2 + #> 4 + ``` + """ + if config is None: + config = ReprexConfig() + advertise = config.advertise if config.advertise is not None else True + out = [] + out.append("```python") + out.append(reprex_str) + out.append("```") + if advertise: + out.append("\n" + Advertisement().markdown()) + if config.session_info: + out.append("\n
Session Info") + out.append("```text") + out.append(str(SessionInfo())) out.append("```") - out.append(str(reprex_str)) + out.append("
") + return "\n".join(out) + "\n" + + +@formatter_registry.register(venue=Venue.HTML, label="HTML") +def format_as_html(reprex_str: str, config: Optional[ReprexConfig] = None) -> str: + """Format a rendered reprex reprex as an HTML code block. If optional dependency Pygments is + available, the rendered HTML will have syntax highlighting for the Python code. By default, + includes a footer that credits reprexlite. + + Args: + reprex_str (str): The reprex string to render. + config (Optional[ReprexConfig]): Configuration for the reprex. Defaults to None. + + Returns: + str: The rendered reprex + + Example: +
2+2
+        #> 4
+ """ + if config is None: + config = ReprexConfig() + advertise = config.advertise if config.advertise is not None else True + out = [] + try: + from pygments import highlight + from pygments.formatters import HtmlFormatter + from pygments.lexers import PythonLexer + + formatter = HtmlFormatter(style="friendly", lineanchors=True, linenos=True, wrapcode=True) + out.append(f"") + out.append(highlight(str(reprex_str), PythonLexer(), formatter)) + except ImportError: + out.append(f"
{reprex_str}
") + + if advertise: + out.append(Advertisement().html().strip()) + if config.session_info: + out.append("
Session Info") + out.append(f"
{SessionInfo()}
") + out.append("
") + return "\n".join(out) + "\n" + + +@formatter_registry.register(venue=Venue.PY, label="Python script") +def format_as_python_script(reprex_str: str, config: Optional[ReprexConfig] = None) -> str: + """Format a rendered reprex reprex as a Python script. + + Args: + reprex_str (str): The reprex string to render. + config (Optional[ReprexConfig]): Configuration for the reprex. Defaults to None. + + Returns: + str: The rendered reprex + + Example: + 2+2 + #> 4 + """ + if config is None: + config = ReprexConfig() + advertise = config.advertise if config.advertise is not None else False + out = [str(reprex_str)] + if advertise: + out.append("\n" + Advertisement().code_comment()) + if config.session_info: + out.append("") + sess_lines = str(SessionInfo()).split("\n") + out.extend("# " + line for line in sess_lines) + return "\n".join(out) + "\n" + + +@formatter_registry.register(venue=Venue.RTF, label="Rich Text Format") +def format_as_rtf(reprex_str: str, config: Optional[ReprexConfig] = None) -> str: + """Format a rendered reprex reprex as a Rich Text Format (RTF) document. Requires dependency + Pygments.""" + if config is None: + config = ReprexConfig() + advertise = config.advertise if config.advertise is not None else False + try: + from pygments import highlight + from pygments.formatters import RtfFormatter + from pygments.lexers import PythonLexer + except ModuleNotFoundError as e: + if e.name == "pygments": + raise PygmentsNotFoundError("Pygments is required for RTF output.", name="pygments") + else: + raise + + out = str(reprex_str) + if advertise: + out += "\n\n" + Advertisement().text() + if config.session_info: + out += "\n\n" + str(SessionInfo()) + return highlight(out, PythonLexer(), RtfFormatter()) + "\n" + + +@formatter_registry.register(venue=Venue.SLACK, label="Slack") +def format_for_slack(reprex_str: str, config: Optional[ReprexConfig] = None) -> str: + """Format a rendered reprex as Slack markup. + + Args: + reprex_str (str): The reprex string to render. + config (Optional[ReprexConfig]): Configuration for the reprex. Defaults to None. + + Returns: + str: The rendered reprex + + Example: + ``` + 2+2 + #> 4 + ``` + """ + if config is None: + config = ReprexConfig() + advertise = config.advertise if config.advertise is not None else False + out = [] + out.append("```") + out.append(str(reprex_str)) + out.append("```") + if advertise: + out.append("\n" + Advertisement().text()) + if config.session_info: + out.append("\n```") + out.append(str(SessionInfo())) out.append("```") - if advertise: - out.append("\n" + Advertisement().text()) - if session_info: - out.append("\n```") - out.append(str(SessionInfo())) - out.append("```") - return "\n".join(out) + "\n" + return "\n".join(out) + "\n" class Advertisement: diff --git a/reprexlite/ipython.py b/reprexlite/ipython.py index 690a4bd..f283812 100644 --- a/reprexlite/ipython.py +++ b/reprexlite/ipython.py @@ -1,9 +1,8 @@ -from contextlib import contextmanager +from contextlib import contextmanager, redirect_stdout +import io import re from typing import Optional -from typer.testing import CliRunner - import reprexlite.cli from reprexlite.config import ReprexConfig from reprexlite.exceptions import IPythonNotFoundError @@ -24,9 +23,6 @@ raise -runner = CliRunner() - - @contextmanager def patch_edit(input: str): """Patches typer.edit to return the input string instead of opening up the text editor. This @@ -36,10 +32,10 @@ def patch_edit(input: str): def return_input(*args, **kwargs) -> str: return input - original = reprexlite.cli.typer.edit - setattr(reprexlite.cli.typer, "edit", return_input) + original = reprexlite.cli.handle_editor + setattr(reprexlite.cli, "handle_editor", return_input) yield - setattr(reprexlite.cli.typer, "edit", original) + setattr(reprexlite.cli, "handle_editor", original) @magics_class @@ -50,16 +46,16 @@ def reprex(self, line: str, cell: Optional[str] = None): render a reprex.""" # Line magic, print help if cell is None: - help_text = runner.invoke( - reprexlite.cli.app, ["--help"], env={"TERM": "dumb"} - ).stdout.strip() - help_text = re.sub(r"^Usage: main", r"Cell Magic Usage: %%reprex", help_text) + with io.StringIO() as buffer, redirect_stdout(buffer): + reprexlite.cli.app("--help") + help_text = buffer.getvalue() + help_text = re.sub(r"^Usage: reprex", r"Cell Magic Usage: %%reprex", help_text) print(f"reprexlite v{__version__} IPython Magic\n\n" + help_text) return # Cell magic, render reprex with patch_edit(cell): - result = runner.invoke(reprexlite.cli.app, line.split()) - print(result.stdout, end="") + reprexlite.cli.app(line.split()) + # print(stdout, end="") def load_ipython_extension(ipython: InteractiveShell): @@ -80,7 +76,7 @@ class ReprexTerminalInteractiveShell(TerminalInteractiveShell): """Subclass of IPython's TerminalInteractiveShell that automatically executes all cells using reprexlite instead of normally.""" - banner1 = "".join(ipython_banner_parts) + banner1 = "".join(ipython_banner_parts) # type: ignore _reprex_config: Optional[ReprexConfig] = None def run_cell(self, raw_cell: str, *args, **kwargs): @@ -88,13 +84,13 @@ def run_cell(self, raw_cell: str, *args, **kwargs): if raw_cell != "exit": try: r = Reprex.from_input(raw_cell, config=self.reprex_config) - print(r.format(terminal=True), end="") + print(r.render_and_format(terminal=True), end="") except Exception as e: print("ERROR: reprexlite has encountered an error while evaluating your input.") print(e, end="") # Store history - self.history_manager.store_inputs(self.execution_count, raw_cell, raw_cell) + self.history_manager.store_inputs(self.execution_count, raw_cell, raw_cell) # type: ignore self.execution_count += 1 return None @@ -111,9 +107,9 @@ def reprex_config(self) -> ReprexConfig: class ReprexTerminalIPythonApp(TerminalIPythonApp): """Subclass of TerminalIPythonApp that launches ReprexTerminalInteractiveShell.""" - interactive_shell_class = ReprexTerminalInteractiveShell + interactive_shell_class = ReprexTerminalInteractiveShell # type: ignore @classmethod def set_reprex_config(cls, config: ReprexConfig): """Set the reprex config bound on the interactive shell.""" - cls.interactive_shell_class._reprex_config = config + cls.interactive_shell_class._reprex_config = config # type: ignore diff --git a/reprexlite/reprexes.py b/reprexlite/reprexes.py index 7817aeb..41b9113 100644 --- a/reprexlite/reprexes.py +++ b/reprexlite/reprexes.py @@ -393,14 +393,7 @@ def __eq__(self, other: Any) -> bool: return NotImplemented def __str__(self) -> str: - if self.config.keep_old_results: - lines = chain.from_iterable(zip(self.statements, self.old_results, self.results)) - else: - lines = chain.from_iterable(zip(self.statements, self.results)) - out = "\n".join(str(line) for line in lines if line) - if not out.endswith("\n"): - out += "\n" - return out + return self.render() @property def results_match(self) -> bool: @@ -409,8 +402,16 @@ def results_match(self) -> bool: result == old_result for result, old_result in zip(self.results, self.old_results) ) - def format(self, terminal: bool = False) -> str: - out = str(self) + def render(self, terminal: bool = False) -> str: + """Render the reprex as code.""" + if self.config.keep_old_results: + lines = chain.from_iterable(zip(self.statements, self.old_results, self.results)) + else: + lines = chain.from_iterable(zip(self.statements, self.results)) + out = "\n".join(str(line) for line in lines if line) + if not out.endswith("\n"): + out += "\n" + # if terminal=True and Pygments is available, apply syntax highlighting if terminal: try: from pygments import highlight @@ -420,10 +421,13 @@ def format(self, terminal: bool = False) -> str: out = highlight(out, PythonLexer(), Terminal256Formatter(style="friendly")) except ModuleNotFoundError: pass - formatter = formatter_registry[self.config.venue] - return formatter.format( - out.strip(), advertise=self.config.advertise, session_info=self.config.session_info - ) + return out + + def render_and_format(self, terminal: bool = False) -> str: + """Render the reprex as code and format it for the configured output venue.""" + out = self.render(terminal=terminal) + formatter_fn = formatter_registry[self.config.venue].fn + return formatter_fn(out.strip(), config=self.config) def __repr__(self) -> str: return f"" @@ -438,9 +442,9 @@ def _repr_html_(self) -> str: formatter = HtmlFormatter(style="friendly", wrapcode=True) out.append(f"") - out.append(highlight(self.format(), PythonLexer(), formatter)) + out.append(highlight(self.render_and_format(), PythonLexer(), formatter)) except ModuleNotFoundError: - out.append(f"
{self.format()}
") + out.append(f"
{self.render_and_format()}
") return "\n".join(out) @@ -505,10 +509,10 @@ def reprex( # Don't screw up output file or lexing for HTML and RTF with terminal syntax highlighting terminal = False r = Reprex.from_input(input, config=config) - output = r.format(terminal=terminal) + output = r.render_and_format(terminal=terminal) if outfile is not None: with Path(outfile).open("w") as fp: - fp.write(r.format(terminal=False)) + fp.write(r.render_and_format(terminal=False)) if print_: print(output) return r diff --git a/reprexlite/session_info.py b/reprexlite/session_info.py index 8fcc91e..6b09583 100644 --- a/reprexlite/session_info.py +++ b/reprexlite/session_info.py @@ -1,12 +1,7 @@ +import importlib.metadata import platform -import sys from typing import List, Tuple -if sys.version_info[:2] >= (3, 8): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - class SessionInfo: """Class for pretty-formatting Python session info. Includes details about your Python version, @@ -25,7 +20,7 @@ def __init__(self) -> None: self.os: str = platform.platform() self.packages: List[Package] = [ - Package(distr) for distr in importlib_metadata.Distribution.discover() + Package(distr) for distr in importlib.metadata.Distribution.discover() ] def __str__(self) -> str: @@ -46,7 +41,7 @@ class Package: instances for introspection by [`SessionInfo`][reprexlite.session_info.SessionInfo]. """ # noqa: E501 - def __init__(self, distribution: importlib_metadata.Distribution): + def __init__(self, distribution: importlib.metadata.Distribution): self.distribution = distribution @property diff --git a/reprexlite/version.py b/reprexlite/version.py index 5519be3..1ea1a68 100644 --- a/reprexlite/version.py +++ b/reprexlite/version.py @@ -1,9 +1,3 @@ -import sys +import importlib.metadata -if sys.version_info[:2] >= (3, 8): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - - -__version__ = importlib_metadata.version(__name__.split(".", 1)[0]) +__version__ = importlib.metadata.version(__name__.split(".", 1)[0]) diff --git a/tests/test_cli.py b/tests/test_cli.py index 166ef27..2721dd8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,17 +3,15 @@ import sys from textwrap import dedent +import platformdirs import pytest -import typer -from typer.testing import CliRunner -from reprexlite.cli import app +import reprexlite.cli +from reprexlite.cli import app, user_reprexlite_toml_loader from reprexlite.exceptions import IPythonNotFoundError from reprexlite.version import __version__ from tests.utils import remove_ansi_escape -runner = CliRunner() - INPUT = dedent( """\ x = 2 @@ -36,11 +34,12 @@ def __init__(self): self.input = INPUT def mock_edit(self, *args, **kwargs): + sys.stderr.write("Mocking editor\n") return self.input patch = EditPatch() - monkeypatch.setattr(typer, "edit", patch.mock_edit) - return patch + monkeypatch.setattr(reprexlite.cli, "handle_editor", patch.mock_edit) + yield patch @pytest.fixture @@ -55,32 +54,56 @@ def mocked_import(name, *args): monkeypatch.setattr(builtins, "__import__", mocked_import) -def test_reprex(patch_edit): - result = runner.invoke(app) - print(result.stdout) - assert result.exit_code == 0 - assert EXPECTED in remove_ansi_escape(result.stdout) +@pytest.fixture +def project_dir(tmp_path, monkeypatch): + project_dir = tmp_path / "project_dir" + project_dir.mkdir() + monkeypatch.chdir(project_dir) + yield project_dir + + +@pytest.fixture +def user_config_dir(tmp_path, monkeypatch): + user_config_dir = tmp_path / "user_config_dir" + user_config_dir.mkdir() + + def _mock_get_user_config_dir(*args, **kwargs): + return user_config_dir + + monkeypatch.setattr(platformdirs, "user_config_dir", _mock_get_user_config_dir) + yield user_config_dir + + +def test_reprex(project_dir, user_config_dir, patch_edit, capsys): + assert reprexlite.cli.handle_editor == patch_edit.mock_edit + capsys.readouterr() + app([]) + stdout = capsys.readouterr().out + print(stdout) + assert EXPECTED in remove_ansi_escape(stdout) -def test_reprex_infile(tmp_path): +def test_reprex_infile(project_dir, user_config_dir, tmp_path, capsys): infile = tmp_path / "infile.py" with infile.open("w") as fp: fp.write(INPUT) - result = runner.invoke(app, ["-i", str(infile)]) - assert result.exit_code == 0 - assert EXPECTED in remove_ansi_escape(result.stdout) + app(["-i", str(infile)]) + stdout = capsys.readouterr().out + print(stdout) + assert EXPECTED in remove_ansi_escape(stdout) -def test_reprex_outfile(patch_edit, tmp_path): +def test_reprex_outfile(project_dir, user_config_dir, patch_edit, tmp_path, capsys): outfile = tmp_path / "outfile.md" - result = runner.invoke(app, ["-o", str(outfile)]) - assert result.exit_code == 0 + app(["-o", str(outfile)]) with outfile.open("r") as fp: assert EXPECTED in fp.read() - assert str(outfile) in result.stdout + stdout = capsys.readouterr().out + print(stdout) + assert str(outfile) in stdout -def test_old_results(patch_edit): +def test_old_results(project_dir, user_config_dir, patch_edit, capsys): patch_edit.input = dedent( """\ arr = [1, 2, 3, 4, 5] @@ -90,51 +113,61 @@ def test_old_results(patch_edit): ) # no --old-results (default) - result = runner.invoke(app) - print(result.stdout) - assert result.exit_code == 0 - assert "#> old line" not in result.stdout - assert "#> [2, 3, 4, 5, 6]" in result.stdout + capsys.readouterr() + app([]) + stdout = capsys.readouterr().out + print(stdout) + assert "#> old line" not in stdout + assert "#> [2, 3, 4, 5, 6]" in stdout # with --old-results - result = runner.invoke(app, ["--keep-old-results"]) - print(result.stdout) - assert result.exit_code == 0 - assert "#> old line" in result.stdout - assert "#> [2, 3, 4, 5, 6]" in result.stdout + app(["--keep-old-results"]) + stdout = capsys.readouterr().out + print(stdout) + assert "#> old line" in stdout + assert "#> [2, 3, 4, 5, 6]" in stdout -def test_ipython_editor(): - """Test that IPython interactive editor opens as expected. Not testing a reprex. Not sure how - to inject input into the IPython shell.""" - result = runner.invoke(app, ["-e", "ipython"]) - assert result.exit_code == 0 - assert "Interactive reprex editor via IPython" in result.stdout # text from banner +# def test_ipython_editor(project_dir, user_config_dir): +# """Test that IPython interactive editor opens as expected. Not testing a reprex.""" +# result = subprocess.run( +# [sys.executable, "-I", "-m", "reprexlite", "-e", "ipython"], +# stdout=subprocess.PIPE, +# stderr=subprocess.PIPE, +# universal_newlines=True, +# text=True, +# input="exit", +# ) +# assert result.returncode == 0 +# assert "Interactive reprex editor via IPython" in result.stdout # text from banner -def test_ipython_editor_not_installed(no_ipython): + +def test_ipython_editor_not_installed(project_dir, user_config_dir, no_ipython, capsys): """Test for expected error when opening the IPython interactive editor without IPython installed""" - result = runner.invoke(app, ["-e", "ipython"]) - assert result.exit_code == 1 - assert "ipython is required" in result.stdout + with pytest.raises(SystemExit) as excinfo: + app(["-e", "ipython"]) + assert excinfo.value.code == 1 + stdout = capsys.readouterr().out + assert "ipython is required" in stdout -def test_help(): +def test_help(project_dir, user_config_dir, capsys): """Test the CLI with --help flag.""" - result = runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "Render reproducible examples of Python code for sharing." in result.output + app(["--help"]) + stdout = capsys.readouterr().out + assert "Render reproducible examples of Python code for sharing." in stdout -def test_version(): +def test_version(project_dir, user_config_dir, capsys): """Test the CLI with --version flag.""" - result = runner.invoke(app, ["--version"]) - assert result.exit_code == 0 - assert result.output.strip() == __version__ + app(["--version"]) + stdout = capsys.readouterr().out + assert stdout.strip() == __version__ -def test_python_m_version(): +def test_python_m_version(project_dir, user_config_dir): """Test the CLI with python -m and --version flag.""" result = subprocess.run( [sys.executable, "-I", "-m", "reprexlite", "--version"], @@ -144,3 +177,47 @@ def test_python_m_version(): ) assert result.returncode == 0 assert result.stdout.strip() == __version__ + + +def test_pyproject_toml(project_dir, user_config_dir): + pyproject_toml = project_dir / "pyproject.toml" + with pyproject_toml.open("w") as fp: + fp.write( + dedent( + """\ + [tool.reprexlite] + editor = "test_editor" + """ + ) + ) + params = app(["--debug"]) + assert params["config"]["editor"] == "test_editor" + + +@pytest.mark.parametrize("filename", [".reprexlite.toml", "reprexlite.toml"]) +def test_reprexlite_toml(project_dir, user_config_dir, filename): + reprexlite_toml = project_dir / filename + with reprexlite_toml.open("w") as fp: + fp.write( + dedent( + """\ + editor = "test_editor" + """ + ) + ) + params = app(["--debug"]) + assert params["config"]["editor"] == "test_editor" + + +def test_user_config_dir(project_dir, user_config_dir, monkeypatch): + with (user_config_dir / "config.toml").open("w") as fp: + fp.write( + dedent( + """\ + editor = "test_editor" + """ + ) + ) + monkeypatch.setattr(user_reprexlite_toml_loader, "path", user_config_dir / "config.toml") + params = app(["--debug"]) + assert params["config"]["editor"] == "test_editor" diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 8bd02b1..3ceb46b 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -4,9 +4,9 @@ import pytest -from reprexlite.config import ReprexConfig -from reprexlite.exceptions import NotAFormatterError, PygmentsNotFoundError -from reprexlite.formatting import register_formatter +from reprexlite.config import ReprexConfig, Venue +from reprexlite.exceptions import PygmentsNotFoundError +from reprexlite.formatting import formatter_registry from reprexlite.reprexes import Reprex from tests.expected_formatted import ( ASSETS_DIR, @@ -37,7 +37,7 @@ def patch_session_info(monkeypatch): @pytest.mark.parametrize("ereprex", expected_reprexes, ids=[e.filename for e in expected_reprexes]) def test_reprex(ereprex, patch_datetime, patch_session_info, patch_version): r = Reprex.from_input(INPUT, ReprexConfig(**ereprex.kwargs)) - actual = r.format() + actual = r.render_and_format() with (ASSETS_DIR / ereprex.filename).open("r") as fp: assert str(actual) == fp.read() assert str(actual).endswith("\n") @@ -55,9 +55,15 @@ def mocked_import(name, *args): monkeypatch.setattr(builtins, "__import__", mocked_import) +def test_all_venues_have_formatters(): + for venue in Venue: + print(venue) + assert venue in formatter_registry + + def test_html_no_pygments(patch_datetime, patch_version, no_pygments): r = Reprex.from_input(INPUT, ReprexConfig(venue="html")) - actual = r.format() + actual = r.render_and_format() expected = dedent( """\
x = 2
@@ -73,7 +79,7 @@ def test_html_no_pygments(patch_datetime, patch_version, no_pygments):
 def test_rtf_no_pygments(patch_datetime, patch_version, no_pygments):
     with pytest.raises(PygmentsNotFoundError):
         r = Reprex.from_input(INPUT, ReprexConfig(venue="rtf"))
-        r.format()
+        r.render_and_format()
 
 
 @pytest.fixture
@@ -95,15 +101,7 @@ def test_rtf_pygments_bad_dependency(patch_datetime, patch_version, pygments_bad
     """Test that a bad import inside pygments does not trigger PygmentsNotFoundError"""
     with pytest.raises(ModuleNotFoundError) as exc_info:
         r = Reprex.from_input(INPUT, ReprexConfig(venue="rtf"))
-        r.format()
+        r.render_and_format()
     assert not isinstance(exc_info.type, PygmentsNotFoundError)
     assert exc_info.value.name != "pygments"
     assert exc_info.value.name == pygments_bad_dependency
-
-
-def test_not_a_formatter_error():
-    with pytest.raises(NotAFormatterError):
-
-        @register_formatter("l33t", label="l33t")
-        class F0rm4tt3r:
-            pass
diff --git a/tests/test_ipython_editor.py b/tests/test_ipython_editor.py
index 7048e01..aafc50c 100644
--- a/tests/test_ipython_editor.py
+++ b/tests/test_ipython_editor.py
@@ -58,7 +58,7 @@ def test_ipython_editor(reprexlite_ipython, capsys):
     reprexlite_ipython.run_cell(input)
     captured = capsys.readouterr()
     r = Reprex.from_input(input)
-    expected = r.format()
+    expected = r.render_and_format()
 
     print("\n---EXPECTED---\n")
     print(expected)
diff --git a/tests/test_ipython_magics.py b/tests/test_ipython_magics.py
index e1cfc19..df56856 100644
--- a/tests/test_ipython_magics.py
+++ b/tests/test_ipython_magics.py
@@ -51,7 +51,7 @@ def test_cell_magic(ipython, capsys):
     captured = capsys.readouterr()
 
     r = Reprex.from_input(input, config=ReprexConfig(advertise=False, session_info=True))
-    expected = r.format(terminal=True)
+    expected = r.render_and_format(terminal=True)
 
     print("\n---EXPECTED---\n")
     print(expected)
diff --git a/tests/test_reprexes.py b/tests/test_reprexes.py
index 59ea3a2..4ae192b 100644
--- a/tests/test_reprexes.py
+++ b/tests/test_reprexes.py
@@ -697,7 +697,7 @@ def mocked_import(name, *args):
 def test_no_black(no_black):
     with pytest.raises(BlackNotFoundError):
         reprex = Reprex.from_input("2+2", config=ReprexConfig(style=True))
-        reprex.format()
+        reprex.render_and_format()
 
 
 @pytest.fixture
@@ -718,7 +718,7 @@ def mocked_import(name, *args):
 def test_black_bad_dependency(black_bad_dependency, monkeypatch):
     with pytest.raises(ModuleNotFoundError) as exc_info:
         reprex = Reprex.from_input("2+2", config=ReprexConfig(style=True))
-        reprex.format()
+        reprex.render_and_format()
     assert not isinstance(exc_info.type, BlackNotFoundError)
     assert exc_info.value.name != "black"
     assert exc_info.value.name == black_bad_dependency
@@ -739,7 +739,7 @@ def mocked_import(name, *args):
 def test_no_pygments_terminal(no_pygments):
     """Test that format for terminal works even if pygments is not installed."""
     r = Reprex.from_input("2+2")
-    assert_str_equals(r.format(terminal=False), r.format(terminal=True))
+    assert_str_equals(r.render_and_format(terminal=False), r.render_and_format(terminal=True))
 
 
 def test_repr_html():
@@ -776,4 +776,4 @@ def test_reprex_function(tmp_path):
 
     # Test writing to file
     with (tmp_path / "rendered.txt").open("r") as fp:
-        assert expected.format() == fp.read()
+        assert expected.render_and_format() == fp.read()