excel-list-transform 0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Take an excel file and and change columns before saving as excel or CSV."""
@@ -0,0 +1,11 @@
1
+ #! /usr/local/bin/python3
2
+ """Take an excel file and and change columns before saving as excel or CSV."""
3
+
4
+ # Copyright (c) 2024 Tom Björkholm
5
+ # MIT License
6
+
7
+
8
+ from excel_list_transform.transform_cmd import transform_cmd # pragma: no cover # noqa: E501
9
+
10
+ if __name__ == '__main__': # pragma: no cover
11
+ transform_cmd()
@@ -0,0 +1,39 @@
1
+ #! /usr/local/bin/python3
2
+ """Function for common indata checks.."""
3
+
4
+ # Copyright (c) 2024 Tom Björkholm
5
+ # MIT License
6
+
7
+
8
+ import sys
9
+ from typing import Callable, Optional
10
+ from excel_list_transform.commontypes import Data
11
+
12
+
13
+ def check_indata_common(indata: Data,
14
+ fix_indata_empty_rows: Callable[[Data], None]) \
15
+ -> None:
16
+ """Check that the indata is well formed common part."""
17
+ assert isinstance(indata, list), 'Internal error. ' + \
18
+ f'Expected list of rows but got {type(indata).__name__}'
19
+ if len(indata) < 1:
20
+ msg = 'No rows in input data'
21
+ print(msg, file=sys.stderr)
22
+ sys.exit(1)
23
+ fix_indata_empty_rows(indata)
24
+ if len(indata) < 1:
25
+ msg = 'No columns in input data'
26
+ print(msg, file=sys.stderr)
27
+ sys.exit(1)
28
+ num_cols: Optional[int] = None
29
+ for row in indata:
30
+ rowlen: int = len(row)
31
+ if num_cols is None:
32
+ num_cols = rowlen
33
+ else:
34
+ assert num_cols is not None
35
+ if num_cols != rowlen:
36
+ msg = 'Number of columns different between lines. '
37
+ msg += f'Found {num_cols} and {rowlen}. Aborting.'
38
+ print(msg, file=sys.stderr)
39
+ sys.exit(1)
@@ -0,0 +1,59 @@
1
+ #! /usr/local/bin/python3
2
+ """Common type definitions used for type hints."""
3
+
4
+ # Copyright (c) 2024 Tom Björkholm
5
+ # MIT License
6
+
7
+
8
+ from typing import TypeAlias, Optional, cast, Sequence, Mapping, TypeVar, \
9
+ Any
10
+ from datetime import datetime
11
+ # imports needed by mypy, but not by python:
12
+ from typing import Union, List, Dict # pylint: disable=unused-import,ungrouped-imports # noqa: E501
13
+
14
+
15
+ # types used to describe input and output data
16
+ Value: TypeAlias = Optional[str | int | float | datetime]
17
+ NumRow: TypeAlias = list[Value]
18
+ NumRowSeq: TypeAlias = Sequence[Value]
19
+ NameRow: TypeAlias = dict[str, Value]
20
+ NameRowMap: TypeAlias = Mapping[str, Value]
21
+ NumData: TypeAlias = list[NumRow]
22
+ NumDataSeq: TypeAlias = list[NumRowSeq]
23
+ NameData: TypeAlias = list[NameRow]
24
+ NameDataMap: TypeAlias = Sequence[NameRowMap]
25
+ Data = TypeVar('Data', NumData, NameData)
26
+ DataCov = TypeVar('DataCov', NumDataSeq, NameDataMap)
27
+
28
+ JsonType: TypeAlias = \
29
+ 'Union[None, int, str, bool, List[JsonType], Dict[str, JsonType]]'
30
+
31
+ # helper functions
32
+
33
+
34
+ def num_row_to_str_list(row: NumRowSeq) -> list[str]:
35
+ """Convert NumRow to list of str."""
36
+ ret: list[str] = []
37
+ for i in row:
38
+ if isinstance(i, str):
39
+ ret.append(i)
40
+ elif i is None:
41
+ raise TypeError('Found None when expecting str.')
42
+ else:
43
+ ret.append(str(i))
44
+ return ret
45
+
46
+
47
+ def str_list_to_num_row(row: list[str]) -> NumRow:
48
+ """Convert list of str to NumRow."""
49
+ return cast(NumRow, row)
50
+
51
+
52
+ T = TypeVar('T')
53
+
54
+
55
+ def get_checked_type(value: Optional[Any], istype: type[T]) -> T:
56
+ """Get value narrowed to be of type istype."""
57
+ assert value is not None
58
+ assert isinstance(value, istype)
59
+ return value
@@ -0,0 +1,358 @@
1
+ #! /usr/local/bin/python3
2
+ """Base class to read and write configurations."""
3
+
4
+ # Copyright (c) 2024 Tom Björkholm
5
+ # MIT License
6
+
7
+
8
+ from copy import deepcopy
9
+ import json
10
+ import sys
11
+ import csv
12
+ from typing import Any, Optional, Type, TypeVar, Mapping, NamedTuple, Callable
13
+ from enum import Enum, IntEnum
14
+ from excel_list_transform.str_to_enum import string_to_enum_best_match
15
+ from excel_list_transform.config_enums import RewriteKind
16
+ from excel_list_transform.file_must_exist import file_must_exist
17
+
18
+
19
+ Keya = TypeVar('Keya', str, Enum, RewriteKind)
20
+
21
+
22
+ class ConfigEncoder(json.JSONEncoder):
23
+ """Encoder for enumerations in config."""
24
+
25
+ def default(self, o: object) -> object:
26
+ """Define default encoding of enumerations."""
27
+ if isinstance(o, (Enum, IntEnum)):
28
+ return str(o.name)
29
+ return super().default(o)
30
+
31
+
32
+ class ConfigBadJson(json.JSONDecodeError):
33
+ """Exception class for bad JSON in configuration."""
34
+
35
+
36
+ def over_ride_needed(stri: str) -> Any:
37
+ """Tell programmer override is needed."""
38
+ if stri is not None:
39
+ msg = 'Override of Config.parse_converters needed.'
40
+ raise NotImplementedError(msg)
41
+ return 42
42
+
43
+
44
+ ParseConverter = NamedTuple('ParseConverter', [('result_type', type),
45
+ ('func', Callable[..., Any]),
46
+ ('args', dict[str, Any])])
47
+
48
+
49
+ class Config():
50
+ """Base class for classes that read and write configurations.
51
+
52
+ Derive from this class to and have the configuration parameters
53
+ as instance member variables of the derived class.
54
+ """
55
+
56
+ def __init__(self, from_json_data_text: Optional[str],
57
+ from_json_filename: Optional[str]) -> None:
58
+ """Construct configuration base class.
59
+
60
+ Derived class __init__ must create all object variables
61
+ before calling super().__init__()
62
+ Derived class __init__ usually does checking after
63
+ call to super().__init__()
64
+ """
65
+ self_keys = [i for i in vars(self).keys() if not
66
+ callable(getattr(self, i)) and not i.startswith('_')]
67
+ if not self_keys:
68
+ msg = 'No object variables in object of class derived from '
69
+ msg += 'Config. (Create object variables in __init__ before '
70
+ msg += 'calling super().__init__().)'
71
+ raise AttributeError(msg)
72
+ unchecked = getattr(self, '_unchecked_dicts', None)
73
+ if unchecked is None:
74
+ self._unchecked_dicts: list[str] = []
75
+ elif not isinstance(unchecked, list):
76
+ msg = '_unchecked_dicts must be a list'
77
+ raise TypeError(msg)
78
+ self._hook_dict = self.parse_converters()
79
+ if from_json_data_text is not None:
80
+ self.parse_json(from_json_data_text)
81
+ elif from_json_filename is not None:
82
+ self.read(from_json_filename)
83
+
84
+ def parse_converters(self) -> dict[str, ParseConverter]:
85
+ """Get converters for use when parsing JSON.
86
+
87
+ Override in derived class.
88
+ Return None if no conversions.
89
+ Return dict of dict for use in json decoder hook.
90
+ Structure of return value shall be:
91
+ {key: {'result type': res_type, 'func': function,
92
+ 'args': {arg_name: arg_value}}}.
93
+ """
94
+ return {'in_type': ParseConverter(result_type=int,
95
+ func=over_ride_needed,
96
+ args={})}
97
+
98
+ @staticmethod
99
+ def check_key_match(expected_keys: list[str],
100
+ j_keys: list[str],
101
+ ok_to_use_defaults: bool) -> None:
102
+ """Check if keys in imported JSON match exptected keys."""
103
+ if not ok_to_use_defaults:
104
+ for i in expected_keys:
105
+ if i not in j_keys:
106
+ errmsg = f'No value for {i} in JSON data'
107
+ print(errmsg, file=sys.stderr)
108
+ raise KeyError(errmsg)
109
+ for i in j_keys:
110
+ if i not in expected_keys:
111
+ errmsg = f'Unexpected parameter {i} in JSON data'
112
+ print(errmsg, file=sys.stderr)
113
+ raise KeyError(errmsg)
114
+
115
+ @staticmethod
116
+ def check_dict_parse(self_data: dict[str, Any], json_data: dict[str, Any],
117
+ key: str, ok_to_use_defaults: bool,
118
+ unchecked_dicts: list[str]) -> None:
119
+ """Check and parse dictionary."""
120
+ if not isinstance(self_data, dict) and \
121
+ not isinstance(json_data, dict):
122
+ return
123
+ if isinstance(self_data, dict):
124
+ if not isinstance(json_data, dict):
125
+ errmsg = f'Not dictionary for {key} in JSON data'
126
+ print(errmsg, file=sys.stderr)
127
+ raise KeyError(errmsg)
128
+ if not isinstance(self_data, dict):
129
+ errmsg = f'Unexpected dictionary for {key} in JSON data'
130
+ print(errmsg, file=sys.stderr)
131
+ raise KeyError(errmsg)
132
+ if key in unchecked_dicts:
133
+ return
134
+ Config.check_key_match(list(self_data.keys()), list(json_data.keys()),
135
+ ok_to_use_defaults)
136
+ for i in self_data.keys():
137
+ if i in json_data:
138
+ Config.check_dict_parse(self_data[i], json_data[i], i,
139
+ ok_to_use_defaults, unchecked_dicts)
140
+
141
+ def _json_parse_obj_hook(self, indict: dict[str, Any]) -> dict[str, Any]:
142
+ """Convert str to correct type."""
143
+ hookd = self._hook_dict
144
+ if hookd is None:
145
+ return indict # pragma: no cover
146
+ ret = deepcopy(indict)
147
+ for key, value in ret.items():
148
+ if key in hookd:
149
+ parse_c = hookd[key]
150
+ if not isinstance(value, parse_c.result_type):
151
+ ret[key] = parse_c.func(value, **parse_c.args)
152
+ return ret
153
+
154
+ def parse_json(self, from_json_text: str,
155
+ ok_to_use_defaults: bool = False) -> None:
156
+ """Parse a string of JSON data and set self to that."""
157
+ self._hook_dict = self.parse_converters()
158
+ hook = self._json_parse_obj_hook if self._hook_dict is not None \
159
+ else None
160
+ data = None
161
+ try:
162
+ data = json.loads(from_json_text, object_hook=hook)
163
+ except Exception as exc:
164
+ if isinstance(exc, NotImplementedError):
165
+ raise exc
166
+ msg = 'Config.parse_json failed to load JSON from string/file.\n'
167
+ msg += 'Probably incorrectly edited configuration,\n'
168
+ msg += 'or using wrong file (not config file) as configuration.\n'
169
+ msg += str(exc)
170
+ print(msg, file=sys.stderr)
171
+ if isinstance(exc, json.JSONDecodeError):
172
+ raise ConfigBadJson(msg=msg, doc=exc.doc, pos=exc.pos) from exc
173
+ raise ConfigBadJson(msg=msg, doc='', pos=0) from exc
174
+ self_keys = [i for i in vars(self).keys() if not
175
+ callable(getattr(self, i)) and not i.startswith('_')]
176
+ self.check_key_match(self_keys, data.keys(), ok_to_use_defaults)
177
+ for i in self_keys:
178
+ if i in data.keys():
179
+ self.check_dict_parse(getattr(self, i), data[i], i,
180
+ ok_to_use_defaults,
181
+ self._unchecked_dicts)
182
+ setattr(self, i, data[i])
183
+
184
+ def as_json_string(self) -> str:
185
+ """Return string with this configuration as JSON data."""
186
+ data = {}
187
+ self_keys = [i for i in vars(self).keys() if not
188
+ callable(getattr(self, i)) and not i.startswith('_')]
189
+ for i in self_keys:
190
+ data[i] = getattr(self, i)
191
+ return json.dumps(data, sort_keys=True, indent=4, cls=ConfigEncoder)
192
+
193
+ def read(self, from_json_filename: str,
194
+ ok_to_use_defaults: bool = False) -> None:
195
+ """Read configuration JSON from named file."""
196
+ file_must_exist(filename=from_json_filename,
197
+ with_content_txt='with configuration JSON input')
198
+ with open(from_json_filename, "r", encoding='UTF-8') as file:
199
+ data = file.read()
200
+ self.parse_json(data, ok_to_use_defaults)
201
+
202
+ def write(self, to_json_filename: str) -> None:
203
+ """Create named file with configuration as JSON."""
204
+ with open(to_json_filename, "w", encoding='UTF-8') as file:
205
+ text = self.as_json_string()
206
+ file.write(text)
207
+
208
+ @staticmethod
209
+ def get_csv_dialect(name: Optional[str], delimiter: Optional[str], # pylint: disable=too-many-arguments, line-too-long, too-many-branches # noqa: E501
210
+ quoting: Optional[str], quotechar: Optional[str], # pylint: disable=too-many-arguments, line-too-long, too-many-branches # noqa: E501
211
+ lineterminator: Optional[str], # pylint: disable=too-many-arguments, line-too-long, too-many-branches # noqa: E501
212
+ escapechar: Optional[str] # pylint: disable=too-many-arguments, line-too-long, too-many-branches # noqa: E501
213
+ ) -> type[csv.Dialect]: # pylint: disable=too-many-arguments, line-too-long, too-many-branches # noqa: E501
214
+ """Get CSV dialect object matching arguments."""
215
+ ret: Optional[type[csv.Dialect]] = None
216
+ if name is None or name.lower() == 'csv.excel':
217
+ ret = csv.excel
218
+ ret.lineterminator = '\r\n'
219
+ elif name.lower() == 'csv.excel_tab':
220
+ ret = csv.excel_tab
221
+ ret.lineterminator = '\r\n'
222
+ elif name.lower() == 'csv.unix_dialect':
223
+ ret = csv.unix_dialect
224
+ ret.lineterminator = '\n'
225
+ else:
226
+ errmsg = f'Unknown csv dialect: {name}'
227
+ print(errmsg, file=sys.stderr)
228
+ raise KeyError(errmsg)
229
+ if delimiter is not None:
230
+ ret.delimiter = delimiter
231
+ if quoting is None:
232
+ ret.quoting = csv.QUOTE_MINIMAL
233
+ elif quoting.lower() == 'csv.quote_all':
234
+ ret.quoting = csv.QUOTE_ALL
235
+ elif quoting.lower() == 'csv.quote_minimal':
236
+ ret.quoting = csv.QUOTE_MINIMAL
237
+ elif quoting.lower() == 'csv.quote_none':
238
+ ret.quoting = csv.QUOTE_NONE
239
+ elif quoting.lower() == 'csv.quote_nonnumeric':
240
+ ret.quoting = csv.QUOTE_NONNUMERIC
241
+ else:
242
+ errmsg = f'Unknown csv quoting: {quoting}'
243
+ print(errmsg, file=sys.stderr)
244
+ raise KeyError(errmsg)
245
+ if quotechar is None:
246
+ ret.quotechar = '"'
247
+ else:
248
+ ret.quotechar = quotechar
249
+ if lineterminator is not None:
250
+ ret.lineterminator = lineterminator
251
+ if escapechar is None:
252
+ ret.escapechar = '\\'
253
+ else:
254
+ ret.escapechar = escapechar
255
+ return ret
256
+
257
+ @staticmethod
258
+ def check_array_keys(name_of_cfg: str, array: list[dict[str, Any]],
259
+ mandatory_keys: list[str],
260
+ allowed_keys: Optional[list[str]] = None) -> None:
261
+ """Check an array of dicts that all keys match allowed/mandatory.
262
+
263
+ Check that all keys of the list of dict are allowed.
264
+ A key is allowed if listed in mandatory_keys of allowed_keys.
265
+ Check that all mandatory keys exist in all dicts in the list.
266
+ @param nam_of_cfg The name of the configuration used in error message.
267
+ @param array The array of dicts to check.
268
+ @param mandatory_keys An array of the mandatory keys.
269
+ @param allowed_keys An array of allowed keys excluding madatory keys.
270
+ """
271
+ to_allow = deepcopy(mandatory_keys)
272
+ in_cfg = f' in config of {name_of_cfg}'
273
+ if allowed_keys is not None:
274
+ to_allow += deepcopy(allowed_keys)
275
+ for i in array:
276
+ for used_key in list(i.keys()):
277
+ if used_key not in to_allow:
278
+ bad_k = f'Found non-allowed key "{used_key}"'
279
+ print(bad_k + in_cfg, file=sys.stderr)
280
+ raise KeyError(bad_k + in_cfg)
281
+ for k in mandatory_keys:
282
+ if k not in list(i.keys()):
283
+ miss = f'Missing key "{k}"'
284
+ print(miss + in_cfg, file=sys.stderr)
285
+ raise KeyError(miss + in_cfg)
286
+
287
+ @staticmethod
288
+ def value_of_type(input_value: Any, to_type: Any) -> Any:
289
+ """Convert input to given type."""
290
+ if isinstance(input_value, to_type):
291
+ return input_value
292
+ return to_type(input_value)
293
+
294
+ @staticmethod
295
+ def check_array_dicts(name_of_cfg: str, # pylint: disable=too-many-locals
296
+ array: list[dict[str, Any]],
297
+ kind_key: str, kind_type: type,
298
+ dict_of_templates: Mapping[Keya, Mapping[str, type]]
299
+ ) -> None:
300
+ """Check a list of dicts that all dicts matches a template.
301
+
302
+ Check that all dicts in list of dicts matches one of the
303
+ templates. The templates are each a dict and all keys must
304
+ match one of the template dicts. For each key in matching
305
+ template the value must be of the template value type.
306
+ @param nam_of_cfg The name of the configuration used in error message.
307
+ @param array The array of dicts to check.
308
+ @param kind_key The key of the kind enum that determines template.
309
+ @param kind_type The type of enum that is used as kind enum.
310
+ @param dict_of_templates The dict of template dicts.
311
+ """
312
+ Msgs = NamedTuple('Msgs', [('in_cfg', str), ('bad_arg', str),
313
+ ('bad_templ', str)])
314
+ msgs = Msgs(in_cfg=f' in config of {name_of_cfg} ',
315
+ bad_arg='argument not list of dicts',
316
+ bad_templ='Internal error: template not dict of dicts')
317
+ if not isinstance(array, list):
318
+ print(msgs.bad_arg + msgs.in_cfg + '(list_of_dicts)',
319
+ file=sys.stderr)
320
+ raise KeyError(msgs.bad_arg + msgs.in_cfg + '(list_of_dicts)')
321
+ if not isinstance(dict_of_templates, dict):
322
+ print(msgs.bad_templ + msgs.in_cfg + '(dict_of_templates)',
323
+ file=sys.stderr)
324
+ raise KeyError(msgs.bad_templ + msgs.in_cfg +
325
+ '(dict_of_templates)')
326
+ for key1, template in dict_of_templates.items():
327
+ if not isinstance(template, dict):
328
+ msg = f' in template for {key1.name}'
329
+ print(msgs.bad_templ + msgs.in_cfg + msg, file=sys.stderr)
330
+ raise KeyError(msgs.bad_templ + msgs.in_cfg + msg)
331
+ for i, row in enumerate(array):
332
+ litem = f'(list index {i})'
333
+ if not isinstance(row, dict):
334
+ print(msgs.bad_arg + msgs.in_cfg + litem, file=sys.stderr)
335
+ raise KeyError(msgs.bad_arg + msgs.in_cfg + litem)
336
+ if kind_key not in row:
337
+ msg = f'Key {kind_key} not in dict'
338
+ print(msg + msgs.in_cfg + litem, file=sys.stderr)
339
+ raise KeyError(msg + msgs.in_cfg + litem)
340
+ kind = Config.value_of_type(row[kind_key], kind_type)
341
+ for key2, valtype in dict_of_templates[kind].items():
342
+ if key2 not in row:
343
+ msg = f'Key {key2} not in dict'
344
+ print(msg + msgs.in_cfg + litem, file=sys.stderr)
345
+ raise KeyError(msg + msgs.in_cfg + litem)
346
+ if not isinstance(row[key2], valtype):
347
+ msg = f'Value for key {key2} = {row[key2]} '
348
+ msg += f'is not {valtype.__name__} '
349
+ msg += f'it is {type(row[key2]).__name__} '
350
+ print(msg + msgs.in_cfg + litem, file=sys.stderr)
351
+ raise KeyError(msg + msgs.in_cfg + litem)
352
+
353
+ @staticmethod
354
+ def get_converter_dict(enum_type: Type[Enum]) -> ParseConverter:
355
+ """Get dict for converting to given enum_type."""
356
+ return ParseConverter(result_type=enum_type,
357
+ func=string_to_enum_best_match,
358
+ args={'num_type': enum_type})
@@ -0,0 +1,53 @@
1
+ #! /usr/local/bin/python3
2
+ """Enumerations used in configuration of excel list transform."""
3
+
4
+ # Copyright (c) 2024 Tom Björkholm
5
+ # MIT License
6
+
7
+
8
+ from enum import Enum, auto
9
+
10
+
11
+ class FileType(Enum):
12
+ """Is I/O file excel or CSV."""
13
+
14
+ EXCEL = auto()
15
+ CSV = auto()
16
+
17
+
18
+ class SplitWhere(Enum):
19
+ """Use leftmost or rightmost separator for splitting."""
20
+
21
+ LEFTMOST = auto()
22
+ RIGHTMOST = auto()
23
+
24
+
25
+ class ExcelLib(Enum):
26
+ """Use specified library for excel reading or writing."""
27
+
28
+ OPENPYXL = auto()
29
+ XLSXWRITER = auto()
30
+ PYLIGHTXL = auto()
31
+
32
+
33
+ class RewriteKind(Enum):
34
+ """Kind of write operation to apply."""
35
+
36
+ STRIP = auto()
37
+ REMOVECHARS = auto()
38
+ STR_SUBSTITUTE = auto()
39
+ REGEX_SUBSTITUTE = auto()
40
+
41
+
42
+ class CaseSensitivity(Enum):
43
+ """Shall matching be case sensitive or not."""
44
+
45
+ MATCH_CASE = auto()
46
+ IGNORE_CASE = auto()
47
+
48
+
49
+ class ColumnRef(Enum):
50
+ """Are columns referenced by number or name."""
51
+
52
+ BY_NUMBER = auto()
53
+ BY_NAME = auto()