From d6628d0cf62ec44de1bb330521704a0964e87a69 Mon Sep 17 00:00:00 2001 From: Kaloian Kozlev Date: Sat, 24 Oct 2026 15:34:10 +0300 Subject: [PATCH] Prepare AdvancedPrinter 2.0 with reliable rendering, tests, and PyPI publishing --- .github/dependabot.yml | 13 + .github/workflows/ci.yml | 77 +++++ .github/workflows/publish.yml | 47 +++ .gitignore | 14 + .idea/.gitignore | 8 - .idea/AdvancedPrinter.iml | 10 - .idea/inspectionProfiles/Project_Default.xml | 36 --- .../inspectionProfiles/profiles_settings.xml | 6 - .idea/misc.xml | 7 - .idea/modules.xml | 8 - .idea/vcs.xml | 6 - CHANGELOG.md | 38 +++ CONTRIBUTING.md | 47 +++ MANIFEST.in | 5 + README.md | 292 +++++++++++------- docs/api.md | 170 ++++++++++ docs/migration.md | 58 ++++ docs/releasing.md | 76 +++++ examples/repository_sync.py | 23 ++ examples/styled_logging.py | 24 ++ pyproject.toml | 64 +++- scripts/check_release.py | 28 ++ setup.cfg | 23 -- src/advancedprinter/__init__.py | 41 ++- src/advancedprinter/__main__.py | 20 ++ src/advancedprinter/_codes.py | 85 +++++ src/advancedprinter/logging.py | 52 ++++ src/advancedprinter/main.py | 271 ++-------------- src/advancedprinter/printer.py | 275 +++++++++++++++++ src/advancedprinter/py.typed | 0 src/advancedprinter/render.py | 232 ++++++++++++++ src/advancedprinter/styles.py | 155 ++++++++++ src/advancedprinter/terminal.py | 83 +++++ tests/conftest.py | 21 ++ tests/test_frames.py | 138 +++++++++ tests/test_logging.py | 48 +++ tests/test_printing.py | 181 +++++++++++ tests/test_project.py | 56 ++++ tests/test_styles.py | 138 +++++++++ tests/test_terminal.py | 95 ++++++ 40 files changed, 2497 insertions(+), 474 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore delete mode 100644 .idea/.gitignore delete mode 100644 .idea/AdvancedPrinter.iml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 MANIFEST.in create mode 100644 docs/api.md create mode 100644 docs/migration.md create mode 100644 docs/releasing.md create mode 100644 examples/repository_sync.py create mode 100644 examples/styled_logging.py create mode 100644 scripts/check_release.py delete mode 100644 setup.cfg create mode 100644 src/advancedprinter/__main__.py create mode 100644 src/advancedprinter/_codes.py create mode 100644 src/advancedprinter/logging.py create mode 100644 src/advancedprinter/printer.py create mode 100644 src/advancedprinter/py.typed create mode 100644 src/advancedprinter/render.py create mode 100644 src/advancedprinter/styles.py create mode 100644 src/advancedprinter/terminal.py create mode 100644 tests/conftest.py create mode 100644 tests/test_frames.py create mode 100644 tests/test_logging.py create mode 100644 tests/test_printing.py create mode 100644 tests/test_project.py create mode 100644 tests/test_styles.py create mode 100644 tests/test_terminal.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..eb46869 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + - package-ecosystem: pip + directory: / + schedule: + interval: monthly + groups: + development: + dependency-type: development diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..87b07cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + push: + pull_request: + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ['3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ matrix.python }} + - name: Install package and tests + run: python -m pip install . pytest pytest-cov + - name: Test the minimum runtime dependency on Python 3.10 + if: matrix.python == '3.10' + run: python -m pip install 'wcwidth==0.9.0' + - name: Run tests against installed package + run: python -m pytest --cov=advancedprinter --cov-fail-under=90 + - name: Run demonstration + run: python -m advancedprinter + + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + - run: python -m pip install '.[dev]' + - run: python -m ruff check . + - run: python -m ruff format --check . + - run: python -m mypy src/advancedprinter + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + - run: python -m pip install build twine + - name: Build source distribution and wheel + run: python -m build + - name: Check distribution metadata and README + run: python -m twine check --strict dist/* + - name: Install wheel and verify public API + shell: bash + run: | + python -m pip install dist/*.whl + python -c "from advancedprinter import Printer, format_frame; assert format_frame('ok', color_mode='never'); Printer().success('Installed wheel works')" + - name: Check source distribution can build a wheel + shell: bash + run: python -m pip wheel --no-deps dist/*.tar.gz --wheel-dir /tmp/sdist-wheel + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: distributions + path: dist/ + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..576942c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: pypi-${{ github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + validate: + uses: ./.github/workflows/ci.yml + + check-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + - run: python -m pip install . + - name: Require the release tag to match the package version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: python scripts/check_release.py + + publish: + needs: [validate, check-version] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/AdvancedPrinter/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: distributions + path: dist/ + - name: Publish the validated distributions with Trusted Publishing + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d881c33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.idea/ +.vscode/ +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage* +htmlcov/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/AdvancedPrinter.iml b/.idea/AdvancedPrinter.iml deleted file mode 100644 index 6cb8b9a..0000000 --- a/.idea/AdvancedPrinter.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 4a6bc2c..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - \ 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="***") - ``` - ![Example1](https://i.imgur.com/sLSRK2N.png) - -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='***') - ``` - ![Example2](https://i.imgur.com/E7lAGM6.png) - -## 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='***') - ``` - ![Example3](https://i.imgur.com/ONPWHJj.png) - -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") - ``` - ![Basic](https://i.imgur.com/U7s3Gvc.png) -- basic2 - ```python - frame("This is a frame with basic2 design", f_s='basic2') - ``` - ![Basic2](https://i.imgur.com/rIUXksJ.png) -- single - ```python - frame("This is a frame with single design", f_s='single') - ``` - ![Single](https://i.imgur.com/bleI3fu.png) -- double - ```python - frame("This is a frame with double design", f_s='double') - ``` - ![Double](https://i.imgur.com/eJapuu6.png) -- 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') - ``` - ![Color](https://i.imgur.com/pXqiIQ1.png) -- 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') - ``` - ![Multicolor](https://i.imgur.com/eBtGdDc.png) - -3. Use the `input` method: - ```python - from advancedprinter import input - - something = input('Please enter something: ', c='red', s='b-u') - print(something) - ``` - ![Input](https://i.imgur.com/67mfnOI.png) - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Support Me -
-Buy Me A Coffee -
\ No newline at end of file +[![CI](https://github.com/kbkozlev/AdvancedPrinter/actions/workflows/ci.yml/badge.svg)](https://github.com/kbkozlev/AdvancedPrinter/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/AdvancedPrinter)](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()