#+title: Log9er
#+author: Joseph Edwards VIII
#+email: jedwards8th at gmail.com
#+date: <2025-03-14 Fri>
#+options: num:nil ^:nil H:5 toc:2

*Log the same way, everywhere*

* Introduction

*Log9er* is a simple, opinionated Python ~logging~ wrapper class.

You can use it to normalize ~logging~ footprints across multiple projects, reducing boilerplate in development, and making it easier to create a uniform experience for log reading and parsing.

** Is Log9er For You?

Log9er is tiny, because it replaces the standard Python library approach ~logger.debug(m)~ with ~logger.log(m, level=logging.DEBUG)~.

That's really the only big difference: It's parameterized, not declarative. The smaller difference is that it's intended to be passed around between related classes in a project, to enforce uniformity in the logs.

Log9er is for devs who have written too many ~def log()~ methods that clash with the other ones they've written. *Like me.*

Just do this instead:

#+begin_src python
  from log9er import Logger

  class Foo:
      def __init__(self, logger:Logger=None):

          # Pass a `log9er.Logger` in, or make a new one?
          # If new, we use `logging.getLogger()` to make sure it really is new.

          self.logger = logger or Logger(self.__class__.__name__)

          # Attach the `Logger.log()` method to your class.

          self.log = self.logger.log

          # Then log something at default `INFO` log-level.

          self.log(f"Initialized {self.__class__.__name__} with logger: {self.logger}")
#+end_src

This approach isn't for every project. For example, legacy projects with a lot of logging won't find it worthwhile to refactor.

But for those who want to have a ~self.log()~ method instead of a globally namespaced ~logging.{LOGLEVEL}()~ function... *Log9er* might be for you.

** Features

Python's standard ~logging~ library is extremely flexible. It's like Photoshop.

*Log9er* is really very inflexible. It just gives you a ~Logger~ class and ~Logger.log()~ method that wrap Python's ~logging~ library methods, and gives you some basic ways to override some parameters.

The main benefit is uniformity of usage in development, reading, and parsing of logging across projects in the same ecosystem, or written by the same developer.

- Reduce boilerplate logging logic across projects.
- Log to ~stderr~, ~syslog~, or size-based rotating log files.
- Reuse the same name to use the same Python ~logging~ logger.
- Exception formatting, with traceback if the logger is in debug mode.

That's about it. Read the code. This is stupid-simple logging. It's not fancy.

Maybe that makes it elegant.

* Installation

*Log9er* is available on PyPi, so just use ~pip~ or its alternatives to do something like:

#+begin_src shell
  pip install log9er
#+end_src

* Usage

*Log9er* is a library, so you have to write some Python.

See the bundled [[file:docs/pdoc/index.html][API Documentation]] for reference, but here are the broad strokes.

#+begin_src python
  import log9er

  # Make a basic logger that prints to stderr
  logger = log9er.Logger("HelloWorld", log="stderr")

  # Grab the log function
  log = logger.log

  # Log something
  log("Hello, World!")

  # Output:
  # Mar 29 13:34:46 HelloWorld[472647]: [INFO] HelloWorld!
#+end_src

** Initialize Logger

The ~Logger~ class provides a flexible interface for logging messages to various backends (such as syslog, file-based logging, or stderr) with customizable log levels.

If an existing Python built-in ~logging.Logger~ instance is provided, all other initialization parameters (except ~debug~) are ignored.

*** Arguments

- *name (str)*: The name to assign to the logger instance, unless ~logger~ provided.

- *log (str)*: The log output destination. For example, ~syslog~ or a file path. If ~stderr~, logs go to standard output.

  - If ~log~ is set to ~stderr~, then error-level messages will be in output in red, if supported by your terminal emulator.

- *level (int | str)*: The default logging level. Can be a string (e.g., ~INFO~ ) or an integer constant from the ~logging~ module (e.g., ~logging.INFO~ ).

- *propagate (bool)*: Whether log records should propagate to the root logger. If True, the root logger also processes these messages (default: False).

- *debug (bool)*: If True, enable traceback logging when ~exception=True~.

- *max_bytes (int | None)*: If provided, and if ~log~ is a file path, use as the ~maxBytes~ for the ~RotatingFileHandler~ (default: ~10485760~).

- *backup_count (int | None)*: If provided, and if ~log~ is a file path, use as the ~backup_count~ for the ~RotatingFileHandler~ (default: ~5~).

- *logger (logging.Logger | None)*: If provided, use this logger instance directly and ignore other arguments (except ~debug~).

*** Raises

~TypeError~: If ~logger~ is provided but is not an instance of ~logging.Logger~.
~ValueError~: If ~level~ is an invalid log level string.
~FileNotFoundError~: If ~log~ is a file path and its directory does not exist.
~OSError~: If ~log="syslog"~ but ~/dev/log~ is not available on this system.

*** Default System Log Example

By default, ~Logger~ will log to ~syslog~, at log level ~INFO~.

This makes it easier to initialize the most common use case.

#+begin_src python
  import logging
  from log9er import Logger

  # The 'name' arg is required for a new Logger
  logger = Logger("Example")

  # The above is equivalent to the following
  logger = Logger(
      name = "Example",             # 'name' is the 1st kwarg for simplicity
      log = "syslog",
      level = logging.INFO,
      propagate = False,
      debug = False,
      max_bytes = (10*1024*1024),
      backup_count = 5,
      logging = None
  )
#+end_src

*** Stream Log Example

Another common use case would be to stream the logging to console. For convenience, we call this the ~stderr~ option, since by default the Python ~logging.StreamHandler~ outputs to ~stderr~, and *Log9er* uses this default.

In addition, if the console supports it, then error-level messages will appear in red.

If your CLI application supports a debug mode, you can pass this through to get full traceback of exceptions.

#+begin_src python
  from log9er import Logger

  logger = Logger("Example", log="stderr", debug=True)
#+end_src

*** Rotating File Log Example

Here's an example of initializing ~Logger~ using all possible arguments and keyword arguments (except ~logger~).

Here we override the default ~max_bytes~ and ~backup_count~ for rotating log files, and limit the logger to error-level items.

*Note* that ~name~ is a *required* argument unless ~logger~ is passed.

#+begin_src python
  from log9er import Logger

  logger = Logger(
      "Example",
      log = "/var/log/example.log",
      level = "ERROR",
      propagate = True,
      debug = True,
      max_bytes = (5*1024*1024),
      backup_count = 3
  )
#+end_src

*** Inject Logger Example

In addition, you can pass an existing Python ~logging.Logger~ object into the ~log9er.Logger~ wrapper.

*Log9er* will not override anything in the passed-in logger. It will not change the name, formatter, or handler defined on the given logger.

All it will do is provide the ~log9er.Logger.log()~ method to call the underlying ~logging.log()~ method. This allows the user to implement a uniform and parameterized approach to logging in related applications.

A less common use case would be to normalize an external Python ~logging.Logger~ for implementation in code that was written for a ~log9er.Logger~.

#+begin_src python
  import logging
  import log9er

  # 1. Suppose we already have a logging.Logger...

  logging.basicConfig(level=logging.INFO)       # default level is WARNING
  logger1 = logging.getLogger()                 # default logger name is "root"
  logger1.info("Hello from logging.Logger!")

  # Output:
  # INFO:root:Hello from logging.Logger!

  # 2. Create a log9er.Logger from the above

  logger2 = log9er.Logger(logger=logger1)       # name not required if logger is passed
  logger2.log("Hello from log9er.Logger!")

  # Output:
  # INFO:root:Hello from log9er.Logger!
#+end_src

** Log Method

The initialized ~Logger~ class provides the ~Logger.log()~ method, which may be used directly on the object, or attached to your custom class, as seen in the [[Is Log9er For You?]] section.

While ~log()~ is a simple wrapper on Python's ~logger~, it does have a few differences worth mentioning:

- *Multiple Messages*: You can provide multiple messages as positional arguments, and they will be concatenated with a newline and a tab.

- *Log Level*: You can supply a string like ~level="DEBUG"~ or the usual constant like ~level=logging.DEBUG~.

- *Exceptions*: You can set ~exception=True~ to treat the messages as an exception. Messages will be prefixed with ~Exception:~ and in debug mode, this method will also dump the traceback.

- *Prefix*: You can set ~prefix="myfunction"~ or any other string to prefix your message(s), emphasized by placing the prefix in brackets.

*** Arguments

- **msgs (str)*: One or more strings to log, e.g., ~log("Hello", "World")~.

- *level (int | str)*: The log level, e.g., ~logging.INFO~ or ~INFO~ . Defaults to ~logging.INFO~.

- *exception (bool)*: If True, prefix with "Exception:" and log a traceback if ~self.debug~ is also True.

- *prefix (str | None)*: Optional prefix label. If provided, the log message is prefixed with, e.g. ~[myfunction] message~.

*** Multiple Messages

One of the bigger differences between Python's ~logging~ and ~log9er~ is that it accepts multiple messages, and will format them for you on multiple lines, with tabs to indent subsequent message parts.

#+begin_src python
  logger.log("Message 1", "Message 2", "Message 3", level=logging.WARNING)

  # Output:
  # Mar 29 17:14:10 Dummy[491147]: [WARNING] Message 1
  #	Message 2
  #	Message 3
#+end_src

*** Log Level

As seen above, the ~level~ argument accepts ~logging~ constants.

It also accepts simple case-insensitive string values:

#+begin_src python
  logger.log("DEBUG constant", level=logging.DEBUG)
  logger.log("INFO is the default")
  logger.log("WARNING in upper case", level="WARNING")
  logger.log("ERROR in lower case", level="error")
  logger.log("CRITICAL in sarcastic case", level="CriTiCaL")
#+end_src

It will throw an exception if the level is invalid:

#+begin_src python
  logger.log("Bad log level 'foo'", level="foo")

  # Output:
  # ValueError: Invalid log level 'FOO'
#+end_src

*** Exceptions

In *Log9er*, you just tell ~log()~ that the message is an exception, and if it's in debug mode, then it will also dump traceback.

With debug mode *disabled*, an exception only logs a single line:

#+begin_example
  >>> logger = log9er.Logger("Logger", log="stderr")
  >>> try:
  ...     raise Exception("NO debug mode!")
  ... except Exception as ex:
  ...     logger.log(ex, exception=True)
  ...
  Mar 29 18:13:30 Logger[498590]: [ERROR] Exception: NO debug mode!
#+end_example

With debug mode *enabled*, we also get the traceback of the exception:

#+begin_example
  >>> logger = log9er.Logger("Logger", log="stderr", debug=True)
  >>> try:
  ...     raise Exception("YES debug mode!")
  ... except Exception as ex:
  ...     logger.log(ex, exception=True)
  ...
  Mar 29 18:16:13 Logger[498590]: [ERROR] Exception: YES debug mode!
  Traceback (most recent call last):
    File "<stdin>", line 2, in <module>
  Exception: YES debug mode!
#+end_example

*Log9er* simply wraps Python's built-in ~logging.exception()~ function for this traceback feature. It doesn't do anything special, or surprising.

*** Prefix

Logging only helps if it tells us what we need to know. The ~prefix~ argument allows you to set an arbitrary identifier in brackets prior to the message.

For example:

#+begin_example
  >>> logger.log("Prefixed message", prefix="Example")
  Mar 29 18:23:34 Logger[498590]: [INFO] [Example] Prefixed message
#+end_example

* Authors

This is a dead-simple wrapper. It's practically a toy, except that it's just so handy.

I doubt anyone else will work on it.

Joseph Edwards VIII <joseph8th at gmail.com>
