Coverage for src/xlbudget/commands.py: 67.69%

106 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2024-01-05 07:44 +0000

1"""The commands, implemented as implementations of the abstract class `Command`.""" 

2 

3import os 

4import sys 

5from abc import ABC, abstractmethod 

6from argparse import ArgumentParser, Namespace, _SubParsersAction 

7from logging import getLogger 

8from typing import List, Optional, Type 

9 

10from openpyxl import Workbook, load_workbook 

11 

12from xlbudget.inputformat import GetInputFormats, InputFormat, parse_input 

13from xlbudget.rwxlb import update_xlbudget 

14 

15logger = getLogger(__name__) 

16 

17 

18class Command(ABC): 

19 """The abstract class that the command implementations implement. 

20 

21 Attributes: Class Attributes 

22 default_path (str): The default path of the xlbudget file. 

23 

24 Attributes: 

25 trial (bool): If True, the xlbudget file will not be written to. 

26 path (str): The path to the xlbudget file. 

27 """ 

28 

29 default_path: str = "xlbudget.xlsx" 

30 

31 @property 

32 @abstractmethod 

33 def name(self) -> str: 

34 """Ensures that the `name` class attribute is defined in subclasses. 

35 Part 1/2 of the abstract attribute implementation of `name`. 

36 Reference: https://stackoverflow.com/a/53417582. 

37 """ 

38 raise NotImplementedError 

39 

40 def get_name(self) -> str: 

41 """Used to access the `name` class attribute defined in subclasses. 

42 Part 2/2 of the abstract attribute implementation of `name`. 

43 Reference: https://stackoverflow.com/a/53417582. 

44 """ 

45 return self.name 

46 

47 @property 

48 @abstractmethod 

49 def aliases(self) -> List[str]: 

50 """Ensures that the `aliases` class attribute is defined in subclasses. 

51 Part 1/2 of the abstract attribute implementation of `aliases`. 

52 Reference: https://stackoverflow.com/a/53417582. 

53 """ 

54 raise NotImplementedError 

55 

56 def get_aliases(self) -> List[str]: 

57 """Used to access the `aliases` class attribute defined in subclasses. 

58 Part 2/2 of the abstract attribute implementation of `aliases`. 

59 Reference: https://stackoverflow.com/a/53417582. 

60 """ 

61 return self.aliases 

62 

63 @classmethod 

64 def configure_common_args(cls, parser: ArgumentParser) -> None: 

65 """Configures the arguments that are used by all commands. 

66 

67 Args: 

68 parser (ArgumentParser): The argument parser. 

69 """ 

70 parser.add_argument( 

71 "-t", 

72 "--trial", 

73 action="store_true", 

74 help="try a command without generating/updating the xlbudget file", 

75 ) 

76 parser.add_argument( 

77 "-p", 

78 "--path", 

79 help="path to the xlbudget file (default: %(default)s)", 

80 default=cls.default_path, 

81 ) 

82 

83 @classmethod 

84 @abstractmethod 

85 def configure_args(cls, subparsers: _SubParsersAction) -> None: 

86 pass 

87 

88 @abstractmethod 

89 def __init__(self, args: Namespace) -> None: 

90 self.trial = args.trial 

91 

92 self._check_path(args.path) 

93 self.path = args.path 

94 

95 @staticmethod 

96 def _check_path(path: str) -> None: 

97 """Check that `path` is a valid path to an xlbudget file. 

98 

99 Args: 

100 path (str): The xlbudget path. 

101 

102 Raises: 

103 ValueError: If `path` is not a XLSX file. 

104 FileNotFoundError: If `path` is not in an existing directory. 

105 """ 

106 xlsx_ext = ".xlsx" 

107 if not path.endswith(xlsx_ext): 

108 raise ValueError(f"Path '{path}' does not end with '{xlsx_ext}'") 

109 

110 dir = os.path.dirname(path) 

111 if dir and not os.path.isdir(dir): 

112 raise FileNotFoundError(f"Directory '{dir}' does not exist") 

113 

114 @abstractmethod 

115 def run(self) -> None: 

116 pass 

117 

118 

119class Update(Command): 

120 """The `update` command updates an existing xlbudget file. 

121 

122 Attributes: Class Attributes 

123 name (str): The command's CLI name. 

124 aliases (List[str]): The command's CLI aliases. 

125 

126 Attributes: 

127 input (Optional[str]): The path to the input file, otherwise paste in terminal. 

128 format (inputformat.InputFormat): The input file format. 

129 year (Optional[str]): The year all transactions were made, only relevant if 

130 the input format is 'BMO_CC_ADOBE'. 

131 """ 

132 

133 name: str = "update" 

134 aliases: List[str] = ["u"] 

135 

136 @classmethod 

137 def configure_args(cls, subparsers: _SubParsersAction) -> None: 

138 """Configures the argument parser for the `update` command. 

139 

140 Args: 

141 subparsers (_SubParsersAction): The command `subparsers`. 

142 """ 

143 parser = _add_parser( 

144 subparsers, 

145 name=cls.name, 

146 aliases=cls.aliases, 

147 help="update an existing xlbudget file", 

148 cmd_cls=Update, 

149 ) 

150 

151 # required arguments 

152 parser.add_argument( 

153 "format", 

154 action=GetInputFormats, 

155 choices=GetInputFormats.input_formats.keys(), 

156 help="select an input format", 

157 ) 

158 

159 # optional arguments 

160 parser.add_argument("-i", "--input", help="path to the input file") 

161 parser.add_argument( 

162 "-y", 

163 "--year", 

164 help="year that all transactions were made, only relevant if input format " 

165 "is 'BMO_CC_ADOBE'", 

166 ) 

167 

168 def __init__(self, args: Namespace) -> None: 

169 super().__init__(args) 

170 

171 self._check_input(args.input, args.format, args.year) 

172 self.input = args.input 

173 self.format = args.format 

174 self.year = args.year 

175 

176 logger.debug(f"instance variables: {vars(self)}") 

177 

178 @staticmethod 

179 def _check_input( 

180 input: Optional[str], input_format: Optional[InputFormat], year: Optional[str] 

181 ) -> None: 

182 """Check that `input` and `year` are valid. 

183 

184 Args: 

185 input (Optional[str]): The input path. 

186 input_format (Optional[InputFormat]): The input format. 

187 year (Optional[str]): The year of all transactions. 

188 

189 Raises: 

190 ValueError: If `input` is not None and the wrong file extension or DNE. 

191 ValueError: If `year` is None when `input_format` is 'BMO_CC_ADOBE'. 

192 """ 

193 if input is None: 193 ↛ 194line 193 didn't jump to line 194, because the condition on line 193 was never true

194 return 

195 

196 in_ext = (".csv", ".tsv", ".txt") 

197 if not input.endswith(in_ext): 

198 raise ValueError(f"Input '{input}' does not end with one of '{in_ext}'") 

199 

200 if not os.path.isfile(input): 

201 raise ValueError(f"Input '{input}' is not an existing file") 

202 

203 # get key from value: https://stackoverflow.com/a/13149770 

204 if input_format is not None: 

205 # validate year 

206 format = list(GetInputFormats.input_formats.keys())[ 

207 list(GetInputFormats.input_formats.values()).index(input_format) 

208 ] 

209 if format == "BMO_CC_ADOBE" and year is None: 209 ↛ 210line 209 didn't jump to line 210, because the condition on line 209 was never true

210 raise ValueError(f"Must specify 'year' argument when {format=}") 

211 

212 # validate input file type in more detail 

213 if input.endswith(".csv") and not input_format.seperator == ",": 213 ↛ 214line 213 didn't jump to line 214, because the condition on line 213 was never true

214 raise ValueError(f"Input file should be CSV for {format=}") 

215 

216 if input.endswith(".tsv") and not input_format.seperator == "\t": 216 ↛ 217line 216 didn't jump to line 217, because the condition on line 216 was never true

217 raise ValueError(f"Input file should be TSV for {format=}") 

218 

219 def run(self) -> None: 

220 logger.info(f"Parsing input {self.input}") 

221 df = parse_input(self.input, self.format, self.year) 

222 logger.debug(f"input file: {df.shape=}, df.dtypes=\n{df.dtypes}") 

223 logger.debug(f"df.head()=\n{df.head()}") 

224 

225 if os.path.exists(self.path): 

226 logger.info(f"Loading xlbudget file {self.path}") 

227 wb = load_workbook(self.path) 

228 else: 

229 logger.warning(f"xlbudget file {self.path} does not exist, creating") 

230 wb = Workbook() 

231 ws = wb.active 

232 # ignore type mismatch of active worksheet 

233 wb.remove(ws) # type: ignore[arg-type] 

234 

235 logger.info("Updating xlbudget file") 

236 update_xlbudget(wb, df) 

237 

238 if not self.trial: 

239 logger.info(f"Saving xlbudget file to {self.path}") 

240 wb.save(self.path) 

241 else: 

242 logger.info(f"Trial run: not saving xlbudget file to {self.path}") 

243 

244 

245def get_command_classes() -> List[Type[Command]]: 

246 """Gets all classes that implement the `Command` abstract class. 

247 

248 Returns: 

249 A[n] `List[Type[Command]]` of all command classes. 

250 """ 

251 command_module = sys.modules[__name__] 

252 return [getattr(command_module, c.__name__) for c in Command.__subclasses__()] 

253 

254 

255def _add_parser( 

256 subparsers: _SubParsersAction, 

257 name: str, 

258 aliases: List[str], 

259 help: str, 

260 cmd_cls: Type[Command], 

261) -> ArgumentParser: 

262 """Adds an argument parser for a command. Any configuration that is common 

263 across commands should go here. 

264 

265 Args: 

266 subparsers (_SubParsersAction): The subparsers object. 

267 name (str): The command name. 

268 aliases (List[str]): The command aliases. 

269 help (str): The command help message. 

270 cmd_cls (Type[Command]): The command class. 

271 

272 Returns: 

273 A[n] `ArgumentParser` for a command. 

274 """ 

275 parser = subparsers.add_parser(name, aliases=aliases, help=help) 

276 

277 # initialize the command with args.init(...) 

278 parser.set_defaults(init=cmd_cls) 

279 

280 return parser