Firstly the goal of this code is a little more complex than in this post and the other invoice questions are C#

Is there anything that just pops out as immeadiately unpythonic/wrong ?

I have a few questions that are written in the code in the comments pertaining to specific issues in the code. (Denoted by the '?' at the end) and I also have the following general questions:

Is my intent easy to understand from the code ?

Do I need to put in more comments/doc strings ?

Are there any style/implementation issues ?

How can the code be more pythonic ?

How can the code be more professional ?

Is there a much better way to solve that problem that is in some way totally different to the code I've written ?

Main loop (file:InvoiceHelp.py):

from InvoiceCreator import *

InvoiceCreator is an the main class, talking input from the user (JobStructures) and
saving that in a list called 'Month.All_Jobs'. InvoiceCreator uses it's own methods
and those from HelperFunctions to store and eventually turn that data into an
invoice in PDF form using InvoiceAsPDF (which needs some re-writing). Information
stored on disc at startup is handled by ConfigManagerClass and instructions are
contained in Instructions.py

CONFIGFILENAME = "/home/hp-350/Desktop/Computing M2/Python/Invoice Help 5/config.IH5"

if __name__ == '__main__':
    running = True
    while running:
        invoice_creator = InvoiceCreator(CONFIGFILENAME)

This is the meat of the code and is the InvoiceCreator class that the docstring (in InvoiceHelp.py) is taking about: HelperFunctions, JobStructures, Instructions and the rest will be after the following code.


from sys import exit
import functools
from JobStructures import *
from Month import Month

# Docs recommend importing *
from fpdf import *

from InvoiceAsPDF import *
from ConfigManagerClass import *
from HelperFunctions import *
from Instructions import *

class InvoiceCreator:
    Takes simple user input (see process_input()) and information stored in the
    config file (see ConfigManager) to generate simple data about how many
    hours where worked on each calender day and whether on that day I gt paid a
    travel allowance. Thsi data can be manipulated also through various one
    letter commands eg, saved, loaded, deleted, replaced etc. This data can
    then be used to generate an invoice (stored in Month.All_Jobs) which can be
    subsequent turned into a pdf. See instructions.py.

    # Some of the following statements worry me, (just calling a class definition)
    # I Can do this but some how Month() here doesn't feel right ?
    # There will be only on Month() and no objects of its type will be created
    invoices_directory = ""

    def __init__(self, filename):
        print("\n              --------------- Python Invoice Help ---------------\n")
        InvoiceCreator.CONFIGFILENAME = filename
        successful_load = False
        self.daily_job_data = DailyJobData()
        self.current_date = question_range_reply("\nStart Date is (followed by RETURN/ENTER) ",
                                                 int, range(1, Month.last_day + 1))

        self.accepting_user_data = True

        # Used in process_input, the keys (of the dict) being possible user input
        self.FUNCTION_DICTIONARY = {
            "q" : self.__quit_both_loops,
            "p" : self.__print_this_invoice,
            "c" : clear_screen,
            "d" : functools.partial(self.__forward, 1),
            "b" : functools.partial(self.__forward, -1),
            "t" : functools.partial(self.__forward, 3),
            "w" : functools.partial(self.__forward, +7),
            "r" : functools.partial(self.__forward, -7),
            "j" : self.__new_ref_and_TA,
            "i" : instructions,
            "e" : self.__delete,
            "k" : self.__kill_all,
            "l" : self.__loading,
            "s" : self.__save_invoice_data,
            "f" : self.__convert_to_pdf,
            "#" : self.__change_invoice_number

    def __data_to_from_config_file(self, filename):
        cm = ConfigManager()
        config_data = cm.get_config_data(filename)
        if JobConsts.consts_are_empty(config_data.job_consts):
            cm.save_new_job_consts_to_config(filename, self.daily_job_data.job_consts)
        InvoiceCreator.invoices_directory = config_data.destination_path
        self.invoice_number = config_data.invoice_number
        self.pay_rate = config_data.pay_rate

    def __print_this_invoice(self):
        if not Month.All_Jobs:
            print("\n-------------- No Data in the Month to print --------------")
        list_of_days_of_work = self.__generate_invoice_contents_by_date()
        for day in list_of_days_of_work[:-1]:
        print(f"\nNumber of Days Worked: {(len(list_of_days_of_work) - 2)}."
            f"  Grand Total: {float(str(*list_of_days_of_work[-1:])):.2f}"
            f"  Invoice number: {self.invoice_number}")

    def __new_ref_and_TA(self):
        ref = input("Input new job reference: ")
        ta = question_range_reply("Input new travel_allowance: ", float, [0, 300])
        self.daily_job_data.job_consts.change_ref_and_ta(ref, ta)

    def __change_invoice_number(self):
        self.invoice_number = question_range_reply("Input new invoice number (min 3000)",
                                    int, range(3000, 4000))

    def __generate_invoice_contents_by_date(self):
        Final element of returned list is the grand_total

        1.00 hrs worked plus £50.00 travel allowance
        1000.00 hrs worked only
        The above lines are different lengths and SPACE_STR takes care of that
        SPACE_STR not technicaly a constant but for each loop it is
        see comment "Even out the number of spaces bewteen different lines"
        in function
        return_list = ["Date \t                    Description              \t Job Ref \t Cost \t"]
        grand_total = 0.0
        if self.daily_job_data.job_consts.travel_allowance == None:
            travel_al = 0
            travel_al = float(self.daily_job_data.job_consts.travel_allowance)

        for ordered_day in range(1, (Month.last_day + 1)):
            for date, invoice_data in Month.All_Jobs.items():
                if ordered_day == date:
                    # Even out the number of spaces bewteen different lines, so it looks pretty
                    num_of_spaces = (len(f"£{travel_al:.2f} travel allowance\t") -
                                        (len(f"{invoice_data.job_vars.hours:.2f}") - len("1.00")))
                    SPACE_STR = " " * num_of_spaces

                    total = float(invoice_data.job_vars.hours * self.pay_rate)
                    if not invoice_data.job_vars.TA_for_this_day:
                        return_list.append(f"{date}\t{invoice_data.job_vars.hours:.2f} hrs worked only {SPACE_STR}\t"
                            f"{self.daily_job_data.job_consts.reference}\t £{total:.2f}\t")
                        total += float(self.daily_job_data.job_consts.travel_allowance)
                        return_list.append(f"{date}\t{invoice_data.job_vars.hours:.2f} hrs worked plus "
                            f"£{travel_al:.2f} travel allowance\t"
                            f"{self.daily_job_data.job_consts.reference}\t £{total:.2f}\t")
                    grand_total += total

        return return_list

    def __forward(self, number_of_days):
        if number_of_days < 0:
            if self.current_date + number_of_days < 1:
        elif self.current_date + number_of_days > Month.last_day:
        self.current_date += number_of_days

    def __delete(self):
            del Month.All_Jobs[self.current_date]
        except KeyError:
            print(f"\nNo data on {self.current_date} of {Month.month}")
            print(f"\nDATA DELETED on {self.current_date} of {Month.month}")

    def __kill_all(self):
        print("-------------- KILLING ALL DATA ! --------------")
        if question_user("Do you want to save first (y/n)", 'y'):

    def __loading(self) -> bool:
        Having given the user a choice as to which .ih5 file to load as job data:
        An .ih5 file looks like this and loading() assigns the job constants from the first line,
        Month from the third and then the job data from subsequent lines of the file
        (Each of those lines starting with 'Date:'):

        Name: Former ZFG Unit   Job address: Blossom Ind Est   City: Cirencester SN20 4GH   Ref: AAA 567   Travel_allowance: 25

        MONTH: March
        Date:20 100.00 hrs worked only                          AAA 567     £2500.00
        Date:28 50.00 hrs worked only                           AAA 567     £1250.00

        If all the data is successfully allocated the True is returned else False
        if Month.All_Jobs:
            print("-------------- LOAD WILL OVER WRITE ALL DATA --------------")
            if question_user("Do you want to save first (y/n) ", 'y'):

        print("\n-------------- Available Invoice Help files: -------------- ")
        list_of_IH_files = list_of_files_in_dir(InvoiceCreator.invoices_directory, ".ih5")

        if not list_of_IH_files:
            print("-------------- There are no .ih5 files to load --------------")
            return False
        for idx, a_file in enumerate(list_of_IH_files):
            print(f"{(idx + 1)} {a_file}\n")
        choice = question_range_reply("Select file number or just press 0 followed by RETURN to quit this option: ",
                                        int, range(0, idx + 2))
        if (choice == 0):
            print("-------------- Leaving IH files to be read without doing anything --------------")
            return False

        filedata = read_strings_from_file((InvoiceCreator.invoices_directory
                                             + list_of_IH_files[(choice - 1)]))
        # I could have used a dict here, (str:function) but origionally
        # there were only two function calls not four ?
        for line in filedata:
            if 'INVOICE:' in line:
            if 'Name:' in line:
            if 'MONTH:' in line:
            if 'Date:' in line:
                self.__assign_job_data_from_str(line, self.daily_job_data.job_consts.reference,
        return True

    def __assign_invoice_num_from_str(self, inp_str):
         self.invoice_number = int(inp_str.replace("INVOICE:", "").strip())

    def __assign_job_consts_from_str(self, inp_str):

    def __assign_month_from_str(self, inp_str):
        new_month = inp_str.replace("MONTH:", "").strip()
        if new_month != Month.month:
            # again this could be a really bad idea letting the user change month in this way
            if question_user(f"Do you want to replace {Month.month} with {new_month} (y/n)", 'y'):
                Month.month = new_month

    def __assign_job_data_from_str(self, inp_str, self_job_ref, self_job_travel_allowance):
        inp_str will look something like either of the two lines below:
        Date:20 2.00 hrs worked plus £50.00 travel allowance    LU0 3198     £100.00
        Date:21 2.00 hrs worked only

        From these strings __assign_job_data_from_str  assigns values to
        daily_job_data.job_consts & daily_job_data.job_vars, by breaking up the string.
        It also checks whether you want to assign data if it's from a different job.
        It returns success = True if data is assigned to daily_job_data,  and False
        if there's some kind of failure, i.e. the user doesn't want the data from a different

        # Is there a better way to do this than assign all these sub strings.
        # Doesn't feel very Pythonic..?

        inp_str_no_date_word = inp_str.replace("Date:", "")
        job_date_str = inp_str_no_date_word.split('\t',1)[0]
        str_no_date = inp_str_no_date_word.split('\t',1)[1]
        hours_str = str_no_date.split("hrs worked")[0].strip()
        ta_and_rest_str = str_no_date.replace(f"{hours_str} hrs worked ", "")
        ta_today = True
        ref_and_total_str = ""

        if "only" in ta_and_rest_str:
            ta_today = False
            ref_and_total_str = ta_and_rest_str.replace("only", "")
        elif "travel allowance" in ta_and_rest_str:
            intermidiate_str = ta_and_rest_str.replace("plus £",  "").strip()
            ta_str = intermidiate_str.split("travel allowance")[0].strip()
            ref_and_total_str = intermidiate_str.replace(f"{ta_str} travel allowance","").strip()
            print("There's trouble getting job data, format error in file maybe?")

        ref_str = ref_and_total_str.split('\t')[0].strip()
        total_str = ref_and_total_str.split('\t',1)[1].strip()
        total_str_b = total_str.replace("£", "")

        self.daily_job_data.add_days_work(int(job_date_str), float(hours_str), ta_today)
        Month.add_a_day_of_work_to_month(int(job_date_str), self.daily_job_data)

    def __save_invoice_data(self):
        if not Month.All_Jobs:
            print("\n-------------- No Data in the Month to save --------------")
        invoice_data = [f"INVOICE: {self.invoice_number}"]
        invoice_data.append(f"MONTH: {Month.month}")
        list_of_days_of_work = self.__generate_invoice_contents_by_date()

        for day in list_of_days_of_work[1:-1]:
            invoice_data.append(('Date:' + day))
        filename = self.__generate_filename("", ".ih5")
        write_to_file(filename, invoice_data, "w")

    def __convert_to_pdf(self):
        if not Month.All_Jobs:
            print("\n-------------- No Data in the Month to pdf --------------")
            if not question_user("Do you want to PDF a blank invoice (y/n)?", 'y'):
        # create filename with job name month and invoice number, which is got from config file
        print("Converting to pdf,  saving as .ih5 file first")
        print("Saved .ih5 file")

        pdf = PDF(orientation = 'P', unit = 'mm', format = 'A4')
        pdf.add_font('DejaVu', '', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', uni=True)
        pdf.add_font('DejaVu-Bold', '', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', uni=True)
        pdf.titles(self.invoice_number, self.daily_job_data, self.__generate_invoice_contents_by_date(), Month)

        # Used for testing purposes. Anything with Invoice at the beginning is Not a test file.
        # INV_STR = ""
        INV_STR = "Invoice "
        filename = self.__generate_filename(INV_STR, ".pdf")
        print('filename at convert_to_pdf:',filename)
        self.invoice_number += 1
        max_invoice_num = self.__max_invoice_num_from_files(InvoiceCreator.invoices_directory)
        lin_str = f"Last Invoice Number:{max_invoice_num}\n"
        write_to_file(InvoiceCreator.CONFIGFILENAME, [lin_str], "Last Invoice Number", 'i')

    def __max_invoice_num_from_files(self, path):
        list_of_files = list_of_files_in_dir(path)
        # search for files with "Invoice" until second space
        max_invoice_num = self.invoice_number
        for a_file in list_of_files:
            if "Invoice" in a_file:
                invoice_str = a_file.split(" ", 2)[1]
                    invoice_num_from_files = int(invoice_str)
                except Exception:
                    if invoice_num_from_files > max_invoice_num:
                        max_invoice_num = invoice_num_from_files
        return max_invoice_num

    def __generate_filename(self, TEST_STR, file_ext):
        The TEST_STR parameter will be empty when running tests on the software.
        After testing it'll contain the value "Invoice " See note at call site
        return InvoiceCreator.invoices_directory + TEST_STR + str(self.invoice_number) + " " + \
                    self.daily_job_data.job_consts.job_name  + " " + self.daily_job_data.job_consts.job_city  + " " + \
                    str(Month.month) + file_ext

    def process_input(self, month : Month) -> bool:
        This function extracts either a command letter, a number of hours
        either with or without a travel allowance.
        Correct  inputs are (should be case insensitive):
        "8.75n" (8.75 hours with no travel allowance)
        "68" (68 hours with travel allowance, a lot of hours for 1 day !)
        "c" (clear the screen)
        "D" (advance an extra day)
        "3N" (3 hours with no TA)
        already_str = ""
        if ((self.current_date in Month.All_Jobs) and (Month.All_Jobs[self.current_date].job_vars.hours != 0)):
            already_str = f"CURRENTLY: {Month.All_Jobs[self.current_date].job_vars.hours}: "

        hours_or_command = input(f"\n Input number of hours worked on the {self.current_date} "
                                    f"of {Month.month}, at {self.daily_job_data.job_consts.job_name}: {already_str}")
        if len(hours_or_command) > 8:
            if not question_user(" That's a lot of info are you sure (Y/N) ? ", 'y'):

        input_str_l = hours_or_command.lower()
        hours = 0
        hours_inputted = False
        TA_for_today = True
        # This should have been done with a regex to test if input
        # was correct but couldn't get it to work. Something like
        # ^(?:0|[1-9]\d+|)?(?:.?\d{0,2})+^([a-z])$
            hours = float(input_str_l)
        except ValueError:
            if 'n' in input_str_l:
                    hours = float(input_str_l.replace("n", ""))
                except ValueError:
                    print("\n There's been an input error, too many characters or mangled number before/after 'n'\n")
                    TA_for_today = False
                    hours_inputted = True
            elif len(input_str_l) == 1:
                command_executed = False
                for key in self.FUNCTION_DICTIONARY:
                    if (key in input_str_l) and not command_executed:
                        command_executed = True
                if not command_executed:
                    print("\nThis letter isn't a command.\n")
                    return self.accepting_user_data
                print("\nInput Error!\n")
            hours_inputted = True
            if hours > 24:
                print("\n-------------- That's a LOT of HOURS, JUST letting you KNOW --------------")
        if hours_inputted:
            self.__add_daily_data_to_month(hours, TA_for_today)
        return self.accepting_user_data

    def __add_daily_data_to_month(self, hours, TA_for_today):
        if hours < 0.0:
            print(" ------------------ CAN'T HAVE NEGATIVE HOURS ------------------ ")
        elif hours == 0.0:
            if not question_user("Are you sure you want 0 hours on this day ? (y/n)", 'y'):
        if (self.daily_job_data.job_consts.travel_allowance == None or
            (int(float(self.daily_job_data.job_consts.travel_allowance))) == 0):
            TA_for_today = False

        self.daily_job_data.add_days_work(self.current_date, hours, TA_for_today)
        Month.add_a_day_of_work_to_month(self.current_date, self.daily_job_data)
        if self.current_date < Month.last_day:
            self.current_date += 1

    # Make process_input 'public' and call that from IH5.py ?
    def main_function(self):
        while self.process_input(Month.month):

JobStructures.py these are the units that are essentially a day of work in a month, and lots of them together are stored in Month.All_Jobs to form an invoice (which can then be turned into a PDF).

# ----------------------- See below post for init issues -----------------------
# https://stackoverflow.com/questions/55800218/setting-default-empty-attributes-for-user-classes-in-init

from HelperFunctions import rsetattr, rgetattr, question_user

# I thought about using builder pattern for some of the objects of
# these classes (but decided against it). Any thoughts ?
# https://stackoverflow.com/questions/11977279/builder-pattern-equivalent-in-python

class JobConsts:
    Each Job has a job reference, a job address and¸travel allowance. Within a
    months billing period these don't change. Sometimes we may do
    another job at the same site, the address would be the same but the job
    reference wuld be different. Also if the price of fuel goes up the travel
    allowance may change to reflect that, but again during the month that
    value is fixed.
    NUMBER_OF_CONSTS = 5   # job_name, job_address, job_city, reference, travel_allowance
                    "Name:" : 'job_name',
                    "Job address:" : 'job_address',
                    "City:" : 'job_city',
                    "Ref:" : 'reference',
                    "Travel_allowance:" : 'travel_allowance'

    # Builder patter for these (I decided against but have a nagging doubt) ?
    def __init__(self):
        self.job_name = ""
        self.job_address = ""
        self.job_city = ""
        self.reference = ""
        self.travel_allowance = None

    def consts_are_empty(external_job_consts):
        condition = ((external_job_consts.job_name == "")
            and (external_job_consts.job_address == "")
            and (external_job_consts.job_city == "") and (external_job_consts.reference == "")
            and (external_job_consts.travel_allowance == None))
        return condition

    def reset(self):
        # According to this post:
        # https://stackoverflow.com/questions/2144988/python-multiple-calls-to-init-on-the-same-instance
        # it's OK to do this. It's certainly DRY but feels a bit weird maybe ?

    def set_job_consts(self, external_job_consts):
        if JobConsts.consts_are_empty(external_job_consts):
            self.job_name = input("\nJob name is (followed by RETURN/ENTER) ")
            self.job_address = input("\nSite Address for this job (No City)(followed by RETURN/ENTER) ")
            self.job_city = input("\nCity/Town where job is (followed by RETURN/ENTER) ")
            self.reference = input("\nJob REFERENCE is (followed by RETURN/ENTER) ")
            self.travel_allowance = float(input("\nTravel Allowance for this job is (followed by RETURN/ENTER) "))
            self.job_name = external_job_consts.job_name
            self.job_address = external_job_consts.job_address
            self.job_city = external_job_consts.job_city
            self.reference = external_job_consts.reference
            self.travel_allowance = external_job_consts.travel_allowance
            print(f"\n-------------- Job_consts load from config file. This was a PREVIOUS JOB --------------:\n"

    def change_ref_and_ta(self, new_ref, new_ta):
        if new_ref != self.reference:
            if question_user(f"New job reference ({new_ref}) is different from "
                            f"current job ref ({self.reference})"
                            f", replace (y/n): ", 'y'):
                 self.reference = new_ref
        if new_ta != self.travel_allowance:
            if question_user(f"New travel allowance ({new_ta}) is different from "
                f"current travel allowance ({self.travel_allowance})"
                f", replace (y/n): ", 'y'):
                self.travel_allowance = new_ta

    def consts_from_str(self, inp_str):
        list_of_job_consts = inp_str.split("   ")           # See JobConsts.__str__
        list_of_job_consts = [s.strip(' ') for s in list_of_job_consts]

        for possible_const in list_of_job_consts:
            for string_key in JobConsts.TEXT_TO_MEMBER_DICT:
                if (string_key in possible_const):
                    new_job_const = possible_const.replace(string_key, "").strip()
                    rsetattr(self, JobConsts.TEXT_TO_MEMBER_DICT[string_key], new_job_const)

    # Not sure that this is neccessary ?
    def __iter__(self):
        return iter(self.__dict__)

    def __str__(self):
        SPACE = " "
        return (f"{SPACE:<0}Name: {self.job_name}{SPACE:<3}Job address: {self.job_address}"
            f"{SPACE:<3}City: {self.job_city}{SPACE:<3}Ref: {self.reference}"
            f"{SPACE:<3}Travel_allowance: {self.travel_allowance}")

class JobVariables:
    Each month will have a number of days that I worked on a particular job (as
    denoted by the JobConsts). Each data that I worked will have the number of hours
    I worked at that job on that date. And whether or not I'll be paid a travel
    allowance. Sometimes the job is local or I'm using a company vehicle and so
    I don't get paid a T.A. for that day.
    def __init__(self, date = 0, hrs = 0, TA_yes_no = True):
        self.TA_for_this_day = TA_yes_no
        self.date = date
        self.hours = hrs

    def set_job_vars(self, current_date , hours, TA_yes_no):
        self.TA_for_this_day = TA_yes_no
        self.date = current_date
        self.hours = hours

    def __str__(self):
        SPACE = " "
        return (f"{SPACE:<3}Job date: {self.date}"
            f"{SPACE:<3}Hours: {self.hours}{SPACE:<3}"
            f"{SPACE:<3}TA_for_this_day: {self.TA_for_this_day}")

class DailyJobData:
    DailyJobData form the 'units' that are kept in Month.All_Jobs and
    make up one invoice.
    def __init__(self):
        self.job_consts = JobConsts()
        self.job_vars = JobVariables()

    def __iter__(self):
        return iter(self.__dict__)

    def __str__(self):
        return f"{self.job_consts}{self.job_vars}"

    def add_days_work(self, current_date, hours, TA_yes_no):
        self.job_vars.set_job_vars(current_date, hours, TA_yes_no)
        print(f"{self.job_consts.job_name} : {self.job_consts.reference} :{self.job_vars}")


from JobStructures import *
from datetime import datetime, timedelta
from calendar import month_name, monthrange
from HelperFunctions import question_range_reply
import copy

class Month():

    All_Jobs = {}
    month = None
    today = None
    current_month = None
    current_month_number = 0
    previous_month = None
    last_day = None
    year = None

    # Because there isn't an instance of Month() I'm not using
    # __init__() again this all works but it feels a bit odd.
    # is this pythonic, just using a class without an instance ?
    def init():
        Month.today = datetime.now()
        Month.current_month = Month.today.strftime("%B")
        Month.current_month_number = int(Month.today.strftime("%m"))
        Month.previous_month = month_name[Month.today.month -1] or month_name[-1]
        Month.month = Month.current_month
        Month.year = Month.today.year

    def choose_this_or_previous():
        choice = question_range_reply(f"Are you entering data for (T)his month,"
                            f" {Month.current_month}, or the (P)revious month,"
                            f" {Month.previous_month}: ", str, ['t', 'p'])
        if choice == "p":
            Month.month = Month.previous_month
            if Month.current_month_number == 1:
                Month.last_day = 31
                Month.last_day = monthrange(Month.year, (Month.current_month_number - 1))[-1]
            Month.last_day = monthrange(Month.year, Month.current_month_number)[-1]

    def add_a_day_of_work_to_month(date , a_days_job_data):
        if not Month.All_Jobs:
            # Deepcopy because different versions of
            # a_days_job_data are the same object, otherwise
            # Month.All_Jobs values will all be the same, just
            # with different keys
            Month.All_Jobs[date] = copy.deepcopy(a_days_job_data)
        elif date not in Month.All_Jobs:
            Month.All_Jobs[date] = copy.deepcopy(a_days_job_data)
        else:   #if there's two jobs on the same date
            print("There are two jobs on this date:")
            print(f"1) {a_days_job_data}\n")
            print(f"2) {Month.All_Jobs[date]}")
            choice = question_range_reply("Which data do you want for this date: ", int,
                                            range(1, 3))
            if choice == 1:
                Month.All_Jobs[date] = copy.deepcopy(a_days_job_data)

HelperFunctions.py and Instructions.py

from os import listdir, system
from os.path import isfile, join, exists

import functools
import fileinput
import sys

# --------------------------------------------------------------------------

# These two functions created by unutbu
# https://stackoverflow.com/questions/31174295/getattr-and-setattr-on-nested-subobjects-chained-properties

def rsetattr(obj, attr, val):
    pre, _, post = attr.rpartition('.')
    return setattr(rgetattr(obj, pre) if pre else obj, post, val)

# using wonder's beautiful simplification: https://stackoverflow.com/questions/31174295/getattr-and-setattr-on-nested-objects/31174427?noredirect=1#comment86638618_31174427

def rgetattr(obj, attr, *args):
    def _getattr(obj, attr):
        return getattr(obj, attr, *args)
    return functools.reduce(_getattr, [obj] + attr.split('.'))

# --------------------------------------------------------------------------

def clear_screen():

def write_to_file(filename, text_to_be_written, search_pattern = "", mode ='w'):
    text_to_be_written should be in list form,  otherwise it'll
    write a line as a char per line.
    If mode is 'i' i.e insert it's expected that text_to_be_written should
    only be 1 element. It replaces the with the search_pattern in with
    if mode =='i':
            for line in fileinput.input(filename, inplace=1):
                if search_pattern in line:
                    line = text_to_be_written[0]
            print(f"{search_pattern} replaced with {text_to_be_written[0]} in {filename}")
        except IOError as e:
            print(f"Error {e.errno},  {e.strerror}, has occured writing to file :{filename}")
        except Exception as err:
           print(f"A general error has occured writing to file:{filename}.")

    if mode == "a":
        print("@write_to_file. APPEND MODE SELECTED")
        with open(filename, mode) as wtfile:
            for a_line in text_to_be_written:
                wtfile.write(str(a_line + '\n'))
        print(f"Saved {filename}\n")
    except IOError as e:
        print(f"Error {e.errno},  {e.strerror}, has occured writing to file :{filename}")
    except Exception as err:
       print(f"A general error has occured writing to file:{filename}.")
    print("Nothing has been saved")

def list_of_files_in_dir(directory_name = ".", file_ext = "" ) -> list:
    if not exists(directory_name):
        print(f"@ list_of_files_in_dir(): {directory_name} doesn't exist. Returning empty list")
        return []
    if isfile(directory_name):
        print(f"@ list_of_files_in_dir(): {directory_name} is a file not a directory. Returning empty list")
        return []
    return ([item for item in listdir(directory_name)
        if (isfile(join(directory_name, item)) and (file_ext in item))])
# Should these else statements raise an exception instead of returning an empty list ?

def read_strings_from_file(filename):
    Returns a list of strings (returnList) from filename, whatever the
    form of data contained within, this if it's all numeric data in the
     file you'll still get a list of strings.
    returnlist = []
    if not exists(filename):
        print(f"@read_strings_from_file(). {filename} doesn't exist. Returning empty list.")
        return []
    if not isfile(filename):
        print(f"@read_strings_from_file(). {filename} isn't a file. Returning empty list.")
        return []
        with open(filename) as fileobj:
            for line in fileobj:
        return returnlist
    except IOError as e:
        print(f"Error {e.errno},  {e.strerror}, has occured reading from file :{filename}. Returning empty list.")
    except Exception as err:
       print(f"A general error has occured reading from file:{filename}"
                f" It's {err.errno},  {err.strerror}. Returning empty list.")
    return []
# Should these else statements raise an exception instead of returning an empty list ?

def question_range_reply(question_str, type_paramT, range_param):
    Take input from the user (which should within a range, range_param)
    and returns a value (of type type_param).
    range_param can be in the form of a list. If user inputs a value
    outside of the range, an error message is printed and the user asked
    to input again.
            reply = type_paramT(input(question_str).strip())
        except ValueError:
            print(f"There's a problem with input, illegal charcters ?")
            if type(reply) == float:
                if ((reply >= float(range_param[0])) and
                    (reply < float(range_param[-1]))):
                    return reply
            if type(reply) == str:
                reply = reply.lower()
            if reply in range_param:
                return reply
        print(f"Input out of range, should be {range_param[0]} to {range_param[-1]}")

def question_user(question_str, reply_char) -> bool:
    A simpler version of the prevoius function
    reply = input(question_str)
    return reply.strip().lower() == reply_char


INSTRUCTIONS = "\n       --------------- Python Invoice Help Instructions ---------------\n"\
                "(Q)uit, (N)o travel allowance for that day, skip a (D)ay, skip (T)hree days, "\
                "skip a (W)eek go (B)ack a day\n(R)etreat a week, (P)rint current job, "\
                "(E)rase current date, new (J)ob Ref & Travel Allowance, (I)nformation, "\
                "\n(C)lear screen, (K)ill all data, (S)ave, (L)oad, Convert to PD(F), Change invoice (#) number\n"

# Should I prefer free functions as opposed to class methods ?
# Therefore i've moved it outside of InvoiceCreator,  is this right ?
def instructions():

InvoiceAsPDF.py I apologise for the quality of code in this module. I had a deadline to meet and just had to get something working. The comments detail some of the errors I've made. If there are aspects I've missed in terms of bad code/design, please let me know.

from fpdf import *
from Month import Month
from HelperFunctions import question_range_reply

# The following PDF class is terribly written, it was a case of
# getting something working as quickly as possible and I haven't had
# the time to refine it. Major problems as I see it:-
# Separation of concerns, it's tightly coupled to InvoiceCreator,
# It could be broken down into smaller functions,  making it easier to read.
# I've included it for completeness, and perhaps it'll serve as a good
# example of how not to write a class, but sometimes 'it gets dirty'...
# I've peppered the following code with my own critique,  if there's other
# points to be made, please do so..

class PDF(FPDF):
    def titles(self, invoice_number, invoice, days_worked, month):
        dw_len = len(days_worked) - 2 # grand total and the table titles
        if dw_len < 1:
            print("Not enough data going to PDF for an invoice !")
        # Working out the spaceings could be done elsewhere
        PAGE_WIDTH_MM = 210
        client_n_address = ["Bobs Construction (North West) Ltd,", "4 Springton Road,", "Marksbury, Mk12 9DQ"]
        banner_height18 = 18
        banner_height12 = 12
        banner_height13 = 13
        banner_height15 = 15
        spacing = 0
        spacing2 = 0
        spacings = (4, 6, 16, 27)
        spacings2 = (8, 11, 16, 23)
        possible_lengths = (21, 14, 7, 0)
        for idx, l in enumerate(possible_lengths):
            if dw_len >= l:
                spacing = spacings[idx]
                spacing2 = spacings2[idx]
        ypos = 10
        self.set_xy(0.0, ypos)
        self.set_font('DejaVu-Bold', '', 18)
        self.set_text_color(0, 0, 0)
        self.cell(w = 210.0, h = banner_height18, align = 'C', txt = "Loz Lamarais", border = 0)
        ypos += spacing2

        self.set_xy(0.0, ypos)
        self.set_font('DejaVu', '', 12)
        self.cell(w = 210.0, h = banner_height12, align = 'C', txt = "Lozminda Lamarais:  10 Conneticette Street, Birmingham, B1 4TQ.", border = 0)
        ypos += banner_height12 - 6

        self.set_xy(0.0, ypos)
        self.cell(w = 210.0, h = banner_height12, align = 'C', txt = "Tel: 07955 565656", border = 0)
        ypos += banner_height12

        self.set_font('DejaVu-Bold', '', 12)
        self.set_xy(0.0, ypos)
        self.cell(w = 210.0, h = banner_height12, align = 'C', txt = "My References: Nat Ins MN 56 78 78B, UTR 46574 585484 ", border = 0)
        ypos += spacing2

        self.set_xy(0.0, ypos)
        self.set_font('DejaVu-Bold', '', 18)
        self.cell(w = 210.0, h = banner_height18, align = 'C', txt = "INVOICE", border = 0)
        ypos += spacing2

        self.set_xy(10.0, ypos)
        self.set_font('DejaVu-Bold', '', 13)
        self.cell(w = 10.0, h = banner_height13, align = 'L', txt = "To:", border = 0)
        self.set_xy(20.0, ypos)
        ypos_to = ypos
        self.set_font('DejaVu', '', 13)
        for line in client_n_address:
            self.cell(w = 100.0, h = banner_height13, align = 'L', txt = line , border = 0)
            ypos += banner_height13 - 8
            self.set_xy(20.0, ypos)

        ypos_to += 5
        self.set_xy(135.0, ypos_to)
        self.set_font('DejaVu-Bold', '', 12)
        self.cell(w = (70.0 / 3), h = ((banner_height13 / 3) + 2), align = 'C', txt = "Number:", border = 1, ln = 0)
        self.cell(w = (70.0 / 3), h = ((banner_height13 / 3) + 2), align = 'C', txt = str(invoice_number), border = 1, ln = 0)

        self.set_xy(135.0, (ypos_to + 8))
        self.set_font('DejaVu-Bold', '', 8)
        self.cell(w = (70.0 / 3), h = ((banner_height13 / 3) + 2), align = 'C', txt = "Issue Date :", border = 1)
        self.set_font('DejaVu-Bold', '', 6)
        self.cell(w = (70.0 / 3), h = ((banner_height13 / 3) + 2), align = 'C',
                    txt = (str(Month.last_day) + " " + Month.month + " " + str(Month.year)), border = 1, ln = 0)

        ypos += spacing  # TODO this can be a spacer depending on size of days worked
        self.set_font('DejaVu-Bold', '', 15)
        self.set_xy(0.0, ypos)
        description = "For work carried out at " + invoice.job_consts.job_name + ", " + invoice.job_consts.job_address
        description2 = invoice.job_consts.job_city
        description3 = "Month ending " + str(Month.last_day) + " " + Month.month + " " + str(Month.year)
        self.cell(w = 210, h = ((banner_height13 / 3) + 2), align = 'C', txt = description, border = 0, ln = 1)
        self.cell(w = 210, h = ((banner_height13 / 3) + 2), align = 'C', txt = description2, border = 0, ln = 1)
        self.cell(w = 210, h = ((banner_height13 / 3) + 2), align = 'C', txt = description3, border = 0, ln = 1)
        ypos += 20 + spacing
        self.set_xy(0.0, ypos)
        # create table data

        table_widths = (18, 100, 32, 32)
        table_alignments = ('C', 'L', 'C', 'R')
        cell_start = (0, 0, 0, 1)

        self.set_font('DejaVu-Bold', '', 11)

        # This table could be a separate function/method
        half_length = True  # To add 6.5 to ypos ie (6, 7, 6, 7)
        for day in days_worked[:-1]:
            self.x = int((PAGE_WIDTH_MM - sum(table_widths)) / 2)
            pos = 0
            table_idx = 0
            ypos += (5 + int(half_length))
            # ypos += 6
            half_length = not half_length
            for index, char in enumerate(day):
                if char == '\t':
                    text = day[pos:index]
                    self.cell(w = table_widths[table_idx], h = ((banner_height12 / 3) + 1),
                        align = table_alignments[table_idx], txt = text, border = 1, ln = cell_start[table_idx])
                    pos = index + 1
                    table_idx += 1
            self.set_font('DejaVu', '', 10)

        self.set_xy(0, ypos)
        self.set_font('DejaVu-Bold', '', 13)
        tot_str = f"Total: £{float(str(*days_worked[-1:])):.2f}"
        self.cell(w = 210, h = 6, align = 'C', txt = tot_str, border = 0, ln = 1)

        # This should be outside class and passed as a parameter
        expenses = question_range_reply("Any expenses? If input is 0 nothing will appear on pdf ",
                                        float, [0, 5000.00])
        self.x = 0
        if expenses != 0:
            temp_text = f"Expenses: £{expenses:.2f}"
            self.cell(w = 210, h = 6, align = 'C', txt = temp_text, border = 0, ln = 1)

        self.y = 255
        self.set_font('DejaVu', '', 11)
        self.set_text_color(100, 20, 0)
        self.cell(w = 200, h = (banner_height13 / 3) + 2, align = 'C',
            txt = "Bank Details: Acc Name: L. Q. Lamarais, Acc Number ******** , Sort Code 00-00-00",
            border = 0, ln = 1)
        self.set_font('DejaVu', '', 12)
        self.set_text_color(0, 0, 0)
        self.cell(w = 210, h = (banner_height13 / 3) + 2, align = 'C', txt = "Please make cheques payable to L. Lamarais",
            border = 0, ln = 1)
        self.cell(w = 170, h = (banner_height13 / 3) + 2, align = 'R', txt = "Thank you", border = 0, ln = 1)

And finally the config file manager and the config file itself: ConfigManagerClass.py

from os import path
from JobStructures import *
from HelperFunctions import read_strings_from_file, write_to_file, question_range_reply
import sys

class ConfigReturnData:

    def __init__(self):
        self.job_consts = JobConsts()
        self.invoice_number = 0
        self.pay_rate = 0
        self.destination_path = ""

class ConfigManager():

    possible_jobs = []

    def get_config_data(self, filename):
        self.CONFIG_FILE_DICT = {
            "Rate Of Pay:" : self.set_pay_rate,
            "Destination Path:" : self.set_destination_path,
            "Last Invoice Number:" : self.set_invoice_number,
            "Name:" : self.add_job_to_possible_jobs
        self.config_file_str = ""
        self.return_data = ConfigReturnData()
        return self.return_data

    def load_config(self, filename):
        config_file_data = read_strings_from_file(filename)
        for line in config_file_data:
            for key in self.CONFIG_FILE_DICT:
                if (key in line):
                        self.config_file_str = line.replace(key,"").strip()

        if len(ConfigManager.possible_jobs) > 0:
            self.return_data.job_consts = None

    def save_new_job_consts_to_config(self, filename, job_consts):
        write_to_file(filename, [f"{job_consts}"], mode = 'a')

    def set_pay_rate(self):
            self.return_data.pay_rate = float(self.config_file_str)
        except ValueError:
            print("Illegal value load_pay_rate in config file, program terminating\n")
            print('\nRate of Pay',self.return_data.pay_rate, end="    ")

    def add_job_to_possible_jobs(self):

        # Because 'Name:' was stripped off in calling loop
        self.config_file_str = "Name: " + self.config_file_str
        temp = JobConsts()

    def user_choose_job(self):
        for idx, job in enumerate(ConfigManager.possible_jobs):
            print(f"({(idx + 1)}){job}")
        choice = question_range_reply("\nIs this invoice for any of the preceding jobs, if input"
                        f" number else just press 0 plus RETURN to input new info: ",
                        int, range(0, (idx + 2)))
        if choice != 0:
            job_number = int(choice) - 1
            self.return_data.job_consts.job_name = ConfigManager.possible_jobs[job_number].job_name
            self.return_data.job_consts.job_address = ConfigManager.possible_jobs[job_number].job_address
            self.return_data.job_consts.job_city = ConfigManager.possible_jobs[job_number].job_city
            self.return_data.job_consts.reference = ConfigManager.possible_jobs[job_number].reference
            self.return_data.job_consts.travel_allowance = ConfigManager.possible_jobs[job_number].travel_allowance

    def set_invoice_number(self):
            self.return_data.invoice_number = int(self.config_file_str)
        except ValueError:
            print("Illegal value invoice_number in config file, program terminating\n")
            print(f'Last Invoice Number :{self.return_data.invoice_number}\n' )

    def set_destination_path(self):
        path_str = self.config_file_str.strip()
        if not path.exists(path_str):
            print(f"Directory {path_str}, for invoices doesn't exist,  program terminating (but perhaps i should do something about this)\n")
            self.return_data.destination_path = path_str

config file

Rate Of Pay:20.00
Destination Path:/home/hp-350/Desktop/Invoices/
Last Invoice Number:5000
Name: Unit 6   Job address:Edward Court Industrial Estate   City:Woking   Ref:266 3198   Travel_allowance: 50
Name: BrownFields Community Centre   Job address: Thicket Rd   City: Bristol   Ref: 299 3190   Travel_allowance: 40
Name: Former QED Building   Job address: Cloud Way   City: Cirencester   Ref: 300 3200   Travel_allowance: 55.0
 Name: Units 10-12   Job address: Sharps Close   City: Swindon   Ref: 500 3169   Travel_allowance: 35.0
  • \$\begingroup\$ I thought my functions and method were in the wrong case, but then i saw this post: stackoverflow.com/questions/8908760/… \$\endgroup\$
    – Lozminda
    May 11, 2023 at 5:03
  • \$\begingroup\$ It came to me in the shower: I probably shouldn't be taking user input in __init__() and should have a class/function that handles all input and then either returns data to some kind of handling function that then passes that data to my objects, or just pass the objects to the key_board_input function (as parameters) and initialise them within that. I'm assuming the first option would be better..? Either would remove my concerns about builder pattern. \$\endgroup\$
    – Lozminda
    May 11, 2023 at 13:15
  • \$\begingroup\$ My title has been edited to a statement though this is the dialog in the title box: " Title :Be specific and imagine you’re asking a question to another person" thus my original title was a question: Could Sᴀᴍ Onᴇᴌᴀ perhaps clarify please, I'm assuming it was you that edited my post ? \$\endgroup\$
    – Lozminda
    May 11, 2023 at 13:23
  • \$\begingroup\$ Yes, there's a lot of feedback; no, don't break it up into separate questions. \$\endgroup\$
    – Reinderien
    May 11, 2023 at 13:39
  • \$\begingroup\$ I was aware of the size (and definitely appreciate the work folks have done) but remember seeing a SECR post broadly saying keep it small for SO but for CR it's fine. Here's a post that says something similar: codereview.meta.stackexchange.com/questions/1338/… as I can't find the origional. \$\endgroup\$
    – Lozminda
    May 12, 2023 at 14:32

2 Answers 2


I agree that there is too much code to review at once. A tool where comments can be made in-line would be helpful, but in general the smaller the code review, the easier it is to grasp the contents. There are also too many questions hiding under one.

However, here are some observations:

  • No tests... tests document the code being reviewed and would explain the purpose.
  • there are some spelling mistakes in comments, would be great to automate validation of those.
  • Some comments are too describing and will rot quickly. E.g. "InvoiceCreator uses it's own methods and those from HelperFunctions to store..." What if you start calling another helper class? I'd just remove this part.
  • in add_a_day_of_work_to_month two first if cases do the same.
  • Month.py is interesting - you end up dealing with static variables for Month class. Note the difference from DailyJobData - all functions are augmented with self parameter. Also see https://stackoverflow.com/questions/4109552/python-class-definition-syntax - class Month: is preferred to class Month():. Here is demonstration:
class Month:
    def a(self):
        self.one = 1
    def b():
        Month.one = 3

print(f"{Month.one} {z.one}")
  • Month class is hard to comprehend by name. JobsInMonth might be better name.
  • interacting with user in ConfigManager is a bit surprising in first read.
  • instead of sys.exit(1) - would it be possible to throw and let the main handle all things?
  • instead of "self.job_consts.reset()" you could do ...job_consts = JobConsts()
  • key in line is dangerous - what if line contains the key after the beginning? Could use line.strip().startswith better?
  • Input acquisition is split across many locations and coupled with parsing of it. For example, process_input takes input and then tries to parse it. Name could be more specific, maybe "handle_input_command"? But parsing of it should be a separate function.
  • Is config file a "configuration"? It sounds like it is actually a data file for all jobs done instead.

Here is what I think is happening:

  • Month.py seems to point out that you are learning python. Please read https://www.geeksforgeeks.org/g-fact-34-class-or-static-variables-in-python/
  • Python has amazing unittest library and that one should be used - https://docs.python.org/3/library/unittest.html
  • In object oriented programming, each class should have one purpose. ConfigManager is several: parsing and container of data file contents and also user choice of which job to work on and some description of output. I'd split this into JobData that would contain parsed file into memory and then add additional functionality somewhere else to choose the job and create the return_data structure.
  • \$\begingroup\$ Re typos in the comments, some of them were flagged due to British English vs U.S. English, and I went with British, being a Brit. (Much as I quite like 'Z's in my post-fixes). (I accept your point BTW). 2 questions (if I may): (1) Is it better to get something out than spend forever trying for perfection? (2) What to do about international language variations ? \$\endgroup\$
    – Lozminda
    May 12, 2023 at 14:43
  • \$\begingroup\$ Re. 'Static' Month, there is only ever one month (for the purposes of this program), and had I'd been writing it in C++ I'd have used the 'static' keyword. Your comment re. this indicates (certainly) a failure of documentation (on my part), naming (perhaps it should have been named 'Invoice') and code design (I had a change of design brief 2/3 the way through). I've got some code revisions/redesigns, which hopefully I'll post and refer back to my OP and these helpful reviews. Time is the issue, so it might not be for a while, unfortunately. But thanks to you both. \$\endgroup\$
    – Lozminda
    May 13, 2023 at 15:53

There is a lot of code to review, so I will just pick a few points gratuitously.

Let's consider the function read_strings_from_file

def read_strings_from_file(filename):
    Returns a list of strings (returnList) from filename, whatever the
    form of data contained within, this if it's all numeric data in the
     file you'll still get a list of strings.
    returnlist = []
    if not exists(filename):
        print(f"@read_strings_from_file(). {filename} doesn't exist. Returning empty list.")
        return []
    if not isfile(filename):
        print(f"@read_strings_from_file(). {filename} isn't a file. Returning empty list.")
        return []
        with open(filename) as fileobj:
            for line in fileobj:
        return returnlist
    except IOError as e:
        print(f"Error {e.errno},  {e.strerror}, has occured reading from file :{filename}. Returning empty list.")
    except Exception as err:
       print(f"A general error has occured reading from file:{filename}"
                f" It's {err.errno},  {err.strerror}. Returning empty list.")
    return []

It has too much exception "handling". if not exists and if not isfile(filename) are redundant. Perhaps you could just let the default exception handler take over here, or raise your own exception.

You decide to return an empty list in case of error, but then it is hard for any calling routing to determine if the call to read_strings_from_file was successful or not. An empty file will not return any items but the routine will not fail either. It may not be desirable behavior: if you depend on this routine to load a configuration file, will your application still behave reliably at all?

Then you proceed to open the file, read it line by line, feed a list (returnlist) and again you have another piece of exception handling. Note that there is a bit of redundancy here as well, because FileNotFoundError is a subclass of IOError.

If you had to have only one exception handler in this function, I guess you would want to catch IOError and that could be enough. The details of the exception can be gleaned from the stacktrace anyway. It's not like you need fine-grained differentiation of the causes or errors.

Take advantage of built-in python functions. What you need is no more than that actually:

def read_strings_from_file(filename):
    with open(filename) as f:
        return f.readlines()

The function name write_to_file is misleading when you consider what it effectively does. It's more like replace_in_file or similar. It's not very intuitive to use and lacks consistency because the search pattern is a string while text_to_be_written is a list. The fact that you've documented this oddity is no real consolation.

Again, too much exception handling here and it is distracting. There is more exception handling than actual useful code. And it's not like you are doing anything special to recover gracefully from the errors that might arise here. So either let Python take over, or (preferred), define a single, all-purpose exception routine that will take care of catching and logging exceptions when they occur. There is no need to cover every function, except where you expect errors to happen and you want to handle them in a specific way.

Perhaps what you need instead is something else, a template string or a templating engine such as Jinja perhaps. I would need to have a more in-depth look at your code so I'll leave it at that for the moment. But it's clear that there are quite a few hardcoded strings, so I think at a minimum they should go in a separate file, along with transformation rules. It should be possible to maintain the list of transformation rules without touching the class files.

Speaking of consistency, the error messages vary widely depending on the type of error:

except IOError as e:
    print(f"Error {e.errno},  {e.strerror}, has occured writing to file :{filename}")
except Exception as err:
   print(f"A general error has occured writing to file:{filename}.")

Unfortunately the section of last resort (except Exception) does not yield any useful information. You know that the function failed but no idea why because you are not retrieving the details of the exception and that will make debugging more difficult as a result, especially with transient errors that cannot be reproduced at will. It would be better to do nothing, then Python will at least show an ugly but actionable message.

Suggestion: use the logging module to log your errors to file (and console too). That will be an improvement over the prints.

The same remarks could be made about list_of_files_in_dir for example. You chose to use os.listdir but there are alternatives like scandir not to mention what the pathlib module could do for you. But if you're interested in filtering a list of files bearing a certain extension then the glob module seems more appropriate.

The code could be more concise, and it would become slightly more legible as a result.

Some function names like __assign_job_data_from_str are hard for me to comprehend. __loading is too broad to be meaningful. Keep in mind that I have not run your code, merely looked over it.


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.