1
\$\begingroup\$

Code is posted after explanation.

Due to the size of the project, this is being posted in three separate posts. This also ensures each post is more focused.

This is a follow-up to an earlier version of the same project. The feedback from the last round is tracked in an issue.


What is this?

This application helps you calculate monthly newspaper bills. The goal is to generate a message that I can paste into WhatsApp and send to my newspaper vendor. The end result here is a CLI tool that will be later used as a back-end to build GUIs (hence learn about: C#, HTML/CSS/JS, Flutter). In its current form, everything will be "compiled" by PyInstaller into one-file stand-alone executables for the end-user using GitHub Actions.

The other important goal was to be a testbed for learning a bunch of new tools: more Python libraries, SQL connectors, GitHub Actions (CI/CD, if I understand correctly), unit tests, CLI libraries, type-hinting, regex. I had earlier built this on a different platform, so I now have a solid idea of how this application is used.

Key concepts

  1. Each newspaper has a certain cost per day of the week
  2. Each newspaper may or may not be delivered on a given day
  3. Each newspaper has a name, and a number called a key
  4. You may register any dates when you didn't receive a paper in advance using the addudl command
  5. Once you calculate, the results are displayed and logged.

What files exist?

(ignoring conventional ones like README and requirements.txt)

File Purpose/Description Review
npbc_core.py Provide the core functionality: the calculation, parsing and validation of user input, interaction with the DB etc. Later on, some functionality from this will be extracted to create server-side code that can service more users, but I have to learn a lot more before getting there. Please review this.
npbc_regex.py Contains all the regex statements used to validate and parse user input. Please review this.
npbc_exceptions.py Defines classes for all the custom exceptions used by the core and the CLI. Please review this.
npbc_cli.py Import functionality from npbc_core.py and wrap a CLI layer on it using argparse. Also provide some additional validation. Please review this.
npbc_updater.py Provide a utility to update the application on the user's end. Don't bother reviewing this (code not included).
test_core.py Test the functionality of the core file (pytest), except anything to do with the database. Please review this.
test_db.py Test the functionality of the core file (pytest), for anything to do with the database. Please review this.
test_regex.py Test the functionality of the regex statements. Please review this.
data/schema.sql Database schema. In my local environment, the data folder also has a test database file (but I don't want to upload this online). Please review this if you can (not high priority).
data/test.sql SQL statements to generate test data for test_db.py. Please review this if you can (not high priority).

npbc_cli.py

"""
wraps a CLI around the core functionality (using argparse)
- inherits functionality from `npbc_core.py`
- inherits regex from `npbc_regex.py`, used for validation
- inherits exceptions from `npbc_exceptions.py`, used for error handling
- performs some additional validation
- formats data retrieved from the core for the user
"""


import sqlite3
from argparse import ArgumentParser
from argparse import Namespace as ArgNamespace
from datetime import datetime
from sys import argv
from typing import Generator

from colorama import Fore, Style

import npbc_core
import npbc_exceptions
from npbc_regex import DELIVERY_MATCH_REGEX


def define_and_read_args(arguments: list[str]) -> ArgNamespace:
    """configure parsers
    - define the main parser for the application executable
    - define subparsers (one for each functionality)
    - parse the arguments"""

    # main parser for all commands
    main_parser = ArgumentParser(
        prog="npbc",
        description="Calculates your monthly newspaper bill."
    )
    functions = main_parser.add_subparsers(required=True)


    # calculate subparser
    calculate_parser = functions.add_parser(
        'calculate',
        help="Calculate the bill for one month. Previous month will be used if month or year flags are not set."
    )

    calculate_parser.set_defaults(func=calculate)
    calculate_parser.add_argument('-m', '--month', type=int, help="Month to calculate bill for. Must be between 1 and 12.")
    calculate_parser.add_argument('-y', '--year', type=int, help="Year to calculate bill for. Must be greater than 0.")
    calculate_parser.add_argument('-l', '--nolog', help="Don't log the result of the calculation.", action='store_true')


    # add undelivered string subparser
    addudl_parser = functions.add_parser(
        'addudl',
        help="Store a date when paper(s) were not delivered. Current month will be used if month or year flags are not set. Either paper ID must be provided, or the all flag must be set."
    )

    addudl_parser.set_defaults(func=addudl)
    addudl_parser.add_argument('-m', '--month', type=int, help="Month to register undelivered incident(s) for. Must be between 1 and 12.")
    addudl_parser.add_argument('-y', '--year', type=int, help="Year to register undelivered incident(s) for. Must be greater than 0.")
    addudl_parser.add_argument('-p', '--paperid', type=str, help="ID of paper to register undelivered incident(s) for.")
    addudl_parser.add_argument('-a', '--all', help="Register undelivered incidents for all papers.", action='store_true')
    addudl_parser.add_argument('-s', '--strings', type=str, help="Dates when you did not receive any papers.", required=True, nargs='+')


    # delete undelivered string subparser
    deludl_parser = functions.add_parser(
        'deludl',
        help="Delete a stored date when paper(s) were not delivered. If no parameters are provided, the function will not default; it will throw an error instead."
    )

    deludl_parser.set_defaults(func=deludl)
    deludl_parser.add_argument('-p', '--paperid', type=str, help="ID of paper to unregister undelivered incident(s) for.")
    deludl_parser.add_argument('-i', '--stringid', type=str, help="String ID of paper to unregister undelivered incident(s) for.")
    deludl_parser.add_argument('-m', '--month', type=int, help="Month to unregister undelivered incident(s) for. Must be between 1 and 12.")
    deludl_parser.add_argument('-y', '--year', type=int, help="Year to unregister undelivered incident(s) for. Must be greater than 0.")
    deludl_parser.add_argument('-s', '--string', type=str, help="Dates when you did not receive any papers.")


    # get undelivered string subparser
    getudl_parser = functions.add_parser(
        'getudl',
        help="Get a list of all stored date strings when paper(s) were not delivered. All parameters are optional and act as filters."
    )

    getudl_parser.set_defaults(func=getudl)
    getudl_parser.add_argument('-p', '--paperid', type=str, help="ID for paper.")
    getudl_parser.add_argument('-i', '--stringid', type=str, help="String ID of paper to unregister undelivered incident(s) for.")
    getudl_parser.add_argument('-m', '--month', type=int, help="Month. Must be between 1 and 12.")
    getudl_parser.add_argument('-y', '--year', type=int, help="Year. Must be greater than 0.")
    getudl_parser.add_argument('-s', '--string', type=str, help="Dates when you did not receive any papers.")


    # edit paper subparser
    editpaper_parser = functions.add_parser(
        'editpaper',
        help="Edit a newspaper's name, days delivered, and/or price."
    )

    editpaper_parser.set_defaults(func=editpaper)
    editpaper_parser.add_argument('-n', '--name', type=str, help="Name for paper to be edited.")
    editpaper_parser.add_argument('-d', '--delivered', type=str, help="Number of days the paper to be edited is delivered. All seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't. No separator required.")
    editpaper_parser.add_argument('-c', '--costs', type=str, help="Daywise prices of paper to be edited. 0s are ignored.", nargs='*')
    editpaper_parser.add_argument('-p', '--paperid', type=str, help="ID for paper to be edited.", required=True)


    # add paper subparser
    addpaper_parser = functions.add_parser(
        'addpaper',
        help="Add a new newspaper to the list of newspapers."
    )

    addpaper_parser.set_defaults(func=addpaper)
    addpaper_parser.add_argument('-n', '--name', type=str, help="Name for paper to be added.", required=True)
    addpaper_parser.add_argument('-d', '--delivered', type=str, help="Number of days the paper to be added is delivered. All seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't. No separator required.", required=True)
    addpaper_parser.add_argument('-c', '--costs', type=str, help="Daywise prices of paper to be added. 0s are ignored.", required=True, nargs='+')


    # delete paper subparser
    delpaper_parser = functions.add_parser(
        'delpaper',
        help="Delete a newspaper from the list of newspapers."
    )

    delpaper_parser.set_defaults(func=delpaper)
    delpaper_parser.add_argument('-p', '--paperid', type=str, help="ID for paper to be deleted.", required=True)

    # get paper subparser
    getpapers_parser = functions.add_parser(
        'getpapers',
        help="Get all newspapers."
    )

    getpapers_parser.set_defaults(func=getpapers)
    getpapers_parser.add_argument('-n', '--names', help="Get the names of the newspapers.", action='store_true')
    getpapers_parser.add_argument('-d', '--delivered', help="Get the days the newspapers are delivered. All seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't.", action='store_true')
    getpapers_parser.add_argument('-c', '--cost', help="Get the daywise prices of the newspapers. Values must be separated by semicolons.", action='store_true')

    # get undelivered logs subparser
    getlogs_parser = functions.add_parser(
        'getlogs',
        help="Get the log of all undelivered dates."
    )

    getlogs_parser.set_defaults(func=getlogs)
    getlogs_parser.add_argument('-i', '--logid', type=int, help="ID for log to be retrieved.")
    getlogs_parser.add_argument('-p', '--paperid', type=str, help="ID for paper.")
    getlogs_parser.add_argument('-m', '--month', type=int, help="Month. Must be between 1 and 12.")
    getlogs_parser.add_argument('-y', '--year', type=int, help="Year. Must be greater than 0.")
    getlogs_parser.add_argument('-t' , '--timestamp', type=str, help="Timestamp. Must be in the format dd/mm/yyyy hh:mm:ss AM/PM.")


    # update application subparser
    update_parser = functions.add_parser(
        'update',
        help="Update the application."
    )

    update_parser.set_defaults(func=update)


    return main_parser.parse_args(arguments)


def status_print(status: bool, message: str) -> None:
    """
    print out a coloured status message using Colorama
    - if the status is True, print in green (success)
    - if the status is False, print in red (failure)
    """

    if status:
        print(f"{Fore.GREEN}", end="")
    else:
        print(f"{Fore.RED}", end="")

    print(f"{Style.BRIGHT}{message}{Style.RESET_ALL}\n")


def calculate(parsed_arguments: ArgNamespace) -> None:
    """calculate the cost for a given month and year
    - default to the previous month if no month and no year is given
    - default to the current month if no month is given and year is given
    - default to the current year if no year is given and month is given"""

    ## deal with month and year

    # if either of them are given
    if parsed_arguments.month or parsed_arguments.year:

        # validate them
        try:
            npbc_core.validate_month_and_year(parsed_arguments.month, parsed_arguments.year)
        
        except npbc_exceptions.InvalidMonthYear:
            status_print(False, "Invalid month and/or year.")
            return

        # for each, if it is not given, set it to the current month and/or year
        month = parsed_arguments.month or datetime.now().month
        year = parsed_arguments.year or datetime.now().year

    # if neither are given
    else:

        # set them to the previous month and year
        previous_month = npbc_core.get_previous_month()
        month = previous_month.month
        year = previous_month.year

    # prepare a dictionary for undelivered strings
    undelivered_strings = {
        int(paper_id): []
        for paper_id, _, _, _, _ in npbc_core.get_papers()
    }

    # get the undelivered strings from the database
    try:
        raw_undelivered_strings = npbc_core.get_undelivered_strings(month=month, year=year)

        # add them to the dictionary
        for _, paper_id, _, _, string in raw_undelivered_strings:
            undelivered_strings[paper_id].append(string)

    # ignore if none exist
    except npbc_exceptions.StringNotExists:
        pass
    
    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error: {e}\nPlease report this to the developer.")
        return

    try:
        # calculate the cost for each paper
        costs, total, undelivered_dates = npbc_core.calculate_cost_of_all_papers(
            undelivered_strings,
            month,
            year
        )
    
    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error: {e}\nPlease report this to the developer.")
        return

    # format the results
    formatted = '\n'.join(npbc_core.format_output(costs, total, month, year))

    # unless the user specifies so, log the results to the database
    if not parsed_arguments.nolog:
        try:
            npbc_core.save_results(costs, undelivered_dates, month, year)

        # if there is a database error, print an error message
        except sqlite3.DatabaseError as e:
            status_print(False, f"Database error: {e}\nPlease report this to the developer.")
            return

        formatted += '\n\nLog saved to file.'

    # print the results
    status_print(True, "Success!")
    print(f"SUMMARY:\n\n{formatted}")


def addudl(parsed_arguments: ArgNamespace) -> None:
    """add undelivered strings to the database
    - default to the current month if no month and/or no year is given"""

    # validate the month and year
    try:
        npbc_core.validate_month_and_year(parsed_arguments.month, parsed_arguments.year)

    # if they are invalid, print an error message
    except npbc_exceptions.InvalidMonthYear:
        status_print(False, "Invalid month and/or year.")
        return

    ## deal with month and year
    # for any that are not given, set them to the current month and year
    month = parsed_arguments.month or datetime.now().month
    year = parsed_arguments.year or datetime.now().year

    # if the user either specifies a specific paper or specifies all papers
    if parsed_arguments.paperid or parsed_arguments.all:

        # attempt to add the strings to the database
        try:
            npbc_core.add_undelivered_string(month, year, parsed_arguments.paperid, *parsed_arguments.strings)

        # if the paper doesn't exist, print an error message
        except npbc_exceptions.PaperNotExists:
            status_print(False, f"Paper with ID {parsed_arguments.paperid} does not exist.")
            return

        # if the string was invalid, print an error message
        except npbc_exceptions.InvalidUndeliveredString:
            status_print(False, "Invalid undelivered string(s).")
            return
    
        # if there is a database error, print an error message
        except sqlite3.DatabaseError as e:
            status_print(False, f"Database error: {e}\nPlease report this to the developer.")
            return

    # if no paper is specified, print an error message
    else:
        status_print(False, "No paper(s) specified.")
        return

    status_print(True, "Success!")


def deludl(parsed_arguments: ArgNamespace) -> None:
    """delete undelivered strings from the database"""

    # validate the month and year
    try:
        npbc_core.validate_month_and_year(parsed_arguments.month, parsed_arguments.year)

    # if they are invalid, print an error message
    except npbc_exceptions.InvalidMonthYear:
        status_print(False, "Invalid month and/or year.")
        return

    # attempt to delete the strings from the database
    try:
        npbc_core.delete_undelivered_string(
            month=parsed_arguments.month,
            year=parsed_arguments.year,
            paper_id=parsed_arguments.paperid,
            string=parsed_arguments.string,
            string_id=parsed_arguments.stringid
        )

    # if no parameters are given, print an error message
    except npbc_exceptions.NoParameters:
        status_print(False, "No parameters specified.")
        return

    # if the string doesn't exist, print an error message
    except npbc_exceptions.StringNotExists:
        status_print(False, "String does not exist.")
        return
    
    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error: {e}\nPlease report this to the developer.")
        return
        
    status_print(True, "Success!")


def getudl(parsed_arguments: ArgNamespace) -> None:
    """get undelivered strings from the database
    filter by whichever parameter the user provides. they as many as they want.
    available parameters: month, year, paper_id, string_id, string"""

    # validate the month and year
    try:
        npbc_core.validate_month_and_year(parsed_arguments.month, parsed_arguments.year)

    # if they are invalid, print an error message
    except npbc_exceptions.InvalidMonthYear:
        status_print(False, "Invalid month and/or year.")
        return

    # attempt to get the strings from the database
    try:
        undelivered_strings = npbc_core.get_undelivered_strings(
            month=parsed_arguments.month,
            year=parsed_arguments.year,
            paper_id=parsed_arguments.paperid,
            string_id=parsed_arguments.stringid,
            string=parsed_arguments.string
        )

    # if the string doesn't exist, print an error message
    except npbc_exceptions.StringNotExists:
        status_print(False, "No strings found for the given parameters.")
        return

    status_print(True, "Success!")

    ## format the results

    # print the column headers
    print(f"{Fore.YELLOW}string_id{Style.RESET_ALL} | {Fore.YELLOW}paper_id{Style.RESET_ALL} | {Fore.YELLOW}year{Style.RESET_ALL} | {Fore.YELLOW}month{Style.RESET_ALL} | {Fore.YELLOW}string{Style.RESET_ALL}")

    # print the strings
    for items in undelivered_strings:
        print(', '.join([str(item) for item in items]))


def extract_delivery_from_user_input(input_delivery: str) -> list[bool]:
    """convert the /[YN]{7}/ user input to a Boolean list"""

    if not DELIVERY_MATCH_REGEX.match(input_delivery):
        raise npbc_exceptions.InvalidInput("Invalid delivery days.")

    return list(map(lambda x: x == 'Y', input_delivery))


def extract_costs_from_user_input(paper_id: int | None, delivery_data: list[bool] | None, *input_costs: float) -> Generator[float, None, None]:
    """convert the user input to a float list"""

    # filter the data to remove zeros
    suspected_data = [
        cost
        for cost in input_costs
        if cost != 0
    ]

    # reverse it so that we can pop to get the first element first (FILO to FIFO)
    suspected_data.reverse()

    # if the delivery data is given, use it
    if delivery_data:

        # if the number of days the paper is delivered is not equal to the number of costs, raise an error
        if (len(suspected_data) != delivery_data.count(True)):
            raise npbc_exceptions.InvalidInput("Number of costs don't match number of days delivered.")

        # for each day, yield the cost if it is delivered or 0 if it is not
        for day in delivery_data:
            yield suspected_data.pop() if day else 0

    # if the delivery data is not given, but the paper ID is given, get the delivery data from the database
    elif paper_id:
        
        # get the delivery data from the database, and filter for the paper ID
        try:
            raw_data = [paper for paper in npbc_core.get_papers() if paper[0] == int(paper_id)]
    
        # if there is a database error, print an error message
        except sqlite3.DatabaseError as e:
            status_print(False, f"Database error: {e}\nPlease report this to the developer.")
            return

        raw_data.sort(key=lambda paper: paper[2])

        # extract the data from the database
        delivered = [
            bool(delivered)
            for _, _, _, delivered, _ in raw_data
        ]

        # if the number of days the paper is delivered is not equal to the number of costs, raise an error
        if len(suspected_data) != delivered.count(True):
            raise npbc_exceptions.InvalidInput("Number of costs don't match number of days delivered.")

        # for each day, yield the cost if it is delivered or 0 if it is not
        for day in delivered:
            yield suspected_data.pop() if day else 0

    else:
        raise npbc_exceptions.InvalidInput("Neither delivery data nor paper ID given.")


def editpaper(parsed_arguments: ArgNamespace) -> None:
    """edit a paper's information"""


    try:

        # attempt to get the delivery data. if it's not given, set it to None
        delivery_data = extract_delivery_from_user_input(parsed_arguments.delivered) if parsed_arguments.delivered else None

        # attempt to edit the paper. if costs are given, use them, else use None
        npbc_core.edit_existing_paper(
            paper_id=parsed_arguments.paperid,
            name=parsed_arguments.name,
            days_delivered=delivery_data,
            days_cost=list(extract_costs_from_user_input(parsed_arguments.paperid, delivery_data, *parsed_arguments.costs)) if parsed_arguments.costs else None
        )

    # if the paper doesn't exist, print an error message
    except npbc_exceptions.PaperNotExists:
        status_print(False, "Paper does not exist.")
        return

    # if some input is invalid, print an error message
    except npbc_exceptions.InvalidInput as e:
        status_print(False, f"Invalid input: {e}")
        return
    
    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error: {e}\nPlease report this to the developer.")
        return

    status_print(True, "Success!")


def addpaper(parsed_arguments: ArgNamespace) -> None:
    """add a new paper to the database"""

    try:
        # attempt to get the delivery data
        delivery_data = extract_delivery_from_user_input(parsed_arguments.delivered)

        # attempt to add the paper.
        npbc_core.add_new_paper(
            name=parsed_arguments.name,
            days_delivered=delivery_data,
            days_cost=list(extract_costs_from_user_input(None, delivery_data, *parsed_arguments.costs))
        )

    # if the paper already exists, print an error message
    except npbc_exceptions.PaperAlreadyExists:
        status_print(False, "Paper already exists.")
        return
    
    # if some input is invalid, print an error message
    except npbc_exceptions.InvalidInput as e:
        status_print(False, f"Invalid input: {e}")
        return
    
    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error: {e}\nPlease report this to the developer.")
        return

    status_print(True, "Success!")


def delpaper(parsed_arguments: ArgNamespace) -> None:
    """delete a paper from the database"""

    # attempt to delete the paper
    try:
        npbc_core.delete_existing_paper(parsed_arguments.paperid)

    # if the paper doesn't exist, print an error message
    except npbc_exceptions.PaperNotExists:
        status_print(False, "Paper does not exist.")
        return
    
    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error: {e}\nPlease report this to the developer.")
        return

    status_print(True, "Success!")


def getpapers(parsed_arguments: ArgNamespace) -> None:
    """get a list of all papers in the database
    - filter by whichever parameter the user provides. they may use as many as they want (but keys are always printed)
    - available parameters: name, days, costs
    - the output is provided as a formatted table, printed to the standard output"""

    # get the papers from the database
    try:
        raw_data = npbc_core.get_papers()

    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error: {e}\nPlease report this to the developer.")
        return

    # initialize lists for column headers and paper IDs
    headers = ['paper_id']
    ids = []

    # extract paperr IDs
    ids = list(set(paper[0] for paper in raw_data))
    ids.sort()
    
    # initialize lists for the data, based on the number of paper IDs
    delivery = [None for _ in ids]
    costs = [None for _ in ids]
    names = [None for _ in ids]

    # if the user wants the name, add it to the headers and the data to the list
    if parsed_arguments.names:
        headers.append('name')

        names = list(set(
            (paper_id, name)
            for paper_id, name, _, _, _ in raw_data
        ))

        # sort the names by paper ID and extract the names only
        names.sort(key=lambda item: item[0])
        names = [name for _, name in names]

    # if the user wants the delivery data or the costs, get the data about days
    if parsed_arguments.delivered or parsed_arguments.cost:

        # initialize a dictionary for the days of each paper
        days = {
            paper_id: {}
            for paper_id in ids
        }

        # for each paper, add the days to the dictionary
        for paper_id, _, day_id, _, _ in raw_data:
            days[paper_id][day_id] = {}
        
        # for each paper, add the costs and delivery data to the dictionary
        for paper_id, _, day_id, day_delivery, day_cost in raw_data:
            days[paper_id][day_id]['delivery'] = day_delivery
            days[paper_id][day_id]['cost'] = day_cost

        # if the user wants the delivery data, add it to the headers and the data to the list
        if parsed_arguments.delivered:
            headers.append('days')

            # convert the data to the /[YN]{7}/ format the user is used to
            delivery = [
                ''.join([
                    'Y' if days[paper_id][day_id]['delivery'] else 'N'
                    for day_id, _ in enumerate(npbc_core.WEEKDAY_NAMES)
                ])
                for paper_id in ids
            ]

        # if the user wants the costs, add it to the headers and the data to the list
        if parsed_arguments.cost:
            headers.append('costs')

            # convert the data to the /x(;x){0,6}/ where x is a floating point number format the user is used to
            costs = [
                ';'.join([
                    str(days[paper_id][day_id]['cost'])
                    for day_id, _ in enumerate(npbc_core.WEEKDAY_NAMES)
                    if days[paper_id][day_id]['cost'] != 0
                ])
                for paper_id in ids
            ]

    # print the headers
    print(' | '.join([
        f"{Fore.YELLOW}{header}{Style.RESET_ALL}"
        for header in headers
    ]))

    # print the data
    for paper_id, name, delivered, cost in zip(ids, names, delivery, costs):
        print(paper_id, end='')

        if parsed_arguments.names:
            print(f", {name}", end='')

        if parsed_arguments.delivered:
            print(f", {delivered}", end='')

        if parsed_arguments.cost:
            print(f", {cost}", end='')

        print()


def getlogs(parsed_arguments: ArgNamespace) -> None:
    """get a list of all logs in the database
    - filter by whichever parameter the user provides. they may use as many as they want (but log IDs are always printed)
    - available parameters: log_id, paper_id, month, year, timestamp
    - will return both date logs and cost logs"""

    # attempt to get the logs from the database
    try:
        data = npbc_core.get_logged_data(
            query_log_id = parsed_arguments.logid,
            query_paper_id=parsed_arguments.paperid,
            query_month=parsed_arguments.month,
            query_year=parsed_arguments.year,
            query_timestamp= datetime.strptime(parsed_arguments.timestamp, r'%d/%m/%Y %I:%M:%S %p') if parsed_arguments.timestamp else None
        )

    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error. Please report this to the developer.\n{e}")
        return

    # if there is a date format error, print an error message
    except ValueError:
        status_print(False, "Invalid date format. Please use the following format: dd/mm/yyyy hh:mm:ss AM/PM")
        return

    # print column headers
    print(' | '.join(
        f"{Fore.YELLOW}{header}{Style.RESET_ALL}"
        for header in ['log_id', 'paper_id', 'month', 'year', 'timestamp', 'date/cost']
    ))

    # print the data
    for row in data:
        print(', '.join(str(item) for item in row))


def update(parsed_arguments: ArgNamespace) -> None:
    """update the application
    - under normal operation, this function should never run
    - if the update CLI argument is provided, this script will never run and the updater will be run instead"""

    status_print(False, "Update failed.")


def main(arguments: list[str]) -> None:
    """main function
    - initialize the database
    - parses the command line arguments
    - calls the appropriate function based on the arguments"""

    # attempt to initialize the database
    try:
        npbc_core.setup_and_connect_DB()
    
    # if there is a database error, print an error message
    except sqlite3.DatabaseError as e:
        status_print(False, f"Database error: {e}\nPlease report this to the developer.")
        return

    # parse the command line arguments
    parsed_namespace = define_and_read_args(arguments)

    # execute the appropriate function
    parsed_namespace.func(parsed_namespace)


if __name__ == "__main__":
    main(argv[1:])

Relevant section of test_db.py

from pathlib import Path
import npbc_cli

ACTIVE_DIRECTORY = Path("data")
DATABASE_PATH = ACTIVE_DIRECTORY / "npbc.db"

def test_db_creation():
    DATABASE_PATH.unlink(missing_ok=True)
    assert not DATABASE_PATH.exists()

    try:
        npbc_cli.main([])

    except SystemExit:
        pass

    assert DATABASE_PATH.exists()

If you need it, here is a link to the GitHub repo for this project. It's at the same commit as the code above, and I won't edit this so that any discussion is consistent.

https://github.com/eccentricOrange/npbc/tree/041a949bbba018531ec590a2193523f1530659aa

\$\endgroup\$

1 Answer 1

0
\$\begingroup\$

This print:

print(f"{Fore.GREEN}", end="")

doesn't need the f-string; you can just print Fore.GREEN directly. However, that if can be refactored so that it uses a colour variable:

    if status:
        colour = Fore.GREEN
    else:
        colour = Fore.RED

    print(f"{colour}{Style.BRIGHT}{message}{Style.RESET_ALL}\n")

These two return tuples:

    for paper_id, _, _, _, _ in npbc_core.get_papers()

    for _, paper_id, _, _, string in raw_undelivered_strings:

are a little cumbersome. Instead consider simple named tuples so that you can access members instead of positions.

When you call status_print, it's probably best if you pass status by named argument. status is a little ambiguous and would be clearer as success.

Swallowing SystemExit seems like a really, really bad idea. Why is that there? If you just want to enforce that last assert regardless of what happens, then:

    try:
        npbc_cli.main([])
    finally:
        assert DATABASE_PATH.exists()
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.