Python basic to advance
43 subscribers
131 photos
98 links
Learn Python daily with bite-sized lessons, code examples, output previews, and fun quizzes. Perfect for beginners and enthusiasts!
Download Telegram
Hey Pythonistas! 🐍 Get ready to unlock some serious processing power!

### Day 35: Coroutine Superpowers: Building High-Performance Data Pipelines with Asynchronous Generators 🚀

Beyond basic `asyncio`, today we dive into the magic of **Asynchronous Generators**. ✨ They combine the power of `async/await` for non-blocking operations with the memory efficiency of generators, making them perfect for building high-performance, back-pressured data pipelines. Imagine processing huge datasets or real-time streams without blocking your event loop! Let's see how to chain these powerful constructs for efficient data flow. ⚡️

```python
import asyncio
import random

async def produce_data(count: int, delay: float):
"""An asynchronous generator that yields simulated data."""
print("Producer started 📦")
for i in range(1, count + 1):
# Simulate async I/O or a network call
await asyncio.sleep(delay + random.uniform(0, 0.1))
print(f" Producer yielded: {i}")
yield i
print("Producer finished ✅")

async def process_data(data_stream):
"""An asynchronous generator that processes data from another async generator."""
print("Processor started ⚙️")
async for item in data_stream:
if item % 2 == 0: # Only process even numbers
processed_item = item * 10
print(f" Processor transformed: {item} -> {processed_item}")
yield processed_item
else:
print(f" Processor skipped: {item} (odd)")
print("Processor finished ✅")

async def consume_data(processed_stream):
"""An asynchronous function that consumes data from an async generator."""
print("Consumer started 📈")
total_processed = 0
async for item in processed_stream:
print(f" Consumer received: {item}")
total_processed += 1
print(f"Consumer finished. Total items received: {total_processed} ✅")

async def main():
print("--- Starting Pipeline ---")
data_source = produce_data(5, 0.2)
# Chain async generators: producer -> processor -> consumer
processed_pipeline = process_data(data_source)
await consume_data(processed_pipeline)
print("--- Pipeline Finished ---")

if __name__ == "__main__":
asyncio.run(main())

```

Output example:
```
--- Starting Pipeline ---
Consumer started 📈
Processor started ⚙️
Producer started 📦
Producer yielded: 1
Processor skipped: 1 (odd)
Producer yielded: 2
Processor transformed: 2 -> 20
Consumer received: 20
Producer yielded: 3
Processor skipped: 3 (odd)
Producer yielded: 4
Processor transformed: 4 -> 40
Consumer received: 40
Producer yielded: 5
Processor skipped: 5 (odd)
Producer finished ✅
Processor finished ✅
Consumer finished. Total items received: 2 ✅
--- Pipeline Finished ---
```

**Small Challenge:**
Add another asynchronous generator `log_data(processed_stream)` between `process_data` and `consume_data`. This new generator should simply print each item it receives before yielding it again, simulating logging or inspection without modification. Chain it into the pipeline! 🕵️‍♀️

---
Here's your Telegram post for Day 36 of the Python learning series!

---

🔥🐍 **Day 36: Cracking the CPython Egg!** 🤯🚀

Ready to peel back the layers of Python and truly understand how it works under the hood? Today, we're not just coding; we're *cracking the CPython egg* and exploring the advanced mechanisms that power your applications. Get ready to peek into bytecode, manipulate runtime, and sculpt complex systems with meta-programming. This isn't your average Python – it's the stuff that builds robust frameworks! ⚙️🔍

Let's start by looking at Python's bytecode, the low-level instructions the CPython interpreter executes. The `dis` module is our secret weapon here!

```python
import dis

def calculate_and_decide(a, b):
"""
A simple function to demonstrate Python bytecode.
It calculates a sum and applies a conditional transformation.
"""
intermediate_result = a + b
if intermediate_result > 10:
return intermediate_result * 2
else:
return intermediate_result + 1

print("--- Disassembling 'calculate_and_decide' function ---")
dis.dis(calculate_and_decide)
```

Output example:
```
--- Disassembling 'calculate_and_decide' function ---
8 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 STORE_FAST 2 (intermediate_result)

9 8 LOAD_FAST 2 (intermediate_result)
10 LOAD_CONST 1 (10)
12 COMPARE_OP 4 (>)
14 POP_JUMP_IF_FALSE 24

10 16 LOAD_FAST 2 (intermediate_result)
18 LOAD_CONST 2 (2)
20 BINARY_MULTIPLY
22 RETURN_VALUE

12 24 LOAD_FAST 2 (intermediate_result)
26 LOAD_CONST 3 (1)
28 BINARY_ADD
30 RETURN_VALUE
```

### Small Challenge:
Try disassembling a function that uses a `for` loop or a `while` loop. Can you identify the bytecode instructions responsible for the iteration, comparison, and jumping logic? Share your insights and which specific instructions you found interesting!

---
🚀 Day 37 Alert! Today, we're diving deep into Python's performance secrets with **Zero-Cost Abstractions**. Ever wondered how to squeeze every byte and cycle out of your code? We'll uncover techniques like `__slots__` that dramatically reduce memory footprint by eliminating `__dict__`, bringing C-like efficiency to your Python objects. Get ready to sculpt leaner, faster applications! ⚡️

```python
import sys

# Class without __slots__
# Each instance has its own __dict__ for attributes
class RegularPoint:
def __init__(self, x, y):
self.x = x
self.y = y

# Class with __slots__
# Attributes are stored directly in the instance, no __dict__
class SlottedPoint:
__slots__ = ('x', 'y') # Explicitly declare attributes
def __init__(self, x, y):
self.x = x
self.y = y

# Demonstrate memory usage
p_regular = RegularPoint(10, 20)
p_slotted = SlottedPoint(10, 20)

print(f"Memory size of RegularPoint: {sys.getsizeof(p_regular)} bytes")
print(f"Memory size of SlottedPoint: {sys.getsizeof(p_slotted)} bytes")

# Characteristic: __slots__ prevents adding new attributes dynamically
try:
p_slotted.z = 30
except AttributeError as e:
print(f"nAttempted to add new attribute to SlottedPoint: {e}")

# Check for __dict__ presence
print(f"RegularPoint has __dict__: {'__dict__' in p_regular.__dir__()}")
print(f"SlottedPoint has __dict__: {'__dict__' in p_slotted.__dir__()}")
```

Output example:
```
Memory size of RegularPoint: 56 bytes
Memory size of SlottedPoint: 40 bytes

Attempted to add new attribute to SlottedPoint: 'SlottedPoint' object has no attribute 'z'
RegularPoint has __dict__: True
SlottedPoint has __dict__: False
```

💡 **Your Challenge:** How does `__slots__` impact inheritance? What happens if a subclass *doesn't* define `__slots__` but its parent does? Share your thoughts! 👇

---
🚀 Day 38: Deconstructing Python with AST! 🚀

Day 38 is here, and we're diving deep into Python's soul! 🧠 Today, we're not just writing Python; we're *rewriting* Python from its very core. We'll explore the Abstract Syntax Tree (AST), the internal representation of your code. Learn how to parse, traverse, and even modify your Python programs programmatically. This is powerful stuff for building custom linters, static analyzers, code generators, or even domain-specific languages. Get ready to deconstruct and reconstruct! 🛠️🔍

```python
import ast
import inspect

# A simple Python function to analyze
def example_function(x):
result = x * 2
return result + 1

# Convert the function's source code to an AST
source_code = inspect.getsource(example_function)
parsed_ast = ast.parse(source_code)

# --- AST Transformation Example ---
class FunctionTransformer(ast.NodeTransformer):
"""
A NodeTransformer that renames 'example_function' to 'transformed_function'
and inserts a print statement at the beginning of its body.
"""
def visit_FunctionDef(self, node):
if node.name == 'example_function':
node.name = 'transformed_function'
# Create a print statement: print("Function transformed via AST!")
print_call = ast.Expr(
value=ast.Call(
func=ast.Name(id='print', ctx=ast.Load()),
args=[ast.Constant(value="Function transformed via AST!")],
keywords=[]
)
)
# Add the print statement at the beginning of the function body
node.body.insert(0, print_call)
self.generic_visit(node) # Ensure we visit child nodes of the function
return node

transformer = FunctionTransformer()
modified_ast = transformer.visit(parsed_ast)

# Unparse the modified AST back into Python code
unparsed_code = ast.unparse(modified_ast)
print("--- Original Code ---")
print(source_code)
print("n--- Transformed Code ---")
print(unparsed_code)

# Compile and execute the transformed code to verify its functionality
print("n--- Executing Transformed Function ---")
exec_globals = {} # A dictionary to capture the defined function
exec(compile(modified_ast, '<string>', 'exec'), exec_globals)

# Now, call the transformed function from the exec_globals dict
if 'transformed_function' in exec_globals:
print(f"Result from transformed_function(5): {exec_globals['transformed_function'](5)}")

```

Output example:
```
--- Original Code ---
def example_function(x):
result = x * 2
return result + 1

--- Transformed Code ---
def transformed_function(x):
print('Function transformed via AST!')
result = x * 2
return result + 1

--- Executing Transformed Function ---
Function transformed via AST!
Result from transformed_function(5): 11
```

Challenge:
Your Challenge: Modify the `FunctionTransformer` to change all integer literals (e.g., `2` and `1` in `example_function`) within any function to their float equivalents (e.g., `2.0` and `1.0`). Hint: You'll need to override `visit_Constant` and check if its `value` is an `int`. Good luck, AST wizard! ✨

---
Which `ast` module class is primarily used for modifying nodes during AST traversal, allowing for transformation of the code structure?
Anonymous Quiz
0%
ast.NodeTransformer
0%
ast.NodeVisitor
0%
ast.ASTWalker
0%
ast.CodeModifier
Hey Pythonistas! 👋 Ever wondered how `property()`, `classmethod()`, or even regular methods work their magic? ✨ Today, on Day 39, we're unlocking the secrets of Python's **Descriptor Protocol**!

This advanced concept allows objects to customize attribute access (`__get__`, `__set__`, `__delete__`), going far beyond simple `__getattr__` or `__setattr__`. Understanding descriptors is key to grasping Python's object model at a much deeper level and writing truly powerful, reusable code. Ready to empower your classes? Let's dive in! 🚀

```python
import types

class ValueLogger:
"""A descriptor that logs attribute access, assignment, and deletion."""
def __init__(self, name):
# We need an internal place to store the actual value.
# In a real scenario, this might store data in the instance's __dict__
# or in a weakref map keyed by instance. For simplicity, we'll store it here.
self._name = name
self._value = None

def __get__(self, instance, owner):
if instance is None:
# Accessing descriptor via the class (e.g., MyClass.my_attr)
print(f"[{self._name}] Accessing descriptor via class {owner.__name__}")
return self
# Accessing descriptor via an instance (e.g., obj.my_attr)
print(f"[{self._name}] Getting value for {instance.__class__.__name__}.{self._name}")
return self._value

def __set__(self, instance, value):
# Setting an attribute on an instance (e.g., obj.my_attr = 42)
print(f"[{self._name}] Setting value for {instance.__class__.__name__}.{self._name} to '{value}'")
self._value = value

def __delete__(self, instance):
# Deleting an attribute on an instance (e.g., del obj.my_attr)
print(f"[{self._name}] Deleting value for {instance.__class__.__name__}.{self._name}")
del self._value

class Account:
balance = ValueLogger('balance') # Assigning a descriptor instance

def __init__(self, initial_balance):
self.balance = initial_balance # This calls ValueLogger.__set__

# --- Demonstration ---
print("--- Creating Account and setting initial balance ---")
my_account = Account(100)
print(f"Initial balance is: {my_account.balance}") # Calls ValueLogger.__get__

print("n--- Changing balance ---")
my_account.balance = 150 # Calls ValueLogger.__set__
print(f"New balance is: {my_account.balance}") # Calls ValueLogger.__get__

print("n--- Accessing descriptor via class ---")
print(Account.balance) # Calls ValueLogger.__get__ with instance=None

print("n--- Deleting balance ---")
del my_account.balance # Calls ValueLogger.__delete__

try:
print(my_account.balance) # This will likely fail now that _value is deleted
except AttributeError:
print("Attribute 'balance' successfully deleted from my_account.")

```

Output example:
```
--- Creating Account and setting initial balance ---
[balance] Setting value for Account.balance to '100'
[balance] Getting value for Account.balance
Initial balance is: 100

--- Changing balance ---
[balance] Setting value for Account.balance to '150'
[balance] Getting value for Account.balance
New balance is: 150

--- Accessing descriptor via class ---
[balance] Accessing descriptor via class Account
<__main__.ValueLogger object at 0x...>

--- Deleting balance ---
[balance] Deleting value for Account.balance
Attribute 'balance' successfully deleted from my_account.
```

### Small Challenge:
Can you modify the `ValueLogger` descriptor to enforce **type checking**? For example, make `balance` always an `int` or `float`, raising a `TypeError` if a different type is assigned. 🕵️‍♂️ Alternatively, explore the `__set_name__` method (added in Python 3.6) to automatically get the attribute's name without needing to pass it in `__init__`! 💡

---
Which of the following built-in Python features *primarily* relies on the descriptor protocol for its functionality?
Anonymous Quiz
0%
A) The `property()` decorator
0%
B) The `__getattr__` special method
0%
C) The `try...except` block for error handling
0%
D) List comprehensions
🚀 **Day 40: Synthesizing Reality - Dynamic Code Generation with AST Manipulation!** 🤯

We've reached the apex! For our grand finale, let's explore one of Python's most mind-bending capabilities: **dynamic code generation**. Forget just defining classes; today, we're building and executing code *from scratch* at runtime using Python's Abstract Syntax Tree (AST) and the mighty `exec` function. This isn't just theory; it's the foundation for DSLs, high-performance runtime optimizations, and even self-modifying programs! Are you ready to dive into the ultimate metaprogramming challenge? 🛠️✨

```python
import ast
import inspect
import sys

# Our goal: Dynamically create a function like this:
# def greet_dynamic(name):
# print(f"Hello, {name}! This code was generated on the fly.")

# Step 1: Build the Abstract Syntax Tree (AST) for the function.
# This part manually constructs the AST nodes representing the function.
func_name_node = ast.Name(id='greet_dynamic', ctx=ast.Store())
arg_node = ast.arg(arg='name', annotation=None)
args_node = ast.arguments(posonlyargs=[], args=[arg_node], vararg=None,
kwonlyargs=[], kw_defaults=[], defaults=[])

# The f-string: f"Hello, {name}! This code was generated on the fly."
f_string_parts = [
ast.Constant(value="Hello, "),
ast.FormattedValue(value=ast.Name(id='name', ctx=ast.Load()), conversion=-1, format_spec=None),
ast.Constant(value="! This code was generated on the fly.")
]
f_string_node = ast.JoinedStr(values=f_string_parts)

# The print statement: print(...)
print_call_node = ast.Expr(value=ast.Call(
func=ast.Name(id='print', ctx=ast.Load()),
args=[f_string_node],
keywords=[]
))

# The function body
func_body_nodes = [print_call_node]

# The function definition node
func_def_node = ast.FunctionDef(
name=func_name_node.id,
args=args_node,
body=func_body_nodes,
decorator_list=[],
returns=None,
type_comment=None
)

# Step 2: Create a module AST that contains our function definition.
module_node = ast.Module(body=[func_def_node], type_ignores=[])

# Step 3: Compile the AST into a code object.
# The '<string>' argument is the filename for traceback purposes.
compiled_code_obj = compile(module_node, '<string>', 'exec')

# Step 4: Execute the compiled code in a new environment.
# `exec_env` will store the definitions created by `exec`.
exec_env = {}
exec(compiled_code_obj, exec_env)

# Step 5: Retrieve and use the dynamically created function.
dynamic_function = exec_env['greet_dynamic']

print("--- Dynamically Generated Code ---")
# Use ast.unparse (Python 3.9+) to show what the AST represents
# For older Python versions, this part needs manual reconstruction or a custom visitor.
if sys.version_info >= (3, 9):
print(ast.unparse(module_node))
else:
# Fallback for Python versions < 3.9
print("# ast.unparse requires Python 3.9+ to directly visualize the AST.")
print("def greet_dynamic(name):")
print(" print(f"Hello, {name}! This code was generated on the fly.")")
print("----------------------------------")

print("n--- Executing Generated Function ---")
# Call the function
dynamic_function("Pythonista")
```

Output example:
```
--- Dynamically Generated Code ---
def greet_dynamic(name):
print(f'Hello, {name}! This code was generated on the fly.')
----------------------------------

--- Executing Generated Function ---
Hello, Pythonista! This code was generated on the fly.
```

✨ **Challenge for Day 40:**
Can you modify the AST to make `greet_dynamic` also print the current timestamp *before* its greeting? (Hint: You'll need to add an `ast.Import` for `datetime` and an additional `ast.Expr` node containing a call to `datetime.datetime.now()` to the function body! Remember to add the `ast.Import` at the module level.)
Which of the following methods are part of Python's Descriptor Protocol, allowing customization of attribute access?
Anonymous Quiz
0%
A. `__iter__`, `__next__`
0%
B. `__enter__`, `__exit__`
0%
C. `__get__`, `__set__`, `__delete__`
0%
D. `__str__`, `__repr__`
Here's your Telegram post for Day 43!

---

🌟 **Day 43: The Zen of Descriptors - Crafting Intuitive & Efficient Attribute Access** 🌟

Ever wondered how `@property`, methods, or even `staticmethod`s work their magic under the hood? ✨ The secret often lies with **descriptors**! These powerful Python objects allow you to customize attribute access, setting, and deletion for class instances.

Descriptors empower you to design cleaner APIs, enforce validation rules, implement caching, or create sophisticated attribute behaviors with elegant reuse. It's a key concept for building robust and "Pythonic" code, often used implicitly in popular frameworks!

Let's dive into creating our own descriptor for validated attributes! 👇

```python
import numbers

class ValidatedNumber:
"""A descriptor that ensures an attribute is a positive number."""
def __set_name__(self, owner, name):
# __set_name__ is called when the descriptor is assigned to a class attribute.
# It's perfect for knowing the attribute's name in the owner class.
self.public_name = name
self.private_name = '_' + name # Store value in a private attribute

def __get__(self, obj, obj_type=None):
if obj is None: # Accessing descriptor directly from class (e.g., Product.price)
return self
return getattr(obj, self.private_name)

def __set__(self, obj, value):
if not isinstance(value, numbers.Number):
raise TypeError(f"'{self.public_name}' must be a number.")
if value <= 0:
raise ValueError(f"'{self.public_name}' must be positive.")
setattr(obj, self.private_name, value)

def __delete__(self, obj):
delattr(obj, self.private_name)

class Product:
price = ValidatedNumber()
quantity = ValidatedNumber()

def __init__(self, price: float, quantity: int):
self.price = price
self.quantity = quantity

def get_total_cost(self) -> float:
return self.price * self.quantity

```

**Output example:**
```
# ✅ Valid product creation
laptop = Product(price=1200.50, quantity=2)
print(f"Laptop cost: ${laptop.get_total_cost()}")
print(f"Laptop price: {laptop.price}, quantity: {laptop.quantity}")

# ❌ Trying to set an invalid price (negative)
try:
phone = Product(price=-500, quantity=1)
except ValueError as e:
print(f"nError creating phone: {e}")

# ❌ Trying to set an invalid quantity (not a number)
try:
tablet = Product(price=300, quantity="one")
except TypeError as e:
print(f"nError creating tablet: {e}")

# 🔄 Modifying an existing attribute with invalid value
try:
laptop.price = 0
except ValueError as e:
print(f"nError modifying laptop price: {e}")

# 🗑️ Deleting an attribute
del laptop.quantity
try:
print(laptop.quantity)
except AttributeError as e:
print(f"nError accessing deleted quantity: {e}")
```

**Output:**
```
Laptop cost: $2401.0
Laptop price: 1200.5, quantity: 2

Error creating phone: 'price' must be positive.

Error creating tablet: 'quantity' must be a number.

Error modifying laptop price: 'price' must be positive.

Error accessing deleted quantity: '_quantity'
```

---

💡 **Small Challenge:**
Enhance the `ValidatedNumber` descriptor! Modify it so that it can accept an optional `max_value` argument during its creation (e.g., `price = ValidatedNumber(max_value=5000)`). Ensure that the set value is always less than or equal to `max_value` if it's provided. Good luck! 🚀

---
Which special method in a descriptor class is primarily responsible for receiving the name of the attribute it's assigned to in the owner class?
Anonymous Quiz
0%
A. `__get__`
100%
B. `__set__`
0%
C. `__set_name__`
0%
D. `__init__`
Which of the following special methods is NOT a core part of Python's descriptor protocol for controlling attribute access?
Anonymous Quiz
0%
__get__
0%
__set__
0%
__call__
0%
__delete__
🚀 Day 45: Ever wondered how `property()`, `classmethod()`, or `staticmethod()` work their magic in Python? ✨ Dive deep with us into the #DescriptorProtocol! This powerful, often-hidden mechanism is the secret sauce behind attribute access. Understanding descriptors unlocks a new level of control over how attributes behave, allowing you to create highly customized and reusable attribute logic. Let's peel back the curtain and see how you can harness this core Python feature! 🐍💡

```python
class ReadOnly:
"""
A descriptor that makes an attribute read-only after its initial assignment.
"""
def __init__(self, initial_value=None):
self.initial_value = initial_value
self._name = None # To be set by __set_name__

def __set_name__(self, owner, name):
# This method is called automatically when the descriptor is assigned
# to a class attribute. 'name' will be the attribute name (e.g., 'id').
self._name = name

def __get__(self, obj, obj_type=None):
if obj is None:
# Accessing from class, return the descriptor itself
return self
# Accessing from instance, return the instance's private attribute
# If not yet set, return the descriptor's initial_value
return getattr(obj, f'_{self._name}', self.initial_value)

def __set__(self, obj, value):
# Check if the attribute has already been set on the instance
if hasattr(obj, f'_{self._name}'):
raise AttributeError(f"'{self._name}' is a read-only attribute and cannot be modified.")
# Initial assignment
setattr(obj, f'_{self._name}', value)

class Product:
# 'product_id' and 'name' are read-only attributes using our descriptor
product_id = ReadOnly()
name = ReadOnly()

def __init__(self, p_id, p_name):
self.product_id = p_id # Calls ReadOnly.__set__ for initial assignment
self.name = p_name # Calls ReadOnly.__set__ for initial assignment

# --- Demonstration ---
print("Creating product1...")
product1 = Product("P001", "Laptop Pro")
print(f"Product1 ID: {product1.product_id}")
print(f"Product1 Name: {product1.name}")

print("nAttempting to modify product_id:")
try:
product1.product_id = "P002"
except AttributeError as e:
print(f"Error: {e}")

print("nCreating product2...")
product2 = Product("P003", "Mouse MX")
print(f"Product2 ID: {product2.product_id}")

print("nAccessing the descriptor directly from the class (not an instance):")
print(f"Product.product_id: {Product.product_id}")
```

Output example:
```
Creating product1...
Product1 ID: P001
Product1 Name: Laptop Pro

Attempting to modify product_id:
Error: 'product_id' is a read-only attribute and cannot be modified.

Creating product2...
Product2 ID: P003

Accessing the descriptor directly from the class (not an instance):
Product.product_id: <__main__.ReadOnly object at 0x...>
```

**Small challenge:**
Your challenge: Modify the `ReadOnly` descriptor to accept a `validator` function in its `__init__`. This `validator` function should be called within the `__set__` method *before* assigning the value, raising an error if the validation fails. This would allow for more complex validation logic beyond just read-only behavior!

---
Which of the following methods is called when you *access* an attribute (e.g., `obj.attribute`) that is managed by a descriptor in Python?
Anonymous Quiz
0%
__get__(self, obj, obj_type)
0%
__set__(self, obj, value)
0%
__call__(self, *args, **kwargs)
0%
__getattr__(self, name)
🚀 Day 47 Alert! Ready to dive deep into Python's core? Today, we're unveiling a truly powerful "secret weapon": **Abstract Syntax Tree (AST) Manipulation**! 🤯 Forget just writing code; what if you could inspect, modify, or even *generate* Python code at runtime? This technique is the foundation for linters, static analyzers, code transformers, and even building your own Domain-Specific Languages (DSLs)! Get ready to become a Python sorcerer. ✨

```python
import ast

# 1. Define a string with our target Python code
code_str = """
def apply_op(a, b):
result = a + b
return result
"""

print(f"Original code string defines addition.")

# 2. Parse the code string into an Abstract Syntax Tree (AST)
tree = ast.parse(code_str)

# 3. Define an AST NodeTransformer to modify the tree
class AddToMulTransformer(ast.NodeTransformer):
def visit_BinOp(self, node):
# If the binary operation is addition, change it to multiplication
if isinstance(node.op, ast.Add):
print(f" --> Found '+' operation, changing to '*'")
node.op = ast.Mult()
# Ensure we visit child nodes to catch nested operations if any
return self.generic_visit(node)

# 4. Apply the transformation
transformer = AddToMulTransformer()
modified_tree = transformer.visit(tree)

# 5. Compile the modified AST into executable code
# The '<string>' argument is a filename for debugging purposes
# 'exec' mode means it's a sequence of statements
compiled_code = compile(modified_tree, '<transformed_code_string>', 'exec')

# 6. Execute the compiled code in a new, isolated namespace
transformed_namespace = {}
# Using empty globals/locals for isolation ensures our new function doesn't clash
exec(compiled_code, {}, transformed_namespace)

# 7. Retrieve the transformed function and call it
transformed_func = transformed_namespace['apply_op']

print(f"After AST transformation:")
print(f"apply_op(5, 3) now yields: {transformed_func(5, 3)}")

print("n--- Verifying AST Change ---")
# Optional: Inspect the original and transformed ASTs to confirm the change
original_tree = ast.parse(code_str)
# We need to find the specific operation node for clarity
for node in original_tree.body:
if isinstance(node, ast.FunctionDef) and node.name == 'apply_op':
for stmt in node.body:
if isinstance(stmt, ast.Assign) and
any(isinstance(t, ast.Name) and t.id == 'result' for t in stmt.targets) and
isinstance(stmt.value, ast.BinOp):
print(f"Original AST operation for 'result': {type(stmt.value.op).__name__}")
break
break

for node in modified_tree.body:
if isinstance(node, ast.FunctionDef) and node.name == 'apply_op':
for stmt in node.body:
if isinstance(stmt, ast.Assign) and
any(isinstance(t, ast.Name) and t.id == 'result' for t in stmt.targets) and
isinstance(stmt.value, ast.BinOp):
print(f"Transformed AST operation for 'result': {type(stmt.value.op).__name__}")
break
break
```

Output example:
```
Original code string defines addition.
--> Found '+' operation, changing to '*'
After AST transformation:
apply_op(5, 3) now yields: 15

--- Verifying AST Change ---
Original AST operation for 'result': Add
Transformed AST operation for 'result': Mult
```

Challenge: Can you modify the `AddToMulTransformer` to change *all* occurrences of the variable name `result` to `final_value` within the function definition? (Hint: You'll need to override `visit_Name`!) Good luck, Python magicians! 🧙‍♂️

---
Here's your Telegram post for Day 48!

---

Day 48 is here, and we're elevating our Python architecture game! 🚀 Ever wanted to build systems that are not just type-checked, but also incredibly flexible and reusable? Today, we dive into the true power of Python's `typing` module! We'll unlock `Protocol` for structural subtyping and master complex generic patterns to craft robust, future-proof codebases. Say goodbye to runtime surprises and hello to beautifully designed, type-safe Python! ✨ Let's build better together!

```python
from typing import Protocol, TypeVar, Iterable

# Define a Protocol for any object that has a 'name' attribute and a 'process' method
class Processor(Protocol):
name: str
def process(self, data: str) -> str:
... # Ellipsis indicates no implementation needed in protocol

# Implementations that adhere to the Processor Protocol
class SimpleProcessor:
def __init__(self, name: str):
self.name = name
def process(self, data: str) -> str:
return f"{self.name} processed: {data.upper()}"

class AdvancedProcessor:
def __init__(self, identifier: str):
self.name = f"Adv-{identifier}" # Provides 'name' attribute
def process(self, data: str) -> str:
return f"[{self.name}] handled: {data.lower()}"

# A generic function that accepts any type adhering to the Processor Protocol
T = TypeVar('T', bound=Processor) # T must be a Processor or a subtype

def run_processors(processors: Iterable[T], input_data: str) -> list[str]:
results = []
for p in processors:
print(f"Running {p.name}...")
results.append(p.process(input_data))
return results

# Let's test it!
simple_p = SimpleProcessor("Basic")
advanced_p = AdvancedProcessor("Complex")

# Create a list of objects that adhere to the Processor Protocol
processor_list: list[Processor] = [simple_p, advanced_p] # Type-checked!

print("n--- Running Processors ---")
output = run_processors(processor_list, "hello world")
print("nResults:")
for res in output:
print(f"- {res}")

# If we tried to add a non-conforming object and run a static type checker (like mypy),
# it would flag an error because it doesn't meet the Protocol's requirements.
# Example (uncomment and run mypy to see error):
# class BadProcessor:
# def execute(self, data: str) -> str: # No 'name' and method name mismatch
# return "Oops"
# bad_p = BadProcessor()
# processor_list.append(bad_p) # Mypy error: "BadProcessor" does not implement protocol "Processor"
```

Output example:
```
--- Running Processors ---
Running Basic...
Running Adv-Complex...

Results:
- Basic processed: HELLO WORLD
- [Adv-Complex] handled: hello world
```

**Challenge time!** 🎯
1. Modify `AdvancedProcessor` to *not* have a `name` attribute or `process` method, and observe what `mypy` reports if you try to use it with `run_processors`.
2. Can you create another class `Logger` that implements a `Loggable` `Protocol` (e.g., with a `log_message` method and `level` attribute), and then write a generic function that takes an `Iterable` of `Loggable` objects? Share your solutions!

---