3

TL;DR: The Standard Library fails to close a file when an exception is raised. I'm looking for the best way to handle this situation. Feel free to read from the paragraph beginning with "Upon closer inspection of CPython's source code". Also scroll down to the end of the question to grab a self-contained script that reproduces this issue on Windows.

I'm writing a Python package in which I use STL's ConfigParser (2.x) or configparser (3.x) to parse user config file (I will refer to both as ConfigParser since the problem mainly lies in the 2.x implementation). From now on my relevant lines of code on GitHub will be linked when appropriate throughout. ConfigParser.ConfigParser.read(filenames) (used in my code here) raises a ConfigParser.Error exception when the config file is malformed. I have some code in my test suite targeting this situation, using unittest.TestCase.assertRaises(ConfigParser.Error). The malformed config file is properly generated with tempfile.mkstemp (the returned fd is closed with os.close before anything) and I attempt to remove the temp file with os.remove.

The os.remove is where trouble begins. My tests fail on Windows (while working on both OS X and Ubuntu) with Python 2.7 (see this AppVeyor build):

Traceback (most recent call last):
  File "C:\projects\storyboard\tests\test_util.py", line 235, in test_option_reader
    os.remove(malformed_conf_file)
WindowsError: [Error 32] The process cannot access the file because it is being used by another process: 'c:\\users\\appveyor\\appdata\\local\\temp\\1\\storyboard-test-3clysg.conf'

Note that as I said above, malformed_conf_file is generated with tempfile.mkstemp and immediately closed with os.close, so the only time it is opened is when I call ConfigParser.ConfigParser.read([malformed_conf_file]) here inside the unittest.TestCase.assertRaises(ConfigParser.Error) context. So the culprit seems to be the STL rather than my own code.

Upon closer inspection of CPython's source code, I found that ConfigParser.ConfigPaser.read indeed doesn't close the file properly when an exception is raised. The read method from 2.7 (here on CPython's Mercurial) has the following lines:

for filename in filenames:
    try:
        fp = open(filename)
    except IOError:
        continue
    self._read(fp, filename)
    fp.close()
    read_ok.append(filename)

The exception (if any) is raised by self._read(fp, filename), but as you can see, if self._read raises, then fp won't be closed, since fp.close() is only called after self._read returns.

Meanwhile, the read method from 3.4 (here) does not suffer from the same problem since this time they properly embedded file handling in a context:

for filename in filenames:
    try:
        with open(filename, encoding=encoding) as fp:
            self._read(fp, filename)
    except OSError:
        continue
    read_ok.append(filename)

So I think it's pretty clear that the problem is a defect in 2.7's STL. And what's the best way to handle this situation? Specifically:

  • Is there anything I can do from my side to close that file?
  • Is it worth reporting to bugs.python.org?

For now I guess I'll just add a try .. except OSError .. to that os.remove (any suggestions?).


Update: A self-contained script that can be used to reproduce this issue on Windows:

#!/usr/bin/env python2.7
import ConfigParser
import os
import tempfile

def main():
    fd, path = tempfile.mkstemp()
    os.close(fd)
    with open(path, 'w') as f:
        f.write("malformed\n")
    config = ConfigParser.ConfigParser()
    try:
        config.read(path)
    except ConfigParser.Error:
        pass
    os.remove(path)

if __name__ == '__main__':
    main()

When I run it with Python 2.7 interpreter:

Traceback (most recent call last):
  File ".\example.py", line 19, in <module>
    main()
  File ".\example.py", line 16, in main
    os.remove(path)
WindowsError: [Error 32] The process cannot access the file because it is being used by another process: 'c:\\users\\redacted\\appdata\\local\\temp\\tmp07yq2v'
4ae1e1
  • 7,228
  • 8
  • 44
  • 77
  • Although the 2.x isn't using a context manager (for backwards compatibility), the file should be closed when the handle goes out of scope (see e.g. http://stackoverflow.com/a/2404671/3001761) - do you still have a reference to it? Could you provide a [minimal example](http://stackoverflow.com/help/mcve) to allow others to recreate the issue without cloning your whole repo? – jonrsharpe Apr 26 '15 at 19:04
  • @jonrsharpe Hmm, good point. Then do you have any idea why that `WindowError` is raised? – 4ae1e1 Apr 26 '15 at 19:05
  • @jonrsharpe except that the traceback object can still hold a reference to the file handle - isn't this exactly what's happening here? – Lukas Graf Apr 26 '15 at 19:07
  • Can you provide a self-contained example demonstrating the problem? It's difficult to debug this without having a file that will fail in the particular way you're describing. – BrenBarn Apr 26 '15 at 19:08
  • @LukasGraf It will (I have little knowledge of the traceback object)? Then that's definitely the problem. – 4ae1e1 Apr 26 '15 at 19:09
  • 1
    @BrenBarn Updated with example. Thanks for the suggestion. – 4ae1e1 Apr 26 '15 at 19:20
  • Thanks; have you considered seeing if using `tempfile.NamedTemporaryFile` avoids this? – jonrsharpe Apr 26 '15 at 19:25
  • @jonrsharpe In fact I can reproduce the problem even when I don't use `tempfile` at all; just manually create a malformed config file called, say, `hello`, and replace `path` by `'hello'`, and you'll see the same problem. I'm using `tempfile` in the example just so that one's working directory is not polluted. – 4ae1e1 Apr 26 '15 at 19:28

1 Answers1

3

This is an interesting problem. As Lukas Graf noted in a comment, the problem seems to be that the exception traceback object holds a reference to the call frame where the exception was raised. This call frame includes the local variables that existed at that time, one of which is a reference to the open file. So that file object still has a reference to it and is not properly closed.

For your self-contained example, simply removing the try/except ConfigParser.Error "works": the exception about the malformed config file is uncaught and stops the program. However, in your actual application, assertRaises is catching the exception in order to check that it is the one you're testing for. I'm not 100% sure why the traceback persists even after the with assertRaises block, but apparently it does.

For your example, another more promising fix is to change the pass in your except clause to sys.exc_clear():

try:
    config.read(path)
except ConfigParser.Error:
    sys.exc_clear()

This will get rid of the pesky traceback object and allow the file to be closed.

However, it's not clear exactly how to do that in your real application, because the offending except clause there is inside unittest. I think the easiest thing might be to not use assertRaises directly. Instead, write a helper function that does your test, checks for the exception you want, cleans up with the sys.exc_clear() trick, and then raises another custom exception. Then wrap a call to that helper method in assertRaises. This way you gain control of the problematic exception raised by ConfigParser and can properly clean it up (which unittest isn't doing).

Here's a sketch of what I mean:

# in your test method
assertRaises(CleanedUpConfigError, helperMethod, conf_file, malformed_conf_file)

# helper method that you add to your test case class
def helperMethod(self, conf_file, malformed_conf_file):
     gotRightError = False
     try:
          or6 = OptionReader(
               config_files=[conf_file, malformed_conf_file],
               section='sec',
          )
     except ConfigParser.Error:
          gotRightError = True
          sys.exc_clear()
     if gotRightError:
          raise CleanedUpConfigError("Config error was raised and cleaned up!")

I haven't actually tested this, of course, because I don't have the whole unittest thing set up with your code. You might have to tweak it a bit. (Come to think of it, you might not even need exc_clear() if you do this, because since the exception handler is now in a separate function, the traceback should be correctly cleared when helperMethod exits.) However, I think this idea may get you somewhere. Basically you need to make sure the except clause that catches this particular ConfigParser.Error is written by you, so that it can be cleaned up before you try to remove your test file.

Addendum: it seems that if the context manager handles an exception, the traceback is actually stored until the function containing the with block ends, as this example shows:

class CM(object):
    def __init__(self):
        pass

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, tb):
        return True

def foo():
    with CM():
        raise ValueError
    print(sys.exc_info())

Even though the with block has ended by the time the print occurs, so the exception handling should be finished, sys.exc_info still returns exception info as if there were an active exception. This is what is happening in your code too: the with assertRaises block causes the traceback to persist all the way to the end of that function, interfering with your os.remove. This seems like buggy behavior, and I notice that it no longer works this way in Python 3 (the print prints (None, None None)), so I imagine this was a wart that was fixed with Python 3.

Based on this, I suspect it may be sufficient to just insert a sys.exc_clear() right before your os.remove (after the with assertRaises block).

BrenBarn
  • 242,874
  • 37
  • 412
  • 384
  • Thanks, reading your answer right now. As to "why the traceback persists even after the with `assertRaises` block, but apparently it is", I believe the reason is that the contextmanager itself has an `exception` attribute that holds the caught exception so that you can inspect further. I'm not very familiar with GC process but I guess the contextmanager isn't deleted until end of scope, even if it is named as in `with ... as ...`. See [the docs](https://docs.python.org/2/library/unittest.html#unittest.TestCase.assertRaises) of `assertRaises`. – 4ae1e1 Apr 26 '15 at 20:04
  • @4ae1e1: At first that's what I thought, but the exception object is different from the traceback object. The exception object iself doesn't contain all the references to stack frames, so just storing that shouldn't matter. – BrenBarn Apr 26 '15 at 20:10
  • Hmm, looking at the source code of [`ConfigParser.ConfigParser._read`](https://hg.python.org/cpython/file/050e0c0b3d90/Lib/ConfigParser.py#l464), the exception objects only contain `fpname` and possibly the line and line numbers, so as you said the file object `fp` shouldn't in an exception object. Confused. – 4ae1e1 Apr 26 '15 at 20:14
  • @4ae1e1: I added an addendum to my answer. It appears that if the context manager handles an exception, all the exception info (including the traceback) persists until the end of the function containing the `with` block. – BrenBarn Apr 26 '15 at 20:16
  • Thank you for the `sys.exc_clear` trick and the detailed explanation of exception handling within context manager! I learned quite a bit. I upvoted the answer, but not accepting it just yet to see if there are any better solutions (I doubt it). – 4ae1e1 Apr 26 '15 at 20:21
  • I won't use it in production though since an unimportant case isn't worth so much trouble (sorry); I'll just write a `try: os.remove(path) except OSError: pass`. The problem only occurs on Windows (Unix doesn't care about open file descriptors before removing the file), which is tested on CI only (my local environment is OS X), in which case a dangling temp file is okay. Well, even on a dev machine a dangling temp file of a few bytes doesn't really matter — just a matter of aesthetics. – 4ae1e1 Apr 26 '15 at 20:21
  • @4ae1e1: I added one more note to the end of my answer. A simple `sys.exc_clear()` call right before the `os.remove` might do the trick while adding minimal clutter to the code. – BrenBarn Apr 26 '15 at 20:23
  • Sorry, I wasted quite a bit of time setting up the full dev environment on Windows. Yes, a simple `sys.exc_clear()` at the end works locally, but I was thinking about whether that might interfere with proper reporting of other failures. It appears that it won't after some thinking. I'll come back and accept if when the solution also passes on CI. – 4ae1e1 Apr 26 '15 at 20:50