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
« 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`."""
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
10from openpyxl import Workbook, load_workbook
12from xlbudget.inputformat import GetInputFormats, InputFormat, parse_input
13from xlbudget.rwxlb import update_xlbudget
15logger = getLogger(__name__)
18class Command(ABC):
19 """The abstract class that the command implementations implement.
21 Attributes: Class Attributes
22 default_path (str): The default path of the xlbudget file.
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 """
29 default_path: str = "xlbudget.xlsx"
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
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
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
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
63 @classmethod
64 def configure_common_args(cls, parser: ArgumentParser) -> None:
65 """Configures the arguments that are used by all commands.
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 )
83 @classmethod
84 @abstractmethod
85 def configure_args(cls, subparsers: _SubParsersAction) -> None:
86 pass
88 @abstractmethod
89 def __init__(self, args: Namespace) -> None:
90 self.trial = args.trial
92 self._check_path(args.path)
93 self.path = args.path
95 @staticmethod
96 def _check_path(path: str) -> None:
97 """Check that `path` is a valid path to an xlbudget file.
99 Args:
100 path (str): The xlbudget path.
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}'")
110 dir = os.path.dirname(path)
111 if dir and not os.path.isdir(dir):
112 raise FileNotFoundError(f"Directory '{dir}' does not exist")
114 @abstractmethod
115 def run(self) -> None:
116 pass
119class Update(Command):
120 """The `update` command updates an existing xlbudget file.
122 Attributes: Class Attributes
123 name (str): The command's CLI name.
124 aliases (List[str]): The command's CLI aliases.
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 """
133 name: str = "update"
134 aliases: List[str] = ["u"]
136 @classmethod
137 def configure_args(cls, subparsers: _SubParsersAction) -> None:
138 """Configures the argument parser for the `update` command.
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 )
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 )
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 )
168 def __init__(self, args: Namespace) -> None:
169 super().__init__(args)
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
176 logger.debug(f"instance variables: {vars(self)}")
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.
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.
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
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}'")
200 if not os.path.isfile(input):
201 raise ValueError(f"Input '{input}' is not an existing file")
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=}")
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=}")
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=}")
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()}")
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]
235 logger.info("Updating xlbudget file")
236 update_xlbudget(wb, df)
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}")
245def get_command_classes() -> List[Type[Command]]:
246 """Gets all classes that implement the `Command` abstract class.
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__()]
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.
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.
272 Returns:
273 A[n] `ArgumentParser` for a command.
274 """
275 parser = subparsers.add_parser(name, aliases=aliases, help=help)
277 # initialize the command with args.init(...)
278 parser.set_defaults(init=cmd_cls)
280 return parser