diff --git a/.github/workflows/docs-main.yml b/.github/workflows/docs-main.yml index c7f611a..d20acdd 100644 --- a/.github/workflows/docs-main.yml +++ b/.github/workflows/docs-main.yml @@ -19,16 +19,14 @@ jobs: cache: "pip" cache-dependency-path: | pyproject.toml - requirements-dev.txt - - name: Install dependencies + - name: Install hatch run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt + pip install hatch - name: Test building documentation run: | - make docs + hatch run docs:build - name: Deploy docs to gh-pages working-directory: docs @@ -36,4 +34,4 @@ jobs: git fetch origin gh-pages --depth=1 git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - mike deploy --push ~latest --title=latest + hatch run docs:mike deploy --push ~latest --title=latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 934d4ff..f5e673f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,21 +20,19 @@ jobs: cache: "pip" cache-dependency-path: | pyproject.toml - requirements-dev.txt - - name: Install dependencies + - name: Install hatch run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt + pip install hatch - name: Check that versions match id: version run: | echo "Release tag: [${{ github.event.release.tag_name }}]" - PACKAGE_VERSION=$(python -c "import reprexlite; print(reprexlite.__version__)") + PACKAGE_VERSION=$(hatch project metadata | jq -r .version) echo "Package version: [$PACKAGE_VERSION]" [ ${{ github.event.release.tag_name }} == "v$PACKAGE_VERSION" ] || { exit 1; } - echo "::set-output name=major_minor_version::v${PACKAGE_VERSION%.*}" + echo "major_minor_version=v${PACKAGE_VERSION%.*}" >> $GITHUB_OUTPUT - name: Build package run: | @@ -61,6 +59,7 @@ jobs: git fetch origin gh-pages --depth=1 git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com + hatch shell docs # Rename old stable version mike list -j | jq OLD_STABLE=$(mike list -j | jq -r '.[] | select(.aliases | index("stable")) | .title' | awk '{print $1;}') diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4b26da..66fe3d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,26 +23,37 @@ jobs: cache: "pip" cache-dependency-path: | pyproject.toml - requirements-dev.txt - - name: Install dependencies + - name: Install hatch run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt + pip install hatch - name: Lint package run: | - make lint - make typecheck + hatch run lint:lint tests: - name: "Tests (${{ matrix.os }}, Python ${{ matrix.python-version }})" + name: "Tests (${{ matrix.os }}, Python ${{ matrix.python-version }}, Extra ${{ matrix.extra }})" needs: code-quality runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] + extra: [""] + include: + - os: ubuntu-latest + python-version: "3.10" + extra: "-black" + - os: ubuntu-latest + python-version: "3.10" + extra: "-ipython" + - os: ubuntu-latest + python-version: "3.10" + extra: "-pygments" + - os: ubuntu-latest + python-version: "3.10" + extra: "-rich" steps: - uses: actions/checkout@v3 @@ -54,20 +65,19 @@ jobs: cache: "pip" cache-dependency-path: | pyproject.toml - requirements-dev.txt - - name: Install dependencies + - name: Install hatch run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt + pip install hatch - name: Run tests run: | - make test + hatch run tests.py${{ matrix.python-version }}${{ matrix.extra }}:run-cov - name: Upload coverage to codecov uses: codecov/codecov-action@v3 with: + token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml fail_ci_if_error: true if: ${{ matrix.os == 'ubuntu-latest' }} @@ -75,7 +85,7 @@ jobs: - name: Build distribution and test installation shell: bash run: | - make dist + hatch build if [[ ${{ matrix.os }} == "windows-latest" ]]; then PYTHON_BIN=Scripts/python else @@ -94,7 +104,7 @@ jobs: - name: Test building documentation run: | - make docs + hatch run docs:build if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.10' - name: Deploy site preview to Netlify diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e646a..8c859ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ 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. + - Rather than `*Reprex` classes that encapsulate reprex data, we now have simple formatter callables that 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. - Formatters are added to the registry using a `register_formatter` decorator instead of being hard-coded. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..abe29ea --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing to reprexlite + +[![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) +[![linting - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v0.json)](https://github.com/charliermarsh/ruff) +[![code style - black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![types - mypy](https://img.shields.io/badge/types-mypy-blue.svg)](https://github.com/python/mypy) + +## Report a bug or request a feature + +Please file an issue in the [issue tracker](https://github.com/jayqi/reprexlite/issues). + +## Standalone tests + +To run tests in your current environment, you should install from source with the `tests` extra to additionally install test dependencies. Then, use pytest to run the tests. + +```bash +# Install with test dependencies +pip install .[tests] +# Run tests +pytest tests.py +``` + +## Developers guide + +This project uses [Hatch](https://github.com/pypa/hatch) as its project management tool. The default environment includes dependencies for linting as well as all extra dependencies. + +### Linting + +To run linting or typechecking from the default environment: + +```bash +hatch run lint +hatch run typecheck +``` + +### Tests + +To run tests on the full test matrix, use the Hatch command: + +```bash +hatch run tests:run +``` diff --git a/pyproject.toml b/pyproject.toml index 1cdb8f9..dd48702 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["flit_core >=3.2,<4"] -build-backend = "flit_core.buildapi" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] name = "reprexlite" @@ -31,8 +31,10 @@ dependencies = [ [project.optional-dependencies] black = ["black"] -pygments = ["Pygments"] ipython = ["ipython"] +pygments = ["Pygments"] +rich = ["rich"] +tests = ["pytest"] [project.scripts] reprex = "reprexlite.cli:app" @@ -43,6 +45,91 @@ reprex = "reprexlite.cli:app" "Bug Tracker" = "https://github.com/jayqi/reprexlite/issues" "Changelog" = "https://jayqi.github.io/reprexlite/stable/changelog/" +[tool.hatch.build] +exclude = ["docs/"] + +## DEFAULT ENVIRONMENT ## + +[tool.hatch.envs.default] +features = ["black", "pygments", "ipython", "rich", "tests"] +dependencies = [ + "tqdm", # For script to regenerate test assets +] +path = ".venv" +template = "lint" + +## LINTING ENVIRONMENT ## +[tool.hatch.envs.lint] +dependencies = ["black", "mypy", "ruff"] +python = "3.10" +detached = true + +[tool.hatch.envs.lint.scripts] +lint = ["black --check reprexlite tests", "ruff check reprexlite tests"] +typecheck = ["mypy reprexlite --install-types --non-interactive"] + +## TESTS ENVIRONMENTS ## + +[tool.hatch.envs.tests] +features = ["tests"] +dependencies = ["coverage", "pytest-cov"] +template = "tests" + +[[tool.hatch.envs.tests.matrix]] +python = ["3.7", "3.8", "3.9", "3.10", "3.11"] + +[[tool.hatch.envs.tests.matrix]] +python = ["3.10"] +extras = ["black", "ipython", "pygments", "rich"] + +[tool.hatch.envs.tests.overrides] +matrix.extras.features = [ + { value = "black", if = [ + "black", + ] }, + { value = "ipython", if = [ + "ipython", + ] }, + { value = "pygments", if = [ + "pygments", + ] }, + { value = "rich", if = [ + "rich", + ] }, +] + +[tool.hatch.envs.tests.scripts] +run = "pytest tests" +run-cov = "run --cov=reprexlite --cov-report=term --cov-report=html --cov-report=xml" +run-debug = "run --pdb" + + +## DOCS ENVIRONMENT ## + +[tool.hatch.envs.docs] +dependencies = [ + "mkdocs", + "mkdocs-jupyter", + "mkdocs-macros-plugin", + "mkdocs-material", + "mike", + "mkdocstrings[python]", + "mdx-truly-sane-lists", + "py-markdown-table==0.3.3", + "typenames", +] +template = "docs" + +[tool.hatch.envs.docs.scripts] +build = [ + "rm -rf docs/site/", + "cp README.md docs/docs/index.md", + "cp CHANGELOG.md docs/docs/changelog.md", + "cd docs && mkdocs build", +] + + +## TOOLS ## [tool.black] line-length = 99 @@ -81,7 +168,6 @@ ignore_missing_imports = true [tool.pytest.ini_options] minversion = "6.0" -addopts = "--cov=reprexlite --cov-report=term --cov-report=html --cov-report=xml" testpaths = ["tests"] [tool.coverage.run] diff --git a/reprexlite/cli.py b/reprexlite/cli.py index afc323f..424518d 100644 --- a/reprexlite/cli.py +++ b/reprexlite/cli.py @@ -4,6 +4,18 @@ import typer +try: + from rich.console import Console + + console = Console(soft_wrap=True) + print = console.print +except ModuleNotFoundError as e: + if e.name == "rich": + pass + else: + raise # pragma: no cover + + from reprexlite.config import ParsingMethod, ReprexConfig from reprexlite.exceptions import InputSyntaxError, IPythonNotFoundError from reprexlite.formatting import formatter_registry @@ -172,10 +184,10 @@ def main( if outfile: with outfile.open("w") as fp: - fp.write(r.format(terminal=False)) + fp.write(r.format()) print(f"Wrote rendered reprex to {outfile}") else: - print(r.format(terminal=True), end="") + r.print(end="") return r diff --git a/reprexlite/config.py b/reprexlite/config.py index 5a37d82..7e8a395 100644 --- a/reprexlite/config.py +++ b/reprexlite/config.py @@ -28,6 +28,15 @@ class ReprexConfig: formatting. """ + # Editor + editor: Optional[str] = field(default=None, metadata={"help": "..."}) + no_color: bool = field( + default=False, + metadata={ + "help": ("If True, will disable the colored text output when printing to stdout.") + }, + ) + # Formatting venue: str = field( default="gh", diff --git a/reprexlite/exceptions.py b/reprexlite/exceptions.py index d13dcd2..6e5b696 100644 --- a/reprexlite/exceptions.py +++ b/reprexlite/exceptions.py @@ -26,18 +26,10 @@ class IPythonNotFoundError(ModuleNotFoundError, ReprexliteException): """Raised when ipython cannot be found when using an IPython-dependent feature.""" -class MissingDependencyError(ImportError, ReprexliteException): - pass - - 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 @@ -46,6 +38,10 @@ class PygmentsNotFoundError(ModuleNotFoundError, ReprexliteException): """Raised when pygments cannot be found when using a pygments-dependent feature.""" +class RichNotFoundError(ModuleNotFoundError, ReprexliteException): + """Raised when rich cannot be found when using a rich-dependent feature.""" + + class UnexpectedError(ReprexliteException): """Raised when an unexpected case happens.""" diff --git a/reprexlite/formatting.py b/reprexlite/formatting.py index 1209de1..b3d04a0 100644 --- a/reprexlite/formatting.py +++ b/reprexlite/formatting.py @@ -1,40 +1,39 @@ -from abc import ABC, abstractmethod import dataclasses from datetime import datetime -from textwrap import dedent -from typing import ClassVar, Dict, Optional, Type +from typing import Dict, NamedTuple, Optional -from reprexlite.exceptions import NotAFormatterError, PygmentsNotFoundError -from reprexlite.session_info import SessionInfo -from reprexlite.version import __version__ +try: + from typing import Protocol +except ImportError: + from typing_extensions import Protocol # type: ignore[assignment] +try: + from pygments import highlight + import pygments.formatters + from pygments.lexers import PythonLexer -@dataclasses.dataclass -class FormatterMetadata: - example: Optional[str] - venues: Dict[str, str] = dataclasses.field(default_factory=lambda: dict()) + PYGMENTS_IS_AVAILABLE = True +except ModuleNotFoundError as e: + if e.name == "pygments": + PYGMENTS_IS_AVAILABLE = False + else: + raise # pragma: no cover -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. +from reprexlite.exceptions import PygmentsNotFoundError +from reprexlite.session_info import SessionInfo +from reprexlite.version import __version__ - 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 Formatter(Protocol): + """Callback protocol that defines the venue formatter callable type. A formatter callable + should take a reprex string (code with results as comments) and format it for rendering + in a particular venue.""" - @classmethod - @abstractmethod - def format( - cls, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False + def __call__( + self, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False ) -> str: - """Format a reprex string for a specific sharing venue. + """Format a stringified reprex for rendering in some venue. Args: reprex_str (str): String containing rendered reprex output. @@ -48,25 +47,30 @@ def format( """ -formatter_registry: Dict[str, Type[Formatter]] = {} -"""Registry of formatters keyed by venue keywords.""" +class FormatterRegistration(NamedTuple): + """Named tuple that contains a reference to a venue formatter callable and a human-readable + label.""" + + formatter: Formatter + label: str + + +formatter_registry: Dict[str, FormatterRegistration] = {} +"""Registry of venue formatters keyed by venue keywords.""" def register_formatter(venue: str, label: str): - """Decorator that registers a formatter implementation. + """Decorator that registers a venue formatter implementation to a venue keyword. Args: venue (str): Venue keyword that formatter will be registered to. label (str): Short human-readable label explaining the venue. """ - def registrar(cls): + def registrar(fn: Formatter): 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 + formatter_registry[venue] = FormatterRegistration(formatter=fn, label=label) + return fn return registrar @@ -74,76 +78,79 @@ def registrar(cls): @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 - ``` - """ - ) - ) +def format_github_flavored_markdown( + reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False +) -> str: + """Format a reprex in GitHub Flavored Markdown. - @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) + 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. + """ + if advertise is None: + advertise = True + 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("```") - 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" + 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.""" +@dataclasses.dataclass +class HtmlFormatter: + """Format a reprex in HTML. Can use Pygments to add syntax highlighting to the rendered Python + code block. - default_advertise = True - meta = FormatterMetadata( - example=dedent( - """\ -
2+2
-            #> 4
- """ - ) - ) + Attributes: + no_color (bool): Whether to disable syntax highlighting, regardless of whether Pygments is + available. + pygments_style (str): A valid Pygments style name. + """ - @classmethod - def format( - cls, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False + no_color: bool + pygments_style: str = "default" + + def __call__( + self, reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False ) -> str: + """Format a reprex in HTML. + + 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. + """ if advertise is None: - advertise = cls.default_advertise + advertise = 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 + if self.no_color or not PYGMENTS_IS_AVAILABLE: + out.append(f'
{reprex_str}
') + else: + formatter = pygments.formatters.HtmlFormatter( + lineanchors=True, linenos=True, wrapcode=True, style=self.pygments_style ) 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()) @@ -154,102 +161,61 @@ def format( 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 - """ - ) - ) +register_formatter(venue="html", label="HTML")(HtmlFormatter(no_color=False)) +register_formatter(venue="htmlnocolor", label="HTML (No Color)")(HtmlFormatter(no_color=True)) - @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="py", label="Python script") +def format_python( + reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False +) -> str: + if advertise is None: + advertise = False + 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.""" +def format_rtf( + reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False +) -> str: + if not PYGMENTS_IS_AVAILABLE: + raise PygmentsNotFoundError("Pygments is required for RTF output.", name="pygments") - default_advertise = False - meta = FormatterMetadata(example=None) + if advertise is None: + advertise = False - @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" + out = str(reprex_str) + if advertise: + out += "\n\n" + Advertisement().text() + if session_info: + out += "\n\n" + str(SessionInfo()) + return highlight(out, PythonLexer(), pygments.formatters.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 = [] - out.append("```") - out.append(str(reprex_str)) +def format_slack( + reprex_str: str, advertise: Optional[bool] = None, session_info: bool = False +) -> str: + if advertise is None: + advertise = False + out = [] + out.append("```") + out.append(str(reprex_str)) + out.append("```") + if advertise: + out.append("\n" + Advertisement().text()) + if 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 6a053c8..b47cf0f 100644 --- a/reprexlite/ipython.py +++ b/reprexlite/ipython.py @@ -21,7 +21,7 @@ if e.name == "IPython": raise IPythonNotFoundError(*e.args, name="IPython") else: - raise + raise # pragma: no cover runner = CliRunner() @@ -86,7 +86,7 @@ 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="") + r.print(end="") except Exception as e: print("ERROR: reprexlite has encountered an error while evaluating your input.") print(e, end="") diff --git a/reprexlite/printing.py b/reprexlite/printing.py new file mode 100644 index 0000000..736344e --- /dev/null +++ b/reprexlite/printing.py @@ -0,0 +1,93 @@ +from typing import Dict + +try: + from typing import Protocol +except ImportError: + from typing_extensions import Protocol # type: ignore[assignment] + + +try: + from rich.console import Console + from rich.syntax import Syntax + + RICH_IS_AVAILABLE = True + console = Console(soft_wrap=True) +except ModuleNotFoundError as e: + if e.name == "rich": + RICH_IS_AVAILABLE = False + else: + raise # pragma: no cover + + +from reprexlite.exceptions import RichNotFoundError + + +class Printer(Protocol): + """Callback protocol for a printer callable type. A printer callable should print a given + venue-formatted reprex to stdout, potentially with terminal coloring.""" + + def __call__(self, formatted_reprex: str, **kwargs): + """Print given venue-formatted reprex to stdout. + + Args: + formatted_reprex (str): Formatted reprex. + **kwargs: Arguments passed to print(). + """ + + +printer_registry: Dict[str, Printer] = {} +"""Registry of venue printers keyed by venue keywords.""" + + +def register_printer(venue: str): + def registrar(fn: Printer): + global formatter_registry + printer_registry[venue] = fn + return fn + + return registrar + + +@register_printer("ds") +@register_printer("so") +@register_printer("gh") +def print_markdown(formatted_reprex: str, **kwargs): + """Print a formatted markdown reprex using rich. + + Args: + formatted_reprex (str): Formatted reprex. + **kwargs: Arguments passed to rich's Console.print. + """ + if RICH_IS_AVAILABLE: + console.print(Syntax(formatted_reprex, "markdown", theme="ansi_dark"), **kwargs) + else: + raise RichNotFoundError + + +@register_printer("htmlnocolor") +@register_printer("html") +def print_html(formatted_reprex: str, **kwargs): + """Print a formatted HTML reprex using rich. + + Args: + formatted_reprex (str): Formatted reprex. + **kwargs: Arguments passed to rich's Console.print. + """ + if RICH_IS_AVAILABLE: + console.print(Syntax(formatted_reprex, "html", theme="ansi_dark"), **kwargs) + else: + raise RichNotFoundError + + +@register_printer("py") +def print_python_code(formatted_reprex: str, **kwargs): + """Print a formatted Python code reprex using rich. + + Args: + formatted_reprex (str): Formatted reprex. + **kwargs: Arguments passed to rich's Console.print. + """ + if RICH_IS_AVAILABLE: + console.print(Syntax(formatted_reprex, "python", theme="ansi_dark"), **kwargs) + else: + raise RichNotFoundError diff --git a/reprexlite/reprexes.py b/reprexlite/reprexes.py index 7817aeb..ed9210f 100644 --- a/reprexlite/reprexes.py +++ b/reprexlite/reprexes.py @@ -16,9 +16,15 @@ import libcst as cst from reprexlite.config import ParsingMethod, ReprexConfig -from reprexlite.exceptions import BlackNotFoundError, InputSyntaxError, UnexpectedError +from reprexlite.exceptions import ( + BlackNotFoundError, + InputSyntaxError, + RichNotFoundError, + UnexpectedError, +) from reprexlite.formatting import formatter_registry from reprexlite.parsing import LineType, auto_parse, parse +from reprexlite.printing import printer_registry @dataclasses.dataclass @@ -137,7 +143,7 @@ def evaluate(self, scope: dict) -> RawResult: return RawResult(config=self.config, raw=None, stdout=None) if "__name__" not in scope: - scope["__name__"] = "__reprex__" + scope["__name__"] = "__main__" stdout_io = StringIO() try: with redirect_stdout(stdout_io): @@ -409,22 +415,24 @@ 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: + def format(self) -> str: + """Returns the reprex formatted for the configured venue.""" out = str(self) - if terminal: - try: - from pygments import highlight - from pygments.formatters import Terminal256Formatter - from pygments.lexers import PythonLexer - - out = highlight(out, PythonLexer(), Terminal256Formatter(style="friendly")) - except ModuleNotFoundError: - pass - formatter = formatter_registry[self.config.venue] - return formatter.format( + formatter = formatter_registry[self.config.venue].formatter + return formatter( out.strip(), advertise=self.config.advertise, session_info=self.config.session_info ) + def print(self, **kwargs) -> None: + """Prints to stdout the reprex formatted for the configured venue.""" + if self.config.no_color: + print(self.format(), **kwargs) + else: + try: + printer_registry[self.config.venue](self.format(), **kwargs) + except (KeyError, RichNotFoundError): + print(self.format(), **kwargs) + def __repr__(self) -> str: return f"" @@ -445,6 +453,7 @@ def _repr_html_(self) -> str: def to_snippet(s: str, n: int) -> str: + """Utility function that truncates a string to n characters.""" if len(s) <= n: return rf"{s}" else: @@ -455,7 +464,6 @@ def reprex( input: str, outfile: Optional[Union[str, os.PathLike]] = None, print_: bool = True, - terminal: bool = False, config: Optional[ReprexConfig] = None, **kwargs, ) -> Reprex: @@ -484,8 +492,6 @@ def reprex( outfile (Optional[str | os.PathLike]): If provided, path to write formatted reprex output to. Defaults to None, which does not write to any file. print_ (bool): Whether to print formatted reprex output to console. - terminal (bool): Whether currently in a terminal. If true, will automatically apply code - highlighting if pygments is installed. config (Optional[ReprexConfig]): Instance of the configuration dataclass. Default of none will instantiate one with default values. **kwargs: Configuration options from [ReprexConfig][reprexlite.config.ReprexConfig]. Any @@ -501,14 +507,10 @@ def reprex( config = dataclasses.replace(config, **kwargs) config = ReprexConfig(**kwargs) - if config.venue in ["html", "rtf"]: - # 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) if outfile is not None: with Path(outfile).open("w") as fp: - fp.write(r.format(terminal=False)) + fp.write(r.format()) if print_: - print(output) + r.print() return r diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index bab6875..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,20 +0,0 @@ --e .[black,pygments,ipython] - -black -build -mdx_truly_sane_lists==1.3 -mike -mkdocs>=1.2.2 -mkdocs-jupyter -mkdocs-macros-plugin -mkdocs-material>=7.2.6 -mkdocstrings[python-legacy]>=0.15.2 -mypy -pip>=21.3 -py-markdown-table==0.3.3 -pytest -pytest-cov -ruff -tqdm -typenames -wheel diff --git a/tests/assets/ad/html.html b/tests/assets/ad/html.html deleted file mode 100644 index 56bac09..0000000 --- a/tests/assets/ad/html.html +++ /dev/null @@ -1,82 +0,0 @@ - -
1
-2
-3
x = 2
-x + 2
-#> 4
-
- -

Created at DATETIME by reprexlite vVERSION

diff --git a/tests/assets/ad/ds.md b/tests/assets/formatted/ad/ds.md similarity index 100% rename from tests/assets/ad/ds.md rename to tests/assets/formatted/ad/ds.md diff --git a/tests/assets/ad/gh.md b/tests/assets/formatted/ad/gh.md similarity index 100% rename from tests/assets/ad/gh.md rename to tests/assets/formatted/ad/gh.md diff --git a/tests/assets/formatted/ad/html.html b/tests/assets/formatted/ad/html.html new file mode 100644 index 0000000..e980610 --- /dev/null +++ b/tests/assets/formatted/ad/html.html @@ -0,0 +1,82 @@ + +
1
+2
+3
x = 2
+x + 2
+#> 4
+
+ +

Created at DATETIME by reprexlite vVERSION

diff --git a/tests/assets/formatted/ad/htmlnocolor.html b/tests/assets/formatted/ad/htmlnocolor.html new file mode 100644 index 0000000..c549309 --- /dev/null +++ b/tests/assets/formatted/ad/htmlnocolor.html @@ -0,0 +1,4 @@ +
x = 2
+x + 2
+#> 4
+

Created at DATETIME by reprexlite vVERSION

diff --git a/tests/assets/ad/py.py b/tests/assets/formatted/ad/py.py similarity index 100% rename from tests/assets/ad/py.py rename to tests/assets/formatted/ad/py.py diff --git a/tests/assets/ad/rtf.rtf b/tests/assets/formatted/ad/rtf.rtf similarity index 100% rename from tests/assets/ad/rtf.rtf rename to tests/assets/formatted/ad/rtf.rtf diff --git a/tests/assets/ad/slack.txt b/tests/assets/formatted/ad/slack.txt similarity index 100% rename from tests/assets/ad/slack.txt rename to tests/assets/formatted/ad/slack.txt diff --git a/tests/assets/ad/so.md b/tests/assets/formatted/ad/so.md similarity index 100% rename from tests/assets/ad/so.md rename to tests/assets/formatted/ad/so.md diff --git a/tests/assets/ds.md b/tests/assets/formatted/ds.md similarity index 100% rename from tests/assets/ds.md rename to tests/assets/formatted/ds.md diff --git a/tests/assets/gh.md b/tests/assets/formatted/gh.md similarity index 100% rename from tests/assets/gh.md rename to tests/assets/formatted/gh.md diff --git a/tests/assets/formatted/html.html b/tests/assets/formatted/html.html new file mode 100644 index 0000000..e980610 --- /dev/null +++ b/tests/assets/formatted/html.html @@ -0,0 +1,82 @@ + +
1
+2
+3
x = 2
+x + 2
+#> 4
+
+ +

Created at DATETIME by reprexlite vVERSION

diff --git a/tests/assets/formatted/htmlnocolor.html b/tests/assets/formatted/htmlnocolor.html new file mode 100644 index 0000000..c549309 --- /dev/null +++ b/tests/assets/formatted/htmlnocolor.html @@ -0,0 +1,4 @@ +
x = 2
+x + 2
+#> 4
+

Created at DATETIME by reprexlite vVERSION

diff --git a/tests/assets/no_ad/ds.md b/tests/assets/formatted/no_ad/ds.md similarity index 100% rename from tests/assets/no_ad/ds.md rename to tests/assets/formatted/no_ad/ds.md diff --git a/tests/assets/no_ad/gh.md b/tests/assets/formatted/no_ad/gh.md similarity index 100% rename from tests/assets/no_ad/gh.md rename to tests/assets/formatted/no_ad/gh.md diff --git a/tests/assets/formatted/no_ad/html.html b/tests/assets/formatted/no_ad/html.html new file mode 100644 index 0000000..a3b8e7c --- /dev/null +++ b/tests/assets/formatted/no_ad/html.html @@ -0,0 +1,81 @@ + +
1
+2
+3
x = 2
+x + 2
+#> 4
+
+ diff --git a/tests/assets/formatted/no_ad/htmlnocolor.html b/tests/assets/formatted/no_ad/htmlnocolor.html new file mode 100644 index 0000000..0da0ee0 --- /dev/null +++ b/tests/assets/formatted/no_ad/htmlnocolor.html @@ -0,0 +1,3 @@ +
x = 2
+x + 2
+#> 4
diff --git a/tests/assets/no_ad/py.py b/tests/assets/formatted/no_ad/py.py similarity index 100% rename from tests/assets/no_ad/py.py rename to tests/assets/formatted/no_ad/py.py diff --git a/tests/assets/no_ad/rtf.rtf b/tests/assets/formatted/no_ad/rtf.rtf similarity index 100% rename from tests/assets/no_ad/rtf.rtf rename to tests/assets/formatted/no_ad/rtf.rtf diff --git a/tests/assets/no_ad/slack.txt b/tests/assets/formatted/no_ad/slack.txt similarity index 100% rename from tests/assets/no_ad/slack.txt rename to tests/assets/formatted/no_ad/slack.txt diff --git a/tests/assets/no_ad/so.md b/tests/assets/formatted/no_ad/so.md similarity index 100% rename from tests/assets/no_ad/so.md rename to tests/assets/formatted/no_ad/so.md diff --git a/tests/assets/py.py b/tests/assets/formatted/py.py similarity index 100% rename from tests/assets/py.py rename to tests/assets/formatted/py.py diff --git a/tests/assets/rtf.rtf b/tests/assets/formatted/rtf.rtf similarity index 100% rename from tests/assets/rtf.rtf rename to tests/assets/formatted/rtf.rtf diff --git a/tests/assets/session_info/ds.md b/tests/assets/formatted/session_info/ds.md similarity index 100% rename from tests/assets/session_info/ds.md rename to tests/assets/formatted/session_info/ds.md diff --git a/tests/assets/session_info/gh.md b/tests/assets/formatted/session_info/gh.md similarity index 100% rename from tests/assets/session_info/gh.md rename to tests/assets/formatted/session_info/gh.md diff --git a/tests/assets/formatted/session_info/html.html b/tests/assets/formatted/session_info/html.html new file mode 100644 index 0000000..56cbe78 --- /dev/null +++ b/tests/assets/formatted/session_info/html.html @@ -0,0 +1,91 @@ + +
1
+2
+3
x = 2
+x + 2
+#> 4
+
+ +

Created at DATETIME by reprexlite vVERSION

+
Session Info +
-- Session Info --------------------------------------------------------------
+version Python 3.x.y (Jan 01 2020 03:33:33)
+os      GLaDOS
+-- Packages ------------------------------------------------------------------
+datatable 1.0
+ggplot2   2.0
+pkgnet    3.0
+
diff --git a/tests/assets/formatted/session_info/htmlnocolor.html b/tests/assets/formatted/session_info/htmlnocolor.html new file mode 100644 index 0000000..5c34abb --- /dev/null +++ b/tests/assets/formatted/session_info/htmlnocolor.html @@ -0,0 +1,13 @@ +
x = 2
+x + 2
+#> 4
+

Created at DATETIME by reprexlite vVERSION

+
Session Info +
-- Session Info --------------------------------------------------------------
+version Python 3.x.y (Jan 01 2020 03:33:33)
+os      GLaDOS
+-- Packages ------------------------------------------------------------------
+datatable 1.0
+ggplot2   2.0
+pkgnet    3.0
+
diff --git a/tests/assets/session_info/py.py b/tests/assets/formatted/session_info/py.py similarity index 100% rename from tests/assets/session_info/py.py rename to tests/assets/formatted/session_info/py.py diff --git a/tests/assets/session_info/rtf.rtf b/tests/assets/formatted/session_info/rtf.rtf similarity index 100% rename from tests/assets/session_info/rtf.rtf rename to tests/assets/formatted/session_info/rtf.rtf diff --git a/tests/assets/session_info/slack.txt b/tests/assets/formatted/session_info/slack.txt similarity index 100% rename from tests/assets/session_info/slack.txt rename to tests/assets/formatted/session_info/slack.txt diff --git a/tests/assets/session_info/so.md b/tests/assets/formatted/session_info/so.md similarity index 100% rename from tests/assets/session_info/so.md rename to tests/assets/formatted/session_info/so.md diff --git a/tests/assets/slack.txt b/tests/assets/formatted/slack.txt similarity index 100% rename from tests/assets/slack.txt rename to tests/assets/formatted/slack.txt diff --git a/tests/assets/so.md b/tests/assets/formatted/so.md similarity index 100% rename from tests/assets/so.md rename to tests/assets/formatted/so.md diff --git a/tests/assets/html.html b/tests/assets/html.html deleted file mode 100644 index 56bac09..0000000 --- a/tests/assets/html.html +++ /dev/null @@ -1,82 +0,0 @@ - -
1
-2
-3
x = 2
-x + 2
-#> 4
-
- -

Created at DATETIME by reprexlite vVERSION

diff --git a/tests/assets/no_ad/html.html b/tests/assets/no_ad/html.html deleted file mode 100644 index e9e10b8..0000000 --- a/tests/assets/no_ad/html.html +++ /dev/null @@ -1,81 +0,0 @@ - -
1
-2
-3
x = 2
-x + 2
-#> 4
-
- diff --git a/tests/assets/printed/ds.md b/tests/assets/printed/ds.md new file mode 100644 index 0000000..9159c97 --- /dev/null +++ b/tests/assets/printed/ds.md @@ -0,0 +1,8 @@ +```python +x = 2 +x + 2 +#> 4 +``` + +Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION + diff --git a/tests/assets/printed/gh.md b/tests/assets/printed/gh.md new file mode 100644 index 0000000..9159c97 --- /dev/null +++ b/tests/assets/printed/gh.md @@ -0,0 +1,8 @@ +```python +x = 2 +x + 2 +#> 4 +``` + +Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION + diff --git a/tests/assets/printed/htmlnocolor.html b/tests/assets/printed/htmlnocolor.html new file mode 100644 index 0000000..79300d9 --- /dev/null +++ b/tests/assets/printed/htmlnocolor.html @@ -0,0 +1,5 @@ +<pre><code class="language-python">x = 2 +x + 2 +#> 4 +<p><sup>Created at DATETIME by <a href="https://github.com/jayqi/reprexlite">reprexlite vVERSION + diff --git a/tests/assets/printed/no_color/ds.md b/tests/assets/printed/no_color/ds.md new file mode 100644 index 0000000..fcfe0a4 --- /dev/null +++ b/tests/assets/printed/no_color/ds.md @@ -0,0 +1,8 @@ +```python +x = 2 +x + 2 +#> 4 +``` + +Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION + diff --git a/tests/assets/printed/no_color/gh.md b/tests/assets/printed/no_color/gh.md new file mode 100644 index 0000000..fcfe0a4 --- /dev/null +++ b/tests/assets/printed/no_color/gh.md @@ -0,0 +1,8 @@ +```python +x = 2 +x + 2 +#> 4 +``` + +Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION + diff --git a/tests/assets/printed/no_color/htmlnocolor.html b/tests/assets/printed/no_color/htmlnocolor.html new file mode 100644 index 0000000..aafad2d --- /dev/null +++ b/tests/assets/printed/no_color/htmlnocolor.html @@ -0,0 +1,5 @@ +
x = 2
+x + 2
+#> 4
+

Created at DATETIME by reprexlite vVERSION

+ diff --git a/tests/assets/printed/no_color/py.py b/tests/assets/printed/no_color/py.py new file mode 100644 index 0000000..62f0e60 --- /dev/null +++ b/tests/assets/printed/no_color/py.py @@ -0,0 +1,4 @@ +x = 2 +x + 2 +#> 4 + diff --git a/tests/assets/printed/no_color/slack.txt b/tests/assets/printed/no_color/slack.txt new file mode 100644 index 0000000..67c1f39 --- /dev/null +++ b/tests/assets/printed/no_color/slack.txt @@ -0,0 +1,6 @@ +``` +x = 2 +x + 2 +#> 4 +``` + diff --git a/tests/assets/printed/no_color/so.md b/tests/assets/printed/no_color/so.md new file mode 100644 index 0000000..fcfe0a4 --- /dev/null +++ b/tests/assets/printed/no_color/so.md @@ -0,0 +1,8 @@ +```python +x = 2 +x + 2 +#> 4 +``` + +Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION + diff --git a/tests/assets/printed/py.py b/tests/assets/printed/py.py new file mode 100644 index 0000000..a3a0efb --- /dev/null +++ b/tests/assets/printed/py.py @@ -0,0 +1,4 @@ +x = 2 +x + 2 +#> 4 + diff --git a/tests/assets/printed/slack.txt b/tests/assets/printed/slack.txt new file mode 100644 index 0000000..67c1f39 --- /dev/null +++ b/tests/assets/printed/slack.txt @@ -0,0 +1,6 @@ +``` +x = 2 +x + 2 +#> 4 +``` + diff --git a/tests/assets/printed/so.md b/tests/assets/printed/so.md new file mode 100644 index 0000000..9159c97 --- /dev/null +++ b/tests/assets/printed/so.md @@ -0,0 +1,8 @@ +```python +x = 2 +x + 2 +#> 4 +``` + +Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION + diff --git a/tests/assets/session_info/html.html b/tests/assets/session_info/html.html deleted file mode 100644 index 45ad741..0000000 --- a/tests/assets/session_info/html.html +++ /dev/null @@ -1,91 +0,0 @@ - -
1
-2
-3
x = 2
-x + 2
-#> 4
-
- -

Created at DATETIME by reprexlite vVERSION

-
Session Info -
-- Session Info --------------------------------------------------------------
-version Python 3.x.y (Jan 01 2020 03:33:33)
-os      GLaDOS
--- Packages ------------------------------------------------------------------
-datatable 1.0
-ggplot2   2.0
-pkgnet    3.0
-
diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6acd499 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,54 @@ +import sys + +if sys.version_info[:2] >= (3, 8): + import importlib.metadata as importlib_metadata +else: + import importlib_metadata + +import pytest + + +def pytest_configure(config): + try: + import IPython + + print("ipython", IPython.__version__) + pytest.IPYTHON_IS_AVAILABLE = True + except ModuleNotFoundError as e: + if e.name == "IPython": + pytest.IPYTHON_IS_AVAILABLE = False + else: + raise + + try: + import black + + print("black", black.__version__) + pytest.BLACK_IS_AVAILABLE = True + except ModuleNotFoundError as e: + if e.name == "black": + pytest.BLACK_IS_AVAILABLE = False + else: + raise + + try: + import pygments + + print("pygments", pygments.__version__) + pytest.PYGMENTS_IS_AVAILABLE = True + except ModuleNotFoundError as e: + if e.name == "pygments": + pytest.PYGMENTS_IS_AVAILABLE = False + else: + raise + + try: + import rich + + print("rich", importlib_metadata.version(rich.__name__)) + pytest.RICH_IS_AVAILABLE = True + except ModuleNotFoundError as e: + if e.name == "rich": + pytest.RICH_IS_AVAILABLE = False + else: + raise diff --git a/tests/expected_formatted.py b/tests/expected_formatted.py index 67bbbd3..130ac35 100644 --- a/tests/expected_formatted.py +++ b/tests/expected_formatted.py @@ -4,20 +4,17 @@ python -m tests.expected_formatted """ -from contextlib import contextmanager from dataclasses import dataclass +from itertools import chain from pathlib import Path import shutil -import sys from textwrap import dedent from typing import Any, Dict -from tqdm import tqdm - from reprexlite import reprex -from reprexlite.session_info import Package, SessionInfo +from tests.utils import patch_datetime, patch_session_info, patch_version -ASSETS_DIR = (Path(__file__).parent / "assets").resolve() +ASSETS_DIR = (Path(__file__).parent / "assets" / "formatted").resolve() INPUT = dedent( @@ -39,104 +36,56 @@ class ExpectedReprex: ExpectedReprex("gh.md", {"venue": "gh"}), ExpectedReprex("so.md", {"venue": "so"}), ExpectedReprex("ds.md", {"venue": "ds"}), - ExpectedReprex("html.html", {"venue": "html"}), + ExpectedReprex("htmlnocolor.html", {"venue": "htmlnocolor"}), ExpectedReprex("py.py", {"venue": "py"}), - ExpectedReprex("rtf.rtf", {"venue": "rtf"}), ExpectedReprex("slack.txt", {"venue": "slack"}), # With ad ExpectedReprex("ad/gh.md", {"venue": "gh", "advertise": True}), ExpectedReprex("ad/so.md", {"venue": "so", "advertise": True}), ExpectedReprex("ad/ds.md", {"venue": "ds", "advertise": True}), - ExpectedReprex("ad/html.html", {"venue": "html", "advertise": True}), + ExpectedReprex("ad/htmlnocolor.html", {"venue": "htmlnocolor", "advertise": True}), ExpectedReprex("ad/py.py", {"venue": "py", "advertise": True}), - ExpectedReprex("ad/rtf.rtf", {"venue": "rtf", "advertise": True}), ExpectedReprex("ad/slack.txt", {"venue": "slack", "advertise": True}), # No ad ExpectedReprex("no_ad/gh.md", {"venue": "gh", "advertise": False}), ExpectedReprex("no_ad/so.md", {"venue": "so", "advertise": False}), ExpectedReprex("no_ad/ds.md", {"venue": "ds", "advertise": False}), - ExpectedReprex("no_ad/html.html", {"venue": "html", "advertise": False}), + ExpectedReprex("no_ad/htmlnocolor.html", {"venue": "htmlnocolor", "advertise": False}), ExpectedReprex("no_ad/py.py", {"venue": "py", "advertise": False}), - ExpectedReprex("no_ad/rtf.rtf", {"venue": "rtf", "advertise": False}), ExpectedReprex("no_ad/slack.txt", {"venue": "slack", "advertise": False}), # With session info ExpectedReprex("session_info/gh.md", {"venue": "gh", "session_info": True}), ExpectedReprex("session_info/so.md", {"venue": "so", "session_info": True}), ExpectedReprex("session_info/ds.md", {"venue": "ds", "session_info": True}), - ExpectedReprex("session_info/html.html", {"venue": "html", "session_info": True}), + ExpectedReprex( + "session_info/htmlnocolor.html", + {"venue": "htmlnocolor", "session_info": True}, + ), ExpectedReprex("session_info/py.py", {"venue": "py", "session_info": True}), - ExpectedReprex("session_info/rtf.rtf", {"venue": "rtf", "session_info": True}), ExpectedReprex("session_info/slack.txt", {"venue": "slack", "session_info": True}), ] -MOCK_VERSION = "VERSION" - - -@contextmanager -def patch_version(): - version = sys.modules["reprexlite.formatting"].__version__ - sys.modules["reprexlite.formatting"].__version__ = MOCK_VERSION - yield - sys.modules["reprexlite.formatting"].__version__ = version - - -class MockDateTime: - @classmethod - def now(cls): - return cls() - - def astimezone(self): - return self - - def strftime(self, format): - return "DATETIME" - - -@contextmanager -def patch_datetime(): - datetime = sys.modules["reprexlite.formatting"].datetime - sys.modules["reprexlite.formatting"].datetime = MockDateTime - yield - sys.modules["reprexlite.formatting"].datetime = datetime - - -class MockPackage(Package): - def __init__(self, name: str, version: str): - self._name = name - self._version = version - - @property - def name(self): - return self._name - - @property - def version(self): - return self._version - - -class MockSessionInfo(SessionInfo): - def __init__(self, *args, **kwargs): - self.python_version = "3.x.y" - self.python_build_date = "Jan 01 2020 03:33:33" - self.os = "GLaDOS" - self.packages = [ - MockPackage("datatable", "1.0"), - MockPackage("ggplot2", "2.0"), - MockPackage("pkgnet", "3.0"), - ] - - -@contextmanager -def patch_session_info(): - sys.modules["reprexlite.formatting"].SessionInfo = MockSessionInfo - yield - sys.modules["reprexlite.formatting"].SessionInfo = SessionInfo +expected_reprexes_requires_pygments = [ + ExpectedReprex("html.html", {"venue": "html"}), + ExpectedReprex("rtf.rtf", {"venue": "rtf"}), + # With ad + ExpectedReprex("ad/html.html", {"venue": "html", "advertise": True}), + ExpectedReprex("ad/rtf.rtf", {"venue": "rtf", "advertise": True}), + # No ad + ExpectedReprex("no_ad/html.html", {"venue": "html", "advertise": False}), + ExpectedReprex("no_ad/rtf.rtf", {"venue": "rtf", "advertise": False}), + # With session info + ExpectedReprex("session_info/html.html", {"venue": "html", "session_info": True}), + ExpectedReprex("session_info/rtf.rtf", {"venue": "rtf", "session_info": True}), +] if __name__ == "__main__": + from tqdm import tqdm + shutil.rmtree(ASSETS_DIR, ignore_errors=True) with patch_datetime(), patch_version(), patch_session_info(): - for ereprex in tqdm(expected_reprexes): + for ereprex in tqdm(chain(expected_reprexes, expected_reprexes_requires_pygments)): outfile = ASSETS_DIR / ereprex.filename outfile.parent.mkdir(exist_ok=True) reprex(INPUT, outfile=outfile, **ereprex.kwargs, print_=False) diff --git a/tests/expected_printed.py b/tests/expected_printed.py new file mode 100644 index 0000000..8493cde --- /dev/null +++ b/tests/expected_printed.py @@ -0,0 +1,73 @@ +"""This module holds metadata about formatted test cases. It also can be run as a script to +generate expected formatted test assets. + + python -m tests.expected_formatted +""" + +from contextlib import redirect_stdout +from dataclasses import dataclass +from itertools import chain +from pathlib import Path +import shutil +from textwrap import dedent +from typing import Any, Dict + +from reprexlite import reprex +from tests.utils import patch_datetime, patch_version + +ASSETS_DIR = (Path(__file__).parent / "assets" / "printed").resolve() + + +INPUT = dedent( + """\ + x = 2 + x + 2 + """ +) + + +@dataclass +class ExpectedReprex: + filename: str + kwargs: Dict[str, Any] + + +expected_reprexes = [ + # Defaults + ExpectedReprex("gh.md", {"venue": "gh"}), + ExpectedReprex("so.md", {"venue": "so"}), + ExpectedReprex("ds.md", {"venue": "ds"}), + ExpectedReprex("htmlnocolor.html", {"venue": "htmlnocolor"}), + ExpectedReprex("py.py", {"venue": "py"}), + ExpectedReprex("slack.txt", {"venue": "slack"}), +] +expected_reprexes_no_color = [ + # No Color + ExpectedReprex("no_color/gh.md", {"venue": "gh", "no_color": True}), + ExpectedReprex("no_color/so.md", {"venue": "so", "no_color": True}), + ExpectedReprex("no_color/ds.md", {"venue": "ds", "no_color": True}), + ExpectedReprex("no_color/htmlnocolor.html", {"venue": "htmlnocolor", "no_color": True}), + ExpectedReprex("no_color/py.py", {"venue": "py", "no_color": True}), + ExpectedReprex("no_color/slack.txt", {"venue": "slack", "no_color": True}), +] + +if __name__ == "__main__": + import rich # noqa: F401 + from tqdm import tqdm + + shutil.rmtree(ASSETS_DIR, ignore_errors=True) + with patch_datetime(), patch_version(): + for ereprex in tqdm( + list( + chain( + expected_reprexes, + expected_reprexes_no_color, + ) + ) + ): + outfile = ASSETS_DIR / ereprex.filename + outfile.parent.mkdir(exist_ok=True) + r = reprex(INPUT, **ereprex.kwargs, print_=False) + with outfile.open("w") as fp: + with redirect_stdout(fp): + r.print() diff --git a/tests/pytest_utils.py b/tests/pytest_utils.py new file mode 100644 index 0000000..3b37aae --- /dev/null +++ b/tests/pytest_utils.py @@ -0,0 +1,22 @@ +import pytest + +# Requires invoking with pytest + +## SKIP DECORATORS + +requires_ipython = pytest.mark.skipif( + not pytest.IPYTHON_IS_AVAILABLE, reason="ipython is not available" +) +requires_no_ipython = pytest.mark.skipif( + pytest.IPYTHON_IS_AVAILABLE, reason="ipython is available" +) +requires_black = pytest.mark.skipif(not pytest.BLACK_IS_AVAILABLE, reason="black is not available") +requires_no_black = pytest.mark.skipif(pytest.BLACK_IS_AVAILABLE, reason="black is available") +requires_pygments = pytest.mark.skipif( + not pytest.PYGMENTS_IS_AVAILABLE, reason="pygments is not available" +) +requires_no_pygments = pytest.mark.skipif( + pytest.PYGMENTS_IS_AVAILABLE, reason="pygments is available" +) +requires_rich = pytest.mark.skipif(not pytest.RICH_IS_AVAILABLE, reason="rich is not available") +requires_no_rich = pytest.mark.skipif(pytest.RICH_IS_AVAILABLE, reason="rich is available") diff --git a/tests/test_cli.py b/tests/test_cli.py index 2adb8e6..e03ae64 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,5 @@ -import builtins import subprocess +import sys from textwrap import dedent import pytest @@ -7,8 +7,8 @@ from typer.testing import CliRunner from reprexlite.cli import app -from reprexlite.exceptions import IPythonNotFoundError from reprexlite.version import __version__ +from tests.pytest_utils import requires_ipython, requires_no_ipython from tests.utils import remove_ansi_escape runner = CliRunner() @@ -42,18 +42,6 @@ def mock_edit(self, *args, **kwargs): return patch -@pytest.fixture -def no_ipython(monkeypatch): - import_orig = builtins.__import__ - - def mocked_import(name, *args): - if name.startswith("reprexlite.ipython"): - raise IPythonNotFoundError - return import_orig(name, *args) - - monkeypatch.setattr(builtins, "__import__", mocked_import) - - def test_reprex(patch_edit): result = runner.invoke(app) print(result.stdout) @@ -103,6 +91,7 @@ def test_old_results(patch_edit): assert "#> [2, 3, 4, 5, 6]" in result.stdout +@requires_ipython 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.""" @@ -111,7 +100,8 @@ def test_ipython_editor(): assert "Interactive reprex editor via IPython" in result.stdout # text from banner -def test_ipython_editor_not_installed(no_ipython): +@requires_no_ipython +def test_ipython_editor_not_installed(): """Test for expected error when opening the IPython interactive editor without IPython installed""" result = runner.invoke(app, ["-e", "ipython"]) @@ -119,6 +109,25 @@ def test_ipython_editor_not_installed(no_ipython): assert "ipython is required" in result.stdout +def test_syntax_error(patch_edit): + patch_edit.input = dedent( + """\ + 2+ + """ + ) + result = runner.invoke(app) + assert result.exit_code == 1 + assert "Syntax Error" in result.stdout + assert "Incomplete input." in result.stdout + + +def test_verbose(patch_edit): + result = runner.invoke(app, ["--verbose"]) + print(result.stdout) + assert result.exit_code == 0 + assert "ReprexConfig" in remove_ansi_escape(result.stdout) + + def test_help(): """Test the CLI with --help flag.""" result = runner.invoke(app, ["--help"]) @@ -136,7 +145,7 @@ def test_version(): def test_python_m_version(): """Test the CLI with python -m and --version flag.""" result = subprocess.run( - ["python", "-m", "reprexlite", "--version"], + [sys.executable, "-m", "reprexlite", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 8bd02b1..4425446 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -1,22 +1,23 @@ -import builtins import sys -from textwrap import dedent import pytest from reprexlite.config import ReprexConfig -from reprexlite.exceptions import NotAFormatterError, PygmentsNotFoundError -from reprexlite.formatting import register_formatter +from reprexlite.exceptions import PygmentsNotFoundError from reprexlite.reprexes import Reprex from tests.expected_formatted import ( ASSETS_DIR, INPUT, + expected_reprexes, + expected_reprexes_requires_pygments, +) +from tests.pytest_utils import requires_no_pygments, requires_pygments +from tests.utils import ( MOCK_VERSION, MockDateTime, MockSessionInfo, - expected_reprexes, + assert_str_equals, ) -from tests.utils import assert_str_equals @pytest.fixture @@ -35,7 +36,8 @@ 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): +def test_reprex_formatting(ereprex, patch_datetime, patch_session_info, patch_version): + """Test that venue formatting works in basic cases.""" r = Reprex.from_input(INPUT, ReprexConfig(**ereprex.kwargs)) actual = r.format() with (ASSETS_DIR / ereprex.filename).open("r") as fp: @@ -43,67 +45,37 @@ def test_reprex(ereprex, patch_datetime, patch_session_info, patch_version): assert str(actual).endswith("\n") -@pytest.fixture -def no_pygments(monkeypatch): - import_orig = builtins.__import__ - - def mocked_import(name, *args): - if name.startswith("pygments"): - raise ModuleNotFoundError(name="pygments") - return import_orig(name, *args) - - monkeypatch.setattr(builtins, "__import__", mocked_import) - - -def test_html_no_pygments(patch_datetime, patch_version, no_pygments): - r = Reprex.from_input(INPUT, ReprexConfig(venue="html")) +@requires_pygments +@pytest.mark.parametrize( + "ereprex", + expected_reprexes_requires_pygments, + ids=[e.filename for e in expected_reprexes_requires_pygments], +) +def test_reprex_formatting_requires_pygments( + ereprex, patch_datetime, patch_session_info, patch_version +): + """Test that venue formatting works in basic cases.""" + r = Reprex.from_input(INPUT, ReprexConfig(**ereprex.kwargs)) actual = r.format() - expected = dedent( - """\ -
x = 2
-        x + 2
-        #> 4
-

Created at DATETIME by reprexlite vVERSION

- """ # noqa: E501 - ) - assert_str_equals(expected, str(actual)) - assert str(actual).endswith("\n") - - -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() + with (ASSETS_DIR / ereprex.filename).open("r") as fp: + assert str(actual) == fp.read() + assert str(actual).endswith("\n") -@pytest.fixture -def pygments_bad_dependency(monkeypatch): - """ModuleNotFoundError inside pygments""" - module_name = "dependency_of_pygments" - import_orig = builtins.__import__ +@requires_no_pygments +def test_html_no_pygments(patch_datetime, patch_version): + """Test that html produces the same thing as htmlnocolor when pygments is not installed.""" + r_html = Reprex.from_input(INPUT, ReprexConfig(venue="html")) + actual_html = r_html.format() - def mocked_import(name, *args): - if name.startswith("pygments"): - raise ModuleNotFoundError(name=module_name) - return import_orig(name, *args) + r_htmlnocolor = Reprex.from_input(INPUT, ReprexConfig(venue="htmlnocolor")) + actual_htmlnocolor = r_htmlnocolor.format() - monkeypatch.setattr(builtins, "__import__", mocked_import) - yield module_name + assert_str_equals(str(actual_htmlnocolor), str(actual_html)) -def test_rtf_pygments_bad_dependency(patch_datetime, patch_version, pygments_bad_dependency): - """Test that a bad import inside pygments does not trigger PygmentsNotFoundError""" - with pytest.raises(ModuleNotFoundError) as exc_info: +@requires_no_pygments +def test_rtf_no_pygments(patch_datetime, patch_version): + with pytest.raises(PygmentsNotFoundError): r = Reprex.from_input(INPUT, ReprexConfig(venue="rtf")) r.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..b2faca2 100644 --- a/tests/test_ipython_editor.py +++ b/tests/test_ipython_editor.py @@ -3,17 +3,20 @@ import sys from textwrap import dedent -from IPython.testing import globalipapp import pytest from reprexlite.exceptions import IPythonNotFoundError -from reprexlite.ipython import ReprexTerminalInteractiveShell from reprexlite.reprexes import Reprex +from tests.pytest_utils import requires_ipython, requires_no_ipython from tests.utils import remove_ansi_escape @pytest.fixture() def reprexlite_ipython(monkeypatch): + from IPython.testing import globalipapp + + from reprexlite.ipython import ReprexTerminalInteractiveShell + monkeypatch.setattr(globalipapp, "TerminalInteractiveShell", ReprexTerminalInteractiveShell) monkeypatch.setattr(ReprexTerminalInteractiveShell, "_instance", None) ipython = globalipapp.start_ipython() @@ -48,6 +51,7 @@ def mocked_import(name, *args): yield module_name +@requires_ipython def test_ipython_editor(reprexlite_ipython, capsys): input = dedent( """\ @@ -68,12 +72,30 @@ def test_ipython_editor(reprexlite_ipython, capsys): assert remove_ansi_escape(captured.out) == expected -def test_no_ipython_error(no_ipython, monkeypatch): - monkeypatch.delitem(sys.modules, "reprexlite.ipython") +@requires_ipython +def test_ipython_syntax_error(reprexlite_ipython, capsys): + input = dedent( + """\ + 2+ + """ + ) + reprexlite_ipython.run_cell(input) + captured = capsys.readouterr() + + print("\n---ACTUAL-----\n") + print(captured.out) + print("\n--------------\n") + assert "Syntax Error" in remove_ansi_escape(captured.out) + assert "Incomplete input." in remove_ansi_escape(captured.out) + + +@requires_no_ipython +def test_no_ipython_error(monkeypatch): with pytest.raises(IPythonNotFoundError): importlib.import_module("reprexlite.ipython") +@requires_ipython def test_bad_ipython_dependency(ipython_bad_dependency, monkeypatch): """Test that a bad import inside IPython does not trigger IPythonNotFoundError""" monkeypatch.delitem(sys.modules, "reprexlite.ipython") diff --git a/tests/test_ipython_magics.py b/tests/test_ipython_magics.py index e1cfc19..5a6e024 100644 --- a/tests/test_ipython_magics.py +++ b/tests/test_ipython_magics.py @@ -1,18 +1,19 @@ import builtins import importlib -import sys from textwrap import dedent -from IPython.terminal.interactiveshell import TerminalInteractiveShell -from IPython.testing import globalipapp import pytest from reprexlite.config import ReprexConfig from reprexlite.reprexes import Reprex +from tests.pytest_utils import requires_ipython, requires_no_ipython @pytest.fixture() def ipython(monkeypatch): + from IPython.terminal.interactiveshell import TerminalInteractiveShell + from IPython.testing import globalipapp + monkeypatch.setattr(TerminalInteractiveShell, "_instance", None) ipython = globalipapp.start_ipython() ipython.run_line_magic("load_ext", "reprexlite") @@ -33,6 +34,7 @@ def mocked_import(name, *args): monkeypatch.setattr(builtins, "__import__", mocked_import) +@requires_ipython def test_line_magic(ipython, capsys): ipython.run_line_magic("reprex", line="") captured = capsys.readouterr() @@ -40,6 +42,7 @@ def test_line_magic(ipython, capsys): assert r"Cell Magic Usage: %%reprex" in captured.out +@requires_ipython def test_cell_magic(ipython, capsys): input = dedent( """\ @@ -51,7 +54,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.format() print("\n---EXPECTED---\n") print(expected) @@ -62,8 +65,7 @@ def test_cell_magic(ipython, capsys): assert captured.out == expected -def test_no_ipython(no_ipython, monkeypatch): +@requires_no_ipython +def test_no_ipython(monkeypatch): """Tests that not having ipython installed should not cause any import errors.""" - monkeypatch.delitem(sys.modules, "reprexlite") - monkeypatch.delitem(sys.modules, "reprexlite.ipython") importlib.import_module("reprexlite") diff --git a/tests/test_printing.py b/tests/test_printing.py new file mode 100644 index 0000000..1007613 --- /dev/null +++ b/tests/test_printing.py @@ -0,0 +1,93 @@ +from contextlib import redirect_stdout +import io +from itertools import chain +import sys + +import pytest + +from reprexlite.config import ReprexConfig +import reprexlite.printing +from reprexlite.reprexes import Reprex +from tests.expected_printed import ASSETS_DIR, INPUT, expected_reprexes, expected_reprexes_no_color +from tests.pytest_utils import requires_no_rich, requires_rich +from tests.utils import ( + MOCK_VERSION, + MockDateTime, + assert_str_equals, +) + +all_expected_reprexes = list(chain(expected_reprexes, expected_reprexes_no_color)) + + +@pytest.fixture +def patch_datetime(monkeypatch): + monkeypatch.setattr(sys.modules["reprexlite.formatting"], "datetime", MockDateTime) + + +@pytest.fixture +def patch_version(monkeypatch): + monkeypatch.setattr(sys.modules["reprexlite.formatting"], "__version__", MOCK_VERSION) + + +@pytest.fixture +def patch_rich_force_terminal(monkeypatch): + from rich.console import Console + + force_terminal_console = Console(soft_wrap=True, force_terminal=True) + monkeypatch.setattr(reprexlite.printing, "console", force_terminal_console) + + +@requires_rich +@pytest.mark.parametrize( + "ereprex", all_expected_reprexes, ids=[e.filename for e in all_expected_reprexes] +) +def test_reprex_printing(ereprex, patch_datetime, patch_version, patch_rich_force_terminal): + """Test that printing works.""" + r = Reprex.from_input(INPUT, ReprexConfig(**ereprex.kwargs)) + + with io.StringIO() as buffer: + with redirect_stdout(buffer): + r.print() + buffer.seek(0) + actual = buffer.read() + + with (ASSETS_DIR / ereprex.filename).open("r") as fp: + assert_str_equals(expected=fp.read(), actual=actual) + assert str(actual).endswith("\n") + + +@requires_no_rich +@pytest.mark.parametrize( + "ereprex", expected_reprexes_no_color, ids=[e.filename for e in expected_reprexes_no_color] +) +def test_reprex_printing_no_rich(ereprex, patch_datetime, patch_version): + """Test that printing works when rich is not available and produces the same output as + no_color=True.""" + kwargs = ereprex.kwargs.copy() + kwargs.pop("no_color") + + ## default + r = Reprex.from_input(INPUT, ReprexConfig(**kwargs)) + + with io.StringIO() as buffer: + with redirect_stdout(buffer): + r.print() + buffer.seek(0) + actual = buffer.read() + + with (ASSETS_DIR / ereprex.filename).open("r") as fp: + assert_str_equals(expected=fp.read(), actual=actual) + assert str(actual).endswith("\n") + + # no_color=True + r = Reprex.from_input(INPUT, ReprexConfig(**kwargs, no_color=True)) + + with io.StringIO() as buffer: + with redirect_stdout(buffer): + r.print() + buffer.seek(0) + actual = buffer.read() + + with (ASSETS_DIR / ereprex.filename).open("r") as fp: + assert_str_equals(expected=fp.read(), actual=actual) + assert str(actual).endswith("\n") diff --git a/tests/test_reprexes.py b/tests/test_reprexes.py index 59ea3a2..771e0bf 100644 --- a/tests/test_reprexes.py +++ b/tests/test_reprexes.py @@ -5,8 +5,9 @@ import pytest from reprexlite.config import ReprexConfig -from reprexlite.exceptions import BlackNotFoundError, UnexpectedError +from reprexlite.exceptions import BlackNotFoundError, InputSyntaxError, UnexpectedError from reprexlite.reprexes import ParsedResult, RawResult, Reprex, reprex +from tests.pytest_utils import requires_black, requires_no_black, requires_no_pygments from tests.utils import assert_equals, assert_not_equals, assert_str_equals Case = namedtuple("Case", ["id", "input", "expected"]) @@ -199,11 +200,11 @@ class MyClass: ... """, expected="""\ __name__ - #> '__reprex__' + #> '__main__' class MyClass: ... MyClass.__module__ - #> '__reprex__' + #> '__main__' """, ), ] @@ -663,6 +664,7 @@ def test_raw_result_to_parsed_result_comparisons(): ) +@requires_black def test_style_with_black(): input = dedent( """\ @@ -682,19 +684,8 @@ def test_style_with_black(): reprex.statements[0].raw_code == expected.strip() -@pytest.fixture -def no_black(monkeypatch): - import_orig = builtins.__import__ - - def mocked_import(name, *args): - if name.startswith("black"): - raise ModuleNotFoundError(name="black") - return import_orig(name, *args) - - monkeypatch.setattr(builtins, "__import__", mocked_import) - - -def test_no_black(no_black): +@requires_no_black +def test_no_black(): with pytest.raises(BlackNotFoundError): reprex = Reprex.from_input("2+2", config=ReprexConfig(style=True)) reprex.format() @@ -724,22 +715,10 @@ def test_black_bad_dependency(black_bad_dependency, monkeypatch): assert exc_info.value.name == black_bad_dependency -@pytest.fixture -def no_pygments(monkeypatch): - import_orig = builtins.__import__ - - def mocked_import(name, *args): - if name.startswith("pygments"): - raise ModuleNotFoundError(name="pygments") - return import_orig(name, *args) - - monkeypatch.setattr(builtins, "__import__", mocked_import) - - -def test_no_pygments_terminal(no_pygments): - """Test that format for terminal works even if pygments is not installed.""" +def test_repr(): + """Test rich HTML display for Jupyter Notebooks runs without error.""" r = Reprex.from_input("2+2") - assert_str_equals(r.format(terminal=False), r.format(terminal=True)) + assert repr(r).startswith("