Meet the dunders
Methods named with two underscores on each side, like __init__, are special methods, or dunder methods (double underscore). You rarely call them yourself: Python calls them for you when you write Coins(3), a + b or print(c).
Coins at work
Running coins.py, Python first defines the class. Line 8 calls the class like a function: Coins(3). Python makes a new, empty Coins object, then calls its __init__ method with that object as self and 3 as n.
__init__ runs line 3: self.n = n stores 3 inside the new object, as its attribute n. __init__ returns nothing; Coins(3) gives back the object, and line 8 names it a.
Line 9 does it all again: __init__ runs for a second object, which gets its own n = 4 and the name b. Each object keeps its own attributes, so a.n is still 3.
Line 10 says a + b. For +, Python looks up the special method __add__ on the class of a and calls it: a.__add__(b). Numbers and strings have __add__ too; Coins gets its own on line 4.
Inside __add__, self is a and o is b. self.n + o.n is 3 + 4 = 7, and Coins(7) makes a brand-new object (calling __init__ again). It is returned, and line 10 names it c.
print(c) needs text, so it calls str(c). Coins has no __str__, so Python falls back to __repr__, which returns the string Coins(7). That is what appears in the terminal.
Take one away
Take away __repr__ and print(c) still works, but shows the default every object inherits: its class name and a memory address, like <__main__.Coins object at 0x…>. A __repr__ gives your objects a readable face.
Take away __add__ instead and line 10 fails. Python finds no special method that adds two Coins, so a + b raises a TypeError. The special method is what makes + work for your class.
Try it yourself
;; step 1/9 # a special method has two underscores on each side: __init__ __add__ __repr__ __len__ __eq__ # you write ordinary syntax ... len(x) x + y print(x) # ... and Python calls the special method: x.__len__() x.__add__(y) x.__str__()
Strictly, Python looks the special method up on the class, not on the object: a + b runs type(a).__add__(a, b). If that is missing (or returns NotImplemented) and b is of a different type, Python tries type(b).__radd__(b, a) before raising TypeError. For two Coins, __radd__ is never tried. The figure shortens the traceback (it leaves out the File line and the ~~^~~ marker) and wraps the long TypeError line to fit its small terminal; the Try it panel shows the full text. $ is the terminal's own prompt.