Python & CircuitPython Workshops

More Python

Python Datatypes

Python provides the common int integer and float floating point numbers, and str strings. A Python list is similar to an array in other languages. Python also has dict dictionaries which are key-value pairs.

Python can get a bit crazy because of types can be nested together. It's possible to have a list of dictionaries, a dictionary of lists, and even a list of lists of dictionaries of dictionaries!

For ad-hoc learning, I recommend tutorials and examples on W3Schools. They cover the basics of Python syntax, variables, conditionals, loops, functions, and more.

For more structured leaning, you might consider exploring codecademy.

Also look for AI Prompt suggestions!

Built-in Datatypes

Python has the following data types built-in by default, in these categories:

Category Datatypes
Text str
Numbers int, float, complex
Boolean bool
Sequences list, tuple, range
Maps dict
Sets set, frozenset
Binary bytes, bytearray, memoryview
None NoneType

(source)

The the main differences between a list, a dict, and a set are that a list is ordered and can contain duplicate items, a dict hold key-value pairs with unique keys, and a set is an unordered collections of unique items.

The values in a list are editable while the values in a tuple are immutable - aka fixed. To experience the difference, create a simple list and a simple tuple in teh Python REPL. Try to change a value in a list and then try to change a value in a tuple.

>>>
>>> my_list = [1,2,3]
>>> my_tuple = (1,2,3)
>>> print(my_list[0], my_tuple[0])
1 1
>>> my_list[0] = 9
>>> my_tuple[0] = 5
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: 'tuple' object doesn't support item assignment
>>>

The sequences and maps types are very powerful and the more Python you encounter, the stranger the things you will see with these types. Some examples that will be covered in the workshop include:

  • using a predefined list of constant strings in for loops
  • using range() to iterate over data
  • converting JSON data into a dict or event a list of dict

A quick note about None. This can be used to declare a variable when the final data is not one of the simple datat types or you want a known starting point to test. Some libraries use this as a return value when the code was not able to perform the expected task.

When assigning values to variables, there are many ways of accomplishing the same end result. Some are good. Some are bad. Some are just wild.

a1 = 1
a2 = 10
a3 = 100

a1, a2, a3 = 1, 10, 100

a1 = a2 = a3 = 1    # all variables have the same value

a = [1, 10, 100]    # a is a list and accessed using a[0], a[1], and a[2]

a1, a2, a3 = a      # the individual elements of the list are assigned

This example demonstrates several assignments of int integers.

Exercise: Use REPL and create some variables of different types and then use the print() function to see the results.

Globals, Locals, and Parameters

The scope of a variable - i.e. where is is available for use - falls into three classifications:

Scope Availability
Global available everywhere and to anything that wants to use it
Local only available within a function
Constants a variable that does not change; does not really exist in python; are globals or locals formatted (typically all uppercase) to make them stand out

A local variable and a global variable can have the same name. This can be confusing and lead to errors. When both exist with the same name, the local variable takes precedence. Python does not require a variable to be explicitly declared before use. This can further lead to confusion and errors. Here is an example (albeit not a very good one).

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
DAY_COUNT = 7

# global
today = 0   # start on Monday


def start_weekend():
    today = 5 # set to Saturday
    print(f"skip forward to {DAYSOFWEEK[today]}")

def next_day(day):
    return (day + 1) % DAY_COUNT # advanced 'day' and wrap around at the end of the week

def is_weekend(day):
    if day > 4: # our variable is zero-based meaning sunday is 0, monday is 1, etc
        return True
    return False


start_weekend()
for day in range(0, DAY_COUNT):
    if is_weekend(today):
        print("It's the weekend! ", end="")
    print(f"today is {DAYSOFWEEK[today]}")
    today = next_day(today)

Here is the result:

Hello World!
skip forward to Saturday
today is Monday
today is Tuesday
today is Wednesday
today is Thursday
today is Friday
It's the weekend! today is Saturday
It's the weekend! today is Sunday
Good Bye

This code demonstrates a problem with local and global variables. It is possible to have a local variable and a global variable of the same name.

In start_weekend() the use of today is a local variable and changing it does not change the global value.

Using the global keyword indicates a variable has a scope outside of the function.

To correct the code, we add global today at the start of both start_weekend() and next_day().

Here is the corrected result:

Hello World!
skip forward to Saturday
It's the weekend! today is Saturday
It's the weekend! today is Sunday
today is Monday
today is Tuesday
today is Wednesday
today is Thursday
today is Friday
Good Bye

Using global variables is quick and easy but error prone. We can avoid global variables by passing parameters to the functions and returning data from the functions - as in the function next_day(day).

Larger Python programs become more and more complex and there is risks of managing global and local variables. We can combine code and data into reusable object by creating Python Class objects and avoid this problem.

But first, let's look at the functions in this code.

Functions

The first workshop touched on functions. Here is some more details to explore.

  • Functions can take parameters of any data type.
  • Parameters can be made optional.
  • Parameters can have default values.
  • Functions can return any data type.
  • Functions can return more than one value.

Parameters and Returns

Parameters are data given to a function for use within the function. Parameters avoid the errors which can arise from using global data.

Parameters can be:

  • required - the code will generate an error if the parameter is missing
  • optional - the parameter may be omitted and the function uses a default value or the code recognizes missing parameters with conditional code.

When a function has many parameters, the optional parameters must be at the end. The user of a function then specifies which optional parameters they wish to use by including the parameter name and a default value.

Returns are data generated by a function and provided back when the function completes. Returns represent new data. A function may return more than one value.

Here is an example:

27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def circle_point(angle, radius=100):
    '''
        Finding a point on the circle given an angle and radius
        the angle is given in degrees

        Returns a tuple of the x and y coordinates
    '''
    n = math.radians(angle)
    return (radius * math.cos(n)), (radius * math.sin(n))

for a in range(180):
    x, y = circle_point(a)  # we let the function parameter 'radius' take its default
    print(f"circle angle {a:3d} results in ({x: >+9.4f}, {y: >+9.4f})")

print("Good Bye")

Here is the result:

Hello World!
circle angle 0.00 results in (+100.00,   +0.00)
circle angle 0.10 results in ( +99.50,   +9.98)
circle angle 0.20 results in ( +98.01,  +19.87)
circle angle 0.30 results in ( +95.53,  +29.55)
circle angle 0.40 results in ( +92.11,  +38.94)
circle angle 0.50 results in ( +87.76,  +47.94)
circle angle 0.60 results in ( +82.53,  +56.46)
circle angle 0.70 results in ( +76.48,  +64.42)
circle angle 0.80 results in ( +69.67,  +71.74)
circle angle 0.90 results in ( +62.16,  +78.33)
...

The function circle_point() has one required parameter, the angle. The radius is optinal and has a default value of 100. The radians flag is optional and defaults to False.

Fun Tip: The examples have been using the Thonny Editor Serial Terminal. It also has a Serial Plotter. The plotter expects data in a specific format - (value1, value2, ...). Let's change the print function to output if a format compatible with the plotter: print(f"({x:f}, {y:f})") and change the loop to be forever.

27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def circle_point(angle :int, radius :int=100) -> tuple[float, float]:
    '''
        Finding a point on the circle given an angle and radius
        the angle is given in degrees

        Returns a tuple of the x and y coordinates
    '''
    r :float
    r = math.radians(angle)
    return (radius * math.cos(r)), (radius * math.sin(r))


counter :int = 0

while True:
    x: float; y: float

    for a in range(0, 360, 4):
        x, y = circle_point(a)  # we let the function parameter 'radius' take its default
        print(f"({x:+8.4f}, {y:+8.4f})")
        time.sleep(0.025)

    counter += 1
    # loop 40 times
    if counter > 40:
        break

Here is the result:

Thonny Editor Plotting XY points on a circle

Plotting can be a useful way of visualizing data changing over time.

The example introduces a new concept - Python typing. Typing code simply means we give hints as to what data type is expected for variable, a function parameter, or a function return.

From the first workshop, we noted:

  • Python is dynamically typed. You don't need to declare the type of a variable - e.g. a text string, integer, float, etc. - before using it.
  • Python will automatically determine the type of a variable based on the value assigned to it. This makes Python code more flexible and easier to write, but it can also make it harder to debug.

Python typing helps make the code easier to debug. Many editors will use the typing information to validate the code.

(Use it. Don't use it. It's completely up to you.) 😊

Classes

A Python class introduces object-oriented programming.

A class combines data and code into a single entity. Classes are hugely convenient for making code easier to understand and reuse.

A class is the definition of the data and the code. To use a class, we create an instance of the class. The instance is a copy of the class with all of the data and code. Each instance is called an object.

One big difference, when moving from variables and functions to classes is the introduction of self. This provides a means of referencing the object's data and code.

Previously, we created functions to work with data. In a class, the same functions are called methods. The first argument of a method is always self. This is a reference to the object itself.

There is way too much to cover about classes in a short Workshop. This is another opportunity to check out online resources such as W3Schools.

There is some vocabulary which is helpful for discussing object-oriented programming:

Vocabulary

Vocabulary Meaning
class defines a combination of data and related code
constant a name given a fixed value; referenced using the class name as in ClassName.CONSTANT_NAME
property a variable within a class; not accessible outside of the class
private refers to a property or method used exclusively within the class
public refers to a property or method which is available to the user
method a method is like a function and has access to public and private properties of the class
constructor a special named method, required by a class; sets up the internal data before the object is used
instance an executable copy of a class

Example

Let's look at the circle code and convert the function and global variables to a class.

There is a lot to unpack in this example. Let's break it down using the vocabulary.

Constructor

The constructor is a method on the class which is called when an instance of the class is created. It has a specific name, __init__(self, ...). All methods of a class, including the constructor, have self as the first parameter. The constructor may have additional parameters - required or optional.

26
27
28
29
30
31
32
33
34
class Circle:
    def __init__(self, radius=100, inc=None, radians=False):
        ''' create a circle object with optional configurations '''

        ''' class properties with '__' indicate they should be considered private and should not be used outside of the class '''
        self.__radius = radius
        self.__use_radians = radians
        self.__increment = inc
        self.__angle = 0.0

In our example, the constructor method has several optional parameters. The constructor stores data used by other methods.

Private Data

Python does not differentiate between private properties and public properties.

In object oriented programming:

  • A private property is data which is only available internally to methods of the class.
  • A public property is data available to both the class methods and to the user.

A common practice in Python is to indicate a private property by preceding the property name with a single or double underscore.

A good practice is to make all class properties private. (It's not a requirement but it is good practice.) Python provides a method for exposing class data as properties in a safe manner - both for read-only data and for read-write data.

Creating Properties

The @property instruction is used to create a class method which acts like a read-only property. The @<name>.setter instruction is used to create a class method which act like a writeable property.

The difference between a method and a property is in the way they are used:

  • class method: my_val = the_class.the_read_method()
  • class method: the_class.the_write_method(my_val)
  • class property: my_val = the_class.the_property
  • class property: the_class.the_property = my_val
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
    ''' the radius property is read and write '''
    @property       # read a property
    def radius(self):
        return self.__radius
    @radius.setter  # write a property
    def radius(self, val):
        self.__radius = val

    '''
        the radians property is read and write
        when it is changed via the `setter` we convert the internal angle value
        to the new type of data
      '''
    @property       # read a property
    def radians(self):
        return self.__use_radians
    @radians.setter # write a property
    def radians(self, flag):
        if self.__use_radians == flag:  # nothing changes
            return
        # when switching angle type, we need to convert the private angle value
        if self.__use_radians:          # switching from radians to degrees
            self.__angle = math.degrees(self.__angle)
        else:                       # switching from degrees to radians
            self.__angle = math.radians(self.__angle)
        self.__use_radians = flag

    ''' the angle property is read-only and does not have a write '''
    @property       # read-only property
    def angle(self):
        return self.__angle

The radians property demonstrates the advantage of using the @property and @<property>.setter instructions. If the class data were exposed directly, then there would be no opportunity to make sure the changes were valid. There would also be a problem with the internal details of managing degrees vs radians in the maths calculations.

Methods

Class methods are like functions. The difference is a method has access to all of the class data by using self.

71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
    ''' computing a point on a circle; optionally using the last angle and increment to advance on each use '''
    def point(self, angle = None):
        if angle is None:
            angle = self.__angle

        inc = self.__increment

        if self.__use_radians:
            r = angle
            self.__angle = r + (inc if inc else 0.1)    # save for next time
        else:                       # switching from degrees to radians
            r = angle
            self.__angle = r + (inc if inc else 4)     # save for next time
            r = math.radians(r)

        return (self.__radius * math.cos(r)), (self.__radius * math.sin(r))

The point() method can be used in two different ways. The user may setup the starting angle and increment ahead of time, then call point() with no arguments and it will automatically advance around the circle. Alternately, the user may provide the angle parameter and the code will compute the specific point.

Instances

An instance is a copy of the class. Each instance has it's own copy of the class data.

91
92
circle1 = Circle()                      # create a circle using defaults
circle2 = Circle(50, 12, radians=False) # create a circle radius of 50 and only incrementing by 12

The example creates two instances - one has all default data and the other has specific initial values.

I/O

Python has three primary sources of input: command line arguments, terminal window user input, and file input. Both the terminal window and file are also targets for output.

This workshop will not cover command line because CircuitPython does not have the notion of a command line. Please check out tutorialspoint, geeksforgeeks, or other sources for an overview of sys.argv[] and argparse for processing command line arguments and building command line interfaces.

OK, we lied. There is an example: code/python_commandline.py. But that's it. You are on your own to explore it.

For the curious, the code is the result of an AI prompt.

AI Prompt: Create a Python program which takes command line arguments (greeting, name, age) and prints them out. The program should create help text if the command line arguments are missing.

Terminal Input

Python used input() and print() as the basic functions for getting user input and providing information back to the user in an interactive experience. These interact with the command or terminal window. In CircuitPython, these functions interact with the serial terminal and provide the same experience.

Here is a REPL example of input from a prompt:

>>> num = input("Enter a number:")
>>> print(num)
42
>>>
>>> print(type(num))
<class 'str'>
>>>

Remember, Python is dynamically typed. You don't need to declare the type of a variable before using it. The type(variable) is a runtime function for determining the data type.

Even though we entered the number 42, it is a string. To make it into an integer, Python has functions to convert between types.

>>> num = input("Enter a number:")
>>> num = int(num)
>>> print(num)
42
>>>
>>> print(type(num))
<class 'int'>
>>>

File

Files in Python are a class object with methods for reading, writing, and moving around within hte data of the file. A file may be use for reading data or writing data and the data may be text or binary. The file object is created using the open() function.

Let's open the file data/friends.txt and read all of the names:

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# read all of the contents of a file and print it to the serial console
finished: bool = False
line: str
filename: str = "data/text.txt"

MAX_LEN = 50

try:
    data_file = open(filename, "r")
except Exception as e:
    print("error opening file", e)
    finished = True # no file to process

# the use of 'While True:' creates an infinite loop where code will run forever ... or until an error occurs :-)
while not finished:
    line = data_file.readline()
    if not line:
        finished = True
        data_file.close()
        print("\n - end of file - ")
    else:
        line = line.strip("\n")
        if len(line) < MAX_LEN:
            padding = (MAX_LEN - len(line)) // 2
            print(" " * padding, end="")
        print(line)

print("Good Bye")

This example introduces try .. except which handles errors from functions and classes which are raised by the code rather than returned. The try section is the code which executes if everything works as expected. If any of the code in the try sections raises an error then the except code will execute. The except can test for specific exceptions or it can be a catch all. Adding Exception as e provides details about the error.

Give it a try: Edit the code and change the name of the file to something that does not exist. See the results of the except code.

Here is an example of opening a file, prompting the user for a new name (text string), adding the text to the end, and then closing it.

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def show_file_contents(filename: str) -> None:
    finished: bool = False
    line: str

    try:
        data_file = open(filename, "r")
    except Exception as e:
        print("error opening file", e)
        finished = True # no file to process

    # the use of 'While True:' creates an infinite loop where code will run forever ... or until an error occurs :-)
    while not finished:
        line = data_file.readline()
        if not line:
            finished = True
            data_file.close()
        else:
            line = line.strip("\n")
            print(line)
    # end of show_file_contents()



filename: str = "data/friends.txt"

print("Original File Contents:")
show_file_contents(filename)
print()


# create a file add a string
friend: str = input("Enter the nickname of a friend: ")

try:
    data_file = open(filename, "a") # w=write, a=append
    if len(friend) > 0:
        data_file.write("\n" + friend)
    data_file.close()
    print("Updated File Contents:")
    show_file_contents(filename)
except Exception as e:
    print("error adding '{friend}' to file", e)

Python: This works as written.

CircuitPython The storage device is either read-write as a filesystem for the operating system xor it is read-write to the CircuitPython code. It is not both. The above code will generate an error read only filesystem.

There is a way to toggle how the storage is treated but it requires a user accessible button on the hardware. There is also a way to use the CIRCUITPY storage for both the operating system and for the CircuitPython code but there are risks. If the device is unplugged or the operating system attempts to write to CIRCUITPY while the CircuitPython code is also writing to storage, it all could become a big corrupted mess.

With that caution, here is how to allow CircuitPython code to write to the storage.

Add the following boot.py file to the root of CIRCUITPY:

 5
 6
 7
 8
 9
10
11
12
13
14
import time
import storage

print("**************** WARNING ******************")
print("Using the filesystem as a write-able cache!")
print("This is risky behavior, backup your files!")
print("**************** WARNING ******************")

storage.remount("/", disable_concurrent_write_protection=True)
time.sleep(5)

Unplug and plug the badge back into USB for the boot.py code to take effect. Now, the previous code will run.

Unless you need write-access from your CircuitPython code, it is advised to not have the unsafe boot code active.

A better option is to have a second storage device for the CircuitPython code to write to - such as an SD card.

Important: Any time you change boot.py you need to power-cycle the badge - either by unplugging and plugging it back in to USB or by using the reset button on the back of the badge.

Exercise

Read the contents of the data/text.txt file and print out the lines with preceding line numbers.

JSON

Python has a special library for handling files which contain JSON data. The file is opened as before but then its contents are read by the JSON library and all of the data is processed into the appropriate combination of lists, dictionaries, strings, integers, etc.

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
print("Hello World!")

filename: str = 'data/friends.json'
data = None

try:
    f = open(filename, "r")
    data = json.load(f)
    f.close()
except Exception as e:
    print(f"unable to process {filename}", e)

counter: int = 0

if data:
    for friend in data:
        counter += 1
        print (f"Friend #{counter:2d} is {friend['nickname']:10s} and their color is {friend['favorite_color']}")

print("Good Bye")

The JSON data in the file is a list of dictionaries where each dictionary represents a friend. Each friend has a set of key:value pairs: nickname, favorite_color, description, and a passcode. Access to a a key:value of a dictionary is done with brackets with the key name represented as a string. In the example, friend is the dictionary and the code accesses the 'nickname' and 'favorite_color' values.

Exercise

Here are a few things to try:

  1. Use REPL to explore the data created by the json library from a file.
  2. Modify the code to print out the entire JSON data. Hint: it's just one line of code.
    • AI Prompt: print the entire JSON data
  3. Try to sort the list of friends. This one is a bit more work but, again, it only needs one line of code.
    • AI Prompt: sort the JSON data alphabetically using nickname
  4. Create a sorted list of the passcodes.
  5. Decompress data/story.json
    • lines are organized into paragraphs
    • the ''data''' uses bit packing to store a series of 7 bit ASCII characters into 8 bit values
    • validate your answer against the source provided
  6. Create your own cipher tool by writing two functions - one to encode a string and one to decode a string - and use input() and print() for the input and output.
    • AI Prompt: create a function which takes a plaintext string and encrypts it using ROT13; also create a function which does the opposite; use input() for either an encrypted or plaintext input and output the reciprocal

Conclusion

If you are new to Python, this workshop has introduces a lot of new content. The best way to get comfortable with all of this material is to keep writing code (and ask questions).

Here are a few useful things we did not cover:

  • popular libraries
  • extending classes
  • web services - server, client, and listeners

We already mentioned the tutorials and examples on W3Schools.

You can also get some great tips and ideas using one of the on-line chat AI tools. Understand that the chat AI tools make mistakes so any code they generate may not work. That said, they do make it easy to generate examples.

Here is a series of prompts used with ChatGPT:

AI Prompt: create a python class which scans a JSON file for a list; create a set using two key words

AI Prompt: create data.json file with a list of 20 items where each item has a random program name, random runtime, random memory usage, and random description; some of the items should have the same program name; a few should also have an error message

Important: At the time this workshop was created, neither of these examples - using Copilot - created what we asked for 😉

Here is a correct program and data for the first prompt:

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import json

class JSONScanner:
    def __init__(self, filename):
        self.__filename = filename
        self.__data = None
        with open(self.__filename, 'r') as f:
            # read the file contents and create a list of nested dictionaries and list data
            self.__data = json.load(f)

    def scan(self, key, value):
        result = {} # this is an empty dictionary
        '''
        a dictionary consists of key:value pairs
        eg: dict[key] = value
        '''
        # keywords is a list; the for look iterates the list, returning one element at a time
        # data is a dictionary; data[keyword] is a value
        for person in self.__data:
            if key in person:
                result[person[key]] = person[value]
        return result


scanner = JSONScanner('data/people.json')
result = scanner.scan('name', 'age')
print(result)   # results in a dictionary where the key is a person's name and the value is their age
[
    {
        "name": "John Doe",
        "age": 30,
        "city": "New York",
        "hobbies": ["reading", "swimming", "traveling"],
        "address": {
            "street": "123 Main St",
            "city": "New York",
            "state": "NY",
            "zip": "10001"
        }
    },
    {
        "name": "Jane Smith",
        "age": 25,
        "city": "San Francisco",
        "hobbies": ["hiking", "biking", "cooking"],
        "address": {
            "street": "456 Elm St",
            "city": "San Francisco",
            "state": "CA",
            "zip": "94109"
        }
    }
]