Using default function arguments to avoid creating a class
Recently I was writing some Python code to print out Prometheus metrics about whether or not we could log in to an IMAP server. As an end to end test, this is something that can fail for a wide assortment of reasons; we can fail to connect to the IMAP server, experience a TLS error during TLS session negotiation, have the server's TLS certificate fail to validate, there could be an IMAP protocol problem, or the server could reject our login attempt. If we fail, we would like to know why for diagnostic purposes (especially, some sorts of failures are more important than others in this test). In the Prometheus world, this is traditionally done by emitting a separate metric for every different thing that can fail.
In my code, the metrics are all prepared by a single function that gets called at various points. It looks something like this:
def logingauges(host, ok, ...): [...] def logincheck(host, user, pw): try: c = ssl.create_default_context() m = imaplib.IMAP4_SSL(host=host, ssl_context=c) except ssl.CertificateError: return logingauges(host, 0, ...) except [...] [...] try: r = m.login(user, pw) [...] except imaplib.IMAP4.error: return logingauges(host, 0, ...) except [...] # success, finally. return logingauges(host, 1, ...)
When I first started writing this code, I only distinguished a
couple of different reasons that we could fail so I passed the state
of those reasons directly as additional parameters to
As the number of failure reasons rose, this got both unwieldy and
annoying, partly because adding a new failure reason required going
through all existing calls to
logingauges() to add a new parameter
to each of them.
So I gave up. I turned all of the failure reasons into keyword arguments that defaulted to 0:
def logingauges(host, ok, connerr=0, loginerr=0, certerr=0, sslerr=0, imaperr=0): [...]
Now to call
logingauges() on failure I only needed to supply an
argument for the specific failure:
return logingauges(host, 0, sslerr=1)
Adding a new failure reason became much more localized; I only had
to add a new gauge metric to
logingauges(), with a new keyword
argument, and then call it from the right place.
This strikes me as pretty much a hack. The proper way is probably
to create a class to hold all of this status information as attributes
on instances, create an instance of it at the start of
manipulate the attributes as appropriate, and return the instance
when done. The class can even have a
to_gauges() function that
generates all of the actual metrics from its current values.
(In Python 3.7, I would use a dataclass, but this has to run on Ubuntu 18.04 with Python 3.6.7, so it needs to be a boring old class.)
However, not only do I already have the version that uses default function arguments, but the class based version would require a bunch more code and bureaucracy for what is basically a simple situation in a small program. I like doing things the right way, but I'm not sure I like it that much. As it stands, the default function arguments approach is pleasantly minimal and low overhead.
(Or maybe this is considered an appropriate use of default function
arguments in Python these days. Arguments with default values are
often used to set default initial values for instance attributes,
and that is kind of what I'm doing here. One version of the class
based approach could actually look the same; instead of calling a
function, I'd return a just-created instance of my
(This is only somewhat similar to using default function arguments to merge several APIs together. Here it would be a real stretch to say that there are multiple APIs, one for each failure reason.)