{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0d75c413",
   "metadata": {},
   "source": [
    "# Classes: Introduction\n",
    "\n",
    "This article provides a guide to understanding and using classes in Python.\n",
    "Classes are a fundamental part of Python's Object-Oriented Programming (OOP) paradigm,\n",
    "allowing you to bundle data and functionality together."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "62757e93",
   "metadata": {},
   "source": [
    "## 1. What is a Class?\n",
    "A class is a blueprint for creating objects. \n",
    "It defines:\n",
    "- data (attributes) and \n",
    "- behavior (methods).\n",
    "Think of it like a template: Class -> blueprint, Object -> actual instance."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0105a0be",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 2. Example Class\n",
    "class Person:\n",
    "    def __init__(self, name, year, citizenship):\n",
    "        self.name = name\n",
    "        self.year = year\n",
    "        self.citizenship = citizenship"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ace234a3",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 3. Creating Objects (Instances)\n",
    "person1 = Person(\"Alicja\", 1990, \"Polish\")\n",
    "person2 = Person(\"James\", 1985, \"American\")\n",
    "\n",
    "print(person1.name)  # Alicja\n",
    "print(person2.name)  # James"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c52c172f",
   "metadata": {},
   "source": [
    "When we call `Person(\"Alicja\", 1990, \"Polish\")`, Python creates a new object and automatically calls the `__init__` method. The `p1` and `p2` instances are initialized this way. \n",
    "\n",
    "The `self` parameter is a **reference** to the current instance of the class. A reference is essentially the memory address—the specific location in your computer's RAM—where that object's data is stored. It allows the object to access its own attributes and methods. We don't need to pass it manually; Python handles it for us automatically."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dff61267",
   "metadata": {},
   "source": [
    "## 2. Adding Methods (Behavior)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "75a86eb4",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Person:\n",
    "    def __init__(self, name, year, citizenship):\n",
    "        self.name = name\n",
    "        self.year = year\n",
    "        self.citizenship = citizenship\n",
    "\n",
    "    def greet(self):\n",
    "        return f\"Hello, my name is {self.name}.\"\n",
    "\n",
    "    def age(self, current_year):\n",
    "        return current_year - self.year\n",
    "\n",
    "p1 = Person(\"Alicja\", 1990, \"Polish\")\n",
    "print(p1.greet())\n",
    "print(p1.age(2025))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "28fb9593",
   "metadata": {},
   "source": [
    "### Tasks:\n",
    "1. Create a `Book` class with attributes `title`, `author`, and `pages`. Implement an `__init__` method that prints \"New book: [title]\", and an `is_long()` method that returns `True` if pages > 300. Finally, create two instances and print their details.\n",
    "2. Add a `get_detailed_info()` method that returns a string formatted as \"'Title' by Author, [X] pages\".\n",
    "3. Use the `is_long()` method to print a message like \"This is a long read!\" for books with more than 300 pages.\n",
    "4. Add a method `update_pages(new_pages)` that allows you to change the page count of the book instance."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a115520e",
   "metadata": {},
   "source": [
    "## 3. Default Values"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "38ceae1d",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Person:\n",
    "    def __init__(self, name, year, citizenship=\"Unknown\"):\n",
    "        self.name = name\n",
    "        self.year = year\n",
    "        self.citizenship = citizenship\n",
    "    \n",
    "    def greet(self):\n",
    "        return f\"Hi, I'm {self.name}.\"\n",
    "\n",
    "p1 = Person(\"John\", 2005)\n",
    "p2 = Person(\"Luke\", 1990, 'American')\n",
    "print(p1.citizenship)  # Unknown\n",
    "print(p2.citizenship)  # American"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2333ea6b",
   "metadata": {},
   "source": [
    "You can provide default values for parameters in the `__init__` method. If a value is not provided when creating an instance, the default value will be used. This makes your classes more flexible and easier to use, as you only need to provide essential information.\n",
    "\n",
    "\n",
    "### Tasks:\n",
    "1. Create a `Product` class where the `price` attribute has a default value of 0.0. Instantiate one product with a price and another without, then print both."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c17653ad",
   "metadata": {},
   "source": [
    "## 4. Instance Attributes vs Class Attributes"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3a3e8daf",
   "metadata": {},
   "source": [
    "A **class attribute** is a variable that is shared by all instances of a class. Unlike instance attributes (which are unique to each object), class attributes are defined outside of any methods (usually right at the top of the class). They are accessed using the class name itself or through any instance."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d147219c",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Person:\n",
    "   \n",
    "    # Class Attributes: Shared by all instances of the class\n",
    "    number_of_people = 0\n",
    "    planet = \"Earth\"\n",
    "\n",
    "    def __init__(self, name, year, citizenship):\n",
    "        self.name = name\n",
    "        self.year = year\n",
    "        self.citizenship = citizenship\n",
    "\n",
    "        # Increment class attribute whenever a new instance is created\n",
    "        Person.number_of_people += 1\n",
    "\n",
    "p1 = Person(\"Luke\", 2001, \"Polish\")\n",
    "p2 = Person(\"Bob\", 1985, \"German\")\n",
    "\n",
    "print(Person.number_of_people)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d73767f3",
   "metadata": {},
   "source": [
    "### Tasks:\n",
    "1. Create a `Car` class with a class attribute `wheels = 4`. Instantiate two cars, verify they show 4 wheels, then change `Car.wheels` to 6 and check both instances to see the shared update.\n",
    "2. Add instance attributes `brand` and `model` to the `Car` class. Create an instance for a \"Toyota Corolla\".\n",
    "3. Add a class attribute `fuel_type = 'Petrol'` and demonstrate that all instances access the same value.\n",
    "4. Add a method `drive()` that prints \"The [brand] [model] is now driving on [wheels] wheels.\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "53f264a8",
   "metadata": {},
   "source": [
    "## 5. Understanding Decorators\n",
    "\n",
    "A **decorator** is a design pattern in Python that allows you to modify the behavior of a function or class without permanently changing its source code. Essentially, a decorator is a function that takes another function as an argument and returns a new function that \"wraps\" the original one.\n",
    "\n",
    "This is possible because in Python, functions are **first-class objects**, meaning they can be passed around as arguments, returned from other functions, and assigned to variables.\n",
    "\n",
    "We can use built-in decorators like `@classmethod` or `@staticmethod` (explained in the next section), or create custom decorators for various purposes such as logging, timing, or authorization.\n",
    "\n",
    "To modify the behavior of a function using a built-in decorator, it is enough to place the decorator name above the function definition, preceded by `@`.\n",
    "\n",
    "In the case of custom decorators, we follow the same approach, as shown in the example below, where we place `@log_execution` above `def greet(name):` . However, the decorator must be defined beforehand.\n",
    "\n",
    "***args** and ****kwargs** require further explanation, but for the purpose of below example it is enough to know that this construction allows all arguments passed to the `greet()` function to be forwarded to the `wrapper()` function, and then passed again—in the same form—to the original `greet()` function. Thanks to this, we can execute `greet()` from within `wrapper()` using exactly the same arguments as if `greet()` were called directly, without a decorator."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d7e9b1a2",
   "metadata": {},
   "outputs": [],
   "source": [
    "def log_execution(func):\n",
    "    def wrapper(*args, **kwargs):\n",
    "        print(f\"Executing {func.__name__}...\")\n",
    "        result = func(*args, **kwargs)\n",
    "        print(f\"{func.__name__} finished.\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "@log_execution\n",
    "def greet(name):\n",
    "    return f\"Hello, {name}!\"\n",
    "\n",
    "print(greet(\"Alice\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cfd6f979",
   "metadata": {},
   "source": [
    "The `greet()` function is called with the argument \"Alice\". If it were executed without the decorator, only \"Hello, Alice\" would be printed. However, the `@log_execution` decorator extends its behavior by printing two additional lines: \"Executing greet...\" and \"greet finished.\"."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a4b2c8d1",
   "metadata": {},
   "source": [
    "### Tasks:\n",
    "1. Create a decorator `bold_decorator` that wraps a function returning a string and adds `<b>` and `</b>` tags around it.\n",
    "2. Apply it to a function `get_text()` that returns \"Hello World\"."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "91b14a9d",
   "metadata": {},
   "source": [
    "## 6. Class Methods vs Static Methods"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cbe343ca",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Person:\n",
    "   \n",
    "    # Class Attributes: Shared by all instances of the class\n",
    "    number_of_people = 0\n",
    "    planet = \"Earth\"\n",
    "\n",
    "    def __init__(self, name, year, citizenship):\n",
    "        self.name = name\n",
    "        self.year = year\n",
    "        self.citizenship = citizenship\n",
    "\n",
    "        # Increment class attribute whenever a new instance is created\n",
    "        Person.number_of_people += 1\n",
    "\n",
    "    def greet(self):\n",
    "        return f\"Hello, my name is {self.name}.\"\n",
    "    \n",
    "    @classmethod\n",
    "    def get_population(cls):\n",
    "        return f\"Current registered population: {cls.number_of_people}\"\n",
    "    \n",
    "    @staticmethod\n",
    "    def is_adult(age):\n",
    "        return age >= 18\n",
    "\n",
    "p1 = Person(\"Luke\", 2001, \"Polish\")\n",
    "p2 = Person(\"Bob\", 1985, \"German\")\n",
    "p3 = Person(\"Kathrine\", 1985, \"American\")\n",
    "\n",
    "print(Person.get_population())\n",
    "print(p3.get_population())\n",
    "\n",
    "# Example of using is_adult static method\n",
    "print(f\"Is Luke an adult? {Person.is_adult(24)}\")\n",
    "print(f\"Is age 15 an adult? {Person.is_adult(15)}\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f0c14360",
   "metadata": {},
   "source": [
    "- **@classmethod**: Receives the class as its first argument (`cls`). Use it when you need to access class-level attributes or create alternative constructors (factory methods).\n",
    "- **@staticmethod**: Does not receive `self` or `cls`. It behaves like a regular function but lives inside the class namespace. Use it for utility functions that relate to the class logic but don't need to access its state.\n",
    "\n",
    "**Key Difference**: A class method can modify class state that applies across all instances; a static method is self-contained and doesn't depend on the class or instance state at all."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0fbce47b",
   "metadata": {},
   "source": [
    "### Tasks:\n",
    "1. In the `Person` class, add a `@classmethod` called `from_string(cls, person_str)` that parses a string like \"Alice,1990,Polish\" and returns a new `Person` instance.\n",
    "2. Add a `@staticmethod` called `is_valid_citizenship(citizenship)` that returns `True` if the citizenship is in a predefined list of allowed countries."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "022309f3",
   "metadata": {},
   "source": [
    "## 7. Scope: Global vs Local Variables in Classes"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6b7bfc20",
   "metadata": {},
   "source": [
    "To use global variables inside a class method, you can simply refer to them by name. However, if you need to modify a global variable, you must use the `global` keyword within the method.\n",
    "\n",
    "Variables created inside a function or method (local variables) can only be accessed within that scope. In contrast, attributes assigned to `self` are instance attributes and are accessible by all instance methods within the class."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b075c4a8",
   "metadata": {},
   "outputs": [],
   "source": [
    "counter = 0  # Global variable\n",
    "\n",
    "class Tracker:\n",
    "    def __init__(self, name):\n",
    "        self.name = name  # Instance attribute\n",
    "    \n",
    "    def increment_global(self):\n",
    "        global counter\n",
    "        counter += 1\n",
    "    \n",
    "    def local_demo(self):\n",
    "        temp_val = \"I only exist here\"  # Local variable\n",
    "        print(temp_val)\n",
    "\n",
    "t = Tracker(\"Alpha\")\n",
    "t.increment_global()\n",
    "print(f\"Global counter: {counter}\")\n",
    "t.local_demo()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e4e77c21",
   "metadata": {},
   "source": [
    "### Tasks:\n",
    "1. Create a global variable `APP_MODE` set to \"development\". Then implement a class `Application` with a method `show_mode()` that prints the current mode using the global variable. ( Do **not** pass the mode as a parameter. Access the global variable directly inside the method)\n",
    "2. You are building a visitor counter. Create a global variable `visits` = 0 and a class `Website`. Each time the method `visit()` is called, it should increment the global counter. Add a method `show_visits()` that prints the total visits."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fa31135e",
   "metadata": {},
   "source": [
    "## 8. Method Overriding"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b54d2c8f",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Student(Person):\n",
    "    def __init__(self, name, year, citizenship, university):\n",
    "        super().__init__(name, year, citizenship)\n",
    "        self.university = university    \n",
    "    def greet(self):\n",
    "        return f\"Hi, I'm {self.name} and I'm a student.\"\n",
    "\n",
    "s = Student(\"Alice\", 2000, \"Polish\", \"University of Warsaw\")\n",
    "print(s.greet())           # Calls overridden method in Student\n",
    "print(Person.greet(s))     # Explicitly calls method from Parent (Person) class\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b855b1e7",
   "metadata": {},
   "source": [
    "Method overriding occurs when a child class provides a specific implementation of a method that is already defined in its parent class. This is helpful when you want to change or extend the behavior of a parent method to suit the needs of the child class, allowing for polymorphism.<br>\n",
    "In this section, this concept is only introduced. For a better understanding, refer to the article `Classes: Object-Oriented Programming Paradigms`."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "df3e234f",
   "metadata": {},
   "source": [
    "### Tasks:\n",
    "1. Create a `Shape` class with a method `area()` that returns 0.\n",
    "2. Create a `Square` class that inherits from `Shape`, takes `side` in its `__init__`, and overrides `area()` to return `side * side`.\n",
    "3. Create a `Circle` class that inherits from `Shape`, takes `radius`, and overrides `area()` using `math.pi`.\n",
    "4. Use `super()` in a `ColoredSquare` class (inheriting from `Square`) to set both `side` and `color` during initialization.\n",
    "\n",
    "**Note on `super()`**: We use `super()` because both the parent and child classes define an `__init__` method, and the child's version overrides the parent's. However, if the parent has a method that the child does *not* redefine, the child inherits it automatically and can call it without `super()`."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1c60bf11",
   "metadata": {},
   "source": [
    "## 9. Documentation"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1d6fb5ab",
   "metadata": {},
   "source": [
    "A **docstring** is a string literal that occurs as the first statement in a module, function, class, or method definition. Following **PEP 257**, docstrings should be enclosed in triple double quotes `\"\"\"`. They are essential for documentation and are used by tools like Sphinx, pdoc, and Doxygen to generate external manuals.\n",
    "\n",
    "- `__str__`: Returns a user-friendly string representation of an object (intended for end-users).\n",
    "- `__repr__`: Returns an unambiguous string representation of an object, ideally matching the command used to create it (intended for developers/debugging)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3d4a3564",
   "metadata": {},
   "outputs": [],
   "source": [
    "from datetime import datetime\n",
    "\n",
    "class Person:\n",
    "    \"\"\"\n",
    "    A class to represent a person.\n",
    "    \n",
    "    Attributes:\n",
    "        name (str): The name of the person.\n",
    "        year_of_birth (int): The year the person was born.\n",
    "        citizenship (str): The citizenship of the person (default is 'Polish').\n",
    "    \"\"\"\n",
    "    \n",
    "    # Class Attribute: Shared by all instances of the class\n",
    "    number_of_people = 0\n",
    "\n",
    "    def __init__(self, name: str, year: int, citizenship: str = 'Polish'):\n",
    "        \"\"\"\n",
    "        The constructor method. Initializes the attributes for each new instance.\n",
    "        \"\"\"\n",
    "        self.name = name\n",
    "        self.year_of_birth = year\n",
    "        self.citizenship = citizenship\n",
    "        \n",
    "        # Increment class attribute whenever a new instance is created\n",
    "        Person.number_of_people += 1\n",
    "        print(f\"Creating profile for {self.name}...\")\n",
    "\n",
    "    # Instance Method: Operates on an instance of the class\n",
    "    def calculate_age(self):\n",
    "        \"\"\"\n",
    "        Calculates and returns the age of the person based on the current year.\n",
    "        \"\"\"\n",
    "        current_year = datetime.now().year\n",
    "        return current_year - self.year_of_birth\n",
    "\n",
    "    # Static Method: Does not require access to instance or class (utility function)\n",
    "    @staticmethod\n",
    "    def is_adult(age):\n",
    "        \"\"\"\n",
    "        A utility function to check if a given age is adult.\n",
    "        \"\"\"\n",
    "        return age >= 18\n",
    "\n",
    "    # Class Method: Operates on the class itself, not on an instance\n",
    "    @classmethod\n",
    "    def get_population(cls):\n",
    "        \"\"\"\n",
    "        Returns the total number of Person instances created.\n",
    "        \"\"\"\n",
    "        return f\"Current registered population: {cls.number_of_people}\"\n",
    "\n",
    "    # Special Magic Method: Changes how the object is represented as a string\n",
    "    def __str__(self):\n",
    "        return f\"Person(name={self.name}, age={self.calculate_age()})\"\n",
    "\n",
    "    def __repr__(self):\n",
    "        return f\"Person('{self.name}', {self.year_of_birth}, '{self.citizenship}')\"\n",
    "\n",
    "# Usage of the Person class\n",
    "print(\"--- 1. Creating Instances ---\")\n",
    "person1 = Person(\"Andrew\", 1980)\n",
    "person2 = Person(\"James\", 2000)\n",
    "\n",
    "print(f\"Name: {person1.name}, Age: {person1.calculate_age()}\")\n",
    "print(f\"Name: {person2.name}, Age: {person2.calculate_age()}\")\n",
    "\n",
    "print(\"\\n--- 2. Class vs Instance Attributes ---\")\n",
    "print(f\"Total people: {Person.number_of_people}\")\n",
    "print(Person.get_population())\n",
    "\n",
    "print(\"\\n--- 3. Static Methods ---\")\n",
    "age = person2.calculate_age()\n",
    "print(f\"Is {person2.name} an adult? {'Yes' if Person.is_adult(age) else 'No'}\")\n",
    "\n",
    "print(\"\\n--- 4. String Representations ---\")\n",
    "print(str(person1))  # Uses __str__\n",
    "print(repr(person1)) # Uses __repr__"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bf03c6e0",
   "metadata": {},
   "source": [
    "### Tasks:\n",
    "1. Add comprehensive docstrings to your previously created `Book` class.\n",
    "2. Implement the `__str__` method to show a friendly message like \"Book: Quo Vadis by Henryk Sienkiewicz - 600 pages\".\n",
    "3. Implement the `__repr__` method to return a string like \"Book(title='Quo Vadis', author='Henryk Sienkiewicz', pages=600)\"."
   ]
  }
 ],
 "metadata": {
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
