Setting up
Some things must be undone after use: close a file, release a lock, turn the lights off. A context manager is an object that does the set-up and the clean-up, and the with statement makes sure the clean-up runs.
Lines 1 to 7 only define the class. The program starts at line 9. with first runs Lights(), making a new object: the context manager. Python keeps it, and looks up its two special methods, __enter__ and __exit__.
Before the indented block runs, with calls the manager's __enter__ method. You never call it yourself: the with statement does. Names with two underscores on each side are special methods that Python calls for you.
__enter__ does the set-up. Here it prints lights on; the lamp stands for the resource being in use. In a real program this is where a file opens or a lock is taken.
__enter__ returns 'room', and as r stores that return value in r. So r is whatever __enter__ returns, not always the manager. A file's __enter__ returns the file itself.
Now the indented block runs, while the resource is set up. print() puts one space between its items, so the terminal shows in the room.
Cleaning up
Line 11 divides by zero, so Python raises ZeroDivisionError and the block stops at once. Before the error goes anywhere, with calls __exit__ and hands it the error's type, the error itself and its traceback.
__exit__ does the clean-up: lights off. It runs however the block ends: at its last line, through an error, or by return, break or continue. That promise is the whole point of with.
__exit__ returns False, which means: do not swallow the error. So the error carries on out of the with statement and stops the program with a traceback. The lights are already off.
Variations
Change line 7 to return True and __exit__ swallows the error: no traceback, and the program carries on after the with block. contextlib.suppress does this for the errors you name. Use it sparingly; hidden errors are hard to find.
@contextmanager builds the same manager from a generator. Code before yield is the set-up, the yielded value goes to as, and the finally part after yield is the clean-up. Same output, same traceback.
Try it yourself
;; step 1/11
# without with, the clean-up is easy to miss:
f = open("notes.txt", "w")
f.write("hi")
f.close() # skipped if write() fails
# with closes the file, whatever happens:
with open("notes.txt", "w") as f:
f.write("hi")
__exit__ always gets three arguments after self. When the block ends without an error all three are None; on an error they are the exception's type, the exception and its traceback. The figure shortens the traceback. The real one shows the full path to lights.py, then line 11 itself with ~~^~~ marks under the division.