-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 105ce2d..0000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index a30f980..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 0eaca28..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b74f387
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,38 @@
+# Changelog
+
+## 2.0.0
+
+### Added
+
+- Independently configurable `Printer` instances, stream handling, and message themes.
+- Readable colour/style aliases, custom hex/RGB/index colours, and depth fallback.
+- Destination-aware `auto` / `always` / `never` policies, `NO_COLOR`, and Windows VT detection.
+- Pure `format_text()` / `format_frame()`, ANSI stripping, and visible-width helpers.
+- Success/warning/error/info helpers with visible labels; errors default to stderr.
+- Unicode-aware frame wrapping, titles, padding, alignment, widths, rounded borders,
+ optional dedenting, and ASCII fallback.
+- Standard `logging` integration without mutating records; built-in module demo.
+- Typed public API, regression tests, Linux/Windows/macOS CI, distribution checks,
+ and GitHub Release-driven PyPI Trusted Publishing.
+
+### Fixed
+
+- Respect `sep`, and avoid unnecessary resets for unstyled text.
+- Support zero-argument input; preserve prompt whitespace and stringify prompts.
+- Support empty frames, preserve indentation and trailing whitespace, and measure
+ terminal cells rather than ANSI bytes or code-point counts.
+- Restore outer formatting after nested spans and selective resets.
+- Validate unknown colours, styles, borders, and conflicting aliases.
+
+### Changed
+
+- Require Python 3.10+ and `wcwidth>=0.9,<1`.
+- Printing uses automatic colour policy by default; redirected output is plain.
+- Unknown options raise errors instead of silently falling back.
+- Consolidate packaging in `pyproject.toml` and remove tracked IDE configuration.
+
+See `docs/migration.md` for compatibility details.
+
+## 1.2.0 โ 2024-03-06
+
+- Add styled input prompts.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..7e7efe2
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,47 @@
+# Contributing
+
+Use Python 3.10 or newer. Create a virtual environment, activate it for your
+platform, then install the development tools:
+
+```bash
+python -m venv .venv
+# Linux/macOS: source .venv/bin/activate
+# Windows PowerShell: .venv\Scripts\Activate.ps1
+python -m pip install -e '.[dev]'
+```
+
+Run the same checks used by CI:
+
+```bash
+python -m pytest --cov=advancedprinter --cov-fail-under=90
+python -m ruff check .
+python -m ruff format --check .
+python -m mypy src/advancedprinter
+python -m build
+python -m twine check --strict dist/*
+python -m advancedprinter
+```
+
+Use `python -m ruff format .` to format changes. Tests run against the installed
+package; there is no implicit `src` path injection. Build in a clean checkout
+or clear old build outputs before a release so stale versions are not included.
+
+## Project layout
+
+| Path | Purpose |
+|---|---|
+| `src/advancedprinter/styles.py` | Validated colour/style specifications and aliases |
+| `src/advancedprinter/terminal.py` | ANSI handling, destination policy, Windows VT support |
+| `src/advancedprinter/render.py` | Pure text/frame rendering and terminal cell widths |
+| `src/advancedprinter/printer.py` | Configurable streams, input, messages, palette help |
+| `src/advancedprinter/logging.py` | Standard logging formatter adapter |
+| `src/advancedprinter/main.py` | Original class API compatibility |
+| `src/advancedprinter/_codes.py` | Original ANSI constants |
+| `tests/` | Behavioural, regression, packaging, and documentation tests |
+| `examples/` | Runnable demonstrations |
+| `scripts/check_release.py` | Release version/tag guard |
+| `.github/workflows/` | CI and Trusted Publishing |
+
+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.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..775e81a
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,5 @@
+include CHANGELOG.md CONTRIBUTING.md
+recursive-include docs *.md
+recursive-include examples *.py
+recursive-include scripts *.py
+recursive-include tests *.py
diff --git a/README.md b/README.md
index 5a31c99..7975a85 100644
--- a/README.md
+++ b/README.md
@@ -1,117 +1,179 @@
# AdvancedPrinter
-AdvancedPrinter is a custom Python library designed to mimic the behavior of the `print` function with added color and style options using ANSI escape codes. This allows you to print text with various colors and styles to the terminal.
-
-## Features
-
-- Print text in different colors: `black`, `white`,` red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `orange`, `gray`, `olive` - a list of all colors available by calling the `help`method.
-- Apply styles to text: `bold` = `b`, `italic` = `it`, `underline` = `u`, `inverted` = `inv`, `strike-through` = `str`.
-- Combine styles: `b-it`, `b-u`, `it-u`, `b-it-u` etc.
-- Use `frame` method to frame text with different designs.
-- Use `input` method with colors and styles
-
-## Usage
-
-1. Install the package from PyPi
- ```bash
- pip install AdvancedPrinter
- ```
-2. Import the `AdvancedPrinter` class into your Python script.
-
- ```python
- from advancedprinter import AdvancedPrinter as ap
- ```
-
-3. Use the `ap.print()` method to print colored text.
-
- ```python
- # Example usage
- ap.print("Green text", c="green")
- ap.print("Underlined text", s="u")
- ap.print("Bold italic text", s="b-it")
- ap.print("Orange italic underlined", s="it-u", c='orange')
- ap.print("White text on blue b!", c="white", b="blue")
- ap.print("Bold red underlined italic text!", c="red", s="b-it-u")
-
- # Additional arguments example
- ap.print("This is a bold example with additional arguments.", s="b", end="***")
- ```
- 
-
-4. Use `ap.line()` method to print multiple colors in line using nested `f-strings`
-
- ```python
- name = "Kaloian"
- print(f"""{ap.line("Hello, my name is", c='green', s='b')} {ap.line(f"{name}", c='blue')} {ap.line("and I'm", c='white')} {ap.line("28", c='red')} {ap.line('years old!', c='magenta')}""", end='***')
- ```
- 
-
-## Import directly
-
-1. Import `print` and `line` directly to override the inbuilt methods
-
- ```python
- from advancedprinter import print, line
-
- print("Green text", c="green")
-
- name = "Kaloian"
- print(f"""{line("Hello, my name is", c='green', s='b')} {line(f"{name}", c='blue')} {line("and I'm", c='white')} {line("28", c='red')} {line('years old!', c='magenta')}""", end='***')
- ```
- 
-
-2. Import the `frame` method to frame text with different designs
- ```python
- from advancedprinter import frame
- ```
-- basic
- ```python
- frame("This is a frame with basic design")
- ```
- 
-- basic2
- ```python
- frame("This is a frame with basic2 design", f_s='basic2')
- ```
- 
-- single
- ```python
- frame("This is a frame with single design", f_s='single')
- ```
- 
-- double
- ```python
- frame("This is a frame with double design", f_s='double')
- ```
- 
-- color (if no parameters are provided for the frame color, it will take the color and background of the text)
- ```python
- frame("This is a frame with color", f_s='double', c='white', b='blue')
- ```
- 
-- multiline / multicolor
- ```python
- frame("""This is a frame with different
- text and frame color and
- multiple lines""",
- f_s='double', f_c='black', f_b='olive', c='white', b='cyan', s='b-u-it')
- ```
- 
-
-3. Use the `input` method:
- ```python
- from advancedprinter import input
-
- something = input('Please enter something: ', c='red', s='b-u')
- print(something)
- ```
- 
-
-## License
-
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
-
-## Support Me
-
-
-
\ No newline at end of file
+[](https://github.com/kbkozlev/AdvancedPrinter/actions/workflows/ci.yml)
+[](https://pypi.org/project/AdvancedPrinter/)
+
+Small terminal printing helpers for Python: colours, styles, inline text, readable
+status messages, and Unicode-aware frames. Built on Python's standard streams
+and one runtime dependency, `wcwidth`.
+
+**Requires Python 3.10+.** Upgrading from 1.x? Read the
+[migration guide](https://github.com/kbkozlev/AdvancedPrinter/blob/master/docs/migration.md).
+
+## Install
+
+```bash
+python -m pip install --upgrade AdvancedPrinter
+python -m advancedprinter
+```
+
+## Print with colours and styles
+
+```python
+from advancedprinter import print
+
+print("Repository updated", color="green", style="bold")
+print("Downloaded", 3, "packages", sep=" | ")
+print("Custom colour", color="#ff8800", background="black")
+print("Combined styles", style="bold italic underline")
+```
+
+The original short arguments and class imports still work:
+
+```python
+from advancedprinter import AdvancedPrinter as ap
+
+ap.print("Green text", c="green")
+ap.print("Bold italic text", s="b-it")
+ap.print("White on blue", c="white", b="blue")
+ap.help()
+```
+
+`print()` supports the standard `sep`, `end`, `file`, and `flush` parameters.
+Colours may be palette names, `#RRGGBB`, RGB tuples, or xterm indexes from 0 to 255.
+Unknown names and conflicting aliases raise descriptive exceptions.
+
+## Terminal output and log files
+
+```python
+from advancedprinter import Printer
+
+console = Printer(color_mode="auto")
+console.success("Repository updated")
+console.warning("Using cached release information")
+console.info("Downloaded", 3, "packages")
+console.error("Download failed") # stderr by default
+
+with open("sync.log", "w", encoding="utf-8") as log:
+ console.print("This file gets plain text", color="green", file=log)
+```
+
+The default `auto` policy inspects the actual destination stream. Redirected
+output, pipes, and `TERM=dumb` get plain text. Non-empty `NO_COLOR` disables
+colours in automatic mode while retaining attributes such as bold.
+
+- `color_mode="always"` explicitly enables styling, even in files or with `NO_COLOR`.
+- `color_mode="never"` removes all ANSI escape sequences, including inline ones.
+- `color_system="auto"` detects standard, 256-colour, or truecolour support from
+ `TERM` and `COLORTERM`. Set the depth explicitly when detection is insufficient.
+
+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.
+
+## Inline formatting and pure rendering
+
+```python
+from advancedprinter import format_text, line, print
+
+name = line("Kaloian", color="blue", style="bold")
+print(f"Hello, {name}!")
+
+# Outer formatting resumes after the inner span ends.
+print("Before " + line("inside", color="blue") + " after", color="red")
+
+plain = format_text("No escape codes", color="green", color_mode="never")
+```
+
+`line()` is an alias of `format_text()`. These pure functions return strings and
+**default to ANSI output**, independent of the environment. They do not inspect
+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.
+
+## Frames
+
+```python
+from advancedprinter import Printer, format_frame
+
+console = Printer()
+console.frame(
+ "Downloaded 3 packages\nไธญๆ and emoji ๐ are measured in terminal cells",
+ title="Repository sync",
+ border="rounded",
+ border_color="cyan",
+ max_width=48,
+ padding=1,
+)
+
+text = format_frame("Ready", border="double", color_mode="never")
+console.print(text)
+```
+
+Borders: `basic`, `basic2`, `single`, `double`, and `rounded`. Frames support
+colours, titles, horizontal padding, alignment, wrapping, and `ascii=True`.
+`width` and `max_width` include borders and padding. Indentation, trailing spaces,
+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.
+
+## Input, themes, and logging
+
+```python
+from advancedprinter import MessageStyle, Printer
+
+console = Printer(
+ theme={
+ "success": MessageStyle(color="cyan", style="bold", prefix="DONE"),
+ }
+)
+console.success("All tasks complete")
+answer = console.input(" Continue? ", color="yellow")
+```
+
+Input prompts are preserved exactly. `input()` without a prompt is supported;
+EOF and keyboard interrupts behave normally.
+
+```python
+import logging
+import sys
+from advancedprinter import ColorFormatter
+
+handler = logging.StreamHandler(sys.stderr)
+handler.setFormatter(
+ ColorFormatter(
+ "%(levelname)s: %(message)s",
+ stream=handler.stream,
+ )
+)
+logger = logging.getLogger("sync")
+logger.addHandler(handler)
+logger.setLevel(logging.INFO)
+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.
+
+## Documentation and development
+
+- [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)
+- [Changelog](https://github.com/kbkozlev/AdvancedPrinter/blob/master/CHANGELOG.md)
+- [Contributing](https://github.com/kbkozlev/AdvancedPrinter/blob/master/CONTRIBUTING.md)
+
+CI tests Python 3.10โ3.14 on Linux, Windows, and macOS, checks formatting and
+static types, and validates the built wheel and source distribution. Publishing
+a GitHub Release runs the same checks, verifies the tag matches the package
+version, and uploads the validated distributions through PyPI Trusted Publishing.
+The one-time publisher setup is described in the release guide.
+
+Terminal rendering still depends on fonts and terminal support. Italic and
+strike-through are not available everywhere; emoji widths can vary between
+terminals. Windows automatic mode uses VT support where available and falls back
+to plain output otherwise. Border characters fall back to ASCII when the stream
+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)
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..d4050d7
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,170 @@
+# API reference
+
+## Imports
+
+```python
+from advancedprinter import (
+ AdvancedPrinter,
+ Printer,
+ MessageStyle,
+ ColorFormatter,
+ print,
+ input,
+ line,
+ frame,
+ help,
+ success,
+ warning,
+ error,
+ info,
+ format_text,
+ format_frame,
+ strip_ansi,
+ visible_width,
+)
+```
+
+`AdvancedPrinter` preserves class-style calls and the original
+`advancedprinter.main.AdvancedPrinter` import path. `AdvancedPrinter.C` retains
+the original ANSI constants. New configuration belongs to a `Printer` instance.
+All public functions are typed; the wheel includes `py.typed`.
+
+## Printer configuration
+
+`Printer(*, color_mode="auto", color_system="auto", file=None, error_file=None, theme=None)`
+
+| Parameter | Values / behaviour |
+|---|---|
+| `color_mode` | `auto`, `always`, `never` |
+| `color_system` | `auto`, `standard` (16 colours), `256`, `truecolor` |
+| `file` | Default output stream; `None` means current `sys.stdout` |
+| `error_file` | Default destination of `error()`; `None` means current `sys.stderr` |
+| `theme` | Partial mapping of `success`, `warning`, `error`, `info` to `MessageStyle` |
+
+A call's explicit `file=` overrides the instance stream. Output methods also
+accept per-call `color_mode` and `color_system` overrides. Explicit `always`
+overrides `NO_COLOR` and stream detection; automatic mode does not.
+In automatic mode, `COLORTERM=truecolor` or `24bit` selects truecolour,
+`TERM` containing `256color` selects 256 colours, otherwise standard colours are
+used. RGB and indexed colours are approximated for the selected depth. Standard
+ANSI colours use the terminal's palette, which may differ from the conventional
+RGB palette used for approximation. Environment detection is a heuristic; force
+a depth if your terminal advertises incomplete information. Pure inline spans
+are already-rendered strings and retain their original colour depth.
+
+## Printing and text formatting
+
+`Printer.print(*values, ...)` and module-level `print(*values, ...)` return `None`.
+
+| Parameter | Default | Meaning |
+|---|---|---|
+| `c` / `color` | `None` | Foreground name, `#RRGGBB`, RGB tuple, or xterm index |
+| `b` / `background` | `None` | Background specification |
+| `s` / `style` | `None` | Styles separated by spaces, commas, or legacy hyphens; list/tuple also accepted |
+| `sep` | `" "` | Separator between stringified values; `None` uses the default |
+| `end` | `"\n"` | Text appended after the values; `None` uses the default |
+| `file` | instance stream | Destination used for both detection and output |
+| `flush` | `False` | Flush output immediately |
+| `color_mode` / `color_system` | instance settings | Per-call policy overrides |
+
+Supplying both an alias and its long name with different values raises
+`TypeError`. Unknown colours/styles raise `ValueError`, even when output is
+plain. Colours `0` and `(0, 0, 0)` are valid. RGB channels and indexes must be
+integers, excluding booleans. Names are case-insensitive. `default` selects the
+terminal's default foreground/background.
+
+`format_text(*values, ...)` returns a string; `line` is its alias. They support
+the styling arguments above, `sep`, `end=""`, `color_mode="always"`, and
+`color_system="truecolor"`; they have no stream/flush arguments and reject `auto`.
+`Printer.line`, `Printer.format_text`, and `Printer.format_frame` are the same
+pure functions, independent of instance output policy.
+
+Nested formatted spans restore enclosing formatting on full or selective resets.
+This is intended for SGR styles generated by this library, not as a complete
+terminal emulator or sanitiser for untrusted terminal content.
+
+## Palette
+
+| Foregrounds | Backgrounds |
+|---|---|
+| `black`, `white` | `black`, `white` |
+| `red`, `red1`, `red2` | `red`, `red1` |
+| `green`, `green1`, `green2`, `green3` | `green`, `green1` |
+| `yellow`, `yellow1`, `yellow2`, `olive` | `yellow`, `olive` |
+| `blue`, `blue1`, `blue2`, `blue3` | `blue`, `blue1` |
+| `magenta`, `magenta1`, `pink`, `purple` | `magenta`, `pink` |
+| `cyan`, `cyan1`, `cyan2` | `cyan`, `cyan1` |
+| `orange` | `orange`, `orange1`, `brown` |
+| `gray`, `gray1` | `gray`, `gray1` |
+
+The numbered names preserve the original palette; run `python -m advancedprinter`
+or `help()` to preview it. Many named colours use the terminal palette; the
+RGB-based names and custom hex/RGB values specify a target colour.
+
+| Style | Short name |
+|---|---|
+| `bold` | `b` |
+| `italic` | `it` |
+| `underline` | `u` |
+| `inverted` | `inv` |
+| `strike-through` / `strikethrough` / `strike` | `str` |
+
+## Frames
+
+`format_frame(text, f_s=None, f_c=None, f_b=None, c=None, b=None, s=None, *, ...)`
+returns a string with no trailing newline. The first seven positional arguments
+remain compatible with 1.x. `frame()` prints the rendered frame and returns `None`.
+
+| Parameter | Default | Meaning |
+|---|---|---|
+| `f_s` / `border` | `basic` | `basic`, `basic2`, `single`, `double`, `rounded` |
+| `f_c` / `border_color` | text foreground | Border foreground; use `default` to opt out of inheritance |
+| `f_b` / `border_background` | text background | Border background |
+| `c` / `color`, `b` / `background`, `s` / `style` | `None` | Content styling |
+| `title` | `""` | Single-line title in the top border |
+| `padding` | `1` | Horizontal padding on each side, in cells |
+| `width` | natural width | Exact total width including borders and padding |
+| `max_width` | no limit | Upper bound on total width |
+| `wrap` | `True` | Wrap by display cells, preserving whitespace and ANSI spans |
+| `align` | `left` | `left`, `center`, `right` |
+| `dedent` | `False` | Remove common leading indentation |
+| `ascii` | `False` | Use the basic ASCII border |
+
+Pure frames use `color_mode="always"`, `color_system="truecolor"` and reject
+`auto`. Printed frames use the printer policy and accept `file`, `end`, and
+`flush`. Widths must leave at least one cell for content. A title that does not
+fit, a grapheme wider than the available space, or overflow with `wrap=False`
+raises `ValueError`. No truncation is performed. Tabs expand to four spaces;
+unsupported control characters are rejected. Empty content produces an empty
+frame. Blank lines and trailing newlines are preserved. Default padding is
+horizontal only. Terminal-specific emoji rendering may still vary.
+
+## Input and semantic messages
+
+`input(prompt="", /, *, c=None, b=None, s=None, color=None, background=None, style=None)`
+writes the stringified prompt without stripping whitespace, flushes the printer's
+output, then reads from Python's standard input. It returns the entered string.
+It accepts at most one positional prompt. EOF and keyboard interrupts propagate.
+
+`success`, `warning`, `info`, and `error` accept `*values`, `sep`, `end`, `file`,
+and `flush`. The default visible prefixes are `[SUCCESS]`, `[WARNING]`, `[INFO]`,
+and `[ERROR]`; labels remain meaningful without colours. `error()` uses stderr
+unless its own stream is supplied or `error_file` is configured.
+
+`MessageStyle(color=None, style=None, prefix="")` is an immutable theme entry.
+Theme dictionaries are copied per instance. A partial theme overrides only the
+specified message kinds. An empty prefix omits the label.
+
+## Logging and utilities
+
+`ColorFormatter(fmt=None, datefmt=None, *, stream=None, color_mode="auto", color_system="auto")`
+colours the final formatted log entry, including tracebacks. `stream=None`
+means the current stderr; normally pass the actual `StreamHandler.stream`.
+DEBUG is grey, INFO cyan, WARNING yellow, ERROR/CRITICAL bold red. A copied
+record is formatted so other handlers keep their own original record and
+exception formatting. It does not create handlers or change logger configuration.
+
+`strip_ansi(text)` removes ANSI sequences including CSI and OSC hyperlinks.
+`visible_width(text)` returns terminal display cells ignoring ANSI; control
+characters with indeterminate width raise `ValueError`.
+`help(file=None)` prints the palette and style names with the output policy.
diff --git a/docs/migration.md b/docs/migration.md
new file mode 100644
index 0000000..3bce416
--- /dev/null
+++ b/docs/migration.md
@@ -0,0 +1,58 @@
+# Migrating from AdvancedPrinter 1.x to 2.0
+
+Version 2.0 keeps the package name, class import, direct function imports, short
+style parameters, original palette constants, and four original border designs.
+There are deliberate behaviour changes:
+
+1. **Python 3.10 or later is required.** Version 1.2 remains available for older
+ interpreters. `wcwidth>=0.9,<1` is the sole runtime dependency.
+2. **Printing detects the destination.** Files, pipes, captured output, unsupported
+ Windows consoles, and `TERM=dumb` receive plain output by default. Use
+ `print(..., color_mode="always")` or `Printer(color_mode="always")` when you
+ explicitly need ANSI output. Non-empty `NO_COLOR` suppresses default colours.
+3. **Options are validated.** Misspelled colours, styles, or borders now raise
+ descriptive errors. They no longer silently disappear or select a basic border.
+ Correct the typo; `None` means no requested formatting.
+4. **Prompts and frames preserve whitespace.** The old `strip()` behaviour removed
+ indentation and trailing spaces. Use `dedent=True` on a frame if indentation
+ in a triple-quoted source string is incidental. Prompts are used exactly.
+5. **`input()` accepts zero or one prompt.** Extra prompt arguments raise
+ `TypeError`; the old version ignored all but the first. Non-string prompts
+ are stringified consistently with Python input conventions.
+6. **Plain text has no gratuitous reset.** `line("hello")` now returns `"hello"`.
+ Code comparing raw bytes should account for this correction.
+7. **Pure formatting is explicit.** `line()` / `format_text()` and
+ `format_frame()` return ANSI strings by default, including when accessed
+ through a Printer instance. Use `color_mode="never"` for plain strings.
+ `auto` is only valid for output operations that have a destination stream.
+
+`sep` now works correctly, empty frames are supported, frame widths ignore ANSI
+codes and use terminal cells, and nested styling restores its enclosing style.
+These corrections can change exact captured output. Review snapshots if your
+application depended on the old formatting bugs.
+
+Old usage continues to work:
+
+```python
+from advancedprinter import AdvancedPrinter as ap
+
+ap.print("Hello", c="green", s="b-it")
+ap.frame("Hello", "double", "red", None, "white", "blue", "u")
+```
+
+For new code prefer readable parameters and independently configurable printers:
+
+```python
+from advancedprinter import Printer
+
+console = Printer(color_mode="auto")
+console.print("Hello", color="green", style="bold italic")
+console.frame(
+ "Hello",
+ border="double",
+ border_color="red",
+ color="white",
+ background="blue",
+ style="underline",
+)
+```
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 0000000..2d5968f
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,76 @@
+# Releases and automatic PyPI publishing
+
+The package version has one source: `[project].version` in `pyproject.toml`.
+`advancedprinter.__version__` reads installed distribution metadata.
+
+## One-time setup for the existing PyPI project
+
+Sign in as an owner of the AdvancedPrinter PyPI project. Under **Your projects โ
+AdvancedPrinter โ Manage โ Publishing**, add a GitHub Trusted Publisher:
+
+| Field | Value |
+|---|---|
+| Owner | `kbkozlev` |
+| Repository | `AdvancedPrinter` |
+| Workflow filename | `publish.yml` |
+| Environment | `pypi` |
+
+The workflow filename is the basename, not `.github/workflows/publish.yml`.
+Create the `pypi` environment in GitHub repository **Settings โ Environments**.
+Keep the name identical on both sides. An environment may require a reviewer
+if you want an additional release approval step; with no required reviewer,
+publishing proceeds automatically after validation.
+
+Trusted Publishing uses short-lived OIDC credentials. No PyPI password or API
+token belongs in the repository or its secrets. The publishing job alone has
+`id-token: write`; builds and tests use read-only repository permissions.
+
+Official references:
+
+- https://docs.pypi.org/trusted-publishers/adding-a-publisher/
+- https://docs.pypi.org/trusted-publishers/using-a-publisher/
+
+## Publish a release
+
+1. Update `pyproject.toml` and add release notes to `CHANGELOG.md`.
+2. Run the local checks in `CONTRIBUTING.md`, then merge the change after CI passes.
+3. Create a GitHub Release from that tested commit with a matching tag, e.g.
+ `v2.0.0` (the unprefixed `2.0.0` form is also accepted).
+4. Publish the release. A draft does not upload anything.
+5. Check the **Publish to PyPI** run, then verify the version on PyPI and install
+ it in a fresh environment.
+
+The release workflow calls the same CI workflow: 15 OS/Python combinations,
+format/lint/type checks, a wheel and source build, strict metadata validation,
+wheel installation, and a wheel rebuilt from the source distribution. In parallel,
+`check_release.py` requires the release tag to match installed package metadata.
+The final upload job downloads the already-built `distributions` artifact; it
+has no source checkout and does not rebuild the package. Any failed prerequisite
+blocks publishing. Third-party actions are pinned to commit SHAs and Dependabot
+proposes updates.
+
+GitHub prereleases follow the same path to PyPI: use a PEP 440 prerelease version
+such as `2.1.0rc1` with tag `v2.1.0rc1`. A GitHub prerelease flag alone does not
+change the Python package version. Ordinary pushes and pull requests only test
+and build; they never publish to PyPI.
+
+## Troubleshooting
+
+- **Invalid publisher:** match owner, repository, `publish.yml`, and environment
+ exactly. Confirm the publisher is registered on PyPI, not only TestPyPI.
+- **Tag mismatch:** fix the package version/tag before publishing. The check is
+ intentionally strict, including the prerelease suffix.
+- **Version already exists:** PyPI does not allow replacing an uploaded file.
+ Publish a new version; do not delete and try to reuse the old version.
+- **Partial upload:** inspect the PyPI files and workflow log before retrying.
+ The workflow does not silently skip existing files.
+- **A failed release:** fix the failure and rerun appropriate jobs where safe.
+ Upload is only allowed after all validation jobs succeed.
+
+## TestPyPI (optional)
+
+The production release workflow does not require TestPyPI. To test publishing
+there, register a separate Trusted Publisher on TestPyPI with its own GitHub
+environment and use the official publishing action with
+`repository-url: https://test.pypi.org/legacy/` in a separate workflow. TestPyPI
+accounts and publisher configuration are independent of PyPI.
diff --git a/examples/repository_sync.py b/examples/repository_sync.py
new file mode 100644
index 0000000..6143b7c
--- /dev/null
+++ b/examples/repository_sync.py
@@ -0,0 +1,23 @@
+"""An example status summary for a repository updater (does not download anything)."""
+
+from advancedprinter import MessageStyle, Printer
+
+
+def main() -> None:
+ console = Printer(
+ theme={
+ "success": MessageStyle("green2", "bold", "DONE"),
+ }
+ )
+ console.frame(
+ "Bitwarden: up to date\nCitrix: updated\nRambox: updated",
+ title="Repository sync",
+ border="rounded",
+ max_width=50,
+ )
+ console.info("Checked", 3, "repositories")
+ console.success("Finished")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/styled_logging.py b/examples/styled_logging.py
new file mode 100644
index 0000000..ee24d71
--- /dev/null
+++ b/examples/styled_logging.py
@@ -0,0 +1,24 @@
+"""Use AdvancedPrinter with a standard logging handler."""
+
+import logging
+import sys
+
+from advancedprinter import ColorFormatter
+
+
+def main() -> None:
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setFormatter(ColorFormatter("%(levelname)s: %(message)s", stream=handler.stream))
+ logger = logging.getLogger("advancedprinter-example")
+ logger.addHandler(handler)
+ logger.setLevel(logging.INFO)
+ try:
+ logger.info("Repository scan complete")
+ logger.warning("Using cached metadata")
+ finally:
+ logger.removeHandler(handler)
+ handler.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index fcff509..a830003 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,63 @@
[build-system]
-requires = ['setuptools>=42']
-build-backend = 'setuptools.build_meta'
\ No newline at end of file
+requires = ["setuptools>=77"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "AdvancedPrinter"
+version = "2.0.0"
+description = "Small, dependable terminal printing with colours, styles, and Unicode-aware frames"
+readme = "README.md"
+requires-python = ">=3.10"
+license = "MIT"
+license-files = ["LICENSE"]
+authors = [{name = "Kaloian Kozlev", email = "kbkozlev@gmail.com"}]
+dependencies = ["wcwidth>=0.9,<1"]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Typing :: Typed",
+]
+
+[project.urls]
+Repository = "https://github.com/kbkozlev/AdvancedPrinter"
+Issues = "https://github.com/kbkozlev/AdvancedPrinter/issues"
+Documentation = "https://github.com/kbkozlev/AdvancedPrinter/blob/master/docs/api.md"
+Changelog = "https://github.com/kbkozlev/AdvancedPrinter/blob/master/CHANGELOG.md"
+
+[project.optional-dependencies]
+dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.11", "mypy>=1.10", "build>=1.2", "twine>=6"]
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools.package-data]
+advancedprinter = ["py.typed"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "--strict-markers"
+
+[tool.coverage.run]
+source = ["advancedprinter"]
+branch = true
+
+[tool.coverage.report]
+show_missing = true
+
+[tool.ruff]
+target-version = "py310"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B"]
+
+[tool.mypy]
+python_version = "3.10"
+strict = true
diff --git a/scripts/check_release.py b/scripts/check_release.py
new file mode 100644
index 0000000..5dcde91
--- /dev/null
+++ b/scripts/check_release.py
@@ -0,0 +1,28 @@
+"""Fail a release before upload if its tag does not match installed metadata."""
+
+from __future__ import annotations
+
+import argparse
+import os
+from importlib.metadata import version
+
+
+def check_tag(tag: str, package_version: str) -> None:
+ if tag not in (package_version, "v" + package_version):
+ raise ValueError(f"Release tag {tag!r} does not match package version {package_version!r}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--tag", default=os.environ.get("RELEASE_TAG", ""))
+ arguments = parser.parse_args()
+ installed = version("AdvancedPrinter")
+ try:
+ check_tag(arguments.tag, installed)
+ except ValueError as error:
+ parser.error(str(error))
+ print(f"Release tag matches AdvancedPrinter {installed}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index efc00f5..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,23 +0,0 @@
-[metadata]
-name = AdvancedPrinter
-version = 1.2.0
-author = Kaloian Kozlev
-author_email = kbkozlev@gmail.com
-description = Used for printing to the CLI in different colors and styles
-long_description = file: README.md
-long_description_content_type = text/markdown
-url = https://github.com/kbkozlev/AdvancedPrinter
-classifiers =
- Programming Language :: Python :: 3
- License :: OSI Approved :: MIT License
- Operating System :: OS Independent
-project_urls =
- repository = https://github.com/kbkozlev/AdvancedPrinter
-[options]
-package_dir =
- = src
-packages = find:
-python_requires = >=3.6
-
-[options.packages.find]
-where = src
diff --git a/src/advancedprinter/__init__.py b/src/advancedprinter/__init__.py
index cbcd8b6..fa09d43 100644
--- a/src/advancedprinter/__init__.py
+++ b/src/advancedprinter/__init__.py
@@ -1,8 +1,47 @@
+"""Small terminal printing helpers with a backwards-compatible class facade."""
+
+from importlib.metadata import version
+
+from .logging import ColorFormatter
from .main import AdvancedPrinter
+from .printer import MessageStyle, Printer
+from .render import format_frame, format_text, visible_width
+from .styles import Color, ColorMode, ColorSystem, Style
+from .terminal import strip_ansi
+
+__version__ = version("AdvancedPrinter")
-# Importing print and line functions directly
print = AdvancedPrinter.print
input = AdvancedPrinter.input
line = AdvancedPrinter.line
frame = AdvancedPrinter.frame
help = AdvancedPrinter.help
+success = AdvancedPrinter.success
+warning = AdvancedPrinter.warning
+error = AdvancedPrinter.error
+info = AdvancedPrinter.info
+
+__all__ = [
+ "AdvancedPrinter",
+ "Printer",
+ "MessageStyle",
+ "ColorFormatter",
+ "Color",
+ "Style",
+ "ColorMode",
+ "ColorSystem",
+ "__version__",
+ "print",
+ "input",
+ "line",
+ "frame",
+ "help",
+ "success",
+ "warning",
+ "error",
+ "info",
+ "format_text",
+ "format_frame",
+ "strip_ansi",
+ "visible_width",
+]
diff --git a/src/advancedprinter/__main__.py b/src/advancedprinter/__main__.py
new file mode 100644
index 0000000..2ef2696
--- /dev/null
+++ b/src/advancedprinter/__main__.py
@@ -0,0 +1,20 @@
+"""Run python -m advancedprinter for a destination-aware demonstration."""
+
+from .printer import Printer
+
+
+def main() -> None:
+ console = Printer()
+ console.frame(
+ "Colours, styles, and Unicode-aware frames",
+ title="AdvancedPrinter",
+ border="rounded",
+ max_width=60,
+ )
+ console.help()
+ console.success("Ready to print")
+ console.info("Redirect this demo to a file to get plain output.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/advancedprinter/_codes.py b/src/advancedprinter/_codes.py
new file mode 100644
index 0000000..167fca3
--- /dev/null
+++ b/src/advancedprinter/_codes.py
@@ -0,0 +1,85 @@
+"""Legacy ANSI constants, retained for AdvancedPrinter.C compatibility."""
+
+
+class Codes:
+ """
+ Nested class containing ANSI escape codes for different text colors and styles.
+ """
+
+ RESET = "\033[0m"
+
+ BLACK = "\033[30m"
+ WHITE = "\033[97m"
+
+ RED = "\033[31m"
+ RED1 = "\033[91m"
+ RED2 = "\033[38;2;128;0;0m"
+
+ GREEN = "\033[38;2;0;128;0m"
+ GREEN1 = "\033[32m"
+ GREEN2 = "\033[92m"
+ GREEN3 = "\033[38;2;0;255;0m"
+
+ YELLOW = "\033[33m"
+ YELLOW1 = "\033[93m"
+ YELLOW2 = "\033[38;2;255;255;0m"
+
+ OLIVE = "\033[38;2;128;128;0m"
+
+ BLUE = "\033[34m"
+ BLUE1 = "\033[94m"
+ BLUE2 = "\033[38;2;0;0;255m"
+ BLUE3 = "\033[38;2;0;0;128m"
+
+ MAGENTA = "\033[35m"
+ MAGENTA1 = "\033[38;2;255;0;255m"
+
+ PINK = "\033[95m"
+
+ PURPLE = "\033[38;2;128;0;128m"
+
+ CYAN = "\033[38;2;0;128;128m"
+ CYAN1 = "\033[36m"
+ CYAN2 = "\033[96m"
+
+ ORANGE = "\033[38;5;208m"
+
+ GRAY = "\033[90m"
+ GRAY1 = "\033[37m"
+
+ # Background Colors
+ BG_BLACK = "\033[40m"
+ BG_WHITE = "\033[107m"
+
+ BG_GRAY = "\033[47m"
+ BG_GRAY1 = "\033[100m"
+
+ BG_RED = "\033[41m"
+ BG_RED1 = "\033[101m"
+
+ BG_GREEN = "\033[42m"
+ BG_GREEN1 = "\033[102m"
+
+ BG_YELLOW = "\033[103m"
+ BG_OLIVE = "\033[43m"
+
+ BG_BLUE = "\033[44m"
+ BG_BLUE1 = "\033[104m"
+
+ BG_MAGENTA = "\033[45m"
+
+ BG_PINK = "\033[105m"
+
+ BG_CYAN = "\033[46m"
+ BG_CYAN1 = "\033[106m"
+
+ BG_ORANGE = "\033[48;5;208m"
+ BG_ORANGE1 = "\033[48;5;202m"
+ BG_BROWN = "\033[48;5;166m"
+
+ # Styles
+ B = "\033[1m"
+ IT = "\x1b[3m"
+ U = "\x1b[4m"
+ INV = "\u001b[7m"
+ STR = "\u001b[9m"
diff --git a/src/advancedprinter/logging.py b/src/advancedprinter/logging.py
new file mode 100644
index 0000000..1858c70
--- /dev/null
+++ b/src/advancedprinter/logging.py
@@ -0,0 +1,52 @@
+"""An adapter for Python logging; records are never modified."""
+
+from __future__ import annotations
+
+import copy
+import logging
+import sys
+from typing import TextIO
+
+from .render import _render
+from .styles import validate_policy
+from .terminal import capabilities
+
+
+class ColorFormatter(logging.Formatter):
+ """Colour a formatted log record for an explicitly associated stream.
+
+ Set stream= to the same stream as the logging.StreamHandler. Use an ordinary
+ logging.Formatter on file handlers; each handler keeps its own formatting.
+ """
+
+ def __init__(
+ self,
+ fmt: str | None = None,
+ datefmt: str | None = None,
+ *,
+ stream: TextIO | None = None,
+ color_mode: str = "auto",
+ color_system: str = "auto",
+ ) -> None:
+ super().__init__(fmt, datefmt)
+ validate_policy(color_mode, color_system)
+ self.stream = stream
+ self.color_mode = color_mode
+ self.color_system = color_system
+
+ def format(self, record: logging.LogRecord) -> str:
+ text = super().format(copy.copy(record))
+ enabled, colors, system = capabilities(
+ self.stream if self.stream is not None else sys.stderr,
+ self.color_mode,
+ self.color_system,
+ )
+ if record.levelno >= logging.ERROR:
+ color, style = "red", "bold"
+ elif record.levelno >= logging.WARNING:
+ color, style = "yellow", None
+ elif record.levelno >= logging.INFO:
+ color, style = "cyan1", None
+ else:
+ color, style = "gray", None
+ return _render(text, c=color, s=style, enabled=enabled, colors=colors, system=system)
diff --git a/src/advancedprinter/main.py b/src/advancedprinter/main.py
index 8e671a8..98fd6b4 100644
--- a/src/advancedprinter/main.py
+++ b/src/advancedprinter/main.py
@@ -1,256 +1,23 @@
-class AdvancedPrinter:
- """
- Custom class to mimic the behavior of the print function with added color and style options.
-
- - For more colors call the 'help' method
-
- Colors for text and background:
- - BLACK
- - WHITE
- - RED
- - GREEN
- - YELLOW
- - BLUE
- - MAGENTA
- - CYAN
- - ORANGE
- - GRAY
- - OLIVE
-
- Text styles:
- - B (Bold)
- - IT (Italic)
- - U (Underline)
- - INV (Invert)
- - STR (Strike-through)
- """
-
- class C:
- """
- Nested class containing ANSI escape codes for different text colors and styles.
- """
- RESET = '\033[0m'
-
- BLACK = '\033[30m'
- WHITE = '\033[97m'
-
- RED = '\033[31m'
- RED1 = '\033[91m'
- RED2 = '\033[38;2;128;0;0m'
-
- GREEN = '\033[38;2;0;128;0m'
- GREEN1 = '\033[32m'
- GREEN2 = '\033[92m'
- GREEN3 = '\033[38;2;0;255;0m'
-
- YELLOW = '\033[33m'
- YELLOW1 = '\033[93m'
- YELLOW2 = '\033[38;2;255;255;0m'
-
- OLIVE = '\033[38;2;128;128;0m'
-
- BLUE = '\033[34m'
- BLUE1 = '\033[94m'
- BLUE2 = '\033[38;2;0;0;255m'
- BLUE3 = '\033[38;2;0;0;128m'
-
- MAGENTA = '\033[35m'
- MAGENTA1 = '\033[38;2;255;0;255m'
-
- PINK = '\033[95m'
-
- PURPLE = '\033[38;2;128;0;128m'
-
- CYAN = '\033[38;2;0;128;128m'
- CYAN1 = '\033[36m'
- CYAN2 = '\033[96m'
-
- ORANGE = '\033[38;5;208m'
-
- GRAY = '\033[90m'
- GRAY1 = '\033[37m'
-
- # Background Colors
- BG_BLACK = '\033[40m'
- BG_WHITE = '\033[107m'
-
- BG_GRAY = '\033[47m'
- BG_GRAY1 = '\033[100m'
-
- BG_RED = '\033[41m'
- BG_RED1 = '\033[101m'
-
- BG_GREEN = '\033[42m'
- BG_GREEN1 = '\033[102m'
-
- BG_YELLOW = '\033[103m'
- BG_OLIVE = '\033[43m'
-
- BG_BLUE = '\033[44m'
- BG_BLUE1 = '\033[104m'
-
- BG_MAGENTA = '\033[45m'
+"""Compatibility facade for the original class and its import path."""
- BG_PINK = '\033[105m'
+from ._codes import Codes
+from .printer import Printer
- BG_CYAN = '\033[46m'
- BG_CYAN1 = '\033[106m'
+_default = Printer()
- BG_ORANGE = '\033[48;5;208m'
- BG_ORANGE1 = '\033[48;5;202m'
- BG_BROWN = '\033[48;5;166m'
- # Styles
- B = '\033[1m'
- IT = '\x1B[3m'
- U = '\x1B[4m'
- INV = '\u001b[7m'
- STR = '\u001b[9m'
-
- @staticmethod
- def _prepare_text(*args, foreground=None, background=None, style=None):
- """
- Helper method to prepare the colored and styled text.
-
- :param args: The text to print.
- :param foreground: The text color.
- :param background: The background color.
- :param style: The style.
- :return: The colored and styled text.
- """
- color_code = getattr(AdvancedPrinter.C, foreground.upper(), '') if foreground else ''
- background_code = getattr(AdvancedPrinter.C, f"BG_{background.upper()}", '') if background else ''
-
- # Process s
- style_code = ''
- if style:
- styles = style.split('-')
- for style in styles:
- style_code += getattr(AdvancedPrinter.C, style.upper(), '')
-
- text = ' '.join(str(arg) for arg in args)
- return f'{background_code}{style_code}{color_code}{text}{AdvancedPrinter.C.RESET}'
-
- @staticmethod
- def print(*args, c=None, b=None, s=None, end='\n', flush=False, file=None, **kwargs):
- """
- Print function wrapper with added color and style options.
-
- :param args: The text to print.
- :param c: The text color
- :param b: The background color.
- :param s: The style.
- :param end: The string appended after the last value, default is a newline.
- :param flush: Whether to forcibly flush the stream, default is False.
- :param file: A file-like object (stream); defaults to the current sys.stdout.
- :param kwargs: Additional keyword arguments supported by the built-in print function.
- """
- colored_text = AdvancedPrinter._prepare_text(*args, foreground=c, background=b, style=s)
- print(colored_text, end=end, flush=flush, file=file, **kwargs)
-
- @staticmethod
- def line(*args, c=None, b=None, s=None, end='') -> str:
- """
- Custom function with added color and style options used for inline printing
-
- :param args: The text to return.
- :param c: The text color.
- :param b: The background color.
- :param s: The style.
- :param end: The string appended after the last value, default is ''.
- """
- colored_text = AdvancedPrinter._prepare_text(*args, foreground=c, background=b, style=s)
- return f"{colored_text}{end}"
-
- @staticmethod
- def input(*args, c=None, b=None, s=None):
- """
- Input function wrapper with added color and style options
-
- :param args: The text to print.
- :param c: The text color
- :param b: The background color.
- :param s: The style.
- :return: Return the inbuilt 'input' function but with colored formatting
- """
- text = args[0]
- formatted_text = AdvancedPrinter._prepare_text(text.strip(), foreground=c, background=b, style=s)
- return input(formatted_text + ' ') if text.endswith(' ') else input(formatted_text)
-
- @staticmethod
- def frame(text, f_s='basic', f_c=None, f_b=None, c=None, b=None, s=None):
- """
-
- :param text: The text in the frame
- :param f_s: The style of the frame
- :param f_c: The color of the frame
- :param f_b: The background color of the frame
- :param c: The color of the text in the frame
- :param b: The background color of the text in the frame
- :param s: The style of the text in the frame
- :return:
-
- If no parameters are given to the frame, it will take the 'basic' style and the color and background of the text
-
- Frame Styles:
- - basic
- - basic2
- - single
- - double
- """
-
- borders = {
- 'basic': {'horizontal': '-', 'vertical': '|', 'corner': '++++'},
- 'basic2': {'horizontal': '=', 'vertical': '#', 'corner': '===='},
- 'single': {'horizontal': 'โ', 'vertical': 'โ', 'corner': 'โโโโ'},
- 'double': {'horizontal': 'โ', 'vertical': 'โ', 'corner': 'โโโโ'}
- }
-
- border_chars = borders.get(f_s, borders['basic'])
- max_length = max(len(line.strip()) for line in text.splitlines())
- top_border = border_chars['corner'][0] + border_chars['horizontal'] * (max_length + 2) + border_chars['corner'][
- 1]
- bottom_border = border_chars['corner'][2] + border_chars['horizontal'] * (max_length + 2) + border_chars['corner'][3]
- middle_border = border_chars['vertical'] + ' ' * (max_length + 2) + border_chars['vertical']
-
- framed_text = [AdvancedPrinter._prepare_text(top_border, foreground=f_c or c, background=f_b or b)]
- for line in text.splitlines():
- line_content = line.strip().ljust(max_length)
- colored_text = AdvancedPrinter._prepare_text(f'{line_content}', foreground=c, background=b, style=s)
- left_border = AdvancedPrinter._prepare_text(border_chars['vertical'] + ' ', foreground=f_c or c,
- background=f_b or b)
- right_border = AdvancedPrinter._prepare_text(' ' + border_chars['vertical'], foreground=f_c or c,
- background=f_b or b)
- colored_line = f"{left_border}{colored_text}{right_border}"
- framed_text.append(colored_line)
-
- if framed_text[-1] != middle_border: # If the last line is not the middle border, append bottom border
- framed_text.append(AdvancedPrinter._prepare_text(bottom_border, foreground=f_c or c, background=f_b or b))
-
- final = '\n'.join(framed_text)
- print(final)
-
- @staticmethod
- def help():
- """
- Print out all available colors and styles.
- """
- styles = ['B', 'IT', 'U', 'INV', 'STR']
-
- print("Text Colors:")
- for color in dir(AdvancedPrinter.C):
- if color.isupper() and not color.startswith('BG_') and not color == 'RESET' and color not in styles:
- AdvancedPrinter.print("- {}".format(color), c=color)
-
- print("\nBackground Colors:")
- for color in dir(AdvancedPrinter.C):
- if color.startswith('BG_'):
- bg_color = color[3:]
- line_style = AdvancedPrinter.line(' ', b=bg_color)
- AdvancedPrinter.print("{} - {}".format(line_style, bg_color))
-
- print("\nAvailable styles:")
- for style in dir(AdvancedPrinter.C):
- if style.isupper() and style in styles:
- line_style = AdvancedPrinter.line("{}".format(style), s=style)
- AdvancedPrinter.print("- {}".format(line_style))
+class AdvancedPrinter:
+ """Legacy class-style API; use Printer for independent configuration."""
+
+ C = Codes
+ print = staticmethod(_default.print)
+ line = staticmethod(_default.line)
+ input = staticmethod(_default.input)
+ frame = staticmethod(_default.frame)
+ help = staticmethod(_default.help)
+ format_text = staticmethod(_default.format_text)
+ format_frame = staticmethod(_default.format_frame)
+ success = staticmethod(_default.success)
+ warning = staticmethod(_default.warning)
+ error = staticmethod(_default.error)
+ info = staticmethod(_default.info)
diff --git a/src/advancedprinter/printer.py b/src/advancedprinter/printer.py
new file mode 100644
index 0000000..deb25fc
--- /dev/null
+++ b/src/advancedprinter/printer.py
@@ -0,0 +1,275 @@
+"""Stream-aware printing and independently configurable printer instances."""
+
+from __future__ import annotations
+
+import builtins
+import sys
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import TextIO, cast
+
+from .render import _join, _render, format_frame, format_text
+from .styles import BACKGROUNDS, FOREGROUNDS, STYLES, Color, Style, alias, validate_policy
+from .terminal import capabilities, strip_ansi, strip_colors
+
+
+@dataclass(frozen=True)
+class MessageStyle:
+ """Theme entry for a semantic message; the prefix remains in plain output."""
+
+ color: Color = None
+ style: Style = None
+ prefix: str = ""
+
+
+DEFAULT_THEME: Mapping[str, MessageStyle] = {
+ "success": MessageStyle("green2", prefix="[SUCCESS]"),
+ "warning": MessageStyle("yellow", prefix="[WARNING]"),
+ "error": MessageStyle("red", "bold", "[ERROR]"),
+ "info": MessageStyle("cyan1", prefix="[INFO]"),
+}
+
+
+class Printer:
+ """A printer with per-instance policy, streams, and semantic message theme.
+
+ file=None resolves sys.stdout at call time; error_file=None resolves
+ sys.stderr for error(). Explicit per-call file= always takes precedence.
+ """
+
+ def __init__(
+ self,
+ *,
+ color_mode: str = "auto",
+ color_system: str = "auto",
+ file: TextIO | None = None,
+ error_file: TextIO | None = None,
+ theme: Mapping[str, MessageStyle] | None = None,
+ ) -> None:
+ validate_policy(color_mode, color_system)
+ self.color_mode = color_mode
+ self.color_system = color_system
+ self.file = file
+ self.error_file = error_file
+ self.theme = dict(DEFAULT_THEME)
+ if theme:
+ if set(theme) - set(DEFAULT_THEME):
+ raise ValueError("Theme keys must be success, warning, error, or info")
+ if not all(isinstance(entry, MessageStyle) for entry in theme.values()):
+ raise TypeError("Theme values must be MessageStyle instances")
+ self.theme.update(theme)
+
+ def _stream(self, file: TextIO | None = None) -> TextIO:
+ return file if file is not None else self.file if self.file is not None else sys.stdout
+
+ def print(
+ self,
+ *values: object,
+ c: Color = None,
+ b: Color = None,
+ s: Style = None,
+ color: Color = None,
+ background: Color = None,
+ style: Style = None,
+ sep: str | None = " ",
+ end: str | None = "\n",
+ file: TextIO | None = None,
+ flush: bool = False,
+ color_mode: str | None = None,
+ color_system: str | None = None,
+ ) -> None:
+ """Print with built-in sep/end/file/flush semantics and optional styling."""
+ stream = self._stream(file)
+ enabled, colors, system = capabilities(
+ stream,
+ self.color_mode if color_mode is None else color_mode,
+ self.color_system if color_system is None else color_system,
+ )
+ c = cast(Color, alias(c, color, "c", "color"))
+ b = cast(Color, alias(b, background, "b", "background"))
+ s = cast(Style, alias(s, style, "s", "style"))
+ text = _render(
+ _join(values, sep), c=c, b=b, s=s, enabled=enabled, colors=colors, system=system
+ )
+ if isinstance(end, str):
+ end = strip_ansi(end) if not enabled else strip_colors(end) if not colors else end
+ builtins.print(text, end=end, file=stream, flush=flush)
+
+ # Pure operations intentionally retain their explicit/default ANSI policy.
+ format_text = staticmethod(format_text)
+ line = staticmethod(format_text)
+ format_frame = staticmethod(format_frame)
+
+ def frame(
+ self,
+ text: object,
+ f_s: str | None = None,
+ f_c: Color = None,
+ f_b: Color = None,
+ c: Color = None,
+ b: Color = None,
+ s: Style = None,
+ *,
+ border: str | None = None,
+ border_color: Color = None,
+ border_background: Color = None,
+ color: Color = None,
+ background: Color = None,
+ style: Style = None,
+ title: str = "",
+ padding: int = 1,
+ width: int | None = None,
+ max_width: int | None = None,
+ wrap: bool = True,
+ align: str = "left",
+ dedent: bool = False,
+ ascii: bool = False,
+ file: TextIO | None = None,
+ end: str | None = "\n",
+ flush: bool = False,
+ color_mode: str | None = None,
+ color_system: str | None = None,
+ ) -> None:
+ """Print format_frame() output, applying this destination's colour policy."""
+ stream = self._stream(file)
+ enabled, colors, system = capabilities(
+ stream,
+ self.color_mode if color_mode is None else color_mode,
+ self.color_system if color_system is None else color_system,
+ )
+
+ def render(use_ascii: bool) -> str:
+ result = format_frame(
+ text,
+ f_s,
+ f_c,
+ f_b,
+ c,
+ b,
+ s,
+ border=border,
+ border_color=border_color,
+ border_background=border_background,
+ color=color,
+ background=background,
+ style=style,
+ title=title,
+ padding=padding,
+ width=width,
+ max_width=max_width,
+ wrap=wrap,
+ align=align,
+ dedent=dedent,
+ ascii=use_ascii,
+ color_mode="always" if enabled else "never",
+ color_system=system,
+ )
+ return strip_colors(result) if not colors and enabled else result
+
+ rendered = render(ascii)
+ # Use ASCII borders automatically if the destination cannot encode them.
+ encoding = getattr(stream, "encoding", None)
+ if encoding:
+ try:
+ rendered.encode(encoding)
+ except (UnicodeEncodeError, LookupError):
+ rendered = render(True)
+ if isinstance(end, str):
+ end = strip_ansi(end) if not enabled else strip_colors(end) if not colors else end
+ builtins.print(rendered, file=stream, end=end, flush=flush)
+
+ def input(
+ self,
+ prompt: object = "",
+ /,
+ *,
+ c: Color = None,
+ b: Color = None,
+ s: Style = None,
+ color: Color = None,
+ background: Color = None,
+ style: Style = None,
+ ) -> str:
+ """Read input after writing an unmodified, optionally styled prompt."""
+ self.print(
+ prompt,
+ c=c,
+ b=b,
+ s=s,
+ color=color,
+ background=background,
+ style=style,
+ end="",
+ flush=True,
+ )
+ return builtins.input()
+
+ def _message(
+ self,
+ kind: str,
+ values: tuple[object, ...],
+ *,
+ file: TextIO | None = None,
+ sep: str | None = " ",
+ end: str | None = "\n",
+ flush: bool = False,
+ ) -> None:
+ entry = self.theme[kind]
+ text = _join(values, sep)
+ if entry.prefix:
+ text = entry.prefix + (" " + text if text else "")
+ if kind == "error" and file is None:
+ file = self.error_file if self.error_file is not None else sys.stderr
+ self.print(text, color=entry.color, style=entry.style, file=file, end=end, flush=flush)
+
+ def success(
+ self,
+ *values: object,
+ file: TextIO | None = None,
+ sep: str | None = " ",
+ end: str | None = "\n",
+ flush: bool = False,
+ ) -> None:
+ self._message("success", values, file=file, sep=sep, end=end, flush=flush)
+
+ def warning(
+ self,
+ *values: object,
+ file: TextIO | None = None,
+ sep: str | None = " ",
+ end: str | None = "\n",
+ flush: bool = False,
+ ) -> None:
+ self._message("warning", values, file=file, sep=sep, end=end, flush=flush)
+
+ def error(
+ self,
+ *values: object,
+ file: TextIO | None = None,
+ sep: str | None = " ",
+ end: str | None = "\n",
+ flush: bool = False,
+ ) -> None:
+ self._message("error", values, file=file, sep=sep, end=end, flush=flush)
+
+ def info(
+ self,
+ *values: object,
+ file: TextIO | None = None,
+ sep: str | None = " ",
+ end: str | None = "\n",
+ flush: bool = False,
+ ) -> None:
+ self._message("info", values, file=file, sep=sep, end=end, flush=flush)
+
+ def help(self, *, file: TextIO | None = None) -> None:
+ """Show the palette and style names using the destination colour policy."""
+ self.print("Text Colors:", file=file)
+ for name in FOREGROUNDS:
+ self.print("- " + name, c=name, file=file)
+ self.print("\nBackground Colors:", file=file)
+ for name in BACKGROUNDS:
+ self.print("- " + name, b=name, file=file)
+ self.print("\nAvailable styles:", file=file)
+ for name in STYLES:
+ self.print("- " + name, s=name, file=file)
diff --git a/src/advancedprinter/py.typed b/src/advancedprinter/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/advancedprinter/render.py b/src/advancedprinter/render.py
new file mode 100644
index 0000000..e7d6a38
--- /dev/null
+++ b/src/advancedprinter/render.py
@@ -0,0 +1,232 @@
+"""Pure rendering functions: no output streams or global configuration."""
+
+from __future__ import annotations
+
+import re
+import textwrap
+from typing import cast
+
+from wcwidth import iter_graphemes, wcswidth
+from wcwidth import wrap as cell_wrap
+
+from .styles import RESET, Color, Style, alias, color_code, style_code, validate_policy
+from .terminal import SGR_RE, strip_ansi, strip_colors
+
+
+def visible_width(text: str) -> int:
+ """Measure printable terminal cells, ignoring ANSI; reject control characters."""
+ measured = wcswidth(strip_ansi(text))
+ if measured < 0:
+ raise ValueError("Frame content contains a non-printable control character")
+ return measured
+
+
+def _restore(text: str, foreground: str, background: str, styles: str) -> str:
+ prefix = background + styles + foreground
+ resets = {22: "\x1b[1m", 23: "\x1b[3m", 24: "\x1b[4m", 27: "\x1b[7m", 29: "\x1b[9m"}
+
+ def restore(found: re.Match[str]) -> str:
+ parameters = (found[1] or "0").split(";")
+ output = ""
+ i = 0
+ while i < len(parameters):
+ number = int(parameters[i] or "0")
+ if number in (38, 48, 58) and i + 1 < len(parameters):
+ size = 5 if parameters[i + 1] == "2" else 3
+ output += "\x1b[" + ";".join(parameters[i : i + size]) + "m"
+ i += size
+ continue
+ code = f"\x1b[{number}m"
+ if number == 0:
+ code += prefix
+ elif number == 39:
+ code += foreground
+ elif number == 49:
+ code += background
+ elif number in resets and resets[number] in styles:
+ code += resets[number]
+ output += code
+ i += 1
+ return output
+
+ return SGR_RE.sub(restore, text)
+
+
+def _render(
+ text: str,
+ *,
+ c: Color = None,
+ b: Color = None,
+ s: Style = None,
+ enabled: bool = True,
+ colors: bool = True,
+ system: str = "truecolor",
+) -> str:
+ fg = color_code(c, system=system)
+ bg = color_code(b, background=True, system=system)
+ styles = style_code(s)
+ if not enabled:
+ return strip_ansi(text)
+ if not colors:
+ fg = bg = ""
+ text = strip_colors(text)
+ prefix = bg + styles + fg
+ if not prefix:
+ return text
+ # Restore the enclosing style after an embedded line()/format_text() reset.
+ return prefix + _restore(text, fg, bg, styles) + RESET
+
+
+def _join(values: tuple[object, ...], sep: str | None) -> str:
+ if sep is None:
+ sep = " "
+ if not isinstance(sep, str):
+ raise TypeError(f"sep must be None or a string, not {type(sep).__name__}")
+ return sep.join(str(value) for value in values)
+
+
+def format_text(
+ *values: object,
+ c: Color = None,
+ b: Color = None,
+ s: Style = None,
+ color: Color = None,
+ background: Color = None,
+ style: Style = None,
+ sep: str | None = " ",
+ end: str = "",
+ color_mode: str = "always",
+ color_system: str = "truecolor",
+) -> str:
+ """Return styled text. Pure formatting defaults to explicit ANSI output.
+
+ Use color_mode='never' for a plain string. Destination-aware policy belongs
+ to Printer.print(), which can inspect the stream it actually writes to.
+ """
+ validate_policy(color_mode, color_system)
+ if color_mode == "auto" or color_system == "auto":
+ raise ValueError(
+ "Pure formatting needs an explicit colour policy/depth; use Printer for auto"
+ )
+ if not isinstance(end, str):
+ raise TypeError("end must be a string")
+ c = cast(Color, alias(c, color, "c", "color"))
+ b = cast(Color, alias(b, background, "b", "background"))
+ s = cast(Style, alias(s, style, "s", "style"))
+ return _render(
+ _join(values, sep), c=c, b=b, s=s, enabled=color_mode != "never", system=color_system
+ ) + (strip_ansi(end) if color_mode == "never" else end)
+
+
+BORDERS = {
+ "basic": ("-", "|", "++++"),
+ "basic2": ("=", "#", "===="),
+ "single": ("โ", "โ", "โโโโ"),
+ "double": ("โ", "โ", "โโโโ"),
+ "rounded": ("โ", "โ", "โญโฎโฐโฏ"),
+}
+
+
+def format_frame(
+ text: object,
+ f_s: str | None = None,
+ f_c: Color = None,
+ f_b: Color = None,
+ c: Color = None,
+ b: Color = None,
+ s: Style = None,
+ *,
+ border: str | None = None,
+ border_color: Color = None,
+ border_background: Color = None,
+ color: Color = None,
+ background: Color = None,
+ style: Style = None,
+ title: str = "",
+ padding: int = 1,
+ width: int | None = None,
+ max_width: int | None = None,
+ wrap: bool = True,
+ align: str = "left",
+ dedent: bool = False,
+ ascii: bool = False,
+ color_mode: str = "always",
+ color_system: str = "truecolor",
+) -> str:
+ """Return a frame; width/max_width include borders and horizontal padding.
+
+ Whitespace is preserved. Widths are terminal cells, not Python string length.
+ Content too wide to fit with wrap=False raises ValueError.
+ """
+ validate_policy(color_mode, color_system)
+ if color_mode == "auto" or color_system == "auto":
+ raise ValueError("Pure formatting needs explicit colour policy/depth")
+ border = cast(str | None, alias(f_s, border, "f_s", "border"))
+ if border is None:
+ border = "basic"
+ c = cast(Color, alias(c, color, "c", "color"))
+ b = cast(Color, alias(b, background, "b", "background"))
+ s = cast(Style, alias(s, style, "s", "style"))
+ f_c = cast(Color, alias(f_c, border_color, "f_c", "border_color"))
+ f_b = cast(Color, alias(f_b, border_background, "f_b", "border_background"))
+ if border not in BORDERS:
+ raise ValueError(f"Unknown border {border!r}; choose from {', '.join(BORDERS)}")
+ if type(padding) is not int or padding < 0:
+ raise ValueError("padding must be a non-negative integer")
+ if align not in ("left", "center", "right"):
+ raise ValueError("align must be 'left', 'center', or 'right'")
+ if not isinstance(title, str) or "\n" in title or "\r" in title:
+ raise ValueError("title must be a single-line string")
+ for value in (width, max_width):
+ if value is not None and (type(value) is not int or value < 2 + 2 * padding + 1):
+ raise ValueError("Frame width must leave at least one cell for content")
+ if width is not None and max_width is not None and width > max_width:
+ raise ValueError("width cannot exceed max_width")
+ raw = str(text).replace("\r\n", "\n").expandtabs(4)
+ if dedent:
+ raw = textwrap.dedent(raw)
+ lines = raw.split("\n")
+ content_width = max(1, *(visible_width(line) for line in lines))
+ title_width = visible_width(title) + 2 if title else 0
+ natural_width = max(content_width + 2 * padding, title_width) + 2
+ total = width if width is not None else min(natural_width, max_width or natural_width)
+ available = total - 2 - 2 * padding
+ if title_width > total - 2:
+ raise ValueError("title is too wide for the frame")
+ rendered_lines: list[str] = []
+ for line in lines:
+ if visible_width(line) > available:
+ if not wrap:
+ raise ValueError("Content exceeds frame width; enable wrap or increase width")
+ if any(wcswidth(cluster) > available for cluster in iter_graphemes(strip_ansi(line))):
+ raise ValueError("A character is wider than the available frame content width")
+ rendered_lines.extend(
+ cell_wrap(
+ line,
+ width=available,
+ drop_whitespace=False,
+ replace_whitespace=False,
+ break_on_hyphens=False,
+ )
+ )
+ else:
+ rendered_lines.append(line)
+ horizontal, vertical, corners = BORDERS["basic" if ascii else border]
+ frame_fg = c if f_c is None else f_c
+ frame_bg = b if f_b is None else f_b
+ enabled = color_mode != "never"
+
+ def edge(value: str) -> str:
+ return _render(value, c=frame_fg, b=frame_bg, enabled=enabled, system=color_system)
+
+ top_fill = (" " + title + " ") if title else ""
+ top = edge(corners[0] + top_fill + horizontal * (total - 2 - title_width) + corners[1])
+ output = [top]
+ for line in rendered_lines:
+ remaining = available - visible_width(line)
+ left = remaining if align == "right" else remaining // 2 if align == "center" else 0
+ padded = " " * left + line + " " * (remaining - left)
+ body = _render(padded, c=c, b=b, s=s, enabled=enabled, system=color_system)
+ output.append(edge(vertical + " " * padding) + body + edge(" " * padding + vertical))
+ output.append(edge(corners[2] + horizontal * (total - 2) + corners[3]))
+ return "\n".join(output)
diff --git a/src/advancedprinter/styles.py b/src/advancedprinter/styles.py
new file mode 100644
index 0000000..577c4c6
--- /dev/null
+++ b/src/advancedprinter/styles.py
@@ -0,0 +1,155 @@
+"""Validated style and colour specifications, with legacy palette names."""
+
+from __future__ import annotations
+
+import re
+from typing import Literal, TypeAlias
+
+from ._codes import Codes
+
+Color: TypeAlias = str | int | tuple[int, int, int] | None
+Style: TypeAlias = str | tuple[str, ...] | list[str] | None
+ColorMode: TypeAlias = Literal["auto", "always", "never"]
+ColorSystem: TypeAlias = Literal["auto", "standard", "256", "truecolor"]
+
+RESET = "\x1b[0m"
+# Keep separate registries: a style name cannot accidentally be used as a colour.
+_NAMES = (
+ "black white red red1 red2 green green1 green2 green3 yellow yellow1 yellow2 "
+ "olive blue blue1 blue2 blue3 magenta magenta1 pink purple cyan cyan1 cyan2 orange gray gray1"
+).split()
+_BG_NAMES = (
+ "black white gray gray1 red red1 green green1 yellow olive blue blue1 magenta pink "
+ "cyan cyan1 orange orange1 brown"
+).split()
+FOREGROUNDS: dict[str, str] = {name: getattr(Codes, name.upper()) for name in _NAMES}
+BACKGROUNDS: dict[str, str] = {name: getattr(Codes, "BG_" + name.upper()) for name in _BG_NAMES}
+STYLES = {"bold": 1, "italic": 3, "underline": 4, "inverted": 7, "strike-through": 9}
+_ALIASES = {
+ "b": "bold",
+ "it": "italic",
+ "u": "underline",
+ "inv": "inverted",
+ "str": "strike-through",
+ "strikethrough": "strike-through",
+ "strike": "strike-through",
+}
+# xterm's conventional ANSI palette; actual terminal themes can differ.
+_PALETTE = (
+ (0, 0, 0),
+ (128, 0, 0),
+ (0, 128, 0),
+ (128, 128, 0),
+ (0, 0, 128),
+ (128, 0, 128),
+ (0, 128, 128),
+ (192, 192, 192),
+ (128, 128, 128),
+ (255, 0, 0),
+ (0, 255, 0),
+ (255, 255, 0),
+ (0, 0, 255),
+ (255, 0, 255),
+ (0, 255, 255),
+ (255, 255, 255),
+)
+
+
+def alias(short: object, long: object, short_name: str, long_name: str) -> object:
+ """Reject conflicting spellings rather than silently choosing one."""
+ if short is not None and long is not None and short != long:
+ raise TypeError(f"Use either {short_name}= or {long_name}=, not conflicting values")
+ return long if long is not None else short
+
+
+def validate_policy(mode: str, system: str) -> None:
+ if mode not in ("auto", "always", "never"):
+ raise ValueError("color_mode must be 'auto', 'always', or 'never'")
+ if system not in ("auto", "standard", "256", "truecolor"):
+ raise ValueError("color_system must be 'auto', 'standard', '256', or 'truecolor'")
+
+
+def _index_rgb(index: int) -> tuple[int, int, int]:
+ if index < 16:
+ return _PALETTE[index]
+ if index >= 232:
+ n = 8 + (index - 232) * 10
+ return n, n, n
+ index -= 16
+ levels = (0, 95, 135, 175, 215, 255)
+ return levels[index // 36], levels[(index // 6) % 6], levels[index % 6]
+
+
+def _nearest(rgb: tuple[int, int, int], limit: int) -> int:
+ return min(
+ range(limit),
+ key=lambda i: sum((a - b) ** 2 for a, b in zip(rgb, _index_rgb(i), strict=True)),
+ )
+
+
+def color_code(value: Color, *, background: bool = False, system: str = "truecolor") -> str:
+ """Accept palette names, #RRGGBB, RGB tuples, or xterm indexes 0..255."""
+ if value is None:
+ return ""
+ prefix = 48 if background else 38
+ registry = BACKGROUNDS if background else FOREGROUNDS
+ if isinstance(value, str):
+ key = value.lower()
+ if key == "default":
+ return "\x1b[49m" if background else "\x1b[39m"
+ if key in registry:
+ code = registry[key]
+ parameters = [int(p) for p in code[2:-1].split(";")]
+ if len(parameters) == 1:
+ return code
+ value = tuple(parameters[2:]) if parameters[1] == 2 else parameters[2] # type: ignore[assignment]
+ elif re.fullmatch(r"#[0-9a-fA-F]{6}", value):
+ value = tuple(int(value[i : i + 2], 16) for i in (1, 3, 5)) # type: ignore[assignment]
+ else:
+ raise ValueError(
+ f"Unknown {'background' if background else 'foreground'} colour {value!r}"
+ )
+ if isinstance(value, bool):
+ raise ValueError("Colour indexes must be integers from 0 to 255, not booleans")
+ if isinstance(value, int):
+ if not 0 <= value <= 255:
+ raise ValueError("Colour index must be between 0 and 255")
+ if system != "standard":
+ return f"\x1b[{prefix};5;{value}m"
+ rgb = _index_rgb(value)
+ elif (
+ isinstance(value, tuple)
+ and len(value) == 3
+ and all(type(channel) is int and 0 <= channel <= 255 for channel in value)
+ ):
+ rgb = value
+ if system == "truecolor":
+ return f"\x1b[{prefix};2;{rgb[0]};{rgb[1]};{rgb[2]}m"
+ if system == "256":
+ return f"\x1b[{prefix};5;{_nearest(rgb, 256)}m"
+ else:
+ raise ValueError("Colour must be a name, #RRGGBB, RGB tuple, or index 0..255")
+ index = _nearest(rgb, 16)
+ base = 40 if background else 30
+ return f"\x1b[{base + index if index < 8 else base + 60 + index - 8}m"
+
+
+def style_code(value: Style) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ # Preserve the hyphenated full name while supporting legacy 'b-it-u'.
+ tokens = value.lower().replace("strike-through", "strikethrough").replace(",", " ").split()
+ tokens = [part for token in tokens for part in token.split("-")]
+ elif isinstance(value, (list, tuple)) and all(isinstance(item, str) for item in value):
+ tokens = [item.lower() for item in value]
+ else:
+ raise TypeError("style must be a string or sequence of style names")
+ codes: list[int] = []
+ for token in tokens:
+ name = _ALIASES.get(token, token)
+ if name not in STYLES:
+ raise ValueError(f"Unknown style {token!r}; choose from {', '.join(STYLES)}")
+ if STYLES[name] not in codes:
+ codes.append(STYLES[name])
+ return "".join(f"\x1b[{code}m" for code in codes)
diff --git a/src/advancedprinter/terminal.py b/src/advancedprinter/terminal.py
new file mode 100644
index 0000000..78e5322
--- /dev/null
+++ b/src/advancedprinter/terminal.py
@@ -0,0 +1,83 @@
+"""Destination-aware colour policy and ANSI helpers."""
+
+from __future__ import annotations
+
+import os
+import re
+from typing import TextIO
+
+from .styles import validate_policy
+
+# CSI, OSC (including hyperlinks), and simple two-byte escape sequences.
+ANSI_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])")
+SGR_RE = re.compile(r"\x1b\[([0-9;]*)m")
+
+
+def strip_ansi(text: str) -> str:
+ """Remove terminal escape sequences, leaving the printable text."""
+ return ANSI_RE.sub("", text)
+
+
+def _windows_vt(stream: TextIO) -> bool:
+ """Enable native VT processing on Windows console streams when possible."""
+ try:
+ import ctypes
+ import msvcrt
+
+ handle = msvcrt.get_osfhandle(stream.fileno()) # type: ignore[attr-defined]
+ mode = ctypes.c_ulong()
+ kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
+ handle_pointer = ctypes.c_void_p(handle)
+ return bool(
+ kernel.GetConsoleMode(handle_pointer, ctypes.byref(mode))
+ and kernel.SetConsoleMode(handle_pointer, mode.value | 4)
+ )
+ except (ImportError, AttributeError, OSError, ValueError):
+ return False
+
+
+def capabilities(stream: TextIO, mode: str, system: str) -> tuple[bool, bool, str]:
+ """Return (styling enabled, colours enabled, resolved colour depth)."""
+ validate_policy(mode, system)
+ if mode == "never":
+ return False, False, "standard"
+ if mode == "always":
+ return True, True, "truecolor" if system == "auto" else system
+ try:
+ tty = stream.isatty()
+ except (AttributeError, OSError, ValueError):
+ tty = False
+ enabled = bool(tty) and os.environ.get("TERM") != "dumb"
+ if enabled and os.name == "nt":
+ enabled = bool(
+ os.environ.get("WT_SESSION") or os.environ.get("ANSICON") or _windows_vt(stream)
+ )
+ colors = enabled and not bool(os.environ.get("NO_COLOR"))
+ if system == "auto":
+ if os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit"):
+ system = "truecolor"
+ elif "256color" in os.environ.get("TERM", ""):
+ system = "256"
+ else:
+ system = "standard"
+ return enabled, colors, system
+
+
+def strip_colors(text: str) -> str:
+ """Remove SGR colours but retain attributes such as bold (NO_COLOR)."""
+
+ def replace(match: re.Match[str]) -> str:
+ parameters = (match[1] or "0").split(";")
+ kept: list[str] = []
+ i = 0
+ while i < len(parameters):
+ number = int(parameters[i] or "0")
+ if number in (38, 48, 58) and i + 1 < len(parameters):
+ i += 5 if parameters[i + 1] == "2" else 3
+ continue
+ if not (30 <= number <= 49 or 90 <= number <= 107 or number == 59):
+ kept.append(parameters[i])
+ i += 1
+ return "\x1b[" + ";".join(kept) + "m" if kept else ""
+
+ return SGR_RE.sub(replace, text)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..4d8b921
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,21 @@
+import io
+
+import pytest
+
+
+class Terminal(io.StringIO):
+ def isatty(self):
+ return True
+
+
+@pytest.fixture(autouse=True)
+def clean_color_environment(monkeypatch):
+ for key in ("NO_COLOR", "TERM", "COLORTERM", "WT_SESSION", "ANSICON"):
+ monkeypatch.delenv(key, raising=False)
+
+
+@pytest.fixture
+def terminal(monkeypatch):
+ monkeypatch.setenv("TERM", "xterm-256color")
+ monkeypatch.setenv("WT_SESSION", "test")
+ return Terminal()
diff --git a/tests/test_frames.py b/tests/test_frames.py
new file mode 100644
index 0000000..6e21655
--- /dev/null
+++ b/tests/test_frames.py
@@ -0,0 +1,138 @@
+import io
+
+import pytest
+
+from advancedprinter import Printer, format_frame, line, strip_ansi, visible_width
+
+
+def plain(text, **options):
+ return format_frame(text, color_mode="never", **options)
+
+
+def test_empty_frame_and_no_trailing_newline():
+ assert plain("") == "+---+\n| |\n+---+"
+
+
+def test_preserves_indentation_trailing_spaces_and_blank_lines():
+ rendered = plain(" code \n\n")
+ rows = rendered.splitlines()
+ assert rows[1] == "| code |"
+ assert len(rows) == 5
+
+
+def test_dedent_is_opt_in():
+ assert plain(" hello\n world", dedent=True) == plain("hello\nworld")
+
+
+@pytest.mark.parametrize("text", ["ไธญๆ", "cafe\u0301", "๐จ\u200d๐ฉ\u200d๐ง", "๐ง๐ฌ", "a\tb"])
+def test_unicode_and_tabs_are_aligned(text):
+ widths = [visible_width(row) for row in plain(text + "\nx").splitlines()]
+ assert len(set(widths)) == 1
+
+
+def test_ansi_sequences_are_not_counted_in_width():
+ rendered = format_frame(line("hi", c="red"), border="double")
+ assert len({visible_width(row) for row in rendered.splitlines()}) == 1
+ assert "โ" * 4 in strip_ansi(rendered)
+
+
+@pytest.mark.parametrize(
+ "border,corner",
+ [("basic", "+"), ("basic2", "="), ("single", "โ"), ("double", "โ"), ("rounded", "โญ")],
+)
+def test_borders(border, corner):
+ assert plain("x", border=border).startswith(corner)
+
+
+def test_ascii_override():
+ assert plain("x", border="double", ascii=True) == plain("x")
+
+
+def test_legacy_positional_arguments():
+ assert strip_ansi(format_frame("x", "double", "red", "blue", "white", "black", "b")) == plain(
+ "x", border="double"
+ )
+
+
+@pytest.mark.parametrize(
+ "align,body", [("left", "| x |"), ("right", "| x |"), ("center", "| x |")]
+)
+def test_alignment(align, body):
+ assert plain("x", width=8, align=align).splitlines()[1] == body
+
+
+def test_width_max_width_and_padding():
+ assert all(visible_width(row) == 10 for row in plain("abcdefghi", width=10).splitlines())
+ assert all(visible_width(row) <= 8 for row in plain("abcdefghijkl", max_width=8).splitlines())
+ assert plain("x", padding=0) == "+-+\n|x|\n+-+"
+
+
+def test_wrapping_preserves_text_and_emoji_clusters():
+ text = "๐จ\u200d๐ฉ\u200d๐งabcd"
+ rendered = plain(text, width=7)
+ assert rendered.count("๐จ\u200d๐ฉ\u200d๐ง") == 1
+ assert all(visible_width(row) == 7 for row in rendered.splitlines())
+ assert "".join(row[2:-2].rstrip() for row in rendered.splitlines()[1:-1]) == text
+
+
+def test_wrapped_ansi_has_consistent_width():
+ rendered = format_frame(line("abcdefghij", c="red"), width=8)
+ assert all(visible_width(row) == 8 for row in rendered.splitlines())
+ assert "\x1b[31m" in rendered.splitlines()[2]
+
+
+def test_title():
+ assert plain("hello", title="Title", width=15).splitlines()[0] == "+ Title ------+"
+
+
+def test_printed_frame_and_no_color(terminal, monkeypatch):
+ monkeypatch.setenv("NO_COLOR", "1")
+ Printer(file=terminal).frame("x", c="red", s="b", border="double")
+ assert "\x1b[31m" not in terminal.getvalue()
+ assert "\x1b[1m" in terminal.getvalue()
+
+
+def test_frame_accepts_output_controls():
+ out = io.StringIO()
+ Printer().frame("x", file=out, end="!")
+ assert out.getvalue() == plain("x") + "!"
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ {"border": "bad"},
+ {"padding": -1},
+ {"padding": True},
+ {"width": 4},
+ {"width": True},
+ {"max_width": 1},
+ {"width": 10, "max_width": 8},
+ {"align": "bad"},
+ {"title": "a\nb"},
+ {"title": "long title", "width": 6},
+ {"wrap": False, "width": 5},
+ {"color_mode": "auto"},
+ {"color_system": "auto"},
+ {"c": "red", "color": "blue"},
+ {"f_s": "single", "border": "double"},
+ ],
+)
+def test_invalid_frame_options(options):
+ with pytest.raises((TypeError, ValueError)):
+ format_frame("abcdef", **options)
+
+
+def test_nonprintable_control_characters_rejected():
+ with pytest.raises(ValueError, match="control character"):
+ plain("a\bb")
+
+
+def test_wide_character_cannot_fit():
+ with pytest.raises(ValueError, match="character is wider"):
+ plain("ไธญ", width=5)
+
+
+def test_empty_border_name_is_invalid():
+ with pytest.raises(ValueError, match="Unknown border"):
+ plain("x", border="")
diff --git a/tests/test_logging.py b/tests/test_logging.py
new file mode 100644
index 0000000..1fee09b
--- /dev/null
+++ b/tests/test_logging.py
@@ -0,0 +1,48 @@
+import io
+import logging
+
+import pytest
+
+from advancedprinter import ColorFormatter
+
+
+@pytest.mark.parametrize(
+ "level", [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]
+)
+def test_color_formatter_keeps_records_and_file_handler_plain(level, terminal):
+ record = logging.LogRecord("demo", level, "test.py", 1, "hello %s", ("world",), None)
+ before = record.msg, record.args
+ formatter = ColorFormatter("%(levelname)s: %(message)s", stream=terminal)
+ styled = formatter.format(record)
+ plain = logging.Formatter("%(levelname)s: %(message)s").format(record)
+ assert "\x1b[" in styled
+ assert "\x1b" not in plain
+ assert "hello world" in plain
+ assert (record.msg, record.args) == before
+
+
+def test_redirected_logging_is_plain():
+ stream = io.StringIO()
+ handler = logging.StreamHandler(stream)
+ handler.setFormatter(ColorFormatter("%(message)s", stream=stream))
+ handler.handle(logging.makeLogRecord({"msg": "test", "levelno": logging.ERROR}))
+ assert stream.getvalue() == "test\n"
+
+
+def test_logging_exception_traceback_is_preserved():
+ try:
+ raise ValueError("failure")
+ except ValueError:
+ import sys
+
+ record = logging.LogRecord("demo", logging.ERROR, "test.py", 1, "oops", (), sys.exc_info())
+ result = ColorFormatter(color_mode="never").format(record)
+ assert "ValueError: failure" in result
+ assert "\x1b" not in result
+
+
+def test_logging_does_not_add_or_change_record_attributes():
+ record = logging.makeLogRecord({"msg": "hello %s", "args": ("world",), "levelno": logging.INFO})
+ before = dict(record.__dict__)
+ ColorFormatter(color_mode="always").format(record)
+ assert record.__dict__ == before
diff --git a/tests/test_printing.py b/tests/test_printing.py
new file mode 100644
index 0000000..aea205b
--- /dev/null
+++ b/tests/test_printing.py
@@ -0,0 +1,181 @@
+import builtins
+import io
+
+import pytest
+
+import advancedprinter as ap
+from advancedprinter import AdvancedPrinter, MessageStyle, Printer, format_text
+
+
+@pytest.mark.parametrize("values", [(), ("hello",), ("a", "b"), (1, None, {"a": 2}), ("", "")])
+@pytest.mark.parametrize("sep", [" ", "|", "", None])
+@pytest.mark.parametrize("end", ["\n", "", "!", None])
+def test_plain_print_matches_builtin(values, sep, end):
+ expected, actual = io.StringIO(), io.StringIO()
+ builtins.print(*values, sep=sep, end=end, file=expected)
+ Printer().print(*values, sep=sep, end=end, file=actual)
+ assert actual.getvalue() == expected.getvalue()
+
+
+def test_flush_and_current_stdout(monkeypatch):
+ class Flushing(io.StringIO):
+ calls = 0
+
+ def flush(self):
+ self.calls += 1
+
+ console = Printer()
+ output = Flushing()
+ monkeypatch.setattr("sys.stdout", output)
+ console.print("ok", flush=True)
+ assert output.getvalue() == "ok\n"
+ assert output.calls == 1
+
+
+def test_destination_policy_uses_overridden_file(terminal):
+ console = Printer(file=terminal)
+ plain = io.StringIO()
+ console.print("red", c="red", file=plain)
+ console.print("red", c="red")
+ assert plain.getvalue() == "red\n"
+ assert "\x1b[31m" in terminal.getvalue()
+
+
+def test_never_strips_nested_ansi(terminal):
+ Printer(color_mode="never", file=terminal).print(ap.line("red", c="red"))
+ assert terminal.getvalue() == "red\n"
+
+
+def test_always_overrides_no_color_and_nonterminal(monkeypatch):
+ monkeypatch.setenv("NO_COLOR", "1")
+ out = io.StringIO()
+ Printer(color_mode="always", file=out).print("red", c="red")
+ assert out.getvalue() == "\x1b[31mred\x1b[0m\n"
+
+
+def test_no_color_preserves_styles_and_removes_nested_colors(terminal, monkeypatch):
+ monkeypatch.setenv("NO_COLOR", "1")
+ Printer(file=terminal).print(ap.line("red", c="red"), c="blue", s="bold")
+ assert "\x1b[1m" in terminal.getvalue()
+ assert "\x1b[31m" not in terminal.getvalue()
+ assert "\x1b[34m" not in terminal.getvalue()
+
+
+def test_empty_no_color_does_not_disable_color(terminal, monkeypatch):
+ monkeypatch.setenv("NO_COLOR", "")
+ Printer(file=terminal).print("red", c="red")
+ assert "\x1b[31m" in terminal.getvalue()
+
+
+def test_dumb_terminal(terminal, monkeypatch):
+ monkeypatch.setenv("TERM", "dumb")
+ Printer(file=terminal).print("red", c="red", s="bold")
+ assert terminal.getvalue() == "red\n"
+
+
+@pytest.mark.parametrize("prompt", ["", " prompt: ", 123, None])
+def test_input_preserves_prompt_and_returns_answer(prompt, monkeypatch):
+ output = io.StringIO()
+ monkeypatch.setattr("builtins.input", lambda: "answer")
+ assert Printer(file=output).input(prompt) == "answer"
+ assert output.getvalue() == str(prompt)
+
+
+def test_input_without_prompt(monkeypatch):
+ monkeypatch.setattr("builtins.input", lambda: "answer")
+ assert Printer(file=io.StringIO()).input() == "answer"
+
+
+@pytest.mark.parametrize("error", [EOFError, KeyboardInterrupt])
+def test_input_exceptions_propagate(error, monkeypatch):
+ def read():
+ raise error
+
+ monkeypatch.setattr("builtins.input", read)
+ with pytest.raises(error):
+ Printer(file=io.StringIO()).input()
+
+
+def test_input_too_many_arguments():
+ with pytest.raises(TypeError):
+ Printer().input("one", "two")
+
+
+@pytest.mark.parametrize("interface", [ap, AdvancedPrinter, AdvancedPrinter()])
+def test_legacy_interfaces(interface, capsys):
+ interface.print("a", "b", sep="|")
+ assert capsys.readouterr().out == "a|b\n"
+ assert interface.line("red", c="red") == format_text("red", color="red")
+ assert interface.C.RED == "\x1b[31m" if hasattr(interface, "C") else True
+
+
+def test_instances_and_error_stream_are_independent():
+ one, two, errors = io.StringIO(), io.StringIO(), io.StringIO()
+ a = Printer(file=one, error_file=errors, theme={"success": MessageStyle("blue", prefix="DONE")})
+ b = Printer(file=two)
+ a.success("a")
+ b.success("b")
+ a.error("failed")
+ assert one.getvalue() == "DONE a\n"
+ assert two.getvalue() == "[SUCCESS] b\n"
+ assert errors.getvalue() == "[ERROR] failed\n"
+
+
+def test_error_defaults_to_current_stderr(capsys):
+ Printer().error("failed")
+ assert capsys.readouterr().err == "[ERROR] failed\n"
+
+
+def test_semantic_helpers_separators_and_prefixes():
+ out = io.StringIO()
+ printer = Printer(file=out)
+ printer.info("a", "b", sep="|", end="!")
+ printer.warning()
+ assert out.getvalue() == "[INFO] a|b![WARNING]\n"
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ {"theme": {"bad": MessageStyle()}},
+ {"theme": {"success": "green"}},
+ {"color_mode": "bad"},
+ {"color_system": "bad"},
+ ],
+)
+def test_invalid_configuration(options):
+ with pytest.raises((ValueError, TypeError)):
+ Printer(**options)
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ {"c": "red", "color": "blue"},
+ {"sep": 2},
+ {"end": 2},
+ {"unknown": True},
+ {"c": "gren"},
+ {"s": "bodl"},
+ ],
+)
+def test_print_rejects_invalid_options(options):
+ with pytest.raises((TypeError, ValueError)):
+ Printer(file=io.StringIO()).print("x", **options)
+
+
+@pytest.mark.parametrize("method", ["print", "frame"])
+def test_ansi_in_end_is_plain_when_redirected(method):
+ out = io.StringIO()
+ getattr(Printer(file=out), method)("hello", end=ap.line("!", c="red"))
+ assert "\x1b" not in out.getvalue()
+ assert out.getvalue().endswith("!")
+
+
+def test_no_color_also_applies_to_end(terminal, monkeypatch):
+ monkeypatch.setenv("NO_COLOR", "1")
+ console = Printer(file=terminal)
+ console.print("hi", end=ap.line("!", c="red", s="b"))
+ console.frame("hi", end=ap.line("!", c="red", s="b"))
+ assert "\x1b[31m" not in terminal.getvalue()
+ assert "\x1b[1m" in terminal.getvalue()
diff --git a/tests/test_project.py b/tests/test_project.py
new file mode 100644
index 0000000..ce1607a
--- /dev/null
+++ b/tests/test_project.py
@@ -0,0 +1,56 @@
+import importlib.metadata
+import importlib.resources
+import re
+import runpy
+from pathlib import Path
+
+import pytest
+
+import advancedprinter
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_installed_metadata_and_typing_marker():
+ assert advancedprinter.__version__ == importlib.metadata.version("AdvancedPrinter")
+ assert importlib.resources.files("advancedprinter").joinpath("py.typed").is_file()
+ assert importlib.metadata.metadata("AdvancedPrinter")["Requires-Python"] == ">=3.10"
+
+
+def test_module_demo(capsys):
+ runpy.run_module("advancedprinter", run_name="__main__")
+ output = capsys.readouterr().out
+ assert "[SUCCESS] Ready to print" in output
+ assert "Text Colors:" in output
+ assert "\x1b" not in output
+
+
+@pytest.mark.parametrize("example", ["repository_sync.py", "styled_logging.py"])
+def test_runnable_examples(example, capsys):
+ runpy.run_path(str(ROOT / "examples" / example), run_name="__main__")
+ captured = capsys.readouterr()
+ assert captured.out or captured.err
+
+
+def test_readme_python_examples_execute(tmp_path, monkeypatch, capsys):
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr("builtins.input", lambda: "yes")
+ namespace = {}
+ for snippet in re.findall(r"```python\n(.*?)```", (ROOT / "README.md").read_text(), re.S):
+ exec(compile(snippet, "README.md", "exec"), namespace)
+ assert namespace["answer"] == "yes"
+ assert (tmp_path / "sync.log").read_text() == "This file gets plain text\n"
+ capsys.readouterr()
+
+
+@pytest.mark.parametrize("tag", ["2.0.0", "v2.0.0"])
+def test_release_tag_accepts_matching_version(tag):
+ check = runpy.run_path(str(ROOT / "scripts" / "check_release.py"))["check_tag"]
+ check(tag, "2.0.0")
+
+
+@pytest.mark.parametrize("tag", ["", "v1.2.0", "v2.0", "v2.0.0rc1", "release-2.0.0"])
+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")
diff --git a/tests/test_styles.py b/tests/test_styles.py
new file mode 100644
index 0000000..89ba4b1
--- /dev/null
+++ b/tests/test_styles.py
@@ -0,0 +1,138 @@
+import pytest
+
+from advancedprinter import format_text, line, strip_ansi
+from advancedprinter.styles import BACKGROUNDS, FOREGROUNDS, color_code, style_code
+from advancedprinter.terminal import strip_colors
+
+
+def test_unstyled_text_and_empty_text_have_no_escape():
+ assert format_text("hello") == "hello"
+ assert format_text() == ""
+ assert format_text(1, 2, sep="|", end="!") == "1|2!"
+
+
+@pytest.mark.parametrize("name", list(FOREGROUNDS))
+def test_legacy_foregrounds(name):
+ assert color_code(name.upper()) == FOREGROUNDS[name]
+
+
+@pytest.mark.parametrize("name", list(BACKGROUNDS))
+def test_legacy_backgrounds(name):
+ assert color_code(name.upper(), background=True) == BACKGROUNDS[name]
+
+
+@pytest.mark.parametrize(
+ "short,long",
+ [
+ ("b", "bold"),
+ ("it", "italic"),
+ ("u", "underline"),
+ ("inv", "inverted"),
+ ("str", "strike-through"),
+ ],
+)
+def test_style_aliases(short, long):
+ assert style_code(short) == style_code(long)
+
+
+def test_styles_combine_and_deduplicate():
+ assert style_code("b-it-u-b") == "\x1b[1m\x1b[3m\x1b[4m"
+ assert style_code(["bold", "italic"]) == style_code("bold italic")
+ assert style_code("bold,strike-through") == style_code("b-str")
+
+
+def test_custom_colors_and_default():
+ assert color_code("#ff0000") == color_code((255, 0, 0)) == "\x1b[38;2;255;0;0m"
+ assert color_code(208, background=True) == "\x1b[48;5;208m"
+ assert color_code("default") == "\x1b[39m"
+ assert color_code("default", background=True) == "\x1b[49m"
+
+
+def test_depth_fallback():
+ assert color_code("#ff0000", system="256") == "\x1b[38;5;9m"
+ assert color_code((255, 0, 0), system="standard") == "\x1b[91m"
+ assert color_code(196, background=True, system="standard") == "\x1b[101m"
+ assert color_code("orange", system="standard").startswith("\x1b[")
+
+
+@pytest.mark.parametrize(
+ "value",
+ ["gren", "#fff", "#zzzzzz", -1, 256, True, (0, 1), (256, 0, 0), (True, 0, 0), [0, 0, 0], 1.2],
+)
+def test_invalid_colors(value):
+ with pytest.raises(ValueError):
+ color_code(value)
+
+
+@pytest.mark.parametrize("value", ["bodl", "bold-", ["unknown"], 5])
+def test_invalid_styles(value):
+ with pytest.raises((ValueError, TypeError)):
+ style_code(value)
+
+
+def test_nested_reset_restores_outer_style():
+ result = line("before " + line("inner", c="blue") + " after", c="red", s="b")
+ assert "\x1b[0m\x1b[1m\x1b[31m after" in result
+ assert strip_ansi(result) == "before inner after"
+
+
+def test_nested_rgb_zero_channels_are_not_resets():
+ result = line(line("inner", c=(255, 0, 0)), c="blue")
+ assert "\x1b[38;2;255;0;0minner" in result
+
+
+def test_combined_reset_keeps_following_inner_color():
+ result = line("before\x1b[0;34minner", c="red")
+ assert "\x1b[0m\x1b[31m\x1b[34minner" in result
+
+
+@pytest.mark.parametrize(
+ "reset,outer",
+ [
+ (39, {"c": "red"}),
+ (49, {"b": "blue"}),
+ (22, {"s": "b"}),
+ (23, {"s": "it"}),
+ (24, {"s": "u"}),
+ (27, {"s": "inv"}),
+ (29, {"s": "str"}),
+ ],
+)
+def test_selective_resets_restore_outer(reset, outer):
+ result = line(f"inner\x1b[{reset}m after", **outer)
+ prefix = line("", **outer).removesuffix("\x1b[0m")
+ assert f"\x1b[{reset}m{prefix} after" in result
+
+
+def test_plain_mode_strips_inline_styles():
+ assert format_text(line("hi", c="red"), color_mode="never") == "hi"
+
+
+def test_no_color_removes_only_color_parameters():
+ assert strip_colors("\x1b[1;38;2;255;0;0mred\x1b[0m") == "\x1b[1mred\x1b[0m"
+ assert strip_colors("\x1b[48;5;208mbg\x1b[39m") == "bg"
+
+
+def test_strip_ansi_handles_hyperlinks():
+ assert strip_ansi("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\") == "link"
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ {"color_mode": "auto"},
+ {"color_system": "auto"},
+ {"color_mode": "bad"},
+ {"color_system": "bad"},
+ {"end": None},
+ {"b": "red", "background": "blue"},
+ {"s": "b", "style": "u"},
+ ],
+)
+def test_pure_format_validation(options):
+ with pytest.raises((ValueError, TypeError)):
+ format_text("x", **options)
+
+
+def test_plain_formatting_also_strips_ansi_from_end():
+ assert format_text("x", end=line("!", c="red"), color_mode="never") == "x!"
diff --git a/tests/test_terminal.py b/tests/test_terminal.py
new file mode 100644
index 0000000..7144f8b
--- /dev/null
+++ b/tests/test_terminal.py
@@ -0,0 +1,95 @@
+import io
+import sys
+from types import SimpleNamespace
+
+import pytest
+
+import advancedprinter.terminal as terminal_module
+from advancedprinter import Printer, strip_ansi
+from advancedprinter.terminal import _windows_vt, capabilities
+
+
+@pytest.mark.parametrize(
+ "term,colorterm,expected",
+ [
+ ("xterm", "", "standard"),
+ ("xterm-256color", "", "256"),
+ ("xterm", "truecolor", "truecolor"),
+ ("xterm", "24bit", "truecolor"),
+ ],
+)
+def test_automatic_depth(terminal, monkeypatch, term, colorterm, expected):
+ monkeypatch.setenv("TERM", term)
+ monkeypatch.setenv("COLORTERM", colorterm)
+ assert capabilities(terminal, "auto", "auto") == (True, True, expected)
+
+
+def test_stream_without_isatty():
+ assert capabilities(object(), "auto", "auto")[:2] == (False, False)
+
+
+def test_broken_isatty():
+ class Broken:
+ def isatty(self):
+ raise OSError("closed")
+
+ assert capabilities(Broken(), "auto", "auto")[:2] == (False, False)
+
+
+@pytest.mark.parametrize("supported", [False, True])
+def test_windows_console_detection(terminal, monkeypatch, supported):
+ monkeypatch.delenv("WT_SESSION", raising=False)
+ monkeypatch.setattr(terminal_module, "os", SimpleNamespace(name="nt", environ={}))
+ monkeypatch.setattr(terminal_module, "_windows_vt", lambda stream: supported)
+ assert capabilities(terminal, "auto", "standard")[:2] == (supported, supported)
+
+
+def test_windows_vt_enables_processing_without_losing_flags(monkeypatch):
+ called = []
+ mode = SimpleNamespace(value=3)
+ kernel = SimpleNamespace(
+ GetConsoleMode=lambda handle, pointer: 1,
+ SetConsoleMode=lambda handle, value: called.append((handle, value)) or 1,
+ )
+ monkeypatch.setitem(sys.modules, "msvcrt", SimpleNamespace(get_osfhandle=lambda fd: 123))
+ monkeypatch.setitem(
+ sys.modules,
+ "ctypes",
+ SimpleNamespace(
+ c_ulong=lambda: mode,
+ c_void_p=lambda value: value,
+ byref=lambda value: value,
+ windll=SimpleNamespace(kernel32=kernel),
+ ),
+ )
+ stream = SimpleNamespace(fileno=lambda: 1)
+ assert _windows_vt(stream)
+ assert called == [(123, 7)]
+
+
+def test_windows_vt_failure_is_plain(monkeypatch):
+ monkeypatch.setitem(sys.modules, "msvcrt", SimpleNamespace())
+ assert not _windows_vt(io.StringIO())
+
+
+def test_ascii_border_fallback_and_no_color(monkeypatch):
+ class ASCIIStream(io.StringIO):
+ @property
+ def encoding(self):
+ return "ascii"
+
+ def isatty(self):
+ return True
+
+ monkeypatch.setenv("NO_COLOR", "1")
+ monkeypatch.setenv("WT_SESSION", "test")
+ output = ASCIIStream()
+ Printer(file=output).frame("hello", border="double", c="red", s="b")
+ assert strip_ansi(output.getvalue()).startswith("+")
+ assert "\x1b[31m" not in output.getvalue()
+
+
+def test_per_call_policy_and_depth_override():
+ output = io.StringIO()
+ Printer(file=output).print("x", c="#ff0000", color_mode="always", color_system="256")
+ assert "\x1b[38;5;9m" in output.getvalue()