diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index c84ae35..0000000 --- a/.coveragerc +++ /dev/null @@ -1,18 +0,0 @@ -[run] -omit = - */python?.?/* - */lib-python/?.?/*.py - */lib_pypy/*.py - */site-packages/nose/* - */unittest2/* - */pyshared/* - */dist-packages/* - */tests/* - */venv/* - demo.py - setup.py - -[report] -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover \ No newline at end of file diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index dd77125..0000000 --- a/.coveralls.yml +++ /dev/null @@ -1,2 +0,0 @@ -# .coveralls.yml -repo_token: GzsXpWT6B1llGwECNpJAyzcpXB6KK89Sq \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..46ca962 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Publish to PyPI + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Build sdist and wheel + run: | + pip install build + python -m build + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..783a7e5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with test dependencies + run: pip install -e .[test] + + - name: Run tests + run: pytest -v diff --git a/.gitignore b/.gitignore index b62bbc3..7ca034b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ docs/_build/ target/ .DS_Store -test.html \ No newline at end of file +test.html +.pytest_cache/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index eba2d87..0000000 --- a/.travis.yml +++ /dev/null @@ -1,47 +0,0 @@ -language: python -virtualenv: - system_site_packages: true - -cache: - apt: true - directories: - - /home/travis/virtualenv/python2.7/lib/python2.7/site-packages - -before_install: - - sudo apt-get update - - sudo apt-get install -qq zlib1g-dev build-essential python-numpy - - wget http://prdownloads.sourceforge.net/libpng/libpng-1.5.12.tar.gz - - tar xzf libpng-1.5.12.tar.gz - - rm libpng-1.5.12.tar.gz - - pushd libpng-1.5.12 - - ./configure --prefix=/usr/local - - make - - sudo make install - - sudo ldconfig - - popd - - curl -sL https://github.com/Itseez/opencv/archive/2.4.9.zip > opencv.zip - - unzip opencv.zip - - rm opencv.zip - - mkdir opencv-build - - pushd opencv-build/ - - cmake -DCMAKE_BUILD_TYPE=RELEASE -DBUILD_DOCS=OFF -DBUILD_EXAMPLES=OFF -DBUILD_opencv_java=OFF -DBUILD_JASPER=ON -DWITH_JASPER=ON -DBUILD_ZLIB=ON -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTS=OFF -DBUILD_PERF_TESTS=OFF -DWITH_OPENEXR=OFF -DBUILD_PNG=ON -DWITH_PNG=ON -DWITH_TIFF=ON -DBUILD_TIFF=ON -DWITH_WEBP=OFF -DWITH_JPEG=ON -DBUILD_JPEG=ON ../opencv-2.4.9/ - - sudo make install - - popd - -python: - - "2.7" - # - "3.4" - # - "3.3" - -install: - - pip install -r devrequirements.txt - -# command to run tests -script: nosetests -vs vlogging/tests/corner_cases.py vlogging/tests/basic.py --with-isolation --with-coverage --cover-erase --cover-package=. - -notifications: - slack: unshredit:2nm3MUsOgq9Bz9bqDPAOPn7S - -after_success: - - coveralls - diff --git a/README.md b/README.md index 3cc7a3b..f0a5170 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,80 @@ visual-logging ============== -A simple way to generate beautiful html logs with embedded images for CV purposes. - -visual-logging piggy backs on logging module and allows you to use sick power of logging to debug your computer vision application on a whole new level. - -Now you can add OpenCV images (well technically it's numpy arrays), PIL images and matplotlib graphs to your logs. +[![Tests](https://github.com/dchaplinsky/visual-logging/actions/workflows/tests.yml/badge.svg)](https://github.com/dchaplinsky/visual-logging/actions/workflows/tests.yml) +[![PyPI](https://img.shields.io/pypi/v/visual-logging.svg)](https://pypi.org/project/visual-logging/) -You can read about it in details in a great blog post [visual-logging, my new favorite tool for debugging OpenCV and Python apps](http://www.pyimagesearch.com/2014/12/22/visual-logging-new-favorite-tool-debugging-opencv-python-apps/) written by Adrian Rosebrock +A simple way to generate beautiful html logs with embedded images for CV purposes. +visual-logging piggybacks on the standard `logging` module and allows you to use the sick power of logging to debug your computer vision application on a whole new level: OpenCV images (well, technically numpy arrays), PIL images and matplotlib figures get embedded into your html logs as inline images. -[![Build Status](https://travis-ci.org/dchaplinsky/visual-logging.svg?branch=master)](https://travis-ci.org/dchaplinsky/visual-logging) -[![Coverage Status](https://coveralls.io/repos/dchaplinsky/visual-logging/badge.png)](https://coveralls.io/r/dchaplinsky/visual-logging) +You can read about it in detail in a great blog post [visual-logging, my new favorite tool for debugging OpenCV and Python apps](http://www.pyimagesearch.com/2014/12/22/visual-logging-new-favorite-tool-debugging-opencv-python-apps/) written by Adrian Rosebrock. ## Installation -```pip install visual-logging``` -No extra dependencies +``` +pip install visual-logging +``` + +No extra dependencies — whichever of OpenCV, PIL/Pillow and matplotlib you already have installed are picked up automatically (numpy arrays render through PIL when OpenCV isn't around). Requires Python 3.9+. ## Usage example (see demo.py) + ```python -from logging import FileHandler -from vlogging import VisualRecord +import logging +from vlogging import HTMLFileHandler, VisualRecord -if __name__ == '__main__': - import cv2 - from PIL import Image - import numpy as np - import matplotlib.pyplot as plt +import cv2 # or PIL.Image, or matplotlib — whatever you use - # evenly sampled time at 200ms intervals - t = np.arange(0., 5., 0.2) +logger = logging.getLogger("demo") +logger.setLevel(logging.DEBUG) +logger.addHandler(HTMLFileHandler("test.html", title="My debug log")) - fig1 = plt.figure() - plt.plot(t, t, 'r--', t, t ** 2, 'bs', t, t ** 3, 'g^') +cv_image = cv2.imread("lenna.jpg") - cv_image = cv2.imread('lenna.jpg') - pil_image = Image.open('lenna.jpg') +logger.debug(VisualRecord( + "Hello from OpenCV", cv_image, "This is an OpenCV image", fmt="png")) - import logging - logger = logging.getLogger("demo") - fh = FileHandler('test.html', mode="w") +# Mix images of different origins in one record, downscale the big ones +# to at most 320x240 to keep the log small: +logger.warning(VisualRecord( + "Hello from all", [cv_image, pil_image, mpl_figure], + fmt="png", max_size=(320, 240))) - logger.setLevel(logging.DEBUG) - logger.addHandler(fh) +# Ordinary log calls work too, and land in the same page: +logger.info("Processed frame %d", 42) - logger.debug(VisualRecord( - "Hello from OpenCV", cv_image, "This is openCV image", fmt="png")) +logging.shutdown() # flushes and closes the html file +``` - logger.info(VisualRecord( - "Hello from PIL", pil_image, "This is PIL image", fmt="jpeg")) +Open `test.html` in a browser and enjoy: `HTMLFileHandler` writes a styled, self-contained page — records are color-coded by log level with timestamps and logger names, plain messages are escaped, and exceptions logged with `logger.exception(...)` include their traceback. Records are flushed as they happen, so you can watch the page mid-run. - logger.info(VisualRecord( - "Hello from pylab", fig1, "This is PyLab graph", fmt="png")) +Everything composes the stdlib way: `HTMLFileHandler` is a `logging.FileHandler` that installs a `VisualFormatter` (a `logging.Formatter`) by default — use either piece on its own if you prefer. Passing a `VisualRecord` to a plain `FileHandler` still produces bare html fragments, exactly as in 1.x. - logger.warning( - VisualRecord("Hello from all", [cv_image, pil_image, fig1], - fmt="png")) +In **Jupyter**, a `VisualRecord` displays itself inline — no logging setup needed: +```python +VisualRecord("Detected edges", edges_img, "Canny output") ``` -You can check generated html [here](http://dchaplinsky.github.io/visual-logging/) +### `VisualRecord` arguments + +| Argument | Meaning | +| --- | --- | +| `title` | Header of the log record | +| `imgs` | A single image or a list of images: OpenCV/numpy arrays, PIL images and matplotlib figures in any combination | +| `footnotes` | Optional text rendered as `
` under the images |
+| `fmt` | Image format to embed: `png` (default), `jpeg`, `webp` — anything your imaging library can encode |
+| `max_size` | Optional `(width, height)` tuple: images bigger than that are downscaled proportionally before embedding (matplotlib figures by lowering the render dpi), to keep log files readable and small |
+
+## Changelog
+
+**2.0**
+- Modern packaging (`pyproject.toml`), Python 3.9+ only, `py.typed` type hints
+- New `HTMLFileHandler` + `VisualFormatter`: styled, self-contained html pages with level colors, timestamps, escaped plain-text records and tracebacks
+- New `max_size` option to downscale embedded images (all renderers)
+- `VisualRecord` displays inline in Jupyter notebooks
+- numpy arrays render via PIL when OpenCV is not installed
+- Embedded images use `loading="lazy"`, so huge logs open fast
+- matplotlib support no longer relies on the deprecated `pylab` module
+- Tests run on GitHub Actions against Python 3.9–3.13; releases publish to PyPI from tags via trusted publishing
diff --git a/demo.py b/demo.py
index a4d4198..dcb77eb 100644
--- a/demo.py
+++ b/demo.py
@@ -1,11 +1,15 @@
-from logging import FileHandler
-from vlogging import VisualRecord
+import logging
+from pathlib import Path
 
-if __name__ == '__main__':
+from vlogging import HTMLFileHandler, VisualRecord
+
+if __name__ == "__main__":
     import cv2
-    from PIL import Image
-    import numpy as np
     import matplotlib.pyplot as plt
+    import numpy as np
+    from PIL import Image
+
+    lenna = str(Path(__file__).parent / "vlogging" / "tests" / "lenna.jpg")
 
     # evenly sampled time at 200ms intervals
     t = np.arange(0., 5., 0.2)
@@ -13,25 +17,37 @@
     fig1 = plt.figure()
     plt.plot(t, t, 'r--', t, t ** 2, 'bs', t, t ** 3, 'g^')
 
-    cv_image = cv2.imread('vlogging/tests/lenna.jpg')
-    pil_image = Image.open('vlogging/tests/lenna.jpg')
+    cv_image = cv2.imread(lenna)
+    pil_image = Image.open(lenna)
 
-    import logging
     logger = logging.getLogger("demo")
-    fh = FileHandler('test.html', mode="w")
-
     logger.setLevel(logging.DEBUG)
-    logger.addHandler(fh)
+    logger.addHandler(HTMLFileHandler("test.html", title="visual-logging demo"))
 
     logger.debug(VisualRecord(
-        "Hello from OpenCV", cv_image, "This is openCV image", fmt="png"))
+        "Hello from OpenCV", cv_image, "This is OpenCV image", fmt="png"))
 
     logger.info(VisualRecord(
         "Hello from PIL", pil_image, "This is PIL image", fmt="jpeg"))
 
     logger.info(VisualRecord(
-        "Hello from pylab", fig1, "This is PyLab graph", fmt="png"))
+        "Hello from matplotlib", fig1, "This is matplotlib graph", fmt="png"))
 
     logger.warning(
         VisualRecord("Hello from all", [cv_image, pil_image, fig1],
                      fmt="png"))
+
+    logger.warning(
+        VisualRecord("Hello from all (downscaled to fit 200x200)",
+                     [cv_image, pil_image, fig1],
+                     fmt="png", max_size=(200, 200)))
+
+    logger.info("Plain text records work too, and are escaped:  & so on")
+
+    try:
+        1 / 0
+    except ZeroDivisionError:
+        logger.exception(VisualRecord(
+            "Exceptions come with tracebacks", cv_image))
+
+    logging.shutdown()
diff --git a/devrequirements.txt b/devrequirements.txt
deleted file mode 100644
index 1b90f87..0000000
--- a/devrequirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-matplotlib==1.4.1
-Pillow==2.5.1
-coveralls
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..4288068
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,49 @@
+[build-system]
+requires = ["setuptools>=77"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "visual-logging"
+dynamic = ["version"]
+description = "A simple way to generate beautiful html logs with embedded images for CV purposes."
+readme = "README.md"
+license = "MIT"
+authors = [
+    {name = "Dmitry Chaplinsky", email = "chaplinsky.dmitry@gmail.com"},
+]
+requires-python = ">=3.9"
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "Intended Audience :: Developers",
+    "Intended Audience :: Science/Research",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
+    "Topic :: Scientific/Engineering :: Image Processing",
+    "Topic :: System :: Logging",
+]
+
+[project.urls]
+Homepage = "https://github.com/dchaplinsky/visual-logging"
+Issues = "https://github.com/dchaplinsky/visual-logging/issues"
+
+[project.optional-dependencies]
+test = [
+    "pytest",
+    "numpy",
+    "opencv-python-headless",
+    "Pillow",
+    "matplotlib",
+]
+
+[tool.setuptools]
+packages = ["vlogging"]
+
+[tool.setuptools.package-data]
+vlogging = ["py.typed"]
+
+[tool.setuptools.dynamic]
+version = {attr = "vlogging.__version__"}
diff --git a/setup.py b/setup.py
deleted file mode 100644
index ad821b6..0000000
--- a/setup.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from setuptools import setup
-from vlogging import __version__
-
-setup(
-    name='visual-logging',
-    version=__version__,
-    url='https://github.com/dchaplinsky/visual-logging',
-    license='MIT',
-    author='Dmitry Chaplinsky',
-    author_email='chaplinsky.dmitry@gmail.com',
-    packages=["vlogging"],
-    description="A simple way to generate beautiful html "
-                "logs with embedded images for CV purposes.",
-    platforms='any'
-)
diff --git a/vlogging/__init__.py b/vlogging/__init__.py
index a8ce13c..a332332 100644
--- a/vlogging/__init__.py
+++ b/vlogging/__init__.py
@@ -1,25 +1,50 @@
-# -*- coding: utf-8 -*-
+"""Embed OpenCV / PIL / matplotlib images into standard logging as HTML."""
+
+from __future__ import annotations
 
-from io import BytesIO as StringIO
-from string import Template
 import base64
+import html
+import logging
+from io import BytesIO
+from typing import Any, Optional
+
+__version__ = "2.0"
+
+__all__ = ["VisualRecord", "VisualFormatter", "HTMLFileHandler", "renderers"]
+
+RenderResult = Optional["tuple[bytes, str]"]
 
-__version__ = "1.0"
 renderers = []
 
+
+def _fit_scale(width: float, height: float,
+               max_size: tuple[int, int] | None) -> float:
+    """Shrink-only scale factor that fits width x height into max_size."""
+    if max_size is None:
+        return 1
+
+    return min(max_size[0] / width, max_size[1] / height, 1)
+
+
 try:
     import cv2
     import numpy
 
-    def render_opencv(img, fmt="png"):
+    def render_opencv(img: Any, fmt: str = "png",
+                      max_size: tuple[int, int] | None = None) -> RenderResult:
         if not isinstance(img, numpy.ndarray):
             return None
 
-        retval, buf = cv2.imencode(".%s" % fmt, img)
+        scale = _fit_scale(img.shape[1], img.shape[0], max_size)
+        if scale < 1:
+            img = cv2.resize(img, None, fx=scale, fy=scale,
+                             interpolation=cv2.INTER_AREA)
+
+        retval, buf = cv2.imencode(f".{fmt}", img)
         if not retval:
             return None
 
-        return buf, "image/%s" % fmt
+        return buf, f"image/{fmt}"
 
     renderers.append(render_opencv)
 except ImportError:
@@ -28,44 +53,103 @@ def render_opencv(img, fmt="png"):
 try:
     from PIL import Image
 
-    def render_pil(img, fmt="png"):
-        if not callable(getattr(img, "save", None)):
+    def render_pil(img: Any, fmt: str = "png",
+                   max_size: tuple[int, int] | None = None) -> RenderResult:
+        if not isinstance(img, Image.Image):
             return None
 
-        output = StringIO()
+        scale = _fit_scale(img.width, img.height, max_size)
+        if scale < 1:
+            img = img.resize((max(1, round(img.width * scale)),
+                              max(1, round(img.height * scale))))
+
+        output = BytesIO()
         img.save(output, format=fmt)
-        contents = output.getvalue()
-        output.close()
 
-        return contents, "image/%s" % fmt
+        return output.getvalue(), f"image/{fmt}"
 
     renderers.append(render_pil)
 except ImportError:
     pass
 
 try:
-    import pylab
+    import numpy
+    from PIL import Image
 
-    def render_pylab(img, fmt="png"):
-        if not callable(getattr(img, "savefig", None)):
+    def render_numpy(img: Any, fmt: str = "png",
+                     max_size: tuple[int, int] | None = None) -> RenderResult:
+        # Fallback for numpy arrays when OpenCV is not installed (arrays are
+        # assumed to be RGB; with OpenCV present, render_opencv wins and
+        # treats them as BGR, as this library always did)
+        if not isinstance(img, numpy.ndarray):
             return None
 
-        output = StringIO()
-        img.savefig(output, format=fmt)
-        contents = output.getvalue()
-        output.close()
+        try:
+            return render_pil(Image.fromarray(img), fmt, max_size)
+        except (TypeError, ValueError):
+            return None
 
-        return contents, "image/%s" % fmt
+    renderers.append(render_numpy)
+except ImportError:
+    pass
+
+try:
+    import matplotlib  # noqa: F401
+
+    def render_matplotlib(fig: Any, fmt: str = "png",
+                          max_size: tuple[int, int] | None = None
+                          ) -> RenderResult:
+        if not callable(getattr(fig, "savefig", None)):
+            return None
+
+        kwargs = {}
+        if max_size is not None:
+            # fig may also be the pyplot module: measure its current figure
+            sized = fig.gcf() if callable(getattr(fig, "gcf", None)) else fig
+            if hasattr(sized, "get_size_inches"):
+                dpi = sized.get_dpi()
+                width, height = sized.get_size_inches() * dpi
+                scale = _fit_scale(width, height, max_size)
+                if scale < 1:
+                    kwargs["dpi"] = dpi * scale
 
-    renderers.append(render_pylab)
+        output = BytesIO()
+        fig.savefig(output, format=fmt, **kwargs)
+
+        return output.getvalue(), f"image/{fmt}"
+
+    renderers.append(render_matplotlib)
+
+    # Name of the renderer prior to 2.0
+    render_pylab = render_matplotlib
 except ImportError:
     pass
 
 
-class VisualRecord(object):
-    def __init__(self, title="", imgs=None, footnotes="", fmt="png"):
+class VisualRecord:
+    """A log record that renders itself as HTML with embedded images.
+
+    Pass it to any logger whose handler writes to an html file:
+
+        logger.debug(VisualRecord("title", img, "notes"))
+
+    ``imgs`` accepts a single image or a list of OpenCV/numpy arrays, PIL
+    images and matplotlib figures, in any combination. ``max_size`` is an
+    optional ``(width, height)`` bound: images larger than that are
+    downscaled (preserving aspect ratio) before being embedded, to keep log
+    files small; matplotlib figures are downscaled by lowering the render
+    dpi. ``title`` and ``footnotes`` are embedded as-is, so they may contain
+    markup.
+
+    Also renders itself in Jupyter notebooks via ``_repr_html_``.
+    """
+
+    def __init__(self, title: str = "", imgs: Any = None, footnotes: str = "",
+                 fmt: str = "png",
+                 max_size: tuple[int, int] | None = None) -> None:
         self.title = title
         self.fmt = fmt
+        self.max_size = max_size
 
         if imgs is None:
             imgs = []
@@ -77,13 +161,13 @@ def __init__(self, title="", imgs=None, footnotes="", fmt="png"):
 
         self.footnotes = footnotes
 
-    def render_images(self):
+    def render_images(self) -> str:
         rendered = []
 
         for img in self.imgs:
             for renderer in renderers:
                 # Trying renderers we have one by one
-                res = renderer(img, self.fmt)
+                res = renderer(img, self.fmt, self.max_size)
 
                 if res is None:
                     continue
@@ -92,29 +176,166 @@ def render_images(self):
                     break
 
         return "".join(
-            Template('').substitute({
-                "data": base64.b64encode(data).decode(),
-                "mime": mime
-            }) for data, mime in rendered)
+            '' %
+            (mime, base64.b64encode(data).decode())
+            for data, mime in rendered)
 
-    def render_footnotes(self):
+    def render_footnotes(self) -> str:
         if not self.footnotes:
             return ""
 
-        return Template("
$footnotes
").substitute({ - "footnotes": self.footnotes - }) - - def __str__(self): - t = Template( - """ -

$title

- $imgs - $footnotes -
""") - - return t.substitute({ - "title": self.title, - "imgs": self.render_images(), - "footnotes": self.render_footnotes() - }) + return f"
{self.footnotes}
" + + def html(self) -> str: + """The record as an HTML fragment, without the trailing rule.""" + return ( + f""" +

{self.title}

+ {self.render_images()} + {self.render_footnotes()}""") + + _repr_html_ = html + + def __str__(self) -> str: + return self.html() + "\n
" + + +class VisualFormatter(logging.Formatter): + """Formats log records as styled HTML cards. + + ``VisualRecord`` messages get their images embedded; any other message + is escaped and rendered as text, so ordinary log calls mix in safely. + Meant for handlers that write to an html page, e.g. ``HTMLFileHandler`` + (which installs it by default and embeds ``STYLE``, the CSS for the + cards, into the page it writes). + """ + + STYLE = """.record { + border: 1px solid #8884; border-left: .25rem solid var(--accent, #888); + border-radius: .25rem; padding: .5rem .75rem; margin: .75rem 0; +} +.record.debug { --accent: #9e9e9e; } +.record.info { --accent: #2196f3; } +.record.warning { --accent: #ff9800; } +.record.error { --accent: #f44336; } +.record.critical { --accent: #b71c1c; } +.record > header { + display: flex; gap: .75rem; align-items: baseline; + font-size: .8rem; margin-bottom: .25rem; +} +.record .level { color: var(--accent); font-weight: 700; } +.record .logger { opacity: .7; } +.record time { + margin-left: auto; opacity: .7; font-variant-numeric: tabular-nums; +} +.record h4 { margin: .25rem 0; } +.record img { max-width: 100%; height: auto; margin: .25rem .25rem 0 0; } +.record pre { + overflow-x: auto; background: #8881; + padding: .5rem; border-radius: .25rem; +} +""" + + def format(self, record: logging.LogRecord) -> str: + if isinstance(record.msg, VisualRecord): + body = record.msg.html() + else: + body = f"
{html.escape(record.getMessage())}
" + + for text in ( + record.exc_info and self.formatException(record.exc_info), + record.stack_info and self.formatStack(record.stack_info), + ): + if text: + body += f'\n
{html.escape(text)}
' + + # Bucket by severity rather than name, so custom levels registered + # with logging.addLevelName still get the nearest standard color + if record.levelno >= logging.CRITICAL: + levelclass = "critical" + elif record.levelno >= logging.ERROR: + levelclass = "error" + elif record.levelno >= logging.WARNING: + levelclass = "warning" + elif record.levelno >= logging.INFO: + levelclass = "info" + else: + levelclass = "debug" + + return ( + '
\n' + '
%s' + '%s' + '
%s\n' + '
' % ( + levelclass, + html.escape(record.levelname), + html.escape(record.name), + html.escape(self.formatTime(record, self.datefmt)), + body, + )) + + +_PAGE_HEADER = """ + + + + +%(title)s + + + +
+""" + +_PAGE_FOOTER = "
\n\n\n" + + +class HTMLFileHandler(logging.FileHandler): + """A ``logging.FileHandler`` that writes a self-contained html page. + + Writes the page header (with embedded CSS) when the file is opened, + formats records with ``VisualFormatter`` unless another formatter is + set, and closes the page on ``close()`` / ``logging.shutdown()``. + Records are flushed as they are emitted, so the page can be watched + while the program is still running. + + Unlike ``FileHandler``, the default mode is ``"w"``: a log page is + normally rewritten per run. + """ + + def __init__(self, filename: Any, mode: str = "w", + encoding: str | None = "utf-8", delay: bool = False, + title: str = "Log") -> None: + self.title = title + super().__init__(filename, mode=mode, encoding=encoding, delay=delay) + self.setFormatter(VisualFormatter()) + + def _open(self): + stream = super()._open() + if stream.tell() == 0: + # The formatter is not set yet when the stream opens eagerly + # during __init__; the default formatter's style applies then + style = getattr(getattr(self, "formatter", None), "STYLE", None) + stream.write(_PAGE_HEADER % { + "title": html.escape(self.title), + "style": style or VisualFormatter.STYLE, + }) + + return stream + + def close(self) -> None: + if self.stream and not self.stream.closed: + self.acquire() + try: + self.stream.write(_PAGE_FOOTER) + finally: + self.release() + + super().close() diff --git a/vlogging/tests/dummies/__init__.py b/vlogging/py.typed similarity index 100% rename from vlogging/tests/dummies/__init__.py rename to vlogging/py.typed diff --git a/vlogging/tests/corner_cases.py b/vlogging/tests/corner_cases.py deleted file mode 100644 index 355dc2d..0000000 --- a/vlogging/tests/corner_cases.py +++ /dev/null @@ -1,13 +0,0 @@ -import os -import sys -import unittest - -sys.path.insert( - 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), - "dummies")) -import vlogging - - -class CornerCasesTestCase(unittest.TestCase): - def test_no_renderers(self): - self.assertEquals(vlogging.renderers, []) diff --git a/vlogging/tests/dummies/PIL.py b/vlogging/tests/dummies/PIL.py deleted file mode 100644 index f36e4fe..0000000 --- a/vlogging/tests/dummies/PIL.py +++ /dev/null @@ -1 +0,0 @@ -raise ImportError() diff --git a/vlogging/tests/dummies/cv2.py b/vlogging/tests/dummies/cv2.py deleted file mode 100644 index f36e4fe..0000000 --- a/vlogging/tests/dummies/cv2.py +++ /dev/null @@ -1 +0,0 @@ -raise ImportError() diff --git a/vlogging/tests/dummies/pylab.py b/vlogging/tests/dummies/pylab.py deleted file mode 100644 index f36e4fe..0000000 --- a/vlogging/tests/dummies/pylab.py +++ /dev/null @@ -1 +0,0 @@ -raise ImportError() diff --git a/vlogging/tests/basic.py b/vlogging/tests/test_basic.py similarity index 54% rename from vlogging/tests/basic.py rename to vlogging/tests/test_basic.py index f10d3d1..c9cd3ee 100644 --- a/vlogging/tests/basic.py +++ b/vlogging/tests/test_basic.py @@ -1,12 +1,10 @@ import unittest - -import sys - -if sys.path[0].endswith("dummies"): - sys.path = sys.path[1:] +from pathlib import Path import vlogging +LENNA = str(Path(__file__).parent / "lenna.jpg") + class BasicTestCase(unittest.TestCase): def test_nothing(self): @@ -20,7 +18,7 @@ def test_text_only(self): self.assertTrue("
" in s)
 
     def test_all_renderers(self):
-        self.assertEqual(len(vlogging.renderers), 3)
+        self.assertEqual(len(vlogging.renderers), 4)
 
     def test_invalid_images(self):
         s = str(vlogging.VisualRecord(
@@ -46,7 +44,7 @@ def test_invalid_images(self):
     def test_pil(self):
         from PIL import Image
 
-        pil_image = Image.open('vlogging/tests/lenna.jpg')
+        pil_image = Image.open(LENNA)
         s = str(vlogging.VisualRecord(
             title="title",
             imgs=pil_image,
@@ -74,10 +72,54 @@ def test_pil(self):
         self.assertTrue("image/jpeg" in s)
         self.assertEqual(s.count(""), 0)
+
+    def test_numpy(self):
+        import numpy as np
+
+        arr = np.zeros((8, 8, 3), dtype=np.uint8)
+        data, mime = vlogging.render_numpy(arr)
+        self.assertEqual(mime, "image/png")
+        self.assertTrue(data)
+
+        self.assertIsNone(vlogging.render_numpy("foobar"))
+        self.assertIsNone(
+            vlogging.render_numpy(np.zeros((4, 4), dtype=np.complex128)))
+
+    def test_pil_max_size(self):
+        from io import BytesIO
+
+        from PIL import Image
+
+        pil_image = Image.open(LENNA)
+
+        resized, mime = vlogging.render_pil(pil_image, max_size=(64, 64))
+        self.assertEqual(mime, "image/png")
+        restored = Image.open(BytesIO(resized))
+        self.assertTrue(max(restored.size) <= 64)
+
+        # Image already fits: rendered as is
+        same, _ = vlogging.render_pil(pil_image, max_size=(10000, 10000))
+        restored = Image.open(BytesIO(same))
+        self.assertEqual(restored.size, pil_image.size)
+
     def test_opencv(self):
         import cv2
 
-        cv_image = cv2.imread('vlogging/tests/lenna.jpg')
+        cv_image = cv2.imread(LENNA)
         s = str(vlogging.VisualRecord(
             title="title",
             imgs=cv_image,
@@ -102,7 +144,42 @@ def test_opencv(self):
 
         self.assertEqual(s.count("" in s)
         self.assertEqual(s.count(""))
+        self.assertTrue("