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,455 @@
1
+ """Locations as specified (by example) in locations.json.
2
+
3
+ The schema how locations are hierachically defined is defined in
4
+ locations.json.
5
+
6
+ Recognised keys are:
7
+ - seperator (str)
8
+ - schema (dict)
9
+ - name (str|int) : what this location is called
10
+ - shortcut (str|int) : how this location is called in a short location string
11
+ - levelname (str) : key for specifying subelements
12
+ - default (str) : name of default subelement. This is only used to help UIs to suggest
13
+ something. It is not used to fill missing data.
14
+ - subs (dict): same information for the subelements (a list of schemas)
15
+ if non-existing, all names are accepted
16
+ - subschema (dict): information given to all subelements
17
+ - locations (dict): where the things are
18
+ - keys (str) : given by levelname
19
+ - values (str|int): the possible values given by the keys in the
20
+ subs Schema or any value if no subs are given
21
+ """
22
+
23
+ import logging
24
+
25
+ import pathlib
26
+
27
+ from typing import Any, Union, Optional, Self, Literal
28
+ import os.path
29
+ import yaml
30
+
31
+ from . import constant
32
+
33
+ Value = Union[str, int, float, bool]
34
+ """Valid level specifier types (values in location dict)."""
35
+
36
+
37
+ class InvalidLocationSchema(Exception):
38
+ """Raised if the schema is invalid.
39
+
40
+ Should be raised "from" the original exception.
41
+ """
42
+
43
+
44
+ class InvalidLocation(Exception):
45
+ """Raised if a location is invalid.
46
+
47
+ Should be raised "from" the original exception.
48
+ """
49
+
50
+
51
+ class Schema:
52
+ """Schema as described in module docstring, possibly with sub-schemas.
53
+
54
+ Not yet possible: creation of json based on Schema.
55
+ Do not know if I might need that.
56
+ """
57
+
58
+ def __init__(self, json_content: dict[str, Any]):
59
+ """Create schema with sub schemas."""
60
+ # just save it to be able to give it back
61
+ self.json = json_content
62
+ try:
63
+ self._name = json_content["name"]
64
+ except KeyError as key_error:
65
+ raise InvalidLocationSchema("Name is mandatory for schema.") from key_error
66
+ self._name = (
67
+ self._name.strip() if isinstance(self._name, str) else int(self._name)
68
+ )
69
+ if self._name == "":
70
+ raise InvalidLocationSchema(
71
+ "A schema name must not be the empty/ a pure whitespace string."
72
+ )
73
+ try:
74
+ self._shortcut = json_content["shortcut"]
75
+ except KeyError:
76
+ self._shortcut = (
77
+ self._name[0] if isinstance(self._name, str) else int(self._name)
78
+ )
79
+ self._levelname = json_content.get("levelname", None)
80
+ self._default = json_content.get("default", None)
81
+ if isinstance(self._default, str):
82
+ self._default = self._default.strip()
83
+ if self._default == "":
84
+ raise InvalidLocationSchema(
85
+ "A default subschema key must not be the empty string."
86
+ )
87
+ if self._levelname is None and self._default is not None:
88
+ raise InvalidLocationSchema(
89
+ "A default subschema makes no sense if no levelname is given."
90
+ )
91
+ self._sub_schema_default, self._subs = self._initialise_subs(json_content)
92
+ if self._levelname is None and self._subs is not None:
93
+ raise InvalidLocationSchema(
94
+ f"If sub schemas for {self._name} are mentioned, "
95
+ "also a levelname is necessary "
96
+ "to specify the subschema."
97
+ )
98
+
99
+ @classmethod
100
+ def _initialise_subs(
101
+ cls, json_content: dict[str, Any]
102
+ ) -> tuple[dict[str, Any], Optional[list[Self]]]:
103
+ """Read sub element information and return info accordingly.
104
+
105
+ Returns:
106
+ - sub_schema_default
107
+ - subs
108
+ """
109
+ sub_schema_default = json_content.get("subschema", {})
110
+ try:
111
+ if "name" in sub_schema_default or "shortcut" in sub_schema_default:
112
+ raise InvalidLocationSchema(
113
+ "It does not make sense to give all "
114
+ "sub elements the same name or shortcut."
115
+ )
116
+ except TypeError as typeerror:
117
+ # mainly check this here to make pylint happy with the "in" statements
118
+ raise InvalidLocationSchema("subschema must be a dict.") from typeerror
119
+ subs = None
120
+ try:
121
+ sub_element_list = json_content["subs"]
122
+ except KeyError:
123
+ pass
124
+ else:
125
+ subs = []
126
+ try:
127
+ sub_element_iter = iter(sub_element_list)
128
+ except TypeError as typeerror:
129
+ raise InvalidLocationSchema(
130
+ "subs contains something that is not a list"
131
+ ) from typeerror
132
+ for sub_element in sub_element_iter:
133
+ if isinstance(sub_element, (str, int)):
134
+ subs.append(cls(sub_schema_default | {"name": sub_element}))
135
+ elif isinstance(sub_element, dict):
136
+ subs.append(cls(sub_schema_default | sub_element))
137
+ else:
138
+ raise InvalidLocationSchema(
139
+ 'Elements of list in "subs" must be '
140
+ "str, int (name) or dict (schema)."
141
+ )
142
+ assert len(subs) == len(sub_element_list)
143
+ return sub_schema_default, subs
144
+
145
+ def get_subschema(self, key: str | int) -> Self:
146
+ """Get the subschema for the given key.
147
+
148
+ If subs are given, first their names are checked for a match,
149
+ then their shortcuts.
150
+ Otherwise, a generic schema based on the default schema is given.
151
+ Then all keys are accepted.
152
+ """
153
+ if self._subs is None:
154
+ return type(self)(self._sub_schema_default | {"name": key})
155
+ suitably_named_subs = [sub for sub in self._subs if sub.name == key]
156
+ if len(suitably_named_subs) == 1:
157
+ return suitably_named_subs[0]
158
+ if len(suitably_named_subs) > 1:
159
+ raise InvalidLocationSchema(
160
+ f"{self._levelname} has the name " f"{key} twice in subschemas."
161
+ )
162
+ suitably_named_subs = [sub for sub in self._subs if sub.shortcut == key]
163
+ if len(suitably_named_subs) == 1:
164
+ return suitably_named_subs[0]
165
+ if len(suitably_named_subs) > 1:
166
+ raise InvalidLocationSchema(
167
+ f"{self._levelname} has the shortcut " f"{key} twice in subschemas."
168
+ )
169
+ raise InvalidLocation(
170
+ f"The key {key} is not a valid subschema "
171
+ f"for {self._name} (levelname: {self._levelname}.)"
172
+ )
173
+
174
+ @property
175
+ def name(self) -> str | int:
176
+ """Get the shortcut used in location strings."""
177
+ return self._name
178
+
179
+ @property
180
+ def shortcut(self) -> str | int:
181
+ """Get the shortcut used in location strings."""
182
+ return self._shortcut
183
+
184
+ @property
185
+ def levelname(self) -> str:
186
+ """Get levelname, which is the key in locations to specify the subschema.
187
+
188
+ Raises:
189
+ InvalidLocation if no subschemas are allowed due to missing levelname.
190
+ """
191
+ if self._levelname is None:
192
+ raise InvalidLocation(f"The schema {self._name} has no levelname.")
193
+ return self._levelname
194
+
195
+ def get_schema_hierarchy(self, location_info: dict[str, Any]) -> list[Self]:
196
+ """Return a list of nested schemas for this location starting with this.
197
+
198
+ Default values are ignored.
199
+ """
200
+ if (
201
+ self._levelname is None # this schema has no subelements
202
+ # no sub is given and no default is set
203
+ or self._levelname not in location_info
204
+ # sub is explicitly set to None
205
+ or (
206
+ self._levelname in location_info
207
+ and location_info[self._levelname] is None
208
+ )
209
+ ):
210
+ return [self]
211
+ return [self] + self.get_subschema(
212
+ location_info[self._levelname]
213
+ ).get_schema_hierarchy(location_info)
214
+
215
+ def get_valid_subs(
216
+ self, shortcuts: Literal["yes", "only", "()", "*"] = "yes"
217
+ ) -> Optional[list[Union[str, int]]]:
218
+ """A list of valid sub schemas. Usable for suggesting options.
219
+
220
+ Args:
221
+ shortcuts: if valid shortcuts should be listed as well:
222
+ 'yes': shortcuts listed in the same way as names,
223
+ 'only': only shortcuts are listed
224
+ '()': shortcuts are listed in parentheses after the name (note: result
225
+ elements are not valid values)
226
+ '*': shortcuts are marked with ** ** if they are in the name, otherwise like '()'
227
+ Return:
228
+ None if no levelname is given,
229
+ an empty list if everything is valid
230
+ """
231
+ if self._levelname is None:
232
+ return None
233
+ if self._subs is None:
234
+ return []
235
+ if shortcuts == "yes":
236
+ return [name for sub in self._subs for name in (sub.name, sub.shortcut)]
237
+ if shortcuts == "only":
238
+ return [sub.shortcut for sub in self._subs]
239
+ if shortcuts == "()":
240
+ return [f"{sub.name} ({sub.shortcut})" for sub in self._subs]
241
+ if shortcuts == "*":
242
+ return [
243
+ (
244
+ (
245
+ f"{str(sub.name)[:str(sub.name).index(str(sub.shortcut))]}"
246
+ f"**{sub.shortcut}**"
247
+ + str(sub.name)[
248
+ str(sub.name).index(str(sub.shortcut))
249
+ + len(str(sub.shortcut)) :
250
+ ]
251
+ )
252
+ if str(sub.shortcut) in str(sub.name)
253
+ else f"{sub.name} ({sub.shortcut})"
254
+ )
255
+ for sub in self._subs
256
+ ]
257
+ raise ValueError('shortcuts needs to be one of "yes", "only", "()", "*"')
258
+
259
+ @classmethod
260
+ def from_file(cls, directory):
261
+ """Read schema from SCHEMA_FILE in directory.
262
+
263
+ If file does not exist, create empty schema.
264
+ """
265
+ try:
266
+ schema_file = open(os.path.join(directory, constant.SCHEMA_FILE))
267
+ except FileNotFoundError:
268
+ return cls({"name": "Workshop"})
269
+ return cls(yaml.safe_load(schema_file))
270
+
271
+
272
+ class Location(dict[str, Value]):
273
+ """A location for stuff. Member var definition in schema.
274
+
275
+ Values are accessed with dict member notation. `loc["shelf"] = 5`
276
+
277
+ Attributes:
278
+ _schema: schema defining the meaning of the keys
279
+ _levels: list of schemas for all levels of the location hierarchy
280
+ options: global options including the seperator
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ schema: Schema,
286
+ data: dict[str, Value],
287
+ directory: str,
288
+ options: constant.Options,
289
+ ):
290
+ """Create a location from data and the schema.
291
+
292
+ schema: location schema
293
+ data: location data as a dict
294
+ directory: where to save this location to (saved to directory/{constant.LOCATION_FILE})
295
+ options: general inventory options (separator is used)
296
+ """
297
+ self._directory = None # no autosave during initialisation
298
+ super().__init__(data)
299
+ self.options = options
300
+ self._directory = directory
301
+ self._logger = logging.getLogger(__name__)
302
+ self._schema = schema
303
+ self._levels = self._schema.get_schema_hierarchy(self)
304
+ self.canonicalize()
305
+
306
+ def _shortcut(self):
307
+ """Get string representation with shortcuts."""
308
+ return self.options.separator.join(
309
+ str(level.shortcut) for level in self._levels
310
+ )
311
+
312
+ @property
313
+ def long_name(self):
314
+ """Get the string representation with names."""
315
+ return self.options.separator.join(str(level.name) for level in self._levels)
316
+
317
+ @property
318
+ def schema(self) -> Schema:
319
+ """Return the schema given in the location file used to define this location.
320
+
321
+ Please do not alter.
322
+ """
323
+ return self._schema
324
+
325
+ def __str__(self):
326
+ """Get string representation of the location.
327
+
328
+ Uses the schema and the seperator.
329
+
330
+ Assumes that the schema is valid.
331
+
332
+ Raises:
333
+ InvalidLocation: if the location info do not suit
334
+ the schema
335
+ Returns:
336
+ shortcuts for places separated by
337
+ seperator given at initialisation
338
+ Can be an empty string if the top-level schema
339
+ is not present or None.
340
+ """
341
+ return self._shortcut()
342
+
343
+ def to_sortable_tuple(self):
344
+ """Return the data of this location as sortable tuple."""
345
+ return (level.name for level in self._levels)
346
+
347
+ def canonicalize(self):
348
+ """Replace shortcuts by entire names."""
349
+ for index, level in list(enumerate(self._levels))[:-1]:
350
+ # use super() because with self[key] = ... we get an infinite recursion:
351
+ super().__setitem__(level.levelname, self._levels[index + 1].name)
352
+
353
+ def __setitem__(self, level: str, value: Value) -> None:
354
+ """Set one level info.
355
+
356
+ Or deletes it if it's None or "" or [] or {}
357
+
358
+ Update internal info.
359
+ """
360
+ if value in [None, "", [], {}]:
361
+ if level in self:
362
+ del self[level]
363
+ else:
364
+ super().__setitem__(level, value)
365
+ self._levels = self._schema.get_schema_hierarchy(self)
366
+ if self._directory is not None:
367
+ self.save()
368
+
369
+ def __delitem__(self, key: str) -> None:
370
+ """Deletes one information.
371
+
372
+ Update internal info.
373
+ """
374
+ super().__delitem__(key)
375
+ self._levels = self._schema.get_schema_hierarchy(self)
376
+ if self._directory is not None:
377
+ self.save()
378
+
379
+ def __bool__(self):
380
+ """True if something is saved."""
381
+ return bool(super())
382
+
383
+ def save(self):
384
+ """Save location data to yaml file."""
385
+ path = pathlib.Path(os.path.join(self._directory, constant.LOCATION_FILE))
386
+ if len(self) > 0: # not empty?
387
+ with open(path, mode="w", encoding="utf-8") as location_file:
388
+ yaml.dump(
389
+ data=self.to_jsonable_data(),
390
+ stream=location_file,
391
+ **constant.YAML_DUMP_OPTIONS,
392
+ )
393
+ self._logger.info(f"Saved {self.directory} location to {path}.")
394
+ else:
395
+ if path.is_file():
396
+ self._logger.info(f"Delete {path}")
397
+ path.unlink(missing_ok=True)
398
+
399
+ def to_jsonable_data(self) -> dict[str, Value]:
400
+ """Convert to dict with levelnames as keys.
401
+
402
+ Sorts by schema hierarchy.
403
+ """
404
+ self._levels = self.schema.get_schema_hierarchy(self)
405
+ as_dict = {
406
+ level.levelname: self._levels[index + 1].name
407
+ for index, level in list(enumerate(self._levels))[:-1]
408
+ }
409
+ for key in self:
410
+ as_dict.setdefault(key, self[key])
411
+ return as_dict
412
+
413
+ @classmethod
414
+ def from_yaml_file(cls, directory: str, schema: Schema, options: constant.Options):
415
+ """Create location from yaml file.
416
+
417
+ Args:
418
+ directory: file is directory/{constant.LOCATION_FILE}
419
+ schema: location schema for interpreting the data
420
+ options: inventory-wide options, in particular separator
421
+ """
422
+ path = pathlib.Path(os.path.join(directory, constant.LOCATION_FILE))
423
+ try:
424
+ location_file = open(path, mode="r", encoding="utf-8")
425
+ except FileNotFoundError:
426
+ return cls(schema=schema, data={}, directory=directory, options=options)
427
+ with location_file:
428
+ data = yaml.safe_load(location_file)
429
+ return cls(
430
+ schema=schema,
431
+ data=data,
432
+ directory=directory,
433
+ options=options,
434
+ )
435
+
436
+ @property
437
+ def directory(self) -> Optional[str]:
438
+ """The directory in which the location data file is saved.
439
+
440
+ If none, changes are not saved.
441
+ """
442
+ return self._directory
443
+
444
+ @directory.setter
445
+ def directory(self, new_directory):
446
+ """Set the directory in which the data file is saved.
447
+
448
+ Save the data there.
449
+ The old data file is removed.
450
+ """
451
+ pathlib.Path(os.path.join(self._directory, constant.LOCATION_FILE)).unlink(
452
+ missing_ok=True
453
+ )
454
+ self._directory = new_directory
455
+ self.save()
flinventory/sign.py ADDED
@@ -0,0 +1,160 @@
1
+ #! /usr/bin/env python
2
+ """Class sign that contains the info for a sign for a thing."""
3
+ import itertools
4
+
5
+ import logging
6
+ import os.path
7
+
8
+ import pathlib
9
+ import yaml
10
+ from typing import Optional
11
+ from typing_extensions import override
12
+
13
+ from . import constant
14
+ from . import defaulted_data
15
+
16
+
17
+ class Sign(defaulted_data.DefaultedDict):
18
+ """Contains info about a sign."""
19
+
20
+ def __init__(
21
+ self,
22
+ data: defaulted_data.Data,
23
+ thing: defaulted_data.DefaultedDict,
24
+ options: constant.Options,
25
+ directory: Optional[str],
26
+ ):
27
+ """Create a sign.
28
+
29
+ Args:
30
+ data: dict with data, e.g. read from yaml file
31
+ thing: similar structured dict object from which to use data if
32
+ it is not overwritten in data. Useful for the name which is usually
33
+ given by the thing for which this is a sign but can be replaced by a
34
+ different version of the name in data
35
+ options: languages and other inventory-wide options
36
+ directory: directory where to save this sign data when changed.
37
+ The file where the data is saved is directory/{constant.SIGN_FILE}
38
+ If None, it not saved. (Can be set later to save.)
39
+ """
40
+ self._directory = None # to avoid saving during initialisation
41
+ for source, target in [("fontsize_de", ("fontsize", "de")),
42
+ ("fontsize_en", ("fontsize", "en")),
43
+ ("fontsize_main", ("fontsize", "de")),
44
+ ("fontsize_secondary", ("fontsize", "en"))]:
45
+ try:
46
+ data[target] = data[source]
47
+ del data[source]
48
+ # could throw exception if source was in default but probably will never happen
49
+ except KeyError:
50
+ pass # fine, not there
51
+ super().__init__(
52
+ data=data,
53
+ default=thing,
54
+ non_defaulted=["width", "height", "printed", "landscape", "fontsize"],
55
+ lists=thing.lists,
56
+ translated=thing.translated + ("fontsize",),
57
+ default_order=itertools.chain(
58
+ thing.default_order,
59
+ (
60
+ "width",
61
+ "height",
62
+ "location_shift_down",
63
+ "fontsize",
64
+ "printed",
65
+ ),
66
+ ),
67
+ options=options,
68
+ )
69
+ self._logger = logging.getLogger(__name__)
70
+ self._directory = directory
71
+
72
+ @property
73
+ def directory(self) -> Optional[str]:
74
+ """The directory in which the sign data file is saved.
75
+
76
+ None if data is not saved.
77
+ """
78
+ return self._directory
79
+
80
+ @directory.setter
81
+ def directory(self, new_directory: Optional[str]):
82
+ """Set the directory in which the data file is saved.
83
+
84
+ Save the data there if it is not None.
85
+ The old data file is removed.
86
+ """
87
+ pathlib.Path(os.path.join(self._directory, constant.SIGN_FILE)).unlink(
88
+ missing_ok=True
89
+ )
90
+ self._directory = new_directory
91
+ self.save()
92
+
93
+ def printable(self) -> bool:
94
+ """True if width and height are given."""
95
+ return "width" in self and "height" in self
96
+
97
+ def should_be_printed(self) -> bool:
98
+ """True if printable and not printed."""
99
+ return self.printable() and not bool(self.get("printed", False))
100
+
101
+ def save(self):
102
+ """Save data to yaml file.
103
+
104
+ Do not check if something is overwritten.
105
+
106
+ Delete file if no data is there to be written.
107
+
108
+ If self.directory is None, do nothing.
109
+ Todo: include git add and commit.
110
+ """
111
+ if self._directory is None:
112
+ return
113
+ jsonable_data = self.to_jsonable_data()
114
+ path = pathlib.Path(os.path.join(self._directory, constant.SIGN_FILE))
115
+ if not jsonable_data:
116
+ if path.is_file():
117
+ self._logger.info(f"Delete {path}")
118
+ path.unlink(missing_ok=True)
119
+ else:
120
+ with open(path, mode="w", encoding="utf-8") as sign_file:
121
+ yaml.dump(
122
+ self.to_jsonable_data(), sign_file, **constant.YAML_DUMP_OPTIONS
123
+ )
124
+ self._logger.info(
125
+ f"Saved {self.get(('name', 0), self._directory)} sign to {path}."
126
+ )
127
+
128
+ @override
129
+ def __setitem__(self, key, value):
130
+ """self[key] = value as in DefaultedDict but saved afterward."""
131
+ super().__setitem__(key, value)
132
+ self.save()
133
+
134
+ @override
135
+ def __delitem__(self, key):
136
+ """del self[key] as in DefaultedDict but saved afterward."""
137
+ super().__delitem__(key)
138
+ self.save()
139
+
140
+ @classmethod
141
+ def from_yaml_file(
142
+ cls,
143
+ directory: str,
144
+ thing: defaulted_data.DefaultedDict,
145
+ options: constant.Options,
146
+ ):
147
+ """Create a sign from data in a file.
148
+
149
+ Args:
150
+ directory: directory with sign file
151
+ thing: thing for which this is a sign. Used as default data.
152
+ options: inventory-wide options
153
+ """
154
+ path = pathlib.Path(os.path.join(directory, constant.SIGN_FILE))
155
+ try:
156
+ sign_file = open(path, mode="r", encoding="utf-8")
157
+ except FileNotFoundError:
158
+ return cls({}, thing, options, directory)
159
+ with sign_file:
160
+ return cls(yaml.safe_load(sign_file), thing, options, directory)