Release 0.2c: Another project with Python

The 3rd week of October is coming to an end and, luckily, so my 3rd PR!

Issue #3: Add Edit Transaction Feature for budget-cli

Link: Repository | Issue | Pull Request

The project I was working on this week is a Budget Management app. It is basically an app that allows the user to quickly interact with a Google Spreadsheet document through the command line.

Demo for budget-cli: Inserting a transaction through command line

The spreadsheet document has been templated and provided by Google through its Spreadsheet tool. The user only needs to connect their spreadsheet and Google Account to the app.

The app is written in Python and is backed by the Google Spreadsheet API which is the main reason why I chose this project. Since the main functionality of the project is to parse the user's argument into data structures in Python, I think working in this project gives me opportunities to have practical experience in handling data and data types in Python. Besides that, the project also interact with a Google API and I want to see, at a basic level, how this looks like in Python

First Struggle: Setting Up

The first struggle of a project is always the setup process! Firstly, it took me ages to set up my own spreadsheet documents on my Google but I eventually got it done.

Next, I followed the instruction and saw that the author of the project has a set-up script. Wonderful! I proceed to run the script and...
bash: python3: command not found
I  was confused at first since I am 100% sure I installed Python 3 and it worked perfectly until now. I soon realize that I usually run Python as python instead of  python3. I also did some Googling and found that it is common for Windows users to do python and Linux to do python3 since python3 is not defined on a Windows system. So I went into the set-up script and replaced all instances of python to python3. I retried the script.

This time, everything went through and installed successfully. However, when I tested the app with a simple use case: Inserting a transaction, I got this error:
  File "createtoken.py", line 1, in <module>
    from oauth2client import file, client, tools
ModuleNotFoundError: No module named 'oauth2client'
So apparently, a module called outh2client was not installed. This is strange since I tried to install it separately after that with pip install oauth2client, it said the requirement has already been met. I brought the error up with the project author but got no luck except he told me to test installing them alone with the command pip3 install oauth2client.

The command he gave suggested one thing to me because of the "pip3" instead of "pip" which I usually use. I continued to go back to the set-up script and checked for all instances of "pip3" to replace with "pip". After creating a new virtual environment and running the set-up script again, I tested the tool for a second time and... I was so happy that it worked!

Reading Code

After testing out some commands of the app, I have a good general idea of what the app can do. Now, I need to know how it does that. Since the app is fairly simple and small, I decided to read into their code.

My first impression with the codebase was that everything is in one file __main__.py. I think this is quite a bad practice but a friend reminded me that it is not my place or time to change this and for that, I need to file a new issue. I would leave it there for now.

While going through the code, I actually learnt a lot more about Python:

Specialized Container Types

The author of the project used some strange keyword, at first, that later on, I found out that they are adaptive specialized containers in Python. The 2 I learnt about are:
  • OrderedDict: A Dictionary that remembers the order of entries insertion
MONTH_COLS = OrderedDict([
    ('Jan','D'), ('Feb','E'), ('Mar','F'), ('Apr','G'), ('May','H'), ('Jun','I'),
    ('Jul','J'), ('Aug','K'), ('Sep','L'), ('Oct','M'), ('Nov','N'), ('Dec','O')
])
  • namedtuple(): Return a subclass of Tuple with named field
Categories = namedtuple('Categories', 'expense, income')
Summary = namedtuple('Summary', 'cells, title, categories')

Returning Multiple Values in return Statement

This is one reason that is going to makes me speak highly of Python from now on. How amazing it is that returning multiple values from a function is not a painful thing anymore. I remember in C++, doing it requires so many "workarounds".

This is a code snippet in the project to interpret the user's arguments passed in the app:
# reads program arguments
def readArgs():
    if sys.argv[1] not in COMMAND_SET:
        raise UserWarning("Invalid command. Valid commands are:\n{0}".format(COMMAND_SET))
    elif sys.argv[1] in ('income', 'expense'):
        if len(sys.argv) != 3:
            raise UserWarning("Missing transaction parameters for '{0}' command.".format(sys.argv[1]))
        return sys.argv[1], sys.argv[2]
    else:
        if len(sys.argv) == 3:
            month = sys.argv[2].lower()
            if month not in [m.lower() for m in MONTH_COLS.keys()]:
                raiseInvalidMonthError(month)
        else:
            month = datetime.now().strftime("%b").lower()
        return sys.argv[1], month
If you pay attention to the bolded lines, you will see that the return statements are receiving 2  values at once. And of course, you can just add more comma to return more values.

And receiving returned values from this function is also super simple that you also only need to add more comma:
command, param = readArgs()
Compare to all languages I know, even JavaScript, Python has the most concise and elegant way to handle multiple returned values.

Slicing a Sequence

Last but not least, in Python, a sequence such as a list or a string can be sliced with a very simple syntax while this operation in other languages requires designated methods such as slice() or splice() in JavaScript.

The following example is from the project. It sets the first element in the list object named "transaction" (which is a string originally) to take only its first 10 characters:
transaction[0] = str(transaction[0])[:10]

Coding Tasks

As I got a fair understanding of the code structure and functionality, I start my coding work by orienting my approaching. According to the author's suggestion...



I see that my first job is to set up the line number in the budget log command. I easily tracked down the logic where all transactions are printed to a single function thanks to the careful comments of the author. Then, I used a counter to display the line index in an extra column.

# prints entries on the terminal as a table
def logTransactions(entries, header):
    index = 1
    printHeader(header, 79)
    for rows in entries:
        print("{0:>4s} {1:>12s} {2:>12s}    {3:<35s} {4:<15s}".format(str(index), rows[0], rows[1], rows[2], rows[3]))
        index += 1
I tested and it works as I wanted it to be:
$ budget log

Oct Expense Log
===============================================================================      
   1   08/09/2000    $1,500.00    Rent                                Home
   2   13/10/2019       $35.00    Groceries                           Home
   3   13/10/2019       $12.00    Burger                              Food
   4   14/10/2019        $3.50    Fries                               Food
   5   14/10/2019        $5.00    Pizza                               Food

Oct Income Log
===============================================================================      
   1   08/09/2000      $500.00    Salary                              Paycheck       
   2   13/10/2019       $75.00    Tax Return                          Other
   3   13/10/2019      $100.00    Scholarship                         Bonus
   4   12/10/2019      $250.00    Work Bonus                          Bonus
Nice! Now, come to the editing transaction part, I follow the pattern of the author for the inserting income and expense feature, start by adding the new command into the command set...
COMMAND_SET = ['summary', 'categories', 'log', 'sync', 'expense', 'income', 'edit']
followed by adding code to parse command-line arguments for the new command.
# reads program arguments
def readArgs():
    if sys.argv[1] not in COMMAND_SET:
        raise UserWarning("Invalid command. Valid commands are:\n{0}".format(COMMAND_SET))
    # ...
    elif sys.argv[1] == "edit":
        if sys.argv[2] not in ('income', 'expense'):
            raise UserWarning("Invalid command. Edit command has to have 'expense' or 'income' as 3rd argument.")
        if len(sys.argv) != 5:
            raise UserWarning("Too many or too few argument provided. Exactly 5 arguments are required for edit command.")
        return sys.argv[1], (sys.argv[2], sys.argv[3], sys.argv[4])
    # ...
Notice that I returned a bunch of things in the return statement. However, technically, there are only 2 things got returned: 1) The 1st user's argument which is the actual command keyword (i.e.: edit) and  2) a tuple containing the 2nd, 3rd and 4th user' arguments which are, respectively, a keyword indicating the transaction type: 'expense' or 'income', a line number and a new transaction.

These retuned values are received in the main function by 2 variables which param is going to be, actually, a tuple:
def main():
    # ...
    command, param = readArgs()
    # ...
I continue by adding a new function to handle changing the content on the spreadsheet by interacting with the Google Spreadsheet API. Fortunately, the part of "writing to spreadsheet" has been coded by the author before, I only need to worry about writing data to the right place:
# edits an existing income/expense transaction in monthly budget spreadsheet
def editTransaction(lineIndex, newTransaction, service, subcommand, monthlySheetId, title):
    rowIdx = FIRST_TRANSACTION_ROW + lineIndex - 1
    startCol = "B" if subcommand == 'expense' else "G"
    endCol = "E" if subcommand == 'expense' else "J"
    rangeName = "Transactions!" + startCol + str(rowIdx) + ":" + endCol + str(rowIdx)
    writeCells(service, monthlySheetId, rangeName, [newTransaction])
    print('Transaction edited in {0} budget:\n{1}'.format(title, newTransaction))
Oh, and not just the right place, I also need to make sure the right data (and data shape) is written to that place. I do this by adding logic in the main function to process an edit command, which based on the pattern the author has been using:
def main():
    try:
        sheetIds = readConfig()
        command, param = readArgs()
        service = getSheetService()
        # ...
        if command == "edit":
            subcommand = param[0]
            lineIndex = int(param[1])
            transaction = parseTransaction(param[2])
            monthlySheetId = getMonthlySheetId(transaction[0], sheetIds)
            transactions = readTransactions(service, monthlySheetId, subcommand)
            validateLineIndex(lineIndex, transactions)
            summary = readSummaryPage(service, monthlySheetId)
            categories = summary.categories.expense if subcommand == 'expense' else summary.categories.income
            validate(transaction, categories)
            transaction[0] = str(transaction[0])[:10]
            editTransaction(lineIndex, transaction, service, subcommand, monthlySheetId, summary.title)
            return
        # ...
    except UserWarning as e:
        print(str(e), file=sys.stderr)
Notice that I retrieved data from param as a tuple since that is what I returned in the readArgs() function. Besides that, since I want to avoid users passing in invalid line index and corrupting their spreadsheet, I added another function to validate the line index and called it in the main:
# raises a UserWarning if the line index is invalid
def validateLineIndex(lineIndex, transactions):
    if lineIndex not in range(1, len(transactions) + 1):
        raise UserWarning("Line index of {0} is invalid.".format(lineIndex))
With the changes I made, I believed that it was sufficient enough for the new feature. I went ahead and tested the newly created command. Everything went well! Hooray!

However, as I took a closer look, there is a small issue. When I ran the edit command with a transaction without an explicit date, the current date was assumed and replaced the original date. This is an unexpected behavior for the users since most people, in this case, will expect the program not to update the date.

The logic to add the date when an explicit one is not provided lies in the parseTransaction() function. I was hoping that I did not need to change this function since it is used quite a lot throughout the project so I was afraid that might break codes in many places. Turn out, I did not need to change is behavior!

I just need it to return an extra bit of information indicating if it added a date to the transaction or not. If it did, I just need to readjust the added date to the original date before writing the transaction to the spreadsheet! I achieved this by, again, using one of the most beautiful features of Python: returning multiple values from a function.
# parses transaction fields & inserts a date field (today) if not specified
def parseTransaction(params):
    transaction = [e.strip() for e in params.split(',')]
    noExplicitDate = len(transaction) is NUM_TRANSACTION_FIELDS - 1
    if noExplicitDate:
        transaction.insert(0, datetime.now())
    if len(transaction) != NUM_TRANSACTION_FIELDS:
        raise UserWarning("Invalid number of fields in transaction.")
    try:
        if float(transaction[1]) <= 0 or float(transaction[1]) > 99999:
            raise ValueError()
    except ValueError:
            raise UserWarning("Invalid transaction amount: {0}".format(transaction[1]))
    return transaction, noExplicitDate
With this modification, I adjust the logic in main function to match my intent:
def main():
    try:
        sheetIds = readConfig()
        command, param = readArgs()
        service = getSheetService()
        # ...
        if command == "edit":
            subcommand = param[0]
            lineIndex = int(param[1])
            transaction, noExplicitDate = parseTransaction(param[2])
            monthlySheetId = getMonthlySheetId(transaction[0], sheetIds)
            transactions = readTransactions(service, monthlySheetId, subcommand)
            validateLineIndex(lineIndex, transactions)
            if noExplicitDate is True:
                print("Only 3 fields were specified. Assigning original date to date field.")
                transaction[0] = transactions[lineIndex - 1][0]
            summary = readSummaryPage(service, monthlySheetId)
            validateCategory(subcommand, transaction, summary)
            transaction[0] = str(transaction[0])[:10]
            editTransaction(lineIndex, transaction, service, subcommand, monthlySheetId, summary.title)
            return
        # ...
    except UserWarning as e:
        print(str(e), file=sys.stderr)
I tested the improvement and it worked!

Demo for the new feature: Edit Transaction

I proceed to make a PR which later on got accepted. I am glad that I got a chance to work on this project. I  am still now a contributor to it and I have been suggesting 2 new features which I think is going to be useful:
  1. Capability to delete a transaction
  2. Adding transactions in batch through a text file
I am looking forward to contributing more!

Plan for the next and last PR

I have given some thought into this and based on the facts that...
  • I technically have 4 PRs done but I still want an extra
  • I have quite a long time left for Hacktoberfest (roughly 11 days)
  • I want to challenge myself with a bigger project
  • A friend encouraged me to do the one above
I am aiming to find an issue in a large, well-known project and spend time on solving it. I see that it can be challenging but... hey, I 99% got my T-shirt and nothing else to lose :)

See you again in later posts!

Comments

Popular posts from this blog

discord.js - A powerful library for interacting with Discord API

Release 0.2a: #good-first-issue - Make web app work offline!

My Lightweight Noteboard 1.0