[Tutor] How to avoid "UnboundLocalError: local variable 'goal_year' referenced before assignment"?

Cameron Simpson cs at cskk.id.au
Sun Mar 24 01:22:28 EDT 2019


Discussion below your post here, since I feel I should quote it all:

On 23Mar2019 22:15, boB Stepp <robertvstepp at gmail.com> wrote:
>I have just written a small program earlier today to allow the user
>(me) to enter a date by which I wish to finish reading a book (=
>massive programming-related book) and calculate how many pages I need
>to read each day (starting today) in order to finish the book by the
>target date.  Looking over my code I found that I was repeating a
>fairly regular pattern in collecting the user input, so I thought I
>would *improve* my working program by removing the duplicated code
>into a single function.  So I came up with:
>
>from datetime import date
>
>def get_input(greeting_msg, identifier, input_prompt, date_value_err_ck,
>        err_msg, conditions):
>    """ ??? """
>
>    if greeting_msg:
>        print(greeting_msg)
>
>    while True:
>        try:
>            identifier = int(input(input_prompt))
>            if date_value_err_ck:
>                date(*date_value_err_ck)
>        except ValueError:
>            print(err_msg)
>            continue
>        for (condition, condition_msg) in conditions:
>            if condition:
>                print(condition_msg)
>                break
>        else:
>            return identifier
>
>When I attempt to use this function with:
>
>goal_year_params = {
>            'greeting_msg': "Please enter the date by which you wish to attain"
>                " your goal.\n",
>            'identifier': 'goal_year',
>            'input_prompt': "Enter year of your goal as an integer:  ",
>            'date_value_err_ck': (goal_year, 1, 1),
>            'err_msg': "That is not a valid year.  Please try again.",
>            'conditions': [
>                ('goal_year < date.today().year',
>                    "Have you invented a time machine?  If not, please enter a"
>                    " year that makes more sense!"),
>                ('goal_year >= date.today().year + 100',
>                    "Have you discovered the secret to eternal life?  And how"
>                    " long is this book anyway?  Please enter a year that"
>                    " makes more sense.")]}
>goal_year = get_input(**goal_year_params)
>
>I get the following traceback:
>
>Traceback (most recent call last):
>  File "pages_per_day.py", line 250, in <module>
>    start_pgm()
>  File "pages_per_day.py", line 246, in start_pgm
>    goal_date_obj, pages_read, total_pages_to_read = get_inputs()
>  File "pages_per_day.py", line 63, in get_inputs
>    'date_value_err_ck': (goal_year, 1, 1),
>UnboundLocalError: local variable 'goal_year' referenced before assignment
>
>I understand this result, but cannot come up with a way to implement
>my desired DRY strategy as I am stuck on how to get around this "local
>variable ... referenced before assignment" issue.  On subsequent
>passes "goal_year" will become "goal_month" and then "goal_day" as the
>user needs to input all three of these numbers.  Is there a way to
>accomplish my goal or am I attempting to be too clever?

You're not attemnpting to be too clever, but you are making some basic 
errors. For example, you can't just run a string like 'goal_year < 
date.today).year' and you shouldn't be trying i.e. do _not_ reach for 
the eval() function.

Let's look at your get_input() function. It basicly loops until you get 
a value you're happy with, and returns that value. Or it would if it 
were correct. Let's examine the main loop body:

    try:
        identifier = int(input(input_prompt))
        if date_value_err_ck:
            date(*date_value_err_ck)
    except ValueError:
        print(err_msg)
        continue
    for (condition, condition_msg) in conditions:
        if condition:
            print(condition_msg)
            break
    else:
        return identifier

To start with, you have confusion in the code bwteen the name you're 
intending to use for the input value (the "identifier" parameter) and 
the value you're reading from the user. You go:

    identifier = int(input(input_prompt))

That immediatey destroys the name you passed in as a parameter. Instead, 
use a distinct variable for the input value. Let's be imaginitive and 
call it "value":

    value = int(input(input_prompt))

and at the bottom of the get_input() function you should:

    return value

Then you try to create a date from that value (though you don't save it 
anywhere). I presume you want to use the datetime.date() constructor.  
So:

    # at the start of your programme
    import datetime

then in the function:

    date = datetime.date(*date_value_err_ck)

I think your plan is that datetime.date() will also raise a ValueError 
for a bad year number in "value". So you want, in fact, to call:

    date = datetime.date(goal_year, 1, 1)

And the construction of that will be different on your subsequent calls 
where you have goal_year and are inputting goal_month, and so on.

Your date_value_err_ck comes from your dict at the bottom of the 
programme. However, it is in the natural of things that you're computing 
the content of that dict _before_ you call get_input().

Let me introduce you to the lambda.

You _don't_ want to compute (goal_year,1,1) beofre the call to get_input 
(and in fact doing so is the immediate cause of your error message).  
What you actaully want is to compute that value _during_ the get_input 
function, _after_ you've read a value from the user.

So you should not be passing a tuple (goal_year,1,1). You should be 
passing a function which computes that tuple from the input value.

Let me suggest this dict entry for the bottom dict:

    'date_constructor': lambda goal_year: datetime.date(goal_year, 1, 1),

instead of your 'date_value_err_ck' entry. The lambda is shorthand for a 
single function definition: it gets you a function which accepts a 
single parameter "goal_year" and returns a datetime.date result. Note 
that here, at the bottom of the programme, it DOES NOT CALL the 
function. It just makes the function. The longer way to do this might 
look like this:

    def date_from_year(goal_year):
      return datetime.date(goal_year, 1, 1)
    goal_year_params = {
      ....
      'date_constructor': date_from_year,
      ....
    }

The lambda is just a single line function definition, and doesn't get a 
function name. The above could also be written:

    date_from_year = lambda goal_year: datetime.date(goal_year, 1, 1)
    goal_year_params = {
      ....
      'date_constructor': date_from_year,
      ....
    }

much like the one liner I started with.

So your get_input name now accepts a "date_constructor" parameter and 
you would change the input step from this:

    try:
        identifier = int(input(input_prompt))
        if date_value_err_ck:
            date(*date_value_err_ck)
    except ValueError:

to this:

    try:
        value = int(input(input_prompt))
        date = date_constructor(value)
    except ValueError:

So you're passing in a function to make the date, and only calling it 
once you've read in the value.

This solves your use of goal_year before it is defined in 2 ways: it 
doesn't use the value before the call to get_input and also the name 
"goal_year" in the constructor function is local to that function - it 
may as well be called "year".

Now to your conditions. You have:

    'conditions': [
        ('goal_year < date.today().year',
            "Have you invented a time machine?  If not, please enter a"
            " year that makes more sense!"),
        ('goal_year >= date.today().year + 100',
            "Have you discovered the secret to eternal life?  And how"
            " long is this book anyway?  Please enter a year that"
            " makes more sense.")]}

These seem to be tests for bad values (personally I tend to write tests 
for good values, but this is an arbitrary choice). These should also be 
written as lambdas:

    'conditions': [
        (lambda goal_year: goal_year < date.today().year,
            "Have you invented a time machine?  If not, please enter a"
            " year that makes more sense!"),
        (lambda goal_year: goal_year >= date.today().year + 100,
            "Have you discovered the secret to eternal life?  And how"
            " long is this book anyway?  Please enter a year that"
            " makes more sense.")]}

so that now the conditions are functions, not strings. (And you want to 
call datetime.date, not date, because we're using a variable "date" 
locally anyway.) Then the get_input function test becomes:

    for condition, condition_msg in conditions:
        if condition(value):
            print(condition_msg)
            break

i.e. call each function with the input value.

But wait, there's more!

After you have phase 1 complete (inputting goal_year) you then want 
goal_month.

Let me introduce you to the closure:

For the goal_year you have the year-based constructor using only the 
input value:

    'date_constructor': lambda goal_year: datetime.date(goal_year, 1, 1),

in your setup dict. For the goal_month you want both the goal_year and 
the input value. So...

    'date_constructor': lambda goal_month: datetime.date(goal_year, goal_month, 1),

But, I hear you ask, I'm not passing in the goal_year to the get_input() 
for the month value! Indeed, your code will look like this:

    goal_year_params = {
        ....
        'date_constructor': lambda goal_year: datetime.date(goal_year, 1, 1),
        ....
    }
    goal_year = get_input(**goal_year_params)
    goal_month_params = {
        ....
        'date_constructor': lambda goal_month: datetime.date(goal_year, goal_month, 1),
        ....
    }
    goal_month = get_input(**goal_month_params)

That's your plan, yes?

Well, when you define the goal_month constructor lambda function, 
goal_year _is_ a local variable. And _because_ it is not a parameter of 
the lambda, the lambda finds it in the scope where it was defined: the 
scope at the bottom of your programme.

This is called a "closure": functions has access to the scope they are 
define in for identifiers not locally defined (eg in the parameters or 
the function local variables - which are those assigned to in the code).

This means you get to pass in a lambda which still just takes one 
parameter (the input value, which it will use for the month) and still 
have access to the goal_year from the place the lambda was defined.

A final note regarding "what is a local variable"? A local variable is 
one which is assigned to in the function code. Looks:

    x = 1
    y = 2
    z = 3
    def func(x):
      y = 5
      return x + y + z

In "func", "y" is local because of the "y = 5" line. "x" is also local 
because it is a function parameter (which amounts to assigning a value 
to "x" when the function is called. However, "z" comes from the scrope 
where the function was defined.

Cheers,
Cameron Simpson <cs at cskk.id.au>


More information about the Tutor mailing list