phomemo 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.
- phomemo/__init__.py +82 -0
- phomemo/bridge/__init__.py +23 -0
- phomemo/bridge/__main__.py +315 -0
- phomemo/bridge/client.py +310 -0
- phomemo/bridge/config.py +184 -0
- phomemo/bridge/protocol.py +82 -0
- phomemo/bridge/server.py +307 -0
- phomemo/bridge/session.py +741 -0
- phomemo/bridge/testpage.py +232 -0
- phomemo/discovery.py +72 -0
- phomemo/events.py +288 -0
- phomemo/imaging.py +278 -0
- phomemo/models/README.md +26 -0
- phomemo/models/m08f/icon-128.png +0 -0
- phomemo/models/m08f/icon-48.png +0 -0
- phomemo/models/m08f/icon-512.png +0 -0
- phomemo/models/m08f/model.json +30 -0
- phomemo/printer.py +1050 -0
- phomemo/profiles/__init__.py +60 -0
- phomemo/protocol.py +228 -0
- phomemo/py.typed +0 -0
- phomemo/registry.py +378 -0
- phomemo/transport.py +251 -0
- phomemo-0.3.0.dist-info/METADATA +82 -0
- phomemo-0.3.0.dist-info/RECORD +29 -0
- phomemo-0.3.0.dist-info/WHEEL +4 -0
- phomemo-0.3.0.dist-info/entry_points.txt +2 -0
- phomemo-0.3.0.dist-info/licenses/COPYING +674 -0
- phomemo-0.3.0.dist-info/licenses/COPYING.LESSER +165 -0
phomemo/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# This file is part of phomemo.
|
|
2
|
+
# Copyright (C) 2026 Paul Bütof, Passionate Bytes Solutions
|
|
3
|
+
#
|
|
4
|
+
# phomemo is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Lesser General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# phomemo is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Lesser General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
15
|
+
# along with phomemo. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Async BLE driver library for Phomemo thermal printers.
|
|
18
|
+
|
|
19
|
+
Provides a modular, profile-based API for communicating with Phomemo
|
|
20
|
+
thermal printers over Bluetooth Low Energy.
|
|
21
|
+
|
|
22
|
+
Basic usage::
|
|
23
|
+
|
|
24
|
+
from phomemo import Printer
|
|
25
|
+
|
|
26
|
+
async with Printer("m08f", "AA:BB:CC:DD:EE:FF") as printer:
|
|
27
|
+
# connect() must be called after entering the context
|
|
28
|
+
await printer.connect()
|
|
29
|
+
await printer.wait_for_ready()
|
|
30
|
+
await printer.print_image("photo.png")
|
|
31
|
+
await printer.eject_paper()
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from phomemo import profiles # noqa F401 # auto-load profiles at import time
|
|
35
|
+
from phomemo.discovery import discover
|
|
36
|
+
from phomemo.events import (
|
|
37
|
+
BatteryEvent,
|
|
38
|
+
DeviceEvent,
|
|
39
|
+
EventKind,
|
|
40
|
+
FirmwareEvent,
|
|
41
|
+
LidEvent,
|
|
42
|
+
LidState,
|
|
43
|
+
MotorStopEvent,
|
|
44
|
+
PaperEvent,
|
|
45
|
+
PaperState,
|
|
46
|
+
SerialEvent,
|
|
47
|
+
TimerEvent,
|
|
48
|
+
)
|
|
49
|
+
from phomemo.imaging import DitherMode, ImageFit, image_to_bitmap, prepare_image
|
|
50
|
+
from phomemo.printer import Printer
|
|
51
|
+
from phomemo.protocol import Density
|
|
52
|
+
from phomemo.registry import (
|
|
53
|
+
PrinterProfile,
|
|
54
|
+
get_profile,
|
|
55
|
+
list_profiles,
|
|
56
|
+
register_profile,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"BatteryEvent",
|
|
61
|
+
"Density",
|
|
62
|
+
"DeviceEvent",
|
|
63
|
+
"DitherMode",
|
|
64
|
+
"EventKind",
|
|
65
|
+
"FirmwareEvent",
|
|
66
|
+
"ImageFit",
|
|
67
|
+
"LidEvent",
|
|
68
|
+
"LidState",
|
|
69
|
+
"MotorStopEvent",
|
|
70
|
+
"PaperEvent",
|
|
71
|
+
"PaperState",
|
|
72
|
+
"Printer",
|
|
73
|
+
"PrinterProfile",
|
|
74
|
+
"SerialEvent",
|
|
75
|
+
"TimerEvent",
|
|
76
|
+
"discover",
|
|
77
|
+
"get_profile",
|
|
78
|
+
"image_to_bitmap",
|
|
79
|
+
"list_profiles",
|
|
80
|
+
"prepare_image",
|
|
81
|
+
"register_profile",
|
|
82
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# This file is part of phomemo.
|
|
2
|
+
# Copyright (C) 2026 Paul Bütof, Passionate Bytes Solutions
|
|
3
|
+
#
|
|
4
|
+
# phomemo is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Lesser General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# phomemo is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Lesser General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
15
|
+
# along with phomemo. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""The bridge: drives a printer over a unix socket for the printer app.
|
|
18
|
+
|
|
19
|
+
The bridge owns the device side (Bluetooth connection, raster encoding, tone
|
|
20
|
+
conversion, sheet handling); a printer app owns the IPP side and
|
|
21
|
+
talks to the bridge with a few JSON commands. See ``protocol`` for the wire
|
|
22
|
+
format and ``server`` for the command set.
|
|
23
|
+
"""
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# This file is part of phomemo.
|
|
2
|
+
# Copyright (C) 2026 Paul Bütof, Passionate Bytes Solutions
|
|
3
|
+
#
|
|
4
|
+
# phomemo is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Lesser General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# phomemo is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Lesser General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
15
|
+
# along with phomemo. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""``phomemo-bridge`` command line: run the bridge or drive it directly.
|
|
18
|
+
|
|
19
|
+
Sub-command names follow the printer app's where both do the same
|
|
20
|
+
thing (``server``, ``devices``, ``status``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import sys
|
|
28
|
+
import textwrap
|
|
29
|
+
from importlib.metadata import version
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from phomemo.bridge.client import MEDIA, QUALITY, BridgeClient, BridgeError
|
|
33
|
+
from phomemo.bridge.config import ConfigError, load_config
|
|
34
|
+
from phomemo.bridge.server import serve
|
|
35
|
+
from phomemo.imaging import ToneMode
|
|
36
|
+
|
|
37
|
+
_PRINTER_CHOICE = (
|
|
38
|
+
"Which printer: the one given with --address, else the printer connectioned or "
|
|
39
|
+
"used last since the bridge started, else the only printer of a known model "
|
|
40
|
+
"a scan finds. With several printers in range and none of these, the "
|
|
41
|
+
'command refuses and lists them; pick one with --address (see "devices"). '
|
|
42
|
+
"The model comes from the printer's advertised name."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_COMMANDS: dict[str, tuple[str, str]] = {
|
|
46
|
+
"server": (
|
|
47
|
+
"run the bridge service",
|
|
48
|
+
"Run the bridge in the foreground: listen on the socket, own the "
|
|
49
|
+
"Bluetooth connection to the printer and serve the printer app and "
|
|
50
|
+
"this command line. The packaged system service runs this.",
|
|
51
|
+
),
|
|
52
|
+
"devices": (
|
|
53
|
+
"list printers in range",
|
|
54
|
+
"Scan for printers of every known model and list each one's Bluetooth "
|
|
55
|
+
"address, model and advertised name. A printer shows up only while it "
|
|
56
|
+
"is switched on and not linked to another host.",
|
|
57
|
+
),
|
|
58
|
+
"connect": (
|
|
59
|
+
"connect to the printer",
|
|
60
|
+
"Connect to the printer over Bluetooth. The bridge keeps the connection until "
|
|
61
|
+
"the printer switches off or leaves range, or until "
|
|
62
|
+
"idle_disconnect_minutes pass without a command (see the "
|
|
63
|
+
"configuration), so later commands and jobs skip the reconnect. At each "
|
|
64
|
+
"new connection the bridge sets the printer's "
|
|
65
|
+
"auto-off timer to auto_off_minutes when that key is set.\n\n"
|
|
66
|
+
+ _PRINTER_CHOICE,
|
|
67
|
+
),
|
|
68
|
+
"disconnect": (
|
|
69
|
+
"drop the connection now",
|
|
70
|
+
"Drop the Bluetooth connection right away instead of after the idle time, so "
|
|
71
|
+
"another host (a phone, another computer) can reach the printer.",
|
|
72
|
+
),
|
|
73
|
+
"status": (
|
|
74
|
+
"show connection, sensors and device details",
|
|
75
|
+
"Show whether the bridge holds a connection and, when it does, the connected "
|
|
76
|
+
"printer's address, lid and paper sensors, battery, firmware, serial "
|
|
77
|
+
"number and auto-off timer. Without a connection, shows the values last read "
|
|
78
|
+
'from that printer under "last_seen", with the time they were read. Does '
|
|
79
|
+
'not connect by itself; run "connect" first. With --address, reports on '
|
|
80
|
+
"that printer.",
|
|
81
|
+
),
|
|
82
|
+
"auto-off": (
|
|
83
|
+
"show or set the auto-off timer",
|
|
84
|
+
"Show the linked printer's auto-off timer, or set it first when MINUTES "
|
|
85
|
+
"is given (a multiple of 5 up to 1275; 0 = never switch off). When "
|
|
86
|
+
"auto_off_minutes is set in the configuration, the bridge writes that "
|
|
87
|
+
"value again at the next connection, so change the key for a lasting "
|
|
88
|
+
'setting. Needs a connection; run "connect" first.',
|
|
89
|
+
),
|
|
90
|
+
"print": (
|
|
91
|
+
"print one image file as a page",
|
|
92
|
+
"Print an image file (PNG, JPEG, anything Pillow reads) as one page, "
|
|
93
|
+
"scaled to the media width, then eject the sheet.\n\n" + _PRINTER_CHOICE,
|
|
94
|
+
),
|
|
95
|
+
"eject": (
|
|
96
|
+
"feed the sheet out",
|
|
97
|
+
"Feed the loaded sheet out of the printer.\n\n" + _PRINTER_CHOICE,
|
|
98
|
+
),
|
|
99
|
+
"identify": (
|
|
100
|
+
"make the printer show itself",
|
|
101
|
+
"Eject a loaded sheet, then pulse the paper feed three times so you can "
|
|
102
|
+
"tell which printer this is.\n\n" + _PRINTER_CHOICE,
|
|
103
|
+
),
|
|
104
|
+
"testpage": (
|
|
105
|
+
"print the test page",
|
|
106
|
+
"Print the test page: device details, millimetre scales, a gray ramp, "
|
|
107
|
+
"line widths and text samples.\n\n" + _PRINTER_CHOICE,
|
|
108
|
+
),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parser() -> argparse.ArgumentParser:
|
|
113
|
+
ap = argparse.ArgumentParser(prog="phomemo-bridge", description=__doc__)
|
|
114
|
+
ap.add_argument(
|
|
115
|
+
"--config", type=Path, help="read this TOML file instead of the defaults"
|
|
116
|
+
)
|
|
117
|
+
ap.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
|
118
|
+
ap.add_argument(
|
|
119
|
+
"-V",
|
|
120
|
+
"--version",
|
|
121
|
+
action="version",
|
|
122
|
+
version=f"%(prog)s {version('phomemo')}",
|
|
123
|
+
help="print the version and exit",
|
|
124
|
+
)
|
|
125
|
+
ap.set_defaults(address=None)
|
|
126
|
+
sub = ap.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
127
|
+
|
|
128
|
+
# Shared by every command that works on one printer.
|
|
129
|
+
printer_choice = argparse.ArgumentParser(add_help=False)
|
|
130
|
+
printer_choice.add_argument(
|
|
131
|
+
"-a",
|
|
132
|
+
"--address",
|
|
133
|
+
help="Bluetooth address of the printer to use (AA:BB:CC:DD:EE:FF)",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def add(name: str, targets_printer: bool = True) -> argparse.ArgumentParser:
|
|
137
|
+
summary, description = _COMMANDS[name]
|
|
138
|
+
paragraphs = description.split("\n\n")
|
|
139
|
+
return sub.add_parser(
|
|
140
|
+
name,
|
|
141
|
+
parents=[printer_choice] if targets_printer else [],
|
|
142
|
+
help=summary,
|
|
143
|
+
description="\n\n".join(textwrap.fill(p, 79) for p in paragraphs),
|
|
144
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
add("server", targets_printer=False)
|
|
148
|
+
devices = add("devices", targets_printer=False)
|
|
149
|
+
devices.add_argument(
|
|
150
|
+
"--timeout", type=float, default=5.0, help="scan seconds (default: 5)"
|
|
151
|
+
)
|
|
152
|
+
add("connect")
|
|
153
|
+
add("disconnect", targets_printer=False)
|
|
154
|
+
add("status")
|
|
155
|
+
auto_off = add("auto-off")
|
|
156
|
+
auto_off.add_argument(
|
|
157
|
+
"minutes", type=int, nargs="?", help="new timeout in minutes, 0 = never"
|
|
158
|
+
)
|
|
159
|
+
pr = add("print")
|
|
160
|
+
pr.add_argument("file", type=Path, help="image file to print")
|
|
161
|
+
pr.add_argument(
|
|
162
|
+
"--media", choices=sorted(MEDIA), default="a4", help="sheet size (default: a4)"
|
|
163
|
+
)
|
|
164
|
+
pr.add_argument(
|
|
165
|
+
"--tone",
|
|
166
|
+
choices=["auto", *[t.value for t in ToneMode]],
|
|
167
|
+
default="auto",
|
|
168
|
+
help="how gray becomes dots (default: auto, chosen from the content)",
|
|
169
|
+
)
|
|
170
|
+
pr.add_argument(
|
|
171
|
+
"--quality",
|
|
172
|
+
choices=sorted(QUALITY),
|
|
173
|
+
default="normal",
|
|
174
|
+
help="print density (default: normal)",
|
|
175
|
+
)
|
|
176
|
+
pr.add_argument(
|
|
177
|
+
"--content",
|
|
178
|
+
choices=["auto", "text", "graphic", "photo"],
|
|
179
|
+
default="auto",
|
|
180
|
+
help="content hint for --tone auto (default: auto)",
|
|
181
|
+
)
|
|
182
|
+
pr.add_argument(
|
|
183
|
+
"--abort-after",
|
|
184
|
+
type=float,
|
|
185
|
+
metavar="SECONDS",
|
|
186
|
+
help="cancel the page this many seconds into the transfer (testing)",
|
|
187
|
+
)
|
|
188
|
+
add("eject")
|
|
189
|
+
add("identify")
|
|
190
|
+
tp = add("testpage")
|
|
191
|
+
tp.add_argument(
|
|
192
|
+
"--quality",
|
|
193
|
+
choices=sorted(QUALITY),
|
|
194
|
+
default="normal",
|
|
195
|
+
help="print density (default: normal)",
|
|
196
|
+
)
|
|
197
|
+
return ap
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fail(message: str) -> int:
|
|
201
|
+
print(f"error: {message}", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _linked(client: BridgeClient, address: str | None) -> dict | None:
|
|
206
|
+
"""The bridge's status when connected to ``address`` (or any printer)."""
|
|
207
|
+
status = await client.status(address)
|
|
208
|
+
return status if status["connected"] else None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _status(client: BridgeClient, address: str | None) -> dict:
|
|
212
|
+
status = await client.status(address)
|
|
213
|
+
if not status["connected"]:
|
|
214
|
+
return status
|
|
215
|
+
info = await client.info()
|
|
216
|
+
for key in ("firmware", "serial", "auto_off_minutes"):
|
|
217
|
+
status[key] = info[key]
|
|
218
|
+
return status
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _run_client(args: argparse.Namespace, socket_path: Path) -> int:
|
|
222
|
+
def show(event: dict) -> None:
|
|
223
|
+
print(json.dumps(event), file=sys.stderr)
|
|
224
|
+
|
|
225
|
+
async with BridgeClient(socket_path, on_event=show) as client:
|
|
226
|
+
if args.command in ("connect", "print", "eject", "identify", "testpage"):
|
|
227
|
+
opened = await client.open(args.address)
|
|
228
|
+
if not opened["connected"] and args.command != "print":
|
|
229
|
+
return _fail(f"not reachable: {opened.get('reason', 'unknown')}")
|
|
230
|
+
if args.command == "connect":
|
|
231
|
+
status = await client.status()
|
|
232
|
+
print(f"connected to {status.get('address')}")
|
|
233
|
+
elif args.command == "disconnect":
|
|
234
|
+
await client.drop_link()
|
|
235
|
+
print("disconnected")
|
|
236
|
+
elif args.command == "status":
|
|
237
|
+
print(json.dumps(await _status(client, args.address), indent=2))
|
|
238
|
+
elif args.command == "auto-off":
|
|
239
|
+
status = await _linked(client, args.address)
|
|
240
|
+
if status is None:
|
|
241
|
+
return _fail('not connected; run "phomemo-bridge connect" first')
|
|
242
|
+
minutes = await client.auto_off(args.minutes)
|
|
243
|
+
shown = "never" if minutes == 0 else f"{minutes} min"
|
|
244
|
+
print(f"{status['address']}: auto-off {shown}")
|
|
245
|
+
elif args.command == "print":
|
|
246
|
+
impressions = await client.print_image(
|
|
247
|
+
args.file,
|
|
248
|
+
media=args.media,
|
|
249
|
+
tone=args.tone,
|
|
250
|
+
quality=args.quality,
|
|
251
|
+
content=args.content,
|
|
252
|
+
abort_after=args.abort_after,
|
|
253
|
+
)
|
|
254
|
+
print(f"printed {impressions} page(s)")
|
|
255
|
+
elif args.command == "devices":
|
|
256
|
+
printers = await client.discover(args.timeout)
|
|
257
|
+
for printer in printers:
|
|
258
|
+
print(f"{printer['address']} {printer['model']:<8} {printer['name']}")
|
|
259
|
+
if not printers:
|
|
260
|
+
print("no printer in range", file=sys.stderr)
|
|
261
|
+
return 1
|
|
262
|
+
elif args.command == "eject":
|
|
263
|
+
print("ejected" if await client.eject() else "sheet still present")
|
|
264
|
+
await client.close()
|
|
265
|
+
elif args.command == "identify":
|
|
266
|
+
await client.identify()
|
|
267
|
+
await client.close()
|
|
268
|
+
print("identified")
|
|
269
|
+
elif args.command == "testpage":
|
|
270
|
+
impressions = await client.print_test_page(quality=args.quality)
|
|
271
|
+
print(f"printed {impressions} page(s)")
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main(argv: list[str] | None = None) -> int:
|
|
276
|
+
"""Entry point.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
argv: Arguments without the program name; ``None`` uses ``sys.argv``.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Process exit status.
|
|
283
|
+
"""
|
|
284
|
+
args = _parser().parse_args(argv)
|
|
285
|
+
logging.basicConfig(
|
|
286
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
287
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
288
|
+
)
|
|
289
|
+
try:
|
|
290
|
+
config = load_config(
|
|
291
|
+
paths=[args.config] if args.config else None,
|
|
292
|
+
require_files=args.config is not None,
|
|
293
|
+
)
|
|
294
|
+
except ConfigError as exc:
|
|
295
|
+
print(f"configuration error: {exc}", file=sys.stderr)
|
|
296
|
+
return 2
|
|
297
|
+
try:
|
|
298
|
+
if args.command == "server":
|
|
299
|
+
asyncio.run(serve(config))
|
|
300
|
+
return 0
|
|
301
|
+
return asyncio.run(_run_client(args, config.socket_path))
|
|
302
|
+
except KeyboardInterrupt:
|
|
303
|
+
return 130
|
|
304
|
+
except BridgeError as exc:
|
|
305
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
306
|
+
return 1
|
|
307
|
+
except (ConnectionError, FileNotFoundError) as exc:
|
|
308
|
+
print(
|
|
309
|
+
f"cannot reach the bridge at {config.socket_path}: {exc}", file=sys.stderr
|
|
310
|
+
)
|
|
311
|
+
return 1
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
sys.exit(main())
|