Where the batch is
The hackathon is closed. 58 submissions arrived, 42 of them on time, and five will be picked as winners. Everyone who submitted gets a certificate, and those certificates will carry a public link you can share. The winners, the full report and a write-up of the strongest ideas are being published separately, with conversations with the winners to follow.
The themes people built around, without attributing any of them: browser-driving agents, API testing agents, requirement-to-test-case pipelines, locator helpers, defect leakage analysis, ATS resume tooling, RAG explorers, Jira-to-test-case generators, release risk analysis, and flaky-test diagnosis with root-cause suggestions.
This session closes Python. Next comes deep eval, LangChain, Crew.ai and MCP, and the reason for spending this long on the language was stated plainly: when an LLM writes your evaluation code, you are the one who has to read it and say whether it is right. That is not possible without knowing what a class, a decorator and an exception actually do.
Polymorphism, in two forms
One name, more than one behaviour. The class analogy was behaving differently in front of different people, which is the right intuition: same person, different form depending on context.
It arrives two ways, and Python only really has one of them.
Overloading: Python does not have it
Write two methods with the same name and Python does not complain. It also does not overload. The second def simply rebinds the name, and the first one is gone.
class Calculator:
def add(self, a, b):
return a + b
def add(self, a, b, c): # this one replaces the first
return a + b + c
calc = Calculator()
print(calc.add(2, 4, 6)) # 12
print(calc.add(2, 4)) # TypeError: Calculator.add() missing 1 required positional argument: 'c'
The two-argument call fails, because as far as Python is concerned only the three-argument version was ever defined. There is no warning, no error at definition time, and nothing in the editor to tell you the first method is dead.
The reason is worth knowing, because it explains a whole family of Python behaviour. def is not a declaration, it is an assignment. def add(...) creates a function object and binds it to the name add in the class body. A second def add rebinds the same name, exactly as x = 1 followed by x = 2 leaves you with 2. Nothing is overloaded because nothing was ever a signature in the first place.
What Python offers instead
Default parameters give one function a flexible arity, which covers most of what overloading was for:
class Calculator:
def add(self, a, b, c=0):
return a + b + c
calc = Calculator()
print(calc.add(2, 4)) # 6 c falls back to 0
print(calc.add(2, 4, 6)) # 12 c is supplied
The class demonstrated the same idea with a request builder, where the auth type and user default sensibly and callers override only what they care about.
Two more tools exist beyond what was covered, worth knowing they are there: *args and **kwargs accept any number of positional and keyword arguments, and functools.singledispatch gives genuine type-based dispatch, choosing an implementation from the type of the first argument. That last one is the closest Python gets to real overloading, and it lives in the standard library.
Overriding: this one works normally
class BaseTest:
def run(self):
print("Running the base test")
class LoginTest(BaseTest):
def run(self):
print("Running the login test")
class ApiTest(BaseTest):
def run(self):
print("Running the API test")
BaseTest().run() # Running the base test
LoginTest().run() # Running the login test
ApiTest().run() # Running the API test
The method that runs is decided by which class the object came from, not by the type of the variable holding it. Same shape as Java, and the same shape your test framework's base class uses.
Abstraction
Encapsulation, covered earlier in the course, is about keeping variables private inside a class and reaching them only through methods. Abstraction is a different thing: hiding the machinery entirely and exposing only what the caller needs.
The car analogy: engine, gearbox and brakes are all there, and you are handed a steering wheel. The class added a second framing that lands harder for inheritance: an abstract method is an unpaid debt. The parent declares it and does not implement it. Any child that inherits has to settle it.
Python does this with the abc module:
from abc import ABC, abstractmethod
class Animal(ABC):
def __init__(self, name):
self.name = name
@abstractmethod
def sound(self):
pass # deliberately incomplete
class Dog(Animal):
def sound(self):
print("Bark")
Dog("Rex").sound() # Bark
Animal("Generic") # TypeError: Can't instantiate abstract class Animal
# without an implementation for abstract method 'sound'
Three parts, all required:
ABCis the base class, imported from theabcpackage.@abstractmethodis a decorator. It attaches to the method below it and marks it incomplete.passis the placeholder body, because Python has no empty blocks.
Leave the method out of the subclass and the error arrives when you try to instantiate, not when you define the class. That is the safety net: an incomplete implementation cannot be constructed.
The automation shape it maps onto: a BrowserManager with start() abstract and stop() concrete, a Chrome subclass implementing start(), and a test that calls neither directly.
Static versus instance
A class attribute is shared by every object of the class. Change it through one, and every other sees the change. The blackboard analogy from the session is exact: one board, many people reading it.
class TestCounter:
count = 0 # class attribute, shared
def __init__(self):
TestCounter.count += 1
TestCounter(); TestCounter()
print(TestCounter.count) # 2
An attribute assigned on self inside __init__ is the opposite: it belongs to that one object, and no other object knows it exists.
Python has no static keyword. A variable declared in the class body simply is shared, and a method is made static with a decorator:
class MathUtils:
@staticmethod
def add(a, b):
return a + b
def divide(self, a, b): # ordinary instance method
return a / b
print(MathUtils.add(2, 3)) # 5, no object needed
print(MathUtils().divide(10, 2)) # 5.0, object required
The practical difference is the one to remember: a static method is callable straight off the class name. For utility code (an Excel reader, a formatter, a helper) that is exactly what you want.
Calling a static method through an instance also works, and the class advised against it. MathUtils().add(2, 3) runs fine, and it misleads every reader into thinking there is object state involved. Reach for the class name.
The access modifiers from the earlier session still apply, and all three are conventions plus one piece of real machinery:
| Written as | Means | Enforced? |
|---|---|---|
name |
public | n/a |
_name |
protected, internal use | convention only, nothing stops you |
__name |
private | name-mangled to _ClassName__name, so it is genuinely awkward to reach |
Exceptions
An exception is an event that interrupts the normal flow. Python's hierarchy runs BaseException, then Exception, then the specific errors underneath.
The ones you will actually meet:
| Error | Cause |
|---|---|
NameError |
using a variable that was never assigned |
TypeError |
an operation between incompatible types, such as 1 + "a" |
ValueError |
right type, impossible value, such as int("abc") |
IndexError |
an index past the end of a sequence |
ZeroDivisionError |
division by zero |
FileNotFoundError |
opening a file that is not there |
SyntaxError |
malformed code, raised before anything runs |
Do not carry over checked and unchecked exceptions from Java. Python has no such distinction. Nothing is declared, nothing is forced, and no method signature tells you what might be raised. The trade is flexibility for a compiler that will not catch an unhandled case for you.
The four blocks
try:
a = int(input("Enter a number: "))
b = int(input("Enter another: "))
c = a / b
except ZeroDivisionError:
print("Cannot divide by zero")
except ValueError:
print("That was not a number")
else:
print(f"Result: {c}") # only if try finished cleanly
finally:
print("Done") # always, either way
else is the part that catches Java developers out, and it is genuinely useful once it clicks: else runs only when try completed without raising. It is where the success path goes, which keeps the try block down to just the lines that can actually fail. The narrower the try, the less chance of catching an error you did not mean to.
finally runs in every case, exception or not, which is where cleanup belongs.
Multiple handlers are allowed, and a single handler can cover several types:
except (ValueError, TypeError, ZeroDivisionError) as e:
print(f"Failed: {e}")
Raising your own
def login(user):
if user != "admin":
raise Exception("Access denied")
return "Welcome admin"
print(login("admin")) # Welcome admin
Custom exception classes are possible by subclassing Exception, and the honest note attached in class was that in practice you will rarely define your own. Built-in types cover almost everything.
Modules and packages
A module is one .py file. A package is a directory of them, and the __init__.py file is what makes it a regular package.
from mypackage import util_module # package, a folder with __init__.py
import mymodule # module, a single file
mymodule.greet("Pramod")
util_module.blah()
That is exactly the shape of the abc import at the top of this page: abc is a package, and ABC and abstractmethod are things inside it.
One correction worth carrying, because the old rule is repeated everywhere and stopped being strictly true in Python 3.3. A directory without __init__.py still imports: it becomes a namespace package, and from nopkg import m works fine. Verified on 3.12. So __init__.py is not what makes a directory importable any more. It is still what you want, for three reasons: it runs package initialisation code, it controls what from package import * exposes, and it stops two same-named directories on the path from silently merging into one package. Write it. Just know that its absence is not the reason your import failed.
Three kinds of module, and the difference matters when something will not import: built-in (ships with Python), user-defined (yours), and external (installed with pip).
The os module is the one that will come up most in QA work, for log directories, environment variables and test data paths:
import os
print(os.name) # 'posix' on macOS and Linux, 'nt' on Windows
print(os.getcwd()) # current working directory
os.makedirs("ai", exist_ok=True)
print(os.listdir("."))
pip is the package manager that installs the external ones. Requests, Flask, Django, NumPy, Pandas, Transformers, LangChain and Selenium are all packages in exactly this sense, and so is deep eval, which is where the course goes next. It is a folder of classes you install and import, and by this point that sentence should be completely unmysterious.
Tasks and announcements
- Today's task: find the built-in Python modules, work out roughly how many there are, and post what you find in the comments with the ones a tester would actually use.
- Finish the exercises. The running set is past 170.
- Post at least one doubt in the doubt thread.
- Tomorrow: the last of Python, collections included, and then Crew.ai and the first AI agents.
- Then: deep eval, LangChain and MCP, with one to two weeks budgeted for the remainder.
- Tuesday 25 August and Thursday 27 August, 8:00 PM IST: AI Fluency certification, parts one and two.
- Claude 101 should be finished if it is not already. Claude Code 101 is targeted the week after next, and it needs a paid Claude plan.
- Hackathon results in about a week, with winners, the strongest ideas, and shareable certificates for everyone who submitted.
Interview forms of today's material: what happens when a Python class defines two methods with the same name, and why? Name the difference between encapsulation and abstraction in one sentence each. When does an else block on a try run, and when does finally? What makes a directory a package rather than a folder? Why can a static method be called without an object?