Define a context function that returns the wrapped object if outside of context

I want something similar to a function wrapped with contextlib.contextmanger but can optionally return the a non-context object if used outside of a context call (e.g., with or __enter__() )

A silly example is this mutability switch.

def foobar(wrapped: tuple) -> AbstractContextManager | tuple:
    if ... :   # WHAT GOES HERE?
        # Handles context called (e.g., `with` is used)
        mutable_wrapped = list(wrapped)
        try:
            yield mutable_wrapped
        finally:
            return tuple(mutable_wrapped)
    else:
        return tuple(wrapped)


a = (1,2,3)

# Behavior using context
with foobar(a) as b:
    b.append(4)

assert b is not a
assert baz == (1,2,3,4)

# Behavior not using context
c = foobar(a)
assert c is not a
assert c == a

My real use case is to design a immutable nested data object that can only be mutated during a copy (in context).

The code example above is very wrong. Python does not support returning on __exit__() and you can only change the yielded object by changing its internal state (nb: adding this could be a PEP).

Regardless, I hope the example at least present my intentions.

I think you might just need a class that defines __enter__ and __call__. I don’t think you can do this with one function.

But a “working” example (i.e. one that would work and make sense, if this thing worked) might help clarify your intent.

Here’s a working example that involves storing a lot of private state (undesired) and have a few undesirables:

class MyTuple():
  def __init__(self, *args, _copied=False):
    self._data = list(args)
    self._copied = _copied 
    self._in_context = False
  
  def append(self, value):
    if self._copied and self._in_context:
      self._data.append(value)
    else:
      raise TypeError("MyTuple cannot append outside of a 'with self.copy() as copied:")
  
  def __enter__(self):
    self._in_context = True
    return self
  
  def __exit__(self, exc_type, exc_val, exc_tb):
    self._in_context = False
    self._mutable_in_context = False
  
  def __eq__(self, other):
    return self._data == other._data

  def copy(self):
    return MyTuple(*self._data, _copied=True)
  
a = MyTuple(1,2,3)

# Intended behavior
with a.copy() as b:
  b.append(4)

assert b is not a
assert b == MyTuple(1,2,3,4)

# Should raise TypeError
with a as c:
  c.append(4)

Undesirables:

  • MyTuple has to define __enter__ and __exit__ which I feel it doesn’t belong
  • Need to maintain private state variables
  • Awkward constructor argument of _copied

To me it’s seems a lot of work just to allow this special behavior when copy() is called.

This is a pretty different example, in the sense that you actually modify the data in place rather than returning a new copy. In your original example b would be a list, not a new tuple.

But have you considered subclassing tuple itself:

from contextlib import contextmanager

class MyTuple(tuple):
    @contextmanager
    def copy(self):
        yield list(self)


a = MyTuple((1,2,3))  # just like tuple(), this takes one arg

# Intended behavior
with a.copy() as b:
  b.append(4)

assert b is not a
assert b == MyTuple((1,2,3,4))  # this fails
assert b == [1,2,3,4]  # this works
assert tuple(b) == MyTuple((1,2,3,4))  # or this


# raises TypeError
with a as c:
  c.append(4)

So I was wrong, you can do this with one function, if “this” is what you wrote in the OP, not the follow-up :wink:

The examples being different doesn’t change intent I introduced in the OP—to return a non-context object if outside the context call.

Thank you for taking the time to make an example. Your implementation will not allow the following:

b = a.copy()

assert b is not a
assert b==a

I don’t think there’s a way for the method to know it is being used in a with statement, so you can’t have one method that does both of these things. You can use a different method to copy, like a[:], for those cases.

1 Like

This StackOverflow question introduces more reason that I agree with. It seems there is no way around needing more than 1 call syntax with the exception of

with factory.create() as product:
  product.a = 1

with factory.create(a=2) as product:
  pass

Other solutions requires multiple call syntax:

# Defining a dedicated context method
with factory.create_with_context() as product:
  product.a = 1

product = factory.create(a=2)

I especially love the last answer which gives you:

# Take advantage of @property
with factory.create as product:
  product.a = 1

product = factory.create(a=2)