Python does not care about checking types, inheritance relationships, etc. ahead of time. It only cares what happens when the code is run. This allows for what is called "duck typing":
class Employee:
def fired(self):
print("Oh no, I have to look for a new job")
class Gun:
def fired(self):
print("Bang! Did the bullet hit the target?")
for thing in [Employee(), Gun()]:
thing.fired()
It does not matter that the classes Employee and Gun have nothing to do with each other, and it does not matter that the purpose of fired is completely different. Both of the objects in the list have a method that is named fired, so the code works without error.
Similarly in your code:
class emp:
def __init__(self,fname,lname,empno):
self.empno=empno
person.__init__(self,fname,lname)
If we call person.__init__, then that is just a function that we found inside the person class - not a method that we looked up on an object. This is because we used a class on the left-hand side of ., not an instance. That function will happily accept an emp instance as the value for self - it does not care whether that self is a person instance. It's a user-defined object, so there is no problem setting fname and lname attributes.
After that, the printper method will have no problem finding fname and lname attributes on the emp instance - because they are there.
"So why bother ever inheriting at all, then?"
Because even though this "works", it still doesn't actually make an inheritance relationship. That has some consequences:
super does not work - the __init__ call had to say explicitly what other class to use, and the code doesn't really make sense. From an outside perspective, it's sheer coincidence that it sets the right attributes.
isinstance will not work, by default. emp is not in the __bases__, nor the __mro__ of person.