Program 4

Checking Annotations

ICS-33: Intermediate Programming


Introduction This programming assignment is designed to show how we can get Python to check function annotations whenever annotated functions are called. For each of Python's built-in data types, we will develop an interpretation (and write code to check the interpretation) of how the data type specifies type information that is checkable.

For example, we specified the main dict for the NDFA in Programming Assignment #1 by the notation

  dict[str]->(dict[str]->set[str])
Recall the outer-dict associates a str state key with a value that is an inner-dict; and the inner-dict associates a str transition with a value that is a set of str states. In our annotation checker, we will use an alternative notation to specify this type information equivalently:
  {str : {str : {str}}}
Note that this is a data structure: an outer-dict whose key is a str and whose value is an inner-dict whose key is a str and whose value is a set of str.

We will write the Check_Annotation class and use it as a decorator for functions whose annotations we want to check each time the function is called. Internally it will use mutual recursion (not direct recursion), in a natural way, to process the nesting of data types inside data types for our notation. It will also know how to process a special annotation-checking protocol (via the __check_annotation__ method) that we can implement in any classes that we write, so that that class can become part of the annotation language.

I suggest that you look at the code in the modules that appear in the project folder that you will download. Then (see the detailed instructions in this document) we can add/test/debug capabilities for each of the built-in data types we can use in the annotation language, iteratively enhancing our code until we can use all the data types in annotations. Once we do this for list (fairly early on), all the others are variants and therefore much easier to write (but still with some interesting details).

You should download the program4 project folder and use it to create an Eclipse project. We can test our class (put it in the checkannotation.py module, which already includes some useful code) at the end of the class itself, or in a special driver that is included, which uses a batch file to test progressively more and more complex data types in the annotation language.

You should work on this assignment in pairs, with someone in your lab section. Try to find someone who lives near you, with similar programming skills, and work habits/schedule: e.g., talk about whether you prefer to work mornings, nights, or weekends; what kind of commitment you will make to submit program early. If you believe that it is impossible for you to work with someone, because of some special reason(s), you should send me email stating them and asking for special permission to work alone (which I do grant, but not frequently).

Only one student should submit all parts of the the assignment, but both student's names should appear in the comments at the top of each submitted .py file. It should look something like


# Romeo Montague, Lab 1
# Juliet Capulet, Lab 1
# We certify that we worked cooperatively on this programming
#   assignment, according to the rules for pair programming

Print this document and carefully read it, marking any parts that contain important detailed information that you find (for review before you turn in the files). The code you write should be as compact and elegant as possible, using appropriate Python idioms.


Problem #1: Check_Annotation

Problem Summary:

Write a class named Check_Annotation that decorates a function, such that the decorator class check's the decorated function's annotation, using the annotation language described below. We use this decorator (turning on annotaton checking) by writing either
  def f(params-annotation) -> result-annotation:
      ...
  f = Check_Annotation(f)
or
  @Check_Annotation
  def f(params-annotation) -> result-annotation:
      ...
which is a special Python syntactic form that expands to the former. Thus, when the decorated f is called, Python calls Check_Annotations.__call__ in the decorator, which can both check the annotations and compute/return the decorated function f: the original one written.

This class defines four major attributes:

  • the checking_on instance name to turn off/on annotation checking in all decorated functions; it starts on.

  • the __init__ method to remember the function being decorated and initialize a per-function name that helps controls annotation checking; it also starts on: for a function call to check its annotation, both checking_on and its per-function name must be True.

  • the __call__ method that intercepts each call to the decorated function and decides (and implements) annotation checking, both for parameters and returned results, if they are specified; if annotation checking succeeds, this method returns the result of calling the decorated function.

  • the check method (specified in more detail below) that does the annotation checking: it either succeeds silently or raises an AssertionError exception with useful information. Note that the unconditional assertion,
      assert False, message
    is a simple way to raise AssertionError with a message.

Details

The __call__ method intercepts calls to the decorated function; it specifies *args and **kargs to handle all calls, regardless of their parameter structure. My method was about 40 lines (but about 17 lines were comments/blank, and 7 comprise the param_arg_binding local function supplied in the download. The __call__ method
  • determines whether to check the annotations (see above); if not just call the decorated function and return its result.

  • determines the parameters of the function and their matching arguments they are bound to. The param_arg_bindings function (written locally in this method) returns a dictionary of parameter/value bindings. It uses the various attributes in the inspect module to do the job. You might be interested in reading the documentation for the inspect module: it is quite interesting and many of its (powerful) features are new to Python.

  • determines the annotations of the parameters by using the __annotations__ attribute of any function object.

  • If any checked annotations (parameters or returned result) raise the AssertionError handle it by printing the relevant source lines for the function (see the getsourcelines function in the inspect module's documentation) and reraise the exception, skipping the rest of the code in this method.
    • Checks every parameter that has an annotation

    • Call the decorated function to compute its returned result (and save it).

    • If 'return' is in the dictionary of annotions: (a) add the result as the value associated with the key _return in the dictionary of parameter and argument bindings; (b) check the annotation for return

    • Return the result.

The check method has the following header

  def check(self,param,annot,value,check_history=''):
where
  • self is an instance of the Check_Annotation class
  • param is a string that specifies the parameter being checked
  • annot is a data structure that specifies the annotation
  • value is the value of param that the annotation should check
  • check_history is a string that embodies the history of checking the annotation for the parameter to here (it is extended in each recursive call to provide context for any annotation violations later)
Each call to check decodes the annot to check, and checks it against the value: its body is one big if/elif/.../else. Most annotations are checked by calling a locally defined function in check that can use the parameters of check freely, because these functions are in check's local scope (in fact these local functions are often parameterless: many get all the information they need from check's parameters). My method was about 100 lines (but about 13 lines were comments/blank, and 60 more in 5 locally declared functions -including one to solve the extra credit part of this assignment).

The annotation checking language comprises the following components (for Python's built-in) types. I strongly suggest writing/testing each component before moving on to the next: all are similar and understanding/testing/debugging list (the first really interesting one) will supply tremendous insight for writing all. Write the required exception messages exactly to match the ones shown.

  • annot is None: do nothing (succeed silently). note that def f(x): has no annotation to check for its parameter x, but def f(x:None): has an annotation to check for x, but it never fails. None has more interesting uses inside more complicated data types.

  • annot is a type: fail if value is not an instance of the specified type, with an exception messages matching the following examples.

    For def f(x:int):... called as f('abc') or f(x='abc') the exception message would be:

    AssertionError: 'x' failed annotation check(wrong type): value = 'abc'
      was type str ...should be type int
    For def f(x:list):... called as f({1,2}) the exception message would be:
    AssertionError: 'x' failed annotation check(wrong type): value = {1, 2}
      was type set ...should be type list

  • annot is a list (not the list class object, but an instance of list: a real list of values) where each element in annot is an annotation. Fail if
    1. value is not a list
    2. annot has just one element-annotation, and any of the elements in the value list fails the element-annotation check
    3. annot has more than one element-annotation, and the value list has a different number of elements
    4. annot has more than one element-annotation, and any element in the value list fails its corresponding element-annotation

    Here are some examples of failures:

    1. For def f(x:[int]):... called as f({1,2}) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = {1, 2}
        was type set ...should be type list

    2. For def f(x:[int]):... called as f([1,'a']) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = 'a'
        was type str ...should be type int
      list[1] check: <class 'int'>
      Note that when each element in the list is tested, it appends the index it is checking and the the annotation it is checking to the check_history (which prints after the actual annotation that fails: here the line starting list[1] check: ...): the element at index 0 did not fail this annotation.

    3. For def f(x:[int,str]):... called as f([1]) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong number of elements): value = [1]
        annotation had 2 elements[<class 'int'>, <class 'str'>]

    4. For def f(x:[int,str]):... called as f([1,2]) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = 2
        was type int ...should be type str
      list[1] check: <class 'str'>

    Note that that for def f(x:[[str]]):... called as f([['a','b'],['c','d']]) no exception is raised, because the annotation says x is a list containing lists that contain only strings. The code to check list annotations will indirectly call itself (recursively) in the process of checking this annotation. Think about this now, when there are few data types being processed; it will be natural to perform other recursive annotation checks in the check method. Note if we called f([['a',1],['c','d']]) the exception message would be

    AssertionError: 'x' failed annotation check(wrong type): value = 1
      was type int ...should be type str
    list[0] check: [<class 'str'>]
    list[1] check: <class 'str'>
    which indicates that the annotation of list[0] was being checked when the annotation for list[1] was being checked, when Python found a non-string.

    Finally note that for def f(x:[int,None]):... called as f([1,'a']) no exception is raised, because the annotation for the list element at index 1 is None, which according to the rule at the top does no checking of the list's value at index 1.

  • annot is a tuple (not the tuple class object, but an instance of tuple: a real tuple of values), where each element in annot is an annotation.

    Structurally, checking tuples is equivalent to checking lists (all 4 rules apply). In fact, I parameterized the local function that I originally wrote for chcking lists to work for checking tuples as well). Of course, the error messages should use the word list and tuple where appropriate. Caution: remember for tuples we must write f(x:(int,)):...; notice the comma after int.

  • annot is a dict (not the dict class object, but an instance of dict: a real dictonary), where one key and its associated value are each an annotation. Note, this annotation should work for subclases of dict, e.g., defaultdict. Fail if
    1. value is not a dict or a subclass of dict
    2. annot has more than one key/value association: this is actually a bad/illegal annotation, not a failed annotation
    3. annot has one key/value association, and any key in the value dictionary fails the key-annotation check
    4. annot has one key/value association, and any value in the value dictionary fails the value-annotation check

    Here are some examples of failures:

    1. For def f(x:{str : int}):... called as f(['a',0]) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = ['a', 0]
        was type list ...should be type dict

    2. For def f(x:{str : int, int : int}):... called as f({'a':0}) the exception message would be:
      AssertionError: 'x' annotation inconsistency: dict should have 1 item but had 2
      annotation = {<class 'str'>: <class 'int'>, <class 'int'>: <class 'int'>}

    3. For def f(x:{str : int}):... called as f({1:0}) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = 1
        was type int ...should be type str
      dict key check: <class 'str'>

    4. For def f(x:{str : int}):... called as f({'a':'b'}) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = 'b'
        was type str ...should be type int
      dict value check: <class 'int'>
  • annot is a set (not the set class object, but an instance of set: a real set of values) where its one value is an annotation. Fail if
    1. value is not a set
    2. annot has more than one value: this is actually a bad/illegal annotation, not a failed annotation
    3. annot has one value, and any value in the value set fails the value-annotation check

    Here are some examples of failures:

    1. For def f(x:{str}):... called as f(['a','b']) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = ['a', 'b']
        was type list ...should be type set

    2. For def f(x:{str,int}):... called as f({'a',1}) the exception message would be:
      AssertionError: 'x' annotation inconsistency: set should have 1 value but had 2
        annotation = {<class 'str'>, <class 'int'>}

    3. For def f(x:{str}):... called as f({'a',1}) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = 1
        was type int ...should be type str
      set value check: <class 'str'>

  • annot is a frozenset (not the frozenset class object, but an instance of frozenset: a real frozenset of values) where its one value is an annotation.

    Structurally, checking frozensets are equivalent to checking sets (all 3 rules apply). In fact, I parameterized the local function that I originally wrote for chcking sets to work for checking frozensets as well, similarly to the function I wrote for checking lists/tuple Of course, the error messages should use the word set and frozenset where appropriate.

  • annot is a lambda (or any function object) that is a predicate with one parameter and returning a values that can be interpreted as a bool. Fail if
    1. annot has zero/more than one parameters: this is actually a bad/illegal annotation, not a failed annotation
    2. Calling the lambda/function on value returns False
    3. Calling the lambda/function on value raises an exception

    Note that we can recognize a function/lambda object by calling the inspect module's isfunction predicate; we can determine the number of parameters in a function/lambda object by accessing its __code__.co_varnames attribute. You might be interested in reading the documentation for the inspect module: it is quite interesting and many of its (powerful) features are new to Python.

    Here are some examples of failures:

    1. For def f(x:[lambda x,y : x>0]):... called as f([1,0]) the exception message would be:
      AssertionError: 'x' annotation inconsistency: predicate should have 1 parameter but had 2
        predicate = <function <lambda> at 0x02BDDC90>
      list[0] check: <function <lambda> at 0x02BDDC90>

    2. For def f(x:[lambda x : x>0]):... called as f([1,0]) the exception message would be:
      AssertionError: 'x' failed annotation check: value = 0
        predicate = <function <lambda> at 0x02BDDC90>
      list[1] check: <function <lambda> at 0x02BDDC90>

    3. For def f(x:[lambda x : x>0]):... called as f([1,'a']) the exception message would be:
      AssertionError: 'x' annotation predicate(<function <lambda> at 0x02C9DC90>) raised exception
        exception = TypeError: unorderable types: str() > int()
      list[1] check: <function <lambda> at 0x02BDDC90>
      Note that for def f(x:[lambda x : type(x) is int and x>0]):... called as f([1,'a']) the exception message would be the more reasonable:
      AssertionError: 'x' failed annotation check: value = 'a'
        predicate = <function <lambda> at 0x02BDDC90>
      list[1] check: <function <lambda> at 0x02BDDC90>

  • annot is not any of the above (or , specified in the extra credit part below). Assume it is an object constructed from a class that supports annotation checking, by defining the the __check_annotation__ method. Fail if
    1. calling the __check_annotation__ method raises the AttributeError exception (the object was not constructed from a class that supports the annotation checking protocol): this is actually a bad/illegal annotation, not a failed annotation
    2. calling its __check_annotation__ method fails
    3. calling its __check_annotation__ method raises any other exception

    Note that I have written the Check_All_OK and Check_Any_OK classes that support the annotation checking protocol; check them out.

    Here are some examples of failures. The first assumes the Bag class does not support the annotation checking protocol; the second assumes it does; the third assumes it supports the protocol but raises some other exception (not AssertionError).

    1. For def f(x:Bag([str])):... called as f(Bag('a')) the exception message would be:
      AssertionError: 'x' annotation undecipherable: Bag(<class 'str'>[1])

    2. For def f(x:Bag([str])):... called as f(Bag(['a',1])) the exception message would be:
      AssertionError: 'x' failed annotation check(wrong type): value = 1
        was type int ...should be type str
      Bag value check: <class 'str'>

    3. For def f(x:Bag([lambda x : x > 0)):... called as f(Bag(['a',1])) the exception message would be:
      AssertionError: 'x' annotation predicate(<function <lambda&rt; at 0x02C5C390>) raised exception
        exception = TypeError: unorderable types: str() > int()
      Bag value check: <function <lambda> at 0x02C5C390>

      The checkannotation.py module defines the Check_All_OK and Check_Any_OK classes, which implement the check annotation protocol. Note that with the Check_Any_OK class, we can specify that every value in a list must contain a string or integer. So for def f(x:[Check_Any_OK(str,int)]):... called as f(['a',1]) there is no exception raised. Likewise with the Check_All_OK class, we can specify that every value in a list must be an integer and must be bigger than 0. So for def f(x:[Check_All_OK(int,lambda x : x > 0)]:... called as f([1,2]) there is no exception raised.

Extra credit: Implement the following annotations as well.
  • annot is a str which when evaluated using a dictionary in which all the parameters are defined (and the returned result is the value of the key '_return') returns a value that can be interpreted as a bool. This specifiction is similar to lambdas/functions, but more general, because the expressions can name multiple names. Fail if
    1. Evaluating the string returns False
    2. Evaluating the string raises an exception

    Here are some examples of failures.

    1. For def f(x,y:'y>x'):... called as f(0,0) the exception message would be:
      AssertionError: 'y' failed annotation check(str predicate: 'y>x')
        args for evaluation: x->0, y->0
      Notice that with this form of annotation, we can check properties that depend on values of multiple parameters (not just type information). The values of all the parameters are included in the error message. Likewise we can check properties that depdend on the returned values. For def f(x,y)->'_result < x or _return < y': return x + y called as f(3, 5) the exception message would be:
      AssertionError: 'return' failed annotation check(str predicate: '_return < x or _return < y')
        args for evaluation: x->3, y->5, _return->8
      Notice the value of _return is listed with all the parameter values. Of course, such strings are easier to read than what Python prints for lambdas/functions.

    2. For def f(x:'x>0'):... called as f('a') the exception message would be:
      AssertionError: 'x' annotation predicate(<function <lambda> at 0x02C9DC90>) raised exception
        exception = TypeError: unorderable types: str() > int()

  • Implement __check_annotation__ for the Bag class or the class produced by calling pnamedtuple.

A Largish Example: Full Output

When I put the following in a script to check
  @Check_Annotation
  def f(x:[[int]]): pass
    
  f([[1,2],[3,4],[5,'a']])
the result printed was the following , although I edited out some of the code that Python displays from my program: lines that start with ...
--------------------------------------------------------------------------------
    @Check_Annotation
    def f(x:[[int]]): pass
--------------------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\Pattis\workspace\33program4\checkannotationsolution.py", line 209, in <module>
    f([[1,2],[3,4],[5,'a']])
  File "C:\Users\Pattis\workspace\33program4\checkannotationsolution.py", line 183, in __call__
    ...my call to self.check
  File "C:\Users\Pattis\workspace\33program4\checkannotationsolution.py", line 138, in check
    ...my call to check a list
  File "C:\Users\Pattis\workspace\33program4\checkannotationsolution.py", line 70, in check_sequence
    ...my call to check a value in the list
  File "C:\Users\Pattis\workspace\33program4\checkannotationsolution.py", line 138, in check
    ...my call to check a list
  File "C:\Users\Pattis\workspace\33program4\checkannotationsolution.py", line 70, in check_sequence
    ...my call to check a value in the list
  File "C:\Users\Pattis\workspace\33program4\checkannotationsolution.py", line 137, in check
    ...my call to check a type (which failed the assertion causing the following exception)
AssertionError: 'x' failed annotation check(wrong type): value = 'a'
  was type str ...should be type int
list[2] check: [<class 'int'>]
list[1] check: <class 'int'>

Testing

The sections above present various tests for elements of the annotation language: they are easy to specify because the parameter annotations involve only the header: the body can be pass; when checking return annotations, we can put one return statement in the body of the code, to return a value that does/doesn't satisfy the annotation.

I provided an if __name__ == '__main__': section in the checkannotation.py module. Again, it is easy to test a simple function there by annotating it and then calling it.

I provided a short driver script whose infinite loop at the end allows you to enter one-line Python statements commands and then see their result (via exc). You and also enter a command to run the batch_test and batch_self_check, providing each with a file that is appropriate for each.

Files for batch_self_check (see cabsc.txt for an example; you might have to remove all but the earliest tests as you start testing your code) contain three parts.

  1. Either a # (for comment), c (for command), e (for expression), or ^ (for command expected to raise an exception) followed by a -->
  2. A command or expression (as specified) followed by a -->
  3. For commands nothing; for expressions a string specifying the expected value for the expression; for exceptions a comma separated list of allowable types to raise (often just one).
The batch_self_check will execute the commands and evaluate the expressions: for commands it checks whether they avoided/raised exceptions correctly; for expressions it checks whether the expression produces the specified result; in both cases printing an error mesage for each that doesn't do what is expected. At the end of all tests, it tabulates the number of passed and failed tests (and enumerates the failed tests) for printing at the end of the test.

To use the batch_self_check you must remove the part of your __call__ method that prints out the source lines: Python will raise a strange execption, which disrupts batch_self_check if you do not.

I have provided the file cabsc.txt which contains checks for every aspect of this assignment (in the order that you should implement the aspects). We will use this file, possibly augments a bit, as the grading rubric for the assignment. You may find it useful to build more test files for the batch_self_check to perform all sorts of checks on your code automatically.