2
\$\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.


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 copied to your clipboard

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_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). This isn't as exhaustive as I'd like, but it did a good job of capturing many of my mistakes. 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).

Known problems

  • Tests are not exhaustive (please suggest anything you think of).
  • Tests are not well commented (working on this right now in a local branch).
  • SQL injection is possible in some cases by -k/--key CLI parameters, if you can figure out a way to insert a semicolon in an integer. I will remove this in a future version, once I find a way to improve or remove the generate_sql_query() function.
  • A lot of documentation is tied up in the CLI UI and comments, and is not an explicit document.

npbc_core.py

from sqlite3 import connect
from calendar import day_name as weekday_names_iterable
from calendar import monthrange, monthcalendar
from datetime import date as date_type, datetime, timedelta
from pathlib import Path
from re import compile as compile_regex

## paths for the folder containing schema and database files
 # during normal use, the DB will be in ~/.npbc (where ~ is the user's home directory) and the schema will be bundled with the executable
 # during development, the DB and schema will both be in "data"

DATABASE_DIR = Path().home() / '.npbc'  # normal use path
# DATABASE_DIR = Path('data')  # development path

DATABASE_PATH = DATABASE_DIR / 'npbc.db'

SCHEMA_PATH = Path(__file__).parent / 'schema.sql'  # normal use path
# SCHEMA_PATH = DATABASE_DIR / 'schema.sql'  # development path


## list constant for names of weekdays
WEEKDAY_NAMES = list(weekday_names_iterable)


## regex for validating user input
VALIDATE_REGEX = {
    # match for a list of comma separated values. each value must be/contain digits, or letters, or hyphens. spaces are allowed between values and commas. any number of values are allowed, but at least one must be present.
    'CSVs': compile_regex(r'^[-\w]+( *, *[-\w]+)*( *,)?$'),

    # match for a single number. must be one or two digits
    'number': compile_regex(r'^[\d]{1,2}?$'),

    # match for a range of numbers. each number must be one or two digits. numbers are separated by a hyphen. spaces are allowed between numbers and the hyphen.
    'range': compile_regex(r'^\d{1,2} *- *\d{1,2}$'),

    # match for weekday name. day must appear as "daynames" (example: "mondays"). all lowercase.
    'days': compile_regex(f"^{'|'.join([day_name.lower() + 's' for day_name in WEEKDAY_NAMES])}$"),

    # match for nth weekday name. day must appear as "n-dayname" (example: "1-monday"). all lowercase. must be one digit.
    'n-day': compile_regex(f"^\\d *- *({'|'.join([day_name.lower() for day_name in WEEKDAY_NAMES])})$"),

    # match for real values, delimited by semicolons. each value must be either an integer or a float with a decimal point. spaces are allowed between values and semicolons, and up to 7 (but at least 1) values are allowed.
    'costs': compile_regex(r'^\d+(\.\d+)?( *; *\d+(\.\d+)?){0,6} *;?$'),

    # match for seven values, each of which must be a 'Y' or an 'N'. there are no delimiters.
    'delivery': compile_regex(r'^[YN]{7}$')
}

## regex for splitting strings
SPLIT_REGEX = {
    # split on hyphens. spaces are allowed between hyphens and values.
    'hyphen': compile_regex(r' *- *'),
    
    # split on semicolons. spaces are allowed between hyphens and values.
    'semicolon': compile_regex(r' *; *'),

    # split on commas. spaces are allowed between commas and values.
    'comma': compile_regex(r' *, *')
}


## ensure DB exists and it's set up with the schema
def setup_and_connect_DB() -> None:
    DATABASE_DIR.mkdir(parents=True, exist_ok=True)
    DATABASE_PATH.touch(exist_ok=True)

    with connect(DATABASE_PATH) as connection:
        connection.executescript(SCHEMA_PATH.read_text())
        connection.commit()


## generate a "SELECT" SQL query
 # use params to specify columns to select, and "WHERE" conditions
def generate_sql_query(table_name: str, conditions: dict[str, int | str] | None = None, columns: list[str] | None = None) -> str:
    sql_query = f"SELECT"
    
    if columns:
        sql_query += f" {', '.join(columns)}"
    
    else:
        sql_query += f" *"
    
    sql_query += f" FROM {table_name}"

    if conditions:
        conditions_segment = ' AND '.join([
            f"{parameter_name} = {parameter_value}"
            for parameter_name, parameter_value in conditions.items()
        ])
        
        sql_query += f" WHERE {conditions_segment}"

    return f"{sql_query};"


## execute a "SELECT" SQL query and return the results
def query_database(query: str) -> list[tuple]:
    with connect(DATABASE_PATH) as connection:
        return connection.execute(query).fetchall()
    
    return []


## generate a list of number of times each weekday occurs in a given month
 # the list will be in the same order as WEEKDAY_NAMES (so the first day should be Monday)
def get_number_of_days_per_week(month: int, year: int) -> list[int]:
    main_calendar = monthcalendar(year, month)
    number_of_weeks = len(main_calendar)
    number_of_weekdays = []

    for i, _ in enumerate(WEEKDAY_NAMES):
        number_of_weekday = number_of_weeks

        if main_calendar[0][i] == 0:
            number_of_weekday -= 1
        
        if main_calendar[-1][i] == 0:
            number_of_weekday -= 1

        number_of_weekdays.append(number_of_weekday)

    return number_of_weekdays


## validate a string that specifies when a given paper was not delivered
 # first check to see that it meets the comma-separated requirements
 # then check against each of the other acceptable patterns in the regex dictionary
def validate_undelivered_string(string: str) -> bool:
    if VALIDATE_REGEX['CSVs'].match(string):
        
        for section in SPLIT_REGEX['comma'].split(string.rstrip(',')):
            section_validity = False

            for pattern, regex in VALIDATE_REGEX.items():
                if (not section_validity) and (pattern not in ["CSVs", "costs", "delivery"]) and (regex.match(section)):
                    section_validity = True

            if not section_validity:
                return False
        
        return True
    
    return False


## parse a string that specifies when a given paper was not delivered
 # each CSV section states some set of dates
 # this function will return a set of dates that uniquely identifies each date mentioned across all the CSVs
def parse_undelivered_string(string: str, month: int, year: int) -> set[date_type]:
    dates = set()

    for section in SPLIT_REGEX['comma'].split(string.rstrip(',')):

        # if the date is simply a number, it's a single day. so we just identify that date
        if VALIDATE_REGEX['number'].match(section):
            date = int(section)

            if date > 0 and date <= monthrange(year, month)[1]:
                dates.add(date_type(year, month, date))

        # if the date is a range of numbers, it's a range of days. we identify all the dates in that range, bounds inclusive
        elif VALIDATE_REGEX['range'].match(section):
            start, end = [int(date) for date in SPLIT_REGEX['hyphen'].split(section)]

            if (0 < start) and (start <= end) and (end <= monthrange(year, month)[1]):
                dates.update(
                    date_type(year, month, day)
                    for day in range(start, end + 1)
                )

        # if the date is the plural of a weekday name, we identify all dates in that month which are the given weekday
        elif VALIDATE_REGEX['days'].match(section):
            weekday = WEEKDAY_NAMES.index(section.capitalize().rstrip('s'))

            dates.update(
                date_type(year, month, day)
                for day in range(1, monthrange(year, month)[1] + 1)
                if date_type(year, month, day).weekday() == weekday
            )

        # if the date is a number and a weekday name (singular), we identify the date that is the nth occurrence of the given weekday in the month
        elif VALIDATE_REGEX['n-day'].match(section):
            n, weekday = SPLIT_REGEX['hyphen'].split(section)

            n = int(n)

            if n > 0 and n <= get_number_of_days_per_week(month, year)[WEEKDAY_NAMES.index(weekday.capitalize())]:
                weekday = WEEKDAY_NAMES.index(weekday.capitalize())

                valid_dates = [
                    date_type(year, month, day)
                    for day in range(1, monthrange(year, month)[1] + 1)
                    if date_type(year, month, day).weekday() == weekday
                ]

                dates.add(valid_dates[n - 1])

        # bug report :)
        else:
            print("Congratulations! You broke the program!")
            print("You managed to write a string that the program considers valid, but isn't actually.")
            print("Please report it to the developer.")
            print(f"\nThe string you wrote was: {string}")
            print("This data has not been counted.")

    return dates


## get the cost and delivery data for a given paper from the DB
 # each of them are converted to a dictionary, whose index is the day_id
 # the two dictionaries are then returned as a tuple
def get_cost_and_delivery_data(paper_id: int) -> tuple[dict[int, float], dict[int, bool]]:
    cost_query = generate_sql_query(
        'papers_days_cost',
        columns=['day_id', 'cost'],
        conditions={'paper_id': paper_id}
    )

    delivery_query = generate_sql_query(
        'papers_days_delivered',
        columns=['day_id', 'delivered'],
        conditions={'paper_id': paper_id}
    )

    with connect(DATABASE_PATH) as connection:
        cost_tuple = connection.execute(cost_query).fetchall()
        delivery_tuple = connection.execute(delivery_query).fetchall()

    cost_dict = {
        day_id: cost
        for day_id, cost in cost_tuple # type: ignore
    }

    delivery_dict = {
        day_id: delivery
        for day_id, delivery in delivery_tuple # type: ignore
    }

    return cost_dict, delivery_dict


## calculate the cost of one paper for the full month
 # any dates when it was not delivered will be removed
def calculate_cost_of_one_paper(number_of_days_per_week: list[int], undelivered_dates: set[date_type], cost_and_delivered_data: tuple[dict[int, float], dict[int, bool]]) -> float:
    cost_data, delivered_data = cost_and_delivered_data
    
    # initialize counters corresponding to each weekday when the paper was not delivered
    number_of_days_per_week_not_received = [0] * len(number_of_days_per_week)

    # for each date that the paper was not delivered, we increment the counter for the corresponding weekday
    for date in undelivered_dates:
        number_of_days_per_week_not_received[date.weekday()] += 1
    
    # calculate the total number of each weekday the paper was delivered (if it is supposed to be delivered)
    number_of_days_delivered = [
        number_of_days_per_week[day_id] - number_of_days_per_week_not_received[day_id] if delivered else 0
        for day_id, delivered in delivered_data.items()
    ]

    # calculate the total cost of the paper for the month
    return sum(
        cost * number_of_days_delivered[day_id]
        for day_id, cost in cost_data.items()
    )


## calculate the cost of all papers for the full month
 # return data about the cost of each paper, the total cost, and dates when each paper was not delivered
def calculate_cost_of_all_papers(undelivered_strings: dict[int, str], month: int, year: int) -> tuple[dict[int, float], float, dict[int, set[date_type]]]:
    NUMBER_OF_DAYS_PER_WEEK = get_number_of_days_per_week(month, year)

    # get the IDs of papers that exist
    with connect(DATABASE_PATH) as connection:
        papers = connection.execute(
            generate_sql_query(
                'papers',
                columns=['paper_id']
            )
        ).fetchall()

    # get the data about cost and delivery for each paper
    cost_and_delivery_data = [
        get_cost_and_delivery_data(paper_id)
        for paper_id, in papers # type: ignore
    ]

    # initialize a "blank" dictionary that will eventually contain any dates when a paper was not delivered
    undelivered_dates: dict[int, set[date_type]] = {
        paper_id: {}
        for paper_id, in papers # type: ignore
    }

    # calculate the undelivered dates for each paper
    for paper_id, undelivered_string in undelivered_strings.items(): # type: ignore
        undelivered_dates[paper_id] = parse_undelivered_string(undelivered_string, month, year)

    # calculate the cost of each paper
    costs = {
        paper_id: calculate_cost_of_one_paper(
            NUMBER_OF_DAYS_PER_WEEK,
            undelivered_dates[paper_id],
            cost_and_delivery_data[index]
        )
        for index, (paper_id,) in enumerate(papers) # type: ignore
    }

    # calculate the total cost of all papers
    total = sum(costs.values())

    return costs, total, undelivered_dates


## save the results of undelivered dates to the DB
 # save the dates any paper was not delivered
def save_results(undelivered_dates: dict[int, set[date_type]], month: int, year: int) -> None:
    TIMESTAMP = datetime.now().strftime(r'%d/%m/%Y %I:%M:%S %p')

    with connect(DATABASE_PATH) as connection:
        for paper_id, undelivered_date_instances in undelivered_dates.items():
            connection.execute(
                "INSERT INTO undelivered_dates (timestamp, month, year, paper_id, dates) VALUES (?, ?, ?, ?, ?);",
                (
                    TIMESTAMP,
                    month,
                    year,
                    paper_id,
                    ','.join([
                        undelivered_date_instance.strftime(r'%d')
                        for undelivered_date_instance in undelivered_date_instances
                    ])
                )
            )


## format the output of calculating the cost of all papers
def format_output(costs: dict[int, float], total: float, month: int, year: int) -> str:
    papers = {
        paper_id: name
        for paper_id, name in query_database(
            generate_sql_query('papers')
        )
    }


    format_string = f"For {date_type(year=year, month=month, day=1).strftime(r'%B %Y')}\n\n"
    format_string += f"*TOTAL*: {total}\n"

    format_string += '\n'.join([
        f"{papers[paper_id]}: {cost}"  # type: ignore
        for paper_id, cost in costs.items()
    ])

    return f"{format_string}\n"


## add a new paper
 # do not allow if the paper already exists
def add_new_paper(name: str, days_delivered: list[bool], days_cost: list[float]) -> tuple[bool, str]:
    with connect(DATABASE_PATH) as connection:

        # get the names of all papers that already exist
        paper = connection.execute(
            generate_sql_query('papers', columns=['name'], conditions={'name': f"\"{name}\""})
        ).fetchall()

        # if the proposed paper already exists, return an error message
        if paper:
            return False, "Paper already exists. Please try editing the paper instead."
        
        # otherwise, add the paper name to the database
        connection.execute(
            "INSERT INTO papers (name) VALUES (?);",
            (name, )
        )

        # get the ID of the paper that was just added
        paper_id = connection.execute(
            "SELECT paper_id FROM papers WHERE name = ?;",
            (name, )
        ).fetchone()[0]

        # add the cost and delivery data for the paper
        for day_id, (cost, delivered) in enumerate(zip(days_cost, days_delivered)):
            connection.execute(
                "INSERT INTO papers_days_cost (paper_id, day_id, cost) VALUES (?, ?, ?);",
                (paper_id, day_id, cost)
            )
            connection.execute(
                "INSERT INTO papers_days_delivered (paper_id, day_id, delivered) VALUES (?, ?, ?);",
                (paper_id, day_id, delivered)
            )
        
        connection.commit()

        return True, f"Paper {name} added."

    return False, "Something went wrong."


## edit an existing paper
 # do not allow if the paper does not exist
def edit_existing_paper(paper_id: int, name: str | None = None, days_delivered: list[bool] | None = None, days_cost: list[float] | None = None) -> tuple[bool, str]:
    with connect(DATABASE_PATH) as connection:

        # get the IDs of all papers that already exist
        paper = connection.execute(
            generate_sql_query('papers', columns=['paper_id'], conditions={'paper_id': paper_id})
        ).fetchone()

        # if the proposed paper does not exist, return an error message
        if not paper:
            return False, f"Paper {paper_id} does not exist. Please try adding it instead."

        # if a name is proposed, update the name of the paper
        if name is not None:
            connection.execute(
                "UPDATE papers SET name = ? WHERE paper_id = ?;",
                (name, paper_id)
            )
        
        # if delivery data is proposed, update the delivery data of the paper
        if days_delivered is not None:
            for day_id, delivered in enumerate(days_delivered):
                connection.execute(
                    "UPDATE papers_days_delivered SET delivered = ? WHERE paper_id = ? AND day_id = ?;",
                    (delivered, paper_id, day_id)
                )

        # if cost data is proposed, update the cost data of the paper
        if days_cost is not None:
            for day_id, cost in enumerate(days_cost):
                connection.execute(
                    "UPDATE papers_days_cost SET cost = ? WHERE paper_id = ? AND day_id = ?;",
                    (cost, paper_id, day_id)
                )

        connection.commit()

        return True, f"Paper {paper_id} edited."

    return False, "Something went wrong."


## delete an existing paper
 # do not allow if the paper does not exist
def delete_existing_paper(paper_id: int) -> tuple[bool, str]:
    with connect(DATABASE_PATH) as connection:

        # get the IDs of all papers that already exist
        paper = connection.execute(
            generate_sql_query('papers', columns=['paper_id'], conditions={'paper_id': paper_id})
        ).fetchone()

        # if the proposed paper does not exist, return an error message
        if not paper:
            return False, f"Paper {paper_id} does not exist. Please try adding it instead."

        # delete the paper from the names table
        connection.execute(
            "DELETE FROM papers WHERE paper_id = ?;",
            (paper_id, )
        )

        # delete the paper from the delivery data table
        connection.execute(
            "DELETE FROM papers_days_delivered WHERE paper_id = ?;",
            (paper_id, )
        )

        # delete the paper from the cost data table
        connection.execute(
            "DELETE FROM papers_days_cost WHERE paper_id = ?;",
            (paper_id, )
        )

        connection.commit()

        return True, f"Paper {paper_id} deleted."

    return False, "Something went wrong."


## record strings for date(s) paper(s) were not delivered
def add_undelivered_string(paper_id: int, undelivered_string: str, month: int, year: int) -> tuple[bool, str]:
    
    # if the string is not valid, return an error message
    if not validate_undelivered_string(undelivered_string):
        return False, f"Invalid undelivered string."
    
    with connect(DATABASE_PATH) as connection:
        # check if given paper exists
        paper = connection.execute(
            generate_sql_query(
                'papers',
                columns=['paper_id'],
                conditions={'paper_id': paper_id}
            )
        ).fetchone()

        # if the paper does not exist, return an error message
        if not paper:
            return False, f"Paper {paper_id} does not exist. Please try adding it instead."

        # check if a string with the same month and year, for the same paper, already exists
        existing_string = connection.execute(
            generate_sql_query(
                'undelivered_strings',
                columns=['string'],
                conditions={
                    'paper_id': paper_id,
                    'month': month,
                    'year': year
                }
            )
        ).fetchone()

        # if a string with the same month and year, for the same paper, already exists, concatenate the new string to it
        if existing_string:
            new_string = f"{existing_string[0]},{undelivered_string}"

            connection.execute(
                "UPDATE undelivered_strings SET string = ? WHERE paper_id = ? AND month = ? AND year = ?;",
                (new_string, paper_id, month, year)
            )

        # otherwise, add the new string to the database
        else:
            connection.execute(
                "INSERT INTO undelivered_strings (string, paper_id, month, year) VALUES (?, ?, ?, ?);",
                (undelivered_string, paper_id, month, year)
            )

        connection.commit()

    return True, f"Undelivered string added."


## delete an existing undelivered string
 # do not allow if the string does not exist
def delete_undelivered_string(paper_id: int, month: int, year: int) -> tuple[bool, str]:
    with connect(DATABASE_PATH) as connection:
    
        # check if a string with the same month and year, for the same paper, exists
        existing_string = connection.execute(
            generate_sql_query(
                'undelivered_strings',
                columns=['string'],
                conditions={
                    'paper_id': paper_id,
                    'month': month,
                    'year': year
                }
            )
        ).fetchone()

        # if it does, delete it
        if existing_string:
            connection.execute(
                "DELETE FROM undelivered_strings WHERE paper_id = ? AND month = ? AND year = ?;",
                (paper_id, month, year)
            )

            connection.commit()

            return True, f"Undelivered string deleted."

        # if the string does not exist, return an error message
        return False, f"Undelivered string does not exist."

    return False, "Something went wrong."


## get the previous month, by looking at 1 day before the first day of the current month (duh)
def get_previous_month() -> date_type:
    return (datetime.today().replace(day=1) - timedelta(days=1)).replace(day=1)


## extract delivery days and costs from user input
def extract_days_and_costs(days_delivered: str | None, prices: str | None, paper_id: int | None = None) -> tuple[list[bool], list[float]]:
    days = []
    costs = []

    # if the user has provided delivery days, extract them
    if days_delivered is not None:
        days = [
            bool(int(day == 'Y')) for day in str(days_delivered).upper()
        ]

    # if the user has not provided delivery days, fetch them from the database
    else:
        if isinstance(paper_id, int):
            days = [
                (int(day_id), bool(delivered))
                for day_id, delivered in query_database(
                    generate_sql_query(
                        'papers_days_delivered',
                        columns=['day_id', 'delivered'],
                        conditions={
                            'paper_id': paper_id
                        }
                    )
                )
            ]

            days.sort(key=lambda x: x[0])

            days = [delivered for _, delivered in days]

    # if the user has provided prices, extract them
    if prices is not None:

        costs = []
        encoded_prices = [float(price) for price in SPLIT_REGEX['semicolon'].split(prices.rstrip(';')) if float(price) > 0]

        day_count = -1
        for day in days:
            if day:
                day_count += 1
                cost = encoded_prices[day_count]

            else:
                cost = 0

            costs.append(cost)

    return days, costs

## validate month and year
def validate_month_and_year(month: int | None = None, year: int | None = None) -> tuple[bool, str]:
    if ((month is None) or (isinstance(month, int) and (0 < month) and (month <= 12))) and ((year is None) or (isinstance(year, int) and (year >= 0))):
        return True, ""
    
    return False, "Invalid month and/or year."

test_core.py

from datetime import date as date_type

from npbc_core import (SPLIT_REGEX, VALIDATE_REGEX,
                       calculate_cost_of_one_paper, extract_days_and_costs,
                       generate_sql_query, get_number_of_days_per_week,
                       parse_undelivered_string, validate_month_and_year)


def test_regex_number():
    assert VALIDATE_REGEX['number'].match('') is None
    assert VALIDATE_REGEX['number'].match('1') is not None
    assert VALIDATE_REGEX['number'].match('1 2') is None
    assert VALIDATE_REGEX['number'].match('1-2') is None
    assert VALIDATE_REGEX['number'].match('11') is not None
    assert VALIDATE_REGEX['number'].match('11-12') is None
    assert VALIDATE_REGEX['number'].match('11-12,13') is None
    assert VALIDATE_REGEX['number'].match('11-12,13-14') is None
    assert VALIDATE_REGEX['number'].match('111') is None
    assert VALIDATE_REGEX['number'].match('a') is None
    assert VALIDATE_REGEX['number'].match('1a') is None
    assert VALIDATE_REGEX['number'].match('1a2') is None
    assert VALIDATE_REGEX['number'].match('12b') is None

def test_regex_range():
    assert VALIDATE_REGEX['range'].match('') is None
    assert VALIDATE_REGEX['range'].match('1') is None
    assert VALIDATE_REGEX['range'].match('1 2') is None
    assert VALIDATE_REGEX['range'].match('1-2') is not None
    assert VALIDATE_REGEX['range'].match('11') is None
    assert VALIDATE_REGEX['range'].match('11-') is None
    assert VALIDATE_REGEX['range'].match('11-12') is not None
    assert VALIDATE_REGEX['range'].match('11-12-1') is None
    assert VALIDATE_REGEX['range'].match('11 -12') is not None
    assert VALIDATE_REGEX['range'].match('11 - 12') is not None
    assert VALIDATE_REGEX['range'].match('11- 12') is not None
    assert VALIDATE_REGEX['range'].match('11-2') is not None
    assert VALIDATE_REGEX['range'].match('11-12,13') is None
    assert VALIDATE_REGEX['range'].match('11-12,13-14') is None
    assert VALIDATE_REGEX['range'].match('111') is None
    assert VALIDATE_REGEX['range'].match('a') is None
    assert VALIDATE_REGEX['range'].match('1a') is None
    assert VALIDATE_REGEX['range'].match('1a2') is None
    assert VALIDATE_REGEX['range'].match('12b') is None
    assert VALIDATE_REGEX['range'].match('11-a') is None
    assert VALIDATE_REGEX['range'].match('11-12a') is None

def test_regex_CSVs():
    assert VALIDATE_REGEX['CSVs'].match('') is None
    assert VALIDATE_REGEX['CSVs'].match('1') is not None
    assert VALIDATE_REGEX['CSVs'].match('a') is not None
    assert VALIDATE_REGEX['CSVs'].match('adcef') is not None
    assert VALIDATE_REGEX['CSVs'].match('-') is not None
    assert VALIDATE_REGEX['CSVs'].match(' ') is None
    assert VALIDATE_REGEX['CSVs'].match('1,2') is not None
    assert VALIDATE_REGEX['CSVs'].match('1-3') is not None
    assert VALIDATE_REGEX['CSVs'].match('monday') is not None
    assert VALIDATE_REGEX['CSVs'].match('monday,tuesday') is not None
    assert VALIDATE_REGEX['CSVs'].match('mondays') is not None
    assert VALIDATE_REGEX['CSVs'].match('tuesdays') is not None
    assert VALIDATE_REGEX['CSVs'].match('1,2,3') is not None
    assert VALIDATE_REGEX['CSVs'].match('1-3') is not None
    assert VALIDATE_REGEX['CSVs'].match('monday,tuesday') is not None
    assert VALIDATE_REGEX['CSVs'].match('mondays,tuesdays') is not None
    assert VALIDATE_REGEX['CSVs'].match(';') is None
    assert VALIDATE_REGEX['CSVs'].match(':') is None
    assert VALIDATE_REGEX['CSVs'].match(':') is None
    assert VALIDATE_REGEX['CSVs'].match('!') is None
    assert VALIDATE_REGEX['CSVs'].match('1,2,3,4') is not None

def test_regex_days():
    assert VALIDATE_REGEX['days'].match('') is None
    assert VALIDATE_REGEX['days'].match('1') is None
    assert VALIDATE_REGEX['days'].match('1,2') is None
    assert VALIDATE_REGEX['days'].match('1-3') is None
    assert VALIDATE_REGEX['days'].match('monday') is None
    assert VALIDATE_REGEX['days'].match('monday,tuesday') is None
    assert VALIDATE_REGEX['days'].match('mondays') is not None
    assert VALIDATE_REGEX['days'].match('tuesdays') is not None

def test_regex_n_days():
    assert VALIDATE_REGEX['n-day'].match('') is None
    assert VALIDATE_REGEX['n-day'].match('1') is None
    assert VALIDATE_REGEX['n-day'].match('1-') is None
    assert VALIDATE_REGEX['n-day'].match('1,2') is None
    assert VALIDATE_REGEX['n-day'].match('1-3') is None
    assert VALIDATE_REGEX['n-day'].match('monday') is None
    assert VALIDATE_REGEX['n-day'].match('monday,tuesday') is None
    assert VALIDATE_REGEX['n-day'].match('mondays') is None
    assert VALIDATE_REGEX['n-day'].match('1-tuesday') is not None
    assert VALIDATE_REGEX['n-day'].match('11-tuesday') is None
    assert VALIDATE_REGEX['n-day'].match('111-tuesday') is None
    assert VALIDATE_REGEX['n-day'].match('11-tuesdays') is None
    assert VALIDATE_REGEX['n-day'].match('1 -tuesday') is not None
    assert VALIDATE_REGEX['n-day'].match('1- tuesday') is not None
    assert VALIDATE_REGEX['n-day'].match('1 - tuesday') is not None

def test_regex_costs():
    assert VALIDATE_REGEX['costs'].match('') is None
    assert VALIDATE_REGEX['costs'].match('a') is None
    assert VALIDATE_REGEX['costs'].match('1') is not None
    assert VALIDATE_REGEX['costs'].match('1.') is None
    assert VALIDATE_REGEX['costs'].match('1.5') is not None
    assert VALIDATE_REGEX['costs'].match('1.0') is not None
    assert VALIDATE_REGEX['costs'].match('16.0') is not None
    assert VALIDATE_REGEX['costs'].match('16.06') is not None
    assert VALIDATE_REGEX['costs'].match('1;2') is not None
    assert VALIDATE_REGEX['costs'].match('1 ;2') is not None
    assert VALIDATE_REGEX['costs'].match('1; 2') is not None
    assert VALIDATE_REGEX['costs'].match('1 ; 2') is not None
    assert VALIDATE_REGEX['costs'].match('1;2;') is not None
    assert VALIDATE_REGEX['costs'].match('1;2 ;') is not None
    assert VALIDATE_REGEX['costs'].match('1:2') is None
    assert VALIDATE_REGEX['costs'].match('1,2') is None
    assert VALIDATE_REGEX['costs'].match('1-2') is None
    assert VALIDATE_REGEX['costs'].match('1;2;3') is not None
    assert VALIDATE_REGEX['costs'].match('1;2;3;4') is not None
    assert VALIDATE_REGEX['costs'].match('1;2;3;4;5') is not None
    assert VALIDATE_REGEX['costs'].match('1;2;3;4;5;6') is not None
    assert VALIDATE_REGEX['costs'].match('1;2;3;4;5;6;7;') is not None
    assert VALIDATE_REGEX['costs'].match('1;2;3;4;5;6;7') is not None
    assert VALIDATE_REGEX['costs'].match('1;2;3;4;5;6;7;8') is None

def test_delivery_regex():
    assert VALIDATE_REGEX['delivery'].match('') is None
    assert VALIDATE_REGEX['delivery'].match('a') is None
    assert VALIDATE_REGEX['delivery'].match('1') is None
    assert VALIDATE_REGEX['delivery'].match('1.') is None
    assert VALIDATE_REGEX['delivery'].match('1.5') is None
    assert VALIDATE_REGEX['delivery'].match('1,2') is None
    assert VALIDATE_REGEX['delivery'].match('1-2') is None
    assert VALIDATE_REGEX['delivery'].match('1;2') is None
    assert VALIDATE_REGEX['delivery'].match('1:2') is None
    assert VALIDATE_REGEX['delivery'].match('1,2,3') is None
    assert VALIDATE_REGEX['delivery'].match('Y') is None
    assert VALIDATE_REGEX['delivery'].match('N') is None
    assert VALIDATE_REGEX['delivery'].match('YY') is None
    assert VALIDATE_REGEX['delivery'].match('YYY') is None
    assert VALIDATE_REGEX['delivery'].match('YYYY') is None
    assert VALIDATE_REGEX['delivery'].match('YYYYY') is None
    assert VALIDATE_REGEX['delivery'].match('YYYYYY') is None
    assert VALIDATE_REGEX['delivery'].match('YYYYYYY') is not None
    assert VALIDATE_REGEX['delivery'].match('YYYYYYYY') is None
    assert VALIDATE_REGEX['delivery'].match('NNNNNNN') is not None
    assert VALIDATE_REGEX['delivery'].match('NYNNNNN') is not None
    assert VALIDATE_REGEX['delivery'].match('NYYYYNN') is not None
    assert VALIDATE_REGEX['delivery'].match('NYYYYYY') is not None
    assert VALIDATE_REGEX['delivery'].match('NYYYYYYY') is None
    assert VALIDATE_REGEX['delivery'].match('N,N,N,N,N,N,N') is None
    assert VALIDATE_REGEX['delivery'].match('N;N;N;N;N;N;N') is None
    assert VALIDATE_REGEX['delivery'].match('N-N-N-N-N-N-N') is None
    assert VALIDATE_REGEX['delivery'].match('N N N N N N N') is None
    assert VALIDATE_REGEX['delivery'].match('YYYYYYy') is None
    assert VALIDATE_REGEX['delivery'].match('YYYYYYn') is None



def test_regex_hyphen():
    assert SPLIT_REGEX['hyphen'].split('1-2') == ['1', '2']
    assert SPLIT_REGEX['hyphen'].split('1-2-3') == ['1', '2', '3']
    assert SPLIT_REGEX['hyphen'].split('1 -2-3') == ['1', '2', '3']
    assert SPLIT_REGEX['hyphen'].split('1 - 2-3') == ['1', '2', '3']
    assert SPLIT_REGEX['hyphen'].split('1- 2-3') == ['1', '2', '3']
    assert SPLIT_REGEX['hyphen'].split('1') == ['1']
    assert SPLIT_REGEX['hyphen'].split('1-') == ['1', '']
    assert SPLIT_REGEX['hyphen'].split('1-2-') == ['1', '2', '']
    assert SPLIT_REGEX['hyphen'].split('1-2-3-') == ['1', '2', '3', '']
    assert SPLIT_REGEX['hyphen'].split('1,2-3') == ['1,2', '3']
    assert SPLIT_REGEX['hyphen'].split('1,2-3-') == ['1,2', '3', '']
    assert SPLIT_REGEX['hyphen'].split('1,2, 3,') == ['1,2, 3,']
    assert SPLIT_REGEX['hyphen'].split('') == ['']

def test_regex_comma():
    assert SPLIT_REGEX['comma'].split('1,2') == ['1', '2']
    assert SPLIT_REGEX['comma'].split('1,2,3') == ['1', '2', '3']
    assert SPLIT_REGEX['comma'].split('1 ,2,3') == ['1', '2', '3']
    assert SPLIT_REGEX['comma'].split('1 , 2,3') == ['1', '2', '3']
    assert SPLIT_REGEX['comma'].split('1, 2,3') == ['1', '2', '3']
    assert SPLIT_REGEX['comma'].split('1') == ['1']
    assert SPLIT_REGEX['comma'].split('1,') == ['1', '']
    assert SPLIT_REGEX['comma'].split('1, ') == ['1', '']
    assert SPLIT_REGEX['comma'].split('1,2,') == ['1', '2', '']
    assert SPLIT_REGEX['comma'].split('1,2,3,') == ['1', '2', '3', '']
    assert SPLIT_REGEX['comma'].split('1-2,3') == ['1-2', '3']
    assert SPLIT_REGEX['comma'].split('1-2,3,') == ['1-2', '3', '']
    assert SPLIT_REGEX['comma'].split('1-2-3') == ['1-2-3']
    assert SPLIT_REGEX['comma'].split('1-2- 3') == ['1-2- 3']
    assert SPLIT_REGEX['comma'].split('') == ['']

def test_regex_semicolon():
    assert SPLIT_REGEX['semicolon'].split('1;2') == ['1', '2']
    assert SPLIT_REGEX['semicolon'].split('1;2;3') == ['1', '2', '3']
    assert SPLIT_REGEX['semicolon'].split('1 ;2;3') == ['1', '2', '3']
    assert SPLIT_REGEX['semicolon'].split('1 ; 2;3') == ['1', '2', '3']
    assert SPLIT_REGEX['semicolon'].split('1; 2;3') == ['1', '2', '3']
    assert SPLIT_REGEX['semicolon'].split('1') == ['1']
    assert SPLIT_REGEX['semicolon'].split('1;') == ['1', '']
    assert SPLIT_REGEX['semicolon'].split('1; ') == ['1', '']
    assert SPLIT_REGEX['semicolon'].split('1;2;') == ['1', '2', '']
    assert SPLIT_REGEX['semicolon'].split('1;2;3;') == ['1', '2', '3', '']
    assert SPLIT_REGEX['semicolon'].split('1-2;3') == ['1-2', '3']
    assert SPLIT_REGEX['semicolon'].split('1-2;3;') == ['1-2', '3', '']
    assert SPLIT_REGEX['semicolon'].split('1-2-3') == ['1-2-3']
    assert SPLIT_REGEX['semicolon'].split('1-2- 3') == ['1-2- 3']
    assert SPLIT_REGEX['semicolon'].split('') == ['']


def test_undelivered_string_parsing():
    MONTH = 5
    YEAR = 2017


    assert parse_undelivered_string('', MONTH, YEAR) == set([])

    assert parse_undelivered_string('1', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=1)
    ])

    assert parse_undelivered_string('1-2', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=1),
        date_type(year=YEAR, month=MONTH, day=2)
    ])

    assert parse_undelivered_string('5-17', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=5),
        date_type(year=YEAR, month=MONTH, day=6),
        date_type(year=YEAR, month=MONTH, day=7),
        date_type(year=YEAR, month=MONTH, day=8),
        date_type(year=YEAR, month=MONTH, day=9),
        date_type(year=YEAR, month=MONTH, day=10),
        date_type(year=YEAR, month=MONTH, day=11),
        date_type(year=YEAR, month=MONTH, day=12),
        date_type(year=YEAR, month=MONTH, day=13),
        date_type(year=YEAR, month=MONTH, day=14),
        date_type(year=YEAR, month=MONTH, day=15),
        date_type(year=YEAR, month=MONTH, day=16),
        date_type(year=YEAR, month=MONTH, day=17)
    ])

    assert parse_undelivered_string('5-17,19', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=5),
        date_type(year=YEAR, month=MONTH, day=6),
        date_type(year=YEAR, month=MONTH, day=7),
        date_type(year=YEAR, month=MONTH, day=8),
        date_type(year=YEAR, month=MONTH, day=9),
        date_type(year=YEAR, month=MONTH, day=10),
        date_type(year=YEAR, month=MONTH, day=11),
        date_type(year=YEAR, month=MONTH, day=12),
        date_type(year=YEAR, month=MONTH, day=13),
        date_type(year=YEAR, month=MONTH, day=14),
        date_type(year=YEAR, month=MONTH, day=15),
        date_type(year=YEAR, month=MONTH, day=16),
        date_type(year=YEAR, month=MONTH, day=17),
        date_type(year=YEAR, month=MONTH, day=19)
    ])

    assert parse_undelivered_string('5-17,19-21', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=5),
        date_type(year=YEAR, month=MONTH, day=6),
        date_type(year=YEAR, month=MONTH, day=7),
        date_type(year=YEAR, month=MONTH, day=8),
        date_type(year=YEAR, month=MONTH, day=9),
        date_type(year=YEAR, month=MONTH, day=10),
        date_type(year=YEAR, month=MONTH, day=11),
        date_type(year=YEAR, month=MONTH, day=12),
        date_type(year=YEAR, month=MONTH, day=13),
        date_type(year=YEAR, month=MONTH, day=14),
        date_type(year=YEAR, month=MONTH, day=15),
        date_type(year=YEAR, month=MONTH, day=16),
        date_type(year=YEAR, month=MONTH, day=17),
        date_type(year=YEAR, month=MONTH, day=19),
        date_type(year=YEAR, month=MONTH, day=20),
        date_type(year=YEAR, month=MONTH, day=21)
    ])

    assert parse_undelivered_string('5-17,19-21,23', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=5),
        date_type(year=YEAR, month=MONTH, day=6),
        date_type(year=YEAR, month=MONTH, day=7),
        date_type(year=YEAR, month=MONTH, day=8),
        date_type(year=YEAR, month=MONTH, day=9),
        date_type(year=YEAR, month=MONTH, day=10),
        date_type(year=YEAR, month=MONTH, day=11),
        date_type(year=YEAR, month=MONTH, day=12),
        date_type(year=YEAR, month=MONTH, day=13),
        date_type(year=YEAR, month=MONTH, day=14),
        date_type(year=YEAR, month=MONTH, day=15),
        date_type(year=YEAR, month=MONTH, day=16),
        date_type(year=YEAR, month=MONTH, day=17),
        date_type(year=YEAR, month=MONTH, day=19),
        date_type(year=YEAR, month=MONTH, day=20),
        date_type(year=YEAR, month=MONTH, day=21),
        date_type(year=YEAR, month=MONTH, day=23)
    ])

    assert parse_undelivered_string('mondays', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=1),
        date_type(year=YEAR, month=MONTH, day=8),
        date_type(year=YEAR, month=MONTH, day=15),
        date_type(year=YEAR, month=MONTH, day=22),
        date_type(year=YEAR, month=MONTH, day=29)
    ])

    assert parse_undelivered_string('mondays, wednesdays', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=1),
        date_type(year=YEAR, month=MONTH, day=8),
        date_type(year=YEAR, month=MONTH, day=15),
        date_type(year=YEAR, month=MONTH, day=22),
        date_type(year=YEAR, month=MONTH, day=29),
        date_type(year=YEAR, month=MONTH, day=3),
        date_type(year=YEAR, month=MONTH, day=10),
        date_type(year=YEAR, month=MONTH, day=17),
        date_type(year=YEAR, month=MONTH, day=24),
        date_type(year=YEAR, month=MONTH, day=31)
    ])

    assert parse_undelivered_string('2-monday', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=8)
    ])

    assert parse_undelivered_string('2-monday, 3-wednesday', MONTH, YEAR) == set([
        date_type(year=YEAR, month=MONTH, day=8),
        date_type(year=YEAR, month=MONTH, day=17)
    ])


def test_sql_query():
    assert generate_sql_query(
        'test'
    ) == "SELECT * FROM test;"

    assert generate_sql_query(
        'test',
        columns=['a']
    ) == "SELECT a FROM test;"

    assert generate_sql_query(
        'test',
        columns=['a', 'b']
    ) == "SELECT a, b FROM test;"

    assert generate_sql_query(
        'test',
        conditions={'a': '\"b\"'}
    ) == "SELECT * FROM test WHERE a = \"b\";"

    assert generate_sql_query(
        'test',
        conditions={
            'a': '\"b\"',
            'c': '\"d\"'
        }
    ) == "SELECT * FROM test WHERE a = \"b\" AND c = \"d\";"

    assert generate_sql_query(
        'test',
        conditions={
            'a': '\"b\"',
            'c': '\"d\"'
        },
        columns=['a', 'b']
    ) == "SELECT a, b FROM test WHERE a = \"b\" AND c = \"d\";"


def test_number_of_days_per_week():
    assert get_number_of_days_per_week(1, 2022) == [5, 4, 4, 4, 4, 5, 5]
    assert get_number_of_days_per_week(2, 2022) == [4, 4, 4, 4, 4, 4, 4]
    assert get_number_of_days_per_week(3, 2022) == [4, 5, 5 ,5, 4, 4, 4]
    assert get_number_of_days_per_week(2, 2020) == [4, 4, 4, 4, 4, 5, 4]
    assert get_number_of_days_per_week(12, 1954) == [4, 4, 5, 5, 5, 4, 4]


def test_calculating_cost_of_one_paper():
    DAYS_PER_WEEK = [5, 4, 4, 4, 4, 5, 5]
    COST_PER_DAY: dict[int, float] = {
        0: 0,
        1: 0,
        2: 2,
        3: 2,
        4: 5,
        5: 0,
        6: 1
    }
    DELIVERY_DATA: dict[int, bool] = {
        0: False,
        1: False,
        2: True,
        3: True,
        4: True,
        5: False,
        6: True
    }
    
    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 41

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([]),
        (
            COST_PER_DAY,
            {
                0: False,
                1: False,
                2: True,
                3: True,
                4: True,
                5: False,
                6: False
            }
        )
    ) == 36

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([
            date_type(year=2022, month=1, day=8)
        ]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 41

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([
            date_type(year=2022, month=1, day=8),
            date_type(year=2022, month=1, day=8)
        ]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 41

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([
            date_type(year=2022, month=1, day=8),
            date_type(year=2022, month=1, day=17)
        ]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 41

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([
            date_type(year=2022, month=1, day=2)
        ]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 40

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([
            date_type(year=2022, month=1, day=2),
            date_type(year=2022, month=1, day=2)
        ]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 40

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([
            date_type(year=2022, month=1, day=6),
            date_type(year=2022, month=1, day=7)
        ]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 34

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([
            date_type(year=2022, month=1, day=6),
            date_type(year=2022, month=1, day=7),
            date_type(year=2022, month=1, day=8)
        ]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 34

    assert calculate_cost_of_one_paper(
        DAYS_PER_WEEK,
        set([
            date_type(year=2022, month=1, day=6),
            date_type(year=2022, month=1, day=7),
            date_type(year=2022, month=1, day=7),
            date_type(year=2022, month=1, day=7),
            date_type(year=2022, month=1, day=8),
            date_type(year=2022, month=1, day=8),
            date_type(year=2022, month=1, day=8)
        ]),
        (
            COST_PER_DAY,
            DELIVERY_DATA
        )
    ) == 34


def test_extracting_days_and_costs():
    assert extract_days_and_costs(None, None) == ([], [])
    assert extract_days_and_costs('NNNNNNN', None) == (
        [False, False, False, False, False, False, False],
        []
    )

    assert extract_days_and_costs('NNNYNNN', '7') == (
        [False, False, False, True, False, False, False],
        [0, 0, 0, 7, 0, 0, 0]
    )

    assert extract_days_and_costs('NNNYNNN', '7;7') == (
        [False, False, False, True, False, False, False],
        [0, 0, 0, 7, 0, 0, 0]
    )

    assert extract_days_and_costs('NNNYNNY', '7;4') == (
        [False, False, False, True, False, False, True],
        [0, 0, 0, 7, 0, 0, 4]
    )

    assert extract_days_and_costs('NNNYNNY', '7;4.7') == (
        [False, False, False, True, False, False, True],
        [0, 0, 0, 7, 0, 0, 4.7]
    )


def test_validate_month_and_year():
    assert validate_month_and_year(1, 2020)[0]
    assert validate_month_and_year(12, 2020)[0]
    assert validate_month_and_year(1, 2021)[0]
    assert validate_month_and_year(12, 2021)[0]
    assert validate_month_and_year(1, 2022)[0]
    assert validate_month_and_year(12, 2022)[0]
    assert not validate_month_and_year(-54, 2020)[0]
    assert not validate_month_and_year(0, 2020)[0]
    assert not validate_month_and_year(13, 2020)[0]
    assert not validate_month_and_year(45, 2020)[0]
    assert not validate_month_and_year(1, -5)[0]
    assert not validate_month_and_year(12, -5)[0]
    assert not validate_month_and_year(1.6, 10)[0]  # type: ignore
    assert not validate_month_and_year(12.6, 10)[0]  # type: ignore
    assert not validate_month_and_year(1, '10')[0]  # type: ignore
    assert not validate_month_and_year(12, '10')[0]  # type: ignore

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/6020a4f5db0bf40f54e35b725b305cfeafdd8f2b

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Once you calculate, the results are displayed and copied to your clipboard

This is not a good idea for a CLI. It may be a reasonable (opt-in!) option for a GUI, but for a CLI the overwhelming expectation is that the results are printed to stdout, and the user can copy from there if they want. You say

I will be the sole user of this app until it's converted into a server thing as stated in the table. But you're not expected to know that, and it's probably a bad idea for me to learn non-standard practices.

Indeed. For us to get better as practitioners, even if our programs are only intended for us, best practice is to make it approachable for everyone.

Overall this code is pretty good! There are type hints, you're using pathlib, you have reasonable data directories, you have tests, etc.

Don't represent VALIDATE_REGEX as a dictionary where all of the keys are referenced statically. A saner option is to move this to a different file, which will still offer scope but not require the machinery of a dictionary. SPLIT_REGEX is probably best as three separate regex variables.

Having a regex validator for CSV content is concerning. You shouldn't be parsing CSV content yourself. You say:

This refers to user input, not a "data" file

CSV is vaguely a machine-legible format and not expected to be a user-legible format. So if it doesn't come from a file, then it's probably not an appropriate choice for user input of your undelivered string. Your other post shows that you're accepting this as one argument. Instead, consider using argparse and its nargs='+' support to accumulate multiple arguments to a list, which will not require a comma separator.

generate_sql_query is attempting to be too clever. Don't construct your queries like this; just write them out.

What's with query_database returning []? That statement will never be executed, so remove it.

get_number_of_days_per_week is a good candidate for being converted from returning a list to yielding an iterator. You ask:

it's called only once and the value is saved to a constant. So, why does list vs generator matter?

It doesn't matter how many times it's called, and it doesn't matter whether it's saved to a constant. An iterator function offers "streamed" calculation instead of in-memory calculation to any callers that want it, and if a caller does actually want a list or tuple, they can cast to it there. Other than the memory representation flexibility, this is mostly a matter of syntactic sugar since no return is necessary and no inner list variable is needed.

Your "bug report" here:

            print("Congratulations! You broke the program!")
            print("You managed to write a string that the program considers valid, but isn't actually.")
            print("Please report it to the developer.")
            print(f"\nThe string you wrote was: {string}")
            print("This data has not been counted.")

is well-intended, but would be better-represented by

  1. refactoring out the contents of the loop to its own function;
  2. in that function, throwing an exception at this scope - likely a custom exception subclass that can accept your invalid string as a member;
  3. back in the loop that calls that function, wrap it in a try/except that catches your (specific) exception and prints something like what you've already shown.

Similar for return False, "Something went wrong." which should be replaced with proper exception handling rather than a boolean return.

(0 < start) and (start <= end)

is better-represented as

0 < start <= end

. Similar for 0 < n <= get_number_of_days_per_week(month, year)[WEEKDAY_NAMES.index(weekday.capitalize())].

TIMESTAMP should not be capitalised, since it's a local.

Your ','.join([ should not have an inner list and should pass the generator directly.

This formatting block:

    format_string = f"For {date_type(year=year, month=month, day=1).strftime(r'%B %Y')}\n\n"
    format_string += f"*TOTAL*: {total}\n"

    format_string += '\n'.join([
        f"{papers[paper_id]}: {cost}"  # type: ignore
        for paper_id, cost in costs.items()
    ])

    return f"{format_string}\n"

is... fine? Other than the last f-string, which - since you've already iteratively concatenated, should just have a + '\n'. If you're concerned about the O(n^2) cost of successive string concatenation (which you probably shouldn't need to be), then either use StringIO, or do '\n'.join(). The latter option would be nice as an iterator function, something like

def format_total() -> Iterator[str]:
    # ...
    date_type_str = date_type(year=year, month=month, day=1).strftime(r'%B %Y')
    yield f"For {date_type_str}"

    yield f"*TOTAL*: {total}"

    for paper_id, cost in costs.items():
        yield f"{papers[paper_id]}: {cost}"


# ...
'\n'.join(format_total())

bool(int(day == 'Y')) should just be day == 'Y' which is already a boolean.

\$\endgroup\$
4
  • \$\begingroup\$ I agree with most of this, thank you very much, but have a few questions. (1 about clipboard) I will be the sole user of this app until it's converted into a server thing as stated in the table, and copying is the norm. But you're not expected to know that, and it's probably a bad idea for me to learn non-standard practices. (2 CSV validation) This refers to user input, not a "data" file, so is this okay? (3 yield get_number_of_days_per_week) Why? If you refer to where it's called, it's called only once and the value is saved to a constant. So, why does list vs generator matter? \$\endgroup\$ May 2, 2022 at 14:47
  • \$\begingroup\$ Disregard point 3, sorry. I'd forgotten about the use in parse_undelivered_string and referred only to calculate_cost_of_all_papers. \$\endgroup\$ May 2, 2022 at 14:53
  • 1
    \$\begingroup\$ Edited for all points \$\endgroup\$
    – Reinderien
    May 2, 2022 at 15:06
  • \$\begingroup\$ Understood, thank you very much for your time :) No further clarifications are needed. \$\endgroup\$ May 2, 2022 at 15:26

Your Answer

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

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