diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e7efe2..2aa721a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,3 +45,17 @@ or clear old build outputs before a release so stale versions are not included. When changing output, test both terminal and redirected streams. For frames, include Unicode display widths, ANSI spans, empty input, and whitespace. Keep new runtime dependencies deliberate. See `docs/releasing.md` for publishing. + +## Visual examples and documentation images + +Run `python examples/visual_examples.py all` to explore the public API. To +regenerate the terminal previews from actual example output in a source checkout: + +```bash +python -m pip install -e '.[screenshots]' +python scripts/render_examples.py +``` + +The screenshot dependencies are optional and do not change runtime requirements. +The renderer uses `pyte`, Pillow, and DejaVu Sans Mono. See +`docs/visual-guide.md` for font configuration and image-link update instructions. diff --git a/MANIFEST.in b/MANIFEST.in index 775e81a..d100be7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include CHANGELOG.md CONTRIBUTING.md -recursive-include docs *.md +recursive-include docs *.md *.png recursive-include examples *.py recursive-include scripts *.py recursive-include tests *.py diff --git a/README.md b/README.md index 7975a85..20b5c72 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,20 @@ python -m pip install --upgrade AdvancedPrinter python -m advancedprinter ``` +## Visual examples + +[See the illustrated guide](https://github.com/kbkozlev/AdvancedPrinter/blob/5d2e48a1e097734189db0f655e31a88de8ec68e4/docs/visual-guide.md) +for runnable examples and output pictures, similar to the original README. +From a source checkout, run all five demonstrations: + +```bash +python examples/visual_examples.py all +``` + +The pictures below are rendered from the examples' actual ANSI output on a dark +terminal theme. They force colour for capture; normal application output uses +`auto` by default. Fonts and terminal palettes may differ on your system. + ## Print with colours and styles ```python @@ -43,6 +57,8 @@ ap.help() Colours may be palette names, `#RRGGBB`, RGB tuples, or xterm indexes from 0 to 255. Unknown names and conflicting aliases raise descriptive exceptions. +![Colours, styles, and custom colours](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/colours-and-styles.png) + ## Terminal output and log files ```python @@ -70,6 +86,8 @@ colours in automatic mode while retaining attributes such as bold. Printers have independent configuration. No global setup or monkey-patching is required. `file=None` resolves the current `sys.stdout` at the time of each call. +![Colour in a terminal and plain text in a log file](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/terminal-and-log-file.png) + ## Inline formatting and pure rendering ```python @@ -90,6 +108,8 @@ stdout; the printing operation applies the destination's policy. Use `color_mode="never"` when you need a plain string directly. The pure functions require explicit policies; `auto` is only available on output operations. +![Inline formatting and restoration of outer styles](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/inline-and-nested-styles.png) + ## Frames ```python @@ -116,6 +136,8 @@ and blank lines are preserved; use `dedent=True` to remove common indentation. A character or title that cannot fit raises `ValueError` rather than breaking the frame. `frame()` prints; `format_frame()` returns a string. +![Frames with titles, alignment, and wrapping](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/frames-and-wrapping.png) + ## Input, themes, and logging ```python @@ -154,8 +176,11 @@ logger.info("Repository updated") Use an ordinary `logging.Formatter` for file handlers. Pass the console handler's actual stream to `ColorFormatter`; log records are not modified. +![Status messages, custom themes, and standard logging](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/messages-and-logging.png) + ## Documentation and development +- [Visual guide and examples](https://github.com/kbkozlev/AdvancedPrinter/blob/5d2e48a1e097734189db0f655e31a88de8ec68e4/docs/visual-guide.md) - [API reference and full palette](https://github.com/kbkozlev/AdvancedPrinter/blob/master/docs/api.md) - [Migration from 1.x](https://github.com/kbkozlev/AdvancedPrinter/blob/master/docs/migration.md) - [Release and PyPI setup](https://github.com/kbkozlev/AdvancedPrinter/blob/master/docs/releasing.md) @@ -176,4 +201,4 @@ encoding requires it; non-encodable user content still raises the normal error. ## License and support -[MIT](LICENSE) · [Buy me a coffee](https://www.buymeacoffee.com/kbkozlev) +[MIT](https://github.com/kbkozlev/AdvancedPrinter/blob/master/LICENSE) · [Buy me a coffee](https://www.buymeacoffee.com/kbkozlev) diff --git a/docs/images/colours-and-styles.png b/docs/images/colours-and-styles.png new file mode 100644 index 0000000..5e77f05 Binary files /dev/null and b/docs/images/colours-and-styles.png differ diff --git a/docs/images/frames-and-wrapping.png b/docs/images/frames-and-wrapping.png new file mode 100644 index 0000000..e8b958b Binary files /dev/null and b/docs/images/frames-and-wrapping.png differ diff --git a/docs/images/inline-and-nested-styles.png b/docs/images/inline-and-nested-styles.png new file mode 100644 index 0000000..b9c6346 Binary files /dev/null and b/docs/images/inline-and-nested-styles.png differ diff --git a/docs/images/messages-and-logging.png b/docs/images/messages-and-logging.png new file mode 100644 index 0000000..83713cb Binary files /dev/null and b/docs/images/messages-and-logging.png differ diff --git a/docs/images/terminal-and-log-file.png b/docs/images/terminal-and-log-file.png new file mode 100644 index 0000000..1d3e84e Binary files /dev/null and b/docs/images/terminal-and-log-file.png differ diff --git a/docs/visual-guide.md b/docs/visual-guide.md new file mode 100644 index 0000000..aedb6ac --- /dev/null +++ b/docs/visual-guide.md @@ -0,0 +1,236 @@ +# AdvancedPrinter 2.0: a visual guide + +The familiar coloured `print()` is still there. Version 2.0 makes it easier to +configure, adds useful output helpers, and improves how frames and inline styles +behave. This guide shows the changes through runnable examples. + +These images are **rendered from the demo's actual ANSI output** with a terminal +emulator and a consistent dark theme. They are not AI-generated illustrations +or captures of a particular operating system. Your terminal palette and fonts +can look different. The screenshots force colour so the examples remain visible +when captured; normal applications should generally use the default `auto` mode. + +From a checkout of the repository, run the whole demonstration: + +```bash +python -m pip install -e . +python examples/visual_examples.py all +``` + +Or run one section: `colours`, `inline`, `frames`, `messages`, or `output-policy`. +The exact command appears at the top of each image. The snippets below highlight +the main calls; [visual_examples.py](../examples/visual_examples.py) contains the +complete runnable versions, including labels and additional combinations. + +## 1. Readable styling and custom colours + +Colours and styles existed in 1.x. The new parts are readable parameter names, +full style names, and custom colours. Existing `c`, `b`, and `s` arguments still +work. Choose named colours, a hex string, an RGB tuple, or a palette index. + +```python +from advancedprinter import Printer + +console = Printer() +console.print("Success in bold", color="green2", style="bold") +console.print("Hex colour: #ffaf5f", color="#ffaf5f") +console.print("RGB colour: (190, 150, 255)", color=(190, 150, 255)) +console.print("Palette index: 45", color=45) +console.print("Bold + italic + underline", style="bold italic underline") +console.print("Strike-through text", style="strike-through") +console.print(" White text on a blue background ", color="white", background="blue") +``` + +![Colours, styles, and custom colours](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/colours-and-styles.png) + +`color="green2"` is a bright terminal-palette green. A hex/RGB value specifies a +target colour; automatic output can approximate it for a terminal with fewer +colours. Italics and strike-through still depend on terminal/font support. + +## 2. Inline text and reliable nested formatting + +`line()` already returned a coloured string for use inside a sentence. It is now +also named `format_text()`. The important fix is that an outer colour resumes +after an inner formatted span ends. + +```python +from advancedprinter import Printer, line, strip_ansi + +console = Printer() +console.print("Hello, " + line("Kaloian", color="cyan2", style="bold") + "!") +console.print("Before " + line("inside", color="cyan2") + " after", color="red1") + +formatted = line("Ready for export", color="green2", style="bold") +console.print("Formatted string: " + formatted) +console.print("Plain string: " + strip_ansi(formatted)) +``` + +![Inline colours and restoration of outer styles](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/inline-and-nested-styles.png) + +Notice that **after** is red again. `strip_ansi()` removes formatting when you +need ordinary text. `line()` / `format_text()` return strings; they do not print. +They produce ANSI strings by default, while the eventual print operation applies +the destination policy. Use `format_text(..., color_mode="never")` to create a +plain string directly. + +## 3. Frames with titles, alignment, wrapping, and preserved indentation + +Frames existed before; titles, rounded borders, explicit widths, wrapping, +alignment, optional dedenting, and pure frame rendering are new. Widths are +measured in terminal cells and ignore ANSI codes, so styled text does not make +the border too long. Wide characters and combining marks are handled using +`wcwidth`; terminal-specific emoji rendering may still vary. + +```python +from advancedprinter import Printer, format_frame + +console = Printer() +console.frame( + "Bitwarden up to date\nCitrix updated\nRambox updated", + title="Repository sync", + border="rounded", + border_color="cyan2", + color="white", + width=46, +) +console.frame( + "All systems ready", + title="Status", + border="double", + border_color="green2", + color="green2", + style="bold", + align="center", + width=46, +) +console.frame( + "A long message wraps automatically inside the chosen width.\n Indentation is preserved.", + title="Wrapped message", + border="single", + border_color="yellow1", + width=46, +) + +saved = format_frame("This frame is a string", color_mode="never") +console.print(saved) +``` + +![Rounded, double, wrapping, and pure string frames](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/frames-and-wrapping.png) + +`width` is the **total** width including borders and padding. Use `max_width` +for an upper limit without forcing short content to fill a larger frame. +`frame()` prints immediately; `format_frame()` returns a reusable string. +Indentation is kept unless you explicitly request `dedent=True`. + +## 4. Status messages, themes, and standard logging + +The new convenience methods apply consistent labels and styling. These are +particularly useful in repository updaters and other maintenance scripts. + +```python +from advancedprinter import MessageStyle, Printer + +console = Printer() +console.success("Repository updated") +console.info("Downloaded", 3, "packages") +console.warning("Using cached release information") +console.error("Example failure: connection timed out") + +custom = Printer( + theme={ + "success": MessageStyle("cyan2", "bold", "DONE"), + } +) +custom.success("A custom prefix and colour") +``` + +![Default messages, a custom theme, and standard logging levels](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/messages-and-logging.png) + +`error()` uses stderr by default; the other methods use the printer's output +stream. The visible labels remain useful when colours are disabled. Each Printer +has its own streams, policies, and theme, so changing one does not configure the +whole application. + +The lower half of the image uses Python's normal `logging` module: + +```python +import logging +import sys +from advancedprinter import ColorFormatter + +handler = logging.StreamHandler(sys.stdout) +handler.setFormatter(ColorFormatter("%(levelname)s: %(message)s", stream=handler.stream)) +logger = logging.getLogger("repository-sync") +logger.addHandler(handler) +logger.setLevel(logging.DEBUG) +logger.debug("Checking local metadata") +logger.info("Repository scan complete") +logger.warning("Retrying one download") +logger.error("Example failure: download unavailable") +``` + +Use a normal `logging.Formatter` on file handlers. AdvancedPrinter's formatter +does not change the original record or configure your loggers for you. + +## 5. Colour in terminals and clean text in files + +This is the main default behaviour change: `auto` checks the actual destination. +A styled message goes to a capable terminal with colour, and to a redirected +file without escape codes. You do not need separate printing code for cron. + +```python +from advancedprinter import Printer + +console = Printer() # color_mode="auto" +console.print("Repository updated", color="green2", style="bold") +with open("sync.log", "w", encoding="utf-8") as log: + console.print("Repository updated", color="green2", style="bold", file=log) +``` + +![Automatic policy on a terminal versus a file destination](https://raw.githubusercontent.com/kbkozlev/AdvancedPrinter/083938fb4d15bb18e821719db1a0dc4c675d3a82/docs/images/terminal-and-log-file.png) + +For a repeatable comparison, this demo uses an in-memory simulated terminal and +an in-memory non-terminal stream. Both messages are produced by the same real +`Printer.print()` method. The final `repr()` shows that the file contains only +the message and newline, with no `\x1b` escape sequences. + +| Policy | Behaviour | +|---|---| +| `auto` | Detect the destination; respect non-empty `NO_COLOR`; use plain output for non-terminals or `TERM=dumb` | +| `always` | Explicitly emit ANSI styling, including when capturing screenshots | +| `never` | Produce plain output and strip embedded ANSI sequences | + +`NO_COLOR` suppresses default colours, while attributes such as bold can remain. +Use `never` to turn off all styling. `color` is the text colour; `color_mode` is +the output policy. Pure formatting functions require an explicit policy and +do not accept `auto`, because they have no destination stream to inspect. + +## Other improvements you will notice + +- `input()` works with no prompt, preserves prompt spaces, and stringifies + non-string prompts. You can still style it with `color` and `style`. +- `sep`, `end`, `file`, and `flush` behave like standard print parameters. +- Empty frames work; misspelled colours/styles/borders and conflicting aliases + raise descriptive errors instead of silently hiding mistakes. +- `python -m advancedprinter` provides a built-in palette demonstration. +- The public API has type hints; CI and automated release checks cover the + supported Python versions and operating systems. + +Python 3.10+ is required. See [the API reference](api.md) and +[migration guide](migration.md) for exact compatibility details. + +## Regenerate the pictures + +These commands are for a source checkout, not just the PyPI installation: + +```bash +python -m pip install -e '.[screenshots]' +python scripts/render_examples.py +``` + +The renderer uses `pyte`, Pillow, and the DejaVu Sans Mono font family. The default +font directory is `/usr/share/fonts/truetype/dejavu`; use `--font-dir` to point to +another installation containing the four font files. The generated images are +stored in `docs/images/`. Regeneration executes the examples, so the pictures +stay grounded in the actual library output. If the images change, commit them +and update the pinned image URLs in this guide and the README to the new commit. diff --git a/examples/visual_examples.py b/examples/visual_examples.py new file mode 100644 index 0000000..ebdbc47 --- /dev/null +++ b/examples/visual_examples.py @@ -0,0 +1,188 @@ +"""Run one visual example, or run the complete gallery. + + python examples/visual_examples.py colours + python examples/visual_examples.py all --color-mode always + +Each example uses the public AdvancedPrinter API. No network access or input +is needed. Terminal policy uses simulated in-memory streams for a repeatable +TTY-versus-file comparison; it does not modify the caller's environment. +""" + +from __future__ import annotations + +import argparse +import io +import logging +import sys +from collections.abc import Callable + +from advancedprinter import ColorFormatter, MessageStyle, Printer, format_frame, line, strip_ansi + + +def heading(console: Printer, title: str) -> None: + console.print(title, color="cyan1", style="bold") + console.print() + + +def colours(console: Printer) -> None: + heading(console, "COLOURS AND STYLES") + console.print("Normal text: the familiar print() behaviour") + console.print("Success in bold", color="green2", style="bold") + console.print("Hex colour: #ffaf5f", color="#ffaf5f") + console.print("RGB colour: (190, 150, 255)", color=(190, 150, 255)) + console.print("Palette index: 45", color=45) + console.print() + console.print("Bold + italic + underline", style="bold italic underline") + console.print("Strike-through text", style="strike-through") + console.print("Inverted text", style="inverted") + console.print(" White text on a blue background ", color="white", background="blue") + console.print() + console.print("Bitwarden", "Citrix", "Rambox", sep=" | ") + + +def inline(console: Printer) -> None: + heading(console, "INLINE COLOURS AND NESTED STYLES") + console.print("Hello, " + line("Kaloian", color="cyan2", style="bold") + "!") + console.print( + line("3 updated", color="green2") + + " / " + + line("1 cached", color="yellow1") + + " / " + + line("0 failed", color="red1") + ) + console.print() + console.print("Before " + line("inside", color="cyan2") + " after", color="red1") + console.print("The outer red resumes after the cyan word.", color="gray1") + console.print() + formatted = line("Ready for export", color="green2", style="bold") + console.print("Formatted string: " + formatted) + console.print("Plain string: " + strip_ansi(formatted)) + + +def frames(console: Printer) -> None: + heading(console, "FRAMES: TITLES, ALIGNMENT AND WRAPPING") + console.frame( + "Bitwarden up to date\nCitrix updated\nRambox updated", + title="Repository sync", + border="rounded", + border_color="cyan2", + color="white", + width=46, + ) + console.print() + console.frame( + "All systems ready", + title="Status", + border="double", + border_color="green2", + color="green2", + style="bold", + align="center", + width=46, + ) + console.print() + console.frame( + "A long message wraps automatically inside the chosen width.\n" + " Indentation is preserved.", + title="Wrapped message", + border="single", + border_color="yellow1", + width=46, + ) + console.print() + saved = format_frame("This frame is a string", border="basic", color_mode="never") + console.print(saved) + + +def messages(console: Printer) -> None: + heading(console, "STATUS MESSAGES, THEMES AND LOGGING") + console.success("Repository updated") + console.info("Downloaded", 3, "packages") + console.warning("Using cached release information") + # Explicit stream keeps this demonstration ordered even when captured. + console.error("Example failure: connection timed out", file=console.file) + console.print() + custom = Printer( + color_mode=console.color_mode, + color_system=console.color_system, + file=console.file, + theme={"success": MessageStyle("cyan2", "bold", "DONE")}, + ) + custom.success("A custom prefix and colour") + console.print() + handler = logging.StreamHandler(console.file or sys.stdout) + handler.setFormatter( + ColorFormatter( + "%(levelname)s: %(message)s", + stream=handler.stream, + color_mode=console.color_mode, + color_system=console.color_system, + ) + ) + logger = logging.getLogger("advancedprinter-visual-demo") + previous_level, previous_propagation = logger.level, logger.propagate + logger.setLevel(logging.DEBUG) + logger.propagate = False + logger.addHandler(handler) + try: + logger.debug("Checking local metadata") + logger.info("Repository scan complete") + logger.warning("Retrying one download") + logger.error("Example failure: download unavailable") + finally: + logger.removeHandler(handler) + logger.setLevel(previous_level) + logger.propagate = previous_propagation + handler.close() + + +def output_policy(console: Printer) -> None: + heading(console, "AUTO MODE: COLOUR IN THE TERMINAL, CLEAN LOG FILES") + + class TerminalBuffer(io.StringIO): + def isatty(self) -> bool: + return True + + # Feed the real public API two destinations. The first simulates a TTY; + # the second behaves like an ordinary redirected file. For deterministic + # screenshots, auto-mode terminal support is already established by the + # screenshot runner's TERM/COLORTERM settings. + terminal_output, file_output = TerminalBuffer(), io.StringIO() + auto = Printer(color_mode="auto", color_system="truecolor") + auto.print("Repository updated", color="green2", style="bold", file=terminal_output) + auto.print("Repository updated", color="green2", style="bold", file=file_output) + console.print("Terminal destination (isatty=True):", color="gray1") + console.print(terminal_output.getvalue(), end="") + console.print() + console.print("File destination (isatty=False):", color="gray1") + console.print(file_output.getvalue(), end="") + console.print() + console.print("File contents, shown with repr():", color="gray1") + console.print(repr(file_output.getvalue())) + console.print("No ANSI escape codes in the file.", color="cyan2") + + +SECTIONS: dict[str, Callable[[Printer], None]] = { + "colours": colours, + "inline": inline, + "frames": frames, + "messages": messages, + "output-policy": output_policy, +} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("section", nargs="?", default="all", choices=["all", *SECTIONS]) + parser.add_argument("--color-mode", choices=["auto", "always", "never"], default="auto") + args = parser.parse_args() + console = Printer(color_mode=args.color_mode, color_system="truecolor", file=sys.stdout) + selected = SECTIONS.values() if args.section == "all" else [SECTIONS[args.section]] + for index, example in enumerate(selected): + if index: + console.print("\n") + example(console) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index f706aa1..2f246eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ Changelog = "https://github.com/kbkozlev/AdvancedPrinter/blob/master/CHANGELOG.m [project.optional-dependencies] dev = ["pytest>=9.1.1", "pytest-cov>=7.1.0", "ruff>=0.17.1", "mypy>=2.4.0", "build>=1.6.1", "twine>=7.0.0"] +screenshots = ["Pillow>=10", "pyte>=0.8.2"] + [tool.setuptools.packages.find] where = ["src"] diff --git a/scripts/render_examples.py b/scripts/render_examples.py new file mode 100644 index 0000000..8305126 --- /dev/null +++ b/scripts/render_examples.py @@ -0,0 +1,176 @@ +"""Render the visual examples' actual ANSI output into documentation PNGs. + +Install the optional dependencies with: python -m pip install '.[screenshots]' +Run: python scripts/render_examples.py + +This is an offline terminal rendering, not an operating-system window capture. +pyte interprets the real ANSI stream; Pillow draws the resulting terminal cells. +The default font is DejaVu Sans Mono (as packaged on Debian/Ubuntu). Pass +--font-dir to use another directory containing the same four font filenames. +""" + +from __future__ import annotations + +import argparse +import math +import os +import subprocess +import sys +from pathlib import Path + +import pyte +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +SECTIONS = { + "colours": ("colours-and-styles.png", "Colours and styles"), + "inline": ("inline-and-nested-styles.png", "Inline and nested styles"), + "frames": ("frames-and-wrapping.png", "Frames and wrapping"), + "messages": ("messages-and-logging.png", "Messages, themes and logging"), + "output-policy": ("terminal-and-log-file.png", "Terminal output and log files"), +} +BACKGROUND = "#111927" +FOREGROUND = "#dde7f3" +PALETTE = { + "black": "#111927", + "red": "#ef6a72", + "green": "#82c99b", + "brown": "#e5c07b", + "blue": "#6aa7f4", + "magenta": "#bf90e7", + "cyan": "#6fd1dd", + "white": "#d5dce6", + "brightblack": "#73839a", + "brightred": "#ff8189", + "brightgreen": "#8de4a3", + "brightyellow": "#ffe08c", + "brightbrown": "#ffe08c", + "brightblue": "#93bbff", + "brightmagenta": "#ddb2ff", + "brightcyan": "#89e4f3", + "brightwhite": "#ffffff", +} + + +def terminal_color(value: str, default: str) -> str: + if value == "default": + return default + if value in PALETTE: + return PALETTE[value] + if len(value) == 6 and all(char in "0123456789abcdef" for char in value.lower()): + return "#" + value + raise ValueError(f"Unexpected terminal colour {value!r}") + + +def capture(section: str) -> str: + environment = dict(os.environ) + environment.pop("NO_COLOR", None) + environment.update( + TERM="xterm-256color", + COLORTERM="truecolor", + WT_SESSION="advancedprinter-documentation", + PYTHONIOENCODING="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + "-u", + str(ROOT / "examples" / "visual_examples.py"), + section, + "--color-mode", + "always", + ], + cwd=ROOT, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + check=True, + ) + return result.stdout + + +def render(section: str, title: str, text: str, destination: Path, font_dir: Path) -> None: + fonts = { + (False, False): ImageFont.truetype(str(font_dir / "DejaVuSansMono.ttf"), 22), + (True, False): ImageFont.truetype(str(font_dir / "DejaVuSansMono-Bold.ttf"), 22), + (False, True): ImageFont.truetype(str(font_dir / "DejaVuSansMono-Oblique.ttf"), 22), + (True, True): ImageFont.truetype(str(font_dir / "DejaVuSansMono-BoldOblique.ttf"), 22), + } + regular = fonts[False, False] + small = ImageFont.truetype(str(font_dir / "DejaVuSansMono.ttf"), 14) + screen = pyte.Screen(80, 100) + stream = pyte.Stream(screen) + stream.feed(text.replace("\r\n", "\n").replace("\n", "\r\n")) + used_rows = ( + max( + ( + row + for row, cells in screen.buffer.items() + if any(cell.data.strip() or cell.bg != "default" for cell in cells.values()) + ), + default=0, + ) + + 1 + ) + if screen.cursor.y >= 99: + raise ValueError("Example exceeded the screenshot terminal height") + cell_width, row_height = regular.getlength("M"), 27 + padding, header, command_area, footer = 28, 54, 58, 48 + width = math.ceil(80 * cell_width) + padding * 2 + height = header + command_area + used_rows * row_height + padding + footer + image = Image.new("RGB", (width, height), BACKGROUND) + draw = ImageDraw.Draw(image) + draw.rectangle((0, 0, width, header), fill="#1d293b") + draw.text((padding, 18), "AdvancedPrinter 2.0 / " + title, font=small, fill=FOREGROUND) + command = f"$ python examples/visual_examples.py {section} --color-mode always" + draw.text((padding, header + 16), command, font=small, fill="#91a2ba") + top = header + command_area + for row in range(used_rows): + for column in range(80): + cell = screen.buffer[row][column] + if not cell.data: + continue + foreground = terminal_color(cell.fg, FOREGROUND) + background = terminal_color(cell.bg, BACKGROUND) + if cell.reverse: + foreground, background = background, foreground + x, y = padding + column * cell_width, top + row * row_height + if background != BACKGROUND: + draw.rectangle((x, y, x + cell_width, y + row_height), fill=background) + draw.text( + (x, y + 21), + cell.data, + font=fonts[cell.bold, cell.italics], + fill=foreground, + anchor="ls", + ) + if cell.underscore: + draw.line((x, y + 23, x + cell_width, y + 23), fill=foreground, width=1) + if cell.strikethrough: + draw.line((x, y + 13, x + cell_width, y + 13), fill=foreground, width=1) + draw.text( + (padding, height - 29), + "Rendered from actual example output | dark terminal theme", + font=small, + fill="#73839a", + ) + image.save(destination, optimize=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--font-dir", type=Path, default=Path("/usr/share/fonts/truetype/dejavu")) + parser.add_argument("--output-dir", type=Path, default=ROOT / "docs" / "images") + arguments = parser.parse_args() + arguments.output_dir.mkdir(parents=True, exist_ok=True) + for section, (filename, title) in SECTIONS.items(): + output = capture(section) + destination = arguments.output_dir / filename + render(section, title, output, destination, arguments.font_dir) + print(f"{section}: {destination.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_project.py b/tests/test_project.py index ce1607a..b78871e 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -54,3 +54,15 @@ def test_release_tag_rejects_mismatches(tag): check = runpy.run_path(str(ROOT / "scripts" / "check_release.py"))["check_tag"] with pytest.raises(ValueError, match="does not match"): check(tag, "2.0.0") + + +def test_visual_guide_python_examples_execute(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + namespace = {} + for snippet in re.findall( + r"```python\n(.*?)```", (ROOT / "docs" / "visual-guide.md").read_text(), re.S + ): + exec(compile(snippet, "docs/visual-guide.md", "exec"), namespace) + assert (tmp_path / "sync.log").read_text() == "Repository updated\n" + assert namespace["saved"].startswith("+") + capsys.readouterr()