Getting Started with Python and the Spyder 5 IDE (Code Blocks)

Video

perquisites

Make sure you have installed the Anaconda Python distribution with Spyder 5 and are comfortable with Python procedural programming before starting with code blocks. For more details see:

concept of a code block

We have focused primarily on procedural programming so far which executes every line of code in a script in order line by line. Sometimes we will want to control what code within a script is ran by using if, elif and else code blocks or want code to execute n times using a for loop or want to run code while a certain condition is true.

Before beginning with any code block, let's outline generic code block syntax:

The line (usually with the condition) in this case called "codeblock" ends with a colon : which denotes the beginning of a code block. Each line of code belonging to the code block is indented by 4 spaces and lines of code not belonging to the codeblock are not indented. A blank line is usually placed between code blocks and normal code to more clearly distinguish them.

if code block

Let's have a look first at the if code block. The if code block relies on a condition. We can create a variable object and assign it to the Boolean True.

We use the if keyword to begin a code block and then we specify a condition. The code block in the code block will only execute when the condition is True. We can explicitly specify this for clarity.

This line ends in a colon denoting the beginning of a code block. We will include two basic print statements in the code block.

condition = True

if condition == True:
    print("The condition was True.")
    print("hello world")

Because the condition was True, the code within the code block was executed:

Because condition is already a bool, we can simplify the code above to:

condition = True

if condition:
    print("The condition was True.")
    print("hello world")

We can also click to the left hand side of line 1 and line 5 to set breakpoints in the script (clicking again will clear any set breakpoint). This will allow us to use the debug options to examine how the script is ran in more detail:

More details about these commands are available in the Debug menu:

In this example we are only going to use Debug to begin the debugging and then Step to step through each Step.

Selecting Debug shows a blue line at the top breakpoint.

Selecting Step executes this line of code and the variable condition displays on the variable explorer. Now we are the if statement.

Selecting step will check this condition. As this condition is True we will begin to execute the lines of code within the code block.

Selecting step executes the first nested print statement and then moves onto the next line of code in the code block:

Selecting step executes the second nested print statement and then shows return as we reach the end breakpoint:

Selecting Step exits the debugging:

Now let's update the condition to False and repeat the debugging:

condition = False

if condition:
    print("The condition was True.")
    print("hello world")

Once again selecting Debug shows a blue line at the top breakpoint.

Selecting Step executes this line of code and the variable condition displays on the variable explorer. Now we are the if statement.

Selecting step takes us directly to the end breakpoint showing Return i.e. the condition is not True so we ignore all the code in the if code block:

Selecting Step exits the debugging:

Let's now change our code so one of the print statements is outside the code block:

condition = False

if condition:
    print("The condition was True.")

print("hello world")

For convenience I will put the screenshots of the debugger in the the form of a slideshow with a caption. Press the right arrow button to scroll through these:

Let's now change our code so the condition is True again:

condition = True

if condition:
    print("The condition was True.")

print("hello world")

if and else code blocks

Instead of doing nothing when the condition is False, we can set up a complementary else block which will be ran only if the condition in the if block is False. We can think of procedural programming used in earlier guides as a car driving only along a straight road. Setting up an if and else code blocks can be visualized as reaching a junction on a road and either turning left (if) or right (else).

Let's create a script with a switch that is set to a bool value (in this case) False.

switch = False

We will then include an if statement that checks to see if the switch is on and only execute in this scenario. This will end in a colon : which denotes the beginning of a code block. Any code belonging to this code block is indented by 4 spaces.

if switch:
    print("switch is on.")

After the if code block we will include an else statement. We do not include a condition after the keyword else as by definition else will be carried out if, the if condition is not met. We do however end this is a colon : which denotes the beginning of a code block. Any code belonging to this code block is indented by 4 spaces.

else:
    print("switch is off.")

Finally we will include a print statement that informs us that we are at the end of the script and this print statement should execute respective of the if and else code blocks.

Putting this code together we get:

switch = False

if switch:
    print("switch is on.")
else:
    print("switch is off.")

print("end of script")

In Spyder we can highlight the line numbers (in this case 1 and 8) to begin and end debugging:

Now we will select Debug. Note that on the script editor a blue arrow appears to the left of line 1 indicating we are on this line:

Selecting Step will take the blue arrow to line 3.

This condition is not True so selecting Step again will instead take us to the code block of the else statement:

Selecting Step again will execute the print statement within the else code block. We will now be outside the else code block.

Selecting Step again will execute the print statement outside the code blocks and as this is the end of the script –Return– will now display:

Selecting step once again will end the debugging and a new prompt will display in the console:

if elif, elif, … , else code blocks

The bool has only has two discrete values True or False. We can use the following comparison operators with a bool == and !=. An int or a float on the other hand can have numerous discrete values allowing a number of comparisons to be made between both numbers i.e. ==, !=, >, >=, <, <=. This allows us to set up a series of linked code blocks using additional elif statements.

Note only one of the linked code blocks will execute. Continue using the analogy of a car hitting a junction. Once it has driven down one of the paths it can only continue and there are no instructions to turn around.

Let's set up a series of these code blocks.

num = 6
if num > 7:
    print("num is greater than 7")
elif num > 5:
    print("num is greater than 5")
elif num > 3:
    print("num is greater than 3")
else:
    print("num is less than or equal to 3")

print("end of script")

Now we will select Debug. Note that on the script editor a blue arrow appears to the left of line 1 indicating we are on this line:

Select step creates our variable num which displays in the variable explorer and then takes us to the if condition:

This condition is False so step takes us to look at the condition of the next elif code block:

The condition of this elif statement is True so selecting step takes us to the code within this code block:

Selecting step executes the nested print statement. Then because we have already ran one of the associated code blocks, we skip all others (the second elif code block does not get executed although this condition is also technically also True):

We are taken to our code outside of the code blocks. We execute the print statement and then reach the end breakpoint and display return:

Selecting step again exits the debugger:

We can change the value of num to 8 and 2 and rerun our code again to check it works as expected:

num = 8
if num > 7:
    print("num is greater than 7")
elif num > 5:
    print("num is greater than 5")
elif num > 3:
    print("num is greater than 3")
else:
    print("num is less than or equal to 3")

print("end of script")
num = 2
if num > 7:
    print("num is greater than 7")
elif num > 5:
    print("num is greater than 5")
elif num > 3:
    print("num is greater than 3")
else:
    print("num is less than or equal to 3")

print("end of script")

logical code block groups

A logical code block group always begins with an if statement. In the code below we can see that the if, elif, elif and else code blocks are all associated with one another and only one of these will execute depending on the condition. The next if code block looks at an entirely different condition and is not associated to the group of logical code block groups above it.

num = 8
condition = True
if num > 7:
    print("num is greater than 7")
elif num > 5:
    print("num is greater than 5")
elif num > 3:
    print("num is greater than 3")
else:
    print("num is less than or equal to 3")
if condition:
    print("condition is True")

print("end of script")

In general an empty line is used before beginning a logical code block group and at the end of a logical code block group to make it obvious.

num = 2
condition = True

if num > 7:
    print("num is greater than 7")
elif num > 5:
    print("num is greater than 5")
elif num > 3:
    print("num is greater than 3")
else:
    print("num is less than or equal to 3")

if condition:
    print("condition is True")

print("end of script")

We can highlight these logical code block groups below:

When this script is ran, the conditions allow the else condition to be executed in the first logical code blcok group and the if condition to execute in the second logical code block group.

nested if, elif, else, code blocks

It is also possible to nest logical code blocks. Returning to the analogy of a car driving along the road this is like reaching going through a junction, just to reach another junction:

Like the visualization above this is done using indentation.

num = 9

if num > 7:
    print("num is greater than 7")
    if num > 8:
        print("num is greater than 8")
    elif num > 9:
        print("num is greater than 9")
else:
    print("num is less than or equal to 7")

print("end of script")

if (line 3) and else (line 9) are a logical code block group. They have the same indentation guide. Code belonging to the if code block (line 4-8) is indented once (by 4 spaces). A nested if (line 5) and nested elif (line 7) are a nested (nested within the if) logical code block group. The print statement (line 6) is indented twice (by 8 spaces) indicating that it belongs to the nested if code block (line 5) and if code block (line 4). Likewise, the print statement (line 8) is indented twice (by 8 spaces) indicating that it belongs to the nested elif code block (line 7) and if code block (line 4).

When the code is ran, the following pathway is taken:

Going back to the analogy of a junction, a much more complicated series of road network can be created by continuing to add more and more junctions. In the case of Python this is done by adding more and more nested conditions.

for loops

For a for loop, we need to use an iterable. One of the most common iterables used is the range object. Let's have a look at the range function in more detail. The range function can utilize either 1-3 positional input arguments:

range(start, stop, step)

It creates a numeric sequences from the start value (inclusive) to the stop value (exclusive) in the steps specified. If only two positional input arguments are specified the step is assumed to be 1:

range(start, stop)

If only one positional input arguments are specified, the start is assumed to be 0:

range(stop)

Let's create a range object with a single positional input argument 5:

rng = range(5)

Now for the purpose of visualization let's cast it to a collection such as a list:

rng_list = list(rng)

When we view the object within the variable explorer we see that the values are 0, 1, 2, 3 and 4 (i.e. inclusive of 0 and exclusive of 5, in steps of 1 and with a len of 5).

Now that we understand our iterable, we can use it within a for loop to execute a line of code multiple times. The for loop has the following form. The for keyword followed by assignment of a loop variable; the loop variable follows the same rules as object names. We do not explicitly assign the loop variable to a value using an assignment operator and instead use the keyword in followed by an iterable to look in:

iterable = range(5)
for loop_variable in iterable:
    print("hello")

We can debug this code and step through. We see we are on the top line.

Selecting step assigns the range object to the object name iterable:

Selecting step assigns the loop_variable to the 0th index of the range object which in this case has a value 0:

Selecting step executes the print statement in the code block. Once the code block is complete, we are taken back to the for statement:

Selecting step reassigns the loop_variable to the 1st index of the range object which in this case has a value 1:

Selecting step executes the print statement in the code block. Once the code block is complete, we are taken back to the for statement:

Selecting step reassigns the loop_variable to the 2nd index of the range object which in this case has a value 2:

Selecting step executes the print statement in the code block. Once the code block is complete, we are taken back to the for statement:

Selecting step reassigns the loop_variable to the 3rd index of the range object which in this case has a value 3:

Selecting step executes the print statement in the code block. Once the code block is complete, we are taken back to the for statement:

Selecting step reassigns the loop_variable to the 4th index of the range object which in this case has a value 4:

Selecting step executes the print statement in the code block. Once the code block is complete, we exit the for loop as we are at the last value of the iterable and return displays:

Selecting step again exits the debugger:

Let's run this without debugging now, we see "hello" is printed to the console five times as expected:

Since the object name loop_variable is a number it could also be called num for the sake of readability and the range object can be directly specified instead of being assigned to the object name iterable:

for num in range(5):
    print("hello")

With these changes we can see the code works in an identical manner to before:

We can use the numeric loop variable within our code block. Recall that an int multiplied by a str performs str replication. str replication can be used within a print statement, within a for loop to create a variety of patterns.

For example we can create a basic right angle triangle using:

for num in range(5):
    print(num * "*")

We can also create another type of triangle. To do this we can use the numeric loop index to calculate the number of spaces to place before the stars being printed. Then use a formula which involves the numeric loop index to calculate the number of stars to be printed:

for num in range(10):
    print((10 - num - 1) * " " + (2 * num - 1) * "*")

To turn this into an arrow we can use a second for loop to print repeating lines of spaces and stars:

for num1 in range(10):
    print((10 - num1 - 1) * " " + (2 * num1 - 1) * "*" )
    
for num2 in range(3):
    print(7 * " " + 3 * "*")

Take your time to play around with the numbers in the for loops above to make sure you understand how you can manipulate the numeric loop index to print the above shapes to the console. As an exercise try printing an upside down right angle triangle and a double arrow.

So far we have used a range with a start of 0 and a step of 1 and we have been looping over the numeric value. The for loop uses the indexes on the left hand side to iterate and the loop variable takes on each value at the respective index. In this case the index and values match. We can visualize this by casting the range object to a numeric list:

rng = range(5)
rng_list = list(rng)

The start and step can changed, for example to remove the top blank line corresponding to a numeric loop variable of value 0 and to change the gradient of the arrow:

for num1 in range(1,10,2):
    print((10 - num1 - 1) * " " + (2 * num1 - 1) * "*" )
    
for num2 in range(3):
    print(7 * " " + 3 * "*")

You can copy and paste the above code and debug it within Spyder to see in more detail what is going on.

In the above we used the following range object. The for loop uses the indexes on the left hand side to iterate and the loop variable takes on each value at the corresponding index. These no longer match. We can visualize how the numeric value changes by casting the range object to a numeric list:

rng = range(1,10,2)
rng_list = list(rng)

Now let's have a look at a standard list.

greeting = ["hello", "world", "planet", "earth"]

Once again the for loop uses the indexes on the left hand side to iterate and the loop variable takes on each value at the corresponding index. In this case the values are each a str of characters corresponding to a word:

In this case we can use word as the name of the loop variable:

greeting = ["hello", "world", "planet", "earth"]
for word in greeting:
    print(word)

The str itself is a collection and can be indexed in the same manner as a list. The value of each indexed character in a str is an individual letter. For convenience we can call the loop variable letter and construct a similar for loop which prints every letter within the word:

word = "hello"
for letter in word:
    print(letter)

We seen when using the if, elif, else code blocks that we can nest code blocks by use of additional indentation levels. This can also be done with for loops. For example if we wanted to print every letter of every word in the list greeting, we could use:

greeting = ["hello", "world", "planet", "earth"]
for word in greeting:
    for letter in word:
        print(letter)
    print(" ")

Note the print(" ") belongs to the indentation level of the outer for loop and therefore will create a blank space after each individual word has been printed (after the inner code block has been executed). Again if you aren't sure how the below output is generated, take your time to debug the code through the debugger in Spyder:

When we looped over greeting, the loop variable took on the value of each word (string of characters).

Sometimes it is useful to have both the value of each word and the numeric index. To get this we need to create a enumerate object which can be done using the enumerate function. There is limited interaction with the enumerate object within the variable explorer, so we will cast it to a list for the purpose of data visualization:

# %% create a list
greeting = ["hello", "world", "planet", "earth"]
# %% create an enumerate object from the list
greeting_enum = enumerate(greeting)
# %% cast the enumerate object to a list
greeting_enum_list = list(greeting_enum)

We see that each value in the enumerated list is now a tuple. A tuple where the 0th value is numeric and where the 1st value is the word (string of characters). Each tuple corresponds to its associated numeric index:

# %% create a list
greeting = ["hello", "world", "planet", "earth"]
# %% create an enumerate object from the list
greeting_enum = enumerate(greeting)
# %% cast the enumerate object to a list
greeting_enum_list = list(greeting_enum)
# %% create a for loop
for (idx, val) in greeting_enum_list:
    print(idx, val)

The following code above can be simplified by using tuple unpacking. Let's explorer the concept of tuple unpacking in more detail. The variable names num1 and num2 can be assigned to the values 1 and 2 by specifying a new tuple on the right hand side with the values (1, 2) and assigning it to tuple with the object names (num1, num2) on the left hand side. The variables num1 and num2 are created and display on the variable explorer:

# %% variable assignment using a tuple
(num1, num2) = (1, 2)

Tuple unpacking can also be used to swap variables:

# %% create variables
x = "hello"
y = "goodbye"
# %% tuple unpacking (explicit)
(x, y) = (y, x)
# %% tuple unpacking
x, y = y, x

Running the first cell creates the variables x and y:

To the right hand side of the assignment operator a new tuple (x, y) is created from the existing x and y variables and assigned to a new tuple object (y, x). This swaps x and y on the variable explorer:

Tuple unpacking on both sides of the assignment operator can be carried out without the parenthesis and this swaps x and y again back to their original values:

If the parenthesis of the tuple on the left hand side of the assignment operator are not specified, the tuple unpacks in a similar way, this is known as tuple unpacking:

# %% tuple unpacking
num1, num2 = (1, 2)

We can simplify our code using tuple unpacking:

# %% create a list
greeting = ["hello", "world", "planet", "earth"]
# %% create an enumerate object from the list
greeting_enum = enumerate(greeting)
# %% cast the enumerate object to a list
greeting_enum_list = list(greeting_enum)
# %% create a for loop
for idx, val in greeting_enum_list:
    print(idx, val)

Then we can simplify it further by creating the enumerate object in the same line as the for loop:

# %% create a list
greeting = ["hello", "world", "planet", "earth"]
# %% create a for loop
for idx, val in enumerate(greeting):
    print(idx, val)

In the case of a dict, we have the three methods; keys, values and items which create a list of the keys, values and a tuple of (key, values) which can be thought of as the dict analogous enumeration we seen with a list. Let's create a dictionary and demonstrate using each of these within a for loop:

# %% create a dict
colors = {"r": "red",
          "g": "green",
          "b": "blue"}
# %% loop over keys
for key in colors.keys():
    print(key)

# %% loop over values
for value in colors.values():
    print(value)

# %% loop over items
for key, value in colors.items():
    print(key, value)

creating a custom function

Let's now have a look at creating a custom function. We use the keyword def, an abbreviation for define, to begin defining a function. def is followed by the function name, which follows the same naming rules for variable names. i.e. snake_case. After the function name, parenthesis must be used to enclose any input arguments and a colon is used indicating the beginning of a code block.

Notice that when the script is ran that nothing appears to happen. Although the function is defined, unlike variables, functions do not display on the variable explorer.

def my_fun():
    print("Hello World")

We can use the dir function to view the local directory of the console. When we do so we see the function my_fun.

We can reference the function by typing in:

my_fun

Notice that when we reference the function, we are just informed that it is a function and the function is not called. We get a similar behaviour when we attempt to do this with any builtin function:

print

We must call a function using parenthesis. As this function was defined without any input arguments, we don't supply any when calling it:

my_fun()

It is good practice to include a docstring (document string) with each function. We can use triple quotes to begin a multiline document string at the top of the functions code block. Notice a default docstring is generated as you type these in.

In the case of our function, there is no return statement and the function only prints to the console, so Returns None is displayed.

def my_fun():
    """
    

    Returns
    -------
    None.

    """
    print("Hello World")

We can update this:

def my_fun():
    """
    prints "Hello World" to the console.

    Returns
    -------
    None.

    """
    print("Hello World")

Let's now define and call a function in the same script file. The function must of course be defined before it is called. We can set breakpoints within our code and run the debugger.

def my_fun():
    """
    prints "Hello World" to the console.

    Returns
    -------
    None.

    """
    print("Hello World")
    
my_fun()

Select debug file. Selecting step will carry out the first line and define the function (this won't show up on the variable explorer but the function will be in the consoles directory and can be called).

Once this is done now my_fun is highlighted. We can select step into (instead of step) to enter the functions local namespace as the function is called:

We are taken to the top line of the function, the line where we defined the function. This line is used to supply variables from the consoles local name space as input arguments which can be accessed within the functions local name space. In this specific case however there are no input arguments so no variables are imported.

Selecting step will take us to the print statement. print however is also a function. We will select step, to call the function opposed to step into as we are not interested at this particular moment in seeing how the print function works.

We see "Hello World" print on the console as the print function is executed. Selecting step takes us to the end of the functions code block where we see Return. This means we are leaving the functions local namespace and re-entering the consoles namespace.

Selecting step again displays return, which in this case means we are reaching the end of the script.

Selecting step again exits the debugger:

functions can have positional input arguments or keyword input arguments. Both types of input arguments can be thought of as object names and therefore follow object naming convention i.e. snake_case. Positional input arguments must be placed (as the name suggests in positional order) before any keyword input arguments. Keyword input arguments are assigned a default value using the assignment operator.

Let's create a function to convert inches to cm. This function will have no return statement and will print out the value. Let's run the script to define the function:

def inch2cm(input_inches):
    """
    Converts input_inches to output_cm and prints the output_cm.

    Parameters
    ----------
    input_inches : TYPE
        DESCRIPTION.

    Returns
    -------
    None.

    """
    output_cm = input_inches * 2.54
    print(output_cm)

Now let's call it using the console. As this is a positional input argument we can specify the object_name of the input argument and assign it:

inch2cm(input_inches=2)

Or alternatively as it is a positional input argument, we can input it without specifying the object_name:

inch2cm(2)

In either case output_mm prints the value to the console:

This function has no return statement. Therefore if we try and assign the function to an object name when we call the function we get a NoneType object within the variable explorer and the value output_mm still prints.

length_1 = inch2cm(1)

To change this behaviour instead of using a print function we can use a return statement.

def inch2cm(input_inches):
    """
    Converts input_inches to output_cm.

    Parameters
    ----------
    input_inches : TYPE
        DESCRIPTION.

    Returns
    -------
    output_cm

    """
    output_cm = input_inches * 2.54
    return output_cm

Let's restart the Kernel and relaunch the script to define the function.

Now assigning the function to an output in the console works correctly:

length_1 = inch2cm(1)

It should be noted that the return statement of a function is not a function itself and therefore doesn't require parenthesis.

return output_cm

However it will work in the same manner if parenthesis are provided:

return(output_cm)

Now let's update the functions positional input argument to a keyword input argument. In this case the function will convert a default value of 1 inch to cm unless input_inches is reassigned:

def inch2cm(input_inches=1):
    """
    Converts input_inches to output_cm and prints the output_cm.
    The default conversion, returns 1 inch.

    Parameters
    ----------
    input_inches : TYPE
        DESCRIPTION.

    Returns
    -------
    None.

    """
    output_cm = input_inches * 2.54
    return output_cm

Let's now use this function within the console to convert 1 inch and 2 inch to cm:

length_1 = inch2cm()
length_2 = inch2cm(2)
length_2 = inch2cm(input_inches=2)

Note that in this case the keyword argument input_inches could be inferred by using its position as it was the only input argument. When using a function with multiple keyword input arguments however, it is good practice to always explicitly specify the input argument name and assign it to the desired value. This allows greater flexibility allowing the other keyword arguments to be unspecified when calling the function so they can take on their default assigned value.

scope

It is now worth taking some time to understand the concept of scope as each function has its own unique local scope(also known as a function namespace). Let's create some variables x and y and then define a function with positional input arguments x and y. Then call the function by supplying the input arguments x and y.

Note that the x and y in the functions local scope are completely independent of the x and y in the consoles local scope.

# %% Create variables
a = 5
b = 6
x = 1
y = 2

# %% create a function
def add_values(x=0, y=0):
    summed = x + y
    return summed

# %% Call function
my_sum = add_values(x=3, y=4)

Notice that when this code is executed that the x and y variables displayed in the variable explorer do not change from x = 1 and y = 2 to x = 3 and y = 4 when the function is called:

Let's run the debugger to explore this concept in more detail:

Selecting step 4 times will assign the variables a, b, x and y in the consoles directory (consoles namespace) and these will display on the variable explorer:

Selecting step will take us to the definition of the function, where the function will be defined and be accessible within the consoles directory but not display on the variable explorer i.e. running dir() within the console would show the function add_values:

Selecting step again will take us onto the line of code which calls the function. When this is highlighted, we can select step into to enter the functions local environment. Notice that the x and y that display on the variable explorer are now the x and y provided when the function was called i.e. are the x and y within the functions local environment. These are independent of the x and y in the consoles local environment.

Unfortunately a and b still display on the variable explorer which doesn't makes sense as these cannot be accessed in the functions local environment… I have left Spyder feedback as I don't think this is setup to work correctly. For now however I will manually cross them out in the screenshot.

Alternatively to help with the conceptualization I can modify the screenshot to show both the Console Local Environment and the Function Local Environment.

Selecting step into again takes us into the next line in the functions local environment:

This line creates the variable summed which is currently only accessible in the consoles namespace:

Selecting step will execute the functions return statement which is used to return a value to the consoles local environment:

Selecting step will now exit the functions local environment and all the variables which display on the variable explorer are in the consoles local environment:

As we are on the last line of the script file selecting step again takes us to the next prompt in the console:

functions with nested conditional code blocks

We can begin creating some pretty useful functions by nesting logical code blocks within the function. For example we can create a basic bmi calculator which will calculate a persons bmi from their height in m height_m and weight in kg weight_kg. This will be rounded to 1 decimal place and then conditional code blocks will be used to assign a persons classification. The bmi and classification will be printed to the console using a formatted str and the bmi will be returned, this time as a tuple. Note that the return statement will pack all values provided into a tuple by default.

def bmi_calc(height_m, weight_kg):
    """

    Parameters
    ----------
    height_m : float
        height in m.
    weight_kg : float
        weight in kg.

    Returns
    -------
    bmi : int
        bmi value.
    classification : str
        classification.

    """
    bmi = weight_kg / (height_m ** 2)
    bmi = round(bmi, 1)
    if bmi < 18.5:
        classification = "underweight"
    elif bmi < 25:
        classification = "healthy"
    elif bmi < 30:
        classification = "overweight"
    else:
        classification = "obese"
    print(f"Your body mass index is {bmi} and you are {classification}.")
    return bmi, classification

We can test in the console to see if this works correctly. When I type the function with open parenthesis in the console, the docstring displays as expected so it is obvious I need to supply 2 positional input arguments:

Providing the following test case scenario calculates the bmi and classification correctly:

bmi_calc(1.67, 70)
bmi_calc(1.67, 69)
bmi_calc(1.67, 94)

To make the bmi calculator more useful, you could modify the code above to also calculate the weight required for an "ideal" bmi of 22 which corresponds to the height input and include in the output tuple the appropriate weight loss or weight gain.

*args and **kwargs

If a function only has positional input arguments or keyword arguments they can be supplied by use of a tuple (or list) or dict respectively.

Let's create a basic function that expects 3 positional input arguments a, b and c and returns the sum of these. We can supply a tuple of correct dimensions as an input argument to the function. This must be prepended by *

def sum_my_values(a, b, c):
    return a + b + c

tuple_1 = (1, 2, 3)

summed = sum_my_values(*tuple_1)

Let's now modify this basic function so that it instead expects 3 keyword input arguments a, b and c . We can supply a dict of correct dimensions, with matching key names as the keyword input arguments to the function. This must be prepended by **

def sum_my_values(a=0, b=0, c=0):
    return a + b + c

dict_1 = {"a":1, 
          "b":2, 
          "c":3}

summed = sum_my_values(**dict_1)

function with nested for loop

We can create a function which has a variable number of input arguments by use of *args and **kwargs for positional and keyword input arguments respectively. Let's update the function above to sum any number of numeric input arguments:

def sum_my_values(*args):
    summed = 0
    for num in args:
        summed += num
    return summed

Then test it in the console using:

sum_my_values(1,2,3,4,5)

while loops

We seen earlier the use of for loops to iterate over an iterable object that has a known sequence and we seen earlier the use of an if statement which only executes when a condition is True. A while loop is a loop that only executes while a condition is True. Usually a while loop condition will reference an object that is altered within the code block of a while loop so that eventually the condition of a while loop is broken and the loop no longer executes.

It is however possible to create an infinite while loop i.e. a while loop whose condition never changes and therefore runs forever. For example:

while True:
    print("spam my console!")

To break an infinite loop. Type in the console window and press [Ctrl] + [ c ].

To understand a while loop we can make some while loops which mimic the behaviour of a for loop.

for num in range(4):
    print(num)
    

num = 0
while num < 4:
    print(num)
    num += 1

Recall when we created the range object that we could use a variable number of positional input arguments 1, 2 or 3. Under the hood this would have checked the len of the input arguments and then assigned the start, stop and step values appropriately. A good exercise to check your understanding is to create a custom function which mimics the range objects behaviour outputting to a list.

  • To do this use a nested if, elif, else code block and calculate the start, stop and step values which correspond to the number of inputs selected.
  • Use a nested if statement (1 indent level) with a nested while loop (2 indent levels) to handle step sizes >1 and a nested elif statement (1 indent level) with a nested while loop (2 indent levels) to handle step sizes <1 to return a list num_seq analogous to the output of the range function.
  • Instead of using a while loop, approach the problem with a for loop. You will need to use the start, stop and step to calculate the seq_length of your desired output sequence. Once you have this seq_length use it to create a list num_seq to iterate over. Use a for loop to iterate through this list, at each step index into the list and replace the value to create the final num_seq.

My solutions for the problems are below:

def my_range(*args):
    if len(args) == 3:
        start = args[0]
        stop = args[1]
        step = args[2]
    elif len(args) == 2:
        start = args[0]
        stop = args[1]
        step = 1  
    elif len(args) == 1:
        start = 0
        stop = args[0]
        step = 1      
    
    num_seq = []
    if step >= 1:
        num = start
        while num < stop:
            num_seq.append(num)
            num += step        
    elif step <= 1:
        num = start
        while num > stop:
            num_seq.append(num)
            num += step      
    return num_seq
def my_range(*args):
    if len(args) == 3:
        start = args[0]
        stop = args[1]
        step = args[2]
    elif len(args) == 2:
        start = args[0]
        stop = args[1]
        step = 1  
    elif len(args) == 1:
        start = 0
        stop = args[0]
        step = 1      
    
    if step == 0:
        return []
    seq_length = (stop - start) // step
    
    num_seq = seq_length * ["blank"]
    
    idx = 0
    val = start
    for num in num_seq:
        num_seq[idx] = val
        idx += 1
        val += step
    return num_seq
    return num_seq

asserting function input arguments

Let's create a very basic function num_plus_one which takes an input number and adds 1 to it:

def num_plus_one(number):
    return number + 1

We can use this function as intended with a number:

num_plus_one(2)

However if we input the input argument as a str then we get this TypeError as line 2 cannot be performed:

num_plus_one("a")

In a function it is good practice to use the assert statement so the input arguments supplied are the correct type or within the correct expected parameters. This reduces the likelihood of the user using the function incorrectly.

The form of the assert condition is as follows:

assert condition, "optional message"

Note that 

assert
 is not a function and therefore does not use parenthesis.

def num_plus_one(number):
    assert type(number) == int
    return number + 1

Now when we call our function with a num it works as expected:

num_plus_one(2)

Instead of getting a TypeError we get an AssertionError:

num_plus_one("a")

If the function only expected positive ints we can create 2 assertion statements and include the optional str.

def num_plus_one(number):
    assert type(number) == int, "datatype must be int"
    assert number > 0, "num must be positive"
    return number + 1

Notice that when we call the function with the following input arguments we get the AssertionError followed by the appropriate message:

num_plus_one("a")
num_plus_one(-1)

try, except, else, finally code blocks

Earlier we seen the use of if, elif and else statements to carry out different code blocks in response to a condition or conditions. We have a similar structure setup for handling errors:

  • try: This block will test the code for an expected error.
  • except: Here you can handle the error.
  • else: If there is no error, this additional code block gets carried out.
  • finally: carried out regardless if there is an error or not.

Let's modify the code above to give num_sq_plus_one.

Recall when we tried to add the int to the str we got a TypeError. Let's add a check in the try code block that will raise this TypeError if number is a str.

We can handle this TypeError using an except TypeError code block, by setting number_sq to 0 and informing the user.

In the else statement (the code block carried out if there is no errors) we can carry out code to calculate number_sq from a numeric number.

In the finally statement we can return number_sq + 1 (this will use number_sq = 0 if number was originally a str).

def num_sq_plus_one(number):
    try:
        number + 1
    except TypeError:
        print("invalid number, number_sq set to 0")
        number_sq = 0
    else:
        number_sq = number * number
    finally:
        return number_sq + 1

lambda expressions

Sometimes we require a small anonymous function that is only used a handful of times. This can be achieved by using a lambda expression (sometimes known as a lambda function). Let's take a look at our function inch2cm and convert it into a lambda expression:

def inch2cm(input_inches):
    output_cm = input_inches * 2.54
    return output_cm

Now let's simply this so we return the output directly as input_inches * 2.54 opposed to calculating output_cm seperately:

def inch2cm(input_inches):
    return input_inches * 2.54

We can think of the lambda expression being the reduction of the function above to essentially a single line:

inch2cm = lambda input_inches: input_inches * 2.54

Let's compare these two in the function we use def to define the function name. In the lambda expression we instead use the assignment operator to the function name:

Next we have the input argument(s). In the function, these are enclosed in parenthesis before the colon (which begins a code block). In the lambda expression there are no parenthesis required for the input arguments. In both cases multiple input arguments can be specified using a comma , as a delimiter. Positional input arguments are just stated, keyword input arguments are placed after positional input arguments and are assigned to a default value:

Next is the colon : which is used to indicate the beginning of a code block in a function. All code belonging to the code block is indented. A lambda expression is only designed to have a short return statement and therefore instead of a code block, the return statement is just to the right hand side of the colon:

The function uses a code block which optionally ends in a return statement which is used to return a value. The lambda expression on the other hand has a single line expression which usually returns a value. In either case this value is assigned to the output when the function is called and assigned to an object name.

Let's create three code blocks one to define a function, one to define an equivalent lambda expression and a third cell to call either the function or lambda expression:

# %% function
def inch2cm(input_inches):
    return input_inches * 2.54

# %% lambda expression
inch2cm = lambda input_inches: input_inches * 2.54
# %% call the function
length = inch2cm(2)

We see that use of the function and lambda expression is identical:

We can use keyword input arguments by assigning them to a default value. For example if we wanted to default to a value of 1 inch to return essentially the conversion factor:

inch2cm = lambda input_inches=1: input_inches * 2.54

A lambda function can have multiple arguments but has only has a single expression. We can rewrite the add_values function we defined earlier as a more concise function and lambda expression:

# %% function 
def add_values(x=0, y=0):
    return x + y

# %% lambda expression
add_values = lambda x=0, y=0 : x + y
# %% call function
added1 = add_values()
added2 = add_values(x=2)
added3 = add_values(y=3)
added4 = add_values(x=2, y=3)

Once again we see that these are equivalent:

We can create functions without return statements that for example instead use another function such as the print function which prints text to the console. The return statement of a lambda expression can likewise be another function. Let's create a function and equivalent lambda expression that prints a formatted string which incorporates the input argument:

# %% function
def print_greeting(pn="..."):
    print(f"Hello {pn}")

# %% lambda expression
print_greeting = lambda pn="...": print(f"Hello {pn}")
# %% call function
print_greeting()
print_greeting(pn="Philip")
print_greeting("Lucie")

Once again we can see that these are equivalent:

Note in both cases if the function is called and signed to an output argument a NoneType object will display on the variable explorer.

list comprehension

A list comprehension is essentially a one line expression for loop that is used to create a new list by iterating and operating over an existing list. Let's take for example a numeric range object 0,1,2,3, … and convert it into a list of str values corresponding to row numbers r0, r1, r2, r3, …

Let's do this first with a for loop:

# %% create a numeric range object
numbers = list(range(5))
# %% create an empty list row_numbers
row_numbers = []
# %% populate row_numbers by use of a for loop
for number in numbers:
    row_number = "r" + str(number)
    row_numbers.append(row_number)

Running the first 2 cells gives us the starting list numbers and empty list row_numbers:

Running the third cell uses the for loop to populate the previously empty list row_numbers with the desired row numbers:

This can be simplified by combining lines 1 and 6 together and lines 7 and 8 together. However we still need to define an empty list before the for loop to populate using the for loop:

row_numbers = []
for number in range(5):
    row_numbers.append("r" + str(number))

We can do the above in one line by use of a list comprehension:

row_numbers = ["r" + str(number) for number in range(5)]

Let's compare the code from the for loop with the list comprehension. In both cases we are creating a new object row_numbers:

For the for loop we initially create an empty list and then populate it with the for loop. For the list comprehension we instead enclose our code to generate the list elements within the square brackets:

In both cases we need to create a for loop_variable in iterable expression which essentially states we are iterating within a collection In the case of a for loop a colon follows indicating the beginning of a code block. In the case of a list comprehension this is placed towards the right hand side of the list.

Finally an expression which uses the iteration to create an output for each variable most be created. In the case of a for loop this is done using a code block usually with a list method such as append called from the list object row_numbers (which you can recall was defined in a subsequent line). In the case of a list comprehension the list we are updating is defined on the same line and so we can directly state the expression without the additional need to call up the list methods.

The list comprehension has the following format:

new_list = [expression for loop_variable in iterable]

An optional condition can be added:

new_list = [expression for loop_variable in iterable if condition]

For example to only get even rows we check for all values where integer division by 2 gives no remainder (modulo):

row_numbers = ["r" + str(number) for number in range(5) if number % 2 == 0]

We can use a list comprehension in place of a nested for loop. For example if we wanted to make a list of letter number coordinates for example on a chess board:

coordinates = []
for letter in "abcde":
    for number in range(5):
        coordinates.append(letter+str(number))     
coordinates = [letter+str(number) for letter in "abcde" for number in range(5)]

Again you should be able to see the equivalence between the explicit nested for loop and the list comprehension.

It is also possible to carry out dict comprehension in much the same way. The { } have to be used in place of [ ] and the syntax needs to be updated to form an expression that has a key colon and value. Iteration also uses a key and value opposed to a single index:

new_dict = {expression: expression for key, variable in old_dict.items() if condition]

Take the dictionary of colors. If we want to make a new dictionary with single letter keys. We can use the following, making an exception for the key "black" which does not use its first letter as a 1 letter abbreviation:

colors = {"red" : "#FF0000",
          "green" : "#00FF00",
          "blue" : "#0000FF",
          "cyan" : "#00FFFF",
          "yellow" : "#FFFF00",
          "magenta" : "#FF00FF",
          "black" : "#000000",
          "white" : "#FFFFFF",}

colors2 = {key[0]: value for key, value
           in colors.items() if key != "black"}