Based on the course syllabus for CSD 4523 Python II, here's an outline for study review questions for the Midterm Test, which covers Weeks 1-7:

### Week 1: Python Program Architecture and Module Usage 1. Explain the architecture of a Python program. 2. How do imports work in Python? Describe the module search path. 3. Discuss the process of creating and using modules and packages in Python.

Here are some Python code examples and lecture notes on Week 1 topics:

Python Program Architecture, Module Usage, and Creation of Modules and Packages.

Week 1: Python Program Architecture and Module Usage

1. Explain the Architecture of a Python Program

Lecture Notes:
Python programs are composed of modules.
Each file with .py extension is considered a module.
Python scripts typically start with import statements, followed by function and class definitions, and finally, a main section to execute code.
# def greet(name): print(f"Hello, {name}!")
if __name__ == "__main__": greet("Alice")
The `if __name__ == "__main__":` block in a Python script serves an essential purpose in distinguishing whether the script is being run directly by the Python interpreter or if it is being imported as a module by another script.
When a Python script is run directly, the special variable `__name__` is set to `"__main__"`.
This specific block of code under `if __name__ == "__main__":` will only be executed when the script is run directly. This allows you to include code that should only run when the script is executed standalone, such as testing code or script execution logic.

When a script is imported as a module by another script, the `__name__` variable is set to the name of the module instead of `"__main__"`.

By using `if __name__ == "__main__":`, you can prevent certain pieces of code (like test code or execution logic) from running unintentionally when the script is being imported as a module by another script.

This helps keep your code organized and allows you to reuse functions and classes from your script without executing the test or execution code every time the module is imported.

In your specific example, the `if __name__ == "__main__":` block ensures that the `greet("Alice")` function call is only executed when the script `` is run directly, not when it is imported as a module.

2. How do Imports Work in Python? Describe the Module Search Path

Lecture Notes:
Imports in Python are used to access code from other modules (which is just a Python file).
Python looks for the module in the following order:
Built-in modules.
Modules in the directory of the input script (or current directory).
Modules listed in PYTHONPATH environment variable.
Installation-dependent default directories.
# Importing a standard library module import math
# Using a function from the math module print(math.sqrt(16))

3. Discuss the Process of Creating and Using Modules and Packages in Python

Lecture Notes:
A module is a file containing Python definitions and statements.
Packages are a way of structuring Python’s module namespace by using “dotted module names”.
A package is a collection of modules in a directory.
Creating a module
def say_hello(name):
return f"Hello, {name}!"

Using the module:
import greetings

Creating a package mypackage with and inside.
The file can be empty or can contain package initialization code.

- the architecture of a Python program
- the process of importing modules
- the creation and usage of modules and packages in Python.

The explanation touches on several fundamental concepts in Python related to the architecture of programs, and the creation and usage of modules and packages.
Let's break down these concepts for a better understanding:

1. Python Program Architecture

Python programs are typically organized into modules and packages, which helps in managing and structuring code, especially as the size of the codebase grows.
Module: A module in Python is simply a file containing Python code. It may define functions, classes, and variables, and can also include runnable code. For example, is a module.
Package: A package is a way of organizing related modules into a directory hierarchy. This organization is beneficial for structuring projects and reusing code. A package is essentially a directory that contains a special file named (which can be empty or contain initialization code for the package) along with one or more modules.

2. Creating Modules

Creating a module is as straightforward as writing Python code in a file.
For example, with a function say_hello is a module. This module can define functions, classes, and variables.

3. Using Modules

To use a module, you import it into your Python script.
This is done using the import statement. Once a module is imported, you can use its functions, classes, and variables.
In our example, is imported in, and its function say_hello is called.

4. Creating a Package

A package is created by making a directory and placing an file inside it, along with one or more modules.
** The presence of the file in a directory signals to Python that this directory should be treated as a package.
This file can be empty or contain initialization code for the package.
In your example, mypackage is a package that contains the file and the module.

5. Using a Package

Once a package is created, you can import modules from it in a similar way to how you import standard modules. You use a dotted path to specify the location of the module inside the package. For example, if mypackage contains, you can import it using:
pythonCopy code
from mypackage import greetings

And then use its functions:
pythonCopy code


In summary, the architecture of Python programs often involves the use of modules and packages to organize code.
Modules are individual files with Python code, and packages are collections of modules organized into directories.
This structure helps in maintaining large codebases, promotes code reuse, and enhances readability and maintainability.

### Week 2: Module Namespaces and Reloading 1. What are module namespaces? How are they useful? 2. Describe the process and implications of reloading modules in Python.

Week 2: Module Namespaces and Reloading

1. What are Module Namespaces? How are They Useful?

Lecture Notes:
A namespace in Python is a mapping from names to objects.
Module namespaces help in avoiding naming conflicts in large programs by isolating variables, functions, classes, etc., in a separate named scope.
Each module has its own private namespace, which is used as the global namespace by all functions defined in the module (module = a file of code ending in .py)
# File: def add(a, b): return a + b
def multiply(a, b): return a * b
# File: import math_operations as math_ops
print(math_ops.add(5, 3)) print(math_ops.multiply(4, 2))
Here, math_operations is a module with its own namespace containing add and multiply functions.

To print out the namespaces (i.e., the names of all the attributes, functions, classes, etc.) available in a module, you can use the dir() function in Python. This function returns a list of valid attributes of the object it is called with. If you want to see the namespaces used in a specific module, you should first import that module and then call dir() with that module as an argument.
Here's a Python script that demonstrates how to do this. Suppose you have a module named modulea and you want to list its namespaces:
pythonCopy code
import modulea

# Print all names defined in modulea
This script will print out all the attributes and methods defined in modulea. Keep in mind that this will include not only the items you've defined in modulea but also those inherited from Python's base object class and any imported modules or variables within modulea.

2. Describe the Process and Implications of Reloading Modules in Python

Lecture Notes:
Reloading a module in Python means re-executing the module's code to update the module’s contents.
This is useful during development for testing changes without restarting the interpreter.
However, reloading can have unexpected consequences, particularly if objects from the module have already been imported elsewhere.
# Initial version of the module # File: def say_hello(): print("Hello!")
# After modifying def say_hello(): print("Hello, world!")
# Using reload in the interpreter import greetings from importlib import reload reload(greetings) greetings.say_hello() # Output: Hello, world!
These examples and notes will help students understand the concept of namespaces in Python modules and the process and implications of reloading modules (if you change the code and want to get a continously running program to swap in the updated code).
This knowledge is crucial for effective Python programming, especially in larger projects.

Classes and OOP in Python

1. Discuss the concepts of classes and Object-Oriented Programming (OOP) in Python. 2. Explain method calls, class statements, inheritance, and operator overloading in the context of Python classes.

Example of an Object-Oriented Data Structure: The LINKED LIST in PYTHON

Data structures in PYTHON (dictionary, set, array) are coded using Linked Lists:

The complete Linked List Example:

class Node: def __init__(self, data): = data = None
class CircularQueue: def __init__(self): self.head = None self.tail = None self.size = 0
def is_empty(self): return self.head is None
def enqueue(self, data): new_node = Node(data) if self.head is None: self.head = new_node self.tail = new_node = self.head else: = self.head = new_node self.tail = new_node self.size += 1
def dequeue(self): if self.head is None: return "Queue is empty" elif self.head == self.tail: removed_data = self.head = None self.tail = None else: removed_data = self.head = = self.head self.size -= 1 return removed_data
def peek(self): if self.head is None: return "Queue is empty" else: return
def display(self): if self.head is None: print("Queue is empty") else: current = self.head while True: print(, end=' ') current = if current == self.head: break print()
def queue_size(self): return self.size
def main(): # Create a new circular queue queue = CircularQueue()
# Enqueue some elements queue.enqueue(5) queue.enqueue(10) queue.enqueue(15)
# Display the queue print("Queue elements:") queue.display()
# Peek at the first element print("Peek:", queue.peek())
# Dequeue some elements print("Dequeue:", queue.dequeue()) print("Dequeue:", queue.dequeue())
# Display the updated queue print("Queue elements after dequeue:") queue.display()
# Check the queue size print("Queue size:", queue.queue_size())
# Dequeue remaining elements and display the queue print("Dequeue:", queue.dequeue()) print("Queue elements after final dequeue:") queue.display()
# Check if the queue is empty print("Is the queue empty?", queue.is_empty())
if __name__ == "__main__": main()

Classes and OOP in Python

1. Discuss the Concepts of Classes and Object-Oriented Programming (OOP) in Python

Lecture Notes:
Classes provide a means of bundling data and functionality together.
A class is a data structure created by the Python Runtime. in-memory data structure
“Walled Garden” : data in the OBJECT can only accessed by methods in the class.
Objects give us simplified SURFACES to access complex functionality via METHOD CALLS
I get all the services build into your CLASS by calling the methods with the right parametric method signatures and then processing the returned value.
The reason that OOP and Classes were introduced into programming: Complexity management.
Creating a new class creates a new type of object, allowing new instances of that type to be made.
Programming language have 2 constituent elements:
1. CODE (syntax, for loops, while loops, if then, def)
2. Data Variables:
Primitive (scalar) data : int
Complex Data Types : OBJECTS (bind in the walled garden): Data + METHODS
OOP provides a way to create reusable code, known as encapsulation. Encapsulation means: All the data is hidden behind the walled garden, accessible only via METHOD CALLS.
class Car: def __init__(self, make, model, year): self.make = make self.model = model self.year = year self.odometer_reading = 0
def get_descriptive_name(self): long_name = f"{self.year} {self.make} {self.model}" return long_name.title()

2. Explain Method Calls, Class Statements, Inheritance, and Operator Overloading

Method Calls and Class Statements:
Methods are functions defined within a class.
Class statement creates a new class instance.
Inheritance allows a new class to extend an existing class.
Operator Overloading:
Operator overloading lets objects of classes use Python's built-in operations.
Detailed Example:
class ElectricCar(Car): def __init__(self, make, model, year): super().__init__(make, model, year) self.battery_size = 75
def describe_battery(self): print(f"This car has a {self.battery_size}-kWh battery.")
def __str__(self): return f"{self.get_descriptive_name()} - {self.battery_size} kWh"
# Using the classes my_tesla = ElectricCar('tesla', 'model s', 2019) print(my_tesla) my_tesla.describe_battery()
In this example, ElectricCar is a subclass of Car and inherits its attributes and methods. It also defines its own method describe_battery. The __str__ method is overloaded to provide a meaningful string representation of the object.
These examples and notes should provide a comprehensive understanding of classes and OOP in Python, including method calls, class statements, inheritance, and operator overloading, especially in the context of a real-world application like modeling cars.
In Python, the self parameter refers to the instance of the class itself. It is used to access variables or methods that belong to the class.
When an instance of a class calls a method, Python automatically passes self, which refers to the instance itself.
Here's a lecture and illustrative lab code to help students understand the concept of self in Python object construction:
Lecture: Understanding 'self' in Python Object Construction
In Python, when we create a class, we define methods to operate on the class instances. The first parameter in the definition of a class method is always self, which refers to the instance of the class. When calling a method on an instance of a class, self is passed automatically and allows the method to access and modify the instance's attributes.
Let's illustrate the usage of self in the context of a simple Car class:
Class Exercise: Move the class definition to its own module, and get the program to run:
class Car:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
self.odometer_reading = 0

def get_descriptive_name(self):
long_name = f"{self.year} {self.make} {self.model}"
return long_name.title()

# Create an instance of the Car class
my_car = Car("Audi", "A4", 2022)

# Accessing attributes using the instance and 'self'
print(my_car.get_descriptive_name()) # This is equivalent to Car.get_descriptive_name(my_car)

In this example, self is used to refer to the specific instance of the car (my_car) that is calling the get_descriptive_name method. This allows the method to access the instance's attributes (make, model, and year) using self.make, self.model, and self.year respectively.

In Python, the `self` parameter plays a crucial role in creating and manipulating objects.

Here are some key reasons why `self` is necessary to make objects work in an interpreted language like Python:

1. **Instance Specific Behavior**:
In object-oriented programming, objects are instances of classes. When methods are defined within a class, they operate on individual instances of the class. `self` parameter helps identify the instance on which a method is called, allowing methods to work with the specific instance's data.
2. **Instance Attribute Access**:
The `self` parameter enables access to instance-specific attributes within methods. By using `self`, you can store and retrieve data that is unique to each instance of a class. This allows objects to have their own states and behaviors.
3. **Method Invocation**:
When a method is called on an object in Python, the instance is implicitly passed as the first argument to the method. This instance is referenced by the `self` parameter. Without `self`, methods would not know on which instance to operate.
4. **Encapsulation**: Using `self` helps in achieving encapsulation in Python classes. It allows instance methods to interact with other instance attributes and methods without affecting the attributes of other instances or global variables.
5. **Object Identity**:
By using `self`, Python distinguishes between different instances of the same class. It helps maintain the identity and uniqueness of each object.

6. **Inheritance and Polymorphism**:

In more complex scenarios involving inheritance and polymorphism, `self` enables classes and subclasses to work correctly with their own data, ensuring that methods are dispatched to the right instance.
7. **Implicit Binding**: By using `self`, Python achieves a form of implicit binding where the instance is associated with the method call, enabling the method to operate effectively on that specific instance.
Given these reasons, `self` is a fundamental concept in Python classes that enables the creation, manipulation, and interaction of objects within the object-oriented paradigm.
Without `self’ : We would not have a mechanism to deliver encapsulation → Encapsulation being the “walled garden” that lets only methods of an object mutate the values of variables of that OBJECT. it would not be possible to maintain the integrity and uniqueness of objects in a dynamic and interpreted language like Python.

The Von Neumann architecture, heap and stack memory, and how they relate to the concept of "self" in Python, specifically in the context of types and classes compared to a compiled language like Java.

Lecture: Von Neumann Architecture, Memory Management, and "Self" in Interpreted Languages**

**Von Neumann Architecture**

The Von Neumann architecture, named after physicist and mathematician John von Neumann, describes the design of the computer architecture that forms the basis of most modern computing systems. In this architecture, both data and program instructions are stored in the computer's memory, and the CPU fetches and executes these instructions sequentially. The key components of Von Neumann architecture are the Central Processing Unit (CPU), memory, input/output devices, and the system bus that connects them.

Heap and Stack Memory

In computing, memory management is the process of controlling and coordinating computer memory, assigning portions called blocks to various running programs to optimize overall system performance. Two critical areas of memory management are the heap and stack memory:
- Heap Memory: Heap memory is a segment of the computer's memory that is used for dynamic memory allocation.
This is the memory where objects, instances, or instances of classes are typically allocated during program execution.
The memory allocated on the heap needs to be managed manually in lower level programming languages like C/C++, while in higher level languages like Python, memory management is handled by the interpreter or language runtime.
Stack Memory:
Stack memory is a specialized region of memory where local variables and function call information are stored. Each function call in a program creates a new stack frame, and when the function terminates, its stack frame is deallocated. Stack memory management is handled automatically by the system, primarily through a mechanism known as the call stack.
Interpreted Languages and "Self"
In interpreted languages like Python, the concept of "self" is directly related to the management of heap and stack memory. The "self" parameter refers to the instance of the class itself and is implicitly passed as the first argument to instance methods. In Python, when an instance method is called, the method operates on the specific instance passed as "self", allowing access to that instance's attributes and methods.
**Challenges with Types and Classes in Interpreted Languages**
One challenge in interpreted languages like Python, related to managing types and classes, stems from the dynamic nature of memory management. Since heap memory is used for dynamic memory allocation of objects and instances, the type information associated with objects is managed at runtime. This dynamic nature can lead to challenges with type safety and strict class adherence.
In contrast, in compiled languages like Java, type information and class structures are more rigidly defined and managed, as the code is compiled into machine code before execution. This allows for stricter type checking and enforcement of class structures, reducing certain types of runtime errors related to type mismatches and class inconsistencies.


The Von Neumann architecture provides the foundational structure for memory management, including heap and stack memory, in computing systems. The "self" parameter in interpreted languages like Python is closely tied to this memory management model, allowing for instance-specific behavior and manipulation of heap-allocated objects. However, the dynamic nature of heap memory management in interpreted languages can present challenges related to types and classes that differ from the more rigidly defined structures of compiled languages like Java.
This understanding of memory management, "self," and the challenges related to type and class management provides insight into the differences between interpreted and compiled languages and their implications for software development.

Lab Code: Visualizing 'self' in Python Object Construction
Here's a lab code to visualize and demonstrate the usage of self:
class Car: def __init__(self, make, model, year): self.make = make self.model = model self.year = year self.odometer_reading = 0
def get_descriptive_name(self): long_name = f"{self.year} {self.make} {self.model}" return long_name.title()
# Create instances of the Car class my_car = Car("Audi", "A4", 2022) your_car = Car("BMW", "X5", 2023)
# Access and update attributes using 'self' print(my_car.get_descriptive_name()) # Output: 2022 Audi A4 print(your_car.get_descriptive_name()) # Output: 2023 BMW X5
# Demonstrate updating an attribute using 'self' my_car.odometer_reading = 100 print(my_car.odometer_reading) # Output: 100
In this lab code, we create instances of the Car class and demonstrate how self is used to access and update the attributes of the instances.
This lab code helps students visualize and understand the role of self in Python object construction.

Let's create an Object-Oriented Python program that demonstrates classes, objects, and passing data between objects using method calls.

In this example, we'll model a simple bookstore system with two classes:

Book in one module

Bookstore in another module

Python Program: Bookstore System

Class Definitions:

Book Class - Represents a book with attributes like title, author, and price. It will have a method to display book details.
class Book:
def __init__(self, title, author, price):
self.title = title = author
self.price = price

def display(self):
print(f"Book: {self.title}\nAuthor: {}\nPrice: ${self.price}")

Bookstore Class - Represents a bookstore that can store multiple books. It will have methods to add books and show all books in the store.

class Bookstore:
def __init__(self, name): = name
self.books = []

def add_book(self, book):

def show_books(self):
print(f"Books available in {}:")
for book in self.books:

Using the Classes:

# import class_definitions
# Assuming you have a Book class defined somewhere,
# either in this file or imported from another # You need to ensure that the Book class is also accessible here
# Creating instances of Book book1 = class_definitions.Book("The Alchemist", "Paulo Coelho", 15.99) book2 = class_definitions.Book("To Kill a Mockingbird", "Harper Lee", 12.99)
# Creating an instance of Bookstore my_bookstore = class_definitions.Bookstore("Book Haven")
# Adding books to the bookstore my_bookstore.add_book(book1) my_bookstore.add_book(book2)
# Displaying all books in the bookstore my_bookstore.show_books()

Output of the Program:

vbnetCopy code
Books available in Book Haven:
Book: The Alchemist
Author: Paulo Coelho
Price: $15.99
Book: To Kill a Mockingbird
Author: Harper Lee
Price: $12.99

In this program, we have two classes Book and Bookstore. Each Book object holds data about a book, and the Bookstore object manages a collection of books.
Books are added to the bookstore with the add_book method, and all books in the bookstore are displayed using the show_books method, which internally calls the display method of each Book object to show its details.
This demonstrates the creation of classes and objects and how data is passed between objects using method calls.

The Factory Method: Automating the generation of objects under program control:

To use a factory method to generate 1000 books, you can modify the code as follows:
import classdefs
def create_books(): books = [] for i in range(1, 1001): new_book = classdefs.Book(f"Book {i}", "Author", 10.00) books.append(new_book) return books
my_bookstore = classdefs.Bookstore("Book Haven")
for book in create_books(): my_bookstore.add_book(book)
In this code, a factory method `create_books()` is created to generate 1000 books using a loop.
It generates books with generic names ("Book 1", "Book 2", etc.) and a standard price of $10.00.
The `add_books()` method in the `Bookstore` class is used to add the generated books to the bookstore.
Finally, the `show_books()` method is called to display all the books in the bookstore.

Explanation of The Bookstore:

Let's break down this Python program for a bookstore system, explaining each line and section:
### Class Definitions:
#### Book Class
```python class Book: def __init__(self, title, author, price): self.title = title = author self.price = price ```
- `class Book:`: This line defines a new class named `Book`. - `def __init__(self, title, author, price):`: This is the initializer method for the `Book` class. It is automatically called when a new instance of `Book` is created. - `self` refers to the instance of the `Book` class. - `title`, `author`, `price` are parameters passed to the initializer that set the attributes of the class instance. - `self.title = title`, etc.: These lines assign the passed values to the instance attributes.
```python def display(self): print(f"Book: {self.title}\nAuthor: {}\nPrice: ${self.price}") ```
- `def display(self):`: This is a method of the `Book` class that allows an instance to display its details. - `print(...`: This line prints the book's details in a formatted string.
#### Bookstore Class
```python class Bookstore: def __init__(self, name): = name self.books = [] ```
- `class Bookstore:`: This line defines a new class named `Bookstore`. - `def __init__(self, name):`: The initializer method for `Bookstore`. It's called when a `Bookstore` instance is created. - ` = name`: Sets the bookstore's name. - `self.books = []`: Initializes an empty list to store books.
```python def add_book(self, book): self.books.append(book) ```
- `def add_book(self, book):`: A method to add a `Book` instance to the bookstore. - `self.books.append(book)`: Adds the `Book` instance to the `books` list.
```python def show_books(self): print(f"Books available in {}:") for book in self.books: book.display() ```
- `def show_books(self):`: Method to display all books in the bookstore. - `print(f"Books available in {}:")`: Prints the name of the bookstore. - `for book in self.books:`: Loops through each book in the `books` list. - `book.display()`: Calls the `display` method of each `Book` to print its details.

Using the Classes:

#### Importing and Creating Instances
```python import class_definitions ```
- This line imports the `class_definitions` module which contains the `Book` and `Bookstore` class definitions.
```python book1 = class_definitions.Book("The Alchemist", "Paulo Coelho", 15.99) book2 = class_definitions.Book("To Kill a Mockingbird", "Harper Lee", 12.99) ```
- Here, two instances of `Book` are created with specified titles, authors, and prices.
```python my_bookstore = class_definitions.Bookstore("Book Haven") ```
- Creates an instance of `Bookstore` with the name "Book Haven".
Want to print your doc?
This is not the way.
Try clicking the ⋯ next to your doc name or using a keyboard shortcut (
) instead.