Continue With the Menues

I had no idea this would take me so long or involve so much effort and time. Guess I should really have had a better plan. Well, if I had a plan!

First of all I’d like to add some more variables related to command line escape sequences. Then make sure the code uses the new variables, rather than hard coding the escape sequences in the output strings. There will likely be more in future, but for now I am adding those I know from my testing are likely to be used. So here they are. Note: I got many of these at Wikipedia: ANSI escape code. Going to rename ENDC to something more meaningful. I expect this will make it easier to change colours, cursor control, etc. if I ever wish to do so.

Before:

TGREEN =  '\033[32m' # Green Text
ENDC = '\033[m' # reset to the defaults

After:

# Terminal escape sequences
TRED = '\033[1;31m' # Red text
TGREEN =  '\033[32m' # Green text
TBLUE = '\033[34m' # Blue text

TRESET = '\033[m' # reset to the defaults

CLREOF = '\033[0K' # erase to end of current line
ONELU = '\033[1F' # move cursor one line up

# Menu item colours
CTTL = '\033[1;34m' # colour for menu title lines, highlighted blue
CHLITE = '\033[1;34m' # colour for highlighted text in menu lines, highlighted blue
CMITEM = '\033[1;31m' # colour for menu selection values, hightlighted red

I currently have a number of lines with the escape sequences hard coded in them. So going to modify to use the variables above. I will show you one or two so you get the idea.

Before in do_chart_menu():

    if add_valid:
      print(f"\n\tPlease select a \033[1;34mvalid\033[0;0m chart type:")
    else:
      print(f"\n\tPlease select a chart type:")
    for key, value in MENU_C.items():
      print(f"\t\t\033[1;31m{key}\033[0;0m: {value}")

After:

    if add_valid:
      print(f"\n\tPlease select a {CHLITE}valid{TRESET} chart type:")
    else:
      print(f"\n\tPlease select a chart type:")
    for key, value in MENU_C.items():
      print(f"\t\t{CMITEM}{key}{TRESET}: {value}")

Once I’ve changed them all, I will commit my changes before moving on.

Now I am going to write the code to get the user data for each chart type and build the data structure to be used by the plotting functions. Each will be a function that gets the data and returns it. For now I will just print the returned data and not worry about trying to plot the charts. That will come in a future post.

Chart Type 1: One country/region, 1-5 years, all age groups

Okay this is the one we basically already have working. Except for the 1-5 years bit. We currently only do one year. But, small steps. And, I am going to make some changes to how the code works. In our current application (master branch), we have some data processing related code in the population_by_age.py module. I want to take that out and put it in the database\population.py module. The only thing I want going on in menues.py is just that — the user interface.

And, to hopefully help with generating the different plot styles, I am also going to rework the format data gets passed around from function to function. Firstly from the menu loop, i.e. the user specified plot parameters, to the database and plotting functions. Then amongst the database and plotting functions. The latter will develop as we go along.

I propose that the menu code send an array of lists to the database and plotting functions. Something like the following for our current case.

plot_data = [
  ['Colombia'],
  ['2020', 1],
  ['all']
]

The 1 in the second list indicates only 1 year. If this were say 5 then we would want to plot data for 2020-2024 in adjacent bars for each age group. Something like: preliminary look at a grouped bar chart showing population for 5 years for each age group I am using a list for the first item, because we eventually plan to allow plotting more than one country on a plot. So, a list of names would seem to work best. As for the last item, it will only ever contain one value. So, I may look at changing it to a string. But a list of lists seems so much more consistent.

The function for each chart will print a title indicating what the chart will show. It will then present a number of input statements to collect the data needed to generate the appropriate plot. And when the data is complete return it to the caller.

So, let’s start by creating the first function, and having it print the title. And, I want to use a different colour for these titles. So we also need to add another constant to our list of menu colours. I will also add empty values for the data that will be return and return that after printing the title.

CCTTL = TGREEN # colour for chart interface title

def do_chart_1ma():
  """Obtain the data needed to generate this type of chart and return it to the caller.
     Chart type: One country/region, 1-5 years, all age groups

    Parameters
    ---
      None

    Side Effects
    ---
      Prints messages to terminal window

    Returns
    ---
      c_data : list of lists
        [[country/rgn name: str], [start year: str, nbr of years to display: int] ['all']]
  """
  c_nm = ''
  p_yr = ''
  n_yr = 1

  print(f"\n\t{CCTTL}{MENU_C['1']}{TRESET}")

  return [[c_nm], [p_yr, n_yr], ['all']]

Next we need to modify our menu control loop to access this function when chart type ‘1’ is selected. Don’t forget to collect the user supplied (well eventually) data that we get from the call. And, let’s print the returned data for testing purposes.

Replace

        else:
          print(f"\n\tYou wish to print a plot showing: {MENU_C[c_choice]}")

with

        else:
          if c_choice == '1':
            p_data = do_chart_1ma()
            ## for testing
            print(f"do_chart_1ma() returned: {p_data}")
          else:
            print(f"\n\tYou wish to print a plot showing: {MENU_C[c_choice]}")

Let’s give that a test.

Seems to work. So let’s write function stubs for the other two chart types: do_chart_m1a() and do_chart_mm1() And modify the menu loop code to use our stubs accordingly. Then commit these changes, including the function stubs.

A method stub is a piece of code used to stand in for some other programming functionality in software development. It may simulate the behavior of an existing piece of code, or stand in for code that has not yet been developed. Stubs play an important role in software development, testing and porting, …

source: techopedia

Now back to work on do_chart_1ma().

This may strike you as overkill, but I am going to write individual functions for getting a country name to plot and for getting the year to plot. Eventually I will extend these to get multiple country/region names and a year range. When necessary I will add one to get an age range. The chart functions will use these new functions to get the user data to return to the menu loop for further processing (i.e. plotting the selected chart).

In addition I am going to use the search function module we wrote to check that the country name can be found in the CSV database. If not I will ask the user to enter another name or quit. I am going to change some of my original premises in this validation process. I will accept any string. If it matches the beginning of any country/region name, but only one, then I will use the name the search found as the country/region name for the plot. E.G. user enters ‘ven’, search only finds ‘Venezuela (Bolivarian Republic of)’: the latter will be returned as the country name.

Give it a try and come back when you’re ready. I suggest you commit each function separately when you have it working. But do read the bit below before going off to work on your solution.

I ran into a bit of a *gotcha* when testing the code for the country name validation using the search module. Specifically:
FileNotFoundError: [Errno 2] No such file or directory: 'cr_list.txt'

Now you may recall that I decided not to put that file under version control because it could be generated whenever needed from the CSV file. If necessary see: Helping the User Out. So I ran the rc_names.py file. Another error:
No such file or directory: 'WPP2019_PopulationByAgeSex_Medium.csv'

You may recall we had a similar issue running population_by_age.py when we restructured our project layout. So refer back to that and I will leave you to fix the problem.

But do get the cr_list.txt file generated before continuing.

Get Country/Region Name

Added the function get_cr_name() and modified do_chart_1ma to use it. So far only getting one country/region name. Will expand to multiple countries once everything for a single country works. Note: I am leaving out the function documentation in the listing below.

I have also added a couple of ways to exit the loop being used to get and validate possible names. Mainly, I found it rather annoying during testing to have to back out of all the menus one by one in order to exit the app so I could restart it following changes to my code. So, I am going to check for an ‘X’ being entered by the user. If so, I exit(0) the program completely. I am also accepting a ‘D’ to allow the user to stop the loop requesting a valid name.

I had to import the the database/rc_names.py module to access the name search function. Do note that the import statement should be at the top of the file with all the other imports.

from database import rc_names as rcn

def get_cr_name():
  is_done = False
  cr_nm = ''
  while not is_done:
    cr_nm = input(f"{CLREOF}\t\tPlease enter a country/region name: ")
    # for testing allow exiting the application rather than continuing with data entry
    if cr_nm.upper() == 'X':
      exit(1)
    # stop data entry and return
    if cr_nm.upper() == 'D':
      cr_nm = 'X'
      break
    # search for name in list of countries/regions
    fnd_nms = rcn.find_names(cr_nm)
    # if only one matching name found, we are good to go
    if len(fnd_nms) == 1:
      cr_nm = fnd_nms[0]
      is_done = True
    else:
      if len(fnd_nms) > 1:
        print(f"{ONELU}{CLREOF}\t\tMore than one country/region matched the name you entered: {TRED}{cr_nm}{TRESET}!")
      else:
        print(f"{ONELU}{CLREOF}\t\tUnable to find {TRED}{cr_nm}{TRESET} in the database!")
  return cr_nm

And, do_chart_1ma() now looks like:

def do_chart_1ma():
  c_nm = ''
  p_yr = ''
  n_yr = 1

  print(f"\n\t{CCTTL}{MENU_C['1']}{TRESET}")
  c_nm = get_cr_name()

  return [[c_nm], [p_yr, n_yr], ['all']]

Get Start Year and Number of Years to Plot

Same basic idea as for the function above. But, we are getting the informaton regarding what years to plot. Since this is a fairly straight forward procedure, I am going to use the same function to get only 1 year or multiple years. So I am going to add a parameter indicating the maximum number of years that the user can select. With the default being one year. Something like get_years(max_rng=1).

I will also add the get out of jail exit codes as well. And, update do_chart_1ma() to use the new function.

Could perhaps have cut the code more or less in half by using the same loop to get both values, but for now don’t think it is a big deal. And certainly clearer if you are reading the code anew.

def get_years(max_rng=1):
  yr_ok = False
  rng_ok = False
  b_yr = ''   # base year
  r_yrs = max_rng   # range of years
  t_yr = 0    # test year as int
  # get valid base year or exit code
  while not yr_ok:
    if t_yr == 1:
      b_yr = input(f"{ONELU}{CLREOF}\t\tPlease enter a year ({CHLITE}1950-2100{TRESET}): ")
    else:
      b_yr = input(f"\t\tPlease enter a year (1950-2100): ")
    if b_yr.upper() == 'X':
      exit(1)  
    if b_yr.upper() == 'D':
      b_yr = 'X'
      break
    try:
      t_yr = int(b_yr)
      yr_ok = t_yr >= 1950 and t_yr <= 2100
    except:
      yr_ok = False
    if not yr_ok:
      t_yr = 1
  # get valid year range or exit code, only if have valid base year and more than 1 year allowed
  while (b_yr != 'X') and (max_rng > 1) and (not rng_ok):
    if t_yr == 1:
      r_yrs = input(f"{ONELU}{CLREOF}\t\tPlease enter the number of years you wish plotted ({CHLITE}1-{max_rng}{TRESET}): ")
    else:
      r_yrs = input(f"\t\tPlease enter the number of years you wish plotted (1-{max_rng}): ")
    if r_yrs.upper() == 'X':
      exit(1)  
    if r_yrs.upper() == 'Q':
      r_yrs = 0
      break
    try:
      r_yrs = int(r_yrs)
      rng_ok = r_yrs >= 1 and r_yrs <= max_rng
    except:
      rng_ok = False
    if not yr_ok:
      t_yr = 1

  return [b_yr, r_yrs]

nd, do_chart_1ma() now looks like:

  c_nm = ''
  p_yr = ''
  n_yr = 1

  print(f"\n\t{CCTTL}{MENU_C['1']}{TRESET}")
  c_nm = get_cr_name()
  p_yr, n_yr = get_years(max_rng=1)

  return [[c_nm], [p_yr, n_yr], ['all']]

A simple test shows that the 1 name, 1 year case appears to work as desired. So, now to test the multiple year situation. Replace p_yr, n_yr = get_years(max_rng=1) with p_yr, n_yr = get_years(max_rng=5). And do some more simple testing.

Time to commit the latest changes, and call it a day. Will continue with the other functions to get plot data from users next time. Will continue semi-weekly posts for now.