DEV Community

Cover image for Simple Python Logging - and a digression on dependencies, trust, and Copy/pasting code
Tai Kedzierski
Tai Kedzierski

Posted on

Simple Python Logging - and a digression on dependencies, trust, and Copy/pasting code

Header Image (C) Tai Kedzierski

Goto Snippet

This post is opinionated.

Python's default log setup is unhelpful; it works against the "batteries included" approach we have come to expect.

From a useful log message, I want to know when, what level, and what information. I may want it on console, I may want it in a file.

This should be simple - but in Python I end up every time having to look up how to create a full logging utility with custom file handling and string formatting.

It should be as simple as logger = getLogger(), but the default behaviour for some unknown reason is to provide a completely useless formatting, and no shorthand for a sensible default.

That or I need to download some pip package of unknown provenance, trust that it hasn't been name-hijacked, or doing some obfuscated exfilration. The leftpad incident from 2016 comes to mind, as well as the Revival hijack attack from 2024 which was essentially the same problem in a different repo system.

In fact, any user-repo without namespacing is vulnerable to this: Node's npm, Python's pip, Arch's AUR, Canonical's snap ... to name a handful who just let users upload whatever. Even namespacing isn't a guarantee of trust - I've come across projects that distribute their software through these channels not through the project's name, but via some arbitrary dev's monicker, raising doubt as to the authenticity of the package. I gave my thought process on how to decide on whether to trust a source in a previous post on using syncthing in a work environment.

External dependencies in user-controlled repos are the devil, and should only be considered when the solution to a problem is complex. And in general, simple solutions should just exist directly in the code base - ideally self-written, but sometimes the problem just strafes into the "cumbersome enough" space to make a dependency feel both reasonable and icky.

The answer: write it once, stash it away in a Github gist or in a "useful snippets" repo of your own. Copy and paste.

Copy Paste? Ew!

"Copy and paste" of code probably sends alarm bells ringing for any seasoned coder. "Don't repeat yourself," "use a package manager," "write once, update everywhere." These are good instincts to have, but case-by-case, it is also good to know when copy-paste is preferable.

In this case, the requirement is to avoid unnecessary external dependencies for a simple solution to a simple need . In leftpad as with this mini-logger, the required code snippet is short and easy to understand ; it is no loss to reimplement if needed. It is also appropriately licensed (yes, it may be just a snippet; it remains however recommendable to ensure that what you are copying is indeed allowable. Be wary of copying random blobs of code.)

Mini Logger Snippet

I include below a code snippet for a mini-logger utility which allows for a single call with minimal configuration:

from minilog import SimpleLogger

LOG = SimpleLogger(name="mylog", level=SimpleLogger.INFO)

LOG.info("this is useful")
Enter fullscreen mode Exit fullscreen mode

Which prints to console:

2024-11-20 10:43:44,567 | INFO | mylog : this is useful
Enter fullscreen mode Exit fullscreen mode

The mini-logger code

Copy this into a minilogger.py file in your project. Tada - no external dependency needed. Left untouched, it will remain the same forever. No name hijacking. No supply-chain injection.

# For completeness:
# (C) Tai Kedzierski - Provided under MIT license. Go wild.

import logging

class SimpleLogger(logging.Logger):
    FORMAT_STRING = '%(asctime)s | %(levelname)s | %(name)s : %(message)s'
    ERROR = logging.ERROR
    WARN = logging.WARN
    INFO = logging.INFO
    DEBUG = logging.DEBUG

    def __init__(self, name="main", fmt_string=FORMAT_STRING, level=logging.WARNING, console=True, files=None):
        logging.Logger.__init__(self, name, level)
        formatter_obj = logging.Formatter(fmt_string)

        if files is None:
            files = []
        elif isinstance(files, str):
            files = [files]

        def _add_stream(handler:logging.Handler, **kwargs):
            handler = handler(**kwargs)
            handler.setLevel(level)
            handler.setFormatter(formatter_obj)
            self.addHandler(handler)

        if console is True:
            _add_stream(logging.StreamHandler, stream=sys.stdout)

        for filepath in files:
            _add_stream(logging.FileHandler, filename=filepath)
Enter fullscreen mode Exit fullscreen mode

The MIT license essentially allows you to "do whatever you want with this." No strings attached.

There we are. A simple log 🪵

Top comments (0)