labelle 1.0.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.
labelle/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .labeler import DymoLabeler
2
+ from .metadata import __version__
3
+
4
+ __all__ = ["__version__", "DymoLabeler"]
labelle/_version.py ADDED
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '1.0.0'
16
+ __version_tuple__ = version_tuple = (1, 0, 0)
@@ -0,0 +1,108 @@
1
+ # === LICENSE STATEMENT ===
2
+ # Copyright (c) 2011 Sebastian J. Bronner <waschtl@sbronner.com>
3
+ #
4
+ # Copying and distribution of this file, with or without modification, are
5
+ # permitted in any medium without royalty provided the copyright notice and
6
+ # this notice are preserved.
7
+ # === END LICENSE STATEMENT ===
8
+
9
+ from typing import Optional
10
+
11
+ from barcode.writer import BaseWriter
12
+ from PIL import Image, ImageDraw
13
+
14
+
15
+ def mm2px(mm, dpi=25.4):
16
+ return (mm * dpi) / 25.4
17
+
18
+
19
+ class BarcodeImageWriter(BaseWriter):
20
+ _draw: Optional[ImageDraw.ImageDraw]
21
+
22
+ def __init__(self):
23
+ super().__init__(self._init, self._paint_module, None, self._finish)
24
+ self.format = "PNG"
25
+ self.dpi = 25.4
26
+ self._image = None
27
+ self._draw = None
28
+ self.vertical_margin = 0
29
+
30
+ def calculate_size(self, modules_per_line, number_of_lines, dpi=25.4):
31
+ width = 2 * self.quiet_zone + modules_per_line * self.module_width
32
+ height = self.vertical_margin * 2 + self.module_height * number_of_lines
33
+ return int(mm2px(width, dpi)), int(mm2px(height, dpi))
34
+
35
+ def render(self, code):
36
+ """Renders the barcode to whatever the inheriting writer provides,
37
+ using the registered callbacks.
38
+
39
+ :parameters:
40
+ code : List
41
+ List of strings matching the writer spec
42
+ (only contain 0 or 1).
43
+ """
44
+ if self._callbacks["initialize"] is not None:
45
+ self._callbacks["initialize"](code)
46
+ ypos = self.vertical_margin
47
+ for cc, line in enumerate(code):
48
+ """
49
+ Pack line to list give better gfx result, otherwise in can
50
+ result in aliasing gaps
51
+ '11010111' -> [2, -1, 1, -1, 3]
52
+ """
53
+ line += " "
54
+ c = 1
55
+ mlist = []
56
+ for i in range(0, len(line) - 1):
57
+ if line[i] == line[i + 1]:
58
+ c += 1
59
+ else:
60
+ if line[i] == "1":
61
+ mlist.append(c)
62
+ else:
63
+ mlist.append(-c)
64
+ c = 1
65
+ # Left quiet zone is x startposition
66
+ xpos = self.quiet_zone
67
+ for mod in mlist:
68
+ if mod < 1:
69
+ color = self.background
70
+ else:
71
+ color = self.foreground
72
+ # remove painting for background colored tiles?
73
+ self._callbacks["paint_module"](
74
+ xpos, ypos, self.module_width * abs(mod), color
75
+ )
76
+ xpos += self.module_width * abs(mod)
77
+ # Add right quiet zone to every line, except last line,
78
+ # quiet zone already provided with background,
79
+ # should it be removed complety?
80
+ if (cc + 1) != len(code):
81
+ self._callbacks["paint_module"](
82
+ xpos, ypos, self.quiet_zone, self.background
83
+ )
84
+ ypos += self.module_height
85
+ return self._callbacks["finish"]()
86
+
87
+ def _init(self, code):
88
+ size = self.calculate_size(len(code[0]), len(code), self.dpi)
89
+ self._image = Image.new("1", size, self.background)
90
+ self._draw = ImageDraw.Draw(self._image)
91
+
92
+ def _paint_module(self, xpos, ypos, width, color):
93
+ size = [
94
+ (mm2px(xpos, self.dpi), mm2px(ypos, self.dpi)),
95
+ (
96
+ mm2px(xpos + width, self.dpi),
97
+ mm2px(ypos + self.module_height, self.dpi),
98
+ ),
99
+ ]
100
+ self._draw.rectangle(size, outline=color, fill=color)
101
+
102
+ def _finish(self):
103
+ return self._image
104
+
105
+ def save(self, filename, output):
106
+ filename = f"{filename}.{self.format.lower()}"
107
+ output.save(filename, self.format.upper())
108
+ return filename
@@ -0,0 +1,265 @@
1
+ # === LICENSE STATEMENT ===
2
+ # Copyright (c) 2011 Sebastian J. Bronner <waschtl@sbronner.com>
3
+ #
4
+ # Copying and distribution of this file, with or without modification, are
5
+ # permitted in any medium without royalty provided the copyright notice and
6
+ # this notice are preserved.
7
+ # === END LICENSE STATEMENT ===
8
+
9
+ import argparse
10
+ import os
11
+
12
+ from PIL import Image, ImageOps
13
+
14
+ from . import __version__
15
+ from .constants import (
16
+ AVAILABLE_BARCODES,
17
+ DEFAULT_MARGIN_PX,
18
+ PIXELS_PER_MM,
19
+ USE_QR,
20
+ e_qrcode,
21
+ )
22
+ from .dymo_print_engines import DymoRenderEngine, print_label
23
+ from .font_config import font_filename
24
+ from .metadata import our_metadata
25
+ from .unicode_blocks import image_to_unicode
26
+ from .utils import die
27
+
28
+
29
+ def parse_args():
30
+ # check for any text specified on the command line
31
+ parser = argparse.ArgumentParser(description=our_metadata["Summary"])
32
+ parser.add_argument(
33
+ "--version", action="version", version=f"%(prog)s {__version__}"
34
+ )
35
+ parser.add_argument(
36
+ "text",
37
+ nargs="+",
38
+ help="Text Parameter, each parameter gives a new line",
39
+ type=str,
40
+ )
41
+ parser.add_argument(
42
+ "-f",
43
+ action="count",
44
+ help="Draw frame around the text, more arguments for thicker frame",
45
+ )
46
+ parser.add_argument(
47
+ "-s",
48
+ choices=["r", "b", "i", "n"],
49
+ default="r",
50
+ help="Set fonts style (regular,bold,italic,narrow)",
51
+ )
52
+ parser.add_argument(
53
+ "-a",
54
+ choices=[
55
+ "left",
56
+ "center",
57
+ "right",
58
+ ],
59
+ default="left",
60
+ help="Align multiline text (left,center,right)",
61
+ )
62
+ parser.add_argument(
63
+ "--test-pattern",
64
+ type=int,
65
+ default=0,
66
+ help="Prints test pattern of a desired dot width",
67
+ )
68
+
69
+ length_options = parser.add_argument_group("Length options")
70
+
71
+ length_options.add_argument(
72
+ "-l",
73
+ "--min-length",
74
+ type=int,
75
+ default=0,
76
+ help="Specify minimum label length in mm",
77
+ )
78
+ length_options.add_argument(
79
+ "--max-length",
80
+ type=int,
81
+ default=None,
82
+ help="Specify maximum label length in mm, error if the label won't fit",
83
+ )
84
+ length_options.add_argument(
85
+ "--fixed-length",
86
+ type=int,
87
+ default=None,
88
+ help="Specify fixed label length in mm, error if the label won't fit",
89
+ )
90
+
91
+ length_options.add_argument(
92
+ "-j",
93
+ choices=[
94
+ "left",
95
+ "center",
96
+ "right",
97
+ ],
98
+ default="center",
99
+ help=(
100
+ "Justify content of label if label content is less than the "
101
+ "minimum or fixed length (left, center, right)"
102
+ ),
103
+ )
104
+ parser.add_argument("-u", nargs="?", help='Set user font, overrides "-s" parameter')
105
+ parser.add_argument(
106
+ "-n",
107
+ "--preview",
108
+ action="store_true",
109
+ help="Unicode preview of label, do not send to printer",
110
+ )
111
+ parser.add_argument(
112
+ "--preview-inverted",
113
+ action="store_true",
114
+ help="Unicode preview of label, colors inverted, do not send to printer",
115
+ )
116
+ parser.add_argument(
117
+ "--imagemagick",
118
+ action="store_true",
119
+ help="Preview label with Imagemagick, do not send to printer",
120
+ )
121
+ parser.add_argument(
122
+ "-qr", action="store_true", help="Printing the first text parameter as QR-code"
123
+ )
124
+ parser.add_argument(
125
+ "-c",
126
+ "--barcode",
127
+ choices=AVAILABLE_BARCODES,
128
+ default=False,
129
+ help="Printing the first text parameter as barcode",
130
+ )
131
+ parser.add_argument(
132
+ "--barcode-text",
133
+ choices=AVAILABLE_BARCODES,
134
+ default=False,
135
+ help="Printing the first text parameter as barcode and text under it",
136
+ )
137
+ parser.add_argument("-p", "--picture", help="Print the specified picture")
138
+ parser.add_argument(
139
+ "-m",
140
+ type=int,
141
+ default=DEFAULT_MARGIN_PX,
142
+ help=f"Margin in px (default is {DEFAULT_MARGIN_PX})",
143
+ )
144
+ parser.add_argument(
145
+ "--scale", type=int, default=90, help="Scaling font factor, [0,10] [%%]"
146
+ )
147
+ parser.add_argument(
148
+ "-t",
149
+ type=int,
150
+ choices=[6, 9, 12, 19],
151
+ default=12,
152
+ help="Tape size: 6,9,12,19 mm, default=12mm",
153
+ )
154
+ return parser.parse_args()
155
+
156
+
157
+ def mm_to_payload_px(mm, margin):
158
+ """Convert a length in mm to a number of pixels of payload
159
+
160
+ The print resolution is 7 pixels/mm, and margin is subtracted
161
+ from each side."""
162
+ return (mm * PIXELS_PER_MM) - margin * 2
163
+
164
+
165
+ def main():
166
+ args = parse_args()
167
+ render_engine = DymoRenderEngine(args.t)
168
+
169
+ # read config file
170
+ FONT_FILENAME = font_filename(args.s)
171
+
172
+ labeltext = args.text
173
+
174
+ if args.u is not None:
175
+ if os.path.isfile(args.u):
176
+ FONT_FILENAME = args.u
177
+ else:
178
+ die("Error: file '%s' not found." % args.u)
179
+
180
+ # check if barcode, qrcode or text should be printed, use frames only on text
181
+ if args.qr and not USE_QR:
182
+ die("Error: %s" % e_qrcode)
183
+
184
+ if args.barcode and args.qr:
185
+ die("Error: can not print both QR and Barcode on the same label (yet)")
186
+
187
+ if args.fixed_length is not None and (
188
+ args.min_length != 0 or args.max_length is not None
189
+ ):
190
+ die("Error: can't specify min/max and fixed length at the same time")
191
+
192
+ if args.max_length is not None and args.max_length < args.min_length:
193
+ die("Error: maximum length is less than minimum length")
194
+
195
+ bitmaps = []
196
+
197
+ if args.test_pattern:
198
+ bitmaps.append(render_engine.render_test(args.test_pattern))
199
+
200
+ if args.qr:
201
+ bitmaps.append(render_engine.render_qr(labeltext.pop(0)))
202
+
203
+ elif args.barcode:
204
+ bitmaps.append(render_engine.render_barcode(labeltext.pop(0), args.barcode))
205
+
206
+ elif args.barcode_text:
207
+ bitmaps.append(
208
+ render_engine.render_barcode_with_text(
209
+ labeltext.pop(0), args.barcode_text, FONT_FILENAME, args.f
210
+ )
211
+ )
212
+
213
+ if labeltext:
214
+ bitmaps.append(
215
+ render_engine.render_text(
216
+ text_lines=labeltext,
217
+ font_file_name=FONT_FILENAME,
218
+ frame_width_px=args.f,
219
+ font_size_ratio=int(args.scale) / 100.0,
220
+ align=args.a,
221
+ )
222
+ )
223
+
224
+ if args.picture:
225
+ bitmaps.append(render_engine.render_picture(args.picture))
226
+
227
+ margin = args.m
228
+ justify = args.j
229
+
230
+ if args.fixed_length is not None:
231
+ min_label_mm_len = args.fixed_length
232
+ max_label_mm_len = args.fixed_length
233
+ else:
234
+ min_label_mm_len = args.min_length
235
+ max_label_mm_len = args.max_length
236
+
237
+ min_payload_len_px = max(0, mm_to_payload_px(min_label_mm_len, margin))
238
+ max_payload_len_px = (
239
+ mm_to_payload_px(max_label_mm_len, margin)
240
+ if max_label_mm_len is not None
241
+ else None
242
+ )
243
+
244
+ label_bitmap = render_engine.merge_render(
245
+ bitmaps=bitmaps,
246
+ min_payload_len_px=min_payload_len_px,
247
+ max_payload_len_px=max_payload_len_px,
248
+ justify=justify,
249
+ )
250
+
251
+ # print or show the label
252
+ if args.preview or args.preview_inverted or args.imagemagick:
253
+ print("Demo mode: showing label..")
254
+ # fix size, adding print borders
255
+ label_image = Image.new(
256
+ "L", (margin + label_bitmap.width + margin, label_bitmap.height)
257
+ )
258
+ label_image.paste(label_bitmap, (margin, 0))
259
+ if args.preview or args.preview_inverted:
260
+ label_rotated = label_bitmap.transpose(Image.ROTATE_270)
261
+ print(image_to_unicode(label_rotated, invert=args.preview_inverted))
262
+ if args.imagemagick:
263
+ ImageOps.invert(label_image).show()
264
+ else:
265
+ print_label(label_bitmap, margin_px=args.m, tape_size_mm=args.t)
labelle/constants.py ADDED
@@ -0,0 +1,97 @@
1
+ # === LICENSE STATEMENT ===
2
+ # Copyright (c) 2011 Sebastian J. Bronner <waschtl@sbronner.com>
3
+ #
4
+ # Copying and distribution of this file, with or without modification, are
5
+ # permitted in any medium without royalty provided the copyright notice and
6
+ # this notice are preserved.
7
+ # === END LICENSE STATEMENT ===
8
+
9
+ # On systems with access to sysfs under /sys, this script will use the three
10
+ # variables DEV_CLASS, DEV_VENDOR, and DEV_PRODUCT to find the device file
11
+ # under /dev automatically. This behavior can be overridden by setting the
12
+ # variable DEV_NODE to the device file path. This is intended for cases, where
13
+ # either sysfs is unavailable or unusable by this script for some reason.
14
+ # Please beware that DEV_NODE must be set to None when not used, else you will
15
+ # be bitten by the NameError exception.
16
+
17
+ from pathlib import Path
18
+
19
+ import labelle.resources.fonts
20
+ import labelle.resources.icons
21
+
22
+ try:
23
+ from pyqrcode import QRCode
24
+
25
+ USE_QR = True
26
+ e_qrcode = None
27
+ except ImportError as error:
28
+ e_qrcode = error
29
+ USE_QR = False
30
+ QRCode = None
31
+
32
+
33
+ UNCONFIRMED_MESSAGE = (
34
+ "WARNING: This device is not confirmed to work with this software. Please "
35
+ "report your experiences in https://github.com/computerlyrik/dymoprint/issues/44"
36
+ )
37
+ SUPPORTED_PRODUCTS = {
38
+ 0x0011: "DYMO LabelMANAGER PC",
39
+ 0x0015: "LabelPoint 350",
40
+ 0x1001: "LabelManager PnP (no mode switch)",
41
+ 0x1002: "LabelManager PnP (mode switch)",
42
+ 0x1003: f"LabelManager 420P (no mode switch) {UNCONFIRMED_MESSAGE}",
43
+ 0x1004: f"LabelManager 420P (mode switch) {UNCONFIRMED_MESSAGE}",
44
+ 0x1005: "LabelManager 280 (no mode switch)",
45
+ 0x1006: "LabelManager 280 (no mode switch)",
46
+ 0x1007: f"LabelManager Wireless PnP (no mode switch) {UNCONFIRMED_MESSAGE}",
47
+ 0x1008: f"LabelManager Wireless PnP (mode switch) {UNCONFIRMED_MESSAGE}",
48
+ 0x1009: f"MobileLabeler {UNCONFIRMED_MESSAGE}",
49
+ }
50
+ DEV_VENDOR = 0x0922
51
+
52
+ PRINTER_INTERFACE_CLASS = 0x07
53
+ HID_INTERFACE_CLASS = 0x03
54
+
55
+ # Escape character preceeding all commands
56
+ ESC = 0x1B
57
+
58
+ # Synchronization character preceding uncompressed print data
59
+ SYN = 0x16
60
+
61
+ FONT_SIZERATIO = 7 / 8
62
+
63
+ DEFAULT_FONT_STYLE = "regular"
64
+
65
+ DEFAULT_MARGIN_PX = 56
66
+
67
+ FLAG_TO_STYLE = {
68
+ "r": "regular",
69
+ "b": "bold",
70
+ "i": "italic",
71
+ "n": "narrow",
72
+ }
73
+
74
+ DPI = 180
75
+ MM_PER_INCH = 25.4
76
+ PIXELS_PER_MM = DPI / MM_PER_INCH
77
+
78
+ DEFAULT_FONT_DIR = Path(labelle.resources.fonts.__file__).parent
79
+ ICON_DIR = Path(labelle.resources.icons.__file__).parent
80
+
81
+ AVAILABLE_BARCODES = [
82
+ "code39",
83
+ "code128",
84
+ "ean",
85
+ "ean13",
86
+ "ean8",
87
+ "gs1",
88
+ "gtin",
89
+ "isbn",
90
+ "isbn10",
91
+ "isbn13",
92
+ "issn",
93
+ "jan",
94
+ "pzn",
95
+ "upc",
96
+ "upca",
97
+ ]
labelle/detect.py ADDED
@@ -0,0 +1,214 @@
1
+ import platform
2
+ from typing import NamedTuple, NoReturn
3
+
4
+ import usb
5
+
6
+ from labelle.utils import die
7
+
8
+ from .constants import (
9
+ DEV_VENDOR,
10
+ HID_INTERFACE_CLASS,
11
+ PRINTER_INTERFACE_CLASS,
12
+ SUPPORTED_PRODUCTS,
13
+ UNCONFIRMED_MESSAGE,
14
+ )
15
+
16
+ GITHUB_LINK = "<https://github.com/computerlyrik/dymoprint/pull/56>"
17
+
18
+
19
+ class DetectedDevice(NamedTuple):
20
+ id: int
21
+ """See labelle.constants.SUPPORTED_PRODUCTS for a list of known IDs."""
22
+ dev: usb.core.Device
23
+ intf: usb.core.Interface
24
+ devout: usb.core.Endpoint
25
+ devin: usb.core.Endpoint
26
+
27
+
28
+ def device_info(dev: usb.core.Device) -> str:
29
+ try:
30
+ dev.manufacturer
31
+ except ValueError:
32
+ instruct_on_access_denied(dev)
33
+ res = ""
34
+ res += f"{repr(dev)}\n"
35
+ res += f" manufacturer: {dev.manufacturer}\n"
36
+ res += f" product: {dev.product}\n"
37
+ res += f" serial: {dev.serial_number}\n"
38
+ configs = dev.configurations()
39
+ if configs:
40
+ res += " configurations:\n"
41
+ for cfg in configs:
42
+ res += f" - {repr(cfg)}\n"
43
+ intfs = cfg.interfaces()
44
+ if intfs:
45
+ res += " interfaces:\n"
46
+ for intf in intfs:
47
+ res += f" - {repr(intf)}\n"
48
+ return res
49
+
50
+
51
+ def detect_device() -> DetectedDevice:
52
+ dymo_devs = list(usb.core.find(idVendor=DEV_VENDOR, find_all=True))
53
+ if len(dymo_devs) == 0:
54
+ print(f"No Dymo devices found (expected vendor {hex(DEV_VENDOR)})")
55
+ for dev in usb.core.find(find_all=True):
56
+ print(
57
+ f"- Vendor ID: {hex(dev.idVendor):6} "
58
+ f"Product ID: {hex(dev.idProduct)}"
59
+ )
60
+ die("Unable to open device.")
61
+ if len(dymo_devs) > 1:
62
+ print("Found multiple Dymo devices:")
63
+ for dev in dymo_devs:
64
+ print(device_info(dev))
65
+ print("Using first device.")
66
+ dev = dymo_devs[0]
67
+ else:
68
+ dev = dymo_devs[0]
69
+ print(f"Found one Dymo device: {device_info(dev)}")
70
+ dev = dymo_devs[0]
71
+ if dev.idProduct in SUPPORTED_PRODUCTS:
72
+ print(f"Recognized device as {SUPPORTED_PRODUCTS[dev.idProduct]}")
73
+ else:
74
+ print(f"Unrecognized device: {hex(dev.idProduct)}. {UNCONFIRMED_MESSAGE}")
75
+
76
+ try:
77
+ dev.get_active_configuration()
78
+ print("Active device configuration already found.")
79
+ except usb.core.USBError:
80
+ try:
81
+ dev.set_configuration()
82
+ print("Device configuration set.")
83
+ except usb.core.USBError as e:
84
+ if e.errno == 13:
85
+ raise RuntimeError("Access denied")
86
+ if e.errno == 16:
87
+ print("Device is busy, but this is okay.")
88
+ else:
89
+ raise
90
+
91
+ intf = usb.util.find_descriptor(
92
+ dev.get_active_configuration(), bInterfaceClass=PRINTER_INTERFACE_CLASS
93
+ )
94
+ if intf is not None:
95
+ print(f"Opened printer interface: {repr(intf)}")
96
+ else:
97
+ intf = usb.util.find_descriptor(
98
+ dev.get_active_configuration(), bInterfaceClass=HID_INTERFACE_CLASS
99
+ )
100
+ if intf is not None:
101
+ print(f"Opened HID interface: {repr(intf)}")
102
+ else:
103
+ die("Could not open a valid interface.")
104
+ assert isinstance(intf, usb.core.Interface)
105
+
106
+ try:
107
+ if dev.is_kernel_driver_active(intf.bInterfaceNumber):
108
+ print(f"Detaching kernel driver from interface {intf.bInterfaceNumber}")
109
+ dev.detach_kernel_driver(intf.bInterfaceNumber)
110
+ except NotImplementedError:
111
+ print(f"Kernel driver detaching not necessary on " f"{platform.system()}.")
112
+ devout = usb.util.find_descriptor(
113
+ intf,
114
+ custom_match=(
115
+ lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
116
+ == usb.util.ENDPOINT_OUT
117
+ ),
118
+ )
119
+ devin = usb.util.find_descriptor(
120
+ intf,
121
+ custom_match=(
122
+ lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
123
+ == usb.util.ENDPOINT_IN
124
+ ),
125
+ )
126
+
127
+ if not devout or not devin:
128
+ die("The device endpoints not be found.")
129
+ return DetectedDevice(
130
+ id=dev.idProduct, dev=dev, intf=intf, devout=devout, devin=devin
131
+ )
132
+
133
+
134
+ def instruct_on_access_denied(dev: usb.core.Device) -> NoReturn:
135
+ system = platform.system()
136
+ if system == "Linux":
137
+ instruct_on_access_denied_linux(dev)
138
+ elif system == "Windows":
139
+ raise RuntimeError(
140
+ "Couldn't access the device. Please make sure that the "
141
+ "device driver is set to WinUSB. This can be accomplished "
142
+ "with Zadig <https://zadig.akeo.ie/>."
143
+ )
144
+ elif system == "Darwin":
145
+ raise RuntimeError(
146
+ f"Could not access {dev}. Thanks for bravely trying this on a Mac. You "
147
+ f"are in uncharted territory. It would be appreciated if you share the "
148
+ f"results of your experimentation at {GITHUB_LINK}."
149
+ )
150
+ else:
151
+ raise RuntimeError(f"Unknown platform {system}")
152
+
153
+
154
+ def instruct_on_access_denied_linux(dev: usb.core.Device) -> NoReturn:
155
+ # try:
156
+ # os_release = platform.freedesktop_os_release()
157
+ # except OSError:
158
+ # os_release = {}
159
+ # dists_with_empties = [os_release.get("ID", "")] + os_release.get(
160
+ # "ID_LIKE", ""
161
+ # ).split(" ")
162
+ # dists = [dist for dist in dists_with_empties if dist]
163
+ # if "arch" in dists:
164
+ # restart_udev_command = "sudo udevadm control --reload"
165
+ # elif "ubuntu" in dists or "debian" in dists:
166
+ # restart_udev_command = "sudo systemctl restart udev.service"
167
+ # # detect whether we are in arch linux or ubuntu linux
168
+ # if Path("/etc/arch-release").exists():
169
+ # restart_udev_command = "sudo udevadm control --reload"
170
+ # elif Path("/etc/lsb-release").exists():
171
+ # restart_udev_command = "sudo systemctl restart udev.service"
172
+ # else:
173
+ # restart_udev_command = None
174
+
175
+ lines = []
176
+ lines.append(
177
+ "You do not have sufficient access to the "
178
+ "device. You probably want to add the a udev rule in "
179
+ "/etc/udev/rules.d with the following command:"
180
+ )
181
+ lines.append("")
182
+ udev_rule = ", ".join(
183
+ [
184
+ 'ACTION=="add"',
185
+ 'SUBSYSTEMS=="usb"',
186
+ f'ATTRS{{idVendor}}=="{dev.idVendor:04x}"',
187
+ f'ATTRS{{idProduct}}=="{dev.idProduct:04x}"',
188
+ 'MODE="0666"',
189
+ ]
190
+ )
191
+ lines.append(
192
+ f" echo '{udev_rule}' "
193
+ f"| sudo tee /etc/udev/rules.d/91-dymo-{dev.idProduct:x}.rules"
194
+ )
195
+ lines.append("")
196
+ lines.append("Next refresh udev with:")
197
+ lines.append("")
198
+ lines.append(" sudo udevadm control --reload-rules")
199
+ lines.append(' sudo udevadm trigger --attr-match=idVendor="0922"')
200
+ lines.append("")
201
+ lines.append(
202
+ "Finally, turn your device off and back "
203
+ "on again to activate the new permissions."
204
+ )
205
+ lines.append("")
206
+ lines.append(
207
+ f"If this still does not resolve the problem, you might need to reboot. "
208
+ f"In case rebooting is necessary, please report this at {GITHUB_LINK}. "
209
+ f"We are still trying to figure out a simple procedure which works "
210
+ f"for everyone. In case you still cannot connect, "
211
+ f"or if you have any information or ideas, please post them at "
212
+ f"that link."
213
+ )
214
+ raise RuntimeError("\n\n" + "\n".join(lines) + "\n")