axi-control 0.1.1__tar.gz

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,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: axi-control
3
+ Version: 0.1.1
4
+ Summary: Add your description here
5
+ Requires-Dist: axicli
6
+ Requires-Dist: flask>=3.1.2
7
+ Requires-Dist: ipykernel>=7.1.0
8
+ Requires-Dist: ipywidgets>=8.1.8
9
+ Requires-Dist: platformdirs>=4.2.0
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
File without changes
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["uv_build"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "axi-control"
7
+ version = "0.1.1"
8
+ description = "Add your description here"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "axicli",
13
+ "flask>=3.1.2",
14
+ "ipykernel>=7.1.0",
15
+ "ipywidgets>=8.1.8",
16
+ "platformdirs>=4.2.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ axicontrol-server = "axi_control.cli:main"
21
+
22
+ [tool.uv.workspace]
23
+ members = ["axidraw"]
24
+
25
+ [tool.uv]
26
+ required-version = ">=0.7.0"
27
+
28
+ [tool.uv.sources]
29
+ axicli = { workspace = true }
30
+
31
+ [tool.uv-ship]
32
+ release-branch = false
33
+ allow-dirty = false
34
+ changelog-path = "CHANGELOG.md"
35
+ date-first = true
36
+ changelog-template = "- {message}"
@@ -0,0 +1,6 @@
1
+ from . import utils as utils
2
+ from .axicontroller import AxiController as AxiController
3
+ from .routes import control as control
4
+ from .routes import plotting as plotting
5
+ from .routes import status as status
6
+ from .servercontext import ServerContext as ServerContext
@@ -0,0 +1,145 @@
1
+ # server.py
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import signal
7
+ import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from axidrawinternal.plot_utils_import import from_dependency_import
12
+ from flask import Flask, g, request
13
+
14
+ import axi_control as axc
15
+
16
+ SRV_OPTS = 'config/srv_options.json'
17
+ PID_FILE = Path('/tmp/axi_server.pid')
18
+
19
+ LOCKED_ENDPOINTS = {
20
+ 'nudge_x',
21
+ 'nudge_y',
22
+ 'home',
23
+ 'move_xy',
24
+ 'move_to',
25
+ 'set_home',
26
+ 'toggle_pen',
27
+ 'cycle_pen',
28
+ 'disable_motors',
29
+ 'enable_motors',
30
+ 'prime',
31
+ 'motor_state',
32
+ }
33
+
34
+
35
+ # ---------- setup paths & logging ----------
36
+ log_dir = Path(os.getcwd()) / 'logs'
37
+ log_dir.mkdir(parents=True, exist_ok=True)
38
+
39
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
40
+ log_filename = log_dir / 'axiserver.log'
41
+ plot_status_log_filename = log_dir / 'plot_status.log'
42
+
43
+ logging.basicConfig(
44
+ filename=log_filename,
45
+ filemode='w',
46
+ level=logging.INFO,
47
+ format='%(asctime)s %(levelname)s: %(message)s',
48
+ )
49
+
50
+ # logging.getLogger('werkzeug').addFilter(axc.utils.RequestFilter())
51
+ SKIP_LOG_PATHS = {} # {'/log', '/axidraw_status', '/motor_state'}
52
+ # ˆˆˆ these tend to be very frequent and clutter the access log. replaces above
53
+ logging.getLogger('werkzeug').setLevel(logging.WARNING)
54
+ ACCESS_LOGGER = logging.getLogger('axi_access')
55
+
56
+
57
+ # ---------- app & controller ----------
58
+ def create_app(artwork_root: str | None):
59
+ file_root = axc.utils.select_file_root(artwork_root)
60
+ app = Flask(__name__)
61
+ ad = axc.AxiController()
62
+ ebb_serial = from_dependency_import('plotink.ebb_serial')
63
+ ctx = axc.ServerContext(
64
+ ad=ad,
65
+ ebb_serial=ebb_serial,
66
+ file_root=file_root,
67
+ srv_opts_path=SRV_OPTS,
68
+ log_filename=log_filename,
69
+ plot_status_log_filename=plot_status_log_filename,
70
+ )
71
+
72
+ @app.before_request
73
+ def _lock_ad_access():
74
+ if request.endpoint in LOCKED_ENDPOINTS:
75
+ ctx.ad_lock.acquire()
76
+ g.ad_lock_acquired = True
77
+
78
+ @app.teardown_request
79
+ def _unlock_ad_access(_exc):
80
+ if getattr(g, 'ad_lock_acquired', False):
81
+ ctx.ad_lock.release()
82
+
83
+ @app.after_request
84
+ def _log_access(response):
85
+ if request.path in SKIP_LOG_PATHS:
86
+ return response
87
+ remote_addr = request.remote_addr or '-'
88
+ path = request.full_path.rstrip('?')
89
+ size = response.calculate_content_length()
90
+ size_text = size if size is not None else '-'
91
+ ACCESS_LOGGER.info(
92
+ '%s "%s %s" %s %s',
93
+ remote_addr,
94
+ request.method,
95
+ path,
96
+ response.status_code,
97
+ size_text,
98
+ )
99
+ return response
100
+
101
+ axc.control.register(app, ctx)
102
+ axc.status.register(app, ctx)
103
+ axc.plotting.register(app, ctx)
104
+ return app, ctx
105
+
106
+
107
+ def cleanup(signum=None, frame=None):
108
+ logging.info('Cleaning up, signal=%s', signum)
109
+ PID_FILE.unlink(missing_ok=True)
110
+ sys.exit(0)
111
+
112
+
113
+ # ---------- entry point ----------
114
+ def _parse_args() -> argparse.Namespace:
115
+ parser = argparse.ArgumentParser(description='AxiControl Flask server')
116
+ parser.add_argument(
117
+ '--dev',
118
+ action='store_true',
119
+ help='Skip hardware connection checks for local development',
120
+ )
121
+ parser.add_argument(
122
+ '--artwork-root',
123
+ default=None,
124
+ help='Root directory for artwork SVGs (overrides ARTWORK_ROOT).',
125
+ )
126
+ return parser.parse_args()
127
+
128
+
129
+ if __name__ == '__main__':
130
+ args = _parse_args()
131
+ app, ctx = create_app(args.artwork_root)
132
+ logging.info('Artwork root set to %s', ctx.file_root)
133
+
134
+ if not args.dev:
135
+ if not ctx.ad.connect():
136
+ logging.error('AxiController connection failed')
137
+ raise RuntimeError('AxiController connection failed')
138
+ # write PID only for the real server process
139
+ PID_FILE.write_text(str(os.getpid()))
140
+
141
+ # clean shutdown
142
+ signal.signal(signal.SIGTERM, cleanup)
143
+ signal.signal(signal.SIGINT, cleanup)
144
+
145
+ app.run(host='0.0.0.0', port=5050, threaded=True)
@@ -0,0 +1,271 @@
1
+ import logging
2
+ import time
3
+ from pathlib import Path
4
+
5
+ from axidrawinternal.plot_utils_import import from_dependency_import
6
+ from pyaxidraw.axidraw import AxiDraw
7
+
8
+ from . import utils
9
+
10
+ ebb_motion = from_dependency_import('plotink.ebb_motion')
11
+ logger = logging.getLogger(__name__)
12
+
13
+ SCRIPT_PATH = Path(__file__).resolve()
14
+ repo_path = SCRIPT_PATH.parent.parent.parent
15
+
16
+
17
+ class AxiController(AxiDraw):
18
+ def __init__(self):
19
+ self._force_cli_api = False
20
+ self._forced_total_mm = None
21
+ super().__init__(user_message_fun=logger.info)
22
+
23
+ setattr(self.options, 'units', 0) # set the default right away
24
+
25
+ def set_defaults(self):
26
+ super().set_defaults()
27
+ self.plot_status.progress.enable = False
28
+ self.plot_status.progress.dry_run = False
29
+ self.plot_status.progress.total = 0
30
+ self.plot_status.progress.last = 0
31
+ self.plot_status.progress.p_bar = None
32
+ self.plot_status.progress.sub_bar = None
33
+ if self._force_cli_api:
34
+ self.plot_status.cli_api = True
35
+ self.options.progress = True
36
+ if self._forced_total_mm is not None:
37
+ self.plot_status.progress.total = self._forced_total_mm
38
+
39
+ def _estimate_total_mm(self, file) -> float | None:
40
+ self.plot_setup(file)
41
+ time.sleep(0.2)
42
+ self.options.mode = 'plot'
43
+ self.options.preview = True
44
+ self.options.rendering = 0
45
+ self.options.digest = 0
46
+ self.options.progress = False
47
+ self.options.progress_no_dry_run = True
48
+ self._force_cli_api = False
49
+ self.plot_status.cli_api = False
50
+ self.apply_srv_options()
51
+ try:
52
+ self.plot_run()
53
+ except Exception as exc:
54
+ logger.warning('Estimate failed: %s', exc)
55
+ return None
56
+ total_inch = self.plot_status.stats.down_travel_inch + self.plot_status.stats.up_travel_inch
57
+ return 25.4 * total_inch
58
+
59
+ def plot_file(
60
+ self,
61
+ file,
62
+ enable_progress: bool = False,
63
+ allow_estimate: bool = True,
64
+ return_output: bool = False,
65
+ ):
66
+ total_mm = None
67
+ if enable_progress and allow_estimate:
68
+ total_mm = self._estimate_total_mm(file)
69
+
70
+ self.plot_setup(file)
71
+ time.sleep(0.5) # Wait half second
72
+ self.options.mode = 'plot'
73
+ self.options.preview = False
74
+ self.options.digest = 0
75
+ self.options.progress_no_dry_run = True
76
+ self._force_cli_api = enable_progress
77
+ self.plot_status.cli_api = enable_progress
78
+ self.options.progress = enable_progress
79
+ self._forced_total_mm = total_mm if total_mm is not None else None
80
+ logger.info(
81
+ 'Plot start: progress=%s cli_api=%s preview=%s digest=%s mode=%s estimate=%s',
82
+ self.options.progress,
83
+ self.plot_status.cli_api,
84
+ self.options.preview,
85
+ self.options.digest,
86
+ self.options.mode,
87
+ allow_estimate,
88
+ )
89
+ self.apply_srv_options()
90
+ output_svg = self.plot_run(output=return_output)
91
+ if return_output:
92
+ return output_svg, self.plot_status.stopped
93
+ return None
94
+
95
+ def resume_plot(self, svg_input, enable_progress: bool = False):
96
+ self.plot_setup(svg_input)
97
+ time.sleep(0.5) # Wait half second
98
+ self.options.mode = 'res_plot'
99
+ self.options.preview = False
100
+ self.options.digest = 0
101
+ self.options.progress_no_dry_run = True
102
+ self._force_cli_api = enable_progress
103
+ self.plot_status.cli_api = enable_progress
104
+ self.options.progress = enable_progress
105
+ logger.info(
106
+ 'Resume start: progress=%s cli_api=%s preview=%s digest=%s mode=%s',
107
+ self.options.progress,
108
+ self.plot_status.cli_api,
109
+ self.options.preview,
110
+ self.options.digest,
111
+ self.options.mode,
112
+ )
113
+ self.apply_srv_options()
114
+ output_svg = self.plot_run(output=True)
115
+ return output_svg, self.plot_status.stopped
116
+
117
+ def apply_srv_options(self, srv_options: dict = None):
118
+ if srv_options is None:
119
+ file = repo_path / 'config' / 'srv_options.json'
120
+ srv_options = utils.load_srv_options(file)
121
+
122
+ for k, v in srv_options.items():
123
+ if hasattr(self.options, k):
124
+ if k == 'units':
125
+ unit_map = {'mm': 2, 'cm': 1, 'in': 0}
126
+ v = unit_map.get(v, 0)
127
+
128
+ setattr(self.options, k, v)
129
+
130
+ def motor_state(self):
131
+ temp_connected = False
132
+ try:
133
+ if not self.connected or self.plot_status.port is None:
134
+ self.serial_connect()
135
+ temp_connected = True
136
+ if self.plot_status.port is None:
137
+ return None
138
+ res = ebb_motion.query_enable_motors(self.plot_status.port, False)
139
+ if not res or res[0] is None or res[1] is None:
140
+ return None
141
+ return res[0] > 0 and res[1] > 0
142
+ except Exception:
143
+ return None
144
+ finally:
145
+ if temp_connected:
146
+ self.disconnect()
147
+
148
+ def _util(self, func, **kwargs):
149
+ self.plot_setup()
150
+ self.options.mode = 'manual'
151
+ self.options.manual_cmd = func
152
+
153
+ for k, v in kwargs.items():
154
+ if v is not None:
155
+ if hasattr(self.options, k):
156
+ setattr(self.options, k, v)
157
+
158
+ self.apply_srv_options()
159
+ self.plot_run()
160
+
161
+ def util_motors_on(self):
162
+ self._util('enable_xy')
163
+
164
+ def util_motors_off(self):
165
+ self._util('disable_xy')
166
+
167
+ def util_cycle(self):
168
+ self.i_pendown()
169
+ self.i_delay(500)
170
+ self.i_penup()
171
+ # self.disconnect()
172
+
173
+ def util_toggle(self):
174
+ self.i_penup() if not self.i_current_pen() else self.i_pendown()
175
+ # self.disconnect()
176
+
177
+ def util_align(self):
178
+ self.plot_setup()
179
+ self.options.mode = 'align'
180
+ self.apply_srv_options()
181
+ self.plot_run()
182
+
183
+ def util_home(self):
184
+ self._util('walk_home')
185
+
186
+ def util_nudge_x(self, dist, unit='mm'):
187
+ if unit == 'in':
188
+ self._util('walk_x', dist=dist)
189
+ elif unit == 'mm':
190
+ self._util('walk_mmx', dist=dist)
191
+ else:
192
+ raise ValueError('Invalid unit for walk_x: use "mm" or "in"')
193
+
194
+ def util_nudge_y(self, dist, unit='mm'):
195
+ if unit == 'in':
196
+ self._util('walk_y', dist=dist)
197
+ elif unit == 'mm':
198
+ self._util('walk_mmy', dist=dist)
199
+ else:
200
+ raise ValueError('Invalid unit for walk_y: use "mm" or "in"')
201
+
202
+ ############ Interactive commands
203
+ def _iactive(self, func, *args, **kwargs) -> None:
204
+ if not self.options.mode == 'interactive':
205
+ print('Switching to interactive mode...')
206
+ self.interactive()
207
+
208
+ self.apply_srv_options()
209
+
210
+ if not self.connected:
211
+ print('Connecting to AxiDraw device...')
212
+ self.connect()
213
+ if not self.connected:
214
+ raise ConnectionError('Could not connect to AxiDraw device.')
215
+
216
+ self.update() # when options are changed after connect
217
+ res = func(*args, **kwargs)
218
+ return res
219
+
220
+ # self.disconnect()
221
+
222
+ def i_goto(self, x_target: float, y_target: float) -> None:
223
+ return self._iactive(self.goto, x_target=x_target, y_target=y_target)
224
+
225
+ def i_moveto(self, x_target: float, y_target: float) -> None:
226
+ return self._iactive(self.moveto, x_target=x_target, y_target=y_target)
227
+
228
+ def i_lineto(self, x_target: float, y_target: float) -> None:
229
+ return self._iactive(self.lineto, x_target=x_target, y_target=y_target)
230
+
231
+ def i_go(self, delta_x: float, delta_y: float) -> None:
232
+ return self._iactive(self.go, delta_x, delta_y)
233
+
234
+ def i_move(self, delta_x: float, delta_y: float) -> None:
235
+ return self._iactive(self.move, delta_x, delta_y)
236
+
237
+ def i_line(self, delta_x: float, delta_y: float) -> None:
238
+ return self._iactive(self.line, delta_x=delta_x, delta_y=delta_y)
239
+
240
+ def i_penup(self) -> None:
241
+ return self._iactive(self.penup)
242
+
243
+ def i_pendown(self) -> None:
244
+ return self._iactive(self.pendown)
245
+
246
+ def i_draw_path(self, vertex_list) -> None:
247
+ return self._iactive(self.draw_path, vertex_list=vertex_list)
248
+
249
+ def i_delay(self, time_ms) -> None:
250
+ return self._iactive(self.delay, time_ms=time_ms)
251
+
252
+ def i_block(self) -> None:
253
+ return self._iactive(self.block)
254
+
255
+ def i_current_pos(self) -> None:
256
+ return self._iactive(self.current_pos)
257
+
258
+ def i_turtle_pos(self) -> None:
259
+ return self._iactive(self.turtle_pos)
260
+
261
+ def i_current_pen(self) -> None:
262
+ return self._iactive(self.current_pen)
263
+
264
+ def i_turtle_pen(self) -> None:
265
+ return self._iactive(self.turtle_pen)
266
+
267
+ def i_usb_command(self) -> None:
268
+ return self._iactive(self.usb_command)
269
+
270
+ def i_usb_query(self) -> None:
271
+ return self._iactive(self.usb_query)
@@ -0,0 +1,196 @@
1
+ import argparse
2
+ import logging
3
+ import os
4
+ import signal
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import platformdirs
9
+ from axidrawinternal.plot_utils_import import from_dependency_import
10
+ from flask import Flask, g, request
11
+
12
+ import axi_control as axc
13
+
14
+ APP_NAME = "axi-control"
15
+ APP_AUTHOR = "AxiControl"
16
+ PID_FILE = Path("/tmp/axi_server.pid")
17
+
18
+ LOCKED_ENDPOINTS = {
19
+ "nudge_x",
20
+ "nudge_y",
21
+ "home",
22
+ "move_xy",
23
+ "move_to",
24
+ "set_home",
25
+ "toggle_pen",
26
+ "cycle_pen",
27
+ "disable_motors",
28
+ "enable_motors",
29
+ "prime",
30
+ "motor_state",
31
+ }
32
+
33
+
34
+ def create_app(artwork_root: str | None, srv_opts_path: Path, log_filename: Path, plot_status_log_filename: Path):
35
+ file_root = axc.utils.select_file_root(artwork_root)
36
+ app = Flask(__name__)
37
+ ad = axc.AxiController()
38
+ ebb_serial = from_dependency_import("plotink.ebb_serial")
39
+ ctx = axc.ServerContext(
40
+ ad=ad,
41
+ ebb_serial=ebb_serial,
42
+ file_root=file_root,
43
+ srv_opts_path=srv_opts_path,
44
+ log_filename=log_filename,
45
+ plot_status_log_filename=plot_status_log_filename,
46
+ )
47
+
48
+ @app.before_request
49
+ def _lock_ad_access():
50
+ if request.endpoint in LOCKED_ENDPOINTS:
51
+ ctx.ad_lock.acquire()
52
+ g.ad_lock_acquired = True
53
+
54
+ @app.teardown_request
55
+ def _unlock_ad_access(_exc):
56
+ if getattr(g, "ad_lock_acquired", False):
57
+ ctx.ad_lock.release()
58
+
59
+ @app.after_request
60
+ def _log_access(response):
61
+ if request.path in SKIP_LOG_PATHS:
62
+ return response
63
+ remote_addr = request.remote_addr or "-"
64
+ path = request.full_path.rstrip("?")
65
+ size = response.calculate_content_length()
66
+ size_text = size if size is not None else "-"
67
+ ACCESS_LOGGER.info(
68
+ "%s \"%s %s\" %s %s",
69
+ remote_addr,
70
+ request.method,
71
+ path,
72
+ response.status_code,
73
+ size_text,
74
+ )
75
+ return response
76
+
77
+ axc.control.register(app, ctx)
78
+ axc.status.register(app, ctx)
79
+ axc.plotting.register(app, ctx)
80
+ return app, ctx
81
+
82
+
83
+ def cleanup(signum=None, frame=None):
84
+ logging.info("Cleaning up, signal=%s", signum)
85
+ PID_FILE.unlink(missing_ok=True)
86
+ sys.exit(0)
87
+
88
+
89
+ def _resolve_dir(cli_value: str | None, env_var: str | None, fallback: Path) -> Path:
90
+ if cli_value:
91
+ return Path(cli_value).expanduser().resolve()
92
+ if env_var:
93
+ return Path(env_var).expanduser().resolve()
94
+ return fallback
95
+
96
+
97
+ def _parse_args() -> argparse.Namespace:
98
+ parser = argparse.ArgumentParser(description="AxiControl Flask server")
99
+ parser.add_argument(
100
+ "--dev",
101
+ action="store_true",
102
+ help="Skip hardware connection checks for local development",
103
+ )
104
+ parser.add_argument(
105
+ "--artwork-root",
106
+ default=None,
107
+ help="Root directory for artwork SVGs (overrides ARTWORK_ROOT).",
108
+ )
109
+ parser.add_argument(
110
+ "--config-dir",
111
+ default=None,
112
+ help="Directory to store configuration (overrides AXI_CONTROL_CONFIG_DIR).",
113
+ )
114
+ parser.add_argument(
115
+ "--log-dir",
116
+ default=None,
117
+ help="Directory to store logs (overrides AXI_CONTROL_LOG_DIR).",
118
+ )
119
+ parser.add_argument(
120
+ "--srv-options",
121
+ default=None,
122
+ help="Path to srv_options.json (overrides AXI_CONTROL_SRV_OPTIONS).",
123
+ )
124
+ parser.add_argument(
125
+ "--host",
126
+ default="0.0.0.0",
127
+ help="Host to bind the server.",
128
+ )
129
+ parser.add_argument(
130
+ "--port",
131
+ default=5050,
132
+ type=int,
133
+ help="Port to bind the server.",
134
+ )
135
+ return parser.parse_args()
136
+
137
+
138
+ SKIP_LOG_PATHS = {}
139
+ logging.getLogger("werkzeug").setLevel(logging.WARNING)
140
+ ACCESS_LOGGER = logging.getLogger("axi_access")
141
+
142
+
143
+ def main() -> None:
144
+ args = _parse_args()
145
+
146
+ config_dir = _resolve_dir(
147
+ args.config_dir,
148
+ os.environ.get("AXI_CONTROL_CONFIG_DIR"),
149
+ Path(platformdirs.user_config_dir(APP_NAME, APP_AUTHOR)),
150
+ )
151
+ log_dir = _resolve_dir(
152
+ args.log_dir,
153
+ os.environ.get("AXI_CONTROL_LOG_DIR"),
154
+ Path(platformdirs.user_log_dir(APP_NAME, APP_AUTHOR)),
155
+ )
156
+ config_dir.mkdir(parents=True, exist_ok=True)
157
+ log_dir.mkdir(parents=True, exist_ok=True)
158
+
159
+ srv_opts_path = args.srv_options or os.environ.get("AXI_CONTROL_SRV_OPTIONS")
160
+ if srv_opts_path:
161
+ srv_opts_path = Path(srv_opts_path).expanduser().resolve()
162
+ else:
163
+ srv_opts_path = config_dir / "srv_options.json"
164
+
165
+ axc.utils.ensure_srv_options(srv_opts_path)
166
+
167
+ log_filename = log_dir / "axiserver.log"
168
+ plot_status_log_filename = log_dir / "plot_status.log"
169
+
170
+ logging.basicConfig(
171
+ filename=log_filename,
172
+ filemode="a",
173
+ level=logging.INFO,
174
+ format="%(asctime)s %(levelname)s: %(message)s",
175
+ )
176
+
177
+ app, ctx = create_app(args.artwork_root, srv_opts_path, log_filename, plot_status_log_filename)
178
+ logging.info("Artwork root set to %s", ctx.file_root)
179
+ logging.info("Using srv_options at %s", srv_opts_path)
180
+ logging.info("Logs directory set to %s", log_dir)
181
+
182
+ if not args.dev:
183
+ if not ctx.ad.connect():
184
+ logging.error("AxiController connection failed")
185
+ raise RuntimeError("AxiController connection failed")
186
+
187
+ PID_FILE.write_text(str(os.getpid()))
188
+
189
+ signal.signal(signal.SIGTERM, cleanup)
190
+ signal.signal(signal.SIGINT, cleanup)
191
+
192
+ app.run(host=args.host, port=args.port, threaded=True)
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()