flinventory 0.3.0__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.
- flinventory/__init__.py +8 -0
- flinventory/__main__.py +45 -0
- flinventory/box.py +289 -0
- flinventory/constant.py +285 -0
- flinventory/datacleanup.py +349 -0
- flinventory/defaulted_data.py +552 -0
- flinventory/generate_labels.py +214 -0
- flinventory/inventory_io.py +295 -0
- flinventory/location.py +455 -0
- flinventory/sign.py +160 -0
- flinventory/signprinter_latex.py +471 -0
- flinventory/thing.py +145 -0
- flinventory/thingtemplate_latex/.gitignore +6 -0
- flinventory/thingtemplate_latex/dummyImage.jpg +0 -0
- flinventory/thingtemplate_latex/sign.tex +26 -0
- flinventory/thingtemplate_latex/signlist-footer.tex +1 -0
- flinventory/thingtemplate_latex/signlist-header.tex +12 -0
- flinventory/thingtemplate_latex/signs-example.tex +95 -0
- flinventory-0.3.0.dist-info/METADATA +63 -0
- flinventory-0.3.0.dist-info/RECORD +23 -0
- flinventory-0.3.0.dist-info/WHEEL +4 -0
- flinventory-0.3.0.dist-info/entry_points.txt +4 -0
- flinventory-0.3.0.dist-info/licenses/LICENSE +626 -0
flinventory/__init__.py
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
from .box import BoxedThing
|
2
|
+
from .location import Location, Schema
|
3
|
+
from .inventory_io import Inventory
|
4
|
+
from .sign import Sign
|
5
|
+
from .defaulted_data import DefaultedDict
|
6
|
+
from .constant import Options
|
7
|
+
from .thing import Thing
|
8
|
+
from .signprinter_latex import SignPrinterLaTeX
|
flinventory/__main__.py
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
"""Entrypoint to the package if called for running without import.
|
2
|
+
|
3
|
+
Command-line interface with the different housekeeping tasks.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import argparse
|
7
|
+
|
8
|
+
from . import generate_labels, inventory_io
|
9
|
+
from . import datacleanup
|
10
|
+
|
11
|
+
|
12
|
+
def parse_args():
|
13
|
+
"""Parse the command-line arguments.
|
14
|
+
|
15
|
+
Supply information from command-line arguments.
|
16
|
+
|
17
|
+
Add various subparsers that have each a func
|
18
|
+
default that tells the main program which
|
19
|
+
function should be called if this subcommand
|
20
|
+
is issued.
|
21
|
+
|
22
|
+
Returns:
|
23
|
+
Options as a dict-like object.
|
24
|
+
|
25
|
+
"""
|
26
|
+
parser = argparse.ArgumentParser(
|
27
|
+
description="""Collection of various data cleaning tasks and label making.""",
|
28
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
29
|
+
)
|
30
|
+
subparsers = parser.add_subparsers()
|
31
|
+
inventory_io.add_file_args(parser)
|
32
|
+
datacleanup.add_arg_parsers(subparsers)
|
33
|
+
generate_labels.add_arg_parsers(subparsers)
|
34
|
+
return parser.parse_args()
|
35
|
+
|
36
|
+
|
37
|
+
if __name__ == "__main__":
|
38
|
+
arguments = parse_args()
|
39
|
+
datacleanup.logging_config(arguments)
|
40
|
+
try:
|
41
|
+
function = arguments.func
|
42
|
+
except AttributeError:
|
43
|
+
print("No command given. See -h for help information.")
|
44
|
+
else:
|
45
|
+
function(arguments)
|
flinventory/box.py
ADDED
@@ -0,0 +1,289 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""Class representing a thing in an inventory for a specific workshop with location and sign."""
|
3
|
+
import itertools
|
4
|
+
import os.path
|
5
|
+
|
6
|
+
import collections.abc
|
7
|
+
from typing import Any, Optional, Self, Union, cast
|
8
|
+
|
9
|
+
from . import constant
|
10
|
+
from . import defaulted_data
|
11
|
+
|
12
|
+
from .location import Location, Schema
|
13
|
+
from .sign import Sign
|
14
|
+
from .thing import Thing
|
15
|
+
|
16
|
+
|
17
|
+
class BoxedThing:
|
18
|
+
"""Represents one thing in the workshop inventory.
|
19
|
+
|
20
|
+
That is: a thing, a location, a sign, an image.
|
21
|
+
|
22
|
+
Dictionary-like functions are forwarded to the underlying thing.
|
23
|
+
|
24
|
+
Location and sign are special members, given by the respective files.
|
25
|
+
|
26
|
+
Properties:
|
27
|
+
location: Location Where this thing is stored.
|
28
|
+
sign: Sign Parameters for the sign for this thing.
|
29
|
+
options: Options Global options.
|
30
|
+
directory: str Where information about this thing is stored.
|
31
|
+
Accessible only via property directory.
|
32
|
+
"""
|
33
|
+
|
34
|
+
def __init__(
|
35
|
+
self,
|
36
|
+
options: constant.Options,
|
37
|
+
schema: Schema,
|
38
|
+
directory: str,
|
39
|
+
thing: Optional[Thing] = None,
|
40
|
+
location: Optional[Location] = None,
|
41
|
+
sign: Optional[Sign] = None,
|
42
|
+
):
|
43
|
+
"""Create a thing from the given data.
|
44
|
+
|
45
|
+
If created empty, adding a name afterward is heavily encouraged.
|
46
|
+
If thing and sign are given, sign.default must be thing.
|
47
|
+
The directory for thing, sign and location must be the same.
|
48
|
+
Args:
|
49
|
+
directory: directory where the thing data should be saved (later).
|
50
|
+
options: global options
|
51
|
+
schema: location schema for the location
|
52
|
+
thing: the thing to be stored. If None, replaced with empty thing.
|
53
|
+
location: where the thing is stored. If none, empty location is created.
|
54
|
+
sign: how the sign for this thing looks like. If none, empty sign information is used.
|
55
|
+
"""
|
56
|
+
self._thing = (
|
57
|
+
Thing(data={}, default={}, options=options, directory=directory)
|
58
|
+
if thing is None
|
59
|
+
else thing
|
60
|
+
)
|
61
|
+
self._location = (
|
62
|
+
Location(schema=schema, data={}, directory=directory, options=options)
|
63
|
+
if location is None
|
64
|
+
else location
|
65
|
+
)
|
66
|
+
self._sign = (
|
67
|
+
Sign(
|
68
|
+
data={},
|
69
|
+
thing=self._thing,
|
70
|
+
options=options,
|
71
|
+
directory=directory,
|
72
|
+
)
|
73
|
+
if sign is None
|
74
|
+
else sign
|
75
|
+
)
|
76
|
+
self._directory = directory
|
77
|
+
self.options = options
|
78
|
+
assert (error := self.consistent()) == "", error
|
79
|
+
|
80
|
+
@property
|
81
|
+
def thing(self):
|
82
|
+
"""The underlying thing."""
|
83
|
+
return self._thing
|
84
|
+
|
85
|
+
@property
|
86
|
+
def location(self):
|
87
|
+
"""The location of the box."""
|
88
|
+
return self._location
|
89
|
+
|
90
|
+
@property
|
91
|
+
def sign(self):
|
92
|
+
"""The sign information for this box."""
|
93
|
+
return self._sign
|
94
|
+
|
95
|
+
def consistent(self):
|
96
|
+
"""Checks if the data in all sub elements (thing, sign, location) are consistent.
|
97
|
+
|
98
|
+
- .directory is the same
|
99
|
+
- thing is default of sign
|
100
|
+
- todo: options are the same for all
|
101
|
+
|
102
|
+
Usable in assert statement:
|
103
|
+
assert (error := self.consistent) == "", error
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
"" if fine, error message str if not consistent
|
107
|
+
"""
|
108
|
+
if self._sign.default != self._thing:
|
109
|
+
return (
|
110
|
+
"When creating a boxed thing, the default for sign data must the thing."
|
111
|
+
)
|
112
|
+
if self._sign.directory != self.directory:
|
113
|
+
return (
|
114
|
+
f"When creating a boxed thing, the directory for the sign must "
|
115
|
+
f"be same as for the box, not {self._sign.directory=} != {self.directory=}."
|
116
|
+
)
|
117
|
+
if self._location.directory != self.directory:
|
118
|
+
return (
|
119
|
+
f"When creating a boxed thing, the directory for the sign must "
|
120
|
+
f"be same as for the box, not {self._location.directory=} != {self.directory=}."
|
121
|
+
)
|
122
|
+
if self._thing.directory != self.directory:
|
123
|
+
return (
|
124
|
+
f"When creating a boxed thing, the directory for the thing must "
|
125
|
+
f"be same as for the box, not {self._thing.directory=} != {self.directory=}."
|
126
|
+
)
|
127
|
+
return ""
|
128
|
+
|
129
|
+
@classmethod
|
130
|
+
def from_files(
|
131
|
+
cls, directory: str, options: constant.Options, schema: Schema
|
132
|
+
) -> Self:
|
133
|
+
"""Create a new boxed thing based on yaml files in directory.
|
134
|
+
|
135
|
+
Arguments:
|
136
|
+
directory: directory which includes a THING_FILE file and maybe
|
137
|
+
a SIGN_FILE and LOCATION_FILE (a missing THING_FILE raises no error
|
138
|
+
but is a bit useless)
|
139
|
+
options: global options
|
140
|
+
schema: location schema to interpret location data
|
141
|
+
"""
|
142
|
+
thing = Thing.from_yaml_file(directory=directory, default={}, options=options)
|
143
|
+
location = Location.from_yaml_file(
|
144
|
+
directory=directory, schema=schema, options=options
|
145
|
+
)
|
146
|
+
sign = Sign.from_yaml_file(directory=directory, thing=thing, options=options)
|
147
|
+
return cls(
|
148
|
+
options=options,
|
149
|
+
schema=schema,
|
150
|
+
directory=directory,
|
151
|
+
thing=thing,
|
152
|
+
location=location,
|
153
|
+
sign=sign,
|
154
|
+
)
|
155
|
+
|
156
|
+
def __getitem__(
|
157
|
+
self, key: Union[defaulted_data.Key, tuple[str, int]]
|
158
|
+
) -> defaulted_data.Value:
|
159
|
+
"""Uses self.thing[item] on internal thing."""
|
160
|
+
return self._thing[key]
|
161
|
+
|
162
|
+
def get(self, key: Union[defaulted_data.Key, tuple[str, int]], default: Any) -> Any:
|
163
|
+
"""Call self.thing.get(key, default)."""
|
164
|
+
return self._thing.get(key, default)
|
165
|
+
|
166
|
+
def __setitem__(
|
167
|
+
self,
|
168
|
+
key: Union[defaulted_data.Key, tuple[str, int]],
|
169
|
+
value: Union[defaulted_data.Value, dict[str, defaulted_data.Value]],
|
170
|
+
):
|
171
|
+
"""Calls self.thing[key] = value"""
|
172
|
+
self._thing[key] = value
|
173
|
+
|
174
|
+
def __delitem__(self, key: defaulted_data.Key):
|
175
|
+
"""Calls del self.thing[key]."""
|
176
|
+
del self._thing[key]
|
177
|
+
|
178
|
+
def __contains__(self, key: defaulted_data.Key):
|
179
|
+
"""Calls key in self.thing."""
|
180
|
+
key in self.thing
|
181
|
+
|
182
|
+
def best(self, translated_key: str, **backup: Any):
|
183
|
+
"""Calls best on self.thing."""
|
184
|
+
return self._thing.best(translated_key, **backup)
|
185
|
+
|
186
|
+
@property
|
187
|
+
def where(self):
|
188
|
+
"""Where the thing is as a printable string."""
|
189
|
+
return "" if self.location is None else str(self.location)
|
190
|
+
|
191
|
+
@property
|
192
|
+
def directory(self) -> str:
|
193
|
+
"""Directory where information about this thing is saved."""
|
194
|
+
return self._directory
|
195
|
+
|
196
|
+
@directory.setter
|
197
|
+
def directory(self, new_directory: str):
|
198
|
+
"""Change directory where information about this thing is saved.
|
199
|
+
|
200
|
+
Move the existing data but does not save the current in-memory data.
|
201
|
+
Use save() for that.
|
202
|
+
|
203
|
+
Deletes original directory if it is empty.
|
204
|
+
|
205
|
+
Args:
|
206
|
+
new_directory: where the thing data should be saved from now on.
|
207
|
+
Raises:
|
208
|
+
FileExistsException:
|
209
|
+
- if new_directory is a regular file
|
210
|
+
- if there are files with the special names for data in the new directory
|
211
|
+
(if there is no location saved in this thing and a LOCATION_FILE already exists
|
212
|
+
the error is also raised even if currently no data would be overwritten)
|
213
|
+
|
214
|
+
"""
|
215
|
+
if self._directory == new_directory:
|
216
|
+
# nothing to be done
|
217
|
+
return
|
218
|
+
if os.path.isfile(new_directory):
|
219
|
+
raise FileExistsError(
|
220
|
+
f"{new_directory} is a regular file. "
|
221
|
+
f"Cannot save thing data since it is not a directory."
|
222
|
+
)
|
223
|
+
if os.path.exists(new_directory):
|
224
|
+
# must be directory
|
225
|
+
for reserved in constant.RESERVED_FILENAMES:
|
226
|
+
if os.path.exists(os.path.join(new_directory, reserved)):
|
227
|
+
raise FileExistsError(
|
228
|
+
f"{os.path.join(new_directory, reserved)} exists. Do not overwrite it."
|
229
|
+
)
|
230
|
+
else:
|
231
|
+
os.makedirs(new_directory)
|
232
|
+
# setting new directory deletes old and creates new file
|
233
|
+
self._thing.directory = new_directory
|
234
|
+
self._location.directory = new_directory
|
235
|
+
self._sign.directory = new_directory
|
236
|
+
self._directory = new_directory
|
237
|
+
assert self.consistent()
|
238
|
+
try:
|
239
|
+
os.rmdir(self._directory)
|
240
|
+
except (OSError, FileNotFoundError):
|
241
|
+
# not empty or not existing
|
242
|
+
pass
|
243
|
+
|
244
|
+
def markdown_representation(self):
|
245
|
+
"""Create representation in Markdown syntax for this thing."""
|
246
|
+
sec = self.get(("name", 1), "")
|
247
|
+
prim = self.get(("name", 0), sec)
|
248
|
+
title = f"- **{prim}**"
|
249
|
+
if prim != sec and sec:
|
250
|
+
title += f" (**{sec}**)"
|
251
|
+
return f"{title}: {self.where}"
|
252
|
+
|
253
|
+
def alt_names_markdown(self):
|
254
|
+
"""Create markdown lines with references of alternative names.
|
255
|
+
|
256
|
+
Each line is a dictionary entry mapping the alt name to the
|
257
|
+
markdown line.
|
258
|
+
"""
|
259
|
+
# if there is a primary name all other names are only in
|
260
|
+
# parentheses behind the primary name (see markdown_representation)
|
261
|
+
# and therefore not in the correct alphabetical place
|
262
|
+
# so add it to alternative names
|
263
|
+
prim = self.best("name", backup="")
|
264
|
+
return {
|
265
|
+
alt_name: f"- **{alt_name}** → {prim} {self.where}"
|
266
|
+
for alt_name in filter(
|
267
|
+
lambda other_name: other_name and other_name != prim,
|
268
|
+
itertools.chain(
|
269
|
+
cast(collections.abc.Mapping, self.get("name", {})).values(),
|
270
|
+
*cast(collections.abc.Mapping, self.get("name_alt", {})).values(),
|
271
|
+
),
|
272
|
+
)
|
273
|
+
}
|
274
|
+
|
275
|
+
def save(self) -> None:
|
276
|
+
"""Save all information into a directory.
|
277
|
+
|
278
|
+
If target files already exists, they are overwritten.
|
279
|
+
|
280
|
+
Calling this save should not be really needed since the data
|
281
|
+
is saved upon change. But who knows if I missed something.
|
282
|
+
Raises:
|
283
|
+
yaml.representer.RepresenterError:
|
284
|
+
If any data is not safe for yaml writing and was not caught
|
285
|
+
by checks in location, sign, thing.
|
286
|
+
"""
|
287
|
+
self._thing.save()
|
288
|
+
self._sign.save()
|
289
|
+
self._location.save()
|
flinventory/constant.py
ADDED
@@ -0,0 +1,285 @@
|
|
1
|
+
#!/usr/bin/env python
|
2
|
+
"""Collection of constants.
|
3
|
+
|
4
|
+
By extracting it into a file that is not importing any other one,
|
5
|
+
this can be imported everywhere without circular import problem.
|
6
|
+
"""
|
7
|
+
import os.path
|
8
|
+
|
9
|
+
from typing import Any, Self, Union, Optional
|
10
|
+
import yaml
|
11
|
+
import pycountry
|
12
|
+
|
13
|
+
try:
|
14
|
+
import slugify
|
15
|
+
except ModuleNotFoundError:
|
16
|
+
print(
|
17
|
+
"module slugify not found. "
|
18
|
+
"Install it system-wide or create a conda environment "
|
19
|
+
"with the environment.yml named bikeparts "
|
20
|
+
"or a virtual environment with the necessary packages liste in environment.yml."
|
21
|
+
)
|
22
|
+
import sys
|
23
|
+
|
24
|
+
sys.exit(1)
|
25
|
+
|
26
|
+
VALID_LANGUAGES = {}
|
27
|
+
for gl_language in pycountry.languages:
|
28
|
+
try:
|
29
|
+
VALID_LANGUAGES[gl_language.alpha_2] = gl_language
|
30
|
+
except AttributeError:
|
31
|
+
VALID_LANGUAGES[gl_language.alpha_3] = gl_language
|
32
|
+
|
33
|
+
|
34
|
+
class InvalidLanguageError(Exception):
|
35
|
+
"""Raised if an invalid language code is used."""
|
36
|
+
|
37
|
+
|
38
|
+
SCHEMA_FILE = "schema.yaml"
|
39
|
+
"""File name of the schema."""
|
40
|
+
|
41
|
+
OPTION_FILE = "preferences.yaml"
|
42
|
+
"""File name of the preferences."""
|
43
|
+
|
44
|
+
THING_DIRECTORY = "things"
|
45
|
+
"""Directory within the main data directory with a directory for every thing"""
|
46
|
+
THING_FILE = "thing.yaml"
|
47
|
+
"""File name for thing (non-localised information) data in directory for a single thing."""
|
48
|
+
LOCATION_FILE = "location.yaml"
|
49
|
+
"""File name for location data in directory for a single thing."""
|
50
|
+
SIGN_FILE = "sign.yaml"
|
51
|
+
"""File name for sign data in directory for a single thing."""
|
52
|
+
IMAGE_FILE_BASE = "image"
|
53
|
+
"""File base name (without extension) for image file."""
|
54
|
+
IMAGE_FILE_TYPES = ["jpg", "png", "jpeg", "PNG", "JPG", "JPEG", "webp"]
|
55
|
+
"""All supported image file types."""
|
56
|
+
RESERVED_FILENAMES = {THING_FILE, LOCATION_FILE, SIGN_FILE} | {
|
57
|
+
IMAGE_FILE_BASE + filetype for filetype in IMAGE_FILE_TYPES
|
58
|
+
}
|
59
|
+
"""List of all reserved file names in a thing data directory."""
|
60
|
+
DISPLAY_RESOURCES = "website_resources"
|
61
|
+
"""The directory within main_data_directory with the favicon and possibly more data for UIs."""
|
62
|
+
|
63
|
+
YAML_DUMP_OPTIONS = {
|
64
|
+
"Dumper": yaml.SafeDumper,
|
65
|
+
"sort_keys": False,
|
66
|
+
"allow_unicode": True,
|
67
|
+
"indent": 2,
|
68
|
+
}
|
69
|
+
"""Options for dumping to yaml files. Should be the same everywhere."""
|
70
|
+
|
71
|
+
|
72
|
+
def normalize_file_name(file_name: str) -> str:
|
73
|
+
"""Normalize a string such that it is a file name that makes no problems."""
|
74
|
+
return slugify.slugify(
|
75
|
+
file_name,
|
76
|
+
separator="_",
|
77
|
+
# allow . in file name:
|
78
|
+
regex_pattern=r"[^-a-z0-9_.]+",
|
79
|
+
replacements=[
|
80
|
+
["Ü", "Ue"],
|
81
|
+
["ü", "ue"],
|
82
|
+
["Ä", "Ae"],
|
83
|
+
["ä", "ae"],
|
84
|
+
["Ö", "Oe"],
|
85
|
+
["ö", "oe"],
|
86
|
+
["ẞ", "Ss"],
|
87
|
+
["ß", "ss"],
|
88
|
+
],
|
89
|
+
).replace(
|
90
|
+
".jpeg", ".jpg"
|
91
|
+
) # this replacement after slugify
|
92
|
+
# to also replace JPEG by jpg (slugify lowers)
|
93
|
+
|
94
|
+
|
95
|
+
class Options:
|
96
|
+
"""Collections of kinda global options.
|
97
|
+
|
98
|
+
The following **class** members are just for documentation. There are never
|
99
|
+
filled or intended to be used but instead the **instance** members of the same name.
|
100
|
+
|
101
|
+
Maybe one could use the class members and not pass the options object around
|
102
|
+
but instead refer to the class and its members. But then it would be a huge hassle
|
103
|
+
to support for different options in the same program in case one ever wants that.
|
104
|
+
"""
|
105
|
+
|
106
|
+
separator: str
|
107
|
+
"""How different parts of location shortcuts are separated."""
|
108
|
+
|
109
|
+
languages: list[str]
|
110
|
+
"""Language codes in order of preference.
|
111
|
+
|
112
|
+
The corresponding language can be found in VALID_LANGUAGES.
|
113
|
+
"""
|
114
|
+
|
115
|
+
main_data_directory: str
|
116
|
+
"""The directory with all data."""
|
117
|
+
|
118
|
+
length_unit: str
|
119
|
+
"""length unit (preferably mm) for all lengths, mainly sign size.
|
120
|
+
|
121
|
+
For backwards compatibility, the default is cm (centimeter).
|
122
|
+
|
123
|
+
Should be a length unit that LaTeχ understands.
|
124
|
+
"""
|
125
|
+
|
126
|
+
sign_max_width: float
|
127
|
+
"""Maximum width for a sign in length_unit.
|
128
|
+
|
129
|
+
Should be set if length_unit is set to fit to it.
|
130
|
+
By default 18 which in cm fits to A4 portrait page.
|
131
|
+
"""
|
132
|
+
|
133
|
+
sign_max_height: float
|
134
|
+
"""Maximum height for a sign in length_unit.
|
135
|
+
|
136
|
+
Should be set if length_unit is set to fit to it.
|
137
|
+
By default 28 which in cm fits to A4 portrait page.
|
138
|
+
"""
|
139
|
+
|
140
|
+
@classmethod
|
141
|
+
def from_file(cls, directory: str = ".") -> Self:
|
142
|
+
"""Parse options from the options file.
|
143
|
+
|
144
|
+
No possibility to set the config file. Convention over customization!
|
145
|
+
Args:
|
146
|
+
directory: the directory in which to find the config file
|
147
|
+
"""
|
148
|
+
try:
|
149
|
+
option_file = open(
|
150
|
+
os.path.join(directory, OPTION_FILE), mode="r", encoding="utf-8"
|
151
|
+
)
|
152
|
+
except FileNotFoundError:
|
153
|
+
return cls({"main_data_directory": directory})
|
154
|
+
with option_file:
|
155
|
+
return cls(yaml.safe_load(option_file) | {"main_data_directory": directory})
|
156
|
+
|
157
|
+
def __init__(self, options: dict[str, Any]):
|
158
|
+
"""Create options from content of option file."""
|
159
|
+
self.languages = options.get("languages", ["de", "en"])
|
160
|
+
if isinstance(self.languages, str):
|
161
|
+
self.languages = [self.languages]
|
162
|
+
if not isinstance(self.languages, list):
|
163
|
+
raise ValueError(
|
164
|
+
"Language codes must be a list of strings of "
|
165
|
+
"valid language codes or a single valid language code."
|
166
|
+
)
|
167
|
+
for language in self.languages:
|
168
|
+
if language not in VALID_LANGUAGES:
|
169
|
+
raise ValueError(
|
170
|
+
f"{language} is not a valid language code. "
|
171
|
+
f"Valid language codes are {VALID_LANGUAGES}."
|
172
|
+
)
|
173
|
+
if not self.languages:
|
174
|
+
# empty list
|
175
|
+
raise ValueError("We need to use at least one language.")
|
176
|
+
|
177
|
+
for simple_option, default, data_type in (
|
178
|
+
("separator", "-", str),
|
179
|
+
("length_unit", "cm", str),
|
180
|
+
("sign_max_height", 28, (int, float)),
|
181
|
+
("sign_max_width", 18, (int, float)),
|
182
|
+
):
|
183
|
+
vars(self)[simple_option] = options.get(simple_option, default)
|
184
|
+
if not isinstance(vars(self)[simple_option], data_type):
|
185
|
+
raise ValueError(
|
186
|
+
f"{simple_option} unit must a {data_type}, "
|
187
|
+
f"not {vars(self)[simple_option]} "
|
188
|
+
f"of type {type(vars(self)[simple_option])}"
|
189
|
+
)
|
190
|
+
|
191
|
+
self.main_data_directory = options.get("main_data_directory", ".")
|
192
|
+
|
193
|
+
def to_yaml(self) -> None:
|
194
|
+
"""Write options to options file.
|
195
|
+
|
196
|
+
Overwrites existing options file. Therefore, delete all invalid options
|
197
|
+
and add all defaults.
|
198
|
+
"""
|
199
|
+
with open(
|
200
|
+
os.path.join(self.main_data_directory, OPTION_FILE),
|
201
|
+
mode="w",
|
202
|
+
encoding="utf-8",
|
203
|
+
) as option_file:
|
204
|
+
yaml.dump(
|
205
|
+
{
|
206
|
+
"separator": self.separator,
|
207
|
+
"languages": self.languages,
|
208
|
+
"length_unit": self.length_unit,
|
209
|
+
"sign_max_height": self.sign_max_height,
|
210
|
+
"sign_max_width": self.sign_max_width,
|
211
|
+
},
|
212
|
+
option_file,
|
213
|
+
**YAML_DUMP_OPTIONS,
|
214
|
+
)
|
215
|
+
|
216
|
+
|
217
|
+
def merge_lists(orig: list[Any], additional: list[Any]) -> list[Any]:
|
218
|
+
"""Add all elements of additional to orig that are not in there yet.
|
219
|
+
|
220
|
+
Duplicates in orig and additional individually are preserved.
|
221
|
+
"""
|
222
|
+
return orig + [element for element in additional if element not in orig]
|
223
|
+
|
224
|
+
|
225
|
+
def fix_deprecated_language_keys(
|
226
|
+
data: dict[str, Any], options: Options, delimiter: str = "_"
|
227
|
+
) -> None:
|
228
|
+
"""Change language suffixes into subelements.
|
229
|
+
|
230
|
+
Deprecated. Probably does not work anymore.
|
231
|
+
|
232
|
+
For each key xyz_LA move data["xyz_LA"] into data["xyz"]["LA"]
|
233
|
+
where LA is a recognized language suffix.
|
234
|
+
|
235
|
+
If the value is a list, merge it with an existing list.
|
236
|
+
|
237
|
+
Args:
|
238
|
+
data: dictionary that is changed
|
239
|
+
options: options including the language suffixes
|
240
|
+
delimiter: what splits the language from the actual key
|
241
|
+
Raises:
|
242
|
+
ValueError: if I would overwrite data. The keys that are processed
|
243
|
+
before the error are changed. (Yes, this is a bug.)
|
244
|
+
"""
|
245
|
+
for key in data:
|
246
|
+
if any(key.endswith(f"{delimiter}{LA}") for LA in options.languages):
|
247
|
+
new_key, language = key.rsplit(delimiter, 1)
|
248
|
+
if new_key in data:
|
249
|
+
if not isinstance(new_key, dict):
|
250
|
+
raise ValueError(
|
251
|
+
f"{new_key} has no support for languages, "
|
252
|
+
f"cannot insert {key}: {data[key]}"
|
253
|
+
)
|
254
|
+
if data[new_key][language] == data[key]:
|
255
|
+
# already there
|
256
|
+
del data[key]
|
257
|
+
elif isinstance(data[new_key][language], list):
|
258
|
+
if isinstance(data[key], list):
|
259
|
+
data[new_key][language] = merge_lists(
|
260
|
+
data[new_key][language], data[key]
|
261
|
+
)
|
262
|
+
else:
|
263
|
+
data[new_key][language].append(data[key])
|
264
|
+
del data[key]
|
265
|
+
elif isinstance(data[new_key][language], dict):
|
266
|
+
raise NotImplementedError(
|
267
|
+
"Inserting into dictionaries at level 2 not supported."
|
268
|
+
)
|
269
|
+
elif isinstance(data[key], (str, int, float, bool)):
|
270
|
+
raise ValueError(
|
271
|
+
f"Value {data[new_key][language]} for key {new_key} in {language} "
|
272
|
+
f"would be overwritten by value {data[key]} for key {new_key}."
|
273
|
+
)
|
274
|
+
else:
|
275
|
+
raise NotImplementedError(
|
276
|
+
"Did not think of this possibility of"
|
277
|
+
f"key = {key}, "
|
278
|
+
f"value = {data[key]}, "
|
279
|
+
f"type = {type(data[key])}, "
|
280
|
+
f"new_key = {new_key}, "
|
281
|
+
f"language = {language}, "
|
282
|
+
f"existing value = {data[key][language]}, "
|
283
|
+
)
|
284
|
+
# else: new_key not in data
|
285
|
+
data[new_key] = {language: data[key]}
|