Skip to content

About

This introduction to python 3.10+ for beginners has been created by FALAMLIH

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

26 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 

Repository files navigation

Python 3.10+ — A Beginner's Guide

Notes compiled and expanded by FALAMLIH


Table of Contents

  1. Unicode & Encoding
  2. What is Python?
  3. From Code to Result: The Interpreter Pipeline
  4. Keywords & Comments
  5. Control Flow
  6. Data Types: The Collections
  7. Other Built-in Data Types
  8. Useful Built-in Functions & Variable Basics
  9. Operators
  10. Errors & Exception Handling
  11. Modules & Packages
  12. Generators & Comprehensions
  13. File I/O & Context Managers
  14. Object-Oriented Programming (OOP)
  15. Scope & Namespaces
  16. Polymorphism, Abstraction & Protocols
  17. Virtual Environments, .env & Poetry
  18. Pydantic: Data Validation
  19. Lambda Functions
  20. Functions in Depth
  21. Types of Methods & Special (Dunder) Methods
  22. Advanced Python
  23. Further Reading

1. Unicode & Encoding

Unicode is a universal character-encoding standard that assigns a unique numeric value — a code point — to virtually every character, symbol, and emoji across all the world's writing systems.

  • A code point is usually written as U+XXXX (e.g. U+0041 is A).
  • UTF-8 is the default source-code encoding in Python 3 — it's a variable-width encoding (1–4 bytes per character) that is backward-compatible with ASCII.
print(ord("A"))      # 65  -> character to code point
print(chr(65))        # 'A' -> code point to character
print("café".encode("utf-8"))   # b'caf\xc3\xa9'

2. What is Python?

#!/usr/bin/python3

Python is a general-purpose, interpreted, interactive, object-oriented, high-level programming language. It is:

  • Dynamically typed — variable types are checked at runtime, not compile time.
  • Garbage-collected — memory is managed automatically.
  • Object-oriented — everything in Python (numbers, functions, classes, modules) is an object.

Python source code is not compiled to machine code. Instead, it runs through an interpreter at runtime, so there's no separate build step before execution.

The interpreter's internal components

Component Role
Parser Reads source code and checks its syntax, building a parse tree
Compiler Translates the parse tree into bytecode
Bytecode Generator Produces the .pyc-style bytecode instructions
Python Virtual Machine (PVM) Executes the bytecode instruction by instruction
Garbage Collector Reclaims memory occupied by unreachable objects
Memory Manager Allocates and tracks memory for Python objects
Standard Library Ships with the interpreter; provides built-in modules (os, sys, json...)

Interpreter vs. PVM: The interpreter (python / python3) is the whole program you install and run. The PVM is the component inside the interpreter that actually executes bytecode. The CPU never runs Python bytecode directly — it executes the interpreter's own compiled machine code, which in turn carries out what the bytecode says.

What you can build with Python

  • Web development (Django, Flask, FastAPI)
  • Data analysis & machine learning (pandas, NumPy, scikit-learn, PyTorch)
  • Automation & scripting (system administration, CI/CD)
  • Software testing (pytest, unittest)
  • Desktop apps, game prototyping, embedded scripting, and more

Virtual environments (preview — see §17 for details)

A virtual environment is a self-contained, isolated directory tree holding its own Python installation and its own set of installed packages, so a project's dependencies never collide with another project's or the system-wide Python.


3. From Code to Result: The Interpreter Pipeline

Running a Python program goes through three broad stages:

  1. code.py — the file containing your Python source code.
  2. Compilation to bytecode — the interpreter translates the source into bytecode, a lower-level, platform-independent instruction set. (Bytecode is not machine code — machine code is executed directly by the CPU; bytecode is executed by a virtual machine.)
  3. Execution by the PVM — the Python Virtual Machine runs an interpreter loop that reads each bytecode instruction and carries it out on the host machine.

Along the way, the PVM also:

  • Maintains a Python object model representing every data type (ints, strings, lists, custom objects, ...) and manages their creation, mutation, and destruction.
  • Handles memory allocation and garbage collection dynamically.
  • Uses reference counting plus a cyclic garbage collector — the latter specifically detects and cleans up circular references (objects that reference each other and would otherwise never hit a reference count of zero), preventing memory leaks.
 code.py  ──compile──▶  bytecode  ──PVM interprets──▶  result

4. Keywords & Comments

Reserved keywords (Python 3.10+)

and       as        assert    async     await     break     class
continue  def       del       elif      else      except    False
finally   for       from      global    if        import    in
is        lambda    None      nonlocal  not       or        pass
raise     return    True      try       while     with      yield

Python 3.10 also introduced the soft keywords match, case, and _, which only act as keywords inside a match statement — they remain valid identifiers elsewhere.

Comments

# This is a single-line comment

"""
This is a multi-line string,
commonly used as a comment block
or as a docstring when placed at the
top of a module/function/class.
"""

5. Control Flow

Statement Purpose
if / elif / else Branch based on a condition
for ... else Loop over a sequence; the else runs only if the loop completes without hitting break
while ... else Loop while a condition holds; same else rule as above
break Terminates the loop entirely
continue Skips the rest of the current iteration and jumps to the next one
pass A null operation — does nothing, used as a placeholder
return Exits the current function, optionally sending back a value
for num in numbers:
    total += num
else:
    print("Loop finished without break")

match / case (structural pattern matching, Python 3.10+)

Similar to switch / case in C, C++, or Java, but far more powerful — it can match on structure, not just values.

match variable_name:
    case "pattern 1":
        statement_1()
    case "pattern 2":
        statement_2()
    case [x, y]:            # matches a 2-element sequence, unpacks it
        print(x, y)
    case {"key": value}:    # matches a dict containing "key"
        print(value)
    case _:                 # wildcard, like `default`
        print("no match")

6. Data Types: The Collections

Mutability

  • Immutable objects (their internal state can't change): numbers, strings, tuples, frozensets. You can't add or remove elements — any "modification" actually creates a new object.
  • Mutable objects (state can change in place): lists, sets, dictionaries.

Overview map

                          Python collections
                                │
                ┌───────────────┴───────────────┐
                │                                │
            Sequences                       Non-sequences
                │                                │
        ┌───────┼───────┐                ┌───────┴───────┐
        ↓       ↓       ↓                ↓               ↓
      list    tuple    str             dict             set
        │       │       │                │               │
     ordered ordered ordered         ordered by       unordered,
     indexed indexed indexed         insertion,       membership-
     mutable immutable immutable     keyed, mutable   based, mutable

Comparison table

Type Ordered Mutable Duplicates Indexing/Slicing Literal
list ✅ ✅ ✅ ✅ [1, 2, 3]
tuple ✅ ❌ ✅ ✅ (1, 2, 3)
set ❌ ✅ ❌ ❌ {1, 2, 3}
dict ✅ (insertion order, 3.7+) ✅ ❌ (keys) ❌ (by key, not position) {"a": 1}

Lists []

An ordered, mutable collection that can hold mixed data types.

fruits = [1, 2, 3, 4, 5]
fruits.append(6)          # add to the end — you don't insert by index
fruits.insert(0, 0)       # insert at a specific position
fruits.remove(3)          # remove the first occurrence of a value
  • Allows duplicates.
  • Supports indexing and slicing: seq[start:stop:step] selects elements from start up to (but excluding) stop, moving step at a time.
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(nums[2:7:2])   # [2, 4, 6]
print(nums[::-1])    # reversed list

Sets {}

An unordered collection of unique elements — duplicates are silently dropped. Sets do not support indexing or slicing (they're not a sequence).

a = {1, 2, 3}
b = {2, 3, 4}

a.intersection(b)   # {2, 3}   — elements common to both
a.union(b)           # {1, 2, 3, 4} — all elements from both
a.difference(b)      # {1}      — elements only in a
a.symmetric_difference(b)  # {1, 4} — in either, but not both

Tuples ()

An ordered, immutable collection. Once created, elements cannot be modified. Allows duplicates and supports indexing/slicing.

point = (1, 2, 3, 4, 5)
  • Tuples are hashable (as long as every element inside is also hashable) and can therefore be used as dictionary keys — lists cannot, because they're mutable.

    Hashable = something Python can "lock in a box" and trust it will never change, so it can safely compute a fixed hash value for it.

  • Because they're immutable, tuples help protect the structure of your data — great for fixed records like coordinates or RGB values.

Unpacking: assigning a collection's elements to multiple variables in one line.

x, y, z = (1, 2, 3)
first, *rest = [1, 2, 3, 4]   # first = 1, rest = [2, 3, 4]

Dictionaries {}

A non-homogeneous structure storing key–value pairs. Keys must be unique and hashable; dictionaries are ordered (by insertion, since Python 3.7) but not a sequence — you can't index by position.

bag = {1: "a", 2: "b", 3: "c"}

bag.keys()      # dict_keys([1, 2, 3])
bag.values()    # dict_values(['a', 'b', 'c'])
bag.items()     # dict_items([(1, 'a'), (2, 'b'), (3, 'c')])

for item, quantity in bag.items():
    print(item, quantity)
  • get(key, default): safely reads a value without crashing.

    "If the key exists → give me its value. If it doesn't → give me this default."

    bag.get("apple", 0)   # returns 0 if "apple" isn't a key
  • update(): adds or overwrites multiple key–value pairs at once.
    bag.update({"apple": 5, "pear": 2})
  • Nested dictionaries: use .items() at each level to drill down.
    print(players.items())
    print(players["alice"].items())
    print(players["bob"]["items"].items())

7. Other Built-in Data Types

Type Description Example
int Signed integers, arbitrary precision 42
float Floating-point real numbers 3.14
complex Complex numbers 5 + 6j
str Sequence of Unicode characters (non-numeric) "hello"
bool True / False (a subtype of int) True
bytes Immutable sequence of bytes b"abc"
bytearray Mutable sequence of bytes bytearray(b"abc")
memoryview A view into the memory of another binary object, without copying it memoryview(b"abc")
range An immutable, memory-efficient sequence of numbers range(start, stop, step)
NoneType The null type — represents the absence of a value None

On None and empty values

  • An empty string "" is not None — it is a valid, "empty" string, unlike some languages where an uninitialized string might be null.
  • None is not 0, not 0.0, and not False — they are different objects of different types.
  • Comparing None == anything returns False, except None == None, which is True. (Prefer is None / is not None over == for this check.)

Raw strings

A raw string is created by prefixing the literal with r or R. It tells Python to treat backslashes as literal characters rather than escape sequences — very useful for regex patterns and Windows file paths.

raw_string = r"This is a newline:\n"
print(raw_string)   # This is a newline:\n   (no actual newline is produced)

Bytes, bytearray & memoryview

b = bytes([104, 105])       # b'hi'   — immutable
ba = bytearray(b"hi")       # mutable copy — ba[0] = 72 is allowed
mv = memoryview(b"hello")   # zero-copy view into the buffer

Dictionary construction

# Using curly braces
my_dict = {"name": "Jake", "age": 22, "city": "London"}

# Using the dict() constructor
another_dict = dict(brand="Ford", model="Mustang", year=1964)

# Initializing a dict with default values from a list of keys
keys = ["A", "B", "C", "D"]
graph = dict.fromkeys(keys, [])

⚠️ Gotcha: dict.fromkeys(keys, []) makes every key point to the same list object. Mutating one entry's list will affect all of them. If you need independent lists, build the dict with a comprehension instead: {k: [] for k in keys}.


8. Useful Built-in Functions & Variable Basics

Function / Concept Purpose
input() Reads from standard input
print() Writes to standard output
id(obj) Returns the memory address ("identity") of an object
del name Deletes a variable (or removes an item from a collection)
type(obj) Returns the type of an object
a, b, c = 1, 2, "text" Declares multiple variables in a single line

Type casting

a = 3
a = str(a)   # a is now '3'

Casting converts a value from one data type to another so it becomes compatible with the operation you need. One common trick: cast a string to a list, mutate it with insert(), append(), or remove(), then join it back into a string.

letters = list("hello")
letters.append("!")
"".join(letters)   # 'hello!'

Variable and object rules

  • A variable name must start with a letter or an underscore (_), and can contain letters, digits, and underscores after that (but not start with a digit).
  • Local variables are defined inside a function and only exist while that function runs.
  • Python has no formally defined const keyword — "constants" are a naming convention (e.g. MAX_SIZE = 100), not enforced by the language.
  • A Python variable is a name that refers to an object, not a memory location itself — think of variables as labels/tags attached to objects, not boxes that hold values.
  • An object is stored in memory only once; multiple variables can reference the same object (id(a) == id(b)).
  • Private variables: prefixing an attribute with a double underscore (__name) triggers name mangling, making it awkward to access from outside the class without going through a method (see §14).

Functions & Lambdas (preview)

def function_name(parameters):
    ...

# lambda: any number of arguments, exactly one expression
total = lambda arg1, arg2: arg1 + arg2
print("Value of total:", total(10, 20))

enumerate()

Iterates over an iterable while automatically tracking the index, returning (index, element) pairs — no manual counter needed.

for index, value in enumerate(["a", "b", "c"]):
    print(index, value)
# 0 a
# 1 b
# 2 c

for index, value in enumerate(["a", "b", "c"], start=1):
    print(index, value)   # start counting from 1

📚 More on function arguments (positional, arbitrary, keyword, default): tutorialspoint.com/python/python_functions.htm


9. Operators

Category Operators
Arithmetic + - * / % (modulus) ** (exponent) // (floor division, e.g. 9 // 2 = 4)
Comparison == != > < >= <=
Logical and or not
Identity is is not
Membership in not in
Assignment = += -= *= /= //= **= %= := (walrus, 3.8+)
if (n := len(my_list)) > 10:   # walrus operator: assign & test in one expression
    print(f"List is too long ({n} elements)")

📚 Full reference: w3schools.com/python/python_operators.asp


10. Errors & Exception Handling

Raising errors

raise says: "This situation is invalid — stop normal execution."

  • It stops execution immediately.
  • Python looks for the nearest matching except block, walking up the call stack.
  • If no matching except is found anywhere, the program crashes and prints a traceback.
if age < 0:
    raise ValueError("Age cannot be negative")

Exception

Exception is the base class for almost all built-in runtime errors (ValueError, TypeError, KeyError, IndexError, ZeroDivisionError, etc.). You defend your program by wrapping risky code in a try: block, followed by one or more except: clauses that handle the problem gracefully.

try:
    result = 10 / 0
except ZeroDivisionError as e:
    print(f"Can't divide by zero: {e}")
except (TypeError, ValueError) as e:
    print(f"Bad input: {e}")
else:
    print("Ran only if no exception was raised")
finally:
    print("Always runs — success, failure, or return")

Custom exception classes

An exception class is a specialized class — usually subclassing Exception — that serves as a blueprint for creating exception objects representing a specific kind of runtime error.

class InsufficientFundsError(Exception):
    """Raised when an account balance can't cover a withdrawal."""
    def __init__(self, balance, amount):
        super().__init__(f"Balance {balance} is less than requested {amount}")
        self.balance = balance
        self.amount = amount

The finally block

Code in finally always executes — whether an exception was raised, handled, or never occurred at all (even if the try block returns). Its purpose is to guarantee cleanup actions run in every circumstance (closing files, releasing locks, disconnecting from a database).


11. Modules & Packages

A module is simply a file containing definitions — functions, classes, variables, constants, or any other Python object — that you can reuse via the import keyword.

import sys
from sys import argv
import numpy as np
from . import sibling_module   # relative import within a package

sys

Provides access to variables and functions used or maintained by the interpreter, and interacts closely with the runtime and OS.

  • sys.argv — a list of command-line arguments passed to the script (sys.argv[0] is always the script's own name).
  • len(sys.argv) — the number of command-line arguments (including the script name).
  • sys gives your program direct access to the Python runtime and operating-system interface (e.g. sys.exit(), sys.path, sys.platform).

Packages & __init__.py

A package is a directory of related modules. The __init__.py file inside it serves two purposes:

  1. It marks the directory as a regular Python package (so import package.module works).
  2. It provides a place for package-level initialization code that runs the first time the package is imported.

Since Python 3.3, "namespace packages" can technically work without __init__.py, but including it remains the clear, explicit convention for regular packages.


12. Generators & Comprehensions

Generators

A generator is a function that behaves like an iterator, producing a sequence of values one at a time, lazily — only when requested. They're memory-efficient because they never hold the entire sequence in memory; instead, execution pauses at each yield and resumes right there when the next value is requested.

def countdown(n):
    while n > 0:
        yield n
        n -= 1

for value in countdown(3):
    print(value)   # 3, 2, 1
  • Calling a generator function doesn't run its body immediately — it returns a generator object.
  • Each call to next() (implicitly done by a for loop) resumes execution until the next yield.
  • Once the function returns (falls off the end, or hits a bare return), the generator raises StopIteration.

Generator expressions are a compact, lazy alternative to list comprehensions:

squares = (x**2 for x in range(1_000_000))   # nothing computed yet
next(squares)   # 0

Comprehensions

A comprehension is a concise, single-line way to build a new collection by transforming and/or filtering an existing iterable — generally faster and more readable than an equivalent for loop with .append().

# List comprehension
squares = [x**2 for x in range(10)]

# with a condition (filter)
evens = [x for x in range(20) if x % 2 == 0]

# with if/else (transform, not filter)
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]

# Set comprehension
unique_lengths = {len(word) for word in ["hi", "bye", "ok"]}

# Dict comprehension
squares_map = {x: x**2 for x in range(5)}

# Nested comprehension (flatten a 2D list)
matrix = [[1, 2], [3, 4]]
flat = [num for row in matrix for num in row]   # [1, 2, 3, 4]

List comprehension vs. generator expression: [x for x in ...] builds the entire list in memory immediately. (x for x in ...) builds a lazy generator that produces values on demand — use it when you don't need the whole collection at once (e.g. streaming through a huge file).


13. File I/O & Context Managers

open()

Python's built-in open() function opens a file in a given mode.

file = open("filename", "mode")
Mode Meaning
'r' Read only (default). File pointer starts at the beginning; errors if the file doesn't exist.
'w' Write only. Overwrites the file if it exists; creates it if it doesn't.
'a' Append. Writes are added to the end of the file; creates it if it doesn't exist.
'x' Exclusive creation. Fails if the file already exists.
'b' Binary mode modifier (combine, e.g. 'rb', 'wb').
'+' Update modifier — allows both reading and writing (e.g. 'r+').

Context managers & the with statement

A context manager is an object that defines two things: a setup action run when a block is entered, and a cleanup action run when the block is exited — even if an error occurs inside it. The with statement is the syntax that invokes a context manager.

with open("data.txt", "r") as f:
    contents = f.read()
# f.close() is called automatically here, even if an exception was raised above

with replaces verbose try / except / finally boilerplate with clean, guaranteed cleanup. If a file isn't closed properly, you risk:

  • Resource leaks (too many open file handles)
  • Corrupted or incomplete data (buffered writes never flushed to disk)
  • Locked files (other processes can't access them)
  • Crashes in long-running programs (running out of file descriptors)

Under the hood, any object used with with must implement two dunder methods:

class ManagedResource:
    def __enter__(self):
        print("Setting up")
        return self          # this becomes the `as` variable
    def __exit__(self, exc_type, exc_value, traceback):
        print("Cleaning up")
        return False         # False = don't suppress exceptions

with ManagedResource() as r:
    print("Using the resource")

You can also write context managers with the contextlib module, which is often shorter:

from contextlib import contextmanager

@contextmanager
def managed_resource():
    print("Setting up")
    yield "the resource"
    print("Cleaning up")

with managed_resource() as r:
    print("Using", r)

RAII

RAII (Resource Acquisition Is Initialization) is a programming idiom, originally from C++, where a resource's lifetime is tightly bound to an object's lifetime: acquiring the resource happens in the constructor, and releasing it happens automatically when the object is destroyed. Python doesn't have deterministic destructors the way C++ does (garbage collection timing isn't guaranteed), so Python's idiomatic equivalent of RAII is precisely the context manager / with statement pattern above — deterministic setup and teardown, without relying on the garbage collector's timing.


14. Object-Oriented Programming (OOP)

OOP is a programming model built around objects — units that bundle together state (data) and behavior (functions) to represent real-world entities.

Concept Meaning
Class The blueprint — defines what attributes and methods its objects will have
Object / Instance A concrete "real thing" built from a class
__init__ The setup function (constructor), run automatically when an object is created
self A reference to the object itself, passed automatically to instance methods
class Plant:
    def __init__(self, name, height, age):
        self.name = name
        self.height = height
        self.age = age

p = Plant("Rose", 10, 3)
# Python automatically calls: Plant.__init__(p, "Rose", 10, 3)

Why __init__ matters:

  • Gives the object its starting values.
  • Makes sure the object is ready to use as soon as it's created.
  • Avoids ending up with "empty" objects that have no meaningful data.

__main__

Every Python module has a built-in __name__ variable.

  • If you run the file directly, Python sets __name__ to "__main__".
  • If the file is imported into another module, __name__ is set to that file's module name instead.
if __name__ == "__main__":
    main()   # only runs when this file is executed directly, not when imported

Class attributes vs. instance attributes

  • A class attribute belongs to the class itself, and its value is shared across every instance — unless a specific instance overrides it.
  • An instance attribute belongs to one specific object and is typically defined inside __init__().
class Dog:
    species = "Canis familiaris"   # class attribute — shared by every Dog

    def __init__(self, name):
        self.name = name           # instance attribute — unique per Dog

Built-in class attributes

Attribute Meaning
__dict__ Dictionary containing the class's (or instance's) namespace
__doc__ The class's docstring, or None if undefined
__name__ The class's name
__module__ The name of the module the class was defined in ("__main__" in interactive mode)
__bases__ A (possibly empty) tuple of the class's base classes, in declaration order

Encapsulation

Encapsulation means keeping data and the code that operates on it bundled together, and protecting that data from misuse — typically by making attributes private and controlling access through getters/setters or @property.

Access level Convention Accessible from
Public name Anywhere, inside or outside the class
Protected _name (single underscore) The class and its subclasses (a convention, not enforced)
Private __name (double underscore) Only within the defining class

Analogy: a public variable is a public place anyone can enter; a protected variable is like a family home accessible to relatives (subclasses) but not outsiders; a private variable is a vault only its owner (the class itself) can open.

You can still technically reach a "private" attribute from outside via name mangling.

Name mangling

Python automatically renames any class attribute that starts with a double underscore (__name) to include the class name — __name becomes _ClassName__name. This avoids accidental name collisions, especially in subclasses.

class Account:
    def __init__(self, balance):
        self.__balance = balance   # actually stored as _Account__balance

a = Account(100)
print(a._Account__balance)   # 100 — works, but defeats the purpose of privacy

Inheritance

An OOP mechanism where a class (the subclass) derives from another class (the superclass), reusing its behavior and state, while guaranteeing the subclass can be used anywhere the base class is expected (the Liskov substitution principle) without breaking correctness.

class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return f"{self.name} makes a sound"

class Dog(Animal):
    def speak(self):
        base = super().speak()   # call the parent's implementation
        return f"{base}: Woof!"

super() gives you access to the parent class's methods and attributes from inside a child class — most commonly to extend, rather than fully replace, the parent's behavior.

pass

A do-nothing statement. It tells Python: "There must be a statement here, but I intentionally want nothing to happen." Commonly used as a placeholder for a class, function, or loop body you haven't implemented yet.

class NotImplementedYet:
    pass

15. Scope & Namespaces

The scope of a variable is the region of code where that variable can be accessed. Python stores objects and their name bindings inside the namespace of the relevant scope.

Scope Where it's accessible
Built-in Everywhere — names like print, len, Exception provided by Python itself
Global (module) Anywhere within the module it's defined in
Enclosing Inside nested functions, referring to the enclosing function's scope
Local Only inside the function it's defined in

This is Python's LEGB rule: when resolving a name, Python looks in Local → Enclosing → Global → Built-in scope, in that order, stopping at the first match — Python scopes are nested.

x = "global"

def outer():
    x = "enclosing"
    def inner():
        x = "local"
        print(x)   # "local"
    inner()
    print(x)       # "enclosing"

outer()
print(x)           # "global"

Use the global keyword when you want to modify a global variable from inside a function (reading it doesn't require this — only reassignment does):

counter = 0

def increment():
    global counter
    counter += 1

16. Polymorphism, Abstraction & Protocols

Polymorphism

Literally "many forms." In programming, it means a single interface (e.g. a method name) can operate on objects of many different types, each providing its own specific behavior.

class Circle:
    def area(self):
        return "π × r²"

class Square:
    def area(self):
        return "side²"

for shape in [Circle(), Square()]:
    print(shape.area())   # same call, different behavior per type

Abstract classes

A class that cannot be instantiated directly — it exists purely as a template that defines a required interface for its subclasses.

  • It forces every concrete subclass to implement certain methods.
  • In Python, this is built with the abc module.
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return 3.14159 * self.radius ** 2

# Shape()        -> TypeError: Can't instantiate abstract class
circle = Circle(5)   # OK — Circle implements area()

📚 More on polymorphism: cs.sjsu.edu — polymorphism

Protocols (structural typing)

A protocol defines an interface based purely on what methods/attributes an object has, not on what class it inherits from — this is Python's flavor of "duck typing," formalized with the typing.Protocol class (introduced in Python 3.8).

from typing import Protocol

class Speaker(Protocol):
    def speak(self) -> str: ...

def announce(entity: Speaker):
    print(entity.speak())

class Dog:
    def speak(self):
        return "Woof!"

announce(Dog())   # works — Dog satisfies the Speaker protocol
                   # without ever inheriting from it explicitly

Protocol vs. Abstract Class: an abstract class requires explicit inheritance (class Circle(Shape)); a Protocol only requires the object to have the right methods — no inheritance relationship needed at all. This is "if it walks like a duck and quacks like a duck, it's a duck."


17. Virtual Environments, .env & Poetry

venv

A virtual environment is a separate, self-contained Python workspace with its own interpreter and its own installed packages, isolated from your system Python and other projects.

# Create a virtual environment
python -m venv matrix_env

# Activate it (Linux/macOS)
source matrix_env/bin/activate

# Activate it (Windows)
matrix_env\Scripts\activate

Once activated, you can check whether you're actually inside the venv:

import sys
if sys.base_prefix != sys.prefix:
    print("You are in a virtual environment")

sys.base_prefix points to the "real" system Python installation, while sys.prefix points to wherever the currently running interpreter lives — inside a venv, these differ.

.env files & python-dotenv

Sensitive data (API keys, database passwords, secrets) should never be hard-coded directly into your source. Instead, store them in a .env file (excluded from version control) and load them at runtime.

from dotenv import load_dotenv
import os

load_dotenv()                       # reads .env into the process environment
api_key = os.getenv("API_KEY")      # safely read the variable

if os.path.exists(".env"):
    print(".env file found")

.toml

.toml (Tom's Obvious, Minimal Language) is a configuration-file format built specifically to store structured data in a human-readable way. Python projects commonly use pyproject.toml as the standard place to declare project metadata, dependencies, and build settings.

Poetry

Poetry is a dependency-management and packaging tool that reads and drives everything from pyproject.toml. It:

  • Creates a virtual environment for your project automatically.
  • Installs and locks dependencies (via poetry.lock, for reproducible installs).
  • Manages the whole project lifecycle: building, publishing, versioning.
poetry install     # create venv + install dependencies
poetry add requests   # add a new dependency
poetry env info    # show info about the active environment

By default, Poetry creates its virtual environments in:

~/.cache/pypoetry/virtualenvs/

18. Pydantic: Data Validation

Pydantic is a library for data validation using Python type hints — extremely popular in APIs (especially with FastAPI), config loading, and any place where you need to guarantee incoming data has the right shape.

BaseModel

Declares that data flowing into your model must conform to a defined schema — Pydantic validates and coerces types automatically at object-creation time, raising a clear ValidationError if the data doesn't fit.

from pydantic import BaseModel, Field

class User(BaseModel):
    name: str = Field(min_length=2, max_length=50)
    age: int = Field(gt=0, le=120)
    email: str

Field

A function used to define validation rules, default values, and metadata for a BaseModel attribute.

Applies to Common constraints
str min_length, max_length, pattern, strip_whitespace, to_lower, to_upper
int, float gt (>), ge (>=), lt (<), le (<=), multiple_of
bool default, description, alias
list min_length, max_length

Model validators

A model validator is custom logic that checks or transforms the entire model's data (not just one field) to ensure it's consistent as a whole (e.g. "end_date must be after start_date").

from pydantic import BaseModel, model_validator

class DateRange(BaseModel):
    start: str
    end: str

    @model_validator(mode="before")   # runs before field validation, raw dict input
    def check_raw(cls, data):
        return data

    @model_validator(mode="after")    # runs after field validation, on the final instance
    def check_order(self):
        if self.end < self.start:
            raise ValueError("end must be after start")
        return self
Mode Runs Receives
"before" Before field-level validation Raw input (usually a dict)
"after" After field-level validation The final, validated model instance

Pydantic validates Python data against a defined schema using BaseModel.

Define fields with field: type.

model_validate(data) validates a Python dictionary/object.

model_validate_json(json) validates JSON text directly.

Missing required fields → ValidationError.

Nested models validate nested structures such as list[Level].

extra="forbid" rejects unknown fields.

Catch errors with except ValidationError as e.

e.errors() gives structured errors: loc → where the error is msg → what went wrong type → error type input → received value

Build clean messages with loc + msg.

Enum

Lets you define a variable that can only take one of a fixed, predefined set of values.

from enum import Enum

class Status(Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"
    BANNED = "banned"

class User(BaseModel):
    status: Status

19. Lambda Functions

A lambda is a small, anonymous, single-expression function — used for short operations where defining a full def would be overkill.

add = lambda x, y: x + y
add(3, 4)   # 7

(lambda x: x + 1)(5)   # 6, called immediately

list(map(lambda x: x * 2, [1, 2, 3]))   # [2, 4, 6]
  • A lambda can take any number of arguments, but its body must be a single expression — no statements, no multiple lines, and it implicitly returns that expression's value.
  • Common partners: map(), filter(), sorted(..., key=...).
words = ["banana", "kiwi", "apple"]
sorted(words, key=lambda w: len(w))   # ['kiwi', 'apple', 'banana']

20. Functions in Depth

Callables

A callable is anything in Python you can invoke with parentheses () — this includes ordinary functions, lambdas, classes (calling one constructs an instance), and any object implementing __call__.

class Multiplier:
    def __init__(self, factor):
        self.factor = factor
    def __call__(self, x):
        return x * self.factor

double = Multiplier(2)
double(5)   # 10 — the instance itself is callable

*args and **kwargs

def demo(*args, **kwargs):
    print(args)     # tuple of all extra positional arguments
    print(kwargs)   # dict of all extra keyword arguments

demo(1, 2, 3, name="Alice", age=30)
# (1, 2, 3)
# {'name': 'Alice', 'age': 30}
  • *args collects any number of extra positional arguments into a tuple.
  • **kwargs collects any number of extra keyword arguments into a dict.

nonlocal

Lets a nested function modify a variable that lives in its enclosing (outer) function's scope — not global, specifically the immediately enclosing local scope.

def outer():
    count = 0
    def inner():
        nonlocal count
        count += 1
    inner()
    inner()
    return count

outer()   # 2

Rules:

  • nonlocal only works if the variable already exists in an outer (enclosing) function — it can't create a new one.
  • It does not work for truly global variables (use global for that instead).
  • It must be declared before the variable is used/assigned inside the nested function.

First-class citizens

A first-class citizen (or first-class object) is an entity treated like any other value in the language: it can be stored in a variable, passed as an argument, returned from a function, and stored in data structures — without restriction.

Most languages treat basic types (integers, strings) as first-class, but Python goes further: functions and classes are first-class citizens too.

def greet():
    return "hello"

def call_it(fn):     # a function passed as an argument
    return fn()

call_it(greet)        # 'hello' — functions can be passed around like any value

functions_list = [greet, str.upper, len]   # functions stored in a list

Decorators

A decorator is a function that modifies or extends the behavior of another function without changing its source code. Applied with the @ symbol.

from functools import wraps

def logger(func):
    @wraps(func)   # preserves func.__name__ and func.__doc__
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@logger
def add(a, b):
    return a + b

add(2, 3)
# Calling add
# add returned 5

functools.wraps is essential — without it, wrapper would shadow the original function's __name__ and __doc__, breaking introspection and documentation tools.


21. Types of Methods & Special (Dunder) Methods

Instance, class, and static methods

Method type Decorator First parameter Operates on
Instance method (none) self A specific object's state
Class method @classmethod cls The class itself (e.g. alternate constructors)
Static method @staticmethod (none) Nothing — a plain utility function namespaced inside the class
class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings           # instance state

    def describe(self):                    # instance method
        return f"Pizza with {self.toppings}"

    @classmethod
    def margherita(cls):                   # class method — alternate constructor
        return cls(["tomato", "mozzarella"])

    @staticmethod
    def is_valid_topping(topping):         # static method — independent utility
        return topping in {"tomato", "mozzarella", "basil", "pepperoni"}

Special ("dunder") methods

Named __something__, these let your objects hook into Python's built-in syntax and behavior.

Method Triggered by
__init__ Object creation
__str__ str(obj) / print(obj) — a human-readable representation
__repr__ repr(obj) — an unambiguous, developer-facing representation
__len__ len(obj)
__add__ obj + other
__eq__ obj == other
__lt__ obj < other
__call__ obj(...) — makes an instance callable
__getitem__ obj[key]
__iter__ / __next__ Iterating with for or next()
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __eq__(self, other):
        return (self.x, self.y) == (other.x, other.y)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

Vector(1, 2) + Vector(3, 4)   # Vector(4, 6)

@property

Turns a method into something accessed like a plain attribute — enabling controlled, validated reads/writes without changing the calling syntax.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):                # getter — accessed as obj.radius
        return self._radius

    @radius.setter
    def radius(self, value):         # setter — obj.radius = value
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value

    @radius.deleter
    def radius(self):                # deleter — del obj.radius
        del self._radius

Abstract methods

Declared with @abstractmethod inside a class deriving from ABC (see §16) — they define an interface/requirement that every concrete subclass must implement, or Python raises a TypeError at instantiation time.


22. Advanced Python

dataclasses

A decorator that auto-generates the boring boilerplate of a class — __init__, __repr__, __eq__ — just from type-annotated attributes.

from dataclasses import dataclass, field

@dataclass
class Point:
    x: int
    y: int
    tags: list = field(default_factory=list)   # safe way to default a mutable value

p1 = Point(1, 2)
p2 = Point(1, 2)
p1 == p2       # True — __eq__ is generated automatically
print(p1)      # Point(x=1, y=2, tags=[])
  • field(default_factory=list) avoids the classic "mutable default argument" bug — each instance gets its own fresh list.
  • Add frozen=True to make instances immutable, like a tuple with names.

Type hints, in depth

Type hints don't change runtime behavior — Python doesn't enforce them — but they power editor autocomplete, static checkers (mypy), and self-documenting code.

from typing import Optional, Union, Literal

def find_user(user_id: int) -> Optional[str]:      # str or None
    ...

def parse(value: Union[int, str]) -> int:            # int or str in, int out
    ...

def set_mode(mode: Literal["read", "write"]) -> None:  # only these exact values
    ...

# 3.10+ shorthand — no need to import Optional/Union anymore
def find_user2(user_id: int) -> str | None:
    ...

Generics let you write a class or function that works with any type, while still being type-checkable:

from typing import TypeVar, Generic

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, item: T):
        self.item = item

int_box: Box[int] = Box(5)

Metaclasses

A class defines how objects behave. A metaclass defines how classes themselves behave — it's "a class whose instances are classes." By default, every class's metaclass is type. This introduction to python 3.10+ for beginners has been created by FALAMLIH the sys module provides a variable called sys.argv. It's main purpose are: It is a list of command-line arguments. len(sys.argv) provides the number of command-line arguments. sys is a built-in Python module that gives your program direct access to the Python runtime and the operating system interface The __init.py file in Python serves two primary purposes: it marks a directory as a regular Python package, and it provides a place for initialization code when that package is importe

Python generators && comprehension

generator is a function that acts as an iterator to produce a sequence of values one at a time, and only when requested. They are memory efficient because they don't store the entire sequence in memory at once; instead, they pause their execution with the yield keyword and resume when the next value is needed.

comprehension:

python I/O

open:Python's built-in open() function is used to open files in various modes, such as reading, writing, and appending file = open("filename", "mode") Where, filename is the name of the file to open and mode is the mode in which the file is opened (e.g., 'r' for reading, 'w' for writing, 'a' for appending) r: Opens a file for reading only. The file pointer is placed at the beginning of the file. This is the default mode of open w: Opens a file for writing only. Overwrites the file if the file exists. If the file does not exist, creates a new file for writing

CONTEXT MANAGERS

WITH STATEMENT

RAII

OOP (Object-oriented programming)

is defined as a programming model that uses the concept of objects which refers to real-world entities with state and behavior

Class → blueprint

Object → real thing

init → setup function

self → the object itself

main (if name == "main") : Every Python file has a built-in variable called name. If you run the file directly, Python sets name to "main". If the file is imported into another file, name is set to the filename instead.

Class attributes are those variables that belong to a class and whose value is shared among all the instances of that class

Built-In Class Attributes: dict − Dictionary containing the class's namespace.

doc − Class documentation string or none, if undefined.

name − Class name.

module − Module name in which the class is defined. This attribute is "main" in interactive mode.

bases − A possibly empty tuple containing the base classes, in the order of their occurrence in the base class list.

instance attribute in Python is a variable that is specific to an individual object of a class. It is defined inside the init() method.

init is a special function inside a class (called constructor) p = Plant("Rose", 10, 3) Python automatically calls: Plant.init(p, "Rose", 10, 3)

We need init to:

Give the object starting values

Make sure the object is ready to use

Avoid creating empty objects with no data ft_command_quest.py Encapsulation = keeping data and the code that uses it together, and protecting it from misuse, it hide internal data & control how it is accessed or modified by using private attributes, setters and gtters

Public Variables: Public variables are accessible from anywhere, both inside and outside the class. In the image, a public place is considered as an analogy for public variables as they can be accessed by anyone.

class Meta(type):
    def __new__(mcs, name, bases, namespace):
        print(f"Creating class {name}")
        return super().__new__(mcs, name, bases, namespace)

class MyClass(metaclass=Meta):
    pass
# prints: Creating class MyClass

Metaclasses are rare in everyday code — they're what libraries like Django (models) and Pydantic use internally to auto-register fields when a class is defined, not when it's instantiated. __new__ builds the object before __init__ initializes it; you almost never need to override __new__ outside of metaclasses or immutable-type subclassing.

Descriptors

The mechanism that actually powers @property, methods, and __slots__ under the hood. A descriptor is any object implementing __get__, __set__, or __delete__, placed as a class attribute.

class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = "_" + name
    def __get__(self, obj, objtype=None):
        return getattr(obj, self.name)
    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.name} must be positive")
        setattr(obj, self.name, value)

class Product:
    price = PositiveNumber()   # descriptor — validates every assignment

p = Product()
p.price = 10       # OK
p.price = -5       # raises ValueError

@property is really just a convenient, built-in descriptor.

__slots__

By default, every instance gets a __dict__ to hold its attributes, which is flexible but memory-hungry when you have millions of small objects. __slots__ declares a fixed set of attributes up front, skipping the per-instance __dict__ entirely.

class Point:
    __slots__ = ("x", "y")   # no __dict__, no arbitrary new attributes
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
p.z = 3   # AttributeError — 'z' isn't a declared slot

functools

Tool What it does
@lru_cache / @cache Memoizes a function's return values — repeated calls with the same arguments skip recomputation
partial "Pre-fills" some arguments of a function, returning a new callable
reduce Cumulatively applies a function across an iterable, reducing it to one value
@singledispatch Lets a function behave differently depending on the type of its first argument
from functools import lru_cache, partial, reduce, singledispatch

@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

double = partial(lambda x, y: x * y, 2)   # y=2 pre-filled
double(5)   # 10

reduce(lambda acc, x: acc + x, [1, 2, 3, 4])   # 10

@singledispatch
def describe(value):
    return f"a value: {value}"

@describe.register
def _(value: int):
    return f"an integer: {value}"

describe(5)      # 'an integer: 5'
describe("hi")   # 'a value: hi'

itertools

Tool What it does
chain(a, b) Iterates over several iterables as if they were one
groupby(iterable, key) Groups consecutive items sharing a key (input must already be sorted by that key)
product(a, b) Cartesian product — every combination of elements
islice(iterable, n) Lazily slices an iterator without loading it all into memory
count(), cycle(), repeat() Infinite iterators — pair with islice or break to stop them
from itertools import chain, product, islice

list(chain([1, 2], [3, 4]))     # [1, 2, 3, 4]
list(product([1, 2], ["a"]))    # [(1, 'a'), (2, 'a')]
list(islice(range(1_000_000), 5))   # [0, 1, 2, 3, 4] — never builds the full range

async / await & asyncio

Concurrency for I/O-bound work (network calls, file access, waiting on other systems) — a single thread cooperatively switches between tasks whenever one is waiting, instead of sitting idle.

import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)   # simulates an I/O wait, doesn't block the whole program
    return f"{name} done"

async def main():
    results = await asyncio.gather(
        fetch("A", 2),
        fetch("B", 1),
    )
    print(results)

asyncio.run(main())   # both run "at once" — total time ≈ 2s, not 3s
  • async def defines a coroutine function — calling it returns a coroutine object, it doesn't run yet.
  • await pauses the current coroutine until the awaited thing finishes, letting other coroutines run meanwhile.
  • asyncio.gather() runs several coroutines concurrently and waits for all of them.

Threading, multiprocessing & the GIL

The GIL (Global Interpreter Lock) allows only one thread to execute Python bytecode at a time, even on a multi-core machine.

Approach Good for Why
threading I/O-bound work Threads release the GIL while waiting on I/O, so they still overlap usefully
multiprocessing CPU-bound work Each process gets its own interpreter and GIL, giving true parallelism across cores
asyncio I/O-bound work, many concurrent tasks Single thread, no GIL contention at all, very lightweight
from multiprocessing import Pool

def square(x):
    return x * x

with Pool(4) as pool:
    print(pool.map(square, [1, 2, 3, 4]))   # runs across 4 real processes

Rule of thumb: I/O-bound → asyncio or threading. CPU-bound → multiprocessing. Threading won't speed up pure number-crunching because the GIL still serializes bytecode execution.

weakref

A weak reference points to an object without increasing its reference count — so the object can still be garbage-collected even while the weak reference exists. Useful for caches or observer patterns where you don't want to keep something alive just by referencing it.

import weakref

class Big:
    pass

obj = Big()
ref = weakref.ref(obj)
print(ref())   # <Big object> — still alive

del obj
print(ref())   # None — it's been garbage-collected

Chained exceptions & exception groups

raise ... from ... preserves the original exception as context when you raise a new one — crucial for honest debugging instead of hiding the root cause.

try:
    1 / 0
except ZeroDivisionError as e:
    raise RuntimeError("Calculation failed") from e
# Traceback shows both: the ZeroDivisionError *and* the RuntimeError

Exception groups (except*, Python 3.11+) let you raise and handle multiple unrelated exceptions at once — common in concurrent code where several tasks can fail independently.

try:
    raise ExceptionGroup("multiple failures", [ValueError("bad value"), TypeError("bad type")])
except* ValueError as eg:
    print("Handled ValueErrors:", eg.exceptions)
except* TypeError as eg:
    print("Handled TypeErrors:", eg.exceptions)

Multiple inheritance & MRO

When a class inherits from more than one parent, Python needs a deterministic order to search for methods — the MRO (Method Resolution Order), computed with the C3 linearization algorithm.

class A:
    def greet(self): return "A"

class B(A):
    def greet(self): return "B"

class C(A):
    def greet(self): return "C"

class D(B, C):
    pass

D().greet()          # 'B' — B comes before C in the MRO
print(D.__mro__)     # (D, B, C, A, object) — the exact search order

This is how Python resolves the classic diamond problem (D inheriting from both B and C, which both inherit from A) predictably, instead of ambiguously.


23. Further Reading

About

This introduction to python 3.10+ for beginners has been created by FALAMLIH

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors