From 7696069b1664d16f0372eb835be077457885b00a Mon Sep 17 00:00:00 2001 From: Dmitry Chaplinsky Date: Sun, 19 Jul 2026 09:27:26 +0000 Subject: [PATCH 1/5] Modernize for Python 3.9+: pyproject packaging, GitHub Actions CI, max_size option - Replace setup.py/devrequirements.txt with pyproject.toml (version 2.0) - Replace dead Travis CI + coveralls setup with a GitHub Actions test matrix for Python 3.9-3.13 - Add max_size option to VisualRecord to downscale embedded images (inspired by PR #10) - Gate the matplotlib renderer on matplotlib itself instead of the deprecated pylab module (render_pylab kept as an alias) - Move tests to tests/, port them from nose to pytest and make the no-renderers corner case run in-process - Refresh README and demo.py, document logging.shutdown() (issue #8) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ha16zNy2fDMdrZBMQGgpBt --- .coveragerc | 18 ---- .coveralls.yml | 2 - .github/workflows/tests.yml | 26 +++++ .gitignore | 3 +- .travis.yml | 47 -------- README.md | 79 +++++++------- demo.py | 29 +++-- devrequirements.txt | 3 - pyproject.toml | 46 ++++++++ setup.py | 15 --- {vlogging/tests => tests}/dummies/PIL.py | 0 {vlogging/tests => tests}/dummies/__init__.py | 0 {vlogging/tests => tests}/dummies/cv2.py | 0 .../pylab.py => tests/dummies/matplotlib.py | 0 tests/dummies/pylab.py | 1 + {vlogging/tests => tests}/lenna.jpg | Bin .../tests/basic.py => tests/test_basic.py | 51 +++++++-- tests/test_corner_cases.py | 28 +++++ vlogging/__init__.py | 102 ++++++++++-------- vlogging/tests/corner_cases.py | 13 --- 20 files changed, 265 insertions(+), 198 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .coveralls.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml delete mode 100644 devrequirements.txt create mode 100644 pyproject.toml delete mode 100644 setup.py rename {vlogging/tests => tests}/dummies/PIL.py (100%) rename {vlogging/tests => tests}/dummies/__init__.py (100%) rename {vlogging/tests => tests}/dummies/cv2.py (100%) rename vlogging/tests/dummies/pylab.py => tests/dummies/matplotlib.py (100%) create mode 100644 tests/dummies/pylab.py rename {vlogging/tests => tests}/lenna.jpg (100%) rename vlogging/tests/basic.py => tests/test_basic.py (70%) create mode 100644 tests/test_corner_cases.py delete mode 100644 vlogging/tests/corner_cases.py 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/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5a13928 --- /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 tests/ -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..2df8d34 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,66 @@ 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. Requires Python 3.9+. ## Usage example (see demo.py) + ```python +import logging from logging import FileHandler from vlogging import VisualRecord -if __name__ == '__main__': - import cv2 - from PIL import Image - import numpy as np - import matplotlib.pyplot as plt - - # evenly sampled time at 200ms intervals - t = np.arange(0., 5., 0.2) +import cv2 # or PIL.Image, or matplotlib — whatever you use - fig1 = plt.figure() - plt.plot(t, t, 'r--', t, t ** 2, 'bs', t, t ** 3, 'g^') +logger = logging.getLogger("demo") +logger.setLevel(logging.DEBUG) +logger.addHandler(FileHandler("test.html", mode="w")) - cv_image = cv2.imread('lenna.jpg') - pil_image = Image.open('lenna.jpg') +cv_image = cv2.imread("lenna.jpg") - import logging - logger = logging.getLogger("demo") - fh = FileHandler('test.html', mode="w") +logger.debug(VisualRecord( + "Hello from OpenCV", cv_image, "This is an OpenCV image", fmt="png")) - logger.setLevel(logging.DEBUG) - logger.addHandler(fh) +# 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.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. A sample of generated html is available [here](http://dchaplinsky.github.io/visual-logging/). - logger.info(VisualRecord( - "Hello from pylab", fig1, "This is PyLab graph", fmt="png")) +### `VisualRecord` arguments - logger.warning( - VisualRecord("Hello from all", [cv_image, pil_image, fig1], - fmt="png")) +| 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: raster images bigger than that are downscaled proportionally before embedding, to keep log files readable and small |
 
-```
+## Changelog
 
-You can check generated html [here](http://dchaplinsky.github.io/visual-logging/)
+**2.0**
+- Modern packaging (`pyproject.toml`), Python 3.9+ only
+- New `max_size` option to downscale embedded images
+- matplotlib support no longer relies on the deprecated `pylab` module
+- Tests run on GitHub Actions against Python 3.9–3.13
diff --git a/demo.py b/demo.py
index a4d4198..0192a46 100644
--- a/demo.py
+++ b/demo.py
@@ -1,11 +1,16 @@
+import logging
 from logging import FileHandler
+from pathlib import Path
+
 from vlogging import VisualRecord
 
-if __name__ == '__main__':
+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 / "tests" / "lenna.jpg")
 
     # evenly sampled time at 200ms intervals
     t = np.arange(0., 5., 0.2)
@@ -13,25 +18,31 @@
     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")
+    fh = FileHandler("test.html", mode="w")
 
     logger.setLevel(logging.DEBUG)
     logger.addHandler(fh)
 
     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)))
+
+    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..30c7497
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,46 @@
+[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.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/tests/dummies/PIL.py b/tests/dummies/PIL.py
similarity index 100%
rename from vlogging/tests/dummies/PIL.py
rename to tests/dummies/PIL.py
diff --git a/vlogging/tests/dummies/__init__.py b/tests/dummies/__init__.py
similarity index 100%
rename from vlogging/tests/dummies/__init__.py
rename to tests/dummies/__init__.py
diff --git a/vlogging/tests/dummies/cv2.py b/tests/dummies/cv2.py
similarity index 100%
rename from vlogging/tests/dummies/cv2.py
rename to tests/dummies/cv2.py
diff --git a/vlogging/tests/dummies/pylab.py b/tests/dummies/matplotlib.py
similarity index 100%
rename from vlogging/tests/dummies/pylab.py
rename to tests/dummies/matplotlib.py
diff --git a/tests/dummies/pylab.py b/tests/dummies/pylab.py
new file mode 100644
index 0000000..f36e4fe
--- /dev/null
+++ b/tests/dummies/pylab.py
@@ -0,0 +1 @@
+raise ImportError()
diff --git a/vlogging/tests/lenna.jpg b/tests/lenna.jpg
similarity index 100%
rename from vlogging/tests/lenna.jpg
rename to tests/lenna.jpg
diff --git a/vlogging/tests/basic.py b/tests/test_basic.py
similarity index 70%
rename from vlogging/tests/basic.py
rename to tests/test_basic.py
index f10d3d1..82dc799 100644
--- a/vlogging/tests/basic.py
+++ b/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):
@@ -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,27 @@ def test_pil(self):
         self.assertTrue("image/jpeg" in s)
         self.assertEqual(s.count("" in s)
         self.assertEqual(s.count(" max_size[0] or img.height > max_size[1]):
+            img = img.copy()
+            img.thumbnail(max_size)
+
+        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 matplotlib  # noqa: F401
 
-    def render_pylab(img, fmt="png"):
-        if not callable(getattr(img, "savefig", None)):
+    def render_matplotlib(fig, fmt="png", max_size=None):
+        if not callable(getattr(fig, "savefig", None)):
             return None
 
-        output = StringIO()
-        img.savefig(output, format=fmt)
-        contents = output.getvalue()
-        output.close()
+        output = BytesIO()
+        fig.savefig(output, format=fmt)
 
-        return contents, "image/%s" % fmt
+        return output.getvalue(), f"image/{fmt}"
 
-    renderers.append(render_pylab)
+    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 arrays, PIL images
+    and matplotlib figures, in any combination. ``max_size`` is an optional
+    ``(width, height)`` bound: raster images larger than that are downscaled
+    (preserving aspect ratio) before being embedded, to keep log files small.
+    """
+
+    def __init__(self, title="", imgs=None, footnotes="", fmt="png",
+                 max_size=None):
         self.title = title
         self.fmt = fmt
+        self.max_size = max_size
 
         if imgs is None:
             imgs = []
@@ -83,7 +108,7 @@ def render_images(self):
         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 +117,20 @@ 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):
         if not self.footnotes:
             return ""
 
-        return Template("
$footnotes
").substitute({ - "footnotes": self.footnotes - }) + return f"
{self.footnotes}
" def __str__(self): - t = Template( - """ -

$title

- $imgs - $footnotes + return ( + f""" +

{self.title}

+ {self.render_images()} + {self.render_footnotes()}
""") - - return t.substitute({ - "title": self.title, - "imgs": self.render_images(), - "footnotes": self.render_footnotes() - }) 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, []) From 834656fef1d56e3b6f9fe64c3e21e80cb0109149 Mon Sep 17 00:00:00 2001 From: Dmitry Chaplinsky Date: Sun, 19 Jul 2026 09:31:06 +0000 Subject: [PATCH 2/5] Keep tests under vlogging/tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ha16zNy2fDMdrZBMQGgpBt --- .github/workflows/tests.yml | 2 +- demo.py | 2 +- {tests => vlogging/tests}/dummies/PIL.py | 0 {tests => vlogging/tests}/dummies/__init__.py | 0 {tests => vlogging/tests}/dummies/cv2.py | 0 {tests => vlogging/tests}/dummies/matplotlib.py | 0 {tests => vlogging/tests}/dummies/pylab.py | 0 {tests => vlogging/tests}/lenna.jpg | Bin {tests => vlogging/tests}/test_basic.py | 0 {tests => vlogging/tests}/test_corner_cases.py | 0 10 files changed, 2 insertions(+), 2 deletions(-) rename {tests => vlogging/tests}/dummies/PIL.py (100%) rename {tests => vlogging/tests}/dummies/__init__.py (100%) rename {tests => vlogging/tests}/dummies/cv2.py (100%) rename {tests => vlogging/tests}/dummies/matplotlib.py (100%) rename {tests => vlogging/tests}/dummies/pylab.py (100%) rename {tests => vlogging/tests}/lenna.jpg (100%) rename {tests => vlogging/tests}/test_basic.py (100%) rename {tests => vlogging/tests}/test_corner_cases.py (100%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5a13928..783a7e5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,4 +23,4 @@ jobs: run: pip install -e .[test] - name: Run tests - run: pytest tests/ -v + run: pytest -v diff --git a/demo.py b/demo.py index 0192a46..9c60e21 100644 --- a/demo.py +++ b/demo.py @@ -10,7 +10,7 @@ import numpy as np from PIL import Image - lenna = str(Path(__file__).parent / "tests" / "lenna.jpg") + lenna = str(Path(__file__).parent / "vlogging" / "tests" / "lenna.jpg") # evenly sampled time at 200ms intervals t = np.arange(0., 5., 0.2) diff --git a/tests/dummies/PIL.py b/vlogging/tests/dummies/PIL.py similarity index 100% rename from tests/dummies/PIL.py rename to vlogging/tests/dummies/PIL.py diff --git a/tests/dummies/__init__.py b/vlogging/tests/dummies/__init__.py similarity index 100% rename from tests/dummies/__init__.py rename to vlogging/tests/dummies/__init__.py diff --git a/tests/dummies/cv2.py b/vlogging/tests/dummies/cv2.py similarity index 100% rename from tests/dummies/cv2.py rename to vlogging/tests/dummies/cv2.py diff --git a/tests/dummies/matplotlib.py b/vlogging/tests/dummies/matplotlib.py similarity index 100% rename from tests/dummies/matplotlib.py rename to vlogging/tests/dummies/matplotlib.py diff --git a/tests/dummies/pylab.py b/vlogging/tests/dummies/pylab.py similarity index 100% rename from tests/dummies/pylab.py rename to vlogging/tests/dummies/pylab.py diff --git a/tests/lenna.jpg b/vlogging/tests/lenna.jpg similarity index 100% rename from tests/lenna.jpg rename to vlogging/tests/lenna.jpg diff --git a/tests/test_basic.py b/vlogging/tests/test_basic.py similarity index 100% rename from tests/test_basic.py rename to vlogging/tests/test_basic.py diff --git a/tests/test_corner_cases.py b/vlogging/tests/test_corner_cases.py similarity index 100% rename from tests/test_corner_cases.py rename to vlogging/tests/test_corner_cases.py From 14d07441f80628e193e44fbcab96aa02d493cd5c Mon Sep 17 00:00:00 2001 From: Dmitry Chaplinsky Date: Sun, 19 Jul 2026 09:41:34 +0000 Subject: [PATCH 3/5] Simplify max_size handling and the no-renderers test - Share the shrink-only scale policy between renderers via _fit_scale instead of two divergent per-renderer formulations - Downscale PIL images with resize() instead of a full-resolution copy() followed by in-place thumbnail() - Replace the dummy-module directory and hand-rolled sys.modules save/restore in the corner-case test with mock.patch.dict - Document that max_size does not apply to matplotlib figures Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ha16zNy2fDMdrZBMQGgpBt --- README.md | 2 +- vlogging/__init__.py | 32 +++++++++++++++++----------- vlogging/tests/dummies/PIL.py | 1 - vlogging/tests/dummies/__init__.py | 0 vlogging/tests/dummies/cv2.py | 1 - vlogging/tests/dummies/matplotlib.py | 1 - vlogging/tests/dummies/pylab.py | 1 - vlogging/tests/test_corner_cases.py | 26 ++++++---------------- 8 files changed, 28 insertions(+), 36 deletions(-) delete mode 100644 vlogging/tests/dummies/PIL.py delete mode 100644 vlogging/tests/dummies/__init__.py delete mode 100644 vlogging/tests/dummies/cv2.py delete mode 100644 vlogging/tests/dummies/matplotlib.py delete mode 100644 vlogging/tests/dummies/pylab.py diff --git a/README.md b/README.md index 2df8d34..8ca8220 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Open `test.html` in a browser and enjoy. A sample of generated html is available | `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: raster images bigger than that are downscaled proportionally before embedding, to keep log files readable and small |
+| `max_size` | Optional `(width, height)` tuple: OpenCV and PIL images bigger than that are downscaled proportionally before embedding, to keep log files readable and small (matplotlib figures are embedded as rendered) |
 
 ## Changelog
 
diff --git a/vlogging/__init__.py b/vlogging/__init__.py
index 506317d..b7b647d 100644
--- a/vlogging/__init__.py
+++ b/vlogging/__init__.py
@@ -7,6 +7,15 @@
 
 renderers = []
 
+
+def _fit_scale(width, height, max_size):
+    """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
@@ -15,12 +24,10 @@ def render_opencv(img, fmt="png", max_size=None):
         if not isinstance(img, numpy.ndarray):
             return None
 
-        if max_size is not None:
-            scale = min(max_size[0] / img.shape[1],
-                        max_size[1] / img.shape[0])
-            if scale < 1:
-                img = cv2.resize(img, None, fx=scale, fy=scale,
-                                 interpolation=cv2.INTER_AREA)
+        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:
@@ -39,10 +46,10 @@ def render_pil(img, fmt="png", max_size=None):
         if not isinstance(img, Image.Image):
             return None
 
-        if max_size is not None and \
-                (img.width > max_size[0] or img.height > max_size[1]):
-            img = img.copy()
-            img.thumbnail(max_size)
+        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)
@@ -82,8 +89,9 @@ class VisualRecord:
 
     ``imgs`` accepts a single image or a list of OpenCV arrays, PIL images
     and matplotlib figures, in any combination. ``max_size`` is an optional
-    ``(width, height)`` bound: raster images larger than that are downscaled
-    (preserving aspect ratio) before being embedded, to keep log files small.
+    ``(width, height)`` bound: OpenCV and PIL images larger than that are
+    downscaled (preserving aspect ratio) before being embedded, to keep log
+    files small; matplotlib figures are embedded as rendered.
     """
 
     def __init__(self, title="", imgs=None, footnotes="", fmt="png",
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/__init__.py b/vlogging/tests/dummies/__init__.py
deleted file mode 100644
index e69de29..0000000
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/matplotlib.py b/vlogging/tests/dummies/matplotlib.py
deleted file mode 100644
index f36e4fe..0000000
--- a/vlogging/tests/dummies/matplotlib.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/test_corner_cases.py b/vlogging/tests/test_corner_cases.py
index 0b1c09f..8a60248 100644
--- a/vlogging/tests/test_corner_cases.py
+++ b/vlogging/tests/test_corner_cases.py
@@ -1,28 +1,16 @@
 import sys
 import unittest
-from pathlib import Path
-
-DUMMIES = str(Path(__file__).parent / "dummies")
-BLOCKED = ("vlogging", "cv2", "numpy", "PIL", "matplotlib", "pylab")
+from unittest import mock
 
 
 class CornerCasesTestCase(unittest.TestCase):
     def test_no_renderers(self):
-        # Reimport vlogging with dummy cv2/PIL/matplotlib modules that raise
-        # ImportError, then restore the real modules for the other tests
-        saved = {name: mod for name, mod in sys.modules.items()
-                 if name.split(".")[0] in BLOCKED}
-
-        sys.path.insert(0, DUMMIES)
-        try:
-            for name in saved:
-                del sys.modules[name]
+        # None in sys.modules makes imports of the imaging libraries raise
+        # ImportError; patch.dict restores the real modules (and the original
+        # vlogging, dropped inside the block) afterwards
+        blocked = dict.fromkeys(("cv2", "numpy", "PIL", "matplotlib"))
+        with mock.patch.dict(sys.modules, blocked):
+            sys.modules.pop("vlogging", None)
 
             import vlogging
             self.assertEqual(vlogging.renderers, [])
-        finally:
-            sys.path.remove(DUMMIES)
-            for name in [name for name in sys.modules
-                         if name.split(".")[0] in BLOCKED]:
-                del sys.modules[name]
-            sys.modules.update(saved)

From 193290d034758fe602955ef4bf43785ff670913f Mon Sep 17 00:00:00 2001
From: Dmitry Chaplinsky 
Date: Sun, 19 Jul 2026 09:54:48 +0000
Subject: [PATCH 4/5] Round out 2.0: HTML page handler, Jupyter support, numpy
 renderer, PyPI publishing

- New HTMLFileHandler + VisualFormatter: self-contained styled html pages
  with level-colored record cards, timestamps, logger names, escaped
  plain-text records and tracebacks; flushed per record so the page can
  be watched mid-run. Both are plain logging.FileHandler/Formatter
  subclasses and compose the stdlib way; 1.x usage with a bare
  FileHandler keeps working
- VisualRecord displays inline in Jupyter via _repr_html_
- max_size now applies to matplotlib figures too, by lowering savefig dpi
- New render_numpy fallback: numpy arrays render via PIL when OpenCV is
  not installed
- Embedded images get loading="lazy" so large logs open fast
- Type hints throughout and a py.typed marker
- publish.yml: build + publish to PyPI via trusted publishing on v* tags
- demo.py showcases the new handler; README rewritten accordingly

Co-Authored-By: Claude Fable 5 
Claude-Session: https://claude.ai/code/session_01Ha16zNy2fDMdrZBMQGgpBt
---
 .github/workflows/publish.yml    |  40 ++++++
 README.md                        |  32 +++--
 demo.py                          |  15 ++-
 pyproject.toml                   |   3 +
 vlogging/__init__.py             | 214 ++++++++++++++++++++++++++++---
 vlogging/py.typed                |   0
 vlogging/tests/test_basic.py     |  46 ++++++-
 vlogging/tests/test_formatter.py |  72 +++++++++++
 8 files changed, 390 insertions(+), 32 deletions(-)
 create mode 100644 .github/workflows/publish.yml
 create mode 100644 vlogging/py.typed
 create mode 100644 vlogging/tests/test_formatter.py

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/README.md b/README.md
index 8ca8220..f0a5170 100644
--- a/README.md
+++ b/README.md
@@ -16,20 +16,19 @@ You can read about it in detail in a great blog post [visual-logging, my new fav
 pip install visual-logging
 ```
 
-No extra dependencies — whichever of OpenCV, PIL/Pillow and matplotlib you already have installed are picked up automatically. Requires Python 3.9+.
+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
 import logging
-from logging import FileHandler
-from vlogging import VisualRecord
+from vlogging import HTMLFileHandler, VisualRecord
 
 import cv2  # or PIL.Image, or matplotlib — whatever you use
 
 logger = logging.getLogger("demo")
 logger.setLevel(logging.DEBUG)
-logger.addHandler(FileHandler("test.html", mode="w"))
+logger.addHandler(HTMLFileHandler("test.html", title="My debug log"))
 
 cv_image = cv2.imread("lenna.jpg")
 
@@ -42,10 +41,21 @@ logger.warning(VisualRecord(
     "Hello from all", [cv_image, pil_image, mpl_figure],
     fmt="png", max_size=(320, 240)))
 
+# Ordinary log calls work too, and land in the same page:
+logger.info("Processed frame %d", 42)
+
 logging.shutdown()  # flushes and closes the html file
 ```
 
-Open `test.html` in a browser and enjoy. A sample of generated html is available [here](http://dchaplinsky.github.io/visual-logging/).
+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.
+
+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.
+
+In **Jupyter**, a `VisualRecord` displays itself inline — no logging setup needed:
+
+```python
+VisualRecord("Detected edges", edges_img, "Canny output")
+```
 
 ### `VisualRecord` arguments
 
@@ -55,12 +65,16 @@ Open `test.html` in a browser and enjoy. A sample of generated html is available
 | `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: OpenCV and PIL images bigger than that are downscaled proportionally before embedding, to keep log files readable and small (matplotlib figures are embedded as rendered) |
+| `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
-- New `max_size` option to downscale embedded images
+- 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
+- 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 9c60e21..dcb77eb 100644
--- a/demo.py
+++ b/demo.py
@@ -1,8 +1,7 @@
 import logging
-from logging import FileHandler
 from pathlib import Path
 
-from vlogging import VisualRecord
+from vlogging import HTMLFileHandler, VisualRecord
 
 if __name__ == "__main__":
     import cv2
@@ -22,10 +21,8 @@
     pil_image = Image.open(lenna)
 
     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"))
@@ -45,4 +42,12 @@
                      [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/pyproject.toml b/pyproject.toml
index 30c7497..4288068 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,5 +42,8 @@ test = [
 [tool.setuptools]
 packages = ["vlogging"]
 
+[tool.setuptools.package-data]
+vlogging = ["py.typed"]
+
 [tool.setuptools.dynamic]
 version = {attr = "vlogging.__version__"}
diff --git a/vlogging/__init__.py b/vlogging/__init__.py
index b7b647d..8a611c6 100644
--- a/vlogging/__init__.py
+++ b/vlogging/__init__.py
@@ -1,14 +1,24 @@
 """Embed OpenCV / PIL / matplotlib images into standard logging as HTML."""
 
+from __future__ import annotations
+
 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]"]
+
 renderers = []
 
 
-def _fit_scale(width, height, max_size):
+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
@@ -20,7 +30,8 @@ def _fit_scale(width, height, max_size):
     import cv2
     import numpy
 
-    def render_opencv(img, fmt="png", max_size=None):
+    def render_opencv(img: Any, fmt: str = "png",
+                      max_size: tuple[int, int] | None = None) -> RenderResult:
         if not isinstance(img, numpy.ndarray):
             return None
 
@@ -42,7 +53,8 @@ def render_opencv(img, fmt="png", max_size=None):
 try:
     from PIL import Image
 
-    def render_pil(img, fmt="png", max_size=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
 
@@ -60,15 +72,49 @@ def render_pil(img, fmt="png", max_size=None):
 except ImportError:
     pass
 
+try:
+    import numpy
+    from PIL import Image
+
+    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
+
+        try:
+            return render_pil(Image.fromarray(img), fmt, max_size)
+        except (TypeError, ValueError):
+            return None
+
+    renderers.append(render_numpy)
+except ImportError:
+    pass
+
 try:
     import matplotlib  # noqa: F401
 
-    def render_matplotlib(fig, fmt="png", max_size=None):
+    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
+
         output = BytesIO()
-        fig.savefig(output, format=fmt)
+        fig.savefig(output, format=fmt, **kwargs)
 
         return output.getvalue(), f"image/{fmt}"
 
@@ -87,15 +133,20 @@ class VisualRecord:
 
         logger.debug(VisualRecord("title", img, "notes"))
 
-    ``imgs`` accepts a single image or a list of OpenCV arrays, PIL images
-    and matplotlib figures, in any combination. ``max_size`` is an optional
-    ``(width, height)`` bound: OpenCV and PIL images larger than that are
+    ``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 embedded as rendered.
+    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="", imgs=None, footnotes="", fmt="png",
-                 max_size=None):
+    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
@@ -110,7 +161,7 @@ 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:
@@ -125,20 +176,149 @@ def render_images(self):
                     break
 
         return "".join(
-            '' %
+            '' %
             (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 f"
{self.footnotes}
" - def __str__(self): + def html(self) -> str: + """The record as an HTML fragment, without the trailing rule.""" return ( f"""

{self.title}

{self.render_images()} - {self.render_footnotes()} -
""") + {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). + """ + + def format(self, record: logging.LogRecord) -> str: + if isinstance(record.msg, VisualRecord): + body = record.msg.html() + else: + body = f"
{html.escape(record.getMessage())}
" + + if record.exc_info: + body += ('\n
%s
' % + html.escape(self.formatException(record.exc_info))) + + if record.stack_info: + body += ('\n
%s
' % + html.escape(self.formatStack(record.stack_info))) + + levelclass = "".join( + c for c in record.levelname.lower() if c.isalnum()) + + 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 = """ + + + + +%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: + stream.write(_PAGE_HEADER % html.escape(self.title)) + + 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/py.typed b/vlogging/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/vlogging/tests/test_basic.py b/vlogging/tests/test_basic.py index 82dc799..c9cd3ee 100644 --- a/vlogging/tests/test_basic.py +++ b/vlogging/tests/test_basic.py @@ -18,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(
@@ -72,6 +72,33 @@ 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
 
@@ -135,6 +162,23 @@ def test_opencv_max_size(self):
             numpy.frombuffer(bytes(same), numpy.uint8), cv2.IMREAD_COLOR)
         self.assertEqual(restored.shape, cv_image.shape)
 
+    def test_matplotlib_max_size(self):
+        from io import BytesIO
+
+        import matplotlib.pyplot as plt
+        from PIL import Image
+
+        fig = plt.figure(figsize=(6.4, 4.8), dpi=100)
+
+        data, _ = vlogging.render_matplotlib(fig, max_size=(64, 48))
+        restored = Image.open(BytesIO(data))
+        self.assertTrue(max(restored.size) <= 64)
+
+        # Figure already fits: rendered at its own dpi
+        data, _ = vlogging.render_matplotlib(fig, max_size=(10000, 10000))
+        restored = Image.open(BytesIO(data))
+        self.assertEqual(restored.size, (640, 480))
+
     def test_matplotlib_basic(self):
         import matplotlib.pyplot as plt
         import numpy as np
diff --git a/vlogging/tests/test_formatter.py b/vlogging/tests/test_formatter.py
new file mode 100644
index 0000000..b307d8a
--- /dev/null
+++ b/vlogging/tests/test_formatter.py
@@ -0,0 +1,72 @@
+import logging
+import tempfile
+import unittest
+from pathlib import Path
+
+import vlogging
+
+
+class HTMLFileHandlerTestCase(unittest.TestCase):
+    def render_log(self, emit, **handler_kwargs):
+        with tempfile.TemporaryDirectory() as tmp:
+            path = Path(tmp) / "log.html"
+            handler = vlogging.HTMLFileHandler(str(path), **handler_kwargs)
+
+            logger = logging.Logger("demo")
+            logger.setLevel(logging.DEBUG)
+            logger.addHandler(handler)
+
+            emit(logger)
+            handler.close()
+
+            return path.read_text(encoding="utf-8")
+
+    def test_page_structure(self):
+        s = self.render_log(lambda logger: logger.info("hello"),
+                            title="my ")
+
+        self.assertTrue(s.startswith(""))
+        self.assertTrue("
+%(style)s
 
 
 
@@ -309,7 +320,13 @@ def __init__(self, filename: Any, mode: str = "w", def _open(self): stream = super()._open() if stream.tell() == 0: - stream.write(_PAGE_HEADER % html.escape(self.title)) + # 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