Chapter 16: Following Best Practices

In this chapter, we will learn some of the best practices from Python programming that we can follow and apply to metaprogramming too. The practices suggested in Python Enhancement Proposal 8 (PEP 8), the style guide for Python code, also apply to metaprogramming.

The concepts behind PEP 8 originated and are explained in detail in the documentation by Guido van Rossum, Barry Warsaw, and Nick Coghlan at https://peps.python.org/pep-0008/. This chapter will cover some of the important concepts from PEP 8 with examples using ABC Megamart of how they can be implemented in metaprogramming as well as general Python programming.

In this chapter, we will be looking at the following main topics:

  • Following PEP 8 standards
  • Writing clear comments for debugging and reusability
  • Adding documentation strings
  • Naming conventions
  • Avoiding the reuse of names
  • Avoiding metaprogramming where not required

By the end of this chapter, you will know the best practices for performing Python metaprogramming.

Technical requirements

The code examples shared in this chapter are available on GitHub under the code for this chapter at https://github.com/PacktPublishing/Metaprogramming-with-Python/tree/main/Chapter16.

Following PEP 8 standards

In this section, we will be looking at the PEP 8 standards that we should follow while coding applications with Python metaprogramming. We will apply these standards from the PEP 8 documentation using our example of ABC Megamart.

In this section, rather than looking at whether the coding standards we follow are right, we will consider the difference between coding standards that are easy to maintain in comparison to those that are not.

Indentation

Python is a language that is very sensitive to indentation and can throw many errors when this is not done correctly. Having discipline with the overall indentation of your code helps to avoid errors and also makes the code more readable. In this example, let’s look at how we can keep the indentation correct.

To start looking at the indentation, let’s begin with an example of a greater-than-10-items counter. We first define a class named GreaterThan10Counter with a return_cart method to return the cart items:

class GreaterThan10Counter():
    def return_cart(self, *items):
        cart_items = []
        for I in items:
            cart_items.append(i)
        return cart_items

Let’s also create an object instance for the class:

greater = GreaterThan10Counter()

Next, we create a variable named cart, which will store the values returned by the return_cart method. Given that the class is for the greater-than-10-items counter, the number of items returned by the cart will be more than 10, hence the code will not be readable.

The following screenshot shows how the code would look in a code editor:

Figure 16.1 – The cart variable assignment

Figure 16.1 – The cart variable assignment

Hard to maintain

The code of the cart variable in Figure 16.1 will look as follows if we move the invisible part of the code onto the next line:

Figure 16.2 – The cart variable adjusted without alignment

Figure 16.2 – The cart variable adjusted without alignment

The preceding code is not incorrect since it will still execute without errors if we run it. The only problem is that it will be difficult to maintain.

Easy to maintain

Let’s now change the indentation by aligning the code with symbols to make it readable and easily maintained if another developer needs to take it over for editing. The realigned code looks as follows:

Figure 16.3 – The cart variable adjusted with alignment

Figure 16.3 – The cart variable adjusted with alignment

Now that we understand this, let’s look at the next best practice, which is to present code in a neat fashion.

Neat representation

Let’s now look at how and where to add white spaces while writing code.

Hard to maintain

Let’s look at the following example where we will define a decorator function named signature with no white spaces between operators and their corresponding variables:

def signature(branch):
    def footnote(*args):
        LOGO='33[43m'
        print(LOGO+'ABC Mega Mart')
        return branch(*args)
    return footnote

Let’s further call the decorator on another function named manager_manhattan without spaces between operators and variables:

@signature
def manager_manhattan(*args):
    GREEN='33[92m'
    SELECT='33[7m'
    for arg in args:
        print(SELECT+GREEN+str(arg))

Next, let’s call the function as follows:

manager_manhattan('John M','[email protected]','40097 5th Main Street','Manhattan','New York City','New York',11007)

The preceding code will still run without errors but the code is not presented neatly nor is it easy to maintain since it is not easy to differentiate between a variable and its operator:

ABC Mega Mart
John M
40097 5th Main Street
Manhattan
New York City
New York
11007

Let’s add white spaces to this code.

Easy to maintain

Let’s add spaces to the signature function:

def signature(branch):
    def footnote(*args):
        LOGO = '33[43m'
        print(LOGO + 'ABC Mega Mart')
        return branch(*args)
    return footnote

Similarly, let’s also add white spaces in the manager_manhattan function:

@signature
def manager_manhattan(*args):
    GREEN = '33[92m'
    SELECT = '33[7m'
    for arg in args:
        print(SELECT + GREEN + str(arg))

Let’s call the function now:

manager_manhattan('John M', '[email protected]', 
                  '40097 5th Main Street', 'Manhattan', 'New York City', 'New York',11007)

Running the preceding code produces the following output:

ABC Mega Mart
John M
40097 5th Main Street
Manhattan
New York City
New York
11007

The preceding code makes it easier to differentiate between variables and their corresponding operators due to the addition of white space.

With this understanding, let’s look at the next best practice, which is to add comments in the code.

Writing clear comments for debugging and reusability

Writing inline comments helps us understand why a specific code block is written and we can keep the comments updated as the code changes. We recommend writing comments to make the code easy to debug in the future. However, keep the comments relevant to the code. Let’s look at a few examples of inline comments.

Redundant comments

Let’s look at the following example where we are creating a meta class and calling the meta class from another class:

class ExampleMetaClass1(type):
    def __new__(classitself, *args):
        print("class itself: ", classitself)
        print("Others: ", args)
        return type.__new__(classitself, *args)
class ExampleClass1(metaclass = ExampleMetaClass1):    
    int1 = 123             # int1 is assigned a value of 123
    str1 = 'test'
    def test():
        print('test')

In the preceding code, the comment explains exactly the same thing that is done by the code, which can be easily understood simply by looking at the code. This will not be helpful when we want to debug or modify the code in the future.

Relevant comment

Let’s look at the Singleton design pattern and add a relevant comment:

class SingletonBilling:         # This code covers an example of Singleton design pattern
    billing_instance = None
    product_name = 'Dark Chocolate'
    unit_price = 6
    quantity = 4
    tax = 0.054    
    def __init__(self):
        if SingletonBilling.billing_instance == None:
            SingletonBilling.billing_instance = self
        else:
            print("Billing can have only one instance")
    
    def generate_bill(self):
        total = self.unit_price * self.quantity 
        final_total = total + total*self.tax
        print('***********------------------**************')
        print('Product:', self.product_name)
        print('Total:',final_total)
        print('***********------------------**************')

In the preceding code, the comment specifies the purpose of SingletonBilling rather than mentioning the obvious task performed by the code.

With this understanding, let’s look at the next best practice, which is to add documentation strings.

Adding documentation strings

Documentation strings are added to provide more information on code that is intended to be imported and used in some other program or application. Documentation strings will provide the end user with information on the code that they are going to call from their programs. This is especially helpful as the end user of the code is not the developer of the library, but a user. Let’s look at an example of where to use documentation strings.

Let’s start by creating a Python file named vegcounter.py and adding the following code:

def return_cart(*items):
    '''
    This function returns the list of items added to the cart.    
    items: input the cart items. Eg: 'pens', 'pencils'
    '''
    cart_items = []
    for i in items:
        cart_items.append(i)
    return cart_items

In the preceding code, we defined the docstring by providing a description of the function and its arguments.

The Python file looks as follows:

Figure 16.4 – Documentation string added to vegcounter.py

Figure 16.4 – Documentation string added to vegcounter.py

Let’s further import vegcounter.py into another program as follows:

import vegcounter as vc

Note that in this program, the code for the functions inside vegcounter is not accessible to the end user, but the functions in vegcounter can be called by the end user's program.

The following screenshot demonstrates how docstrings provide the information required in this example:

Figure 16.5 – Documentation string example

Figure 16.5 – Documentation string example

In this example, the documentation string we added in the Python file provides the end user with information on the function and its corresponding arguments along with an example.

Documentation string for metaprogramming

In this example, let’s define a metaclass named BranchMetaClass and add a docstring that states that this is a meta class and is not meant to be inherited as a super class or parent class. Save this code into branch.py:

class BranchMetaclass(type):
    '''
    This is a meta class for ABC Megamart branch that adds an additional 
    quality to the attributes of branch classes. 
    Add this as only a meta class.
    There are no methods to inherit this class as a parent class or super class.    
    '''
    def __new__(classitself, classname, baseclasses, attributes):
        import inspect
        newattributes = {}
        for attribute, value in attributes.items():
            if attribute.startswith("__"):
                newattributes[attribute] = value
            elif inspect.isfunction(value):
                newattributes['branch' + attribute.title()] = value
            else:
                newattributes[attribute] = value
        return type.__new__(classitself, classname, baseclasses, newattributes)

Let’s now import the branch and its corresponding meta class as follows:

from branch import BranchMetaclass

Let’s now call BranchMetaclass to check the docstring:

BranchMetaclass

The docstring is displayed in the following screenshot:

Figure 16.6 – Documentation string for BranchMetaclass

Figure 16.6 – Documentation string for BranchMetaclass

This is an example of how documentation strings should be included as a best practice. Adding documentation strings in the class definition provides end users with the information required to correctly apply a method or a class in their application.

With this understanding, let’s further look at the naming conventions to be followed in Python code.

Naming conventions

Naming conventions in Python are recommendations of how various elements in a Python program need to be named to ensure ease of navigation and consistency. Navigating through code, connecting the dots, and understanding the flow are all made easier by following consistent naming conventions throughout the code. This is another important standard that helps in developing maintainable applications.

In this section, we will see how you should ideally name classes, variables, functions, and methods.

Class names

While creating a new class, it is recommended to start the class name with an uppercase letter followed by lowercase letters and capitalize whenever there are words that need differentiation within the class name.

For example, let’s define a class for the billing counter.

The following style is not the preferred naming convention:

class billing_counter:
    def __init__(self, productname, unitprice, quantity, tax):
        self.productname = productname
        self.unitprice = unitprice
        self.quantity = quantity
        self.tax = tax

With the preceding naming convention, we will still be able to execute the code and it will work as expected. But maintaining the class names with one well-defined naming style will make future management of the libraries easier. The preferred class naming style is as follows:

class BillingCounter:
    def __init__(self, productname, unitprice, quantity, tax):
        self.productname = productname
        self.unitprice = unitprice
        self.quantity = quantity
        self.tax = tax

Camel case is used to name classes so that they can be differentiated from variables, methods, and functions. The naming conventions for variables are explained next, followed by methods and functions.

Variables

While creating new variables, it is preferred to use all lowercase letters for variable names followed by numbers, if relevant. When there is more than one word in a variable name, it is a good practice to separate them using an underscore operator. This also helps us to differentiate variables from classes since they follow camel case conventions.

Let’s look at an example of how variables should not be named:

class BillingCounter:
    def __init__(self, PRODUCTNAME, UnitPrice, Quantity, TaX):
        self.PRODUCTNAME = PRODUCTNAME
        self.UnitPrice = UnitPrice
        self.Quantity = Quantity
        self.TaX = TaX

Let’s now look at an example of one preferred method of naming variables:

class BillingCounter:
    def __init__(self, product, price, quantity, tax):
        self.product = product
        self.price = price
        self.quantity = quantity
        self.tax = tax

Let’s further look at another preferred method for naming variables:

class BillingCounter:
    def __init__(self, product_name, unit_price, quantity, tax):
        self.product_name = product_name
        self.unit_price = unit_price
        self.quantity = quantity
        self.tax = tax

Functions and methods

Similar to variables, using lowercase for function and method names is the best-practice preference. When there is more than one word in a variable name, it is a good practice to separate them using an underscore operator.

Let’s look at an example of how a function or method should not be named:

class TypeCheck:
    def Intcheck(self,inputvalue):
        if (type(inputvalue) != int) or (len(str(inputvalue)) > 2):
            return False
        else:
            return True
    
    def STRINGCHECK(self,inputvalue):
        if (type(inputvalue) != str) or (len(str(inputvalue)) > 10):
            return False
        else:
            return True

Let’s now look at an example of the preferred method for naming methods or functions:

class TypeCheck:
    def int_check(self,input_value):
        if (type(input_value) != int) or (len(str(input_value)) > 2):
            return False
        else:
            return True
    
    def string_check(self,input_value):
        if (type(input_value) != str) or (len(str(input_value)) > 10):
            return False
        else:
            return True

These naming conventions are recommendations that can be followed while developing new code or a library from scratch. However, if the code has already been developed and is being actively maintained, it is recommended to follow the naming conventions used throughout the code.

Avoiding the reuse of names

In this example, let’s look at another best practice of how to use variable or class names such that the reusability aspect of your code is preserved. Sometimes it might seem easy to reuse the same class or variable names while writing code in a sequence. Reusing names will make it difficult to reuse the classes, variables, methods, or functions in your code as calling them in multiple scenarios will be impacted since the same names are reused for different elements.

Let’s look at an example to understand the method that is not preferred. Let’s define two classes for Branch with a method named maintenance_cost with different definitions.

The first Branch class is defined as follows:

class Branch:
    def maintenance_cost(self, product_type, quantity):
        self.product_type = product_type
        self.quantity = quantity
        cold_storage_cost = 100
        if (product_type == 'FMCG'):
            maintenance_cost = self.quantity * 0.25 + cold_storage_cost    
            return maintenance_cost
        else:
            return "We don't stock this product"

The second Branch class is defined as follows:

class Branch:
    def maintenance_cost(self, product_type, quantity):
        self.product_type = product_type
        self.quantity = quantity
        if (product_type == 'Electronics'):
            maintenance_cost = self.quantity * 0.05
            return maintenance_cost
        else:
            return "We don't stock this product"

In the preceding code, we have two Branch classes doing different tasks. Let’s now instantiate the Branch class, assuming the first Branch class needs to be executed at a later point in the code:

branch = Branch()
branch.maintenance_cost('FMCG', 1)

The preceding code calls the Branch class defined last, and thus ends up losing the definition of the first Branch class:

"We don't stock this product"

To avoid such confusion, it is always preferred to provide different names for different elements in code.

Let’s look at the preferred method now. We will define a class named Brooklyn where FMCG products are stocked as follows:

class Brooklyn:
    def maintenance_cost(self, product_type, quantity):
        self.product_type = product_type
        self.quantity = quantity
        cold_storage_cost = 100
        if (product_type == 'FMCG'):
            maintenance_cost = self.quantity * 0.25 + cold_storage_cost    
            return maintenance_cost
        else:
            return "We don't stock this product"

We will define another class named Queens where electronic products are stocked as follows:

class Queens:
    def maintenance_cost(self, product_type, quantity):
        self.product_type = product_type
        self.quantity = quantity
        if (product_type == 'Electronics'):
            maintenance_cost = self.quantity * 0.05
            return maintenance_cost
        else:
            return "We don't stock this product"

We can now call both the classes and their methods without any issues:

brooklyn = Brooklyn()
brooklyn.maintenance_cost('FMCG', 1)

The output for Brooklyn is as follows:

100.25

Similarly, we can instantiate the Queens class separately:

queens = Queens()
queens.maintenance_cost('Electronics', 1)

The output for Queens is as follows:

0.05

Having looked at why we should avoid reusing names, we can further look at where to avoid metaprogramming.

Avoiding metaprogramming where not required

Writing too much metaprogramming just because the feature is available in Python also makes the overall code very complex and hard to handle. The following aspects should be kept in mind while choosing to write a metaprogram for your application:

  • Identify your use case and determine the need for metaprogramming based on how frequently you need to modify the code.
  • Understand how frequently you need to manipulate your code outside of its core elements such as classes, methods, and variables.
  • Check whether your solution can be developed with object-oriented programming alone or whether it depends on elements such as metaclasses, decorators, and code generation.
  • Check whether your team has the relevant skills to maintain the metaprogramming features after development.
  • Check that you don’t have a dependency on earlier versions of Python that do not support some of the metaprogramming features.

These are some of the points to consider when planning to apply metaprogramming techniques during the application design phase.

Summary

In this chapter, we covered various examples to understand the best practices recommended in the PEP 8 standards for Python. We looked at the preferred methods for indentation and the correct use of white spaces. We also looked at how to write useful comments and where to include documentation strings.

We learned the recommended naming conventions through some examples. We also looked at why we need to avoid reusing names and where to avoid metaprogramming.

While the concepts of metaprogramming are advanced and complex, we have tried to explain them with simple, straightforward examples throughout this book to keep it interesting and engaging. Learning Python and its features is a continuous journey. Keep following the future versions of Python and explore the new capabilities it provides for metaprogramming.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset