with and the protocol
The with statement solves a specific problem: guaranteed cleanup after a block of code, even if an exception is raised inside it. It relies on two methods: __enter__ runs at the start of the block, __exit__ runs at the end no matter what.
The canonical example is a file. with open(path) as f: ... opens the file at the start of the block and closes it at the end. The with statement promises the close runs no matter what happens inside.
Before with, the equivalent code was try / finally with explicit cleanup. The with form is shorter, harder to forget, and clearer about which lines are inside the resource and which are not. Reach for it whenever cleanup is part of using a thing.