linux-face-unlock 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.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,363 @@
1
+ from typing import Optional
2
+
3
+ import fcntl
4
+ import os
5
+ import sys
6
+ import time
7
+ import syslog
8
+ import cv2
9
+ import numpy as np
10
+ from . import config as cfgmod
11
+ from . import camera as cam
12
+ from . import face_engine as fe
13
+ from . import storage as store
14
+ from . import session as sessmod
15
+ from . import dialog as dlgmod
16
+
17
+ _RATE_LIMIT_DIR = "/etc/face-unlock/ratelimit"
18
+
19
+
20
+ def _log(username, success, reason=""):
21
+ priority = syslog.LOG_AUTH | (syslog.LOG_INFO if success else syslog.LOG_WARNING)
22
+ msg = f"face-unlock[PAM]: user={username} {'granted' if success else 'denied'}"
23
+ if reason:
24
+ msg += f" ({reason})"
25
+ syslog.syslog(priority, msg)
26
+
27
+
28
+ def _rate_lock_path(username):
29
+ return os.path.join(_RATE_LIMIT_DIR, username + ".lock")
30
+
31
+
32
+ def _check_rate_limit(username, max_attempts=5, lockout_minutes=5):
33
+ try:
34
+ store.validate_username(username)
35
+ except ValueError:
36
+ return False
37
+ if max_attempts <= 0:
38
+ return True
39
+ now = time.time()
40
+ window = lockout_minutes * 60
41
+ fpath = os.path.join(_RATE_LIMIT_DIR, username)
42
+ lock_path = _rate_lock_path(username)
43
+ try:
44
+ lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
45
+ except IOError:
46
+ return False
47
+ try:
48
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
49
+ try:
50
+ if not os.path.exists(fpath):
51
+ return True
52
+ try:
53
+ with open(fpath, "r") as f:
54
+ timestamps = [float(line.strip()) for line in f if line.strip()]
55
+ except (IOError, ValueError):
56
+ return False
57
+ timestamps = [t for t in timestamps if now - t < window]
58
+ tmp = fpath + ".tmp"
59
+ try:
60
+ with open(tmp, "w") as f:
61
+ for t in timestamps:
62
+ f.write(f"{t}\n")
63
+ f.flush()
64
+ os.fsync(f.fileno())
65
+ os.rename(tmp, fpath)
66
+ except IOError:
67
+ return False
68
+ return len(timestamps) < max_attempts
69
+ finally:
70
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
71
+ os.close(lock_fd)
72
+ except Exception:
73
+ os.close(lock_fd)
74
+ return False
75
+
76
+
77
+ def _record_failed_attempt(username):
78
+ try:
79
+ store.validate_username(username)
80
+ except ValueError:
81
+ return
82
+ d = _RATE_LIMIT_DIR
83
+ os.makedirs(d, mode=0o700, exist_ok=True)
84
+ fpath = os.path.join(d, username)
85
+ lock_path = _rate_lock_path(username)
86
+ try:
87
+ lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
88
+ except IOError:
89
+ return
90
+ try:
91
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
92
+ try:
93
+ tmp = fpath + ".tmp"
94
+ try:
95
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
96
+ with os.fdopen(fd, "a") as f:
97
+ f.write(f"{time.time()}\n")
98
+ f.flush()
99
+ os.fsync(f.fileno())
100
+ os.rename(tmp, fpath)
101
+ except IOError:
102
+ pass
103
+ finally:
104
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
105
+ os.close(lock_fd)
106
+ except Exception:
107
+ os.close(lock_fd)
108
+
109
+
110
+ def _clear_rate_limit(username):
111
+ try:
112
+ store.validate_username(username)
113
+ except ValueError:
114
+ return
115
+ fpath = os.path.join(_RATE_LIMIT_DIR, username)
116
+ try:
117
+ if os.path.exists(fpath):
118
+ os.remove(fpath)
119
+ except IOError:
120
+ pass
121
+
122
+
123
+ def _try_retries(cap, encodings, retries, timeout, auto_timeout, tolerance,
124
+ use_cnn, num_jitters):
125
+ for attempt in range(retries):
126
+ cap_timeout = auto_timeout if (attempt == 0 and auto_timeout > 0) else timeout
127
+ frame = cam.capture_frame(cap, timeout=cap_timeout)
128
+ if frame is None:
129
+ continue
130
+ encoding, _ = fe.encode_single_face(frame, use_cnn=use_cnn,
131
+ num_jitters=num_jitters)
132
+ if encoding is None:
133
+ continue
134
+ match, _ = fe.compare_encodings(encodings, encoding, tolerance=tolerance)
135
+ if match:
136
+ return True
137
+ return False
138
+
139
+
140
+ def authenticate_user(username: str, device_path: Optional[str] = None, timeout: int = 5, retries: int = 3,
141
+ tolerance: float = 0.55, use_cnn: bool = False, num_jitters: int = 5,
142
+ require_blinks: int = 1) -> bool:
143
+ cfg = cfgmod.get_config()
144
+
145
+ max_attempts = cfgmod.get_max_attempts(cfg)
146
+ lockout_minutes = cfgmod.get_lockout_minutes(cfg)
147
+
148
+ if not _check_rate_limit(username, max_attempts, lockout_minutes):
149
+ _log(username, False, "rate limited")
150
+ return False
151
+
152
+ if not store.user_exists(username):
153
+ _log(username, False, "user not found")
154
+ return False
155
+
156
+ encodings = store.load_encodings(username)
157
+ if not encodings:
158
+ _log(username, False, "no encodings")
159
+ return False
160
+
161
+ if not device_path:
162
+ device_path = cfgmod.get_device_path(cfg)
163
+
164
+ cap = cam.open_camera(device_path)
165
+ if cap is None:
166
+ _log(username, False, "camera unavailable")
167
+ return False
168
+
169
+ time.sleep(0.3)
170
+
171
+ liveness_timeout = cfgmod.get_liveness_timeout(cfg)
172
+ auto_timeout = cfgmod.get_auto_capture_timeout(cfg)
173
+ show_dialog = cfgmod.get_show_auth_dialog(cfg)
174
+
175
+ winname = "Face Unlock - Look at the camera"
176
+ gui = cam.has_gui()
177
+ if gui:
178
+ cam.gui_named_window(winname, cv2.WINDOW_NORMAL)
179
+ cam.gui_resize_window(winname, 640, 480)
180
+
181
+ try:
182
+ liveness_ok = require_blinks <= 0
183
+ blink_count = 0
184
+ in_blink = False
185
+ liveness_start = time.time()
186
+
187
+ while not liveness_ok:
188
+ frame = cam.capture_frame(cap, timeout=0.5)
189
+ if frame is None:
190
+ if time.time() - liveness_start >= liveness_timeout:
191
+ break
192
+ continue
193
+
194
+ ear, locations = fe.compute_ear(frame, use_cnn=use_cnn)
195
+ if ear is not None:
196
+ if ear < fe.EAR_THRESHOLD:
197
+ if not in_blink:
198
+ in_blink = True
199
+ else:
200
+ if in_blink:
201
+ blink_count += 1
202
+ in_blink = False
203
+ if blink_count >= require_blinks:
204
+ liveness_ok = True
205
+
206
+ if gui:
207
+ display = frame.copy()
208
+ for (top, right, bottom, left) in locations:
209
+ cv2.rectangle(display, (left, top), (right, bottom), (0, 255, 0), 2)
210
+ remaining = max(0, int(liveness_timeout - (time.time() - liveness_start)))
211
+ text = f"Blink: {blink_count}/{require_blinks} Timeout: {remaining}s"
212
+ cv2.putText(display, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
213
+ if ear is not None:
214
+ cv2.putText(display, "Face detected", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
215
+ else:
216
+ cv2.putText(display, "No face detected", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
217
+ cam.gui_imshow(winname, display)
218
+ if cam.gui_wait_key(1) == 27:
219
+ cam.gui_destroy_all_windows()
220
+ _log(username, False, "cancelled by user")
221
+ return False
222
+
223
+ if time.time() - liveness_start >= liveness_timeout:
224
+ break
225
+
226
+ if not liveness_ok:
227
+ if gui:
228
+ display = np.zeros((480, 640, 3), dtype=np.uint8)
229
+ cv2.putText(display, "Liveness check failed", (100, 240),
230
+ cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
231
+ cam.gui_imshow(winname, display)
232
+ cam.gui_wait_key(1000)
233
+ cam.gui_destroy_all_windows()
234
+ _log(username, False, "liveness check failed")
235
+ _record_failed_attempt(username)
236
+ return False
237
+
238
+ match_result = False
239
+ for attempt in range(retries):
240
+ cap_timeout = auto_timeout if (attempt == 0 and auto_timeout > 0) else timeout
241
+ frame = cam.capture_frame(cap, timeout=cap_timeout)
242
+ if frame is None:
243
+ if gui:
244
+ display = np.zeros((480, 640, 3), dtype=np.uint8)
245
+ cv2.putText(display, "Camera timeout", (150, 240),
246
+ cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
247
+ cam.gui_imshow(winname, display)
248
+ cam.gui_wait_key(500)
249
+ continue
250
+
251
+ encoding, face_locations = fe.encode_single_face(frame, use_cnn=use_cnn,
252
+ num_jitters=num_jitters)
253
+ if gui:
254
+ display = frame.copy()
255
+ if face_locations:
256
+ for (top, right, bottom, left) in face_locations:
257
+ cv2.rectangle(display, (left, top), (right, bottom), (0, 255, 0), 2)
258
+ if encoding is not None:
259
+ text = f"Scanning... ({attempt+1}/{retries})"
260
+ cv2.putText(display, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
261
+ else:
262
+ text = f"No face detected ({attempt+1}/{retries})"
263
+ cv2.putText(display, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
264
+ cam.gui_imshow(winname, display)
265
+ if cam.gui_wait_key(1) == 27:
266
+ break
267
+
268
+ if encoding is not None:
269
+ match, _ = fe.compare_encodings(encodings, encoding, tolerance=tolerance)
270
+ if match:
271
+ match_result = True
272
+ if gui:
273
+ display = frame.copy()
274
+ cv2.putText(display, "Match! Access granted", (120, 240),
275
+ cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2)
276
+ cam.gui_imshow(winname, display)
277
+ cam.gui_wait_key(1500)
278
+ break
279
+ if gui:
280
+ display = frame.copy()
281
+ cv2.putText(display, "No match, retrying...", (120, 240),
282
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
283
+ cam.gui_imshow(winname, display)
284
+ cam.gui_wait_key(500)
285
+
286
+ if gui:
287
+ cam.gui_destroy_all_windows()
288
+
289
+ if match_result:
290
+ _clear_rate_limit(username)
291
+ _log(username, True)
292
+ return True
293
+
294
+ if show_dialog:
295
+ session_info = sessmod.detect_session(username)
296
+ if session_info:
297
+ dlgmod.notify_failure(username, session_info)
298
+ choice = dlgmod.show_auth_choice(
299
+ username, session_info,
300
+ dialog_timeout=cfgmod.get_dialog_timeout(cfg),
301
+ )
302
+
303
+ if choice == "face" and _try_retries(cap, encodings, retries,
304
+ timeout, auto_timeout, tolerance,
305
+ use_cnn, num_jitters):
306
+ _clear_rate_limit(username)
307
+ _log(username, True)
308
+ return True
309
+
310
+ if choice == "password":
311
+ _log(username, False, "user chose password")
312
+ return False
313
+
314
+ _log(username, False, "no match after all retries")
315
+ _record_failed_attempt(username)
316
+ return False
317
+ finally:
318
+ cam.release_camera(cap)
319
+
320
+
321
+ def main():
322
+ username = os.environ.get("PAM_USER", "")
323
+ if not username:
324
+ if len(sys.argv) > 1:
325
+ username = sys.argv[1]
326
+ else:
327
+ sys.exit(1)
328
+
329
+ session_info = sessmod.detect_session(username)
330
+ if session_info:
331
+ for key in ("DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"):
332
+ env_key = key
333
+ if key == "XAUTHORITY":
334
+ val = session_info.get("xauthority", "")
335
+ elif key == "WAYLAND_DISPLAY":
336
+ val = session_info.get("wayland_display", "")
337
+ elif key == "XDG_RUNTIME_DIR":
338
+ val = session_info.get("xdg_runtime_dir", "")
339
+ else:
340
+ val = session_info.get("display", "")
341
+ if val and env_key not in os.environ:
342
+ os.environ[env_key] = val
343
+
344
+ cfg = cfgmod.get_config()
345
+
346
+ if cfgmod.is_disabled(cfg):
347
+ sys.exit(1)
348
+
349
+ result = authenticate_user(
350
+ username=username,
351
+ timeout=cfgmod.get_timeout(cfg),
352
+ retries=cfgmod.get_retries(cfg),
353
+ tolerance=cfgmod.get_tolerance(cfg),
354
+ use_cnn=cfgmod.use_cnn(cfg),
355
+ num_jitters=cfgmod.get_num_jitters(cfg),
356
+ require_blinks=cfgmod.get_liveness_blinks(cfg),
357
+ )
358
+
359
+ sys.exit(0 if result else 1)
360
+
361
+
362
+ if __name__ == "__main__":
363
+ main()
face_unlock/camera.py ADDED
@@ -0,0 +1,116 @@
1
+ import os
2
+ import time
3
+
4
+ import cv2
5
+
6
+
7
+ def detect_cameras():
8
+ devices = []
9
+ for i in range(10):
10
+ path = f"/dev/video{i}"
11
+ if os.path.exists(path):
12
+ devices.append(path)
13
+ return devices
14
+
15
+
16
+ def open_camera(device_path=None, width=None, height=None):
17
+ from . import config as cfgmod
18
+
19
+ if width is None or height is None:
20
+ cfg = cfgmod.get_config()
21
+ if width is None:
22
+ width = cfgmod.get_frame_width(cfg)
23
+ if height is None:
24
+ height = cfgmod.get_frame_height(cfg)
25
+
26
+ if device_path:
27
+ try:
28
+ index = int(os.path.basename(device_path).replace("video", ""))
29
+ except (ValueError, IndexError):
30
+ index = 0
31
+ cap = cv2.VideoCapture(index)
32
+ if not cap.isOpened():
33
+ return None
34
+ else:
35
+ available = detect_cameras()
36
+ if not available:
37
+ return None
38
+ cap = None
39
+ for dev in available:
40
+ try:
41
+ idx = int(os.path.basename(dev).replace("video", ""))
42
+ except (ValueError, IndexError):
43
+ continue
44
+ cap = cv2.VideoCapture(idx)
45
+ if cap is not None and cap.isOpened():
46
+ break
47
+ cap = None
48
+ if cap is None:
49
+ return None
50
+
51
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
52
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
53
+ cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
54
+
55
+ return cap
56
+
57
+
58
+ def capture_frame(cap, timeout=5):
59
+ start = time.time()
60
+ while time.time() - start < timeout:
61
+ ret, frame = cap.read()
62
+ if ret and frame is not None and frame.size > 0:
63
+ return frame
64
+ time.sleep(0.1)
65
+ return None
66
+
67
+
68
+ def release_camera(cap):
69
+ if cap is not None:
70
+ cap.release()
71
+
72
+
73
+ def gui_named_window(winname, flags=cv2.WINDOW_NORMAL):
74
+ try:
75
+ cv2.namedWindow(winname, flags)
76
+ except cv2.error:
77
+ pass
78
+
79
+
80
+ def gui_resize_window(winname, width, height):
81
+ try:
82
+ cv2.resizeWindow(winname, width, height)
83
+ except cv2.error:
84
+ pass
85
+
86
+
87
+ def gui_imshow(winname, mat):
88
+ try:
89
+ cv2.imshow(winname, mat)
90
+ except cv2.error:
91
+ pass
92
+
93
+
94
+ def gui_wait_key(delay=1):
95
+ try:
96
+ return cv2.waitKey(delay) & 0xFF
97
+ except cv2.error:
98
+ return 255
99
+
100
+
101
+ def gui_destroy_all_windows():
102
+ try:
103
+ cv2.destroyAllWindows()
104
+ except cv2.error:
105
+ pass
106
+
107
+
108
+ def has_gui():
109
+ if not os.environ.get("DISPLAY"):
110
+ return False
111
+ try:
112
+ cv2.namedWindow("__test", cv2.WINDOW_NORMAL)
113
+ cv2.destroyWindow("__test")
114
+ return True
115
+ except cv2.error:
116
+ return False
face_unlock/cli.py ADDED
@@ -0,0 +1,175 @@
1
+ import argparse
2
+ import os
3
+ import subprocess
4
+ import sys
5
+
6
+ from . import config as cfgmod
7
+ from . import storage as store
8
+ from . import enroll
9
+ from . import authenticate
10
+ from . import test as testmod
11
+
12
+
13
+ def cmd_enroll(args):
14
+ if os.geteuid() != 0:
15
+ print("ERROR: Must be root. Use sudo.", file=sys.stderr)
16
+ sys.exit(1)
17
+ success = enroll.enroll_user(
18
+ username=args.username,
19
+ device_path=args.device,
20
+ use_cnn=args.cnn,
21
+ num_jitters=args.jitters,
22
+ num_samples=args.samples,
23
+ )
24
+ sys.exit(0 if success else 1)
25
+
26
+
27
+ def cmd_list(args):
28
+ users = store.list_users()
29
+ if not users:
30
+ print("No users enrolled.")
31
+ return
32
+ for u in users:
33
+ try:
34
+ count = store.encoding_count(u)
35
+ print(f"{u}: {count} encoding(s)")
36
+ except PermissionError:
37
+ print(f"{u}: (cannot read encodings, try sudo)")
38
+
39
+
40
+ def cmd_remove(args):
41
+ if os.geteuid() != 0:
42
+ print("ERROR: Must be root. Use sudo.", file=sys.stderr)
43
+ sys.exit(1)
44
+ if not store.user_exists(args.username):
45
+ print(f"User '{args.username}' not found.")
46
+ return
47
+ store.remove_encodings(args.username)
48
+ print(f"Removed encodings for '{args.username}'.")
49
+
50
+
51
+ def cmd_test(args):
52
+ testmod.test_all()
53
+ if args.user:
54
+ testmod.test_recognition(
55
+ args.user, args.device, args.cnn, timeout=args.timeout)
56
+
57
+
58
+ def cmd_config(args):
59
+ cfg_path = cfgmod.CONFIG_PATH
60
+ if not os.path.exists(cfg_path):
61
+ print(f"Config file not found at {cfg_path}")
62
+ return
63
+ editor = os.environ.get("EDITOR", "vi")
64
+ try:
65
+ subprocess.run([editor, cfg_path])
66
+ except FileNotFoundError:
67
+ print(f"ERROR: Editor '{editor}' not found. Set EDITOR env var.", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+
71
+ def cmd_enable(args):
72
+ if os.geteuid() != 0:
73
+ print("ERROR: Must be root. Use sudo.", file=sys.stderr)
74
+ sys.exit(1)
75
+ from . import pam
76
+ results = pam.enable_all()
77
+ for path, status in results.items():
78
+ print(f" {path}: {status}")
79
+
80
+
81
+ def cmd_disable(args):
82
+ if os.geteuid() != 0:
83
+ print("ERROR: Must be root. Use sudo.", file=sys.stderr)
84
+ sys.exit(1)
85
+ from . import pam
86
+ results = pam.disable_all()
87
+ for path, status in results.items():
88
+ print(f" {path}: {status}")
89
+
90
+
91
+ def cmd_default(args):
92
+ if os.geteuid() != 0:
93
+ print("ERROR: Must be root. Use sudo.", file=sys.stderr)
94
+ sys.exit(1)
95
+ from . import pam
96
+ mode = args.mode
97
+ if mode == "on":
98
+ results = pam.set_default_all("on")
99
+ elif mode == "off":
100
+ results = pam.set_default_all("off")
101
+ else:
102
+ print(f"ERROR: Invalid mode '{mode}'. Use 'on' or 'off'.", file=sys.stderr)
103
+ sys.exit(1)
104
+ for path, status in results.items():
105
+ print(f" {path}: {status}")
106
+
107
+
108
+ def cmd_status(args):
109
+ from . import pam
110
+ results = pam.status_all()
111
+ for path, info in results.items():
112
+ if not info["exists"]:
113
+ print(f" {path}: not found")
114
+ elif not info["enabled"]:
115
+ print(f" {path}: disabled")
116
+ else:
117
+ print(f" {path}: enabled (default={info['default']})")
118
+
119
+
120
+ def main():
121
+ parser = argparse.ArgumentParser(description="Face-Unlock management CLI")
122
+ sub = parser.add_subparsers(dest="command")
123
+
124
+ p_enroll = sub.add_parser("enroll", help="Enroll a user")
125
+ p_enroll.add_argument("username", help="Username to enroll")
126
+ p_enroll.add_argument("--device", "-d", help="Camera device path")
127
+ p_enroll.add_argument("--cnn", action="store_true", help="Use CNN model")
128
+ p_enroll.add_argument("--samples", "-n", type=int, default=5)
129
+ p_enroll.add_argument("--jitters", "-j", type=int, default=5)
130
+
131
+ sub.add_parser("list", help="List enrolled users")
132
+
133
+ p_remove = sub.add_parser("remove", help="Remove a user's encodings")
134
+ p_remove.add_argument("username", help="Username to remove")
135
+
136
+ p_test = sub.add_parser("test", help="Test face unlock setup")
137
+ p_test.add_argument("--user", "-u", help="Test against enrolled user")
138
+ p_test.add_argument("--device", "-d", help="Camera device path")
139
+ p_test.add_argument("--cnn", action="store_true", help="Use CNN model")
140
+ p_test.add_argument("--timeout", "-t", type=int, default=10)
141
+
142
+ sub.add_parser("config", help="Edit configuration")
143
+
144
+ p_enable = sub.add_parser("enable", help="Enable face-unlock in PAM")
145
+ p_enable.set_defaults(func=cmd_enable)
146
+
147
+ p_disable = sub.add_parser("disable", help="Disable face-unlock in PAM")
148
+ p_disable.set_defaults(func=cmd_disable)
149
+
150
+ p_default = sub.add_parser("default", help="Set face-unlock as default auth")
151
+ p_default.add_argument("mode", choices=["on", "off"], help="on=face first, off=password first")
152
+ p_default.set_defaults(func=cmd_default)
153
+
154
+ sub.add_parser("status", help="Show face-unlock PAM status").set_defaults(func=cmd_status)
155
+
156
+ args = parser.parse_args()
157
+
158
+ if hasattr(args, "func"):
159
+ args.func(args)
160
+ elif args.command == "enroll":
161
+ cmd_enroll(args)
162
+ elif args.command == "list":
163
+ cmd_list(args)
164
+ elif args.command == "remove":
165
+ cmd_remove(args)
166
+ elif args.command == "test":
167
+ cmd_test(args)
168
+ elif args.command == "config":
169
+ cmd_config(args)
170
+ else:
171
+ parser.print_help()
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()