Edit on GitHub

starplast.logging_util

Levels, a log file that has to be asked for, and separate control over what reaches the screen.

The console pane already tees stdout, so a walk's progress is visible while it runs. What a tee cannot do is tell a progress line from a warning from a traceback -- so it cannot filter them, and it keeps nothing once the window closes. Two failures in this project would have been caught in minutes by a log and instead ran for months:

  • every AlphaFold fetch returning 404, reported each time as the ordinary "no model available";
  • a search completing 40 of 288 configurations with no record of which ones, or how long each took.

Both are the same shape: something that failed reporting itself the way something that succeeded does. That is what levels are for.

Two switches, because they answer different questions

Writing a file is opt-in and off by default. A tool that starts writing to someone's disk without being asked is a tool people stop trusting, and this one is meant to be run on a laptop by someone who did not ask for a logging system.

What reaches the console is a separate choice, and it is never off. DEBUG is exactly what you want in the file during a half-hour walk and exactly what you do not want scrolling past on screen, so the two levels are set independently. With the file disabled the console handler stays, at WARNING: a warning that is only written to a file nobody enabled is a silent failure with extra steps, which is the thing this module exists to end.

The console format is parsed, not just read

Console lines are [LEVEL] message, which is what lets the console pane color them and filter by level. It is a prefix rather than a suffix or a column so that a wrapped or truncated line still begins with its level.

LEVELS = ('DEBUG', 'INFO', 'WARNING', 'ERROR')
ROOT = 'starplast'
MAX_BYTES = 2000000
BACKUPS = 3
CONSOLE_FORMAT = '[%(levelname)s] %(message)s'
FILE_FORMAT = '%(asctime)s %(levelname)-7s %(name)-24s %(message)s'
def get_logger(name: str | None = None) -> logging.Logger:

The logger for one module: get_logger(__name__).

Always a child of starplast, whatever it is passed, so that configuring one name configures everything and no message can escape by being logged under an unexpected one.

def log_dir() -> str:

Where log files go: under the platform cache directory, beside anything else downloaded.

def configure( enabled: bool = False, file_level: str = 'DEBUG', console_level: str = 'WARNING', directory: str | None = None) -> str:

Set logging up, and return the file being written to, or "" when none is.

Idempotent: every call replaces the handlers rather than adding to them, so changing a level in Preferences twice does not print everything twice -- which is how a logging system starts being described as noisy and then gets turned off.

A file that cannot be opened is reported and the rest of the configuration still applies. Losing the console because a directory was read-only would be a strange way to handle a log.

def state() -> dict:

What logging is currently doing: enabled, both levels, and the file if there is one.

def log_file() -> str:

The file being written to, or "" when the log is not being kept.

def parse_level(line: str):

The level a console line was logged at, or None if it was an ordinary print.

The console pane uses this to color and filter. Ordinary prints are not forced into a level: most of this application's output is print, and calling all of it INFO would make an INFO filter useless.

class Timed:

Log how long something took, and what happened, whichever way it ends.

A duration is the number that turns "the search finished" into something worth keeping: the walk that quietly did 40 of 288 configurations looked exactly like the one that did all of them until someone timed it.

Timed(log: logging.Logger, what: str, level: int = 20)
t0