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.
@@ -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
@@ -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()
@@ -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]}