Introduction
In this lab, you will learn about delegating generators in Python using the yield from statement. This feature, introduced in Python 3.3, simplifies code that depends on generators and coroutines.
Generators are special functions that can pause and resume execution, retaining their state between calls. The yield from statement offers an elegant way to delegate control to another generator, enhancing code readability and maintainability.
Objectives:
- Understand the purpose of the
yield fromstatement - Learn how to use
yield fromto delegate to other generators - Apply this knowledge to simplify coroutine-based code
- Understand the connection to modern async/await syntax
Files you will work with:
cofollow.py- Contains coroutine utility functionsserver.py- Contains a simple network server implementation
Understanding the yield from Statement
In this step, we're going to explore the yield from statement in Python. This statement is a powerful tool when working with generators, and it simplifies the process of delegating operations to other generators. By the end of this step, you'll understand what yield from is, how it works, and how it can handle value passing between different generators.
What is yield from?
The yield from statement was introduced in Python 3.3. Its main purpose is to simplify the delegation of operations to subgenerators. A subgenerator is just another generator that a main generator can delegate work to.
Normally, when you want a generator to yield values from another generator, you'd have to use a loop. For example, without yield from, you'd write code like this:
def delegating_generator():
for value in subgenerator():
yield value
In this code, the delegating_generator uses a for loop to iterate over the values produced by subgenerator and then yields each value one by one.
However, with the yield from statement, the code becomes much simpler:
def delegating_generator():
yield from subgenerator()
This single line of code achieves the same result as the loop in the previous example. But yield from is not just a shortcut. It also manages the bidirectional communication between the caller and the subgenerator. This means that any values sent to the delegating generator are passed directly to the subgenerator.
Basic Example
Let's create a simple example to see how yield from works in action.
- First, we need to open the
cofollow.pyfile in the editor. To do this, we'll use thecdcommand to navigate to the correct directory. Run the following command in the terminal:
cd /home/labex/project
- Next, we'll add two functions to the
cofollow.pyfile. Thesubgenfunction is a simple generator that yields the numbers from 0 to 4. Themain_genfunction usesyield fromto delegate the generation of these numbers tosubgenand then yields the string'Done'. Add the following code to the end of thecofollow.pyfile:
def subgen():
for i in range(5):
yield i
def main_gen():
yield from subgen()
yield 'Done'
- Now, let's test these functions. Open a Python shell and run the following code:
from cofollow import subgen, main_gen
## Test subgen directly
for x in subgen():
print(x)
## Test main_gen that delegates to subgen
for x in main_gen():
print(x)
When you run this code, you should see the following output:
0
1
2
3
4
0
1
2
3
4
Done
This output shows that yield from allows main_gen to pass all the values generated by subgen directly to the caller.
Value Passing with yield from
One of the most powerful features of yield from is its ability to handle value passing in both directions. Let's create a more complex example to demonstrate this.
- Add the following functions to the
cofollow.pyfile:
def accumulator():
total = 0
while True:
value = yield total
if value is None:
break
total += value
def caller():
acc = accumulator()
yield from acc
yield 'Total accumulated'
The accumulator function is a coroutine that keeps track of a running total. It yields the current total and then waits to receive a new value. If it receives None, it stops the loop. The caller function creates an instance of accumulator and uses yield from to delegate all send and receive operations to it.
- Test these functions in a Python shell:
from cofollow import caller
c = caller()
print(next(c)) ## Start the coroutine
print(c.send(1)) ## Send value 1, get accumulated value
print(c.send(2)) ## Send value 2, get accumulated value
print(c.send(3)) ## Send value 3, get accumulated value
print(c.send(None)) ## Send None to exit the accumulator
When you run this code, you should see the following output:
0
1
3
6
'Total accumulated'
This output shows that yield from fully delegates all send and receive operations to the subgenerator until it's exhausted.
Now that you understand the basics of yield from, we'll move on to more practical applications in the next step.
Using yield from in Coroutines
In this step, we'll explore how to use the yield from statement with coroutines for more practical applications. Coroutines are a powerful concept in Python, and understanding how to use yield from with them can greatly simplify your code.
Coroutines and Message Passing
Coroutines are special functions that can receive values through the yield statement. They're incredibly useful for tasks such as data processing and event handling. In the cofollow.py file, there's a consumer decorator. This decorator helps set up coroutines by automatically advancing them to the first yield point. This means you don't have to manually start the coroutine; the decorator takes care of it for you.
Let's create a coroutine that receives values and validates their types. Here's how you can do it:
- First, open the
cofollow.pyfile in the editor. You can use the following command in the terminal to navigate to the correct directory:
cd /home/labex/project
- Next, add the following
receivefunction at the end of thecofollow.pyfile. This function is a coroutine that will receive a message and validate its type.
def receive(expected_type):
"""
A coroutine that receives a message and validates its type.
Returns the received message if it matches the expected type.
"""
msg = yield
assert isinstance(msg, expected_type), f'Expected type {expected_type}'
return msg
Here's what this function does:
- It uses
yieldwithout an expression to receive a value. When the coroutine is sent a value, thisyieldstatement will capture it. - It checks if the received value is of the expected type using the
isinstancefunction. If the type doesn't match, it raises anAssertionError. - If the type check passes, it returns the value.
- Now, let's create a coroutine that uses
yield fromwith ourreceivefunction. This new coroutine will receive and print integers only.
@consumer
def print_ints():
"""
A coroutine that receives and prints integers only.
Uses yield from to delegate to the receive coroutine.
"""
while True:
val = yield from receive(int)
print('Got:', val)
- To test this coroutine, open a Python shell and run the following code:
from cofollow import print_ints
p = print_ints()
p.send(42)
p.send(13)
try:
p.send('13') ## This should raise an AssertionError
except AssertionError as e:
print(f"Error: {e}")
You should see the following output:
Got: 42
Got: 13
Error: Expected type <class 'int'>
Understanding How yield from Works with Coroutines
When we use yield from receive(int) in the print_ints coroutine, the following steps occur:
- Control is delegated to the
receivecoroutine. This means that theprint_intscoroutine pauses, and thereceivecoroutine starts executing. - The
receivecoroutine usesyieldto receive a value. It waits for a value to be sent to it. - When a value is sent to
print_ints, it's actually received byreceive. Theyield fromstatement takes care of passing the value fromprint_intstoreceive. - The
receivecoroutine validates the type of the received value. If the type is correct, it returns the value. - The returned value becomes the result of the
yield fromexpression in theprint_intscoroutine. This means that thevalvariable inprint_intsgets assigned the value returned byreceive.
Using yield from makes the code more readable than if we had to handle the yielding and receiving directly. It abstracts away the complexity of passing values between coroutines.
Creating More Advanced Type-Checking Coroutines
Let's expand our utility functions to handle more complex type validation. Here's how you can do it:
- Add the following functions to the
cofollow.pyfile:
def receive_dict():
"""Receive and validate a dictionary"""
result = yield from receive(dict)
return result
def receive_str():
"""Receive and validate a string"""
result = yield from receive(str)
return result
@consumer
def process_data():
"""Process different types of data using the receive utilities"""
while True:
print("Waiting for a string...")
name = yield from receive_str()
print(f"Got string: {name}")
print("Waiting for a dictionary...")
data = yield from receive_dict()
print(f"Got dictionary with {len(data)} items: {data}")
print("Processing complete for this round.")
- To test the new coroutine, open a Python shell and run the following code:
from cofollow import process_data
proc = process_data()
proc.send("John Doe")
proc.send({"age": 30, "city": "New York"})
proc.send("Jane Smith")
try:
proc.send(123) ## This should raise an AssertionError
except AssertionError as e:
print(f"Error: {e}")
You should see output like this:
Waiting for a string...
Got string: John Doe
Waiting for a dictionary...
Got dictionary with 2 items: {'age': 30, 'city': 'New York'}
Processing complete for this round.
Waiting for a string...
Got string: Jane Smith
Waiting for a dictionary...
Error: Expected type <class 'dict'>
The yield from statement makes the code cleaner and more readable. It allows us to focus on the high-level logic of our program rather than getting bogged down in the details of message passing between coroutines.
Wrapping Sockets with Generators
In this step, we're going to learn how to use generators to wrap socket operations. This is a really important concept, especially when it comes to asynchronous programming. Asynchronous programming allows your program to handle multiple tasks at once without waiting for one task to finish before starting another. Using generators to wrap socket operations can make your code more efficient and easier to manage.
Understanding the Problem
The server.py file contains a simple network server implementation using generators. Let's take a look at the current code. This code is the foundation of our server, and understanding it is crucial before we make any changes.
def tcp_server(address, handler):
sock = socket(AF_INET, SOCK_STREAM)
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sock.bind(address)
sock.listen(5)
while True:
yield 'recv', sock
client, addr = sock.accept()
tasks.append(handler(client, addr))
def echo_handler(client, address):
print('Connection from', address)
while True:
yield 'recv', client
data = client.recv(1000)
if not data:
break
yield 'send', client
client.send(b'GOT:' + data)
print('Connection closed')
client.close()
In this code, we use the yield keyword. The yield keyword is used in Python to create generators. A generator is a special type of iterator that allows you to pause and resume a function's execution. Here, yield is used to indicate when the server is ready to receive a connection or when a client handler is ready to receive or send data. However, the manual yield statements expose the internal workings of the event loop to the user. This means that the user has to know how the event loop works, which can make the code harder to understand and maintain.
Creating a GenSocket Class
Let's create a GenSocket class to wrap socket operations with generators. This will make our code cleaner and more readable. By encapsulating the socket operations in a class, we can hide the details of the event loop from the user and focus on the high-level logic of the server.
- Open the
server.pyfile in the editor:
cd /home/labex/project
This command changes the current directory to the project directory where the server.py file is located. Once you're in the correct directory, you can open the file in your preferred text editor.
- Add the following
GenSocketclass at the end of the file, before any existing functions:
class GenSocket:
"""
A generator-based wrapper for socket operations.
"""
def __init__(self, sock):
self.sock = sock
def accept(self):
"""Accept a connection and return a new GenSocket"""
yield 'recv', self.sock
client, addr = self.sock.accept()
return GenSocket(client), addr
def recv(self, maxsize):
"""Receive data from the socket"""
yield 'recv', self.sock
return self.sock.recv(maxsize)
def send(self, data):
"""Send data to the socket"""
yield 'send', self.sock
return self.sock.send(data)
def __getattr__(self, name):
"""Forward any other attributes to the underlying socket"""
return getattr(self.sock, name)
This GenSocket class acts as a wrapper for socket operations. The __init__ method initializes the class with a socket object. The accept, recv, and send methods perform the corresponding socket operations and use yield to indicate when the operation is ready. The __getattr__ method allows the class to forward any other attributes to the underlying socket object.
- Now, modify the
tcp_serverandecho_handlerfunctions to use theGenSocketclass:
def tcp_server(address, handler):
sock = GenSocket(socket(AF_INET, SOCK_STREAM))
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sock.bind(address)
sock.listen(5)
while True:
client, addr = yield from sock.accept()
tasks.append(handler(client, addr))
def echo_handler(client, address):
print('Connection from', address)
while True:
data = yield from client.recv(1000)
if not data:
break
yield from client.send(b'GOT:' + data)
print('Connection closed')
client.close()
Notice how the explicit yield 'recv', sock and yield 'send', client statements have been replaced with cleaner yield from expressions. The yield from keyword is used to delegate the execution to another generator. This makes the code more readable and hides the details of the event loop from the user. Now, the code looks more like normal function calls, and the user doesn't have to worry about the internal workings of the event loop.
- Let's add a simple test function to demonstrate how our server would be used:
def run_server():
"""Start the server on port 25000"""
tasks.append(tcp_server(('localhost', 25000), echo_handler))
try:
event_loop()
except KeyboardInterrupt:
print("Server stopped")
if __name__ == '__main__':
print("Starting echo server on port 25000...")
print("Press Ctrl+C to stop")
run_server()
This code is more readable and maintainable. The GenSocket class encapsulates the yielding logic, allowing the server code to focus on the high-level flow rather than the details of the event loop. The run_server function starts the server on port 25000 and handles the KeyboardInterrupt exception, which allows the user to stop the server by pressing Ctrl+C.
Understanding the Benefits
The yield from approach provides several benefits:
- Cleaner code: The socket operations look more like normal function calls. This makes the code easier to read and understand, especially for beginners.
- Abstraction: The details of the event loop are hidden from the user. The user doesn't have to know how the event loop works to use the server code.
- Readability: The code better expresses what it's doing rather than how it's doing it. This makes the code more self-explanatory and easier to maintain.
- Maintainability: Changes to the event loop won't require changes to the server code. This means that if you need to modify the event loop in the future, you can do so without affecting the server code.
This pattern is a stepping stone to modern async/await syntax, which we'll explore in the next step. The async/await syntax is a more advanced and cleaner way to write asynchronous code in Python, and understanding the yield from pattern will help you transition to it more easily.
From Generators to Async/Await
In this final step, we'll explore how the yield from pattern in Python evolved into the modern async/await syntax. Understanding this evolution is crucial as it helps you see the connection between generators and asynchronous programming. Asynchronous programming allows your program to handle multiple tasks without waiting for each one to finish, which is especially useful in network programming and other I/O - bound operations.
The Connection Between Generators and Async/Await
The async/await syntax, introduced in Python 3.5, is built on top of the generator and yield from functionality. Under the hood, async functions are implemented using generators. This means that the concepts you've learned about generators are directly related to how async/await works.
To transition from using generators to the async/await syntax, we need to follow these steps:
- Use the
@coroutinedecorator from thetypesmodule. This decorator helps convert generator - based functions into a form that can be used withasync/await. - Convert functions that use
yield fromto useasyncandawaitinstead. This makes the code more readable and better expresses the asynchronous nature of the operations. - Update the event loop to handle native coroutines. The event loop is responsible for scheduling and running asynchronous tasks.
Updating the GenSocket Class
Now, let's modify our GenSocket class to work with the @coroutine decorator. This will allow our class to be used in an async/await context.
- Open the
server.pyfile in the editor. You can do this by running the following command in the terminal:
cd /home/labex/project
- At the top of the
server.pyfile, add the import forcoroutine. This import is necessary to use the@coroutinedecorator.
from types import coroutine
- Update the
GenSocketclass to use the@coroutinedecorator. This decorator transforms our generator - based methods into awaitable coroutines, which means they can be used with theawaitkeyword.
class GenSocket:
"""
A generator-based wrapper for socket operations
that works with async/await.
"""
def __init__(self, sock):
self.sock = sock
@coroutine
def accept(self):
"""Accept a connection and return a new GenSocket"""
yield 'recv', self.sock
client, addr = self.sock.accept()
return GenSocket(client), addr
@coroutine
def recv(self, maxsize):
"""Receive data from the socket"""
yield 'recv', self.sock
return self.sock.recv(maxsize)
@coroutine
def send(self, data):
"""Send data to the socket"""
yield 'send', self.sock
return self.sock.send(data)
def __getattr__(self, name):
"""Forward any other attributes to the underlying socket"""
return getattr(self.sock, name)
Converting to Async/Await Syntax
Next, let's convert our server code to use the async/await syntax. This will make the code more readable and clearly express the asynchronous nature of the operations.
async def tcp_server(address, handler):
"""
An asynchronous TCP server using async/await.
"""
sock = GenSocket(socket(AF_INET, SOCK_STREAM))
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sock.bind(address)
sock.listen(5)
while True:
client, addr = await sock.accept()
tasks.append(handler(client, addr))
async def echo_handler(client, address):
"""
An asynchronous handler for echo clients.
"""
print('Connection from', address)
while True:
data = await client.recv(1000)
if not data:
break
await client.send(b'GOT:' + data)
print('Connection closed')
client.close()
Notice that yield from has been replaced with await, and the functions are now defined with async def instead of def. This change makes the code more intuitive and easier to understand.
Understanding the Transformation
The transition from generators with yield from to the async/await syntax is not just a simple syntactic change. It represents a shift in how we think about asynchronous programming.
Generators with yield from:
- When using generators with
yield from, you explicitly yield control to signal that a task is ready. This means you have to manually manage when a task can continue. - You also need to manually manage the scheduling of tasks. This can be complex, especially in larger programs.
- The focus is on the mechanics of control flow, which can make the code harder to read and maintain.
- When using generators with
Async/await syntax:
- With the
async/awaitsyntax, control is implicitly yielded atawaitpoints. This makes the code more straightforward as you don't have to worry about explicitly yielding control. - The event loop takes care of scheduling tasks, so you don't have to manage it manually.
- The focus is on the logical flow of the program, which makes the code more readable and maintainable.
- With the
This transformation allows for more readable and maintainable asynchronous code, which is especially important for complex applications like network servers.
Modern Asynchronous Programming
In modern Python, we usually use the asyncio module for asynchronous programming instead of a custom event loop. The asyncio module provides built - in support for many useful features:
- Running multiple coroutines concurrently. This allows your program to handle multiple tasks at the same time.
- Managing network I/O. It simplifies the process of sending and receiving data over the network.
- Synchronization primitives. These help you manage access to shared resources in a concurrent environment.
- Task scheduling and cancellation. You can easily schedule tasks to run at specific times and cancel them if needed.
Here's how our server might look using asyncio:
import asyncio
async def handle_client(reader, writer):
addr = writer.get_extra_info('peername')
print(f'Connection from {addr}')
while True:
data = await reader.read(1000)
if not data:
break
writer.write(b'GOT:' + data)
await writer.drain()
print('Connection closed')
writer.close()
await writer.wait_closed()
async def main():
server = await asyncio.start_server(
handle_client, 'localhost', 25000
)
addr = server.sockets[0].getsockname()
print(f'Serving on {addr}')
async with server:
await server.serve_forever()
if __name__ == '__main__':
asyncio.run(main())
This code achieves the same functionality as our generator - based server but uses the standard asyncio library, which is more robust and feature - rich.
Conclusion
In this lab, you've learned about several important concepts:
- The
yield fromstatement and how it delegates to another generator. This is a fundamental concept in understanding how generators work. - How to use
yield fromwith coroutines for message passing. This allows you to communicate between different parts of your asynchronous program. - Wrapping socket operations with generators for cleaner code. This makes your network - related code more organized and easier to understand.
- The transition from generators to the modern
async/awaitsyntax. Understanding this transition will help you write more readable and maintainable asynchronous code in Python, whether you're using generators directly or the modernasync/awaitsyntax.
Summary
In this lab, you have learned about the concept of delegating generators in Python, with a focus on the yield from statement and its various applications. You explored how to use yield from to delegate to another generator, which simplifies code and enhances readability. You also learned about creating coroutines with yield from to receive and validate messages, and using generators to wrap socket operations for cleaner network code.
These concepts are essential for understanding asynchronous programming in Python. The transition from generators to the modern async/await syntax represents a significant advancement in handling asynchronous operations. To further explore these concepts, you can study the asyncio module, examine how popular frameworks use async/await, and develop your own asynchronous libraries. Understanding delegating generators and yield from provides a deeper insight into Python's approach to asynchronous programming.