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
@@ -0,0 +1,214 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""Create a list of things to hang in the place with all the stuff (e.g. a bike shed)."""
|
3
|
+
import logging
|
4
|
+
import pypandoc
|
5
|
+
|
6
|
+
import argparse
|
7
|
+
import os.path
|
8
|
+
from typing import cast
|
9
|
+
|
10
|
+
try:
|
11
|
+
import treelib
|
12
|
+
except ModuleNotFoundError:
|
13
|
+
print(
|
14
|
+
"module treelib not found. "
|
15
|
+
"Install it system-wide or create a conda environment "
|
16
|
+
"with the environment.yml named inventory."
|
17
|
+
)
|
18
|
+
import sys
|
19
|
+
|
20
|
+
sys.exit(1)
|
21
|
+
|
22
|
+
from .box import BoxedThing
|
23
|
+
from .signprinter_latex import SignPrinterLaTeX
|
24
|
+
from . import inventory_io
|
25
|
+
|
26
|
+
HEADER_MARKDOWN_THING_LIST = [
|
27
|
+
"---",
|
28
|
+
"classoption:",
|
29
|
+
"- twocolumn",
|
30
|
+
"geometry:",
|
31
|
+
"- margin=0.5cm",
|
32
|
+
"- bottom=1.5cm",
|
33
|
+
"---",
|
34
|
+
"",
|
35
|
+
"# Fahrradteile" "",
|
36
|
+
]
|
37
|
+
|
38
|
+
|
39
|
+
def add_arg_parsers(subparsers):
|
40
|
+
"""Make a command-line argument parser as a child of parent_parser.
|
41
|
+
|
42
|
+
Supply information from command-line arguments.
|
43
|
+
|
44
|
+
Args:
|
45
|
+
subparsers: list of subparsers to add to. Type is private to argparse module.
|
46
|
+
"""
|
47
|
+
parser = subparsers.add_parser(
|
48
|
+
"label",
|
49
|
+
description="""Create lists and signs for things in the inventory.
|
50
|
+
|
51
|
+
The output directory is created. If the outputfiles are
|
52
|
+
not directly in the output directory, the directories
|
53
|
+
where the output files are supposed to land must exist.
|
54
|
+
|
55
|
+
Output files overwrite existing files.
|
56
|
+
""",
|
57
|
+
)
|
58
|
+
parser.add_argument(
|
59
|
+
"-l",
|
60
|
+
"--list",
|
61
|
+
dest="list",
|
62
|
+
action="store_true",
|
63
|
+
help="Create pdf list. Uses LaTeX via pandoc, might take a bit longer.",
|
64
|
+
)
|
65
|
+
parser.add_argument(
|
66
|
+
"--signs",
|
67
|
+
action="store_true",
|
68
|
+
help="Create pdf signs as pdf. Uses LaTeX, might take a bit longer.",
|
69
|
+
)
|
70
|
+
|
71
|
+
parser.set_defaults(func=generate_labels)
|
72
|
+
|
73
|
+
|
74
|
+
def get_markdown_list(things):
|
75
|
+
"""Get markdown representation of thing list."""
|
76
|
+
# each item can appear several times since alternative names
|
77
|
+
# might not be unique: things can be similar.
|
78
|
+
# so for every thing we need a list of lines
|
79
|
+
lines = {}
|
80
|
+
|
81
|
+
def add_line(name: str, new_line: str):
|
82
|
+
if name in lines:
|
83
|
+
lines[name].append(new_line)
|
84
|
+
else:
|
85
|
+
lines[name] = [new_line]
|
86
|
+
|
87
|
+
for thing in things:
|
88
|
+
if thing.get("category", False):
|
89
|
+
continue
|
90
|
+
add_line(thing.best("name", backup="?"), thing.markdown_representation())
|
91
|
+
for alt_name, line in thing.alt_names_markdown().items():
|
92
|
+
add_line(alt_name, line)
|
93
|
+
markdown = "\n".join(
|
94
|
+
HEADER_MARKDOWN_THING_LIST
|
95
|
+
+ [
|
96
|
+
line
|
97
|
+
for name in sorted(list(lines.keys()), key=str.lower)
|
98
|
+
for line in lines[name]
|
99
|
+
]
|
100
|
+
)
|
101
|
+
return markdown
|
102
|
+
|
103
|
+
|
104
|
+
def get_tree(things: inventory_io.Inventory) -> str:
|
105
|
+
"""Create a simple tree structure visualizing the things."""
|
106
|
+
tree = treelib.Tree()
|
107
|
+
nodes = []
|
108
|
+
tree.create_node("Fahrrad", "fahrrad-0")
|
109
|
+
for thing in things:
|
110
|
+
name = str(thing.best("name", backup="?"))
|
111
|
+
parents = cast(tuple, thing.get("part_of", ["fahrrad"]))
|
112
|
+
for instance_nr, parent in enumerate(parents):
|
113
|
+
node_id = f"{things.get_id(thing)}-{instance_nr}"
|
114
|
+
# doesn't work well with categories that appear several times, whatever:
|
115
|
+
parent = parent + "-0"
|
116
|
+
nodes.append((name, node_id, parent))
|
117
|
+
delayed_nodes = []
|
118
|
+
inserted_node = True # save if in previous run,
|
119
|
+
# some node got removed from the list of all nodes
|
120
|
+
while inserted_node:
|
121
|
+
logging.debug("Start insertion walkthrough")
|
122
|
+
inserted_node = False
|
123
|
+
for node in nodes:
|
124
|
+
try:
|
125
|
+
tree.create_node(node[0], node[1], node[2])
|
126
|
+
except treelib.exceptions.NodeIDAbsentError:
|
127
|
+
delayed_nodes.append(node)
|
128
|
+
except treelib.exceptions.DuplicatedNodeIdError as e:
|
129
|
+
logging.error(f"{node[1]} twice: {e}")
|
130
|
+
inserted_node = True
|
131
|
+
else:
|
132
|
+
inserted_node = True
|
133
|
+
nodes = delayed_nodes
|
134
|
+
delayed_nodes = []
|
135
|
+
if len(nodes) > 0:
|
136
|
+
logging.error("Remaining nodes:" + ", ".join((str(node) for node in nodes)))
|
137
|
+
return tree.show(stdout=False)
|
138
|
+
|
139
|
+
|
140
|
+
def create_listpdf(markdown: str, pdffile):
|
141
|
+
"""Create a pdf file that can be printed that lists all things.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
markdown: path to markdown file that is converted
|
145
|
+
pdffile: path to created pdf file
|
146
|
+
"""
|
147
|
+
markdown = markdown.replace("\u2640", "(weiblich)").replace("\u2642", "männlich")
|
148
|
+
pypandoc.convert_text(
|
149
|
+
markdown,
|
150
|
+
format="md",
|
151
|
+
to="pdf",
|
152
|
+
outputfile=pdffile,
|
153
|
+
extra_args=[
|
154
|
+
"-V",
|
155
|
+
"geometry:top=1cm,bottom=1cm,left=1cm,right=1.5cm",
|
156
|
+
"-V",
|
157
|
+
"classoption=twocolumn",
|
158
|
+
],
|
159
|
+
)
|
160
|
+
|
161
|
+
|
162
|
+
def generate_labels(options: argparse.Namespace):
|
163
|
+
"""Create lists and signs based on command-line options.
|
164
|
+
|
165
|
+
Args:
|
166
|
+
options: command line options given
|
167
|
+
"""
|
168
|
+
try:
|
169
|
+
os.mkdir(options.output_dir)
|
170
|
+
except FileExistsError:
|
171
|
+
pass # everything fine
|
172
|
+
logging.basicConfig(
|
173
|
+
filename=os.path.join(options.output_dir, options.logfile),
|
174
|
+
level=logging.DEBUG,
|
175
|
+
filemode="w",
|
176
|
+
)
|
177
|
+
logging.info("Create thing list")
|
178
|
+
all_things: list[BoxedThing] = inventory_io.Inventory.from_json_files(
|
179
|
+
options.dataDirectory
|
180
|
+
)
|
181
|
+
with open(
|
182
|
+
os.path.join(options.output_dir, options.output_tree),
|
183
|
+
mode="w",
|
184
|
+
encoding="UTF-8",
|
185
|
+
) as treefile:
|
186
|
+
logging.info("Start creating tree view.")
|
187
|
+
treefile.write(get_tree(all_things))
|
188
|
+
logging.info("Start creating markdownlist.")
|
189
|
+
markdown = get_markdown_list(all_things)
|
190
|
+
try:
|
191
|
+
with open(
|
192
|
+
list_md_file_name := os.path.join(
|
193
|
+
options.output_dir, options.output_mdlist
|
194
|
+
),
|
195
|
+
mode="w",
|
196
|
+
encoding="UTF-8",
|
197
|
+
) as mdfile:
|
198
|
+
mdfile.write(markdown)
|
199
|
+
except IOError as io_error:
|
200
|
+
logging.error(f"IOError {io_error} occured during writing list Markdown file.")
|
201
|
+
if options.list:
|
202
|
+
logging.info("Create no list pdf since the markdown file does not exist..")
|
203
|
+
else:
|
204
|
+
if options.list:
|
205
|
+
create_listpdf(
|
206
|
+
markdown,
|
207
|
+
os.path.join(options.output_dir, options.output_pdflist),
|
208
|
+
)
|
209
|
+
logging.info("Start creating sign LaTeX.")
|
210
|
+
sign_printer_latex = SignPrinterLaTeX(options)
|
211
|
+
if options.signs:
|
212
|
+
sign_printer_latex.create_signs_pdf(all_things)
|
213
|
+
else:
|
214
|
+
sign_printer_latex.save_signs_latex(all_things)
|
@@ -0,0 +1,295 @@
|
|
1
|
+
"""Utilities to read and write files for things, locations and signs.
|
2
|
+
|
3
|
+
todo: consistency check for Inventory: schema and options all the same and things consistent?
|
4
|
+
"""
|
5
|
+
|
6
|
+
import random
|
7
|
+
|
8
|
+
import argparse
|
9
|
+
import logging
|
10
|
+
import os
|
11
|
+
from typing import Callable, Sequence, Iterable, Optional, Self, Any, Iterator
|
12
|
+
|
13
|
+
from . import constant
|
14
|
+
from .box import BoxedThing
|
15
|
+
from .location import Schema
|
16
|
+
|
17
|
+
|
18
|
+
class Inventory(list[BoxedThing]):
|
19
|
+
"""A list of things with some metadata.
|
20
|
+
|
21
|
+
An inventory can be list as list[Thing] but also stores
|
22
|
+
the location schema as the attribute inventory.schema
|
23
|
+
(None if none given) and options.
|
24
|
+
"""
|
25
|
+
|
26
|
+
def __init__(
|
27
|
+
self,
|
28
|
+
things: Iterable[BoxedThing],
|
29
|
+
directory: str = ".",
|
30
|
+
options: Optional[constant.Options] = None,
|
31
|
+
schema: Optional[Schema] = None,
|
32
|
+
):
|
33
|
+
"""Create an inventory from a list of things.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
directory: where all options and data is stored
|
37
|
+
things: list of things in the inventory
|
38
|
+
options: inventory-wide options. Taken from first thing if not given
|
39
|
+
schema: inventory-wide location schema. Taken from first if not given
|
40
|
+
"""
|
41
|
+
super().__init__(things)
|
42
|
+
self._directory = directory
|
43
|
+
os.makedirs(directory, exist_ok=True)
|
44
|
+
if schema is None:
|
45
|
+
if len(self) == 0:
|
46
|
+
self.schema = Schema({})
|
47
|
+
else:
|
48
|
+
self.schema = self[0].location.schema
|
49
|
+
else:
|
50
|
+
self.schema = schema
|
51
|
+
|
52
|
+
if options is None:
|
53
|
+
if len(self) == 0:
|
54
|
+
self.options = constant.Options({})
|
55
|
+
else:
|
56
|
+
self.options = self[0].options
|
57
|
+
else:
|
58
|
+
self.options = options
|
59
|
+
|
60
|
+
@classmethod
|
61
|
+
def from_json_files(cls, directory: str = ".") -> Self:
|
62
|
+
"""Create an inventory from a directory with the structure described in the README.
|
63
|
+
|
64
|
+
Args:
|
65
|
+
directory: directory with options, schema and data
|
66
|
+
Returns:
|
67
|
+
an inventory of things including location and sign as members
|
68
|
+
"""
|
69
|
+
options = constant.Options.from_file(directory)
|
70
|
+
schema = Schema.from_file(directory)
|
71
|
+
try:
|
72
|
+
thing_directories = os.listdir(
|
73
|
+
os.path.join(directory, constant.THING_DIRECTORY)
|
74
|
+
)
|
75
|
+
except (FileNotFoundError, NotADirectoryError):
|
76
|
+
boxed_things = []
|
77
|
+
else:
|
78
|
+
boxed_things = [
|
79
|
+
BoxedThing.from_files(
|
80
|
+
directory=os.path.join(
|
81
|
+
directory, constant.THING_DIRECTORY, thing_directory
|
82
|
+
),
|
83
|
+
options=options,
|
84
|
+
schema=schema,
|
85
|
+
)
|
86
|
+
for thing_directory in thing_directories
|
87
|
+
]
|
88
|
+
return cls(
|
89
|
+
things=boxed_things, directory=directory, options=options, schema=schema
|
90
|
+
)
|
91
|
+
|
92
|
+
@property
|
93
|
+
def directory(self):
|
94
|
+
"""The directory containing all options and data."""
|
95
|
+
return self._directory
|
96
|
+
|
97
|
+
def add_thing(self) -> BoxedThing:
|
98
|
+
"""Add an empty thing.
|
99
|
+
|
100
|
+
Returns:
|
101
|
+
the newly created thing.
|
102
|
+
"""
|
103
|
+
things_directory = os.path.join(self._directory, constant.THING_DIRECTORY)
|
104
|
+
os.makedirs(things_directory, exist_ok=True)
|
105
|
+
while (
|
106
|
+
thing_id := random.choices(
|
107
|
+
population="abcdefghijklmnopqrstuvwxyz0123456789", k=5
|
108
|
+
)
|
109
|
+
) in os.listdir(things_directory):
|
110
|
+
pass
|
111
|
+
thing_directory = os.path.join(things_directory, thing_id)
|
112
|
+
new_thing = BoxedThing.from_files(thing_directory, self.options, self.schema)
|
113
|
+
self.append(new_thing)
|
114
|
+
return new_thing
|
115
|
+
|
116
|
+
def save(self) -> None:
|
117
|
+
"""Save inventory files.
|
118
|
+
|
119
|
+
todo: save schema. Schema changes are not implemented in schema yet.
|
120
|
+
Saving schema would be possible with schema.json attribute.
|
121
|
+
todo: maybe save options
|
122
|
+
"""
|
123
|
+
logger = logging.getLogger("save_things")
|
124
|
+
for thing in self:
|
125
|
+
thing.save()
|
126
|
+
|
127
|
+
def get_id(self, thing: BoxedThing) -> str:
|
128
|
+
"""Return the id (which is its directory with the thing directory) of a thing.
|
129
|
+
|
130
|
+
Needs to be in the inventory because the things know where they are saved
|
131
|
+
but not how much of it is their id and how much is the general thing directory.
|
132
|
+
"""
|
133
|
+
return os.path.relpath(
|
134
|
+
thing.directory, os.path.join(self._directory, constant.THING_DIRECTORY)
|
135
|
+
)
|
136
|
+
|
137
|
+
def get_by_id(self, thing_id: str) -> BoxedThing:
|
138
|
+
"""Return thing by its id which is its directory name.
|
139
|
+
|
140
|
+
Todo: maybe make more efficient by caching the result in a dict
|
141
|
+
or making the inventory a dict [id: thing]
|
142
|
+
|
143
|
+
Raises:
|
144
|
+
KeyError: if no such thing exists
|
145
|
+
"""
|
146
|
+
for thing in self:
|
147
|
+
if self.get_id(thing) == thing_id:
|
148
|
+
return thing
|
149
|
+
raise KeyError(thing_id)
|
150
|
+
|
151
|
+
def get_by_key(self, key, value) -> Iterator[BoxedThing]:
|
152
|
+
"""Return all boxed things that have this value for this key.
|
153
|
+
|
154
|
+
For example useful for looking up thing by name: get_by_key(('name', 0), 'Nice thing')
|
155
|
+
|
156
|
+
If value is None, return all things without this value (or actually having
|
157
|
+
this value as None). (If this is trouble because you actually want to search
|
158
|
+
for None, the implementation could change but would be more verbose.)
|
159
|
+
|
160
|
+
Todo: maybe make more efficient by caching the result in a dict
|
161
|
+
"""
|
162
|
+
return (box for box in self if box.thing.get(key, None) == value)
|
163
|
+
|
164
|
+
def get_by_best(self, key, value) -> Iterator[BoxedThing]:
|
165
|
+
"""Return all boxed things that have this value as the 'best' option for this key.
|
166
|
+
|
167
|
+
Where 'best' is meant in the sense of DefaultedDict.best.
|
168
|
+
|
169
|
+
If value is None, return all things without this value (or actually having
|
170
|
+
this value as None). (If this is trouble because you actually want to search
|
171
|
+
for None, the implementation could change but would be more verbose.)
|
172
|
+
|
173
|
+
Todo: maybe make more efficient by caching the result in a dict
|
174
|
+
"""
|
175
|
+
return (box for box in self if box.thing.best(key, backup=None) == value)
|
176
|
+
|
177
|
+
|
178
|
+
def check_filename_types(filetypes: Sequence[str]) -> Callable[[str], str]:
|
179
|
+
"""Create function that checks for being a simple file.
|
180
|
+
|
181
|
+
If it isn't of one of the specified file types, add the fileending."""
|
182
|
+
|
183
|
+
def check_file_type(path: str):
|
184
|
+
"""Throw an error if path is not a simple file name."""
|
185
|
+
# basedir, _ = os.path.split(path)
|
186
|
+
# if len(basedir) > 0:
|
187
|
+
# raise argparse.ArgumentTypeError("Only simple file name, not path allowed.")
|
188
|
+
_, ext = os.path.splitext(path)
|
189
|
+
if ext not in filetypes:
|
190
|
+
path += filetypes[0]
|
191
|
+
return path
|
192
|
+
|
193
|
+
return check_file_type
|
194
|
+
|
195
|
+
|
196
|
+
def add_file_args(parser: argparse.ArgumentParser) -> None:
|
197
|
+
"""Add arguments for file names for argument parser."""
|
198
|
+
parser.add_argument(
|
199
|
+
"dataDirectory",
|
200
|
+
default=".",
|
201
|
+
help="Directory with all the data. Structure of this directory is not changable.",
|
202
|
+
)
|
203
|
+
parser.add_argument(
|
204
|
+
"--output-dir",
|
205
|
+
"-d",
|
206
|
+
default="out",
|
207
|
+
help=(
|
208
|
+
"Directory where to put the output "
|
209
|
+
"files like pdf list, sign html pages, LaTeX sign file"
|
210
|
+
),
|
211
|
+
)
|
212
|
+
parser.add_argument(
|
213
|
+
"--output-mdlist",
|
214
|
+
"-md",
|
215
|
+
default="things.md",
|
216
|
+
help=(
|
217
|
+
"File where to write the list of things"
|
218
|
+
" as a markdown file. Relative to out "
|
219
|
+
"directory."
|
220
|
+
),
|
221
|
+
type=check_filename_types([".md"]),
|
222
|
+
)
|
223
|
+
parser.add_argument(
|
224
|
+
"--output-tree",
|
225
|
+
"-tree",
|
226
|
+
default="things-tree.txt",
|
227
|
+
help=(
|
228
|
+
"File where to write the list of things"
|
229
|
+
" as a tree in a text file. Relative "
|
230
|
+
"to out directory."
|
231
|
+
),
|
232
|
+
type=check_filename_types([".txt"]),
|
233
|
+
)
|
234
|
+
parser.add_argument(
|
235
|
+
"--output-pdflist",
|
236
|
+
"-pdf",
|
237
|
+
default="things.pdf",
|
238
|
+
help=(
|
239
|
+
"File where to write the list of things"
|
240
|
+
" as a pdf file. Relative to out "
|
241
|
+
"directory."
|
242
|
+
),
|
243
|
+
type=check_filename_types([".pdf"]),
|
244
|
+
)
|
245
|
+
parser.add_argument(
|
246
|
+
"--output-signs",
|
247
|
+
"-s",
|
248
|
+
default="signs.html",
|
249
|
+
help=(
|
250
|
+
"File where to write the html page"
|
251
|
+
" with big signs,"
|
252
|
+
" relative to out directory."
|
253
|
+
),
|
254
|
+
type=check_filename_types([".html", ".htm"]),
|
255
|
+
)
|
256
|
+
parser.add_argument(
|
257
|
+
"--output-signs-latex",
|
258
|
+
default="signs.tex",
|
259
|
+
help=(
|
260
|
+
"File where to write the LaTeX file"
|
261
|
+
" with big signs,"
|
262
|
+
" relative to out directory."
|
263
|
+
),
|
264
|
+
type=check_filename_types([".tex"]),
|
265
|
+
)
|
266
|
+
parser.add_argument(
|
267
|
+
"--logfile",
|
268
|
+
"-lf",
|
269
|
+
default="things.log",
|
270
|
+
help=("Where to write the log file. Relative to the out directory."),
|
271
|
+
type=check_filename_types([".log"]),
|
272
|
+
)
|
273
|
+
|
274
|
+
|
275
|
+
def dict_warn_on_duplicates(ordered_pairs):
|
276
|
+
"""Log warning for duplicate keys.
|
277
|
+
|
278
|
+
Args:
|
279
|
+
ordered_pairs: list of key value pairs found in json
|
280
|
+
Returns:
|
281
|
+
dictionary with the given keys and values,
|
282
|
+
in case of duplicate keys, take the first
|
283
|
+
"""
|
284
|
+
result_dict = {}
|
285
|
+
logger = logging.getLogger(__name__ + ".jsonImport")
|
286
|
+
for key, value in ordered_pairs:
|
287
|
+
if key in result_dict:
|
288
|
+
logger.warning(
|
289
|
+
f"duplicate key: {key} "
|
290
|
+
f"(first value (used): {result_dict[key]}, "
|
291
|
+
f"new value: {value})"
|
292
|
+
)
|
293
|
+
else:
|
294
|
+
result_dict[key] = value
|
295
|
+
return result_dict
|