Coverage for src/sankey_cashflow/io.py: 44%

109 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-05 04:36 +0000

1from csv import DictReader 

2from os import path 

3from pathlib import Path 

4from typing import Union 

5 

6import pandas as pd 

7import pygsheets 

8 

9from .data_row import DataRow 

10from .utils import logger, normalize_amounts 

11 

12 

13def fetch_data(app_settings_obj): # source_spreadsheet, source_worksheet, csv_src_target, service_account_credentials): 

14 # app_settings_obj.data_source,app_settings_obj.data_sheet,app_settings_obj.labels_source,app_settings_obj.g_creds 

15 # WIP: Handle source data in mutiple sheets, eg. 1 sheet per year... 

16 

17 """ 

18 self.data_source = args.source # A csv file or a google Sheets document containing transactions data. 

19 (In the case of the latter, a sheet name must be provided as well) 

20 self.data_sheet = args.sheet or "Transactions_*" # The name (or prefix plus wildcard) of the sheet containing 

21 the transactions data (if data_source is a google Sheets document 

22 self._labels_source = args.srcmap or "Sources-Targets" #A csv file or sheet name containing sources and targets 

23 """ 

24 def data_source_router( 

25 filename: str, 

26 file_kind: str, 

27 gcreds, 

28 sheetname: Union[str, None] = None, 

29 gcreds_obj=None, 

30 gsheets_obj=None): 

31 """ 

32 Calls itself recursively, looking for data sources either locally or in Google Sheets and returns the first hit. 

33 Handle wildcards expressions for multiple sheets. 

34 Return a dict containing a list of filenames. 

35 :param filename: A filename or wildcard expression, or None (Note: only csv files will use the wildcard 

36 expression for this value) 

37 :param file_kind: "sources-targets" or "transactions" 

38 :param gcreds: Google credentials object 

39 :param sheetname: A sheet name or wildcard expression, or None (only applicable to Google Sheets) 

40 :param gc: Google client object (optional) 

41 

42 Returns: { 

43 "filename" -> list of strings, 

44 "sheetname" -> list of strings, 

45 "filetype" -> ["csv", "gsheet"], 

46 "file_kind" -> ["sources-targets","transactions"], 

47 "gcreds_obj" -> Google credentials object", 

48 "gsheets_obj" -> Google sheets object 

49 } 

50 """ 

51 file_kind = file_kind.lower() 

52 if file_kind not in ["sources-targets", "transactions"]: 

53 raise Exception(f"Invalid file kind: {file_kind}") 

54 if filename is None: 

55 filename = input(f"Enter location for {file_kind} data: ") 

56 return data_source_router(filename, file_kind, gcreds, sheetname, gcreds_obj, gsheets_obj) 

57 if filename.endswith(".csv"): 

58 if path.isfile(filename): # Switch to pathlib? 

59 return { 

60 "filename": [filename], 

61 "sheetname": [None], 

62 "filetype": "csv", 

63 "file_kind": file_kind, 

64 "gcreds_obj": gcreds_obj, 

65 "gsheets_obj": gsheets_obj} 

66 logger.warning(f"File not found: {filename}") 

67 return data_source_router(None, file_kind, gcreds, sheetname, gcreds_obj, gsheets_obj) 

68 if filename.endswith("*"): 

69 # Wildcards on filenames only supported for csvs, Gsheets would use sheetnames for wildcard. 

70 fileparts = filename.split("/") 

71 filepattern = f"{fileparts[-1]}.csv" 

72 if len(fileparts[0]) == 0: 

73 # This must be an absolute path 

74 this_dir = "/" + "/".join(fileparts[1:-1]) 

75 else: 

76 # Possibly a relative path 

77 if len(fileparts) > 1: 

78 this_dir = "./" + "/".join(fileparts[:-1]) 

79 else: 

80 this_dir = "." 

81 dir_obj = Path(this_dir) 

82 if dir_obj.is_dir(): 

83 files = list(dir_obj.glob(filepattern)) 

84 if len(files) > 0: 

85 return {"filename": [str(f) for f in files], 

86 "sheetname": [None], 

87 "filetype": "csv", 

88 "file_kind": file_kind, 

89 "gcreds_obj": gcreds_obj, 

90 "gsheets_obj": gsheets_obj} 

91 logger.warning("No files found at: {filename}") 

92 return data_source_router(None, file_kind, gcreds, sheetname, gcreds_obj, gsheets_obj) 

93 # If we've gotten this far, we must be looking for a Google Sheets file (late binding of the 

94 # gc object to reduce calls to authorize) 

95 if not gcreds_obj: 

96 gcreds_obj = pygsheets.authorize(service_file=gcreds) # trying to avoid calling this multiple times 

97 if filename not in gcreds_obj.spreadsheet_titles(): 

98 logger.warning(f"Spreadsheet not found: {filename}") 

99 return data_source_router(None, file_kind, gcreds, sheetname, gcreds_obj, gsheets_obj) 

100 gsheets_obj = gcreds_obj.open(filename) # TODO: error handling. 

101 if not sheetname: 

102 sheetname = input(f"Enter worksheet title for spreadsheet {filename}: ") 

103 return data_source_router(filename, file_kind, gcreds, sheetname, gcreds_obj, gsheets_obj) 

104 gsheet_titles = [i.title for i in gsheets_obj.worksheets()] 

105 if sheetname.endswith("*"): 

106 # Wildcard handling for sheetnames 

107 results = [] 

108 for sheet in gsheet_titles: 

109 if sheet.startswith(sheetname[:-1]): 

110 results.append(sheet) 

111 if len(results) > 0: 

112 return { 

113 "filename": [filename], 

114 "sheetname": results, 

115 "filetype": "gsheet", 

116 "file_kind": file_kind, 

117 "gcreds_obj": gcreds_obj, 

118 "gsheets_obj": gsheets_obj} 

119 logger.warning(f"No spreadsheets found matching pattern: \"{sheetname}\" in Google Sheets: \"{filename}\"") 

120 return data_source_router(filename, file_kind, gcreds, None, gcreds_obj, gsheets_obj) 

121 if sheetname in gsheet_titles: 

122 return {"filename": [filename], 

123 "sheetname": [sheetname], 

124 "filetype": "gsheet", 

125 "file_kind": file_kind, 

126 "gcreds_obj": gcreds_obj, 

127 "gsheets_obj": gsheets_obj} 

128 logger.warning(f"Spreadsheet \"{sheetname}\" not found in Google Sheets: \"{filename}\"") 

129 return data_source_router(filename, file_kind, gcreds, None, gcreds_obj, gsheets_obj) 

130 

131 try: 

132 # Get sources-targets file location data 

133 # See sample_data/expenses.csv and labels.csv for examples of data format. 

134 # TODO: validation & error handling 

135 # TODO: there should only ever be one sources-targets file, so data_source_router should handle that 

136 src_target = None 

137 if app_settings_obj.labels_source.endswith(".csv"): 

138 src_target_file = data_source_router(app_settings_obj.labels_source, 

139 "sources-targets", 

140 app_settings_obj.g_creds, None, None, None) 

141 else: 

142 src_target_file = data_source_router(app_settings_obj.data_source, 

143 "sources-targets", 

144 app_settings_obj.g_creds, 

145 app_settings_obj.labels_source, None, None) 

146 if src_target_file["filetype"] == "csv": 

147 # Sources-targets data is returned differently than transactions data, so we need to handle it differently. 

148 with open(src_target_file["filename"][0], 'r') as f: 

149 dr = DictReader(f) 

150 src_target = list(dr) 

151 else: 

152 # Fetch source-target info and colors. 

153 src_target = src_target_file["gsheets_obj"].\ 

154 worksheet_by_title(src_target_file["sheetname"][0]).get_all_records() 

155 

156 # Get transactions file(s) location data 

157 df = None 

158 transaction_data_file = data_source_router(app_settings_obj.data_source, 

159 "transactions", 

160 app_settings_obj.g_creds, 

161 app_settings_obj.data_sheet, None, None) 

162 if transaction_data_file["filetype"] == "csv": 

163 # open one or multiple csv files and return a pandas dataframe 

164 df = read_csv_as_df(transaction_data_file["filename"]) 

165 else: 

166 # Open one or multiple gsheet worksheets and return a pandas dataframe 

167 df = read_gsheet_as_df(transaction_data_file["sheetname"], transaction_data_file["gsheets_obj"]) 

168 

169 except Exception as e: 

170 logger.error(f"Unable to open data source: {app_settings_obj.data_source}. \ 

171 Please check your names and try again. Error was {e}") 

172 raise SystemExit 

173 

174 # Clean up value formatting 

175 df.reset_index(inplace=True, drop=True) 

176 df = df.transform(normalize_amounts, axis=1) 

177 

178 is_valid = DataRow.validate(df.columns.to_list(), True) 

179 if not is_valid[0]: 

180 raise Exception(f"Source data is not in correct format! Message was: {is_valid[1]}") 

181 

182 return src_target, df 

183 

184 

185def read_csv_as_df(csv_file): 

186 if type(csv_file) is list: 

187 main_csv = csv_file[0] 

188 addl_csvs = csv_file[1:] 

189 else: 

190 main_csv = csv_file 

191 addl_csvs = [] 

192 if not main_csv.endswith('.csv') or not path.isfile(main_csv): 

193 raise Exception(f"Supplied CSV file ({main_csv}) was not found or is the wrong format.") 

194 main_df = pd.DataFrame(pd.read_csv(main_csv)) 

195 for i in addl_csvs: 

196 if not i.endswith('.csv') or not path.isfile(i): 

197 raise Exception(f"Supplied CSV file ({i}) was not found or is the wrong format.") 

198 add_df = pd.DataFrame(pd.read_csv(i)) 

199 main_df = pd.concat([main_df, add_df], axis=0) 

200 return main_df 

201 

202 

203def read_gsheet_as_df(gsheet_file, gsheet_obj): 

204 # .get_as_df(value_render=pygsheets.ValueRenderOption.UNFORMATTED_VALUE) 

205 # # <-- Using unformatted value rounded decimal amounts - not sure why 

206 if type(gsheet_file) is list: 

207 main_sheet = gsheet_file[0] 

208 addl_sheets = gsheet_file[1:] 

209 else: 

210 main_sheet = gsheet_file 

211 addl_sheets = [] 

212 main_df = gsheet_obj.worksheet_by_title(main_sheet).get_as_df() 

213 for i in addl_sheets: 

214 add_df = gsheet_obj.worksheet_by_title(i).get_as_df() 

215 main_df = pd.concat([main_df, add_df], axis=0) 

216 return main_df