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. +[](https://github.com/dchaplinsky/visual-logging/actions/workflows/tests.yml) +[](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. -[](https://travis-ci.org/dchaplinsky/visual-logging) -[](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("