forked from frictionlessdata/frictionless-py
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
frictionlessdata#1622: Fix issues with nested context manager calls
# Summary ## Problem statement The `Resource` class is also a [Context Manager](https://docs.python.org/3/reference/datamodel.html#context-managers). That is, it implements the `__enter()__` and `__exit()__` methods to allow the use of `with Resource(...)` statements. Prior to this PR, there was no limit on nesting `with` statements on the same `Resource`, but this caused problems because while the second `__enter()__` allowed the `Resource` to already be open, the first `__exit()__` would `close()` the Resource while the higher level context would expect it to still be open. This would cause errors like "ValueError: I/O operation on closed file", or the iterator would appear to start from part way through a file rather than at the start of the file, and other similar behaviour depending on the exact locations of the nested functions. This was made more complex because these `with` statements were often far removed from each other in the code, hidden behind iterators driven by generators, etc. They also could have different behaviour depending on number of rows read, the type of Resource (local file vs inline, etc.), the different steps in a pipeline, etc. etc. All this meant that the problem was rare, hard to reduce down to an obvious reproduction case, and not realistic to expect developers to understand while developing new functionality. ## Solution This PR prevents nested contexts being created by throwing an exception when the second, nested, `with` is attempted. This means that code that risks these issues can be quickly identified and resolved during development. The best way to resolve it is to use `Resource.to_copy()` to copy so that the nested `with` is acting on an independent view of the same Resource, which is likely what is intended in most cases anyway. This PR also updates a number of the internal uses of `with` to work on a copy of the Resource they are passed so that they are independent of any external code and what it might have done with the Resource prior to the library methods being called. ## Breaking Change This is technically a breaking change as any external code that was developed using nested `with` statements - possibly deliberately, but more likely unknowingly not falling into the error cases - will have to be updated to use `to_copy()` or similar. However, the library functions have all been updated in a way that doesn't change their signature or their expected behaviour as documented by the unit tests. All pre-existing unit tests pass with no changes, and added unit tests for the specific updated behaviour do not require any unusual constructs. It is still possible that some undocumented and untested side effect behaviours are different than before and any code relying on those may also be affected (e.g. `to_petl()` iterators are now independent rather than causing changes in each other) So it is likely that very few actual impacts will occur in real world code, and the exception thrown does it's best to explain the issue and suggest resolutions. # Tests - All existing unit tests run and pass unchanged - New unit tests were added to cover the updated behaviour - These unit tests were confirmed to fail without the updates in this PR (where appropriate). - These unit tests now pass with the updated code. - The original script that identified the issue in frictionlessdata#1622 was run and now gives the correct result (all rows appropriately converted and saved to file)
- Loading branch information
1 parent
ae3763d
commit 985cfc0
Showing
14 changed files
with
274 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
from frictionless import Resource, FrictionlessException | ||
import pytest | ||
|
||
# Test that the context manager implementation works correctly | ||
|
||
# As per PEP-343, the context manager should be a single-use object (like files) | ||
# See https://peps.python.org/pep-0343/#caching-context-managers | ||
|
||
|
||
def test_context_manager_opens_resource(): | ||
with Resource("data/table.csv") as resource: | ||
assert resource.closed is False | ||
|
||
|
||
def test_context_manager_closes_resource(): | ||
with Resource("data/table.csv") as resource: | ||
pass | ||
assert resource.closed is True | ||
|
||
|
||
def test_context_manager_returns_same_resource(): | ||
resource = Resource("data/table.csv") | ||
with resource as context_manager_return_value: | ||
assert resource == context_manager_return_value | ||
|
||
|
||
def test_nested_context_causes_exception(): | ||
with pytest.raises(FrictionlessException): | ||
# Create nested with statements to test that we can't open | ||
# the same resource twice via context managers | ||
with Resource("data/table.csv") as resource: | ||
with resource: | ||
pass | ||
|
||
|
||
def test_resource_copy_can_use_nested_context(): | ||
# Create nested with statements to test that we can still open | ||
# the same resource twice via context if we copy the resource | ||
# before the second `with` | ||
with Resource("data/table.csv") as resource: | ||
copy = resource.to_copy() | ||
with copy: | ||
assert (copy.closed is False) | ||
assert (resource.closed is False) | ||
|
||
# Check that the original resource is still open | ||
assert (copy.closed is True) | ||
assert (resource.closed is False) | ||
|
||
|
||
def test_resource_can_use_repeated_non_nested_contexts(): | ||
# Repeat context allowed | ||
resource = Resource("data/table.csv") | ||
with resource: | ||
assert (resource.closed is False) | ||
|
||
assert (resource.closed is True) | ||
|
||
with resource: | ||
assert (resource.closed is False) | ||
assert (resource.closed is True) | ||
|
||
|
||
def test_resource_copy_can_use_repeated_context(): | ||
# Repeated context with a copy is allowed | ||
resource = Resource("data/table.csv") | ||
copy = resource.to_copy() | ||
with resource: | ||
assert (resource.closed is False) | ||
assert (copy.closed is True) | ||
|
||
with copy: | ||
assert (resource.closed is True) | ||
assert (copy.closed is False) | ||
|
||
|
||
def test_context_manager_on_open_resource_throw_exception(): | ||
""" | ||
Using the Resource in a `with` statement after it has been opened will unexpectedly close the resource | ||
at the end of the context. So this is prevented by throwing an exception. | ||
""" | ||
resource = Resource("data/table.csv") | ||
resource.open() | ||
assert (resource.closed is False) | ||
with pytest.raises(FrictionlessException): | ||
with resource: | ||
pass | ||
|
||
|
||
def test_explicit_open_can_be_repeated(): | ||
# Explicit open can be nested | ||
# Note that the first close() call will close the resource, so anyone | ||
# using explicit open() calls must be aware of that. | ||
resource = Resource("data/table.csv") | ||
resource.open() | ||
assert (resource.closed is False) | ||
resource.open() | ||
assert (resource.closed is False) | ||
resource.close() | ||
assert (resource.closed is True) | ||
resource.close() | ||
assert (resource.closed is True) |
Oops, something went wrong.