A cautionary tale of unexpectedly defined attributes

I recently made a change in which I cleaned up the way a class, QzEnvironment, computes a thing. It was cleanly implemented and with comprehensive test coverage, and though it added functionality I was sure it didn't modify the existing public API of the classes. Once released, this had the strangest effect: users reported problems in existing production code, with a traceback like

Traceback (most recent call last):
  File "/qz/grid/hugs_cfg.py", line 81, in resolve
    self.srcdb          = env.srcdbPath(env.stage) or ''
AttributeError: 'QzEnvironment' object has no attribute 'stage'

This was bizarre. QzEnvironment never did have an attribute .stage – that was the preserve of related class QzEnvURI. Yet there lay the production code, clearly accessing the .stage attribute of a QzEnvironment. Had I somehow triggered a codepath that hadn't been triggered before? No. At the version before my change, the debugger showed that env.stage was 'prod', which is a sensible value for a stage – but not something this class would know.

In frustration, I read the diff of my change again. Absolutely did not touch an attribute called self.stage. Never was an attribute called stage.

I hunted high and low – was something external to our code rudely assigning a .stage to the QzEnvironment ? I couldn't find it with a project search. I even made .stage a read-only property, in the hope that it would throw and the traceback would reveal how the stage attribute had been assigned.

def stage(self):
    raise AttributeError("QzEnvironment has no attribute 'stage'")

Alas, this merely threw the exception again at the same line – indicating that QzEnvironment did indeed have no attribute .stage – it had never been assigned, nor indeed accessed. Even more confusing!

I read the diff of my change again. And again.


Finally, I saw something. One of the changes was to pull a lot of the configuration out of class attributes into module-level constants at the top of the file, for easier reading and maintainability. These lines were removed from the class definition:

--- qzenv.py 1.105
+++ qzenv.py 1.106
@@ -37,7 +37,4 @@
 class QzEnvironment(object):

-    __AREA_DBS = dict([ (stage, ('quackPermissionedHotfix', 'legacyHotfix', 'prod', 'source') if stage == 'prod' else ('stage', 'source'))
-                        for stage in qz_cfg.QZ_SRC_ALLOWED_ENVIRONMENTS ])
     def __init__(self, ...):

And it hit me.

Python 2 list comprehensions leak loop variables


Comments powered by Disqus