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("\nSession Info
")
+ out.append("```text")
+ out.append(str(SessionInfo()))
out.append("```")
- if advertise:
- out.append("\n" + Advertisement().markdown())
- if session_info:
- out.append("\nSession Info
")
- out.append("```text")
- out.append(str(SessionInfo()))
- out.append("```")
- out.append("
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"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 @@ + + + +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 @@ + + + +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 @@ + + + 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 @@
+
+
+
+Created at DATETIME by reprexlite vVERSION
+-- 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
+x = 2
+x + 2
+#> 4
+Created at DATETIME by reprexlite vVERSION
+-- 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
+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 @@ - - - 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 @@ +[33m```[0m[33mpython[0m +x = [94m2[0m +x + [94m2[0m +[2m#> 4[0m +[33m```[0m + +Created at DATETIME by [[94mreprexlite[0m]([96mhttps://github.com/jayqi/reprexlite[0m) 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 @@ +[33m```[0m[33mpython[0m +x = [94m2[0m +x + [94m2[0m +[2m#> 4[0m +[33m```[0m + +Created at DATETIME by [[94mreprexlite[0m]([96mhttps://github.com/jayqi/reprexlite[0m) 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 @@ +<[94mpre[0m><[94mcode[0m [96mclass[0m=[33m"language-python"[0m>x = 2 +x + 2 +#> 4[94mcode[0m>[94mpre[0m> +<[94mp[0m><[94msup[0m>Created at DATETIME by <[94ma[0m [96mhref[0m=[33m"https://github.com/jayqi/reprexlite"[0m>reprexlite[94ma[0m> vVERSION[94msup[0m>[94mp[0m> + 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 = [94m2[0m +x + [94m2[0m +[2m#> 4[0m + 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 @@ +[33m```[0m[33mpython[0m +x = [94m2[0m +x + [94m2[0m +[2m#> 4[0m +[33m```[0m + +Created at DATETIME by [[94mreprexlite[0m]([96mhttps://github.com/jayqi/reprexlite[0m) 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 @@ - - - -Created at DATETIME by reprexlite vVERSION
--- 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
-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("